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
23 changes: 23 additions & 0 deletions .iyarc
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,26 @@ GHSA-9ppj-qmqm-q256
# - Resolved sjcl -> npm:@bitgo/sjcl@1.0.1 in root resolutions; sjcl.ecc is absent at runtime
# - No patched version of sjcl exists upstream (first_patched_version: null)
GHSA-2w8x-224x-785m

# Excluded because:
# - sinon>nise>path-to-regexp: ReDoS in path-to-regexp
# - Dev-only test dependency (sinon/nise), not used in production
# - Already resolved **/nise/**/path-to-regexp to 8.0.0 but audit still flags it
GHSA-j3q9-mxjg-w52f

# Excluded because:
# - @bitgo/express>express>path-to-regexp: ReDoS vulnerabilities in path-to-regexp
# - Express 4.x requires path-to-regexp 0.1.x internally; cannot upgrade without breaking express
# - Already resolved **/express/**/path-to-regexp to 0.1.12
GHSA-37ch-88jc-xwx2

# Excluded because:
# - lerna>conventional-changelog-core>conventional-changelog-writer>handlebars
# - Dev-only build tooling dependency, not shipped in production
# - No patched version of lerna available that resolves the handlebars transitive dep
# - Handlebars prototype pollution / arbitrary code execution CVEs
GHSA-xjpj-3mr7-gcpf
GHSA-xhpv-hc6g-r9c6
GHSA-9cx6-37pm-9jff
GHSA-3mfm-83xf-c92r
GHSA-2w6w-674q-4c4q
212 changes: 212 additions & 0 deletions modules/bitgo/test/v2/unit/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2504,6 +2504,218 @@ describe('V2 Wallets:', function () {
});
});

it('should include webauthnInfo in request when provided (ECDH branch)', async () => {
const fromUserPrv = Math.random();
const walletPassphrase = 'bitgo1234';
const webauthnPassphrase = 'prf-derived-secret';
const shareId = '66a229dbdccdcfb95b44fc2745a60bd4';
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}

const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
const path = 'm/999999/1/1';
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');

const eckey = makeRandomKey();
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });

let capturedBody: any;
nock(bgUrl)
.get('/api/v2/walletshares')
.reply(200, {
incoming: [
{
id: shareId,
isUMSInitiated: true,
keychain: {
path: path,
fromPubKey: eckey.publicKey.toString('hex'),
encryptedPrv: newEncryptedPrv,
toPubKey: pubkey,
pub: pubkey,
},
},
],
});
nock(bgUrl)
.put('/api/v2/walletshares/accept', (body) => {
capturedBody = body;
return true;
})
.reply(200, {
acceptedWalletShares: [{ walletShareId: shareId }],
});

const myEcdhKeychain = await bitgo.keychains().create();
sinon.stub(bitgo, 'getECDHKeychain').resolves({
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
});

const prvKey = bitgo.decrypt({
password: walletPassphrase,
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
});
sinon.stub(bitgo, 'decrypt').returns(prvKey);
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');

await wallets.bulkAcceptShare({
walletShareIds: [shareId],
userLoginPassword: walletPassphrase,
webauthnInfo: {
otpDeviceId: 'device-001',
prfSalt: 'salt-abc',
passphrase: webauthnPassphrase,
},
});

const sentEntries = capturedBody.keysForWalletShares;
sentEntries.should.have.length(1);
sentEntries[0].should.have.property('encryptedPrv');
sentEntries[0].should.have.property('webauthnInfo');
sentEntries[0].webauthnInfo.should.have.property('otpDeviceId', 'device-001');
sentEntries[0].webauthnInfo.should.have.property('prfSalt', 'salt-abc');
sentEntries[0].webauthnInfo.should.have.property('encryptedPrv');
sentEntries[0].webauthnInfo.should.not.have.property('passphrase');
});

it('should include webauthnInfo in request when provided (userMultiKeyRotationRequired branch)', async () => {
const walletPassphrase = 'bitgo1234';
const webauthnPassphrase = 'prf-derived-secret';
const shareId = 'multi-key-share-id-001';

sinon.stub(Wallets.prototype, 'listSharesV2').resolves({
incoming: [
{
id: shareId,
coin: 'tsol',
walletLabel: 'testing',
fromUser: 'dummyFromUser',
toUser: 'dummyToUser',
wallet: 'dummyWalletId',
permissions: ['spend'],
state: 'active',
userMultiKeyRotationRequired: true,
},
],
outgoing: [],
});

const myEcdhKeychain = await bitgo.keychains().create();
sinon.stub(bitgo, 'getECDHKeychain').resolves({
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
});
const prvKey = bitgo.decrypt({
password: walletPassphrase,
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
});
sinon.stub(bitgo, 'decrypt').returns(prvKey);

let capturedBody: any;
nock(bgUrl)
.put('/api/v2/walletshares/accept', (body) => {
capturedBody = body;
return true;
})
.reply(200, {
acceptedWalletShares: [{ walletShareId: shareId }],
});

await wallets.bulkAcceptShare({
walletShareIds: [shareId],
userLoginPassword: walletPassphrase,
webauthnInfo: {
otpDeviceId: 'device-002',
prfSalt: 'salt-xyz',
passphrase: webauthnPassphrase,
},
});

const sentEntries = capturedBody.keysForWalletShares;
sentEntries.should.have.length(1);
sentEntries[0].should.have.property('pub');
sentEntries[0].should.have.property('encryptedPrv');
sentEntries[0].should.have.property('webauthnInfo');
sentEntries[0].webauthnInfo.should.have.property('otpDeviceId', 'device-002');
sentEntries[0].webauthnInfo.should.have.property('prfSalt', 'salt-xyz');
sentEntries[0].webauthnInfo.should.have.property('encryptedPrv');
sentEntries[0].webauthnInfo.should.not.have.property('passphrase');
});

