keyring: thread a FIPS-204 signing context through pair.sign() #5

Closed
opened 2026-09-10 10:13:20 +00:00 by grenade · 1 comment
Owner

Depends on #2. The caller side is quantus/extension#4.

Why a context at all

ML-DSA hashes a domain-separation context into the signature, so a signature made under one context does not verify under another — same key, same message, different answer. Quantus uses that deliberately: extrinsics are signed under "QUANTUS_EXTRINSIC", while litep2p node-identity signatures stay on the empty context so mixed-version Noise handshakes keep working. chain:primitives/dilithium-crypto/src/signing_context.rs spells out the reasoning.

There is a version boundary. Spec ≥ 148 verifies under "QUANTUS_EXTRINSIC"; earlier specs use the empty context. The SDK encodes this as:

pub fn context_for_spec(spec_version: u32) -> Option<&'static [u8]> {
    (spec_version >= EXTRINSIC_MIN_SPEC).then_some(EXTRINSIC)
}

The API problem

KeyringPair.sign(message, options) currently takes { withType?: boolean }. The context is a third thing the caller must supply, it is not derivable from anything the keyring knows, and getting it wrong produces a signature that is cryptographically valid and rejected by the chain — the worst failure shape available, because nothing local can detect it.

So: extend the options type with a context, and do not give it a default that silently works. A Quantus pair asked to sign with no context stated should either refuse, or use the empty context and be documented as doing so — but it must not quietly assume "QUANTUS_EXTRINSIC", because then the spec-147-and-earlier case becomes undiagnosable.

Note that only the caller knows the spec version: it arrives in the SignerPayloadJSON the dapp sends. The keyring's job is to carry the context faithfully, not to guess it.

Acceptance

  • a signature made under "QUANTUS_EXTRINSIC" verifies with the runtime's verify_ml_dsa_65 and fails under the empty context
  • the context is length-checked at ≤ 255 bytes, as FIPS 204 requires
  • non-Quantus pairs ignore the option entirely and produce byte-identical output to upstream
Depends on #2. The caller side is quantus/extension#4. ## Why a context at all ML-DSA hashes a domain-separation context into the signature, so a signature made under one context does not verify under another — same key, same message, different answer. Quantus uses that deliberately: extrinsics are signed under `"QUANTUS_EXTRINSIC"`, while litep2p node-identity signatures stay on the empty context so mixed-version Noise handshakes keep working. `chain:primitives/dilithium-crypto/src/signing_context.rs` spells out the reasoning. There is a version boundary. **Spec ≥ 148 verifies under `"QUANTUS_EXTRINSIC"`; earlier specs use the empty context.** The SDK encodes this as: ```rust pub fn context_for_spec(spec_version: u32) -> Option<&'static [u8]> { (spec_version >= EXTRINSIC_MIN_SPEC).then_some(EXTRINSIC) } ``` ## The API problem `KeyringPair.sign(message, options)` currently takes `{ withType?: boolean }`. The context is a third thing the caller must supply, it is not derivable from anything the keyring knows, and getting it wrong produces a signature that is cryptographically valid and rejected by the chain — the worst failure shape available, because nothing local can detect it. So: extend the options type with a context, and **do not give it a default that silently works**. A Quantus pair asked to sign with no context stated should either refuse, or use the empty context and be documented as doing so — but it must not quietly assume `"QUANTUS_EXTRINSIC"`, because then the spec-147-and-earlier case becomes undiagnosable. Note that only the caller knows the spec version: it arrives in the `SignerPayloadJSON` the dapp sends. The keyring's job is to carry the context faithfully, not to guess it. ## Acceptance - [ ] a signature made under `"QUANTUS_EXTRINSIC"` verifies with the runtime's `verify_ml_dsa_65` and fails under the empty context - [ ] the context is length-checked at ≤ 255 bytes, as FIPS 204 requires - [ ] non-Quantus pairs ignore the option entirely and produce byte-identical output to upstream
Author
Owner

Verified against main (7f23d0a60), closing.

  • Context has no default: SignOptions.context exists, and an ML-DSA pair refuses to sign without one (pair/index.ts, with a spec).
  • Extension supplies it: the extension passes contextForSpec(specVersion), and quantus/extension#4 showed chain acceptance under spec 148 and rejection under the empty context.
  • 255-byte limit: enforced and tested in @quantus/crypto.
  • Curve pairs: their context-less wrappers drop the argument.

Small gaps, not worth holding this open for:

  • the context test here covers ML-DSA-87 only;
  • nothing here signs a curve pair with a context and compares the bytes.

The curve pairs go away in quantus/common#6 anyway.

Verified against `main` (`7f23d0a60`), closing. - **Context has no default:** `SignOptions.context` exists, and an ML-DSA pair refuses to sign without one (`pair/index.ts`, with a spec). - **Extension supplies it:** the extension passes `contextForSpec(specVersion)`, and quantus/extension#4 showed chain acceptance under spec 148 and rejection under the empty context. - **255-byte limit:** enforced and tested in `@quantus/crypto`. - **Curve pairs:** their context-less wrappers drop the argument. **Small gaps, not worth holding this open for:** - the context test here covers ML-DSA-87 only; - nothing here signs a curve pair with a context and compares the bytes. The curve pairs go away in quantus/common#6 anyway.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#5