Skip to content

Commit 94170c3

Browse files
committed
feat(sdk-core): add getEddsaMpcV2RecoveryKeyShares helper
Decrypt and CBOR-decode user and backup reduced key shares in parallel, validate that pub and rootChainCode match between the two, then return usable Buffer key shares and the derived commonKeyChain. Also adds a standalone decryptAsync utility to sdk-core (encrypt.ts) that auto-detects v1 SJCL and v2 Argon2id envelopes, requiring @bitgo/argon2 as a new dependency. Ticket: WCI-396 Session-Id: 991c62f4-1a36-4e5b-8507-9e778d77b2e1 Task-Id: 9401c605-944c-4e5b-9659-2ed1470a7f21
1 parent 13c988f commit 94170c3

6 files changed

Lines changed: 306 additions & 2 deletions

File tree

modules/sdk-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
]
4141
},
4242
"dependencies": {
43+
"@bitgo/argon2": "^1.1.0",
4344
"@bitgo/public-types": "6.22.0",
4445
"@bitgo/sdk-lib-mpc": "^10.14.0",
4546
"@bitgo/secp256k1": "^1.11.0",
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { argon2id } from '@bitgo/argon2';
2+
import * as sjcl from '@bitgo/sjcl';
3+
import { randomBytes, webcrypto } from 'crypto';
4+
import * as t from 'io-ts';
5+
6+
import { base64String, boundedInt, decodeWithCodec } from './codecs';
7+
8+
const subtle = globalThis.crypto?.subtle ?? webcrypto.subtle;
9+
10+
const ARGON2_DEFAULTS = { memorySize: 65536, iterations: 3, parallelism: 4, hashLength: 32, saltLength: 16 } as const;
11+
const ARGON2_MAX = { memorySize: 262144, iterations: 16, parallelism: 16 } as const;
12+
const GCM_IV_LENGTH = 12;
13+
const HKDF_INFO = new TextEncoder().encode('bitgo-v2-session');
14+
15+
const V2EnvelopeCodec = t.intersection([
16+
t.type({
17+
v: t.literal(2),
18+
m: boundedInt(1, ARGON2_MAX.memorySize, 'memorySize'),
19+
t: boundedInt(1, ARGON2_MAX.iterations, 'iterations'),
20+
p: boundedInt(1, ARGON2_MAX.parallelism, 'parallelism'),
21+
salt: base64String,
22+
iv: base64String,
23+
ct: base64String,
24+
}),
25+
t.partial({ hkdfSalt: base64String, adata: t.string }),
26+
]);
27+
28+
type V2Envelope = t.TypeOf<typeof V2EnvelopeCodec>;
29+
30+
async function argon2ToAesKey(
31+
password: string,
32+
salt: Uint8Array,
33+
params: { memorySize: number; iterations: number; parallelism: number },
34+
usage: KeyUsage
35+
): Promise<CryptoKey> {
36+
const keyBytes = await argon2id({
37+
password,
38+
salt,
39+
...params,
40+
hashLength: ARGON2_DEFAULTS.hashLength,
41+
outputType: 'binary',
42+
});
43+
return subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, [usage]);
44+
}
45+
46+
async function argon2ToHkdfKey(
47+
password: string,
48+
salt: Uint8Array,
49+
params: { memorySize: number; iterations: number; parallelism: number }
50+
): Promise<CryptoKey> {
51+
const keyBytes = await argon2id({
52+
password,
53+
salt,
54+
...params,
55+
hashLength: ARGON2_DEFAULTS.hashLength,
56+
outputType: 'binary',
57+
});
58+
return subtle.importKey('raw', keyBytes, 'HKDF', false, ['deriveKey']);
59+
}
60+
61+
function hkdfDeriveAesKey(hkdfKey: CryptoKey, hkdfSalt: Uint8Array, usage: KeyUsage): Promise<CryptoKey> {
62+
return subtle.deriveKey(
63+
{ name: 'HKDF', hash: 'SHA-256', salt: hkdfSalt, info: HKDF_INFO },
64+
hkdfKey,
65+
{ name: 'AES-GCM', length: 256 },
66+
false,
67+
[usage]
68+
);
69+
}
70+
71+
async function aesGcmEncrypt(
72+
key: CryptoKey,
73+
iv: Uint8Array,
74+
plaintext: string,
75+
adata?: Uint8Array
76+
): Promise<Uint8Array> {
77+
const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 };
78+
if (adata) params.additionalData = adata;
79+
const ct = await subtle.encrypt(params, key, new TextEncoder().encode(plaintext));
80+
return new Uint8Array(ct);
81+
}
82+
83+
async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array, ct: Uint8Array, adata?: Uint8Array): Promise<string> {
84+
const params: AesGcmParams = { name: 'AES-GCM', iv, tagLength: 128 };
85+
if (adata) params.additionalData = adata;
86+
const plaintext = await subtle.decrypt(params, key, ct);
87+
return new TextDecoder().decode(plaintext);
88+
}
89+
90+
/** Encrypt plaintext using Argon2id KDF + AES-256-GCM. */
91+
export async function encryptV2(
92+
password: string,
93+
plaintext: string,
94+
options?: { salt?: Uint8Array; iv?: Uint8Array; memorySize?: number; iterations?: number; parallelism?: number }
95+
): Promise<string> {
96+
const memorySize = options?.memorySize ?? ARGON2_DEFAULTS.memorySize;
97+
const iterations = options?.iterations ?? ARGON2_DEFAULTS.iterations;
98+
const parallelism = options?.parallelism ?? ARGON2_DEFAULTS.parallelism;
99+
const salt = options?.salt ?? new Uint8Array(randomBytes(ARGON2_DEFAULTS.saltLength));
100+
const iv = options?.iv ?? new Uint8Array(randomBytes(GCM_IV_LENGTH));
101+
102+
const key = await argon2ToAesKey(password, salt, { memorySize, iterations, parallelism }, 'encrypt');
103+
const ct = await aesGcmEncrypt(key, iv, plaintext);
104+
105+
const envelope: V2Envelope = {
106+
v: 2,
107+
m: memorySize,
108+
t: iterations,
109+
p: parallelism,
110+
salt: Buffer.from(salt).toString('base64'),
111+
iv: Buffer.from(iv).toString('base64'),
112+
ct: Buffer.from(ct).toString('base64'),
113+
};
114+
return JSON.stringify(envelope);
115+
}
116+
117+
async function decryptV2(password: string, ciphertext: string): Promise<string> {
118+
const envelope = decodeWithCodec(V2EnvelopeCodec, JSON.parse(ciphertext), 'v2 decrypt: invalid envelope');
119+
const salt = new Uint8Array(Buffer.from(envelope.salt, 'base64'));
120+
const iv = new Uint8Array(Buffer.from(envelope.iv, 'base64'));
121+
const ct = new Uint8Array(Buffer.from(envelope.ct, 'base64'));
122+
const params = { memorySize: envelope.m, iterations: envelope.t, parallelism: envelope.p };
123+
const adataBytes = envelope.adata ? new TextEncoder().encode(envelope.adata) : undefined;
124+
125+
if (envelope.hkdfSalt) {
126+
const hkdfKey = await argon2ToHkdfKey(password, salt, params);
127+
const hkdfSalt = new Uint8Array(Buffer.from(envelope.hkdfSalt, 'base64'));
128+
const aesKey = await hkdfDeriveAesKey(hkdfKey, hkdfSalt, 'decrypt');
129+
return aesGcmDecrypt(aesKey, iv, ct, adataBytes);
130+
}
131+
132+
const key = await argon2ToAesKey(password, salt, params, 'decrypt');
133+
return aesGcmDecrypt(key, iv, ct, adataBytes);
134+
}
135+
136+
/**
137+
* Auto-detect v1 (SJCL) or v2 (Argon2id + AES-256-GCM) from the envelope `v` field and decrypt.
138+
*/
139+
export async function decryptAsync(password: string, ciphertext: string): Promise<string> {
140+
let envelopeVersion: number | undefined;
141+
try {
142+
const envelope = JSON.parse(ciphertext);
143+
envelopeVersion = envelope.v;
144+
} catch {
145+
throw new Error('decrypt: ciphertext is not valid JSON');
146+
}
147+
if (envelopeVersion === 2) {
148+
return decryptV2(password, ciphertext);
149+
}
150+
if (envelopeVersion !== undefined && envelopeVersion !== 1) {
151+
throw new Error(`decrypt: unknown envelope version ${envelopeVersion}`);
152+
}
153+
return sjcl.decrypt(password, ciphertext);
154+
}

