Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/strict-mobile-share-decrypt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fireblocks/extended-key-recovery': patch
---

Mobile key share decryption now uses strict PKCS#7 padding and validates the decrypted share, so a wrong passphrase reports a passphrase error instead of failing the metadata check; the passphrase is encoded as UTF-8 (with a legacy raw-char-code fallback) so non-ASCII passphrases work
2 changes: 1 addition & 1 deletion apps/recovery-utility/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"author": "Fireblocks (https://www.fireblocks.com)",
"repository": "https://github.com/fireblocks/recovery/tree/main/apps/recovery-utility",
"homepage": "https://www.fireblocks.com",
"version": "1.8.6",
"version": "1.8.7",
"license": "GPL-3.0-or-later",
"private": true,
"main": "app/background.js",
Expand Down
37 changes: 6 additions & 31 deletions packages/extended-key-recovery/src/decrypt.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,7 @@
import * as forge from 'node-forge';
import { pbkdf2Sync, createDecipheriv } from 'crypto';

const _unpad = (text: Buffer, k = 16): Buffer => {
const nl = text.length;
const val = text[nl - 1];
if (val > k) {
throw new Error('Input is not padded or padding is corrupt');
}
if (!text.subarray(nl - val, nl).every((x) => x === val)) {
throw new Error('Input is not padded or padding is corrupt');
}
const l_idx = nl - val;
return text.subarray(0, l_idx);
};

