From b46f55ef6a0d2f8137ae32f1fad4ab32e5488cb5 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Fri, 10 May 2024 11:26:52 +0200 Subject: [PATCH] restore recovery key from human-readable form --- frontend/src/common/universalVaultFormat.ts | 32 +++++++++++++---- .../test/common/universalVaultFormat.spec.ts | 35 +++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/frontend/src/common/universalVaultFormat.ts b/frontend/src/common/universalVaultFormat.ts index e674347e..81852010 100644 --- a/frontend/src/common/universalVaultFormat.ts +++ b/frontend/src/common/universalVaultFormat.ts @@ -98,12 +98,12 @@ export class RecoveryKey { } /** - * Loads the public key of the recovery key pair. + * Imports the public key of the recovery key pair. * @param publicKey the DER-encoded public key * @param publicKey the PKCS8-encoded private key * @returns recovery key for encrypting vault metadata */ - public static async load(publicKey: CryptoKey | Uint8Array, privateKey?: CryptoKey | Uint8Array): Promise { + public static async import(publicKey: CryptoKey | Uint8Array, privateKey?: CryptoKey | Uint8Array): Promise { if (publicKey instanceof Uint8Array) { publicKey = await crypto.subtle.importKey('spki', publicKey, RecoveryKey.KEY_DESIGNATION, true, []); } @@ -118,8 +118,28 @@ export class RecoveryKey { * @param recoveryKey the encoded recovery key * @returns complete recovery key for decrypting vault metadata */ - public static recover(recoveryKey: string) { - // TODO + public static async recover(recoveryKey: string): Promise { + // decode and check recovery key: + const decoded = wordEncoder.decode(recoveryKey); + const paddingLength = decoded[decoded.length - 1]; + if (paddingLength > 0x03) { + throw new Error('Invalid padding'); + } + const unpadded = decoded.subarray(0, -paddingLength); + const checksum = unpadded.subarray(-2); + const rawkey = unpadded.subarray(0, -2); + const crc32 = CRC32.compute(rawkey); + if (checksum[0] !== (crc32 & 0xFF) + || checksum[1] !== (crc32 >> 8 & 0xFF)) { + throw new Error('Invalid recovery key checksum.'); + } + + // construct new RecoveryKey from recovered key + const privateKey = await crypto.subtle.importKey('pkcs8', rawkey, RecoveryKey.KEY_DESIGNATION, true, RecoveryKey.KEY_USAGES); + const jwk = await crypto.subtle.exportKey('jwk', privateKey); + delete jwk.d; // remove private part + const publicKey = await crypto.subtle.importKey('jwk', jwk, RecoveryKey.KEY_DESIGNATION, true, []); + return new RecoveryKey(publicKey, privateKey); } /** @@ -292,9 +312,9 @@ export class UniversalVaultFormat implements AccessTokenProducing, VaultTemplate const metadata = await VaultMetadata.decryptWithMemberKey(vault.uvfMetadataFile, memberKey); let recoveryKey: RecoveryKey; if (payload.recoveryKey) { - recoveryKey = await RecoveryKey.load(base64.parse(vault.uvfRecoveryPublicKey), base64.parse(payload.recoveryKey)); + recoveryKey = await RecoveryKey.import(base64.parse(vault.uvfRecoveryPublicKey), base64.parse(payload.recoveryKey)); } else { - recoveryKey = await RecoveryKey.load(base64.parse(vault.uvfRecoveryPublicKey)); + recoveryKey = await RecoveryKey.import(base64.parse(vault.uvfRecoveryPublicKey)); } return new UniversalVaultFormat(metadata, memberKey, recoveryKey); } diff --git a/frontend/test/common/universalVaultFormat.spec.ts b/frontend/test/common/universalVaultFormat.spec.ts index 33584a06..a5d80b8e 100644 --- a/frontend/test/common/universalVaultFormat.spec.ts +++ b/frontend/test/common/universalVaultFormat.spec.ts @@ -109,6 +109,41 @@ describe('UVF', () => { expect(recoveryKey).to.be.not.null; }); + it('recover() succeeds for valid recovery key', async () => { + const serialized = `cult hold all away buck do law relaxed other stimulus all bank fit indulge dad any ear grey cult golf + all baby dig war linear tour sleep humanity threat question neglect stance radar bank coup misery painter tragedy buddy + compare winter national approval budget deep screen outdoor audience tear stream cure type ugly chamber supporter franchise + accept sexy ad imply being drug doctor regime where thick dam training grass chamber domestic dictator educate sigh music spoken + connected measure voice lemon pig comprise disturb appear greatly satisfied heat news curiosity top impress nor method reflect + lesson recommend dual revenge thorough bus count broadband living riot prejudice target blonde excess company thereby tribe + respond horror mere way proud shopping wise liver mortgage plastic gentleman eighteen terms worry melt`; + + const recoveryKey = await RecoveryKey.recover(serialized); + + return Promise.all([ + expect(recoveryKey.serializePublicKey()).to.eventually.eq('MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESzrRXmyI8VWFJg1dPUNbFcc9jZvjZEfH7ulKI1UkXAltd7RGWrcfFxqyGPcwu6AQhHUag3OvDzEr0uUQND4PXHQTXP5IDGdYhJhL+WLKjnGjQAw0rNGy5V29+aV+yseW'), + expect(recoveryKey.serializePrivateKey()).to.eventually.eq('MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDCi4K1Ts3DgTz/ufkLX7EGMHjGpJv+WJmFgyzLwwaDFSfLpDw0Kgf3FKK+LAsV8r+hZANiAARLOtFebIjxVYUmDV09Q1sVxz2Nm+NkR8fu6UojVSRcCW13tEZatx8XGrIY9zC7oBCEdRqDc68PMSvS5RA0Pg9cdBNc/kgMZ1iEmEv5YsqOcaNADDSs0bLlXb35pX7Kx5Y=') + ]); + }); + + it('recover() fails for invalid recovery key', async () => { + const notInDict = RecoveryKey.recover('hallo bonjour'); + const invalidPadding = RecoveryKey.recover('cult hold all away buck do law relaxed other stimulus'); + const invalidCrc = RecoveryKey.recover(`wrong hold all away buck do law relaxed other stimulus all bank fit indulge dad any ear grey cult golf + all baby dig war linear tour sleep humanity threat question neglect stance radar bank coup misery painter tragedy buddy + compare winter national approval budget deep screen outdoor audience tear stream cure type ugly chamber supporter franchise + accept sexy ad imply being drug doctor regime where thick dam training grass chamber domestic dictator educate sigh music spoken + connected measure voice lemon pig comprise disturb appear greatly satisfied heat news curiosity top impress nor method reflect + lesson recommend dual revenge thorough bus count broadband living riot prejudice target blonde excess company thereby tribe + respond horror mere way proud shopping wise liver mortgage plastic gentleman eighteen terms worry melt`); + + return Promise.all([ + expect(notInDict).to.be.rejectedWith(Error, /Word not in dictionary/), + expect(invalidPadding).to.be.rejectedWith(Error, /Invalid padding/), + expect(invalidCrc).to.be.rejectedWith(Error, /Invalid recovery key checksum/), + ]); + }); + describe('instance methods', () => { let recoveryKey: RecoveryKey;