Skip to main content

holographic_memory/core/provenance/
cawg.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::Result;
5use base64::Engine;
6use ed25519_dalek::{Signer, SigningKey};
7use serde::{Deserialize, Serialize};
8
9use super::did;
10
11/// A hashed URI reference to a C2PA assertion.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13pub struct HashedUri {
14    pub url: String,
15    pub alg: String,
16    pub hash: String,
17}
18
19/// Build a HashedUri from a label and content bytes (hex-encoded SHA-256 digest).
20pub 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
30/// The CAWG ICA `sig_type` for the identity claims aggregation credential.
31const 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
37/// Create a CAWG Identity Claims Aggregation (ICA) identity assertion — the production
38/// path c2pa-rs ships. Builds the cogmem ICA shape: a `signer_payload`
39/// `{referenced_assertions, sig_type}`, a W3C VC v2 of type
40/// `IdentityClaimsAggregationCredential` issued by the agent's did:jwk, secured by a
41/// tag-18 COSE_Sign1 (EdDSA, `application/vc`) over the VC JSON, embedded as
42/// `{signer_payload, signature, pad1}`. `referenced` is `(url, alg, raw_hash)` and MUST
43/// include the hard binding (a `c2pa.hash.*` assertion). Returns the JSON wrapper stored
44/// under the `cawg.identity` assertion label: `{"alg":"EdDSA","ica":"<base64 CBOR>"}`,
45/// whose `ica` field is the embedded IdentityAssertion CBOR that [`verify_cawg_ica`]
46/// reads back.
47pub 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    // signer_payload: CBOR map with raw bytestring hashes (carried in the assertion).
58    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    // c2paAsset: the SignerPayload in JSON form with STANDARD base64 hashes.
80    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    // W3C VC v2 IdentityClaimsAggregationCredential.
96    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    // Tag-18 COSE_Sign1 over the VC JSON: protected {alg EdDSA, content_type
115    // application/vc}, empty external_aad.
116    let cose = sign_cose_vc(agent_key, &vc_bytes)?;
117
118    // Embedded IdentityAssertion: {signer_payload, signature, pad1}.
119    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
134/// Read the embedded IdentityAssertion CBOR bytes from a `cawg.identity` assertion JSON
135/// produced by [`create_identity_assertion_ica`] (the base64-encoded `ica` field).
136pub 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
146/// Tag-18 COSE_Sign1 over the VC JSON: protected {alg EdDSA, content_type
147/// application/vc}, empty external_aad, non-detached payload.
148fn 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
168/// Current UTC time as an RFC 3339 / ISO 8601 `validFrom` timestamp.
169fn 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
194/// Verify a CAWG Identity Claims Aggregation (ICA) identity assertion from its raw CBOR
195/// bytes — the `cawg.identity` assertion box content `{signer_payload, signature, pad1}`.
196/// The signature is a tag-18 COSE_Sign1 (`application/vc`) over the W3C VC JSON, signed by
197/// the did:jwk issuer. Checks: sig_type is ICA, a hard binding is referenced, and the COSE
198/// signature is valid under the issuer DID. Returns the verified, parsed VC JSON.
199pub 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        // Corrupt the COSE signature bytes near the tail — must be rejected.
338        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        // A cawg.identity (ICA) assertion produced by cogmem (Python / cbor2). HMS verifies
365        // the tag-18 COSE over the VC from the raw bytes — cross-implementation conformance.
366        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}