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
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