export const decryptMobilePrivateKeyV2 = (
pass: string,
pass: string | Buffer,
userId: string,
encryptedKey: Buffer,
iv: string,
Expand All @@ -28,20 +14,9 @@ export const decryptMobilePrivateKeyV2 = (
return Buffer.concat([decipher.update(encryptedKey), decipher.final()]);
};

export const decryptMobilePrivateKey = (pass: string, userId: string, encryptedKey: Buffer): Buffer => {
const wrappedKey = forge.pkcs5.pbkdf2(pass, userId, 10000, 32, 'sha1');
const decipher = forge.cipher.createDecipher(
'AES-CBC',
forge.util.createBuffer(new Uint8Array(Buffer.from(wrappedKey, 'binary'))),
);
decipher.start({ iv: new Array(16).fill(0) });
decipher.update(forge.util.createBuffer(new Uint8Array(encryptedKey)));
decipher.finish();

const decryptedBytes = Buffer.from(decipher.output.toHex(), 'hex');
if (decryptedBytes.length === 48) {
const unpaddedData = _unpad(decryptedBytes);
return unpaddedData;
}
return decryptedBytes;
// Strict PKCS#7: Node rejects any ciphertext whose padding bytes are inconsistent, as the mobile apps do.
export const decryptMobilePrivateKey = (pass: string | Buffer, userId: string, encryptedKey: Buffer): Buffer => {
const wrappedKey = pbkdf2Sync(pass, userId, 10000, 32, 'sha1');
const decipher = createDecipheriv('aes-256-cbc', wrappedKey, Buffer.alloc(16));
return Buffer.concat([decipher.update(encryptedKey), decipher.final()]);
};
75 changes: 51 additions & 24 deletions packages/extended-key-recovery/src/mobileKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,30 @@ const algorithmMapping: { [key: string]: number } = {
MPC_CMP_EDDSA_ED25519: 1,
};

// The apps encode the passphrase as UTF-8; earlier utility versions used raw char codes (Latin-1), so keep that as a fallback.
const passphraseEncodings = (pass: string): Buffer[] => {
const utf8 = Buffer.from(pass, 'utf8');
const legacy = Buffer.from(pass, 'latin1');
return utf8.equals(legacy) ? [utf8] : [utf8, legacy];
};

// Shape only: 32 B, or 36 B with a 4-byte algorithm prefix. Whether the algorithm matches the key is checked in recoverMobileKeyShare.
const isShareShaped = (share: Buffer): boolean => share.length === 32 || share.length === 36;

// Returns the share bytes from a JSON-wrapped or raw plaintext, or undefined if it is neither.
const extractShare = (plain: Buffer): Buffer | undefined => {
try {
const key = JSON.parse(plain.toString()).key;
if (typeof key === 'string' && /^([0-9a-fA-F]{2})+$/.test(key)) {
const share = Buffer.from(key, 'hex');
return isShareShaped(share) ? share : undefined;
}
} catch {
// not JSON
}
return isShareShaped(plain) ? plain : undefined;
};

export const recoverMobileKeyShare = (
signingKeys: { [key: string]: SigningKeyMetadata },
keyShareStr: string,
Expand All @@ -17,38 +41,41 @@ export const recoverMobileKeyShare = (
) => {
const keyShare = JSON.parse(keyShareStr) as MobileKeyShare;
const { keyId } = keyShare;
let decryptedKey: Buffer;
if (!Object.keys(signingKeys).includes(keyId)) {
throw new KeyIdNotInMetadata();
}

try {
const meta = keyShare.encryptionMetaData;
if (meta?.version === 2 && meta.iv && meta.kdfHash && meta.kdfIterations) {
onLog?.(`[mobileKey] Decrypting share with V2 (kdfHash=${meta.kdfHash}, kdfIterations=${meta.kdfIterations})`);
decryptedKey = decryptMobilePrivateKeyV2(
mobilePass,
keyShare.userId,
Buffer.from(keyShare.encryptedKey, 'hex'),
meta.iv,
meta.kdfHash,
meta.kdfIterations,
);
} else {
onLog?.(`[mobileKey] Decrypting share with V1 (metaVersion=${meta?.version ?? 'none'})`);
decryptedKey = decryptMobilePrivateKey(mobilePass, keyShare.userId, Buffer.from(keyShare.encryptedKey, 'hex'));
const meta = keyShare.encryptionMetaData;
const encryptedKey = Buffer.from(keyShare.encryptedKey, 'hex');
const v2 =
meta?.version === 2 && meta.iv && meta.kdfHash && meta.kdfIterations
? { iv: meta.iv, kdfHash: meta.kdfHash, kdfIterations: meta.kdfIterations }
: undefined;
onLog?.(
v2
? `[mobileKey] Decrypting share with V2 (kdfHash=${v2.kdfHash}, kdfIterations=${v2.kdfIterations})`
: `[mobileKey] Decrypting share with V1 (metaVersion=${meta?.version ?? 'none'})`,
);

// A wrong passphrase can survive PKCS#7 unpadding (~1/256 for a single 0x01 pad byte), so the plaintext must also be share-shaped.
// For the 48-byte V1 ciphertexts that means a specific multi-byte pad pattern, which a wrong key essentially never produces;
// for JSON shares the guard is the JSON parse plus the hex key check.
let decryptedKey: Buffer | undefined;
for (const pass of passphraseEncodings(mobilePass)) {
try {
const plain = v2
? decryptMobilePrivateKeyV2(pass, keyShare.userId, encryptedKey, v2.iv, v2.kdfHash, v2.kdfIterations)
: decryptMobilePrivateKey(pass, keyShare.userId, encryptedKey);
Comment thread
SlavaSereb marked this conversation as resolved.
Resolved
decryptedKey = extractShare(plain);
if (decryptedKey) break;
} catch {
// wrong key / bad padding: try the next encoding
}
} catch (e) {
}
if (!decryptedKey) {
throw new DecryptMobileKeyError();
}

// In case the decrypted data is actually a JSON, load and try got get the key
try {
const decryptedJSON = JSON.parse(decryptedKey.toString());
decryptedKey = Buffer.from(decryptedJSON.key, 'hex');
// eslint-disable-next-line no-empty
} catch {}

if (decryptedKey.length === 36) {
const algoId = decryptedKey.subarray(0, 4).readInt32LE();
if (algorithmMapping[signingKeys[keyId].algo.toString() as string] !== algoId) {
Expand Down
92 changes: 92 additions & 0 deletions packages/extended-key-recovery/tests/mobileKey.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createCipheriv, pbkdf2Sync, randomBytes } from 'crypto';
import { decryptMobilePrivateKey } from '../src/decrypt';
import { recoverMobileKeyShare } from '../src/mobileKey';
import { DecryptMobileKeyError, UnknownAlgorithmError } from '../src/types';

const KEY_ID = '00000000-0000-4000-8000-000000000001';
const USER_ID = '00000000-0000-4000-8000-000000000002';
const DEVICE_ID = '00000000-0000-4000-8000-000000000003';
const ALGO_CMP_ECDSA = 0;

const signingKeys: any = { [KEY_ID]: { algo: 'MPC_CMP_ECDSA_SECP256K1', keysetId: 1 } };

// Mirrors how the mobile apps wrap a V1 share: PBKDF2-HMAC-SHA1(10k) over the UTF-8 passphrase, zero IV, AES-256-CBC.
const encryptV1 = (plaintext: Buffer, pass: string): Buffer => {
const key = pbkdf2Sync(Buffer.from(pass, 'utf8'), USER_ID, 10000, 32, 'sha1');
const cipher = createCipheriv('aes-256-cbc', key, Buffer.alloc(16));
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
};

const buildShare = (plaintext: Buffer, pass: string): string =>
JSON.stringify({
keyId: KEY_ID,
userId: USER_ID,
deviceId: DEVICE_ID,
encryptedKey: encryptV1(plaintext, pass).toString('hex'),
});

const rawShare = () => randomBytes(32);
const prefixedShare = () => Buffer.concat([Buffer.from([ALGO_CMP_ECDSA, 0, 0, 0]), randomBytes(32)]);
const jsonShare = () => Buffer.from(JSON.stringify({ key: randomBytes(32).toString('hex'), pad: 'x'.repeat(200) }));

const wrongPassphrases = (n: number): string[] => Array.from({ length: n }, (_, i) => `wrong-passphrase-${i}-Aa1!`);

const tryRecover = (share: string, pass: string): 'ok' | 'rejected' => {
try {
recoverMobileKeyShare(signingKeys, share, pass);
return 'ok';
} catch (e) {
if (e instanceof DecryptMobileKeyError) return 'rejected';
throw e;
}
};

describe('V1 mobile share decryption', () => {
const PASS = 'Correct-Horse-1!';

it.each([
['raw 32 byte share', rawShare],
['36 byte algo-prefixed share', prefixedShare],
['JSON share', jsonShare],
])('recovers a %s with the right passphrase', (_name, make) => {
expect(tryRecover(buildShare(make(), PASS), PASS)).toBe('ok');
});

it.each([
['raw 32 byte share', rawShare],
['36 byte algo-prefixed share', prefixedShare],
['JSON share', jsonShare],
])('never returns key material for a wrong passphrase (%s)', (_name, make) => {
const share = buildShare(make(), PASS);
const accepted = wrongPassphrases(300).filter((p) => tryRecover(share, p) === 'ok');
expect(accepted).toEqual([]);
});

it('reports an unknown algorithm, not a passphrase error, when the passphrase is correct', () => {
const unknownAlgo = Buffer.concat([Buffer.from([2, 0, 0, 0]), randomBytes(32)]);
expect(() => recoverMobileKeyShare(signingKeys, buildShare(unknownAlgo, PASS), PASS)).toThrow(UnknownAlgorithmError);
});

it('rejects data whose padding bytes are inconsistent', () => {
const key = pbkdf2Sync(Buffer.from(PASS, 'utf8'), USER_ID, 10000, 32, 'sha1');
const cipher = createCipheriv('aes-256-cbc', key, Buffer.alloc(16));
cipher.setAutoPadding(false);
// 48 bytes ending in 0x05 but with non-matching preceding bytes
const bad = Buffer.concat([randomBytes(43), Buffer.from([1, 2, 3, 4, 5])]);
const enc = Buffer.concat([cipher.update(bad), cipher.final()]);
expect(() => decryptMobilePrivateKey(PASS, USER_ID, enc)).toThrow();
});

it.each(['é', 'Pässwörd1!', '£€’pass1A', 'пароль-Aa1'])('recovers a share protected by the non-ASCII passphrase %s', (pass) => {
expect(tryRecover(buildShare(rawShare(), pass), pass)).toBe('ok');
});

it('still recovers a share wrapped with the legacy raw-char-code encoding', () => {
const pass = 'Pässwörd1!';
const key = pbkdf2Sync(Buffer.from(pass, 'latin1'), USER_ID, 10000, 32, 'sha1');
const cipher = createCipheriv('aes-256-cbc', key, Buffer.alloc(16));
const enc = Buffer.concat([cipher.update(rawShare()), cipher.final()]).toString('hex');
const share = JSON.stringify({ keyId: KEY_ID, userId: USER_ID, deviceId: DEVICE_ID, encryptedKey: enc });
expect(tryRecover(share, pass)).toBe('ok');
});
});
Loading