From bb3d7d6490c28ef8726c28f4c6967c601b09aad5 Mon Sep 17 00:00:00 2001 From: Sibi Krishnan Date: Tue, 8 Sep 2026 21:01:28 +0000 Subject: [PATCH] fix(key-card): carry VRF material through safe recovery Parse the versioned safe MPC envelope alongside legacy reduced shares, and return both signing and VRF material to recovery callers. Keep safe keycard boxes on the existing layout while documenting and testing the VRF-bearing payload and its QR fragmentation. This prevents safe recovery from treating the versioned envelope as a bare ReducedKeyShare, which would discard the VRF material required for child re-derivation. Ticket: WCN-2325 Session-Id: b5978d12-c20a-4c72-b9d0-b66a56204cd0 Task-Id: 7f111b0b-938f-4512-9dab-397624fe5f74 --- modules/key-card/src/generateQrData.ts | 3 +- modules/key-card/src/parseKeycard.ts | 3 +- modules/key-card/src/types.ts | 3 +- modules/key-card/test/unit/safeQrData.ts | 47 ++++++++++++++- .../src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts | 57 ++++++++++-------- .../bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts | 13 +--- .../src/bitgo/utils/tss/ecdsa/index.ts | 1 + .../bitgo/utils/tss/ecdsa/keyShareEnvelope.ts | 56 ++++++++++++++++++ .../bitgo/utils/tss/ecdsa/keyShareEnvelope.ts | 59 +++++++++++++++++++ 9 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 modules/sdk-core/src/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts create mode 100644 modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts diff --git a/modules/key-card/src/generateQrData.ts b/modules/key-card/src/generateQrData.ts index 4258126357..1174e70788 100644 --- a/modules/key-card/src/generateQrData.ts +++ b/modules/key-card/src/generateQrData.ts @@ -224,7 +224,8 @@ export async function generateLightningQrData(params: GenerateLightningQrDataPar } function selectRootPrivateKey(keychain: Keychain, slot: SafeRootKeyType, role: 'user' | 'backup'): string { - // Prefer the compact MPCv2 reduced share; fall back to encryptedPrv (e.g. multisig roots). + // Prefer the compact MPCv2 reduced envelope; safe MPC envelopes carry the serialized + // VRF keyshare alongside the reduced signing share. Fall back to encryptedPrv (e.g. multisig roots). const data = keychain.reducedEncryptedPrv ?? keychain.encryptedPrv; assert.ok(data, `Safe ${role} root ${slot} is missing encrypted private key material`); return data; diff --git a/modules/key-card/src/parseKeycard.ts b/modules/key-card/src/parseKeycard.ts index 0b90792e40..498dc337c4 100644 --- a/modules/key-card/src/parseKeycard.ts +++ b/modules/key-card/src/parseKeycard.ts @@ -34,7 +34,8 @@ const SafeKeycardBoxFromString = JsonFromString.pipe(SafeKeycardRootsCodec); * `{"secp256k1Multisig":"…","ecdsaMpc":"…",…}` — into its four roots. Throws if the value is * not valid JSON or any root is missing/non-string. Recovery tooling calls this on the A/B/C * box value returned by {@link parseKeycardFromLines}, then decrypts each root value with the - * safe password. + * safe password. An MPC root value is an opaque versioned envelope; recovery must unwrap its + * `prvKeyShare` and `vrf` fields instead of treating the decrypted bytes as a bare share. */ export function parseSafeKeycardBox(data: string): SafeKeycardRoots { const decoded = SafeKeycardBoxFromString.decode(data); diff --git a/modules/key-card/src/types.ts b/modules/key-card/src/types.ts index 385d3f6e40..43fa652f0e 100644 --- a/modules/key-card/src/types.ts +++ b/modules/key-card/src/types.ts @@ -71,7 +71,8 @@ export const SAFE_ROOT_ORDER: SafeRootKeyType[] = ['secp256k1Multisig', 'ecdsaMp /** * The JSON object encoded in a safe keycard box (A/B/C): the four roots keyed by * {@link SafeRootKeyType}. Values are per-root ciphertext for A/B (encryptedPrv or - * reducedEncryptedPrv) or public keys for C. The root-key-type keys are self-identifying, so a + * reducedEncryptedPrv; safe MPC ciphertext decrypts to a versioned signing+VRF envelope) or + * public keys for C. The root-key-type keys are self-identifying, so a * consumer parses by key rather than by size/offset. */ export type SafeKeycardRoots = Record; diff --git a/modules/key-card/test/unit/safeQrData.ts b/modules/key-card/test/unit/safeQrData.ts index def736d15e..a8f2e0ed04 100644 --- a/modules/key-card/test/unit/safeQrData.ts +++ b/modules/key-card/test/unit/safeQrData.ts @@ -2,7 +2,7 @@ import 'should'; import * as assert from 'assert'; import { decrypt, encrypt } from '@bitgo/sdk-api'; import { coins } from '@bitgo/statics'; -import { Keychain, KeychainsTriplet, KeyType } from '@bitgo/sdk-core'; +import { ECDSAUtils, Keychain, KeychainsTriplet, KeyType } from '@bitgo/sdk-core'; import { generateSafeQrData } from '../../src/generateQrData'; import { splitKeys } from '../../src/utils'; import { QRBinaryMaxLength } from '../../src/drawKeycard'; @@ -119,6 +119,51 @@ describe('generateSafeQrData', function () { } }); + it('preserves the versioned VRF envelope in the MPC root card blob', async function () { + const { roots } = await buildRoots(); + const { reducedEnvelope } = ECDSAUtils.buildVrfKeyEnvelopes( + Buffer.from('full-signing-share'), + Buffer.from('reduced-signing-share'), + Buffer.alloc(32, 7) + ); + roots.ecdsaMpc.userKeychain.reducedEncryptedPrv = await encrypt(passphrase, reducedEnvelope.toString('base64')); + + const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots }); + const userBox = parseSafeKeycardBox(qrData.user.data); + const decoded = ECDSAUtils.parseMpcV2KeyShareEnvelope(await decrypt(passphrase, userBox.ecdsaMpc)); + + decoded.signingKeyShare.toString().should.equal('reduced-signing-share'); + assert.ok(decoded.vrfKeyShare); + decoded.vrfKeyShare.length.should.equal(32); + }); + + it('keeps a realistic versioned DKLS+VRF box within EC-L QR fragments', async function () { + const { roots } = await buildRoots(); + // Measured ranges from the DKLS DKG tests: the reduced signing share is about + // 606 bytes and a serialized VRF keyshare is 600–700 bytes before encryption. + const { reducedEnvelope } = ECDSAUtils.buildVrfKeyEnvelopes( + Buffer.alloc(1200, 1), + Buffer.alloc(606, 2), + Buffer.alloc(650, 3) + ); + roots.ecdsaMpc.userKeychain.reducedEncryptedPrv = await encrypt( + passphrase, + reducedEnvelope.toString('base64') + ); + + const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots }); + const fragments = splitKeys(qrData.user.data, QRBinaryMaxLength); + assert.ok(fragments.length > 1, 'realistic safe root data must use multiple QR fragments'); + fragments.every((fragment) => fragment.length <= QRBinaryMaxLength).should.equal(true); + + const parsed = ECDSAUtils.parseMpcV2KeyShareEnvelope( + await decrypt(passphrase, parseSafeKeycardBox(reassemble(qrData.user.data)).ecdsaMpc) + ); + parsed.signingKeyShare.length.should.equal(606); + assert.ok(parsed.vrfKeyShare); + parsed.vrfKeyShare.length.should.equal(650); + }); + it('uses reducedEncryptedPrv for MPC roots and encryptedPrv for multisig roots', async function () { const { roots } = await buildRoots(); const qrData = await generateSafeQrData({ coin: coins.get('btc'), roots }); diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts index 787f0cf143..6ee7947ee2 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts @@ -60,6 +60,7 @@ import { InvalidTransactionError } from '../../../errors'; import { BitGoBase } from '../../../bitgoBase'; import { resolveEffectiveTxParams } from '../recipientUtils'; import type { EcdsaMPCv2KeyGenCallbacks } from '../../../wallet/iWallets'; +import { parseMpcV2KeyShareEnvelope } from './keyShareEnvelope'; export class EcdsaMPCv2Utils extends BaseEcdsaUtils { private static readonly DKLS23_SIGNING_USER_GPG_KEY = 'DKLS23_SIGNING_USER_GPG_KEY'; @@ -1549,16 +1550,21 @@ export async function isGG18SigningMaterial( * @param bitgo BitGo instance for v1/v2 auto-detect decrypt * @returns MPC v2 recovery key shares */ +export interface MpcV2RecoveryKeyShares { + userKeyShare: Buffer; + backupKeyShare: Buffer; + commonKeyChain: string; + /** Serialized VRF keyshares from safe-root envelopes, when present. */ + userVrfKeyShare?: Buffer; + backupVrfKeyShare?: Buffer; +} + export async function getMpcV2RecoveryKeyShares( encryptedUserKey: string, encryptedBackupKey: string, walletPassphrase: string | undefined, bitgo: BitGoBase -): Promise<{ - userKeyShare: Buffer; - backupKeyShare: Buffer; - commonKeyChain: string; -}> { +): Promise { if (await isGG18SigningMaterial(encryptedUserKey, walletPassphrase, bitgo)) { return getMpcV2RecoveryKeySharesFromGG18(encryptedUserKey, encryptedBackupKey, walletPassphrase, bitgo); } @@ -1622,11 +1628,7 @@ async function getMpcV2RecoveryKeySharesFromGG18( encryptedGG18BackupKey: string, walletPassphrase: string | undefined, bitgo: BitGoBase -): Promise<{ - userKeyShare: Buffer; - backupKeyShare: Buffer; - commonKeyChain: string; -}> { +): Promise { const [userKeyCombined, backupKeyCombined] = await getKeyCombinedFromTssKeyShares( encryptedGG18UserKey, encryptedGG18BackupKey, @@ -1665,22 +1667,18 @@ async function getMpcV2RecoveryKeySharesFromReducedKey( encryptedMPCv2BackupKey: string, walletPassphrase: string | undefined, bitgo: BitGoBase -): Promise<{ - userKeyShare: Buffer; - backupKeyShare: Buffer; - commonKeyChain: string; -}> { - const userCompressedPrv = Buffer.from( - await bitgo.decrypt({ password: walletPassphrase, input: encryptedMPCv2UserKey }), - 'base64' +): Promise { + const userMaterial = parseMpcV2KeyShareEnvelope( + await bitgo.decrypt({ password: walletPassphrase, input: encryptedMPCv2UserKey }) ); - const bakcupCompressedPrv = Buffer.from( - await bitgo.decrypt({ password: walletPassphrase, input: encryptedMPCv2BackupKey }), - 'base64' + const backupMaterial = parseMpcV2KeyShareEnvelope( + await bitgo.decrypt({ password: walletPassphrase, input: encryptedMPCv2BackupKey }) ); - const userPrvJSON: DklsTypes.ReducedKeyShare = DklsTypes.getDecodedReducedKeyShare(userCompressedPrv); - const backupPrvJSON: DklsTypes.ReducedKeyShare = DklsTypes.getDecodedReducedKeyShare(bakcupCompressedPrv); + const userPrvJSON: DklsTypes.ReducedKeyShare = DklsTypes.getDecodedReducedKeyShare(userMaterial.signingKeyShare); + const backupPrvJSON: DklsTypes.ReducedKeyShare = DklsTypes.getDecodedReducedKeyShare( + backupMaterial.signingKeyShare + ); const userKeyRetrofit: DklsTypes.RetrofitData = { xShare: { x: Buffer.from(userPrvJSON.prv).toString('hex'), @@ -1701,7 +1699,18 @@ async function getMpcV2RecoveryKeySharesFromReducedKey( const userKeyShare = user.getKeyShare(); const backupKeyShare = backup.getKeyShare(); const commonKeyChain = DklsTypes.getCommonKeychain(userKeyShare); - return { userKeyShare, backupKeyShare, commonKeyChain }; + const hasUserVrf = userMaterial.vrfKeyShare !== undefined; + const hasBackupVrf = backupMaterial.vrfKeyShare !== undefined; + if (hasUserVrf !== hasBackupVrf) { + throw new Error('MPC keyshare envelopes must either both contain VRF keyshares or both omit them'); + } + return { + userKeyShare, + backupKeyShare, + commonKeyChain, + userVrfKeyShare: userMaterial.vrfKeyShare, + backupVrfKeyShare: backupMaterial.vrfKeyShare, + }; } /** diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts index 69f814ff06..a1b76d72b1 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts @@ -13,14 +13,7 @@ import { envRequiresBitgoPubGpgKeyConfig, isBitgoMpcPubKey } from '../../../tss/ import { EcdsaMPCv2Utils } from './ecdsaMPCv2'; import { KeyGenSenderForEnterprise } from './ecdsaMPCv2KeyGenSender'; import { MPCv2PartiesEnum, MpcV2VrfKeyGenResponseFields } from './typesMPCv2'; - -/** - * Version field of the `encryptedPrv` envelope used when a ceremony produces both a - * signing keyshare and a VRF keyshare. The plaintext handed to encrypt() is - * `base64(cborEncode(envelope))`, keeping it a single opaque base64 token exactly as - * the ordinary MPCv2 format does. - */ -const VRF_KEY_ENVELOPE_VERSION = 1; +import { MPC_VRF_KEY_ENVELOPE_VERSION } from './keyShareEnvelope'; /** * Wire format for VRF DKG messages riding the MPCv2-R1/R2 payloads: an opaque blob, @@ -71,12 +64,12 @@ export function buildVrfKeyEnvelopes( vrfKeyShare: Buffer ): { envelope: Buffer; reducedEnvelope: Buffer } { const envelope = encode({ - version: VRF_KEY_ENVELOPE_VERSION, + version: MPC_VRF_KEY_ENVELOPE_VERSION, prvKeyShare: new Uint8Array(privateMaterial), vrf: new Uint8Array(vrfKeyShare), }); const reducedEnvelope = encode({ - version: VRF_KEY_ENVELOPE_VERSION, + version: MPC_VRF_KEY_ENVELOPE_VERSION, prvKeyShare: new Uint8Array(reducedPrivateMaterial), vrf: new Uint8Array(vrfKeyShare), }); diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts index bf08dc843a..f1e8097590 100644 --- a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts @@ -5,3 +5,4 @@ export * from './types'; export * from './typesMPCv2'; export * from './SMC/utils'; export * from './ecdsaMPCv2KeyGenSender'; +export * from './keyShareEnvelope'; diff --git a/modules/sdk-core/src/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts new file mode 100644 index 0000000000..31e03a8a62 --- /dev/null +++ b/modules/sdk-core/src/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts @@ -0,0 +1,56 @@ +import { decode } from 'cbor-x'; +import { Buffer } from 'buffer'; + +/** Version of the safe MPC keyshare envelope that carries VRF material. */ +export const MPC_VRF_KEY_ENVELOPE_VERSION = 1; + +export interface ParsedMpcV2KeyShare { + /** Serialized DKLS signing keyshare or reduced signing keyshare. */ + signingKeyShare: Buffer; + /** Serialized VRF keyshare, present in safe-root envelopes. */ + vrfKeyShare?: Buffer; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function asBuffer(value: unknown, field: string): Buffer { + if (!(value instanceof Uint8Array)) { + throw new Error(`Invalid MPC keyshare envelope: ${field} must be a byte string`); + } + return Buffer.from(value); +} + +/** + * Parses decrypted MPCv2 key material. + * + * Legacy MPCv2 cards contain base64(CBOR ReducedKeyShare). Safe-root cards contain + * base64(CBOR({ version: 1, prvKeyShare, vrf })); the signing share remains reduced, + * while `vrf` is the complete serialized VrfKeyshare required by the VRF wasm API. + * + * The legacy path is intentionally retained because existing wallet cards do not have + * VRF material and must continue to recover as before. + */ +export function parseMpcV2KeyShareEnvelope(decryptedKeyShare: string): ParsedMpcV2KeyShare { + const encoded = Buffer.from(decryptedKeyShare, 'base64'); + let decoded: unknown; + try { + decoded = decode(encoded); + } catch { + return { signingKeyShare: encoded }; + } + + if (!isRecord(decoded) || !('version' in decoded)) { + return { signingKeyShare: encoded }; + } + + if (decoded.version !== MPC_VRF_KEY_ENVELOPE_VERSION) { + throw new Error(`Unsupported MPC keyshare envelope version: ${String(decoded.version)}`); + } + + return { + signingKeyShare: asBuffer(decoded.prvKeyShare, 'prvKeyShare'), + vrfKeyShare: asBuffer(decoded.vrf, 'vrf'), + }; +} diff --git a/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts new file mode 100644 index 0000000000..25e6f29c9c --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts @@ -0,0 +1,59 @@ +import * as assert from 'assert'; +import { encode } from 'cbor-x'; +import * as sinon from 'sinon'; +import { DklsUtils, DklsVrfUtils } from '@bitgo/sdk-lib-mpc'; +import { BitGoBase, ECDSAUtils } from '../../../../../../src'; + +function encodeEnvelope(signingKeyShare: Buffer, vrfKeyShare: Buffer): string { + return Buffer.from( + encode({ + version: ECDSAUtils.MPC_VRF_KEY_ENVELOPE_VERSION, + prvKeyShare: new Uint8Array(signingKeyShare), + vrf: new Uint8Array(vrfKeyShare), + }) + ).toString('base64'); +} + +describe('MPCv2 keyshare envelopes', function () { + this.timeout(30000); + + it('parses a versioned envelope and preserves legacy reduced shares', function () { + const reducedKeyShare = Buffer.from([1, 2, 3]); + const vrfKeyShare = Buffer.from([4, 5, 6]); + const versioned = ECDSAUtils.parseMpcV2KeyShareEnvelope(encodeEnvelope(reducedKeyShare, vrfKeyShare)); + assert.deepStrictEqual(versioned.signingKeyShare, reducedKeyShare); + assert.deepStrictEqual(versioned.vrfKeyShare, vrfKeyShare); + + const legacy = ECDSAUtils.parseMpcV2KeyShareEnvelope(reducedKeyShare.toString('base64')); + assert.deepStrictEqual(legacy.signingKeyShare, reducedKeyShare); + assert.strictEqual(legacy.vrfKeyShare, undefined); + }); + + it('returns VRF keyshares when recovery parses safe-root reduced envelopes', async function () { + const [userDkg, backupDkg] = await DklsUtils.generateDKGKeyShares(); + const [userVrf, backupVrf] = await DklsVrfUtils.generateVrfDKGKeyShares(); + const userEnvelope = encodeEnvelope(userDkg.getReducedKeyShare(), userVrf.getKeyShare()); + const backupEnvelope = encodeEnvelope(backupDkg.getReducedKeyShare(), backupVrf.getKeyShare()); + + // The first decrypt is the GG18/MPCv1 probe; the following two are the actual + // reduced key reads. This mirrors BitGoBase.decrypt without requiring a network. + const decrypt = sinon.stub(); + decrypt.onCall(0).resolves(userEnvelope); + decrypt.onCall(1).resolves(userEnvelope); + decrypt.onCall(2).resolves(backupEnvelope); + const bitgo = { decrypt } as unknown as BitGoBase; + + const recovered = await ECDSAUtils.getMpcV2RecoveryKeyShares( + 'encrypted-user-key', + 'encrypted-backup-key', + 'test-passphrase', + bitgo + ); + + assert.ok(recovered.userKeyShare.length > 0); + assert.ok(recovered.backupKeyShare.length > 0); + assert.ok(recovered.commonKeyChain); + assert.deepStrictEqual(recovered.userVrfKeyShare, userVrf.getKeyShare()); + assert.deepStrictEqual(recovered.backupVrfKeyShare, backupVrf.getKeyShare()); + }); +});