it('should NOT include webauthnInfo when not provided (backward compat)', async () => {
const fromUserPrv = Math.random();
const walletPassphrase = 'bitgo1234';
const shareId = '66a229dbdccdcfb95b44fc2745a60bd4';
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}

const toKeychain = utxoLib.bip32.fromSeed(Buffer.from('deadbeef02deadbeef02deadbeef02deadbeef02', 'hex'));
const path = 'm/999999/1/1';
const pubkey = toKeychain.derivePath(path).publicKey.toString('hex');

const eckey = makeRandomKey();
const secret = getSharedSecret(eckey, Buffer.from(pubkey, 'hex')).toString('hex');
const newEncryptedPrv = bitgo.encrypt({ password: secret, input: userPrv });

let capturedBody: any;
nock(bgUrl)
.get('/api/v2/walletshares')
.reply(200, {
incoming: [
{
id: shareId,
isUMSInitiated: true,
keychain: {
path: path,
fromPubKey: eckey.publicKey.toString('hex'),
encryptedPrv: newEncryptedPrv,
toPubKey: pubkey,
pub: pubkey,
},
},
],
});
nock(bgUrl)
.put('/api/v2/walletshares/accept', (body) => {
capturedBody = body;
return true;
})
.reply(200, {
acceptedWalletShares: [{ walletShareId: shareId }],
});

const myEcdhKeychain = await bitgo.keychains().create();
sinon.stub(bitgo, 'getECDHKeychain').resolves({
encryptedXprv: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
});
const prvKey = bitgo.decrypt({
password: walletPassphrase,
input: bitgo.encrypt({ input: myEcdhKeychain.xprv, password: walletPassphrase }),
});
sinon.stub(bitgo, 'decrypt').returns(prvKey);
sinon.stub(moduleBitgo, 'getSharedSecret').resolves('fakeSharedSecret');

await wallets.bulkAcceptShare({
walletShareIds: [shareId],
userLoginPassword: walletPassphrase,
});

const sentEntries = capturedBody.keysForWalletShares;
sentEntries.should.have.length(1);
sentEntries[0].should.have.property('encryptedPrv');
sentEntries[0].should.not.have.property('webauthnInfo');
});

it('should handle 413 payload too large error with smart retry', async () => {
const walletPassphrase = 'bitgo1234';
const fromUserPrv = Math.random();
Expand Down
12 changes: 12 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,17 @@ export interface AcceptShareOptions {
newWalletPassphrase?: string;
}

export interface AcceptShareWebauthnInfo {
otpDeviceId: string;
prfSalt: string;
passphrase: string;
}

export interface BulkAcceptShareOptions {
walletShareIds: string[];
userLoginPassword: string;
newWalletPassphrase?: string;
webauthnInfo?: AcceptShareWebauthnInfo;
}

export interface AcceptShareOptionsRequest {
Expand All @@ -148,6 +155,11 @@ export interface AcceptShareOptionsRequest {
* Required for userMultiKeyRotationRequired shares.
*/
pub?: string;
webauthnInfo?: {
otpDeviceId: string;
prfSalt: string;
encryptedPrv: string;
};
}

export interface BulkUpdateWalletShareOptions {
Expand Down
45 changes: 32 additions & 13 deletions modules/sdk-core/src/bitgo/wallet/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,7 @@ export class Wallets implements IWallets {
input: sharingKeychain.encryptedXprv,
});
const newWalletPassphrase = params.newWalletPassphrase || params.userLoginPassword;
const webauthnInfo = params.webauthnInfo;
const keysForWalletShares = walletShares.flatMap((walletShare) => {
// Handle userMultiKeyRotationRequired case - these shares don't have keychains
if (walletShare.userMultiKeyRotationRequired) {
Expand All @@ -1066,13 +1067,22 @@ export class Wallets implements IWallets {
password: newWalletPassphrase,
input: walletKeychain.prv,
});
return [
{
walletShareId: walletShare.id,
encryptedPrv: encryptedPrv,
pub: walletKeychain.pub,
},
];
const entry: AcceptShareOptionsRequest = {
walletShareId: walletShare.id,
encryptedPrv: encryptedPrv,
pub: walletKeychain.pub,
};
if (webauthnInfo) {
entry.webauthnInfo = {
otpDeviceId: webauthnInfo.otpDeviceId,
prfSalt: webauthnInfo.prfSalt,
encryptedPrv: this.bitgo.encrypt({
password: webauthnInfo.passphrase,
input: walletKeychain.prv,
}),
};
}
return [entry];
}

// Standard case: shares with keychains
Expand All @@ -1092,12 +1102,21 @@ export class Wallets implements IWallets {
password: newWalletPassphrase,
input: decryptedSharedWalletPrv,
});
return [
{
walletShareId: walletShare.id,
encryptedPrv: newEncryptedPrv,
},
];
const entry: AcceptShareOptionsRequest = {
walletShareId: walletShare.id,
encryptedPrv: newEncryptedPrv,
};
if (webauthnInfo) {
entry.webauthnInfo = {
otpDeviceId: webauthnInfo.otpDeviceId,
prfSalt: webauthnInfo.prfSalt,
encryptedPrv: this.bitgo.encrypt({
password: webauthnInfo.passphrase,
input: decryptedSharedWalletPrv,
}),
};
}
return [entry];
});

return this.bulkAcceptShareRequest(keysForWalletShares);
Expand Down
Loading