diff --git a/tests/json_bounds.rs b/tests/json_bounds.rs index aa473f2..dda51f7 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,30 @@ 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. + /// + /// 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); + 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..375e8e2 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: 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); + + 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.