Follow-up to #524 (MVP policy seam PR landing now).
What the MVP shipped
PR #530 plumbed message_type into SessionInfo and added RefuseRawSignatureHooks, an opt-in pre-sign hook that rejects requests labeled message_type="raw". keep frost network serve --refuse-raw-sign installs it. This gives operators of hybrid Nostr + Bitcoin groups a way to stop blind-signing arbitrary 32-byte digests.
What's still missing
The MVP gate trusts the requester to label honestly. A malicious authorized requester can label a Bitcoin sighash as message_type="nostr-event" and the co-signer still blind-signs it.
Decision: Option 2 (responder recomputes from structured payload)
Considered options:
- Tagged digest: co-signers compute
tagged_hash(message_type, request.message) and sign that. REJECTED. The resulting aggregate signature is valid under the group pubkey for the TAGGED hash, not the canonical digest, so it fails verification at Nostr relays (which verify against the canonical event id) and at Bitcoin full nodes (which verify against the BIP-341 sighash). Option 1 trades a real threat for an unusable signature.
- Responder recomputes from structured payload: CHOSEN. Preserves verifier-compatible signatures while cryptographically binding the label to the digest.
Design (Option 2)
For known structured message_type values, the requester sends the structured payload alongside the 32-byte digest. The responder recomputes the digest from the structured payload and refuses if it doesn't match request.message. The signature itself is still over request.message, so relays and full nodes verify it normally.
Per-domain plumbing:
message_type="nostr-event": payload is the serialized UnsignedEvent (pubkey, created_at, kind, tags, content). Responder runs UnsignedEvent::ensure_id() and confirms the result equals request.message. A spoofer claiming "nostr-event" for a Bitcoin sighash fails this check because the Nostr-canonical hash of the supplied event JSON won't equal the sighash bytes.
message_type="bitcoin-sighash": payload is the PSBT + input index + sighash type. Responder reconstructs the BIP-341 sighash and confirms. A spoofer claiming "bitcoin-sighash" for a Nostr event id fails because the reconstructed sighash differs from the event id bytes.
message_type="raw": no structured form to recompute. The MVP RefuseRawSignatureHooks policy owns this case; operators of hybrid groups install it via --refuse-raw-sign. Raw remains opt-out, not domain-validated.
Future structured domains slot in as new arms in the responder's recompute switch. The protocol stays open without adding new wire fields per domain (the structured payload is itself typed).
Wire format
Extend SignRequestPayload with an optional structured_payload: Option<Vec<u8>> whose interpretation is keyed by message_type. Backwards-compat: a peer running pre-#529 code sends None; the responder running #529 code falls back to current behavior (or refuses, depending on a group-level policy flag — design TBD per migration story below).
Migration story
Domain-validated signing is gated behind a group-level policy flag so existing groups don't break the moment one peer upgrades:
- Default OFF on existing groups: responders accept unstructured requests as before.
- Operators flip ON per-group once all participants have upgraded.
- A future major release flips the default to ON.
In-flight sessions started before policy change use the policy in force when the request was received (no mid-session policy switches).
Acceptance
SignRequestPayload carries an optional structured payload typed by message_type.
- Responder recomputes the digest for
nostr-event and bitcoin-sighash and rejects on mismatch.
- Test: a Bitcoin sighash labeled as
nostr-event is refused.
- Test: a Nostr event id labeled as
bitcoin-sighash is refused.
- Test: a correctly-labeled request from each domain produces a signature that verifies under the canonical verifier.
- Group-level "require structured payload" policy flag, default OFF for backward compat.
- MVP
RefuseRawSignatureHooks continues to gate raw (deliberate — raw cannot be domain-validated).
Related
Follow-up to #524 (MVP policy seam PR landing now).
What the MVP shipped
PR #530 plumbed
message_typeintoSessionInfoand addedRefuseRawSignatureHooks, an opt-in pre-sign hook that rejects requests labeledmessage_type="raw".keep frost network serve --refuse-raw-signinstalls it. This gives operators of hybrid Nostr + Bitcoin groups a way to stop blind-signing arbitrary 32-byte digests.What's still missing
The MVP gate trusts the requester to label honestly. A malicious authorized requester can label a Bitcoin sighash as
message_type="nostr-event"and the co-signer still blind-signs it.Decision: Option 2 (responder recomputes from structured payload)
Considered options:
tagged_hash(message_type, request.message)and sign that. REJECTED. The resulting aggregate signature is valid under the group pubkey for the TAGGED hash, not the canonical digest, so it fails verification at Nostr relays (which verify against the canonical event id) and at Bitcoin full nodes (which verify against the BIP-341 sighash). Option 1 trades a real threat for an unusable signature.Design (Option 2)
For known structured
message_typevalues, the requester sends the structured payload alongside the 32-byte digest. The responder recomputes the digest from the structured payload and refuses if it doesn't matchrequest.message. The signature itself is still overrequest.message, so relays and full nodes verify it normally.Per-domain plumbing:
message_type="nostr-event": payload is the serializedUnsignedEvent(pubkey,created_at,kind,tags,content). Responder runsUnsignedEvent::ensure_id()and confirms the result equalsrequest.message. A spoofer claiming"nostr-event"for a Bitcoin sighash fails this check because the Nostr-canonical hash of the supplied event JSON won't equal the sighash bytes.message_type="bitcoin-sighash": payload is the PSBT + input index + sighash type. Responder reconstructs the BIP-341 sighash and confirms. A spoofer claiming"bitcoin-sighash"for a Nostr event id fails because the reconstructed sighash differs from the event id bytes.message_type="raw": no structured form to recompute. The MVPRefuseRawSignatureHookspolicy owns this case; operators of hybrid groups install it via--refuse-raw-sign. Raw remains opt-out, not domain-validated.Future structured domains slot in as new arms in the responder's recompute switch. The protocol stays open without adding new wire fields per domain (the structured payload is itself typed).
Wire format
Extend
SignRequestPayloadwith an optionalstructured_payload: Option<Vec<u8>>whose interpretation is keyed bymessage_type. Backwards-compat: a peer running pre-#529 code sendsNone; the responder running #529 code falls back to current behavior (or refuses, depending on a group-level policy flag — design TBD per migration story below).Migration story
Domain-validated signing is gated behind a group-level policy flag so existing groups don't break the moment one peer upgrades:
In-flight sessions started before policy change use the policy in force when the request was received (no mid-session policy switches).
Acceptance
SignRequestPayloadcarries an optional structured payload typed bymessage_type.nostr-eventandbitcoin-sighashand rejects on mismatch.nostr-eventis refused.bitcoin-sighashis refused.RefuseRawSignatureHookscontinues to gateraw(deliberate — raw cannot be domain-validated).Related