holographic_memory/core/provenance/
cawg.rs1use anyhow::Result;
5use base64::Engine;
6use ed25519_dalek::{Signer, SigningKey};
7use serde::{Deserialize, Serialize};
8
9use super::did;
10
11#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct HashedUri {
14 pub url: String,
15 pub alg: String,
16 pub hash: String,
17}
18
19pub fn hash_assertion(label: &str, content: &[u8]) -> HashedUri {
21 use sha2::{Digest, Sha256};
22 let hash = Sha256::digest(content);
23 HashedUri {
24 url: format!("self#jumbf=c2pa.assertions/{label}"),
25 alg: "sha256".to_string(),
26 hash: hash.iter().map(|b| format!("{b:02x}")).collect(),
27 }
28}
29
30const CAWG_ICA_SIG_TYPE: &str = "cawg.identity_claims_aggregation";
32const VC_CONTEXT: &str = "https://www.w3.org/ns/credentials/v2";
33const ICA_CONTEXT: &str = "https://cawg.io/identity/1.1/ica/context/";
34const ICA_CREDENTIAL_TYPE: &str = "IdentityClaimsAggregationCredential";
35const COSE_CONTENT_TYPE_VC: &str = "application/vc";
36
37pub fn create_identity_assertion_ica(
48 agent_key: &SigningKey,
49 referenced: &[(String, String, Vec<u8>)],
50 display_name: &str,
51) -> Result<serde_json::Value> {
52 use ciborium::value::Value;
53
54 let issuer_did = did::did_jwk_from_ed25519(&agent_key.verifying_key().to_bytes());
55 let now = iso8601_now();
56
57 let sp_cbor_refs: Vec<Value> = referenced
59 .iter()
60 .map(|(url, alg, hash)| {
61 Value::Map(vec![
62 (Value::Text("url".into()), Value::Text(url.clone())),
63 (Value::Text("alg".into()), Value::Text(alg.clone())),
64 (Value::Text("hash".into()), Value::Bytes(hash.clone())),
65 ])
66 })
67 .collect();
68 let signer_payload = Value::Map(vec![
69 (
70 Value::Text("referenced_assertions".into()),
71 Value::Array(sp_cbor_refs),
72 ),
73 (
74 Value::Text("sig_type".into()),
75 Value::Text(CAWG_ICA_SIG_TYPE.into()),
76 ),
77 ]);
78
79 let json_refs: Vec<serde_json::Value> = referenced
81 .iter()
82 .map(|(url, alg, hash)| {
83 serde_json::json!({
84 "url": url,
85 "alg": alg,
86 "hash": base64::engine::general_purpose::STANDARD.encode(hash),
87 })
88 })
89 .collect();
90 let c2pa_asset = serde_json::json!({
91 "referenced_assertions": json_refs,
92 "sig_type": CAWG_ICA_SIG_TYPE,
93 });
94
95 let vc = serde_json::json!({
97 "@context": [VC_CONTEXT, ICA_CONTEXT],
98 "type": ["VerifiableCredential", ICA_CREDENTIAL_TYPE],
99 "issuer": issuer_did,
100 "validFrom": now,
101 "credentialSubject": {
102 "verifiedIdentities": [{
103 "type": "writersproof.ai_agent",
104 "name": display_name,
105 "verifiedAt": now,
106 "provider": { "id": "https://writersproof.com", "name": "WritersProof" },
107 }],
108 "c2paAsset": c2pa_asset,
109 },
110 });
111 let vc_bytes =
112 serde_json::to_vec(&vc).map_err(|e| anyhow::anyhow!("VC serialization failed: {e}"))?;
113
114 let cose = sign_cose_vc(agent_key, &vc_bytes)?;
117
118 let assertion = Value::Map(vec![
120 (Value::Text("signer_payload".into()), signer_payload),
121 (Value::Text("signature".into()), Value::Bytes(cose)),
122 (Value::Text("pad1".into()), Value::Bytes(Vec::new())),
123 ]);
124 let mut assertion_cbor = Vec::new();
125 ciborium::into_writer(&assertion, &mut assertion_cbor)
126 .map_err(|e| anyhow::anyhow!("identity assertion CBOR encoding failed: {e}"))?;
127
128 Ok(serde_json::json!({
129 "alg": "EdDSA",
130 "ica": base64::engine::general_purpose::STANDARD.encode(&assertion_cbor),
131 }))
132}
133
134pub fn ica_embedded_bytes(assertion: &serde_json::Value) -> Result<Vec<u8>> {
137 let ica = assertion
138 .get("ica")
139 .and_then(|v| v.as_str())
140 .ok_or_else(|| anyhow::anyhow!("cawg.identity assertion has no ica field"))?;
141 base64::engine::general_purpose::STANDARD
142 .decode(ica)
143 .map_err(|e| anyhow::anyhow!("ica base64 decode failed: {e}"))
144}
145
146fn sign_cose_vc(signing_key: &SigningKey, payload: &[u8]) -> Result<Vec<u8>> {
149 use coset::iana;
150 use coset::{CoseSign1Builder, HeaderBuilder, TaggedCborSerializable};
151
152 let protected = HeaderBuilder::new()
153 .algorithm(iana::Algorithm::EdDSA)
154 .content_type(COSE_CONTENT_TYPE_VC.to_string())
155 .build();
156
157 let sign1 = CoseSign1Builder::new()
158 .protected(protected)
159 .payload(payload.to_vec())
160 .create_signature(b"", |data| signing_key.sign(data).to_bytes().to_vec())
161 .build();
162
163 sign1
164 .to_tagged_vec()
165 .map_err(|e| anyhow::anyhow!("COSE_Sign1 tagged serialization failed: {e}"))
166}
167
168fn iso8601_now() -> String {
170 let dur = std::time::SystemTime::now()
171 .duration_since(std::time::UNIX_EPOCH)
172 .unwrap_or_default();
173 let secs = dur.as_secs();
174 let days = secs / 86400;
175 let rem = secs % 86400;
176 let z = days + 719468;
177 let era = z / 146097;
178 let doe = z - era * 146097;
179 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
180 let y = yoe + era * 400;
181 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
182 let mp = (5 * doy + 2) / 153;
183 let d = doy - (153 * mp + 2) / 5 + 1;
184 let m = if mp < 10 { mp + 3 } else { mp - 9 };
185 let year = if m <= 2 { y + 1 } else { y };
186 format!(
187 "{year:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
188 rem / 3600,
189 (rem % 3600) / 60,
190 rem % 60
191 )
192}
193
194pub fn verify_cawg_ica(assertion_cbor: &[u8]) -> Result<serde_json::Value> {
200 use ciborium::value::Value;
201 use coset::{CoseSign1, TaggedCborSerializable};
202
203 let assertion: Value = ciborium::from_reader(assertion_cbor)
204 .map_err(|e| anyhow::anyhow!("identity assertion CBOR parse failed: {e}"))?;
205 let map = match &assertion {
206 Value::Map(entries) => entries,
207 _ => return Err(anyhow::anyhow!("identity assertion is not a CBOR map")),
208 };
209 let get = |key: &str| -> Option<&Value> {
210 map.iter()
211 .find(|(k, _)| matches!(k, Value::Text(t) if t == key))
212 .map(|(_, v)| v)
213 };
214
215 let signer_payload = match get("signer_payload") {
216 Some(Value::Map(entries)) => entries,
217 _ => return Err(anyhow::anyhow!("missing signer_payload map")),
218 };
219 let sp_get = |key: &str| -> Option<&Value> {
220 signer_payload
221 .iter()
222 .find(|(k, _)| matches!(k, Value::Text(t) if t == key))
223 .map(|(_, v)| v)
224 };
225
226 match sp_get("sig_type") {
227 Some(Value::Text(t)) if t == "cawg.identity_claims_aggregation" => {}
228 _ => {
229 return Err(anyhow::anyhow!(
230 "sig_type is not cawg.identity_claims_aggregation"
231 ))
232 }
233 }
234
235 let referenced = match sp_get("referenced_assertions") {
236 Some(Value::Array(items)) => items,
237 _ => return Err(anyhow::anyhow!("missing referenced_assertions array")),
238 };
239 let has_hard_binding = referenced.iter().any(|item| {
240 if let Value::Map(entries) = item {
241 entries.iter().any(|(k, v)| {
242 matches!(k, Value::Text(t) if t == "url")
243 && matches!(v, Value::Text(u) if u.contains("c2pa.hash."))
244 })
245 } else {
246 false
247 }
248 });
249 if !has_hard_binding {
250 return Err(anyhow::anyhow!("missing required hard binding"));
251 }
252
253 let sig_bytes = match get("signature") {
254 Some(Value::Bytes(b)) => b,
255 _ => return Err(anyhow::anyhow!("missing signature byte string")),
256 };
257
258 let sign1 = CoseSign1::from_tagged_slice(sig_bytes)
259 .map_err(|e| anyhow::anyhow!("COSE_Sign1 deserialization failed: {e}"))?;
260 let payload = sign1
261 .payload
262 .as_ref()
263 .ok_or_else(|| anyhow::anyhow!("COSE envelope has no payload"))?;
264 let vc: serde_json::Value = serde_json::from_slice(payload)
265 .map_err(|e| anyhow::anyhow!("VC JSON parse failed: {e}"))?;
266
267 let issuer = vc
268 .get("issuer")
269 .and_then(|v| v.as_str())
270 .ok_or_else(|| anyhow::anyhow!("VC has no issuer"))?;
271 let key = super::did::ed25519_from_did_jwk(issuer)?;
272
273 sign1
274 .verify_signature(b"", |sig, data| {
275 let vk = ed25519_dalek::VerifyingKey::from_bytes(&key)
276 .map_err(|e| anyhow::anyhow!("invalid issuer key: {e}"))?;
277 let s = ed25519_dalek::Signature::from_slice(sig)
278 .map_err(|e| anyhow::anyhow!("invalid signature bytes: {e}"))?;
279 ed25519_dalek::Verifier::verify(&vk, data, &s)
280 .map_err(|e| anyhow::anyhow!("signature verification failed: {e}"))
281 })
282 .map_err(|e| anyhow::anyhow!("CAWG ICA verification failed: {e}"))?;
283
284 Ok(vc)
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 fn test_keypair() -> SigningKey {
292 SigningKey::from_bytes(&rand::random())
293 }
294
295 fn ica_refs() -> Vec<(String, String, Vec<u8>)> {
296 vec![
297 (
298 "self#jumbf=c2pa.assertions/c2pa.hash.data".to_string(),
299 "sha256".to_string(),
300 vec![0x11u8; 32],
301 ),
302 (
303 "self#jumbf=c2pa.assertions/cogmem.memory.provenance".to_string(),
304 "sha256".to_string(),
305 vec![0x22u8; 32],
306 ),
307 ]
308 }
309
310 #[test]
311 fn create_and_verify_ica_assertion() {
312 let key = test_keypair();
313 let assertion = create_identity_assertion_ica(&key, &ica_refs(), "cogmem agent").unwrap();
314 let embedded = ica_embedded_bytes(&assertion).unwrap();
315 let vc = verify_cawg_ica(&embedded).unwrap();
316
317 let expected_issuer = did::did_jwk_from_ed25519(&key.verifying_key().to_bytes());
318 assert_eq!(vc["issuer"].as_str().unwrap(), expected_issuer);
319 assert!(vc["type"]
320 .as_array()
321 .unwrap()
322 .iter()
323 .any(|t| t == "IdentityClaimsAggregationCredential"));
324 assert_eq!(
325 vc["credentialSubject"]["verifiedIdentities"][0]["name"]
326 .as_str()
327 .unwrap(),
328 "cogmem agent"
329 );
330 }
331
332 #[test]
333 fn ica_assertion_tamper_rejected() {
334 let key = test_keypair();
335 let assertion = create_identity_assertion_ica(&key, &ica_refs(), "cogmem agent").unwrap();
336 let mut embedded = ica_embedded_bytes(&assertion).unwrap();
337 let last = embedded.len() - 1;
339 embedded[last] ^= 0xff;
340 assert!(verify_cawg_ica(&embedded).is_err());
341 }
342
343 #[test]
344 fn ica_assertion_requires_hard_binding() {
345 let key = test_keypair();
346 let refs = vec![(
347 "self#jumbf=c2pa.assertions/cogmem.memory.provenance".to_string(),
348 "sha256".to_string(),
349 vec![0x33u8; 32],
350 )];
351 let assertion = create_identity_assertion_ica(&key, &refs, "cogmem agent").unwrap();
352 let embedded = ica_embedded_bytes(&assertion).unwrap();
353 assert!(verify_cawg_ica(&embedded).is_err());
354 }
355
356 #[test]
357 fn cross_verifies_cogmem_ica_vector() {
358 fn unhex(s: &str) -> Vec<u8> {
359 (0..s.len())
360 .step_by(2)
361 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
362 .collect()
363 }
364 let assertion = unhex("a3647061643140697369676e61747572655903f7d28453a20127036e6170706c69636174696f6e2f7663a059039b7b2240636f6e74657874223a5b2268747470733a2f2f7777772e77332e6f72672f6e732f63726564656e7469616c732f7632222c2268747470733a2f2f636177672e696f2f6964656e746974792f312e312f6963612f636f6e746578742f225d2c2263726564656e7469616c5375626a656374223a7b22633270614173736574223a7b227265666572656e6365645f617373657274696f6e73223a5b7b22616c67223a22736861323536222c2268617368223a2241414543417751464267634943516f4c4441304f4478415245684d554652595847426b61477877644868383d222c2275726c223a2273656c66236a756d62663d633270612e617373657274696f6e732f633270612e686173682e64617461227d2c7b22616c67223a22736861323536222c2268617368223a22494345694979516c4a69636f4b536f724c4330754c7a41784d6a4d304e5459334f446b364f7a7739506a383d222c2275726c223a2273656c66236a756d62663d633270612e617373657274696f6e732f636f676d656d2e6d656d6f72792e70726f76656e616e6365227d5d2c227369675f74797065223a22636177672e6964656e746974795f636c61696d735f6167677265676174696f6e227d2c2276657269666965644964656e746974696573223a5b7b226e616d65223a22636f676d656d206167656e74222c2270726f7669646572223a7b226964223a2268747470733a2f2f7772697465727370726f6f662e636f6d222c226e616d65223a225772697465727350726f6f66227d2c2274797065223a22636177672e616666696c696174696f6e222c22757269223a2268747470733a2f2f7772697465727370726f6f662e636f6d2f6167656e74732f636f676d656d222c2276657269666965644174223a22323032362d30312d30315430303a30303a30302b30303a3030227d5d7d2c22697373756572223a226469643a6a776b3a65794a6a636e59694f694a465a4449314e5445354969776961335235496a6f69543074514969776965434936496b45325255683258314250525577305a474e4f4d466b314d485a426256646d617a467151324a775554466d5347523552317043536c5a4e596d636966513d3d222c2274797065223a5b2256657269666961626c6543726564656e7469616c222c224964656e74697479436c61696d734167677265676174696f6e43726564656e7469616c225d2c2276616c696446726f6d223a22323032362d30312d30315430303a30303a30302b30303a3030227d5840957d00066cd6d5f6918f18d9f6330decbc0b1df3664d0be7316865581fd77c169a229196116e1a957b521f9eb11900ab60a0adfadc780b5d586de34f6395380d6e7369676e65725f7061796c6f6164a2687369675f747970657820636177672e6964656e746974795f636c61696d735f6167677265676174696f6e757265666572656e6365645f617373657274696f6e7382a363616c67667368613235366375726c782973656c66236a756d62663d633270612e617373657274696f6e732f633270612e686173682e6461746164686173685820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa363616c67667368613235366375726c783373656c66236a756d62663d633270612e617373657274696f6e732f636f676d656d2e6d656d6f72792e70726f76656e616e636564686173685820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f");
367 let vc = verify_cawg_ica(&assertion).unwrap();
368 assert!(vc["issuer"].as_str().unwrap().starts_with("did:jwk:"));
369 assert!(vc["type"]
370 .as_array()
371 .unwrap()
372 .iter()
373 .any(|t| t == "IdentityClaimsAggregationCredential"));
374 }
375}