diff --git a/modules/abstract-lightning/README.md b/modules/abstract-lightning/README.md index 589c5c4b9dc..8b86b51c6c6 100644 --- a/modules/abstract-lightning/README.md +++ b/modules/abstract-lightning/README.md @@ -1 +1,36 @@ # BitGo abstract-lightning + +## Custodial userAuth key reset + +A custody lightning wallet's userAuth key is encrypted with the wallet password. If the customer forgets it, they +can replace the key, with BitGo support approving the request. + +```ts +const wallet = new CustodialLightningWallet(sdkWallet); + +// 1. Start a reset with the NEW passphrase. +const { requestId, status, verificationCode } = await wallet.requestUserAuthKeyReset({ + passphrase: newPassphrase, + encryptionVersion, +}); +// show `verificationCode` to the customer; they read it to BitGo support + +// 2. Show progress. Rejects with an error whose `status` is 404 when there is no request. +const { status, completedAt, releasedAt } = await wallet.getUserAuthKeyResetRequest(); + +// 3. Cancel while the request is still `requested`. +await wallet.cancelUserAuthKeyResetRequest(requestId); +``` + +`requestUserAuthKeyReset` creates the new key locally, encrypts it with the passphrase, registers the encrypted key, signs +a proof with the new key and queues the request. The private key and the passphrase are never sent unencrypted. The +verification code is computed locally and is never taken from a server response. + +`status` is one of `requested`, `canceled`, `expired`, `pending`, `dnr_held`, `wp_swapped`, `completed`, `failed`. +"Released" is not a status: the wallet is usable again once `releasedAt` is set on a `completed` request. + +After a successful reset, other wallet members lose access until the customer re-shares the wallet to them with +`wallets.reshareWalletWithSpenders(walletId, passphrase, encryptionVersion, { includeAdmins: true, onlyNeedsRecovery: true })`. + +`computeUserAuthResetVerificationCode(walletId, newUserAuthKeyPub)` and `compareUserAuthResetVerificationCodes(a, b)` are +exported so BitGo services and tools compute and compare the code identically. diff --git a/modules/abstract-lightning/package.json b/modules/abstract-lightning/package.json index ded31900dac..439ea487c19 100644 --- a/modules/abstract-lightning/package.json +++ b/modules/abstract-lightning/package.json @@ -50,6 +50,9 @@ "io-ts-types": "^0.5.16", "macaroon": "^3.0.4" }, + "devDependencies": { + "@bitgo/sdk-api": "^3.1.6" + }, "gitHead": "18e460ddf02de2dbf13c2aa243478188fb539f0c", "files": [ "dist" diff --git a/modules/abstract-lightning/src/codecs/api/index.ts b/modules/abstract-lightning/src/codecs/api/index.ts index 4ad33a83a23..c0995c617b8 100644 --- a/modules/abstract-lightning/src/codecs/api/index.ts +++ b/modules/abstract-lightning/src/codecs/api/index.ts @@ -5,3 +5,4 @@ export * from './payment'; export * from './withdraw'; export * from './transaction'; export * from './wallet'; +export * from './userAuthKeyReset'; diff --git a/modules/abstract-lightning/src/codecs/api/userAuthKeyReset.ts b/modules/abstract-lightning/src/codecs/api/userAuthKeyReset.ts new file mode 100644 index 00000000000..e4275e9e668 --- /dev/null +++ b/modules/abstract-lightning/src/codecs/api/userAuthKeyReset.ts @@ -0,0 +1,71 @@ +import * as t from 'io-ts'; + +/** + * The client proof for a userAuth key reset: the new key signs this exact set of fields (canonical JSON), + * which proves the client holds the new private key and binds the request to one wallet. + * + * Exact: a missing field fails and so does any extra field. (`t.exact` would silently strip extras.) + */ +const UserAuthKeyResetSignedRequestFields = t.type({ + walletId: t.string, + oldUserAuthKeyPub: t.string, + newUserAuthKeyPub: t.string, + newUserAuthKeyId: t.string, + createdAt: t.string, +}); +const signedRequestFieldCount = Object.keys(UserAuthKeyResetSignedRequestFields.props).length; + +export const UserAuthKeyResetSignedRequest = t.refinement( + UserAuthKeyResetSignedRequestFields, + (request) => Object.keys(request).length === signedRequestFieldCount, + 'UserAuthKeyResetSignedRequest' +); +export type UserAuthKeyResetSignedRequest = t.TypeOf; + +/** + * Status of a reset request as tracked by lightning-services. + * "Released" is not a status: it is `releasedAt !== null` on a `completed` or `failed` request. + */ +export const UserAuthKeyResetStatus = t.keyof({ + requested: null, + canceled: null, + expired: null, + pending: null, + dnr_held: null, + wp_swapped: null, + completed: null, + failed: null, +}); +export type UserAuthKeyResetStatus = t.TypeOf; + +/** Body of POST /api/v2/:coin/wallet/:walletId/lightning/userAuthKey/resetRequest */ +export const UserAuthKeyResetRequest = t.type({ + newUserAuthKeyId: t.string, + signedRequest: UserAuthKeyResetSignedRequest, + signature: t.string, +}); +export type UserAuthKeyResetRequest = t.TypeOf; + +/** Response of POST .../resetRequest */ +export const UserAuthKeyResetResponse = t.type({ + requestId: t.string, + status: UserAuthKeyResetStatus, + createdAt: t.string, +}); +export type UserAuthKeyResetResponse = t.TypeOf; + +/** Response of GET .../resetRequest: the caller's latest request. Never contains the verification code. */ +export const UserAuthKeyResetRequestInfo = t.type({ + requestId: t.string, + status: UserAuthKeyResetStatus, + createdAt: t.string, + completedAt: t.union([t.string, t.null]), + releasedAt: t.union([t.string, t.null]), +}); +export type UserAuthKeyResetRequestInfo = t.TypeOf; + +/** Response of DELETE .../resetRequest/:requestId */ +export const CancelUserAuthKeyResetResponse = t.type({ + status: t.literal('canceled'), +}); +export type CancelUserAuthKeyResetResponse = t.TypeOf; diff --git a/modules/abstract-lightning/src/lightning/signature.ts b/modules/abstract-lightning/src/lightning/signature.ts index cbb06e57119..eb05b526469 100644 --- a/modules/abstract-lightning/src/lightning/signature.ts +++ b/modules/abstract-lightning/src/lightning/signature.ts @@ -2,6 +2,21 @@ import * as utxolib from '@bitgo/utxo-lib'; import * as sdkcore from '@bitgo/sdk-core'; import { canonicalizeObject, Signable } from './signableJson'; +/** + * Parses a BIP32 lightning key. + * + * Lightning auth keys are generated with the default (bitcoin) network, so `tlnbtc` keys are still + * `xpub`/`xprv` strings even though the coin's network is testnet. Parsing them against the testnet + * version bytes would throw, so `xpub`/`xprv` keys are always parsed with the bitcoin versions. + * Any other prefix (e.g. `tpub`) is parsed with the supplied network, as before. + */ +function parseLightningKey(key: string, network: utxolib.Network): utxolib.BIP32Interface { + if (key.startsWith('xpub') || key.startsWith('xprv')) { + return utxolib.bip32.fromBase58(key, utxolib.networks.bitcoin); + } + return utxolib.bip32.fromBase58(key, network); +} + /** * Verifies a signature for a given message. * @@ -18,7 +33,7 @@ export function verifyMessageSignature( network: utxolib.Network = utxolib.networks.bitcoin ): boolean { const messageString = JSON.stringify(canonicalizeObject(message)); - const pubKey = utxolib.bip32.fromBase58(pub, network).publicKey; + const pubKey = parseLightningKey(pub, network).publicKey; const signatureBuffer = Buffer.from(signature, 'hex'); return sdkcore.verifyMessage(messageString, pubKey, signatureBuffer, network); } @@ -37,6 +52,6 @@ export function createMessageSignature( network: utxolib.Network = utxolib.networks.bitcoin ): string { const requestString = JSON.stringify(canonicalizeObject(message)); - const prvKey = utxolib.bip32.fromBase58(xprv, network); + const prvKey = parseLightningKey(xprv, network); return sdkcore.signMessage(requestString, prvKey, network).toString('hex'); } diff --git a/modules/abstract-lightning/src/wallet/custodialLightning.ts b/modules/abstract-lightning/src/wallet/custodialLightning.ts index 887c4a36a48..0454dd284b8 100644 --- a/modules/abstract-lightning/src/wallet/custodialLightning.ts +++ b/modules/abstract-lightning/src/wallet/custodialLightning.ts @@ -1,7 +1,43 @@ -import { ILightningWallet, LightningWallet } from './lightning'; +import { getLightningAuthKeychains, ILightningWallet, LightningWallet } from './lightning'; import * as sdkcore from '@bitgo/sdk-core'; +import { + CancelUserAuthKeyResetResponse, + UserAuthKeyResetRequest, + UserAuthKeyResetRequestInfo, + UserAuthKeyResetResponse, + UserAuthKeyResetSignedRequest, + UserAuthKeyResetStatus, +} from '../codecs'; +import { createMessageSignature, getUtxolibNetwork } from '../lightning'; +import { computeUserAuthResetVerificationCode } from './userAuthKeyReset'; -export type ICustodialLightningWallet = ILightningWallet; +export interface ICustodialLightningWallet extends ILightningWallet { + /** + * Starts a customer-initiated userAuth key reset for a forgotten wallet password. + * + * Generates a new userAuth key locally, encrypts it with the new passphrase, registers it as an unattached key, + * signs a proof with it and queues the request. The customer reads the returned verification code to BitGo support. + * The private key and the passphrase never leave this method. + * + * @param params.passphrase the NEW wallet passphrase that encrypts the new key + * @param params.encryptionVersion encryption version used for the new key + * @returns the request id and status from the server, and the verification code computed locally + */ + requestUserAuthKeyReset(params: { + passphrase: string; + encryptionVersion?: sdkcore.EncryptionVersion; + }): Promise<{ requestId: string; status: UserAuthKeyResetStatus; verificationCode: string }>; + + /** + * Gets the caller's latest userAuth key reset request. Rejects with an error whose `status` is 404 when there is none. + */ + getUserAuthKeyResetRequest(): Promise; + + /** + * Cancels a userAuth key reset request. Only possible while the request is still `requested`. + */ + cancelUserAuthKeyResetRequest(requestId: string): Promise; +} export class CustodialLightningWallet extends LightningWallet implements ICustodialLightningWallet { constructor(wallet: sdkcore.IWallet) { @@ -10,4 +46,78 @@ export class CustodialLightningWallet extends LightningWallet implements ICustod throw new Error(`Invalid lightning wallet type for custodial lightning: ${wallet.subType()}`); } } + + private resetRequestUrl(requestId?: string): string { + const suffix = requestId === undefined ? '' : `/${encodeURIComponent(requestId)}`; + return this.wallet.baseCoin.url(`/wallet/${this.wallet.id()}/lightning/userAuthKey/resetRequest${suffix}`); + } + + async requestUserAuthKeyReset(params: { + passphrase: string; + encryptionVersion?: sdkcore.EncryptionVersion; + }): Promise<{ requestId: string; status: UserAuthKeyResetStatus; verificationCode: string }> { + const coin = this.wallet.baseCoin; + const { userAuthKey } = await getLightningAuthKeychains(this.wallet); + + const { pub, prv } = coin.keychains().create(); + if (!pub || !prv) { + throw new Error('Failed to generate user auth key'); + } + + // Same shape wallet creation uses for the userAuth key; no passcode encryption code is sent. + const newKeychain = await coin.keychains().add({ + pub, + encryptedPrv: await this.wallet.bitgo.encrypt({ + password: params.passphrase, + input: prv, + encryptionVersion: params.encryptionVersion, + }), + source: 'user', + keyType: 'independent', + coinSpecific: { [coin.getChain()]: { purpose: 'userAuth' } }, + }); + + const signedRequest: UserAuthKeyResetSignedRequest = { + walletId: this.wallet.id(), + oldUserAuthKeyPub: userAuthKey.pub, + newUserAuthKeyPub: pub, + newUserAuthKeyId: newKeychain.id, + createdAt: new Date().toISOString(), + }; + const body: UserAuthKeyResetRequest = { + newUserAuthKeyId: newKeychain.id, + signedRequest, + signature: createMessageSignature(signedRequest, prv, getUtxolibNetwork(coin.getChain())), + }; + + const response = await this.wallet.bitgo + .post(this.resetRequestUrl()) + .send(UserAuthKeyResetRequest.encode(body)) + .result(); + const result = sdkcore.decodeOrElse(UserAuthKeyResetResponse.name, UserAuthKeyResetResponse, response, () => { + // DON'T throw errors from decodeOrElse. It could leak sensitive information. + throw new Error('Invalid user auth key reset response'); + }); + + return { + requestId: result.requestId, + status: result.status, + // Never taken from the server: the customer compares it against what support is shown. + verificationCode: computeUserAuthResetVerificationCode(this.wallet.id(), pub), + }; + } + + async getUserAuthKeyResetRequest(): Promise { + const response = await this.wallet.bitgo.get(this.resetRequestUrl()).result(); + return sdkcore.decodeOrElse(UserAuthKeyResetRequestInfo.name, UserAuthKeyResetRequestInfo, response, () => { + throw new Error('Invalid user auth key reset request response'); + }); + } + + async cancelUserAuthKeyResetRequest(requestId: string): Promise { + const response = await this.wallet.bitgo.del(this.resetRequestUrl(requestId)).send().result(); + return sdkcore.decodeOrElse(CancelUserAuthKeyResetResponse.name, CancelUserAuthKeyResetResponse, response, () => { + throw new Error('Invalid user auth key reset cancel response'); + }); + } } diff --git a/modules/abstract-lightning/src/wallet/index.ts b/modules/abstract-lightning/src/wallet/index.ts index a8d5e1f83fc..13c994a541c 100644 --- a/modules/abstract-lightning/src/wallet/index.ts +++ b/modules/abstract-lightning/src/wallet/index.ts @@ -2,3 +2,4 @@ export * from './lightning'; export * from './custodialLightning'; export * from './selfCustodialLightning'; export * from './wallet'; +export * from './userAuthKeyReset'; diff --git a/modules/abstract-lightning/src/wallet/userAuthKeyReset.ts b/modules/abstract-lightning/src/wallet/userAuthKeyReset.ts new file mode 100644 index 00000000000..11c177b3bd6 --- /dev/null +++ b/modules/abstract-lightning/src/wallet/userAuthKeyReset.ts @@ -0,0 +1,51 @@ +import { createHash } from 'crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const VERIFICATION_CODE_LENGTH = 10; +const VERIFICATION_CODE_PREFIX = 'lightning-userauth-reset:v1:'; + +/** + * Computes the short verification code a customer reads to support when they reset their userAuth key. + * + * code = first 10 characters of the RFC 4648 base32 encoding (uppercase, no padding) of + * SHA-256("lightning-userauth-reset:v1:" + walletId + ":" + newUserAuthKeyPub), + * displayed as `XXXXX-XXXXX`. + * + * The browser, lightning-services and bitgo-admin must all produce byte-identical output, so keep this + * function the single implementation. It is pure: it never contacts a server. + * + * @param walletId id of the lightning wallet being reset + * @param newUserAuthKeyPub the new userAuth public key (xpub) + * @returns the display form, e.g. `K7Q2M-ZP4XA` + */ +export function computeUserAuthResetVerificationCode(walletId: string, newUserAuthKeyPub: string): string { + const digest = createHash('sha256') + .update(`${VERIFICATION_CODE_PREFIX}${walletId}:${newUserAuthKeyPub}`, 'utf8') + .digest(); + + let code = ''; + let buffer = 0; + let bits = 0; + for (const byte of digest) { + buffer = (buffer << 8) | byte; + bits += 8; + while (bits >= 5 && code.length < VERIFICATION_CODE_LENGTH) { + bits -= 5; + code += BASE32_ALPHABET[(buffer >>> bits) & 31]; + } + // keep the buffer small; only the unconsumed low bits matter + buffer &= (1 << bits) - 1; + if (code.length === VERIFICATION_CODE_LENGTH) { + break; + } + } + return `${code.slice(0, VERIFICATION_CODE_LENGTH / 2)}-${code.slice(VERIFICATION_CODE_LENGTH / 2)}`; +} + +/** + * Compares two verification codes the way a human types them: case-insensitive, ignoring dashes and spaces. + */ +export function compareUserAuthResetVerificationCodes(left: string, right: string): boolean { + const [normalizedLeft, normalizedRight] = [left, right].map((code) => code.replace(/[-\s]/g, '').toUpperCase()); + return normalizedLeft === normalizedRight; +} diff --git a/modules/abstract-lightning/test/unit/lightning/signature.ts b/modules/abstract-lightning/test/unit/lightning/signature.ts index 9afd8787da6..549a8678469 100644 --- a/modules/abstract-lightning/test/unit/lightning/signature.ts +++ b/modules/abstract-lightning/test/unit/lightning/signature.ts @@ -63,3 +63,33 @@ describe('verifySignature', function () { assert(!result); }); }); + +describe('signatures on the testnet network', function () { + const request: Request = { invoice: 'some data' }; + const key = getKey('testnet key'); + // the same key re-encoded with testnet (tprv/tpub) version bytes + const testnetKey = utxolib.bip32.fromPrivateKey(key.privateKey as Buffer, key.chainCode, utxolib.networks.testnet); + + it('signs and verifies with xprv/xpub keys, as tlnbtc keys are generated with bitcoin versions', function () { + const signature = createMessageSignature(request, key.toBase58(), utxolib.networks.testnet); + assert(verifyMessageSignature(request, signature, key.neutered().toBase58(), utxolib.networks.testnet)); + }); + + it('produces the same signature as the default network (message prefix is identical)', function () { + const testnetSignature = createMessageSignature(request, key.toBase58(), utxolib.networks.testnet); + const bitcoinSignature = createMessageSignature(request, key.toBase58()); + assert.strictEqual(testnetSignature, bitcoinSignature); + assert(verifyMessageSignature(request, testnetSignature, key.neutered().toBase58())); + }); + + it('still parses tprv/tpub keys with the testnet network', function () { + assert(testnetKey.toBase58().startsWith('tprv')); + const signature = createMessageSignature(request, testnetKey.toBase58(), utxolib.networks.testnet); + assert(verifyMessageSignature(request, signature, testnetKey.neutered().toBase58(), utxolib.networks.testnet)); + }); + + it('rejects a tpub when verified with the bitcoin network', function () { + const signature = createMessageSignature(request, testnetKey.toBase58(), utxolib.networks.testnet); + assert.throws(() => verifyMessageSignature(request, signature, testnetKey.neutered().toBase58())); + }); +}); diff --git a/modules/abstract-lightning/test/unit/lightning/userAuthKeyReset.ts b/modules/abstract-lightning/test/unit/lightning/userAuthKeyReset.ts new file mode 100644 index 00000000000..c488dfcb627 --- /dev/null +++ b/modules/abstract-lightning/test/unit/lightning/userAuthKeyReset.ts @@ -0,0 +1,284 @@ +import assert from 'assert'; +import * as E from 'fp-ts/Either'; +import nock from 'nock'; +import { BitGoAPI } from '@bitgo/sdk-api'; +import { Wallet, common } from '@bitgo/sdk-core'; +import * as statics from '@bitgo/statics'; +import * as utxolib from '@bitgo/utxo-lib'; + +import { AbstractLightningCoin } from '../../../src/abstractLightningCoin'; +import { + CancelUserAuthKeyResetResponse, + UserAuthKeyResetRequestInfo, + UserAuthKeyResetSignedRequest, + UserAuthKeyResetStatus, +} from '../../../src/codecs'; +import { getUtxolibNetwork, verifyMessageSignature } from '../../../src/lightning'; +import { + compareUserAuthResetVerificationCodes, + computeUserAuthResetVerificationCode, + CustodialLightningWallet, +} from '../../../src/wallet'; + +const oldPub = + 'xpub661MyMwAqRbcGYjYsnsDj1SHdiXynWEXNnfNgMSpokN54FKyMqbu7rWEfVNDs6uAJmz86UVFtq4sefhQpXZhSAzQcL9zrEPtiLNNZoeSxCG'; +const otherPub = + 'xpub661MyMwAqRbcG9xnTnAnRbJPo3MAHyRtH4zeehN8exYk4VFz5buepUzebhix33BKhS5Eb4V3LEfW5pYiSR8qmaEnyrpeghhKY8JfzAsUDpq'; + +const validSignedRequest = { + walletId: 'wallet', + oldUserAuthKeyPub: oldPub, + newUserAuthKeyPub: otherPub, + newUserAuthKeyId: 'key', + createdAt: '2026-10-26T12:00:00.000Z', +}; + +class TestLightningCoin extends AbstractLightningCoin { + constructor(bitgo: BitGoAPI, private readonly chain: 'lnbtc' | 'tlnbtc') { + super(bitgo, getUtxolibNetwork(chain), statics.coins.get(chain)); + } + getChain(): string { + return this.chain; + } + getFamily(): string { + return 'lnbtc'; + } + getFullName(): string { + return 'Lightning Bitcoin'; + } +} + +describe('user auth key reset', function () { + describe('verification code', function () { + // Vectors independently computed with: base64.b32encode(sha256(input))[:10] in Python. + it('matches fixed vectors for an lnbtc and a tlnbtc pub', function () { + assert.strictEqual(computeUserAuthResetVerificationCode('ln-wallet', oldPub), 'RMJTE-ZBMRO'); + assert.strictEqual(computeUserAuthResetVerificationCode('test-wallet', otherPub), 'S7YHL-YBCKQ'); + }); + + it('binds the code to both the wallet and the key', function () { + assert.strictEqual(computeUserAuthResetVerificationCode('other-wallet', oldPub), '2BQIM-KXT6H'); + assert.notStrictEqual( + computeUserAuthResetVerificationCode('ln-wallet', otherPub), + computeUserAuthResetVerificationCode('ln-wallet', oldPub) + ); + }); + + it('compares case-insensitively and ignores dashes and spaces', function () { + assert(compareUserAuthResetVerificationCodes('rmjte zbmro', 'RMJTE-ZBMRO')); + assert(compareUserAuthResetVerificationCodes('RMJTEZBMRO', 'RMJTE-ZBMRO')); + assert(compareUserAuthResetVerificationCodes(' rmjte-zbmro ', 'RMJTE-ZBMRO')); + assert(!compareUserAuthResetVerificationCodes('RMJTE-ZBMRP', 'RMJTE-ZBMRO')); + }); + }); + + describe('codecs', function () { + it('accepts the signed request', function () { + assert(E.isRight(UserAuthKeyResetSignedRequest.decode(validSignedRequest))); + }); + + it('rejects an extra field', function () { + assert(E.isLeft(UserAuthKeyResetSignedRequest.decode({ ...validSignedRequest, extra: true }))); + }); + + it('rejects each missing field', function () { + for (const field of Object.keys(validSignedRequest)) { + const incomplete: Record = { ...validSignedRequest }; + delete incomplete[field]; + assert(E.isLeft(UserAuthKeyResetSignedRequest.decode(incomplete)), `${field} should be required`); + } + }); + + it('only accepts the known request statuses', function () { + for (const status of [ + 'requested', + 'canceled', + 'expired', + 'pending', + 'dnr_held', + 'wp_swapped', + 'completed', + 'failed', + ]) { + assert(E.isRight(UserAuthKeyResetStatus.decode(status)), status); + } + // "released" is releasedAt !== null, not a status; "cancelled" is a misspelling of canceled + assert(E.isLeft(UserAuthKeyResetStatus.decode('released'))); + assert(E.isLeft(UserAuthKeyResetStatus.decode('cancelled'))); + }); + + it('requires completedAt and releasedAt on the request info, allowing null', function () { + const info = { requestId: 'r', status: 'completed', createdAt: '2026-10-26T12:00:00.000Z' }; + assert(E.isRight(UserAuthKeyResetRequestInfo.decode({ ...info, completedAt: null, releasedAt: null }))); + assert(E.isRight(UserAuthKeyResetRequestInfo.decode({ ...info, completedAt: 'a', releasedAt: 'b' }))); + assert(E.isLeft(UserAuthKeyResetRequestInfo.decode(info))); + }); + + it('only accepts canceled as the cancel response', function () { + assert(E.isRight(CancelUserAuthKeyResetResponse.decode({ status: 'canceled' }))); + assert(E.isLeft(CancelUserAuthKeyResetResponse.decode({ status: 'cancelled' }))); + }); + }); + + for (const chain of ['lnbtc', 'tlnbtc'] as const) { + describe(`CustodialLightningWallet (${chain})`, function () { + const bitgo = new BitGoAPI({ env: 'mock', clientConstants: {}, hmacVerification: false }); + const resetPath = `/api/v2/${chain}/wallet/walletId/lightning/userAuthKey/resetRequest`; + let wallet: CustodialLightningWallet; + let bgUrl: string; + + before(function () { + const coin = new TestLightningCoin(bitgo, chain); + wallet = new CustodialLightningWallet( + new Wallet(bitgo, coin, { + id: 'walletId', + coin: chain, + subType: 'lightningCustody', + coinSpecific: { keys: ['old', 'node'] }, + }) + ); + bgUrl = common.Environments[bitgo.getEnv()].uri; + }); + + afterEach(function () { + nock.cleanAll(); + }); + + function nockAuthKeys(): nock.Scope { + return nock(bgUrl) + .get(`/api/v2/${chain}/key/old`) + .reply(200, { id: 'old', pub: oldPub, source: 'user', coinSpecific: { [chain]: { purpose: 'userAuth' } } }) + .get(`/api/v2/${chain}/key/node`) + .reply(200, { + id: 'node', + pub: otherPub, + source: 'user', + coinSpecific: { [chain]: { purpose: 'nodeAuth' } }, + }); + } + + it('registers the encrypted key, submits a verifiable proof and never sends the private key', async function () { + const requests: unknown[] = []; + let encryptedPrv = ''; + let newPub = ''; + + const keys = nockAuthKeys() + .post(`/api/v2/${chain}/key`) + .reply(function (_uri, body) { + requests.push(body); + const key = body as { + pub: string; + encryptedPrv: string; + source: string; + keyType: string; + coinSpecific: Record; + originalPasscodeEncryptionCode?: string; + }; + assert.strictEqual(key.source, 'user'); + assert.strictEqual(key.keyType, 'independent'); + assert.strictEqual(key.coinSpecific[chain].purpose, 'userAuth'); + assert.strictEqual(key.originalPasscodeEncryptionCode, undefined); + newPub = key.pub; + encryptedPrv = key.encryptedPrv; + return [200, { id: 'newKeyId' }]; + }); + + const route = nock(bgUrl) + .post(resetPath) + .reply(function (_uri, body) { + requests.push(body); + const request = body as { + newUserAuthKeyId: string; + signedRequest: UserAuthKeyResetSignedRequest; + signature: string; + }; + assert.deepStrictEqual(Object.keys(request).sort(), ['newUserAuthKeyId', 'signature', 'signedRequest']); + assert.strictEqual(request.newUserAuthKeyId, 'newKeyId'); + assert(E.isRight(UserAuthKeyResetSignedRequest.decode(request.signedRequest))); + assert.strictEqual(request.signedRequest.walletId, 'walletId'); + assert.strictEqual(request.signedRequest.oldUserAuthKeyPub, oldPub); + assert.strictEqual(request.signedRequest.newUserAuthKeyPub, newPub); + assert.strictEqual(request.signedRequest.newUserAuthKeyId, 'newKeyId'); + assert( + verifyMessageSignature(request.signedRequest, request.signature, newPub, getUtxolibNetwork(chain)), + 'proof must verify against the new pub' + ); + // lightning-services and wallet-platform verify with the default network: must verify there too + assert(verifyMessageSignature(request.signedRequest, request.signature, newPub)); + return [ + 200, + { + requestId: 'resetId', + status: 'requested', + createdAt: '2026-10-26T12:00:00.000Z', + verificationCode: 'WRONG-CODE', + }, + ]; + }); + + const result = await wallet.requestUserAuthKeyReset({ passphrase: 'new-password', encryptionVersion: 1 }); + + const prv = await bitgo.decrypt({ input: encryptedPrv, password: 'new-password' }); + assert.strictEqual(utxolib.bip32.fromBase58(prv).neutered().toBase58(), newPub); + const sent = JSON.stringify(requests); + assert(!sent.includes(prv), 'private key must not be sent'); + assert(!sent.includes('new-password'), 'passphrase must not be sent'); + assert.deepStrictEqual(result, { + requestId: 'resetId', + status: 'requested', + // computed locally; the server's `verificationCode` is ignored + verificationCode: computeUserAuthResetVerificationCode('walletId', newPub), + }); + keys.done(); + route.done(); + }); + + it('does not queue a request when key registration fails', async function () { + const keys = nockAuthKeys().post(`/api/v2/${chain}/key`).reply(500, { error: 'boom' }); + const route = nock(bgUrl).post(resetPath).reply(200, {}); + await assert.rejects(() => wallet.requestUserAuthKeyReset({ passphrase: 'new-password' })); + keys.done(); + assert(!route.isDone()); + }); + + it('rejects a response with an unknown status without echoing it', async function () { + const keys = nockAuthKeys().post(`/api/v2/${chain}/key`).reply(200, { id: 'newKeyId' }); + nock(bgUrl) + .post(resetPath) + .reply(200, { requestId: 'resetId', status: 'secret-status', createdAt: '2026-10-26T12:00:00.000Z' }); + await assert.rejects( + () => wallet.requestUserAuthKeyReset({ passphrase: 'new-password' }), + (err: Error) => err.message === 'Invalid user auth key reset response' + ); + keys.done(); + }); + + it('gets the latest request including completedAt and releasedAt', async function () { + const info = { + requestId: 'resetId', + status: 'completed', + createdAt: '2026-10-26T12:00:00.000Z', + completedAt: '2026-10-26T13:00:00.000Z', + releasedAt: null, + }; + const route = nock(bgUrl).get(resetPath).reply(200, info); + assert.deepStrictEqual(await wallet.getUserAuthKeyResetRequest(), info); + route.done(); + }); + + it('propagates 404 when there is no request', async function () { + nock(bgUrl).get(resetPath).reply(404, { error: 'not found' }); + await assert.rejects( + () => wallet.getUserAuthKeyResetRequest(), + (err: { status?: number }) => err.status === 404 + ); + }); + + it('cancels a request with an encoded request id', async function () { + const route = nock(bgUrl).delete(`${resetPath}/reset%2Fid`).reply(200, { status: 'canceled' }); + assert.deepStrictEqual(await wallet.cancelUserAuthKeyResetRequest('reset/id'), { status: 'canceled' }); + route.done(); + }); + }); + } +}); diff --git a/modules/bitgo/test/v2/unit/wallets.ts b/modules/bitgo/test/v2/unit/wallets.ts index da8330b8084..77bcefdbfe2 100644 --- a/modules/bitgo/test/v2/unit/wallets.ts +++ b/modules/bitgo/test/v2/unit/wallets.ts @@ -1844,9 +1844,10 @@ describe('V2 Wallets:', function () { walletInfoNock.done(); acceptShareNock.done(); enterpriseUserNock.done(); - should.equal(walletShareStub.calledOnce, true); + // a failure for one spender no longer aborts the others: both are attempted and the failures are returned + should.equal(walletShareStub.calledTwice, true); should.equal(walletShareStub.calledWith(shareParamsOne), true); - should.equal(walletShareStub.calledWith(shareParamsTwo), false); + should.equal(walletShareStub.calledWith(shareParamsTwo), true); }); it('when we get a correct resposne from accept share method and reshare wallet with spenders', async function () { @@ -3777,7 +3778,9 @@ describe('V2 Wallets:', function () { }); // Mock reshareWalletWithSpenders (called for keychainOverrideRequired cases) - const reshareStub = sinon.stub(Wallets.prototype, 'reshareWalletWithSpenders').resolves(); + const reshareStub = sinon + .stub(Wallets.prototype, 'reshareWalletWithSpenders') + .resolves({ shared: [], failed: [] }); const result = await wallets.bulkUpdateWalletShare({ shares: [{ walletShareId: 'share1', status: 'accept' }], @@ -3846,7 +3849,9 @@ describe('V2 Wallets:', function () { }); // Mock reshareWalletWithSpenders - const reshareStub = sinon.stub(Wallets.prototype, 'reshareWalletWithSpenders').resolves(); + const reshareStub = sinon + .stub(Wallets.prototype, 'reshareWalletWithSpenders') + .resolves({ shared: [], failed: [] }); const result = await ofcWallets.bulkUpdateWalletShare({ shares: [{ walletShareId: 'share1', status: 'accept' }], @@ -4384,7 +4389,7 @@ describe('V2 Wallets:', function () { rejectedWalletShares: [], walletShareUpdateErrors: [], }); - sinon.stub(Wallets.prototype, 'reshareWalletWithSpenders').resolves(); + sinon.stub(Wallets.prototype, 'reshareWalletWithSpenders').resolves({ shared: [], failed: [] }); await ofcWallets.bulkUpdateWalletShare({ shares: [{ walletShareId: shareId, status: 'accept' }], @@ -4539,6 +4544,110 @@ describe('V2 Wallets:', function () { assert.strictEqual(shareArg.decryptedKeychain, undefined); } }); + + describe('recipient selection and results', function () { + // spender-recovery, spender-ready and spender-unspecified are plain spenders. admin-recovery is an admin who + // can spend. admin-viewer is an admin who cannot spend. missing-enterprise-user can spend but is not an + // enterprise user, so there is no email to share with. + function stubRecoveryRecipients() { + const users = [ + { user: 'spender-recovery', permissions: ['view', 'spend'], needsRecovery: true }, + { user: 'spender-ready', permissions: ['view', 'spend'], needsRecovery: false }, + { user: 'spender-unspecified', permissions: ['view', 'spend'] }, + { user: 'admin-recovery', permissions: ['admin', 'view', 'spend'], needsRecovery: true }, + { user: 'admin-viewer', permissions: ['admin', 'view'], needsRecovery: true }, + { user: 'missing-enterprise-user', permissions: ['view', 'spend'], needsRecovery: true }, + ]; + sinon.stub(Wallets.prototype, 'get').resolves( + new Wallet(bitgo, bitgo.coin('tsol'), { + id: 'recovery-wallet', + coin: 'tsol', + enterprise: 'recovery-enterprise', + users, + keys: ['userKeyId', 'backupKeyId', 'bitgoKeyId'], + }) + ); + nock(bgUrl) + .get('/api/v1/enterprise/recovery-enterprise/user') + .reply(200, { + adminUsers: users + .filter((u) => u.user.startsWith('admin')) + .map((u) => ({ id: u.user, email: { email: `${u.user}@example.com` } })), + nonAdminUsers: users + .filter((u) => u.user.startsWith('spender')) + .map((u) => ({ id: u.user, email: { email: `${u.user}@example.com` } })), + }); + sinon.stub(Wallet.prototype, 'getDecryptedKeychainForSharing').resolves({ prv: 'p', pub: 'q' }); + return sinon.stub(Wallet.prototype, 'shareWallet').resolves({ shared: true }); + } + + const notEnterpriseUser = { + userId: 'missing-enterprise-user', + error: 'User is not a member of the wallet enterprise', + }; + + it('defaults to non-admin spenders regardless of recovery status', async function () { + stubRecoveryRecipients(); + const result = await wallets.reshareWalletWithSpenders('recovery-wallet', 'pw'); + assert.deepStrictEqual(result, { + shared: ['spender-recovery', 'spender-ready', 'spender-unspecified'], + failed: [notEnterpriseUser], + }); + }); + + it('includes admins who can spend when includeAdmins is set', async function () { + stubRecoveryRecipients(); + const result = await wallets.reshareWalletWithSpenders('recovery-wallet', 'pw', undefined, { + includeAdmins: true, + }); + assert.deepStrictEqual(result, { + shared: ['spender-recovery', 'spender-ready', 'spender-unspecified', 'admin-recovery'], + failed: [notEnterpriseUser], + }); + }); + + it('only reshares to users who need recovery, without admins by default', async function () { + stubRecoveryRecipients(); + const result = await wallets.reshareWalletWithSpenders('recovery-wallet', 'pw', undefined, { + onlyNeedsRecovery: true, + }); + assert.deepStrictEqual(result, { shared: ['spender-recovery'], failed: [notEnterpriseUser] }); + }); + + it('combines includeAdmins and onlyNeedsRecovery', async function () { + stubRecoveryRecipients(); + const result = await wallets.reshareWalletWithSpenders('recovery-wallet', 'pw', undefined, { + includeAdmins: true, + onlyNeedsRecovery: true, + }); + assert.deepStrictEqual(result, { + shared: ['spender-recovery', 'admin-recovery'], + failed: [notEnterpriseUser], + }); + }); + + it('reports recipient failures and keeps sharing with the others', async function () { + const share = stubRecoveryRecipients(); + share.onFirstCall().rejects(new Error('active share already exists')); + share.onSecondCall().callsFake(() => Promise.reject('pending share already exists')); + const result = await wallets.reshareWalletWithSpenders('recovery-wallet', 'pw'); + assert.deepStrictEqual(result, { + shared: ['spender-unspecified'], + failed: [ + notEnterpriseUser, + { userId: 'spender-recovery', error: 'active share already exists' }, + { userId: 'spender-ready', error: 'pending share already exists' }, + ], + }); + share.callCount.should.equal(3); + }); + + it('still rejects when the wallet key cannot be decrypted', async function () { + stubRecoveryRecipients(); + (Wallet.prototype.getDecryptedKeychainForSharing as sinon.SinonStub).rejects(new Error('bad passphrase')); + await assert.rejects(() => wallets.reshareWalletWithSpenders('recovery-wallet', 'pw'), /bad passphrase/); + }); + }); }); }); diff --git a/modules/sdk-core/src/bitgo/wallet/iWallet.ts b/modules/sdk-core/src/bitgo/wallet/iWallet.ts index 107962047e9..a99edd6e028 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallet.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallet.ts @@ -1142,7 +1142,7 @@ export interface WalletData { tokens?: Record[]; nfts?: { [contractAddressOrToken: string]: NftBalance }; unsupportedNfts?: { [contractAddress: string]: NftBalance }; - users?: WalletUser[]; + users?: (WalletUser & { needsRecovery?: boolean })[]; evmKeyRingReferenceWalletId?: string; isParent?: boolean; enabledChildChains?: string[]; diff --git a/modules/sdk-core/src/bitgo/wallet/iWallets.ts b/modules/sdk-core/src/bitgo/wallet/iWallets.ts index d33e4efae43..aeb39f0f14b 100644 --- a/modules/sdk-core/src/bitgo/wallet/iWallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/iWallets.ts @@ -484,7 +484,27 @@ export interface BulkAcceptShareResponse { acceptedWalletShares: AcceptShareResponse[]; } +export interface ReshareWalletOptions { + /** Also reshare to wallet admins who can spend. By default admins are skipped. */ + includeAdmins?: boolean; + /** Only reshare to users flagged `needsRecovery` on the wallet, e.g. after a lightning userAuth key reset. */ + onlyNeedsRecovery?: boolean; +} + +export interface ReshareWalletResult { + /** ids of the users the wallet was reshared to */ + shared: string[]; + /** users that were selected but could not be reshared, e.g. an active or pending share already exists */ + failed: { userId: string; error: string }[]; +} + export interface IWallets { + reshareWalletWithSpenders( + walletId: string, + userPassword: string, + encryptionVersion?: EncryptionVersion, + opts?: ReshareWalletOptions + ): Promise; get(params?: GetWalletOptions): Promise; list(params?: ListWalletOptions): Promise<{ wallets: IWallet[] }>; add(params?: AddWalletOptions): Promise; diff --git a/modules/sdk-core/src/bitgo/wallet/wallets.ts b/modules/sdk-core/src/bitgo/wallet/wallets.ts index 772482dbf3f..a097b387d32 100644 --- a/modules/sdk-core/src/bitgo/wallet/wallets.ts +++ b/modules/sdk-core/src/bitgo/wallet/wallets.ts @@ -39,6 +39,8 @@ import { IWallets, LightningWalletWithKeychains, ListWalletOptions, + ReshareWalletOptions, + ReshareWalletResult, UpdateShareOptions, WalletShares, WalletWithKeychains, @@ -1107,15 +1109,26 @@ export class Wallets implements IWallets { } /** - * Re-share wallet with existing spenders of the wallet + * Re-share wallet with existing spenders of the wallet. + * + * Failures for an individual recipient (for example an existing active or pending share to the same user) + * are collected in `failed` and do not stop the remaining recipients. Setup errors (wallet or enterprise + * lookup, decrypting the wallet key) still reject. + * * @param walletId * @param userPassword + * @param encryptionVersion + * @param opts + * @param opts.includeAdmins - also reshare to admins who can spend (default: admins are skipped) + * @param opts.onlyNeedsRecovery - only reshare to users flagged `needsRecovery` on the wallet + * @returns the users the wallet was reshared to and the users that could not be reshared */ async reshareWalletWithSpenders( walletId: string, userPassword: string, - encryptionVersion?: EncryptionVersion - ): Promise { + encryptionVersion?: EncryptionVersion, + opts?: ReshareWalletOptions + ): Promise { const wallet = await this.get({ id: walletId }); if (!wallet?._wallet?.enterprise) { throw new Error('Enterprise not found for the wallet'); @@ -1129,16 +1142,27 @@ export class Wallets implements IWallets { [...enterpriseUsersResponse?.adminUsers, ...enterpriseUsersResponse?.nonAdminUsers].map((obj) => [obj.id, obj]) ); - if (!wallet._wallet.users) { - return; - } + const result: ReshareWalletResult = { shared: [], failed: [] }; - const spenders = wallet._wallet.users.filter( - (user) => user.permissions.includes('spend') && !user.permissions.includes('admin') && usersMap.has(user.user) + const spenders = (wallet._wallet.users ?? []).filter( + (user) => + user.permissions.includes('spend') && + (opts?.includeAdmins || !user.permissions.includes('admin')) && + (!opts?.onlyNeedsRecovery || user.needsRecovery === true) ); - if (spenders.length === 0) { - return; + // A selected spender who is not an enterprise user has no email to share with: report it instead of dropping it silently. + const recipients: typeof spenders = []; + for (const user of spenders) { + if (usersMap.has(user.user)) { + recipients.push(user); + } else { + result.failed.push({ userId: user.user, error: 'User is not a member of the wallet enterprise' }); + } + } + + if (recipients.length === 0) { + return result; } // Decrypt the wallet keychain once and thread the plaintext into each shareWallet call. @@ -1159,20 +1183,26 @@ export class Wallets implements IWallets { // Sequential loop to bound concurrent Argon2id WASM instances on the per-recipient ECDH // encrypt side (each recipient's secret is unique so Promise.all could OOM at scale). - for (const user of spenders) { - const userObject = usersMap.get(user.user); - assert(userObject, `User ${user.user} not found in enterprise users map`); - const shareOptions: ShareWalletOptions = { - permissions: user.permissions.join(','), - walletPassphrase: userPassword, - email: userObject.email.email, - reshare: true, - skipKeychain: false, - encryptionVersion, - decryptedKeychain, - }; - await wallet.shareWallet(shareOptions); + for (const user of recipients) { + try { + const userObject = usersMap.get(user.user); + assert(userObject, `User ${user.user} not found in enterprise users map`); + const shareOptions: ShareWalletOptions = { + permissions: user.permissions.join(','), + walletPassphrase: userPassword, + email: userObject.email.email, + reshare: true, + skipKeychain: false, + encryptionVersion, + decryptedKeychain, + }; + await wallet.shareWallet(shareOptions); + result.shared.push(user.user); + } catch (e) { + result.failed.push({ userId: user.user, error: e instanceof Error ? e.message : String(e) }); + } } + return result; } /** @@ -1641,7 +1671,11 @@ export class Wallets implements IWallets { if (specialOverrideCases.has(walletShareId)) { const walletId = specialOverrideCases.get(walletShareId); try { - await this.reshareWalletWithSpenders(walletId, userLoginPassword, encryptionVersion); + const reshareResult = await this.reshareWalletWithSpenders(walletId, userLoginPassword, encryptionVersion); + // Recipient failures are returned, not thrown: keep logging them as before + for (const { userId, error } of reshareResult.failed) { + console.error(`Error resharing wallet ${walletId} with spender ${userId}: ${error}`); + } } catch (e) { // Log error but continue processing other shares console.error(`Error resharing wallet ${walletId} with spenders: ${e?.message}`);