From ca3b0b713f642bf2699d645ae04eb865da1789c0 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 15:08:39 +0200 Subject: [PATCH 01/34] feat: add `Vault` class --- src/Vault.ts | 455 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 src/Vault.ts diff --git a/src/Vault.ts b/src/Vault.ts new file mode 100644 index 00000000..ff2aa469 --- /dev/null +++ b/src/Vault.ts @@ -0,0 +1,455 @@ +/* eslint-disable no-restricted-globals */ +import { Json } from '@metamask/utils'; +import { v4 as uuidv4 } from 'uuid'; + +// ---------------------------------------------------------------------------- +// Types + +type EncryptedData = { + nonce: Uint8Array; + data: Uint8Array; +}; + +type VaultEntry = { + id: string; + lastUpdatedAt: Date; + lastAccessedAt: Date; + createdAt: Date; + value: EncryptedData; +}; + +// ---------------------------------------------------------------------------- +// Private functions + +/** + * Convert a string to bytes. + * + * @param text - Text to convert. + * @returns Bytes representing the text. + */ +function stringToBytes(text: string): Uint8Array { + const encoder = new TextEncoder(); + return encoder.encode(text.normalize('NFC')); +} + +/** + * Convert a JSON object to bytes. + * + * @param data - Object to convert. + * @returns Bytes representing the JSON object. + */ +function jsonToBytes(data: Json): Uint8Array { + return stringToBytes(JSON.stringify(data)); +} + +/** + * Generate cryptographically secure random bytes. + * + * @param length - Number of bytes to generate. + * @returns Cryptographically secure random bytes. + */ +function randomBytes(length: number): Uint8Array { + const array = new Uint8Array(length); + return crypto.getRandomValues(array); +} + +/** + * Import a password as a raw key. + * + * @param password - Password to import. + * @returns Password as a raw key. + */ +async function importPassword(password: string): Promise { + return crypto.subtle.importKey( + 'raw', + stringToBytes(password), + 'PBKDF2', + false, + ['deriveKey'], + ); +} + +/** + * Derive a wrapping key from a password and salt. + * + * The derived wrapping key actually have the encrypt and decrypt key usages + * due to a limitation in Web Crypto API: the API doesn't allow KDF keys to be + * generated, wrapped, nor unwrapped. So, as a workaround, we generate random + * bits and import them as the master key. + * + * @param password - Password to derive the wrapping key from. + * @param salt - Salt to be used in the key derivation. + * @returns A wrapping key derived from the password and salt. + */ +async function deriveWrappingKey( + password: string, + salt: Uint8Array, +): Promise { + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt, + iterations: 600_000, + }, + await importPassword(password), + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'], + ); +} + +/** + * Generate and wrap a random Master Key. + * + * @param wrappingKey - Wrapping key handler. + * @param additionalData - Additional data. + * @returns The wrapped Master Key and its handler. + */ +async function generateMasterKey( + wrappingKey: CryptoKey, + additionalData: Uint8Array, +): Promise<{ + wrapped: EncryptedData; + handler: CryptoKey; +}> { + const rawKey = randomBytes(32); + const wrappedKey = encryptData(wrappingKey, rawKey, additionalData); + + return { + wrapped: await wrappedKey, + handler: await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, [ + 'deriveKey', + ]), + }; +} + +/** + * Unwrap and import the Master Key. + * + * @param unwrappingKey - Unwrapping key handler. + * @param wrappedKey - Wrapped key data. + * @param additionalData - Additional data. + * @returns Handler to the Master Key. + */ +async function unwrapMasterKey( + unwrappingKey: CryptoKey, + wrappedKey: EncryptedData, + additionalData?: Uint8Array, +): Promise { + const rawKey = await decryptData(unwrappingKey, wrappedKey, additionalData); + return crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']); +} + +/** + * Encrypt data with additional data. + * + * @param key - Encryption key handler. + * @param data - Data to encrypt. + * @param additionalData - Associated data. + * @returns An object containing the nonce and the encrypted data. + */ +async function encryptData( + key: CryptoKey, + data: Uint8Array, + additionalData?: Uint8Array, +): Promise { + const iv = randomBytes(12); + const ct = crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData, + tagLength: 128, + }, + key, + data, + ); + + return { nonce: iv, data: new Uint8Array(await ct) }; +} + +/** + * Decrypt data with additional data. + * + * @param key - Decryption key handler. + * @param ciphertext - Ciphertext object with nonce and data. + * @param additionalData - Additional data. + * @returns The decrypted data. + */ +async function decryptData( + key: CryptoKey, + ciphertext: EncryptedData, + additionalData?: Uint8Array, +): Promise { + return new Uint8Array( + await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: ciphertext.nonce, + additionalData, + tagLength: 128, + }, + key, + ciphertext.data, + ), + ); +} + +// ---------------------------------------------------------------------------- +// Public types + +export class Vault { + readonly id: string; + + #entries: Map; + + #passwordSalt: Uint8Array; + + #wrappedMasterKey: EncryptedData | null; + + #cachedMasterKey: CryptoKey | null; + + constructor() { + this.id = uuidv4(); + this.#entries = new Map(); + this.#cachedMasterKey = null; + this.#wrappedMasterKey = null; + this.#passwordSalt = randomBytes(32); + } + + /** + * Initialize the vault after its creation. + * + * This method MUST to be called after the vault creation, otherwise the + * master key will not be generated. + * + * @param password - Vault's password. + */ + async init(password: string): Promise { + const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); + const additionalData = jsonToBytes(['vaultId', this.id]); + + ({ wrapped: this.#wrappedMasterKey, handler: this.#cachedMasterKey } = + await generateMasterKey(wrappingKey, additionalData)); + } + + /** + * Check if the vault is unlocked. + * + * @returns True if the vault is unlocked, false otherwise. + */ + get isLocked(): boolean { + return this.#cachedMasterKey === null; + } + + #assertUnlocked() { + if (this.isLocked) { + throw new Error('Vault is locked'); + } + } + + /** + * Store a new value in the vault. + * + * @param key - The key to store the value under. + * @param value - The value to store. + */ + async store(key: string, value: Json): Promise { + this.#assertUnlocked(); + + const now = new Date(); + const encryptionKey = await this.#deriveMasterKey(['test']); + const additionalData = jsonToBytes(['vaultId', this.id]); + + this.#entries.set(key, { + id: uuidv4(), + value: await encryptData( + encryptionKey, + jsonToBytes(value), + additionalData, + ), + createdAt: now, + lastAccessedAt: now, + lastUpdatedAt: now, + }); + } + + /** + * Update an existing value in the vault. + * + * @param key - The key to update. + * @param value - The new value. + */ + async update(key: string, value: Json): Promise { + const current = this.#entries.get(key); + if (current === undefined) { + throw new Error('Key does not exist'); + } + + this.#entries.set(key, { + ...current, + value, // FIXME: encrypt value + lastUpdatedAt: new Date(), + }); + } + + /** + * Get the value associated with a key. + * + * @param key - The key to get the value of. + * @returns The value associated with the key. + */ + async get(key: string): Promise { + const entry = this.#entries.get(key); + if (entry === undefined) { + throw new Error('Key does not exist'); + } + + return entry.value; // FIXME: decrypt value + } + + /** + * Delete a vault entry. + * + * @param key - The key to delete. + */ + async delete(key: string): Promise { + if (!this.#entries.has(key)) { + throw new Error('Key does not exist'); + } + + this.#entries.delete(key); + } + + /** + * Lock the vault. + */ + lock(): void { + this.#cachedMasterKey = null; + } + + /** + * Unlock the vault. + * + * @param password - Password to unlock the vault. + */ + async unlock(password: string): Promise { + if (this.#wrappedMasterKey === null) { + throw new Error('Vault is not initialized'); + } + + const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); + this.#cachedMasterKey = await unwrapMasterKey( + wrappingKey, + this.#wrappedMasterKey, + jsonToBytes(['vaultId', this.id]), + ); + } + + /** + * Derive the Master Key given a list of infos. + * + * @param infos - Derivation infos. + * @returns The handler to the derived key. + */ + async #deriveMasterKey(infos: string[]): Promise { + // Make sure that at least one info is provided. + if (infos.length === 0) { + throw new Error('No infos provided'); + } + + // TypeScript isn't happy if we use `isLocked` here, it will say that + // `#masterKey` can be null when we try to await on it. + if (this.#cachedMasterKey === null) { + throw new Error('Vault is locked'); + } + + let derivedKey = this.#cachedMasterKey; + for (const [i, info] of infos.entries()) { + let usages: KeyUsage[]; + let params: HmacKeyGenParams | AesKeyGenParams; + + // Only the last node in the derivation chain can be used to encrypt or + // decrypt data, all intermediate nodes can only be used to derive keys. + if (i === infos.length - 1) { + usages = ['encrypt', 'decrypt']; + params = { + name: 'AES-GCM', + length: 256, + }; + } else { + usages = ['deriveKey']; + params = { + name: 'HMAC', + hash: 'SHA-256', + length: 256, + }; + } + + // Derive the next key from the previous one. + derivedKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + info: Buffer.from(`metamask:vault:${i}:${info}`), + }, + derivedKey, + params, + false, + usages, + ); + } + + return derivedKey; + } + + /** + * Derive the Wrapping Key given a password. + * + * @param password - The password to derive the wrapping key from. + * @returns The handler to the Wrapping Key. + */ + async #getWrappingKey(password: string): Promise { + const rawKey = await crypto.subtle.importKey( + 'raw', + stringToBytes(password), + 'PBKDF2', + false, + ['deriveKey'], + ); + + const wrappingKey = await crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: this.#passwordSalt, + iterations: 600_000, + }, + rawKey, + { + name: 'AES-GCM', + length: 256, + }, + true, + ['deriveKey'], + ); + + return wrappingKey; + } +} + +export const exportedForTesting = { + stringToBytes, + jsonToBytes, + randomBytes, + generateMasterKey, + importPassword, + deriveWrappingKey, + unwrapMasterKey, + encryptData, + decryptData, +}; From 31759e8c0df3611850eb9392f1004b1523271578 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 15:12:54 +0200 Subject: [PATCH 02/34] chore: add `uuid` dependency --- package.json | 4 +++- yarn.lock | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e4955626..133f51b3 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "@metamask/eth-sig-util": "5.0.2", "@metamask/eth-simple-keyring": "^5.0.0", "@metamask/utils": "^5.0.0", - "obs-store": "^4.0.3" + "@types/uuid": "^9.0.1", + "obs-store": "^4.0.3", + "uuid": "^9.0.0" }, "devDependencies": { "@lavamoat/allow-scripts": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index adfff89e..dac757d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1424,6 +1424,7 @@ __metadata: "@metamask/utils": ^5.0.0 "@types/jest": ^29.4.0 "@types/sinon": ^10.0.13 + "@types/uuid": ^9.0.1 "@typescript-eslint/eslint-plugin": ^5.55.0 "@typescript-eslint/parser": ^5.55.0 depcheck: ^1.4.3 @@ -1444,6 +1445,7 @@ __metadata: ts-node: ^10.7.0 typedoc: ^0.23.15 typescript: ~5.0.2 + uuid: ^9.0.0 languageName: unknown linkType: soft @@ -1925,6 +1927,13 @@ __metadata: languageName: node linkType: hard +"@types/uuid@npm:^9.0.1": + version: 9.0.1 + resolution: "@types/uuid@npm:9.0.1" + checksum: c472b8a77cbeded4bc529220b8611afa39bd64677f507838f8083d8aac8033b1f88cb9ddaa2f8589e0dcd2317291d0f6e1379f82d5ceebd6f74f3b4825288e00 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 20.2.0 resolution: "@types/yargs-parser@npm:20.2.0" @@ -7684,6 +7693,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^9.0.0": + version: 9.0.0 + resolution: "uuid@npm:9.0.0" + bin: + uuid: dist/bin/uuid + checksum: 8dd2c83c43ddc7e1c71e36b60aea40030a6505139af6bee0f382ebcd1a56f6cd3028f7f06ffb07f8cf6ced320b76aea275284b224b002b289f89fe89c389b028 + languageName: node + linkType: hard + "v8-compile-cache-lib@npm:^3.0.1": version: 3.0.1 resolution: "v8-compile-cache-lib@npm:3.0.1" From c79f1ebd86c5afb031c54a338939172c1f5e1d50 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 15:13:08 +0200 Subject: [PATCH 03/34] chore: add `Vault` to `index.ts` --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 68943618..c4771b0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ export { KeyringController, keyringBuilderFactory } from './KeyringController'; export { KeyringType, KeyringControllerError } from './constants'; + +export { Vault } from './Vault'; From d43336206921e8344971bf4985ecd7b681293368 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 18:42:41 +0200 Subject: [PATCH 04/34] chore: upgrade jest version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 133f51b3..59fa7a07 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "eslint-plugin-node": "^11.1.0", "eslint-plugin-prettier": "^4.2.1", "ethereumjs-wallet": "^1.0.1", - "jest": "^29.0.0", + "jest": "^29.5.0", "prettier": "^2.8.1", "prettier-plugin-packagejson": "^2.3.0", "sinon": "^15.0.1", From 34731eb21e2d910018f8c45abb249944cf503708 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 23:32:35 +0200 Subject: [PATCH 05/34] chore: bump dependencies --- package.json | 2 +- yarn.lock | 653 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 382 insertions(+), 273 deletions(-) diff --git a/package.json b/package.json index 59fa7a07..2a0158ab 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@metamask/eth-hd-keyring": "^6.0.0", "@metamask/eth-sig-util": "5.0.2", "@metamask/eth-simple-keyring": "^5.0.0", - "@metamask/utils": "^5.0.0", + "@metamask/utils": "^5.0.1", "@types/uuid": "^9.0.1", "obs-store": "^4.0.3", "uuid": "^9.0.0" diff --git a/yarn.lock b/yarn.lock index dac757d4..97277b53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1008,50 +1008,50 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/console@npm:29.4.3" +"@jest/console@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/console@npm:29.5.0" dependencies: - "@jest/types": ^29.4.3 + "@jest/types": ^29.5.0 "@types/node": "*" chalk: ^4.0.0 - jest-message-util: ^29.4.3 - jest-util: ^29.4.3 + jest-message-util: ^29.5.0 + jest-util: ^29.5.0 slash: ^3.0.0 - checksum: 8d9b163febe735153b523db527742309f4d598eda22f17f04e030060329bd3da4de7420fc1f7812f7a16f08273654a7de094c4b4e8b81a99dbfc17cfb1629008 + checksum: 9f4f4b8fabd1221361b7f2e92d4a90f5f8c2e2b29077249996ab3c8b7f765175ffee795368f8d6b5b2bb3adb32dc09319f7270c7c787b0d259e624e00e0f64a5 languageName: node linkType: hard -"@jest/core@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/core@npm:29.4.3" +"@jest/core@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/core@npm:29.5.0" dependencies: - "@jest/console": ^29.4.3 - "@jest/reporters": ^29.4.3 - "@jest/test-result": ^29.4.3 - "@jest/transform": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/console": ^29.5.0 + "@jest/reporters": ^29.5.0 + "@jest/test-result": ^29.5.0 + "@jest/transform": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" ansi-escapes: ^4.2.1 chalk: ^4.0.0 ci-info: ^3.2.0 exit: ^0.1.2 graceful-fs: ^4.2.9 - jest-changed-files: ^29.4.3 - jest-config: ^29.4.3 - jest-haste-map: ^29.4.3 - jest-message-util: ^29.4.3 + jest-changed-files: ^29.5.0 + jest-config: ^29.5.0 + jest-haste-map: ^29.5.0 + jest-message-util: ^29.5.0 jest-regex-util: ^29.4.3 - jest-resolve: ^29.4.3 - jest-resolve-dependencies: ^29.4.3 - jest-runner: ^29.4.3 - jest-runtime: ^29.4.3 - jest-snapshot: ^29.4.3 - jest-util: ^29.4.3 - jest-validate: ^29.4.3 - jest-watcher: ^29.4.3 + jest-resolve: ^29.5.0 + jest-resolve-dependencies: ^29.5.0 + jest-runner: ^29.5.0 + jest-runtime: ^29.5.0 + jest-snapshot: ^29.5.0 + jest-util: ^29.5.0 + jest-validate: ^29.5.0 + jest-watcher: ^29.5.0 micromatch: ^4.0.4 - pretty-format: ^29.4.3 + pretty-format: ^29.5.0 slash: ^3.0.0 strip-ansi: ^6.0.0 peerDependencies: @@ -1059,19 +1059,19 @@ __metadata: peerDependenciesMeta: node-notifier: optional: true - checksum: 4aa10644d66f44f051d5dd9cdcedce27acc71216dbcc5e7adebdea458e27aefe27c78f457d7efd49f58b968c35f42de5a521590876e2013593e675120b9e6ab1 + checksum: 9e8f5243fe82d5a57f3971e1b96f320058df7c315328a3a827263f3b17f64be10c80f4a9c1b1773628b64d2de6d607c70b5b2d5bf13e7f5ad04223e9ef6aac06 languageName: node linkType: hard -"@jest/environment@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/environment@npm:29.4.3" +"@jest/environment@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/environment@npm:29.5.0" dependencies: - "@jest/fake-timers": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/fake-timers": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" - jest-mock: ^29.4.3 - checksum: 7c1b0cc4e84b90f8a3bbeca9bbf088882c88aee70a81b3b8e24265dcb1cbc302cd1eee3319089cf65bfd39adbaea344903c712afea106cb8da6c86088d99c5fb + jest-mock: ^29.5.0 + checksum: 921de6325cd4817dec6685e5ff299b499b6379f3f9cf489b4b13588ee1f3820a0c77b49e6a087996b6de8f629f6f5251e636cba08d1bdb97d8071cc7d033c88a languageName: node linkType: hard @@ -1084,51 +1084,60 @@ __metadata: languageName: node linkType: hard -"@jest/expect@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/expect@npm:29.4.3" +"@jest/expect-utils@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/expect-utils@npm:29.5.0" dependencies: - expect: ^29.4.3 - jest-snapshot: ^29.4.3 - checksum: 08d0d40077ec99a7491fe59d05821dbd31126cfba70875855d8a063698b7126b5f6c309c50811caacc6ae2f727c6e44f51bdcf1d6c1ea832b4f020045ef22d45 + jest-get-type: ^29.4.3 + checksum: c46fb677c88535cf83cf29f0a5b1f376c6a1109ddda266ad7da1a9cbc53cb441fa402dd61fc7b111ffc99603c11a9b3357ee41a1c0e035a58830bcb360871476 languageName: node linkType: hard -"@jest/fake-timers@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/fake-timers@npm:29.4.3" +"@jest/expect@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/expect@npm:29.5.0" dependencies: - "@jest/types": ^29.4.3 + expect: ^29.5.0 + jest-snapshot: ^29.5.0 + checksum: bd10e295111547e6339137107d83986ab48d46561525393834d7d2d8b2ae9d5626653f3f5e48e5c3fa742ac982e97bdf1f541b53b9e1d117a247b08e938527f6 + languageName: node + linkType: hard + +"@jest/fake-timers@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/fake-timers@npm:29.5.0" + dependencies: + "@jest/types": ^29.5.0 "@sinonjs/fake-timers": ^10.0.2 "@types/node": "*" - jest-message-util: ^29.4.3 - jest-mock: ^29.4.3 - jest-util: ^29.4.3 - checksum: adaceb9143c395cccf3d7baa0e49b7042c3092a554e8283146df19926247e34c21b5bde5688bb90e9e87b4a02e4587926c5d858ee0a38d397a63175d0a127874 + jest-message-util: ^29.5.0 + jest-mock: ^29.5.0 + jest-util: ^29.5.0 + checksum: 69930c6922341f244151ec0d27640852ec96237f730fc024da1f53143d31b43cde75d92f9d8e5937981cdce3b31416abc3a7090a0d22c2377512c4a6613244ee languageName: node linkType: hard -"@jest/globals@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/globals@npm:29.4.3" +"@jest/globals@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/globals@npm:29.5.0" dependencies: - "@jest/environment": ^29.4.3 - "@jest/expect": ^29.4.3 - "@jest/types": ^29.4.3 - jest-mock: ^29.4.3 - checksum: ea76b546ceb4aa5ce2bb3726df12f989b23150b51c9f7664790caa81b943012a657cf3a8525498af1c3518cdb387f54b816cfba1b0ddd22c7b20f03b1d7290b4 + "@jest/environment": ^29.5.0 + "@jest/expect": ^29.5.0 + "@jest/types": ^29.5.0 + jest-mock: ^29.5.0 + checksum: b309ab8f21b571a7c672608682e84bbdd3d2b554ddf81e4e32617fec0a69094a290ab42e3c8b2c66ba891882bfb1b8b2736720ea1285b3ad646d55c2abefedd9 languageName: node linkType: hard -"@jest/reporters@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/reporters@npm:29.4.3" +"@jest/reporters@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/reporters@npm:29.5.0" dependencies: "@bcoe/v8-coverage": ^0.2.3 - "@jest/console": ^29.4.3 - "@jest/test-result": ^29.4.3 - "@jest/transform": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/console": ^29.5.0 + "@jest/test-result": ^29.5.0 + "@jest/transform": ^29.5.0 + "@jest/types": ^29.5.0 "@jridgewell/trace-mapping": ^0.3.15 "@types/node": "*" chalk: ^4.0.0 @@ -1141,9 +1150,9 @@ __metadata: istanbul-lib-report: ^3.0.0 istanbul-lib-source-maps: ^4.0.0 istanbul-reports: ^3.1.3 - jest-message-util: ^29.4.3 - jest-util: ^29.4.3 - jest-worker: ^29.4.3 + jest-message-util: ^29.5.0 + jest-util: ^29.5.0 + jest-worker: ^29.5.0 slash: ^3.0.0 string-length: ^4.0.1 strip-ansi: ^6.0.0 @@ -1153,7 +1162,7 @@ __metadata: peerDependenciesMeta: node-notifier: optional: true - checksum: 7aa2e429c915bd96c3334962addd69d2bbf52065725757ddde26b293f8c4420a1e8c65363cc3e1e5ec89100a5273ccd3771bec58325a2cc0d97afdc81995073a + checksum: 481268aac9a4a75cc49c4df1273d6b111808dec815e9d009dad717c32383ebb0cebac76e820ad1ab44e207540e1c2fe1e640d44c4f262de92ab1933e057fdeeb languageName: node linkType: hard @@ -1177,50 +1186,50 @@ __metadata: languageName: node linkType: hard -"@jest/test-result@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/test-result@npm:29.4.3" +"@jest/test-result@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/test-result@npm:29.5.0" dependencies: - "@jest/console": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/console": ^29.5.0 + "@jest/types": ^29.5.0 "@types/istanbul-lib-coverage": ^2.0.0 collect-v8-coverage: ^1.0.0 - checksum: 164f102b96619ec283c2c39e208b8048e4674f75bf3c3a4f2e95048ae0f9226105add684b25f10d286d91c221625f877e2c1cfc3da46c42d7e1804da239318cb + checksum: 2e8ff5242227ab960c520c3ea0f6544c595cc1c42fa3873c158e9f4f685f4ec9670ec08a4af94ae3885c0005a43550a9595191ffbc27a0965df27d9d98bbf901 languageName: node linkType: hard -"@jest/test-sequencer@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/test-sequencer@npm:29.4.3" +"@jest/test-sequencer@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/test-sequencer@npm:29.5.0" dependencies: - "@jest/test-result": ^29.4.3 + "@jest/test-result": ^29.5.0 graceful-fs: ^4.2.9 - jest-haste-map: ^29.4.3 + jest-haste-map: ^29.5.0 slash: ^3.0.0 - checksum: 145e1fa9379e5be3587bde6d585b8aee5cf4442b06926928a87e9aec7de5be91b581711d627c6ca13144d244fe05e5d248c13b366b51bedc404f9dcfbfd79e9e + checksum: eca34b4aeb2fda6dfb7f9f4b064c858a7adf64ec5c6091b6f4ed9d3c19549177cbadcf1c615c4c182688fa1cf085c8c55c3ca6eea40719a34554b0bf071d842e languageName: node linkType: hard -"@jest/transform@npm:^29.4.3": - version: 29.4.3 - resolution: "@jest/transform@npm:29.4.3" +"@jest/transform@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/transform@npm:29.5.0" dependencies: "@babel/core": ^7.11.6 - "@jest/types": ^29.4.3 + "@jest/types": ^29.5.0 "@jridgewell/trace-mapping": ^0.3.15 babel-plugin-istanbul: ^6.1.1 chalk: ^4.0.0 convert-source-map: ^2.0.0 fast-json-stable-stringify: ^2.1.0 graceful-fs: ^4.2.9 - jest-haste-map: ^29.4.3 + jest-haste-map: ^29.5.0 jest-regex-util: ^29.4.3 - jest-util: ^29.4.3 + jest-util: ^29.5.0 micromatch: ^4.0.4 pirates: ^4.0.4 slash: ^3.0.0 write-file-atomic: ^4.0.2 - checksum: 082d74e04044213aa7baa8de29f8383e5010034f867969c8602a2447a4ef2f484cfaf2491eba3179ce42f369f7a0af419cbd087910f7e5caf7aa5d1fe03f2ff9 + checksum: d55d604085c157cf5112e165ff5ac1fa788873b3b31265fb4734ca59892ee24e44119964cc47eb6d178dd9512bbb6c576d1e20e51a201ff4e24d31e818a1c92d languageName: node linkType: hard @@ -1238,6 +1247,20 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:^29.5.0": + version: 29.5.0 + resolution: "@jest/types@npm:29.5.0" + dependencies: + "@jest/schemas": ^29.4.3 + "@types/istanbul-lib-coverage": ^2.0.0 + "@types/istanbul-reports": ^3.0.0 + "@types/node": "*" + "@types/yargs": ^17.0.8 + chalk: ^4.0.0 + checksum: 1811f94b19cf8a9460a289c4f056796cfc373480e0492692a6125a553cd1a63824bd846d7bb78820b7b6f758f6dd3c2d4558293bb676d541b2fa59c70fdf9d39 + languageName: node + linkType: hard + "@jridgewell/gen-mapping@npm:^0.1.0": version: 0.1.1 resolution: "@jridgewell/gen-mapping@npm:0.1.1" @@ -1421,7 +1444,7 @@ __metadata: "@metamask/eth-hd-keyring": ^6.0.0 "@metamask/eth-sig-util": 5.0.2 "@metamask/eth-simple-keyring": ^5.0.0 - "@metamask/utils": ^5.0.0 + "@metamask/utils": ^5.0.1 "@types/jest": ^29.4.0 "@types/sinon": ^10.0.13 "@types/uuid": ^9.0.1 @@ -1436,7 +1459,7 @@ __metadata: eslint-plugin-node: ^11.1.0 eslint-plugin-prettier: ^4.2.1 ethereumjs-wallet: ^1.0.1 - jest: ^29.0.0 + jest: ^29.5.0 obs-store: ^4.0.3 prettier: ^2.8.1 prettier-plugin-packagejson: ^2.3.0 @@ -1485,16 +1508,16 @@ __metadata: languageName: node linkType: hard -"@metamask/utils@npm:^5.0.0": - version: 5.0.0 - resolution: "@metamask/utils@npm:5.0.0" +"@metamask/utils@npm:^5.0.1": + version: 5.0.1 + resolution: "@metamask/utils@npm:5.0.1" dependencies: "@ethereumjs/tx": ^4.1.1 "@types/debug": ^4.1.7 debug: ^4.3.4 semver: ^7.3.8 superstruct: ^1.0.3 - checksum: 34e39fc0bf28db5fe92676753de3291b05a517f8c81dbe332a4b6002739a58450a89fb2bddd85922a4f420affb1674604e6ad4627cdf052459e5371361ef7dd2 + checksum: 29745bd3d2db06bf66263bdec04e93a8f417c46c69d8054149c0046ed54b5f13d26d94a998fff1a31b5a8e7a2200935bfc8392a5fa3c4261e3cecd3ccdd9ddc0 languageName: node linkType: hard @@ -2528,20 +2551,20 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:^29.4.3": - version: 29.4.3 - resolution: "babel-jest@npm:29.4.3" +"babel-jest@npm:^29.5.0": + version: 29.5.0 + resolution: "babel-jest@npm:29.5.0" dependencies: - "@jest/transform": ^29.4.3 + "@jest/transform": ^29.5.0 "@types/babel__core": ^7.1.14 babel-plugin-istanbul: ^6.1.1 - babel-preset-jest: ^29.4.3 + babel-preset-jest: ^29.5.0 chalk: ^4.0.0 graceful-fs: ^4.2.9 slash: ^3.0.0 peerDependencies: "@babel/core": ^7.8.0 - checksum: a1a95937adb5e717dbffc2eb9e583fa6d26c7e5d5b07bb492a2d7f68631510a363e9ff097eafb642ad642dfac9dc2b13872b584f680e166a4f0922c98ea95853 + checksum: eafb6d37deb71f0c80bf3c80215aa46732153e5e8bcd73f6ff47d92e5c0c98c8f7f75995d0efec6289c371edad3693cd8fa2367b0661c4deb71a3a7117267ede languageName: node linkType: hard @@ -2558,15 +2581,15 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:^29.4.3": - version: 29.4.3 - resolution: "babel-plugin-jest-hoist@npm:29.4.3" +"babel-plugin-jest-hoist@npm:^29.5.0": + version: 29.5.0 + resolution: "babel-plugin-jest-hoist@npm:29.5.0" dependencies: "@babel/template": ^7.3.3 "@babel/types": ^7.3.3 "@types/babel__core": ^7.1.14 "@types/babel__traverse": ^7.0.6 - checksum: c8702a6db6b30ec39dfb9f8e72b501c13895231ed80b15ed2648448f9f0c7b7cc4b1529beac31802ae655f63479a05110ca612815aa25fb1b0e6c874e1589137 + checksum: 099b5254073b6bc985b6d2d045ad26fb8ed30ff8ae6404c4fe8ee7cd0e98a820f69e3dfb871c7c65aae0f4b65af77046244c07bb92d49ef9005c90eedf681539 languageName: node linkType: hard @@ -2592,15 +2615,15 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:^29.4.3": - version: 29.4.3 - resolution: "babel-preset-jest@npm:29.4.3" +"babel-preset-jest@npm:^29.5.0": + version: 29.5.0 + resolution: "babel-preset-jest@npm:29.5.0" dependencies: - babel-plugin-jest-hoist: ^29.4.3 + babel-plugin-jest-hoist: ^29.5.0 babel-preset-current-node-syntax: ^1.0.0 peerDependencies: "@babel/core": ^7.0.0 - checksum: a091721861ea2f8d969ace8fe06570cff8f2e847dbc6e4800abacbe63f72131abde615ce0a3b6648472c97e55a5be7f8bf7ae381e2b194ad2fa1737096febcf5 + checksum: 5566ca2762766c9319b4973d018d2fa08c0fcf6415c72cc54f4c8e7199e851ea8f5e6c6730f03ed7ed44fc8beefa959dd15911f2647dee47c615ff4faeddb1ad languageName: node linkType: hard @@ -3943,7 +3966,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0, expect@npm:^29.4.3": +"expect@npm:^29.0.0": version: 29.4.3 resolution: "expect@npm:29.4.3" dependencies: @@ -3956,6 +3979,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:^29.5.0": + version: 29.5.0 + resolution: "expect@npm:29.5.0" + dependencies: + "@jest/expect-utils": ^29.5.0 + jest-get-type: ^29.4.3 + jest-matcher-utils: ^29.5.0 + jest-message-util: ^29.5.0 + jest-util: ^29.5.0 + checksum: 58f70b38693df6e5c6892db1bcd050f0e518d6f785175dc53917d4fa6a7359a048e5690e19ddcb96b65c4493881dd89a3dabdab1a84dfa55c10cdbdabf37b2d7 + languageName: node + linkType: hard + "extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -4960,57 +4996,58 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-changed-files@npm:29.4.3" +"jest-changed-files@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-changed-files@npm:29.5.0" dependencies: execa: ^5.0.0 p-limit: ^3.1.0 - checksum: 9a70bd8e92b37e18ad26d8bea97c516f41119fb7046b4255a13c76d557b0e54fa0629726de5a093fadfd6a0a08ce45da65a57086664d505b8db4b3133133e141 + checksum: a67a7cb3c11f8f92bd1b7c79e84f724cbd11a9ad51f3cdadafe3ce7ee3c79ee50dbea128f920f5fddc807e9e4e83f5462143094391feedd959a77dd20ab96cf3 languageName: node linkType: hard -"jest-circus@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-circus@npm:29.4.3" +"jest-circus@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-circus@npm:29.5.0" dependencies: - "@jest/environment": ^29.4.3 - "@jest/expect": ^29.4.3 - "@jest/test-result": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/environment": ^29.5.0 + "@jest/expect": ^29.5.0 + "@jest/test-result": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" chalk: ^4.0.0 co: ^4.6.0 dedent: ^0.7.0 is-generator-fn: ^2.0.0 - jest-each: ^29.4.3 - jest-matcher-utils: ^29.4.3 - jest-message-util: ^29.4.3 - jest-runtime: ^29.4.3 - jest-snapshot: ^29.4.3 - jest-util: ^29.4.3 + jest-each: ^29.5.0 + jest-matcher-utils: ^29.5.0 + jest-message-util: ^29.5.0 + jest-runtime: ^29.5.0 + jest-snapshot: ^29.5.0 + jest-util: ^29.5.0 p-limit: ^3.1.0 - pretty-format: ^29.4.3 + pretty-format: ^29.5.0 + pure-rand: ^6.0.0 slash: ^3.0.0 stack-utils: ^2.0.3 - checksum: 2739bef9c888743b49ff3fe303131381618e5d2f250f613a91240d9c86e19e6874fc904cbd8bcb02ec9ec59a84e5dae4ffec929f0c6171e87ddbc05508a137f4 + checksum: 44ff5d06acedae6de6c866e20e3b61f83e29ab94cf9f960826e7e667de49c12dd9ab9dffd7fa3b7d1f9688a8b5bfb1ebebadbea69d9ed0d3f66af4a0ff8c2b27 languageName: node linkType: hard -"jest-cli@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-cli@npm:29.4.3" +"jest-cli@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-cli@npm:29.5.0" dependencies: - "@jest/core": ^29.4.3 - "@jest/test-result": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/core": ^29.5.0 + "@jest/test-result": ^29.5.0 + "@jest/types": ^29.5.0 chalk: ^4.0.0 exit: ^0.1.2 graceful-fs: ^4.2.9 import-local: ^3.0.2 - jest-config: ^29.4.3 - jest-util: ^29.4.3 - jest-validate: ^29.4.3 + jest-config: ^29.5.0 + jest-util: ^29.5.0 + jest-validate: ^29.5.0 prompts: ^2.0.1 yargs: ^17.3.1 peerDependencies: @@ -5020,34 +5057,34 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: f4c9f6d76cde2c60a4169acbebb3f862728be03bcf3fe0077d2e55da7f9f3c3e9483cfa6e936832d35eabf96ee5ebf0300c4b0bd43cffff099801793466bfdd8 + checksum: 39897bbbc0f0d8a6b975ab12fd13887eaa28d92e3dee9e0173a5cb913ae8cc2ae46e090d38c6d723e84d9d6724429cd08685b4e505fa447d31ca615630c7dbba languageName: node linkType: hard -"jest-config@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-config@npm:29.4.3" +"jest-config@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-config@npm:29.5.0" dependencies: "@babel/core": ^7.11.6 - "@jest/test-sequencer": ^29.4.3 - "@jest/types": ^29.4.3 - babel-jest: ^29.4.3 + "@jest/test-sequencer": ^29.5.0 + "@jest/types": ^29.5.0 + babel-jest: ^29.5.0 chalk: ^4.0.0 ci-info: ^3.2.0 deepmerge: ^4.2.2 glob: ^7.1.3 graceful-fs: ^4.2.9 - jest-circus: ^29.4.3 - jest-environment-node: ^29.4.3 + jest-circus: ^29.5.0 + jest-environment-node: ^29.5.0 jest-get-type: ^29.4.3 jest-regex-util: ^29.4.3 - jest-resolve: ^29.4.3 - jest-runner: ^29.4.3 - jest-util: ^29.4.3 - jest-validate: ^29.4.3 + jest-resolve: ^29.5.0 + jest-runner: ^29.5.0 + jest-util: ^29.5.0 + jest-validate: ^29.5.0 micromatch: ^4.0.4 parse-json: ^5.2.0 - pretty-format: ^29.4.3 + pretty-format: ^29.5.0 slash: ^3.0.0 strip-json-comments: ^3.1.1 peerDependencies: @@ -5058,7 +5095,7 @@ __metadata: optional: true ts-node: optional: true - checksum: 92f9a9c6850b18682cb01892774a33967472af23a5844438d8c68077d5f2a29b15b665e4e4db7de3d74002a6dca158cd5b2cb9f5debfd2cce5e1aee6c74e3873 + checksum: c37c4dab964c54ab293d4e302d40b09687037ac9d00b88348ec42366970747feeaf265e12e3750cd3660b40c518d4031335eda11ac10b70b10e60797ebbd4b9c languageName: node linkType: hard @@ -5074,6 +5111,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-diff@npm:29.5.0" + dependencies: + chalk: ^4.0.0 + diff-sequences: ^29.4.3 + jest-get-type: ^29.4.3 + pretty-format: ^29.5.0 + checksum: dfd0f4a299b5d127779c76b40106c37854c89c3e0785098c717d52822d6620d227f6234c3a9291df204d619e799e3654159213bf93220f79c8e92a55475a3d39 + languageName: node + linkType: hard + "jest-docblock@npm:^29.4.3": version: 29.4.3 resolution: "jest-docblock@npm:29.4.3" @@ -5083,30 +5132,30 @@ __metadata: languageName: node linkType: hard -"jest-each@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-each@npm:29.4.3" +"jest-each@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-each@npm:29.5.0" dependencies: - "@jest/types": ^29.4.3 + "@jest/types": ^29.5.0 chalk: ^4.0.0 jest-get-type: ^29.4.3 - jest-util: ^29.4.3 - pretty-format: ^29.4.3 - checksum: 1f72738338399efab0139eaea18bc198be0c6ed889770c8cbfa70bf9c724e8171fe1d3a29a94f9f39b8493ee6b2529bb350fb7c7c75e0d7eddfd28c253c79f9d + jest-util: ^29.5.0 + pretty-format: ^29.5.0 + checksum: b8b297534d25834c5d4e31e4c687359787b1e402519e42664eb704cc3a12a7a91a017565a75acb02e8cf9afd3f4eef3350bd785276bec0900184641b765ff7a5 languageName: node linkType: hard -"jest-environment-node@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-environment-node@npm:29.4.3" +"jest-environment-node@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-environment-node@npm:29.5.0" dependencies: - "@jest/environment": ^29.4.3 - "@jest/fake-timers": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/environment": ^29.5.0 + "@jest/fake-timers": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" - jest-mock: ^29.4.3 - jest-util: ^29.4.3 - checksum: 3c7362edfdbd516e83af7367c95dde35761a482b174de9735c07633405486ec73e19624e9bea4333fca33c24e8d65eaa1aa6594e0cb6bfeeeb564ccc431ee61d + jest-mock: ^29.5.0 + jest-util: ^29.5.0 + checksum: 57981911cc20a4219b0da9e22b2e3c9f31b505e43f78e61c899e3227ded455ce1a3a9483842c69cfa4532f02cfb536ae0995bf245f9211608edacfc1e478d411 languageName: node linkType: hard @@ -5117,11 +5166,11 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-haste-map@npm:29.4.3" +"jest-haste-map@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-haste-map@npm:29.5.0" dependencies: - "@jest/types": ^29.4.3 + "@jest/types": ^29.5.0 "@types/graceful-fs": ^4.1.3 "@types/node": "*" anymatch: ^3.0.3 @@ -5129,24 +5178,24 @@ __metadata: fsevents: ^2.3.2 graceful-fs: ^4.2.9 jest-regex-util: ^29.4.3 - jest-util: ^29.4.3 - jest-worker: ^29.4.3 + jest-util: ^29.5.0 + jest-worker: ^29.5.0 micromatch: ^4.0.4 walker: ^1.0.8 dependenciesMeta: fsevents: optional: true - checksum: c7a83ebe6008b3fe96a96235e8153092e54b14df68e0f4205faedec57450df26b658578495a71c6d82494c01fbb44bca98c1506a6b2b9c920696dcc5d2e2bc59 + checksum: 3828ff7783f168e34be2c63887f82a01634261f605dcae062d83f979a61c37739e21b9607ecb962256aea3fbe5a530a1acee062d0026fcb47c607c12796cf3b7 languageName: node linkType: hard -"jest-leak-detector@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-leak-detector@npm:29.4.3" +"jest-leak-detector@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-leak-detector@npm:29.5.0" dependencies: jest-get-type: ^29.4.3 - pretty-format: ^29.4.3 - checksum: ec2b45e6f0abce81bd0dd0f6fd06b433c24d1ec865267af7640fae540ec868b93752598e407a9184d9c7419cbf32e8789007cc8c1be1a84f8f7321a0f8ad01f1 + pretty-format: ^29.5.0 + checksum: 0fb845da7ac9cdfc9b3b2e35f6f623a41c547d7dc0103ceb0349013459d00de5870b5689a625e7e37f9644934b40e8f1dcdd5422d14d57470600350364676313 languageName: node linkType: hard @@ -5162,6 +5211,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-matcher-utils@npm:29.5.0" + dependencies: + chalk: ^4.0.0 + jest-diff: ^29.5.0 + jest-get-type: ^29.4.3 + pretty-format: ^29.5.0 + checksum: 1d3e8c746e484a58ce194e3aad152eff21fd0896e8b8bf3d4ab1a4e2cbfed95fb143646f4ad9fdf6e42212b9e8fc033268b58e011b044a9929df45485deb5ac9 + languageName: node + linkType: hard + "jest-message-util@npm:^29.4.3": version: 29.4.3 resolution: "jest-message-util@npm:29.4.3" @@ -5179,14 +5240,31 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-mock@npm:29.4.3" +"jest-message-util@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-message-util@npm:29.5.0" dependencies: - "@jest/types": ^29.4.3 + "@babel/code-frame": ^7.12.13 + "@jest/types": ^29.5.0 + "@types/stack-utils": ^2.0.0 + chalk: ^4.0.0 + graceful-fs: ^4.2.9 + micromatch: ^4.0.4 + pretty-format: ^29.5.0 + slash: ^3.0.0 + stack-utils: ^2.0.3 + checksum: daddece6bbf846eb6a2ab9be9f2446e54085bef4e5cecd13d2a538fa9c01cb89d38e564c6b74fd8e12d37ed9eface8a362240ae9f21d68b214590631e7a0d8bf + languageName: node + linkType: hard + +"jest-mock@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-mock@npm:29.5.0" + dependencies: + "@jest/types": ^29.5.0 "@types/node": "*" - jest-util: ^29.4.3 - checksum: 8eb4a29b02d2cd03faac0290b6df6d23b4ffa43f72b21c7fff3c7dd04a2797355b1e85862b70b15341dd33ee3a693b17db5520a6f6e6b81ee75601987de6a1a2 + jest-util: ^29.5.0 + checksum: 2a9cf07509948fa8608898c445f04fe4dd6e2049ff431e5531eee028c808d3ba3c67f226ac87b0cf383feaa1055776900d197c895e89783016886ac17a4ff10c languageName: node linkType: hard @@ -5209,95 +5287,95 @@ __metadata: languageName: node linkType: hard -"jest-resolve-dependencies@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-resolve-dependencies@npm:29.4.3" +"jest-resolve-dependencies@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-resolve-dependencies@npm:29.5.0" dependencies: jest-regex-util: ^29.4.3 - jest-snapshot: ^29.4.3 - checksum: 3ad934cd2170c9658d8800f84a975dafc866ec85b7ce391c640c09c3744ced337787620d8667dc8d1fa5e0b1493f973caa1a1bb980e4e6a50b46a1720baf0bd1 + jest-snapshot: ^29.5.0 + checksum: 479d2e5365d58fe23f2b87001e2e0adcbffe0147700e85abdec8f14b9703b0a55758c1929a9989e3f5d5e954fb88870ea4bfa04783523b664562fcf5f10b0edf languageName: node linkType: hard -"jest-resolve@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-resolve@npm:29.4.3" +"jest-resolve@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-resolve@npm:29.5.0" dependencies: chalk: ^4.0.0 graceful-fs: ^4.2.9 - jest-haste-map: ^29.4.3 + jest-haste-map: ^29.5.0 jest-pnp-resolver: ^1.2.2 - jest-util: ^29.4.3 - jest-validate: ^29.4.3 + jest-util: ^29.5.0 + jest-validate: ^29.5.0 resolve: ^1.20.0 resolve.exports: ^2.0.0 slash: ^3.0.0 - checksum: 056a66beccf833f3c7e5a8fc9bfec218886e87b0b103decdbdf11893669539df489d1490cd6d5f0eea35731e8be0d2e955a6710498f970d2eae734da4df029dc + checksum: 9a125f3cf323ceef512089339d35f3ee37f79fe16a831fb6a26773ea6a229b9e490d108fec7af334142e91845b5996de8e7cdd85a4d8d617078737d804e29c8f languageName: node linkType: hard -"jest-runner@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-runner@npm:29.4.3" +"jest-runner@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-runner@npm:29.5.0" dependencies: - "@jest/console": ^29.4.3 - "@jest/environment": ^29.4.3 - "@jest/test-result": ^29.4.3 - "@jest/transform": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/console": ^29.5.0 + "@jest/environment": ^29.5.0 + "@jest/test-result": ^29.5.0 + "@jest/transform": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" chalk: ^4.0.0 emittery: ^0.13.1 graceful-fs: ^4.2.9 jest-docblock: ^29.4.3 - jest-environment-node: ^29.4.3 - jest-haste-map: ^29.4.3 - jest-leak-detector: ^29.4.3 - jest-message-util: ^29.4.3 - jest-resolve: ^29.4.3 - jest-runtime: ^29.4.3 - jest-util: ^29.4.3 - jest-watcher: ^29.4.3 - jest-worker: ^29.4.3 + jest-environment-node: ^29.5.0 + jest-haste-map: ^29.5.0 + jest-leak-detector: ^29.5.0 + jest-message-util: ^29.5.0 + jest-resolve: ^29.5.0 + jest-runtime: ^29.5.0 + jest-util: ^29.5.0 + jest-watcher: ^29.5.0 + jest-worker: ^29.5.0 p-limit: ^3.1.0 source-map-support: 0.5.13 - checksum: c41108e5da01e0b8fdc2a06c5042eb49bb1d8db0e0d4651769fd1b9fe84ab45188617c11a3a8e1c83748b29bfe57dd77001ec57e86e3e3c30f3534e0314f8882 + checksum: 437dea69c5dddca22032259787bac74790d5a171c9d804711415f31e5d1abfb64fa52f54a9015bb17a12b858fd0cf3f75ef6f3c9e94255a8596e179f707229c4 languageName: node linkType: hard -"jest-runtime@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-runtime@npm:29.4.3" +"jest-runtime@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-runtime@npm:29.5.0" dependencies: - "@jest/environment": ^29.4.3 - "@jest/fake-timers": ^29.4.3 - "@jest/globals": ^29.4.3 + "@jest/environment": ^29.5.0 + "@jest/fake-timers": ^29.5.0 + "@jest/globals": ^29.5.0 "@jest/source-map": ^29.4.3 - "@jest/test-result": ^29.4.3 - "@jest/transform": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/test-result": ^29.5.0 + "@jest/transform": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" chalk: ^4.0.0 cjs-module-lexer: ^1.0.0 collect-v8-coverage: ^1.0.0 glob: ^7.1.3 graceful-fs: ^4.2.9 - jest-haste-map: ^29.4.3 - jest-message-util: ^29.4.3 - jest-mock: ^29.4.3 + jest-haste-map: ^29.5.0 + jest-message-util: ^29.5.0 + jest-mock: ^29.5.0 jest-regex-util: ^29.4.3 - jest-resolve: ^29.4.3 - jest-snapshot: ^29.4.3 - jest-util: ^29.4.3 + jest-resolve: ^29.5.0 + jest-snapshot: ^29.5.0 + jest-util: ^29.5.0 slash: ^3.0.0 strip-bom: ^4.0.0 - checksum: b99f8a910d1a38e7476058ba04ad44dfd3d93e837bb7c301d691e646a1085412fde87f06fbe271c9145f0e72d89400bfa7f6994bc30d456c7742269f37d0f570 + checksum: 7af27bd9d54cf1c5735404cf8d76c6509d5610b1ec0106a21baa815c1aff15d774ce534ac2834bc440dccfe6348bae1885fd9a806f23a94ddafdc0f5bae4b09d languageName: node linkType: hard -"jest-snapshot@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-snapshot@npm:29.4.3" +"jest-snapshot@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-snapshot@npm:29.5.0" dependencies: "@babel/core": ^7.11.6 "@babel/generator": ^7.7.2 @@ -5305,25 +5383,24 @@ __metadata: "@babel/plugin-syntax-typescript": ^7.7.2 "@babel/traverse": ^7.7.2 "@babel/types": ^7.3.3 - "@jest/expect-utils": ^29.4.3 - "@jest/transform": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/expect-utils": ^29.5.0 + "@jest/transform": ^29.5.0 + "@jest/types": ^29.5.0 "@types/babel__traverse": ^7.0.6 "@types/prettier": ^2.1.5 babel-preset-current-node-syntax: ^1.0.0 chalk: ^4.0.0 - expect: ^29.4.3 + expect: ^29.5.0 graceful-fs: ^4.2.9 - jest-diff: ^29.4.3 + jest-diff: ^29.5.0 jest-get-type: ^29.4.3 - jest-haste-map: ^29.4.3 - jest-matcher-utils: ^29.4.3 - jest-message-util: ^29.4.3 - jest-util: ^29.4.3 + jest-matcher-utils: ^29.5.0 + jest-message-util: ^29.5.0 + jest-util: ^29.5.0 natural-compare: ^1.4.0 - pretty-format: ^29.4.3 + pretty-format: ^29.5.0 semver: ^7.3.5 - checksum: 79ba52f2435e23ce72b1309be4b17fdbcb299d1c2ce97ebb61df9a62711e9463035f63b4c849181b2fe5aa17b3e09d30ee4668cc25fb3c6f59511c010b4d9494 + checksum: fe5df54122ed10eed625de6416a45bc4958d5062b018f05b152bf9785ab7f355dcd55e40cf5da63895bf8278f8d7b2bb4059b2cfbfdee18f509d455d37d8aa2b languageName: node linkType: hard @@ -5341,56 +5418,70 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-validate@npm:29.4.3" +"jest-util@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-util@npm:29.5.0" dependencies: - "@jest/types": ^29.4.3 + "@jest/types": ^29.5.0 + "@types/node": "*" + chalk: ^4.0.0 + ci-info: ^3.2.0 + graceful-fs: ^4.2.9 + picomatch: ^2.2.3 + checksum: fd9212950d34d2ecad8c990dda0d8ea59a8a554b0c188b53ea5d6c4a0829a64f2e1d49e6e85e812014933d17426d7136da4785f9cf76fff1799de51b88bc85d3 + languageName: node + linkType: hard + +"jest-validate@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-validate@npm:29.5.0" + dependencies: + "@jest/types": ^29.5.0 camelcase: ^6.2.0 chalk: ^4.0.0 jest-get-type: ^29.4.3 leven: ^3.1.0 - pretty-format: ^29.4.3 - checksum: 983e56430d86bed238448cae031535c1d908f760aa312cd4a4ec0e92f3bc1b6675415ddf57cdeceedb8ad9c698e5bcd10f0a856dfc93a8923bdecc7733f4ba80 + pretty-format: ^29.5.0 + checksum: 43ca5df7cb75572a254ac3e92fbbe7be6b6a1be898cc1e887a45d55ea003f7a112717d814a674d37f9f18f52d8de40873c8f084f17664ae562736c78dd44c6a1 languageName: node linkType: hard -"jest-watcher@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-watcher@npm:29.4.3" +"jest-watcher@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-watcher@npm:29.5.0" dependencies: - "@jest/test-result": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/test-result": ^29.5.0 + "@jest/types": ^29.5.0 "@types/node": "*" ansi-escapes: ^4.2.1 chalk: ^4.0.0 emittery: ^0.13.1 - jest-util: ^29.4.3 + jest-util: ^29.5.0 string-length: ^4.0.1 - checksum: 44b64991b3414db853c3756f14690028f4edef7aebfb204a4291cc1901c2239fa27a8687c5c5abbecc74bf613e0bb9b1378bf766430c9febcc71e9c0cb5ad8fc + checksum: 62303ac7bdc7e61a8b4239a239d018f7527739da2b2be6a81a7be25b74ca769f1c43ee8558ce8e72bb857245c46d6e03af331227ffb00a57280abb2a928aa776 languageName: node linkType: hard -"jest-worker@npm:^29.4.3": - version: 29.4.3 - resolution: "jest-worker@npm:29.4.3" +"jest-worker@npm:^29.5.0": + version: 29.5.0 + resolution: "jest-worker@npm:29.5.0" dependencies: "@types/node": "*" - jest-util: ^29.4.3 + jest-util: ^29.5.0 merge-stream: ^2.0.0 supports-color: ^8.0.0 - checksum: c99ae66f257564613e72c5797c3a68f21a22e1c1fb5f30d14695ff5b508a0d2405f22748f13a3df8d1015b5e16abb130170f81f047ff68f58b6b1d2ff6ebc51b + checksum: 1151a1ae3602b1ea7c42a8f1efe2b5a7bf927039deaa0827bf978880169899b705744e288f80a63603fb3fc2985e0071234986af7dc2c21c7a64333d8777c7c9 languageName: node linkType: hard -"jest@npm:^29.0.0": - version: 29.4.3 - resolution: "jest@npm:29.4.3" +"jest@npm:^29.5.0": + version: 29.5.0 + resolution: "jest@npm:29.5.0" dependencies: - "@jest/core": ^29.4.3 - "@jest/types": ^29.4.3 + "@jest/core": ^29.5.0 + "@jest/types": ^29.5.0 import-local: ^3.0.2 - jest-cli: ^29.4.3 + jest-cli: ^29.5.0 peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: @@ -5398,7 +5489,7 @@ __metadata: optional: true bin: jest: bin/jest.js - checksum: 084d10d1ceaade3c40e6d3bbd71b9b71b8919ba6fbd6f1f6699bdc259a6ba2f7350c7ccbfa10c11f7e3e01662853650a6244210179542fe4ba87e77dc3f3109f + checksum: a8ff2eb0f421623412236e23cbe67c638127fffde466cba9606bc0c0553b4c1e5cb116d7e0ef990b5d1712851652c8ee461373b578df50857fe635b94ff455d5 languageName: node linkType: hard @@ -6504,6 +6595,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^29.5.0": + version: 29.5.0 + resolution: "pretty-format@npm:29.5.0" + dependencies: + "@jest/schemas": ^29.4.3 + ansi-styles: ^5.0.0 + react-is: ^18.0.0 + checksum: 4065356b558e6db25b4d41a01efb386935a6c06a0c9c104ef5ce59f2f476b8210edb8b3949b386e60ada0a6dc5ebcb2e6ccddc8c64dfd1a9943c3c3a9e7eaf89 + languageName: node + linkType: hard + "process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -6552,6 +6654,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.0.1 + resolution: "pure-rand@npm:6.0.1" + checksum: 4bb565399993b815658a72e359f574ce4f04827a42a905105d61163ae86f456d91595a0e4241e7bce04328fae0638ae70ac0428d93ecb55971c465bd084f8648 + languageName: node + linkType: hard + "qs@npm:~6.5.2": version: 6.5.3 resolution: "qs@npm:6.5.3" From 8bb16908235d1a935653c3db93bd76fc43711049 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 23:33:37 +0200 Subject: [PATCH 06/34] refactor: simplify `Vault` API --- src/Vault.test.ts | 122 +++++++++++++++++++++++++ src/Vault.ts | 220 +++++++++++++++++++--------------------------- 2 files changed, 211 insertions(+), 131 deletions(-) create mode 100644 src/Vault.test.ts diff --git a/src/Vault.test.ts b/src/Vault.test.ts new file mode 100644 index 00000000..3fbeb964 --- /dev/null +++ b/src/Vault.test.ts @@ -0,0 +1,122 @@ +// import { Vault } from '.'; +import { exportedForTesting } from './Vault'; + +const { + stringToBytes, + bytesToString, + jsonToBytes, + randomBytes, + ensureNotNull, +} = exportedForTesting; + +describe('stringToBytes', () => { + it('should return an empty Uint8Array for an empty string', () => { + const result = stringToBytes(''); + expect(result).toStrictEqual(new Uint8Array()); + }); + + it('should encode ASCII characters correctly', () => { + const result = stringToBytes('hello world'); + const expected = new Uint8Array([ + 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, + ]); + expect(result).toStrictEqual(expected); + }); + + it('should encode non-ASCII characters correctly', () => { + const result = stringToBytes('øçñ'); + const expected = new Uint8Array([195, 184, 195, 167, 195, 177]); + expect(result).toStrictEqual(expected); + }); + + it('should normalize text in NFC form', () => { + const result = stringToBytes('é'); + const expected = new Uint8Array([195, 169]); + expect(result).toStrictEqual(expected); + }); +}); + +describe('bytesToString', () => { + it('should return an empty string for an empty Uint8Array', () => { + const result = bytesToString(new Uint8Array()); + expect(result).toBe(''); + }); + + it('should decode ASCII characters correctly', () => { + const data = new Uint8Array([ + 104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100, + ]); + const expected = 'hello world'; + const result = bytesToString(data); + expect(result).toStrictEqual(expected); + }); + + it('should decode non-ASCII characters correctly', () => { + const data = new Uint8Array([195, 184, 195, 167, 195, 177]); + const expected = 'øçñ'; + const result = bytesToString(data); + expect(result).toStrictEqual(expected); + }); + + it('should handle invalid byte sequences by replacing them with the replacement character', () => { + const data = new Uint8Array([194, 194]); + const expected = '��'; + const result = bytesToString(data); + expect(result).toStrictEqual(expected); + }); +}); + +describe('jsonToBytes', () => { + it('should encode JSON data to UTF-8 bytes', () => { + const data = { name: 'John', age: 30 }; + const expected = new Uint8Array([ + 123, 34, 110, 97, 109, 101, 34, 58, 34, 74, 111, 104, 110, 34, 44, 34, 97, + 103, 101, 34, 58, 51, 48, 125, + ]); + const result = jsonToBytes(data); + expect(result).toStrictEqual(expected); + }); + + it('should handle empty JSON data', () => { + const data = {}; + const expected = new Uint8Array([123, 125]); + const result = jsonToBytes(data); + expect(result).toStrictEqual(expected); + }); +}); + +describe('randomBytes', () => { + it('should return a Uint8Array with the specified length', () => { + const length = 16; + const result = randomBytes(length); + expect(result).toHaveLength(length); + }); + + it('should return different values for each invocation', () => { + const length = 16; + const result1 = randomBytes(length); + const result2 = randomBytes(length); + expect(result1).not.toStrictEqual(result2); + }); + + it('should throw an error if length is negative', () => { + const length = -1; + expect(() => { + randomBytes(length); + }).toThrow('Invalid typed array length'); + }); +}); + +describe('ensureNotNull', () => { + it('should return the value if it is not null', () => { + const value = 'hello'; + const result = ensureNotNull(value, 'Error message'); + expect(result).toBe(value); + }); + + it('should throw an error with the specified message if the value is null', () => { + const value = null; + const errorMessage = 'Error message'; + expect(() => ensureNotNull(value, errorMessage)).toThrowError(errorMessage); + }); +}); diff --git a/src/Vault.ts b/src/Vault.ts index ff2aa469..50289552 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -13,7 +13,6 @@ type EncryptedData = { type VaultEntry = { id: string; lastUpdatedAt: Date; - lastAccessedAt: Date; createdAt: Date; value: EncryptedData; }; @@ -32,6 +31,17 @@ function stringToBytes(text: string): Uint8Array { return encoder.encode(text.normalize('NFC')); } +/** + * Decodes a byte array into a string. + * + * @param data - Bytes to decode. + * @returns A string from the bytes. + */ +function bytesToString(data: Uint8Array): string { + const decoder = new TextDecoder(); + return decoder.decode(data); +} + /** * Convert a JSON object to bytes. * @@ -53,6 +63,20 @@ function randomBytes(length: number): Uint8Array { return crypto.getRandomValues(array); } +/** + * Ensure that a value is not null. + * + * @param value - Value to check. + * @param message - Error message in case value is null. + * @returns The value if it is not null. + */ +function ensureNotNull(value: T | null, message: string): T { + if (value === null) { + throw new Error(message); + } + return value; +} + /** * Import a password as a raw key. * @@ -111,7 +135,7 @@ async function deriveWrappingKey( */ async function generateMasterKey( wrappingKey: CryptoKey, - additionalData: Uint8Array, + additionalData?: Uint8Array, ): Promise<{ wrapped: EncryptedData; handler: CryptoKey; @@ -237,63 +261,45 @@ export class Vault { await generateMasterKey(wrappingKey, additionalData)); } + // TODO: add a static method to create a vault from a serialized state. + /** * Check if the vault is unlocked. * * @returns True if the vault is unlocked, false otherwise. */ - get isLocked(): boolean { - return this.#cachedMasterKey === null; - } - - #assertUnlocked() { - if (this.isLocked) { - throw new Error('Vault is locked'); - } + get isUnlocked(): boolean { + return this.#cachedMasterKey !== null; } /** - * Store a new value in the vault. + * Check if the vault was initialized. * - * @param key - The key to store the value under. - * @param value - The value to store. + * @returns True if the vault was initialized, false otherwise. */ - async store(key: string, value: Json): Promise { - this.#assertUnlocked(); - - const now = new Date(); - const encryptionKey = await this.#deriveMasterKey(['test']); - const additionalData = jsonToBytes(['vaultId', this.id]); - - this.#entries.set(key, { - id: uuidv4(), - value: await encryptData( - encryptionKey, - jsonToBytes(value), - additionalData, - ), - createdAt: now, - lastAccessedAt: now, - lastUpdatedAt: now, - }); + get isInitialized(): boolean { + return this.#wrappedMasterKey !== null; } /** - * Update an existing value in the vault. + * Add a new value to the vault. * - * @param key - The key to update. - * @param value - The new value. + * @param key - Key to store the value under. + * @param value - Value to be encrypted and added to the vault. */ - async update(key: string, value: Json): Promise { + async set(key: string, value: Json): Promise { + const now = new Date(); const current = this.#entries.get(key); - if (current === undefined) { - throw new Error('Key does not exist'); - } + const entryId = current?.id ?? uuidv4(); + const encryptionKey = await this.#deriveMasterKey( + `metamask/vault/${this.id}/entry/${entryId}/key/${key}`, + ); this.#entries.set(key, { - ...current, - value, // FIXME: encrypt value - lastUpdatedAt: new Date(), + id: entryId, + value: await encryptData(encryptionKey, jsonToBytes(value)), + createdAt: current?.createdAt ?? now, + lastUpdatedAt: now, }); } @@ -301,28 +307,43 @@ export class Vault { * Get the value associated with a key. * * @param key - The key to get the value of. - * @returns The value associated with the key. + * @returns The value associated with the key or undefined if the key does + * not exist. */ - async get(key: string): Promise { + async get(key: string): Promise { + // Return undefined if the key does not exist. const entry = this.#entries.get(key); if (entry === undefined) { - throw new Error('Key does not exist'); + return undefined; } - return entry.value; // FIXME: decrypt value + const decryptionKey = await this.#deriveMasterKey( + `metamask/vault/${this.id}/entry/${entry.id}/key/${key}`, + ); + + // Decrypt and parse the value back to JSON. + const data = await decryptData(decryptionKey, entry.value); + return JSON.parse(bytesToString(data)); + } + + /** + * Check if a key is present in the vault. + * + * @param key - Key to be checked. + * @returns True if the key is present, false otherwise. + */ + has(key: string): boolean { + return this.#entries.has(key); } /** * Delete a vault entry. * * @param key - The key to delete. + * @returns True if the entry existed, false otherwise. */ - async delete(key: string): Promise { - if (!this.#entries.has(key)) { - throw new Error('Key does not exist'); - } - - this.#entries.delete(key); + delete(key: string): boolean { + return this.#entries.delete(key); } /** @@ -338,114 +359,51 @@ export class Vault { * @param password - Password to unlock the vault. */ async unlock(password: string): Promise { - if (this.#wrappedMasterKey === null) { - throw new Error('Vault is not initialized'); - } - const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); + + // Unwrap the master key and cache it. this.#cachedMasterKey = await unwrapMasterKey( wrappingKey, - this.#wrappedMasterKey, + ensureNotNull(this.#wrappedMasterKey, 'Vault is not initialized'), jsonToBytes(['vaultId', this.id]), ); } /** - * Derive the Master Key given a list of infos. + * Derive the Master Key given a derivation information. * - * @param infos - Derivation infos. + * @param info - Derivation information. * @returns The handler to the derived key. */ - async #deriveMasterKey(infos: string[]): Promise { - // Make sure that at least one info is provided. - if (infos.length === 0) { - throw new Error('No infos provided'); + async #deriveMasterKey(info: string): Promise { + // Make sure that info is provided. + if (info === '') { + throw new Error('Missing derivation information'); } - // TypeScript isn't happy if we use `isLocked` here, it will say that - // `#masterKey` can be null when we try to await on it. - if (this.#cachedMasterKey === null) { - throw new Error('Vault is locked'); - } - - let derivedKey = this.#cachedMasterKey; - for (const [i, info] of infos.entries()) { - let usages: KeyUsage[]; - let params: HmacKeyGenParams | AesKeyGenParams; - - // Only the last node in the derivation chain can be used to encrypt or - // decrypt data, all intermediate nodes can only be used to derive keys. - if (i === infos.length - 1) { - usages = ['encrypt', 'decrypt']; - params = { - name: 'AES-GCM', - length: 256, - }; - } else { - usages = ['deriveKey']; - params = { - name: 'HMAC', - hash: 'SHA-256', - length: 256, - }; - } - - // Derive the next key from the previous one. - derivedKey = await crypto.subtle.deriveKey( - { - name: 'HKDF', - hash: 'SHA-256', - info: Buffer.from(`metamask:vault:${i}:${info}`), - }, - derivedKey, - params, - false, - usages, - ); - } - - return derivedKey; - } - - /** - * Derive the Wrapping Key given a password. - * - * @param password - The password to derive the wrapping key from. - * @returns The handler to the Wrapping Key. - */ - async #getWrappingKey(password: string): Promise { - const rawKey = await crypto.subtle.importKey( - 'raw', - stringToBytes(password), - 'PBKDF2', - false, - ['deriveKey'], - ); - - const wrappingKey = await crypto.subtle.deriveKey( + return crypto.subtle.deriveKey( { - name: 'PBKDF2', + name: 'HKDF', hash: 'SHA-256', - salt: this.#passwordSalt, - iterations: 600_000, + info: stringToBytes(info), }, - rawKey, + ensureNotNull(this.#cachedMasterKey, 'Vault is locked'), { name: 'AES-GCM', length: 256, }, - true, - ['deriveKey'], + false, + ['encrypt', 'decrypt'], ); - - return wrappingKey; } } export const exportedForTesting = { stringToBytes, + bytesToString, jsonToBytes, randomBytes, + ensureNotNull, generateMasterKey, importPassword, deriveWrappingKey, From 65892b0565855cdcea86f5c989a20915d8e0c0f7 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 19 Apr 2023 23:34:38 +0200 Subject: [PATCH 07/34] chore: fix linting warning --- src/Vault.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 3fbeb964..516a3a12 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -117,6 +117,6 @@ describe('ensureNotNull', () => { it('should throw an error with the specified message if the value is null', () => { const value = null; const errorMessage = 'Error message'; - expect(() => ensureNotNull(value, errorMessage)).toThrowError(errorMessage); + expect(() => ensureNotNull(value, errorMessage)).toThrow(errorMessage); }); }); From df1eafc0988fe77cd91a9ac6dff86988558cac38 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 20 Apr 2023 00:15:46 +0200 Subject: [PATCH 08/34] test: add unit test to `deriveWrappingKey` function --- src/Vault.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 516a3a12..cdac6c1b 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -1,4 +1,4 @@ -// import { Vault } from '.'; +/* eslint-disable no-restricted-globals */ import { exportedForTesting } from './Vault'; const { @@ -7,6 +7,7 @@ const { jsonToBytes, randomBytes, ensureNotNull, + deriveWrappingKey, } = exportedForTesting; describe('stringToBytes', () => { @@ -120,3 +121,47 @@ describe('ensureNotNull', () => { expect(() => ensureNotNull(value, errorMessage)).toThrow(errorMessage); }); }); + +describe('deriveWrappingKey', () => { + it('should derive a wrapping key and do an encryption and obtain the expected ciphertext', async () => { + const pt = new Uint8Array([ + 0xb3, 0x4e, 0x75, 0x12, 0x9f, 0x2b, 0x15, 0x35, 0xa2, 0x95, 0x8c, 0xf3, + 0x83, 0xe2, 0xe2, 0x08, + ]); + + const iv = new Uint8Array([ + 0xd1, 0x37, 0x31, 0x96, 0x41, 0x9b, 0x0d, 0x80, 0x98, 0x7d, 0x57, 0x25, + 0x52, 0x31, 0x0e, 0xd2, + ]); + + const additionalData = stringToBytes('additionalData'); + + // key: 669cfe52482116fda1aa2cbe409b2f56c8e4563752b7a28f6eaab614ee005178 + // source: https://gchq.github.io/CyberChef/#recipe=Derive_PBKDF2_key(%7B'option':'UTF8','string':'password'%7D,256,600000,'SHA256',%7B'option':'UTF8','string':'salt'%7D) + const wrappingKey = await deriveWrappingKey( + 'password', + stringToBytes('salt'), + ); + + // source: https://gchq.github.io/CyberChef/#recipe=AES_Encrypt(%7B'option':'Hex','string':'669cfe52482116fda1aa2cbe409b2f56c8e4563752b7a28f6eaab614ee005178'%7D,%7B'option':'Hex','string':'d1373196419b0d80987d572552310ed2'%7D,'GCM','Hex','Hex',%7B'option':'UTF8','string':'additionalData'%7D)&input=YjM0ZTc1MTI5ZjJiMTUzNWEyOTU4Y2YzODNlMmUyMDg + const expected = new Uint8Array([ + 0x5e, 0x82, 0x04, 0x4f, 0xb7, 0x3e, 0xd3, 0xc7, 0xcb, 0x2a, 0xa7, 0xef, + 0xc2, 0xe2, 0x34, 0xa4, 0x9b, 0x75, 0x0e, 0xf2, 0x59, 0x25, 0x80, 0x43, + 0x47, 0x7f, 0xe2, 0x3f, 0x03, 0x37, 0xfe, 0xfd, + ]); + + const ct = new Uint8Array( + await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData, + }, + wrappingKey, + pt, + ), + ); + + expect(ct).toStrictEqual(expected); + }); +}); From b192f1cb4435dcf30b1aa880d45ef6a627bafbb7 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 20 Apr 2023 12:45:49 +0200 Subject: [PATCH 09/34] test: add unit tests --- src/Vault.test.ts | 209 +++++++++++++++++++++++++++++++++++++++++++++- src/Vault.ts | 154 ++++++++++++++++++++++++++-------- 2 files changed, 324 insertions(+), 39 deletions(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index cdac6c1b..9f2a18f3 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-restricted-globals */ -import { exportedForTesting } from './Vault'; +import { Vault, exportedForTesting } from './Vault'; const { stringToBytes, @@ -7,7 +7,13 @@ const { jsonToBytes, randomBytes, ensureNotNull, + ensureBytes, deriveWrappingKey, + generateMasterKey, + unwrapMasterKey, + deriveMasterKey, + encryptData, + decryptData, } = exportedForTesting; describe('stringToBytes', () => { @@ -120,10 +126,34 @@ describe('ensureNotNull', () => { const errorMessage = 'Error message'; expect(() => ensureNotNull(value, errorMessage)).toThrow(errorMessage); }); + + it('should throw the given error if null', () => { + expect(() => ensureNotNull(null, new Error('foo'))).toThrow('foo'); + }); +}); + +describe('ensureBytes', () => { + it('should return a Uint8Array for a string input', () => { + const result = ensureBytes('hello'); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it('should return the input value for a Uint8Array input', () => { + const input = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]); + const result = ensureBytes(input); + expect(result).toStrictEqual(input); + }); + + it('should convert a string input to the correct bytes', () => { + const input = '✓'; + const result = ensureBytes(input); + const expected = new Uint8Array([0xe2, 0x9c, 0x93]); + expect(result).toStrictEqual(expected); + }); }); describe('deriveWrappingKey', () => { - it('should derive a wrapping key and do an encryption and obtain the expected ciphertext', async () => { + it('should derive a wrapping key and use it to encrypt a known text', async () => { const pt = new Uint8Array([ 0xb3, 0x4e, 0x75, 0x12, 0x9f, 0x2b, 0x15, 0x35, 0xa2, 0x95, 0x8c, 0xf3, 0x83, 0xe2, 0xe2, 0x08, @@ -165,3 +195,178 @@ describe('deriveWrappingKey', () => { expect(ct).toStrictEqual(expected); }); }); + +describe('generateMasterKey', () => { + it('should generate a new master key and wrap it', async () => { + // key: 669cfe52482116fda1aa2cbe409b2f56c8e4563752b7a28f6eaab614ee005178 + // source: https://gchq.github.io/CyberChef/#recipe=Derive_PBKDF2_key(%7B'option':'UTF8','string':'password'%7D,256,600000,'SHA256',%7B'option':'UTF8','string':'salt'%7D) + const wrappingKey = await deriveWrappingKey( + 'password', + stringToBytes('salt'), + ); + + const additionalData = stringToBytes('vault'); + const { wrapped, handler } = await generateMasterKey( + wrappingKey, + additionalData, + ); + expect(handler).toBeDefined(); + expect(wrapped).toBeDefined(); + expect(wrapped.nonce).toHaveLength(12); + expect(wrapped.data).toHaveLength(32 + 16); // key + tag + }); +}); + +describe('unwrapMasterKey', () => { + it('should generate and unwrap the same key', async () => { + // key: 669cfe52482116fda1aa2cbe409b2f56c8e4563752b7a28f6eaab614ee005178 + // source: https://gchq.github.io/CyberChef/#recipe=Derive_PBKDF2_key(%7B'option':'UTF8','string':'password'%7D,256,600000,'SHA256',%7B'option':'UTF8','string':'salt'%7D) + const wrappingKey = await deriveWrappingKey( + 'password', + stringToBytes('salt'), + ); + + const additionalData = stringToBytes('vault'); + const { wrapped, handler } = await generateMasterKey( + wrappingKey, + additionalData, + ); + + const unwrappedKey = await unwrapMasterKey( + wrappingKey, + wrapped, + additionalData, + ); + expect(unwrappedKey).toBeDefined(); + + // Derive keys from the unwrapped and from the generated master key, both + // derived keys should have the same value. + const salt = stringToBytes('salt'); + const encKey1 = await deriveMasterKey(handler, 'info', salt); + const encKey2 = await deriveMasterKey(unwrappedKey, 'info', salt); + + // To check if encKey1 and encKey2 have the same value, we encrypt a text + // using encKey1 and try to decrypt it using encKey2. + const data = stringToBytes('hello world'); + const ct = await encryptData(encKey1, data, stringToBytes('additional')); + const pt = await decryptData(encKey2, ct, stringToBytes('additional')); + expect(pt).toStrictEqual(data); + }); +}); + +describe('Vault', () => { + let vault: Vault; + + beforeEach(() => { + vault = new Vault(); + }); + + it('should check if the vault was created uninitialized', () => { + expect(vault).toBeDefined(); + expect(vault.isInitialized).toBe(false); + expect(vault.isUnlocked).toBe(false); + }); + + it('should initialize the vault', async () => { + await vault.init('password'); + expect(vault.isInitialized).toBe(true); + expect(vault.isUnlocked).toBe(true); + }); + + it('should lock the vault', async () => { + await vault.init('password'); + vault.lock(); + expect(vault.isInitialized).toBe(true); + expect(vault.isUnlocked).toBe(false); + }); + + it('should fail if we try to store a value in an uninitialized vault', async () => { + const value = { keyring: 'test' }; + await expect(vault.set('keyring', value)).rejects.toThrow( + 'Vault is not initialized', + ); + }); + + it('should fail if we try to store a value in a locked vault', async () => { + await vault.init('password'); + vault.lock(); + + const value = { keyring: 'test' }; + await expect(vault.set('keyring', value)).rejects.toThrow( + 'Vault is locked', + ); + }); + + it('should fail if we try to read a value from an uninitialized vault', async () => { + await expect(vault.get('keyring')).rejects.toThrow( + 'Vault is not initialized', + ); + }); + + it('should fail if we try to read a value from a locked vault', async () => { + await vault.init('password'); + vault.lock(); + await expect(vault.get('keyring')).rejects.toThrow('Vault is locked'); + }); + + it('should be possible to set and get a value', async () => { + await vault.init('password'); + const value = { keyring: 'test' }; + await vault.set('keyring', value); + expect(await vault.get('keyring')).toStrictEqual(value); + }); + + it('should be possible to set, lock, unlock, and get a value', async () => { + await vault.init('password'); + const value = { keyring: 'test' }; + await vault.set('keyring', value); + vault.lock(); + await vault.unlock('password'); + expect(await vault.get('keyring')).toStrictEqual(value); + }); + + it('should not be possible to unlock an uninitialized vault', async () => { + await expect(vault.unlock('password')).rejects.toThrow( + 'Vault is not initialized', + ); + }); + + it('should return undefined if we try to get a key that does not exist', async () => { + await vault.init('password'); + expect(await vault.get('foo')).toBeUndefined(); + }); + + it('should have a key that was previouslly inserted', async () => { + await vault.init('password'); + await vault.set('keyring', { keyring: 'test' }); + expect(vault.has('keyring')).toBe(true); + }); + + it('should not have a key that was not inserted', async () => { + await vault.init('password'); + expect(vault.has('keyring')).toBe(false); + }); + + it('should not have a key after it is deleted', async () => { + await vault.init('password'); + await vault.set('keyring', { keyring: 'test' }); + expect(vault.has('keyring')).toBe(true); + vault.delete('keyring'); + expect(vault.has('keyring')).toBe(false); + }); + + it('should be possible to update an existing entry', async () => { + await vault.init('password'); + await vault.set('keyring', { keyring: 'foo' }); + await vault.set('keyring', { keyring: 'bar' }); + expect(await vault.get('keyring')).toStrictEqual({ keyring: 'bar' }); + }); + + it('should fail to unlock a vault using a wrong password', async () => { + await vault.init('password'); + vault.lock(); + await expect(vault.unlock('foobar')).rejects.toThrow( + 'Invalid vault password', + ); + }); +}); diff --git a/src/Vault.ts b/src/Vault.ts index 50289552..15b9a819 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -17,6 +17,8 @@ type VaultEntry = { value: EncryptedData; }; +export class VaultError extends Error {} + // ---------------------------------------------------------------------------- // Private functions @@ -67,12 +69,28 @@ function randomBytes(length: number): Uint8Array { * Ensure that a value is not null. * * @param value - Value to check. - * @param message - Error message in case value is null. + * @param cause - Error cause in case value is null. * @returns The value if it is not null. */ -function ensureNotNull(value: T | null, message: string): T { +function ensureNotNull(value: T | null, cause: string | Error): T { if (value === null) { - throw new Error(message); + if (typeof cause === 'string') { + throw new VaultError(cause); + } + throw cause; + } + return value; +} + +/** + * Ensure that a value is a Uint8Array. + * + * @param value - Value to check or convert. + * @returns A value whose type is Uint8Array. + */ +function ensureBytes(value: string | Uint8Array): Uint8Array { + if (typeof value === 'string') { + return stringToBytes(value); } return value; } @@ -168,6 +186,36 @@ async function unwrapMasterKey( return crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']); } +/** + * Derive the Master Key given a derivation information. + * + * @param masterKey - Master Key to be derived. + * @param info - Derivation information. + * @param salt - Optional salt to be used in the derivation. + * @returns The handler to the derived key. + */ +async function deriveMasterKey( + masterKey: CryptoKey, + info: string | Uint8Array, + salt?: Uint8Array, +): Promise { + return crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + info: ensureBytes(info), + salt: salt ?? new Uint8Array(), + }, + masterKey, + { + name: 'AES-GCM', + length: 256, + }, + false, + ['encrypt', 'decrypt'], + ); +} + /** * Encrypt data with additional data. * @@ -224,7 +272,7 @@ async function decryptData( } // ---------------------------------------------------------------------------- -// Public types +// Main class export class Vault { readonly id: string; @@ -262,6 +310,8 @@ export class Vault { } // TODO: add a static method to create a vault from a serialized state. + // TODO: serialize vault for storage. + // TODO: add method to verify password. /** * Check if the vault is unlocked. @@ -281,6 +331,32 @@ export class Vault { return this.#wrappedMasterKey !== null; } + /** + * Assert that the vault is initialized. + */ + #assertIsInitialized(): void { + if (!this.isInitialized) { + throw new Error('Vault is not initialized'); + } + } + + /** + * Assert that the vault is unlocked. + */ + #assertIsUnlocked(): void { + if (!this.isUnlocked) { + throw new Error('Vault is locked'); + } + } + + /** + * Assert that the vault is initialized and unlocked. + */ + #assertIsOperational(): void { + this.#assertIsInitialized(); + this.#assertIsUnlocked(); + } + /** * Add a new value to the vault. * @@ -288,12 +364,12 @@ export class Vault { * @param value - Value to be encrypted and added to the vault. */ async set(key: string, value: Json): Promise { + this.#assertIsOperational(); + const now = new Date(); const current = this.#entries.get(key); const entryId = current?.id ?? uuidv4(); - const encryptionKey = await this.#deriveMasterKey( - `metamask/vault/${this.id}/entry/${entryId}/key/${key}`, - ); + const encryptionKey = await this.#deriveMasterKey(entryId, key); this.#entries.set(key, { id: entryId, @@ -311,17 +387,16 @@ export class Vault { * not exist. */ async get(key: string): Promise { + this.#assertIsOperational(); + // Return undefined if the key does not exist. const entry = this.#entries.get(key); if (entry === undefined) { return undefined; } - const decryptionKey = await this.#deriveMasterKey( - `metamask/vault/${this.id}/entry/${entry.id}/key/${key}`, - ); - // Decrypt and parse the value back to JSON. + const decryptionKey = await this.#deriveMasterKey(entry.id, key); const data = await decryptData(decryptionKey, entry.value); return JSON.parse(bytesToString(data)); } @@ -333,6 +408,7 @@ export class Vault { * @returns True if the key is present, false otherwise. */ has(key: string): boolean { + this.#assertIsOperational(); return this.#entries.has(key); } @@ -343,57 +419,59 @@ export class Vault { * @returns True if the entry existed, false otherwise. */ delete(key: string): boolean { + this.#assertIsOperational(); return this.#entries.delete(key); } /** * Lock the vault. + * + * Note from the Web Crypto API specification: + * + * > This specification places no normative requirements on how + * > implementations handle key material once all references to it go away. + * > That is, conforming user agents are not required to zeroize key + * > material, and it may still be accessible on device storage or device + * > memory, even after all references to the CryptoKey have gone away. */ lock(): void { this.#cachedMasterKey = null; } /** - * Unlock the vault. + * Unlock the vault and cache the Master Key. * * @param password - Password to unlock the vault. */ async unlock(password: string): Promise { const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); - - // Unwrap the master key and cache it. - this.#cachedMasterKey = await unwrapMasterKey( - wrappingKey, - ensureNotNull(this.#wrappedMasterKey, 'Vault is not initialized'), - jsonToBytes(['vaultId', this.id]), + const wrappedMasterKey = ensureNotNull( + this.#wrappedMasterKey, + 'Vault is not initialized', ); + + try { + this.#cachedMasterKey = await unwrapMasterKey( + wrappingKey, + wrappedMasterKey, + jsonToBytes(['vaultId', this.id]), + ); + } catch (error) { + throw new VaultError('Invalid vault password'); + } } /** * Derive the Master Key given a derivation information. * - * @param info - Derivation information. + * @param entryId - ID of the vault entry. + * @param key - Key of the vault entry. * @returns The handler to the derived key. */ - async #deriveMasterKey(info: string): Promise { - // Make sure that info is provided. - if (info === '') { - throw new Error('Missing derivation information'); - } - - return crypto.subtle.deriveKey( - { - name: 'HKDF', - hash: 'SHA-256', - info: stringToBytes(info), - }, + async #deriveMasterKey(entryId: string, key: string): Promise { + return deriveMasterKey( ensureNotNull(this.#cachedMasterKey, 'Vault is locked'), - { - name: 'AES-GCM', - length: 256, - }, - false, - ['encrypt', 'decrypt'], + `metamask/vault/${this.id}/entry/${entryId}/key/${key}`, ); } } @@ -404,10 +482,12 @@ export const exportedForTesting = { jsonToBytes, randomBytes, ensureNotNull, + ensureBytes, generateMasterKey, importPassword, deriveWrappingKey, unwrapMasterKey, encryptData, decryptData, + deriveMasterKey, }; From edf62729f07b3ea639902ca145fa542212d1b9df Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 20 Apr 2023 13:11:54 +0200 Subject: [PATCH 10/34] feat: add method to verify password --- src/Vault.test.ts | 30 ++++++++++++++++++++++++++++++ src/Vault.ts | 26 +++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 9f2a18f3..b6b6135d 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -369,4 +369,34 @@ describe('Vault', () => { 'Invalid vault password', ); }); + + it('should fail to verify the password on an uninitialized vault', async () => { + expect(await vault.verifyPassword('foo')).toBe(false); + }); + + it('should successful verify the password if it is correct', async () => { + await vault.init('foo'); + expect(await vault.verifyPassword('foo')).toBe(true); + }); + + it('should fail to verify the password if it is incorrect', async () => { + await vault.init('foo'); + expect(await vault.verifyPassword('bar')).toBe(false); + }); + + it('should unlock the vault after the correct password is presented', async () => { + await vault.init('foo'); + vault.lock(); + expect(vault.isUnlocked).toBe(false); + await vault.verifyPassword('foo'); + expect(vault.isUnlocked).toBe(false); + }); + + it('should unlock the vault after a incorrect password is presented', async () => { + await vault.init('foo'); + vault.lock(); + expect(vault.isUnlocked).toBe(false); + await vault.verifyPassword('bar'); + expect(vault.isUnlocked).toBe(false); + }); }); diff --git a/src/Vault.ts b/src/Vault.ts index 15b9a819..486456cc 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -311,7 +311,7 @@ export class Vault { // TODO: add a static method to create a vault from a serialized state. // TODO: serialize vault for storage. - // TODO: add method to verify password. + // TODO: make it thread-safe. /** * Check if the vault is unlocked. @@ -442,8 +442,9 @@ export class Vault { * Unlock the vault and cache the Master Key. * * @param password - Password to unlock the vault. + * @param testOnly - Try to unlock the vault but don't cache the master key. */ - async unlock(password: string): Promise { + async unlock(password: string, testOnly = false): Promise { const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); const wrappedMasterKey = ensureNotNull( this.#wrappedMasterKey, @@ -451,16 +452,35 @@ export class Vault { ); try { - this.#cachedMasterKey = await unwrapMasterKey( + const masterKey = await unwrapMasterKey( wrappingKey, wrappedMasterKey, jsonToBytes(['vaultId', this.id]), ); + + if (!testOnly) { + this.#cachedMasterKey = masterKey; + } } catch (error) { throw new VaultError('Invalid vault password'); } } + /** + * Check if the provided password is correct. + * + * @param password - Password to verify. + * @returns True if the password is correct, false otherwise. + */ + async verifyPassword(password: string): Promise { + try { + await this.unlock(password, true); + return true; + } catch (error) { + return false; + } + } + /** * Derive the Master Key given a derivation information. * From 8d9add9768b45332632bef63cbf71426d6bf8d54 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 20 Apr 2023 15:37:53 +0200 Subject: [PATCH 11/34] chore: add base64 functions and small code refactors --- src/Vault.test.ts | 44 +++++++++++++++++++++++++++++++++++++++ src/Vault.ts | 52 +++++++++++++++++++++++++++++++++-------------- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index b6b6135d..115d146b 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -2,6 +2,8 @@ import { Vault, exportedForTesting } from './Vault'; const { + b64Encode, + b64Decode, stringToBytes, bytesToString, jsonToBytes, @@ -16,6 +18,48 @@ const { decryptData, } = exportedForTesting; +describe('b64Encode', () => { + it('should encode an empty Uint8Array', () => { + const input = new Uint8Array([]); + const result = b64Encode(input); + expect(result).toBe(''); + }); + + it('should encode a simple input', () => { + const input = new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]); + const result = b64Encode(input); + expect(result).toBe('aGVsbG8='); + }); + + it('should encode an input with special characters', () => { + const input = new Uint8Array([0xe2, 0x9c, 0x93]); + const result = b64Encode(input); + expect(result).toBe('4pyT'); + }); +}); + +describe('b64Decode', () => { + it('should decode an empty string', () => { + const input = ''; + const result = b64Decode(input); + expect(result).toStrictEqual(new Uint8Array([])); + }); + + it('should decode a simple input', () => { + const input = 'aGVsbG8='; + const result = b64Decode(input); + expect(result).toStrictEqual( + new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]), + ); + }); + + it('should decode an input with special characters', () => { + const input = '4pyT'; + const result = b64Decode(input); + expect(result).toStrictEqual(new Uint8Array([0xe2, 0x9c, 0x93])); + }); +}); + describe('stringToBytes', () => { it('should return an empty Uint8Array for an empty string', () => { const result = stringToBytes(''); diff --git a/src/Vault.ts b/src/Vault.ts index 486456cc..eb68a41c 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -12,7 +12,7 @@ type EncryptedData = { type VaultEntry = { id: string; - lastUpdatedAt: Date; + modifiedAt: Date; createdAt: Date; value: EncryptedData; }; @@ -22,6 +22,27 @@ export class VaultError extends Error {} // ---------------------------------------------------------------------------- // Private functions +/** + * Encode binary data in base64. + * + * @param data - Data to encode. + * @returns The encoded data. + */ +function b64Encode(data: Uint8Array): string { + return btoa(String.fromCharCode(...data)); +} + +/** + * Decode binary data from a base64 string. + * + * @param data - Encoded data. + * @returns The decoded data. + */ +function b64Decode(data: string): Uint8Array { + // eslint-disable-next-line id-length + return new Uint8Array([...atob(data)].map((c) => c.charCodeAt(0))); +} + /** * Convert a string to bytes. * @@ -54,17 +75,6 @@ function jsonToBytes(data: Json): Uint8Array { return stringToBytes(JSON.stringify(data)); } -/** - * Generate cryptographically secure random bytes. - * - * @param length - Number of bytes to generate. - * @returns Cryptographically secure random bytes. - */ -function randomBytes(length: number): Uint8Array { - const array = new Uint8Array(length); - return crypto.getRandomValues(array); -} - /** * Ensure that a value is not null. * @@ -95,6 +105,17 @@ function ensureBytes(value: string | Uint8Array): Uint8Array { return value; } +/** + * Generate cryptographically secure random bytes. + * + * @param length - Number of bytes to generate. + * @returns Cryptographically secure random bytes. + */ +function randomBytes(length: number): Uint8Array { + const array = new Uint8Array(length); + return crypto.getRandomValues(array); +} + /** * Import a password as a raw key. * @@ -272,7 +293,7 @@ async function decryptData( } // ---------------------------------------------------------------------------- -// Main class +// Main classes export class Vault { readonly id: string; @@ -311,7 +332,6 @@ export class Vault { // TODO: add a static method to create a vault from a serialized state. // TODO: serialize vault for storage. - // TODO: make it thread-safe. /** * Check if the vault is unlocked. @@ -375,7 +395,7 @@ export class Vault { id: entryId, value: await encryptData(encryptionKey, jsonToBytes(value)), createdAt: current?.createdAt ?? now, - lastUpdatedAt: now, + modifiedAt: now, }); } @@ -497,6 +517,8 @@ export class Vault { } export const exportedForTesting = { + b64Encode, + b64Decode, stringToBytes, bytesToString, jsonToBytes, From 3957c05a2f6a294af38e0bd2e07c81287ef6d6a2 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Fri, 21 Apr 2023 12:03:15 +0200 Subject: [PATCH 12/34] refactor: move `ensureNotNull()` to getter methods --- src/Vault.ts | 39 ++++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index eb68a41c..7eba5ca9 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -351,22 +351,40 @@ export class Vault { return this.#wrappedMasterKey !== null; } + /** + * Get the wrapped master key. + * + * This method will throw an error if the vault wasn't initialized. + * + * @returns The wrapped master key. + */ + #getWrappedMasterKey(): EncryptedData { + return ensureNotNull(this.#wrappedMasterKey, 'Vault is not initialized'); + } + + /** + * Get the master key handler. + * + * This method will throw an error if the vault is locked. + * + * @returns The master key handler. + */ + #getCachedMasterKey(): CryptoKey { + return ensureNotNull(this.#cachedMasterKey, 'Vault is locked'); + } + /** * Assert that the vault is initialized. */ #assertIsInitialized(): void { - if (!this.isInitialized) { - throw new Error('Vault is not initialized'); - } + this.#getWrappedMasterKey(); } /** * Assert that the vault is unlocked. */ #assertIsUnlocked(): void { - if (!this.isUnlocked) { - throw new Error('Vault is locked'); - } + this.#getCachedMasterKey(); } /** @@ -465,11 +483,10 @@ export class Vault { * @param testOnly - Try to unlock the vault but don't cache the master key. */ async unlock(password: string, testOnly = false): Promise { + // We must get the wrapped master key _outside_ the try-catch block below + // to distinguish an uninitialized vault from a wrong password. + const wrappedMasterKey = this.#getWrappedMasterKey(); const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); - const wrappedMasterKey = ensureNotNull( - this.#wrappedMasterKey, - 'Vault is not initialized', - ); try { const masterKey = await unwrapMasterKey( @@ -510,7 +527,7 @@ export class Vault { */ async #deriveMasterKey(entryId: string, key: string): Promise { return deriveMasterKey( - ensureNotNull(this.#cachedMasterKey, 'Vault is locked'), + this.#getCachedMasterKey(), `metamask/vault/${this.id}/entry/${entryId}/key/${key}`, ); } From 25e899e867ffbaa44118518eea22f3e3f147661d Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Fri, 21 Apr 2023 13:45:43 +0200 Subject: [PATCH 13/34] chore: remove TODOs --- src/Vault.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index 7eba5ca9..cf25cb68 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -330,9 +330,6 @@ export class Vault { await generateMasterKey(wrappingKey, additionalData)); } - // TODO: add a static method to create a vault from a serialized state. - // TODO: serialize vault for storage. - /** * Check if the vault is unlocked. * From 4f032c6c85d18d314d1037047d5fa80a4fab23fb Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Fri, 21 Apr 2023 14:15:30 +0200 Subject: [PATCH 14/34] test: add more negative tests --- src/Vault.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 115d146b..0d83d95f 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -296,6 +296,25 @@ describe('unwrapMasterKey', () => { const pt = await decryptData(encKey2, ct, stringToBytes('additional')); expect(pt).toStrictEqual(data); }); + + it('should fail if we try to unwrap the master key with the wrong password', async () => { + const wrappingKey1 = await deriveWrappingKey('foo', stringToBytes('salt')); + const wrappingKey2 = await deriveWrappingKey('bar', stringToBytes('salt')); + const { wrapped } = await generateMasterKey(wrappingKey1); + + await expect(unwrapMasterKey(wrappingKey2, wrapped)).rejects.toThrow( + 'The operation failed for an operation-specific reason', + ); + }); + + it('should succeed to unwrap the master key with the correct password', async () => { + const wrappingKey = await deriveWrappingKey('foo', stringToBytes('salt')); + const { wrapped } = await generateMasterKey(wrappingKey); + + expect(async () => { + await unwrapMasterKey(wrappingKey, wrapped); + }).not.toThrow(); + }); }); describe('Vault', () => { From 55b749af2cb4f844533b52e633b49dfd543eac16 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Fri, 21 Apr 2023 17:39:23 +0200 Subject: [PATCH 15/34] chore: add a few comments --- src/Vault.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index cf25cb68..a0c59118 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -20,7 +20,7 @@ type VaultEntry = { export class VaultError extends Error {} // ---------------------------------------------------------------------------- -// Private functions +// Util functions /** * Encode binary data in base64. @@ -105,6 +105,9 @@ function ensureBytes(value: string | Uint8Array): Uint8Array { return value; } +// ---------------------------------------------------------------------------- +// Crypto functions + /** * Generate cryptographically secure random bytes. * @@ -293,14 +296,14 @@ async function decryptData( } // ---------------------------------------------------------------------------- -// Main classes +// Main class export class Vault { - readonly id: string; + public readonly id: string; - #entries: Map; + readonly #entries: Map; - #passwordSalt: Uint8Array; + readonly #passwordSalt: Uint8Array; #wrappedMasterKey: EncryptedData | null; @@ -309,9 +312,11 @@ export class Vault { constructor() { this.id = uuidv4(); this.#entries = new Map(); + this.#passwordSalt = randomBytes(32); + + // The following attributes must be initialized asynchronously. this.#cachedMasterKey = null; this.#wrappedMasterKey = null; - this.#passwordSalt = randomBytes(32); } /** @@ -525,7 +530,7 @@ export class Vault { async #deriveMasterKey(entryId: string, key: string): Promise { return deriveMasterKey( this.#getCachedMasterKey(), - `metamask/vault/${this.id}/entry/${entryId}/key/${key}`, + `metamask:vault:${this.id}:entry:${entryId}:key:${key}`, ); } } From 242302ebdd10e93be0e1b58cc56dbc230210850e Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Mon, 24 Apr 2023 09:50:08 +0200 Subject: [PATCH 16/34] feat: add `rekey` and `changePassword` methods --- src/Vault.test.ts | 88 ++++++++++++++++++++++++++++-- src/Vault.ts | 133 +++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 206 insertions(+), 15 deletions(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 0d83d95f..c5a9cb40 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -13,11 +13,25 @@ const { deriveWrappingKey, generateMasterKey, unwrapMasterKey, - deriveMasterKey, + deriveEncryptionKey, encryptData, decryptData, + reEncryptData, } = exportedForTesting; +/** + * Generate a random encryption key. + * + * @returns A Promise to a key handler. + */ +async function generateKey(): Promise { + return await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ); +} + describe('b64Encode', () => { it('should encode an empty Uint8Array', () => { const input = new Uint8Array([]); @@ -286,14 +300,15 @@ describe('unwrapMasterKey', () => { // Derive keys from the unwrapped and from the generated master key, both // derived keys should have the same value. const salt = stringToBytes('salt'); - const encKey1 = await deriveMasterKey(handler, 'info', salt); - const encKey2 = await deriveMasterKey(unwrappedKey, 'info', salt); + const encKey1 = await deriveEncryptionKey(handler, 'info', salt); + const encKey2 = await deriveEncryptionKey(unwrappedKey, 'info', salt); // To check if encKey1 and encKey2 have the same value, we encrypt a text // using encKey1 and try to decrypt it using encKey2. const data = stringToBytes('hello world'); const ct = await encryptData(encKey1, data, stringToBytes('additional')); const pt = await decryptData(encKey2, ct, stringToBytes('additional')); + expect(ct.nonce?.length).toBe(12); expect(pt).toStrictEqual(data); }); @@ -317,6 +332,42 @@ describe('unwrapMasterKey', () => { }); }); +describe('reEncryptData', () => { + it('should re-encrypt data', async () => { + const decryptionKey = await generateKey(); + const encryptionKey = await generateKey(); + const data = stringToBytes('test'); + const encrypted = await encryptData(decryptionKey, data); + const reEncrypted = await reEncryptData( + decryptionKey, + encryptionKey, + encrypted, + ); + const decrypted = await decryptData(encryptionKey, reEncrypted); + expect(decrypted).toStrictEqual(data); + }); + + it('should re-encrypt data with additional data', async () => { + const decryptionKey = await generateKey(); + const encryptionKey = await generateKey(); + const data = stringToBytes('test'); + const additionalData = stringToBytes('metadata'); + const encrypted = await encryptData(decryptionKey, data, additionalData); + const reEncrypted = await reEncryptData( + decryptionKey, + encryptionKey, + encrypted, + additionalData, + ); + const decrypted = await decryptData( + encryptionKey, + reEncrypted, + additionalData, + ); + expect(decrypted).toStrictEqual(data); + }); +}); + describe('Vault', () => { let vault: Vault; @@ -462,4 +513,35 @@ describe('Vault', () => { await vault.verifyPassword('bar'); expect(vault.isUnlocked).toBe(false); }); + + it('should change the vault password', async () => { + await vault.init('foo'); + await vault.changePassword('foo', 'bar'); + expect(await vault.verifyPassword('foo')).toBe(false); + expect(await vault.verifyPassword('bar')).toBe(true); + }); + + it('should change the password of a locked vault', async () => { + await vault.init('foo'); + vault.lock(); + await vault.changePassword('foo', 'bar'); + expect(await vault.verifyPassword('foo')).toBe(false); + expect(await vault.verifyPassword('bar')).toBe(true); + }); + + it('should be possible to get a value after a password change', async () => { + await vault.init('foo'); + const value = { example: 123 }; + await vault.set('test', value); + await vault.changePassword('foo', 'bar'); + expect(await vault.get('test')).toStrictEqual(value); + }); + + it('should be possible to get a value after rekeying the vault', async () => { + await vault.init('foo'); + const value = { example: 123 }; + await vault.set('test', value); + await vault.rekey('foo'); + expect(await vault.get('test')).toStrictEqual(value); + }); }); diff --git a/src/Vault.ts b/src/Vault.ts index a0c59118..0dc2e619 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; // Types type EncryptedData = { - nonce: Uint8Array; + nonce?: Uint8Array; data: Uint8Array; }; @@ -211,14 +211,33 @@ async function unwrapMasterKey( } /** - * Derive the Master Key given a derivation information. + * Unwrap and re-wrap a key. * - * @param masterKey - Master Key to be derived. + * @param unwrappingKey - Unwrapping key. + * @param wrappingKey - Wrapping key. + * @param wrappedKey - Key to re-wrap. + * @param additionalData - Additional data. + * @returns The re-wrapped key. + */ +async function reWrapMasterKey( + unwrappingKey: CryptoKey, + wrappingKey: CryptoKey, + wrappedKey: EncryptedData, + additionalData?: Uint8Array, +): Promise { + const rawKey = await decryptData(unwrappingKey, wrappedKey, additionalData); + return encryptData(wrappingKey, rawKey, additionalData); +} + +/** + * Derive an encryption key from the master key. + * + * @param masterKey - Master key to derived from. * @param info - Derivation information. * @param salt - Optional salt to be used in the derivation. * @returns The handler to the derived key. */ -async function deriveMasterKey( +async function deriveEncryptionKey( masterKey: CryptoKey, info: string | Uint8Array, salt?: Uint8Array, @@ -295,15 +314,34 @@ async function decryptData( ); } +/** + * Decrypt and re-encrypt data. + * + * @param decryptionKey - Decryption key. + * @param encryptionKey - Encryption key. + * @param ciphertext - Encrypted data. + * @param additionalData - Additional data. + * @returns The re-encrypted data. + */ +async function reEncryptData( + decryptionKey: CryptoKey, + encryptionKey: CryptoKey, + ciphertext: EncryptedData, + additionalData?: Uint8Array, +): Promise { + const data = await decryptData(decryptionKey, ciphertext, additionalData); + return encryptData(encryptionKey, data, additionalData); +} + // ---------------------------------------------------------------------------- // Main class export class Vault { public readonly id: string; - readonly #entries: Map; + #entries: Map; - readonly #passwordSalt: Uint8Array; + #passwordSalt: Uint8Array; #wrappedMasterKey: EncryptedData | null; @@ -409,7 +447,7 @@ export class Vault { const now = new Date(); const current = this.#entries.get(key); const entryId = current?.id ?? uuidv4(); - const encryptionKey = await this.#deriveMasterKey(entryId, key); + const encryptionKey = await this.#deriveEncryptionKey(entryId, key); this.#entries.set(key, { id: entryId, @@ -436,7 +474,7 @@ export class Vault { } // Decrypt and parse the value back to JSON. - const decryptionKey = await this.#deriveMasterKey(entry.id, key); + const decryptionKey = await this.#deriveEncryptionKey(entry.id, key); const data = await decryptData(decryptionKey, entry.value); return JSON.parse(bytesToString(data)); } @@ -463,6 +501,67 @@ export class Vault { return this.#entries.delete(key); } + /** + * Change the vault master key. + * + * @param password - Vault password. + */ + async rekey(password: string): Promise { + const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); + const { wrapped: mkWrapped, handler: mkHandler } = await generateMasterKey( + wrappingKey, + jsonToBytes(['vaultId', this.id]), + ); + + const newEntries = new Map(); + for (const [key, entry] of this.#entries.entries()) { + newEntries.set(key, { + ...entry, + value: await reEncryptData( + await this.#deriveEncryptionKey(entry.id, key), + await this.#deriveEncryptionKey(entry.id, key, mkHandler), + entry.value, + ), + }); + } + + // Update all fields "at once". + this.#cachedMasterKey = mkHandler; + this.#wrappedMasterKey = mkWrapped; + this.#entries = newEntries; + } + + /** + * Change the vault password and salt. + * + * @param oldPassword - Current password. + * @param newPassword - New password. + */ + async changePassword( + oldPassword: string, + newPassword: string, + ): Promise { + const oldWrappingKey = await deriveWrappingKey( + oldPassword, + this.#passwordSalt, + ); + + const newPasswordSalt = randomBytes(32); + const newWrappingKey = await deriveWrappingKey( + newPassword, + newPasswordSalt, + ); + + // Update the password salt _after_ setting the wrapped master key. + this.#wrappedMasterKey = await reWrapMasterKey( + oldWrappingKey, + newWrappingKey, + this.#getWrappedMasterKey(), + jsonToBytes(['vaultId', this.id]), + ); + this.#passwordSalt = newPasswordSalt; + } + /** * Lock the vault. * @@ -523,13 +622,21 @@ export class Vault { /** * Derive the Master Key given a derivation information. * + * If a master key is provided, it will be used instead of the cached master + * key. + * * @param entryId - ID of the vault entry. * @param key - Key of the vault entry. + * @param masterKey - Optional master key. * @returns The handler to the derived key. */ - async #deriveMasterKey(entryId: string, key: string): Promise { - return deriveMasterKey( - this.#getCachedMasterKey(), + async #deriveEncryptionKey( + entryId: string, + key: string, + masterKey?: CryptoKey, + ): Promise { + return deriveEncryptionKey( + masterKey ?? this.#getCachedMasterKey(), `metamask:vault:${this.id}:entry:${entryId}:key:${key}`, ); } @@ -550,5 +657,7 @@ export const exportedForTesting = { unwrapMasterKey, encryptData, decryptData, - deriveMasterKey, + deriveEncryptionKey, + reEncryptData, + reWrapMasterKey, }; From 8902205f34ddfe22ecfdab5d462520ab75de0ef7 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 25 Apr 2023 11:26:52 +0200 Subject: [PATCH 17/34] feat: add methods to serialize and deserialize a vault --- src/Vault.test.ts | 41 ++++++++++++++--- src/Vault.ts | 111 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index c5a9cb40..69769606 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -1,4 +1,6 @@ /* eslint-disable no-restricted-globals */ +import { Json } from '@metamask/utils'; + import { Vault, exportedForTesting } from './Vault'; const { @@ -8,6 +10,7 @@ const { bytesToString, jsonToBytes, randomBytes, + ensureLength, ensureNotNull, ensureBytes, deriveWrappingKey, @@ -172,6 +175,21 @@ describe('randomBytes', () => { }); }); +describe('ensureLength', () => { + it('returns input when length matches', () => { + const input = new Uint8Array([1, 2, 3]); + const result = ensureLength(input, 3); + expect(result).toStrictEqual(input); + }); + + it('throws an error when length does not match', () => { + const input = new Uint8Array([1, 2, 3]); + expect(() => { + ensureLength(input, 4); + }).toThrow('Invalid length: expected 4, got 3'); + }); +}); + describe('ensureNotNull', () => { it('should return the value if it is not null', () => { const value = 'hello'; @@ -184,10 +202,6 @@ describe('ensureNotNull', () => { const errorMessage = 'Error message'; expect(() => ensureNotNull(value, errorMessage)).toThrow(errorMessage); }); - - it('should throw the given error if null', () => { - expect(() => ensureNotNull(null, new Error('foo'))).toThrow('foo'); - }); }); describe('ensureBytes', () => { @@ -308,7 +322,7 @@ describe('unwrapMasterKey', () => { const data = stringToBytes('hello world'); const ct = await encryptData(encKey1, data, stringToBytes('additional')); const pt = await decryptData(encKey2, ct, stringToBytes('additional')); - expect(ct.nonce?.length).toBe(12); + expect(ct.nonce).toHaveLength(12); expect(pt).toStrictEqual(data); }); @@ -369,7 +383,7 @@ describe('reEncryptData', () => { }); describe('Vault', () => { - let vault: Vault; + let vault: Vault; beforeEach(() => { vault = new Vault(); @@ -544,4 +558,19 @@ describe('Vault', () => { await vault.rekey('foo'); expect(await vault.get('test')).toStrictEqual(value); }); + + it('should serialize and deserialize a vault', async () => { + await vault.init('foo'); + const value1 = { answer: 42 }; + const value2 = { answer: 42, verified: true }; + await vault.set('test-1', value1); + await vault.set('test-2', value2); + + const serialized = vault.getState(); + const newVault = new Vault(serialized); + await newVault.unlock('foo'); + expect(await newVault.get('test-1')).toStrictEqual(value1); + expect(await newVault.get('test-2')).toStrictEqual(value2); + expect(newVault.getState()).toStrictEqual(serialized); + }); }); diff --git a/src/Vault.ts b/src/Vault.ts index 0dc2e619..cdab3806 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid'; // Types type EncryptedData = { - nonce?: Uint8Array; + nonce: Uint8Array; data: Uint8Array; }; @@ -82,16 +82,29 @@ function jsonToBytes(data: Json): Uint8Array { * @param cause - Error cause in case value is null. * @returns The value if it is not null. */ -function ensureNotNull(value: T | null, cause: string | Error): T { +function ensureNotNull(value: T | null, cause: string): T { if (value === null) { - if (typeof cause === 'string') { - throw new VaultError(cause); - } - throw cause; + throw new VaultError(cause); } return value; } +/** + * Ensure that a Uint8Array has the expected length. + * + * @param data - Data array. + * @param length - Expected length. + * @returns The same data array. + */ +function ensureLength(data: Uint8Array, length: number): Uint8Array { + if (data.length !== length) { + throw new VaultError( + `Invalid length: expected ${length}, got ${data.length}`, + ); + } + return data; +} + /** * Ensure that a value is a Uint8Array. * @@ -336,7 +349,23 @@ async function reEncryptData( // ---------------------------------------------------------------------------- // Main class -export class Vault { +type EncryptedDataState = { nonce: string; data: string }; + +type VaultEntryState = { + id: string; + value: EncryptedDataState; + createdAt: string; + modifiedAt: string; +}; + +type VaultState = { + id: string; + salt: string; + key: EncryptedDataState; + entries: Record; +}; + +export class Vault { public readonly id: string; #entries: Map; @@ -347,14 +376,34 @@ export class Vault { #cachedMasterKey: CryptoKey | null; - constructor() { - this.id = uuidv4(); + constructor(state?: VaultState) { this.#entries = new Map(); - this.#passwordSalt = randomBytes(32); - // The following attributes must be initialized asynchronously. + if (state === undefined) { + this.id = uuidv4(); + this.#passwordSalt = randomBytes(32); + this.#wrappedMasterKey = null; + } else { + this.id = state.id; + this.#passwordSalt = ensureLength(b64Decode(state.salt), 32); + this.#wrappedMasterKey = { + nonce: b64Decode(state.key.nonce), + data: b64Decode(state.key.data), + }; + for (const [key, entry] of Object.entries(state.entries)) { + this.#entries.set(key, { + ...entry, + createdAt: new Date(entry.createdAt), + modifiedAt: new Date(entry.modifiedAt), + value: { + nonce: ensureLength(b64Decode(entry.value.nonce), 12), + data: b64Decode(entry.value.data), + }, + }); + } + } + this.#cachedMasterKey = null; - this.#wrappedMasterKey = null; } /** @@ -441,7 +490,7 @@ export class Vault { * @param key - Key to store the value under. * @param value - Value to be encrypted and added to the vault. */ - async set(key: string, value: Json): Promise { + async set(key: string, value: Value): Promise { this.#assertIsOperational(); const now = new Date(); @@ -464,7 +513,7 @@ export class Vault { * @returns The value associated with the key or undefined if the key does * not exist. */ - async get(key: string): Promise { + async get(key: string): Promise { this.#assertIsOperational(); // Return undefined if the key does not exist. @@ -473,7 +522,7 @@ export class Vault { return undefined; } - // Decrypt and parse the value back to JSON. + // Decrypt and parse the value back to an object. const decryptionKey = await this.#deriveEncryptionKey(entry.id, key); const data = await decryptData(decryptionKey, entry.value); return JSON.parse(bytesToString(data)); @@ -640,6 +689,37 @@ export class Vault { `metamask:vault:${this.id}:entry:${entryId}:key:${key}`, ); } + + /** + * Get the vault's serialized state. + * + * @returns The vault's serialized state. + */ + getState(): VaultState { + const encodeEncrypted = (encrypted: EncryptedData) => { + return { + nonce: b64Encode(encrypted.nonce), + data: b64Encode(encrypted.data), + }; + }; + + const entries = new Map(); + for (const [key, value] of this.#entries.entries()) { + entries.set(key, { + ...value, + value: encodeEncrypted(value.value), + modifiedAt: value.modifiedAt.toISOString(), + createdAt: value.modifiedAt.toISOString(), + }); + } + + return { + id: this.id, + salt: b64Encode(this.#passwordSalt), + key: encodeEncrypted(this.#getWrappedMasterKey()), + entries: Object.fromEntries(entries), + }; + } } export const exportedForTesting = { @@ -649,6 +729,7 @@ export const exportedForTesting = { bytesToString, jsonToBytes, randomBytes, + ensureLength, ensureNotNull, ensureBytes, generateMasterKey, From 18e29216596a65e071c6cc0d28010ede4e27a486 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 25 Apr 2023 11:38:50 +0200 Subject: [PATCH 18/34] refactor: factorize encode/decode state methods --- src/Vault.ts | 88 ++++++++++++++++++++++++++++++---------------------- 1 file changed, 51 insertions(+), 37 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index cdab3806..54bd54c3 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -17,6 +17,22 @@ type VaultEntry = { value: EncryptedData; }; +type EncryptedDataState = { nonce: string; data: string }; + +type VaultEntryState = { + id: string; + value: EncryptedDataState; + createdAt: string; + modifiedAt: string; +}; + +type VaultState = { + id: string; + salt: string; + key: EncryptedDataState; + entries: Record; +}; + export class VaultError extends Error {} // ---------------------------------------------------------------------------- @@ -118,6 +134,32 @@ function ensureBytes(value: string | Uint8Array): Uint8Array { return value; } +/** + * Encode an EncryptedData object. + * + * @param data - Encrypted data. + * @returns The encoded encrypted data. + */ +function encodeEncryptedData(data: EncryptedData): EncryptedDataState { + return { + nonce: b64Encode(data.nonce), + data: b64Encode(data.data), + }; +} + +/** + * Decode a serialized EncryptedData object. + * + * @param state - Encoded encrypted data. + * @returns A decoded EncryptedData object. + */ +function decodeEncryptedData(state: EncryptedDataState): EncryptedData { + return { + nonce: ensureLength(b64Decode(state.nonce), 12), + data: b64Decode(state.data), + }; +} + // ---------------------------------------------------------------------------- // Crypto functions @@ -349,22 +391,6 @@ async function reEncryptData( // ---------------------------------------------------------------------------- // Main class -type EncryptedDataState = { nonce: string; data: string }; - -type VaultEntryState = { - id: string; - value: EncryptedDataState; - createdAt: string; - modifiedAt: string; -}; - -type VaultState = { - id: string; - salt: string; - key: EncryptedDataState; - entries: Record; -}; - export class Vault { public readonly id: string; @@ -386,19 +412,13 @@ export class Vault { } else { this.id = state.id; this.#passwordSalt = ensureLength(b64Decode(state.salt), 32); - this.#wrappedMasterKey = { - nonce: b64Decode(state.key.nonce), - data: b64Decode(state.key.data), - }; + this.#wrappedMasterKey = decodeEncryptedData(state.key); for (const [key, entry] of Object.entries(state.entries)) { this.#entries.set(key, { ...entry, createdAt: new Date(entry.createdAt), modifiedAt: new Date(entry.modifiedAt), - value: { - nonce: ensureLength(b64Decode(entry.value.nonce), 12), - data: b64Decode(entry.value.data), - }, + value: decodeEncryptedData(entry.value), }); } } @@ -696,27 +716,21 @@ export class Vault { * @returns The vault's serialized state. */ getState(): VaultState { - const encodeEncrypted = (encrypted: EncryptedData) => { - return { - nonce: b64Encode(encrypted.nonce), - data: b64Encode(encrypted.data), - }; - }; - + // Create a map with the serialized vault entries. const entries = new Map(); - for (const [key, value] of this.#entries.entries()) { + for (const [key, entry] of this.#entries.entries()) { entries.set(key, { - ...value, - value: encodeEncrypted(value.value), - modifiedAt: value.modifiedAt.toISOString(), - createdAt: value.modifiedAt.toISOString(), + ...entry, + value: encodeEncryptedData(entry.value), + modifiedAt: entry.modifiedAt.toISOString(), + createdAt: entry.modifiedAt.toISOString(), }); } return { id: this.id, salt: b64Encode(this.#passwordSalt), - key: encodeEncrypted(this.#getWrappedMasterKey()), + key: encodeEncryptedData(this.#getWrappedMasterKey()), entries: Object.fromEntries(entries), }; } From f5b01beac65cb9cd99f65e27df3efa0f6fdd2771 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 25 Apr 2023 11:42:32 +0200 Subject: [PATCH 19/34] fix: prevent a vault from being initialized twice --- src/Vault.test.ts | 7 +++++++ src/Vault.ts | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 69769606..5230d43d 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -389,6 +389,13 @@ describe('Vault', () => { vault = new Vault(); }); + it('should throw an error if we try to initialize an already initialized vault', async () => { + await vault.init('foo'); + await expect(vault.init('foo')).rejects.toThrow( + 'Vault is already initialized', + ); + }); + it('should check if the vault was created uninitialized', () => { expect(vault).toBeDefined(); expect(vault.isInitialized).toBe(false); diff --git a/src/Vault.ts b/src/Vault.ts index 54bd54c3..343b21a2 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -435,6 +435,10 @@ export class Vault { * @param password - Vault's password. */ async init(password: string): Promise { + if (this.isInitialized) { + throw new VaultError('Vault is already initialized'); + } + const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); const additionalData = jsonToBytes(['vaultId', this.id]); From 7ef0c7e298c72445bfa23bad9117a0c84ba2887a Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 25 Apr 2023 13:17:49 +0200 Subject: [PATCH 20/34] chore: add constructor documentation --- src/Vault.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Vault.ts b/src/Vault.ts index 343b21a2..d6f8b35f 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -402,6 +402,11 @@ export class Vault { #cachedMasterKey: CryptoKey | null; + /** + * Create a new vault. + * + * @param state - Existing serialized vault state. + */ constructor(state?: VaultState) { this.#entries = new Map(); From 80ce67264c9fcb53ff2c02a8a97d365e466438d7 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Tue, 25 Apr 2023 22:28:24 +0200 Subject: [PATCH 21/34] refactor: make `init` private and replace it by unlock --- src/Vault.test.ts | 56 ++++++++++++++++++----------------------------- src/Vault.ts | 27 ++++++++++++++++------- 2 files changed, 40 insertions(+), 43 deletions(-) diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 5230d43d..c68fd41d 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -389,27 +389,19 @@ describe('Vault', () => { vault = new Vault(); }); - it('should throw an error if we try to initialize an already initialized vault', async () => { - await vault.init('foo'); - await expect(vault.init('foo')).rejects.toThrow( - 'Vault is already initialized', - ); - }); - it('should check if the vault was created uninitialized', () => { - expect(vault).toBeDefined(); expect(vault.isInitialized).toBe(false); expect(vault.isUnlocked).toBe(false); }); it('should initialize the vault', async () => { - await vault.init('password'); + await vault.unlock('password'); expect(vault.isInitialized).toBe(true); expect(vault.isUnlocked).toBe(true); }); it('should lock the vault', async () => { - await vault.init('password'); + await vault.unlock('password'); vault.lock(); expect(vault.isInitialized).toBe(true); expect(vault.isUnlocked).toBe(false); @@ -423,7 +415,7 @@ describe('Vault', () => { }); it('should fail if we try to store a value in a locked vault', async () => { - await vault.init('password'); + await vault.unlock('password'); vault.lock(); const value = { keyring: 'test' }; @@ -439,20 +431,20 @@ describe('Vault', () => { }); it('should fail if we try to read a value from a locked vault', async () => { - await vault.init('password'); + await vault.unlock('password'); vault.lock(); await expect(vault.get('keyring')).rejects.toThrow('Vault is locked'); }); it('should be possible to set and get a value', async () => { - await vault.init('password'); + await vault.unlock('password'); const value = { keyring: 'test' }; await vault.set('keyring', value); expect(await vault.get('keyring')).toStrictEqual(value); }); it('should be possible to set, lock, unlock, and get a value', async () => { - await vault.init('password'); + await vault.unlock('password'); const value = { keyring: 'test' }; await vault.set('keyring', value); vault.lock(); @@ -460,30 +452,24 @@ describe('Vault', () => { expect(await vault.get('keyring')).toStrictEqual(value); }); - it('should not be possible to unlock an uninitialized vault', async () => { - await expect(vault.unlock('password')).rejects.toThrow( - 'Vault is not initialized', - ); - }); - it('should return undefined if we try to get a key that does not exist', async () => { - await vault.init('password'); + await vault.unlock('password'); expect(await vault.get('foo')).toBeUndefined(); }); it('should have a key that was previouslly inserted', async () => { - await vault.init('password'); + await vault.unlock('password'); await vault.set('keyring', { keyring: 'test' }); expect(vault.has('keyring')).toBe(true); }); it('should not have a key that was not inserted', async () => { - await vault.init('password'); + await vault.unlock('password'); expect(vault.has('keyring')).toBe(false); }); it('should not have a key after it is deleted', async () => { - await vault.init('password'); + await vault.unlock('password'); await vault.set('keyring', { keyring: 'test' }); expect(vault.has('keyring')).toBe(true); vault.delete('keyring'); @@ -491,14 +477,14 @@ describe('Vault', () => { }); it('should be possible to update an existing entry', async () => { - await vault.init('password'); + await vault.unlock('password'); await vault.set('keyring', { keyring: 'foo' }); await vault.set('keyring', { keyring: 'bar' }); expect(await vault.get('keyring')).toStrictEqual({ keyring: 'bar' }); }); it('should fail to unlock a vault using a wrong password', async () => { - await vault.init('password'); + await vault.unlock('password'); vault.lock(); await expect(vault.unlock('foobar')).rejects.toThrow( 'Invalid vault password', @@ -510,17 +496,17 @@ describe('Vault', () => { }); it('should successful verify the password if it is correct', async () => { - await vault.init('foo'); + await vault.unlock('foo'); expect(await vault.verifyPassword('foo')).toBe(true); }); it('should fail to verify the password if it is incorrect', async () => { - await vault.init('foo'); + await vault.unlock('foo'); expect(await vault.verifyPassword('bar')).toBe(false); }); it('should unlock the vault after the correct password is presented', async () => { - await vault.init('foo'); + await vault.unlock('foo'); vault.lock(); expect(vault.isUnlocked).toBe(false); await vault.verifyPassword('foo'); @@ -528,7 +514,7 @@ describe('Vault', () => { }); it('should unlock the vault after a incorrect password is presented', async () => { - await vault.init('foo'); + await vault.unlock('foo'); vault.lock(); expect(vault.isUnlocked).toBe(false); await vault.verifyPassword('bar'); @@ -536,14 +522,14 @@ describe('Vault', () => { }); it('should change the vault password', async () => { - await vault.init('foo'); + await vault.unlock('foo'); await vault.changePassword('foo', 'bar'); expect(await vault.verifyPassword('foo')).toBe(false); expect(await vault.verifyPassword('bar')).toBe(true); }); it('should change the password of a locked vault', async () => { - await vault.init('foo'); + await vault.unlock('foo'); vault.lock(); await vault.changePassword('foo', 'bar'); expect(await vault.verifyPassword('foo')).toBe(false); @@ -551,7 +537,7 @@ describe('Vault', () => { }); it('should be possible to get a value after a password change', async () => { - await vault.init('foo'); + await vault.unlock('foo'); const value = { example: 123 }; await vault.set('test', value); await vault.changePassword('foo', 'bar'); @@ -559,7 +545,7 @@ describe('Vault', () => { }); it('should be possible to get a value after rekeying the vault', async () => { - await vault.init('foo'); + await vault.unlock('foo'); const value = { example: 123 }; await vault.set('test', value); await vault.rekey('foo'); @@ -567,7 +553,7 @@ describe('Vault', () => { }); it('should serialize and deserialize a vault', async () => { - await vault.init('foo'); + await vault.unlock('foo'); const value1 = { answer: 42 }; const value2 = { answer: 42, verified: true }; await vault.set('test-1', value1); diff --git a/src/Vault.ts b/src/Vault.ts index d6f8b35f..4db10233 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -224,11 +224,11 @@ async function deriveWrappingKey( } /** - * Generate and wrap a random Master Key. + * Generate and wrap a random master key. * * @param wrappingKey - Wrapping key handler. * @param additionalData - Additional data. - * @returns The wrapped Master Key and its handler. + * @returns The wrapped master key and its handler. */ async function generateMasterKey( wrappingKey: CryptoKey, @@ -249,12 +249,12 @@ async function generateMasterKey( } /** - * Unwrap and import the Master Key. + * Unwrap and import the master key. * * @param unwrappingKey - Unwrapping key handler. * @param wrappedKey - Wrapped key data. * @param additionalData - Additional data. - * @returns Handler to the Master Key. + * @returns Handler to the master key. */ async function unwrapMasterKey( unwrappingKey: CryptoKey, @@ -405,6 +405,9 @@ export class Vault { /** * Create a new vault. * + * If a state is given, the vault will be restored form it, otherwise, a new + * vault will be created. + * * @param state - Existing serialized vault state. */ constructor(state?: VaultState) { @@ -437,9 +440,11 @@ export class Vault { * This method MUST to be called after the vault creation, otherwise the * master key will not be generated. * - * @param password - Vault's password. + * @param password - Vault password. */ - async init(password: string): Promise { + async #init(password: string): Promise { + // istanbul ignore next: This check should always be false, but we leave it + // as a fail safe to prevent the master key from being overwritten. if (this.isInitialized) { throw new VaultError('Vault is already initialized'); } @@ -656,12 +661,18 @@ export class Vault { } /** - * Unlock the vault and cache the Master Key. + * Unlock the vault and cache the master key. + * + * Note that unlocking will also initialize an uninitialized vault. * * @param password - Password to unlock the vault. * @param testOnly - Try to unlock the vault but don't cache the master key. */ async unlock(password: string, testOnly = false): Promise { + if (!this.isInitialized && !testOnly) { + await this.#init(password); + } + // We must get the wrapped master key _outside_ the try-catch block below // to distinguish an uninitialized vault from a wrong password. const wrappedMasterKey = this.#getWrappedMasterKey(); @@ -698,7 +709,7 @@ export class Vault { } /** - * Derive the Master Key given a derivation information. + * Derive the master key given a derivation information. * * If a master key is provided, it will be used instead of the cached master * key. From 66533ec1f98aa0867b3d96b9ea914e15622b47a1 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 26 Apr 2023 15:08:02 +0200 Subject: [PATCH 22/34] chore: add vault format version --- src/Vault.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Vault.ts b/src/Vault.ts index 4db10233..71629e1a 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -27,6 +27,7 @@ type VaultEntryState = { }; type VaultState = { + version: '2'; id: string; salt: string; key: EncryptedDataState; @@ -748,6 +749,7 @@ export class Vault { } return { + version: '2', id: this.id, salt: b64Encode(this.#passwordSalt), key: encodeEncryptedData(this.#getWrappedMasterKey()), From 97eb09cdad7d82a0c21e3cc7e5c7f12019a249f5 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 26 Apr 2023 17:47:31 +0200 Subject: [PATCH 23/34] chore: import crypto module (wip) --- package.json | 1 + src/Vault.test.ts | 1 + src/Vault.ts | 2 ++ yarn.lock | 8 ++++++++ 4 files changed, 12 insertions(+) diff --git a/package.json b/package.json index 2a0158ab..6783a15d 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@metamask/eth-sig-util": "5.0.2", "@metamask/eth-simple-keyring": "^5.0.0", "@metamask/utils": "^5.0.1", + "@types/node": "^18.16.1", "@types/uuid": "^9.0.1", "obs-store": "^4.0.3", "uuid": "^9.0.0" diff --git a/src/Vault.test.ts b/src/Vault.test.ts index c68fd41d..2a875dfe 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -1,5 +1,6 @@ /* eslint-disable no-restricted-globals */ import { Json } from '@metamask/utils'; +import { webcrypto as crypto } from 'crypto'; import { Vault, exportedForTesting } from './Vault'; diff --git a/src/Vault.ts b/src/Vault.ts index 71629e1a..26161b92 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -1,5 +1,7 @@ /* eslint-disable no-restricted-globals */ import { Json } from '@metamask/utils'; +// eslint-disable-next-line import/no-nodejs-modules +import { webcrypto as crypto } from 'crypto'; import { v4 as uuidv4 } from 'uuid'; // ---------------------------------------------------------------------------- diff --git a/yarn.lock b/yarn.lock index 97277b53..c16e9ea7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1446,6 +1446,7 @@ __metadata: "@metamask/eth-simple-keyring": ^5.0.0 "@metamask/utils": ^5.0.1 "@types/jest": ^29.4.0 + "@types/node": ^18.16.1 "@types/sinon": ^10.0.13 "@types/uuid": ^9.0.1 "@typescript-eslint/eslint-plugin": ^5.55.0 @@ -1888,6 +1889,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^18.16.1": + version: 18.16.1 + resolution: "@types/node@npm:18.16.1" + checksum: 799026b949a48993cba7c9b81b2eabfdfb34c880744cb44c1c990fbedc9e315f3634d126eb2cf9a6e0795577c01016e2326d98565bef695ada9d363fadeb6946 + languageName: node + linkType: hard + "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" From 71d886bc30d268a2a8363902dc608b1624157d75 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Wed, 26 Apr 2023 17:51:16 +0200 Subject: [PATCH 24/34] chore: don't test with node 14 --- .github/workflows/build-lint-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 164d6c94..0b711268 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -24,7 +24,7 @@ jobs: - prepare strategy: matrix: - node-version: [14.x, 16.x, 18.x, 19.x] + node-version: [16.x, 18.x, 19.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -49,7 +49,7 @@ jobs: - prepare strategy: matrix: - node-version: [14.x, 16.x, 18.x, 19.x] + node-version: [16.x, 18.x, 19.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} @@ -80,7 +80,7 @@ jobs: - prepare strategy: matrix: - node-version: [14.x, 16.x, 18.x, 19.x] + node-version: [16.x, 18.x, 19.x] steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} From 4fe958592e232eb51785dd8c7d9bd58acb943d99 Mon Sep 17 00:00:00 2001 From: Daniel Rocha Date: Thu, 27 Apr 2023 10:38:49 +0200 Subject: [PATCH 25/34] chore: increase tests timeout value --- jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jest.config.js b/jest.config.js index 408e18b9..1d1ca437 100644 --- a/jest.config.js +++ b/jest.config.js @@ -23,5 +23,5 @@ module.exports = { restoreMocks: true, testEnvironment: 'node', testRegex: ['\\.test\\.(ts|js)$'], - testTimeout: 2500, + testTimeout: 5000, }; From 4c9c358a5a8c6061681e5740a8702203b61ba57d Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 27 Apr 2023 17:51:49 +0200 Subject: [PATCH 26/34] feat: validate vault state --- package.json | 1 + src/Vault.test.ts | 20 +++++++++- src/Vault.ts | 95 ++++++++++++++++++++++++++++++++++++++++++----- yarn.lock | 27 ++++++++++++++ 4 files changed, 133 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 6783a15d..9bb055da 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@metamask/utils": "^5.0.1", "@types/node": "^18.16.1", "@types/uuid": "^9.0.1", + "ajv": "^8.12.0", "obs-store": "^4.0.3", "uuid": "^9.0.0" }, diff --git a/src/Vault.test.ts b/src/Vault.test.ts index 2a875dfe..b9ea161b 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -2,7 +2,7 @@ import { Json } from '@metamask/utils'; import { webcrypto as crypto } from 'crypto'; -import { Vault, exportedForTesting } from './Vault'; +import { Vault, VaultState, exportedForTesting } from './Vault'; const { b64Encode, @@ -567,4 +567,22 @@ describe('Vault', () => { expect(await newVault.get('test-2')).toStrictEqual(value2); expect(newVault.getState()).toStrictEqual(serialized); }); + + it('should deserialize a vault from JSON', async () => { + await vault.unlock('foo'); + await vault.set('test', { answer: 42 }); + + const state = vault.getState(); + const newVault = new Vault(JSON.parse(JSON.stringify(state))); + await newVault.unlock('foo'); + expect(await newVault.get('test')).toStrictEqual({ answer: 42 }); + }); + + it('should fail to import an invalid state', async () => { + await vault.unlock('foo'); + await vault.set('test', { answer: 42 }); + + const state = { ...vault.getState(), version: 1 } as unknown as VaultState; + expect(() => new Vault(state)).toThrow('Invalid vault state'); + }); }); diff --git a/src/Vault.ts b/src/Vault.ts index 26161b92..fbcc7eb7 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -1,9 +1,12 @@ /* eslint-disable no-restricted-globals */ import { Json } from '@metamask/utils'; +import Ajv, { JSONSchemaType } from 'ajv'; // eslint-disable-next-line import/no-nodejs-modules import { webcrypto as crypto } from 'crypto'; import { v4 as uuidv4 } from 'uuid'; +const ajv = new Ajv(); + // ---------------------------------------------------------------------------- // Types @@ -19,16 +22,16 @@ type VaultEntry = { value: EncryptedData; }; -type EncryptedDataState = { nonce: string; data: string }; +export type EncryptedDataState = { nonce: string; data: string }; -type VaultEntryState = { +export type VaultEntryState = { id: string; value: EncryptedDataState; createdAt: string; modifiedAt: string; }; -type VaultState = { +export type VaultState = { version: '2'; id: string; salt: string; @@ -38,6 +41,72 @@ type VaultState = { export class VaultError extends Error {} +// ---------------------------------------------------------------------------- +// Schemas + +const vaultStateSchema: JSONSchemaType = { + type: 'object', + properties: { + version: { + type: 'string', + enum: ['2'], + }, + id: { + type: 'string', + }, + salt: { + type: 'string', + }, + key: { + type: 'object', + properties: { + nonce: { + type: 'string', + }, + data: { + type: 'string', + }, + }, + required: ['nonce', 'data'], + }, + entries: { + type: 'object', + patternProperties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '^[a-zA-Z0-9-_]{1,64}$': { + type: 'object', + properties: { + id: { + type: 'string', + }, + value: { + type: 'object', + properties: { + nonce: { + type: 'string', + }, + data: { + type: 'string', + }, + }, + required: ['nonce', 'data'], + }, + createdAt: { + type: 'string', + }, + modifiedAt: { + type: 'string', + }, + }, + required: ['id', 'value', 'createdAt', 'modifiedAt'], + }, + }, + required: [], + }, + }, + required: ['version', 'id', 'salt', 'key', 'entries'], +}; + // ---------------------------------------------------------------------------- // Util functions @@ -405,6 +474,8 @@ export class Vault { #cachedMasterKey: CryptoKey | null; + static readonly validate = ajv.compile(vaultStateSchema); + /** * Create a new vault. * @@ -421,6 +492,10 @@ export class Vault { this.#passwordSalt = randomBytes(32); this.#wrappedMasterKey = null; } else { + if (!Vault.validate(state)) { + throw new VaultError('Invalid vault state'); + } + this.id = state.id; this.#passwordSalt = ensureLength(b64Decode(state.salt), 32); this.#wrappedMasterKey = decodeEncryptedData(state.key); @@ -453,10 +528,8 @@ export class Vault { } const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); - const additionalData = jsonToBytes(['vaultId', this.id]); - ({ wrapped: this.#wrappedMasterKey, handler: this.#cachedMasterKey } = - await generateMasterKey(wrappingKey, additionalData)); + await generateMasterKey(wrappingKey, this.#getAdditionalData())); } /** @@ -596,7 +669,7 @@ export class Vault { const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); const { wrapped: mkWrapped, handler: mkHandler } = await generateMasterKey( wrappingKey, - jsonToBytes(['vaultId', this.id]), + this.#getAdditionalData(), ); const newEntries = new Map(); @@ -643,7 +716,7 @@ export class Vault { oldWrappingKey, newWrappingKey, this.#getWrappedMasterKey(), - jsonToBytes(['vaultId', this.id]), + this.#getAdditionalData(), ); this.#passwordSalt = newPasswordSalt; } @@ -685,7 +758,7 @@ export class Vault { const masterKey = await unwrapMasterKey( wrappingKey, wrappedMasterKey, - jsonToBytes(['vaultId', this.id]), + this.#getAdditionalData(), ); if (!testOnly) { @@ -758,6 +831,10 @@ export class Vault { entries: Object.fromEntries(entries), }; } + + #getAdditionalData(): Uint8Array { + return jsonToBytes(['metamask', 'vault', 'version', 2, 'id', this.id]); + } } export const exportedForTesting = { diff --git a/yarn.lock b/yarn.lock index c16e9ea7..7f0fd53c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1451,6 +1451,7 @@ __metadata: "@types/uuid": ^9.0.1 "@typescript-eslint/eslint-plugin": ^5.55.0 "@typescript-eslint/parser": ^5.55.0 + ajv: ^8.12.0 depcheck: ^1.4.3 eslint: ^8.36.0 eslint-config-prettier: ^8.7.0 @@ -2325,6 +2326,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.12.0": + version: 8.12.0 + resolution: "ajv@npm:8.12.0" + dependencies: + fast-deep-equal: ^3.1.1 + json-schema-traverse: ^1.0.0 + require-from-string: ^2.0.2 + uri-js: ^4.2.2 + checksum: 4dc13714e316e67537c8b31bc063f99a1d9d9a497eb4bbd55191ac0dcd5e4985bbb71570352ad6f1e76684fb6d790928f96ba3b2d4fd6e10024be9612fe3f001 + languageName: node + linkType: hard + "ansi-escapes@npm:^4.2.1": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" @@ -5594,6 +5607,13 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 02f2f466cdb0362558b2f1fd5e15cce82ef55d60cd7f8fa828cf35ba74330f8d767fcae5c5c2adb7851fa811766c694b9405810879bc4e1ddd78a7c0e03658ad + languageName: node + linkType: hard + "json-schema@npm:0.2.3": version: 0.2.3 resolution: "json-schema@npm:0.2.3" @@ -6807,6 +6827,13 @@ __metadata: languageName: node linkType: hard +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: a03ef6895445f33a4015300c426699bc66b2b044ba7b670aa238610381b56d3f07c686251740d575e22f4c87531ba662d06937508f0f3c0f1ddc04db3130560b + languageName: node + linkType: hard + "require-package-name@npm:^2.0.1": version: 2.0.1 resolution: "require-package-name@npm:2.0.1" From 81e11c63210f65613f6fd4c21e439a6c84073318 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 27 Apr 2023 19:59:31 +0200 Subject: [PATCH 27/34] feat: check other vault properties --- package.json | 1 + src/Vault.test.ts | 23 ++++++++++++++++++++--- src/Vault.ts | 11 +++++++++++ yarn.lock | 17 ++++++++++++++++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 9bb055da..21730aef 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@types/node": "^18.16.1", "@types/uuid": "^9.0.1", "ajv": "^8.12.0", + "ajv-formats": "^2.1.1", "obs-store": "^4.0.3", "uuid": "^9.0.0" }, diff --git a/src/Vault.test.ts b/src/Vault.test.ts index b9ea161b..f8c3a530 100644 --- a/src/Vault.test.ts +++ b/src/Vault.test.ts @@ -571,18 +571,35 @@ describe('Vault', () => { it('should deserialize a vault from JSON', async () => { await vault.unlock('foo'); await vault.set('test', { answer: 42 }); - const state = vault.getState(); const newVault = new Vault(JSON.parse(JSON.stringify(state))); await newVault.unlock('foo'); expect(await newVault.get('test')).toStrictEqual({ answer: 42 }); }); - it('should fail to import an invalid state', async () => { + it('should fail to import a state with an invalid version', async () => { await vault.unlock('foo'); await vault.set('test', { answer: 42 }); - const state = { ...vault.getState(), version: 1 } as unknown as VaultState; expect(() => new Vault(state)).toThrow('Invalid vault state'); }); + + it('should fail to import a state with an invalid base64 value', async () => { + await vault.unlock('foo'); + await vault.set('test', { answer: 42 }); + const state = vault.getState(); + state.key.data = 'invalid-base64-value'; + expect(() => new Vault(state)).toThrow('Invalid vault state'); + }); + + it('should fail to import a state with an invalid date', async () => { + await vault.unlock('foo'); + await vault.set('test', { answer: 42 }); + const state = vault.getState(); + expect(state.entries.test).toBeDefined(); + if (state.entries.test !== undefined) { + state.entries.test.createdAt = 'invalid-date-time-value'; + } + expect(() => new Vault(state)).toThrow('Invalid vault state'); + }); }); diff --git a/src/Vault.ts b/src/Vault.ts index fbcc7eb7..e6759374 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -1,11 +1,13 @@ /* eslint-disable no-restricted-globals */ import { Json } from '@metamask/utils'; import Ajv, { JSONSchemaType } from 'ajv'; +import addFormats from 'ajv-formats'; // eslint-disable-next-line import/no-nodejs-modules import { webcrypto as crypto } from 'crypto'; import { v4 as uuidv4 } from 'uuid'; const ajv = new Ajv(); +addFormats(ajv); // ---------------------------------------------------------------------------- // Types @@ -53,18 +55,22 @@ const vaultStateSchema: JSONSchemaType = { }, id: { type: 'string', + format: 'uuid', }, salt: { type: 'string', + format: 'byte', }, key: { type: 'object', properties: { nonce: { type: 'string', + format: 'byte', }, data: { type: 'string', + format: 'byte', }, }, required: ['nonce', 'data'], @@ -78,24 +84,29 @@ const vaultStateSchema: JSONSchemaType = { properties: { id: { type: 'string', + format: 'uuid', }, value: { type: 'object', properties: { nonce: { type: 'string', + format: 'byte', }, data: { type: 'string', + format: 'byte', }, }, required: ['nonce', 'data'], }, createdAt: { type: 'string', + format: 'date-time', }, modifiedAt: { type: 'string', + format: 'date-time', }, }, required: ['id', 'value', 'createdAt', 'modifiedAt'], diff --git a/yarn.lock b/yarn.lock index 7f0fd53c..015c6a23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1452,6 +1452,7 @@ __metadata: "@typescript-eslint/eslint-plugin": ^5.55.0 "@typescript-eslint/parser": ^5.55.0 ajv: ^8.12.0 + ajv-formats: ^2.1.1 depcheck: ^1.4.3 eslint: ^8.36.0 eslint-config-prettier: ^8.7.0 @@ -2314,6 +2315,20 @@ __metadata: languageName: node linkType: hard +"ajv-formats@npm:^2.1.1": + version: 2.1.1 + resolution: "ajv-formats@npm:2.1.1" + dependencies: + ajv: ^8.0.0 + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: 4a287d937f1ebaad4683249a4c40c0fa3beed30d9ddc0adba04859026a622da0d317851316ea64b3680dc60f5c3c708105ddd5d5db8fe595d9d0207fd19f90b7 + languageName: node + linkType: hard + "ajv@npm:^6.10.0, ajv@npm:^6.12.3, ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -2326,7 +2341,7 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.12.0": +"ajv@npm:^8.0.0, ajv@npm:^8.12.0": version: 8.12.0 resolution: "ajv@npm:8.12.0" dependencies: From cdb73d3a23a32b80b1cac2f87d3f7f605896323a Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 11:44:39 +0200 Subject: [PATCH 28/34] fix: don't use deprecated functions (sonarlint) --- src/Vault.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index e6759374..422abd62 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -128,7 +128,7 @@ const vaultStateSchema: JSONSchemaType = { * @returns The encoded data. */ function b64Encode(data: Uint8Array): string { - return btoa(String.fromCharCode(...data)); + return Buffer.from(data).toString('base64'); } /** @@ -139,7 +139,7 @@ function b64Encode(data: Uint8Array): string { */ function b64Decode(data: string): Uint8Array { // eslint-disable-next-line id-length - return new Uint8Array([...atob(data)].map((c) => c.charCodeAt(0))); + return Uint8Array.from(Buffer.from(data, 'base64')); } /** From aa65f31b29ba2ff43681dfc61f9e43909a2fbbef Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 11:45:04 +0200 Subject: [PATCH 29/34] chore: stop error message from polluting console --- src/KeyringController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/KeyringController.ts b/src/KeyringController.ts index 298dd20d..3ca937d6 100644 --- a/src/KeyringController.ts +++ b/src/KeyringController.ts @@ -970,7 +970,7 @@ class KeyringController extends EventEmitter { keyring = await this.#newKeyring(type, data); } catch (error) { // Ignore error. - console.error(error); + console.log(`Unable to restore '${type}' keyring`); } if (!keyring) { From 9d4a0dba4fe377ae419e83a66c527f48d43fbcf8 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 11:46:37 +0200 Subject: [PATCH 30/34] chore: rename `uuidv4` variable to `uuid` --- src/Vault.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index 422abd62..c8d61aa9 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -4,7 +4,7 @@ import Ajv, { JSONSchemaType } from 'ajv'; import addFormats from 'ajv-formats'; // eslint-disable-next-line import/no-nodejs-modules import { webcrypto as crypto } from 'crypto'; -import { v4 as uuidv4 } from 'uuid'; +import { v4 as uuid } from 'uuid'; const ajv = new Ajv(); addFormats(ajv); @@ -499,7 +499,7 @@ export class Vault { this.#entries = new Map(); if (state === undefined) { - this.id = uuidv4(); + this.id = uuid(); this.#passwordSalt = randomBytes(32); this.#wrappedMasterKey = null; } else { @@ -616,7 +616,7 @@ export class Vault { const now = new Date(); const current = this.#entries.get(key); - const entryId = current?.id ?? uuidv4(); + const entryId = current?.id ?? uuid(); const encryptionKey = await this.#deriveEncryptionKey(entryId, key); this.#entries.set(key, { From 410917f3d1cd3ddae48131820eb51ac29449af38 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 11:52:02 +0200 Subject: [PATCH 31/34] chore: remove unnecessary linter-ignore comment --- src/Vault.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Vault.ts b/src/Vault.ts index c8d61aa9..2fdb3349 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -138,7 +138,6 @@ function b64Encode(data: Uint8Array): string { * @returns The decoded data. */ function b64Decode(data: string): Uint8Array { - // eslint-disable-next-line id-length return Uint8Array.from(Buffer.from(data, 'base64')); } From 51fcc415efcdbe6917938e926964be6c1ac08a1c Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 11:53:13 +0200 Subject: [PATCH 32/34] chore: simplify function --- src/Vault.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index 2fdb3349..8e056cc4 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -252,8 +252,7 @@ function decodeEncryptedData(state: EncryptedDataState): EncryptedData { * @returns Cryptographically secure random bytes. */ function randomBytes(length: number): Uint8Array { - const array = new Uint8Array(length); - return crypto.getRandomValues(array); + return crypto.getRandomValues(new Uint8Array(length)); } /** From e3bf15b713cbb124446b8c1423fc45c99ba8e642 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 14:33:36 +0200 Subject: [PATCH 33/34] fix: make wrapping key not extractable --- src/Vault.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Vault.ts b/src/Vault.ts index 8e056cc4..41c55d6c 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -299,7 +299,7 @@ async function deriveWrappingKey( name: 'AES-GCM', length: 256, }, - true, + false, ['encrypt', 'decrypt'], ); } From 896adb07810655e48a245910b129988c6e8f6604 Mon Sep 17 00:00:00 2001 From: Daniel Rocha <68558152+danroc@users.noreply.github.com> Date: Thu, 25 May 2023 15:54:20 +0200 Subject: [PATCH 34/34] chore: rename master key handler variable name --- src/Vault.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Vault.ts b/src/Vault.ts index 41c55d6c..8deab789 100644 --- a/src/Vault.ts +++ b/src/Vault.ts @@ -481,7 +481,7 @@ export class Vault { #wrappedMasterKey: EncryptedData | null; - #cachedMasterKey: CryptoKey | null; + #masterKeyHandler: CryptoKey | null; static readonly validate = ajv.compile(vaultStateSchema); @@ -518,7 +518,7 @@ export class Vault { } } - this.#cachedMasterKey = null; + this.#masterKeyHandler = null; } /** @@ -537,7 +537,7 @@ export class Vault { } const wrappingKey = await deriveWrappingKey(password, this.#passwordSalt); - ({ wrapped: this.#wrappedMasterKey, handler: this.#cachedMasterKey } = + ({ wrapped: this.#wrappedMasterKey, handler: this.#masterKeyHandler } = await generateMasterKey(wrappingKey, this.#getAdditionalData())); } @@ -547,7 +547,7 @@ export class Vault { * @returns True if the vault is unlocked, false otherwise. */ get isUnlocked(): boolean { - return this.#cachedMasterKey !== null; + return this.#masterKeyHandler !== null; } /** @@ -578,7 +578,7 @@ export class Vault { * @returns The master key handler. */ #getCachedMasterKey(): CryptoKey { - return ensureNotNull(this.#cachedMasterKey, 'Vault is locked'); + return ensureNotNull(this.#masterKeyHandler, 'Vault is locked'); } /** @@ -694,7 +694,7 @@ export class Vault { } // Update all fields "at once". - this.#cachedMasterKey = mkHandler; + this.#masterKeyHandler = mkHandler; this.#wrappedMasterKey = mkWrapped; this.#entries = newEntries; } @@ -742,7 +742,7 @@ export class Vault { * > memory, even after all references to the CryptoKey have gone away. */ lock(): void { - this.#cachedMasterKey = null; + this.#masterKeyHandler = null; } /** @@ -771,7 +771,7 @@ export class Vault { ); if (!testOnly) { - this.#cachedMasterKey = masterKey; + this.#masterKeyHandler = masterKey; } } catch (error) { throw new VaultError('Invalid vault password');