mirror of
https://github.com/luxfi/bridge.git
synced 2026-09-24 00:26:46 +00:00
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).
218 lines
7.7 KiB
Go
218 lines
7.7 KiB
Go
// 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
|
|
}
|