diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go index 3df9e6a408..23989df182 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native.go @@ -74,6 +74,10 @@ typedef TbtcSignerResult (*tbtc_interactive_session_abort_fn)( const uint8_t* request_ptr, size_t request_len ); +typedef TbtcSignerResult (*tbtc_interactive_aggregate_fn)( + const uint8_t* request_ptr, + size_t request_len +); typedef TbtcSignerResult (*tbtc_start_sign_round_fn)( const uint8_t* request_ptr, size_t request_len @@ -269,6 +273,18 @@ static TbtcSignerResult tbtc_signer_interactive_session_abort(const uint8_t* req return interactive_session_abort(request_ptr, request_len); } +static TbtcSignerResult tbtc_signer_interactive_aggregate(const uint8_t* request_ptr, size_t request_len) { + tbtc_interactive_aggregate_fn interactive_aggregate = (tbtc_interactive_aggregate_fn)dlsym( + RTLD_DEFAULT, + "frost_tbtc_interactive_aggregate" + ); + if (interactive_aggregate == NULL) { + return unavailable_tbtc_signer_result(); + } + + return interactive_aggregate(request_ptr, request_len); +} + static TbtcSignerResult tbtc_signer_start_sign_round(const uint8_t* request_ptr, size_t request_len) { tbtc_start_sign_round_fn start_sign_round = (tbtc_start_sign_round_fn)dlsym( RTLD_DEFAULT, @@ -3085,3 +3101,150 @@ func callBuildTaggedTBTCSignerInteractiveSessionAbort(requestPayload []byte) ([] }, ) } + +// ---------------------------------------------------------------------------- +// Phase 7.3 interactive aggregation bridge. +// +// Aggregates the responsive subset's signature shares for an interactive +// attempt into the BIP-340 signature. The engine resolves the verifying +// material from the session's own DKG state (no public key package crosses +// here). On a share-verification failure it returns the candidate culprits in +// the error payload; InteractiveAggregate surfaces them as a typed +// InteractiveAggregateShareVerificationError for the Go host's envelope-bound +// blame adjudication. Additive: no Go caller yet. +// ---------------------------------------------------------------------------- + +type buildTaggedTBTCSignerInteractiveAggregateRequest struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + SigningPackageHex string `json:"signing_package_hex"` + SignatureShares []buildTaggedTBTCSignerNativeFROSTSignatureShare `json:"signature_shares"` + TaprootMerkleRootHex *string `json:"taproot_merkle_root_hex,omitempty"` +} + +type buildTaggedTBTCSignerInteractiveAggregateResponse struct { + SessionID string `json:"session_id"` + AttemptID string `json:"attempt_id"` + SignatureHex string `json:"signature_hex"` +} + +func (bttse *buildTaggedTBTCSignerEngine) InteractiveAggregate( + sessionID string, + attemptID string, + signingPackage []byte, + signatureShares []nativeFROSTSignatureShare, + taprootMerkleRoot *[32]byte, +) (signature []byte, err error) { + requestPayload, err := buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + sessionID, + attemptID, + signingPackage, + signatureShares, + taprootMerkleRoot, + ) + if err != nil { + return nil, err + } + + responsePayload, err := callBuildTaggedTBTCSignerInteractiveAggregate(requestPayload) + if err != nil { + // Surface a share-verification failure as the typed error carrying the + // candidate culprits; any other error passes through unchanged. + return nil, interpretInteractiveAggregateError(sessionID, attemptID, err) + } + + return decodeBuildTaggedTBTCSignerInteractiveAggregateResponse(responsePayload) +} + +func buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + sessionID string, + attemptID string, + signingPackage []byte, + signatureShares []nativeFROSTSignatureShare, + taprootMerkleRoot *[32]byte, +) ([]byte, error) { + if sessionID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "session ID is empty") + } + if attemptID == "" { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "attempt ID is empty") + } + if len(signingPackage) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "signing package is empty") + } + if len(signatureShares) == 0 { + return nil, buildTaggedTBTCSignerOperationError("InteractiveAggregate", "signature shares are empty") + } + + requestShares := make( + []buildTaggedTBTCSignerNativeFROSTSignatureShare, + 0, + len(signatureShares), + ) + for i, signatureShare := range signatureShares { + if signatureShare.Identifier == "" { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveAggregate", + fmt.Sprintf("signature share [%d] identifier is empty", i), + ) + } + if len(signatureShare.Data) == 0 { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveAggregate", + fmt.Sprintf("signature share [%d] data is empty", i), + ) + } + requestShares = append( + requestShares, + buildTaggedTBTCSignerNativeFROSTSignatureShare{ + Identifier: signatureShare.Identifier, + DataHex: hex.EncodeToString(signatureShare.Data), + }, + ) + } + + var taprootMerkleRootHex *string + if taprootMerkleRoot != nil { + encoded := hex.EncodeToString(taprootMerkleRoot[:]) + taprootMerkleRootHex = &encoded + } + + return buildTaggedTBTCSignerMarshalRequest( + "InteractiveAggregate", + buildTaggedTBTCSignerInteractiveAggregateRequest{ + SessionID: sessionID, + AttemptID: attemptID, + SigningPackageHex: hex.EncodeToString(signingPackage), + SignatureShares: requestShares, + TaprootMerkleRootHex: taprootMerkleRootHex, + }, + ) +} + +func decodeBuildTaggedTBTCSignerInteractiveAggregateResponse( + responsePayload []byte, +) ([]byte, error) { + var response buildTaggedTBTCSignerInteractiveAggregateResponse + if err := json.Unmarshal(responsePayload, &response); err != nil { + return nil, buildTaggedTBTCSignerOperationError( + "InteractiveAggregate", + fmt.Sprintf("cannot decode response payload: %v", err), + ) + } + + return buildTaggedTBTCSignerDecodeHexField( + "InteractiveAggregate", + "response signature", + response.SignatureHex, + ) +} + +func callBuildTaggedTBTCSignerInteractiveAggregate(requestPayload []byte) ([]byte, error) { + return callBuildTaggedTBTCSignerOperation( + "InteractiveAggregate", + requestPayload, + func(requestPtr *C.uint8_t, requestLen C.size_t) C.TbtcSignerResult { + return C.tbtc_signer_interactive_aggregate(requestPtr, requestLen) + }, + ) +} diff --git a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go index c5827a3a7d..a8bc1fdf7f 100644 --- a/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go +++ b/pkg/frost/signing/native_frost_engine_tbtc_signer_registration_frost_native_test.go @@ -1796,3 +1796,143 @@ func TestDecodeBuildTaggedTBTCSignerInteractiveResponses_RejectMalformed(t *test t.Fatal("open: expected a response missing session/attempt ids to be rejected") } } + +func TestBuildTaggedTBTCSignerInteractiveAggregateRequestPayload(t *testing.T) { + shares := []nativeFROSTSignatureShare{ + {Identifier: "id-1", Data: []byte{0xaa}}, + {Identifier: "id-2", Data: []byte{0xbb}}, + } + + payload, err := buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + "session-1", "attempt-1", []byte{0xde, 0xad}, shares, nil, + ) + if err != nil { + t.Fatalf("unexpected payload build error: [%v]", err) + } + + var request buildTaggedTBTCSignerInteractiveAggregateRequest + if err := json.Unmarshal(payload, &request); err != nil { + t.Fatalf("cannot decode request payload: [%v]", err) + } + if request.SessionID != "session-1" || request.AttemptID != "attempt-1" { + t.Fatalf("unexpected session/attempt: [%+v]", request) + } + if request.SigningPackageHex != "dead" { + t.Fatalf("unexpected signing package hex: [%s]", request.SigningPackageHex) + } + if len(request.SignatureShares) != 2 || + request.SignatureShares[0].Identifier != "id-1" || + request.SignatureShares[0].DataHex != "aa" || + request.SignatureShares[1].DataHex != "bb" { + t.Fatalf("unexpected signature shares: [%+v]", request.SignatureShares) + } + + rejections := map[string]struct { + sessionID string + attemptID string + pkg []byte + shares []nativeFROSTSignatureShare + }{ + "empty session": {"", "a", []byte{0x1}, shares}, + "empty attempt": {"s", "", []byte{0x1}, shares}, + "empty signing package": {"s", "a", nil, shares}, + "no shares": {"s", "a", []byte{0x1}, nil}, + "share missing data": {"s", "a", []byte{0x1}, []nativeFROSTSignatureShare{{Identifier: "id-1"}}}, + } + for name, r := range rejections { + t.Run(name, func(t *testing.T) { + if _, err := buildTaggedTBTCSignerInteractiveAggregateRequestPayload( + r.sessionID, r.attemptID, r.pkg, r.shares, nil, + ); err == nil { + t.Fatal("expected invalid input to be rejected") + } + }) + } +} + +func TestDecodeBuildTaggedTBTCSignerInteractiveAggregateResponse(t *testing.T) { + signature, err := decodeBuildTaggedTBTCSignerInteractiveAggregateResponse( + []byte(`{"session_id":"s","attempt_id":"a","signature_hex":"cafe"}`), + ) + if err != nil { + t.Fatalf("unexpected decode error: [%v]", err) + } + if hex.EncodeToString(signature) != "cafe" { + t.Fatalf("unexpected signature: [%x]", signature) + } + + if _, err := decodeBuildTaggedTBTCSignerInteractiveAggregateResponse([]byte("not json")); err == nil { + t.Fatal("expected malformed JSON to be rejected") + } +} + +// The aggregate_share_verification_failed error must surface as the typed error +// carrying the candidate culprits (so the Go host can adjudicate envelope-bound +// blame over them), with the session/attempt filled from the caller's request. +func TestInterpretInteractiveAggregateError_ShareVerificationFailure(t *testing.T) { + structured := &buildTaggedTBTCSignerStructuredError{ + Code: interactiveAggregateShareVerificationFailedCode, + Message: "shares failed verification", + CandidateCulprits: []uint16{2, 3}, + } + // Wrap exactly as the bridge call helper does (double %w). + wrapped := fmt.Errorf( + "%w: tbtc-signer bridge operation [InteractiveAggregate] failed: [%w]", + ErrNativeBridgeOperationFailed, + structured, + ) + + err := interpretInteractiveAggregateError("session-1", "attempt-1", wrapped) + + var aggErr *InteractiveAggregateShareVerificationError + if !errors.As(err, &aggErr) { + t.Fatalf("expected InteractiveAggregateShareVerificationError, got: [%v]", err) + } + if aggErr.SessionID != "session-1" || aggErr.AttemptID != "attempt-1" { + t.Fatalf("unexpected session/attempt: [%+v]", aggErr) + } + if len(aggErr.CandidateCulprits) != 2 || + aggErr.CandidateCulprits[0] != 2 || + aggErr.CandidateCulprits[1] != 3 { + t.Fatalf("unexpected candidate culprits: [%v]", aggErr.CandidateCulprits) + } +} + +func TestInterpretInteractiveAggregateError_OtherErrorPassesThrough(t *testing.T) { + structured := &buildTaggedTBTCSignerStructuredError{Code: "some_other_error", Message: "boom"} + wrapped := fmt.Errorf( + "%w: tbtc-signer bridge operation [InteractiveAggregate] failed: [%w]", + ErrNativeBridgeOperationFailed, + structured, + ) + + err := interpretInteractiveAggregateError("s", "a", wrapped) + + var aggErr *InteractiveAggregateShareVerificationError + if errors.As(err, &aggErr) { + t.Fatal("a non-share-verification error must not become the typed culprit error") + } + if !errors.Is(err, ErrNativeBridgeOperationFailed) { + t.Fatalf("expected the original wrapped error to pass through, got: [%v]", err) + } +} + +func TestBuildTaggedTBTCSignerErrorPayload_CandidateCulprits(t *testing.T) { + structured := buildTaggedTBTCSignerErrorPayload([]byte( + `{"code":"aggregate_share_verification_failed","message":"x","candidate_culprits":[2,3]}`, + )) + if structured.Code != interactiveAggregateShareVerificationFailedCode { + t.Fatalf("unexpected code: [%s]", structured.Code) + } + if len(structured.CandidateCulprits) != 2 || + structured.CandidateCulprits[0] != 2 || + structured.CandidateCulprits[1] != 3 { + t.Fatalf("unexpected candidate culprits: [%v]", structured.CandidateCulprits) + } + + // A non-culprit error decodes with an empty culprit list. + plain := buildTaggedTBTCSignerErrorPayload([]byte(`{"code":"validation_error","message":"x"}`)) + if len(plain.CandidateCulprits) != 0 { + t.Fatalf("expected no culprits for a non-culprit error, got: [%v]", plain.CandidateCulprits) + } +} diff --git a/pkg/frost/signing/native_tbtc_signer_error_frost_native.go b/pkg/frost/signing/native_tbtc_signer_error_frost_native.go index 4e7e845120..b98d3197a8 100644 --- a/pkg/frost/signing/native_tbtc_signer_error_frost_native.go +++ b/pkg/frost/signing/native_tbtc_signer_error_frost_native.go @@ -4,12 +4,23 @@ package signing import ( "encoding/json" + "errors" "fmt" ) +// interactiveAggregateShareVerificationFailedCode is the FFI error `code` the +// engine returns when one or more collected shares failed FROST verification +// during interactive aggregation. The accompanying error payload carries the +// candidate culprits. +const interactiveAggregateShareVerificationFailedCode = "aggregate_share_verification_failed" + type buildTaggedTBTCSignerErrorResponse struct { Code string `json:"code"` Message string `json:"message"` + // CandidateCulprits is populated only for the + // aggregate_share_verification_failed error: the u16 Go member identifiers + // whose shares failed verification (omitted for every other error). + CandidateCulprits []uint16 `json:"candidate_culprits,omitempty"` } // buildTaggedTBTCSignerStructuredError carries the FFI error envelope's @@ -21,6 +32,9 @@ type buildTaggedTBTCSignerErrorResponse struct { type buildTaggedTBTCSignerStructuredError struct { Code string Message string + // CandidateCulprits carries the aggregate_share_verification_failed culprit + // list when present; empty for every other error. + CandidateCulprits []uint16 } func (e *buildTaggedTBTCSignerStructuredError) Error() string { @@ -58,7 +72,56 @@ func buildTaggedTBTCSignerErrorPayload(payload []byte) *buildTaggedTBTCSignerStr } return &buildTaggedTBTCSignerStructuredError{ - Code: errorResponse.Code, - Message: errorResponse.Message, + Code: errorResponse.Code, + Message: errorResponse.Message, + CandidateCulprits: errorResponse.CandidateCulprits, + } +} + +// InteractiveAggregateShareVerificationError is returned by InteractiveAggregate +// when aggregation failed because one or more collected shares did not verify. +// +// CandidateCulprits are the engine's PURE-CRYPTO candidates - the wire (u16) Go +// member identifiers whose FROST shares failed verification against the group's +// own verifying material. They are NOT adjudicated blame: a coordinator that +// aggregated honest shares against a substituted package/root would make those +// honest shares appear here. The Go host performs the envelope-bound blame +// adjudication at an f+1 accuser quorum over these candidates (frozen Phase 7.2b +// spec, section 6); this list is its input, never authoritative on its own. +type InteractiveAggregateShareVerificationError struct { + SessionID string + AttemptID string + CandidateCulprits []uint16 + Message string +} + +func (e *InteractiveAggregateShareVerificationError) Error() string { + return fmt.Sprintf( + "interactive aggregate share verification failed for session %q attempt %q: "+ + "candidate culprits %v: %s", + e.SessionID, + e.AttemptID, + e.CandidateCulprits, + e.Message, + ) +} + +// interpretInteractiveAggregateError maps a failed InteractiveAggregate call to +// a typed InteractiveAggregateShareVerificationError when the engine reported a +// share-verification failure (carrying the candidate culprits), so callers can +// errors.As it and feed the culprits to the envelope-bound blame adjudication. +// Any other error is returned unchanged. sessionID/attemptID are the caller's +// known request values - the error payload does not echo them. +func interpretInteractiveAggregateError(sessionID, attemptID string, err error) error { + var structured *buildTaggedTBTCSignerStructuredError + if errors.As(err, &structured) && + structured.Code == interactiveAggregateShareVerificationFailedCode { + return &InteractiveAggregateShareVerificationError{ + SessionID: sessionID, + AttemptID: attemptID, + CandidateCulprits: structured.CandidateCulprits, + Message: structured.Message, + } } + return err }