Skip to content

Commit dfd8e7e

Browse files
committed
feat(sdk-core): add isEddsaMpcV1SigningMaterial format detector
Export `isEddsaMpcV1SigningMaterial` from eddsaMPCv2.ts. The function decrypts an SJCL-encrypted keycard and checks for the structural shape of MPCv1 SigningMaterial (UShare.seed + at least one YShare.u). Returns false on any error so callers can safely branch to the MPCv2 path. Ticket: WCI-395 Session-Id: 4e39e4ea-4b1c-4f26-820d-5fa89c3edaf0 Task-Id: 1bc00d11-03ef-40b9-a964-9365fd15b62a
1 parent eaba1c2 commit dfd8e7e

2 files changed

Lines changed: 104 additions & 0 deletions

File tree

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import assert from 'assert';
2+
import * as sjcl from '@bitgo/sjcl';
23
import * as pgp from 'openpgp';
34
import { NonEmptyString } from 'io-ts-types';
45
import {
@@ -43,6 +44,34 @@ import {
4344
import { BaseEddsaUtils } from './base';
4445
import { EddsaMPCv2KeyGenSendFn, KeyGenSenderForEnterprise } from './eddsaMPCv2KeyGenSender';
4546

47+
/**
48+
* Detects whether an encrypted keycard contains MPCv1 EdDSA signing material.
49+
*
50+
* Returns true iff the decrypted JSON has the structural shape of a v1
51+
* SigningMaterial (UShare with a seed field + at least one YShare with a u
52+
* field). Returns false on any error — wrong passphrase, CBOR content that
53+
* fails JSON.parse, or unexpected format — so callers can safely branch to
54+
* the MPCv2 path without extra guards.
55+
*/
56+
export async function isEddsaMpcV1SigningMaterial(
57+
encryptedKeyShare: string,
58+
walletPassphrase?: string
59+
): Promise<boolean> {
60+
try {
61+
// v2 (Argon2id) envelopes are not decryptable in sdk-core (no argon2
62+
// dependency), so they throw here and fall through to the catch → false.
63+
const prv = sjcl.decrypt(walletPassphrase ?? '', encryptedKeyShare);
64+
const signingMaterial = JSON.parse(prv);
65+
return (
66+
typeof signingMaterial?.uShare?.seed === 'string' &&
67+
typeof signingMaterial?.bitgoYShare?.u === 'string' &&
68+
(typeof signingMaterial?.backupYShare?.u === 'string' || typeof signingMaterial?.userYShare?.u === 'string')
69+
);
70+
} catch {
71+
return false;
72+
}
73+
}
74+
4675
export class EddsaMPCv2Utils extends BaseEddsaUtils {
4776
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
4877
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import * as assert from 'assert';
2+
import { randomBytes } from 'crypto';
3+
import * as sjcl from '@bitgo/sjcl';
4+
import { isEddsaMpcV1SigningMaterial } from '../../../../../../src/bitgo/utils/tss/eddsa/eddsaMPCv2';
5+
6+
const PASSPHRASE = 'test-passphrase';
7+
8+
/** Minimal valid MPCv1 signing material (backupYShare variant). */
9+
const MPCv1_MATERIAL_BACKUP = {
10+
uShare: { i: 1, t: 2, n: 3, y: 'aabbcc', seed: 'deadbeef01234567', chaincode: '00' },
11+
bitgoYShare: { i: 3, j: 1, y: 'aabbcc', u: 'bitgo-u-value', chaincode: '00' },
12+
backupYShare: { i: 2, j: 1, y: 'aabbcc', u: 'backup-u-value', chaincode: '00' },
13+
};
14+
15+
/** Minimal valid MPCv1 signing material (userYShare variant). */
16+
const MPCv1_MATERIAL_USER = {
17+
uShare: { i: 2, t: 2, n: 3, y: 'aabbcc', seed: 'deadbeef01234567', chaincode: '00' },
18+
bitgoYShare: { i: 3, j: 2, y: 'aabbcc', u: 'bitgo-u-value', chaincode: '00' },
19+
userYShare: { i: 1, j: 2, y: 'aabbcc', u: 'user-u-value', chaincode: '00' },
20+
};
21+
22+
/** CBOR-like binary content that would come from an MPCv2 keycard. */
23+
const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64');
24+
25+
function bytesToWord(bytes: Buffer): number {
26+
return bytes.reduce((num, byte) => num * 0x100 + byte, 0);
27+
}
28+
29+
function encryptSjcl(plaintext: string, password: string): string {
30+
const salt = randomBytes(8);
31+
const iv = randomBytes(16);
32+
return sjcl.encrypt(password, plaintext, {
33+
salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4))],
34+
iv: [
35+
bytesToWord(iv.subarray(0, 4)),
36+
bytesToWord(iv.subarray(4, 8)),
37+
bytesToWord(iv.subarray(8, 12)),
38+
bytesToWord(iv.subarray(12, 16)),
39+
],
40+
});
41+
}
42+
43+
describe('isEddsaMpcV1SigningMaterial', () => {
44+
it('returns true for MPCv1 SJCL-encrypted keycard with backupYShare + correct passphrase', async () => {
45+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_BACKUP), PASSPHRASE);
46+
assert.strictEqual(await isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true);
47+
});
48+
49+
it('returns true for MPCv1 SJCL-encrypted keycard with userYShare + correct passphrase', async () => {
50+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_USER), PASSPHRASE);
51+
assert.strictEqual(await isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true);
52+
});
53+
54+
it('returns false for MPCv2 CBOR content wrapped in SJCL envelope + correct passphrase', async () => {
55+
// CBOR binary content fails JSON.parse → structural checks never reached → false
56+
const encrypted = encryptSjcl(MPCv2_CBOR_BYTES, PASSPHRASE);
57+
assert.strictEqual(await isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false);
58+
});
59+
60+
it('returns false for MPCv2 Argon2id envelope (v2) + correct passphrase (forward-compat)', async () => {
61+
// A v2 envelope is not a valid SJCL ciphertext — sjcl.decrypt throws → catch → false
62+
const fakeV2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' });
63+
assert.strictEqual(await isEddsaMpcV1SigningMaterial(fakeV2Envelope, PASSPHRASE), false);
64+
});
65+
66+
it('returns false for wrong passphrase — does not throw', async () => {
67+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_BACKUP), PASSPHRASE);
68+
assert.strictEqual(await isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase'), false);
69+
});
70+
71+
it('returns false for undefined passphrase — does not throw', async () => {
72+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_BACKUP), PASSPHRASE);
73+
assert.strictEqual(await isEddsaMpcV1SigningMaterial(encrypted, undefined), false);
74+
});
75+
});

0 commit comments

Comments
 (0)