Skip to content

fix(express): require authentication for signer-mode routes (WAL-2458) - #10150

Draft
Charan-Ratlan wants to merge 1 commit into
masterfrom
WAL-2458
Draft

Charan-Ratlan wants to merge 1 commit into
masterfrom
WAL-2458

Conversation

@Charan-Ratlan

Copy link
Copy Markdown
Contributor

problem/goal
Signer-mode Express instances registered their key-using routes (/api/v2/:coin/sign, /tssshare/*, /api/v2/ofc/signPayload) with only the prepareBitGo middleware, which checks no credential, so anyone who could reach the signer's port could have the stored wallet keys applied to attacker-chosen transactions and payloads. The OFC route additionally accepted walletPassphrase in the request body (an unthrottled passphrase oracle), and distinct wallet-lookup error messages allowed walletId enumeration and disclosed the key-file path.

implementation details

  • Add --signerauthsecret / BITGO_SIGNER_AUTH_SECRET config, required when signerMode or externalSignerUrl is set, and --sslca / BITGO_SSL_CA for TLS client authentication.
  • Add requireSignerAuth middleware in front of prepareBitGo on all three signer routes: dedicated X-Auth-Signer header, constant-time SHA-256 digest comparison, generic 401 before any key material is accessed.
  • Attach the secret on the online client side at every forward to the signer: custom signing function, OFC forward, and all TSS round generators.
  • checkPreconditions: refuse to start a signer without a secret of at least 16 characters, and refuse signerMode on non-loopback binds without TLS client authentication (sslCa together with server TLS) regardless of env; createHttpsServer enables requestCert, rejectUnauthorized and ca from sslCa.
  • Route all signer-mode wallet access through getSignerWalletCredentials: one uniform 400 error for unknown wallet, missing passphrase, unreadable key file and failed decryption, env-only passphrase on the OFC route, and a per-walletId lockout after 5 consecutive failures for 15 minutes.
  • Document the new requirements in EXTERNAL_SIGNER.md and README.md.

verification

  • modules/express: tsc -p . --noEmit clean; prettier and eslint clean on all touched files.
  • Full express unit suite with BITGOJS_TEST_PASSWORD set: 2006 passing, 0 failing (pristine master baseline: 1991 passing), including new middleware, gate, uniform-error, lockout and client-forward tests.

BREAKING CHANGE: signer-mode deployments must set BITGO_SIGNER_AUTH_SECRET (--signerauthsecret) and listen on loopback/IPC or configure TLS client authentication (--sslca together with --keypath/--crtpath or --sslkey/--sslcert). Instances using --externalSignerUrl must also set the secret. The OFC extSignPayload route no longer accepts walletPassphrase in the request body (environment variable only). Upgrade the online express instance before the signer; older signers ignore the new header.

Ticket: WAL-2458

@linear-code

linear-code Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

WAL-2458

@Charan-Ratlan

Copy link
Copy Markdown
Contributor Author

@claude review

@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

⚠️ Compatibility checks are failing on Node.js 27-nightly (experimental, non-blocking). This does not block merge, but it signals an incompatibility to fix before this release becomes supported.

View run

Comment thread modules/express/src/expressApp.ts Resolved

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread modules/express/src/clientRoutes.ts Resolved
Comment on lines +515 to +525
assertWalletNotLocked(walletId);
try {
const walletPassphrase = getWalletPwFromEnv(walletId);
const encryptedPrivKey = await getEncryptedPrivKey(signerFileSystemPath, walletId);
const privKey = await decryptPrivKey(bitgo, encryptedPrivKey, walletPassphrase);
walletSignFailures.delete(walletId);
return { privKey, walletPassphrase };
} catch (e) {
recordWalletSignFailure(walletId);
throw new ApiResponseError(WALLET_SIGN_FAILURE_MESSAGE, 400);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) Operators who fix a broken key file can still get 400s for up to 15 minutes, and nothing is logged. The lockout also blocks no passphrase guessing, since passphrases now come only from env. getSignerWalletCredentials counts every failure toward lockout, including a missing key-file entry, a JSON read/parse error and EMFILE. Fix: count only failures driven by caller input, or drop the lockout, since no route takes a caller passphrase now. A key-file fix must take effect on the next request, as on the base branch.

Why this was flagged

After this change no signer route takes a passphrase from the caller. The OFC body passphrase was removed, and getWalletPwFromEnv is the only source (clientRoutes.ts:517). So every failure counted by recordWalletSignFailure (clientRoutes.ts:523) comes from server-side state, not from a guess. The key file is re-read on every request (getEncryptedPrivKey, clientRoutes.ts:465). On the base branch, an operator who corrects encryptedPrivKeys.json (for example, re-running the fetch script for a missing or wrong walletId entry) restores signing at once, without a restart. Now five failed attempts for that walletId set lockedUntil 15 minutes ahead (WALLET_SIGN_FAILURE_LOCKOUT_MS, clientRoutes.ts:56). That is one or two user sends, since each TSS signing makes 3-4 signer calls. assertWalletNotLocked (clientRoutes.ts:487-491) then rejects even after the file is fixed. A transient read error or a partly written file during an update locks every wallet hit in that window the same way. The 400 looks the same as the original error, and nothing is logged.

Verification: In clientRoutes.ts, the catch in getSignerWalletCredentials calls recordWalletSignFailure(walletId) for every exception. After 5 failures it sets a 15-minute lockedUntil, and assertWalletNotLocked then throws the 400 before anything is read. Nothing is logged. On the base branch, fixing encryptedPrivKeys.json worked on the next request.

@Charan-Ratlan

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread modules/express/src/expressApp.ts Resolved
…AL-2458)

problem/goal
The signer auth secret was only length-checked, but it must travel as the
X-Auth-Signer header value: a secret with a trailing newline makes Node
refuse to send the header, and surrounding whitespace is stripped by header
parsing on the signer, so both instances start fine while every signing
forward fails with a 401 at runtime.

implementation details
- Both startup gates (signerMode and externalSignerUrl) now require the
  secret to match /^[\x21-\x7e]+$/ in addition to the 16-character minimum:
  printable, non-whitespace ASCII only, rejected on the signer and the
  online instance alike.

verification
- modules/express: tsc, prettier and eslint clean; full express unit suite
  with BITGOJS_TEST_PASSWORD set: 2012 passing, 0 failing across
  consecutive runs, including new cases for trailing newline, surrounding
  and interior whitespace, and non-ASCII secret characters.

Ticket: WAL-2458
@Charan-Ratlan

Copy link
Copy Markdown
Contributor Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

@Charan-Ratlan
Charan-Ratlan marked this pull request as ready for review November 11, 2026 08:34
@Charan-Ratlan
Charan-Ratlan requested review from a team as code owners November 11, 2026 08:34

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@rishikeshdadam136 rishikeshdadam136 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be a breaking change as discussed and merge conflicts also

@Charan-Ratlan
Charan-Ratlan marked this pull request as draft November 12, 2026 10:40

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants