Skip to content

NUT-11: length-frame the SIG_ALL message aggregation - #404

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

NUT-11: length-frame the SIG_ALL message aggregation#404
robwoodgate wants to merge 3 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 aggregation to the domain-separated, length-framed byte format already used by NUT-20/29, and adds canonical test vectors.

msg_to_sign = b"Cashu_SigAllSig_v1"
              || len32(quote) || quote
              || for each input i (in request order):
                   len32(secret_i) || secret_i
                   || len32(C_i)   || C_i
              || for each output j (in request order):
                   len32(amount_j) || amount_j
                   || len32(B_j)   || B_j

Notes:

  • The quote id is framed directly after the tag and is empty for a swap (len32(quote) = 0), so one layout serves both transaction types and a swap message can never collide with a melt message: the first framed field is always the quote, and a real quote id is never empty.
  • Amounts are canonical minimal big-endian bytes and points are raw bytes, exactly 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 new 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. Wallets can then include signatures over several message formats in one witness while mints upgrade independently.
  • Canonical message vectors, including signatures by the well-known test key, are added to tests/11-test.md and are pinned byte-for-byte in the three implementation PRs above.

EDIT:

Have removed the legacy MTS which does not bind amount from the implementations.

This PR actions for CTS: cashubtc/cashu-ts#947

CDK and Nutshell PRs above edited to remove the legacy signing

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.
Comment thread 11.md

### Signature validation

When validating a witness, the mint **MUST** ignore signatures that do not verify (e.g. signatures produced over a superseded message format) rather than rejecting the transaction, and **MUST** count the number of _unique_ public keys with at least one valid signature towards any threshold. This allows wallets to include signatures over multiple message formats during protocol upgrades.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can explicitly also state that:
A mint MUST NOT accept any SIG_ALL message format that fails to bind the quote, all input secret+C, and all output amount+B_.
Otherwise a naive mint could read "ignore what doesn't verify" as "accept any historical format," reintroducing amount malleability.

@robwoodgate robwoodgate Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a decision we need to make - Nutshell only recently adopted the current MTS (with amount, B_) in the latest releases and CDK adopted from v0.14.0.

I agree that keeping the legacy MTS around longer than needed is not ideal. it should be dropped asap.

I don't think the spec needs to change necessarily - ignoring sigs that do not verify is "enough" to allow historic support by policy. Mints should then just (for example) accept latest and most recent MTS for a few versions.

@robwoodgate robwoodgate Aug 10, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For avoidance of doubt - I'm saying the spec should (as it does now) just specify the current MTS, and the mint implementations should make policy as to which previous versions they support and for how long.

My recommendation would be current ('v0') and the proposed 'v1' only, the legacy MTS should be dropped unilaterally. SIG_ALL is a pretty niche feature, so I think the benefit of dropping it outweighs the inconvenience to older wallets.

Comment thread 11.md
- `b"Cashu_SigAllSig_v1"` is the domain-separation tag as raw ASCII bytes, not length-prefixed.
- `||` denotes byte concatenation and `len32(x)` is the 32-bit (4-byte) big-endian length of the byte array `x` in bytes.
- `quote` is the UTF-8 quote ID being paid by a melt transaction ([NUT-05][05]), or the empty byte array for a swap ([NUT-03][03]); thus `len32(quote)` is `0` for a swap.
- `secret_i` is the UTF-8 bytes of the input's **unescaped** `secret` string.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here we say secret_i is "UTF-8 bytes of the input's unescaped secret string."
the current Nutshell implementation signs p.secret.encode("utf-8"), whether that's escaped or unescaped depends on the Proof model's internal representation and CTS may store it differently. This can make signatures just fail to verify with no obvious cause.

i think we can add one canonical vector whose secret contains a character that differs between escaped/unescaped JSON (a /, a unicode escape, a quote), so CTS, CDK and Nutshell can't quietly disagree.

@robwoodgate robwoodgate Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test vectors show the canonical MTS bytes and the escaped proof structs. A mismatch with escaping would produce different bytes and would be obvious?

eg:

"secret": "[\"P2PK\",{\"nonce\":\"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\",\"data\":\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]",

contains backslash characters, from escaping secret for wire.

@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.
The SIG_ALL example witness now carries one signature per accepted
format, matching what wallets produce.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

2 participants