Skip to content

VER-001: Non-standard 0x29 detached Falcon signatures are accepted - #2

Draft
gnosed wants to merge 5 commits into
mainfrom
audit/ver-001-nonstandard-0x29-signatures
Draft

gnosed wants to merge 5 commits into
mainfrom
audit/ver-001-nonstandard-0x29-signatures

Conversation

@gnosed

@gnosed gnosed commented Aug 19, 2026 •

Copy link
Copy Markdown
Contributor

Description

The verifier accepts both 0x29 and 0x39 as headers for the same detached signature layout:

header || nonce[40] || compressed(s₂)

After checking that the high nibble is either 0x2X or 0x3X, the verifier always extracts bytes 1–40 as the nonce and invokes the compressed polynomial decoder. The selected header family therefore has no effect on framing or decoding.

This conflates two distinct formats defined by Falcon:

  • 0x39 identifies a Falcon-512 compressed signature containing the nonce. Both natural-length and 666-byte padded signatures use this header.
  • 0x59 identifies the alternate fixed-width/constant-time encoding and requires a different decoder.
  • 0x29 is used only for the nonce-less signature tail inside the NIST crypto_sign signed-message envelope.

Consequently, the local representation 0x29 || nonce || compressed(s₂) is a non-standard hybrid format.

The comments claiming that signers disagree about the meanings of these nibbles are inaccurate. The Falcon specification, reference C implementation, PQClean, and the Python signer agree on the relevant conventions:

  • Detached compressed and padded Falcon-512 signatures use 0x39.
  • The CT/fixed-width representation uses 0x59.
  • 0x29 belongs to the nonce-less tail of the NIST signed-message envelope.

The KAT tests currently create the non-standard hybrid by extracting the nonce from the NIST envelope while preserving its 0x29 nonce-less header. A correct conversion to detached form would replace 0x29 with 0x39.

Impact

An attacker can change the first byte of a valid 0x39 signature to 0x29 without invalidating it. This produces multiple accepted byte encodings for the same signature and message.

This does not appear to enable a signature over a new message or public key. However, it has the following consequences:

  • The verifier accepts signatures that do not conform to either defined Falcon framing.
  • Signature bytes are malleable, which can affect systems that hash, identify, deduplicate, cache, or route signatures using their serialized representation.
  • The permissive behavior can cause interoperability differences with strict Falcon implementations, whose detached verifiers reject 0x29.
  • It creates ambiguity for future format dispatch, particularly if support for the 0x59 fixed-width encoding is added.
  • Documentation incorrectly treats the behavior as necessary for signer interoperability.

Recommendation

Require 0x39 for every compressed detached Falcon-512 signature:

if signature[0] != 0x39 {
    return false;
}

Continue determining natural versus padded compressed form from decoder consumption and length:

  • Accept exact consumption for a natural-length compressed signature.
  • Accept trailing zeros only when the total signature length is exactly 666 bytes.
  • Do not use 0x29 to identify padded signatures; padded signatures also use 0x39.

Developers Response

The developers have been notified of the issue, but not yet provided a response.

gnosed added 5 commits August 19, 2026 21:15
Records the Veridise finding that the verifier accepts both 0x29 and 0x39
for the same detached layout, and the agreed remediation (require 0x39,
fix the two inverted comment blocks, rewrite the KAT conversion).

This finding overturns AUD-002, which closed the identical issue as
'Accepted -- required for interop' on the premise that falcon-wasm emits
0x29 for padded signatures. Verified against the vendored signer: it emits
0x39 for both sign() and signPadded(), and never 0x29.

Tracking stub only; no verifier change lands here.
Severity Medium, likelihood Likely, impact Bad (Veridise issue #1292).
…natures

The header gate accepted both 0x2X and 0x3X high nibbles, so flipping a
valid signature's 0x39 header to 0x29 kept it valid (audit finding, PR #2).
Per the Falcon Round-3 spec and every conforming signer (reference C,
PQClean, falcon.py, falcon-wasm), detached compressed signatures — natural
and 666-byte padded alike — use 0x39; 0x29 only labels the nonce-less tail
inside the NIST crypto_sign envelope, and 0x59 (CT) needs a different
decoder. The earlier interop rationale for accepting 0x29 was wrong: the
project's own e2e receipts record 0x39 for falcon-wasm's padded output.

- pin the header to exactly 0x39 = 0x30 | logn in verify_512; natural vs
  padded form is still decided by decoder consumption + total length
- KAT suites now convert the NIST envelope to detached form by replacing
  the envelope's 0x29 with 0x39 (asserting the envelope header) instead
  of preserving it, and pin sig[0] == 0x39
- add test_envelope_header_0x29_rejected regression tests (core unit +
  KAT-based: header flipped to 0x29/0x59/0x31/0x38 must be rejected)
- correct the format story in module docs, web-demo comments, threat
  model, and the CT-analysis standalone copy
- remediation log: register audit finding as EXT-001 (Fixed); close
  AUD-002 as superseded (its 'required for interop' acceptance was
  factually wrong)
Brings the auditor's VER-001 report (non-standard 0x29 detached
signatures) onto the fix branch so the finding and its remediation
travel together.
… Veridise report

