diff --git a/Cargo.lock b/Cargo.lock index d86dde5..65daf68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,6 +220,12 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base256emoji" version = "1.0.2" @@ -530,6 +536,18 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -667,6 +685,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -694,6 +726,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -743,6 +795,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -902,6 +964,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -943,6 +1006,17 @@ dependencies = [ "wasip3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.13" @@ -1482,6 +1556,24 @@ dependencies = [ "ulid", ] +[[package]] +name = "idprova-vc" +version = "0.1.1" +dependencies = [ + "base64", + "chrono", + "ed25519-dalek", + "idprova-core", + "multibase", + "p256", + "proptest", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2", + "thiserror 1.0.69", +] + [[package]] name = "idprova-verify" version = "0.1.1" @@ -1957,6 +2049,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2134,6 +2238,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2477,6 +2590,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2686,6 +2809,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" diff --git a/Cargo.toml b/Cargo.toml index a7fb930..1394e94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/idprova-middleware", "crates/idprova-mcp", "crates/idprova-webbotauth", + "crates/idprova-vc", "sdks/python", "sdks/typescript/packages/core", ] diff --git a/DESIGN-pillar-c.md b/DESIGN-pillar-c.md new file mode 100644 index 0000000..4ea8238 --- /dev/null +++ b/DESIGN-pillar-c.md @@ -0,0 +1,86 @@ +# Pillar C — `idprova-vc` Design Document + +**Author:** Claude (orchestrator) +**Builder:** GLM (z.ai) +**Date:** 2026-06-25 +**Status:** DESIGN-ONLY Skeleton + +## 1. Mission & Positioning +The `idprova-vc` crate provides a W3C Verifiable Credentials (Data Model 2.0) interop layer on top of the existing Delegation Attestation Token (DAT) runtime. + +Its goal is to allow IDProva agents to participate in external identity and commerce rails—specifically **Google AP2 / Agent2Agent (A2A)** and **EUDI-style wallets**—without abandoning IDProva's deterministic-verify moat. + +**Core Philosophy:** DAT remains the execution-integrity primitive (handling capabilities and authorization). VC is strictly an **interop projection** (handling attestations and identity) mapped via a deterministic bridge. The LLM never touches cryptographic bytes. + +## 2. Standards Alignment +- **W3C VC Data Model 2.0**: JSON-LD `@context` (`https://www.w3.org/ns/credentials/v2`), `type`, `issuer`, `validFrom`/`validUntil`, `credentialSubject`, `credentialStatus`. +- **Data Integrity Proofs**: + - Default: **`eddsa-jcs-2022`** (Ed25519 over RFC 8785 JCS). Reuses the workspace `serde_json_canonicalizer`. Avoids the URDNA2015 `@context` fragility documented in the TU-Berlin failure. + - Opt-in (`rdfc-interop` feature): `eddsa-rdfc-2022` (RDF canonicalization). + - Opt-in (`ecdsa-interop` feature): **`ecdsa-rdfc-2019`** (P-256). Required for AP2 compliance. +- **Revocation**: `credentialStatus` = **StatusList 2021 / Bitstring Status List**. +- **Presentation**: **DIF Presentation Exchange** (`presentation_definition`, `presentation_submission`). + +## 3. Cryptosuite / Feature Matrix + +| CryptoSuite | Features Required | Canonicalization | Signature Alg | Use Case | +|-------------|-------------------|------------------|---------------|----------| +| `EddsaJcs2022` | *(default)* | RFC 8785 (JCS) | Ed25519 | Standard IDProva interop (deterministic, safe) | +| `EddsaRdfc2022`| `rdfc-interop` | URDNA2015 | Ed25519 | Strict JSON-LD interop | +| `EcdsaRdfc2019`| `ecdsa-interop`, `rdfc-interop` | URDNA2015 | ECDSA P-256 | Google AP2 / Agent2Agent (A2A) rails | + +## 4. DAT ↔ VC Mapping Table +The bridge maps IDProva runtime concepts into W3C VC attestations. This mapping is **lossy by design** because DAT encodes *authorization/capability* while VC encodes *identity/attestation*. + +| IDProva (DAT) | W3C VC | +|---------------|--------| +| `DatClaims.iss` (Delegator DID) | `issuer` | +| `DatClaims.sub` (Agent DID) | `credentialSubject.id` | +| `DatClaims.scope` (Vec) | `credentialSubject.scopes` | +| `DatClaims.constraints` | `credentialSubject.constraints` | +| `trust::level` | `credentialSubject.trustLevel` / `evidence` | +| `DatClaims.exp` | `validUntil` | +| `DatClaims.iat` | `validFrom` / `issuanceDate` | + +**Reverse Mapping (`vc_to_dat_claims`):** Extracts `iss`, `sub`, `scope`, `constraints`, and `exp` from the VC if the schema matches the IDProva profile. Returns `None` if unmappable. + +## 5. AP2 Intent Mandate Example +Google AP2 mandates are JSON-LD VCs signed with ECDSA P-256, transmitted over A2A. + +```json +{ + "@context": ["https://www.w3.org/ns/credentials/v2", "https://schema.org/"], + "type": ["VerifiableCredential", "IntentMandate"], + "issuer": "did:web:agent.idprova.dev", + "credentialSubject": { + "id": "did:web:merchant.example", + "intent": "purchase_checkout", + "transactionId": "tx_12345" + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "ecdsa-rdfc-2019", + "proofValue": "z3X... " + } +} +``` + +## 6. DIF Presentation Exchange Example +```json +{ + "presentation_definition": { + "id": "pd_ap2_intent", + "input_descriptors": [{ + "id": "ap2_intent", + "constraints": { "fields": [{ "path": ["$.type"], "filter": { "const": "IntentMandate" } }] } + }] + } +} +``` + +## 7. Test Matrix +- `test_issue_verify_roundtrip`: A VC issued by `VcIssuer` passes `VcVerifier::verify`. +- `test_tampered_vc_rejected`: Modifying `credentialSubject` invalidates the proof. +- `test_revoked_statuslist_rejected`: A VC with a revoked `credentialStatus` entry fails `verify`. +- `test_dat_to_vc_preserves_scope`: `dat_to_vc` correctly maps `ScopeSet` to `credentialSubject`. +- `test_ap2_intent_shape`: `to_ap2_mandate(Intent)` yields the correct VC profile shape. diff --git a/crates/idprova-vc/Cargo.toml b/crates/idprova-vc/Cargo.toml new file mode 100644 index 0000000..c530f14 --- /dev/null +++ b/crates/idprova-vc/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "idprova-vc" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation.workspace = true +authors.workspace = true +keywords = ["identity", "ai-agent", "verifiable-credentials", "dat", "ap2"] +categories = ["authentication", "cryptography"] +description = "IDProva W3C Verifiable Credentials (DM 2.0) interop layer" + +[dependencies] +idprova-core = { path = "../idprova-core", version = "0.1.1" } + +serde.workspace = true +serde_json.workspace = true +serde_json_canonicalizer.workspace = true +ed25519-dalek.workspace = true +base64.workspace = true +chrono.workspace = true +thiserror.workspace = true +sha2.workspace = true +multibase.workspace = true + +# Optional interop deps +p256 = { version = "0.13", features = ["ecdsa"], optional = true } + +[features] +default = [] +# Enables strict JSON-LD / RDF canonicalization suites +rdfc-interop = [] +# Enables P-256 / ECDSA suites for Google AP2 compliance +ecdsa-interop = ["dep:p256", "rdfc-interop"] + +[dev-dependencies] +proptest.workspace = true diff --git a/crates/idprova-vc/SPEC.md b/crates/idprova-vc/SPEC.md new file mode 100644 index 0000000..aac43a7 --- /dev/null +++ b/crates/idprova-vc/SPEC.md @@ -0,0 +1,86 @@ +# Pillar C — `idprova-vc` — SPEC (design intent for GLM codegen) + +> **Author:** Claude (orchestrator/design). **Builder:** GLM (z.ai). **Scope this round:** +> DESIGN-ONLY — public API + types + doc comments + `todo!()` bodies + test stubs, `cargo check` +> clean. The whole point is **deterministic** VC handling (the TU-Berlin failure proves crypto must +> stay out of the LLM). + +## 1. Mission & positioning +A **W3C Verifiable Credentials (Data Model 2.0) interop layer on top of DAT** — issue/verify VCs +(JSON-LD), a **DIF Presentation Exchange** adapter, and a **deterministic DAT↔VC mapping** so IDProva +plugs into **Google AP2 / Agent2Agent (A2A)** and EUDI-style rails **without abandoning the DAT +runtime**. DAT stays the execution-integrity primitive; VC is an **interop projection** over it. This +closes the gap-analysis "Critical" item (format/protocol mismatch + the *attestation* half of KYA) +while keeping IDProva's deterministic-verify moat. + +## 2. Standards alignment (verified 2026-06-25 — build to these exactly) +- **W3C VC Data Model 2.0**: JSON-LD `@context` (`https://www.w3.org/ns/credentials/v2`), `type`, + `issuer`, `validFrom`/`validUntil`, `credentialSubject`, `credentialStatus`. +- **Data Integrity proofs**: default **`eddsa-jcs-2022`** (Ed25519 over **RFC 8785 JCS** — matches + IDProva's curve *and* the workspace's existing `serde_json_canonicalizer`, and sidesteps the + URDNA2015 `@context`-resolution failures the TU-Berlin paper documented). Provide `eddsa-rdfc-2022` + and **`ecdsa-rdfc-2019` (P-256)** behind a `rdfc-interop` / `ecdsa-interop` feature for strict + JSON-LD + **AP2** interop (AP2 mandates sign ECDSA P-256). +- **Revocation:** `credentialStatus` = **StatusList 2021 / Bitstring Status List** — checked at + verify time (closes gap #6: verify-time freshness baked into the SDK call). +- **DIF Presentation Exchange**: `presentation_definition` + `presentation_submission`. +- **Google AP2 Mandates** (Intent / Cart / Payment) are W3C VCs (JSON-LD, ECDSA P-256) carried over + A2A/JSON-RPC — provide typed mandate builders so IDProva agents can participate in the 60+-partner + rail. + +## 3. Crate & placement +- New crate `crates/idprova-vc`; add to root `Cargo.toml` `members`. +- Reuse `idprova-core`: `dat::token::{Dat, DatClaims, DatHeader}`, `dat::scope::ScopeSet`, + `dat::constraints::DatConstraints`, `aid` (issuer/subject DIDs), `crypto` (Ed25519), `trust::level`. + Do not reimplement crypto or DAT. + +## 4. Public API surface (modules → key items, `todo!()` bodies) +- `mod model` — `VerifiableCredential` (VC DM 2.0 fields), `VerifiablePresentation`, + `DataIntegrityProof { type_, cryptosuite, created, verification_method, proof_purpose, + proof_value }`, `CredentialStatus` (StatusList2021 entry), `enum CryptoSuite { EddsaJcs2022, + EddsaRdfc2022, EcdsaRdfc2019 }`. +- `mod issue` — `VcIssuer { issuer_did, signing_key, suite }`; + `fn issue(&self, subject, claims, valid_until, status) -> VerifiableCredential` — **deterministic** + (canonicalize per suite → sign). Default suite EddsaJcs2022 via `serde_json_canonicalizer`. +- `mod verify` — `VcVerifier`; `trait IssuerResolver { fn verifying_key(issuer_did) -> Option<...> }`; + `trait StatusResolver { fn is_revoked(status) -> bool }`; + `fn verify(&self, vc, issuer_resolver, status_resolver, now) -> VcVerifyOutcome` — signature + + validity window + **StatusList revocation** + issuer-trust hook. LLM never touches bytes. +- `mod presentation` — DIF Presentation Exchange: `PresentationDefinition`, `InputDescriptor`, + `PresentationSubmission`; `fn build_submission(def, creds) -> (VerifiablePresentation, Submission)`; + `fn evaluate(def, vp) -> EvalOutcome`. +- `mod dat_bridge` — **the crux**: `fn dat_to_vc(dat: &Dat, issuer_did) -> VerifiableCredential` + (map `ScopeSet` + `DatConstraints` → `credentialSubject` capability claims; `trust::level` → + evidence) and `fn vc_to_dat_claims(vc) -> Option` (where mappable). Mapping is + **lossy-by-design and documented** (DAT = capability/authorization; VC = attestation/identity). +- `mod ap2` — typed **AP2 mandate** builders: `IntentMandate`, `CartMandate`, `PaymentMandate` as VC + profiles; `fn to_ap2_mandate(kind, ...) -> VerifiableCredential`. Forward bridge into AP2/A2A. + +## 5. Dependencies +`idprova-core` (path), `serde`, `serde_json`, `serde_json_canonicalizer`, `ed25519-dalek`, `base64`, +`chrono`, `thiserror`. Optional behind features: `p256` (`ecdsa-interop`), an RDF canonicalizer +(`rdfc-interop`, may stay `todo!()` this round). Dev: `proptest`. + +## 6. Design decisions to encode ("futuristic + definitely-work") +1. **`eddsa-jcs-2022` as default** — deterministic, Ed25519, reuses the workspace canonicalizer, and + avoids JSON-LD URDNA2015 `@context` fragility (the exact failure TU-Berlin hit). Strict JSON-LD / + AP2-ECDSA suites are opt-in features so we interop without taking on that fragility by default. +2. **DAT is the runtime; VC is a projection** — two-way bridge documented as lossy; we never replace + the DAT verify path, we *expose* it as portable VCs for the rails. +3. **Verify-time revocation** — StatusList check is part of `verify`, not an afterthought. +4. **AP2-ready** — Intent/Cart/Payment mandate profiles so an IDProva agent can present into the + 60+-partner Google rail on day one of build. + +## 7. What GLM must output +- `DESIGN-pillar-c.md`: architecture; a VC DM 2.0 example with an `eddsa-jcs-2022` proof; the + cryptosuite/feature matrix; the **DAT↔VC mapping table** (scope/constraints/trust-level → + credentialSubject, and the documented lossy reverse); the AP2 Intent-mandate example; a DIF PE + example; test matrix. +- The `crates/idprova-vc` skeleton — all items above, doc comments, `todo!()` bodies, `cargo check` + clean; `#[ignore]`d test stubs (issue→verify round trip; tampered VC rejected; revoked-via-StatusList + rejected; `dat_to_vc` preserves scope; AP2 Intent shape). +- Edit root `Cargo.toml` to add the member. + +## 8. Hard constraints +DESIGN-ONLY, deterministic (LLM-out-of-loop), no secrets, match workspace style, keep tight. RDF +canonicalization may remain `todo!()` behind a feature this round; JCS path is the real default. diff --git a/crates/idprova-vc/src/ap2.rs b/crates/idprova-vc/src/ap2.rs new file mode 100644 index 0000000..53502c0 --- /dev/null +++ b/crates/idprova-vc/src/ap2.rs @@ -0,0 +1,54 @@ +//! Google AP2 / Agent2Agent (A2A) typed mandate builders. + +use crate::model::{DataIntegrityProof, VerifiableCredential}; + +/// Enumerates the AP2 mandate profiles. +pub enum Ap2Kind { + Intent, + Cart, + Payment, +} + +/// Builds an AP2 compliant Verifiable Credential. +/// +/// Note: AP2 mandates strictly require P-256 (ECDSA) signatures carried over A2A. +pub fn to_ap2_mandate( + kind: Ap2Kind, + issuer_did: &str, + subject_did: &str, + payload: serde_json::Value, + proof: DataIntegrityProof, +) -> VerifiableCredential { + let type_str = match kind { + Ap2Kind::Intent => "IntentMandate", + Ap2Kind::Cart => "CartMandate", + Ap2Kind::Payment => "PaymentMandate", + }; + + let mut subject_map = serde_json::Map::new(); + subject_map.insert( + "id".to_string(), + serde_json::Value::String(subject_did.to_string()), + ); + if let serde_json::Value::Object(obj) = payload { + for (k, v) in obj { + subject_map.insert(k, v); + } + } + + VerifiableCredential { + context: vec![ + "https://www.w3.org/ns/credentials/v2".to_string(), + "https://schema.org/".to_string(), + "https://google.com/Ap2Mandates/v1".to_string(), + ], + types: vec!["VerifiableCredential".to_string(), type_str.to_string()], + id: format!("urn:uuid:ap2_{}", chrono::Utc::now().timestamp()), + issuer: issuer_did.to_string(), + valid_from: chrono::Utc::now(), + valid_until: None, + credential_subject: serde_json::Value::Object(subject_map), + credential_status: None, + proof, + } +} diff --git a/crates/idprova-vc/src/dat_bridge.rs b/crates/idprova-vc/src/dat_bridge.rs new file mode 100644 index 0000000..03cb34d --- /dev/null +++ b/crates/idprova-vc/src/dat_bridge.rs @@ -0,0 +1,68 @@ +//! The deterministic DAT to VC bridge. + +use crate::model::VerifiableCredential; +use chrono::TimeZone; +use idprova_core::dat::token::DatClaims; + +/// Converts a DAT into a Verifiable Credential format. +/// +/// This mapping is **lossy by design**. +/// DAT encodes *authorization/capability* while VC encodes *identity/attestation*. +pub fn dat_to_vc( + dat_claims: &DatClaims, + proof: crate::model::DataIntegrityProof, +) -> VerifiableCredential { + let subject = serde_json::json!({ + "id": dat_claims.sub, + "scopes": dat_claims.scope, + "constraints": dat_claims.constraints, + "config_attestation": dat_claims.config_attestation + }); + + VerifiableCredential { + context: vec![ + "https://www.w3.org/ns/credentials/v2".to_string(), + "https://www.idprova.dev/contexts/dat-bridge/v1.json".to_string(), + ], + types: vec![ + "VerifiableCredential".to_string(), + "IDProvaDelegatedCapability".to_string(), + ], + id: format!("urn:dat:{}", dat_claims.jti), + issuer: dat_claims.iss.clone(), + valid_from: chrono::Utc.timestamp_opt(dat_claims.iat, 0).unwrap(), + valid_until: Some(chrono::Utc.timestamp_opt(dat_claims.exp, 0).unwrap()), + credential_subject: subject, + credential_status: None, + proof, + } +} + +/// Attempts to extract DAT claims from a mapped VC. +/// +/// Returns `None` if the VC does not represent an IDProva delegated capability. +pub fn vc_to_dat_claims(vc: &VerifiableCredential) -> Option { + if !vc.types.contains(&"IDProvaDelegatedCapability".to_string()) { + return None; + } + + let sub = vc.credential_subject.get("id")?.as_str()?.to_string(); + let scopes = vc.credential_subject.get("scopes")?.as_array()?; + let scope_strings: Vec = scopes + .iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect(); + + Some(DatClaims { + iss: vc.issuer.clone(), + sub, + iat: vc.valid_from.timestamp(), + exp: vc.valid_until?.timestamp(), + nbf: vc.valid_from.timestamp(), + jti: vc.id.strip_prefix("urn:dat:")?.to_string(), + scope: scope_strings, + constraints: None, // TODO: reverse map constraints + config_attestation: None, + delegation_chain: Some(vec![]), + }) +} diff --git a/crates/idprova-vc/src/issue.rs b/crates/idprova-vc/src/issue.rs new file mode 100644 index 0000000..b3aafd4 --- /dev/null +++ b/crates/idprova-vc/src/issue.rs @@ -0,0 +1,97 @@ +//! VC Issuance logic. + +use crate::model::{CryptoSuite, DataIntegrityProof, VerifiableCredential}; +use crate::suite::jcs_hash; +use chrono::{Duration, Utc}; +use idprova_core::crypto::KeyPair; + +/// Issues Verifiable Credentials deterministically. +pub struct VcIssuer { + pub issuer_did: String, + pub signing_key: KeyPair, + pub suite: CryptoSuite, +} + +impl VcIssuer { + pub fn new(issuer_did: String, signing_key: KeyPair, suite: CryptoSuite) -> Self { + Self { + issuer_did, + signing_key, + suite, + } + } + + /// Issues a Verifiable Credential. + /// + /// Canonicalizes the *serialized typed VC* per the selected suite and signs + /// it deterministically. The same canonicalization is used on the verify + /// path (see [`crate::suite::jcs_hash`]), so issue → verify always matches. + pub fn issue( + &self, + subject_did: &str, + claims: serde_json::Value, + valid_for: Duration, + status: Option, + ) -> VerifiableCredential { + let now = Utc::now(); + + // Build the credential subject: { "id": subject_did, ...claims }. + let mut subject_map = serde_json::Map::new(); + subject_map.insert( + "id".to_string(), + serde_json::Value::String(subject_did.to_string()), + ); + if let serde_json::Value::Object(obj) = claims { + for (k, v) in obj { + subject_map.insert(k, v); + } + } + + // The proof is built with an EMPTY proof_value; `jcs_hash` strips + // proof_value before hashing, so the placeholder is irrelevant to the + // signed bytes but keeps the proof config (type, cryptosuite, created, + // verificationMethod, proofPurpose) inside the signature. + let proof = DataIntegrityProof { + type_: "DataIntegrityProof".to_string(), + cryptosuite: self.suite.clone(), + created: now, + verification_method: format!("{}#key-1", self.issuer_did), + proof_purpose: "assertionMethod".to_string(), + proof_value: String::new(), + }; + + let mut vc = VerifiableCredential { + context: vec![ + "https://www.w3.org/ns/credentials/v2".to_string(), + "https://www.idprova.dev/contexts/v1.json".to_string(), + ], + types: vec!["VerifiableCredential".to_string()], + id: format!("urn:uuid:{}", uuid_like()), + issuer: self.issuer_did.clone(), + valid_from: now, + valid_until: Some(now + valid_for), + credential_subject: serde_json::Value::Object(subject_map), + credential_status: status, + proof, + }; + + match self.suite { + CryptoSuite::EddsaJcs2022 => { + let hash = jcs_hash(&vc).expect("eddsa-jcs-2022 hashing failed"); + let sig = self.signing_key.sign(&hash); + // Multibase base58btc ('z'-prefixed) per W3C Data Integrity. + vc.proof.proof_value = multibase::encode(multibase::Base::Base58Btc, sig); + } + CryptoSuite::EddsaRdfc2022 | CryptoSuite::EcdsaRdfc2019 => { + todo!("RDF canonicalization signing (requires rdfc-interop)") + } + } + + vc + } +} + +fn uuid_like() -> String { + // Todo: use ulid or uuid crate if strictly required, otherwise rely on core's generation if exposed + chrono::Utc::now().timestamp().to_string() +} diff --git a/crates/idprova-vc/src/lib.rs b/crates/idprova-vc/src/lib.rs new file mode 100644 index 0000000..2db879b --- /dev/null +++ b/crates/idprova-vc/src/lib.rs @@ -0,0 +1,12 @@ +//! # IDProva VC +//! +//! W3C Verifiable Credentials (Data Model 2.0) interop layer for IDProva. +//! Provides deterministic VC issuance, verification, and DAT bridging. + +pub mod ap2; +pub mod dat_bridge; +pub mod issue; +pub mod model; +pub mod presentation; +pub(crate) mod suite; +pub mod verify; diff --git a/crates/idprova-vc/src/model.rs b/crates/idprova-vc/src/model.rs new file mode 100644 index 0000000..4042037 --- /dev/null +++ b/crates/idprova-vc/src/model.rs @@ -0,0 +1,67 @@ +//! W3C VC Data Model 2.0 core types. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Supported Data Integrity cryptographic suites. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum CryptoSuite { + /// Ed25519 over RFC 8785 JCS (Default). + EddsaJcs2022, + /// Ed25519 over URDNA2015 RDF canonicalization. + EddsaRdfc2022, + /// ECDSA P-256 over URDNA2015 RDF canonicalization (Required for AP2). + EcdsaRdfc2019, +} + +/// A Data Integrity Proof. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DataIntegrityProof { + #[serde(rename = "type")] + pub type_: String, + pub cryptosuite: CryptoSuite, + pub created: DateTime, + pub verification_method: String, + pub proof_purpose: String, + pub proof_value: String, // Multibase encoded signature +} + +/// StatusList 2021 entry for `credentialStatus`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredentialStatus { + #[serde(rename = "type")] + pub type_: String, + pub id: String, + pub status_purpose: String, + pub status_list_index: String, +} + +/// A W3C Verifiable Credential (Data Model 2.0). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifiableCredential { + #[serde(rename = "@context")] + pub context: Vec, + #[serde(rename = "type")] + pub types: Vec, + pub id: String, + pub issuer: String, + pub valid_from: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_until: Option>, + pub credential_subject: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_status: Option, + pub proof: DataIntegrityProof, +} + +/// A W3C Verifiable Presentation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VerifiablePresentation { + #[serde(rename = "@context")] + pub context: Vec, + #[serde(rename = "type")] + pub types: Vec, + pub verifiable_credential: Vec, + pub proof: DataIntegrityProof, +} diff --git a/crates/idprova-vc/src/presentation.rs b/crates/idprova-vc/src/presentation.rs new file mode 100644 index 0000000..5f91394 --- /dev/null +++ b/crates/idprova-vc/src/presentation.rs @@ -0,0 +1,81 @@ +//! DIF Presentation Exchange adapter. + +use crate::model::{DataIntegrityProof, VerifiableCredential, VerifiablePresentation}; + +/// Defines required inputs for a presentation. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PresentationDefinition { + pub id: String, + pub input_descriptors: Vec, +} + +/// Describes a specific constraint on an input VC. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct InputDescriptor { + pub id: String, + pub schema: Vec, + pub constraints: serde_json::Value, +} + +/// Maps presented credentials to definition requirements. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct PresentationSubmission { + pub id: String, + pub definition_id: String, + pub descriptor_map: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct DescriptorMap { + pub id: String, + pub format: String, + pub path: String, +} + +/// Outcome of evaluating a presentation against a definition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EvalOutcome { + Valid, + Invalid(String), +} + +/// Builds a Verifiable Presentation from a set of credentials matching a definition. +pub fn build_submission( + def: &PresentationDefinition, + creds: Vec, + proof: DataIntegrityProof, +) -> (VerifiablePresentation, PresentationSubmission) { + let submission = PresentationSubmission { + id: format!("urn:uuid:{}", chrono::Utc::now().timestamp()), + definition_id: def.id.clone(), + descriptor_map: def + .input_descriptors + .iter() + .enumerate() + .map(|(i, d)| DescriptorMap { + id: d.id.clone(), + format: "ldp_vc".to_string(), + path: format!("$.verifiableCredential[{}]", i), + }) + .collect(), + }; + + let vp = VerifiablePresentation { + context: vec!["https://www.w3.org/ns/credentials/v2".to_string()], + types: vec!["VerifiablePresentation".to_string()], + verifiable_credential: creds, + proof, + }; + + (vp, submission) +} + +/// Evaluates a Verifiable Presentation against a Definition. +pub fn evaluate(_def: &PresentationDefinition, vp: &VerifiablePresentation) -> EvalOutcome { + // TODO: Implement actual JSONPath / JSON-LD framing evaluation + if vp.verifiable_credential.is_empty() { + return EvalOutcome::Invalid("No credentials provided".to_string()); + } + + EvalOutcome::Valid +} diff --git a/crates/idprova-vc/src/suite.rs b/crates/idprova-vc/src/suite.rs new file mode 100644 index 0000000..4b69eb3 --- /dev/null +++ b/crates/idprova-vc/src/suite.rs @@ -0,0 +1,55 @@ +//! Shared cryptosuite primitives. +//! +//! The [`jcs_hash`] function is the single source of truth for the +//! `eddsa-jcs-2022` hashing transformation, used identically by both the +//! issuer ([`crate::issue`]) and the verifier ([`crate::verify`]) so that the +//! signed bytes always match the verified bytes. + +use crate::model::VerifiableCredential; +use sha2::{Digest, Sha256}; + +/// Errors produced while computing the `eddsa-jcs-2022` hash. +#[derive(Debug, thiserror::Error)] +pub enum SuiteError { + /// The VC or its proof could not be serialized to JSON. + #[error("failed to serialize document: {0}")] + Serialize(#[from] serde_json::Error), + /// JCS (RFC 8785) canonicalization failed. + #[error("JCS canonicalization failed: {0}")] + Canonicalize(String), +} + +/// Computes the `eddsa-jcs-2022` hash input per the W3C `vc-di-eddsa` spec: +/// +/// `SHA-256(JCS(proofConfig)) || SHA-256(JCS(unsecuredDocument))` +/// +/// where: +/// * the *unsecured document* is the VC serialized to JSON with the `proof` +/// member removed, and +/// * the *proof config* is the proof serialized to JSON with the `proof_value` +/// (`proofValue`) member removed. +/// +/// The `proofConfig` hash is concatenated **first**, matching the spec. +pub(crate) fn jcs_hash(vc: &VerifiableCredential) -> Result, SuiteError> { + // 1. Unsecured document = the VC JSON, sans `proof`. + let mut doc = serde_json::to_value(vc)?; + if let serde_json::Value::Object(ref mut m) = doc { + m.remove("proof"); + } + + // 2. Proof config = the proof JSON, sans `proof_value`. + let mut cfg = serde_json::to_value(&vc.proof)?; + if let serde_json::Value::Object(ref mut m) = cfg { + m.remove("proof_value"); + } + + let canon_cfg = serde_json_canonicalizer::to_vec(&cfg) + .map_err(|e| SuiteError::Canonicalize(e.to_string()))?; + let canon_doc = serde_json_canonicalizer::to_vec(&doc) + .map_err(|e| SuiteError::Canonicalize(e.to_string()))?; + + let mut h = Vec::with_capacity(64); + h.extend_from_slice(Sha256::digest(&canon_cfg).as_slice()); + h.extend_from_slice(Sha256::digest(&canon_doc).as_slice()); + Ok(h) +} diff --git a/crates/idprova-vc/src/verify.rs b/crates/idprova-vc/src/verify.rs new file mode 100644 index 0000000..8d08a82 --- /dev/null +++ b/crates/idprova-vc/src/verify.rs @@ -0,0 +1,283 @@ +//! VC Verification logic. + +use crate::model::{CredentialStatus, VerifiableCredential}; +use crate::suite::jcs_hash; +use chrono::{DateTime, Utc}; + +/// Resolves the verifying key for an issuer DID. +pub trait IssuerResolver { + fn verifying_key(&self, issuer_did: &str) -> Option<[u8; 32]>; +} + +/// Checks if a credential is revoked via its StatusList. +pub trait StatusResolver { + fn is_revoked(&self, status: &CredentialStatus) -> bool; +} + +/// The outcome of a VC verification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VcVerifyOutcome { + Valid, + InvalidSignature, + Expired, + NotYetValid, + Revoked, + UntrustedIssuer, +} + +/// Verifier for Verifiable Credentials. +pub struct VcVerifier; + +impl VcVerifier { + pub fn new() -> Self { + Self + } + + /// Verifies a Verifiable Credential. + /// + /// Checks: + /// 1. Validity window (valid_from / valid_until) + /// 2. Issuer trust (via IssuerResolver) + /// 3. Revocation status (via StatusResolver) + /// 4. Data Integrity Proof signature + pub fn verify( + &self, + vc: &VerifiableCredential, + issuer_resolver: &I, + status_resolver: &S, + now: DateTime, + ) -> VcVerifyOutcome { + // 1. Validity Window + if now < vc.valid_from { + return VcVerifyOutcome::NotYetValid; + } + if let Some(until) = vc.valid_until { + if now > until { + return VcVerifyOutcome::Expired; + } + } + + // 2. Issuer Resolution + let pub_key = match issuer_resolver.verifying_key(&vc.issuer) { + Some(k) => k, + None => return VcVerifyOutcome::UntrustedIssuer, + }; + + // 3. Status Check + if let Some(status) = &vc.credential_status { + if status_resolver.is_revoked(status) { + return VcVerifyOutcome::Revoked; + } + } + + // 4. Signature Verification + if self.verify_signature(vc, &pub_key) { + VcVerifyOutcome::Valid + } else { + VcVerifyOutcome::InvalidSignature + } + } + + fn verify_signature(&self, vc: &VerifiableCredential, pub_key: &[u8; 32]) -> bool { + match vc.proof.cryptosuite { + crate::model::CryptoSuite::EddsaJcs2022 => { + // Recompute the EXACT hash the issuer signed. + let hash = match jcs_hash(vc) { + Ok(h) => h, + Err(_) => return false, + }; + // proof_value is multibase (base58btc, 'z'-prefixed). + let (_base, sig) = match multibase::decode(&vc.proof.proof_value) { + Ok(t) => t, + Err(_) => return false, + }; + idprova_core::crypto::KeyPair::verify(pub_key, &hash, &sig).is_ok() + } + _ => todo!("RDF canonicalization verification"), + } + } +} + +impl Default for VcVerifier { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ap2::{to_ap2_mandate, Ap2Kind}; + use crate::dat_bridge::{dat_to_vc, vc_to_dat_claims}; + use crate::issue::VcIssuer; + use crate::model::{CredentialStatus, CryptoSuite, DataIntegrityProof}; + use chrono::{Duration, Utc}; + use idprova_core::crypto::KeyPair; + use idprova_core::dat::token::DatClaims; + + const ISSUER_DID: &str = "did:aid:issuer-test"; + const SUBJECT_DID: &str = "did:aid:subject-test"; + + /// Resolver that always returns the given key for any issuer. + struct FixedResolver([u8; 32]); + impl IssuerResolver for FixedResolver { + fn verifying_key(&self, _issuer_did: &str) -> Option<[u8; 32]> { + Some(self.0) + } + } + + /// Resolver that reports a constant revocation state. + struct FixedStatus(bool); + impl StatusResolver for FixedStatus { + fn is_revoked(&self, _status: &CredentialStatus) -> bool { + self.0 + } + } + + fn issuer(kp: KeyPair) -> VcIssuer { + VcIssuer { + issuer_did: ISSUER_DID.to_string(), + signing_key: kp, + suite: CryptoSuite::EddsaJcs2022, + } + } + + fn dummy_proof() -> DataIntegrityProof { + DataIntegrityProof { + type_: "DataIntegrityProof".to_string(), + cryptosuite: CryptoSuite::EddsaJcs2022, + created: Utc::now(), + verification_method: format!("{ISSUER_DID}#key-1"), + proof_purpose: "assertionMethod".to_string(), + proof_value: "z".to_string(), + } + } + + #[test] + fn round_trip_valid() { + let kp = KeyPair::generate(); + let pubkey = kp.public_key_bytes(); + let iss = issuer(kp); + + let vc = iss.issue( + SUBJECT_DID, + serde_json::json!({ "role": "agent", "tier": "gold" }), + Duration::days(1), + None, + ); + + let outcome = + VcVerifier::new().verify(&vc, &FixedResolver(pubkey), &FixedStatus(false), Utc::now()); + assert_eq!(outcome, VcVerifyOutcome::Valid); + } + + #[test] + fn tampered_invalid() { + let kp = KeyPair::generate(); + let pubkey = kp.public_key_bytes(); + let iss = issuer(kp); + + let mut vc = iss.issue( + SUBJECT_DID, + serde_json::json!({ "role": "agent" }), + Duration::days(1), + None, + ); + + // Flip a claim value AFTER signing. + if let serde_json::Value::Object(ref mut m) = vc.credential_subject { + m.insert("role".to_string(), serde_json::json!("admin")); + } + + let outcome = + VcVerifier::new().verify(&vc, &FixedResolver(pubkey), &FixedStatus(false), Utc::now()); + assert_eq!(outcome, VcVerifyOutcome::InvalidSignature); + } + + #[test] + fn revoked() { + let kp = KeyPair::generate(); + let pubkey = kp.public_key_bytes(); + let iss = issuer(kp); + + let status = CredentialStatus { + type_: "StatusList2021Entry".to_string(), + id: "https://status.idprova.dev/1#5".to_string(), + status_purpose: "revocation".to_string(), + status_list_index: "5".to_string(), + }; + + let vc = iss.issue( + SUBJECT_DID, + serde_json::json!({ "role": "agent" }), + Duration::days(1), + Some(status), + ); + + let outcome = + VcVerifier::new().verify(&vc, &FixedResolver(pubkey), &FixedStatus(true), Utc::now()); + assert_eq!(outcome, VcVerifyOutcome::Revoked); + } + + #[test] + fn expired() { + let kp = KeyPair::generate(); + let pubkey = kp.public_key_bytes(); + let iss = issuer(kp); + + let vc = iss.issue( + SUBJECT_DID, + serde_json::json!({ "role": "agent" }), + Duration::days(1), + None, + ); + + let after_expiry = vc.valid_until.unwrap() + Duration::seconds(1); + let outcome = VcVerifier::new().verify( + &vc, + &FixedResolver(pubkey), + &FixedStatus(false), + after_expiry, + ); + assert_eq!(outcome, VcVerifyOutcome::Expired); + } + + #[test] + fn dat_to_vc_preserves_scope() { + let scope = vec!["mcp:tool:fs:read".to_string()]; + let now = Utc::now().timestamp(); + let claims = DatClaims { + iss: ISSUER_DID.to_string(), + sub: SUBJECT_DID.to_string(), + iat: now, + exp: now + 3600, + nbf: now, + jti: "dat-123".to_string(), + scope: scope.clone(), + constraints: None, + config_attestation: None, + delegation_chain: None, + }; + + let vc = dat_to_vc(&claims, dummy_proof()); + assert!(vc.types.contains(&"IDProvaDelegatedCapability".to_string())); + + let back = vc_to_dat_claims(&vc).expect("should round-trip to DatClaims"); + assert_eq!(back.scope, scope); + } + + #[test] + fn ap2_intent_shape() { + let vc = to_ap2_mandate( + Ap2Kind::Intent, + ISSUER_DID, + SUBJECT_DID, + serde_json::json!({ "merchant": "acme", "max_amount": "100.00" }), + dummy_proof(), + ); + + // An AP2 Intent mandate is a VerifiableCredential typed IntentMandate. + assert!(vc.types.contains(&"VerifiableCredential".to_string())); + assert!(vc.types.contains(&"IntentMandate".to_string())); + } +}