modules/sdk-core/src/bitgo/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as openpgpUtils from './opengpgUtils';
22

33
export * from './abstractUtxoCoinUtil';
4+
export * from './encrypt';
45
export * from './mpcUtils';
56
export * from './opengpgUtils';
67
export * from './promise-utils';

modules/sdk-core/src/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from '../baseTypes';
4343
import { BaseEddsaUtils } from './base';
4444
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
45+
import { decryptAsync } from '../../encrypt';
4546

4647
export class EddsaMPCv2Utils extends BaseEddsaUtils {
4748
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
@@ -899,3 +900,41 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
899900
}
900901
// #endregion
901902
}
903+
904+
export async function getEddsaMpcV2RecoveryKeyShares(
905+
encryptedUserKey: string,
906+
encryptedBackupKey: string,
907+
walletPassphrase?: string
908+
): Promise<{ userKeyShare: Buffer; backupKeyShare: Buffer; commonKeyChain: string }> {
909+
const decodeKey = async (encryptedKey: string): Promise<MPSTypes.EddsaReducedKeyShare> => {
910+
const decrypted = await decryptAsync(walletPassphrase ?? '', encryptedKey);
911+
const reduced = MPSTypes.getDecodedReducedKeyShare(Buffer.from(decrypted, 'base64'));
912+
if (!reduced.keyShare?.length || !reduced.pub?.length || !reduced.rootChainCode?.length) {
913+
throw new Error(
914+
'EdDSA MPCv2 recovery requires keycard material with keyShare, pub, and rootChainCode. ' +
915+
'This keycard may be public-only and cannot be used for recovery.'
916+
);
917+
}
918+
return reduced;
919+
};
920+
921+
const [userReduced, backupReduced] = await Promise.all([decodeKey(encryptedUserKey), decodeKey(encryptedBackupKey)]);
922+
923+
const userPub = Buffer.from(userReduced.pub).toString('hex');
924+
const backupPub = Buffer.from(backupReduced.pub).toString('hex');
925+
if (userPub !== backupPub) {
926+
throw new Error('EdDSA MPCv2 recovery: user and backup pub keys do not match');
927+
}
928+
929+
const userChainCode = Buffer.from(userReduced.rootChainCode).toString('hex');
930+
const backupChainCode = Buffer.from(backupReduced.rootChainCode).toString('hex');
931+
if (userChainCode !== backupChainCode) {
932+
throw new Error('EdDSA MPCv2 recovery: user and backup rootChainCodes do not match');
933+
}
934+
935+
return {
936+
userKeyShare: Buffer.from(userReduced.keyShare),
937+
backupKeyShare: Buffer.from(backupReduced.keyShare),
938+
commonKeyChain: userPub + userChainCode,
939+
};
940+
}

