Skip to content

refactor(noir): single entrypoint for attestation verification - #887

Closed
kilianglas wants to merge 1 commit into
kilianglas/aat-unnest-taktfrom
kilianglas/aat-layered-api
Closed

refactor(noir): single entrypoint for attestation verification#887
kilianglas wants to merge 1 commit into
kilianglas/aat-unnest-taktfrom
kilianglas/aat-layered-api

Conversation

@kilianglas

Copy link
Copy Markdown
Contributor

Makes the attestation library consumable, and makes it impossible to consume unsafely. Stacked on #884 — review that one first.

The problem

Two things were wrong with the API, both surfaced while sketching how the DeepFace circuits would call it.

The library could not be used at all. Every field of TrustAnchorKeyToken and AuthenticatorAssertionToken was module-private, so no external Noir package could construct either token. I confirmed this with a throwaway consumer package: error: exp is private and not visible from the current module, and the same for all eight fields. verify_aat was reachable but uncallable.

The binding was carried by struct nesting. Since #884 un-nested the TAKT, the shared assertion_key is the sole thing tying the two tokens together — the spec says so explicitly ("This equality is the sole binding between the two tokens"). That made it more important, not less, that neither token can be verified in isolation. But the AAT held the TAKT as a field, which mirrors the old nested wire format rather than the current one.

The shape

verify_takt(takt, trust_anchor_key_x, trust_anchor_key_y, now)   // pub(crate)
verify_aat(aat, assertion_key_x, assertion_key_y, now)           // pub(crate)
verify_attestation(aat, takt, trust_anchor_key_x, _y, now)       // pub

The composite feeds verify_aat the assertion_key read from the TAKT it just verified. The binding therefore holds by construction — no equality assert, no second witness for the same key, nothing a refactor can drop. And because the primitives are crate-private, a consumer cannot verify one half and skip the other. From the same external package:

error: verify_aat is private and not visible from the current module
error: verify_takt is private and not visible from the current module

That is the bypass — verify an AAT under a prover-chosen key, never check a TAKT — failing at compile time.

Each primitive is also independently testable now. Previously every AAT test had to drag in a full valid TAKT fixture.

Two structural fixes that fall out

The AAT no longer carries the TAKT, and the TAKT no longer carries the trust_anchor_key. Neither was part of the token it sat in. The TAKT travels in the AAT's unprotected header. And the trust anchor key is not a TAKT claim — the token identifies its signer only by the kid protected-header parameter, which WIP-106 leaves outside the signature and the Security section says to treat as an untrusted hint. It is a verifier-supplied input, exactly like now; takt_digest never hashed it. The signature now reflects that.

Struct fields are pub, so tokens can be constructed. This is the fix for "not consumable," and it is orthogonal to the guarantee above: the prover supplies the token witnesses either way — what matters is that it cannot skip a verification step.

Tests

  • attestation::test_untrusted_trust_anchor_key_rejected — new. A well-formed TAKT signed by anyone other than the RP's allowlisted key must fail. Checked against the BabyJubJub prime-order subgroup generator so the failure is the signature check, not a point-validity assert (an off-curve key trips Public key must be on curve first, which would have made the test pass for the wrong reason).
  • test_aat_key_not_attested moved to the composite, where the binding now lives.
  • aat::test_aat_wrong_assertion_key — the primitive-level counterpart.

What the library still cannot enforce

Documented in crates/proof/noir/README.md with a worked main example, because no API shape can enforce it: the calling circuit must expose trust_anchor_key and now as public inputs (a private now lets the prover pick a time at which any token is fresh; a private anchor key lets it sign its own TAKT), must bind aat.nonce/aat.cdh to the surrounding proof, and must expose whichever of sec_flags and authenticator_meta its RP needs. A CI check over each consumer's compiled ABI would catch the public-input half; worth adding alongside the first consumer.

Test plan

  • cd crates/proof/noir/authenticator-attestation && nargo test — 14 passing (was 11)
  • cargo test -p world-id-proof authenticator_attestation — 16 passing, unchanged (the Rust encoder is untouched)
  • nargo fmt --check clean
  • External consumer package compiles against verify_attestation, with trust_anchor_key_x/_y and now reported as public in the emitted ABI
  • Same package fails to compile when calling verify_aat or verify_takt directly

Splits the WIP-106 verification into two crate-private primitives and one
public composite:

    verify_takt(takt, trust_anchor_key_x, trust_anchor_key_y, now)  pub(crate)
    verify_aat(aat, assertion_key_x, assertion_key_y, now)          pub(crate)
    verify_attestation(aat, takt, trust_anchor_key_x, _y, now)      pub

Since un-nesting, the shared assertion_key is the *sole* binding between the
two tokens, so it matters more than before that neither can be verified alone.
The composite feeds verify_aat the key read from the TAKT it just verified, so
the binding holds by construction with no equality assert, and the primitives
are crate-private so a consumer cannot skip either half. Verified from a
throwaway external package: calling verify_aat directly is a compile error
("verify_aat is private and not visible from the current module").

Two structural fixes fall out of this:

- The AAT no longer carries the TAKT as a field, and the TAKT no longer
  carries the trust_anchor_key. Neither was part of the token it sat in: the
  TAKT travels in the AAT's unprotected header, and the trust anchor key is
  identified only by the kid protected-header parameter, which WIP-106 leaves
  outside the signature. It is a verifier-supplied input, like now, and the
  signature now says so.
- Struct fields are pub. They were module-private, so no external package
  could construct either token -- the library was not consumable at all.

Adds a negative test for an untrusted trust_anchor_key, moves the
key-not-attested test to the composite where the binding now lives, and
documents the public-input obligations the library cannot enforce.

14 Noir tests (was 11), 16 Rust tests unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kilianglas
kilianglas requested a review from a team as a code owner August 5, 2026 13:12
@kilianglas

Copy link
Copy Markdown
Contributor Author

Folding this into #884 instead of stacking another PR — commit 0d65c97 now sits on kilianglas/aat-unnest-takt, rebased onto the two README suggestion commits. Closing.

@kilianglas kilianglas closed this Aug 5, 2026
@kilianglas
kilianglas deleted the kilianglas/aat-layered-api branch August 5, 2026 13:49
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