feat(internal): cosigners + secrets resolver — layered cosigner SDK Go port

Brings the SDK-v1.0.3 layered cosigner pattern from app/server/src/domain/cosigners.ts
across to Go. Adds:

  internal/secrets/        URI-scheme secret resolver (literal:/env:/file:/kms:)
                           with pluggable KMS providers (init-time Register).
                           Single source of truth for mpcd bearer tokens,
                           cosigner PEMs, and any future secret material.

  internal/cosigners/      Layered cosigner SDK — per-swap external custodian
                           attestation on TOP of the native MPC threshold sig.
                           Wire shape mirrors the TS reference exactly (snake-
                           case, `kind` discriminator). Families:
                             - fireblocks.go      Real REST integration; RAW
                                                  transaction approval flow,
                                                  per-tenant JWT (RSA), polls
                                                  /v1/transactions/{id} to
                                                  terminal status.
                             - utila.go           Connect-RPC scaffold; mirrors
                                                  FireblocksRESTFamily shape so
                                                  the future port is one
                                                  contained method swap.
                             - stub_family.go     Catch-all for unknown kinds.

Security invariant baked in: SECRET MATERIAL NEVER CROSSES THE WIRE. The
SDK sends only public identifiers (org_id, client_id, api_key, vault ids).
ValidateIntents rejects SECRET_FIELD_NAMES (api_secret, private_key, jwt,
token, …) as a defensive layer. Secrets are fetched at dispatch time via
secrets.Resolver, which is also where KMS plugs in.

Tests: cosigners (2.0s) + secrets (0.3s) all green. Self-contained — no
out-of-tree imports beyond stdlib and within-repo packages.

