diff --git a/modules/sdk-core/package.json b/modules/sdk-core/package.json index d0d4994808..988a3763f2 100644 --- a/modules/sdk-core/package.json +++ b/modules/sdk-core/package.json @@ -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", diff --git a/modules/sdk-core/src/bitgo/utils/encrypt.ts b/modules/sdk-core/src/bitgo/utils/encrypt.ts new file mode 100644 index 0000000000..ebe30270b2 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/encrypt.ts @@ -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; + +async function argon2ToAesKey( + password: string, + salt: Uint8Array, + params: { memorySize: number; iterations: number; parallelism: number }, + usage: KeyUsage +): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/modules/sdk-core/src/bitgo/utils/index.ts b/modules/sdk-core/src/bitgo/utils/index.ts index 25cd271de5..ba66fd9462 100644 --- a/modules/sdk-core/src/bitgo/utils/index.ts +++ b/modules/sdk-core/src/bitgo/utils/index.ts @@ -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'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 3754f353a2..365d15184b 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -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'; @@ -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 => { + 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, + }; +} diff --git a/modules/sdk-core/src/index.ts b/modules/sdk-core/src/index.ts index ac3aeda639..9714529c17 100644 --- a/modules/sdk-core/src/index.ts +++ b/modules/sdk-core/src/index.ts @@ -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'; diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts index 52ac0c69e7..8f2632b28d 100644 --- a/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts @@ -24,7 +24,9 @@ import { SignatureShareRecord, SignatureShareType, TxRequest, + getEddsaMpcV2RecoveryKeyShares, } from '../../../../../../src'; +import { encryptV2 } from '../../../../../../src/bitgo/utils/encrypt'; import { getSignatureShareRoundOne, getSignatureShareRoundTwo, @@ -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');