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
567 changes: 547 additions & 20 deletions modules/bitgo/test/v2/unit/wallets.ts

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions modules/sdk-api/src/bitgoAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,10 +854,13 @@ export class BitGoAPI implements BitGoBase {

/**
* Create an encryption session for multi-call operations.
* Runs Argon2id once; all subsequent calls derive keys via HKDF.
*
* v2 (default): runs Argon2id once, all subsequent calls derive per-envelope AES keys via HKDF.
* v1: returns a shim that satisfies the same interface but runs SJCL PBKDF2 per call. Lets
* callers that must produce v1 envelopes use the same factory as v2 callers.
*/
async createEncryptionSession(password: string) {
return createEncryptionSession(password);
async createEncryptionSession(password: string, encryptionVersion?: EncryptionVersion) {
return createEncryptionSession(password, { encryptionVersion });
}

/**
Expand Down
64 changes: 61 additions & 3 deletions modules/sdk-api/src/encryptionSession.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { randomBytes } from 'crypto';

import { decrypt, encrypt } from './encrypt';
import {
aesGcmDecrypt,
aesGcmEncrypt,
Expand Down Expand Up @@ -100,11 +101,68 @@ export class EncryptionSession {
}
}

/** Create an EncryptionSession. Runs Argon2id once; all subsequent calls derive keys via HKDF. */
/**
* v1 (SJCL) shim that satisfies the same contract as EncryptionSession but does not open a
* real session. Every encrypt/decrypt call runs its own SJCL PBKDF2 derivation via encrypt() /
* decrypt(). Exists so callers that pin `encryptionVersion: 1` can use the same
* `createEncryptionSession(pw, ver)` factory as v2 callers — no per-site useV2 branching.
* Once the sjcl-replacement rollout removes v1 encrypt, this collapses to nothing.
*/
export class V1EncryptionSession {
private password: string | null;

constructor(password: string) {
this.password = password;
}

async encrypt(input: string, adata?: string): Promise<string> {
return encrypt(this.getPasswordOrThrow(), input, { adata, encryptionVersion: 1 });
}

async decrypt(ciphertext: string): Promise<string> {
return decrypt(this.getPasswordOrThrow(), ciphertext);
}

destroy(): void {
this.password = null;
}

private getPasswordOrThrow(): string {
if (this.password === null) {
throw new Error('V1EncryptionSession has been destroyed');
}
return this.password;
}
}

/**
* Create an EncryptionSession.
*
* When encryptionVersion is undefined or 2 (the default), runs Argon2id once so every
* subsequent encrypt/decrypt derives a per-call AES key via HKDF (<1ms, native WebCrypto).
*
* When encryptionVersion is 1, returns a V1EncryptionSession shim that runs SJCL PBKDF2 on
* every call. The shim satisfies the same interface so callers that must produce v1 (SJCL)
* envelopes for legacy consumers use one factory regardless of version. No useV2 branching
* needed at the call site.
*
* Callers MUST call destroy() to clear the HKDF root (or retained password in v1 mode) from
* memory. Use-after-destroy throws.
*/
export async function createEncryptionSession(
password: string,
options?: { memorySize?: number; iterations?: number; parallelism?: number; salt?: Uint8Array }
): Promise<EncryptionSession> {
options?: {
memorySize?: number;
iterations?: number;
parallelism?: number;
salt?: Uint8Array;
encryptionVersion?: 1 | 2;
}
): Promise<EncryptionSession | V1EncryptionSession> {
if (options?.encryptionVersion === 1) {
return new V1EncryptionSession(password);
}

const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize;
const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations;
const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism;
Expand Down
72 changes: 72 additions & 0 deletions modules/sdk-api/test/unit/encrypt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,78 @@ describe('encryption methods tests', () => {
});
});

describe('createEncryptionSession with encryptionVersion=1 (v1 shim)', () => {
const password = 'v1-shim-password';
const plaintext = 'legacy consumer data';

it('produces v1 (SJCL) envelopes', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
const ct = await session.encrypt(plaintext);
const envelope = JSON.parse(ct);
// v1 SJCL envelopes carry iter/mode/ks fields; no hkdfSalt/argon2 params
assert.ok(envelope.iter, 'v1 envelope must have iter');
assert.ok(envelope.mode, 'v1 envelope must have mode');
assert.notStrictEqual(envelope.v, 2);
assert.strictEqual(envelope.hkdfSalt, undefined);
session.destroy();
});

it('round-trips via session.decrypt', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
const ct = await session.encrypt(plaintext);
const rt = await session.decrypt(ct);
assert.strictEqual(rt, plaintext);
session.destroy();
});

it('produced envelopes decrypt via the standard decrypt() with the same password', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
const ct = await session.encrypt(plaintext);
session.destroy();
const rt = await decrypt(password, ct);
assert.strictEqual(rt, plaintext);
});

