Skip to content
Merged
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
149 changes: 149 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,155 @@ 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 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
18 changes: 18 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;
}

Comment thread
mohammadalfaiyazbitgo marked this conversation as resolved.
export interface BulkAcceptShareOptions {
walletShareIds: string[];
userLoginPassword: string;
newWalletPassphrase?: string;
webauthnInfo?: AcceptShareWebauthnInfo;
}

export interface AcceptShareOptionsRequest {
Expand All @@ -148,6 +155,17 @@ export interface AcceptShareOptionsRequest {
* Required for userMultiKeyRotationRequired shares.
*/
pub?: string;
/**
* Optional WebAuthn PRF-based encryption info.
* When provided, the wallet private key is additionally encrypted with the
* PRF-derived passphrase so the server can store a WebAuthn-protected copy.
* The passphrase itself is never sent to the server.
*/
webauthnInfo?: {
Comment thread
mohammadalfaiyazbitgo marked this conversation as resolved.
otpDeviceId: string;
prfSalt: string;
encryptedPrv: string;
};
}

export interface BulkUpdateWalletShareOptions {
Expand Down
22 changes: 16 additions & 6 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 Down Expand Up @@ -1092,12 +1093,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