Skip to content
Closed
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
1 change: 1 addition & 0 deletions modules/sdk-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
]
},
"dependencies": {
"@bitgo/argon2": "^1.1.0",
"@bitgo/public-types": "6.22.0",
"@bitgo/sdk-lib-mpc": "^10.14.0",
"@bitgo/secp256k1": "^1.11.0",
Expand Down
154 changes: 154 additions & 0 deletions modules/sdk-core/src/bitgo/utils/encrypt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { argon2id } from '@bitgo/argon2';
import * as sjcl from '@bitgo/sjcl';
import { randomBytes, webcrypto } from 'crypto';
import * as t from 'io-ts';

import { base64String, boundedInt, decodeWithCodec } from './codecs';

const subtle = globalThis.crypto?.subtle ?? webcrypto.subtle;

const ARGON2_DEFAULTS = { memorySize: 65536, iterations: 3, parallelism: 4, hashLength: 32, saltLength: 16 } as const;
const ARGON2_MAX = { memorySize: 262144, iterations: 16, parallelism: 16 } as const;
const GCM_IV_LENGTH = 12;
const HKDF_INFO = new TextEncoder().encode('bitgo-v2-session');

const V2EnvelopeCodec = t.intersection([
t.type({
v: t.literal(2),
m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'),
t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'),
p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'),
salt: base64String,
iv: base64String,
ct: base64String,
}),
t.partial({ hkdfSalt: base64String, adata: t.string }),
]);

type V2Envelope = t.TypeOf<typeof V2EnvelopeCodec>;

async function argon2ToAesKey(
password: string,
salt: Uint8Array,
params: { memorySize: number; iterations: number; parallelism: number },
usage: KeyUsage
): Promise<CryptoKey> {
const keyBytes = await argon2id({
password,
salt,
...params,
hashLength: ARGON2_DEFAULTS.hashLength,
outputType: 'binary',
});
return subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, [usage]);
}

async function argon2ToHkdfKey(
password: string,
salt: Uint8Array,
params: { memorySize: number; iterations: number; parallelism: number }
): Promise<CryptoKey> {
const keyBytes = await argon2id({
password,
salt,
...params,
hashLength: ARGON2_DEFAULTS.hashLength,
outputType: 'binary',
});
return subtle.importKey('raw', keyBytes, 'HKDF', false, ['deriveKey']);
}

function hkdfDeriveAesKey(hkdfKey: CryptoKey, hkdfSalt: Uint8Array, usage: KeyUsage): Promise<CryptoKey> {
return subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO },
hkdfKey,
{ name: 'AES-GCM', length: 256 },
false,
[usage]
);
}

async function aesGcmEncrypt(
key: CryptoKey,
iv: Uint8Array,
plaintext: string,
adata?: Uint8Array
): Promise<Uint8Array> {
const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 };
if (adata) params.additionalData = adata;
const ct = await subtle.encrypt(params, key, new TextEncoder().encode(plaintext));
return new Uint8Array(ct);
}

async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ct: Uint8Array, adata?: Uint8Array): Promise<string> {
const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 };
if (adata) params.additionalData = adata;
const plaintext = await subtle.decrypt(params, key, ct);
return new TextDecoder().decode(plaintext);
}

/** Encrypt plaintext using Argon2id KDF + AES-256-GCM. */
export async function encryptV2(
password: string,
plaintext: string,
options?: { salt?: Uint8Array; iv?: Uint8Array; memorySize?: number; iterations?: number; parallelism?: number }
): Promise<string> {
const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize;
const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations;
const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism;
const salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength));
const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH));

const key = await argon2ToAesKey(password, salt, { memorySize, iterations, parallelism }, 'encrypt');
const ct = await aesGcmEncrypt(key, iv, plaintext);

const envelope: V2Envelope = {
v: 2,
m: memorySize,
t: iterations,
p: parallelism,
salt: Buffer.from(salt).toString('base64'),
iv: Buffer.from(iv).toString('base64'),
ct: Buffer.from(ct).toString('base64'),
};
return JSON.stringify(envelope);
}

async function decryptV2(password: string, ciphertext: string): Promise<string> {
const envelope = decodeWithCodec(V2EnvelopeCodec, JSON.parse(ciphertext), 'v2 decrypt: invalid envelope');
const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64'));
const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64'));
const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64'));
const params = { memorySize: envelope.m, iterations: envelope.t, parallelism: envelope.p };
const adataBytes = envelope.adata ? new TextEncoder().encode(envelope.adata) : undefined;

if (envelope.hkdfSalt) {
const hkdfKey = await argon2ToHkdfKey(password, salt, params);
const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64'));
const aesKey = await hkdfDeriveAesKey(hkdfKey, hkdfSalt, 'decrypt');
return aesGcmDecrypt(aesKey, iv, ct, adataBytes);
}

const key = await argon2ToAesKey(password, salt, params, 'decrypt');
return aesGcmDecrypt(key, iv, ct, adataBytes);
}