modules/sdk-core/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import { EcdsaUtils } from './bitgo/utils/tss/ecdsa/ecdsa';
1010
export { EcdsaUtils };
1111
import { EcdsaMPCv2Utils } from './bitgo/utils/tss/ecdsa/ecdsaMPCv2';
1212
export { EcdsaMPCv2Utils };
13-
import { EddsaMPCv2Utils } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
14-
export { EddsaMPCv2Utils };
13+
import { EddsaMPCv2Utils, getEddsaMpcV2RecoveryKeyShares } from './bitgo/utils/tss/eddsa/eddsaMPCv2';
14+
export { EddsaMPCv2Utils, getEddsaMpcV2RecoveryKeyShares };
1515
export { verifyEddsaTssWalletAddress, verifyMPCWalletAddress } from './bitgo/utils/tss/addressVerification';
1616
export { GShare, SignShare, YShare } from './account-lib/mpc/tss/eddsa/types';
1717
export { TssEcdsaStep1ReturnMessage, TssEcdsaStep2ReturnMessage } from './bitgo/tss/types';

modules/sdk-core/test/unit/bitgo/utils/tss/eddsa/eddsaMPCv2.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ import {
2424
SignatureShareRecord,
2525
SignatureShareType,
2626
TxRequest,
27+
getEddsaMpcV2RecoveryKeyShares,
2728
} from '../../../../../../src';
29+
import { encryptV2 } from '../../../../../../src/bitgo/utils/encrypt';
2830
import {
2931
getSignatureShareRoundOne,
3032
getSignatureShareRoundTwo,
@@ -1527,6 +1529,113 @@ describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => {
15271529
});
15281530
});
15291531

