Skip to content

NUT-11: typed, length-framed SIG_ALL message over output hashes - #404

Open
robwoodgate wants to merge 9 commits into
mainfrom
nut11-sigall-v1
Open

robwoodgate wants to merge 9 commits into
mainfrom
nut11-sigall-v1

Conversation

@robwoodgate

@robwoodgate robwoodgate commented Jul 17, 2026 •

Copy link
Copy Markdown
Collaborator

This PR re-encodes the NUT-11 SIG_ALL message as typed records using the length-framed field encoding of NUT-20/29, hashed with a BIP-340 tagged hash as in #443, and adds canonical test vectors for NUT-11 and NUT-14.

h_j      = SHA256(B_j)

input_i  = 0x11 || len32(secret_i) || secret_i || len32(C_i) || C_i
output_j = 0x21 || len32(amount_j) || amount_j || len32(h_j) || h_j
melt     = 0x22 || len32(quote)    || quote

message = input_0 || ... || input_n
          || output_0 || ... || output_m
          || melt                            (melt transactions only)

message_hash = tagged_hash("Cashu_SigAllSig_v1", message)

Notes:

  • Outputs commit h_j = SHA256(B_j), not the blinded point, so a signer can approve outputs from their amounts and hashes alone: the party that will unblind them can keep B_ to itself until submission.
  • The record type byte makes every record self-describing, so inputs, outputs and the melt quote cannot be read as one another, and a swap (no melt record) cannot collide with a melt. The type codes follow the input, output and melt quote containers of the NUT-10: Nutroot secrets (v3 keysets) #443 transaction transcript.
  • Amounts are canonical minimal big-endian bytes and points are raw bytes, as in NUT-20/29. The output keyset id remains unbound, also as in NUT-20/29: it is already pinned by mint invariants (outputs from active keysets only, one unit per transaction), and leaving it out keeps pre-signed transactions valid across keyset rotation.
  • A normative signature validation rule makes the transition workable: mints MUST ignore signatures that do not verify and MUST count unique public keys with valid signatures towards thresholds. Nutshell and CDK already behave this way, so wallets can sign both the new and the legacy message in one witness while mints upgrade, and each mint drops the legacy format when it chooses.
  • Canonical SIG_ALL vectors in tests/11-test.md use real proofs and outputs under a derived keyset, signed by the well-known test key. tests/14-test.md adds the NUT-14 vectors (preimage, keyless hashlock and an HTLC SIG_ALL case). They are pinned byte-for-byte in the implementation PRs above.

History

Comment thread 11.md
Comment thread 11.md
@robwoodgate

robwoodgate commented Aug 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Here is a worked collision under the old unframed transcript that length-prefixing removes

The old transcript concatenates str(amount) || B_ per output with no delimite: a leading digit of one amount can migrate across the boundary into the neighbouring blinded point. Both points below are valid compressed curve points.

Two output allocations, both summing to 205:

  • approved: [(2, 0202c96265458f975a9389e7ff3fd6d11052cec5076711adad0664b2404a112286), (203, 038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39)]
  • reallocated: [(202, 02c96265458f975a9389e7ff3fd6d11052cec5076711adad0664b2404a11228620), (3, 038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39)]

Both produce the byte-identical output transcript:

20202c96265458f975a9389e7ff3fd6d11052cec5076711adad0664b2404a112286203038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39

So a single signature over the approved allocation also authorises the reallocation, moving value to a different blinded output while conservation still holds. Under the v1 framing in this PR (len32(amount) || amount || len32(B_) || B_) the two allocations produce different messages, so the collision goes away.

Note this specific example uses non-power-of-2 amounts; against a mint that only issues power-of-2 denominations I could not construct a collision (searched the two-output case up to 2^64), but denominations are not mandated by the protocol, so the framing closes it regardless.

KvngMikey added a commit to KvngMikey/nuts that referenced this pull request Aug 11, 2026
The digests object is computable from the rest of the package, so
carrying it added nothing a signer could not derive itself. Worse, a
signer that trusts a supplied digest signs a message it has never
checked against the transaction it is approving.

