Skip to content

Commit 61de14a

Browse files
authored
Merge pull request #8592 from BitGo/pranavjain/wcn-32-v2-encryption-call-sites
feat: opt-in v2 Argon2+HKDF encryption for multisig and MPC flows
2 parents 9656f29 + 7680291 commit 61de14a

28 files changed

Lines changed: 1000 additions & 169 deletions

File tree

modules/abstract-eth/src/abstractEthLikeNewCoins.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2554,7 +2554,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
25542554
const walletPassphrase = buildParams.walletPassphrase;
25552555

25562556
const userKeychain = await this.keychains().get({ id: wallet.keyIds()[0] });
2557-
const userPrv = wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });
2557+
const userPrv = await wallet.getUserPrvAsync({ keychain: userKeychain, walletPassphrase });
25582558
const userPrvBuffer = bip32.fromBase58(userPrv).privateKey;
25592559
if (!userPrvBuffer) {
25602560
throw new Error('invalid userPrv');

modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,7 @@ export class InscriptionBuilder implements IInscriptionBuilder {
262262
inscriptionData: Buffer
263263
): Promise<SubmitTransactionResponse> {
264264
const userKeychain = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[KeyIndices.USER] });
265-
const xprv = await this.wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });
265+
const xprv = await this.wallet.getUserPrvAsync({ keychain: userKeychain, walletPassphrase });
266266

267267
const halfSignedCommitTransaction = (await this.wallet.signTransaction({
268268
prv: xprv,
@@ -302,7 +302,7 @@ export class InscriptionBuilder implements IInscriptionBuilder {
302302
txPrebuild: PrebuildTransactionResult
303303
): Promise<SubmitTransactionResponse> {
304304
const userKeychain = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[KeyIndices.USER] });
305-
const prv = this.wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });
305+
const prv = await this.wallet.getUserPrvAsync({ keychain: userKeychain, walletPassphrase });
306306

307307
const halfSigned = (await this.wallet.signTransaction({ prv, txPrebuild })) as HalfSignedUtxoTransaction;
308308
return this.wallet.submitTransaction({ halfSigned });

modules/abstract-utxo/src/transaction/fixedScript/verifyTransaction.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
7979
let userPublicKeyVerified = false;
8080
try {
8181
// verify the user public key matches the private key - this will throw if there is no match
82-
userPublicKeyVerified = verifyUserPublicKey(bitgo, { userKeychain: keychains.user, disableNetworking, txParams });
82+
userPublicKeyVerified = verifyUserPublicKey(bitgo, {
83+
userKeychain: keychains.user,
84+
disableNetworking,
85+
txParams,
86+
});
8387
} catch (e) {
8488
debug('failed to verify user public key!', e);
8589
}

modules/bitgo/test/unit/decryptKeychain.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import 'should';
2-
import { decryptKeychainPrivateKey, OptionalKeychainEncryptedKey } from '@bitgo/sdk-core';
2+
import {
3+
decryptKeychainPrivateKey,
4+
decryptKeychainPrivateKeyAsync,
5+
OptionalKeychainEncryptedKey,
6+
} from '@bitgo/sdk-core';
37
import { BitGoAPI } from '@bitgo/sdk-api';
48

59
describe('decryptKeychainPrivateKey', () => {
@@ -78,3 +82,53 @@ describe('decryptKeychainPrivateKey', () => {
7882
(decryptKeychainPrivateKey(bitgo, {}, 'password') === undefined).should.be.true();
7983
});
8084
});
85+
86+
describe('decryptKeychainPrivateKeyAsync', () => {
87+
const bitgo = new BitGoAPI();
88+
89+
const prv1 = Math.random().toString();
90+
const password1 = Math.random().toString();
91+
92+
const prv2 = Math.random().toString();
93+
const password2 = Math.random().toString();
94+
95+
it('should decrypt encryptedPrv (v1)', async () => {
96+
const keychain: OptionalKeychainEncryptedKey = {
97+
encryptedPrv: bitgo.encrypt({ input: prv1, password: password1 }),
98+
};
99+
const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, password1);
100+
result!.should.equal(prv1);
101+
});
102+
103+
it('should decrypt webauthnDevices encryptedPrv (v1)', async () => {
104+
const keychain: OptionalKeychainEncryptedKey = {
105+
webauthnDevices: [
106+
{
107+
otpDeviceId: '123',
108+
authenticatorInfo: {
109+
credID: 'credID',
110+
fmt: 'packed',
111+
publicKey: 'some value',
112+
},
113+
prfSalt: '456',
114+
encryptedPrv: bitgo.encrypt({ input: prv2, password: password2 }),
115+
},
116+
],
117+
};
118+
const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, password2);
119+
result!.should.equal(prv2);
120+
});
121+
122+
it('should return undefined if no encryptedPrv can be decrypted', async () => {
123+
const keychain: OptionalKeychainEncryptedKey = {
124+
encryptedPrv: bitgo.encrypt({ input: prv1, password: password1 }),
125+
};
126+
const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, Math.random().toString());
127+
(result === undefined).should.equal(true);
128+
});
129+
130+
it('should return undefined if no encryptedPrv is present', async () => {
131+
const result = await decryptKeychainPrivateKeyAsync(bitgo, {}, 'password');
132+
(result === undefined).should.be.true();
133+
});
134+
});

modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/createKeychains.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,55 @@ describe('TSS Ecdsa MPCv2 Utils:', async function () {
176176
assert.equal(bitgoKeychain.source, 'bitgo');
177177
});
178178

179+
it('should generate TSS MPCv2 keys with v2 encryption envelopes', async function () {
180+
const bitgoSession = new DklsDkg.Dkg(3, 2, 2);
181+
182+
const round1Nock = await nockKeyGenRound1(bitgoSession, 1);
183+
const round2Nock = await nockKeyGenRound2(bitgoSession, 1);
184+
const round3Nock = await nockKeyGenRound3(bitgoSession, 1);
185+
const addKeyNock = await nockAddKeyChain(coinName, 3);
186+
const params = {
187+
passphrase: 'test',
188+
enterprise: enterpriseId,
189+
originalPasscodeEncryptionCode: '123456',
190+
encryptionVersion: 2 as const,
191+
};
192+
const { userKeychain, backupKeychain, bitgoKeychain } = await tssUtils.createKeychains(params);
193+
assert.ok(round1Nock.isDone());
194+
assert.ok(round2Nock.isDone());
195+
assert.ok(round3Nock.isDone());
196+
assert.ok(addKeyNock.isDone());
197+
198+
assert.ok(userKeychain);
199+
assert.equal(userKeychain.source, 'user');
200+
assert.ok(userKeychain.commonKeychain);
201+
assert.ok(ECDSAUtils.EcdsaMPCv2Utils.validateCommonKeychainPublicKey(userKeychain.commonKeychain));
202+
203+
// Verify v2 envelopes for encryptedPrv
204+
assert.ok(userKeychain.encryptedPrv);
205+
const encryptedPrvParsed: { v: number } = JSON.parse(userKeychain.encryptedPrv);
206+
assert.equal(encryptedPrvParsed.v, 2, 'encryptedPrv should be a v2 envelope');
207+
208+
// Verify v2 envelopes for reducedEncryptedPrv
209+
assert.ok(userKeychain.reducedEncryptedPrv);
210+
const reducedEncryptedPrvParsed: { v: number } = JSON.parse(userKeychain.reducedEncryptedPrv);
211+
assert.equal(reducedEncryptedPrvParsed.v, 2, 'reducedEncryptedPrv should be a v2 envelope');
212+
213+
// Verify v2 envelope is decryptable via decryptAsync
214+
const decrypted = await bitgo.decryptAsync({ input: userKeychain.encryptedPrv, password: params.passphrase });
215+
assert.ok(decrypted, 'decryptAsync should successfully decrypt v2 envelope');
216+
217+
// Verify backup keychain also uses v2 envelopes
218+
assert.ok(backupKeychain);
219+
assert.equal(backupKeychain.source, 'backup');
220+
assert.ok(backupKeychain.encryptedPrv);
221+
const backupEncryptedPrvParsed: { v: number } = JSON.parse(backupKeychain.encryptedPrv);
222+
assert.equal(backupEncryptedPrvParsed.v, 2, 'backup encryptedPrv should be a v2 envelope');
223+
224+
assert.ok(bitgoKeychain);
225+
assert.equal(bitgoKeychain.source, 'bitgo');
226+
});
227+
179228
it('should generate TSS MPCv2 keys for retrofit', async function () {
180229
const xiList = [
181230
Array.from(bigIntToBufferBE(BigInt(1), 32)),

modules/bitgo/test/v2/unit/internal/tssUtils/ecdsaMPCv2/signTxRequest.ts

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,128 @@ describe('signTxRequest:', function () {
263263
nockPromises[2].isDone().should.be.true();
264264
});
265265

266+
describe('v2 encryption (offline rounds with adata)', function () {
267+
it('e2e: 3-round offline signing with v2 encrypted keys preserves adata context binding', async function () {
268+
const walletPassphrase = 'testpassphrase';
269+
const userShare = fs.readFileSync(shareFiles[vector.party1]);
270+
const userPrvBase64 = Buffer.from(userShare).toString('base64');
271+
272+
const encryptedPrv = await bitgo.encryptAsync({
273+
input: userPrvBase64,
274+
password: walletPassphrase,
275+
encryptionVersion: 2,
276+
});
277+
JSON.parse(encryptedPrv).v.should.equal(2);
278+
279+
const round1Result = await tssUtils.createOfflineRound1Share({
280+
txRequest,
281+
prv: userPrvBase64,
282+
walletPassphrase,
283+
encryptedPrv,
284+
});
285+
286+
const r1SessionEnvelope = JSON.parse(round1Result.encryptedRound1Session);
287+
r1SessionEnvelope.v.should.equal(2);
288+
r1SessionEnvelope.should.have.property('adata');
289+
r1SessionEnvelope.should.have.property('hkdfSalt');
290+
r1SessionEnvelope.adata.should.containEql('DKLS23_SIGNING_ROUND1_STATE');
291+
292+
const r1GpgEnvelope = JSON.parse(round1Result.encryptedUserGpgPrvKey);
293+
r1GpgEnvelope.v.should.equal(2);
294+
r1GpgEnvelope.should.have.property('adata');
295+
r1GpgEnvelope.adata.should.containEql('DKLS23_SIGNING_USER_GPG_KEY');
296+
297+
await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey);
298+
const transactions = getRoute('ecdsa');
299+
const round1TxRequestResponse = await bitgo
300+
.post(bitgo.url(`/wallet/${txRequest.walletId}/txrequests/${txRequest.txRequestId + transactions}/sign`, 2))
301+
.send({
302+
signatureShares: [round1Result.signatureShareRound1],
303+
signerGpgPublicKey: round1Result.userGpgPubKey,
304+
})
305+
.result();
306+
307+
const round1TxReq: TxRequest = {
308+
...txRequest,
309+
transactions: [
310+
{
311+
...txRequest.transactions![0],
312+
signatureShares: round1TxRequestResponse.transactions[0].signatureShares,
313+
},
314+
],
315+
};
316+
317+
const round2Result = await tssUtils.createOfflineRound2Share({
318+
txRequest: round1TxReq,
319+
prv: userPrvBase64,
320+
walletPassphrase,
321+
bitgoPublicGpgKey: bitgoGpgKey.publicKey,
322+
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
323+
encryptedRound1Session: round1Result.encryptedRound1Session,
324+
});
325+
326+
const r2Envelope = JSON.parse(round2Result.encryptedRound2Session);
327+
r2Envelope.v.should.equal(2);
328+
r2Envelope.should.have.property('adata');
329+
r2Envelope.adata.should.containEql('DKLS23_SIGNING_ROUND2_STATE');
330+
331+
await nockTxRequestResponseSignatureShareRoundTwo(bitgoParty, txRequest, bitgoGpgKey);
332+
const round2TxRequestResponse = await bitgo
333+
.post(bitgo.url(`/wallet/${txRequest.walletId}/txrequests/${txRequest.txRequestId + transactions}/sign`, 2))
334+
.send({
335+
signatureShares: [round2Result.signatureShareRound2],
336+
signerGpgPublicKey: round1Result.userGpgPubKey,
337+
})
338+
.result();
339+
340+
const round2TxReq: TxRequest = {
341+
...txRequest,
342+
transactions: [
343+
{
344+
...txRequest.transactions![0],
345+
signatureShares: round2TxRequestResponse.transactions[0].signatureShares,
346+
},
347+
],
348+
};
349+
350+
const round3Result = await tssUtils.createOfflineRound3Share({
351+
txRequest: round2TxReq,
352+
prv: userPrvBase64,
353+
walletPassphrase,
354+
bitgoPublicGpgKey: bitgoGpgKey.publicKey,
355+
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
356+
encryptedRound2Session: round2Result.encryptedRound2Session,
357+
});
358+
359+
round3Result.should.have.property('signatureShareRound3');
360+
});
361+
362+
it('validateAdata accepts v2 envelopes with matching adata and domain separator', async function () {
363+
const adata = 'txhash:m/0/1';
364+
const domainSep = 'DKLS23_SIGNING_ROUND1_STATE';
365+
const ct = await bitgo.encryptAsync({
366+
input: 'test-data',
367+
password: 'testpass',
368+
encryptionVersion: 2,
369+
adata: `${domainSep}:${adata}`,
370+
});
371+
372+
(tssUtils as any).validateAdata(adata, ct, domainSep);
373+
});
374+
375+
it('validateAdata rejects v2 envelopes with mismatched adata', async function () {
376+
const domainSep = 'DKLS23_SIGNING_ROUND1_STATE';
377+
const ct = await bitgo.encryptAsync({
378+
input: 'test-data',
379+
password: 'testpass',
380+
encryptionVersion: 2,
381+
adata: `${domainSep}:context-A`,
382+
});
383+
384+
(() => (tssUtils as any).validateAdata('context-B', ct, domainSep)).should.throw(/Adata does not match/);
385+
});
386+
});
387+
266388
it('fails to signs a txRequest for a dkls hot wallet after receiving over 3 429 errors', async function () {
267389
const nockPromises = [
268390
await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey),

modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,107 @@ describe('TSS Utils:', async function () {
547547
})
548548
.should.be.rejectedWith('Failed to create backup keychain - commonKeychains do not match.');
549549
});
550+
551+
it('should generate TSS key chains with v2 encryption envelopes', async function () {
552+
const passphrase = 'passphrase';
553+
const userKeyShare = MPC.keyShare(1, 2, 3);
554+
const backupKeyShare = MPC.keyShare(2, 2, 3);
555+
556+
await nockBitgoKeychain({
557+
coin: coinName,
558+
userKeyShare,
559+
backupKeyShare,
560+
bitgoKeyShare,
561+
userGpgKey,
562+
backupGpgKey,
563+
bitgoGpgKey,
564+
});
565+
await nockUserKeychain({ coin: coinName });
566+
await nockBackupKeychain({ coin: coinName });
567+
568+
const bitgoKeychain = await tssUtils.createBitgoKeychain({
569+
userGpgKey,
570+
backupGpgKey,
571+
userKeyShare,
572+
backupKeyShare,
573+
});
574+
const userKeychain = await tssUtils.createUserKeychain({
575+
userGpgKey,
576+
backupGpgKey,
577+
userKeyShare,
578+
backupKeyShare,
579+
bitgoKeychain,
580+
passphrase,
581+
encryptionVersion: 2,
582+
});
583+
584+
should.exist(userKeychain.encryptedPrv);
585+
const envelope = JSON.parse(userKeychain.encryptedPrv!);
586+
envelope.v.should.equal(2);
587+
588+
const decrypted = await bitgo.decryptAsync({ input: userKeychain.encryptedPrv!, password: passphrase });
589+
should.exist(decrypted);
590+
const parsed: Record<string, unknown> = JSON.parse(decrypted);
591+
should.exist(parsed.uShare);
592+
});
593+
});
594+
595+
describe('v2 encryption (EdDSA signing dispatch)', function () {
596+
const signingTxRequest: TxRequest = {
597+
txRequestId: 'v2-signing-test',
598+
unsignedTxs: [
599+
{
600+
serializedTxHex: 'test-payload',
601+
signableHex: 'deadbeef',
602+
derivationPath: 'm/0',
603+
},
604+
],
605+
date: new Date().toISOString(),
606+
intent: { intentType: 'payment' },
607+
latest: true,
608+
state: 'pendingUserSignature',
609+
walletType: 'hot',
610+
walletId: 'walletId',
611+
policiesChecked: true,
612+
version: 1,
613+
userId: 'userId',
614+
};
615+
616+
it('v2 R-share round-trip: encrypt via commitment, verify envelope, decrypt via createRShare', async function () {
617+
const passphrase = 'test-passphrase';
618+
const prv = JSON.stringify(validUserSigningMaterial);
619+
620+
// 1. Encrypt prv with v2
621+
const encryptedPrv = await bitgo.encryptAsync({
622+
input: prv,
623+
password: passphrase,
624+
encryptionVersion: 2,
625+
});
626+
JSON.parse(encryptedPrv).v.should.equal(2);
627+
628+
// 2. Create commitment -- R-share should be a v2 envelope
629+
const commitResult = await tssUtils.createCommitmentShareFromTxRequest({
630+
txRequest: signingTxRequest,
631+
prv,
632+
walletPassphrase: passphrase,
633+
bitgoGpgPubKey: bitgoGpgKey.publicKey,
634+
encryptedPrv,
635+
});
636+
637+
const rShareEnvelope = JSON.parse(commitResult.encryptedUserToBitgoRShare.share);
638+
rShareEnvelope.v.should.equal(2);
639+
rShareEnvelope.should.have.property('hkdfSalt');
640+
641+
// 3. Round-trip: decrypt the v2 R-share
642+
const { rShare } = await tssUtils.createRShareFromTxRequest({
643+
txRequest: signingTxRequest,
644+
walletPassphrase: passphrase,
645+
encryptedUserToBitgoRShare: commitResult.encryptedUserToBitgoRShare,
646+
});
647+
648+
should.exist(rShare.xShare);
649+
should.exist(rShare.rShares);
650+
});
550651
});
551652

552653
describe('signTxRequest:', function () {

0 commit comments

Comments
 (0)