mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]) + "…"
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user