Skip to content

test: gate the JSON depth bound on the two grant entry points 0.10.0 added - #29

Merged
stormer78 merged 2 commits into
mainfrom
sec-4045/regression-gates
Sep 12, 2026
Merged

stormer78 merged 2 commits into
mainfrom
sec-4045/regression-gates

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Regression gates for the two SEC-4045 findings in this crate — H13 (a membership
acknowledgement built from an unverified grant) and M2 (unbounded JSON depth). Both were
fixed in #28; this adds the cases that hold the fixes in place, after checking what #27 and
#28 already gate.

Tests only. No library source changes, and no fix was weakened to suit a test:
git diff origin/main -- src/ is empty.

161 tests → 164 with --all-features.

Gap analysis

Most of what the PoC demonstrated was already gated by the suites that landed with the
fixes. Two entry points were not, both of them introduced by #28 itself.

H13 — acknowledgement from an unverified grant

PoC Gated by This PR
PART 1 — new_member_vmc mints an ack from a forged, unsigned grant; the ack's issuer is the grant's subject, its subject the grant's issuer, and the digest binds the exact bytes membership_edge::the_deprecated_constructor_checks_neither_the_member_nor_the_grant_expiry for the deprecated path; binding_does_not_establish_that_the_grant_was_signed for the _for path Already covered, nothing added
PART 2 — acknowledges() accepts the forged pair, so the binding layer does not catch it membership_edge::binding_does_not_establish_that_the_grant_was_signed; delegation_chain::an_acceptance_from_the_wrong_party_binds_nothing for the VDC half Already covered, nothing added
PART 3 — the forged grant is unsigned, so a proof-checking verifier rejects the edge membership_edge::verifying_the_grant::an_unsigned_grant_does_not_verify and delegation_chain::an_unsigned_grant_does_not_verify, both against the real verify_grant_with_public_key rather than the PoC's signed() probe Already covered, nothing added. A second case for the self-authored grant was written and then dropped — see below
PART 4 — the ack's issuer is forced to the grant's subject, so the caller's identity is not honoured under _for the scenario is refused outright: membership_edge::a_grant_naming_someone_else_is_refused, delegation_chain::a_grant_appointing_someone_else_is_refused. Under the deprecated constructor the PoC's property is pinned by the_deprecated_constructor_checks_neither_the_member_nor_the_grant_expiry Already covered, nothing added
The limit of the fix: the member check cannot refuse a grant its own subject wrote, because the attacker satisfies both sides of the comparison New: membership_edge::a_grant_its_own_subject_wrote_satisfies_the_member_check. Not a gate; see Not a gate below

M2 — unbounded JSON depth

PoC Gated by This PR
medium_unbounded_json_is_embedded_verbatimnew_vec embeds an unvalidated blob verbatim, with no check of shape, keys or size json_bounds::open_members_are_carried_verbatim_within_the_bound. Deliberate: endorsement is open vocabulary, so only its depth is bounded Already covered, nothing added
medium_deep_json_serialization_stack_overflow_dos — a deep endorsement aborts the process a_deep_endorsement_is_refused_without_exhausting_the_stack, an_endorsement_beyond_the_bound_is_refused, signing::sign_refuses_a_deep_endorsement, signing::verification_refuses_a_deep_member, a_deep_document_is_refused_without_exhausting_the_stack, deriving_from_a_deep_parent_is_refused_without_exhausting_the_stack Already covered for digesting, validating, signing, verifying, and deriving from a parent
The same bound on the grant-answering constructors #28 added. new_member_vmc_for and new_delegate_vdc_for reach the 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 New: json_bounds::answering_a_deep_grant_is_refused_without_exhausting_the_stack
The same bound on verify_grant_with_public_key, also added by #28 — the one entry point handed a whole counterparty document before anything about it is established New: json_bounds::signing::verifying_a_deep_grant_is_refused_without_exhausting_the_stack

The PoC's temporal finding

Not among the two findings this branch is for, but it appears in the same PoC file and is
fixed in #28, so it was checked: new_vac accepting an inverted window is gated by
authority_chain::an_inverted_window_is_refused_at_issue and
a_window_narrower_than_a_second_is_refused, with the same shape in
delegation_chain::an_inverted_window_is_refused_at_issue and
membership_edge::an_inverted_window_is_refused. The PoC's far-past validFrom is accepted
deliberately, and authority_chain::a_backdated_window_is_accepted_at_issue pins that it
stays accepted. Already covered, nothing added.

Dropped as redundant

a_grant_its_own_subject_wrote_does_not_verify was written and then removed in 05aba97. It
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.

Not a gate

