Skip to main content

qualia_core_db/crypto/
verifiable_credential.rs

1//! Verifiable Credentials — native issue / verify core (#19).
2//!
3//! A credential = an ISSUER (an agent), a SUBJECT (an agent), a set of claim quins, and
4//! a validity window, sealed with an Ed25519 signature over a canonical SHA-256 digest of
5//! those fields. This is the NATIVE proof (fast, fits the engine); a W3C JSON-LD Data
6//! Integrity export is future work, and the lineage is the W3C Verifiable Claims WG.
7//!
8//! Two principles are enforced here:
9//! * **Verification authenticates ORIGIN, not TRUTH** (principle-identifiers-not-identity):
10//!   a valid signature proves *who said it*, not that the claim is true. A verified VC
11//!   still enters the frame-relative machinery; it is never auto-promoted to fact.
12//! * **Grounded issuers** (agency.n3 G1', via `agent.rs`): a credential whose issuer is
13//!   an `ArtificialAgent` with no Principal is rejected by [`verify_grounded`] — an AI
14//!   agent cannot issue free-floating credentials with no human accountable behind it.
15
16use crate::NQuin;
17use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
18use sha2::{Digest, Sha256};
19
20/// A credential: who attests, about whom, what, and for how long.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Credential {
23    /// The issuing agent's identifier.
24    pub issuer: u64,
25    /// The subject the claims are about.
26    pub subject: u64,
27    /// Transaction time (when issued), unix seconds.
28    pub issued_at: u32,
29    /// Valid-until, unix seconds; `0` = no expiry.
30    pub valid_until: u32,
31    /// The claim quins (the subject's attested attributes). Order is part of the credential.
32    pub claims: Vec<NQuin>,
33}
34
35#[derive(Debug, PartialEq, Eq)]
36pub enum VcError {
37    /// The signature does not verify against the issuer key over the credential bytes.
38    InvalidSignature,
39    /// `now` is past `valid_until`.
40    Expired,
41    /// The issuer is an ungrounded artificial agent (no Principal) — agency.n3 G1'.
42    UngroundedIssuer,
43    /// The binary payload is too short to parse a Credential header.
44    DecodeTooShort,
45    /// The binary payload is too short for the declared claim count.
46    DecodeBadClaimCount,
47}
48
49impl std::fmt::Display for VcError {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        match self {
52            Self::InvalidSignature => write!(f, "VC invalid signature"),
53            Self::Expired => write!(f, "VC expired"),
54            Self::UngroundedIssuer => write!(f, "VC ungrounded issuer"),
55            Self::DecodeTooShort => write!(f, "VC decode: too short"),
56            Self::DecodeBadClaimCount => write!(f, "VC decode: bad claim count"),
57        }
58    }
59}
60impl std::error::Error for VcError {}
61
62/// Canonical SHA-256 digest over the binding fields + claim quins (claim count is
63/// length-prefixed to prevent extension ambiguity). Streams — no allocation.
64fn digest(c: &Credential) -> [u8; 32] {
65    let mut h = Sha256::new();
66    h.update(b"q42-vc-v1");
67    h.update(c.issuer.to_le_bytes());
68    h.update(c.subject.to_le_bytes());
69    h.update(c.issued_at.to_le_bytes());
70    h.update(c.valid_until.to_le_bytes());
71    h.update((c.claims.len() as u64).to_le_bytes());
72    for q in &c.claims {
73        let b: &[u8; 48] = bytemuck::cast_ref(q);
74        h.update(b);
75    }
76    h.finalize().into()
77}
78
79/// Issue: seal the credential with the issuer's Ed25519 signing key.
80pub fn issue(signing_key: &SigningKey, credential: &Credential) -> Signature {
81    signing_key.sign(&digest(credential))
82}
83
84/// Verify the signature + expiry. Authenticates the claim's ORIGIN (who issued it) and
85/// that it has not lapsed — NOT that the claim is true.
86pub fn verify(
87    credential: &Credential,
88    issuer_key: &VerifyingKey,
89    signature: &Signature,
90    now: u32,
91) -> Result<(), VcError> {
92    if credential.valid_until != 0 && now > credential.valid_until {
93        return Err(VcError::Expired);
94    }
95    issuer_key
96        .verify(&digest(credential), signature)
97        .map_err(|_| VcError::InvalidSignature)
98}
99
100/// Verify as [`verify`], and additionally reject the credential if its issuer is an
101/// ungrounded artificial agent in `index` (agency.n3 G1' — no human Principal behind it).
102pub fn verify_grounded(
103    credential: &Credential,
104    issuer_key: &VerifyingKey,
105    signature: &Signature,
106    now: u32,
107    index: &crate::indexing::QuinIndex,
108) -> Result<(), VcError> {
109    if crate::agent::is_ungrounded_agency(index, credential.issuer) {
110        return Err(VcError::UngroundedIssuer);
111    }
112    verify(credential, issuer_key, signature, now)
113}
114
115/// Serialize a `Credential` to binary format.
116pub fn encode_credential(c: &Credential) -> Vec<u8> {
117    let mut out = Vec::with_capacity(28 + c.claims.len() * 48);
118    out.extend_from_slice(&c.issuer.to_le_bytes());
119    out.extend_from_slice(&c.subject.to_le_bytes());
120    out.extend_from_slice(&c.issued_at.to_le_bytes());
121    out.extend_from_slice(&c.valid_until.to_le_bytes());
122    out.extend_from_slice(&(c.claims.len() as u32).to_le_bytes());
123    for q in &c.claims {
124        let b: &[u8; 48] = bytemuck::cast_ref(q);
125        out.extend_from_slice(b);
126    }
127    out
128}
129
130/// Deserialize a `Credential` from binary format.
131pub fn decode_credential(bytes: &[u8]) -> Result<Credential, VcError> {
132    if bytes.len() < 28 {
133        return Err(VcError::DecodeTooShort);
134    }
135    let issuer = u64::from_le_bytes(bytes[0..8].try_into().unwrap());
136    let subject = u64::from_le_bytes(bytes[8..16].try_into().unwrap());
137    let issued_at = u32::from_le_bytes(bytes[16..20].try_into().unwrap());
138    let valid_until = u32::from_le_bytes(bytes[20..24].try_into().unwrap());
139    let claims_len = u32::from_le_bytes(bytes[24..28].try_into().unwrap()) as usize;
140    if bytes.len() < 28 + claims_len * 48 {
141        return Err(VcError::DecodeBadClaimCount);
142    }
143    let mut claims = Vec::with_capacity(claims_len);
144    for i in 0..claims_len {
145        let start = 28 + i * 48;
146        let b = &bytes[start..start + 48];
147        // Claims sit at offset 28 + i*48 in the byte stream, so `b` is only
148        // 4-aligned while NQuin (6×u64) needs 8-alignment — `from_bytes` would
149        // panic (TargetAlignmentGreaterAndInputNotAligned) on any real decode.
150        // `pod_read_unaligned` copies the 48 bytes with no alignment requirement.
151        let q: NQuin = bytemuck::pod_read_unaligned(b);
152        claims.push(q);
153    }
154    Ok(Credential {
155        issuer,
156        subject,
157        issued_at,
158        valid_until,
159        claims,
160    })
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::agent::{A_ARTIFICIAL_AGENT, P_OPERATED_BY, P_RDF_TYPE};
167    use crate::indexing::QuinIndex;
168    use crate::q_hash;
169
170    fn key() -> SigningKey {
171        // Static secret so the test needs no RNG (mirrors agency.rs).
172        SigningKey::from_bytes(&[7u8; 32])
173    }
174    fn quin(s: u64, p: u64, o: u64) -> NQuin {
175        NQuin {
176            subject: s,
177            predicate: p,
178            object: o,
179            context: 0,
180            metadata: 0,
181            parity: 0,
182        }
183    }
184    fn sample() -> Credential {
185        Credential {
186            issuer: q_hash("did:example:issuer"),
187            subject: q_hash("did:example:alice"),
188            issued_at: 1_000,
189            valid_until: 2_000,
190            claims: vec![quin(
191                q_hash("did:example:alice"),
192                q_hash("https://ns.webcivics.net/capability/heldBy"),
193                q_hash("cap:FluidDynamics"),
194            )],
195        }
196    }
197
198    #[test]
199    fn issue_and_verify_roundtrip() {
200        let sk = key();
201        let c = sample();
202        let sig = issue(&sk, &c);
203        assert_eq!(verify(&c, &sk.verifying_key(), &sig, 1_500), Ok(()));
204    }
205
206    #[test]
207    fn tampered_claim_fails_verification() {
208        let sk = key();
209        let mut c = sample();
210        let sig = issue(&sk, &c);
211        c.claims[0].object = q_hash("cap:ForgedCredential"); // tamper after signing
212        assert_eq!(
213            verify(&c, &sk.verifying_key(), &sig, 1_500),
214            Err(VcError::InvalidSignature)
215        );
216    }
217
218    #[test]
219    fn wrong_issuer_key_fails() {
220        let sk = key();
221        let impostor = SigningKey::from_bytes(&[9u8; 32]);
222        let c = sample();
223        let sig = issue(&sk, &c);
224        assert_eq!(
225            verify(&c, &impostor.verifying_key(), &sig, 1_500),
226            Err(VcError::InvalidSignature)
227        );
228    }
229
230    #[test]
231    fn expired_credential_fails() {
232        let sk = key();
233        let c = sample();
234        let sig = issue(&sk, &c);
235        assert_eq!(
236            verify(&c, &sk.verifying_key(), &sig, 2_001),
237            Err(VcError::Expired)
238        );
239    }
240
241    #[test]
242    fn ungrounded_ai_issuer_is_rejected_but_grounded_one_is_accepted() {
243        let sk = key();
244        let c = sample();
245        let sig = issue(&sk, &c);
246
247        // Issuer is an ArtificialAgent with NO Principal -> ungrounded -> rejected.
248        let ungrounded = QuinIndex::from_slice(&[quin(c.issuer, P_RDF_TYPE, A_ARTIFICIAL_AGENT)]);
249        assert_eq!(
250            verify_grounded(&c, &sk.verifying_key(), &sig, 1_500, &ungrounded),
251            Err(VcError::UngroundedIssuer)
252        );
253
254        // Same issuer, now with a human Principal behind it -> grounded -> signature governs.
255        let human = q_hash("did:example:tim");
256        let grounded = QuinIndex::from_slice(&[
257            quin(c.issuer, P_RDF_TYPE, A_ARTIFICIAL_AGENT),
258            quin(c.issuer, P_OPERATED_BY, human),
259        ]);
260        assert_eq!(
261            verify_grounded(&c, &sk.verifying_key(), &sig, 1_500, &grounded),
262            Ok(())
263        );
264    }
265
266    #[test]
267    fn encode_decode_roundtrips() {
268        let c = sample();
269        let bytes = encode_credential(&c);
270        let back = decode_credential(&bytes).unwrap();
271        assert_eq!(c.issuer, back.issuer);
272        assert_eq!(c.subject, back.subject);
273        assert_eq!(c.issued_at, back.issued_at);
274        assert_eq!(c.valid_until, back.valid_until);
275        assert_eq!(c.claims.len(), back.claims.len());
276        assert_eq!(c.claims[0].subject, back.claims[0].subject);
277    }
278
279    #[test]
280    fn decode_rejects_truncated() {
281        assert_eq!(decode_credential(&[0u8; 10]), Err(VcError::DecodeTooShort));
282
283        let c = sample();
284        let mut bytes = encode_credential(&c);
285        bytes.truncate(bytes.len() - 10);
286        assert_eq!(decode_credential(&bytes), Err(VcError::DecodeBadClaimCount));
287    }
288}