Skip to content

Commit 4f1c19a

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: 5a65f0b2-1638-4b8c-b090-10d1a78ca491 Task-Id: f0c8184f-d80d-4652-af77-119a855e5029
1 parent eaba1c2 commit 4f1c19a

2 files changed

Lines changed: 90 additions & 0 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ export class EddsaMPCv2Utils extends BaseEddsaUtils {
4747
private static readonly MPS_DSG_SIGNING_USER_GPG_KEY = 'MPS_DSG_SIGNING_USER_GPG_KEY';
4848
private static readonly MPS_DSG_SIGNING_ROUND1_STATE = 'MPS_DSG_SIGNING_ROUND1_STATE';
4949

50+
async isEddsaMpcV1SigningMaterial(encryptedKeyShare: string, walletPassphrase: string): Promise<boolean> {
51+
try {
52+
const prv = await this.bitgo.decryptAsync({ input: encryptedKeyShare, password: walletPassphrase });
53+
const signingMaterial = JSON.parse(prv);
54+
return (
55+
typeof signingMaterial?.uShare?.seed === 'string' &&
56+
typeof signingMaterial?.bitgoYShare?.u === 'string' &&
57+
(typeof signingMaterial?.backupYShare?.u === 'string' || typeof signingMaterial?.userYShare?.u === 'string')
58+
);
59+
} catch {
60+
return false;
61+
}
62+
}
63+
5064
/** @inheritdoc */
5165
async createKeychains(params: {
5266
passphrase: string;

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,3 +765,79 @@ describe('EddsaMPCv2Utils.signEddsaMPCv2TssUsingExternalSigner', () => {
765765
);
766766
});
767767
});
768+
769+
describe('EddsaMPCv2Utils.isEddsaMpcV1SigningMaterial', () => {
770+
const PASSPHRASE = 'test-passphrase';
771+
772+
const MPCv1_MATERIAL_BACKUP = {
773+
uShare: { i: 1, t: 2, n: 3, y: 'aabbcc', seed: 'deadbeef01234567', chaincode: '00' },
774+
bitgoYShare: { i: 3, j: 1, y: 'aabbcc', u: 'bitgo-u-value', chaincode: '00' },
775+
backupYShare: { i: 2, j: 1, y: 'aabbcc', u: 'backup-u-value', chaincode: '00' },
776+
};
777+
778+
const MPCv1_MATERIAL_USER = {
779+
uShare: { i: 2, t: 2, n: 3, y: 'aabbcc', seed: 'deadbeef01234567', chaincode: '00' },
780+
bitgoYShare: { i: 3, j: 2, y: 'aabbcc', u: 'bitgo-u-value', chaincode: '00' },
781+
userYShare: { i: 1, j: 2, y: 'aabbcc', u: 'user-u-value', chaincode: '00' },
782+
};
783+
784+
const MPCv2_CBOR_BYTES = Buffer.from([0xd9, 0x01, 0x04, 0xa3, 0x61, 0x78, 0x18, 0x00]).toString('base64');
785+
786+
let eddsaUtils: EddsaMPCv2Utils;
787+
let mockBitgo: BitGoBase;
788+
789+
function encryptSjcl(plaintext: string, password: string): string {
790+
const salt = randomBytes(8);
791+
const iv = randomBytes(16);
792+
return sjcl.encrypt(password, plaintext, {
793+
salt: [bytesToWord(salt.subarray(0, 4)), bytesToWord(salt.subarray(4, 8))],
794+
iv: [
795+
bytesToWord(iv.subarray(0, 4)),
796+
bytesToWord(iv.subarray(4, 8)),
797+
bytesToWord(iv.subarray(8, 12)),
798+
bytesToWord(iv.subarray(12, 16)),
799+
],
800+
});
801+
}
802+
803+
beforeEach(() => {
804+
mockBitgo = {
805+
decryptAsync: sinon
806+
.stub()
807+
.callsFake(async (params: { input: string; password: string }) => sjcl.decrypt(params.password, params.input)),
808+
} as unknown as BitGoBase;
809+
810+
eddsaUtils = new EddsaMPCv2Utils(mockBitgo, {} as unknown as IBaseCoin);
811+
});
812+
813+
it('returns true for MPCv1 SJCL-encrypted keycard with backupYShare + correct passphrase', async () => {
814+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_BACKUP), PASSPHRASE);
815+
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true);
816+
});
817+
818+
it('returns true for MPCv1 SJCL-encrypted keycard with userYShare + correct passphrase', async () => {
819+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_USER), PASSPHRASE);
820+
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), true);
821+
});
822+
823+
it('returns false for MPCv2 CBOR content wrapped in SJCL envelope + correct passphrase', async () => {
824+
const encrypted = encryptSjcl(MPCv2_CBOR_BYTES, PASSPHRASE);
825+
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false);
826+
});
827+
828+
it('returns false for MPCv2 Argon2id envelope (v2) + correct passphrase (forward-compat)', async () => {
829+
const fakeV2Envelope = JSON.stringify({ v: 2, m: 65536, t: 3, p: 4, salt: 'AAAA', iv: 'AAAA', ct: 'AAAA' });
830+
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(fakeV2Envelope, PASSPHRASE), false);
831+
});
832+
833+
it('returns false for wrong passphrase — does not throw', async () => {
834+
const encrypted = encryptSjcl(JSON.stringify(MPCv1_MATERIAL_BACKUP), PASSPHRASE);
835+
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, 'wrong-passphrase'), false);
836+
});
837+
838+
it('returns false when neither backupYShare.u nor userYShare.u is present', async () => {
839+
const partial = { uShare: { seed: 'abc' }, bitgoYShare: { u: 'xyz' } };
840+
const encrypted = encryptSjcl(JSON.stringify(partial), PASSPHRASE);
841+
assert.strictEqual(await eddsaUtils.isEddsaMpcV1SigningMaterial(encrypted, PASSPHRASE), false);
842+
});
843+
});

0 commit comments

Comments
 (0)