it('multiple encrypts produce distinct ciphertexts (per-call SJCL salt/iv)', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
const ct1 = await session.encrypt(plaintext);
const ct2 = await session.encrypt(plaintext);
assert.notStrictEqual(ct1, ct2);
const e1 = JSON.parse(ct1);
const e2 = JSON.parse(ct2);
// v1 salts are per-call, so they must differ across envelopes even under same password
assert.notStrictEqual(e1.salt, e2.salt);
session.destroy();
});

it('forwards adata to the v1 envelope', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
const ct = await session.encrypt(plaintext, 'enterprise-id-42');
const envelope = JSON.parse(ct);
assert.strictEqual(envelope.adata, 'enterprise-id-42');
session.destroy();
});

it('destroy blocks further encrypt calls', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
session.destroy();
await assert.rejects(() => session.encrypt(plaintext), /destroyed/);
});

it('destroy blocks further decrypt calls', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
const ct = await session.encrypt(plaintext);
session.destroy();
await assert.rejects(() => session.decrypt(ct), /destroyed/);
});

it('destroy is idempotent', async () => {
const session = await createEncryptionSession(password, { encryptionVersion: 1 });
session.destroy();
session.destroy();
});
});

describe('BitGoAPI.encrypt', () => {
let bitgo: BitGoAPI;
const password = 'test-password';
Expand Down
3 changes: 2 additions & 1 deletion modules/sdk-core/src/bitgo/bitgoBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BitGoRequest,
DecryptKeysOptions,
DecryptOptions,
EncryptionVersion,
EncryptOptions,
GetSharingKeyOptions,
IEncryptionSession,
Expand Down Expand Up @@ -43,7 +44,7 @@ export interface BitGoBase {
decryptKeys(params: DecryptKeysOptions): Promise<string[]>;
del(url: string): BitGoRequest;
encrypt(params: EncryptOptions): Promise<string>;
createEncryptionSession(password: string): Promise<IEncryptionSession>;
createEncryptionSession(password: string, encryptionVersion?: EncryptionVersion): Promise<IEncryptionSession>;
readonly env: EnvironmentName;
fetchConstants(): Promise<any>;
get(url: string): BitGoRequest;
Expand Down
8 changes: 6 additions & 2 deletions modules/sdk-core/src/bitgo/keychain/iKeychains.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EncryptionVersion, IRequestTracer } from '../../api';
import { EncryptionVersion, IEncryptionSession, IRequestTracer } from '../../api';
import { KeychainsTriplet, KeyPair } from '../baseCoin';
import { BitgoPubKeyType } from '../utils/tss/baseTypes';
import { IWallet } from '../wallet';
Expand Down Expand Up @@ -282,6 +282,10 @@ export interface IKeychains {
createMpc(params: CreateMpcOptions): Promise<KeychainsTriplet>;
recreateMpc(params: RecreateMpcOptions): Promise<KeychainsTriplet>;
createTssBitGoKeyFromOvcShares(ovcOutput: OvcToBitGoJSON, enterprise?: string): Promise<BitGoKeyFromOvcShares>;
createUserKeychain(userPassword: string, encryptionVersion?: EncryptionVersion): Promise<Keychain>;
createUserKeychain(
userPassword: string,
encryptionVersion?: EncryptionVersion,
session?: IEncryptionSession
): Promise<Keychain>;
rotateKeychain(params: RotateKeychainOptions): Promise<Keychain>;
}
22 changes: 15 additions & 7 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
UpdateSingleKeychainPasswordOptions,
} from './iKeychains';
import { BitGoKeyFromOvcShares, BitGoToOvcJSON, OvcToBitGoJSON } from './ovcJsonCodec';
import { EncryptionVersion } from '../../api';
import { EncryptionVersion, IEncryptionSession } from '../../api';

export class Keychains implements IKeychains {
private readonly bitgo: BitGoBase;
Expand Down Expand Up @@ -571,16 +571,24 @@ export class Keychains implements IKeychains {
* @param walletPassphrase
* @returns Keychain including the decrypted private key
*/
async createUserKeychain(walletPassphrase: string, encryptionVersion?: EncryptionVersion): Promise<Keychain> {
async createUserKeychain(
walletPassphrase: string,
encryptionVersion?: EncryptionVersion,
session?: IEncryptionSession
): Promise<Keychain> {
const keychains = this.baseCoin.keychains();
const newKeychain = keychains.create();
const originalPasscodeEncryptionCode = generateRandomPassword(5);

const encryptedPrv = await this.bitgo.encrypt({
password: walletPassphrase,
input: newKeychain.prv,
encryptionVersion,
});
// When a session is threaded in (typically from a bulk operation that already ran the KDF
// once), reuse it so this envelope's AES key derives via HKDF instead of another Argon2.
const encryptedPrv = session
? await session.encrypt(newKeychain.prv)
: await this.bitgo.encrypt({
password: walletPassphrase,
input: newKeychain.prv,
encryptionVersion,
});

return {
...(await keychains.add({
Expand Down
7 changes: 7 additions & 0 deletions modules/sdk-core/src/bitgo/wallet/iWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,13 @@ export interface ShareWalletOptions {
skipKeychain?: boolean;
disableEmail?: boolean;
encryptionVersion?: EncryptionVersion;
/**
* Pre-decrypted wallet keychain. When supplied, shareWallet skips its internal
* getDecryptedKeychainForSharing call — useful when the caller is sharing the same wallet
* with many recipients under one walletPassphrase and wants to avoid N-1 redundant Argon2id
* decryptions of the user's wallet keychain.
*/
decryptedKeychain?: DecryptedKeychainData;
}

export interface BulkCreateShareOption {
Expand Down
20 changes: 9 additions & 11 deletions modules/sdk-core/src/bitgo/wallet/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2019,20 +2019,17 @@ export class Wallet implements IWallet {
walletPassphrase: string | undefined,
pubkey: string,
path: string,
encryptionVersion?: EncryptionVersion
encryptionVersion?: EncryptionVersion,
decryptedKeychain?: DecryptedKeychainData
): Promise<SharedKeyChain> {
try {
const decryptedKeychain = await this.getDecryptedKeychainForSharing(walletPassphrase);
if (!decryptedKeychain) {
// Callers that share the same wallet with many recipients can pass the pre-decrypted
// keychain to avoid re-running Argon2id per recipient. When absent, decrypt on demand.
const keychain = decryptedKeychain ?? (await this.getDecryptedKeychainForSharing(walletPassphrase));
if (!keychain) {
return {};
}
return await this.encryptPrvForUser(
decryptedKeychain.prv,
decryptedKeychain.pub,
pubkey,
path,
encryptionVersion
);
return await this.encryptPrvForUser(keychain.prv, keychain.pub, pubkey, path, encryptionVersion);
} catch (e) {
if (e instanceof MissingEncryptedKeychainError) {
// ignore this error because this looks like a cold wallet
Expand Down Expand Up @@ -2082,7 +2079,8 @@ export class Wallet implements IWallet {
params.walletPassphrase,
sharing.pubkey,
sharing.path,
params.encryptionVersion
params.encryptionVersion,
params.decryptedKeychain
);
}

Expand Down
Loading
Loading