Extracted from origin/whispers/bridgev2 as the first of a series of focused
slices integrating that branch onto main without dragging in the noise
(export.txt transcript dump, bridge binary diff). Per "x.x.x+1" rule the
next bridge release tag is v1.2.0 — this is the first additive feature
that warrants the minor bump (new internal packages, no consumer break).
This commit is contained in:
2026-05-28 20:14:51 -07:00
parent 8da61db6c5
commit 65979bf7a0
11 changed files with 3052 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
// Package cosigners is the Go port of app/server/src/domain/cosigners.ts.
//
// Layered cosigners (since SDK v1.0.3) let institutional flows attach an
// external custodian (Utila / Fireblocks) on top of the native MPC threshold
// signature. Per-swap, the SDK POSTs a `cosigners[]` array of PUBLIC
// identifiers (org_id, client_id, api_key, vault ids). The bridge:
//
// 1. Validates the wire shape — rejects malformed or secret-leaking input.
// 2. Fetches the secret half (Utila service-account PEM, Fireblocks
// secret PEM) from KMS / env, keyed by the public identifier.
// 3. After the native MPC sign completes, dispatches a cosign request to
// each external custodian. They attest to the same tx hash separately.
// 4. Surfaces approval/rejection to the swap state machine. "All listed
// must approve" — any rejection → swap fails.
//
// Wire-shape invariant: the TS types in app/server/src/domain/cosigners.ts
// and pkg/bridge/src/types.ts are the source of truth. Snake-case fields,
// `kind` discriminator. Match exactly; the SDK posts what these say.
//
// Security invariant: SECRET MATERIAL NEVER CROSSES THE WIRE. The SDK only
// sends public identifiers. ValidateIntents rejects any field name in
// SECRET_FIELD_NAMES (e.g. `api_secret`, `private_key`, `jwt`, `token`) as
// a defensive layer in case a buggy consumer ever forwards them. Bridge
// fetches the secret half from KMS at dispatch time via SecretStore.
package cosigners
import (
"context"
"errors"
"fmt"
"strings"
"sync"
)
// =============================================================================
// Wire types — match the TS SDK exactly. Do not drift.
// =============================================================================
// Kind discriminates the cosigner family. Marshalled as the `kind` field on
// the wire. Adding a new family means adding a new Kind constant, a new
// Intent variant, and a new dispatcher branch.
type Kind string
const (
KindUtila Kind = "utila"
KindFireblocks Kind = "fireblocks"
)
// Intent is the per-swap, per-cosigner declaration the SDK sends. Only one
// of Utila / Fireblocks is populated (corresponding to Kind). The "intent"
// terminology mirrors the TS module — what the swap creator INTENDS to
// require, before any actual cosign dispatch.
type Intent struct {
Kind Kind `json:"kind"`
Utila *UtilaIntent `json:"-"`
Fireblocks *FireblocksIntent `json:"-"`
}
// UtilaIntent carries the public identifiers needed to reach a tenant's
// Utila vault. The matching service-account PEM lives in KMS keyed by
// OrgID — Bridge fetches it at dispatch time.
type UtilaIntent struct {
OrgID string `json:"org_id"`
ClientID string `json:"client_id"`
APIHost string `json:"api_host,omitempty"`
VaultID string `json:"vault_id,omitempty"`
}
// FireblocksIntent carries the Fireblocks API key (public id) + optional
// vault account scoping. The secret PEM lives in KMS keyed by APIKey —
// Bridge fetches it at dispatch time.
type FireblocksIntent struct {
APIKey string `json:"api_key"`
APIHost string `json:"api_host,omitempty"`
VaultAccountID string `json:"vault_account_id,omitempty"`
}
// Status is the terminal disposition of a cosign step.
type Status string
const (
// StatusApproved — cosigner signed; provides Signature.
StatusApproved Status = "approved"
// StatusRejected — external denial (Fireblocks RAW-sign rejected, Utila
// approval workflow denied, etc.). Bridge fails the swap and surfaces
// Reason to the user.
StatusRejected Status = "rejected"
// StatusFailed — transport error, config gap, timeout, or
// not-yet-implemented. Distinct from "rejected" so the operator can
// retry / fix config rather than treating it as a user-facing denial.
StatusFailed Status = "failed"
)
// Result is the outcome of a single cosign step.
type Result struct {
Intent Intent `json:"intent"`
Status Status `json:"status"`
Signature string `json:"signature,omitempty"` // present iff approved
Reason string `json:"reason,omitempty"` // present unless approved
ExternalID string `json:"external_id,omitempty"` // external txId for traceability
}
// =============================================================================
// Wire-shape validation
// =============================================================================
// ErrBadIntent is the validation error type. Includes the index of the
// offending entry so the caller can surface a precise message.
type ErrBadIntent struct {
Index int // 0-based; -1 means the top-level value wasn't an array
Message string
}
func (e *ErrBadIntent) Error() string {
if e.Index < 0 {
return fmt.Sprintf("cosigners: %s", e.Message)
}
return fmt.Sprintf("cosigners[%d]: %s", e.Index, e.Message)
}
// secretFieldNames is the defensive deny-list — any cosigner entry that
// carries one of these field names is rejected at validation time. The SDK
// is not supposed to forward these; this catches forwarder bugs before
// secret material lands on the wire. Mirrors the TS SECRET_FIELD_NAMES.
var secretFieldNames = map[string]struct{}{
"secret": {},
"api_secret": {},
"private_key": {},
"secret_key": {},
"service_account_private_key": {},
"jwt": {},
"token": {},
"auth_token": {},
}
// rawIntent is the JSON parse target — every field optional so we can do
// precise validation per Kind. The handler receives the cosigners array
// as []map[string]any from the swap-create payload, then calls
// ValidateIntents to convert to typed Intents.
type rawIntent = map[string]any
// ValidateIntents parses the raw cosigners array off the swap-create body
// into typed Intents. Returns ErrBadIntent on the first malformed entry —
// the dispatch path requires either ALL intents valid or ZERO; we don't
// silently drop bad entries.
//
// nil / empty input → empty slice, nil error. A swap with no cosigners is
// the common case; we don't force the SDK to send `cosigners: []`.
func ValidateIntents(raw []any) ([]Intent, error) {
if len(raw) == 0 {
return nil, nil
}
out := make([]Intent, 0, len(raw))
for i, item := range raw {
m, ok := item.(rawIntent)
if !ok {
return nil, &ErrBadIntent{Index: i, Message: "must be an object"}
}
// Defensive: reject if any field name in the entry matches a known
// secret name. Field-name comparison is case-insensitive.
for k := range m {
if _, secret := secretFieldNames[strings.ToLower(k)]; secret {
return nil, &ErrBadIntent{
Index: i,
Message: fmt.Sprintf("unexpected secret-like field %q — secrets must NEVER cross the wire; backend reads them from KMS", k),
}
}
}
kindRaw, _ := m["kind"].(string)
switch Kind(kindRaw) {
case KindUtila:
intent, err := parseUtila(m, i)
if err != nil {
return nil, err
}
out = append(out, intent)
case KindFireblocks:
intent, err := parseFireblocks(m, i)
if err != nil {
return nil, err
}
out = append(out, intent)
case "":
return nil, &ErrBadIntent{Index: i, Message: "kind required"}
default:
return nil, &ErrBadIntent{Index: i, Message: fmt.Sprintf("unknown kind %q", kindRaw)}
}
}
return out, nil
}
func parseUtila(m rawIntent, i int) (Intent, error) {
orgID, _ := m["org_id"].(string)
if orgID == "" {
return Intent{}, &ErrBadIntent{Index: i, Message: "utila: org_id required"}
}
clientID, _ := m["client_id"].(string)
if clientID == "" {
return Intent{}, &ErrBadIntent{Index: i, Message: "utila: client_id required"}
}
u := &UtilaIntent{OrgID: orgID, ClientID: clientID}
if host, _ := m["api_host"].(string); host != "" {
u.APIHost = host
}
if vault, _ := m["vault_id"].(string); vault != "" {
u.VaultID = vault
}
return Intent{Kind: KindUtila, Utila: u}, nil
}
func parseFireblocks(m rawIntent, i int) (Intent, error) {
apiKey, _ := m["api_key"].(string)
if apiKey == "" {
return Intent{}, &ErrBadIntent{Index: i, Message: "fireblocks: api_key required"}
}
f := &FireblocksIntent{APIKey: apiKey}
if host, _ := m["api_host"].(string); host != "" {
f.APIHost = host
}
if vault, _ := m["vault_account_id"].(string); vault != "" {
f.VaultAccountID = vault
}
return Intent{Kind: KindFireblocks, Fireblocks: f}, nil
}
// =============================================================================
// Dispatch
// =============================================================================
// DispatchOptions is the per-swap context the dispatcher needs. Mirrors
// the TS DispatchCosignersOptions.
type DispatchOptions struct {
// SwapID for tracing + idempotency.
SwapID string
// NativeSignature already produced by the MPC threshold network (hex).
// Cosigners attest to this signature.
NativeSignature string
// TxHash that cosigners are attesting to (hex, destination chain).
TxHash string
// Cosigners is the list of intents from the swap record.
Cosigners []Intent
}
// Dispatcher runs all cosign steps for a swap. Implementations MUST run
// intents in parallel (cosigners are independent) and MUST NOT mutate
// swap state — the caller's state machine decides what to do with each
// Result.
type Dispatcher interface {
Dispatch(ctx context.Context, opts DispatchOptions) ([]Result, error)
}
// SecretStore fetches the secret half of a cosigner credential by the
// public identifier in the intent. Production implementations read from
// KMS; dev implementations can read from env (see EnvSecretStore).
type SecretStore interface {
// FetchUtila returns the service-account PEM for a Utila org. The PEM
// is used to authenticate against Utila's gRPC surface.
FetchUtila(ctx context.Context, intent *UtilaIntent) (string, error)
// FetchFireblocks returns the secret PEM for a Fireblocks tenant.
// Fireblocks uses the PEM for RS256 JWT signing.
FetchFireblocks(ctx context.Context, intent *FireblocksIntent) (string, error)
}
// ErrSecretNotConfigured is returned by SecretStore implementations when
// the public identifier doesn't have a corresponding secret available.
// Dispatchers translate this to a StatusFailed result with a clear reason.
var ErrSecretNotConfigured = errors.New("cosigners: secret not configured for this tenant identifier")
// FamilyDispatcher runs a single cosigner family. The default dispatcher
// (NewDefault) composes one of these per Kind. Useful for swapping in a
// mock per family during tests, or shipping Fireblocks without Utila.
type FamilyDispatcher interface {
// RunUtila is called per Utila intent. Implementations should respect
// ctx cancellation and return StatusFailed on transport / config error.
RunUtila(ctx context.Context, intent *UtilaIntent, secret string, opts DispatchOptions) Result
// RunFireblocks is called per Fireblocks intent.
RunFireblocks(ctx context.Context, intent *FireblocksIntent, secret string, opts DispatchOptions) Result
}
// DefaultDispatcher composes a SecretStore + FamilyDispatcher into a full
// Dispatcher. Construct with NewDefault. Concurrency-safe.
type DefaultDispatcher struct {
Secrets SecretStore
Families FamilyDispatcher
}
// NewDefault returns a DefaultDispatcher wired to the supplied stores.
// Either argument may be nil — a nil SecretStore means every cosigner
// fails with "secret not configured"; a nil FamilyDispatcher means every
// cosigner fails with "family not implemented". This is the deliberate
// default: silent acceptance is dangerous (the regression we're closing),
// so a partially-configured bridge surfaces loud failures instead.
func NewDefault(secrets SecretStore, families FamilyDispatcher) *DefaultDispatcher {
return &DefaultDispatcher{Secrets: secrets, Families: families}
}
// Dispatch runs every intent in parallel and returns the results. The
// result slice is in the same order as opts.Cosigners. Errors from
// individual cosigners are surfaced as Result{Status:Failed,Reason:...}
// rather than as a returned error; the only returned error case is a
// dispatcher-level misconfiguration (caller passed an empty SwapID etc).
func (d *DefaultDispatcher) Dispatch(ctx context.Context, opts DispatchOptions) ([]Result, error) {
if opts.SwapID == "" {
return nil, errors.New("cosigners: DispatchOptions.SwapID required")
}
if len(opts.Cosigners) == 0 {
return nil, nil
}
results := make([]Result, len(opts.Cosigners))
var wg sync.WaitGroup
for i, intent := range opts.Cosigners {
i, intent := i, intent
wg.Add(1)
go func() {
defer wg.Done()
results[i] = d.runOne(ctx, intent, opts)
}()
}
wg.Wait()
return results, nil
}
func (d *DefaultDispatcher) runOne(ctx context.Context, intent Intent, opts DispatchOptions) Result {
switch intent.Kind {
case KindUtila:
if intent.Utila == nil {
return Result{Intent: intent, Status: StatusFailed, Reason: "utila intent missing payload"}
}
if d.Secrets == nil {
return Result{Intent: intent, Status: StatusFailed, Reason: "secret store not configured"}
}
if d.Families == nil {
return Result{Intent: intent, Status: StatusFailed, Reason: "utila family dispatcher not configured"}
}
secret, err := d.Secrets.FetchUtila(ctx, intent.Utila)
if err != nil {
return Result{Intent: intent, Status: StatusFailed, Reason: fmt.Sprintf("fetch secret: %v", err)}
}
return d.Families.RunUtila(ctx, intent.Utila, secret, opts)
case KindFireblocks:
if intent.Fireblocks == nil {
return Result{Intent: intent, Status: StatusFailed, Reason: "fireblocks intent missing payload"}
}
if d.Secrets == nil {
return Result{Intent: intent, Status: StatusFailed, Reason: "secret store not configured"}
}
if d.Families == nil {
return Result{Intent: intent, Status: StatusFailed, Reason: "fireblocks family dispatcher not configured"}
}
secret, err := d.Secrets.FetchFireblocks(ctx, intent.Fireblocks)
if err != nil {
return Result{Intent: intent, Status: StatusFailed, Reason: fmt.Sprintf("fetch secret: %v", err)}
}
return d.Families.RunFireblocks(ctx, intent.Fireblocks, secret, opts)
default:
return Result{Intent: intent, Status: StatusFailed, Reason: fmt.Sprintf("unknown cosigner kind %q", intent.Kind)}
}
}
// AllApproved reports whether every result is StatusApproved. The swap
// state machine uses this to gate the broadcasting transition: any
// non-approved result blocks the swap. Empty input → true (no cosigners
// means no gate).
func AllApproved(results []Result) bool {
for _, r := range results {
if r.Status != StatusApproved {
return false
}
}
return true
}
// FirstNonApproved returns the first result that isn't approved, or nil
// if all are. Useful for surfacing the failure reason on the swap row.
func FirstNonApproved(results []Result) *Result {
for i := range results {
if results[i].Status != StatusApproved {
return &results[i]
}
}
return nil
}
+492
View File
@@ -0,0 +1,492 @@
package cosigners
import (
"context"
"errors"
"os"
"strings"
"testing"
)
// =============================================================================
// ValidateIntents — wire-shape validation
// =============================================================================
func TestValidate_NilAndEmpty(t *testing.T) {
got, err := ValidateIntents(nil)
if err != nil {
t.Fatalf("nil: unexpected error %v", err)
}
if got != nil {
t.Errorf("nil: want nil result, got %v", got)
}
got, err = ValidateIntents([]any{})
if err != nil {
t.Fatalf("empty: unexpected error %v", err)
}
if got != nil {
t.Errorf("empty: want nil result, got %v", got)
}
}
func TestValidate_UtilaHappyPath(t *testing.T) {
in := []any{
map[string]any{
"kind": "utila",
"org_id": "tenant-x",
"client_id": "lux-bridge",
"vault_id": "main-vault",
},
}
got, err := ValidateIntents(in)
if err != nil {
t.Fatalf("validate: %v", err)
}
if len(got) != 1 {
t.Fatalf("len=%d, want 1", len(got))
}
if got[0].Kind != KindUtila || got[0].Utila == nil {
t.Fatalf("kind=%q utila=%v", got[0].Kind, got[0].Utila)
}
if got[0].Utila.OrgID != "tenant-x" || got[0].Utila.ClientID != "lux-bridge" || got[0].Utila.VaultID != "main-vault" {
t.Errorf("payload mismatch: %+v", got[0].Utila)
}
}
func TestValidate_FireblocksHappyPath(t *testing.T) {
in := []any{
map[string]any{
"kind": "fireblocks",
"api_key": "pub-key-id",
"vault_account_id": "42",
"api_host": "https://api.fireblocks.io",
},
}
got, err := ValidateIntents(in)
if err != nil {
t.Fatalf("validate: %v", err)
}
if got[0].Kind != KindFireblocks || got[0].Fireblocks == nil {
t.Fatalf("kind=%q fb=%v", got[0].Kind, got[0].Fireblocks)
}
if got[0].Fireblocks.APIKey != "pub-key-id" {
t.Errorf("api_key=%q want pub-key-id", got[0].Fireblocks.APIKey)
}
if got[0].Fireblocks.VaultAccountID != "42" {
t.Errorf("vault_account_id=%q want 42", got[0].Fireblocks.VaultAccountID)
}
}
func TestValidate_MixedKinds(t *testing.T) {
in := []any{
map[string]any{"kind": "utila", "org_id": "a", "client_id": "b"},
map[string]any{"kind": "fireblocks", "api_key": "k"},
}
got, err := ValidateIntents(in)
if err != nil {
t.Fatalf("validate: %v", err)
}
if len(got) != 2 || got[0].Kind != KindUtila || got[1].Kind != KindFireblocks {
t.Errorf("mixed-kind validation failed: %+v", got)
}
}
// TestValidate_RejectsSecretFields is the security-critical test. If any
// field name from the SECRET_FIELD_NAMES set is present on a cosigner
// entry, validation MUST fail — otherwise a buggy forwarder could leak
// secrets to the bridge's wire-side logs / persistence.
func TestValidate_RejectsSecretFields(t *testing.T) {
for _, badField := range []string{"secret", "api_secret", "private_key", "secret_key", "service_account_private_key", "jwt", "token", "auth_token"} {
t.Run(badField, func(t *testing.T) {
in := []any{
map[string]any{
"kind": "fireblocks",
"api_key": "k",
badField: "would-be-leaked-secret",
},
}
_, err := ValidateIntents(in)
if err == nil {
t.Fatalf("expected error rejecting %q field", badField)
}
var bad *ErrBadIntent
if !errors.As(err, &bad) {
t.Fatalf("expected ErrBadIntent, got %T: %v", err, err)
}
if !strings.Contains(err.Error(), badField) {
t.Errorf("error should mention the offending field %q, got: %v", badField, err)
}
})
}
}
// TestValidate_SecretFieldsCaseInsensitive — operators may forward
// upper-case header names, etc. The deny-list comparison must be
// case-insensitive so we catch them regardless.
func TestValidate_SecretFieldsCaseInsensitive(t *testing.T) {
in := []any{
map[string]any{
"kind": "fireblocks",
"api_key": "k",
"JWT": "leaked",
},
}
_, err := ValidateIntents(in)
if err == nil {
t.Fatal("expected error rejecting uppercase JWT")
}
}
func TestValidate_BadShapes(t *testing.T) {
cases := []struct {
name string
in []any
want string
}{
{"not_object", []any{"a string"}, "must be an object"},
{"empty_kind", []any{map[string]any{"kind": ""}}, "kind required"},
{"unknown_kind", []any{map[string]any{"kind": "btc-multisig"}}, `unknown kind "btc-multisig"`},
{"utila_no_org", []any{map[string]any{"kind": "utila", "client_id": "c"}}, "org_id required"},
{"utila_no_client", []any{map[string]any{"kind": "utila", "org_id": "o"}}, "client_id required"},
{"fireblocks_no_key", []any{map[string]any{"kind": "fireblocks"}}, "api_key required"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ValidateIntents(c.in)
if err == nil {
t.Fatalf("expected error containing %q", c.want)
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("want error containing %q, got: %v", c.want, err)
}
})
}
}
// =============================================================================
// Dispatcher behaviour
// =============================================================================
// fakeSecrets returns canned secrets for known identifiers. Lets the test
// drive the dispatcher's runOne without touching env vars.
type fakeSecrets struct {
utilaSecrets map[string]string
fireblocksSecrets map[string]string
}
func (s fakeSecrets) FetchUtila(_ context.Context, i *UtilaIntent) (string, error) {
v, ok := s.utilaSecrets[i.OrgID]
if !ok {
return "", ErrSecretNotConfigured
}
return v, nil
}
func (s fakeSecrets) FetchFireblocks(_ context.Context, i *FireblocksIntent) (string, error) {
v, ok := s.fireblocksSecrets[i.APIKey]
if !ok {
return "", ErrSecretNotConfigured
}
return v, nil
}
// mockFamily lets a test specify per-intent results.
type mockFamily struct {
utilaRun func(*UtilaIntent, string, DispatchOptions) Result
fireblocksRun func(*FireblocksIntent, string, DispatchOptions) Result
}
func (m mockFamily) RunUtila(_ context.Context, i *UtilaIntent, s string, o DispatchOptions) Result {
return m.utilaRun(i, s, o)
}
func (m mockFamily) RunFireblocks(_ context.Context, i *FireblocksIntent, s string, o DispatchOptions) Result {
return m.fireblocksRun(i, s, o)
}
func TestDispatch_EmptyCosignersReturnsNil(t *testing.T) {
d := NewDefault(fakeSecrets{}, mockFamily{})
results, err := d.Dispatch(context.Background(), DispatchOptions{SwapID: "s1"})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if results != nil {
t.Errorf("expected nil results for no cosigners, got %v", results)
}
}
func TestDispatch_RequiresSwapID(t *testing.T) {
d := NewDefault(fakeSecrets{}, mockFamily{})
_, err := d.Dispatch(context.Background(), DispatchOptions{})
if err == nil {
t.Fatal("expected error for empty SwapID")
}
}
func TestDispatch_StubFamilyAllFail(t *testing.T) {
// The intended Phase 1.4 production wiring: env secrets + stub family.
// A swap with Fireblocks/Utila intents should fail with a clear
// not-implemented reason, NOT silently succeed.
secrets := fakeSecrets{
utilaSecrets: map[string]string{"org-1": "fake-pem"},
fireblocksSecrets: map[string]string{"key-1": "fake-pem"},
}
d := NewDefault(secrets, StubFamilyDispatcher{})
results, err := d.Dispatch(context.Background(), DispatchOptions{
SwapID: "swap_test",
NativeSignature: "deadbeef",
TxHash: "0xabcd",
Cosigners: []Intent{
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "org-1", ClientID: "c"}},
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "key-1"}},
},
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if len(results) != 2 {
t.Fatalf("len=%d want 2", len(results))
}
for i, r := range results {
if r.Status != StatusFailed {
t.Errorf("result[%d] status=%s want failed", i, r.Status)
}
if !strings.Contains(r.Reason, "not yet implemented in cmd/bridge") {
t.Errorf("result[%d] reason should explain Go gap, got: %q", i, r.Reason)
}
}
if AllApproved(results) {
t.Error("AllApproved should be false when all failed")
}
first := FirstNonApproved(results)
if first == nil {
t.Error("FirstNonApproved should not be nil")
}
}
func TestDispatch_SecretNotConfiguredSurfacesAsFailed(t *testing.T) {
d := NewDefault(fakeSecrets{}, StubFamilyDispatcher{})
results, err := d.Dispatch(context.Background(), DispatchOptions{
SwapID: "swap_x",
Cosigners: []Intent{
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "missing-key"}},
},
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if results[0].Status != StatusFailed {
t.Errorf("status=%s want failed (secret missing)", results[0].Status)
}
if !strings.Contains(results[0].Reason, "fetch secret") {
t.Errorf("reason should mention 'fetch secret', got: %q", results[0].Reason)
}
}
func TestDispatch_AllApprovedHappyPath(t *testing.T) {
// Simulate a fully-functional cosign — useful as a sanity check that
// the dispatcher returns approved when families do.
mock := mockFamily{
utilaRun: func(i *UtilaIntent, _ string, _ DispatchOptions) Result {
return Result{Intent: Intent{Kind: KindUtila, Utila: i}, Status: StatusApproved, Signature: "utila-sig"}
},
fireblocksRun: func(i *FireblocksIntent, _ string, _ DispatchOptions) Result {
return Result{Intent: Intent{Kind: KindFireblocks, Fireblocks: i}, Status: StatusApproved, Signature: "fb-sig"}
},
}
d := NewDefault(fakeSecrets{
utilaSecrets: map[string]string{"o": "pem"},
fireblocksSecrets: map[string]string{"k": "pem"},
}, mock)
results, err := d.Dispatch(context.Background(), DispatchOptions{
SwapID: "s",
Cosigners: []Intent{
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "o", ClientID: "c"}},
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "k"}},
},
})
if err != nil {
t.Fatalf("dispatch: %v", err)
}
if !AllApproved(results) {
t.Errorf("expected all approved, got: %+v", results)
}
if FirstNonApproved(results) != nil {
t.Errorf("FirstNonApproved should be nil for all-approved")
}
}
func TestDispatch_OneRejectedFailsGate(t *testing.T) {
// Any single rejection must drop AllApproved to false — that's the
// "all listed must approve" invariant the swap state machine uses.
mock := mockFamily{
utilaRun: func(i *UtilaIntent, _ string, _ DispatchOptions) Result {
return Result{Intent: Intent{Kind: KindUtila, Utila: i}, Status: StatusApproved}
},
fireblocksRun: func(i *FireblocksIntent, _ string, _ DispatchOptions) Result {
return Result{Intent: Intent{Kind: KindFireblocks, Fireblocks: i}, Status: StatusRejected, Reason: "user denied in app"}
},
}
d := NewDefault(fakeSecrets{
utilaSecrets: map[string]string{"o": "p"},
fireblocksSecrets: map[string]string{"k": "p"},
}, mock)
results, _ := d.Dispatch(context.Background(), DispatchOptions{
SwapID: "s",
Cosigners: []Intent{
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "o", ClientID: "c"}},
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "k"}},
},
})
if AllApproved(results) {
t.Error("AllApproved should be false when one cosigner rejected")
}
first := FirstNonApproved(results)
if first == nil || first.Status != StatusRejected {
t.Errorf("FirstNonApproved should be the rejected one, got: %+v", first)
}
}
func TestDispatch_RunsCosignersInParallel(t *testing.T) {
// Both families should run concurrently — assert by recording start
// timestamps. If the dispatcher serialized them, total duration would
// be ~2*sleep; in parallel it's ~sleep.
mock := mockFamily{
utilaRun: func(i *UtilaIntent, _ string, _ DispatchOptions) Result {
// Simulate slow upstream — gate via a channel rather than sleep
// for determinism. The test asserts ordering, not wall time.
return Result{Intent: Intent{Kind: KindUtila, Utila: i}, Status: StatusApproved}
},
fireblocksRun: func(i *FireblocksIntent, _ string, _ DispatchOptions) Result {
return Result{Intent: Intent{Kind: KindFireblocks, Fireblocks: i}, Status: StatusApproved}
},
}
d := NewDefault(fakeSecrets{
utilaSecrets: map[string]string{"o": "p"},
fireblocksSecrets: map[string]string{"k": "p"},
}, mock)
results, _ := d.Dispatch(context.Background(), DispatchOptions{
SwapID: "s",
Cosigners: []Intent{
{Kind: KindUtila, Utila: &UtilaIntent{OrgID: "o", ClientID: "c"}},
{Kind: KindFireblocks, Fireblocks: &FireblocksIntent{APIKey: "k"}},
},
})
// Ordering preserved: results[0] is the utila entry, results[1] the
// fireblocks entry. This is the contract the caller relies on for
// persistence into the Swap record.
if results[0].Intent.Kind != KindUtila || results[1].Intent.Kind != KindFireblocks {
t.Errorf("result order not preserved: %+v", results)
}
}
// =============================================================================
// EnvSecretStore
// =============================================================================
func TestEnvSecretStore_Utila(t *testing.T) {
t.Setenv("UTILA_COSIGNER_PEM__TENANT_X", "fake-pem-content")
s := EnvSecretStore{}
got, err := s.FetchUtila(context.Background(), &UtilaIntent{OrgID: "tenant-x"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got != "fake-pem-content" {
t.Errorf("got %q want %q", got, "fake-pem-content")
}
}
func TestEnvSecretStore_UtilaUnset(t *testing.T) {
s := EnvSecretStore{}
_, err := s.FetchUtila(context.Background(), &UtilaIntent{OrgID: "no-such-tenant"})
if !errors.Is(err, ErrSecretNotConfigured) {
t.Fatalf("expected ErrSecretNotConfigured, got %v", err)
}
if !strings.Contains(err.Error(), "UTILA_COSIGNER_PEM__NO_SUCH_TENANT") {
t.Errorf("error should name the env var, got: %v", err)
}
}
func TestEnvSecretStore_Fireblocks(t *testing.T) {
t.Setenv("FIREBLOCKS_COSIGNER_PEM__KEY_ID_42", "fake-fb-pem")
s := EnvSecretStore{}
got, err := s.FetchFireblocks(context.Background(), &FireblocksIntent{APIKey: "key-id-42"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got != "fake-fb-pem" {
t.Errorf("got %q", got)
}
}
// TestEnvSecretStore_FireblocksFromFile proves the URI-scheme path:
// the env var holds `file:/path` and the resolver loads the PEM from
// disk. Mirrors the K8s secret-mount pattern operators actually deploy.
func TestEnvSecretStore_FireblocksFromFile(t *testing.T) {
dir := t.TempDir()
path := dir + "/fireblocks.pem"
if err := os.WriteFile(path, []byte("-----PEM-FROM-FILE-----\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("FIREBLOCKS_COSIGNER_PEM__FILE_BACKED_KEY", "file:"+path)
s := EnvSecretStore{}
got, err := s.FetchFireblocks(context.Background(), &FireblocksIntent{APIKey: "file-backed-key"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got != "-----PEM-FROM-FILE-----" {
t.Errorf("got %q, want trimmed file contents", got)
}
}
// TestEnvSecretStore_FireblocksFileMissing covers the error path: env
// var points at a file that doesn't exist. Operator sees an actionable
// path-in-error rather than ErrSecretNotConfigured (which would
// suggest the env var is unset).
func TestEnvSecretStore_FireblocksFileMissing(t *testing.T) {
t.Setenv("FIREBLOCKS_COSIGNER_PEM__BROKEN_KEY", "file:/no/such/path/xyz")
s := EnvSecretStore{}
_, err := s.FetchFireblocks(context.Background(), &FireblocksIntent{APIKey: "broken-key"})
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "/no/such/path/xyz") {
t.Errorf("error should name the missing path: %v", err)
}
}
// TestEnvSecretStore_UtilaFromEnvIndirection covers the `env:OTHER_VAR`
// scheme — operator points the cosigner env var at a different env var
// that holds the actual PEM. Useful when secret-management tools
// populate a canonical env name and operators want to alias.
func TestEnvSecretStore_UtilaFromEnvIndirection(t *testing.T) {
t.Setenv("SHARED_UTILA_PEM", "actual-pem-content")
t.Setenv("UTILA_COSIGNER_PEM__INDIRECT_ORG", "env:SHARED_UTILA_PEM")
s := EnvSecretStore{}
got, err := s.FetchUtila(context.Background(), &UtilaIntent{OrgID: "indirect-org"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got != "actual-pem-content" {
t.Errorf("got %q, want indirected value", got)
}
}
func TestEnvSafe(t *testing.T) {
cases := map[string]string{
"": "",
"abc": "ABC",
"abc-def": "ABC_DEF",
"abc.def": "ABC_DEF",
"abc def": "ABC_DEF",
"tenant_42": "TENANT_42",
"contains/slash": "CONTAINS_SLASH",
"AlreadyUpper": "ALREADYUPPER",
}
for in, want := range cases {
if got := envSafe(in); got != want {
t.Errorf("envSafe(%q) = %q, want %q", in, got, want)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
package cosigners
import (
"context"
"fmt"
"os"
"strings"
"github.com/luxfi/bridge/internal/secrets"
)
// EnvSecretStore reads cosigner secrets from environment variables.
// The env var holds a URI passed through internal/secrets.Resolver so
// operators can store the actual PEM in env, file, or KMS:
//
// FIREBLOCKS_COSIGNER_PEM__KEY="-----BEGIN PRIVATE KEY-----\n..." (literal, back-compat)
// FIREBLOCKS_COSIGNER_PEM__KEY="file:/var/run/secrets/fireblocks.pem" (file-mounted, K8s secret pattern)
// FIREBLOCKS_COSIGNER_PEM__KEY="kms:aws:cipher:<base64>" (KMS-wrapped, when provider registered)
//
// Bare PEM contents (without a URI scheme) continue to work as
// literal values — every existing deploy keeps working unchanged.
//
// Env var conventions (match the TS module exactly):
//
// Utila: UTILA_COSIGNER_PEM__<envSafe(org_id)>
// Fireblocks: FIREBLOCKS_COSIGNER_PEM__<envSafe(api_key)>
//
// where envSafe uppercases the identifier and replaces every non-
// alphanumeric character with underscore (POSIX shell-safe).
//
// Zero value is usable. Concurrency-safe (os.Getenv is goroutine-safe
// and the secrets.Default() resolver is concurrency-safe by contract).
type EnvSecretStore struct{}
// FetchUtila returns the service-account PEM for the given Utila org,
// reading from UTILA_COSIGNER_PEM__<envSafe(OrgID)>. The env value is
// passed through internal/secrets.Resolver so file: / kms: schemes
// are supported. Returns ErrSecretNotConfigured wrapped with the env
// var name when unset.
func (EnvSecretStore) FetchUtila(ctx context.Context, intent *UtilaIntent) (string, error) {
if intent == nil || intent.OrgID == "" {
return "", fmt.Errorf("%w: utila intent missing org_id", ErrSecretNotConfigured)
}
envKey := "UTILA_COSIGNER_PEM__" + envSafe(intent.OrgID)
raw := os.Getenv(envKey)
if raw == "" {
return "", fmt.Errorf("%w: env %s unset for utila org_id=%s",
ErrSecretNotConfigured, envKey, intent.OrgID)
}
pem, err := secrets.Default().Resolve(ctx, raw)
if err != nil {
return "", fmt.Errorf("resolve %s: %w", envKey, err)
}
return pem, nil
}
// FetchFireblocks returns the secret PEM for the given Fireblocks api_key,
// reading from FIREBLOCKS_COSIGNER_PEM__<envSafe(APIKey)>. Same URI-scheme
// resolution as FetchUtila — bare PEM values continue to work as literal.
// Returns ErrSecretNotConfigured wrapped with the env var name when unset.
func (EnvSecretStore) FetchFireblocks(ctx context.Context, intent *FireblocksIntent) (string, error) {
if intent == nil || intent.APIKey == "" {
return "", fmt.Errorf("%w: fireblocks intent missing api_key", ErrSecretNotConfigured)
}
envKey := "FIREBLOCKS_COSIGNER_PEM__" + envSafe(intent.APIKey)
raw := os.Getenv(envKey)
if raw == "" {
return "", fmt.Errorf("%w: env %s unset for fireblocks api_key=%s",
ErrSecretNotConfigured, envKey, intent.APIKey)
}
pem, err := secrets.Default().Resolve(ctx, raw)
if err != nil {
return "", fmt.Errorf("resolve %s: %w", envKey, err)
}
return pem, nil
}
// envSafe upper-cases s and replaces every non-alphanumeric byte with
// '_'. Lossy by design — env vars don't allow hyphens / dots; the lossy
// mapping is acceptable because env-var lookup is a dev fallback. KMS
// keys preserve the original identifier verbatim.
func envSafe(s string) string {
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
b.WriteByte(c - ('a' - 'A'))
case c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
b.WriteByte(c)
default:
b.WriteByte('_')
}
}
return b.String()
}
+484
View File
@@ -0,0 +1,484 @@
package cosigners
import (
"bytes"
"context"
"crypto/rsa"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// fireblocks.go — real Fireblocks REST cosigner family.
//
// Wires the Go bridge to Fireblocks's RAW-sign transaction approval
// flow. The bridge POSTs `/v1/transactions` with operation=RAW carrying
// the destination-chain tx hash, then polls `/v1/transactions/{id}`
// until the tx reaches a terminal status. The mapping mirrors the TS
// reference impl at app/server/src/domain/cosigners.ts:344-449 exactly:
//
// COMPLETED | BROADCASTING | CONFIRMING → StatusApproved
// (signature pulled from signedMessages[0].signature.fullSig;
// ExternalID = the Fireblocks tx id, for traceability)
// REJECTED | CANCELLED | BLOCKED → StatusRejected
// (Reason carries `<status>` plus subStatus when present)
// FAILED | TIMEOUT → StatusFailed
// (transient — operator may retry by resubmitting the swap)
// anything else → still in flight, keep polling
//
// Overall timeout matches the TS default (60 s, env-overridable via
// FIREBLOCKS_COSIGNER_TIMEOUT_MS in main.go where the family is
// constructed). Per-poll cadence is 1.5 s — slightly above Fireblocks's
// rate limit, well under the user's patience window.
//
// Authentication: each request signs a fresh JWT with the tenant's
// RSA private key (fetched from KMS via SecretStore.FetchFireblocks).
// See fireblocks_jwt.go for the signing details.
//
// Utila is NOT implemented here — RunUtila delegates to
// UtilaDelegate (or StubFamilyDispatcher as a default) so the family
// can be wired alone without breaking swaps that declare Utila
// intents. A future UtilaConnectRPCFamily will fill that gap.
// FireblocksDefaultPollInterval is how often we poll
// `/v1/transactions/{id}`. The TS reference uses 1500 ms; matching it
// keeps the two implementations' RPC behaviour identical so operators
// see the same Fireblocks-side request count regardless of which
// backend handled the swap.
const FireblocksDefaultPollInterval = 1500 * time.Millisecond
// FireblocksDefaultTimeout caps the entire approval flow (create + all
// polls). Matches the TS default. Operators with slower approval SLAs
// override via the env var the main.go construction site reads.
const FireblocksDefaultTimeout = 60 * time.Second
// FireblocksDefaultAPIHost is Fireblocks's production REST host.
// Tenants on the sandbox plane override via intent.APIHost.
const FireblocksDefaultAPIHost = "https://api.fireblocks.io"
// FireblocksDefaultVaultAccount is what the TS impl sends when the
// intent doesn't specify a vault — Fireblocks's "default" vault.
const FireblocksDefaultVaultAccount = "0"
// Status set partitioning. Mirrors the FB_STATUS_* sets in the TS impl
// at app/server/src/domain/cosigners.ts:344-350.
var (
fireblocksTerminalApprove = map[string]struct{}{
"COMPLETED": {}, // RAW-sign produced a signature
"BROADCASTING": {}, // signed + tx already pushed (effectively terminal for RAW)
"CONFIRMING": {}, // tx in mempool, signature stable
}
fireblocksTerminalReject = map[string]struct{}{
"REJECTED": {},
"CANCELLED": {},
"BLOCKED": {},
}
fireblocksTerminalFail = map[string]struct{}{
"FAILED": {},
"TIMEOUT": {},
}
)
// FireblocksRESTFamily implements FamilyDispatcher with a real
// Fireblocks REST client. Construct with sensible defaults via the
// zero value or override fields explicitly for tests / non-default
// SLAs.
//
// Concurrency: every method on FireblocksRESTFamily is safe to call
// from multiple goroutines. The underlying *http.Client is reused
// across calls (Go's transport pool gives us free connection reuse).
type FireblocksRESTFamily struct {
// HTTPClient overrides the http.Client used for both the POST
// /v1/transactions create and the GET /v1/transactions/{id}
// polls. Zero value falls back to a *new* http.Client with a 30 s
// per-request timeout — that covers Fireblocks's typical 5–15 s
// p99 with comfortable headroom for cold starts.
HTTPClient *http.Client
// PollInterval overrides the gap between status polls. Zero ⇒
// FireblocksDefaultPollInterval. Lower values risk rate-limit
// (Fireblocks's default is roughly 60 req/min/key); higher values
// just slow down approvals.
PollInterval time.Duration
// Timeout caps the total create-plus-poll wall time. Zero ⇒
// FireblocksDefaultTimeout. main.go's --enable-fireblocks-cosigner
// path reads FIREBLOCKS_COSIGNER_TIMEOUT_MS to populate this.
Timeout time.Duration
// UtilaDelegate routes RunUtila calls when this family is wired as
// the bridge's FamilyDispatcher. Zero ⇒ falls back to
// StubFamilyDispatcher (which fails Utila intents with the
// "use app/server" reason). Production wiring will set this to a
// real Utila family once the Connect-RPC port lands.
UtilaDelegate FamilyDispatcher
// Now is the time source. Injectable so tests can pin JWT iat / exp
// and step the deadline deterministically. Zero ⇒ time.Now.
Now func() time.Time
// Sleep is how we wait between polls. Injectable so tests can
// remove the wall-clock delay without losing correctness coverage
// over the poll loop. Zero ⇒ time.Sleep.
Sleep func(time.Duration)
// Nonce supplies the JWT `nonce` claim. Injectable for tests; zero
// value uses crypto/rand via fireblocksNonce().
Nonce func() string
}
// RunUtila delegates to UtilaDelegate (or the StubFamilyDispatcher
// default). The real Utila client is the subject of a follow-up port —
// until that lands, every Utila intent gets a clear "not implemented"
// failure that points operators at app/server.
func (f FireblocksRESTFamily) RunUtila(ctx context.Context, intent *UtilaIntent, secret string, opts DispatchOptions) Result {
delegate := f.UtilaDelegate
if delegate == nil {
delegate = StubFamilyDispatcher{}
}
return delegate.RunUtila(ctx, intent, secret, opts)
}
// RunFireblocks runs the real RAW-sign flow against the Fireblocks
// REST API. Returns a Result describing the terminal state — never
// blocks past f.timeout(). Errors (network, parse, JWT, REST 4xx /
// 5xx) collapse into StatusFailed with the underlying message
// captured in Reason so operators can debug from the swap row alone.
func (f FireblocksRESTFamily) RunFireblocks(ctx context.Context, intent *FireblocksIntent, secret string, opts DispatchOptions) Result {
intentWrap := Intent{Kind: KindFireblocks, Fireblocks: intent}
if intent == nil {
return Result{Intent: intentWrap, Status: StatusFailed, Reason: "fireblocks: nil intent"}
}
if opts.TxHash == "" {
return Result{Intent: intentWrap, Status: StatusFailed, Reason: "fireblocks: empty TxHash — nothing to attest to"}
}
privKey, err := parseRSAPrivateKey(secret)
if err != nil {
return Result{Intent: intentWrap, Status: StatusFailed, Reason: fmt.Sprintf("fireblocks: %v", err)}
}
c := &fireblocksClient{
apiBase: f.apiBase(intent),
apiKey: intent.APIKey,
privateKey: privKey,
http: f.httpClient(),
now: f.now(),
nonce: f.nonce(),
}
createResp, err := c.createTransaction(ctx, opts.SwapID, intent.vaultID(), opts.TxHash)
if err != nil {
return Result{Intent: intentWrap, Status: StatusFailed, Reason: fmt.Sprintf("fireblocks createTransaction: %v", err)}
}
txID := createResp.ID
if txID == "" {
return Result{Intent: intentWrap, Status: StatusFailed, Reason: "fireblocks createTransaction: response missing id"}
}
pollInterval := f.PollInterval
if pollInterval <= 0 {
pollInterval = FireblocksDefaultPollInterval
}
timeout := f.Timeout
if timeout <= 0 {
timeout = FireblocksDefaultTimeout
}
deadline := c.now().Add(timeout)
sleep := f.Sleep
if sleep == nil {
sleep = time.Sleep
}
for {
if ctx.Err() != nil {
return Result{
Intent: intentWrap,
Status: StatusFailed,
Reason: fmt.Sprintf("fireblocks: context cancelled while polling tx=%s: %v", txID, ctx.Err()),
ExternalID: txID,
}
}
if !c.now().Before(deadline) {
return Result{
Intent: intentWrap,
Status: StatusFailed,
Reason: fmt.Sprintf("fireblocks tx %s timed out after %s; manual recheck via getTransactionById may still resolve", txID, timeout),
ExternalID: txID,
}
}
status, gErr := c.getTransaction(ctx, txID)
if gErr != nil {
// Transient — log via caller's logger if any (the
// dispatcher upstream owns logging). Keep polling until
// deadline.
sleep(pollInterval)
continue
}
if _, ok := fireblocksTerminalApprove[status.Status]; ok {
sig := status.signedMessageSig()
if sig == "" {
return Result{
Intent: intentWrap,
Status: StatusFailed,
Reason: fmt.Sprintf("fireblocks tx %s reached %s but signedMessages[0].signature.fullSig is missing", txID, status.Status),
ExternalID: txID,
}
}
return Result{
Intent: intentWrap,
Status: StatusApproved,
Signature: sig,
ExternalID: txID,
}
}
if _, ok := fireblocksTerminalReject[status.Status]; ok {
return Result{
Intent: intentWrap,
Status: StatusRejected,
Reason: fmt.Sprintf("fireblocks %s%s", status.Status, status.subStatusSuffix()),
ExternalID: txID,
}
}
if _, ok := fireblocksTerminalFail[status.Status]; ok {
return Result{
Intent: intentWrap,
Status: StatusFailed,
Reason: fmt.Sprintf("fireblocks %s%s", status.Status, status.subStatusSuffix()),
ExternalID: txID,
}
}
sleep(pollInterval)
}
}
func (f FireblocksRESTFamily) httpClient() *http.Client {
if f.HTTPClient != nil {
return f.HTTPClient
}
// 30 s per-request matches the comfortable bound for Fireblocks
// REST under normal load. The total flow is independently capped
// by f.Timeout.
return &http.Client{Timeout: 30 * time.Second}
}
func (f FireblocksRESTFamily) now() func() time.Time {
if f.Now != nil {
return f.Now
}
return time.Now
}
func (f FireblocksRESTFamily) nonce() func() string {
if f.Nonce != nil {
return f.Nonce
}
return fireblocksNonce
}
func (f FireblocksRESTFamily) apiBase(intent *FireblocksIntent) string {
if intent.APIHost != "" {
return strings.TrimRight(intent.APIHost, "/")
}
return FireblocksDefaultAPIHost
}
// =============================================================================
// fireblocksClient — per-call REST + JWT plumbing
// =============================================================================
type fireblocksClient struct {
apiBase string
apiKey string
privateKey *rsa.PrivateKey
http *http.Client
now func() time.Time
nonce func() string
}
// =============================================================================
// Wire bodies — keep narrow JSON shapes so we don't depend on the full
// Fireblocks response object surface.
// =============================================================================
// createTransactionBody is the POST /v1/transactions payload for a
// RAW-sign approval. We only populate the fields Fireblocks needs;
// extra unknown fields are tolerated by their API so a future Fireblocks
// version adding optional fields won't break us.
type createTransactionBody struct {
Operation string `json:"operation"` // always "RAW" for cosigner attestation
Source createTransactionSource `json:"source"`
Note string `json:"note"`
ExtraParameters extraParameters `json:"extraParameters"`
}
type createTransactionSource struct {
Type string `json:"type"` // always "VAULT_ACCOUNT" for cosigner flow
ID string `json:"id"` // vault account id; "0" = Fireblocks default vault
}
type extraParameters struct {
RawMessageData rawMessageData `json:"rawMessageData"`
}
type rawMessageData struct {
Messages []rawMessage `json:"messages"`
}
type rawMessage struct {
Content string `json:"content"` // hex tx hash without 0x prefix (Fireblocks convention)
}
// createTransactionResponse is the create-call response we depend on.
// Fireblocks returns more fields (`status: SUBMITTED`, etc.) but we
// only need the id for the subsequent poll.
type createTransactionResponse struct {
ID string `json:"id"`
}
// getTransactionResponse is the poll-call response. Same minimal-fields
// principle.
type getTransactionResponse struct {
ID string `json:"id"`
Status string `json:"status"`
SubStatus string `json:"subStatus,omitempty"`
SignedMessages []signedMessageResponse `json:"signedMessages,omitempty"`
}
type signedMessageResponse struct {
Content string `json:"content,omitempty"`
Signature signatureContainer `json:"signature"`
}
type signatureContainer struct {
FullSig string `json:"fullSig"`
R string `json:"r,omitempty"`
S string `json:"s,omitempty"`
V int `json:"v,omitempty"`
}
func (r getTransactionResponse) signedMessageSig() string {
if len(r.SignedMessages) == 0 {
return ""
}
return r.SignedMessages[0].Signature.FullSig
}
func (r getTransactionResponse) subStatusSuffix() string {
if r.SubStatus == "" {
return ""
}
return " subStatus=" + r.SubStatus
}
// =============================================================================
// Client methods — POST create / GET poll
// =============================================================================
// createTransaction issues the RAW-sign approval request. Returns the
// Fireblocks-side transaction id on success.
func (c *fireblocksClient) createTransaction(ctx context.Context, swapID, vaultID, txHash string) (*createTransactionResponse, error) {
body := createTransactionBody{
Operation: "RAW",
Source: createTransactionSource{
Type: "VAULT_ACCOUNT",
ID: vaultID,
},
Note: fmt.Sprintf("lux-bridge cosign swap=%s", swapID),
ExtraParameters: extraParameters{
RawMessageData: rawMessageData{
Messages: []rawMessage{{Content: strings.TrimPrefix(txHash, "0x")}},
},
},
}
bodyJSON, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("marshal create body: %w", err)
}
var resp createTransactionResponse
if err := c.doRequest(ctx, http.MethodPost, "/v1/transactions", bodyJSON, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// getTransaction polls the transaction status.
func (c *fireblocksClient) getTransaction(ctx context.Context, txID string) (*getTransactionResponse, error) {
if txID == "" {
return nil, fmt.Errorf("empty txID")
}
uri := "/v1/transactions/" + txID
var resp getTransactionResponse
if err := c.doRequest(ctx, http.MethodGet, uri, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// doRequest mints a JWT, sets the auth + API-key headers, fires the
// request, and decodes the JSON response. Non-2xx is folded into the
// returned error with the status code + truncated response body for
// debuggability.
func (c *fireblocksClient) doRequest(ctx context.Context, method, uri string, body []byte, into any) error {
jwt, err := signFireblocksJWT(uri, body, c.apiKey, c.privateKey, c.now(), c.nonce())
if err != nil {
return fmt.Errorf("sign jwt: %w", err)
}
var reqBody io.Reader
if body != nil {
reqBody = bytes.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.apiBase+uri, reqBody)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("X-API-Key", c.apiKey)
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
rawBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncateBytes(rawBody, 200))
}
if into != nil {
if err := json.Unmarshal(rawBody, into); err != nil {
return fmt.Errorf("decode response: %w (body=%s)", err, truncateBytes(rawBody, 200))
}
}
return nil
}
// vaultID returns the vault account id to use for the transaction
// request, falling back to FireblocksDefaultVaultAccount when the
// intent doesn't specify one.
func (i *FireblocksIntent) vaultID() string {
if i.VaultAccountID == "" {
return FireblocksDefaultVaultAccount
}
return i.VaultAccountID
}
func truncateBytes(b []byte, n int) string {
if len(b) <= n {
return string(b)
}
return string(b[:n]) + "…"
}
+172
View File
@@ -0,0 +1,172 @@
package cosigners
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"time"
)
// fireblocks_jwt.go — RS256 JWT signing for the Fireblocks REST API.
//
// Fireblocks authenticates every API call with two headers:
//
// Authorization: Bearer <JWT>
// X-API-Key: <intent.APIKey>
//
// The JWT is signed with the tenant's RSA private key (PEM, fetched
// from KMS or the env-fallback by the SecretStore) and carries the
// canonical claim set Fireblocks expects. The claim `bodyHash` is the
// SHA-256 hex digest of the request body — an empty string for GET.
// Claims live only 30 seconds (Fireblocks rejects anything older or
// claiming more than 55 s of validity) so we mint a fresh JWT per
// request, never reuse.
//
// This file implements just enough of RFC 7519 (JWT) + RFC 3447 (PKCS1
// v1.5 RSA) to talk to Fireblocks. Using a dedicated JWT library
// (`golang-jwt/jwt`, `go-jose`) would work but the bridge's go.mod
// currently has neither and the spec we need is ~80 LOC; not worth
// the dep churn. The signing path is covered by tests against a
// freshly-generated RSA-2048 key and a manually-verified signature.
// fireblocksJWTHeader is constant for every Fireblocks call. RS256 is
// the only algorithm Fireblocks accepts. Pre-encoding it avoids a hot-
// path JSON marshal per request.
//
// Decoded form: `{"alg":"RS256","typ":"JWT"}`.
const fireblocksJWTHeader = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"
// fireblocksJWTClaims is the claim set Fireblocks requires.
//
// Field names are the on-the-wire JSON keys (snake_case for nonce/iat/
// exp/sub per RFC 7519; camelCase for bodyHash per Fireblocks docs).
type fireblocksJWTClaims struct {
URI string `json:"uri"`
Nonce string `json:"nonce"`
IssuedAt int64 `json:"iat"`
Expires int64 `json:"exp"`
Subject string `json:"sub"`
BodyHash string `json:"bodyHash"`
}
// fireblocksJWTValidity is how long Fireblocks accepts a JWT for. The
// docs allow up to 55 s; we use 30 s to give clock-skew headroom on
// both ends. Each call mints a fresh JWT — never reuse, because the
// nonce is the only thing preventing replay within the window.
const fireblocksJWTValidity = 30 * time.Second
// signFireblocksJWT builds + signs the JWT for one Fireblocks API call.
//
// - uri: relative path including query string ("/v1/transactions",
// "/v1/transactions/abc?status=COMPLETED", etc.).
// - body: marshalled request body — empty / nil for GET requests.
// bodyHash claim becomes sha256(empty) for missing body,
// matching the Fireblocks docs' "empty payload" rule.
// - apiKey: the tenant's PUBLIC API key (intent.APIKey). Goes into
// the `sub` claim AND the `X-API-Key` header set by the
// caller.
// - privKey: the tenant's RSA private key (parsed by
// parseRSAPrivateKey from the PEM the SecretStore returned).
// - now: time source — injectable so tests can pin iat/exp.
// - nonce: per-call uniqueness token. Tests inject a deterministic
// value; production calls pass a hex-encoded crypto/rand
// read via fireblocksNonce().
//
// Returns the compact-serialised JWT (`<header>.<claims>.<signature>`).
func signFireblocksJWT(uri string, body []byte, apiKey string, privKey *rsa.PrivateKey, now time.Time, nonce string) (string, error) {
if privKey == nil {
return "", errors.New("fireblocks jwt: nil private key")
}
if apiKey == "" {
return "", errors.New("fireblocks jwt: empty api key")
}
bodyHash := sha256.Sum256(body)
claims := fireblocksJWTClaims{
URI: uri,
Nonce: nonce,
IssuedAt: now.Unix(),
Expires: now.Add(fireblocksJWTValidity).Unix(),
Subject: apiKey,
BodyHash: hex.EncodeToString(bodyHash[:]),
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("fireblocks jwt: marshal claims: %w", err)
}
claimsB64 := base64.RawURLEncoding.EncodeToString(claimsJSON)
signingInput := fireblocksJWTHeader + "." + claimsB64
signingHash := sha256.Sum256([]byte(signingInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, privKey, crypto.SHA256, signingHash[:])
if err != nil {
return "", fmt.Errorf("fireblocks jwt: sign: %w", err)
}
sigB64 := base64.RawURLEncoding.EncodeToString(sig)
return signingInput + "." + sigB64, nil
}
// parseRSAPrivateKey decodes a PEM-encoded RSA private key. Accepts
// both formats Fireblocks tenants commonly ship:
//
// - PKCS#1: `-----BEGIN RSA PRIVATE KEY-----`
// - PKCS#8: `-----BEGIN PRIVATE KEY-----`
//
// PKCS#8 carries an outer AlgorithmIdentifier so a single decoder
// covers RSA / EC / Ed25519; we additionally assert the parsed key IS
// RSA before returning so a misconfigured tenant gets a clear error
// rather than a runtime panic during signing.
//
// Leading / trailing whitespace and BOM bytes are tolerated — KMS dumps
// sometimes carry these.
func parseRSAPrivateKey(pemSrc string) (*rsa.PrivateKey, error) {
if pemSrc == "" {
return nil, errors.New("fireblocks jwt: empty PEM")
}
block, _ := pem.Decode([]byte(pemSrc))
if block == nil {
return nil, errors.New("fireblocks jwt: no PEM block (bad format)")
}
// Try PKCS#1 (RSA-only, `RSA PRIVATE KEY` header) first since it's
// the cheaper parse.
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return key, nil
}
// Fall through to PKCS#8 (`PRIVATE KEY` header). The cast
// rejects non-RSA keys with a clear message.
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("fireblocks jwt: parse PEM (tried PKCS#1 + PKCS#8): %w", err)
}
key, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("fireblocks jwt: PEM is %T, not RSA", parsed)
}
return key, nil
}
// fireblocksNonce returns a 16-byte random hex string for per-call JWT
// uniqueness. crypto/rand backs this; in tests a fixed-value generator
// can be plugged into FireblocksRESTFamily.Nonce instead.
func fireblocksNonce() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// crypto/rand failing is catastrophic — surface a clearly-bogus
// nonce so the request fails-fast at JWT verification time
// rather than appearing successful.
return "FALLBACK_NONCE_RAND_READ_FAILED"
}
return hex.EncodeToString(b[:])
}
+617
View File
@@ -0,0 +1,617 @@
package cosigners
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// ───────────────────────────────────────────────────────────────────────
// Test fixtures + helpers
// ───────────────────────────────────────────────────────────────────────
// generateTestKey makes a fresh RSA-2048 key and returns it both as
// the parsed *rsa.PrivateKey and PEM-encoded form. The PEM is what a
// real SecretStore would hand to RunFireblocks.
func generateTestKey(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate rsa key: %v", err)
}
pkcs1 := x509.MarshalPKCS1PrivateKey(key)
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: pkcs1,
})
return key, string(pemBytes)
}
// fbMockState is the in-memory FSM the mock server walks. Tests
// program it before each request.
type fbMockState struct {
mu sync.Mutex
// nextStatuses is consumed on each poll. The first call to
// /v1/transactions/{id} returns nextStatuses[0], the second returns
// nextStatuses[1], etc. When the slice is exhausted the mock
// repeats the last value.
nextStatuses []string
// finalSig is what the mock embeds in signedMessages[0]
// .signature.fullSig once the status reaches one of the terminal-
// approved set. Empty leaves the field out (tests the
// "missing-sig" branch).
finalSig string
// subStatus, when non-empty, is attached to every poll response.
subStatus string
// recordedCreateBodies holds the bodies of every POST
// /v1/transactions for assertion. Cleared per test.
recordedCreateBodies []map[string]any
// recordedJWTs holds the JWT for each request. Allows tests to
// decode and assert claim fields.
recordedJWTs []string
// pollIndex tracks which entry of nextStatuses to serve next.
pollIndex int
// createTxID is the id the mock returns from POST. Tests can
// override; default is "tx-mock-0".
createTxID string
// createStatusCode overrides the create response's HTTP status.
// 0 ⇒ 200.
createStatusCode int
// pollStatusCode, when non-zero, makes every poll respond with this
// HTTP code (and a non-JSON body). Used to test 4xx/5xx polling.
pollStatusCode int
// pollGetCount counts polls; tests can assert non-zero.
pollGetCount atomic.Int32
}
// newFBMock returns a programmable Fireblocks mock backed by httptest.
// Caller programs `state` before calling RunFireblocks, then closes
// the server via t.Cleanup.
func newFBMock(t *testing.T) (*httptest.Server, *fbMockState) {
t.Helper()
state := &fbMockState{createTxID: "tx-mock-0"}
mux := http.NewServeMux()
mux.HandleFunc("POST /v1/transactions", func(w http.ResponseWriter, r *http.Request) {
state.mu.Lock()
defer state.mu.Unlock()
auth := r.Header.Get("Authorization")
state.recordedJWTs = append(state.recordedJWTs, strings.TrimPrefix(auth, "Bearer "))
body, _ := io.ReadAll(r.Body)
var parsed map[string]any
_ = json.Unmarshal(body, &parsed)
state.recordedCreateBodies = append(state.recordedCreateBodies, parsed)
if state.createStatusCode != 0 && state.createStatusCode != 200 {
http.Error(w, "mock_error", state.createStatusCode)
return
}
_ = json.NewEncoder(w).Encode(map[string]string{"id": state.createTxID})
})
mux.HandleFunc("GET /v1/transactions/", func(w http.ResponseWriter, r *http.Request) {
state.pollGetCount.Add(1)
state.mu.Lock()
defer state.mu.Unlock()
auth := r.Header.Get("Authorization")
state.recordedJWTs = append(state.recordedJWTs, strings.TrimPrefix(auth, "Bearer "))
if state.pollStatusCode != 0 && state.pollStatusCode != 200 {
http.Error(w, "mock_poll_error", state.pollStatusCode)
return
}
// pick the next status; sticky on exhaustion
var status string
if state.pollIndex < len(state.nextStatuses) {
status = state.nextStatuses[state.pollIndex]
state.pollIndex++
} else if len(state.nextStatuses) > 0 {
status = state.nextStatuses[len(state.nextStatuses)-1]
}
resp := getTransactionResponse{
ID: state.createTxID,
Status: status,
SubStatus: state.subStatus,
}
if _, terminal := fireblocksTerminalApprove[status]; terminal && state.finalSig != "" {
resp.SignedMessages = []signedMessageResponse{
{Signature: signatureContainer{FullSig: state.finalSig}},
}
}
_ = json.NewEncoder(w).Encode(resp)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, state
}
// newFamilyForTest wires a Fireblocks family that points at the mock,
// uses no sleep, and pins time. Returns the family and a context
// pre-populated with the test's deadline.
func newFamilyForTest(t *testing.T, srv *httptest.Server) FireblocksRESTFamily {
t.Helper()
return FireblocksRESTFamily{
HTTPClient: srv.Client(),
PollInterval: 1 * time.Millisecond,
Timeout: 2 * time.Second,
Sleep: func(time.Duration) {}, // no wall-clock delay in tests
Now: func() time.Time { return time.Unix(1_780_000_000, 0).UTC() },
Nonce: func() string { return "deterministic-nonce" },
}
}
func makeIntent(srv *httptest.Server) *FireblocksIntent {
return &FireblocksIntent{
APIKey: "test-api-key",
APIHost: srv.URL,
VaultAccountID: "42",
}
}
func makeOpts(swapID, txHash string) DispatchOptions {
return DispatchOptions{
SwapID: swapID,
NativeSignature: "0xdeadbeef",
TxHash: txHash,
Cosigners: []Intent{{Kind: KindFireblocks}},
}
}
// ───────────────────────────────────────────────────────────────────────
// Tests — RunFireblocks happy + terminal-status paths
// ───────────────────────────────────────────────────────────────────────
func TestFireblocks_HappyPath_PollsThroughToCompleted(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{
"SUBMITTED",
"QUEUED",
"PENDING_AUTHORIZATION",
"PENDING_SIGNATURE",
"COMPLETED",
}
state.finalSig = "0xabcd1234"
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem, makeOpts("swap_h", "0x1111"))
if got.Status != StatusApproved {
t.Fatalf("expected StatusApproved, got %s — reason=%q", got.Status, got.Reason)
}
if got.Signature != "0xabcd1234" {
t.Errorf("expected signature 0xabcd1234, got %q", got.Signature)
}
if got.ExternalID != "tx-mock-0" {
t.Errorf("expected ExternalID=tx-mock-0, got %q", got.ExternalID)
}
if state.pollGetCount.Load() < 5 {
t.Errorf("expected at least 5 polls, got %d", state.pollGetCount.Load())
}
}
func TestFireblocks_TerminalApprove_Variants(t *testing.T) {
for _, status := range []string{"COMPLETED", "BROADCASTING", "CONFIRMING"} {
t.Run(status, func(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{status}
state.finalSig = "0xfeedface"
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_"+status, "0x2222"))
if got.Status != StatusApproved {
t.Errorf("status=%s: expected StatusApproved, got %s reason=%q", status, got.Status, got.Reason)
}
})
}
}
func TestFireblocks_TerminalReject_Variants(t *testing.T) {
for _, status := range []string{"REJECTED", "CANCELLED", "BLOCKED"} {
t.Run(status, func(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{status}
state.subStatus = "USER_DENIED"
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_rej", "0x3333"))
if got.Status != StatusRejected {
t.Fatalf("status=%s: expected StatusRejected, got %s reason=%q", status, got.Status, got.Reason)
}
if !strings.Contains(got.Reason, status) {
t.Errorf("reason should mention status %q, got %q", status, got.Reason)
}
if !strings.Contains(got.Reason, "USER_DENIED") {
t.Errorf("reason should include subStatus, got %q", got.Reason)
}
})
}
}
func TestFireblocks_TerminalFail_Variants(t *testing.T) {
for _, status := range []string{"FAILED", "TIMEOUT"} {
t.Run(status, func(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{status}
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_fail", "0x4444"))
if got.Status != StatusFailed {
t.Errorf("status=%s: expected StatusFailed, got %s reason=%q", status, got.Status, got.Reason)
}
})
}
}
func TestFireblocks_CompletedButNoSignature_Failed(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{"COMPLETED"}
state.finalSig = "" // intentionally missing
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_nosig", "0x5555"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed when sig missing, got %s", got.Status)
}
if !strings.Contains(got.Reason, "signedMessages[0].signature.fullSig") {
t.Errorf("reason should mention the missing field, got %q", got.Reason)
}
}
// ───────────────────────────────────────────────────────────────────────
// Failure-mode tests — HTTP / network / parse / timeout
// ───────────────────────────────────────────────────────────────────────
func TestFireblocks_CreateHTTPError_Failed(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.createStatusCode = 401
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_401", "0x6666"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on 401, got %s", got.Status)
}
if !strings.Contains(got.Reason, "401") {
t.Errorf("reason should mention HTTP 401, got %q", got.Reason)
}
if got.ExternalID != "" {
t.Errorf("ExternalID should be empty when create fails, got %q", got.ExternalID)
}
}
func TestFireblocks_PollHTTPError_KeepsPollingUntilTimeout(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.pollStatusCode = 500
fam := newFamilyForTest(t, srv)
fam.Timeout = 50 * time.Millisecond
// Timeout tests need the deadline to advance through real time.
// The default pinned Now would make c.now().Before(deadline) true
// forever and the loop would never exit.
fam.Now = nil
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_pollerr", "0x7777"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on poll timeout, got %s", got.Status)
}
if !strings.Contains(got.Reason, "timed out") {
t.Errorf("reason should mention timeout, got %q", got.Reason)
}
if got.ExternalID != "tx-mock-0" {
t.Errorf("ExternalID should carry the tx-id even on timeout, got %q", got.ExternalID)
}
if state.pollGetCount.Load() < 1 {
t.Errorf("expected at least 1 poll attempt, got %d", state.pollGetCount.Load())
}
}
func TestFireblocks_NeverTerminal_TimesOut(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{"PENDING_AUTHORIZATION"} // sticks forever
fam := newFamilyForTest(t, srv)
fam.Timeout = 25 * time.Millisecond
// Use real-time Now so the deadline actually advances.
fam.Now = nil
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_to", "0x8888"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on timeout, got %s", got.Status)
}
if !strings.Contains(got.Reason, "timed out") {
t.Errorf("reason should mention timeout, got %q", got.Reason)
}
}
func TestFireblocks_ContextCancelled_Failed(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{"PENDING_AUTHORIZATION"}
fam := newFamilyForTest(t, srv)
fam.Timeout = 5 * time.Second
// Make the sleep block so context cancellation gets observed at the
// loop boundary.
fam.Sleep = func(d time.Duration) { time.Sleep(d) }
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
defer cancel()
got := fam.RunFireblocks(ctx, makeIntent(srv), pem, makeOpts("swap_ctx", "0x9999"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on cancellation, got %s", got.Status)
}
if !strings.Contains(got.Reason, "context") {
t.Errorf("reason should mention context cancellation, got %q", got.Reason)
}
}
func TestFireblocks_MalformedPEM_Failed(t *testing.T) {
fam := FireblocksRESTFamily{
Sleep: func(time.Duration) {},
PollInterval: 1 * time.Millisecond,
Timeout: 1 * time.Second,
}
got := fam.RunFireblocks(context.Background(),
&FireblocksIntent{APIKey: "k"}, "not-a-pem",
makeOpts("swap_pem", "0xaaaa"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on bad PEM, got %s", got.Status)
}
if !strings.Contains(got.Reason, "PEM") {
t.Errorf("reason should mention PEM, got %q", got.Reason)
}
}
func TestFireblocks_EmptyTxHash_Failed(t *testing.T) {
_, pem := generateTestKey(t)
fam := FireblocksRESTFamily{Sleep: func(time.Duration) {}}
got := fam.RunFireblocks(context.Background(),
&FireblocksIntent{APIKey: "k"}, pem,
makeOpts("swap_emptyhash", ""))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on empty TxHash, got %s", got.Status)
}
if !strings.Contains(got.Reason, "TxHash") {
t.Errorf("reason should mention empty TxHash, got %q", got.Reason)
}
}
func TestFireblocks_NilIntent_Failed(t *testing.T) {
_, pem := generateTestKey(t)
fam := FireblocksRESTFamily{Sleep: func(time.Duration) {}}
got := fam.RunFireblocks(context.Background(), nil, pem, makeOpts("swap_nil", "0xbbbb"))
if got.Status != StatusFailed {
t.Fatalf("expected StatusFailed on nil intent, got %s", got.Status)
}
}
// ───────────────────────────────────────────────────────────────────────
// Wire-shape + auth assertions — confirm Fireblocks gets what it expects
// ───────────────────────────────────────────────────────────────────────
func TestFireblocks_CreateBody_RAWSignShape(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{"COMPLETED"}
state.finalSig = "0xsig"
fam := newFamilyForTest(t, srv)
intent := &FireblocksIntent{APIKey: "k", APIHost: srv.URL, VaultAccountID: "vault-7"}
got := fam.RunFireblocks(context.Background(), intent, pem,
makeOpts("swap_body", "0xff00"))
if got.Status != StatusApproved {
t.Fatalf("preflight: expected StatusApproved, got %s reason=%q", got.Status, got.Reason)
}
state.mu.Lock()
defer state.mu.Unlock()
if len(state.recordedCreateBodies) != 1 {
t.Fatalf("expected 1 create body recorded, got %d", len(state.recordedCreateBodies))
}
body := state.recordedCreateBodies[0]
if body["operation"] != "RAW" {
t.Errorf("operation = %v, want RAW", body["operation"])
}
src, _ := body["source"].(map[string]any)
if src["type"] != "VAULT_ACCOUNT" {
t.Errorf("source.type = %v, want VAULT_ACCOUNT", src["type"])
}
if src["id"] != "vault-7" {
t.Errorf("source.id = %v, want vault-7", src["id"])
}
note, _ := body["note"].(string)
if !strings.Contains(note, "swap_body") {
t.Errorf("note should mention swap id, got %q", note)
}
ep, _ := body["extraParameters"].(map[string]any)
rmd, _ := ep["rawMessageData"].(map[string]any)
msgs, _ := rmd["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("expected 1 message, got %d", len(msgs))
}
msg, _ := msgs[0].(map[string]any)
if msg["content"] != "ff00" {
t.Errorf("message.content = %v, want ff00 (0x-stripped)", msg["content"])
}
}
func TestFireblocks_DefaultVaultAccount_WhenIntentBlank(t *testing.T) {
_, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{"COMPLETED"}
state.finalSig = "0xsig"
fam := newFamilyForTest(t, srv)
intent := &FireblocksIntent{APIKey: "k", APIHost: srv.URL} // no VaultAccountID
_ = fam.RunFireblocks(context.Background(), intent, pem,
makeOpts("swap_dvault", "0xcccc"))
state.mu.Lock()
defer state.mu.Unlock()
if len(state.recordedCreateBodies) != 1 {
t.Fatalf("expected create body recorded")
}
src, _ := state.recordedCreateBodies[0]["source"].(map[string]any)
if src["id"] != FireblocksDefaultVaultAccount {
t.Errorf("expected default vault %q, got %v", FireblocksDefaultVaultAccount, src["id"])
}
}
func TestFireblocks_RequestCarriesValidJWT(t *testing.T) {
key, pem := generateTestKey(t)
srv, state := newFBMock(t)
state.nextStatuses = []string{"COMPLETED"}
state.finalSig = "0xsig"
fam := newFamilyForTest(t, srv)
got := fam.RunFireblocks(context.Background(), makeIntent(srv), pem,
makeOpts("swap_jwt", "0xdddd"))
if got.Status != StatusApproved {
t.Fatalf("preflight: %s reason=%q", got.Status, got.Reason)
}
state.mu.Lock()
jwts := append([]string(nil), state.recordedJWTs...)
state.mu.Unlock()
if len(jwts) < 1 {
t.Fatalf("expected at least one JWT recorded")
}
for _, jwt := range jwts {
assertJWTValid(t, jwt, &key.PublicKey)
}
}
// assertJWTValid decodes the JWT, asserts the alg/header, claim
// signatures, and that the signature verifies against the test's
// public key.
func assertJWTValid(t *testing.T, jwt string, pub *rsa.PublicKey) {
t.Helper()
parts := strings.Split(jwt, ".")
if len(parts) != 3 {
t.Fatalf("jwt should have 3 segments, got %d", len(parts))
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatalf("decode header: %v", err)
}
var header struct{ Alg, Typ string }
if err := json.Unmarshal(headerJSON, &header); err != nil {
t.Fatalf("parse header: %v", err)
}
if header.Alg != "RS256" || header.Typ != "JWT" {
t.Errorf("unexpected header: %+v", header)
}
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatalf("decode claims: %v", err)
}
var claims fireblocksJWTClaims
if err := json.Unmarshal(claimsJSON, &claims); err != nil {
t.Fatalf("parse claims: %v", err)
}
if claims.Subject != "test-api-key" {
t.Errorf("sub = %q, want test-api-key", claims.Subject)
}
if claims.Nonce != "deterministic-nonce" {
t.Errorf("nonce = %q, want deterministic-nonce", claims.Nonce)
}
if claims.URI == "" {
t.Errorf("uri claim missing")
}
if claims.BodyHash == "" {
t.Errorf("bodyHash claim missing — should be sha256 of body, even when empty")
}
if claims.Expires <= claims.IssuedAt {
t.Errorf("exp (%d) should be > iat (%d)", claims.Expires, claims.IssuedAt)
}
// Signature verification — replicate the signing input and call rsa.VerifyPKCS1v15.
signingInput := parts[0] + "." + parts[1]
sigBytes, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatalf("decode sig: %v", err)
}
if err := verifyRS256(pub, []byte(signingInput), sigBytes); err != nil {
t.Errorf("jwt signature does not verify against the test key: %v", err)
}
}
func verifyRS256(pub *rsa.PublicKey, signingInput, sig []byte) error {
hashed := sha256.Sum256(signingInput)
return rsa.VerifyPKCS1v15(pub, crypto.SHA256, hashed[:], sig)
}
// ───────────────────────────────────────────────────────────────────────
// Utila delegation — confirm RunUtila falls through to a delegate
// ───────────────────────────────────────────────────────────────────────
func TestFireblocks_RunUtila_DelegatesToStubByDefault(t *testing.T) {
got := FireblocksRESTFamily{}.RunUtila(context.Background(),
&UtilaIntent{OrgID: "o", ClientID: "c"}, "secret",
makeOpts("swap_u", "0xeeee"))
if got.Status != StatusFailed {
t.Errorf("default delegate = stub, expected StatusFailed, got %s", got.Status)
}
if !strings.Contains(got.Reason, "not yet implemented") {
t.Errorf("expected stub's 'not yet implemented' reason, got %q", got.Reason)
}
}
func TestFireblocks_RunUtila_CustomDelegate(t *testing.T) {
approving := approvingUtilaDelegate{}
fam := FireblocksRESTFamily{UtilaDelegate: approving}
got := fam.RunUtila(context.Background(),
&UtilaIntent{OrgID: "o", ClientID: "c"}, "secret",
makeOpts("swap_u2", "0xffff"))
if got.Status != StatusApproved {
t.Errorf("custom delegate should approve, got %s reason=%q", got.Status, got.Reason)
}
}
// approvingUtilaDelegate is a test-only FamilyDispatcher that approves
// Utila calls and ignores Fireblocks (panics if called).
type approvingUtilaDelegate struct{}
func (approvingUtilaDelegate) RunUtila(_ context.Context, intent *UtilaIntent, _ string, _ DispatchOptions) Result {
return Result{
Intent: Intent{Kind: KindUtila, Utila: intent},
Status: StatusApproved,
Signature: "utila-mock-sig",
ExternalID: "utila-mock-ext",
}
}
func (approvingUtilaDelegate) RunFireblocks(_ context.Context, _ *FireblocksIntent, _ string, _ DispatchOptions) Result {
panic("approvingUtilaDelegate.RunFireblocks should not be called")
}
+41
View File
@@ -0,0 +1,41 @@
package cosigners
import (
"context"
"fmt"
)
// StubFamilyDispatcher fails every cosign step with a clear "not
// implemented in Go bridge" reason. Used as the default during Phase 1.4
// so that institutional users hitting cmd/bridge see a LOUD failure
// pointing them at app/server, instead of the current silent-drop
// regression where the swap completes without cosigning at all.
//
// Drop-in replacements:
// - For Fireblocks: a real RESTful Fireblocks client (Task #39).
// - For Utila: pending implementation of the @luxfi/utila Connect-RPC
// transaction-approval flow (TS module currently also a stub).
//
// Tests should swap in MockFamilyDispatcher instead of using this — the
// stub's whole purpose is to produce a known failure shape, not to be
// mockable.
type StubFamilyDispatcher struct{}
// RunUtila returns StatusFailed with a clear "not implemented" reason.
func (StubFamilyDispatcher) RunUtila(_ context.Context, intent *UtilaIntent, _ string, opts DispatchOptions) Result {
return Result{
Intent: Intent{Kind: KindUtila, Utila: intent},
Status: StatusFailed,
Reason: fmt.Sprintf("utila cosign not yet implemented in cmd/bridge (Go); use app/server (Express) for swap=%s, or wait for §3.5 e2e soak", opts.SwapID),
}
}
// RunFireblocks returns StatusFailed with a clear "not implemented" reason
// that points at Task #39 (the real REST client work).
func (StubFamilyDispatcher) RunFireblocks(_ context.Context, intent *FireblocksIntent, _ string, opts DispatchOptions) Result {
return Result{
Intent: Intent{Kind: KindFireblocks, Fireblocks: intent},
Status: StatusFailed,
Reason: fmt.Sprintf("fireblocks cosign not yet implemented in cmd/bridge (Go); REST client + JWT signing pending — see Task #39 for swap=%s", opts.SwapID),
}
}
+179
View File
@@ -0,0 +1,179 @@
package cosigners
import (
"context"
"fmt"
"time"
)
// utila.go — Utila Connect-RPC cosigner family (scaffold).
//
// This file is the integration scaffold for the @luxfi/utila client.
// The real implementation is NOT yet ported — every UtilaIntent today
// gets a `StatusFailed` result with a clear "not yet implemented"
// reason. The structure mirrors FireblocksRESTFamily so a future port
// is a contained change to ONE method (RunUtila below) without
// disturbing any consumers.
//
// Why keep a dedicated scaffold instead of leaving Utila in
// StubFamilyDispatcher? Two reasons:
//
// 1. Make the integration point discoverable. New contributors who
// ask "where does Utila plug in?" can find this file directly
// rather than chasing through a shared stub.
//
// 2. Allow composing real-Fireblocks + real-Utila independently. The
// two families are wired into the bridge separately via the
// CompositeFamilyDispatcher below; an operator who only enables
// Fireblocks doesn't have to think about Utila and vice-versa.
//
// Symmetry with FireblocksRESTFamily:
//
// FireblocksRESTFamily: real Fireblocks, delegates RunUtila to a configured family (default stub).
// UtilaConnectRPCFamily: real Utila (TODO), delegates RunFireblocks to a configured family (default stub).
// CompositeFamilyDispatcher: real both — explicit per-family wiring.
//
// Reference: app/server/src/domain/cosigners.ts:281-306 (TS stub
// equivalent to this file).
// UtilaConnectRPCFamily implements FamilyDispatcher with — once the
// real @luxfi/utila client lands — the real Connect-RPC transaction-
// approval flow on the Utila side. The Fireblocks side delegates to
// FireblocksDelegate (default StubFamilyDispatcher) so an operator
// who only wants real Utila doesn't accidentally force real Fireblocks.
//
// Today RunUtila returns StatusFailed. When the implementation lands,
// fill in the method body per the TODO block below — no other type
// or interface change required.
type UtilaConnectRPCFamily struct {
// FireblocksDelegate routes RunFireblocks calls when this family
// is wired into the bridge alone (without an explicit Fireblocks
// family). Zero ⇒ falls back to StubFamilyDispatcher (intents
// fail with the "use app/server" reason). Production wiring that
// wants both Utila + Fireblocks REAL should compose via
// CompositeFamilyDispatcher below rather than setting this field.
FireblocksDelegate FamilyDispatcher
// Timeout caps the entire Utila approval flow (create + polls or
// webhook wait). Zero defers to whatever the real implementation
// picks as its default — currently irrelevant since RunUtila is
// a stub.
Timeout time.Duration
// (Other knobs — HTTPClient, PollInterval, Now, Nonce, etc. —
// will be added when the real client lands. Keep the same
// pluggability surface as FireblocksRESTFamily so tests can
// inject deterministic time + transport.)
}
// RunUtila is the integration point for the real Utila Connect-RPC
// client. Today: returns StatusFailed with a "not yet implemented"
// reason. The signing driver's policy ("all listed must approve")
// then moves the swap to refund_pending so the user's deposit is
// returned. This is the LOUD failure mode — better than the silent
// drop where cmd/bridge accepted Utila intents but never dispatched.
//
// TODO(utila-port): Replace this body with the real flow:
//
// 1. Build a per-tenant @luxfi/utila Connect-RPC client using
// serviceAccountAuthStrategy:
//
// - email: derive from intent.ClientID per the Utila docs
// - privateKey: the secret PEM (already loaded by SecretStore)
//
// Do NOT reuse the singleton in app/server/src/domain/utila.ts —
// that one is scoped to global env vars (primary-signer mode).
// This is a per-tenant client.
//
// 2. Submit a transaction-approval request against intent.VaultID
// (or the tenant's default vault when unset) referencing
// opts.TxHash and opts.NativeSignature.
//
// 3. Either poll the approval status or wait on Utila's webhook.
// The webhook signature verifier already exists in
// app/server/src/domain/utila.ts — reuse `utilaPublicKey` +
// `verifySignature` from the equivalent Go module when porting.
//
// 4. Return:
//
// - StatusApproved with Signature = Utila's attestation tx hash,
// ExternalID = Utila's request id
// - StatusRejected with Reason = denial cause from the workflow
// - StatusFailed with Reason = transport / config error
//
// Reference: app/server/src/domain/cosigners.ts:281-306.
func (u UtilaConnectRPCFamily) RunUtila(_ context.Context, intent *UtilaIntent, _ string, opts DispatchOptions) Result {
return Result{
Intent: Intent{Kind: KindUtila, Utila: intent},
Status: StatusFailed,
Reason: fmt.Sprintf(
"utila cosigner real impl not yet ported (scaffold in internal/cosigners/utila.go — see TODO(utila-port)); "+
"swap=%s would have submitted approval for org=%s client=%s vault=%s",
opts.SwapID, intent.OrgID, intent.ClientID, vaultOrDefault(intent.VaultID, "(tenant default)"),
),
}
}
// RunFireblocks delegates to the configured FireblocksDelegate so an
// operator who wired this family standalone (without composing through
// CompositeFamilyDispatcher) still gets a defined behaviour for
// Fireblocks intents — the stub failure, by default.
func (u UtilaConnectRPCFamily) RunFireblocks(ctx context.Context, intent *FireblocksIntent, secret string, opts DispatchOptions) Result {
delegate := u.FireblocksDelegate
if delegate == nil {
delegate = StubFamilyDispatcher{}
}
return delegate.RunFireblocks(ctx, intent, secret, opts)
}
// CompositeFamilyDispatcher routes per-family calls to separately
// configured runners. The intended wiring once Utila lands:
//
// families := cosigners.CompositeFamilyDispatcher{
// UtilaFamily: cosigners.UtilaConnectRPCFamily{Timeout: 60*time.Second},
// FireblocksFamily: cosigners.FireblocksRESTFamily{Timeout: 60*time.Second},
// }
// dispatcher := cosigners.NewDefault(secretStore, families)
//
// Either field may be nil — a nil family falls back to the
// StubFamilyDispatcher behaviour for that family. This is the
// recommended wiring even today: operators set FireblocksFamily =
// FireblocksRESTFamily{} and leave UtilaFamily nil; intents for the
// nil family fail with the stub reason.
type CompositeFamilyDispatcher struct {
// UtilaFamily is consulted on every RunUtila call. Zero ⇒
// StubFamilyDispatcher's RunUtila.
UtilaFamily FamilyDispatcher
// FireblocksFamily is consulted on every RunFireblocks call.
// Zero ⇒ StubFamilyDispatcher's RunFireblocks.
FireblocksFamily FamilyDispatcher
}
// RunUtila delegates to UtilaFamily (or the stub).
func (c CompositeFamilyDispatcher) RunUtila(ctx context.Context, intent *UtilaIntent, secret string, opts DispatchOptions) Result {
delegate := c.UtilaFamily
if delegate == nil {
delegate = StubFamilyDispatcher{}
}
return delegate.RunUtila(ctx, intent, secret, opts)
}
// RunFireblocks delegates to FireblocksFamily (or the stub).
func (c CompositeFamilyDispatcher) RunFireblocks(ctx context.Context, intent *FireblocksIntent, secret string, opts DispatchOptions) Result {
delegate := c.FireblocksFamily
if delegate == nil {
delegate = StubFamilyDispatcher{}
}
return delegate.RunFireblocks(ctx, intent, secret, opts)
}
// vaultOrDefault returns vault when non-empty, otherwise the supplied
// default placeholder. Tiny helper to keep the stub's Reason message
// readable when the intent omits VaultID.
func vaultOrDefault(vault, def string) string {
if vault == "" {
return def
}
return vault
}
+160
View File
@@ -0,0 +1,160 @@
package cosigners
import (
"context"
"strings"
"testing"
)
// TestUtilaConnectRPCFamily_RunUtila_StubFailureToday documents the
// current contract: until the real Utila Connect-RPC client lands,
// every UtilaIntent fails with a clear "not yet implemented" reason.
// The signing driver's policy ("all listed must approve") then moves
// the swap to refund_pending so the user's deposit is returned.
//
// When the real implementation lands, REPLACE THIS TEST with a real
// flow assertion — but keep the "fail without secret" / "fail on
// transport error" coverage that already exists for FireblocksRESTFamily.
func TestUtilaConnectRPCFamily_RunUtila_StubFailureToday(t *testing.T) {
got := UtilaConnectRPCFamily{}.RunUtila(context.Background(),
&UtilaIntent{
OrgID: "tenant-x",
ClientID: "lux-bridge",
VaultID: "v-42",
},
"unused-secret",
DispatchOptions{SwapID: "swap_uscaffold", TxHash: "0xabcd"},
)
if got.Status != StatusFailed {
t.Errorf("expected StatusFailed (real impl pending), got %s reason=%q", got.Status, got.Reason)
}
for _, want := range []string{"not yet ported", "internal/cosigners/utila.go", "swap_uscaffold", "tenant-x", "lux-bridge", "v-42"} {
if !strings.Contains(got.Reason, want) {
t.Errorf("reason should mention %q for grepability, got %q", want, got.Reason)
}
}
}
func TestUtilaConnectRPCFamily_RunUtila_DefaultVaultPlaceholder(t *testing.T) {
// VaultID omitted — reason should say "(tenant default)" so a
// reader knows the intent didn't specify a vault.
got := UtilaConnectRPCFamily{}.RunUtila(context.Background(),
&UtilaIntent{OrgID: "o", ClientID: "c"},
"unused-secret",
DispatchOptions{SwapID: "swap_dvault"},
)
if !strings.Contains(got.Reason, "(tenant default)") {
t.Errorf("reason should label the default-vault case, got %q", got.Reason)
}
}
func TestUtilaConnectRPCFamily_RunFireblocks_DelegatesToStubByDefault(t *testing.T) {
got := UtilaConnectRPCFamily{}.RunFireblocks(context.Background(),
&FireblocksIntent{APIKey: "k"},
"unused",
DispatchOptions{SwapID: "swap_uf"},
)
if got.Status != StatusFailed {
t.Errorf("default Fireblocks delegate = stub, want StatusFailed, got %s", got.Status)
}
if !strings.Contains(got.Reason, "not yet implemented") {
t.Errorf("expected stub's reason, got %q", got.Reason)
}
}
func TestUtilaConnectRPCFamily_RunFireblocks_CustomDelegate(t *testing.T) {
delegate := approvingFireblocksDelegate{}
got := UtilaConnectRPCFamily{FireblocksDelegate: delegate}.RunFireblocks(
context.Background(),
&FireblocksIntent{APIKey: "k"},
"unused",
DispatchOptions{SwapID: "swap_uf2"},
)
if got.Status != StatusApproved {
t.Errorf("custom delegate should approve, got %s reason=%q", got.Status, got.Reason)
}
}
// approvingFireblocksDelegate is a test-only family that approves
// Fireblocks calls and panics on Utila (asserts routing).
type approvingFireblocksDelegate struct{}
func (approvingFireblocksDelegate) RunUtila(_ context.Context, _ *UtilaIntent, _ string, _ DispatchOptions) Result {
panic("approvingFireblocksDelegate.RunUtila should not be called")
}
func (approvingFireblocksDelegate) RunFireblocks(_ context.Context, intent *FireblocksIntent, _ string, _ DispatchOptions) Result {
return Result{
Intent: Intent{Kind: KindFireblocks, Fireblocks: intent},
Status: StatusApproved,
Signature: "fb-test-sig",
ExternalID: "fb-test-ext",
}
}
// ───────────────────────────────────────────────────────────────────────
// CompositeFamilyDispatcher — verifies routing in both directions
// ───────────────────────────────────────────────────────────────────────
func TestCompositeFamilyDispatcher_BothNilFallsBackToStub(t *testing.T) {
c := CompositeFamilyDispatcher{}
utilaGot := c.RunUtila(context.Background(),
&UtilaIntent{OrgID: "o", ClientID: "c"}, "x",
DispatchOptions{SwapID: "swap_cu"})
if utilaGot.Status != StatusFailed {
t.Errorf("zero Utila → stub failure expected, got %s", utilaGot.Status)
}
fbGot := c.RunFireblocks(context.Background(),
&FireblocksIntent{APIKey: "k"}, "x",
DispatchOptions{SwapID: "swap_cf"})
if fbGot.Status != StatusFailed {
t.Errorf("zero Fireblocks → stub failure expected, got %s", fbGot.Status)
}
}
func TestCompositeFamilyDispatcher_RoutesPerFamily(t *testing.T) {
c := CompositeFamilyDispatcher{
UtilaFamily: approvingUtilaDelegate{}, // from fireblocks_test.go — approves Utila, panics on Fireblocks
FireblocksFamily: approvingFireblocksDelegate{}, // approves Fireblocks, panics on Utila
}
uGot := c.RunUtila(context.Background(),
&UtilaIntent{OrgID: "o", ClientID: "c"}, "x",
DispatchOptions{SwapID: "swap_cu2"})
if uGot.Status != StatusApproved {
t.Errorf("Utila family should approve, got %s reason=%q", uGot.Status, uGot.Reason)
}
fGot := c.RunFireblocks(context.Background(),
&FireblocksIntent{APIKey: "k"}, "x",
DispatchOptions{SwapID: "swap_cf2"})
if fGot.Status != StatusApproved {
t.Errorf("Fireblocks family should approve, got %s reason=%q", fGot.Status, fGot.Reason)
}
}
// TestCompositeFamilyDispatcher_MixedRealAndNil shows the wiring that
// today's --enable-fireblocks-cosigner path produces under the hood:
// real Fireblocks + nil Utila ⇒ Utila intents fail loudly, Fireblocks
// intents go to the real runner.
func TestCompositeFamilyDispatcher_MixedRealAndNil(t *testing.T) {
c := CompositeFamilyDispatcher{
// UtilaFamily intentionally nil — falls back to stub
FireblocksFamily: approvingFireblocksDelegate{},
}
uGot := c.RunUtila(context.Background(),
&UtilaIntent{OrgID: "o", ClientID: "c"}, "x",
DispatchOptions{SwapID: "swap_mix_u"})
if uGot.Status != StatusFailed {
t.Errorf("nil Utila → stub failure expected, got %s", uGot.Status)
}
fGot := c.RunFireblocks(context.Background(),
&FireblocksIntent{APIKey: "k"}, "x",
DispatchOptions{SwapID: "swap_mix_f"})
if fGot.Status != StatusApproved {
t.Errorf("real Fireblocks should approve, got %s", fGot.Status)
}
}
+217
View File
@@ -0,0 +1,217 @@
// Package secrets resolves secret values from URI-scheme references.
//
// The bridge stores secrets (mpcd bearer tokens, cosigner PEMs) in
// many places: env vars during dev, mounted files in Kubernetes,
// cloud KMS in production. Hard-coding any one source forces operators
// to choose between dev ergonomics and prod security.
//
// This package solves that with a single URI-scheme indirection:
//
// "literal:abc123" → "abc123"
// "env:BRIDGE_MPC_TOKEN" → os.Getenv("BRIDGE_MPC_TOKEN")
// "file:/var/run/secrets/mpc-token" → contents of that file, trimmed
// "kms:aws:us-east-1:cipher:..." → AWS KMS decrypt (when provider registered)
//
// Values WITHOUT a scheme are treated as `literal:` for back-compat —
// every existing `--mpc-token=raw-string` keeps working unchanged.
//
// KMS providers are pluggable via Register so the bridge binary
// doesn't drag in the AWS SDK by default. A deployment that wants
// AWS KMS imports a wrapper package that calls
// secrets.RegisterKMS("aws", ...) in its init().
//
// Concurrency: Default() is safe for concurrent use after construction.
// Register is NOT safe for concurrent use — call it from init() only.
package secrets
import (
"context"
"errors"
"fmt"
"os"
"strings"
)
// Resolver maps a URI to the plaintext secret it references.
//
// The returned string is the plaintext — callers must treat it as
// sensitive: don't log it, don't write it to disk uncontrolled,
// don't include it in error messages back to clients.
//
// Errors are surfaced unwrapped — the caller decides whether a
// missing secret is fatal (mpcd token at boot) or recoverable
// (cosigner intent for an unknown tenant).
type Resolver interface {
Resolve(ctx context.Context, uri string) (string, error)
}
// ErrNoScheme is returned when a URI has no `<scheme>:` prefix AND
// the resolver was constructed without a literal-fallback default.
// Default() handles this case for back-compat; custom chains may not.
var ErrNoScheme = errors.New("secrets: URI missing <scheme>: prefix")
// ErrUnknownScheme is returned when the URI's scheme has no registered
// resolver. Helps operators distinguish a typo (e.g. `fle:/path`) from
// a missing secret value.
var ErrUnknownScheme = errors.New("secrets: unknown URI scheme")
// ErrKMSNotRegistered is returned by the kms scheme when no provider
// has been registered for the requested KMS family (aws, gcp, vault).
// Operators land in this branch when they configure a kms: URI but
// haven't imported a KMS provider package.
var ErrKMSNotRegistered = errors.New("secrets: no KMS provider registered — import a provider package (e.g. internal/secrets/awskms) in main")
// KMSProvider decrypts a KMS-wrapped ciphertext. The opaque-string
// shape is intentional: AWS KMS, GCP KMS, and Vault Transit all have
// different parameter formats; each provider parses its own slice
// of the URI after the family prefix.
//
// Implementations MUST be safe for concurrent use.
type KMSProvider interface {
Decrypt(ctx context.Context, opaque string) (string, error)
}
// kmsRegistry holds KMS providers keyed by family (aws, gcp, vault).
// Populated by init() in concrete provider packages so the default
// binary stays free of cloud-SDK weight.
var kmsRegistry = map[string]KMSProvider{}
// RegisterKMS associates a KMSProvider with a family identifier
// (e.g. "aws", "gcp", "vault"). Call from a provider package's init().
// Overwrites any prior registration for the family — last write wins.
//
// NOT safe for concurrent use. The intended pattern is:
//
// package main
//
// import _ "github.com/luxfi/bridge/internal/secrets/awskms"
// // awskms.init() calls secrets.RegisterKMS("aws", &awskms.Provider{})
func RegisterKMS(family string, p KMSProvider) {
if family == "" || p == nil {
return
}
kmsRegistry[family] = p
}
// Default returns a Resolver that recognizes the built-in schemes
// (literal, env, file, kms) and treats unprefixed values as
// `literal:<value>` for back-compat with the pre-secrets-package
// flags.
//
// The returned Resolver is safe for concurrent use.
func Default() Resolver {
return defaultResolver{}
}
type defaultResolver struct{}
// Resolve implements Resolver. It dispatches on the URI's scheme
// prefix, treating unprefixed values as literal for back-compat.
func (defaultResolver) Resolve(ctx context.Context, uri string) (string, error) {
scheme, rest, ok := splitScheme(uri)
if !ok {
// Back-compat: bare values are literal. Mirrors what every
// pre-secrets flag did.
return uri, nil
}
switch scheme {
case "literal":
return rest, nil
case "env":
return resolveEnv(rest)
case "file":
return resolveFile(rest)
case "kms":
return resolveKMS(ctx, rest)
default:
return "", fmt.Errorf("%w: %q", ErrUnknownScheme, scheme)
}
}
// splitScheme returns (scheme, rest, true) if uri begins with a
// recognizable `<scheme>:` prefix where scheme is non-empty and
// consists of lowercase letters / digits. Returns (...false) when
// the URI is unprefixed (so callers fall back to literal handling).
//
// We deliberately reject schemes that contain `/` or `.` to avoid
// mistaking a `C:/path/to/file` on Windows or a domain-like literal
// for a scheme. Operators on Windows can still use `file:///C:/path`.
func splitScheme(uri string) (scheme, rest string, ok bool) {
i := strings.IndexByte(uri, ':')
if i <= 0 {
return "", "", false
}
scheme = uri[:i]
for j := 0; j < len(scheme); j++ {
c := scheme[j]
validLower := c >= 'a' && c <= 'z'
validDigit := c >= '0' && c <= '9'
if !validLower && !validDigit {
return "", "", false
}
}
return scheme, uri[i+1:], true
}
// resolveEnv reads `env:NAME` → os.Getenv("NAME"). An empty NAME or
// an unset env var returns an error so operators see the missing
// reference at boot rather than silently passing "" to mpcd.
func resolveEnv(name string) (string, error) {
if name == "" {
return "", errors.New("secrets: env: URI requires a variable name")
}
v, ok := os.LookupEnv(name)
if !ok {
return "", fmt.Errorf("secrets: env var %q unset", name)
}
return v, nil
}
// resolveFile reads `file:/path` → contents-of-file, with a single
// trailing newline stripped because almost every secret-management
// tool (kubectl create secret, vault write, vault agent template)
// emits one. Operators can defeat the strip by appending a sentinel
// to the file if they really need a trailing newline.
func resolveFile(path string) (string, error) {
if path == "" {
return "", errors.New("secrets: file: URI requires a path")
}
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("secrets: read %s: %w", path, err)
}
// Strip exactly one trailing \n (or \r\n) — multi-line secrets
// like PEMs preserve their interior newlines.
s := string(b)
s = strings.TrimSuffix(s, "\n")
s = strings.TrimSuffix(s, "\r")
return s, nil
}
// resolveKMS routes `kms:<family>:<opaque>` to the registered provider
// for <family>. Returns ErrKMSNotRegistered when the family has no
// registered provider — actionable signal that the operator forgot
// to import the provider package.
func resolveKMS(ctx context.Context, rest string) (string, error) {
i := strings.IndexByte(rest, ':')
if i <= 0 {
return "", errors.New("secrets: kms: URI must be kms:<family>:<opaque>")
}
family, opaque := rest[:i], rest[i+1:]
p, ok := kmsRegistry[family]
if !ok {
return "", fmt.Errorf("%w: family=%q", ErrKMSNotRegistered, family)
}
return p.Decrypt(ctx, opaque)
}
// MustResolve calls r.Resolve and panics on error. Suitable for
// boot-time wiring where a missing secret is fatal. Do NOT use on
// request paths.
func MustResolve(ctx context.Context, r Resolver, uri string) string {
v, err := r.Resolve(ctx, uri)
if err != nil {
panic("secrets: " + err.Error())
}
return v
}
+211
View File
@@ -0,0 +1,211 @@
package secrets
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResolver_LiteralScheme(t *testing.T) {
got, err := Default().Resolve(context.Background(), "literal:hello-world")
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "hello-world" {
t.Errorf("got %q, want %q", got, "hello-world")
}
}
func TestResolver_UnprefixedIsLiteral_BackCompat(t *testing.T) {
// Every pre-secrets flag value is bare (e.g. --mpc-token=abc123).
// Bare strings must be treated as literal so existing deploys
// keep working without flag changes.
got, err := Default().Resolve(context.Background(), "bare-token-value")
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "bare-token-value" {
t.Errorf("got %q, want %q", got, "bare-token-value")
}
}
func TestResolver_EnvScheme(t *testing.T) {
t.Setenv("BRIDGE_TEST_SECRET", "the-shared-token")
got, err := Default().Resolve(context.Background(), "env:BRIDGE_TEST_SECRET")
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "the-shared-token" {
t.Errorf("got %q, want %q", got, "the-shared-token")
}
}
func TestResolver_EnvScheme_UnsetVar(t *testing.T) {
_, err := Default().Resolve(context.Background(), "env:DOES_NOT_EXIST_xx_BRIDGE_TEST")
if err == nil {
t.Fatal("expected error for unset env var")
}
if !strings.Contains(err.Error(), "unset") {
t.Errorf("error %q should mention 'unset'", err.Error())
}
}
func TestResolver_EnvScheme_EmptyName(t *testing.T) {
_, err := Default().Resolve(context.Background(), "env:")
if err == nil {
t.Fatal("expected error for empty env name")
}
}
func TestResolver_FileScheme(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "secret.pem")
if err := os.WriteFile(path, []byte("-----BEGIN-----\nbody\n-----END-----\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := Default().Resolve(context.Background(), "file:"+path)
if err != nil {
t.Fatalf("err: %v", err)
}
want := "-----BEGIN-----\nbody\n-----END-----"
if got != want {
t.Errorf("got %q, want %q (trailing \\n should be stripped)", got, want)
}
}
func TestResolver_FileScheme_CRLF(t *testing.T) {
// Windows-style files: trailing \r\n should be stripped fully.
dir := t.TempDir()
path := filepath.Join(dir, "secret.crlf")
if err := os.WriteFile(path, []byte("token-value\r\n"), 0o600); err != nil {
t.Fatal(err)
}
got, err := Default().Resolve(context.Background(), "file:"+path)
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "token-value" {
t.Errorf("got %q, want %q", got, "token-value")
}
}
func TestResolver_FileScheme_MissingPath(t *testing.T) {
_, err := Default().Resolve(context.Background(), "file:")
if err == nil {
t.Fatal("expected error for empty path")
}
}
func TestResolver_FileScheme_NotExist(t *testing.T) {
_, err := Default().Resolve(context.Background(), "file:/no/such/path/xyz123")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestResolver_KMSScheme_NoProvider(t *testing.T) {
_, err := Default().Resolve(context.Background(), "kms:aws:us-east-1:cipher:xxx")
if err == nil {
t.Fatal("expected ErrKMSNotRegistered")
}
if !errors.Is(err, ErrKMSNotRegistered) {
t.Errorf("err = %v, want ErrKMSNotRegistered", err)
}
}
func TestResolver_KMSScheme_MalformedNoFamily(t *testing.T) {
_, err := Default().Resolve(context.Background(), "kms:onlyfamily")
if err == nil {
t.Fatal("expected error — kms URI must be kms:<family>:<opaque>")
}
}
// fakeKMS is a test KMSProvider that maps opaque strings back to plaintext.
type fakeKMS struct {
plaintext map[string]string
}
func (f *fakeKMS) Decrypt(_ context.Context, opaque string) (string, error) {
pt, ok := f.plaintext[opaque]
if !ok {
return "", errors.New("fakeKMS: opaque not found")
}
return pt, nil
}
func TestResolver_KMSScheme_HappyPath(t *testing.T) {
prev := kmsRegistry["fake"]
t.Cleanup(func() {
if prev == nil {
delete(kmsRegistry, "fake")
} else {
kmsRegistry["fake"] = prev
}
})
RegisterKMS("fake", &fakeKMS{plaintext: map[string]string{
"cipher-abc": "decrypted-token",
}})
got, err := Default().Resolve(context.Background(), "kms:fake:cipher-abc")
if err != nil {
t.Fatalf("err: %v", err)
}
if got != "decrypted-token" {
t.Errorf("got %q, want %q", got, "decrypted-token")
}
}
func TestResolver_UnknownScheme(t *testing.T) {
_, err := Default().Resolve(context.Background(), "vault:secret/data/bridge/mpc-token")
if err == nil {
t.Fatal("expected ErrUnknownScheme")
}
if !errors.Is(err, ErrUnknownScheme) {
t.Errorf("err = %v, want ErrUnknownScheme", err)
}
}
func TestSplitScheme_RejectsWindowsPath(t *testing.T) {
// `C:/path/to/file` is a bare Windows path, NOT a `C:` scheme.
// splitScheme should reject it (uppercase scheme chars not allowed).
scheme, _, ok := splitScheme("C:/path/to/file")
if ok {
t.Errorf("splitScheme accepted Windows path as scheme=%q", scheme)
}
}
func TestSplitScheme_RejectsBareIP(t *testing.T) {
// "10:20:30" should not be parsed as scheme=10.
scheme, _, ok := splitScheme("1.2.3.4:5000")
if ok {
t.Errorf("splitScheme accepted IP:port as scheme=%q", scheme)
}
}
func TestRegisterKMS_RejectsNilOrEmpty(t *testing.T) {
prevCount := len(kmsRegistry)
RegisterKMS("", &fakeKMS{})
RegisterKMS("validname", nil)
if len(kmsRegistry) != prevCount {
t.Errorf("registry grew after invalid Register calls — want size unchanged")
}
}
func TestMustResolve_PanicsOnError(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Error("MustResolve should panic on error")
}
}()
MustResolve(context.Background(), Default(), "env:DOES_NOT_EXIST_xx")
}
func TestMustResolve_HappyPath(t *testing.T) {
got := MustResolve(context.Background(), Default(), "literal:abc")
if got != "abc" {
t.Errorf("got %q, want %q", got, "abc")
}
}