Skip to content

Implement IHAT over P-256, cross-verified against the ihat-rs reference implementation - #14

Merged
OR13 merged 6 commits into
mainfrom
exp-ihat
Jul 28, 2026
Merged

Implement IHAT over P-256, cross-verified against the ihat-rs reference implementation#14
OR13 merged 6 commits into
mainfrom
exp-ihat

Conversation

@OR13

@OR13 OR13 commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Implements IHAT over NIST P-256 in crypto::exp_ihat, behind an off-by-default feature, aligned byte-for-byte with ihat-rs — the reference implementation published in the MoLE org by Samuel Schlesinger, one of the draft authors. Renames the package to mole-exp with publish = false; the lib is still mole to import.

It follows code, not prose

The crypto draft is prose that names no group, no DST, and no wire structures. ihat-rs is executable with a canonical wire format. Where they disagree, ihat-rs wins — it is by an author, and interoperating with it is the only validation available since neither publishes test vectors.

Taken from it: P-256, SEC1 compressed Point[33], RFC 9380 P256_XMD:SHA-256_SSWU_RO_, hash_to_field for scalars, the four MOLE-IHAT-P256:<use>:v1 DSTs verbatim, Y = H₁(nf), a context-bound Pedersen generator, both Fiat–Shamir transcripts, and CDS 1-of-n for the OR proof.

Validation

Cross-verified in both directions. interop/ is a detached package pinned to an upstream revision: ihat-rs accepts presentations produced here and re-encodes them byte-identically, this crate accepts ihat-rs's, keys round-trip, and neither accepts a substituted accepted set.

Appendix A end to end with no mocks. interop/tests/appendix_a_end_to_end.rs pairs the profile with real RFC 9578 Privacy Pass credentials via privacypass — issuance, finalization, presentation, nonce checks on both layers, reissue. tests/appendix_a_flow.rs runs the same exchange on the profile alone, asserting issuer hiding at the protocol level and that the accepted set's order is load-bearing.

The library did not grow

Still #![no_std] on thumbv7em-none-eabi, still two dependencies (sha2, base64), still forbid(unsafe_code). exp-ihat adds one optional dependency, p256. The credential layer needed no Privacy Pass dependency at all — §5.2.1 has MoLE carry TokenRequest/TokenResponse opaquely, so the only addition is credential::reverse_flow::Token, a parser for the one RFC 9578 structure a Moderator must read to enforce §5.2.2. Structure only, no cryptography.

privacypass is std-only with an async issuer API, which is why it lives in the detached package and not here.

The one thing that cannot be reconciled

ihat-rs has no challenge_digest anywhere — not in a structure, not in a transcript, not in its API. So protocols §4.1.3's two MUSTs about binding it are satisfied by no implementation, including the authors' own. ChallengeBinding exposes both sides rather than picking silently:

Mode Binds digest Interoperates Satisfies §4.1.3
IhatRsCompatible ❌ — replayable
MoleBound (default)

Both are tested, including that they do not cross-verify.

Findings worth reporting upstream

Seven, indexed by #13. The sharpest:

  • #8challenge_digest binding is implemented by no one
  • #16 — §5.2.2's constant-digest requirement is unsatisfiable with a conformant RFC 9578 implementation: it cross-references §3.3, but the token's field is computed over an RFC 9577 TokenChallenge, which is never empty
  • #9 — Anchor nonce reuse recovers the secret key, and no draft says so. Deterministic nonces, the usual advice, are the dangerous choice here
  • #11 — no session identifier on the wire in any of the four grant messages

Plus: upstream PR #39 contradicts ihat-rs on point encoding, the Pedersen generator, and the OR-proof construction.

Still not deployable

ihat-rs states it has not been audited. The construction has no security proof and no public cryptanalysis. Interoperability is agreement, not correctness — if the construction is flawed, both implementations are flawed identically.

Verification

111 tests plus 7 in the detached package. Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 builds and tests --locked. Four CI jobs, including the cross-verification and end-to-end run.

🤖 Generated with Claude Code

