Skip to content

feat(s139): complete slice 139 (the N6 single-signer carrier, Ed25519 on commercial; the receipt verifier answers in FIPS mode) - #135

Merged
HackTuah merged 2 commits into
mainfrom
slice/139-n6-carrier-ed25519
Oct 20, 2026
Merged

HackTuah merged 2 commits into
mainfrom
slice/139-n6-carrier-ed25519

Conversation

@HackTuah

Copy link
Copy Markdown
Member

Slice 139. Two additions to the N6 layer that a single signer needs, and a fix for receipt verification on a runtime that refuses a row's algorithm. Nothing Trinity signs changes: no receipt, checkpoint or fingerprint covers different bytes after this PR.

The N6 additions follow the profile publisher's answers of 2026-10-19 to questions on draft r3. A later draft will carry those answers, so both additions are provisional, like the rest of the layer.

What it does

  • The single-signer carrier (Trinity.N6.Carrier).
    • Shape: {0: SINGLE_SCHEMA, 1: payload, 2: [protected, {}, nil, signature]}. The Sign1 is untagged and its payload is detached, the form the twin envelope uses for each of its two signatures.
    • Anything else is refused with a named code.
    • A typ whose profile entry needs two signers is refused before its signature is checked (wrong_carrier_for_typ). Each half of a twin is a valid single signature on its own, so accepting one would accept half a statement as the whole.
    • SINGLE_SCHEMA is a placeholder, 0xFFFF0003, until the profile assigns the number (Trinity.N6.Profile.schemas/0).
  • Ed25519 (-19) on the commercial profile (Trinity.N6.COSE).
    • A verifier says which entry point it is, :nss (the default) or :commercial. Only :commercial admits -19.
    • The profile table binds each typ to its one algorithm, and a header with any other algorithm is refused.
    • The key id is the RFC 9679 SHA-384 thumbprint over the OKP members ({1: 1, -1: 6, -2: x}), which do not include the algorithm. So a verifier takes each key together with the algorithm it was registered under, and that must match the header's.
    • Signing and verifying go through :crypto. FIPS mode refuses Ed25519, and there both answer "no trust" (exit 5), not a crash.
  • Receipt verification answers when the runtime cannot check a signature.
    • Before this PR, a chain signed under an algorithm the runtime refuses (Ed25519 on a FIPS node, ML-DSA-87 on OpenSSL 3.0) made :crypto.verify raise. The verify task, the receipts page, a chain merge, bin/verify_receipt.exs, and a chain writer starting over such a checkpoint all crashed.
    • They now answer exit 5, trust not established, naming the row and the algorithm.
    • :crypto raises the same error shapes for a registered public key it cannot use. So the verifier first asks whether the runtime does the algorithm at all, and only then says whether the algorithm or the key was the problem (Trinity.Receipts.Signer.check/4).

Vectors

The candidate vector set gains:

  • the Ed25519 thumbprint of RFC 8032's published test key;
  • header vectors for -19;
  • carrier samples in the profile's cose/sign1/ layout, each with its .expect and a .ctx.json naming the entry point and keys.

The signed sample is Ed25519, which is deterministic, so its signature is a literal: the generator runs where Ed25519 is refused, and the suite checks that signing reproduces the literal wherever Ed25519 is available. The set is now 384 files, 86,749 bytes. The SHA-384 of SHA384SUMS is 14c666b3efcc9586782d4dbf0205ede7321f32b804313ef02d8415dcf5601230b201f64a96c90f7dfb68085b28257518.

test/fixtures/receipts/ holds exported chains signed under published test keys, with Ed25519 and with ML-DSA-87 (scripts/receipt_foreign_chains.exs).

Evidence

  • Red first. On OTP 28.5.0.6 (OpenSSL 3.0), before the fix, Verifier.verify/2 raised badarg "Bad digest type" on both ML-DSA-87 chains, and starting the chain writer returned the same exception as its error. After the fix: exit 5, alg_unavailable.
  • Both runtimes.
    • On OTP 28.5.0.8 with OpenSSL 3.5.9, the same chains verify (exit 0).
    • bin/verify_receipt.exs gives the same exit as the in-app verifier on every chain, and on one with a malformed key.
  • Planted faults. Each was restored and compared byte for byte afterwards, and each turned only its own tests red:
    1. no binding of typ to its algorithm;
    2. Ed25519 admitted at nss;
    3. any typ admitted in the carrier;
    4. a refused algorithm told apart from a bad key by the error alone;
    5. a key's registered algorithm ignored.
  • The planted registration fault at first changed no test. The two algorithms' keys differ in size, and the size check refused every mismatch the tests made. A test now covers the one case where the check alone decides: an Ed25519 key registered as ML-DSA-87.
  • The thumbprint, outside Elixir. sha384sum over a3 01 01 20 06 21 58 20 followed by the 32 key bytes gives the vector's kid.

CI

The fips job's mode-off step also runs the new receipt-chain tests, since that step is the only place in CI where ML-DSA-87 verifies. In FIPS mode, and on the gate runners, those tests take the no-trust branch, and each run prints which branch it took.

Not in this PR

  • Receipts written as N6 statements: the next slice.
  • An algorithm for FIPS-mode nodes in N6, statement_id for single signers, the twin envelope, the note form and the hash envelope.

… on commercial; the receipt verifier answers in FIPS mode)

Signed-off-by: Ayla Croft <aylacroft@proton.me>
…the body test does not depend on load order

Signed-off-by: Ayla Croft <aylacroft@proton.me>
@HackTuah
HackTuah merged commit f1ad5d1 into main Oct 20, 2026
12 checks passed
@HackTuah
HackTuah deleted the slice/139-n6-carrier-ed25519 branch October 20, 2026 13:00
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