Skip to main content

holographic_memory/core/provenance/
vc.rs

1// Copyright 2024-2026 WritersLogic Contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{anyhow, Result};
5use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9use super::did::did_key_from_ed25519;
10
11/// A W3C Verifiable Credential Data Model 2.0 credential wrapping an HMS fact.
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct FactCredential {
15    #[serde(rename = "@context")]
16    pub context: Vec<String>,
17    #[serde(rename = "type")]
18    pub credential_type: Vec<String>,
19    pub id: String,
20    pub issuer: String,
21    pub valid_from: String,
22    pub credential_subject: FactSubject,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub credential_status: Option<CredentialStatus>,
25    pub proof: Option<DataIntegrityProof>,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct CredentialStatus {
31    pub id: String,
32    #[serde(rename = "type")]
33    pub status_type: String,
34    pub status_purpose: String,
35    pub status_list_index: String,
36    pub status_list_credential: String,
37}
38
39#[derive(Clone, Debug, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase")]
41pub struct FactSubject {
42    pub id: String,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub source_uri: Option<String>,
45    pub content_hash: String,
46    pub encoding_method: String,
47    pub dimensions: u32,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub subject_id: Option<String>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub relation_id: Option<String>,
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub object_id: Option<String>,
54}
55
56#[derive(Clone, Debug, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct DataIntegrityProof {
59    #[serde(rename = "type")]
60    pub proof_type: String,
61    pub cryptosuite: String,
62    pub created: String,
63    pub verification_method: String,
64    pub proof_purpose: String,
65    pub proof_value: String,
66}
67
68/// Create an unsigned VC for a stored fact.
69pub fn create_fact_credential(
70    issuer_did: &str,
71    fact_id: &str,
72    content_hash: &[u8; 32],
73    dimensions: u32,
74    source_uri: Option<&str>,
75    triple: Option<(&str, &str, &str)>,
76    status_index: u64,
77) -> FactCredential {
78    let timestamp = chrono_iso8601_now();
79    let hash_hex = hex_encode(content_hash);
80
81    let mut subject = FactSubject {
82        id: format!("urn:hms:fact:{fact_id}"),
83        source_uri: source_uri.map(String::from),
84        content_hash: hash_hex,
85        encoding_method: "holographic-reduced-representation".to_string(),
86        dimensions,
87        subject_id: None,
88        relation_id: None,
89        object_id: None,
90    };
91
92    if let Some((s, r, o)) = triple {
93        subject.subject_id = Some(s.to_string());
94        subject.relation_id = Some(r.to_string());
95        subject.object_id = Some(o.to_string());
96    }
97
98    let vc_id = format!("urn:uuid:{}", simple_uuid());
99    let status = CredentialStatus {
100        id: format!("{vc_id}#status"),
101        status_type: "BitstringStatusListEntry".to_string(),
102        status_purpose: "revocation".to_string(),
103        status_list_index: status_index.to_string(),
104        status_list_credential: format!("{issuer_did}/status/1"),
105    };
106
107    FactCredential {
108        context: vec![
109            "https://www.w3.org/ns/credentials/v2".to_string(),
110            "https://writerslogic.com/ns/hms/v1".to_string(),
111        ],
112        credential_type: vec![
113            "VerifiableCredential".to_string(),
114            "HMSFactCredential".to_string(),
115        ],
116        id: vc_id,
117        issuer: issuer_did.to_string(),
118        valid_from: timestamp,
119        credential_subject: subject,
120        credential_status: Some(status),
121        proof: None,
122    }
123}
124
125/// RFC 8785 JSON Canonicalization Scheme.
126/// serde_json::Value uses BTreeMap for objects (sorted keys by default).
127/// We serialize to Value first, then to compact JSON bytes.
128fn jcs_serialize<T: serde::Serialize>(value: &T) -> Result<Vec<u8>> {
129    let json_value =
130        serde_json::to_value(value).map_err(|e| anyhow!("JCS serialization failed: {e}"))?;
131    serde_json::to_vec(&json_value).map_err(|e| anyhow!("JCS serialization failed: {e}"))
132}
133
134const PROOF_TYPE: &str = "DataIntegrityProof";
135const CRYPTOSUITE: &str = "eddsa-jcs-2022";
136const PROOF_PURPOSE: &str = "assertionMethod";
137
138fn proof_hash_data<T: Serialize>(
139    unsigned: &T,
140    proof_options: &serde_json::Value,
141) -> Result<[u8; 64]> {
142    let mut hash_data = [0u8; 64];
143    hash_data[..32].copy_from_slice(&Sha256::digest(jcs_serialize(proof_options)?));
144    hash_data[32..].copy_from_slice(&Sha256::digest(jcs_serialize(unsigned)?));
145    Ok(hash_data)
146}
147
148/// Create an eddsa-jcs-2022 Data Integrity proof over a proof-less document.
149/// Per W3C Data Integrity EdDSA Cryptosuites v1.0:
150///   hashData = SHA-256(JCS(proofOptions)) || SHA-256(JCS(unsignedDocument))
151///   signature = Ed25519.sign(hashData)
152pub(crate) fn sign_document<T: Serialize>(
153    signing_key: &SigningKey,
154    unsigned: &T,
155) -> Result<DataIntegrityProof> {
156    let issuer_did = did_key_from_ed25519(&signing_key.verifying_key().to_bytes());
157    let created = chrono_iso8601_now();
158    let verification_method = format!("{issuer_did}#key-0");
159    let proof_options = serde_json::json!({
160        "type": PROOF_TYPE,
161        "cryptosuite": CRYPTOSUITE,
162        "created": &created,
163        "verificationMethod": &verification_method,
164        "proofPurpose": PROOF_PURPOSE
165    });
166    let signature = signing_key.sign(&proof_hash_data(unsigned, &proof_options)?);
167
168    Ok(DataIntegrityProof {
169        proof_type: PROOF_TYPE.to_string(),
170        cryptosuite: CRYPTOSUITE.to_string(),
171        created,
172        verification_method,
173        proof_purpose: PROOF_PURPOSE.to_string(),
174        proof_value: multibase::encode(multibase::Base::Base58Btc, signature.to_bytes()),
175    })
176}
177
178/// Verify an eddsa-jcs-2022 proof over a proof-less document and return the
179/// issuer's key. The proof's verification method DID must equal `issuer`:
180/// without that binding an attacker can tamper a document, re-sign it with
181/// their own key, and have it verify under the original issuer's name.
182pub(crate) fn verify_document<T: Serialize>(
183    unsigned: &T,
184    issuer: &str,
185    proof: &DataIntegrityProof,
186) -> Result<VerifyingKey> {
187    if proof.proof_type != PROOF_TYPE
188        || proof.cryptosuite != CRYPTOSUITE
189        || proof.proof_purpose != PROOF_PURPOSE
190    {
191        return Err(anyhow!("unsupported proof type, cryptosuite, or purpose"));
192    }
193
194    let did_part = proof
195        .verification_method
196        .split('#')
197        .next()
198        .unwrap_or(&proof.verification_method);
199    let issuer_did = issuer.split('#').next().unwrap_or(issuer);
200    if did_part != issuer_did {
201        return Err(anyhow!(
202            "verification method DID ({did_part}) does not match credential issuer ({issuer_did})"
203        ));
204    }
205
206    let pk_bytes = super::did::ed25519_from_did_key(did_part)?;
207    let verifying_key =
208        VerifyingKey::from_bytes(&pk_bytes).map_err(|e| anyhow!("invalid public key: {e}"))?;
209
210    let (_, sig_bytes) = multibase::decode(&proof.proof_value)
211        .map_err(|e| anyhow!("multibase decode failed: {e}"))?;
212    let signature = ed25519_dalek::Signature::from_slice(&sig_bytes)
213        .map_err(|e| anyhow!("invalid signature: {e}"))?;
214
215    let proof_options = serde_json::json!({
216        "type": &proof.proof_type,
217        "cryptosuite": &proof.cryptosuite,
218        "created": &proof.created,
219        "verificationMethod": &proof.verification_method,
220        "proofPurpose": &proof.proof_purpose
221    });
222    verifying_key
223        .verify(&proof_hash_data(unsigned, &proof_options)?, &signature)
224        .map_err(|e| anyhow!("VC signature verification failed: {e}"))?;
225    Ok(verifying_key)
226}
227
228/// Sign a VC with Ed25519 DataIntegrity proof using eddsa-jcs-2022.
229pub fn sign_credential(
230    signing_key: &SigningKey,
231    mut credential: FactCredential,
232) -> Result<FactCredential> {
233    credential.proof = None;
234    credential.proof = Some(sign_document(signing_key, &credential)?);
235    Ok(credential)
236}
237
238/// Verify a signed VC against the DID:key in its proof.
239pub fn verify_credential(credential: &FactCredential) -> Result<()> {
240    let proof = credential
241        .proof
242        .as_ref()
243        .ok_or_else(|| anyhow!("credential has no proof"))?;
244    let mut unsigned = credential.clone();
245    unsigned.proof = None;
246    verify_document(&unsigned, &credential.issuer, proof).map(|_| ())
247}
248
249/// Parse a UTC `YYYY-MM-DDTHH:MM:SSZ` timestamp into unix seconds.
250pub(crate) fn parse_iso8601_utc(value: &str) -> Result<u64> {
251    let b = value.as_bytes();
252    let well_formed = b.len() == 20
253        && b[4] == b'-'
254        && b[7] == b'-'
255        && b[10] == b'T'
256        && b[13] == b':'
257        && b[16] == b':'
258        && b[19] == b'Z';
259    let field = |range: std::ops::Range<usize>| -> Option<u64> {
260        let part = value.get(range)?;
261        part.bytes()
262            .all(|c| c.is_ascii_digit())
263            .then(|| part.parse().ok())
264            .flatten()
265    };
266    let parsed = well_formed
267        .then(|| {
268            Some((
269                field(0..4)?,
270                field(5..7)?,
271                field(8..10)?,
272                field(11..13)?,
273                field(14..16)?,
274                field(17..19)?,
275            ))
276        })
277        .flatten();
278    let Some((year, month, day, hour, minute, second)) = parsed else {
279        return Err(anyhow!("timestamp must be YYYY-MM-DDTHH:MM:SSZ"));
280    };
281    if year < 1970 || !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 {
282        return Err(anyhow!("timestamp field out of range"));
283    }
284    // Days from civil (Howard Hinnant), the inverse of `days_to_ymd`.
285    let y = if month <= 2 { year - 1 } else { year };
286    let era = y / 400;
287    let yoe = y - era * 400;
288    let mp = (month + 9) % 12;
289    let doy = (153 * mp + 2) / 5 + day - 1;
290    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
291    let days = era * 146097 + doe - 719468;
292    if day == 0 || days_to_ymd(days) != (year, month, day) {
293        return Err(anyhow!("timestamp is not a valid calendar date"));
294    }
295    Ok(days * 86400 + hour * 3600 + minute * 60 + second)
296}
297
298pub(crate) fn chrono_iso8601_now() -> String {
299    let dur = std::time::SystemTime::now()
300        .duration_since(std::time::UNIX_EPOCH)
301        .unwrap_or_default();
302    let secs = dur.as_secs();
303    let days = secs / 86400;
304    let rem = secs % 86400;
305    let hours = rem / 3600;
306    let minutes = (rem % 3600) / 60;
307    let seconds = rem % 60;
308
309    let (year, month, day) = days_to_ymd(days);
310    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
311}
312
313fn days_to_ymd(days_since_epoch: u64) -> (u64, u64, u64) {
314    let z = days_since_epoch + 719468;
315    let era = z / 146097;
316    let doe = z - era * 146097;
317    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
318    let y = yoe + era * 400;
319    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
320    let mp = (5 * doy + 2) / 153;
321    let d = doy - (153 * mp + 2) / 5 + 1;
322    let m = if mp < 10 { mp + 3 } else { mp - 9 };
323    let y = if m <= 2 { y + 1 } else { y };
324    (y, m, d)
325}
326
327fn simple_uuid() -> String {
328    use rand::Rng;
329    let mut bytes = [0u8; 16];
330    rand::rng().fill_bytes(&mut bytes);
331    bytes[6] = (bytes[6] & 0x0f) | 0x40;
332    bytes[8] = (bytes[8] & 0x3f) | 0x80;
333    format!(
334        "{:08x}-{:04x}-{:04x}-{:04x}-{:012x}",
335        u32::from_be_bytes(bytes[0..4].try_into().unwrap()),
336        u16::from_be_bytes(bytes[4..6].try_into().unwrap()),
337        u16::from_be_bytes(bytes[6..8].try_into().unwrap()),
338        u16::from_be_bytes(bytes[8..10].try_into().unwrap()),
339        u64::from_be_bytes({
340            let mut buf = [0u8; 8];
341            buf[2..8].copy_from_slice(&bytes[10..16]);
342            buf
343        }),
344    )
345}
346
347fn hex_encode(bytes: &[u8]) -> String {
348    bytes.iter().map(|b| format!("{b:02x}")).collect()
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn test_keypair() -> SigningKey {
356        SigningKey::from_bytes(&rand::random())
357    }
358
359    #[test]
360    fn create_and_sign_vc() {
361        let key = test_keypair();
362        let hash = [0xABu8; 32];
363        let did = did_key_from_ed25519(&key.verifying_key().to_bytes());
364
365        let vc = create_fact_credential(&did, "fact-001", &hash, 16384, None, None, 0);
366        assert_eq!(vc.credential_type[1], "HMSFactCredential");
367
368        let signed = sign_credential(&key, vc).unwrap();
369        assert!(signed.proof.is_some());
370
371        verify_credential(&signed).unwrap();
372    }
373
374    #[test]
375    fn vc_with_triple() {
376        let key = test_keypair();
377        let hash = [0xCDu8; 32];
378        let did = did_key_from_ed25519(&key.verifying_key().to_bytes());
379
380        let vc = create_fact_credential(
381            &did,
382            "triple-001",
383            &hash,
384            16384,
385            Some("https://example.com/source"),
386            Some(("paris", "capital_of", "france")),
387            1,
388        );
389        assert_eq!(vc.credential_subject.subject_id.as_deref(), Some("paris"));
390        assert_eq!(
391            vc.credential_subject.relation_id.as_deref(),
392            Some("capital_of")
393        );
394
395        let signed = sign_credential(&key, vc).unwrap();
396        verify_credential(&signed).unwrap();
397    }
398
399    #[test]
400    fn issuer_mismatch_rejected() {
401        // A victim's credential re-signed by an attacker: the signature is valid
402        // for the attacker's key, but the claimed issuer is the victim. Binding
403        // the verification method to the issuer must reject this forgery.
404        let victim = test_keypair();
405        let attacker = test_keypair();
406        let victim_did = did_key_from_ed25519(&victim.verifying_key().to_bytes());
407        let hash = [0x11u8; 32];
408
409        let vc = create_fact_credential(&victim_did, "forge-001", &hash, 16384, None, None, 0);
410        let signed = sign_credential(&attacker, vc).unwrap();
411
412        assert!(verify_credential(&signed).is_err());
413    }
414
415    #[test]
416    fn tampered_vc_rejected() {
417        let key = test_keypair();
418        let hash = [0xABu8; 32];
419        let did = did_key_from_ed25519(&key.verifying_key().to_bytes());
420
421        let vc = create_fact_credential(&did, "fact-001", &hash, 16384, None, None, 0);
422        let mut signed = sign_credential(&key, vc).unwrap();
423        signed.credential_subject.content_hash = "tampered".to_string();
424
425        assert!(verify_credential(&signed).is_err());
426    }
427
428    #[test]
429    fn vc_json_structure() {
430        let key = test_keypair();
431        let hash = [0u8; 32];
432        let did = did_key_from_ed25519(&key.verifying_key().to_bytes());
433
434        let vc = create_fact_credential(&did, "f1", &hash, 10000, None, None, 0);
435        let json_str = serde_json::to_string_pretty(&vc).unwrap();
436        assert!(json_str.contains("@context"));
437        assert!(json_str.contains("VerifiableCredential"));
438        assert!(json_str.contains("holographic-reduced-representation"));
439    }
440}