Skip to content
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
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
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
12 changes: 6 additions & 6 deletions modules/bitgo/test/v2/unit/wallets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2566,7 +2566,7 @@ describe('V2 Wallets:', function () {
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}
Expand Down Expand Up @@ -2638,7 +2638,7 @@ describe('V2 Wallets:', function () {
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}
Expand Down Expand Up @@ -2717,7 +2717,7 @@ describe('V2 Wallets:', function () {
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}
Expand Down Expand Up @@ -2785,7 +2785,7 @@ describe('V2 Wallets:', function () {
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}
Expand Down Expand Up @@ -2886,7 +2886,7 @@ describe('V2 Wallets:', function () {
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}
Expand Down Expand Up @@ -2988,7 +2988,7 @@ describe('V2 Wallets:', function () {
const keychainTest: OptionalKeychainEncryptedKey = {
encryptedPrv: bitgo.encrypt({ input: fromUserPrv.toString(), password: walletPassphrase }),
};
const userPrv = decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
const userPrv = await decryptKeychainPrivateKey(bitgo, keychainTest, walletPassphrase);
if (!userPrv) {
throw new Error('Unable to decrypt user keychain');
}
Expand Down
25 changes: 25 additions & 0 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
verifyResponseAsync,
} from './api';
import { decrypt, decryptAsync, encrypt } from './encrypt';
import { createEncryptionSession } from './encryptionSession';
import { encryptV2 } from './encryptV2';
import { verifyAddress } from './v1/verifyAddress';
import {
AccessTokenOptions,
Expand Down Expand Up @@ -715,6 +717,29 @@ export class BitGoAPI implements BitGoBase {
return encrypt(params.password, params.input, { adata: params.adata });
}

/**
* Async encrypt that dispatches to v1 (SJCL) or v2 (Argon2id + AES-256-GCM)
* based on `encryptionVersion`.
*/
async encryptAsync(params: EncryptOptions): Promise<string> {
common.validateParams(params, ['input', 'password'], []);
if (!params.password) {
throw new Error('cannot encrypt without password');
}
if (params.encryptionVersion === 2) {
return encryptV2(params.password, params.input);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major — params.adata is silently dropped on the v2 path. v1 forwards adata to sjcl.encrypt; v2 calls encryptV2(password, input) with no AAD. Two consequences:

  1. Callers passing adata get false confidence that AAD is bound — it is not.
  2. In ecdsaMPCv2.ts, the v1 path uses adata = ${hashBuffer}:${derivationPath} to bind the encrypted round-session to the transaction context (validateAdata enforces the binding). The v2 path has no equivalent (see inline comment on ecdsaMPCv2.ts line 1242).

v2's AES-GCM is self-authenticating for the ciphertext — but doesn't replicate the transaction-context binding unless AAD is threaded through encryptV2 envelopes. Fix: add an additionalData?: string option to encryptV2/aesGcmEncrypt, store it in the v2 envelope, and verify in decryptV2. Or, at minimum, throw if params.adata is set and encryptionVersion === 2 so the silent drop becomes a loud error.

}
return encrypt(params.password, params.input, { adata: params.adata });
}

/**
* Create an encryption session for multi-call operations.
* Runs Argon2id once; all subsequent calls derive keys via HKDF.
*/
async createEncryptionSession(password: string) {
return createEncryptionSession(password);
}

/**
* Decrypt an encrypted string locally.
*/
Expand Down
45 changes: 45 additions & 0 deletions modules/sdk-api/test/unit/encrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'assert';
import { randomBytes } from 'crypto';

import { decrypt, decryptAsync, decryptV2, encrypt, encryptV2, V2Envelope, createEncryptionSession } from '../../src';
import { BitGoAPI } from '../../src/bitgoAPI';

describe('encryption methods tests', () => {
describe('encrypt', () => {
Expand Down Expand Up @@ -320,4 +321,48 @@ describe('encryption methods tests', () => {
assert.strictEqual(envelope.p, 2);
});
});

describe('BitGoAPI.encryptAsync', () => {
let bitgo: BitGoAPI;
const password = 'test-password';
const plaintext = 'hello encryptAsync';

before(() => {
bitgo = new BitGoAPI({ env: 'test' });
});

it('dispatches to v1 by default and output is decryptable via decrypt', async () => {
const ct = await bitgo.encryptAsync({ input: plaintext, password });
const envelope = JSON.parse(ct);
assert.notStrictEqual(envelope.v, 2, 'default should not produce v2 envelope');
assert.strictEqual(decrypt(password, ct), plaintext);
});

it('dispatches to v2 when encryptionVersion: 2 and output is decryptable via decryptAsync', async () => {
const ct = await bitgo.encryptAsync({ input: plaintext, password, encryptionVersion: 2 });
const envelope: V2Envelope = JSON.parse(ct);
assert.strictEqual(envelope.v, 2);
const result = await decryptAsync(password, ct);
assert.strictEqual(result, plaintext);
});
});

describe('BitGoAPI.createEncryptionSession', () => {
let bitgo: BitGoAPI;
const password = 'test-password';
const plaintext = 'hello session';

before(() => {
bitgo = new BitGoAPI({ env: 'test' });
});

it('returns working session (encrypt/decrypt/destroy)', async () => {
const session = await bitgo.createEncryptionSession(password);
const ct = await session.encrypt(plaintext);
const result = await session.decrypt(ct);
assert.strictEqual(result, plaintext);
session.destroy();
await assert.rejects(() => session.encrypt(plaintext), /destroyed/);
});
});
});
1 change: 1 addition & 0 deletions modules/sdk-core/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface EncryptOptions {
input: string;
password?: string;
adata?: string;
encryptionVersion?: 2;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major — use a named alias. A bare literal 2 makes encryptionVersion: 1 a type error, even though v1 is the current default — so callers forwarding from a generic config ({ encryptionVersion: cfg.version }) need a cast. Suggest:

export type EncryptionVersion = 1 | 2;

export interface EncryptOptions {
  input: string;
  password?: string;
  adata?: string;
  encryptionVersion?: EncryptionVersion;
}

This prepares for v3 without a hunt-and-replace across the 8+ option types where the field appears (GenerateWalletOptions, GenerateMpcWalletOptions, CreateMpcOptions, CreateBackupOptions, CreateKeychainParamsBase, etc.). Also: adata should get a JSDoc note that it is silently dropped when encryptionVersion === 2 — see the inline comment on BitGoAPI.encryptAsync.

}

export interface GetSharingKeyOptions {
Expand Down
6 changes: 6 additions & 0 deletions modules/sdk-core/src/bitgo/bitgoBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface BitGoBase {
decryptKeys(params: DecryptKeysOptions): string[];
del(url: string): BitGoRequest;
encrypt(params: EncryptOptions): string;
encryptAsync(params: EncryptOptions): Promise<string>;
createEncryptionSession(password: string): Promise<{
encrypt(plaintext: string): Promise<string>;
decrypt(ciphertext: string): Promise<string>;
destroy(): void;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major — extract a named IEncryptionSession. The anonymous inline return type is repeated 4+ times across the PR (ecdsaMPCv2.ts addUserKeychain/addBackupKeychain parameter types, eddsa.ts session usage, etc.). EncryptionSession already exists as a class in modules/sdk-api/src/encryptionSession.ts. The structural problem is that sdk-core can't import from sdk-api (circular). Fix: declare an IEncryptionSession interface in modules/sdk-core/src/api/types.ts, export it from sdk-core/src/index.ts, and have both this interface and the EncryptionSession class in sdk-api reference it.

Without this, no external SDK consumer can write let session: ??? with a proper type import.

}>;
readonly env: EnvironmentName;
fetchConstants(): Promise<any>;
get(url: string): BitGoRequest;
Expand Down
Loading
Loading