From f5c3139eec8ed151649348389f18796f4b334011 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 12 Sep 2026 21:39:06 +0200 Subject: [PATCH 1/2] test: gate the PoC's remaining reach on the grant path and the bound Work in progress, committed as it stands so the review below has a baseline to move from. Four cases, all against the exported API: - `json_bounds`: answering a deep grant through `new_member_vmc_for` / `new_delegate_vdc_for`, and `verify_grant_with_public_key` on a deep grant, both on the 256 KiB stack the rest of the file uses. - `membership_edge`: a grant its own subject wrote satisfies the member check, and the same grant does not verify under the community's key. Signed-off-by: Glenn Gore --- tests/json_bounds.rs | 67 ++++++++++++++++++++++++++++++++++++++++ tests/membership_edge.rs | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/tests/json_bounds.rs b/tests/json_bounds.rs index aa473f2..ae1ce48 100644 --- a/tests/json_bounds.rs +++ b/tests/json_bounds.rs @@ -223,6 +223,52 @@ fn deriving_from_a_deep_parent_is_refused_without_exhausting_the_stack() { drop((vac, vdc)); } +/// Answering a grant digests the document the counterparty sent, in the wire form it +/// arrived in. The `_for` constructors reach that digest only after the member and the +/// grant's expiry have been read, so the bound has to hold at the end of that sequence as +/// well as at the start of a derivation. +#[test] +fn answering_a_deep_grant_is_refused_without_exhausting_the_stack() { + let until = t0() + Duration::days(30); + + let mut membership = serde_json::to_value(DTGCredential::new_vmc( + ISSUER.into(), + SUBJECT.into(), + t0(), + Some(until), + false, + )) + .unwrap(); + membership["evidence"] = deep(OVER_DEEP); + + let mut delegation = serde_json::to_value( + DTGCredential::new_vdc( + ISSUER.into(), + SUBJECT.into(), + t0(), + until, + vec!["sign:invoices".into()], + None, + ) + .unwrap(), + ) + .unwrap(); + delegation["evidence"] = deep(OVER_DEEP); + + // The subject of each grant answers its own grant, so the member check passes and the + // digest is reached. + let (acknowledged, accepted, membership, delegation) = on_a_small_stack(move || { + let acknowledged = + DTGCredential::new_member_vmc_for(&membership, SUBJECT, t0(), Some(until)); + let accepted = DTGCredential::new_delegate_vdc_for(&delegation, SUBJECT, t0(), until); + (acknowledged, accepted, membership, delegation) + }); + + assert!(is_too_deep(&acknowledged), "got {acknowledged:?}"); + assert!(is_too_deep(&accepted), "got {accepted:?}"); + drop((membership, delegation)); +} + /// serde_json refuses to parse JSON nested 128 levels deep by default, which is what bounds /// a credential arriving over the wire before it reaches this library. Pinned, because /// [`MAX_JSON_DEPTH`] is chosen relative to it. @@ -276,6 +322,7 @@ fn open_members_are_carried_verbatim_within_the_bound() { mod signing { use super::*; use affinidi_secrets_resolver::secrets::Secret; + use dtg_credentials::verify_grant_with_public_key; #[tokio::test] async fn sign_refuses_a_deep_endorsement() { @@ -286,6 +333,26 @@ mod signing { assert!(!vec.signed(), "a refused credential must not carry a proof"); } + /// Verifying a grant is the one entry point handed a whole document by a counterparty + /// before anything about it is established, so it is the one most worth running on a + /// stack too small to walk a hostile value. + #[tokio::test] + async fn verifying_a_deep_grant_is_refused_without_exhausting_the_stack() { + let secret = Secret::generate_ed25519(Some(&format!("{ISSUER}#key-1")), None); + let mut grant = DTGCredential::new_vmc(ISSUER.into(), SUBJECT.into(), t0(), None, false); + grant.sign(&secret, None).await.expect("signs"); + + let mut grant = serde_json::to_value(&grant).unwrap(); + grant["credentialStatus"] = deep(OVER_DEEP); + let key = secret.get_public_bytes().to_vec(); + + let (verified, grant) = + on_a_small_stack(move || (verify_grant_with_public_key(&grant, &key, t0()), grant)); + + assert!(is_too_deep(&verified), "got {verified:?}"); + drop(grant); + } + /// A signed credential given a deep member afterwards is refused before its proof is /// examined, rather than walked by the verifier. #[tokio::test] diff --git a/tests/membership_edge.rs b/tests/membership_edge.rs index d05b172..f5d1fe8 100644 --- a/tests/membership_edge.rs +++ b/tests/membership_edge.rs @@ -18,6 +18,7 @@ use serde_json::{Value, json}; const COMMUNITY: &str = "did:example:community"; const MEMBER: &str = "did:example:member"; const SOMEONE_ELSE: &str = "did:example:someone-else"; +const ATTACKER: &str = "did:example:attacker"; fn t(h: i64) -> DateTime { Utc.with_ymd_and_hms(2026, 1, 6, 10, 0, 0).unwrap() + Duration::hours(h) @@ -35,6 +36,22 @@ fn unsigned_grant(community: &str, member: &str, valid_until: Option Value { + json!({ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://firstperson.network/credentials/dtg/v1" + ], + "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"], + "issuer": community, + "validFrom": "2026-01-06T10:00:00Z", + "credentialSubject": { "id": member } + }) +} + /// The ordinary case, and the roles it produces: the member issues, the community is the /// subject, and the digest binds the exact grant received. #[test] @@ -168,6 +185,33 @@ fn binding_does_not_establish_that_the_grant_was_signed() { ); } +/// Naming yourself is the one grant the member check cannot refuse: it compares the grant +/// with the identity the caller expects, and an attacker writing its own grant satisfies +/// both sides of that comparison. So the constructor builds, and the binding holds — which +/// is the whole of what building an acknowledgement claims. The step that refuses this +/// grant is verifying it, pinned in +/// `verifying_the_grant::a_grant_its_own_subject_wrote_does_not_verify`. +#[test] +fn a_grant_its_own_subject_wrote_satisfies_the_member_check() { + let forged = forged_grant(COMMUNITY, ATTACKER); + + let ack = DTGCredential::new_member_vmc_for(&forged, ATTACKER, t(1), None) + .expect("the expected member and the grant's subject are the same identifier"); + assert_eq!(ack.issuer(), ATTACKER); + assert_eq!(ack.subject(), COMMUNITY); + assert_eq!( + ack.subject_digest(), + Some(digest_multibase_json(&forged).unwrap().as_str()) + ); + + let forged: DTGCredential = serde_json::from_value(forged).unwrap(); + assert!(!forged.signed(), "the community it names never signed it"); + assert!( + ack.acknowledges(&forged).unwrap(), + "the binding holds; it is a binding to a document, not to a membership" + ); +} + /// The deprecated constructor keeps its behaviour for existing callers: the member is taken /// from the grant without comparison, and the grant's expiry is not consulted. Its own window /// is checked, as every constructor's is. @@ -218,6 +262,22 @@ mod verifying_the_grant { assert!(matches!(err, DTGCredentialError::NotSigned), "got {err:?}"); } + /// A grant its own subject wrote carries no proof from the community it names, so it + /// does not verify under that community's key. A member that verifies before answering + /// never reaches the constructor with it. + #[test] + fn a_grant_its_own_subject_wrote_does_not_verify() { + let key = key_of(COMMUNITY); + + let err = verify_grant_with_public_key( + &forged_grant(COMMUNITY, ATTACKER), + key.get_public_bytes(), + t(1), + ) + .unwrap_err(); + assert!(matches!(err, DTGCredentialError::NotSigned), "got {err:?}"); + } + /// The whole sequence a member follows: verify the grant, then answer it. #[tokio::test] async fn a_signed_grant_verifies_and_can_then_be_acknowledged() { From 05aba97ef11bf12a02f87b401ddce5f096852817 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sat, 12 Sep 2026 21:43:05 +0200 Subject: [PATCH 2/2] test: drop a verification case already covered, and say why the grant is signed `a_grant_its_own_subject_wrote_does_not_verify` reached `DTGCredentialError::NotSigned` through the same two lines as `an_unsigned_grant_does_not_verify` directly above it: the depth check, then the absence of a top-level `proof`. Neither the document's provenance nor its subject is read before that error, so the two cases exercised identical code. The case that remains cross-references the one that was already there. `verifying_a_deep_grant_is_refused_without_exhausting_the_stack` signs the grant before attaching the deep member, which is load-bearing and was not explained: an unsigned grant is refused for having no proof before the verifier reaches the clone that strips it, so with the bound removed it would return an error rather than overflow, and the case would prove nothing. Signed-off-by: Glenn Gore --- tests/json_bounds.rs | 4 ++++ tests/membership_edge.rs | 20 ++------------------ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/json_bounds.rs b/tests/json_bounds.rs index ae1ce48..dda51f7 100644 --- a/tests/json_bounds.rs +++ b/tests/json_bounds.rs @@ -336,6 +336,10 @@ mod signing { /// Verifying a grant is the one entry point handed a whole document by a counterparty /// before anything about it is established, so it is the one most worth running on a /// stack too small to walk a hostile value. + /// + /// The grant is signed before the deep member is attached. Without the bound, the + /// verifier gets as far as cloning the document to strip `proof` from it, and that clone + /// recurses; an unsigned grant would be refused before reaching it and prove nothing. #[tokio::test] async fn verifying_a_deep_grant_is_refused_without_exhausting_the_stack() { let secret = Secret::generate_ed25519(Some(&format!("{ISSUER}#key-1")), None); diff --git a/tests/membership_edge.rs b/tests/membership_edge.rs index f5d1fe8..375e8e2 100644 --- a/tests/membership_edge.rs +++ b/tests/membership_edge.rs @@ -189,8 +189,8 @@ fn binding_does_not_establish_that_the_grant_was_signed() { /// with the identity the caller expects, and an attacker writing its own grant satisfies /// both sides of that comparison. So the constructor builds, and the binding holds — which /// is the whole of what building an acknowledgement claims. The step that refuses this -/// grant is verifying it, pinned in -/// `verifying_the_grant::a_grant_its_own_subject_wrote_does_not_verify`. +/// grant is verifying it: a grant its own subject wrote carries no proof from the community +/// it names, which `verifying_the_grant::an_unsigned_grant_does_not_verify` pins. #[test] fn a_grant_its_own_subject_wrote_satisfies_the_member_check() { let forged = forged_grant(COMMUNITY, ATTACKER); @@ -262,22 +262,6 @@ mod verifying_the_grant { assert!(matches!(err, DTGCredentialError::NotSigned), "got {err:?}"); } - /// A grant its own subject wrote carries no proof from the community it names, so it - /// does not verify under that community's key. A member that verifies before answering - /// never reaches the constructor with it. - #[test] - fn a_grant_its_own_subject_wrote_does_not_verify() { - let key = key_of(COMMUNITY); - - let err = verify_grant_with_public_key( - &forged_grant(COMMUNITY, ATTACKER), - key.get_public_bytes(), - t(1), - ) - .unwrap_err(); - assert!(matches!(err, DTGCredentialError::NotSigned), "got {err:?}"); - } - /// The whole sequence a member follows: verify the grant, then answer it. #[tokio::test] async fn a_signed_grant_verifies_and_can_then_be_acknowledged() {