Files
zeekay 65979bf7a0 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).
2026-05-28 20:14:51 -07:00

98 lines
3.5 KiB
Go

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()
}