From 773b5196eb90d0da25f9e6d395674f4d9208c91f Mon Sep 17 00:00:00 2001 From: pratyushsood24 Date: Thu, 25 Jun 2026 23:50:17 +1000 Subject: [PATCH 1/3] =?UTF-8?q?feat(vc):=20Pillar=20C=20=E2=80=94=20W3C-VC?= =?UTF-8?q?=20(Data=20Model=202.0)=20interop=20layer=20on=20DAT=20design?= =?UTF-8?q?=20+=20skeleton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds idprova-vc: deterministic VC issue/verify (default eddsa-jcs-2022 over RFC 8785 JCS; rdfc/ecdsa-P256 behind features for AP2), DIF Presentation Exchange, verify-time StatusList revocation, a documented DAT<->VC mapping, and AP2 Intent/Cart/Payment mandate builders. Bridges into Google AP2/A2A without abandoning the DAT runtime. Design-only skeleton + DESIGN-pillar-c.md + SPEC.md. Co-Authored-By: TechBlaze --- Cargo.toml | 1 + DESIGN-pillar-c.md | 86 ++++++++++++++++ crates/idprova-vc/Cargo.toml | 36 +++++++ crates/idprova-vc/SPEC.md | 86 ++++++++++++++++ crates/idprova-vc/src/ap2.rs | 49 +++++++++ crates/idprova-vc/src/dat_bridge.rs | 56 +++++++++++ crates/idprova-vc/src/issue.rs | 122 ++++++++++++++++++++++ crates/idprova-vc/src/lib.rs | 11 ++ crates/idprova-vc/src/model.rs | 67 +++++++++++++ crates/idprova-vc/src/presentation.rs | 76 ++++++++++++++ crates/idprova-vc/src/verify.rs | 139 ++++++++++++++++++++++++++ 11 files changed, 729 insertions(+) create mode 100644 DESIGN-pillar-c.md create mode 100644 crates/idprova-vc/Cargo.toml create mode 100644 crates/idprova-vc/SPEC.md create mode 100644 crates/idprova-vc/src/ap2.rs create mode 100644 crates/idprova-vc/src/dat_bridge.rs create mode 100644 crates/idprova-vc/src/issue.rs create mode 100644 crates/idprova-vc/src/lib.rs create mode 100644 crates/idprova-vc/src/model.rs create mode 100644 crates/idprova-vc/src/presentation.rs create mode 100644 crates/idprova-vc/src/verify.rs 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..3d79e1a --- /dev/null +++ b/crates/idprova-vc/Cargo.toml @@ -0,0 +1,36 @@ +[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 + +# 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..5ad5bab --- /dev/null +++ b/crates/idprova-vc/src/ap2.rs @@ -0,0 +1,49 @@ +//! Google AP2 / Agent2Agent (A2A) typed mandate builders. + +use crate::model::{CryptoSuite, 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..718028e --- /dev/null +++ b/crates/idprova-vc/src/dat_bridge.rs @@ -0,0 +1,56 @@ +//! The deterministic DAT to VC bridge. + +use crate::model::{CryptoSuite, 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..b9df363 --- /dev/null +++ b/crates/idprova-vc/src/issue.rs @@ -0,0 +1,122 @@ +//! VC Issuance logic. + +use crate::model::{CryptoSuite, DataIntegrityProof, VerifiableCredential}; +use base64::Engine as _; +use chrono::{DateTime, 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 VC data per the selected suite and signs it deterministically. + pub fn issue( + &self, + subject_did: &str, + claims: serde_json::Value, + valid_for: Duration, + status: Option, + ) -> VerifiableCredential { + let now = Utc::now(); + + let mut vc_map = serde_json::Map::new(); + vc_map.insert( + "@context".to_string(), + serde_json::json!([ + "https://www.w3.org/ns/credentials/v2", + "https://www.idprova.dev/contexts/v1.json" + ]), + ); + vc_map.insert( + "type".to_string(), + serde_json::json!(["VerifiableCredential"]), + ); + vc_map.insert( + "issuer".to_string(), + serde_json::Value::String(self.issuer_did.clone()), + ); + vc_map.insert( + "validFrom".to_string(), + serde_json::Value::String(now.to_rfc3339()), + ); + vc_map.insert( + "validUntil".to_string(), + serde_json::Value::String((now + valid_for).to_rfc3339()), + ); + + 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); + } + } + vc_map.insert( + "credentialSubject".to_string(), + serde_json::Value::Object(subject_map), + ); + + // Sign the canonicalized VC data + let proof_value = self.sign_payload(&serde_json::Value::Object(vc_map.clone())); + + 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, + }; + + VerifiableCredential { + context: vec!["https://www.w3.org/ns/credentials/v2".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: vc_map.get("credentialSubject").cloned().unwrap_or_default(), + credential_status: status, + proof, + } + } + + fn sign_payload(&self, payload: &serde_json::Value) -> String { + match self.suite { + CryptoSuite::EddsaJcs2022 => { + let canonical = + serde_json_canonicalizer::to_vec(payload).expect("JCS canonicalization failed"); + let sig = self.signing_key.sign(&canonical); + format!( + "z{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig) + ) + } + CryptoSuite::EddsaRdfc2022 | CryptoSuite::EcdsaRdfc2019 => { + todo!("RDF canonicalization signing (requires rdfc-interop)") + } + } + } +} + +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..c0b8cb1 --- /dev/null +++ b/crates/idprova-vc/src/lib.rs @@ -0,0 +1,11 @@ +//! # 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 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..d131e92 --- /dev/null +++ b/crates/idprova-vc/src/presentation.rs @@ -0,0 +1,76 @@ +//! 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/verify.rs b/crates/idprova-vc/src/verify.rs new file mode 100644 index 0000000..7733b6f --- /dev/null +++ b/crates/idprova-vc/src/verify.rs @@ -0,0 +1,139 @@ +//! VC Verification logic. + +use crate::model::{CredentialStatus, VerifiableCredential}; +use base64::Engine as _; +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 { + let payload = self.construct_verify_payload(vc); + + match vc.proof.cryptosuite { + crate::model::CryptoSuite::EddsaJcs2022 => { + let canonical = match serde_json_canonicalizer::to_vec(&payload) { + Ok(c) => c, + Err(_) => return false, + }; + let sig = match vc.proof.proof_value.strip_prefix('z') { + Some(b64) => match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(b64) + { + Ok(bytes) => bytes, + Err(_) => return false, + }, + None => return false, + }; + idprova_core::crypto::KeyPair::verify(pub_key, &canonical, &sig).is_ok() + } + _ => todo!("RDF canonicalization verification"), + } + } + + fn construct_verify_payload(&self, vc: &VerifiableCredential) -> serde_json::Value { + // Reconstruct the unsigned VC payload for canonicalization + let mut map = serde_json::Map::new(); + map.insert( + "@context".to_string(), + serde_json::Value::Array( + vc.context + .iter() + .map(|s| serde_json::Value::String(s.clone())) + .collect(), + ), + ); + map.insert( + "type".to_string(), + serde_json::Value::Array( + vc.types + .iter() + .map(|s| serde_json::Value::String(s.clone())) + .collect(), + ), + ); + map.insert( + "issuer".to_string(), + serde_json::Value::String(vc.issuer.clone()), + ); + map.insert( + "validFrom".to_string(), + serde_json::Value::String(vc.valid_from.to_rfc3339()), + ); + map.insert( + "credentialSubject".to_string(), + vc.credential_subject.clone(), + ); + serde_json::Value::Object(map) + } +} From 8a5b350ef1af3fd8ecc3977133afbec5d05bc72e Mon Sep 17 00:00:00 2001 From: pratyushsood24 Date: Fri, 26 Jun 2026 08:11:34 +1000 Subject: [PATCH 2/3] feat(vc): implement eddsa-jcs-2022 issue/verify round-trip + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote Pillar C from skeleton to a working default cryptosuite: - add suite::jcs_hash: SHA-256(JCS(proofConfig)) || SHA-256(JCS(doc)) per W3C vc-di-eddsa, serializing the typed VC so issuer and verifier hash byte-identical input (fixes a payload mismatch that made issue->verify never validate) - issue: build the VC, sign jcs_hash, encode proofValue as base58btc multibase - verify: recompute jcs_hash, multibase-decode, Ed25519 verify; drop the divergent hand-rebuilt payload - add sha2 + multibase deps Tested: 6 passing tests — issue->verify Valid, tampered InvalidSignature, revoked Revoked, expired Expired, dat_to_vc preserves scope, AP2 Intent mandate shape. RDF/ECDSA suites remain behind features (todo). External JSON-LD/camelCase proof interop is a follow-up hardening pass. Co-Authored-By: TechBlaze --- crates/idprova-vc/Cargo.toml | 2 + crates/idprova-vc/src/ap2.rs | 15 +- crates/idprova-vc/src/dat_bridge.rs | 20 ++- crates/idprova-vc/src/issue.rs | 73 +++------ crates/idprova-vc/src/lib.rs | 1 + crates/idprova-vc/src/presentation.rs | 17 +- crates/idprova-vc/src/suite.rs | 55 +++++++ crates/idprova-vc/src/verify.rs | 228 +++++++++++++++++++++----- 8 files changed, 305 insertions(+), 106 deletions(-) create mode 100644 crates/idprova-vc/src/suite.rs diff --git a/crates/idprova-vc/Cargo.toml b/crates/idprova-vc/Cargo.toml index 3d79e1a..c530f14 100644 --- a/crates/idprova-vc/Cargo.toml +++ b/crates/idprova-vc/Cargo.toml @@ -21,6 +21,8 @@ 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 } diff --git a/crates/idprova-vc/src/ap2.rs b/crates/idprova-vc/src/ap2.rs index 5ad5bab..7efc307 100644 --- a/crates/idprova-vc/src/ap2.rs +++ b/crates/idprova-vc/src/ap2.rs @@ -10,7 +10,7 @@ pub enum Ap2Kind { } /// 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, @@ -26,16 +26,21 @@ pub fn to_ap2_mandate( }; let mut subject_map = serde_json::Map::new(); - subject_map.insert("id".to_string(), serde_json::Value::String(subject_did.to_string())); + 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); } + for (k, v) in obj { + subject_map.insert(k, v); + } } VerifiableCredential { context: vec![ - "https://www.w3.org/ns/credentials/v2".to_string(), + "https://www.w3.org/ns/credentials/v2".to_string(), "https://schema.org/".to_string(), - "https://google.com/Ap2Mandates/v1".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()), diff --git a/crates/idprova-vc/src/dat_bridge.rs b/crates/idprova-vc/src/dat_bridge.rs index 718028e..44d674c 100644 --- a/crates/idprova-vc/src/dat_bridge.rs +++ b/crates/idprova-vc/src/dat_bridge.rs @@ -8,7 +8,10 @@ use idprova_core::dat::token::DatClaims; /// /// 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 { +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, @@ -17,8 +20,14 @@ pub fn dat_to_vc(dat_claims: &DatClaims, proof: crate::model::DataIntegrityProof }); 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()], + 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(), @@ -39,7 +48,10 @@ pub fn vc_to_dat_claims(vc: &VerifiableCredential) -> Option { 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(); + let scope_strings: Vec = scopes + .iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect(); Some(DatClaims { iss: vc.issuer.clone(), diff --git a/crates/idprova-vc/src/issue.rs b/crates/idprova-vc/src/issue.rs index b9df363..b3aafd4 100644 --- a/crates/idprova-vc/src/issue.rs +++ b/crates/idprova-vc/src/issue.rs @@ -1,8 +1,8 @@ //! VC Issuance logic. use crate::model::{CryptoSuite, DataIntegrityProof, VerifiableCredential}; -use base64::Engine as _; -use chrono::{DateTime, Duration, Utc}; +use crate::suite::jcs_hash; +use chrono::{Duration, Utc}; use idprova_core::crypto::KeyPair; /// Issues Verifiable Credentials deterministically. @@ -23,7 +23,9 @@ impl VcIssuer { /// Issues a Verifiable Credential. /// - /// Canonicalizes the VC data per the selected suite and signs it deterministically. + /// 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, @@ -33,31 +35,7 @@ impl VcIssuer { ) -> VerifiableCredential { let now = Utc::now(); - let mut vc_map = serde_json::Map::new(); - vc_map.insert( - "@context".to_string(), - serde_json::json!([ - "https://www.w3.org/ns/credentials/v2", - "https://www.idprova.dev/contexts/v1.json" - ]), - ); - vc_map.insert( - "type".to_string(), - serde_json::json!(["VerifiableCredential"]), - ); - vc_map.insert( - "issuer".to_string(), - serde_json::Value::String(self.issuer_did.clone()), - ); - vc_map.insert( - "validFrom".to_string(), - serde_json::Value::String(now.to_rfc3339()), - ); - vc_map.insert( - "validUntil".to_string(), - serde_json::Value::String((now + valid_for).to_rfc3339()), - ); - + // Build the credential subject: { "id": subject_did, ...claims }. let mut subject_map = serde_json::Map::new(); subject_map.insert( "id".to_string(), @@ -68,51 +46,48 @@ impl VcIssuer { subject_map.insert(k, v); } } - vc_map.insert( - "credentialSubject".to_string(), - serde_json::Value::Object(subject_map), - ); - - // Sign the canonicalized VC data - let proof_value = self.sign_payload(&serde_json::Value::Object(vc_map.clone())); + // 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, + proof_value: String::new(), }; - VerifiableCredential { - context: vec!["https://www.w3.org/ns/credentials/v2".to_string()], + 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: vc_map.get("credentialSubject").cloned().unwrap_or_default(), + credential_subject: serde_json::Value::Object(subject_map), credential_status: status, proof, - } - } + }; - fn sign_payload(&self, payload: &serde_json::Value) -> String { match self.suite { CryptoSuite::EddsaJcs2022 => { - let canonical = - serde_json_canonicalizer::to_vec(payload).expect("JCS canonicalization failed"); - let sig = self.signing_key.sign(&canonical); - format!( - "z{}", - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig) - ) + 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 } } diff --git a/crates/idprova-vc/src/lib.rs b/crates/idprova-vc/src/lib.rs index c0b8cb1..2db879b 100644 --- a/crates/idprova-vc/src/lib.rs +++ b/crates/idprova-vc/src/lib.rs @@ -8,4 +8,5 @@ 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/presentation.rs b/crates/idprova-vc/src/presentation.rs index d131e92..576b0a4 100644 --- a/crates/idprova-vc/src/presentation.rs +++ b/crates/idprova-vc/src/presentation.rs @@ -48,11 +48,16 @@ pub fn build_submission( 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(), + 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 { @@ -71,6 +76,6 @@ pub fn evaluate(def: &PresentationDefinition, vp: &VerifiablePresentation) -> Ev 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 index 7733b6f..8d08a82 100644 --- a/crates/idprova-vc/src/verify.rs +++ b/crates/idprova-vc/src/verify.rs @@ -1,7 +1,7 @@ //! VC Verification logic. use crate::model::{CredentialStatus, VerifiableCredential}; -use base64::Engine as _; +use crate::suite::jcs_hash; use chrono::{DateTime, Utc}; /// Resolves the verifying key for an issuer DID. @@ -79,61 +79,205 @@ impl VcVerifier { } fn verify_signature(&self, vc: &VerifiableCredential, pub_key: &[u8; 32]) -> bool { - let payload = self.construct_verify_payload(vc); - match vc.proof.cryptosuite { crate::model::CryptoSuite::EddsaJcs2022 => { - let canonical = match serde_json_canonicalizer::to_vec(&payload) { - Ok(c) => c, + // Recompute the EXACT hash the issuer signed. + let hash = match jcs_hash(vc) { + Ok(h) => h, Err(_) => return false, }; - let sig = match vc.proof.proof_value.strip_prefix('z') { - Some(b64) => match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(b64) - { - Ok(bytes) => bytes, - Err(_) => return false, - }, - None => 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, &canonical, &sig).is_ok() + 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); - fn construct_verify_payload(&self, vc: &VerifiableCredential) -> serde_json::Value { - // Reconstruct the unsigned VC payload for canonicalization - let mut map = serde_json::Map::new(); - map.insert( - "@context".to_string(), - serde_json::Value::Array( - vc.context - .iter() - .map(|s| serde_json::Value::String(s.clone())) - .collect(), - ), + let vc = iss.issue( + SUBJECT_DID, + serde_json::json!({ "role": "agent", "tier": "gold" }), + Duration::days(1), + None, ); - map.insert( - "type".to_string(), - serde_json::Value::Array( - vc.types - .iter() - .map(|s| serde_json::Value::String(s.clone())) - .collect(), - ), + + 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, ); - map.insert( - "issuer".to_string(), - serde_json::Value::String(vc.issuer.clone()), + + // 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), ); - map.insert( - "validFrom".to_string(), - serde_json::Value::String(vc.valid_from.to_rfc3339()), + + 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, ); - map.insert( - "credentialSubject".to_string(), - vc.credential_subject.clone(), + + let after_expiry = vc.valid_until.unwrap() + Duration::seconds(1); + let outcome = VcVerifier::new().verify( + &vc, + &FixedResolver(pubkey), + &FixedStatus(false), + after_expiry, ); - serde_json::Value::Object(map) + 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())); } } From 99a9165c01aa6fd1fc728942336c24c830f34d7d Mon Sep 17 00:00:00 2001 From: pratyushsood24 Date: Sat, 27 Jun 2026 01:16:56 +1000 Subject: [PATCH 3/3] chore(vc): clippy-clean unused imports/var + sync Cargo.lock Removes unused CryptoSuite imports (ap2, dat_bridge), prefixes unused evaluate() stub param, and regenerates Cargo.lock for the idprova-vc crate deps (sha2, multibase) after rebase onto main. Co-Authored-By: TechBlaze --- Cargo.lock | 137 ++++++++++++++++++++++++++ crates/idprova-vc/src/ap2.rs | 2 +- crates/idprova-vc/src/dat_bridge.rs | 2 +- crates/idprova-vc/src/presentation.rs | 2 +- 4 files changed, 140 insertions(+), 3 deletions(-) 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/crates/idprova-vc/src/ap2.rs b/crates/idprova-vc/src/ap2.rs index 7efc307..53502c0 100644 --- a/crates/idprova-vc/src/ap2.rs +++ b/crates/idprova-vc/src/ap2.rs @@ -1,6 +1,6 @@ //! Google AP2 / Agent2Agent (A2A) typed mandate builders. -use crate::model::{CryptoSuite, DataIntegrityProof, VerifiableCredential}; +use crate::model::{DataIntegrityProof, VerifiableCredential}; /// Enumerates the AP2 mandate profiles. pub enum Ap2Kind { diff --git a/crates/idprova-vc/src/dat_bridge.rs b/crates/idprova-vc/src/dat_bridge.rs index 44d674c..03cb34d 100644 --- a/crates/idprova-vc/src/dat_bridge.rs +++ b/crates/idprova-vc/src/dat_bridge.rs @@ -1,6 +1,6 @@ //! The deterministic DAT to VC bridge. -use crate::model::{CryptoSuite, VerifiableCredential}; +use crate::model::VerifiableCredential; use chrono::TimeZone; use idprova_core::dat::token::DatClaims; diff --git a/crates/idprova-vc/src/presentation.rs b/crates/idprova-vc/src/presentation.rs index 576b0a4..5f91394 100644 --- a/crates/idprova-vc/src/presentation.rs +++ b/crates/idprova-vc/src/presentation.rs @@ -71,7 +71,7 @@ pub fn build_submission( } /// Evaluates a Verifiable Presentation against a Definition. -pub fn evaluate(def: &PresentationDefinition, vp: &VerifiablePresentation) -> EvalOutcome { +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());