/**
* Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt.
*/
export async function decryptAsync(password: string, ciphertext: string): Promise<string> {
let envelopeVersion: number | undefined;
try {
const envelope = JSON.parse(ciphertext);
envelopeVersion = envelope.v;
} catch {
throw new Error('decrypt: ciphertext is not valid JSON');
}
if (envelopeVersion === 2) {
return decryptV2(password, ciphertext);
}
if (envelopeVersion !== undefined && envelopeVersion !== 1) {
throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`);
}
return sjcl.decrypt(password, ciphertext);
}
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as openpgpUtils from './opengpgUtils';

export * from './abstractUtxoCoinUtil';
export * from './encrypt';
export * from './mpcUtils';
export * from './opengpgUtils';
export * from './promise-utils';
Expand Down
39 changes: 39 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
} from '../baseTypes';
import { BaseEddsaUtils } from './base';
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
import { decryptAsync } from '../../encrypt';

export class EddsaMPCv2Utils extends BaseEddsaUtils {
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
Expand Down Expand Up @@ -899,3 +900,41 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
}
// #endregion
}

export async function getEddsaMpcV2RecoveryKeyShares(
encryptedUserKey: string,
encryptedBackupKey: string,
walletPassphrase?: string
): Promise<{ userKeyShare: Buffer; backupKeyShare: Buffer; commonKeyChain: string }> {
const decodeKey = async (encryptedKey: string): Promise<MPSTypes.EddsaReducedKeyShare> => {
const decrypted = await decryptAsync(walletPassphrase ?? '', encryptedKey);
const reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64'));
if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) {
throw new Error(
'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' +
'This keycard may be public-only and cannot be used for recovery.'
);
}
return reduced;
};

const [userReduced, backupReduced] = await Promise.all([decodeKey(encryptedUserKey), decodeKey(encryptedBackupKey)]);

const userPub = Buffer.from(userReduced.pub).toString('hex');
const backupPub = Buffer.from(backupReduced.pub).toString('hex');
if (userPub !== backupPub) {
throw new Error('EdDSA MPCv2 recovery: user and backup pub keys do not match');
}

const userChainCode = Buffer.from(userReduced.rootChainCode).toString('hex');
const backupChainCode = Buffer.from(backupReduced.rootChainCode).toString('hex');
if (userChainCode !== backupChainCode) {
throw new Error('EdDSA MPCv2 recovery: user and backup rootChainCodes do not match');
}

return {
userKeyShare: Buffer.from(userReduced.keyShare),
backupKeyShare: Buffer.from(backupReduced.keyShare),
commonKeyChain: userPub + userChainCode,
};
}
4 changes: 2 additions & 2 deletions modules/sdk-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { EcdsaUtils } from './bitgo/utils/tss/ecdsa/ecdsa';
export { EcdsaUtils };
import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2';
export { EcdsaMPCv2Utils };
import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
export { EddsaMPCv2Utils };
import { EddsaMPCv2Utils, getEddsaMpcV2RecoveryKeyShares } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
export { EddsaMPCv2Utils, getEddsaMpcV2RecoveryKeyShares };
export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification';
export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types';
export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types';
Expand Down
109 changes: 109 additions & 0 deletions modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import {
SignatureShareRecord,
SignatureShareType,
TxRequest,
getEddsaMpcV2RecoveryKeyShares,
} from '../../../../../../src';
import { encryptV2 } from '../../../../../../src/bitgo/utils/encrypt';
import {
getSignatureShareRoundOne,
getSignatureShareRoundTwo,
Expand Down Expand Up @@ -1527,6 +1529,113 @@ describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => {
});
});

describe('getEddsaMpcV2RecoveryKeyShares', () => {
const passphrase = 'test-passphrase';
let userReducedKeyShareBuf: Buffer;
let backupReducedKeyShareBuf: Buffer;
let userPub: Buffer;
let userChainCode: Buffer;

before('generate DKG key shares and build reduced key share buffers', async () => {
// All three parties in a single DKG session share the same pub and rootChainCode.
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
userReducedKeyShareBuf = userDkg.getReducedKeyShare();
backupReducedKeyShareBuf = backupDkg.getReducedKeyShare();

const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
userPub = Buffer.from(userDecoded.pub);
userChainCode = Buffer.from(userDecoded.rootChainCode);
});

it('v1 SJCL-encrypted reduced key, matching pair — returns correct key shares and commonKeyChain', async () => {
const encryptedUser = sjcl.encrypt(passphrase, userReducedKeyShareBuf.toString('base64'));
const encryptedBackup = sjcl.encrypt(passphrase, backupReducedKeyShareBuf.toString('base64'));

const result = await getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase);

const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
assert.deepStrictEqual(result.userKeyShare, Buffer.from(userDecoded.keyShare));
const backupDecoded = MPSTypes.getDecodedReducedKeyShare(backupReducedKeyShareBuf);
assert.deepStrictEqual(result.backupKeyShare, Buffer.from(backupDecoded.keyShare));
assert.strictEqual(result.commonKeyChain, userPub.toString('hex') + userChainCode.toString('hex'));
});

it('v2 Argon2id-encrypted reduced key, matching pair — returns correct key shares and commonKeyChain', async () => {
const encryptedUser = await encryptV2(passphrase, userReducedKeyShareBuf.toString('base64'));
const encryptedBackup = await encryptV2(passphrase, backupReducedKeyShareBuf.toString('base64'));

const result = await getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase);

const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
assert.deepStrictEqual(result.userKeyShare, Buffer.from(userDecoded.keyShare));
const backupDecoded = MPSTypes.getDecodedReducedKeyShare(backupReducedKeyShareBuf);
assert.deepStrictEqual(result.backupKeyShare, Buffer.from(backupDecoded.keyShare));
assert.strictEqual(result.commonKeyChain, userPub.toString('hex') + userChainCode.toString('hex'));
});

it('throws when keyShare field is empty', async () => {
const { encode } = await import('cbor-x');
// Empty keyShare passes the io-ts codec but fails our non-empty check.
const empty = encode({ keyShare: [], pub: Array.from(userPub), rootChainCode: Array.from(userChainCode) });
const encrypted = sjcl.encrypt(passphrase, Buffer.from(empty).toString('base64'));
await assert.rejects(
() => getEddsaMpcV2RecoveryKeyShares(encrypted, encrypted, passphrase),
/keyShare, pub, and rootChainCode/
);
});

it('throws when pub field is empty', async () => {
const { encode } = await import('cbor-x');
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
const empty = encode({ keyShare: userDecoded.keyShare, pub: [], rootChainCode: userDecoded.rootChainCode });
const encrypted = sjcl.encrypt(passphrase, Buffer.from(empty).toString('base64'));
await assert.rejects(
() => getEddsaMpcV2RecoveryKeyShares(encrypted, encrypted, passphrase),
/keyShare, pub, and rootChainCode/
);
});

it('throws when rootChainCode field is empty', async () => {
const { encode } = await import('cbor-x');
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
const empty = encode({ keyShare: userDecoded.keyShare, pub: userDecoded.pub, rootChainCode: [] });
const encrypted = sjcl.encrypt(passphrase, Buffer.from(empty).toString('base64'));
await assert.rejects(
() => getEddsaMpcV2RecoveryKeyShares(encrypted, encrypted, passphrase),
/keyShare, pub, and rootChainCode/
);
});

it('throws when user and backup pub keys do not match', async () => {
const [, differentDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
const differentReducedBuf = differentDkg.getReducedKeyShare();
const encryptedUser = sjcl.encrypt(passphrase, userReducedKeyShareBuf.toString('base64'));
const encryptedBackup = sjcl.encrypt(passphrase, differentReducedBuf.toString('base64'));
await assert.rejects(
() => getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase),
/pub keys do not match/
);
});

it('throws when user and backup rootChainCodes do not match', async () => {
const { encode } = await import('cbor-x');
const backupDecoded = MPSTypes.getDecodedReducedKeyShare(backupReducedKeyShareBuf);
// Use a different chain code (all-zero bytes) to force a mismatch.
const differentChainCode = new Array(32).fill(0);
const mismatchedChainCode = encode({
keyShare: backupDecoded.keyShare,
pub: Array.from(userPub),
rootChainCode: differentChainCode,
});
const encryptedUser = sjcl.encrypt(passphrase, userReducedKeyShareBuf.toString('base64'));
const encryptedBackup = sjcl.encrypt(passphrase, Buffer.from(mismatchedChainCode).toString('base64'));
await assert.rejects(
() => getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase),
/rootChainCodes do not match/
);
});
});

function bytesToWord(bytes?: Uint8Array | number[]): number {
if (!(bytes instanceof Uint8Array) || bytes.length !== 4) {
throw new Error('bytes must be a Uint8Array with length 4');
Expand Down
Loading