NUT-11: length-frame the SIG_ALL message aggregation - #404
Conversation
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.
|
|
||
| ### 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| - `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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
Here is a worked collision under the old unframed transcript that length-prefixing removes The old transcript concatenates Two output allocations, both summing to 205:
Both produce the byte-identical output transcript: 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 ( 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. |
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.
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.
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.
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.
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.
Notes:
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.idremains 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.tests/11-test.mdand 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