1532+
describe('getEddsaMpcV2RecoveryKeyShares', () => {
1533+
const passphrase = 'test-passphrase';
1534+
let userReducedKeyShareBuf: Buffer;
1535+
let backupReducedKeyShareBuf: Buffer;
1536+
let userPub: Buffer;
1537+
let userChainCode: Buffer;
1538+
1539+
before('generate DKG key shares and build reduced key share buffers', async () => {
1540+
// All three parties in a single DKG session share the same pub and rootChainCode.
1541+
const [userDkg, backupDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
1542+
userReducedKeyShareBuf = userDkg.getReducedKeyShare();
1543+
backupReducedKeyShareBuf = backupDkg.getReducedKeyShare();
1544+
1545+
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
1546+
userPub = Buffer.from(userDecoded.pub);
1547+
userChainCode = Buffer.from(userDecoded.rootChainCode);
1548+
});
1549+
1550+
it('v1 SJCL-encrypted reduced key, matching pair — returns correct key shares and commonKeyChain', async () => {
1551+
const encryptedUser = sjcl.encrypt(passphrase, userReducedKeyShareBuf.toString('base64'));
1552+
const encryptedBackup = sjcl.encrypt(passphrase, backupReducedKeyShareBuf.toString('base64'));
1553+
1554+
const result = await getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase);
1555+
1556+
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
1557+
assert.deepStrictEqual(result.userKeyShare, Buffer.from(userDecoded.keyShare));
1558+
const backupDecoded = MPSTypes.getDecodedReducedKeyShare(backupReducedKeyShareBuf);
1559+
assert.deepStrictEqual(result.backupKeyShare, Buffer.from(backupDecoded.keyShare));
1560+
assert.strictEqual(result.commonKeyChain, userPub.toString('hex') + userChainCode.toString('hex'));
1561+
});
1562+
1563+
it('v2 Argon2id-encrypted reduced key, matching pair — returns correct key shares and commonKeyChain', async () => {
1564+
const encryptedUser = await encryptV2(passphrase, userReducedKeyShareBuf.toString('base64'));
1565+
const encryptedBackup = await encryptV2(passphrase, backupReducedKeyShareBuf.toString('base64'));
1566+
1567+
const result = await getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase);
1568+
1569+
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
1570+
assert.deepStrictEqual(result.userKeyShare, Buffer.from(userDecoded.keyShare));
1571+
const backupDecoded = MPSTypes.getDecodedReducedKeyShare(backupReducedKeyShareBuf);
1572+
assert.deepStrictEqual(result.backupKeyShare, Buffer.from(backupDecoded.keyShare));
1573+
assert.strictEqual(result.commonKeyChain, userPub.toString('hex') + userChainCode.toString('hex'));
1574+
});
1575+
1576+
it('throws when keyShare field is empty', async () => {
1577+
const { encode } = await import('cbor-x');
1578+
// Empty keyShare passes the io-ts codec but fails our non-empty check.
1579+
const empty = encode({ keyShare: [], pub: Array.from(userPub), rootChainCode: Array.from(userChainCode) });
1580+
const encrypted = sjcl.encrypt(passphrase, Buffer.from(empty).toString('base64'));
1581+
await assert.rejects(
1582+
() => getEddsaMpcV2RecoveryKeyShares(encrypted, encrypted, passphrase),
1583+
/keyShare, pub, and rootChainCode/
1584+
);
1585+
});
1586+
1587+
it('throws when pub field is empty', async () => {
1588+
const { encode } = await import('cbor-x');
1589+
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
1590+
const empty = encode({ keyShare: userDecoded.keyShare, pub: [], rootChainCode: userDecoded.rootChainCode });
1591+
const encrypted = sjcl.encrypt(passphrase, Buffer.from(empty).toString('base64'));
1592+
await assert.rejects(
1593+
() => getEddsaMpcV2RecoveryKeyShares(encrypted, encrypted, passphrase),
1594+
/keyShare, pub, and rootChainCode/
1595+
);
1596+
});
1597+
1598+
it('throws when rootChainCode field is empty', async () => {
1599+
const { encode } = await import('cbor-x');
1600+
const userDecoded = MPSTypes.getDecodedReducedKeyShare(userReducedKeyShareBuf);
1601+
const empty = encode({ keyShare: userDecoded.keyShare, pub: userDecoded.pub, rootChainCode: [] });
1602+
const encrypted = sjcl.encrypt(passphrase, Buffer.from(empty).toString('base64'));
1603+
await assert.rejects(
1604+
() => getEddsaMpcV2RecoveryKeyShares(encrypted, encrypted, passphrase),
1605+
/keyShare, pub, and rootChainCode/
1606+
);
1607+
});
1608+
1609+
it('throws when user and backup pub keys do not match', async () => {
1610+
const [, differentDkg] = await MPSUtil.generateEdDsaDKGKeyShares();
1611+
const differentReducedBuf = differentDkg.getReducedKeyShare();
1612+
const encryptedUser = sjcl.encrypt(passphrase, userReducedKeyShareBuf.toString('base64'));
1613+
const encryptedBackup = sjcl.encrypt(passphrase, differentReducedBuf.toString('base64'));
1614+
await assert.rejects(
1615+
() => getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase),
1616+
/pub keys do not match/
1617+
);
1618+
});
1619+
1620+
it('throws when user and backup rootChainCodes do not match', async () => {
1621+
const { encode } = await import('cbor-x');
1622+
const backupDecoded = MPSTypes.getDecodedReducedKeyShare(backupReducedKeyShareBuf);
1623+
// Use a different chain code (all-zero bytes) to force a mismatch.
1624+
const differentChainCode = new Array(32).fill(0);
1625+
const mismatchedChainCode = encode({
1626+
keyShare: backupDecoded.keyShare,
1627+
pub: Array.from(userPub),
1628+
rootChainCode: differentChainCode,
1629+
});
1630+
const encryptedUser = sjcl.encrypt(passphrase, userReducedKeyShareBuf.toString('base64'));
1631+
const encryptedBackup = sjcl.encrypt(passphrase, Buffer.from(mismatchedChainCode).toString('base64'));
1632+
await assert.rejects(
1633+
() => getEddsaMpcV2RecoveryKeyShares(encryptedUser, encryptedBackup, passphrase),
1634+
/rootChainCodes do not match/
1635+
);
1636+
});
1637+
});
1638+
15301639
function bytesToWord(bytes?: Uint8Array | number[]): number {
15311640
if (!(bytes instanceof Uint8Array) || bytes.length !== 4) {
15321641
throw new Error('bytes must be a Uint8Array with length 4');

0 commit comments

Comments
 (0)