Skip to content

Commit bfe8514

Browse files
jeswrclaude
andauthored
fix(vc): match W3C EdDSA RDF proof configuration (#6585)
* fix(vc): match the published EdDSA RDF proof configuration Use Dublin Core created and the Data Integrity cryptosuiteString datatype. Check canonical hashes and an independently published W3C signature; reject content and proof-option substitutions. Earlier signatures using the nonconforming mapping must be re-signed; no legacy fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(vc): assert verified configuration and stable tampering targets Check all returned proof configuration fields and reversed triple order. Locate exactly one tampering target by subject and predicate so fixture order changes cannot alter the intended negative cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(vc): clarify proof configuration vocabulary (zkp-14.2) * test(vc): validate W3C fixture IRIs (zkp-14.2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 83cb3b5 commit bfe8514

5 files changed

Lines changed: 344 additions & 6 deletions

File tree

‎crates/sparq-vc/README.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,15 @@ what RDFC-1.0 canonicalizes). Transforming a JSON-LD credential to RDF (context
7373
expansion) is the caller's job — doing it here would force a JSON-LD context
7474
processor onto the lean build. `bbs-2023` is out of scope.
7575

76+
**W3C vector + incompatibility** [OPUS-5.5]: the proof config maps `created` to
77+
`dcterms:created` and types `cryptosuite` as `sec:cryptosuiteString`, matching the
78+
published [vc-di-eddsa test vectors](https://www.w3.org/TR/vc-di-eddsa/#test-vectors)
79+
(`tests/w3c_eddsa_rdfc.rs` verifies the published `proofValue`). Proofs signed by
80+
earlier releases (`sec:created`, plain `cryptosuite` literal) **no longer verify** —
81+
there is no legacy fallback; re-sign them. Only `ProofConfig`'s typed fields are
82+
represented; other proof options/`@context` are not preserved, field values are not
83+
fully validated, and issuer authorization / credential status are not checked.
84+
7685
## Opt-in by construction
7786

7887
Nothing in sparq's default build or the wasm artifact depends on this crate —

‎crates/sparq-vc/src/lib.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@
5555
//! engine's JSON-LD parser); doing it inside this crate would force a JSON-LD
5656
//! context processor (and remote-context fetching) onto the lean build. See
5757
//! [`ProofConfig`] for how the proof options are supplied.
58+
//! - **Proof-config RDF matches the W3C vectors.** [OPUS-5.5] `created` maps to
59+
//! `dcterms:created` and `cryptosuite` is typed `sec:cryptosuiteString`, as in
60+
//! the published [vc-di-eddsa test vectors]; the published `proofValue` is
61+
//! regression-tested. Proofs from earlier releases (`sec:created`, plain
62+
//! `cryptosuite` literal) no longer verify — no legacy fallback; re-sign them.
63+
//! Only [`ProofConfig`]'s typed fields are represented, their values are not
64+
//! fully validated, and issuer authorization / credential status are not checked.
65+
//!
66+
//! [vc-di-eddsa test vectors]: https://www.w3.org/TR/vc-di-eddsa/#test-vectors
5867
//! - **DID methods:** `did:key` (offline, self-certifying) by default; `did:web`
5968
//! (document-fetched, host-rooted) behind the opt-in `did-web` feature, over a
6069
//! pluggable `did::DidDocumentFetcher` (a `did-web`-feature item) so this crate

‎crates/sparq-vc/src/suite.rs‎

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,27 @@
2424
//! would force a JSON-LD context processor onto the lean build. The proof
2525
//! configuration is supplied as a typed [`ProofConfig`] (not extracted from a
2626
//! JSON-LD `proof` node), and its canonical RDF form uses the standard
27-
//! `https://w3id.org/security#` vocabulary.
27+
//! `https://w3id.org/security#` vocabulary plus Dublin Core `dcterms:created`
28+
//! (see the mapping below).
29+
//!
30+
//! # Proof-configuration RDF mapping
31+
//!
32+
//! [OPUS-5.5] zkp-14.2: the typed [`ProofConfig`] maps to the same RDF the W3C
33+
//! vc-di-eddsa test vectors canonicalize (<https://www.w3.org/TR/vc-di-eddsa/#test-vectors>,
34+
//! `eddsa-rdfc-2022` representation): `created` is
35+
//! `<http://purl.org/dc/terms/created>` typed `xsd:dateTime`, and `cryptosuite`
36+
//! is a literal typed `<https://w3id.org/security#cryptosuiteString>`. The
37+
//! published proof-configuration hash and `proofValue` are regression-tested.
38+
//!
39+
//! **Incompatibility:** earlier releases used `sec:created` and a plain
40+
//! `cryptosuite` literal. Proofs produced by that mapping do **not** verify
41+
//! under this one, and there is deliberately no legacy fallback — re-sign them.
42+
//!
43+
//! Only the typed subset `ProofConfig` carries is represented (type,
44+
//! cryptosuite, verificationMethod, proofPurpose, created, domain, challenge);
45+
//! any other proof option or `@context` a JSON-LD proof may carry is not
46+
//! preserved. Field values (the `created` lexical form, IRIs, purpose) are not
47+
//! fully validated here.
2848
2949
use oxrdf::{Literal, NamedNode, NamedOrBlankNode, Term, Triple};
3050
use sha2::{Digest as _, Sha256};
@@ -44,6 +64,12 @@ pub const CRYPTOSUITE: &str = "eddsa-rdfc-2022";
4464
const SEC: &str = "https://w3id.org/security#";
4565
const XSD_DATETIME: &str = "http://www.w3.org/2001/XMLSchema#dateTime";
4666
const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
67+
// [OPUS-5.5] The Data Integrity `@context` maps `created` to Dublin Core, not
68+
// `sec:created`. Changing this breaks every existing proof and the W3C vectors.
69+
const DCTERMS_CREATED: &str = "http://purl.org/dc/terms/created";
70+
// [OPUS-5.5] Datatype of the `cryptosuite` literal in the Data Integrity
71+
// `@context`. Changing it breaks every existing proof and the W3C vectors.
72+
const CRYPTOSUITE_STRING: &str = "https://w3id.org/security#cryptosuiteString";
4773

4874
/// A signing key — a vetted Ed25519 (RFC 8032) keypair.
4975
///
@@ -93,7 +119,7 @@ pub struct ProofConfig {
93119
pub verification_method: String,
94120
/// The proof purpose (`sec:proofPurpose`). Defaults to `assertionMethod`.
95121
pub proof_purpose: String,
96-
/// The proof creation time, an `xsd:dateTime` string (`sec:created`). Optional.
122+
/// The proof creation time, an `xsd:dateTime` string (`dcterms:created`). Optional.
97123
pub created: Option<String>,
98124
/// An optional domain the proof is bound to (`sec:domain`).
99125
pub domain: Option<String>,
@@ -323,7 +349,9 @@ fn hash_data(triples: &[Triple], config: &ProofConfig) -> Result<Vec<u8>, VcErro
323349
/// Build the RDF triples representing the proof configuration, over a single fixed
324350
/// blank-node subject (`_:proof`). RDFC-1.0 relabels the blank node deterministically,
325351
/// so the canonical form depends only on the field VALUES — exactly the binding we
326-
/// want. Uses the standard `https://w3id.org/security#` vocabulary.
352+
/// want. Uses the standard `https://w3id.org/security#` vocabulary, with
353+
/// `dcterms:created` and a `sec:cryptosuiteString`-typed cryptosuite (see the
354+
/// module-level mapping notes).
327355
fn proof_config_triples(config: &ProofConfig) -> Vec<Triple> {
328356
let subject = || NamedOrBlankNode::BlankNode(oxrdf::BlankNode::new_unchecked("proof"));
329357
let pred = |local: &str| NamedNode::new_unchecked(format!("{}{}", SEC, local));
@@ -337,11 +365,14 @@ fn proof_config_triples(config: &ProofConfig) -> Vec<Triple> {
337365
NamedNode::new_unchecked(RDF_TYPE),
338366
Term::NamedNode(pred(PROOF_TYPE)),
339367
),
340-
// sec:cryptosuite "eddsa-rdfc-2022"
368+
// sec:cryptosuite "eddsa-rdfc-2022"^^sec:cryptosuiteString
341369
Triple::new(
342370
subject(),
343371
pred("cryptosuite"),
344-
Term::Literal(Literal::new_simple_literal(CRYPTOSUITE)),
372+
Term::Literal(Literal::new_typed_literal(
373+
CRYPTOSUITE,
374+
NamedNode::new_unchecked(CRYPTOSUITE_STRING),
375+
)),
345376
),
346377
// sec:verificationMethod <iri>
347378
Triple::new(
@@ -357,9 +388,10 @@ fn proof_config_triples(config: &ProofConfig) -> Vec<Triple> {
357388
),
358389
];
359390
if let Some(created) = &config.created {
391+
// dcterms:created "…"^^xsd:dateTime
360392
t.push(Triple::new(
361393
subject(),
362-
pred("created"),
394+
NamedNode::new_unchecked(DCTERMS_CREATED),
363395
Term::Literal(Literal::new_typed_literal(
364396
created.clone(),
365397
NamedNode::new_unchecked(XSD_DATETIME),
@@ -518,4 +550,34 @@ mod tests {
518550
Err(VcError::UnsupportedProof(_))
519551
));
520552
}
553+
554+
/// [OPUS-5.5] zkp-14.2: the proof-config RDF mapping reproduces the W3C
555+
/// vc-di-eddsa `eddsa-rdfc-2022` test vector byte-for-byte — canonical
556+
/// proof-config N-Quads (Example 12) and their published SHA-256 (Example 13).
557+
/// Source: <https://www.w3.org/TR/vc-di-eddsa/#test-vectors> (Recommendation,
558+
/// 15 May 2025, Appendix B.1). Guards against reverting to `sec:created` or a
559+
/// plain `cryptosuite` literal.
560+
#[test]
561+
fn proof_config_matches_w3c_eddsa_rdfc_2022_vector() {
562+
const VM: &str = "did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2\
563+
#z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2";
564+
const EXPECTED_NQUADS: &str = concat!(
565+
"_:c14n0 <http://purl.org/dc/terms/created> \"2023-02-24T23:36:38Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime> .\n",
566+
"_:c14n0 <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <https://w3id.org/security#DataIntegrityProof> .\n",
567+
"_:c14n0 <https://w3id.org/security#cryptosuite> \"eddsa-rdfc-2022\"^^<https://w3id.org/security#cryptosuiteString> .\n",
568+
"_:c14n0 <https://w3id.org/security#proofPurpose> <https://w3id.org/security#assertionMethod> .\n",
569+
"_:c14n0 <https://w3id.org/security#verificationMethod> <did:key:z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2#z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2> .\n",
570+
);
571+
const EXPECTED_SHA256: &str =
572+
"bea7b7acfbad0126b135104024a5f1733e705108f42d59668b05c0c50004c6b0";
573+
574+
let cfg = ProofConfig::new(VM).with_created("2023-02-24T23:36:38Z");
575+
let canon = sparq_canon::canonicalize_triples(&proof_config_triples(&cfg)).unwrap();
576+
let nquads = canon.to_nquads();
577+
assert_eq!(nquads, EXPECTED_NQUADS);
578+
assert_eq!(
579+
hex::encode(<Sha256 as sha2::Digest>::digest(nquads.as_bytes())),
580+
EXPECTED_SHA256
581+
);
582+
}
521583
}

0 commit comments

Comments
 (0)