The legacy entry hashed the pre-amount-binding format (secret||C||B_),
which NUT-11 no longer specifies. Without amount binding a signature
commits to which blinded outputs exist but not to their values, letting
amounts be reordered across outputs while the signature and the mint's
balance check both still pass. Carrying that digest gave a superseded
format renewed standing.

The current entry hashed today's aggregation format, a label that goes
stale the moment the message format changes (see cashubtc#404). Dropping both
leaves the package independent of any aggregation scheme.

Adds a normative requirement that signers derive the message to sign
from the package contents themselves, and regenerates the sigallA
vector without digests.

Addresses review feedback from @robwoodgate. Serialized vector matches
the one pinned in cashubtc/cashu-ts#947.
robwoodgate added a commit to robwoodgate/nutshell that referenced this pull request Aug 11, 2026
Wallets sign v1 and current; the pre-0.21 message (secrets then B_
fields) is no longer produced, as it does not commit to C values or
output amounts (cashubtc/nuts#404). Mint verification is unchanged.
robwoodgate added a commit to robwoodgate/cdk that referenced this pull request Aug 11, 2026
Wallets sign v1 and current; the pre-0.14 message (secrets then B_
values) is no longer produced, as it does not commit to C values or
output amounts (cashubtc/nuts#404). Mint verification is unchanged.
KvngMikey added a commit to KvngMikey/nuts that referenced this pull request Aug 12, 2026
The digests object is computable from the rest of the package, so
carrying it added nothing a signer could not derive itself. Worse, a
signer that trusts a supplied digest signs a message it has never
checked against the transaction it is approving.

The legacy entry hashed the pre-amount-binding format (secret||C||B_),
which NUT-11 no longer specifies. Without amount binding a signature
commits to which blinded outputs exist but not to their values, letting
amounts be reordered across outputs while the signature and the mint's
balance check both still pass. Carrying that digest gave a superseded
format renewed standing.

The current entry hashed today's aggregation format, a label that goes
stale the moment the message format changes (see cashubtc#404). Dropping both
leaves the package independent of any aggregation scheme.

Adds a normative requirement that signers derive the message to sign
from the package contents themselves, and regenerates the sigallA
vector without digests.

Also clarifies the witness encoding. NUT-11 defines Proof.witness as a
serialized JSON string, but the package schema showed witness as a JSON
object without saying which representation applied. An implementer
could serialize it before putting it in the package, or pass the
package's object straight into Proof.witness; both parse as valid JSON
and fail only later. The object form is correct and is what the vector
already encodes, so this documents the field rather than changing it.
The accompanying note also states that inputs[].secret keeps the
representation it has in Proof.secret, since documenting only witness
invites the opposite mistake.

Addresses review feedback from @robwoodgate and Copilot. Serialized
vector matches the one pinned in cashubtc/cashu-ts#947.
robwoodgate added a commit to robwoodgate/cdk that referenced this pull request Aug 18, 2026
Wallets sign v1 and current; the pre-0.14 message (secrets then B_
values) is no longer produced, as it does not commit to C values or
output amounts (cashubtc/nuts#404). Mint verification is unchanged.
robwoodgate added a commit to robwoodgate/nutshell that referenced this pull request Sep 9, 2026
NUT-11 (cashubtc/nuts#404) now signs tagged_hash("Cashu_SigAllSig_v1", message) rather than a plain SHA-256 of a tag-prefixed message. Accepted SIG_ALL formats are carried as 32-byte digests and signed and verified directly; canonical vectors regenerated.
robwoodgate added a commit to cashubtc/cashu-ts that referenced this pull request Sep 9, 2026
…er its BIP-340 tagged hash

NUT-11 (cashubtc/nuts#404) now signs tagged_hash("Cashu_SigAllSig_v1", message) rather than a plain SHA-256 of a tag-prefixed message. The accepted-format list carries prehashed digests: MessageInput gains { digest } so the tagged hash flows through the existing sign and verify helpers. Canonical NUT-11 and NUT-14 vectors regenerated.
robwoodgate added a commit to robwoodgate/cdk that referenced this pull request Sep 9, 2026
NUT-11 (cashubtc/nuts#404) now signs tagged_hash("Cashu_SigAllSig_v1", message) rather than a plain SHA-256 of a tag-prefixed message. Accepted SIG_ALL formats are carried as 32-byte digests and signed and verified directly via sign_digest/verify_digest; canonical vectors regenerated.
@robwoodgate
robwoodgate added this pull request to stack #441 September 9, 2026 19:27
Re-encodes the SIG_ALL message to the domain-separated, length-framed
byte format used by NUT-20/29 (tag "Cashu_SigAllSig_v1"). The quote id
is framed directly after the tag and is empty for swaps, so one layout
serves both transaction types. Adds a normative signature validation
rule (ignore signatures that do not verify, count unique public keys)
and canonical message vectors in tests/11-test.md.
Removes the superseded string-concatenation SIG_ALL vectors so the
spec carries one canonical message aggregation, retitles the
length-framed vector set, and notes why the output keyset id is not
part of the message.
The SIG_ALL example witness now carries one signature per accepted
format, matching what wallets produce.
robwoodgate added a commit to cashubtc/cashu-ts that referenced this pull request Sep 9, 2026
# NUTS:

- cashubtc/nuts#404

The P2PK and HTLC signing and verification functions now take the
32-byte digest that is signed instead of a message string, so every
SIG_ALL format hashes in one place and the signing path never sees a
message.

## Why

The `message?: string` parameter on `signP2PKProofs`,
`hasP2PKSignedProof`, `verifyP2PKSpendingConditions` and the HTLC
wrappers exists only for SIG_ALL, where the signed value is not the
proof secret. None of those functions read the message: they resolve
keys from the secret and hand the message to the Schnorr helpers, which
SHA-256 it. That ties the API to one specific derivation. The framed
[NUT-11](https://github.com/cashubtc/nuts/blob/main/11.md) SIG_ALL
format signs a BIP-340 tagged hash rather than a plain SHA-256, so it
cannot pass through a string parameter at all. `signMintQuote` already
works digest-first for the same reason.

## Changes

- `signP2PKProofs`, `signP2PKProof`, `hasP2PKSignedProof`,
`verifyP2PKSpendingConditions`, `isP2PKSpendAuthorised`,
`verifyHTLCSpendingConditions`, `isHTLCSpendAuthorised`,
`getValidSigners` and `meetsSignerThreshold` take `digest?: DigestInput`
(the existing hex-or-bytes type of `schnorrSignDigest`). With no digest
they hash the proof secret, so SIG_INPUTS behaviour is unchanged.
- `Wallet.signP2PKProofs` passes `computeMessageDigest(v0Message)`; the
v0 message builder itself is untouched.
- Migration entry added to `migration-5.0.0.md`.

## Impact

Breaking for callers that pass a SIG_ALL message string to the functions
above, which is the mint-side use of this library; they hash the message
first. No wire or storage change. Not for backport to v4.
@ye0man ye0man added breaking change This issue/PR entails breaking changes needs review labels Sep 29, 2026
Each input, output and melt quote is now a typed record of
length-framed fields, and outputs commit SHA256(B_) in place of the
blinded point, so a signer can approve outputs from their amounts and
hashes alone. A swap has no melt record.

Vectors are regenerated from real proofs and outputs under a derived
keyset, and the HTLC SIG_ALL vector moves here from the NUT-14 vectors
so every vector on this message lives in one file.
@robwoodgate robwoodgate changed the title NUT-11: length-frame the SIG_ALL message aggregation NUT-11: typed, length-framed SIG_ALL message over output hashes Oct 16, 2026
robwoodgate and others added 4 commits October 16, 2026 16:53
Preimage and signature, and keyless hashlock cases. The HTLC SIG_ALL
vector lives with the NUT-11 SIG_ALL vectors.
Moves the vector from tests/11-test.md into tests/14-test.md, now that
the NUT-14 vectors are part of this PR.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking change This issue/PR entails breaking changes existing nut needs review

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

3 participants