From 65f396317d9c56cfb977c10679d7ac0eb051e216 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 22 May 2026 18:14:26 -0500 Subject: [PATCH] feat(frost/signing): RFC-21 Phase 1B -- optional AttemptContextHash field Extends the three FROST/tbtc-signer protocol message types with an optional 32-byte AttemptContextHash field that binds the message to a specific RFC-21 AttemptContext (introduced in Phase 1A). * nativeFROSTRoundOneCommitmentMessage * nativeFROSTRoundTwoSignatureShareMessage * buildTaggedTBTCSignerRoundContributionMessage Migration contract (Phase 1B intentionally limited): * Field uses omitempty -- absent on the wire when the sender has not bound the message to a context. Old peers continue to interop. * Receiver-side Unmarshal validates length-when-present (must be exactly AttemptContextHashFieldLength = 32) but does not yet match against the locally-computed context. Higher-level acceptance lands in a later RFC-21 phase behind a build tag. * Shared helpers in attempt_context_binding.go convert between the on-wire []byte form and the canonical [32]byte hash form. Senders use SetAttemptContextHash; receivers use GetAttemptContextHash to get the hash + presence flag. Equal-or-reject is extended to compare AttemptContextHash bytewise, so a peer that retransmits the same contribution but mutates the binding mid-stream triggers the existing first-write-wins reject path (introduced in PR #3959). 17 new tests cover: length validation; array<->slice round-trip without caller aliasing; per-message marshal/unmarshal round-trip with field absent and present; backward compatibility with pre-Phase-1B JSON; wrong-length rejection; equal-or-reject sensitivity to the new field. All pass under `go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/...` plus the pkg/tbtc regression subset. Refs RFC-21 (docs/rfc/rfc-21-*); stacked on Phase 1A (#3963). --- pkg/frost/signing/attempt_context_binding.go | 70 ++++ .../signing/attempt_context_binding_test.go | 355 ++++++++++++++++++ ...ffi_primitive_transitional_frost_native.go | 24 +- .../native_frost_protocol_frost_native.go | 54 +++ 4 files changed, 502 insertions(+), 1 deletion(-) create mode 100644 pkg/frost/signing/attempt_context_binding.go create mode 100644 pkg/frost/signing/attempt_context_binding_test.go diff --git a/pkg/frost/signing/attempt_context_binding.go b/pkg/frost/signing/attempt_context_binding.go new file mode 100644 index 0000000000..d185839878 --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding.go @@ -0,0 +1,70 @@ +package signing + +import ( + "fmt" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" +) + +// AttemptContextHashFieldLength is the on-wire byte length of the +// optional AttemptContextHash field carried by the FROST/tbtc-signer +// protocol messages. The field is the canonical SHA-256 hash of the +// AttemptContext (see pkg/frost/roast/attempt), so 32 bytes. +const AttemptContextHashFieldLength = attempt.MessageDigestLength + +// validateAttemptContextHashField checks the length invariant for the +// optional AttemptContextHash field on protocol messages. An absent +// field (nil or zero-length slice) is valid; a present field must +// match AttemptContextHashFieldLength exactly. +// +// This is the only validation Phase 1B performs on the field. Higher- +// level acceptance (the receiver-side check that the hash matches the +// locally-computed AttemptContext) lands in a later RFC-21 phase +// behind a build tag, since enabling it requires honest peers to have +// rolled out the new field first. +func validateAttemptContextHashField(field []byte) error { + if len(field) == 0 { + return nil + } + if len(field) != AttemptContextHashFieldLength { + return fmt.Errorf( + "attempt context hash field has wrong length [%d], expected [%d] or absent", + len(field), + AttemptContextHashFieldLength, + ) + } + return nil +} + +// attemptContextHashFieldFromArray converts a fixed-size 32-byte hash +// into the slice form used on the wire. Returns a fresh slice so the +// caller's array cannot be mutated through the returned reference. +func attemptContextHashFieldFromArray( + hash [AttemptContextHashFieldLength]byte, +) []byte { + out := make([]byte, AttemptContextHashFieldLength) + copy(out, hash[:]) + return out +} + +// attemptContextHashFieldToArray converts a wire-form slice back to +// a fixed-size 32-byte hash plus a presence flag. Returns +// (zeroArray, false) when the field is absent. Caller has already +// validated length via validateAttemptContextHashField; this function +// trusts that invariant and panics on violation. +func attemptContextHashFieldToArray( + field []byte, +) ([AttemptContextHashFieldLength]byte, bool) { + var out [AttemptContextHashFieldLength]byte + if len(field) == 0 { + return out, false + } + if len(field) != AttemptContextHashFieldLength { + panic(fmt.Sprintf( + "attemptContextHashFieldToArray called with wrong-length field [%d]", + len(field), + )) + } + copy(out[:], field) + return out, true +} diff --git a/pkg/frost/signing/attempt_context_binding_test.go b/pkg/frost/signing/attempt_context_binding_test.go new file mode 100644 index 0000000000..f6152f185e --- /dev/null +++ b/pkg/frost/signing/attempt_context_binding_test.go @@ -0,0 +1,355 @@ +package signing + +import ( + "bytes" + "encoding/json" + "strings" + "testing" +) + +var pinnedAttemptContextHash = [AttemptContextHashFieldLength]byte{ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, +} + +func TestValidateAttemptContextHashField_AcceptsAbsentOrCorrectLength(t *testing.T) { + tests := []struct { + name string + input []byte + }{ + {name: "nil is absent", input: nil}, + {name: "empty slice is absent", input: []byte{}}, + { + name: "exact length is accepted", + input: pinnedAttemptContextHash[:], + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateAttemptContextHashField(tt.input); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestValidateAttemptContextHashField_RejectsWrongLength(t *testing.T) { + tests := []struct { + name string + length int + }{ + {name: "too short", length: 31}, + {name: "too long", length: 33}, + {name: "one byte", length: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateAttemptContextHashField( + bytes.Repeat([]byte{0xff}, tt.length), + ) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "wrong length") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestAttemptContextHashField_ArrayRoundTrip(t *testing.T) { + field := attemptContextHashFieldFromArray(pinnedAttemptContextHash) + if len(field) != AttemptContextHashFieldLength { + t.Fatalf( + "expected length %d, got %d", + AttemptContextHashFieldLength, len(field), + ) + } + got, present := attemptContextHashFieldToArray(field) + if !present { + t.Fatal("expected presence=true") + } + if got != pinnedAttemptContextHash { + t.Fatalf("array round-trip mismatch: got %x want %x", got, pinnedAttemptContextHash) + } +} + +func TestAttemptContextHashField_ArrayToArrayAbsent(t *testing.T) { + got, present := attemptContextHashFieldToArray(nil) + if present { + t.Fatal("expected presence=false for nil") + } + var zero [AttemptContextHashFieldLength]byte + if got != zero { + t.Fatalf("expected zero array, got %x", got) + } +} + +func TestAttemptContextHashField_FromArrayDoesNotAliasCaller(t *testing.T) { + arr := pinnedAttemptContextHash + field := attemptContextHashFieldFromArray(arr) + field[0] = 0xff + if arr[0] == 0xff { + t.Fatal("mutation through returned slice modified caller's array") + } +} + +func TestRoundOneCommitmentMessage_OptionalFieldRoundTrip(t *testing.T) { + original := &nativeFROSTRoundOneCommitmentMessage{ + SenderIDValue: 1, + SessionIDValue: "session-1", + ParticipantIdentifier: "p1", + CommitmentData: []byte{0xaa, 0xbb}, + } + + t.Run("absent field round-trips as absent", func(t *testing.T) { + data, err := original.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if strings.Contains(string(data), "attemptContextHash") { + t.Fatalf( + "absent field should be omitted by omitempty, got JSON: %s", + string(data), + ) + } + decoded := &nativeFROSTRoundOneCommitmentMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected attempt context hash to be absent after round-trip") + } + }) + + t.Run("present field round-trips with same value", func(t *testing.T) { + withHash := *original + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + if !strings.Contains(string(data), "attemptContextHash") { + t.Fatalf( + "present field should appear in JSON, got: %s", + string(data), + ) + } + decoded := &nativeFROSTRoundOneCommitmentMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present { + t.Fatal("expected attempt context hash to be present") + } + if got != pinnedAttemptContextHash { + t.Fatalf("round-trip altered hash: got %x want %x", got, pinnedAttemptContextHash) + } + }) +} + +func TestRoundOneCommitmentMessage_BackwardCompatWithOldJSON(t *testing.T) { + // JSON emitted by a pre-Phase-1B peer: no attemptContextHash field + // at all. The new struct must accept it without error and report + // the hash as absent. + oldJSON := []byte(`{ + "senderID":1, + "sessionID":"session-1", + "participantIdentifier":"p1", + "commitmentData":"qrs=" + }`) + + decoded := &nativeFROSTRoundOneCommitmentMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestRoundOneCommitmentMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":1, + "sessionID":"session-1", + "participantIdentifier":"p1", + "commitmentData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &nativeFROSTRoundOneCommitmentMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } + if !strings.Contains(err.Error(), "wrong length") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRoundTwoSignatureShareMessage_OptionalFieldRoundTrip(t *testing.T) { + withHash := &nativeFROSTRoundTwoSignatureShareMessage{ + SenderIDValue: 2, + SessionIDValue: "session-2", + ParticipantIdentifier: "p2", + SignatureShareData: []byte{0xcc, 0xdd}, + } + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + decoded := &nativeFROSTRoundTwoSignatureShareMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present || got != pinnedAttemptContextHash { + t.Fatalf("round-trip lost hash: present=%v got=%x", present, got) + } +} + +func TestRoundTwoSignatureShareMessage_BackwardCompatWithOldJSON(t *testing.T) { + oldJSON := []byte(`{ + "senderID":2, + "sessionID":"session-2", + "participantIdentifier":"p2", + "signatureShareData":"qrs=" + }`) + + decoded := &nativeFROSTRoundTwoSignatureShareMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestRoundTwoSignatureShareMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":2, + "sessionID":"session-2", + "participantIdentifier":"p2", + "signatureShareData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &nativeFROSTRoundTwoSignatureShareMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_OptionalFieldRoundTrip(t *testing.T) { + withHash := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 3, + SessionIDValue: "session-3", + ContributionIdentifier: 1, + ContributionData: []byte{0xee, 0xff}, + } + withHash.SetAttemptContextHash(pinnedAttemptContextHash) + data, err := withHash.Marshal() + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + if err := decoded.Unmarshal(data); err != nil { + t.Fatalf("unmarshal failed: %v", err) + } + got, present := decoded.GetAttemptContextHash() + if !present || got != pinnedAttemptContextHash { + t.Fatalf("round-trip lost hash: present=%v got=%x", present, got) + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_BackwardCompatWithOldJSON(t *testing.T) { + oldJSON := []byte(`{ + "senderID":3, + "sessionID":"session-3", + "contributionIdentifier":1, + "contributionData":"qrs=" + }`) + + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + if err := decoded.Unmarshal(oldJSON); err != nil { + t.Fatalf("unmarshal of old-format JSON failed: %v", err) + } + if _, present := decoded.GetAttemptContextHash(); present { + t.Fatal("expected absent hash for old-format JSON") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessage_RejectsWrongLengthHashField(t *testing.T) { + badJSON := []byte(`{ + "senderID":3, + "sessionID":"session-3", + "contributionIdentifier":1, + "contributionData":"qrs=", + "attemptContextHash":"AAEC" + }`) + + decoded := &buildTaggedTBTCSignerRoundContributionMessage{} + err := decoded.Unmarshal(badJSON) + if err == nil { + t.Fatal("expected wrong-length validation error") + } +} + +func TestBuildTaggedTBTCSignerRoundContributionMessagesEqual_HashFieldDifferentiates(t *testing.T) { + base := &buildTaggedTBTCSignerRoundContributionMessage{ + SenderIDValue: 1, + SessionIDValue: "session-1", + ContributionIdentifier: 1, + ContributionData: []byte{0xaa}, + } + withHashA := *base + withHashA.SetAttemptContextHash(pinnedAttemptContextHash) + + otherHash := pinnedAttemptContextHash + otherHash[0] ^= 0xff + withHashB := *base + withHashB.SetAttemptContextHash(otherHash) + + if buildTaggedTBTCSignerRoundContributionMessagesEqual(base, &withHashA) { + t.Fatal("base (no hash) vs with-hash must compare unequal") + } + if buildTaggedTBTCSignerRoundContributionMessagesEqual(&withHashA, &withHashB) { + t.Fatal("messages with different hashes must compare unequal") + } + withHashAClone := *base + withHashAClone.SetAttemptContextHash(pinnedAttemptContextHash) + if !buildTaggedTBTCSignerRoundContributionMessagesEqual(&withHashA, &withHashAClone) { + t.Fatal("messages with the same hash must compare equal") + } + if !buildTaggedTBTCSignerRoundContributionMessagesEqual(base, base) { + t.Fatal("identical-pointer comparison must be equal") + } +} + +func TestRoundOneCommitmentMessage_JSONEncoderOmitsAbsentField(t *testing.T) { + original := &nativeFROSTRoundOneCommitmentMessage{ + SenderIDValue: 1, + SessionIDValue: "s", + ParticipantIdentifier: "p", + CommitmentData: []byte{0xaa}, + } + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("re-decode failed: %v", err) + } + if _, ok := raw["attemptContextHash"]; ok { + t.Fatalf( + "omitempty did not suppress absent attemptContextHash; raw=%v", + raw, + ) + } +} diff --git a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go index 7768d4fd65..3825b09b95 100644 --- a/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go +++ b/pkg/frost/signing/native_ffi_primitive_transitional_frost_native.go @@ -69,6 +69,9 @@ type buildTaggedTBTCSignerRoundContributionMessage struct { SessionIDValue string `json:"sessionID"` ContributionIdentifier uint16 `json:"contributionIdentifier"` ContributionData []byte `json:"contributionData"` + // AttemptContextHash -- see nativeFROSTRoundOneCommitmentMessage + // for the RFC-21 Phase 1 migration contract. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` } func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SenderID() group.MemberIndex { @@ -108,9 +111,27 @@ func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) Unmarshal(data []b return fmt.Errorf("contribution data is empty") } + if err := validateAttemptContextHashField( + bttsrcm.AttemptContextHash, + ); err != nil { + return err + } + return nil } +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + bttsrcm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +func (bttsrcm *buildTaggedTBTCSignerRoundContributionMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(bttsrcm.AttemptContextHash) +} + func (btlcnnefsp *buildTaggedLegacyCompatibleNativeExecutionFFISigningPrimitive) Sign( ctx context.Context, logger log.StandardLogger, @@ -1023,7 +1044,8 @@ func buildTaggedTBTCSignerRoundContributionMessagesEqual( return left.SenderIDValue == right.SenderIDValue && left.SessionIDValue == right.SessionIDValue && left.ContributionIdentifier == right.ContributionIdentifier && - bytes.Equal(left.ContributionData, right.ContributionData) + bytes.Equal(left.ContributionData, right.ContributionData) && + bytes.Equal(left.AttemptContextHash, right.AttemptContextHash) } func buildTaggedTBTCSignerSyntheticRoundContributions( diff --git a/pkg/frost/signing/native_frost_protocol_frost_native.go b/pkg/frost/signing/native_frost_protocol_frost_native.go index 3dcc1af4d8..14a4ed64e0 100644 --- a/pkg/frost/signing/native_frost_protocol_frost_native.go +++ b/pkg/frost/signing/native_frost_protocol_frost_native.go @@ -110,6 +110,13 @@ type nativeFROSTRoundOneCommitmentMessage struct { SessionIDValue string `json:"sessionID"` ParticipantIdentifier string `json:"participantIdentifier"` CommitmentData []byte `json:"commitmentData"` + // AttemptContextHash binds this message to a specific RFC-21 + // AttemptContext. Optional during the Phase 1 migration: an absent + // field is accepted, a present field must be exactly + // AttemptContextHashFieldLength bytes. Higher-level validation + // against the locally-computed context lands in a later RFC-21 + // phase. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` } func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) SenderID() group.MemberIndex { @@ -149,14 +156,43 @@ func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) Unmarshal(data []byte) error return fmt.Errorf("commitment data is empty") } + if err := validateAttemptContextHashField( + nfr1cm.AttemptContextHash, + ); err != nil { + return err + } + return nil } +// SetAttemptContextHash records the canonical RFC-21 attempt context +// hash on the message. Senders that wish to bind their contribution to +// an attempt context must call this before Marshal; senders that do not +// leave the field absent on the wire. +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + nfr1cm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +// GetAttemptContextHash returns the recorded attempt context hash and a +// presence flag. A receiver that requires the binding should reject +// messages where the flag is false; a receiver that does not yet +// require the binding can ignore the flag without breaking back-compat. +func (nfr1cm *nativeFROSTRoundOneCommitmentMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(nfr1cm.AttemptContextHash) +} + type nativeFROSTRoundTwoSignatureShareMessage struct { SenderIDValue uint32 `json:"senderID"` SessionIDValue string `json:"sessionID"` ParticipantIdentifier string `json:"participantIdentifier"` SignatureShareData []byte `json:"signatureShareData"` + // AttemptContextHash -- see nativeFROSTRoundOneCommitmentMessage + // for the migration contract. + AttemptContextHash []byte `json:"attemptContextHash,omitempty"` } func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) SenderID() group.MemberIndex { @@ -196,9 +232,27 @@ func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) Unmarshal(data []byte) return fmt.Errorf("signature share data is empty") } + if err := validateAttemptContextHashField( + nfr2ssm.AttemptContextHash, + ); err != nil { + return err + } + return nil } +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) SetAttemptContextHash( + hash [AttemptContextHashFieldLength]byte, +) { + nfr2ssm.AttemptContextHash = attemptContextHashFieldFromArray(hash) +} + +func (nfr2ssm *nativeFROSTRoundTwoSignatureShareMessage) GetAttemptContextHash() ( + [AttemptContextHashFieldLength]byte, bool, +) { + return attemptContextHashFieldToArray(nfr2ssm.AttemptContextHash) +} + func registerNativeFROSTSigningUnmarshallers(channel net.BroadcastChannel) { channel.SetUnmarshaler(func() net.TaggedUnmarshaler { return &nativeFROSTRoundOneCommitmentMessage{}