Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions modules/express/src/clientRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { Config } from './config';
import { ApiResponseError, BitGoExpressError } from './errors';
import { promises as fs } from 'fs';
import { retryPromise } from './retryPromise';
import { SENSITIVE_REQUEST_KEYS } from './sensitiveRequestKeys';
import {
handleCreateSignerMacaroon,
handleGetLightningWalletState,
Expand Down Expand Up @@ -845,6 +846,30 @@ export async function handleV2CreateAddress(req: ExpressApiRouteRequest<'express
return result;
}

/**
* handle v2 verifyKey - verify that user-held TSS key material belongs to a wallet
* @param req
*/
export async function handleV2VerifyKey(req: ExpressApiRouteRequest<'express.v2.wallet.verifyKey', 'post'>) {
const coin = req.bitgo.coin(req.decoded.coin);
const wallet = await coin.wallets().get({ id: req.decoded.id });
try {
return await wallet.verifyKey({ prv: req.decoded.prv });
} catch (e) {
// errors with a meaningful HTTP status (e.g. the keychain fetch inside wallet.verifyKey)
// surface it instead of being masked as a 400
if (e instanceof Error && typeof (e as ApiResponseError).status === 'number') {
throw e;
}
// transport-level failures (DNS, connection refused, TLS) carry a system `code` instead;
// they are infrastructure errors, not malformed input
if (e instanceof Error && typeof (e as NodeJS.ErrnoException).code === 'string') {
throw e;
}
throw new ApiResponseError(e instanceof Error ? e.message : String(e), 400);
}
}

/**
* handle v2 isWalletAddress - verify if an address belongs to a wallet
* @param req
Expand Down Expand Up @@ -1751,16 +1776,6 @@ interface RequestHandler extends express.RequestHandler<ParamsDictionary, any, R
| Promise<RequestHandlerResponse>;
}

const SENSITIVE_REQUEST_KEYS = new Set([
'password',
'passphrase',
'walletpassphrase',
'prv',
'privatekey',
'encryptedprv',
'secret',
]);

function collectSensitiveRequestValues(value: unknown, values = new Set<string>()): Set<string> {
if (Array.isArray(value)) {
value.forEach((item) => collectSensitiveRequestValues(item, values));
Expand Down Expand Up @@ -2171,6 +2186,7 @@ export function setupAPIRoutes(app: express.Application, config: Config): void {
]);

router.post('express.v2.wallet.createAddress', [prepareBitGo(config), typedPromiseWrapper(handleV2CreateAddress)]);
router.post('express.v2.wallet.verifyKey', [prepareBitGo(config), typedPromiseWrapper(handleV2VerifyKey)]);
router.post('express.v2.wallet.isWalletAddress', [
prepareBitGo(config),
typedPromiseWrapper(handleV2IsWalletAddress),
Expand Down
13 changes: 13 additions & 0 deletions modules/express/src/sensitiveRequestKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Request body keys whose values must never be logged or echoed back to a caller.
* Compared lowercased against incoming keys.
*/
export const SENSITIVE_REQUEST_KEYS = new Set([
'password',
'passphrase',
'walletpassphrase',
'prv',
'privatekey',
'encryptedprv',
'secret',
]);
9 changes: 9 additions & 0 deletions modules/express/src/typedRoutes/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { PostCreateLocalKeyChain } from './v1/createLocalKeyChain';
import { PutConstructPendingApprovalTx } from './v1/constructPendingApprovalTx';
import { PutConsolidateUnspents } from './v1/consolidateUnspents';
import { PostCreateAddress } from './v2/createAddress';
import { PostVerifyKey } from './v2/verifyKey';
import { PutFanoutUnspents } from './v1/fanoutUnspents';
import { PostOfcSignPayload } from './v2/ofcSignPayload';
import { PostWalletRecoverToken } from './v2/walletRecoverToken';
Expand Down Expand Up @@ -233,6 +234,12 @@ export const ExpressV2WalletCreateAddressApiSpec = apiSpec({
},
});

export const ExpressV2WalletVerifyKeyApiSpec = apiSpec({
'express.v2.wallet.verifyKey': {
post: PostVerifyKey,
},
});

