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()); + }); +});