diff --git a/.dockerignore b/.dockerignore index f15266c4..67208871 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ npm-debug.log yarn-debug.log yarn-error.log .env +deploy/ *.md .DS_Store .vscode diff --git a/.gitignore b/.gitignore index d6f3fa7d..c2bf920b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ coverage/ logs/ tsconfig.tsbuildinfo out/ +deploy/ca/ +deploy/certs/ +deploy/clients/ .vscode/ *.iml .nyc_output/ diff --git a/README.md b/README.md index e19c2289..afb8c88d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Key features include: - **Complete Infrastructure Control** - Host and manage all components in your own secure environment. - **KMS/HSM Integration** - Bring your own KMS or HSM by implementing the provided [advanced wallets key provider API interface specification](./key-provider-api-spec.yaml). Reference implementations available for [AWS HSM](./demo-key-provider-script/aws-interface.md) and [Dinamo HSM](./demo-key-provider-script/dinamo-interface.md). - **Network Isolation** - Advanced Wallet Manager operates in a completely isolated network segment with no external internet access. -- **mTLS Security** - Optional mutual TLS with client certificate validation for secure inter-service communications. +- **mTLS Security** - Mutual TLS with client certificate validation for secure inter-service communications (required for network-accessible recovery). - **Flexible Configuration** - Environment-based setup with file or variable-based certificates. ## Table of Contents @@ -155,7 +155,7 @@ curl -X POST http://localhost:3081/advancedwallet/ping curl -X POST http://localhost:3081/ping/advancedWalletManager ``` -> **Note:** You should only use `TLS_MODE=disabled` for local development and testing. Always use mTLS in production environments. For information about configuring mTLS in production, see the [Production Setup](#production-setup) section. +> **Note:** You should only use `TLS_MODE=disabled` for local development and testing. Never enable recovery on a non-local unauthenticated listener. Always use mTLS in production environments. For information about configuring mTLS in production, see the [Production Setup](#production-setup) section. ## Configuration @@ -208,7 +208,8 @@ These settings are only required when you want to use a **separate AWM instance | Variable | Description | Default | Applies To | | -------------------- | --------------------------------------------------- | ---------------------- | ---------- | -| `RECOVERY_MODE` | Enable recovery mode for wallet recovery operations | `false` | Both | +| `RECOVERY_MODE` | Enable recovery temporarily; disable immediately after use | `false` | Both | +| `RECOVERY_AUTH_TOKEN` | Shared high-entropy secret (at least 32 bytes) required on both services while recovery is enabled | - | Both | | `HTTP_LOGFILE` | Path to HTTP access log file | `logs/http-access.log` | Both | | `KEEP_ALIVE_TIMEOUT` | Keep-alive timeout in milliseconds | - | Both | | `HEADERS_TIMEOUT` | Headers timeout in milliseconds | - | Both | @@ -363,7 +364,7 @@ The application includes a Docker Compose configuration that runs both Advanced The Docker Compose setup creates two isolated services: - **Advanced Wallet Manager (AWM)**: Runs in an isolated internal network with no external access for maximum security. -- **Master BitGo Express (MBE)**: Connects to both internal network (for AWM communication) and public network (for external API access). +- **Master BitGo Express (MBE)**: Connects to the internal network for AWM communication and an outbound network for BitGo API access. No ports are published to the host. - **Network Isolation**: AWM is completely isolated from external networks and only accessible through MBE. ### Network Configuration @@ -377,33 +378,29 @@ The setup creates two distinct networks: - No external internet access for security 2. **my-public-network**: - - Public bridge network - - Used for external access to MBE APIs - - Connected to host networking + - Outbound bridge network for MBE's BitGo API calls; it does not publish MBE to the host. + - Only attach trusted containers: containers on the same Docker network can reach each other's listeners. ### Prerequisites -1. **Install Docker and Docker Compose** -2. **Ensure your key provider API implementation is running** on your host machine (typically on port 3000) - -### Quick Start - -#### 1. Start Services +1. Install Docker Compose and OpenSSL. +2. Configure a reachable **HTTPS** key provider. Its CA certificate must be available as `deploy/certs/awm/key-provider-ca.pem`; the key provider must trust the generated AWM client certificate (or replace the bootstrap credentials with your own). Do not use the sample `demo.key`, `demo.crt`, or checked-in test certificates in a deployment. +3. Generate distinct service and client certificates for **local evaluation only**. The bootstrap CA is a 30-day local CA: use your organization's PKI and a trusted key provider in production. Keep `deploy/ca` and `deploy/clients` private and off container volumes. ```bash -# Navigate to project directory -cd advanced-wallet - -# Start both services in background -docker-compose up -d +./scripts/bootstrap-compose-certs.sh +# Install the CA cert used by the key provider to sign its HTTPS server certificate: +cp /secure/path/to/key-provider-ca.pem deploy/certs/awm/key-provider-ca.pem +export KEY_PROVIDER_URL=https://your-key-provider:3000 +# fingerprints.env pins the MBE client on AWM and your external client on MBE. +docker compose --env-file deploy/certs/fingerprints.env up -d --build ``` -#### 2. Stop Services +The compose file uses mTLS for MBE → AWM and for each inbound connection. It does **not** publish port 3081; to reach MBE, provision a separately secured client path and allowlisted mTLS client, or connect from a trusted private network. The generated `deploy/clients/mbe-client.{crt,key}` are for local evaluation only. `BIND=0.0.0.0` listens inside each container, **not** on a host port. Keep the internal network exclusive to trusted services, and never expose AWM directly. For production set `BITGO_ENV=prod` and replace all bootstrap certificates with certificates issued by your PKI. -```bash -# Stop and remove containers -docker-compose down -``` +**Recovery procedure:** generate a random secret (`openssl rand -hex 32`), provide it as `RECOVERY_AUTH_TOKEN` and set `RECOVERY_MODE=true` on **both** services for the recovery window only. Set `X-Recovery-Token: ` on requests to `/advancedwallet/recovery` and `/advancedwallet/recoveryconsolidations`; MBE forwards the configured secret to AWM recovery endpoints. `Authorization: Bearer ...` is the separate BitGo API token and does not authorize recovery. The token is required even with mTLS; never send it over a network without TLS. Recovery requests can return fully signed transactions from xpubs/commonKeychain alone: treat those public key materials as sensitive and rotate the recovery secret after use. Disable `RECOVERY_MODE` immediately after recovery and restart both services. Recovery with TLS disabled is only allowed on a loopback listener. + +Stop the stack with `docker compose --env-file deploy/certs/fingerprints.env down`. ## API Endpoints @@ -478,7 +475,7 @@ export KEY_PROVIDER_SERVER_CA_CERT_PATH=/secure/certs/key-provider-ca.crt # Security settings - production-grade export CLIENT_CERT_ALLOW_SELF_SIGNED=false export KEY_PROVIDER_SERVER_CERT_ALLOW_SELF_SIGNED=false -export MTLS_ALLOWED_CLIENT_FINGERPRINTS=sha256:1a2b3c...,sha256:4d5e6f... +export MTLS_ALLOWED_CLIENT_FINGERPRINTS= export BITGO_ENV=prod npm start ``` @@ -503,7 +500,7 @@ export AWM_SERVER_CA_CERT_PATH=/secure/certs/awm-ca.crt # Security settings - production-grade export CLIENT_CERT_ALLOW_SELF_SIGNED=false export AWM_SERVER_CERT_ALLOW_SELF_SIGNED=false -export MTLS_ALLOWED_CLIENT_FINGERPRINTS=sha256:7g8h9i...,sha256:0j1k2l... +export MTLS_ALLOWED_CLIENT_FINGERPRINTS= npm start ``` diff --git a/docker-compose.yml b/docker-compose.yml index 1b0ffd98..73e3beea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,120 +1,75 @@ -version: '3.8' - +# Bootstrap certificates with scripts/bootstrap-compose-certs.sh before starting. +# Supply KEY_PROVIDER_URL (HTTPS) and deploy/certs/awm/key-provider-ca.pem from your key provider. +# Recovery is disabled by default; enable temporarily only with RECOVERY_AUTH_TOKEN set. services: - # Service for advanced-wallet-manager (AWM) advanced-wallet-manager: - build: . # Build from the Dockerfile inside the repo - container_name: advanced-wallet-manager + build: . + user: "${COMPOSE_UID:?Run scripts/bootstrap-compose-certs.sh}:${COMPOSE_GID:?Run scripts/bootstrap-compose-certs.sh}" networks: - - my-internal-network # Only part of the internal network + - my-internal-network environment: - # Application mode (required) - APP_MODE=advanced-wallet-manager - - # Network settings - ADVANCED_WALLET_MANAGER_PORT=3080 - - BIND=0.0.0.0 + - BIND=0.0.0.0 # Container-only listener; no host port published + - TLS_MODE=mtls + - SERVER_TLS_KEY_PATH=/app/certs/awm-server.key + - SERVER_TLS_CERT_PATH=/app/certs/awm-server.crt + - MTLS_ALLOWED_CLIENT_FINGERPRINTS=${AWM_ALLOWED_CLIENT_FINGERPRINTS:?Run scripts/bootstrap-compose-certs.sh and supply --env-file deploy/certs/fingerprints.env} + - KEY_PROVIDER_URL=${KEY_PROVIDER_URL:?Set an HTTPS key provider URL} + - KEY_PROVIDER_SERVER_CA_CERT_PATH=/app/certs/key-provider-ca.pem + - KEY_PROVIDER_CLIENT_TLS_KEY_PATH=/app/certs/awm-key-provider-client.key + - KEY_PROVIDER_CLIENT_TLS_CERT_PATH=/app/certs/awm-key-provider-client.crt - TIMEOUT=305000 - KEEP_ALIVE_TIMEOUT=65000 - HEADERS_TIMEOUT=66000 - # TLS settings - - TLS_MODE=disabled - - CLIENT_CERT_ALLOW_SELF_SIGNED=true - - # Key provider settings (required) - - KEY_PROVIDER_URL=http://172.20.0.1:3000 # UPDATE TO YOUR OWN key provider URL - - ALLOW_PLAINTEXT_KEY_PROVIDER=true # Development only: permits http:// KEY_PROVIDER_URL with TLS_MODE=disabled - - KEY_PROVIDER_SERVER_CERT_ALLOW_SELF_SIGNED=true - - # Optional key provider TLS settings (uncomment if using mTLS with key provider) - # - KEY_PROVIDER_SERVER_CA_CERT_PATH=/path/to/key-provider-ca-cert.pem - # - KEY_PROVIDER_CLIENT_TLS_KEY_PATH=/path/to/key-provider-client-key.pem - # - KEY_PROVIDER_CLIENT_TLS_CERT_PATH=/path/to/key-provider-client-cert.pem - # - KEY_PROVIDER_CLIENT_TLS_KEY= - # - KEY_PROVIDER_CLIENT_TLS_CERT= - - # Optional server TLS settings (uncomment if using mTLS) - # - SERVER_TLS_KEY_PATH=/path/to/server-key.pem - # - SERVER_TLS_CERT_PATH=/path/to/server-cert.pem - # - SERVER_TLS_KEY= - # - SERVER_TLS_CERT= - # - MTLS_ALLOWED_CLIENT_FINGERPRINTS=ABC123,DEF456 - - # Logging and debug - HTTP_LOGFILE=logs/http-access.log - - RECOVERY_MODE=true - # Default to local signing mode + - RECOVERY_MODE=${RECOVERY_MODE:-false} + - RECOVERY_AUTH_TOKEN=${RECOVERY_AUTH_TOKEN:-} - NODE_ENV=production - LOG_LEVEL=info restart: always - ports: [] # No public ports exposed volumes: - - ./logs:/app/logs # Mount logs directory + - ./logs:/app/logs + - ./deploy/certs/awm:/app/certs:ro - # Service for master-bitgo-express (MBE) - both internal and publicly accessible master-bitgo-express: - build: . # Build from the Dockerfile inside the repo - container_name: master-bitgo-express + build: . + user: "${COMPOSE_UID:?Run scripts/bootstrap-compose-certs.sh}:${COMPOSE_GID:?Run scripts/bootstrap-compose-certs.sh}" networks: - - my-internal-network # Connect to the internal network for internal communication - - my-public-network # Connect to the public network for external access + - my-internal-network + - my-public-network # Outbound BitGo API access; no host port published environment: - # Application mode (required) - APP_MODE=master-express - - # Network settings - MASTER_EXPRESS_PORT=3081 - - BIND=0.0.0.0 + - BIND=0.0.0.0 # Container-only listener; no host port published + - TLS_MODE=mtls + - SERVER_TLS_KEY_PATH=/app/certs/mbe-server.key + - SERVER_TLS_CERT_PATH=/app/certs/mbe-server.crt + - MTLS_ALLOWED_CLIENT_FINGERPRINTS=${MBE_ALLOWED_CLIENT_FINGERPRINTS:?Run scripts/bootstrap-compose-certs.sh and supply --env-file deploy/certs/fingerprints.env} + - ADVANCED_WALLET_MANAGER_URL=https://advanced-wallet-manager:3080 + - AWM_SERVER_CA_CERT_PATH=/app/certs/ca.crt + - AWM_CLIENT_TLS_KEY_PATH=/app/certs/mbe-awm-client.key + - AWM_CLIENT_TLS_CERT_PATH=/app/certs/mbe-awm-client.crt + - BITGO_ENV=test # Change to prod for production + - BITGO_DISABLE_ENV_CHECK=false + - BITGO_AUTH_VERSION=2 - TIMEOUT=305000 - KEEP_ALIVE_TIMEOUT=65000 - HEADERS_TIMEOUT=66000 - - # BitGo API settings - - BITGO_ENV=test - - BITGO_DISABLE_ENV_CHECK=true - - BITGO_AUTH_VERSION=2 - # - BITGO_CUSTOM_ROOT_URI=https://custom-bitgo-api.com - # - BITGO_CUSTOM_BITCOIN_NETWORK=testnet - - # Advanced Wallet Manager connection (required) - - ADVANCED_WALLET_MANAGER_URL=http://advanced-wallet-manager:3080 - - AWM_SERVER_CERT_ALLOW_SELF_SIGNED=true - - # Optional AWM TLS settings (uncomment if using mTLS with AWM) - # - AWM_SERVER_CA_CERT_PATH=/path/to/awm-ca-cert.pem - # - AWM_CLIENT_TLS_KEY_PATH=/path/to/awm-client-key.pem - # - AWM_CLIENT_TLS_CERT_PATH=/path/to/awm-client-cert.pem - # - AWM_CLIENT_TLS_KEY= - # - AWM_CLIENT_TLS_CERT= - - # TLS settings - - TLS_MODE=disabled - - CLIENT_CERT_ALLOW_SELF_SIGNED=true - - # Optional server TLS settings (uncomment if using mTLS) - # - SERVER_TLS_KEY_PATH=/path/to/server-key.pem - # - SERVER_TLS_CERT_PATH=/path/to/server-cert.pem - # - SERVER_TLS_KEY= - # - SERVER_TLS_CERT= - # - MTLS_ALLOWED_CLIENT_FINGERPRINTS=ABC123,DEF456 - - # Logging and debug - HTTP_LOGFILE=logs/http-access.log - - RECOVERY_MODE=true + - RECOVERY_MODE=${RECOVERY_MODE:-false} + - RECOVERY_AUTH_TOKEN=${RECOVERY_AUTH_TOKEN:-} - NODE_ENV=production - LOG_LEVEL=info restart: always - ports: - - '3081:3081' # Expose MBE publicly on port 3081 volumes: - - ./logs:/app/logs # Mount logs directory + - ./logs:/app/logs + - ./deploy/certs/mbe:/app/certs:ro -# Networks section networks: my-internal-network: - driver: bridge # Internal communication network, no access to the internet - internal: true # Ensures this network is not accessible from outside - + driver: bridge + internal: true my-public-network: - driver: bridge # Public network, allowing external access to MBE + driver: bridge diff --git a/scripts/bootstrap-compose-certs.sh b/scripts/bootstrap-compose-certs.sh new file mode 100755 index 00000000..f2108a0e --- /dev/null +++ b/scripts/bootstrap-compose-certs.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Development bootstrap only: replace this CA and its certificates for production. +set -euo pipefail +umask 077 +cd "$(dirname "$0")/.." + +if [[ -e deploy/certs || -e deploy/ca || -e deploy/clients ]]; then + echo 'deploy credentials already exist; refusing to overwrite them' >&2 + exit 1 +fi +if [[ $(id -u) -eq 0 ]]; then + echo 'Run the bootstrap as an unprivileged user so containers can read its private keys without running as root' >&2 + exit 1 +fi +mkdir -p deploy logs +chmod 700 logs +work=$(mktemp -d deploy/.bootstrap.XXXXXX) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/ca" "$work/certs/awm" "$work/certs/mbe" "$work/clients" + +openssl req -x509 -newkey rsa:3072 -nodes -days 30 -sha256 \ + -keyout "$work/ca/ca.key" -out "$work/ca/ca.crt" \ + -subj '/CN=Advanced Wallets local development CA' + +issue_cert() { + local dir=$1 name=$2 cn=$3 usage=$4 san=$5 + openssl req -newkey rsa:3072 -nodes -sha256 \ + -keyout "$dir/$name.key" -out "$work/$name.csr" -subj "/CN=$cn" + printf 'subjectAltName=%s\nextendedKeyUsage=%s\n' "$san" "$usage" > "$work/$name.ext" + openssl x509 -req -in "$work/$name.csr" -CA "$work/ca/ca.crt" \ + -CAkey "$work/ca/ca.key" -CAcreateserial -out "$dir/$name.crt" \ + -days 30 -sha256 -extfile "$work/$name.ext" +} + +issue_cert "$work/certs/awm" awm-server advanced-wallet-manager serverAuth 'DNS:advanced-wallet-manager' +issue_cert "$work/certs/mbe" mbe-server localhost serverAuth 'DNS:localhost,IP:127.0.0.1' +issue_cert "$work/certs/mbe" mbe-awm-client mbe-awm-client clientAuth 'DNS:mbe-awm-client' +issue_cert "$work/certs/awm" awm-key-provider-client awm-key-provider-client clientAuth 'DNS:awm-key-provider-client' +issue_cert "$work/clients" mbe-client mbe-client clientAuth 'DNS:mbe-client' +cp "$work/ca/ca.crt" "$work/certs/awm/ca.crt" +cp "$work/ca/ca.crt" "$work/certs/mbe/ca.crt" + +fingerprint() { + openssl x509 -in "$1" -noout -fingerprint -sha256 | cut -d= -f2 | tr -d ':' +} +{ + printf 'AWM_ALLOWED_CLIENT_FINGERPRINTS=%s\n' "$(fingerprint "$work/certs/mbe/mbe-awm-client.crt")" + printf 'MBE_ALLOWED_CLIENT_FINGERPRINTS=%s\n' "$(fingerprint "$work/clients/mbe-client.crt")" + printf 'COMPOSE_UID=%s\nCOMPOSE_GID=%s\n' "$(id -u)" "$(id -g)" +} > "$work/certs/fingerprints.env" + +mv "$work/ca" "$work/certs" "$work/clients" deploy/ +rm -rf "$work" +trap - EXIT +printf '%s\n' 'Generated 30-day local certificates under deploy/. Supply deploy/certs/awm/key-provider-ca.pem from your key provider before starting.' diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts index 971ac2a8..798cd44c 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts @@ -28,10 +28,12 @@ describe('recoveryMpc', () => { tlsMode: TlsMode.DISABLED, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -175,10 +177,12 @@ describe('recoveryMpc', () => { httpLoggerFile: '', tlsMode: TlsMode.DISABLED, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; const dualApp = expressApp(dualCfg); const dualAgent = request.agent(dualApp); + dualAgent.set('x-recovery-token', dualCfg.recoveryAuthToken!); // User key served from primary KMS const userKmsNock = nock(primaryKmsUrl) diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts index 78128c11..e9265ef7 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts @@ -74,6 +74,7 @@ describe('recoveryMpcV2', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; configStub = sandbox.stub(configModule, 'initConfig').returns(cfg); @@ -81,6 +82,7 @@ describe('recoveryMpcV2', () => { // app setup app = advancedWalletManagerApp(cfg); agent = request.agent(app); + agent.set('x-recovery-token', cfg.recoveryAuthToken!); }); afterEach(() => { @@ -168,6 +170,7 @@ describe('recoveryMpcV2', () => { configStub.returns(dualCfg); const dualApp = advancedWalletManagerApp(dualCfg); const dualAgent = request.agent(dualApp); + dualAgent.set('x-recovery-token', dualCfg.recoveryAuthToken!); // User key served from primary KMS const userKmsNock = nock(kmsUrl) @@ -227,3 +230,41 @@ describe('recoveryMpcV2', () => { ); }); }); + +describe('mpcv2 recovery with recovery mode disabled', () => { + it('rejects before retrieving either private share', async () => { + const keyProviderUrl = 'http://key-provider.invalid'; + const config: AdvancedWalletManagerConfig = { + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + port: 0, + bind: 'localhost', + timeout: 60000, + httpLoggerFile: '', + keyProviderUrl, + tlsMode: TlsMode.DISABLED, + clientCertAllowSelfSigned: true, + recoveryMode: false, + }; + const pub = 'synthetic-common-keychain'; + const keyRequest = nock(keyProviderUrl) + .get(`/key/${pub}`) + .query({ source: 'user' }) + .reply(200, { prv: 'synthetic-private-share' }); + + const response = await request + .agent(advancedWalletManagerApp(config)) + .post('/api/hteth/mpcv2/recovery') + .send({ + pub, + txHex: + '02f6824268018502540be4008504a817c80083030d409443442e403d64d29c4f64065d0c1a0e8edc03d6c88801550f7dca700000823078c0', + }); + + response.status.should.equal(500); + response.body.details.should.equal( + 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.', + ); + keyRequest.isDone().should.be.false(); + }); +}); diff --git a/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts index ed39d2c4..39fa0459 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMultisigTransaction.test.ts @@ -27,6 +27,7 @@ describe('UTXO recovery', () => { clientCertAllowSelfSigned: true, keyProviderUrl: 'key-provider.example.com', recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; beforeEach(() => { @@ -71,6 +72,7 @@ describe('UTXO recovery', () => { // Create app after middleware is stubbed const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -164,6 +166,7 @@ describe('UTXO recovery — external signing mode', () => { clientCertAllowSelfSigned: true, keyProviderUrl, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; const utxoCoinStub = { @@ -188,6 +191,7 @@ describe('UTXO recovery — external signing mode', () => { const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -360,6 +364,7 @@ describe('EVM recovery — external signing mode', () => { clientCertAllowSelfSigned: true, keyProviderUrl, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; const evmCoinStub = { @@ -383,6 +388,7 @@ describe('EVM recovery — external signing mode', () => { sinon.stub(coinFactory, 'getCoin').resolves(evmCoinStub); agent = request.agent(expressApp(config)); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -496,6 +502,7 @@ describe('UTXO recovery — local signing with keyToSign', () => { clientCertAllowSelfSigned: true, keyProviderUrl: 'key-provider.example.com', recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; beforeEach(() => { @@ -523,6 +530,7 @@ describe('UTXO recovery — local signing with keyToSign', () => { retrieveStub.withArgs({ pub: backupPub, source: 'backup', cfg: config }).resolves(backupPrv); agent = request.agent(expressApp(config)); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -633,6 +641,7 @@ describe('EVM recovery — local signing with keyToSign (two-phase)', () => { clientCertAllowSelfSigned: true, keyProviderUrl: 'key-provider.example.com', recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; beforeEach(() => { @@ -660,6 +669,7 @@ describe('EVM recovery — local signing with keyToSign (two-phase)', () => { retrieveStub.withArgs({ pub: backupPub, source: 'backup', cfg: config }).resolves(backupPrv); agent = request.agent(expressApp(config)); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { diff --git a/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts index b2b4ff6c..2cd036ea 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts @@ -43,6 +43,7 @@ describe('recoveryMultisigTransaction', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; configStub = sandbox.stub(configModule, 'initConfig').returns(cfg); @@ -50,6 +51,7 @@ describe('recoveryMultisigTransaction', () => { // app setup app = advancedWalletManagerApp(cfg); agent = request.agent(app); + agent.set('x-recovery-token', cfg.recoveryAuthToken!); }); afterEach(() => { @@ -122,6 +124,7 @@ describe('recoveryMultisigTransaction', () => { configStub.returns(dualCfg); const dualApp = advancedWalletManagerApp(dualCfg); const dualAgent = request.agent(dualApp); + dualAgent.set('x-recovery-token', dualCfg.recoveryAuthToken!); const mockKmsUserResponse = { prv: userPrv, diff --git a/src/__tests__/api/advancedWalletManager/signMpcRecoveryTransaction.test.ts b/src/__tests__/api/advancedWalletManager/signMpcRecoveryTransaction.test.ts index d7f7a940..b70f3d26 100644 --- a/src/__tests__/api/advancedWalletManager/signMpcRecoveryTransaction.test.ts +++ b/src/__tests__/api/advancedWalletManager/signMpcRecoveryTransaction.test.ts @@ -8,7 +8,7 @@ import { app as expressApp } from '../../../advancedWalletManagerApp'; import { AppMode, AdvancedWalletManagerConfig, TlsMode, SigningMode } from '../../../shared/types'; describe('EdDSA Recovery Signing', () => { - let agent: supertest.SuperTest; + let agent: supertest.SuperAgentTest; const config: AdvancedWalletManagerConfig = { keyProviderUrl: 'http://localhost:3000', appMode: AppMode.ADVANCED_WALLET_MANAGER, @@ -20,6 +20,7 @@ describe('EdDSA Recovery Signing', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; const commonKeychain = @@ -85,7 +86,8 @@ describe('EdDSA Recovery Signing', () => { beforeEach(() => { nock.disableNetConnect(); nock.enableNetConnect('127.0.0.1'); - agent = supertest(expressApp(config)); + agent = supertest.agent(expressApp(config)); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { diff --git a/src/__tests__/api/master/musigRecovery.test.ts b/src/__tests__/api/master/musigRecovery.test.ts index 376c5f72..26b35645 100644 --- a/src/__tests__/api/master/musigRecovery.test.ts +++ b/src/__tests__/api/master/musigRecovery.test.ts @@ -32,11 +32,13 @@ describe('POST /api/v1/:coin/advancedwallet/recovery', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG, }; const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { diff --git a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts index 9abaa4f3..e777b8af 100644 --- a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -151,10 +151,12 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG, }; const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -638,6 +640,7 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { overrides: { recoveryMode: true }, }); asyncAgent = request.agent(expressApp(asyncConfig)); + asyncAgent.set('x-recovery-token', asyncConfig.recoveryAuthToken!); }); it('should return 202 + jobId for a single-tx onchain consolidation recovery', async () => { @@ -775,6 +778,7 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { const response = await request .agent(expressApp(makeSplitAwmMasterExpressConfig())) .post('/api/v1/trx/advancedwallet/recoveryconsolidations') + .set('x-recovery-token', 'test-recovery-token-at-least-32-characters') .set('Authorization', `Bearer ${accessToken}`) .send(trxConsolidationRequest); @@ -798,6 +802,7 @@ describe('POST /api/v1/:coin/advancedwallet/recoveryconsolidations', () => { const response = await request .agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true }))) .post('/api/v1/trx/advancedwallet/recoveryconsolidations') + .set('x-recovery-token', 'test-recovery-token-at-least-32-characters') .set('Authorization', `Bearer ${accessToken}`) .send(trxConsolidationRequest); diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index 392ed19b..6960aad1 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -32,6 +32,7 @@ describe('Recovery Tests', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG, }; @@ -41,6 +42,7 @@ describe('Recovery Tests', () => { const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { @@ -634,6 +636,7 @@ describe('Recovery Tests', () => { before(() => { asyncAgent = request.agent(expressApp(asyncConfig)); + asyncAgent.set('x-recovery-token', asyncConfig.recoveryAuthToken!); }); it('should return 202 + jobId for UTXO multisig recovery, submitting to the bridge not AWM', async () => { @@ -906,6 +909,7 @@ describe('Split AWM recovery (separate user and backup AWMs)', () => { const response = await request .agent(expressApp(makeSplitAwmMasterExpressConfig())) .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('x-recovery-token', 'test-recovery-token-at-least-32-characters') .set('Authorization', `Bearer ${accessToken}`) .send(utxoRecoveryRequest); @@ -928,6 +932,7 @@ describe('Split AWM recovery (separate user and backup AWMs)', () => { const response = await request .agent(expressApp(makeSplitAwmMasterExpressConfig({ asyncEnabled: true }))) .post(`/api/v1/${coin}/advancedwallet/recovery`) + .set('x-recovery-token', 'test-recovery-token-at-least-32-characters') .set('Authorization', `Bearer ${accessToken}`) .send(utxoRecoveryRequest); @@ -998,6 +1003,7 @@ describe('Split AWM recovery (separate user and backup AWMs)', () => { const response = await request .agent(expressApp(makeSplitAwmMasterExpressConfig())) .post(`/api/v1/${ethCoinId}/advancedwallet/recovery`) + .set('x-recovery-token', 'test-recovery-token-at-least-32-characters') .set('Authorization', `Bearer ${accessToken}`) .send({ multiSigRecoveryParams: { diff --git a/src/__tests__/api/master/recoveryWalletMpcV2.test.ts b/src/__tests__/api/master/recoveryWalletMpcV2.test.ts index 9c1b0110..9db5440e 100644 --- a/src/__tests__/api/master/recoveryWalletMpcV2.test.ts +++ b/src/__tests__/api/master/recoveryWalletMpcV2.test.ts @@ -31,11 +31,13 @@ describe('MBE mpcv2 recovery', () => { tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, recoveryMode: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', asyncModeConfig: DEFAULT_ASYNC_MODE_CONFIG, }; const app = expressApp(config); agent = request.agent(app); + agent.set('x-recovery-token', config.recoveryAuthToken!); }); afterEach(() => { diff --git a/src/__tests__/api/master/testUtils.ts b/src/__tests__/api/master/testUtils.ts index 7e1bb5b6..e6f80d0f 100644 --- a/src/__tests__/api/master/testUtils.ts +++ b/src/__tests__/api/master/testUtils.ts @@ -37,6 +37,7 @@ export function makeMasterExpressTestConfig( awmServerCaCert: 'test-cert', tlsMode: TlsMode.DISABLED, clientCertAllowSelfSigned: true, + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', asyncModeConfig: options.asyncEnabled ? { enabled: true, diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index f3fbf5b5..cfd3d6da 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -56,6 +56,7 @@ describe('Configuration', () => { delete process.env.AWM_CLIENT_TLS_CERT_PATH; delete process.env.KEY_PROVIDER_SERVER_CA_CERT_PATH; delete process.env.RECOVERY_MODE; + delete process.env.RECOVERY_AUTH_TOKEN; delete process.env.ADVANCED_WALLET_MANAGER_BACKUP_URL; delete process.env.AWM_BACKUP_SERVER_CA_CERT_PATH; delete process.env.AWM_BACKUP_CLIENT_TLS_KEY_PATH; @@ -142,6 +143,7 @@ describe('Configuration', () => { process.env.KEY_PROVIDER_CLIENT_TLS_KEY = mockClientTlsKey; process.env.KEY_PROVIDER_CLIENT_TLS_CERT = mockClientTlsCert; process.env.RECOVERY_MODE = 'true'; + process.env.RECOVERY_AUTH_TOKEN = 'test-recovery-token-at-least-32-characters'; process.env.KEY_PROVIDER_SERVER_CA_CERT_PATH = path.resolve( __dirname, 'mocks/certs/test-ssl-cert.pem', @@ -150,6 +152,23 @@ describe('Configuration', () => { cfg.recoveryMode!.should.be.true(); }); + it('rejects recovery without a strong token or with unauthenticated non-local binding', () => { + process.env.KEY_PROVIDER_URL = 'http://localhost:3000'; + process.env.TLS_MODE = 'disabled'; + process.env.ALLOW_PLAINTEXT_KEY_PROVIDER = 'true'; + process.env.RECOVERY_MODE = 'true'; + (() => initConfig()).should.throw(/RECOVERY_AUTH_TOKEN/); + process.env.RECOVERY_AUTH_TOKEN = 'short'; + (() => initConfig()).should.throw(/RECOVERY_AUTH_TOKEN/); + process.env.RECOVERY_AUTH_TOKEN = 'test-recovery-token-at-least-32-characters'; + process.env.BIND = '0.0.0.0'; + (() => initConfig()).should.throw(/requires mTLS for non-local TCP binding/); + process.env.BIND = '::'; + (() => initConfig()).should.throw(/requires mTLS for non-local TCP binding/); + process.env.BIND = '127.0.0.1'; + initConfig().recoveryMode!.should.be.true(); + }); + it('should read TLS mode from environment variables', () => { process.env.KEY_PROVIDER_URL = 'https://localhost:3000'; process.env.SERVER_TLS_KEY = mockTlsKey; @@ -432,6 +451,17 @@ describe('Configuration', () => { } }); + it('rejects recovery without a token or with unauthenticated non-local binding', () => { + process.env.TLS_MODE = 'disabled'; + process.env.RECOVERY_MODE = 'true'; + (() => initConfig()).should.throw(/RECOVERY_AUTH_TOKEN/); + process.env.RECOVERY_AUTH_TOKEN = 'test-recovery-token-at-least-32-characters'; + process.env.BIND = '0.0.0.0'; + (() => initConfig()).should.throw(/requires mTLS for non-local TCP binding/); + process.env.BIND = 'localhost'; + initConfig().recoveryMode!.should.be.true(); + }); + it('should handle TLS mode disabled configuration', () => { // Test with TLS disabled process.env.TLS_MODE = 'disabled'; diff --git a/src/__tests__/integration/helpers/setup.ts b/src/__tests__/integration/helpers/setup.ts index 3dd388c4..ef6f7cc1 100644 --- a/src/__tests__/integration/helpers/setup.ts +++ b/src/__tests__/integration/helpers/setup.ts @@ -42,6 +42,7 @@ export async function startServices(opts: StartServicesOptions = {}): Promise { teardownIndexerMocks(); }); + it('refuses credential-free recovery without contacting the key provider', async () => { + const res = await fetch( + `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/recovery`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(recoveryRequestBody), + }, + ); + res.status.should.equal(401); + services.keyProvider.calls.should.have.length(0); + }); + it('recovers tbtc via external key provider, calling AWM multisig/recovery', async () => { const indexer = setupIndexerMocks({ fundsAddress: ADDR_WITH_FUNDS, @@ -100,7 +113,11 @@ describe('Recovery wallet: EXTERNAL signing', () => { `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/recovery`, { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + 'x-recovery-token': 'test-recovery-token-at-least-32-characters', + }, body: JSON.stringify(recoveryRequestBody), }, ); @@ -202,7 +219,11 @@ describe('Recovery wallet: LOCAL signing', () => { `http://${LOCALHOST}:${services.mbePort}/api/v1/tbtc/advancedwallet/recovery`, { method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' }, + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-token', + 'x-recovery-token': 'test-recovery-token-at-least-32-characters', + }, body: JSON.stringify(recoveryRequestBody), }, ); diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index c715ef82..6c56c8c8 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -3,6 +3,9 @@ import 'should'; import request from 'supertest'; import express from 'express'; import { AppMode, TlsMode, SigningMode } from '../shared/types'; +import { app as awmApp } from '../advancedWalletManagerApp'; +import { app as mbeApp } from '../masterBitGoExpressApp'; +import { makeMasterExpressTestConfig } from './api/master/testUtils'; import { setupRoutes } from '../advancedWalletManager/routers/advancedWalletManager'; describe('Routes', () => { @@ -23,6 +26,108 @@ describe('Routes', () => { }); }); + describe('Recovery authorization without TLS', () => { + const token = 'test-recovery-token-at-least-32-characters'; + const awm = awmApp({ + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + keyProviderUrl: 'http://localhost:3000', + bind: 'localhost', + port: 0, + timeout: 5000, + httpLoggerFile: '', + tlsMode: TlsMode.DISABLED, + recoveryMode: true, + recoveryAuthToken: token, + }); + const mbe = mbeApp( + makeMasterExpressTestConfig('http://localhost:3080', { + overrides: { recoveryMode: true, recoveryAuthToken: token }, + }), + ); + + for (const path of [ + '/api/tbtc/multisig/recovery', + '/api/tbtc/mpc/recovery', + '/api/tbtc/mpcv2/recovery', + ]) { + it(`rejects missing and incorrect tokens on AWM ${path}`, async () => { + (await request(awm).post(path).send({})).status.should.equal(401); + ( + await request(awm).post(path).set('x-recovery-token', 'wrong').send({}) + ).status.should.equal(401); + ( + await request(awm).post(path).set('x-recovery-token', token).send({}) + ).status.should.not.equal(401); + }); + } + + for (const path of [ + '/api/v1/tbtc/advancedwallet/recovery', + '/api/v1/tbtc/advancedwallet/recoveryconsolidations', + ]) { + it(`rejects missing and incorrect tokens on MBE ${path}`, async () => { + ( + await request(mbe).post(path).set('Authorization', 'Bearer bitgo-token').send({}) + ).status.should.equal(401); + ( + await request(mbe).post(path).set('x-recovery-token', 'wrong').send({}) + ).status.should.equal(401); + ( + await request(mbe).post(path).set('x-recovery-token', token).send({}) + ).status.should.not.equal(401); + }); + } + + const disabledAwm = awmApp({ + appMode: AppMode.ADVANCED_WALLET_MANAGER, + signingMode: SigningMode.LOCAL, + keyProviderUrl: 'http://localhost:3000', + bind: 'localhost', + port: 0, + timeout: 5000, + httpLoggerFile: '', + tlsMode: TlsMode.DISABLED, + recoveryMode: false, + }); + const disabledMbe = mbeApp( + makeMasterExpressTestConfig('http://localhost:3080', { + overrides: { recoveryMode: false }, + }), + ); + const recoveryModeDisabledMessage = + 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.'; + + for (const path of [ + '/api/tbtc/multisig/recovery', + '/api/tbtc/mpc/recovery', + '/api/tbtc/mpcv2/recovery', + ]) { + it(`rejects disabled AWM recovery before route handling for ${path}`, async () => { + const response = await request(disabledAwm) + .post(path) + .set('x-recovery-token', token) + .send({}); + response.status.should.equal(500); + response.body.should.have.property('details', recoveryModeDisabledMessage); + }); + } + + for (const path of [ + '/api/v1/tbtc/advancedwallet/recovery', + '/api/v1/tbtc/advancedwallet/recoveryconsolidations', + ]) { + it(`rejects disabled MBE recovery before route handling for ${path}`, async () => { + const response = await request(disabledMbe) + .post(path) + .set('x-recovery-token', token) + .send({}); + response.status.should.equal(500); + response.body.should.have.property('details', recoveryModeDisabledMessage); + }); + } + }); + describe('Health Check Routes', () => { it('should return 200 and status message for /ping', async () => { const response = await request(app).post('/ping'); diff --git a/src/advancedWalletManager/handlers/eddsaMPCRecovery.ts b/src/advancedWalletManager/handlers/eddsaMPCRecovery.ts index 1b70abe9..aabec064 100644 --- a/src/advancedWalletManager/handlers/eddsaMPCRecovery.ts +++ b/src/advancedWalletManager/handlers/eddsaMPCRecovery.ts @@ -10,7 +10,7 @@ import { import { Ed25519Bip32HdTree } from '@bitgo-beta/sdk-lib-mpc'; import { CoinFamily, coins } from '@bitgo-beta/statics'; import { type KeyPair as SolKeyPair } from '@bitgo-beta/sdk-coin-sol'; -import { checkRecoveryMode, retrieveKeyProviderPrvKey } from './utils/utils'; +import { retrieveKeyProviderPrvKey } from './utils/utils'; import { AdvancedWalletManagerConfig } from '../../shared/types'; import logger from '../../shared/logger'; @@ -88,8 +88,6 @@ export async function signEddsaRecoveryTransaction({ let publicKey = ''; logger.info(`Received request ${JSON.stringify(request)}`); - checkRecoveryMode(cfg); - const hdTree = await Ed25519Bip32HdTree.initialize(); const MPC = await Eddsa.initialize(hdTree); diff --git a/src/advancedWalletManager/handlers/multisigRecovery.ts b/src/advancedWalletManager/handlers/multisigRecovery.ts index 5e714e51..672f61dd 100644 --- a/src/advancedWalletManager/handlers/multisigRecovery.ts +++ b/src/advancedWalletManager/handlers/multisigRecovery.ts @@ -12,7 +12,7 @@ import { RecoveryMultisigEthLikeHalfSignedCodec, RecoveryMultisigFlatTxHexCodec, } from '../routers/advancedWalletManagerApiSpec'; -import { AdvancedWalletManagerConfig, EnvironmentName } from '../../initConfig'; +import { EnvironmentName } from '../../initConfig'; import logger from '../../shared/logger'; import { BadRequestError, BitgoApiResponseError } from '../../shared/errors'; import { isEthLikeCoin, isFormattedOfflineVaultTxInfo, isUtxoCoin } from '../../shared/coinUtils'; @@ -22,11 +22,7 @@ import { getReplayProtectionOptions, } from '../../shared/recoveryUtils'; import { SignedEthLikeRecoveryTx } from '../../types/transaction'; -import { - checkRecoveryMode, - retrieveKeyProviderPrvKey, - isExternalSigningEnabledForCoin, -} from './utils/utils'; +import { retrieveKeyProviderPrvKey, isExternalSigningEnabledForCoin } from './utils/utils'; import coinFactory from '../../shared/coinFactory'; import { KeyProviderClient } from '../keyProviderClient/keyProviderClient'; import { SignResponse } from '../keyProviderClient/types/sign'; @@ -35,8 +31,6 @@ import { KeySource } from '../../shared/types'; export async function recoveryMultisigTransaction( req: AwmApiSpecRouteRequest<'v1.multisig.recovery', 'post'>, ): Promise { - checkRecoveryMode(req.config as AdvancedWalletManagerConfig); - const { userPub, backupPub, diff --git a/src/advancedWalletManager/handlers/utils/utils.ts b/src/advancedWalletManager/handlers/utils/utils.ts index a8e62c85..0bd8f5e9 100644 --- a/src/advancedWalletManager/handlers/utils/utils.ts +++ b/src/advancedWalletManager/handlers/utils/utils.ts @@ -157,14 +157,6 @@ export function isNonBitgoKeySource(source: string): source is KeySource.USER | return source === KeySource.USER || source === KeySource.BACKUP; } -export function checkRecoveryMode(config: AdvancedWalletManagerConfig) { - if (!config.recoveryMode) { - throw new Error( - 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.', - ); - } -} - export async function verifyWalletSignatures({ bitgo, coin, diff --git a/src/advancedWalletManagerApp.ts b/src/advancedWalletManagerApp.ts index 2995a46b..3dd774ae 100644 --- a/src/advancedWalletManagerApp.ts +++ b/src/advancedWalletManagerApp.ts @@ -19,6 +19,8 @@ import { configureServerTimeouts, prepareIpc, createMtlsMiddleware, + createRecoveryAuthMiddleware, + validateRecoveryConfig, } from './shared/appUtils'; import logger from './shared/logger'; @@ -106,6 +108,7 @@ export function createBaseUri(config: AdvancedWalletManagerConfig): string { * Create and configure the express application */ export function app(cfg: AdvancedWalletManagerConfig): express.Application { + validateRecoveryConfig(cfg); logger.info('App is initializing'); const app = express(); @@ -124,6 +127,12 @@ export function app(cfg: AdvancedWalletManagerConfig): express.Application { app.use(createMtlsMiddleware(cfg)); } + // Authorize recovery before dispatching to signing handlers, even without TLS. + app.post( + ['/api/:coin/multisig/recovery', '/api/:coin/mpc/recovery', '/api/:coin/mpcv2/recovery'], + createRecoveryAuthMiddleware(cfg), + ); + // Setup routes setupRoutes(app, cfg); diff --git a/src/initConfig.ts b/src/initConfig.ts index 13a60942..daf6a1da 100644 --- a/src/initConfig.ts +++ b/src/initConfig.ts @@ -10,7 +10,11 @@ import { EnvironmentName, } from './shared/types'; import logger from './shared/logger'; -import { validateTlsCertificates, validateMasterExpressConfig } from './shared/appUtils'; +import { + validateTlsCertificates, + validateMasterExpressConfig, + validateRecoveryConfig, +} from './shared/appUtils'; export { Config, @@ -209,6 +213,7 @@ function advancedWalletManagerEnvConfig(): Partial mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','), clientCertAllowSelfSigned: readEnvVar('CLIENT_CERT_ALLOW_SELF_SIGNED') === 'true', recoveryMode: readEnvVar('RECOVERY_MODE') === 'true', + recoveryAuthToken: readEnvVar('RECOVERY_AUTH_TOKEN'), }; } @@ -258,6 +263,7 @@ function mergeAkmConfigs( mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), clientCertAllowSelfSigned: get('clientCertAllowSelfSigned'), recoveryMode: get('recoveryMode'), + recoveryAuthToken: get('recoveryAuthToken'), }; } @@ -473,6 +479,7 @@ function masterExpressEnvConfig(): Partial { mtlsAllowedClientFingerprints: readEnvVar('MTLS_ALLOWED_CLIENT_FINGERPRINTS')?.split(','), clientCertAllowSelfSigned, recoveryMode: readEnvVar('RECOVERY_MODE') === 'true', + recoveryAuthToken: readEnvVar('RECOVERY_AUTH_TOKEN'), asyncModeConfig: readAsyncModeConfig(isAsyncMode), bitgoAccessToken: readEnvVar('BITGO_ACCESS_TOKEN'), }; @@ -527,6 +534,7 @@ function mergeMasterExpressConfigs( mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), clientCertAllowSelfSigned: get('clientCertAllowSelfSigned'), recoveryMode: get('recoveryMode'), + recoveryAuthToken: get('recoveryAuthToken'), asyncModeConfig: get('asyncModeConfig'), bitgoAccessToken: get('bitgoAccessToken'), }; @@ -648,13 +656,12 @@ export function configureMasterExpressMode(): MasterExpressConfig { export function initConfig(): Config { const appMode = determineAppMode(); - if (appMode === AppMode.ADVANCED_WALLET_MANAGER) { - return configureAdvancedWalletManagerMode(); - } else if (appMode === AppMode.MASTER_EXPRESS) { - return configureMasterExpressMode(); - } else { - throw new Error(`Unknown app mode: ${appMode}`); - } + const config = + appMode === AppMode.ADVANCED_WALLET_MANAGER + ? configureAdvancedWalletManagerMode() + : configureMasterExpressMode(); + validateRecoveryConfig(config); + return config; } // Type guards for working with the union type diff --git a/src/masterBitGoExpressApp.ts b/src/masterBitGoExpressApp.ts index 91a7b816..2d6883a4 100644 --- a/src/masterBitGoExpressApp.ts +++ b/src/masterBitGoExpressApp.ts @@ -13,6 +13,8 @@ import { configureServerTimeouts, prepareIpc, createMtlsMiddleware, + createRecoveryAuthMiddleware, + validateRecoveryConfig, } from './shared/appUtils'; import logger from './shared/logger'; import { setupRoutes } from './masterBitgoExpress/routers/masterBitGoExpress'; @@ -102,6 +104,7 @@ export function createBaseUri(config: MasterExpressConfig): string { * Create and configure the express application for master express mode */ export function app(cfg: MasterExpressConfig): express.Application { + validateRecoveryConfig(cfg); logger.info('Master express app is initializing'); const app = express(); @@ -114,6 +117,15 @@ export function app(cfg: MasterExpressConfig): express.Application { app.use(createMtlsMiddleware(cfg)); } + // Authorize recovery before dispatching to handlers, independent of TLS. + app.post( + [ + '/api/v1/:coin/advancedwallet/recovery', + '/api/v1/:coin/advancedwallet/recoveryconsolidations', + ], + createRecoveryAuthMiddleware(cfg), + ); + // Setup master express routes setupRoutes(app, cfg); diff --git a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts index 3b1a4008..a43100d5 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -196,6 +196,7 @@ export class AdvancedWalletManagerClient { private readonly awmServerCertAllowSelfSigned: boolean; private readonly coin?: string; private readonly tlsMode: TlsMode; + private readonly recoveryAuthToken?: string; private readonly apiClient: ApiClient; @@ -219,6 +220,7 @@ export class AdvancedWalletManagerClient { this.awmServerCertAllowSelfSigned = cfg.awmServerCertAllowSelfSigned ?? false; this.coin = coin; this.tlsMode = cfg.tlsMode; + this.recoveryAuthToken = cfg.recoveryAuthToken; // Create a request factory with TLS configuration const requestFactory = superagentRequestFactory(superagent, this.baseUrl); @@ -279,6 +281,9 @@ export class AdvancedWalletManagerClient { request = request.agent(this.createHttpsAgent()); } + if (this.recoveryAuthToken) { + request.set('x-recovery-token', this.recoveryAuthToken); + } const response = await request.decodeExpecting(200); return response.body; } catch (error) { @@ -424,6 +429,9 @@ export class AdvancedWalletManagerClient { if (this.tlsMode === TlsMode.MTLS) { request = request.agent(this.createHttpsAgent()); } + if (this.recoveryAuthToken) { + request.set('x-recovery-token', this.recoveryAuthToken); + } logger.info('Recovering multisig (user half-sign) for coin: %s', this.coin); const res = await request.decodeExpecting(200); @@ -453,6 +461,9 @@ export class AdvancedWalletManagerClient { if (this.tlsMode === TlsMode.MTLS) { request = request.agent(this.createHttpsAgent()); } + if (this.recoveryAuthToken) { + request.set('x-recovery-token', this.recoveryAuthToken); + } logger.info('Recovering multisig for coin: %s', this.coin); const res = await request.decodeExpecting(200); @@ -843,6 +854,9 @@ export class AdvancedWalletManagerClient { request = request.agent(this.createHttpsAgent()); } + if (this.recoveryAuthToken) { + request.set('x-recovery-token', this.recoveryAuthToken); + } const response = await request.decodeExpecting(200); return response.body; } catch (error: any) { diff --git a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts index f3379753..2b5bb3fa 100644 --- a/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts +++ b/src/masterBitgoExpress/handlers/handleRecoveryConsolidations.ts @@ -20,8 +20,6 @@ import type { Ada, Tada } from '@bitgo-beta/sdk-coin-ada'; import type { Dot, Tdot } from '@bitgo-beta/sdk-coin-dot'; import type { Tao, Ttao } from '@bitgo-beta/sdk-coin-tao'; import coinFactory from '../../shared/coinFactory'; -import { checkRecoveryMode } from './utils/utils'; -import { MasterExpressConfig } from '../../shared/types'; import { BadRequestError } from '../../shared/errors'; import { orThrow } from '../../shared/utils'; import { submitMultisigRecoveryJob } from './utils/multisigRecoveryUtils'; @@ -40,8 +38,6 @@ type RecoveryConsolidationResult = { export async function handleRecoveryConsolidations( req: MasterApiSpecRouteRequest<'v1.wallet.recoveryConsolidations', 'post'>, ) { - checkRecoveryMode(req.config as MasterExpressConfig); - const bitgo = req.bitgo; const coin = req.decoded.coin; const userClient = req.awmUserClient; diff --git a/src/masterBitgoExpress/handlers/recoveryWallet.ts b/src/masterBitgoExpress/handlers/recoveryWallet.ts index ece81704..97ee5018 100644 --- a/src/masterBitgoExpress/handlers/recoveryWallet.ts +++ b/src/masterBitgoExpress/handlers/recoveryWallet.ts @@ -29,12 +29,11 @@ import { RecoveryMultisigUnsignedSweepTx } from '../clients/advancedWalletManage import { MasterApiSpecRouteRequest, ScriptType2Of3 } from '../routers/masterBitGoExpressApiSpec'; import { CoinSpecificParams, CoinSpecificParamsUnion } from '../routers/recoveryRoute'; import { recoverEddsaWallets } from './recoveryEddsa'; -import { EnvironmentName, MasterExpressConfig } from '../../shared/types'; +import { EnvironmentName } from '../../shared/types'; import { recoverEcdsaMpcV2Params, recoverEcdsaMPCv2Wallets } from './recoveryEcdsa'; import logger from '../../shared/logger'; import { BadRequestError, NotImplementedError, ValidationError } from '../../shared/errors'; import { CoinFamily } from '@bitgo-beta/statics'; -import { checkRecoveryMode } from './utils/utils'; import { AsyncJobResponse } from '../clients/bridgeClient.types'; import { MultisigRecoveryBody, submitMultisigRecoveryJob } from './utils/multisigRecoveryUtils'; @@ -250,8 +249,6 @@ async function handleUtxoLikeRecovery( export async function handleRecoveryWallet( req: MasterApiSpecRouteRequest<'v1.wallet.recovery', 'post'>, ) { - checkRecoveryMode(req.config as MasterExpressConfig); - const bitgo = req.bitgo; const coin = req.decoded.coin; const { recoveryDestinationAddress, coinSpecificParams } = req.decoded; diff --git a/src/masterBitgoExpress/handlers/utils/utils.ts b/src/masterBitgoExpress/handlers/utils/utils.ts index d77f58b9..54bcb3b9 100644 --- a/src/masterBitgoExpress/handlers/utils/utils.ts +++ b/src/masterBitgoExpress/handlers/utils/utils.ts @@ -3,7 +3,6 @@ import { BaseCoin } from '@bitgo-beta/sdk-core'; import { CustomSigningFunction, RequestTracer, KeyIndices, Wallet } from '@bitgo-beta/sdk-core'; import coinFactory from '../../../shared/coinFactory'; import { AdvancedWalletManagerClient } from '../../clients/advancedWalletManagerClient'; -import { MasterExpressConfig } from '../../../shared/types'; /** * Fetch wallet and signing keychain, with validation for source and pubkey. @@ -105,11 +104,3 @@ export function makeCustomSigningFunction({ }); }; } - -export function checkRecoveryMode(config: MasterExpressConfig) { - if (!config.recoveryMode) { - throw new Error( - 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.', - ); - } -} diff --git a/src/shared/appUtils.ts b/src/shared/appUtils.ts index 27a65eb3..1f3565e5 100644 --- a/src/shared/appUtils.ts +++ b/src/shared/appUtils.ts @@ -4,6 +4,7 @@ import https from 'https'; import http from 'http'; import morgan from 'morgan'; import fs from 'fs'; +import { timingSafeEqual } from 'crypto'; import timeout from 'connect-timeout'; import bodyParser from 'body-parser'; import pjson from '../../package.json'; @@ -195,6 +196,46 @@ export function createMtlsMiddleware(config: { }; } +const recoveryModeDisabledDetails = + 'Recovery operations are not enabled. The server must be in recovery mode to perform this action.'; + +export function createRecoveryAuthMiddleware(config: Config): express.RequestHandler { + const expected = config.recoveryAuthToken && Buffer.from(config.recoveryAuthToken); + return (req, res, next) => { + if (!config.recoveryMode) { + return res.status(500).json({ + error: 'Error', + details: recoveryModeDisabledDetails, + }); + } + const supplied = req.get('x-recovery-token'); + if (!supplied || !expected) { + return res.status(401).json({ error: 'Recovery authorization required' }); + } + const actual = Buffer.from(supplied); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return res.status(401).json({ error: 'Recovery authorization required' }); + } + next(); + }; +} + +export function validateRecoveryConfig(config: Config): void { + if (!config.recoveryMode) { + return; + } + if ( + config.tlsMode === TlsMode.DISABLED && + !config.ipc && + !['localhost', '127.0.0.1', '::1', '[::1]'].includes(config.bind) + ) { + throw new Error('RECOVERY_MODE requires mTLS for non-local TCP binding'); + } + if (!config.recoveryAuthToken || Buffer.byteLength(config.recoveryAuthToken, 'utf8') < 32) { + throw new Error('RECOVERY_AUTH_TOKEN must be at least 32 bytes when RECOVERY_MODE is enabled'); + } +} + export function validateTlsCertificates(config: Config) { if (isAdvancedWalletManagerConfig(config)) { if (!config.serverTlsKey || !config.serverTlsCert) { diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index b1b4ce66..5c6664a1 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -33,6 +33,7 @@ export interface BaseConfig { headersTimeout?: number; httpLoggerFile: string; recoveryMode?: boolean; + recoveryAuthToken?: string; } // Advanced wallet manager mode specific configuration @@ -111,7 +112,6 @@ export interface MasterExpressConfig extends BaseConfig { tlsMode: TlsMode; mtlsAllowedClientFingerprints?: string[]; clientCertAllowSelfSigned?: boolean; - recoveryMode?: boolean; asyncModeConfig: AsyncModeConfig; bitgoAccessToken?: string; }