Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion modules/key-card/src/generateQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion modules/key-card/src/parseKeycard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion modules/key-card/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SafeRootKeyType, string>;
Expand Down
47 changes: 46 additions & 1 deletion modules/key-card/test/unit/safeQrData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 });
Expand Down
57 changes: 33 additions & 24 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<MpcV2RecoveryKeyShares> {
if (await isGG18SigningMaterial(encryptedUserKey, walletPassphrase, bitgo)) {
return getMpcV2RecoveryKeySharesFromGG18(encryptedUserKey, encryptedBackupKey, walletPassphrase, bitgo);
}
Expand Down Expand Up @@ -1622,11 +1628,7 @@ async function getMpcV2RecoveryKeySharesFromGG18(
encryptedGG18BackupKey: string,
walletPassphrase: string | undefined,
bitgo: BitGoBase
): Promise<{
userKeyShare: Buffer;
backupKeyShare: Buffer;
commonKeyChain: string;
}> {
): Promise<MpcV2RecoveryKeyShares> {
const [userKeyCombined, backupKeyCombined] = await getKeyCombinedFromTssKeyShares(
encryptedGG18UserKey,
encryptedGG18BackupKey,
Expand Down Expand Up @@ -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<MpcV2RecoveryKeyShares> {
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'),
Expand All @@ -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,
};
}

/**
Expand Down
13 changes: 3 additions & 10 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/ecdsaVrfMPCv2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
});
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from './types';
export * from './typesMPCv2';
export * from './SMC/utils';
export * from './ecdsaMPCv2KeyGenSender';
export * from './keyShareEnvelope';
56 changes: 56 additions & 0 deletions modules/sdk-core/src/bitgo/utils/tss/ecdsa/keyShareEnvelope.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
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'),
};
}
Original file line number Diff line number Diff line change
@@ -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());
});
});
Loading