Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions tests/json_bounds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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() {
Expand All @@ -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]
Expand Down
44 changes: 44 additions & 0 deletions tests/membership_edge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Utc.with_ymd_and_hms(2026, 1, 6, 10, 0, 0).unwrap() + Duration::hours(h)
Expand All @@ -35,6 +36,22 @@ fn unsigned_grant(community: &str, member: &str, valid_until: Option<DateTime<Ut
.unwrap()
}

/// A grant nobody issued: hand-written by `member`, naming `community` as its issuer and
/// carrying no proof, because the community it names never signed anything. Written out
/// rather than built, since an attacker has no reason to use this library's constructors.
fn forged_grant(community: &str, member: &str) -> 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]
Expand Down Expand Up @@ -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.
Expand Down