diff --git a/modules/abstract-lightning/README.md b/modules/abstract-lightning/README.md index 589c5c4b9dc..2a63c4052e2 100644 --- a/modules/abstract-lightning/README.md +++ b/modules/abstract-lightning/README.md @@ -1 +1,17 @@ -# BitGo abstract-lightning +# @bitgo/abstract-lightning + +## Custodial userAuth key reset + +For a custody Lightning wallet, call `requestUserAuthKeyReset` with the new wallet +passphrase and optional `encryptionVersion`. The SDK creates and encrypts a new +userAuth key locally, registers the encrypted key, and signs the reset request. +The returned verification code is calculated locally; the customer reads it to +support. The unencrypted private key and passphrase are never sent to the API. + +Use `getUserAuthKeyResetRequest` to retrieve the latest request (a missing request +returns HTTP 404), and `cancelUserAuthKeyResetRequest` with its request ID to +cancel it. After a successful reset, other wallet members must be re-shared the +new key before they can regain access. + +`computeUserAuthResetVerificationCode` and `compareUserAuthResetVerificationCodes` +are also exported for independent verification. 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..62beb6578f1 --- /dev/null +++ b/modules/abstract-lightning/src/codecs/api/userAuthKeyReset.ts @@ -0,0 +1,44 @@ +import * as t from 'io-ts'; + +const SignedRequestFields = t.type({ + walletId: t.string, + oldUserAuthKeyPub: t.string, + newUserAuthKeyPub: t.string, + newUserAuthKeyId: t.string, + createdAt: t.string, +}); + +export type UserAuthKeyResetSignedRequest = t.TypeOf; + +export const UserAuthKeyResetSignedRequest = new t.Type( + 'UserAuthKeyResetSignedRequest', + (value): value is UserAuthKeyResetSignedRequest => + SignedRequestFields.is(value) && + Object.keys(value).every((key) => Object.prototype.hasOwnProperty.call(SignedRequestFields.props, key)), + (value, context) => { + const result = SignedRequestFields.validate(value, context); + if (result._tag === 'Left') { + return result; + } + return Object.keys(result.right).every((key) => + Object.prototype.hasOwnProperty.call(SignedRequestFields.props, key) + ) + ? result + : t.failure(value, context); + }, + t.identity +); + +export const UserAuthKeyResetRequest = t.type({ + newUserAuthKeyId: t.string, + signedRequest: UserAuthKeyResetSignedRequest, + signature: t.string, +}); +export type UserAuthKeyResetRequest = t.TypeOf; + +export const UserAuthKeyResetResponse = t.type({ + requestId: t.string, + status: t.string, + createdAt: t.string, +}); +export type UserAuthKeyResetResponse = t.TypeOf; diff --git a/modules/abstract-lightning/src/lightning/signature.ts b/modules/abstract-lightning/src/lightning/signature.ts index cbb06e57119..c7315d8e4a9 100644 --- a/modules/abstract-lightning/src/lightning/signature.ts +++ b/modules/abstract-lightning/src/lightning/signature.ts @@ -2,6 +2,14 @@ import * as utxolib from '@bitgo/utxo-lib'; import * as sdkcore from '@bitgo/sdk-core'; import { canonicalizeObject, Signable } from './signableJson'; +function parseLightningKey(key: string, network: utxolib.Network): utxolib.BIP32Interface { + // Lightning auth keys use Bitcoin BIP32 versions on both networks. + 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 +26,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 +45,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..f6d2c97776a 100644 --- a/modules/abstract-lightning/src/wallet/custodialLightning.ts +++ b/modules/abstract-lightning/src/wallet/custodialLightning.ts @@ -1,7 +1,17 @@ -import { ILightningWallet, LightningWallet } from './lightning'; +import { getLightningAuthKeychains, ILightningWallet, LightningWallet } from './lightning'; import * as sdkcore from '@bitgo/sdk-core'; +import { createMessageSignature, getUtxolibNetwork } from '../lightning'; +import { UserAuthKeyResetRequest, UserAuthKeyResetResponse, UserAuthKeyResetSignedRequest } from '../codecs'; +import { computeUserAuthResetVerificationCode } from './userAuthKeyReset'; -export type ICustodialLightningWallet = ILightningWallet; +export interface ICustodialLightningWallet extends ILightningWallet { + requestUserAuthKeyReset(params: { + passphrase: string; + encryptionVersion?: sdkcore.EncryptionVersion; + }): Promise<{ requestId: string; status: string; verificationCode: string }>; + getUserAuthKeyResetRequest(): Promise; + cancelUserAuthKeyResetRequest(requestId: string): Promise; +} export class CustodialLightningWallet extends LightningWallet implements ICustodialLightningWallet { constructor(wallet: sdkcore.IWallet) { @@ -10,4 +20,71 @@ export class CustodialLightningWallet extends LightningWallet implements ICustod throw new Error(`Invalid lightning wallet type for custodial lightning: ${wallet.subType()}`); } } + + async requestUserAuthKeyReset(params: { + passphrase: string; + encryptionVersion?: sdkcore.EncryptionVersion; + }): Promise<{ requestId: string; status: string; verificationCode: string }> { + const { userAuthKey } = await getLightningAuthKeychains(this.wallet); + const coin = this.wallet.baseCoin; + const { pub, prv } = coin.keychains().create(); + if (!pub || !prv) { + throw new Error('Failed to generate user auth key'); + } + const encryptedPrv = await this.wallet.bitgo.encrypt({ + password: params.passphrase, + input: prv, + encryptionVersion: params.encryptionVersion, + }); + const { id: newUserAuthKeyId } = await coin.keychains().add({ + pub, + encryptedPrv, + source: 'user', + keyType: 'independent', + coinSpecific: { [coin.getChain()]: { purpose: 'userAuth' } }, + }); + const signedRequest: UserAuthKeyResetSignedRequest = { + walletId: this.wallet.id(), + oldUserAuthKeyPub: userAuthKey.pub, + newUserAuthKeyPub: pub, + newUserAuthKeyId, + createdAt: new Date().toISOString(), + }; + const request: UserAuthKeyResetRequest = { + newUserAuthKeyId, + signedRequest, + signature: createMessageSignature(signedRequest, prv, getUtxolibNetwork(coin.getChain())), + }; + const response = await this.wallet.bitgo + .post(coin.url(`/wallet/${this.wallet.id()}/lightning/userAuthKey/resetRequest`)) + .send(UserAuthKeyResetRequest.encode(request)) + .result(); + const result = sdkcore.decodeOrElse(UserAuthKeyResetResponse.name, UserAuthKeyResetResponse, response, () => { + throw new Error('Invalid user auth key reset response'); + }); + return { + requestId: result.requestId, + status: result.status, + verificationCode: computeUserAuthResetVerificationCode(this.wallet.id(), pub), + }; + } + + async getUserAuthKeyResetRequest(): Promise { + const response = await this.wallet.bitgo + .get(this.wallet.baseCoin.url(`/wallet/${this.wallet.id()}/lightning/userAuthKey/resetRequest`)) + .result(); + return sdkcore.decodeOrElse(UserAuthKeyResetResponse.name, UserAuthKeyResetResponse, response, () => { + throw new Error('Invalid user auth key reset response'); + }); + } + + async cancelUserAuthKeyResetRequest(requestId: string): Promise { + return await this.wallet.bitgo + .del( + this.wallet.baseCoin.url( + `/wallet/${this.wallet.id()}/lightning/userAuthKey/resetRequest/${encodeURIComponent(requestId)}` + ) + ) + .result(); + } } diff --git a/modules/abstract-lightning/src/wallet/index.ts b/modules/abstract-lightning/src/wallet/index.ts index a8d5e1f83fc..040ec249cf3 100644 --- a/modules/abstract-lightning/src/wallet/index.ts +++ b/modules/abstract-lightning/src/wallet/index.ts @@ -2,3 +2,5 @@ export * from './lightning'; export * from './custodialLightning'; export * from './selfCustodialLightning'; export * from './wallet'; +export * from './userAuthKeyReset'; +export * from '../codecs/api/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..0e1afec2654 --- /dev/null +++ b/modules/abstract-lightning/src/wallet/userAuthKeyReset.ts @@ -0,0 +1,29 @@ +import { createHash } from 'crypto'; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +export function computeUserAuthResetVerificationCode(walletId: string, newUserAuthKeyPub: string): string { + const digest = createHash('sha256') + .update(`lightning-userauth-reset:v1:${walletId}:${newUserAuthKeyPub}`, 'utf8') + .digest(); + let bits = 0; + let value = 0; + let code = ''; + for (const byte of digest) { + value = (value << 8) | byte; + bits += 8; + while (bits >= 5 && code.length < 10) { + bits -= 5; + code += BASE32_ALPHABET[(value >>> bits) & 31]; + } + if (code.length === 10) { + break; + } + } + return `${code.slice(0, 5)}-${code.slice(5)}`; +} + +export function compareUserAuthResetVerificationCodes(left: string, right: string): boolean { + const normalize = (code: string): string => code.replace(/[- ]/g, '').toUpperCase(); + return normalize(left) === normalize(right); +} 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..489bce97d79 --- /dev/null +++ b/modules/abstract-lightning/test/unit/lightning/userAuthKeyReset.ts @@ -0,0 +1,172 @@ +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 { UserAuthKeyResetSignedRequest } from '../../../src/codecs'; +import { getUtxolibNetwork, verifyMessageSignature } from '../../../src/lightning'; +import { + compareUserAuthResetVerificationCodes, + computeUserAuthResetVerificationCode, + CustodialLightningWallet, +} from '../../../src/wallet'; + +const oldPub = + 'xpub661MyMwAqRbcGYjYsnsDj1SHdiXynWEXNnfNgMSpokN54FKyMqbu7rWEfVNDs6uAJmz86UVFtq4sefhQpXZhSAzQcL9zrEPtiLNNZoeSxCG'; +const otherPub = + 'xpub661MyMwAqRbcG9xnTnAnRbJPo3MAHyRtH4zeehN8exYk4VFz5buepUzebhix33BKhS5Eb4V3LEfW5pYiSR8qmaEnyrpeghhKY8JfzAsUDpq'; + +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 () { + it('computes fixed mainnet and testnet verification codes', function () { + assert.strictEqual(computeUserAuthResetVerificationCode('ln-wallet', oldPub), 'RMJTE-ZBMRO'); + assert.strictEqual(computeUserAuthResetVerificationCode('test-wallet', otherPub), 'S7YHL-YBCKQ'); + assert(compareUserAuthResetVerificationCodes('rmjte zbmro', 'RMJTE-ZBMRO')); + assert(!compareUserAuthResetVerificationCodes('RMJTE-ZBMRP', 'RMJTE-ZBMRO')); + assert.notStrictEqual(computeUserAuthResetVerificationCode('other-wallet', oldPub), 'RMJTE-ZBMRO'); + }); + + it('rejects extra and missing signed request fields', function () { + const request = { + walletId: 'wallet', + oldUserAuthKeyPub: oldPub, + newUserAuthKeyPub: otherPub, + newUserAuthKeyId: 'key', + createdAt: '2026-10-26T12:00:00.000Z', + }; + assert(E.isRight(UserAuthKeyResetSignedRequest.decode(request))); + assert(E.isLeft(UserAuthKeyResetSignedRequest.decode({ ...request, extra: true }))); + for (const field of Object.keys(request)) { + const incomplete: Record = { ...request }; + delete incomplete[field]; + assert(E.isLeft(UserAuthKeyResetSignedRequest.decode(incomplete))); + } + }); + + for (const chain of ['lnbtc', 'tlnbtc'] as const) { + describe(chain, function () { + const bitgo = new BitGoAPI({ env: 'mock', clientConstants: {}, hmacVerification: false }); + 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(); + }); + + it('registers an encrypted key and submits a signed proof without the private key', async function () { + const requests: unknown[] = []; + let encryptedPrv = ''; + let pub = ''; + const keys = 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' } } }) + .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); + pub = key.pub; + encryptedPrv = key.encryptedPrv; + return [200, { id: 'newKeyId' }]; + }); + const route = nock(bgUrl) + .post(`/api/v2/${chain}/wallet/walletId/lightning/userAuthKey/resetRequest`) + .reply(function (_uri, body) { + requests.push(body); + const request = body as { + newUserAuthKeyId: string; + signedRequest: UserAuthKeyResetSignedRequest; + signature: string; + }; + assert.strictEqual(request.newUserAuthKeyId, 'newKeyId'); + assert(E.isRight(UserAuthKeyResetSignedRequest.decode(request.signedRequest))); + assert.strictEqual(request.signedRequest.oldUserAuthKeyPub, oldPub); + assert.strictEqual(request.signedRequest.newUserAuthKeyPub, pub); + assert.strictEqual(request.signedRequest.walletId, 'walletId'); + assert.strictEqual(request.signedRequest.newUserAuthKeyId, 'newKeyId'); + assert(verifyMessageSignature(request.signedRequest, request.signature, pub, getUtxolibNetwork(chain))); + return [ + 200, + { + requestId: 'resetId', + status: 'pending', + 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(), pub); + assert(!JSON.stringify(requests).includes(prv)); + assert(!JSON.stringify(requests).includes('new-password')); + assert.deepStrictEqual(result, { + requestId: 'resetId', + status: 'pending', + verificationCode: computeUserAuthResetVerificationCode('walletId', pub), + }); + keys.done(); + route.done(); + }); + + it('retrieves and cancels a request, and propagates 404', async function () { + const path = `/api/v2/${chain}/wallet/walletId/lightning/userAuthKey/resetRequest`; + const response = { requestId: 'resetId', status: 'pending', createdAt: '2026-10-26T12:00:00.000Z' }; + const routes = nock(bgUrl) + .get(path) + .reply(200, response) + .delete(`${path}/resetId`) + .reply(200, { status: 'cancelled' }) + .get(path) + .reply(404, { error: 'not found' }); + assert.deepStrictEqual(await wallet.getUserAuthKeyResetRequest(), response); + assert.deepStrictEqual(await wallet.cancelUserAuthKeyResetRequest('resetId'), { status: 'cancelled' }); + await assert.rejects( + () => wallet.getUserAuthKeyResetRequest(), + (err: { status?: number }) => err.status === 404 + ); + routes.done(); + }); + }); + } +});