Tick the VER-001 remediation checklist and record the developers'
response in the finding doc; rename the log row EXT-001 -> VER-001,
adopt the auditor's Medium severity and 2026-08-19 report date, and
cross-link the finding write-up.
@gnosed
gnosed force-pushed the audit/ver-001-nonstandard-0x29-signatures branch from a587ccb to ec54f5c Compare August 25, 2026 09:10
gnosed added a commit that referenced this pull request Aug 26, 2026
…natures

The header gate accepted both 0x2X and 0x3X high nibbles, so flipping a
valid signature's 0x39 header to 0x29 kept it valid (audit finding, PR #2).
Per the Falcon Round-3 spec and every conforming signer (reference C,
PQClean, falcon.py, falcon-wasm), detached compressed signatures — natural
and 666-byte padded alike — use 0x39; 0x29 only labels the nonce-less tail
inside the NIST crypto_sign envelope, and 0x59 (CT) needs a different
decoder. The earlier interop rationale for accepting 0x29 was wrong: the
project's own e2e receipts record 0x39 for falcon-wasm's padded output.

- pin the header to exactly 0x39 = 0x30 | logn in verify_512; natural vs
  padded form is still decided by decoder consumption + total length
- KAT suites now convert the NIST envelope to detached form by replacing
  the envelope's 0x29 with 0x39 (asserting the envelope header) instead
  of preserving it, and pin sig[0] == 0x39
- add test_envelope_header_0x29_rejected regression tests (core unit +
  KAT-based: header flipped to 0x29/0x59/0x31/0x38 must be rejected)
- correct the format story in module docs, web-demo comments, threat
  model, and the CT-analysis standalone copy
- remediation log: register audit finding as EXT-001 (Fixed); close
  AUD-002 as superseded (its 'required for interop' acceptance was
  factually wrong)
gnosed added a commit that referenced this pull request Aug 26, 2026
Brings the auditor's VER-001 report (non-standard 0x29 detached
signatures) onto the fix branch so the finding and its remediation
travel together.
@gnosed
gnosed force-pushed the audit/ver-001-nonstandard-0x29-signatures branch 3 times, most recently from 6e176e9 to ec54f5c Compare August 26, 2026 11:14
@HusseinAdeiza

Copy link
Copy Markdown

Confirmed the core of VER-001 against main independently, and the mechanism is exactly as you describe. The binding is dead code in the strict sense: fmt is bound at verify.rs:117 and never read again anywhere in the file, so the accepted family has zero effect on framing or decoding.

let fmt = sig_header & 0xF0;
if fmt != 0x20 && fmt != 0x30 {
    return false;
}

followed unconditionally by the fixed &signature[1..41] nonce extraction at Step 4 and the compressed decoder at Step 5. So a 0x39 body re-headered to 0x29 decodes to the same s_2, and the later canonicity check on the body is what actually decides acceptance. That is the malleability, and it follows directly from the family being advisory rather than load-bearing.

I also checked the test at verify.rs:431 and it is consistent with your reading rather than contradictory to it. test_message_too_long_rejected sets sig[0] = 0x29 on a zeroed buffer only to get past the header check, and test_ct_format_rejected_by_size_gate covers 0x59. Neither pins the 0x29/0x39 equivalence, so nothing currently fails if you make the family strict, which means the KAT harness you are updating is the only place that will need to agree with the new behaviour.

One thing I would check while you are in here, since it is adjacent to the framing change and cheap to get wrong. test_ct_format_rejected_by_size_gate documents that 0x5X is rejected by the size gate because 809 > FALCON_SIG_MAX_SIZE = 666. If the framing fix tightens the family check to 0x39 only, that test still passes but stops testing what its name claims, because the family gate would now be rejecting it rather than the size gate. Worth either renaming it or leaving a comment saying which gate is load-bearing after the change, otherwise the next person to relax the size limit inherits a test that no longer proves what it says.

I am not asking for anything to be added to the PR. I read the code, confirmed the finding, and this is a heads-up on a test that your change will quietly make a misnomer.

For the record on my side: I looked for an unclaimed issue to pick up here and there is nothing in this repo. The only open issue in the org is pqchain#4, which is assigned, and these five audit PRs are gnosed remediating Veridise findings on their own audit package. So this is a comment, not a competing contribution.

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.

2 participants