OR13 and others added 2 commits July 28, 2026 14:12
Why: to find out what implementing IHAT actually requires, and to correct a
claim in this crate's own documentation that had gone stale.

[CRYPTO] exists now. draft-authors-mole-crypto.md landed upstream on
2026-07-15 (PR #38, closing issue #31), nine days after the -00 drafts. The
README said it "does not exist yet"; that was checkably wrong, and for a crate
whose credibility rests on precisely characterising what the drafts pin down,
a reviewer finding the document we said was absent is the worst available
failure mode. Corrected in README, SPEC-COVERAGE, lib.rs and crypto.rs. It is
still not on the Datatracker (404) and still not interoperably specified, so
the conclusion is unchanged — only the reason is now accurate.

The construction closes. Deriving it: Z' = xY' = xvY, so Zhat = γv⁻¹Z' = γxY
and Xhat = γX = γxG. With r_target = ε⁻¹(t'−ρ), the client's T1 = r_target·Y
and T2 = r_target·G; then r = ε⁻¹(r'−ρ) = r_target + γxea, so verification
recovers rY − ea·Zhat = r_target·Y and rG − ea·Xhat = r_target·G, and
aG + bH = α⁻¹C' − βH = C. The algebra is self-consistent and implementable.

So crypto::exp_ihat implements it, behind an off-by-default feature. Unlike
insecure_mock this is the real construction: blinding works, the Anchor never
learns the nullifier, issuer hiding is a genuine 1-of-n CDS OR proof, and
verification needs only public keys.

Ten parameters had to be guessed, because the draft is ~50 lines of prose that
names no group, no hash, no DSTs, declares TLS notation without defining a
single structure, and publishes no test vectors. Choices: ristretto255 (the
construction stacks three sigma protocols and curve25519's cofactor 8 makes
those unsound); DST base OR13_EXP_MOLE, deliberately naming this repo so it
cannot squat a string the spec might want; SHA-256 widened to 64 bytes via
expand_message_xmd, because bare 32-byte reduction mod ℓ≈2^252 is biased;
Point[33] filled as a 0x00 tag plus 32-byte ristretto, keeping the drafts'
wire width intact. Each guess is an open issue; #13 indexes them.

Two findings worth reporting upstream:

- Anchor nonce reuse recovers the secret key (#9). Reuse of t' and a' across
  two grants gives r'_1 − r'_2 = (e'_1 − e'_2)a'x, and a' is sent to the
  Client in the clear. This rules out deterministic nonce derivation, which
  inverts the usual advice and is a live footgun given IhatAnchor::sign takes
  &self — the obvious workaround is the dangerous one. No draft says this.
- The group is ambiguous across drafts (#1): Point[33] implies SEC1 256-bit
  while ACT uses 32-byte ristretto255, and upstream #37 folds ACT into the
  crypto draft.

Renamed the package to mole-exp with publish = false. The lib is still `mole`
to import, so no call sites move. Nothing here is publishable and the -exp
suffix should make that hard to forget.

Twelve tests in tests/exp_ihat_profile.rs assert blindness against the actual
request bytes, issuer hiding with size invariant to the real branch, challenge
binding, public verifiability, and detection of every single-byte flip. They
establish self-consistency and nothing more: there are no vectors to check
against (#10), the construction has no security proof or public cryptanalysis,
and upstream PR #39 is still open to define the proof.

100 tests pass; clippy pedantic, rustdoc, fmt clean; no_std verified on
thumbv7em-none-eabi with and without the profile; MSRV 1.75 holds --locked
with zeroize pinned below the edition2024 bump (#12).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Why: P-256 is the group both MoLE drafts point at, and switching to it
*removes* inventions rather than adding them.

Four things that were this crate's choices are now the specification:

- Point[33] is a genuine SEC1 compressed P-256 point. The 0x00 tag byte that
  padded a 32-byte ristretto encoding out to the drafts' width is gone. This
  was the only place the profile had to invent an encoding.
- Hash-to-curve is RFC 9380 P256_XMD:SHA-256_SSWU_RO_, which is what the
  crypto draft's "RO based Hash2Curve function" resolves to over P-256. The
  hand-rolled expand_message_xmd is deleted in favour of p256's hash2curve.
- Scalars come from RFC 9380 hash_to_field, expanding to 48 bytes for P-256
  before reduction, so the draft's "output length sufficiently long" is met by
  construction rather than by this crate choosing a width.
- The Pedersen second generator is Hash2Curve("IHAT second generator"), the
  literal string upstream PR #39 defines.

SHA-256 therefore stops being a preference and becomes the specification:
P256_XMD:SHA-256_SSWU_RO_ expands with SHA-256 internally. The earlier
argument for ristretto255 — curve25519's cofactor 8 against three stacked
sigma protocols — turned out to be moot, because P-256 has cofactor 1.

Also corrects an error in this crate's earlier analysis, recorded on #4: the
crypto draft's {{DLEQ}} reference has always been
I-D.draft-irtf-cfrg-sigma-protocols, not RFC 9497. It never reached an issue
body or the code, but it understated how much specified machinery the draft
already pointed at.

Six items remain open, each an issue, and two of them are now deliberate
deviations from things PR #39 does specify rather than gaps:

- #1 the two MoLE drafts disagree on point compression, Point[33] vs
  bytes[65]; and the group is still only named by a forward reference the
  architecture draft does not satisfy
- #2 no DST upstream; ours now name the suite per RFC 9380 3.1 convention
- #3/#4 e = H(Y, Zhat, T1, T2, C) is the one hash left outside any framework;
  the profile does not yet use the SHAKE128 sponge and P-256 codec of
  draft-irtf-cfrg-fiat-shamir
- #5 C' is still used before it is defined
- #6 textbook CDS OR proof rather than PR #39's one-of-N trapdoor commitment
  tree, held because that PR is unmerged and carries its own TODO saying the
  construction may be replaced
- #7/#8 m typed as Scalar cannot carry endorsement_context, and
  challenge_digest appears in no upstream structure or challenge, so following
  the crypto draft here would produce something that cannot satisfy protocols
  4.1.3 at all

102 tests pass. The profile gains two: Anchor keys are SEC1 compressed with
both parities occurring, and the identity is refused rather than truncated
into a 33-byte lookalike. Clippy pedantic, rustdoc and fmt clean; no_std
verified on thumbv7em-none-eabi with and without the profile; MSRV 1.75 holds
--locked, with zeroize still pinned because p256 reaches it via
elliptic-curve (#12).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OR13 OR13 changed the title Implement IHAT under an explicitly-guessed experimental profile (mole-exp) Implement IHAT over P-256 under a partly-guessed experimental profile (mole-exp) Jul 28, 2026
@OR13

OR13 commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Update: switched from ristretto255 to P-256

Aligned the profile with the group the drafts point at. This removed inventions rather than adding them — four things that were this crate's choices are now the specification:

before now
Group ristretto255 (ours) P-256, per the crypto draft
Point[33] 32-byte ristretto + a 0x00 tag byte genuine SEC1 compressed point
Hash-to-curve hand-rolled expand_message_xmd(SHA-256) + ristretto map RFC 9380 P256_XMD:SHA-256_SSWU_RO_
Scalar derivation SHA-256 widened to 64 bytes, our width RFC 9380 hash_to_field, 48 bytes by the spec
Pedersen generator our "pedersen-H" label Hash2Curve("IHAT second generator"), PR #39's literal string

The clearest win is Point[33]: a compressed SEC1 P-256 point is exactly 33 bytes, so the protocols draft's opaque Point[33] is now the encoding rather than something we pad to fit. The tag-byte hack is gone.

SHA-256 stopped being a preference and became the specificationP256_XMD:SHA-256_SSWU_RO_ expands with SHA-256 internally, and hash_to_field for P-256 expands to 48 bytes before reduction, so the draft's H "whose output length is sufficiently long" is satisfied by construction.

Worth recording: the original argument for ristretto255 was curve25519's cofactor 8 against three stacked sigma protocols. P-256 has cofactor 1, so that argument was moot for the drafts' actual curve.

Correction

The crypto draft's {{DLEQ}} reference has always been I-D.draft-irtf-cfrg-sigma-protocols, not RFC 9497 as an earlier analysis in this work claimed. It never reached an issue body or the code, but it understated how much specified machinery the draft already pointed at — which is why #4 turns out to be much closer to closed than it first looked.

What is still open

Six items, and two of them are now deliberate deviations from things upstream PR #39 does specify rather than gaps:

Verification

102 tests (profile up from 12 to 14 — new ones assert SEC1 compressed encoding with both parities occurring, and that the identity is refused rather than truncated into a 33-byte lookalike). Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 --locked.

CI green: run 30393126931.

Why: the MoLE org publishes a reference implementation, ihat-rs, by Samuel
Schlesinger — one of the draft authors. It settles most of what I had filed as
unguessable, and it disagrees with the drafts in ways worth knowing. I should
have checked the org's other repos before inventing parameters.

The profile now follows code rather than prose. Where ihat-rs and the crypto
draft disagree, ihat-rs wins: it is executable, it is by an author, and
interoperating with it is the only validation available since neither
publishes test vectors.

interop/ is a detached package (git dependency; Cargo cannot make
dev-dependencies optional) pinned to an upstream revision. Four tests, both
directions:

- ihat-rs parses and accepts our presentations for every issuer in a
  four-Anchor set, and re-encodes them to byte-identical output
- we parse and accept ihat-rs's, recovering the same nullifier and context
- Anchor keys round-trip both ways
- neither accepts a substituted accepted set

That converts the crate's worst remaining problem — no test vectors, so
correctness unverifiable — into differential testing against the authors' own
code, which is stronger: it exercises both roles rather than fixed strings.

Adopted from ihat-rs: the four DSTs verbatim (MOLE-IHAT-P256:<use>:v1, which
closes the DST question outright), Y = H1(nf), a context-bound Pedersen
generator H(endorsement_context), the issuance transcript, the CDS OR proof
wire form, and two-byte big-endian VarBytes framing.

Two real weaknesses in our previous revision, both found by reading ihat-rs:

- or_prove branched on the secret real_index (`if i == real_index`), a timing
  and cache channel leaking exactly what issuer hiding conceals. ihat-rs is
  explicitly constant-time here; now rewritten to match, selecting the honest
  base with subtle across the whole accepted set.
- our Fiat-Shamir transcript followed the draft's e = H(Y, Zhat, T1, T2, C).
  ihat-rs additionally binds X_hat and endorsement_context, commenting that
  this is "strengthened to bind the full statement ... so a proof cannot be
  transported to a different X_hat". Now matched.

Also dropped: the separate DLEQ proof we emitted in exchange one. ihat-rs
sends none because (T1', T2', r') already is one — finalize checks
Y'r' = Z'e'a' + T1' and Gr' = Xe'a' + T2'. That was 64 redundant bytes per
grant. And `m` is gone: ihat-rs carries nf and endorsement_context as separate
fields, so our m = opaque<V>(ctx) || nf[32] was working around a non-problem.

One thing cannot be reconciled. ihat-rs has no challenge_digest anywhere, so
protocols 4.1.3's two MUSTs about binding it are satisfied by no
implementation. Rather than pick silently, ChallengeBinding exposes both
sides: IhatRsCompatible interoperates and is replayable; MoleBound (default)
satisfies 4.1.3 and does not interoperate. Tests assert both, including that
they do not cross-verify.

Corrections recorded: {{DLEQ}} resolves to draft-irtf-cfrg-sigma-protocols,
not RFC 9497. And PR #39 contradicts ihat-rs in three places — uncompressed
bytes[65] vs compressed 33, a global vs context-bound Pedersen generator, and
an O(log n) commitment tree vs O(n) CDS.

109 tests plus 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on
thumbv7em-none-eabi with and without the profile; MSRV 1.75 --locked. New CI
job runs the cross-verification. cargo doc caught a bare [DLEQ] citation
during this work, which is why it stays in the check list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OR13 OR13 changed the title Implement IHAT over P-256 under a partly-guessed experimental profile (mole-exp) Implement IHAT over P-256, cross-verified against the ihat-rs reference implementation Jul 28, 2026
@OR13

OR13 commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Update: aligned with ihat-rs — and it interoperates

The MoLE org publishes ihat-rs, a reference implementation by Samuel Schlesinger, one of the draft authors, with a canonical wire format. I should have checked the org's other repos before inventing parameters — several of the things filed as unguessable were already decided in code.

The profile now follows code rather than prose. Where ihat-rs and the crypto draft disagree, ihat-rs wins: it's executable, it's by an author, and interoperating with it is the only validation available since neither publishes test vectors.

It cross-verifies, both directions

interop/ is a detached package (git dependency; Cargo can't make dev-dependencies optional) pinned to an upstream revision. Four tests:

  • ihat-rs parses and accepts our presentations for every issuer in a four-Anchor set, and re-encodes them byte-identically
  • we parse and accept ihat-rs's, recovering the same nullifier and context
  • Anchor keys round-trip both ways
  • neither accepts a substituted accepted set

That converts the crate's worst remaining problem — no test vectors, so correctness unverifiable — into differential testing against the authors' own code. Stronger than vectors: it exercises both roles. New CI job runs it.

Two real defects this found in our own code

1. or_prove leaked the issuing Anchor. It branched on the secret index (if i == real_index { continue; }) — data-dependent control flow and memory access on exactly what issuer hiding conceals. ihat-rs is explicitly constant-time here ("no control flow depends on it"), recovering the honest base with a subtle select across the whole accepted set. Now rewritten to match.

2. Our Fiat–Shamir transcript followed the draft, which is weaker than the author's code. Draft: e = H(Y, Zhat, T1, T2, C). ihat-rs also binds and endorsement_context, commenting that this is "strengthened to bind the full statement: the rerandomised key X_hat — so a proof cannot be transported to a different X_hat". Now matched.

Also removed: a redundant 64-byte DLEQ proof per grant ((T1', T2', r') already is one — finalize checks Y'r' = Z'e'a' + T1' and Gr' = Xe'a' + T2'), and the invented m concatenation.

Adopted from ihat-rs

The four DSTs verbatim — MOLE-IHAT-P256:<use>:v1, which closes #2 outright — plus Y = H₁(nf), a context-bound Pedersen generator H(endorsement_context), the CDS OR-proof wire form, and two-byte big-endian VarBytes framing.

PR #39 contradicts the reference implementation in three places

Worth reporting upstream:

PR #39 ihat-rs
Point encoding uncompressed bytes[65] compressed 33 (agrees with protocols §4.1)
Pedersen generator global Hash2Curve("IHAT second generator") per-context H(endorsement_context)
OR proof O(log n) trapdoor commitment tree O(n) CDS

The one thing that cannot be reconciled

ihat-rs has no challenge_digest anywhere — not in a structure, not in a transcript, not in its API. Its verifier docs say a presentation "verifies every time". So protocols §4.1.3's two MUSTs about binding it are satisfied by no implementation, including the authors' own.

Rather than pick silently, ChallengeBinding exposes both sides:

Mode Binds digest Interoperates Satisfies §4.1.3
IhatRsCompatible ❌ — replayable
MoleBound (default)

Both tested, including that they don't cross-verify, and a test that asserts compat mode accepts an unrelated digest — so the deficiency is visible rather than latent.

Issues

Closed #2 (DSTs), #3 (hash), #7 (m doesn't exist). #1, #5, #6 narrowed to upstream contradictions. #8 is now the sharpest finding in the tracker. #10 largely superseded. #13 has the full before/after.

Correction

{{DLEQ}} resolves to draft-irtf-cfrg-sigma-protocols (§2.2.8 is the DLEQ statement), not RFC 9497 as I said earlier. Recorded on #4.

Verification

109 tests + 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on thumbv7em-none-eabi with and without the profile; MSRV 1.75 --locked. CI green, all four jobs: run 30394911541.

cargo doc caught a bare [DLEQ] citation during this work — the same failure class that reddened CI at the start of the day, which is why it stays in the check list.

Still not deployable

ihat-rs says of itself: "This code has not been audited." Interoperability is agreement, not correctness — if the construction is flawed, both implementations are flawed identically.

Why: the profile was validated in isolation and against ihat-rs, but the
complete-protocol test still ran entirely on insecure_mock. This proves the
profile plugs into MoLE rather than into a harness built around it.

tests/appendix_a_exp_ihat.rs walks the same exchange as appendix_a_flow.rs with
the endorsement half on exp_ihat: the WWW-Authenticate challenge, binding over
the decoded octets, both grant exchanges with their bodies carried inside real
EndorsementRequest/EndorsementResponse envelopes, redemption through
Moderator::redeem against a real accepted set, then the credential exchange and
update. Seven tests.

Two of them say something the mock version could not:

- the_moderator_cannot_tell_which_anchor_issued: every Anchor in the accepted
  set can issue, presentation size is invariant, and the Moderator ends up
  holding an epoch it already knew plus a nullifier. Issuer hiding asserted at
  the protocol level rather than inside the profile's own tests.
- the_accepted_set_order_is_load_bearing: a permutation of the accepted set
  does not verify, which is what makes §4.1.3's normative branch ordering real
  rather than decorative.

A deliberate behavioural difference worth noting: a redemption bound to another
challenge now fails with VerificationFailed, not the mock's ChallengeMismatch.
With real cryptography the Moderator genuinely cannot distinguish "wrong
challenge" from "invalid proof" — the OR proof just fails. That is the better
outcome, since §8 warns redeem's distinct error variants must not be surfaced
to Clients, and here there is nothing to surface.

Also strengthened the MSRV CI job from `cargo build` to `cargo test`. Clippy's
incompatible_msrv lint caught a `split_at_checked` (stable 1.80) in the new
test code; running the tests on 1.75 catches that class directly rather than
relying on a lint.

The credential half is still a stand-in token. That is now the only fake
cryptography left in the reachable protocol surface — Longfellow needs libzk
plus an out-of-band circuit, ACT is expiring upstream, and key rotation and the
config format are TODO. in the draft.

116 tests plus 4 interop. Clippy pedantic, rustdoc, fmt clean; no_std on
thumbv7em-none-eabi with and without the profile; MSRV 1.75 builds and tests
--locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OR13

OR13 commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Update: Appendix A now runs on real cryptography

tests/appendix_a_exp_ihat.rs walks the same exchange as appendix_a_flow.rs, but the endorsement half runs on exp_ihat instead of insecure_mock — and the grant bodies travel inside the real EndorsementRequest/EndorsementResponse envelopes rather than placeholders. Seven tests.

Two say something the mock version couldn't:

  • the_moderator_cannot_tell_which_anchor_issued — issuer hiding asserted at the protocol level: every Anchor in the accepted set can issue, presentation size is invariant to which one did, and what the Moderator ends up holding is an epoch it already knew plus a nullifier.
  • the_accepted_set_order_is_load_bearing — a permutation of the accepted set does not verify. That's what makes §4.1.3's normative branch ordering real rather than decorative.

One deliberate behavioural change

A redemption bound to another challenge now fails with VerificationFailed, not the mock's ChallengeMismatch. With real cryptography the Moderator genuinely cannot distinguish "wrong challenge" from "invalid proof" — the OR proof just fails. That's the better outcome: §8 warns that redeem's distinct error variants must not be surfaced to Clients, and here there's nothing to surface.

CI

Strengthened the MSRV job from cargo build to cargo test. Clippy's incompatible_msrv lint caught a split_at_checked (stable 1.80) in the new test code; running the tests on 1.75 catches that class directly instead of relying on a lint.

116 tests + 4 interop, all four jobs green: run 30396614290.

What's left

The credential half still uses a stand-in token. That is now the only fake cryptography in the reachable protocol surface — Longfellow needs libzk plus an out-of-band circuit, ACT is expiring upstream, and key rotation and the config format are TODO. in the draft.

Why: the credential half was the last fake cryptography in the reachable
surface. It is now real, and the std constraint I expected turned out to be
avoidable.

The boundary was in a better place than I thought. Section 5.2.1 has MoLE carry
TokenRequest and TokenResponse *opaquely* — it defines no structures for them,
and this crate already defines no wrappers. So the library needs no Privacy Pass
dependency at all; it needs exactly one thing it was missing, and the real
integration belongs where interop/ already lives. No `std` feature, no async in
the library, dependency count still two.

Library addition: credential::reverse_flow::Token, a parser for the RFC 9578
Token structure (token_type, nonce, challenge_digest, token_key_id,
authenticator), plus TOKEN_TYPES and authenticator_len. Structure only, no
cryptography, no_std. It exists because a Moderator cannot enforce either of
§5.2.2's two requirements — constant digest, unseen nonce — without reading
those fields, and until now it had no way to do that short of taking a full
Privacy Pass dependency. Five unit tests, including that a known token_type
with the wrong Nk is malformed, an unknown type with any non-empty
authenticator is accepted (a Moderator may be configured with a type this crate
predates), and that truncation never panics.

interop/tests/appendix_a_end_to_end.rs runs Appendix A with nothing mocked:
real IHAT endorsements over P-256 for the endorsement half, real RFC 9578
Privacy Pass via `privacypass` for the credential half — issuance, finalization,
presentation, nonce checks on both layers, and reissue of the update. Three
tests. privacypass is std-only with an async issuer API, which is exactly why it
lives out here and not in the library.

Found a fourth cross-document contradiction while doing it (#16). §5.2.2 says
the token's challenge_digest MUST equal the Moderator's constant
challenge_digest, cross-referencing §3.3 — MoLE's SHA-256(challenge octets),
which for this type is SHA-256(""). That is unsatisfiable: the token's
challenge_digest is computed by RFC 9578 as SHA-256(TokenChallenge), and an
RFC 9577 TokenChallenge always carries a token_type and a non-empty issuer_name,
so it can never digest to SHA-256(""). It would need a preimage. Two different
fields share one name and the MUST tries to equate them. The test takes the
workable reading — one fixed TokenChallenge per Moderator, constant across every
token it issues — which is the property the surrounding text actually asks for.
Nothing is weakened: §5.2.2 is explicit that anti-replay comes from the nonce
being single-use, and that is enforced and tested on both layers.

Library remains no_std on thumbv7em with sha2 and base64 only. 121 tests plus 7
in the detached package. Clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds
and tests --locked. CI's interop job now covers both the ihat-rs
cross-verification and the end-to-end run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OR13

OR13 commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Appendix A now runs with no mocks at all

The credential half was the last fake cryptography in the reachable surface. It's real now — and the std constraint turned out to be avoidable.

The boundary was in a better place than I thought

§5.2.1 has MoLE carry TokenRequest and TokenResponse opaquely — it defines no structures for them, and this crate already defined no wrappers. So the library needs no Privacy Pass dependency at all. It needed exactly one thing it was missing, and the real integration belongs where interop/ already lives.

No std feature. No async in the library. Dependency count still two (sha2, base64), still no_std on thumbv7em.

Library addition

credential::reverse_flow::Token — a parser for the RFC 9578 Token structure (token_type, nonce, challenge_digest, token_key_id, authenticator), plus TOKEN_TYPES and authenticator_len. Structure only, no cryptography.

It's there because a Moderator cannot enforce either of §5.2.2's two requirements — constant digest, unseen nonce — without reading those fields, and until now it had no way to do that short of taking a full Privacy Pass dependency. Five unit tests, including that a known token_type with the wrong Nk is malformed, that an unknown type with any non-empty authenticator is accepted (a Moderator may be configured with a type this crate predates), and that truncation never panics.

End to end, nothing mocked

interop/tests/appendix_a_end_to_end.rs: real IHAT endorsements over P-256, real RFC 9578 Privacy Pass via privacypass — issuance, finalization, presentation, nonce checks on both layers, and reissue of the update. Three tests, including that a real token cannot be spent twice and that mole's parser and privacypass's serializer agree byte-for-byte on the Token layout.

privacypass is std-only with an async issuer API, which is precisely why it lives out there.

A fourth cross-document contradiction — #16

Found by doing this; it doesn't surface any other way.

§5.2.2 says the token's challenge_digest MUST equal the Moderator's constant challenge_digest, cross-referencing §3.3 — MoLE's SHA-256(challenge octets), which for this type is SHA-256("").

That is unsatisfiable. The token's challenge_digest is computed by RFC 9578 as SHA-256(TokenChallenge), and an RFC 9577 TokenChallenge always carries a token_type and a non-empty issuer_name — so it can never digest to SHA-256(""). It would need a preimage. Two different fields share one name and the MUST tries to equate them.

The test takes the workable reading: one fixed TokenChallenge per Moderator, constant across every token it issues — which is the property the surrounding text actually asks for ("This binds the token to the Moderator, not to the exchange that presents it"). Nothing is weakened; §5.2.2 is explicit that anti-replay comes from the nonce being single-use, and that's enforced and tested on both layers.

This is the cheapest of the upstream findings to fix — a cross-reference removal and one sentence.

Verification

121 tests + 7 in the detached package. Clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and tests --locked; library no_std on thumbv7em with and without the profile. All four CI jobs green: run 30397603087.

Why: insecure_mock existed for one reason — this crate had no real
cryptography. It now has real, interoperable cryptography, so 417 lines of
deliberately-insecure code plus a 396-line duplicate Appendix A walk are dead
weight that every reader has to be warned about.

Deleted src/crypto/insecure_mock.rs, the insecure-mock feature, and the
duplicate tests/appendix_a_flow.rs. Renamed the real-crypto Appendix A test
into its place: there is one Appendix A walk now, and it runs on exp_ihat.

Test count drops 121 -> 111 while coverage goes up, because the tests that went
away were exercising the same protocol paths against a mock.

Documentation: removed material that had accumulated as this work progressed
and is now archaeology.

- My own revision history ("an earlier revision of this profile branched on the
  index", "64 wasted bytes per grant", before/after comparison tables). Git has
  it; reference docs should state what is true, not how it got that way.
- PR #39 forensics. What matters is that the profile follows ihat-rs and where
  that differs from the drafts — not a running commentary on an unmerged PR. The
  three places PR #39 contradicts the reference implementation are recorded in
  the issues, which is where they belong.
- The README's 45-line account of [CRYPTO]'s exact publication state, compressed
  to the four sentences a reader needs.
- The insecure_mock comparison table, which compared against something that no
  longer exists.

Reframed the status tables, which were the actual source of confusion. Rows for
Longfellow, ACT, Budget Privacy Pass and key rotation rendered as ❌, which reads
as a backlog. They are not: MoLE specifies several endorsement and credential
protocols to demonstrate agility and a deployment picks one of each. This crate
implements IHAT and Privacy Pass Reverse Flow. For the others the wire
structures, registries and dispatch are implemented and tested — only the
cryptography is external, and Longfellow's and ACT's underlying drafts are not
in a state to build against. New legend: ⬜ for that, ⛔ for sections that are
literally TODO. upstream.

Kept one substantive note: ACT is the only credential protocol with bound
updates, which Reverse Flow cannot provide (§5.2.3). That is a property of the
protocol chosen here, not a task.

Docs shrank from 716 to 561 lines. Library unchanged: no_std on
thumbv7em-none-eabi, two dependencies, one optional feature. 111 tests plus 7 in
the detached package; clippy pedantic, rustdoc, fmt clean; MSRV 1.75 builds and
tests --locked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@OR13
OR13 marked this pull request as ready for review July 28, 2026 21:04
@OR13
OR13 merged commit ddb0d62 into main Jul 28, 2026
4 checks passed
@OR13
OR13 deleted the exp-ihat branch July 28, 2026 21:04
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