Skip to content
Draft
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
35 changes: 35 additions & 0 deletions modules/abstract-lightning/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions modules/abstract-lightning/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions modules/abstract-lightning/src/codecs/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './payment';
export * from './withdraw';
export * from './transaction';
export * from './wallet';
export * from './userAuthKeyReset';
71 changes: 71 additions & 0 deletions modules/abstract-lightning/src/codecs/api/userAuthKeyReset.ts
Original file line number Diff line number Diff line change
@@ -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<typeof UserAuthKeyResetSignedRequest>;

/**
* 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<typeof UserAuthKeyResetStatus>;

/** 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<typeof UserAuthKeyResetRequest>;

/** Response of POST .../resetRequest */
export const UserAuthKeyResetResponse = t.type({
requestId: t.string,
status: UserAuthKeyResetStatus,
createdAt: t.string,
});
export type UserAuthKeyResetResponse = t.TypeOf<typeof UserAuthKeyResetResponse>;

/** 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<typeof UserAuthKeyResetRequestInfo>;

/** Response of DELETE .../resetRequest/:requestId */
export const CancelUserAuthKeyResetResponse = t.type({
status: t.literal('canceled'),
});
export type CancelUserAuthKeyResetResponse = t.TypeOf<typeof CancelUserAuthKeyResetResponse>;
19 changes: 17 additions & 2 deletions modules/abstract-lightning/src/lightning/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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);
}
Expand All @@ -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');
}
114 changes: 112 additions & 2 deletions modules/abstract-lightning/src/wallet/custodialLightning.ts
Original file line number Diff line number Diff line change
@@ -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<UserAuthKeyResetRequestInfo>;

/**
* Cancels a userAuth key reset request. Only possible while the request is still `requested`.
*/
cancelUserAuthKeyResetRequest(requestId: string): Promise<CancelUserAuthKeyResetResponse>;
}

export class CustodialLightningWallet extends LightningWallet implements ICustodialLightningWallet {
constructor(wallet: sdkcore.IWallet) {
Expand All @@ -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<UserAuthKeyResetRequestInfo> {
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<CancelUserAuthKeyResetResponse> {
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');
});
}
}
1 change: 1 addition & 0 deletions modules/abstract-lightning/src/wallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './lightning';
export * from './custodialLightning';
export * from './selfCustodialLightning';
export * from './wallet';
export * from './userAuthKeyReset';
51 changes: 51 additions & 0 deletions modules/abstract-lightning/src/wallet/userAuthKeyReset.ts
Original file line number Diff line number Diff line change
@@ -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;
}
30 changes: 30 additions & 0 deletions modules/abstract-lightning/test/unit/lightning/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
});
});
Loading
Loading