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).
173 lines
6.2 KiB
Go
173 lines
6.2 KiB
Go
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[:])
|
|
}
|