fix(attest): make verify_all lenient under RequireAll (#359) - #365
fix(attest): make verify_all lenient under RequireAll (#359)#365andreolf wants to merge 3 commits into
Conversation
Registry::verify_all propagated every per-attestation error via ?, so a single malformed or unknown attestation aborted the whole batch. Under Policy::RequireAll that is a DoS vector: any third party can attach an attestation, so an unrelated attacker/spam/v1 could block an otherwise valid cert. Under RequireAll, drop entries that fail to verify instead of aborting; the required_types check remains the gate and only counts fully_verified entries, so a required type present only as a malformed attestation still fails with RequiredMissing. AcceptKnown and RejectUnknown stay strict. Adds tests for both: a malformed extra is dropped without aborting, and a malformed-only required type is still rejected. Closes Gitlawb#359
|
Warning Review limit reachedNext included review available in 55 minutes. View limit detailsLimit details: You’ve used the included review currently available. This review ran on the open-source allowance, not this organization's plan, because the pull request author doesn't have an assigned seat. Waiting won't change this — ask an organization admin to assign them a seat, or add seats in Billing if every seat is already assigned, then retry. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
Comment |
beardthelion
left a comment
There was a problem hiding this comment.
Verified the fix does what it claims and does not weaken the gate. Reverting the policy branch to the pre-fix strict loop turns require_all_drops_a_malformed_extra_and_still_accepts red, and disabling the required_types check turns require_all_rejects_when_only_a_malformed_required_type_is_present red, so both new tests bind real behavior rather than passing vacuously. The vector is reachable as described: cert_hash() strips attestations before hashing and attach() is public, so any relaying party can append junk without invalidating a signature. Direction is right, the gate holds, and the blast radius is one caller. Two asks, both in lines this PR adds.
Unrelated to this PR: cargo audit is red on main too, RUSTSEC-2026-0258 in h2 0.4.13. Not yours, and I am not holding this on it.
Findings
-
[P2] Lock the non-
RequireAllpaths with an error-propagation test
crates/gitlawb-attest/src/verifier.rs:166
The new docstring promisesAcceptKnownandRejectUnknownstill surface the first error, but nothing asserts it. Replacing the strict branch's?with a lenientif let Ok(v)leaves the whole crate green; gutting its return does go red, so the branch executes and it is specifically the error propagation that is unbound. A later edit applying the lenient loop to every policy would pass every test here while silently turning defaultAcceptKnownbatch verification into drop-on-error. This closes it, and I confirmed it reddens under exactly that mutation:#[test] fn strict_policies_surface_the_first_batch_error() { let sk = fresh(); let cert_hash = sample_hash(); let bad = signed_demo(&sk, other_hash(), "ok"); let mut accept = Registry::new().with_policy(Policy::AcceptKnown); accept.register(DemoVerifier); let err = accept.verify_all(&[bad.clone()], cert_hash).expect_err("must not drop"); assert!(matches!(err, Error::CertHashMismatch { .. }), "got {err:?}"); let mut reject = Registry::new().with_policy(Policy::RejectUnknown); reject.register(DemoVerifier); let err = reject.verify_all(&[bad], cert_hash).expect_err("must not drop"); assert!(matches!(err, Error::CertHashMismatch { .. }), "got {err:?}"); }
-
[P3] Correct the docstring: unknown types are not dropped
crates/gitlawb-attest/src/verifier.rs:149
The added text lists "a type with no verifier" among entries dropped from the result. UnderRequireAll,verifyreturnsOkwithfully_verified = falsefor an unregistered type, sofilter_map(.ok())keeps it, andrequire_all_is_lenient_on_unknown_types_in_the_batchalready asserts the entry survives withlen == 2. Only the genuineErrpaths are dropped. The PR summary carries the same slip, though the Motivation section states it correctly.
jatmn
left a comment
There was a problem hiding this comment.
I found issues that need to be addressed before this is ready.
Findings
-
[P2] Lock the strict batch policies with an error-propagation test
crates/gitlawb-attest/src/verifier.rs:166
The root cause is that the refactor splitsverify_allby policy but the new test coverage only exercises the lenientRequireAllarm. NeitherAcceptKnownnorRejectUnknownis invoked throughverify_allwith an attestation that makesself.verifyfail. Consequently, an apparently harmless future cleanup that applies thefilter_map(Result::ok)pattern to all policies would make malformed, bad-signature, or wrong-cert-hash entries disappear instead of returning an error; the current added tests would still pass, silently changing the default and fail-closed contracts despite the promise at lines 159–160.Preserve the explicit strict loop, and add a batch-level regression test for both strict policies using a genuinely malformed entry (for example, the existing wrong-cert-hash helper). Assert that
verify_allreturns the underlying verification error rather than a partial result. That binds the policy boundary itself, rather than only the single-attestationverifybehavior. -
[P3] Do not say unknown attestations are dropped
crates/gitlawb-attest/src/verifier.rs:149
The root cause is that the documentation treats “no verifier” as a verification failure, but it is an intentional successful result underRequireAll:verifyreturnsOk(VerifiedAttestation { fully_verified: false }), sofilter_map(Result::ok)retains the entry. The existingrequire_all_is_lenient_on_unknown_types_in_the_batchtest already proves this with a two-entry result containing the unverifiedother/v1attachment. The new wording therefore tells callers that the returned vector excludes unknown valid attestations when it does not.Correct the contract at its source by limiting the dropped-entry list to actual
Errpaths—bad signatures, cert-hash mismatches, and malformed payloads—while stating that valid unknown types remain in the result as unverified. Do not change the implementation to drop unknown entries: retaining them is the establishedRequireAllbehavior and preserves the distinction between untrusted and invalid attestations.
…n test AcceptKnown and RejectUnknown were only exercised per-attestation, so a cleanup applying RequireAll's filter_map(Result::ok) shape to the strict arms would fail open with every test green. Drive both policies through verify_all with a wrong-cert-hash member and assert the batch surfaces the underlying error. Also correct the RequireAll doc: unknown valid types are retained as unverified, not dropped — only real Err paths are.
Superseded by the round at 2b11419, which adds the strict-arm error-propagation test this review asked for. Re-reviewing the current head.
beardthelion
left a comment
There was a problem hiding this comment.
Approving. The strict arms now have a test that fails when they are made lenient, which is what the last round asked for. I checked it by giving AcceptKnown/RejectUnknown the filter_map(Result::ok) shape and watching strict_policies_propagate_a_batch_members_error go red, and by reverting the RequireAll arm to ? propagation, which reds both require_all_* tests. Blast radius is inside the crate: nothing in the workspace depends on gitlawb-attest, and gitlawb-core::cert::verify_all is a separate method.
Two follow-ups, neither blocking.
Findings
-
[P3] Assert
CertHashMismatchexactly instead of the two-variant union
crates/gitlawb-attest/src/verifier.rs:373
verify_signaturecomparescert_hashbefore it touches the signature, so a member signed against a different hash always fails asCertHashMismatchand theError::Signature(_)arm is dead. I narrowed thematches!toCertHashMismatchalone and the suite stayed at 44 passed; narrowing it toSignature(_)alone went red. Pinning the exact variant makes the test say which contract it is holding. -
[P3] Drive the other strict error classes through
verify_alltoo
crates/gitlawb-attest/src/verifier.rs:171-177
Only the cert-hash class crosses the batch path today. I mutated the strict arm to swallow justErr(Error::UnknownType(_)), which makes aRejectUnknownregistry silently accept an attacker-attached unregistered attestation, and the whole suite stayed green at 44 passed; swallowing justErr(Error::Payload(_))was also green. ARejectUnknownbatch with an unregistered member assertingErr(UnknownType), plus a payload-failing member under a strict policy, would close both.
Summary
Registry::verify_allaborted the whole batch on any per-attestation error, violating its documentedRequireAllleniency. UnderPolicy::RequireAll, a single malformed or unknown attestation now drops out of the result instead of failing the batch. Fixes #359.Motivation & context
Closes #359
The module docstring promises that under
RequireAllthe registry "never short-circuits on unknown types — any attestation can be attached by any third party, so blocking the cert because an attacker added an unrelatedattacker/spam/v1would be a denial-of-service vector." Butverify_allpropagated every error via?:So a bad signature, cert-hash mismatch, or malformed payload on any attached attestation aborted the entire batch — exactly the DoS the docstring warns about.
Kind of change
What changed
Crate touched:
gitlawb-attest(src/verifier.rs).Policy::RequireAll,verify_allnow drops entries that fail to verify (filter_map(.ok())) instead of aborting. Therequired_typespresence check remains the gate, and it only countsfully_verifiedentries — so a required type present only as a malformed attestation still fails withRequiredMissing. Leniency does not open a hole.AcceptKnownandRejectUnknownare unchanged: they stay strict and surface the first error.verify_alldocstring to state the per-policy contract.How a reviewer can verify
cargo test -p gitlawb-attest --lib verifier cargo clippy -p gitlawb-attest --all-targets -- -D warningsTwo new tests:
require_all_drops_a_malformed_extra_and_still_accepts— a malformed extra (signed against the wrong cert hash) alongside a valid required attestation no longer aborts; the junk is dropped and the cert verifies. Fails on the pre-fix code.require_all_rejects_when_only_a_malformed_required_type_is_present— when the sole copy of a required type is malformed,verify_allstill returnsRequiredMissing.Before you request review
cargo test --workspacepasses locally (rangitlawb-attest: 52 tests pass)cargo fmt --allandcargo clippy --workspace --all-targets -- -D warningsare cleanfix(attest): ...).env.exampleupdated if behavior or config changed (N/A)Protocol & signing impact
did:key, Ed25519 / RFC 9421 signatures, UCAN, ref certs, or P2P wire formats — attestation verification for ref-update certs. No wire-format or signature-scheme change; only the batch error-handling policy underRequireAll. Behavior forAcceptKnown/RejectUnknownis unchanged.RequireAll, and only for attestations that would otherwise have hard-failed the batch).Notes for reviewers
gitlawb-core'scert.rs; deliberately out of scope here to keep this PR to one crate/one change. Happy to follow up on that separately.