a_grant_its_own_subject_wrote_satisfies_the_member_check asserts that the constructor
builds, so no revert makes it red. It is a characterization pin on the residual risk the
PoC's PART 1 showed, kept so that a later change cannot quietly start describing
new_member_vmc_for as verifying a grant. Demonstrated by removing the found != member
check from new_member_vmc_for: the new case stayed green while the pre-existing gate went
red.

test a_grant_its_own_subject_wrote_satisfies_the_member_check ... ok
test a_grant_naming_someone_else_is_refused ... FAILED

---- a_grant_naming_someone_else_is_refused stdout ----

thread 'a_grant_naming_someone_else_is_refused' panicked at tests/membership_edge.rs:81:77:
called `Result::unwrap_err()` on an `Ok` value: DTGCredential { credential: DTGCommon { ...
issuer: "did:example:someone-else", ... credential_subject: Membership(CredentialSubjectMembership
{ id: "did:example:community", digest_multibase: Some("zQmTWwa8pEyRPCbos9NtGFgQvazVr1YRmQurqvHBpNsVGfC") }),
credential_status: None, proof: None, extra: {} }, type_: Membership, version: V2_0 }

test result: FAILED. 14 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out.

That is the answer to "is H13 gated": it is, by a_grant_naming_someone_else_is_refused
from #28, not by anything here.

Red, then green

Both new depth cases run on a std::thread::Builder thread with a 256 KiB stack, as the
rest of tests/json_bounds.rs does, so a missing guard aborts the binary rather than
passing quietly. Deep values are built programmatically — serde_json's parser caps at 128
levels, so a deep document cannot be parsed to make the case. Each revert below was
restored immediately, confirmed with git diff origin/main -- src/lib.rs printing nothing,
and nothing was committed while a revert was in place.

answering_a_deep_grant_is_refused_without_exhausting_the_stack

Revert: check_json_depth(doc)?; removed from digest_multibase_json, which is what
new_member_vmc_for reaches through assemble_member_vmc.

running 1 test
test answering_a_deep_grant_is_refused_without_exhausting_the_stack ...
thread 'small-stack' (536607) has overflowed its stack
fatal runtime error: stack overflow, aborting
error: test failed, to rerun pass `--test json_bounds`

Caused by:
  process didn't exit successfully: `.../json_bounds-8ec3a180f992084e --exact answering_a_deep_grant_is_refused_without_exhausting_the_stack --nocapture --test-threads=1` (signal: 6, SIGABRT: process abort signal)

That is the PoC's cargo-test-dos-output.txt signature — stack overflow, SIGABRT — reached
through a constructor rather than through serde_json::to_value. Restored:

test answering_a_deep_grant_is_refused_without_exhausting_the_stack ... ok

signing::verifying_a_deep_grant_is_refused_without_exhausting_the_stack

Revert: check_json_depth(grant)?; removed from verify_grant_with_public_key. The grant is
signed before the deep member is attached, which is load-bearing: without a proof the
verifier refuses the grant before it reaches the clone that strips proof, and the case
would prove nothing.

running 1 test
test signing::verifying_a_deep_grant_is_refused_without_exhausting_the_stack ...
thread 'small-stack' (538705) has overflowed its stack
fatal runtime error: stack overflow, aborting
error: test failed, to rerun pass `--test json_bounds`

Caused by:
  process didn't exit successfully: `.../json_bounds-8ec3a180f992084e --exact 'signing::verifying_a_deep_grant_is_refused_without_exhausting_the_stack' --nocapture --test-threads=1` (signal: 6, SIGABRT: process abort signal)

Restored:

test signing::verifying_a_deep_grant_is_refused_without_exhausting_the_stack ... ok

What cannot be gated

The PoC's abort was triggered by serde_json::to_value(&credential) on a credential the
caller had built with a 50,000-level value — plain serialization, not a digest, a signature
or a verification. MAX_JSON_DEPTH cannot cover that, and its own "What it cannot do"
section says so: a serde_json::Value is dropped recursively too, so a caller already
holding such a value overflows when it goes out of scope whatever this library returns.
serde_json's parser limit is the real boundary, and serde_json_still_limits_parsing_depth
pins it. There is no way to assert the uncovered case in a test, because the only way to
exercise it is to overflow, which aborts the binary — which is exactly why the guarded
entry points are the ones worth gating, and why these cases run on a small stack.

Checks

cargo fmt --all --check, cargo clippy --all-targets --all-features -- -D warnings,
cargo test --all-features (164 passed) and cargo test --no-default-features (148 passed)
all clean.

Refs: SEC-4045

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 <glenn.g@affinidi.com>
… 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 <glenn.g@affinidi.com>
@stormer78
stormer78 requested a review from a team as a code owner September 12, 2026 19:47
@stormer78
stormer78 merged commit 66376c0 into main Sep 12, 2026
7 checks passed
@stormer78
stormer78 deleted the sec-4045/regression-gates branch September 12, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant