diff --git a/modules/express/src/typedRoutes/api/common/decrypt.ts b/modules/express/src/typedRoutes/api/common/decrypt.ts index d7ad264169..67e6267559 100644 --- a/modules/express/src/typedRoutes/api/common/decrypt.ts +++ b/modules/express/src/typedRoutes/api/common/decrypt.ts @@ -8,13 +8,16 @@ export const DecryptRequestBody = { }; /** - * Decrypt + * Decrypt messages + * + * Decrypt a ciphertext generated by encrypt route with provided password * * @operationId express.decrypt - * @tag express + * @tag Express + * @public */ export const PostDecrypt = httpRoute({ - path: '/api/v[12]/decrypt', + path: '/api/v2/decrypt', method: 'POST', request: httpRequest({ body: DecryptRequestBody, diff --git a/modules/express/src/typedRoutes/api/express_entry.ts b/modules/express/src/typedRoutes/api/express_entry.ts new file mode 100644 index 0000000000..e5e23f90ec --- /dev/null +++ b/modules/express/src/typedRoutes/api/express_entry.ts @@ -0,0 +1,356 @@ +import * as t from 'io-ts'; +import { apiSpec } from '@api-ts/io-ts-http'; +import * as express from 'express'; + +/* import { GetPing } from './common/ping'; +import { GetPingExpress } from './common/pingExpress'; +import { PostLogin } from './common/login'; */ +import { PostDecrypt } from './common/decrypt'; +import { PostEncrypt } from './common/encrypt'; +/* import { PostVerifyAddress } from './common/verifyAddress'; */ +/* import { PostV1CalculateMinerFeeInfo } from './v1/calculateMinerFeeInfo'; +import { PostV2CalculateMinerFeeInfo } from './v2/calculateMinerFeeInfo'; */ +/* import { PostAcceptShare } from './v1/acceptShare'; +import { PostSimpleCreate } from './v1/simpleCreate'; */ +/* import { PutPendingApproval } from './v1/pendingApproval'; */ +/* import { PostSignTransaction } from './v1/signTransaction'; */ +/* import { PostKeychainLocal } from './v2/keychainLocal'; +import { GetLightningState } from './v2/lightningState'; +import { PostKeychainChangePassword } from './v2/keychainChangePassword'; +import { PostLightningInitWallet } from './v2/lightningInitWallet'; +import { PostUnlockLightningWallet } from './v2/unlockWallet'; */ +/* import { PostVerifyCoinAddress } from './v2/verifyAddress'; */ +/* import { PostDeriveLocalKeyChain } from './v1/deriveLocalKeyChain'; +import { PostCreateLocalKeyChain } from './v1/createLocalKeyChain'; */ +/* import { PutConstructPendingApprovalTx } from './v1/constructPendingApprovalTx'; */ +/* import { PutConsolidateUnspents } from './v1/consolidateUnspents'; */ +/* import { PostCreateAddress } from './v2/createAddress'; */ +/* import { PutFanoutUnspents } from './v1/fanoutUnspents'; */ +/* import { PostOfcSignPayload } from './v2/ofcSignPayload'; */ +/* import { PostWalletRecoverToken } from './v2/walletRecoverToken'; */ +import { PostGenerateWallet } from './v2/generateWallet'; +/* import { PostSignerMacaroon } from './v2/signerMacaroon'; */ +/* import { PostCoinSignTx } from './v2/coinSignTx'; */ +/* import { PostWalletSignTx } from './v2/walletSignTx'; */ +/* import { PostWalletTxSignTSS } from './v2/walletTxSignTSS'; */ +/* import { PostShareWallet } from './v2/shareWallet'; */ +/* import { PutExpressWalletUpdate } from './v2/expressWalletUpdate'; */ +/* import { PostFanoutUnspents } from './v2/fanoutUnspents'; */ +/* import { PostSendMany } from './v2/sendmany'; */ +/* import { PostConsolidateUnspents } from './v2/consolidateunspents'; */ +/* import { PostPrebuildAndSignTransaction } from './v2/prebuildAndSignTransaction'; */ +/* import { PostCoinSign } from './v2/coinSign'; */ +/* import { PostSendCoins } from './v2/sendCoins'; */ +/* import { PostGenerateShareTSS } from './v2/generateShareTSS'; */ +/* import { PostOfcExtSignPayload } from './v2/ofcExtSignPayload'; */ +/* import { PostLightningWalletPayment } from './v2/lightningPayment'; */ +/* import { PostLightningWalletWithdraw } from './v2/lightningWithdraw'; */ +/* import { PutV2PendingApproval } from './v2/pendingApproval'; */ +/* import { PostConsolidateAccount } from './v2/consolidateAccount'; */ +/* import { PostCanonicalAddress } from './v2/canonicalAddress'; */ +/* import { PostWalletEnableTokens } from './v2/walletEnableTokens'; */ +/* import { PostWalletSweep } from './v2/walletSweep'; */ +/* import { PostWalletAccelerateTx } from './v2/walletAccelerateTx'; */ +/* import { PostIsWalletAddress } from './v2/isWalletAddress'; */ +/* import { GetAccountResources } from './v2/accountResources'; +import { GetResourceDelegations } from './v2/resourceDelegations'; */ + +// Too large types can cause the following error +// +// > error TS7056: The inferred type of this node exceeds the maximum length the compiler will serialize. An explicit type annotation is needed. +// +// Workarounds: (1) export heavy httpRoute handlers as `HttpRoute<'post'>` (etc.) in their modules so apiSpec +// inference stays small; (2) only construct expressApi with a single key and add it to the type union at the end. + +/* export const ExpressPingApiSpec = apiSpec({ + 'express.ping': { + get: GetPing, + }, +}); */ + +/* export const ExpressPingExpressApiSpec = apiSpec({ + 'express.pingExpress': { + get: GetPingExpress, + }, +}); */ + +/* export const ExpressLoginApiSpec = apiSpec({ + 'express.login': { + post: PostLogin, + }, +}); */ + +export const ExpressDecryptApiSpec = apiSpec({ + 'express.decrypt': { + post: PostDecrypt, + }, +}); + +export const ExpressEncryptApiSpec = apiSpec({ + 'express.encrypt': { + post: PostEncrypt, + }, +}); + +/* export const ExpressVerifyAddressApiSpec = apiSpec({ + 'express.verifyaddress': { + post: PostVerifyAddress, + }, +}); */ + +/* export const ExpressVerifyCoinAddressApiSpec = apiSpec({ + 'express.verifycoinaddress': { + post: PostVerifyCoinAddress, + }, +}); */ + +/* export const ExpressCalculateMinerFeeInfoApiSpec = apiSpec({ + 'express.v1.calculateminerfeeinfo': { + post: PostV1CalculateMinerFeeInfo, + }, + 'express.calculateminerfeeinfo': { + post: PostV2CalculateMinerFeeInfo, + }, +}); */ + +/* export const ExpressV1WalletAcceptShareApiSpec = apiSpec({ + 'express.v1.wallet.acceptShare': { + post: PostAcceptShare, + }, +}); */ + +/* export const ExpressV1WalletSimpleCreateApiSpec = apiSpec({ + 'express.v1.wallet.simplecreate': { + post: PostSimpleCreate, + }, +}); */ + +/* export const ExpressPendingApprovalsApiSpec = apiSpec({ + 'express.v1.pendingapprovals': { + put: PutPendingApproval, + }, + 'express.pendingapprovals': { + put: PutV2PendingApproval, + }, +}); */ + +/* export const ExpressWalletSignTransactionApiSpec = apiSpec({ + 'express.v1.wallet.signTransaction': { + post: PostSignTransaction, + }, + 'express.v2.wallet.prebuildandsigntransaction': { + post: PostPrebuildAndSignTransaction, + }, +}); */ + +/* export const ExpressV1KeychainDeriveApiSpec = apiSpec({ + 'express.v1.keychain.derive': { + post: PostDeriveLocalKeyChain, + }, +}); */ + +/* export const ExpressV1KeychainLocalApiSpec = apiSpec({ + 'express.v1.keychain.local': { + post: PostCreateLocalKeyChain, + }, +}); */ + +/* export const ExpressV1PendingApprovalConstructTxApiSpec = apiSpec({ + 'express.v1.pendingapproval.constructTx': { + put: PutConstructPendingApprovalTx, + }, +}); */ + +/* export const ExpressWalletConsolidateUnspentsApiSpec = apiSpec({ + 'express.v1.wallet.consolidateunspents': { + put: PutConsolidateUnspents, + }, + 'express.wallet.consolidateunspents': { + post: PostConsolidateUnspents, + }, +}); */ + +/* export const ExpressV2WalletConsolidateAccountApiSpec = apiSpec({ + 'express.wallet.consolidateaccount': { + post: PostConsolidateAccount, + }, +}); */ + +/* export const ExpressWalletFanoutUnspentsApiSpec = apiSpec({ + 'express.v1.wallet.fanoutunspents': { + put: PutFanoutUnspents, + }, + 'express.wallet.fanoutunspents': { + post: PostFanoutUnspents, + }, +}); */ + +/* export const ExpressV2WalletCreateAddressApiSpec = apiSpec({ + 'express.v2.wallet.createAddress': { + post: PostCreateAddress, + }, +}); */ + +/* export const ExpressV2WalletIsWalletAddressApiSpec = apiSpec({ + 'express.v2.wallet.isWalletAddress': { + post: PostIsWalletAddress, + }, +}); */ + +/* export const ExpressV2WalletSendManyApiSpec = apiSpec({ + 'express.wallet.sendmany': { + post: PostSendMany, + }, +}); */ + +/* export const ExpressV2WalletSendCoinsApiSpec = apiSpec({ + 'express.wallet.sendcoins': { + post: PostSendCoins, + }, +}); */ + +/* export const ExpressKeychainLocalApiSpec = apiSpec({ + 'express.keychain.local': { + post: PostKeychainLocal, + }, +}); */ + +/* export const ExpressKeychainChangePasswordApiSpec = apiSpec({ + 'express.keychain.changePassword': { + post: PostKeychainChangePassword, + }, +}); */ + +/* export const ExpressLightningWalletPaymentApiSpec = apiSpec({ + 'express.lightningpayinvoice': { + post: PostLightningWalletPayment, + }, +}); */ + +/* export const ExpressLightningGetStateApiSpec = apiSpec({ + 'express.lightning.getState': { + get: GetLightningState, + }, +}); */ + +/* export const ExpressLightningInitWalletApiSpec = apiSpec({ + 'express.lightning.initWallet': { + post: PostLightningInitWallet, + }, +}); */ + +/* export const ExpressLightningUnlockWalletApiSpec = apiSpec({ + 'express.lightning.unlockWallet': { + post: PostUnlockLightningWallet, + }, +}); */ + +/* export const ExpressLightningWalletWithdrawApiSpec = apiSpec({ + 'express.lightningwithdrawonchain': { + post: PostLightningWalletWithdraw, + }, +}); */ + +/* export const ExpressOfcSignPayloadApiSpec = apiSpec({ + 'express.ofc.signPayload': { + post: PostOfcSignPayload, + }, +}); */ + +/* export const ExpressWalletRecoverTokenApiSpec = apiSpec({ + 'express.wallet.recovertoken': { + post: PostWalletRecoverToken, + }, +}); */ + +/* export const ExpressWalletEnableTokensApiSpec = apiSpec({ + 'express.v2.wallet.enableTokens': { + post: PostWalletEnableTokens, + }, +}); */ + +/* export const ExpressCoinSigningApiSpec = apiSpec({ + 'express.signtx': { + post: PostCoinSignTx, + }, +}); */ + +/* export const ExpressExternalSigningApiSpec = apiSpec({ + 'express.v2.coin.sign': { + post: PostCoinSign, + }, + 'express.v2.tssshare.generate': { + post: PostGenerateShareTSS, + }, + 'express.v2.ofc.extSignPayload': { + post: PostOfcExtSignPayload, + }, +}); */ + +/* export const ExpressWalletSigningApiSpec = apiSpec({ + 'express.wallet.signtx': { + post: PostWalletSignTx, + }, + 'express.wallet.signtxtss': { + post: PostWalletTxSignTSS, + }, +}); */ + +export const ExpressWalletManagementApiSpec = apiSpec({ + 'express.wallet.generate': { + post: PostGenerateWallet, + }, +}); + +/* export const ExpressV2CanonicalAddressApiSpec = apiSpec({ + 'express.canonicaladdress': { + post: PostCanonicalAddress, + }, +}); */ + +/* export const ExpressV2WalletSweepApiSpec = apiSpec({ + 'express.wallet.sweep': { + post: PostWalletSweep, + }, +}); */ + +/* export const ExpressV2WalletAccelerateTxApiSpec = apiSpec({ + 'express.wallet.acceleratetx': { + post: PostWalletAccelerateTx, + }, +}); */ + +/* export const ExpressV2WalletAccountResourcesApiSpec = apiSpec({ + 'express.v2.wallet.getaccountresources': { + post: GetAccountResources, + }, +}); */ + +/* export const ExpressV2WalletResourceDelegationsApiSpec = apiSpec({ + 'express.v2.wallet.resourcedelegations': { + get: GetResourceDelegations, + }, +}); */ + +export type ExpressApi = typeof ExpressDecryptApiSpec & + typeof ExpressEncryptApiSpec & + typeof ExpressWalletManagementApiSpec; + +export const ExpressApi: ExpressApi = { + ...ExpressDecryptApiSpec, + ...ExpressEncryptApiSpec, + ...ExpressWalletManagementApiSpec, +}; + +type ExtractDecoded = T extends t.Type ? O : never; +type FlattenDecoded = T extends Record + ? (T extends { body: infer B } ? B : any) & + (T extends { query: infer Q } ? Q : any) & + (T extends { params: infer P } ? P : any) + : T; +export type ExpressApiRouteRequest< + ApiName extends keyof ExpressApi, + Method extends keyof ExpressApi[ApiName] +> = ExpressApi[ApiName][Method] extends { request: infer R } + ? express.Request & { decoded: FlattenDecoded> } + : never; diff --git a/modules/express/src/typedRoutes/api/v2/generateWallet.ts b/modules/express/src/typedRoutes/api/v2/generateWallet.ts index 0493aa4cd6..dbc6081956 100644 --- a/modules/express/src/typedRoutes/api/v2/generateWallet.ts +++ b/modules/express/src/typedRoutes/api/v2/generateWallet.ts @@ -97,7 +97,7 @@ export const GenerateWalletV2Query = { }; /** - * Generate Wallet + * Generate wallet * * Generate a new wallet for a coin. If you want a wallet to hold tokens, generate a wallet for the native coin of the blockchain (e.g. generate an ETH wallet to hold ERC20 tokens). * @@ -119,6 +119,7 @@ export const GenerateWalletV2Query = { * * @operationId express.wallet.generate * @tag Express + * @public */ export const PostGenerateWallet = httpRoute({ path: '/api/v2/{coin}/wallet/generate', diff --git a/modules/sdk-core/src/bitgo/passkey/index.ts b/modules/sdk-core/src/bitgo/passkey/index.ts new file mode 100644 index 0000000000..2bab8f58ed --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/index.ts @@ -0,0 +1,2 @@ +export { PasskeyDevice, PasskeyAuthResult, WebAuthnProvider } from './types'; +export { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers'; diff --git a/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts b/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts new file mode 100644 index 0000000000..b91b0da2b8 --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/prfHelpers.ts @@ -0,0 +1,32 @@ +import { PasskeyDevice } from './types'; + +/** + * Builds the `evalByCredential` map passed to `WebAuthnProvider.get()`. + * Maps each device's credId to its prfSalt so the authenticator can + * evaluate the PRF with the correct salt for whichever credential it selects. + * + * @param devices - passkey devices stored on the keychain + * @returns a map of { [credId]: prfSalt } + */ +export function buildEvalByCredential(devices: PasskeyDevice[]): Record { + return Object.fromEntries(devices.map((d) => [d.credId, d.prfSalt])); +} + +/** + * Finds the PasskeyDevice whose credId matches the credential ID returned + * by the WebAuthn assertion. + * + * @param devices - passkey devices stored on the keychain + * @param credentialId - base64url credential ID from the WebAuthn assertion + * @throws if no device matches + */ +export function matchDeviceByCredentialId(devices: PasskeyDevice[], credentialId: string): PasskeyDevice { + const device = devices.find((d) => d.credId === credentialId); + if (!device) { + throw new Error( + `No passkey device found matching credential ID "${credentialId}". ` + + `Known credential IDs: [${devices.map((d) => d.credId).join(', ')}]` + ); + } + return device; +} diff --git a/modules/sdk-core/src/bitgo/passkey/types.ts b/modules/sdk-core/src/bitgo/passkey/types.ts new file mode 100644 index 0000000000..32e0a5072b --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/types.ts @@ -0,0 +1,23 @@ +export interface PasskeyDevice { + /** MongoDB ObjectId — used for deletion API calls */ + otpDeviceId: string; + /** base64url WebAuthn credential ID — used for PRF evalByCredential map */ + credId: string; + /** Enterprise-scoped PRF salt stored on the keychain */ + prfSalt: string; + /** SJCL-encrypted private key (present once passkey is attached to a wallet keychain) */ + encryptedPrv?: string; +} + +export interface PasskeyAuthResult { + /** Raw PRF output from a WebAuthn assertion */ + prfResult: ArrayBuffer; + /** base64url credential ID returned by the authenticator — matches PasskeyDevice.credId */ + credentialId: string; + otpCode?: string; +} + +export interface WebAuthnProvider { + create(options: PublicKeyCredentialCreationOptions): Promise; + get(options: PublicKeyCredentialRequestOptions): Promise; +} diff --git a/modules/sdk-core/src/bitgo/passkey/webAuthnProvider.ts b/modules/sdk-core/src/bitgo/passkey/webAuthnProvider.ts new file mode 100644 index 0000000000..15b1bb0d69 --- /dev/null +++ b/modules/sdk-core/src/bitgo/passkey/webAuthnProvider.ts @@ -0,0 +1 @@ +export { WebAuthnProvider } from './types'; diff --git a/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts b/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts new file mode 100644 index 0000000000..776eb819bb --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/passkey/prfHelpers.test.ts @@ -0,0 +1,63 @@ +import * as assert from 'assert'; +import { buildEvalByCredential, matchDeviceByCredentialId } from '../../../../src/bitgo/passkey/prfHelpers'; +import { PasskeyDevice } from '../../../../src/bitgo/passkey/types'; + +const device1: PasskeyDevice = { + otpDeviceId: 'oid-1', + credId: 'cred-aaa', + prfSalt: 'salt-aaa', + encryptedPrv: 'enc-prv-1', +}; + +const device2: PasskeyDevice = { + otpDeviceId: 'oid-2', + credId: 'cred-bbb', + prfSalt: 'salt-bbb', +}; + +describe('buildEvalByCredential', function () { + it('maps each device credId to its prfSalt', function () { + const result = buildEvalByCredential([device1, device2]); + assert.deepStrictEqual(result, { + 'cred-aaa': 'salt-aaa', + 'cred-bbb': 'salt-bbb', + }); + }); + + it('returns an empty object for an empty device list', function () { + assert.deepStrictEqual(buildEvalByCredential([]), {}); + }); + + it('returns a single-entry map for one device', function () { + const result = buildEvalByCredential([device1]); + assert.deepStrictEqual(result, { 'cred-aaa': 'salt-aaa' }); + }); +}); + +describe('matchDeviceByCredentialId', function () { + it('returns the matching device', function () { + const result = matchDeviceByCredentialId([device1, device2], 'cred-bbb'); + assert.strictEqual(result, device2); + }); + + it('returns the first device when it matches', function () { + const result = matchDeviceByCredentialId([device1, device2], 'cred-aaa'); + assert.strictEqual(result, device1); + }); + + it('throws a descriptive error when no device matches', function () { + assert.throws( + () => matchDeviceByCredentialId([device1, device2], 'cred-unknown'), + (err: Error) => { + assert.ok(err.message.includes('cred-unknown')); + assert.ok(err.message.includes('cred-aaa')); + assert.ok(err.message.includes('cred-bbb')); + return true; + } + ); + }); + + it('throws when the device list is empty', function () { + assert.throws(() => matchDeviceByCredentialId([], 'cred-aaa'), Error); + }); +});