Skip to content

Commit a43a16c

Browse files
authored
feat(frost/signing): RFC-21 Phase 1B -- optional AttemptContextHash on protocol messages (#3964)
## Summary RFC-21 Phase 1B: extends the three FROST/tbtc-signer protocol message types with an optional 32-byte `AttemptContextHash` field that binds each message to the deterministic `AttemptContext` introduced in Phase 1A (#3963). **No protocol behaviour change yet.** Receivers do not match the hash against a locally-computed context in this phase; that lands later behind a build tag. Phase 1B is purely the wire-format extension. Stacked on #3963. ## Affected messages - `nativeFROSTRoundOneCommitmentMessage` - `nativeFROSTRoundTwoSignatureShareMessage` - `buildTaggedTBTCSignerRoundContributionMessage` ## Migration contract | Property | Behaviour | |---|---| | Field tag | `json:\"attemptContextHash,omitempty\"` -- absent on the wire when the sender has not bound the message. | | Old peer compatibility | Pre-Phase-1B JSON unmarshals cleanly; field reports as absent via `GetAttemptContextHash`. | | New peer compatibility | New JSON with a 32-byte hash field unmarshals; `GetAttemptContextHash` returns `(hash, true)`. | | Validation | Unmarshal rejects wrong-length hash fields; len must be exactly 32 or absent. | | Equal-or-reject | The existing first-write-wins helper (`buildTaggedTBTCSignerRoundContributionMessagesEqual` from #3959) now compares `AttemptContextHash` bytewise, so a peer mutating the binding mid-stream is reported as a conflict. | ## Why split from Phase 1A Phase 1A (#3963) added the `AttemptContext` type with no consumers. Phase 1B (this PR) adds the wire-format extension. Splitting keeps the type definition reviewable independently of the wire change, since the wire change touches existing message structs in production code paths. ## Test coverage 17 new tests under `pkg/frost/signing/attempt_context_binding_test.go`: - `TestValidateAttemptContextHashField_AcceptsAbsentOrCorrectLength` (3 sub-tests) - `TestValidateAttemptContextHashField_RejectsWrongLength` (3 sub-tests) - `TestAttemptContextHashField_ArrayRoundTrip` - `TestAttemptContextHashField_ArrayToArrayAbsent` - `TestAttemptContextHashField_FromArrayDoesNotAliasCaller` - `TestRoundOneCommitmentMessage_OptionalFieldRoundTrip` (2 sub-tests) - `TestRoundOneCommitmentMessage_BackwardCompatWithOldJSON` - `TestRoundOneCommitmentMessage_RejectsWrongLengthHashField` - `TestRoundTwoSignatureShareMessage_OptionalFieldRoundTrip` - `TestRoundTwoSignatureShareMessage_BackwardCompatWithOldJSON` - `TestRoundTwoSignatureShareMessage_RejectsWrongLengthHashField` - `TestBuildTaggedTBTCSignerRoundContributionMessage_OptionalFieldRoundTrip` - `TestBuildTaggedTBTCSignerRoundContributionMessage_BackwardCompatWithOldJSON` - `TestBuildTaggedTBTCSignerRoundContributionMessage_RejectsWrongLengthHashField` - `TestBuildTaggedTBTCSignerRoundContributionMessagesEqual_HashFieldDifferentiates` - `TestRoundOneCommitmentMessage_JSONEncoderOmitsAbsentField` All pass under: - `go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/...` - `pkg/tbtc` regression subset: `go test -tags 'frost_native frost_tbtc_signer' -run 'TestConfigureFrostSigningBackend|TestNewNode_ConfiguresFrostSigningBackend|TestSigningExecutor_Sign|TestRegisterSignerMaterialResolverForBuild' ./pkg/tbtc/` ## Test plan - [ ] CI green. - [ ] Reviewer confirms the `omitempty`/optional contract is what we want as the *Phase 1* default (i.e., we are intentionally not rejecting messages that lack the field). - [ ] Reviewer confirms it's OK for the equal-or-reject helper to now treat \"contribution with hash\" and \"contribution without hash\" as unequal during the migration window (sketched in Migration contract table above).
2 parents 62106ba + 65f3963 commit a43a16c

4 files changed

Lines changed: 502 additions & 1 deletion

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package signing
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/keep-network/keep-core/pkg/frost/roast/attempt"
7+
)
8+
9+
// AttemptContextHashFieldLength is the on-wire byte length of the
10+
// optional AttemptContextHash field carried by the FROST/tbtc-signer
11+
// protocol messages. The field is the canonical SHA-256 hash of the
12+
// AttemptContext (see pkg/frost/roast/attempt), so 32 bytes.
13+
const AttemptContextHashFieldLength = attempt.MessageDigestLength
14+
15+
// validateAttemptContextHashField checks the length invariant for the
16+
// optional AttemptContextHash field on protocol messages. An absent
17+
// field (nil or zero-length slice) is valid; a present field must
18+
// match AttemptContextHashFieldLength exactly.
19+
//
20+
// This is the only validation Phase 1B performs on the field. Higher-
21+
// level acceptance (the receiver-side check that the hash matches the
22+
// locally-computed AttemptContext) lands in a later RFC-21 phase
23+
// behind a build tag, since enabling it requires honest peers to have
24+
// rolled out the new field first.
25+
func validateAttemptContextHashField(field []byte) error {
26+
if len(field) == 0 {
27+
return nil
28+
}
29+
if len(field) != AttemptContextHashFieldLength {
30+
return fmt.Errorf(
31+
"attempt context hash field has wrong length [%d], expected [%d] or absent",
32+
len(field),
33+
AttemptContextHashFieldLength,
34+
)
35+
}
36+
return nil
37+
}
38+
39+
// attemptContextHashFieldFromArray converts a fixed-size 32-byte hash
40+
// into the slice form used on the wire. Returns a fresh slice so the
41+
// caller's array cannot be mutated through the returned reference.
42+
func attemptContextHashFieldFromArray(
43+
hash [AttemptContextHashFieldLength]byte,
44+
) []byte {
45+
out := make([]byte, AttemptContextHashFieldLength)
46+
copy(out, hash[:])
47+
return out
48+
}
49+
50+
// attemptContextHashFieldToArray converts a wire-form slice back to
51+
// a fixed-size 32-byte hash plus a presence flag. Returns
52+
// (zeroArray, false) when the field is absent. Caller has already
53+
// validated length via validateAttemptContextHashField; this function
54+
// trusts that invariant and panics on violation.
55+
func attemptContextHashFieldToArray(
56+
field []byte,
57+
) ([AttemptContextHashFieldLength]byte, bool) {
58+
var out [AttemptContextHashFieldLength]byte
59+
if len(field) == 0 {
60+
return out, false
61+
}
62+
if len(field) != AttemptContextHashFieldLength {
63+
panic(fmt.Sprintf(
64+
"attemptContextHashFieldToArray called with wrong-length field [%d]",
65+
len(field),
66+
))
67+
}
68+
copy(out[:], field)
69+
return out, true
70+
}

0 commit comments

Comments
 (0)