export const ExpressV2WalletIsWalletAddressApiSpec = apiSpec({
'express.v2.wallet.isWalletAddress': {
post: PostIsWalletAddress,
Expand Down Expand Up @@ -420,6 +427,7 @@ export type ExpressApi = typeof ExpressPingApiSpec &
typeof ExpressV2WalletConsolidateAccountApiSpec &
typeof ExpressWalletFanoutUnspentsApiSpec &
typeof ExpressV2WalletCreateAddressApiSpec &
typeof ExpressV2WalletVerifyKeyApiSpec &
typeof ExpressV2WalletIsWalletAddressApiSpec &
typeof ExpressV2AddressDeriveApiSpec &
typeof ExpressKeychainLocalApiSpec &
Expand Down Expand Up @@ -466,6 +474,7 @@ export const ExpressApi: ExpressApi = {
...ExpressWalletConsolidateUnspentsApiSpec,
...ExpressWalletFanoutUnspentsApiSpec,
...ExpressV2WalletCreateAddressApiSpec,
...ExpressV2WalletVerifyKeyApiSpec,
...ExpressV2WalletConsolidateAccountApiSpec,
...ExpressV2WalletIsWalletAddressApiSpec,
...ExpressV2AddressDeriveApiSpec,
Expand Down
4 changes: 4 additions & 0 deletions modules/express/src/typedRoutes/api/openapi-index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PostV2Encrypt } from './v2/encrypt';
import { PostGenerateWallet } from './v2/generateWallet';
import { GetV2PingExpress } from './v2/pingExpress';
import { PostWalletSweep } from './v2/walletSweep';
import { PostVerifyKey } from './v2/verifyKey';

/**
* Cumulative OpenAPI batch entrypoint.
Expand All @@ -27,4 +28,7 @@ export const ExpressOpenApiSpec = apiSpec({
'express.pingexpress': {
get: GetV2PingExpress,
},
'express.v2.wallet.verifyKey': {
post: PostVerifyKey,
},
});
55 changes: 55 additions & 0 deletions modules/express/src/typedRoutes/api/v2/verifyKey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as t from 'io-ts';
import { httpRoute, httpRequest } from '@api-ts/io-ts-http';
import { BitgoExpressError } from '../../schemas/error';

/**
* Path parameters for verifying user key material against a wallet
*/
export const VerifyKeyParams = {
/** Blockchain identifier (e.g., 'tsol', 'tdot', 'tsui') */
coin: t.string,
/** The wallet ID */
id: t.string,
} as const;

/**
* Request body for verifying user key material against a wallet
*/
export const VerifyKeyBody = {
/**
* User TSS signing material, the same string that would be passed as `prv` when signing a
* transaction (e.g. on sendmany); TSS EdDSA MPCv1 wallets only. Private key material: it is
* recombined locally and never sent to the server, and never echoed in error messages.
*/
prv: t.string,
} as const;

/**
* Response for verifying user key material against a wallet
*/
export const VerifyKeyResponse = {
/** Whether the signing material recombines to the wallet's commonKeychain */
200: t.type({ match: t.boolean }),
/** Invalid request parameters, unsupported wallet type, or malformed signing material */
400: BitgoExpressError,
} as const;

/**
* Verify that user-held TSS key material belongs to a wallet
*
* Recombines the shares locally and compares the result against the wallet's commonKeychain,
* answering up front whether the material can sign for this wallet. Supported for TSS EdDSA
* (MPCv1) wallets; other wallet types return a 400.
*
* @operationId express.v2.wallet.verifyKey
* @tag Express
*/
export const PostVerifyKey = httpRoute({
path: '/api/v2/{coin}/wallet/{id}/verifyKey',
method: 'POST',
request: httpRequest({
params: VerifyKeyParams,
body: VerifyKeyBody,
}),
response: VerifyKeyResponse,
});
11 changes: 10 additions & 1 deletion modules/express/src/typedRoutes/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as t from 'io-ts';

import { ValidationError } from '../errors';
import { SENSITIVE_REQUEST_KEYS } from '../sensitiveRequestKeys';

/**
* Formats io-ts validation errors into clear, human-readable messages.
Expand All @@ -25,7 +26,15 @@ export function formatValidationErrors(errors: t.Errors): string {
if (error.value === undefined) {
messages.push(`Missing required field '${path}'`);
} else {
const value = typeof error.value === 'object' ? JSON.stringify(error.value) : String(error.value);
// values on a sensitive path are redacted rather than interpolated: they can carry
// request material a caller must never get echoed back (e.g. key material sent with
// the wrong field type)
const isSensitive = path.split('.').some((segment) => SENSITIVE_REQUEST_KEYS.has(segment.toLowerCase()));
const value = isSensitive
? '[REDACTED]'
: error.value !== null && typeof error.value === 'object'
? JSON.stringify(error.value)
: String(error.value);
messages.push(`Invalid value for '${path}': expected ${expected}, got '${value}'`);
}
}
Expand Down
21 changes: 21 additions & 0 deletions modules/express/test/unit/typedRoutes/formatValidationErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,27 @@ describe('formatValidationErrors', function () {
assert.strictEqual(formatValidationErrors(errors), "Invalid value for 'field': expected string, got '123'.");
});

it('should redact values on a sensitive path instead of interpolating them', function () {
const errors: t.Errors = [{ value: { uShare: { seed: 'deadbeef' } }, context: [{ key: 'prv', type: t.string }] }];
assert.strictEqual(formatValidationErrors(errors), "Invalid value for 'prv': expected string, got '[REDACTED]'.");
});

it('should redact a sensitive path regardless of value type', function () {
const errors: t.Errors = [{ value: 'hunter2', context: [{ key: 'walletPassphrase', type: t.number }] }];
assert.strictEqual(
formatValidationErrors(errors),
"Invalid value for 'walletPassphrase': expected number, got '[REDACTED]'."
);
});

it('should interpolate structured values on a non-sensitive path', function () {
const errors: t.Errors = [{ value: { invalid: 'object' }, context: [{ key: 'webauthnInfo', type: t.string }] }];
assert.strictEqual(
formatValidationErrors(errors),
'Invalid value for \'webauthnInfo\': expected string, got \'{"invalid":"object"}\'.'
);
});

it('should format nested paths', function () {
const errors: t.Errors = [
{
Expand Down
Loading
Loading