From ed12fcb52fba1d3c3b75da300baa23a859c5fb70 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 24 Sep 2026 15:08:27 +0000 Subject: [PATCH 1/4] fix(mbe): require recovery authorization and private compose Require a recovery token on both services and reject unsafe non-local HTTP recovery. Switch the reference compose to private mTLS links with a certificate bootstrap so deployments cannot expose signed sweeps. Ticket: WCN-1929 Session-Id: b6e61b98-333e-49ec-81c7-f5dc6d5ab18f Task-Id: f204d240-69cb-41c8-b61d-680a4cb321ba --- .gitignore | 3 + README.md | 47 +++---- docker-compose.yml | 129 ++++++------------ scripts/bootstrap-compose-certs.sh | 55 ++++++++ .../advancedWalletManager/recoveryMpc.test.ts | 4 + .../recoveryMpcV2.test.ts | 2 + .../recoveryMultisigTransaction.test.ts | 10 ++ .../recoveryMusigEth.test.ts | 2 + .../signMpcRecoveryTransaction.test.ts | 6 +- .../api/master/musigRecovery.test.ts | 2 + .../recoveryConsolidationsWallet.test.ts | 3 + .../api/master/recoveryWallet.test.ts | 3 + .../api/master/recoveryWalletMpcV2.test.ts | 2 + src/__tests__/api/master/testUtils.ts | 1 + src/__tests__/config.test.ts | 29 ++++ src/__tests__/integration/helpers/setup.ts | 2 + .../integration/recoveryWallet.integ.test.ts | 12 +- src/__tests__/routes.test.ts | 45 ++++++ src/advancedWalletManagerApp.ts | 9 ++ src/initConfig.ts | 18 +-- src/masterBitGoExpressApp.ts | 9 ++ .../clients/advancedWalletManagerClient.ts | 14 ++ src/shared/appUtils.ts | 33 +++++ src/shared/types/index.ts | 2 +- 24 files changed, 317 insertions(+), 125 deletions(-) create mode 100755 scripts/bootstrap-compose-certs.sh 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 d1f00c66..741a6a71 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 @@ -154,7 +154,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 @@ -204,7 +204,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 | @@ -359,7 +360,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 @@ -373,33 +374,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 @@ -474,7 +471,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 ``` @@ -499,7 +496,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 b9d05857..1c1595e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,119 +1,74 @@ -version: '3.8' - +# Bootstrap certificates with scripts/bootstrap-compose-certs.sh before starting. +# Supply KEY_PROVIDER_URL (HTTPS) and ./deploy/certs/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 - - 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..27225046 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..88d18628 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(() => { 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..d861e8bb 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(() => { 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..7e6c1a6a 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 () => { diff --git a/src/__tests__/api/master/recoveryWallet.test.ts b/src/__tests__/api/master/recoveryWallet.test.ts index 392ed19b..6a3d8c8d 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 () => { 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 f57b8e08..2bad3eae 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -54,6 +54,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; @@ -140,6 +141,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', @@ -148,6 +150,22 @@ 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.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 = 'http://localhost:3000'; process.env.SERVER_TLS_KEY = mockTlsKey; @@ -370,6 +388,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 { `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 +206,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..a7d7443b 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,48 @@ 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); + }); + } + }); + 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/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 00a84423..d52d2084 100644 --- a/src/initConfig.ts +++ b/src/initConfig.ts @@ -10,7 +10,7 @@ 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, @@ -182,6 +182,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'), }; } @@ -231,6 +232,7 @@ function mergeAkmConfigs( mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), clientCertAllowSelfSigned: get('clientCertAllowSelfSigned'), recoveryMode: get('recoveryMode'), + recoveryAuthToken: get('recoveryAuthToken'), }; } @@ -441,6 +443,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'), }; @@ -495,6 +498,7 @@ function mergeMasterExpressConfigs( mtlsAllowedClientFingerprints: get('mtlsAllowedClientFingerprints'), clientCertAllowSelfSigned: get('clientCertAllowSelfSigned'), recoveryMode: get('recoveryMode'), + recoveryAuthToken: get('recoveryAuthToken'), asyncModeConfig: get('asyncModeConfig'), bitgoAccessToken: get('bitgoAccessToken'), }; @@ -616,13 +620,11 @@ 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..9882bb37 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,12 @@ 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 a576a31b..15c88884 100644 --- a/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts +++ b/src/masterBitgoExpress/clients/advancedWalletManagerClient.ts @@ -194,6 +194,7 @@ export class AdvancedWalletManagerClient { private readonly awmServerCertAllowSelfSigned: boolean; private readonly coin?: string; private readonly tlsMode: TlsMode; + private readonly recoveryAuthToken?: string; private readonly apiClient: ApiClient; @@ -217,6 +218,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); @@ -277,6 +279,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) { @@ -422,6 +427,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); @@ -451,6 +459,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); @@ -841,6 +852,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/shared/appUtils.ts b/src/shared/appUtils.ts index 27a65eb3..dbc4cf62 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,38 @@ export function createMtlsMiddleware(config: { }; } +export function createRecoveryAuthMiddleware(config: Config): express.RequestHandler { + return (req, res, next) => { + if (!config.recoveryMode) { + return next(); + } + const supplied = req.get('x-recovery-token'); + const expected = config.recoveryAuthToken; + if (!supplied || !expected) { + return res.status(401).json({ error: 'Recovery authorization required' }); + } + const actual = Buffer.from(supplied); + const secret = Buffer.from(expected); + if (actual.length !== secret.length || !timingSafeEqual(actual, secret)) { + 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; } From 6a1eab4642069bb8a687243a771f322a773b59fa Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 24 Sep 2026 15:17:15 +0000 Subject: [PATCH 2/4] fix(docker): exclude bootstrap keys from build context Keep generated deployment credentials out of Docker build layers, verify rejected requests never reach the KMS, and authenticate split recovery fixtures so the security guard covers every route. Ticket: WCN-1929 Session-Id: b6e61b98-333e-49ec-81c7-f5dc6d5ab18f Task-Id: f204d240-69cb-41c8-b61d-680a4cb321ba --- .dockerignore | 1 + docker-compose.yml | 2 +- .../advancedWalletManager/recoveryMpc.test.ts | 2 +- .../recoveryMpcV2.test.ts | 1 + .../recoveryMusigEth.test.ts | 1 + .../recoveryConsolidationsWallet.test.ts | 2 ++ .../api/master/recoveryWallet.test.ts | 3 ++ .../integration/recoveryWallet.integ.test.ts | 13 +++++++++ src/__tests__/routes.test.ts | 28 +++++++++++++------ src/initConfig.ts | 13 ++++++--- src/masterBitGoExpressApp.ts | 5 +++- src/shared/appUtils.ts | 12 ++++---- 12 files changed, 63 insertions(+), 20 deletions(-) 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/docker-compose.yml b/docker-compose.yml index 1c1595e1..f59833bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ # Bootstrap certificates with scripts/bootstrap-compose-certs.sh before starting. -# Supply KEY_PROVIDER_URL (HTTPS) and ./deploy/certs/key-provider-ca.pem from your key provider. +# 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: advanced-wallet-manager: diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts index 27225046..798cd44c 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpc.test.ts @@ -177,7 +177,7 @@ describe('recoveryMpc', () => { httpLoggerFile: '', tlsMode: TlsMode.DISABLED, recoveryMode: true, - recoveryAuthToken: 'test-recovery-token-at-least-32-characters', + recoveryAuthToken: 'test-recovery-token-at-least-32-characters', }; const dualApp = expressApp(dualCfg); diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts index 88d18628..50970e3b 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts @@ -170,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) diff --git a/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts index d861e8bb..2cd036ea 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMusigEth.test.ts @@ -124,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/master/recoveryConsolidationsWallet.test.ts b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts index 7e6c1a6a..e777b8af 100644 --- a/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts +++ b/src/__tests__/api/master/recoveryConsolidationsWallet.test.ts @@ -778,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); @@ -801,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 6a3d8c8d..6960aad1 100644 --- a/src/__tests__/api/master/recoveryWallet.test.ts +++ b/src/__tests__/api/master/recoveryWallet.test.ts @@ -909,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); @@ -931,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); @@ -1001,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__/integration/recoveryWallet.integ.test.ts b/src/__tests__/integration/recoveryWallet.integ.test.ts index e52d303c..3e083334 100644 --- a/src/__tests__/integration/recoveryWallet.integ.test.ts +++ b/src/__tests__/integration/recoveryWallet.integ.test.ts @@ -88,6 +88,19 @@ describe('Recovery wallet: EXTERNAL signing', () => { 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, diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index a7d7443b..63a8bb5c 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -40,9 +40,11 @@ describe('Routes', () => { recoveryMode: true, recoveryAuthToken: token, }); - const mbe = mbeApp(makeMasterExpressTestConfig('http://localhost:3080', { - overrides: { recoveryMode: true, recoveryAuthToken: token }, - })); + const mbe = mbeApp( + makeMasterExpressTestConfig('http://localhost:3080', { + overrides: { recoveryMode: true, recoveryAuthToken: token }, + }), + ); for (const path of [ '/api/tbtc/multisig/recovery', @@ -51,8 +53,12 @@ describe('Routes', () => { ]) { 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); + ( + 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); }); } @@ -61,9 +67,15 @@ describe('Routes', () => { '/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); + ( + 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); }); } }); diff --git a/src/initConfig.ts b/src/initConfig.ts index d52d2084..623c5f6e 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, validateRecoveryConfig } from './shared/appUtils'; +import { + validateTlsCertificates, + validateMasterExpressConfig, + validateRecoveryConfig, +} from './shared/appUtils'; export { Config, @@ -620,9 +624,10 @@ export function configureMasterExpressMode(): MasterExpressConfig { export function initConfig(): Config { const appMode = determineAppMode(); - const config = appMode === AppMode.ADVANCED_WALLET_MANAGER - ? configureAdvancedWalletManagerMode() - : configureMasterExpressMode(); + const config = + appMode === AppMode.ADVANCED_WALLET_MANAGER + ? configureAdvancedWalletManagerMode() + : configureMasterExpressMode(); validateRecoveryConfig(config); return config; } diff --git a/src/masterBitGoExpressApp.ts b/src/masterBitGoExpressApp.ts index 9882bb37..2d6883a4 100644 --- a/src/masterBitGoExpressApp.ts +++ b/src/masterBitGoExpressApp.ts @@ -119,7 +119,10 @@ export function app(cfg: MasterExpressConfig): express.Application { // Authorize recovery before dispatching to handlers, independent of TLS. app.post( - ['/api/v1/:coin/advancedwallet/recovery', '/api/v1/:coin/advancedwallet/recoveryconsolidations'], + [ + '/api/v1/:coin/advancedwallet/recovery', + '/api/v1/:coin/advancedwallet/recoveryconsolidations', + ], createRecoveryAuthMiddleware(cfg), ); diff --git a/src/shared/appUtils.ts b/src/shared/appUtils.ts index dbc4cf62..2235e6a6 100644 --- a/src/shared/appUtils.ts +++ b/src/shared/appUtils.ts @@ -197,18 +197,17 @@ export function createMtlsMiddleware(config: { } export function createRecoveryAuthMiddleware(config: Config): express.RequestHandler { + const expected = config.recoveryAuthToken && Buffer.from(config.recoveryAuthToken); return (req, res, next) => { if (!config.recoveryMode) { return next(); } const supplied = req.get('x-recovery-token'); - const expected = config.recoveryAuthToken; if (!supplied || !expected) { return res.status(401).json({ error: 'Recovery authorization required' }); } const actual = Buffer.from(supplied); - const secret = Buffer.from(expected); - if (actual.length !== secret.length || !timingSafeEqual(actual, secret)) { + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { return res.status(401).json({ error: 'Recovery authorization required' }); } next(); @@ -219,8 +218,11 @@ 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)) { + 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) { From 6322fc7a9cda4771de4d65f7b5da18bcffcb2966 Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 24 Sep 2026 17:43:19 -0400 Subject: [PATCH 3/4] fix(awm): gate MPCv2 recovery Ticket: WCN-1931 --- .../recoveryMpcV2.test.ts | 38 +++++++++++++++++++ .../handlers/ecdsaMPCV2Recovery.ts | 3 +- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts index 50970e3b..e9265ef7 100644 --- a/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts +++ b/src/__tests__/api/advancedWalletManager/recoveryMpcV2.test.ts @@ -230,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/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts index 25a2ee04..852e11d8 100644 --- a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts +++ b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts @@ -8,7 +8,7 @@ import { isCosmosLikeCoin, isEcdsaCoin, isEthLikeCoin } from '../../shared/coinU import { BadRequestError, NotImplementedError } from '../../shared/errors'; import logger from '../../shared/logger'; import coinFactory from '../../shared/coinFactory'; -import { buildBackupKmsConfig, retrieveKeyProviderPrvKey } from './utils/utils'; +import { buildBackupKmsConfig, checkRecoveryMode, retrieveKeyProviderPrvKey } from './utils/utils'; async function getMessageHash(coin: BaseCoin, txHex: string): Promise { const txBuffer = Buffer.from(txHex, 'hex'); @@ -42,6 +42,7 @@ async function getMessageHash(coin: BaseCoin, txHex: string): Promise { export async function ecdsaMPCv2Recovery( req: AwmApiSpecRouteRequest<'v1.mpcv2.recovery', 'post'>, ): Promise { + checkRecoveryMode(req.config); const { txHex, pub } = req.decoded; const bitgo = req.bitgo; const coin = await coinFactory.getCoin(req.params.coin, bitgo); From bc48352c4b5741c484a92f6dc9c6073627da2d5d Mon Sep 17 00:00:00 2001 From: Pranav Jain Date: Thu, 24 Sep 2026 17:58:23 -0400 Subject: [PATCH 4/4] refactor(awm): centralize recovery guards Ticket: WCN-1931 --- src/__tests__/routes.test.ts | 48 +++++++++++++++++++ .../handlers/ecdsaMPCV2Recovery.ts | 3 +- .../handlers/eddsaMPCRecovery.ts | 4 +- .../handlers/multisigRecovery.ts | 10 +--- .../handlers/utils/utils.ts | 8 ---- .../handlers/handleRecoveryConsolidations.ts | 4 -- .../handlers/recoveryWallet.ts | 5 +- .../handlers/utils/utils.ts | 9 ---- src/shared/appUtils.ts | 8 +++- 9 files changed, 60 insertions(+), 39 deletions(-) diff --git a/src/__tests__/routes.test.ts b/src/__tests__/routes.test.ts index 63a8bb5c..6c56c8c8 100644 --- a/src/__tests__/routes.test.ts +++ b/src/__tests__/routes.test.ts @@ -78,6 +78,54 @@ describe('Routes', () => { ).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', () => { diff --git a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts index 852e11d8..25a2ee04 100644 --- a/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts +++ b/src/advancedWalletManager/handlers/ecdsaMPCV2Recovery.ts @@ -8,7 +8,7 @@ import { isCosmosLikeCoin, isEcdsaCoin, isEthLikeCoin } from '../../shared/coinU import { BadRequestError, NotImplementedError } from '../../shared/errors'; import logger from '../../shared/logger'; import coinFactory from '../../shared/coinFactory'; -import { buildBackupKmsConfig, checkRecoveryMode, retrieveKeyProviderPrvKey } from './utils/utils'; +import { buildBackupKmsConfig, retrieveKeyProviderPrvKey } from './utils/utils'; async function getMessageHash(coin: BaseCoin, txHex: string): Promise { const txBuffer = Buffer.from(txHex, 'hex'); @@ -42,7 +42,6 @@ async function getMessageHash(coin: BaseCoin, txHex: string): Promise { export async function ecdsaMPCv2Recovery( req: AwmApiSpecRouteRequest<'v1.mpcv2.recovery', 'post'>, ): Promise { - checkRecoveryMode(req.config); const { txHex, pub } = req.decoded; const bitgo = req.bitgo; const coin = await coinFactory.getCoin(req.params.coin, bitgo); 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/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 2235e6a6..1f3565e5 100644 --- a/src/shared/appUtils.ts +++ b/src/shared/appUtils.ts @@ -196,11 +196,17 @@ 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 next(); + return res.status(500).json({ + error: 'Error', + details: recoveryModeDisabledDetails, + }); } const supplied = req.get('x-recovery-token'); if (!supplied || !expected) {