Skip to content
Open
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
2 changes: 1 addition & 1 deletion modules/abstract-eth/src/abstractEthLikeNewCoins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2554,7 +2554,7 @@ export abstract class AbstractEthLikeNewCoins extends AbstractEthLikeCoin {
const walletPassphrase = buildParams.walletPassphrase;

const userKeychain = await this.keychains().get({ id: wallet.keyIds()[0] });
const userPrv = wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });
const userPrv = await wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });
const userPrvBuffer = bip32.fromBase58(userPrv).privateKey;
if (!userPrvBuffer) {
throw new Error('invalid userPrv');
Expand Down
2 changes: 1 addition & 1 deletion modules/abstract-utxo/src/abstractUtxoCoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,7 +677,7 @@ export abstract class AbstractUtxoCoin
/**
* @deprecated - use function verifyUserPublicKey instead
*/
protected verifyUserPublicKey(params: VerifyUserPublicKeyOptions): boolean {
protected async verifyUserPublicKey(params: VerifyUserPublicKeyOptions): Promise<boolean> {
return verifyUserPublicKey(this.bitgo, params);
}

Expand Down
2 changes: 1 addition & 1 deletion modules/abstract-utxo/src/impl/btc/inscriptionBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ export class InscriptionBuilder implements IInscriptionBuilder {
txPrebuild: PrebuildTransactionResult
): Promise<SubmitTransactionResponse> {
const userKeychain = await this.wallet.baseCoin.keychains().get({ id: this.wallet.keyIds()[KeyIndices.USER] });
const prv = this.wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });
const prv = await this.wallet.getUserPrv({ keychain: userKeychain, walletPassphrase });

const halfSigned = (await this.wallet.signTransaction({ prv, txPrebuild })) as HalfSignedUtxoTransaction;
return this.wallet.submitTransaction({ halfSigned });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ export async function verifyTransaction<TNumber extends bigint | number>(
let userPublicKeyVerified = false;
try {
// verify the user public key matches the private key - this will throw if there is no match
userPublicKeyVerified = verifyUserPublicKey(bitgo, { userKeychain: keychains.user, disableNetworking, txParams });
userPublicKeyVerified = await verifyUserPublicKey(bitgo, {
userKeychain: keychains.user,
disableNetworking,
txParams,
});
} catch (e) {
debug('failed to verify user public key!', e);
}
Expand Down
4 changes: 2 additions & 2 deletions modules/abstract-utxo/src/verifyKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function verifyCustomChangeKeySignatures<TNumber extends number | bigint>
/**
* Decrypt the wallet's user private key and verify that the claimed public key matches
*/
export function verifyUserPublicKey(bitgo: BitGoBase, params: VerifyUserPublicKeyOptions): boolean {
export async function verifyUserPublicKey(bitgo: BitGoBase, params: VerifyUserPublicKeyOptions): Promise<boolean> {
const { userKeychain, txParams, disableNetworking } = params;
if (!userKeychain) {
throw new Error('user keychain is required');
Expand All @@ -94,7 +94,7 @@ export function verifyUserPublicKey(bitgo: BitGoBase, params: VerifyUserPublicKe

let userPrv = userKeychain.prv;
if (!userPrv && txParams.walletPassphrase) {
userPrv = decryptKeychainPrivateKey(bitgo, userKeychain, txParams.walletPassphrase);
userPrv = await decryptKeychainPrivateKey(bitgo, userKeychain, txParams.walletPassphrase);
}

if (!userPrv) {
Expand Down
56 changes: 55 additions & 1 deletion modules/bitgo/test/unit/decryptKeychain.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import 'should';
import { decryptKeychainPrivateKey, OptionalKeychainEncryptedKey } from '@bitgo/sdk-core';
import {
decryptKeychainPrivateKey,
decryptKeychainPrivateKeyAsync,
OptionalKeychainEncryptedKey,
} from '@bitgo/sdk-core';
import { BitGoAPI } from '@bitgo/sdk-api';

describe('decryptKeychainPrivateKey', () => {
Expand Down Expand Up @@ -78,3 +82,53 @@ describe('decryptKeychainPrivateKey', () => {
(decryptKeychainPrivateKey(bitgo, {}, 'password') === undefined).should.be.true();
});
});

describe('decryptKeychainPrivateKeyAsync', () => {
const bitgo = new BitGoAPI();

const prv1 = Math.random().toString();
const password1 = Math.random().toString();

const prv2 = Math.random().toString();
const password2 = Math.random().toString();

it('should decrypt encryptedPrv (v1)', async () => {
const keychain: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: prv1, password: password1 }),
};
const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, password1);
result!.should.equal(prv1);
});

it('should decrypt webauthnDevices encryptedPrv (v1)', async () => {
const keychain: OptionalKeychainEncryptedKey = {
webauthnDevices: [
{
otpDeviceId: '123',
authenticatorInfo: {
credID: 'credID',
fmt: 'packed',
publicKey: 'some value',
},
prfSalt: '456',
encryptedPrv: bitgo.encrypt({ input: prv2, password: password2 }),
},
],
};
const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, password2);
result!.should.equal(prv2);
});

it('should return undefined if no encryptedPrv can be decrypted', async () => {
const keychain: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: prv1, password: password1 }),
};
const result = await decryptKeychainPrivateKeyAsync(bitgo, keychain, Math.random().toString());
(result === undefined).should.equal(true);
});

it('should return undefined if no encryptedPrv is present', async () => {
const result = await decryptKeychainPrivateKeyAsync(bitgo, {}, 'password');
(result === undefined).should.be.true();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,55 @@ describe('TSS Ecdsa MPCv2 Utils:', async function () {
assert.equal(bitgoKeychain.source, 'bitgo');
});

it('should generate TSS MPCv2 keys with v2 encryption envelopes', async function () {
const bitgoSession = new DklsDkg.Dkg(3, 2, 2);

const round1Nock = await nockKeyGenRound1(bitgoSession, 1);
const round2Nock = await nockKeyGenRound2(bitgoSession, 1);
const round3Nock = await nockKeyGenRound3(bitgoSession, 1);
const addKeyNock = await nockAddKeyChain(coinName, 3);
const params = {
passphrase: 'test',
enterprise: enterpriseId,
originalPasscodeEncryptionCode: '123456',
encryptionVersion: 2 as const,
};
const { userKeychain, backupKeychain, bitgoKeychain } = await tssUtils.createKeychains(params);
assert.ok(round1Nock.isDone());
assert.ok(round2Nock.isDone());
assert.ok(round3Nock.isDone());
assert.ok(addKeyNock.isDone());

assert.ok(userKeychain);
assert.equal(userKeychain.source, 'user');
assert.ok(userKeychain.commonKeychain);
assert.ok(ECDSAUtils.EcdsaMPCv2Utils.validateCommonKeychainPublicKey(userKeychain.commonKeychain));

// Verify v2 envelopes for encryptedPrv
assert.ok(userKeychain.encryptedPrv);
const encryptedPrvParsed: { v: number } = JSON.parse(userKeychain.encryptedPrv);
assert.equal(encryptedPrvParsed.v, 2, 'encryptedPrv should be a v2 envelope');

// Verify v2 envelopes for reducedEncryptedPrv
assert.ok(userKeychain.reducedEncryptedPrv);
const reducedEncryptedPrvParsed: { v: number } = JSON.parse(userKeychain.reducedEncryptedPrv);
assert.equal(reducedEncryptedPrvParsed.v, 2, 'reducedEncryptedPrv should be a v2 envelope');

// Verify v2 envelope is decryptable via decryptAsync
const decrypted = await bitgo.decryptAsync({ input: userKeychain.encryptedPrv, password: params.passphrase });
assert.ok(decrypted, 'decryptAsync should successfully decrypt v2 envelope');

// Verify backup keychain also uses v2 envelopes
assert.ok(backupKeychain);
assert.equal(backupKeychain.source, 'backup');
assert.ok(backupKeychain.encryptedPrv);
const backupEncryptedPrvParsed: { v: number } = JSON.parse(backupKeychain.encryptedPrv);
assert.equal(backupEncryptedPrvParsed.v, 2, 'backup encryptedPrv should be a v2 envelope');

assert.ok(bitgoKeychain);
assert.equal(bitgoKeychain.source, 'bitgo');
});

it('should generate TSS MPCv2 keys for retrofit', async function () {
const xiList = [
Array.from(bigIntToBufferBE(BigInt(1), 32)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,122 @@ describe('signTxRequest:', function () {
nockPromises[2].isDone().should.be.true();
});

describe('v2 encryption (offline rounds with adata)', function () {
it('e2e: 3-round offline signing with v2 encrypted keys preserves adata context binding', async function () {
const walletPassphrase = 'testpassphrase';
const userShare = fs.readFileSync(shareFiles[vector.party1]);
const userPrvBase64 = Buffer.from(userShare).toString('base64');

// Encrypt the prv with v2 to trigger the v2 path
const encryptedPrv = await bitgo.encryptAsync({
input: userPrvBase64,
password: walletPassphrase,
encryptionVersion: 2,
});
JSON.parse(encryptedPrv).v.should.equal(2);

// Round 1: encrypt session + GPG key with v2 + adata (purely local, no server call)
const round1Result = await tssUtils.createOfflineRound1Share({
txRequest,
prv: userPrvBase64,
walletPassphrase,
encryptedPrv,
});

// Verify round 1 output has v2 envelopes with adata
const r1SessionEnvelope = JSON.parse(round1Result.encryptedRound1Session);
r1SessionEnvelope.v.should.equal(2);
r1SessionEnvelope.should.have.property('adata');
r1SessionEnvelope.should.have.property('hkdfSalt');

const r1GpgEnvelope = JSON.parse(round1Result.encryptedUserGpgPrvKey);
r1GpgEnvelope.v.should.equal(2);
r1GpgEnvelope.should.have.property('adata');
r1SessionEnvelope.adata.should.equal(r1GpgEnvelope.adata);

// Nock BitGo round 1 response and submit
await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey);
const transactions = getRoute('ecdsa');
const round1TxRequestResponse = await bitgo
.post(bitgo.url(`/wallet/${txRequest.walletId}/txrequests/${txRequest.txRequestId + transactions}/sign`, 2))
.send({
signatureShares: [round1Result.signatureShareRound1],
signerGpgPublicKey: round1Result.userGpgPubKey,
})
.result();

// Merge server response with original txRequest (server only returns signatureShares)
const round1TxReq: TxRequest = {
...txRequest,
transactions: [
{
...txRequest.transactions![0],
signatureShares: round1TxRequestResponse.transactions[0].signatureShares,
},
],
};

// Round 2: decrypt v2 round 1 session (validates adata), encrypt round 2 session
const round2Result = await tssUtils.createOfflineRound2Share({
txRequest: round1TxReq,
prv: userPrvBase64,
walletPassphrase,
bitgoPublicGpgKey: bitgoGpgKey.publicKey,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
encryptedRound1Session: round1Result.encryptedRound1Session,
});

// Verify round 2 output has v2 envelope with adata
const r2Envelope = JSON.parse(round2Result.encryptedRound2Session);
r2Envelope.v.should.equal(2);
r2Envelope.should.have.property('adata');
r2Envelope.adata.should.equal(r1SessionEnvelope.adata);

// Nock BitGo round 2 response and submit
await nockTxRequestResponseSignatureShareRoundTwo(bitgoParty, txRequest, bitgoGpgKey);
const round2TxRequestResponse = await bitgo
.post(bitgo.url(`/wallet/${txRequest.walletId}/txrequests/${txRequest.txRequestId + transactions}/sign`, 2))
.send({
signatureShares: [round2Result.signatureShareRound2],
signerGpgPublicKey: round1Result.userGpgPubKey,
})
.result();

const round2TxReq: TxRequest = {
...txRequest,
transactions: [
{
...txRequest.transactions![0],
signatureShares: round2TxRequestResponse.transactions[0].signatureShares,
},
],
};

// Round 3: decrypt v2 round 2 session (validates adata), produce final signature share
const round3Result = await tssUtils.createOfflineRound3Share({
txRequest: round2TxReq,
prv: userPrvBase64,
walletPassphrase,
bitgoPublicGpgKey: bitgoGpgKey.publicKey,
encryptedUserGpgPrvKey: round1Result.encryptedUserGpgPrvKey,
encryptedRound2Session: round2Result.encryptedRound2Session,
});

round3Result.should.have.property('signatureShareRound3');
});

it('validateAdata rejects v2 envelopes with mismatched adata', async function () {
const ct = await bitgo.encryptAsync({
input: 'test-data',
password: 'testpass',
encryptionVersion: 2,
adata: 'context-A',
});

(() => (tssUtils as any).validateAdata('context-B', ct)).should.throw(/Adata does not match/);
});
});

it('fails to signs a txRequest for a dkls hot wallet after receiving over 3 429 errors', async function () {
const nockPromises = [
await nockTxRequestResponseSignatureShareRoundOne(bitgoParty, txRequest, bitgoGpgKey),
Expand Down
43 changes: 43 additions & 0 deletions modules/bitgo/test/v2/unit/internal/tssUtils/eddsa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -547,6 +547,49 @@ describe('TSS Utils:', async function () {
})
.should.be.rejectedWith('Failed to create backup keychain - commonKeychains do not match.');
});

it('should generate TSS key chains with v2 encryption envelopes', async function () {
const passphrase = 'passphrase';
const userKeyShare = MPC.keyShare(1, 2, 3);
const backupKeyShare = MPC.keyShare(2, 2, 3);

await nockBitgoKeychain({
coin: coinName,
userKeyShare,
backupKeyShare,
bitgoKeyShare,
userGpgKey,
backupGpgKey,
bitgoGpgKey,
});
await nockUserKeychain({ coin: coinName });
await nockBackupKeychain({ coin: coinName });

const bitgoKeychain = await tssUtils.createBitgoKeychain({
userGpgKey,
backupGpgKey,
userKeyShare,
backupKeyShare,
});
const userKeychain = await tssUtils.createUserKeychain({
userGpgKey,
backupGpgKey,
userKeyShare,
backupKeyShare,
bitgoKeychain,
passphrase,
encryptionVersion: 2,
});

should.exist(userKeychain.encryptedPrv);
const envelope = JSON.parse(userKeychain.encryptedPrv!);
envelope.v.should.equal(2);

const decrypted = await bitgo.decryptAsync({ input: userKeychain.encryptedPrv!, password: passphrase });
should.exist(decrypted);
const parsed: Record<string, unknown> = JSON.parse(decrypted);
should.exist(parsed.uShare);
});
});

describe('signTxRequest:', function () {
Expand Down
8 changes: 4 additions & 4 deletions modules/bitgo/test/v2/unit/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ describe('V2 Wallet:', function () {
prv,
coldDerivationSeed: '123',
};
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
});

it('should use the user keychain derivedFromParentWithSeed as the cold derivation seed if none is provided', async () => {
Expand All @@ -365,7 +365,7 @@ describe('V2 Wallet:', function () {
type: 'independent',
},
};
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
});

it('should prefer the explicit cold derivation seed to the user keychain derivedFromParentWithSeed', async () => {
Expand All @@ -379,7 +379,7 @@ describe('V2 Wallet:', function () {
type: 'independent',
},
};
wallet.getUserPrv(userPrvOptions).should.eql(derivedPrv);
(await wallet.getUserPrv(userPrvOptions)).should.eql(derivedPrv);
});

it('should return the prv provided for TSS SMC', async () => {
Expand Down Expand Up @@ -407,7 +407,7 @@ describe('V2 Wallet:', function () {
prv,
keychain,
};
wallet.getUserPrv(userPrvOptions).should.eql(prv);
(await wallet.getUserPrv(userPrvOptions)).should.eql(prv);
});
});

Expand Down
Loading
Loading