From e6b4c62781a5f5ffa409caa6f809fdc277ee7fbc Mon Sep 17 00:00:00 2001 From: Victor Graf Date: Wed, 5 Jan 2022 16:22:33 -0800 Subject: [PATCH] remove new code for encrypted backup --- packages/sdk/encrypted-backup/.gitignore | 4 - packages/sdk/encrypted-backup/.npmignore | 17 - packages/sdk/encrypted-backup/jest.config.js | 13 - packages/sdk/encrypted-backup/jestSetup.ts | 7 - packages/sdk/encrypted-backup/package.json | 45 -- .../sdk/encrypted-backup/src/backup.test.ts | 423 ----------------- packages/sdk/encrypted-backup/src/backup.ts | 431 ------------------ packages/sdk/encrypted-backup/src/config.ts | 216 --------- packages/sdk/encrypted-backup/src/errors.ts | 97 ---- .../sdk/encrypted-backup/src/globals.d.ts | 1 - packages/sdk/encrypted-backup/src/index.ts | 0 .../sdk/encrypted-backup/src/odis.mock.ts | 150 ------ packages/sdk/encrypted-backup/src/odis.ts | 265 ----------- packages/sdk/encrypted-backup/src/schema.ts | 89 ---- packages/sdk/encrypted-backup/src/utils.ts | 142 ------ packages/sdk/encrypted-backup/tsconfig.json | 8 - packages/sdk/encrypted-backup/tslint.json | 9 - packages/sdk/encrypted-backup/typedoc.json | 13 - .../identity/src/odis/circuit-breaker.mock.ts | 137 ------ .../identity/src/odis/circuit-breaker.test.ts | 177 ------- .../sdk/identity/src/odis/circuit-breaker.ts | 254 ----------- 21 files changed, 2498 deletions(-) delete mode 100644 packages/sdk/encrypted-backup/.gitignore delete mode 100644 packages/sdk/encrypted-backup/.npmignore delete mode 100644 packages/sdk/encrypted-backup/jest.config.js delete mode 100644 packages/sdk/encrypted-backup/jestSetup.ts delete mode 100644 packages/sdk/encrypted-backup/package.json delete mode 100644 packages/sdk/encrypted-backup/src/backup.test.ts delete mode 100644 packages/sdk/encrypted-backup/src/backup.ts delete mode 100644 packages/sdk/encrypted-backup/src/config.ts delete mode 100644 packages/sdk/encrypted-backup/src/errors.ts delete mode 100644 packages/sdk/encrypted-backup/src/globals.d.ts delete mode 100644 packages/sdk/encrypted-backup/src/index.ts delete mode 100644 packages/sdk/encrypted-backup/src/odis.mock.ts delete mode 100644 packages/sdk/encrypted-backup/src/odis.ts delete mode 100644 packages/sdk/encrypted-backup/src/schema.ts delete mode 100644 packages/sdk/encrypted-backup/src/utils.ts delete mode 100644 packages/sdk/encrypted-backup/tsconfig.json delete mode 100644 packages/sdk/encrypted-backup/tslint.json delete mode 100644 packages/sdk/encrypted-backup/typedoc.json delete mode 100644 packages/sdk/identity/src/odis/circuit-breaker.mock.ts delete mode 100644 packages/sdk/identity/src/odis/circuit-breaker.test.ts delete mode 100644 packages/sdk/identity/src/odis/circuit-breaker.ts diff --git a/packages/sdk/encrypted-backup/.gitignore b/packages/sdk/encrypted-backup/.gitignore deleted file mode 100644 index 7fabe89f619..00000000000 --- a/packages/sdk/encrypted-backup/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -lib/ -tmp/ -.tmp/ -.env \ No newline at end of file diff --git a/packages/sdk/encrypted-backup/.npmignore b/packages/sdk/encrypted-backup/.npmignore deleted file mode 100644 index 45e506bacd1..00000000000 --- a/packages/sdk/encrypted-backup/.npmignore +++ /dev/null @@ -1,17 +0,0 @@ -/.devchain/ -/.devchain.tar.gz -/coverage/ -/node_modules/ -/src/ -/tmp/ -/.tmp/ - -/tslint.json -/tsconfig.* -/jest.config.* -*.tgz - -/src - -/lib/**/*.test.* -/lib/test-utils \ No newline at end of file diff --git a/packages/sdk/encrypted-backup/jest.config.js b/packages/sdk/encrypted-backup/jest.config.js deleted file mode 100644 index ea4971b7402..00000000000 --- a/packages/sdk/encrypted-backup/jest.config.js +++ /dev/null @@ -1,13 +0,0 @@ -const { nodeFlakeTracking } = require('@celo/flake-tracker/src/jest/config.js') - -module.exports = { - preset: 'ts-jest', - ...nodeFlakeTracking, - testMatch: ['/src/**/?(*.)+(spec|test).ts?(x)'], - setupFilesAfterEnv: [ - '@celo/dev-utils/lib/matchers', - '/jestSetup.ts', - ...nodeFlakeTracking.setupFilesAfterEnv, - ], - verbose: true, -} diff --git a/packages/sdk/encrypted-backup/jestSetup.ts b/packages/sdk/encrypted-backup/jestSetup.ts deleted file mode 100644 index 0e7775597a6..00000000000 --- a/packages/sdk/encrypted-backup/jestSetup.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Setup mock for the fetch API to intercept requests to ODIS and the circuit breaker service. -// cross-fetch is used by the @celo/identity library. -const fetchMockSandbox = require('fetch-mock').sandbox() -jest.mock('cross-fetch', () => fetchMockSandbox) - -// @ts-ignore -global.fetchMock = fetchMockSandbox diff --git a/packages/sdk/encrypted-backup/package.json b/packages/sdk/encrypted-backup/package.json deleted file mode 100644 index 43051d02751..00000000000 --- a/packages/sdk/encrypted-backup/package.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "name": "@celo/encrypted-backup", - "version": "1.5.1-dev", - "description": "Libraries for implemented password encrypted account backups", - "main": "./lib/index.js", - "types": "./lib/index.d.ts", - "author": "Celo", - "license": "Apache-2.0", - "homepage": "https://github.com/celo-org/celo-monorepo/tree/master/packages/sdk/encrypted-backup", - "repository": "https://github.com/celo-org/celo-monorepo/tree/master/packages/sdk/encrypted-backup", - "keywords": [ - "celo", - "blockchain", - "odis", - "backup", - "encrypted", - "encrypted-backup" - ], - "scripts": { - "build": "tsc -b .", - "clean": "tsc -b . --clean", - "docs": "typedoc && ts-node ../utils/scripts/linkdocs.ts identity", - "test": "jest --runInBand", - "lint": "tslint -c tslint.json --project .", - "prepublishOnly": "yarn build" - }, - "dependencies": { - "@celo/base": "1.5.1-dev", - "@celo/identity": "1.5.1-dev", - "@celo/phone-number-privacy-common": "1.0.36-dev", - "@celo/utils": "1.5.1-dev", - "@types/debug": "^4.1.5", - "debug": "^4.1.1", - "fp-ts": "2.1.1", - "io-ts": "2.0.1" - }, - "devDependencies": { - "@celo/dev-utils": "0.0.1-dev", - "@celo/flake-tracker": "0.0.1-dev", - "fetch-mock": "9.10.4" - }, - "engines": { - "node": ">=8.13.0" - } -} diff --git a/packages/sdk/encrypted-backup/src/backup.test.ts b/packages/sdk/encrypted-backup/src/backup.test.ts deleted file mode 100644 index d3889a5d889..00000000000 --- a/packages/sdk/encrypted-backup/src/backup.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { - CircuitBreakerErrorTypes, - CircuitBreakerKeyStatus, -} from '@celo/identity/lib/odis/circuit-breaker' -import { MockCircuitBreaker } from '@celo/identity/lib/odis/circuit-breaker.mock' -import { defined, noBool } from '@celo/utils/lib/sign-typed-data-utils' -import debugFactory from 'debug' -import { Backup, createBackup, openBackup } from './backup' -import { ComputationalHardeningFunction, HardeningConfig } from './config' -import { BackupErrorTypes } from './errors' -import { MockOdis } from './odis.mock' -import { deserializeBackup, serializeBackup } from './schema' - -const debug = debugFactory('kit:encrypted-backup:backup:test') - -// Mock out the BLS blinding client. Verification of the result is not possible without using the -// real BLS OPRF implementation and a set of BLS keys. -jest.mock('@celo/identity/lib/odis/bls-blinding-client', () => { - // tslint:disable-next-line:no-shadowed-variable - class WasmBlsBlindingClient { - blindMessage = (m: string) => m - unblindAndVerifyMessage = (m: string) => m - } - return { - WasmBlsBlindingClient, - } -}) - -const TEST_HARDENING_CONFIG: HardeningConfig = { - odis: { - rateLimit: [{ delay: 0, resetTimer: noBool, batchSize: defined(3), repetitions: defined(1) }], - environment: MockOdis.environment, - }, - circuitBreaker: { - environment: MockCircuitBreaker.environment, - }, - computational: { - function: ComputationalHardeningFunction.SCRYPT, - cost: 1024, - }, -} - -let mockOdis: MockOdis | undefined -let mockCircuitBreaker: MockCircuitBreaker | undefined - -beforeEach(() => { - fetchMock.reset() - fetchMock.config.overwriteRoutes = true - - // Mock ODIS using the mock implementation defined above. - mockOdis = new MockOdis() - mockOdis.install(fetchMock) - - // Mock the circuit breaker service using the implementation from the identity library. - mockCircuitBreaker = new MockCircuitBreaker() - mockCircuitBreaker.install(fetchMock) -}) - -afterEach(() => { - fetchMock.reset() -}) - -describe('end-to-end', () => { - it('should be able to create, serialize, deserialize, and open a backup', async () => { - const testData = Buffer.from('backup test data', 'utf8') - const testPassword = Buffer.from('backup test password', 'utf8') - const testMetadata = { - name: 'test backup', - timestamp: Date.now(), - } - - const backup = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - metadata: testMetadata, - }) - debug('Created backup result', backup) - expect(backup.ok).toBe(true) - if (!backup.ok) { - return - } - expect(backup.result.metadata).toEqual(testMetadata) - - // Attempt to open the backup before passing it through the serialize function. - const opened = await openBackup({ backup: backup.result, password: testPassword }) - debug('Open backup result', opened) - expect(opened.ok).toBe(true) - if (!opened.ok) { - return - } - expect(opened.result).toEqual(testData) - - // Serialize the backup. - const serialized = serializeBackup(backup.result) - debug('Serialized backup', serialized) - - // Deserialize the backup, check that it is correctly deserialized and can be opened. - const deserialized = deserializeBackup(serialized) - debug('Deserialize backup result', deserialized) - expect(deserialized.ok).toBe(true) - if (!deserialized.ok) { - return - } - expect(deserialized.result).toEqual(backup.result) - expect(deserialized.result.metadata).toEqual(testMetadata) - - // Open the backup and check that that the expect data is recovered. - const reopened = await openBackup({ backup: deserialized.result, password: testPassword }) - debug('Reopen backup result', reopened) - expect(reopened.ok).toBe(true) - if (!reopened.ok) { - return - } - expect(reopened.result).toEqual(testData) - }) -}) - -describe('createBackup', () => { - const testData = Buffer.from('backup test data', 'utf8') - const testPassword = Buffer.from('backup test password', 'utf8') - - it('should return a fetch error when request to ODIS fails', async () => { - mockOdis!.installSignEndpoint(fetchMock, { throws: new Error('fetch failed') }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.FETCH_ERROR) - }) - - it('should return a service error when ODIS returns an error status', async () => { - mockOdis!.installSignEndpoint(fetchMock, { status: 501 }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.ODIS_SERVICE_ERROR) - }) - - it('should return a rate limit error when ODIS returns a rate limiting status', async () => { - mockOdis!.installSignEndpoint(fetchMock, { status: 429, headers: { 'Retry-After': '60' } }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.ODIS_RATE_LIMITING_ERROR) - }) - - it('should return a rate limit error when ODIS indicates no quota available', async () => { - mockOdis!.installQuotaEndpoint(fetchMock, { - status: 200, - body: { - success: true, - version: 'mock', - status: { timer: Date.now() / 1000 + 3600, counter: 0, disabled: false }, - }, - }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.ODIS_RATE_LIMITING_ERROR) - }) - - it('should not rely on ODIS when not included in the configuration', async () => { - mockOdis!.installSignEndpoint(fetchMock, { status: 501 }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: { ...TEST_HARDENING_CONFIG, odis: undefined }, - }) - expect(result.ok).toBe(true) - }) - - it('should return a fetch error when the circuit breaker status check fails', async () => { - mockCircuitBreaker!.installStatusEndpoint(fetchMock, { throws: new Error('fetch failed') }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.FETCH_ERROR) - }) - - it('should return a service error when the circuit breaker service returns 501', async () => { - mockCircuitBreaker!.installStatusEndpoint(fetchMock, { status: 501 }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - - it('should return an unavailable error when the circuit breaker key is destroyed', async () => { - mockCircuitBreaker!.keyStatus = CircuitBreakerKeyStatus.DESTROYED - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.UNAVAILABLE_ERROR) - }) - - it('should not rely on the circuit breaker when not included in the configuration', async () => { - mockCircuitBreaker!.installStatusEndpoint(fetchMock, { status: 501 }) - const result = await createBackup({ - data: testData, - password: testPassword, - hardening: { ...TEST_HARDENING_CONFIG, circuitBreaker: undefined }, - }) - expect(result.ok).toBe(true) - }) -}) - -describe('openBackup', () => { - const testPassword = Buffer.from('backup test password', 'utf8') - const testData = Buffer.from('backup test data', 'utf8') - let testBackup: Backup | undefined - - beforeEach(async () => { - // Create a backup to use for tests of opening below - const testBackupResult = await createBackup({ - data: testData, - password: testPassword, - hardening: TEST_HARDENING_CONFIG, - }) - if (!testBackupResult.ok) { - throw new Error(`failed to create backup for test setup: ${testBackupResult.error}`) - } - testBackup = testBackupResult.result - }) - - it('should result in a decryption error if the encrypted data is modified', async () => { - // Flip a bit in the encrypted data. - testBackup!.encryptedData[0] ^= 0x01 - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.DECRYPTION_ERROR) - }) - - it('should result in a decryption error if odis domain is modified', async () => { - testBackup!.odisDomain!.salt = defined('some salt') - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.DECRYPTION_ERROR) - }) - - it('should result in a decryption error if the circuit breaker response changes', async () => { - mockCircuitBreaker!.installUnwrapKeyEndpoint(fetchMock, { - status: 200, - body: { plaintext: Buffer.from('bad fuse key').toString('base64') }, - }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.DECRYPTION_ERROR) - }) - - it('should result in a decryption error if the computational hardening is altered', async () => { - testBackup!.computationalHardening = { - function: ComputationalHardeningFunction.SCRYPT, - cost: 16, - } - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.DECRYPTION_ERROR) - }) - - it('should return a fetch error when request to ODIS fails', async () => { - mockOdis!.installSignEndpoint(fetchMock, { throws: new Error('fetch failed') }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.FETCH_ERROR) - }) - - it('should return a service error when ODIS returns an error status', async () => { - mockOdis!.installSignEndpoint(fetchMock, { status: 501 }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.ODIS_SERVICE_ERROR) - }) - - it('should return a rate limit error when ODIS returns a rate limiting status', async () => { - mockOdis!.installSignEndpoint(fetchMock, { status: 429, headers: { 'Retry-After': '60' } }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.ODIS_RATE_LIMITING_ERROR) - }) - - it('should return a rate limit error when ODIS indicates no quota available', async () => { - mockOdis!.installQuotaEndpoint(fetchMock, { - status: 200, - body: { - success: true, - version: 'mock', - status: { timer: Date.now() / 1000 + 3600, counter: 0, disabled: false }, - }, - }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(BackupErrorTypes.ODIS_RATE_LIMITING_ERROR) - }) - - it('should return a fetch error when circuit breaker unwrap key fails', async () => { - mockCircuitBreaker!.installUnwrapKeyEndpoint(fetchMock, { throws: new Error('fetch failed') }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.FETCH_ERROR) - }) - - it('should return a service error when the circuit breaker service returns 501', async () => { - mockCircuitBreaker!.installUnwrapKeyEndpoint(fetchMock, { status: 501 }) - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - - it('should return an unavailable error when the circuit breaker key is destroyed', async () => { - mockCircuitBreaker!.keyStatus = CircuitBreakerKeyStatus.DESTROYED - const result = await openBackup({ - backup: testBackup!, - password: testPassword, - }) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.UNAVAILABLE_ERROR) - }) -}) diff --git a/packages/sdk/encrypted-backup/src/backup.ts b/packages/sdk/encrypted-backup/src/backup.ts deleted file mode 100644 index 053815bf292..00000000000 --- a/packages/sdk/encrypted-backup/src/backup.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { eqAddress } from '@celo/base/lib/address' -import { Err, Ok, Result } from '@celo/base/lib/result' -import { - CircuitBreakerClient, - CircuitBreakerKeyStatus, - CircuitBreakerServiceContext, - CircuitBreakerUnavailableError, -} from '@celo/identity/lib/odis/circuit-breaker' -import { ServiceContext as OdisServiceContext } from '@celo/identity/lib/odis/query' -import { SequentialDelayDomain } from '@celo/phone-number-privacy-common/lib/domains' -import * as crypto from 'crypto' -import debugFactory from 'debug' -import { - ComputationalHardeningConfig, - EnvironmentIdentifier, - HardeningConfig, - PIN_HARDENING_ALFAJORES_CONFIG, - PIN_HARDENING_MAINNET_CONFIG, -} from './config' -import { BackupError, InvalidBackupError } from './errors' -import { buildOdisDomain, odisHardenKey, odisQueryAuthorizer } from './odis' -import { computationalHardenKey, decrypt, deriveKey, encrypt, KDFInfo } from './utils' - -const debug = debugFactory('kit:encrypted-backup:backup') - -/** - * Backup structure encoding the information needed to implement the encrypted backup protocol. - * - * @remarks The structure below and its related functions implement the encrypted backup protocol - * designed for wallet account backups. More information about the protocol can be found in the - * official {@link https://docs.celo.org/celo-codebase/protocol/identity/encrypted-cloud-backup | - * Celo documentation} - */ -export interface Backup { - /** - * AES-128-GCM encryption of the user's secret backup data. - * - * @remarks The backup key is derived from the user's password or PIN hardened with input from the - * ODIS rate-limited hashing service and optionally a circuit breaker service. - */ - encryptedData: Buffer - - /** - * A randomly chosen 128-bit value. Ensures uniqueness of the password derived encryption key. - * - * @remarks The nonce value is appended to the password for local key derivation. It is also used - * to derive an authentication key to include in the ODIS Domain for domain separation and to - * ensure quota cannot be consumed by parties without access to the backup. - */ - nonce: Buffer - - /** - * ODIS Domain instance to be included in the query to ODIS for password hardening, - * - * @remarks Currently only SequentialDelayDomain is supported. Other ODIS domains intended for key - * hardening may be supported in the future. - */ - odisDomain?: SequentialDelayDomain - - /** - * RSA-OAEP-256 encryption of a randomly chosen 128-bit value, the fuse key. - * - * @remarks The fuse key, if provided, is combined with the password in local key derivation. - * Encryption is under the public key of the circuit breaker service. In order to get the fuseKey - * the client will send this ciphertext to the circuit breaker service for decryption. - */ - encryptedFuseKey?: Buffer - - /** - * Options for local computational hardening of the encryption key through PBKDF or scrypt. - * - * @remarks Adding computational hardening provides a measure of security from password guessing - * when the password has a moderate amount of entropy (e.g. a password generated under good - * guidelines). If the user secret has very low entropy, such as with a 6-digit PIN, - * computational hardening does not add significant security. - */ - computationalHardening?: ComputationalHardeningConfig - - /** Version number for the backup feature. Used to facilitate backwards compatibility. */ - version: string - - /** - * Data provided by the backup creator to identify the backup and its context - * - * @remarks Metadata is provided by, and only meaningful to, the SDK user. The intention is for - * this metadata to be used for identifying the backup and providing any context needed in the - * application - * - * @example - * ```typescript - * { - * // Address of the primary account stored a backup of an account key. Used to display the - * // balance and latest transaction information for a given backup. - * accountAddress: string - * // Unix timestamp used to indicate when the backup was created. - * timestamp: number - * } - * ``` - */ - metadata?: { [key: string]: unknown } - - /** Information including the URL and public keys of the ODIS and circuit breaker services. */ - environment?: { - odis?: OdisServiceContext - circuitBreaker?: CircuitBreakerServiceContext - } -} - -export interface CreatePinEncryptedBackupArgs { - data: Buffer - pin: string - environment?: EnvironmentIdentifier - metadata?: { [key: string]: unknown } -} - -/** - * Create a data backup, encrypting it with a hardened key derived from the given PIN. - * - * @remarks Using a 4 or 6 digit PIN for encryption requires an extremely restrictive rate limit for - * attempts to guess the PIN. This is enforced by ODIS through the SequentialDelayDomain with - * settings to allow the user (or an attacker) only a fixed number of attempts to guess their PIN. - * - * Because PINs have very little entropy, the total number of guesses is very restricted. - * * On the first day, the client has 10 attempts. 5 within 10s. 5 more over roughly 45 minutes. - * * On the second day, the client has 5 attempts over roughly 2 minutes. - * * On the third day, the client has 3 attempts over roughly 40 seconds. - * * On the fourth day, the client has 2 attempts over roughly 10 seconds. - * * Overall, the client has 25 attempts over 4 days. All further attempts will be denied. - * - * It is strongly recommended that the calling application implement a PIN blocklist to prevent the - * user from selecting a number of the most common PIN codes (e.g. blocking the top 25k PINs by - * frequency of appearance in the HIBP Passwords dataset). An example implementation can be seen in - * the Valora wallet. {@link - * https://github.com/valora-inc/wallet/blob/3940661c40d08e4c5db952bd0abeaabb0030fc7a/packages/mobile/src/pincode/authentication.ts#L56-L108 - * | PIN blocklist implementation} - * - * In order to handle the event of an ODIS service compromise, this configuration additionally - * includes a circuit breaker service run by Valora. In the event of an ODIS compromise, the Valora - * team will take their service offline, preventing backups using the circuit breaker from being - * opened. This ensures that an attacker who has compromised ODIS cannot leverage their attack to - * forcibly open backups created with this function. - * - * @param data The secret data (e.g. BIP-39 mnemonic phrase) to be included in the encrypted backup. - * @param password Password, PIN, or other user secret to use in deriving the encryption key. - * @param hardening Configuration for how the password should be hardened in deriving the key. - * @param metadata Arbitrary key-value data to include in the backup to identify it. - */ -export async function createPinEncryptedBackup({ - data, - pin, - environment, - metadata, -}: CreatePinEncryptedBackupArgs): Promise> { - // Select the hardening configuration based on the environment selector. - let hardening: HardeningConfig | undefined - if (environment === EnvironmentIdentifier.ALFAJORES) { - hardening = PIN_HARDENING_ALFAJORES_CONFIG - } else if (environment === EnvironmentIdentifier.MAINNET || environment === undefined) { - hardening = PIN_HARDENING_MAINNET_CONFIG - } - if (hardening === undefined) { - throw new Error('Implementation error: unhandled environment identifier') - } - - return createBackup({ data, password: Buffer.from(pin, 'utf8'), hardening, metadata }) -} - -export interface CreateBackupArgs { - data: Buffer - password: Buffer - hardening: HardeningConfig - metadata?: { [key: string]: unknown } -} - -/** - * Create a data backup, encrypting it with a hardened key derived from the given password or PIN. - * - * @param data The secret data (e.g. BIP-39 mnemonic phrase) to be included in the encrypted backup. - * @param password Password, PIN, or other user secret to use in deriving the encryption key. - * @param hardening Configuration for how the password should be hardened in deriving the key. - * @param metadata Arbitrary key-value data to include in the backup to identify it. - * - * @privateRemarks Most of this functions code is devoted to key generation starting with the input - * password or PIN and ending up with a hardened encryption key. It is important that the order and - * inputs to each step in the derivation be well considered and implemented correctly. One important - * requirement is that no output included in the backup acts as a "commitment" to the password or PIN - * value, except the final ciphertext. An example of an issue with this would be if a hash of the - * password and nonce were included in the backup. If a commitment to the password or PIN is - * included, an attacker can locally brute force that commitment to recover the password, then use - * that knowledge to complete the derivation. - */ -export async function createBackup({ - data, - password, - hardening, - metadata, -}: CreateBackupArgs): Promise> { - // Password and backup data are not included in any logging as they are likely sensitive. - debug('creating a backup with the following information', hardening, metadata) - const nonce = crypto.randomBytes(16) - // DO NOT MERGE(victor): Fix up this variable renaming. Goal of avoiding mutable variables. - const initialKey = deriveKey(KDFInfo.PASSWORD, [password, nonce]) - - // Generate a fuse key and mix it into the entropy of the key - let encryptedFuseKey: Buffer | undefined - let updatedKey: Buffer - if (hardening.circuitBreaker !== undefined) { - debug('generating a fuse key to enabled use of the circuit breaker service') - const circuitBreakerClient = new CircuitBreakerClient(hardening.circuitBreaker.environment) - - // Check that the circuit breaker is online. Although we do not need to interact with the - // service to create the backup, we should ensure its keys are not disabled or destroyed, - // otherwise we may not be able to open the backup that we create. - // Note that this status check is not strictly necessary and can be removed to all users to - // proceed when the circuit breaker is temporarily unavailable at the risk of not being able to - // open their backup later. - const serviceStatus = await circuitBreakerClient.status() - if (!serviceStatus.ok) { - return Err(serviceStatus.error) - } - if (serviceStatus.result !== CircuitBreakerKeyStatus.ENABLED) { - return Err(new CircuitBreakerUnavailableError(serviceStatus.result)) - } - debug('confirmed that the circuit breaker is online') - - // Generate a fuse key and encrypt it against the circuit breaker public key. - debug('generating and wrapping the fuse key') - const fuseKey = crypto.randomBytes(16) - const wrap = circuitBreakerClient.wrapKey(fuseKey) - if (!wrap.ok) { - return Err(wrap.error) - } - encryptedFuseKey = wrap.result - - // Mix the fuse key into the ongoing key hardening. Note that mixing in the circuit breaker key - // occurs before the request to ODIS. This means an attacker would need to acquire the fuse key - // _before_ they can make any attempts to guess the user's secret. - debug('mixing the fuse key into the keying material') - updatedKey = deriveKey(KDFInfo.FUSE_KEY, [initialKey, fuseKey]) - } else { - debug('not using the circuit breaker service') - updatedKey = initialKey - } - - // Harden the key with the output of a rate limited ODIS POPRF function. - let domain: SequentialDelayDomain | undefined - let odisHardenedKey: Buffer | undefined - if (hardening.odis !== undefined) { - debug('hardening the user key with output from ODIS') - // Derive the query authorizer wallet and address from the nonce, then build the ODIS domain. - // This domain acts as a binding rate limit configuration for ODIS, enforcing that the client must - // know the backup nonce, and can only make the given number of queries. - const authorizer = odisQueryAuthorizer(nonce) - domain = buildOdisDomain(hardening.odis, authorizer.address) - - debug('sending request to ODIS to harden the backup encryption key') - const odisHardening = await odisHardenKey( - updatedKey, - domain, - hardening.odis.environment, - authorizer.wallet - ) - if (!odisHardening.ok) { - return Err(odisHardening.error) - } - odisHardenedKey = odisHardening.result - } else { - debug('not using ODIS for key hardening') - } - - let computationalHardenedKey: Buffer | undefined - if (hardening.computational !== undefined) { - debug('hardening user key with computational function', hardening.computational) - const computationalHardening = await computationalHardenKey(updatedKey, hardening.computational) - if (!computationalHardening.ok) { - return Err(computationalHardening.error) - } - computationalHardenedKey = computationalHardening.result - } else { - debug('not using computational key hardening') - } - - debug('finalizing encryption key') - const finalKey = deriveKey( - KDFInfo.FINALIZE, - [updatedKey, odisHardenedKey ?? Buffer.alloc(0), computationalHardenedKey ?? Buffer.alloc(0)], - 16 - ) - - debug('encrypting backup data with final encryption key') - const encryption = encrypt(finalKey, data) - if (!encryption.ok) { - return Err(encryption.error) - } - - // Encrypted and wrap the data in a Backup structure. - debug('created encrypted backup') - return Ok({ - encryptedData: encryption.result, - nonce, - odisDomain: domain, - encryptedFuseKey, - computationalHardening: hardening.computational, - version: '0.0.1', - metadata, - environment: { - odis: hardening.odis?.environment, - circuitBreaker: hardening.circuitBreaker?.environment, - }, - }) -} - -export interface OpenBackupArgs { - backup: Backup - password: Buffer -} - -/** - * Open an encrypted backup file, using the provided password or PIN to derive the decryption key. - * - * @param backup Backup structure including the ciphertext and key derivation information. - * @param password Password, PIN, or other user secret to use in deriving the encryption key. - */ -export async function openBackup({ - backup, - password, -}: OpenBackupArgs): Promise> { - debug('opening an encrypted backup') - const initialKey = deriveKey(KDFInfo.PASSWORD, [password, backup.nonce]) - - // If a circuit breaker is in use, request a decryption of the fuse key and mix it in. - let updatedKey: Buffer - if (backup.encryptedFuseKey !== undefined) { - if (backup.environment?.circuitBreaker === undefined) { - return Err( - new InvalidBackupError( - new Error('encrypted fuse key is provided but no circuit breaker environment is provided') - ) - ) - } - const circuitBreakerClient = new CircuitBreakerClient(backup.environment.circuitBreaker) - - debug( - 'requesting the circuit breaker service unwrap the encrypted circuit breaker key', - backup.environment.circuitBreaker - ) - const unwrap = await circuitBreakerClient.unwrapKey(backup.encryptedFuseKey) - if (!unwrap.ok) { - return Err(unwrap.error) - } - - // Mix the fuse key into the ongoing key hardening. Note that mixing in the circuit breaker key - // occurs before the request to ODIS. This means an attacker would need to aquire the fuse key - // _before_ they can make any attempts to guess the user's secret. - updatedKey = deriveKey(KDFInfo.FUSE_KEY, [initialKey, unwrap.result]) - } else { - debug('backup did not specify an encrypted fuse key') - updatedKey = initialKey - } - - // If ODIS is in use, harden the key with the output of a rate limited ODIS POPRF function. - let odisHardenedKey: Buffer | undefined - if (backup.odisDomain !== undefined) { - const domain = backup.odisDomain - - // Derive the query authorizer wallet and address from the nonce. - // If the ODIS domain is authenticated, the authorizer address should match the domain. - const authorizer = odisQueryAuthorizer(backup.nonce) - if (domain.address.defined && !eqAddress(authorizer.address, domain.address.value)) { - return Err( - new InvalidBackupError( - new Error( - 'domain query authorizer address is provided but is not derived from the backup nonce' - ) - ) - ) - } - if (backup.environment?.odis === undefined) { - return Err( - new InvalidBackupError( - new Error('ODIS domain is provided by no ODIS environment information') - ) - ) - } - - debug('requesting a key hardening response from ODIS') - const odisHardening = await odisHardenKey( - updatedKey, - domain, - backup.environment.odis, - authorizer.wallet - ) - if (!odisHardening.ok) { - return Err(odisHardening.error) - } - odisHardenedKey = odisHardening.result - } else { - debug('not using ODIS for key hardening') - } - - let computationalHardenedKey: Buffer | undefined - if (backup.computationalHardening !== undefined) { - debug('hardening user key with computational function', backup.computationalHardening) - const computationalHardening = await computationalHardenKey( - updatedKey, - backup.computationalHardening - ) - if (!computationalHardening.ok) { - return Err(computationalHardening.error) - } - computationalHardenedKey = computationalHardening.result - } else { - debug('not using computational key hardening') - } - - debug('finalizing decryption key') - const finalKey = deriveKey( - KDFInfo.FINALIZE, - [updatedKey, odisHardenedKey ?? Buffer.alloc(0), computationalHardenedKey ?? Buffer.alloc(0)], - 16 - ) - - debug('decrypting backup with finalized decryption key') - const decryption = decrypt(finalKey, backup.encryptedData) - if (!decryption.ok) { - return Err(decryption.error) - } - - debug('decrypted backup') - return Ok(decryption.result) -} diff --git a/packages/sdk/encrypted-backup/src/config.ts b/packages/sdk/encrypted-backup/src/config.ts deleted file mode 100644 index 97f407bfea5..00000000000 --- a/packages/sdk/encrypted-backup/src/config.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { - CircuitBreakerServiceContext, - VALORA_ALFAJORES_CIRCUIT_BREAKER_ENVIRONMENT, - VALORA_MAINNET_CIRCUIT_BREAKER_ENVIRONMENT, -} from '@celo/identity/lib/odis/circuit-breaker' -import { - ODIS_ALFAJORES_CONTEXT, - ODIS_MAINNET_CONTEXT, - ServiceContext as OdisServiceContext, -} from '@celo/identity/lib/odis/query' -import { SequentialDelayStage } from '@celo/phone-number-privacy-common' -import { defined, noNumber } from '@celo/utils/lib/sign-typed-data-utils' -import { ScryptOptions } from './utils' - -export interface HardeningConfig { - /** - * If provided, a computational hardening function (e.g. scrypt or PBKDF2) will be applied to - * locally harden the backup encryption key. - * - * @remarks Recommended for password-encrypted backups, especially if a circuit breaker is not in - * use, as this provides some degree of protection in the event of an ODIS compromise. When - * generating backups on low-power devices (e.g. budget smart phones) and encrypting with - * low-entropy secrets (e.g. 6-digit PINs) local hardening cannot offer significant protection. - */ - computational?: ComputationalHardeningConfig - - /** If provided, ODIS will be used with the given configuration to harden the backup key */ - odis?: OdisHardeningConfig - - /** - * If provided, a circuit breaker will be used with the given configuration to protect the backup key - */ - circuitBreaker?: CircuitBreakerConfig -} - -/** Configuration for usage of ODIS to harden the encryption keys */ -export interface OdisHardeningConfig { - /** - * Rate limiting information used to construct the ODIS domain which will be used to harden the - * encryption key through ODIS' domain password hardening service. - * - * @remarks Currently supports the SequentialDelayDomain. In the future, as additional domains are - * standardized for key hardening, they may be added here to allow a wider range of configuration. - */ - rateLimit: SequentialDelayStage[] - - /** Environment information including the URL and public key of the ODIS service */ - environment: OdisServiceContext -} - -/** Configuration for usage of a circuit breaker to protect the encryption keys */ -export interface CircuitBreakerConfig { - /** Environment information including the URL and public key of the circuit breaker service */ - environment: CircuitBreakerServiceContext -} - -export enum ComputationalHardeningFunction { - PBKDF = 'pbkdf2_sha256', - SCRYPT = 'scrypt', -} - -export interface PbkdfConfig { - function: ComputationalHardeningFunction.PBKDF - iterations: number -} - -export interface ScryptConfig extends ScryptOptions { - function: ComputationalHardeningFunction.SCRYPT -} - -export type ComputationalHardeningConfig = PbkdfConfig | ScryptConfig - -/** - * ODIS SequentialDelayDomain rate limit configured to be appropriate for hardening a 6-digit PIN. - * - * @remarks Because PINs have very little entropy, the total number of guesses is very restricted. - * * On the first day, the client has 10 attempts. 5 within 10s. 5 more over roughly 45 minutes. - * * On the second day, the client has 5 attempts over roughly 2 minutes. - * * On the third day, the client has 3 attempts over roughly 40 seconds. - * * On the fourth day, the client has 2 attempts over roughly 10 seconds. - * * Overall, the client has 20 attempts over 4 days. All further attempts will be denied. - */ -const PIN_HARDENING_RATE_LIMIT: SequentialDelayStage[] = [ - // First stage is setup, as the user will need to make a single query to create their backup. - { - delay: 0, - resetTimer: defined(true), - batchSize: defined(1), - repetitions: noNumber, - }, - // On the first day, the client has 10 attempts. 5 within 10s. 5 more over roughly 45 minutes. - { - delay: 0, - resetTimer: defined(true), - batchSize: defined(3), - repetitions: noNumber, - }, - { - delay: 10, - resetTimer: defined(true), - batchSize: defined(2), - repetitions: noNumber, - }, - { - delay: 30, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 60, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 300, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 900, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 1800, - resetTimer: defined(true), - batchSize: defined(1), - repetitions: noNumber, - }, - // On the second day, the client has 5 attempts over roughly 2 minutes. - { - delay: 86400, - resetTimer: defined(true), - batchSize: defined(2), - repetitions: noNumber, - }, - { - delay: 10, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 30, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 60, - resetTimer: defined(true), - batchSize: defined(1), - repetitions: noNumber, - }, - // On the third day, the client has 3 attempts over roughly 40 seconds. - { - delay: 86400, - resetTimer: defined(true), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 10, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 30, - resetTimer: defined(true), - batchSize: defined(1), - repetitions: noNumber, - }, - // On the fourth day, the client has 2 attempts over roughly 10 seconds. - { - delay: 86400, - resetTimer: defined(true), - batchSize: defined(1), - repetitions: noNumber, - }, - { - delay: 10, - resetTimer: defined(false), - batchSize: defined(1), - repetitions: noNumber, - }, -] - -export enum EnvironmentIdentifier { - MAINNET = 'MAINNET', - ALFAJORES = 'ALFAJORES', -} - -export const PIN_HARDENING_MAINNET_CONFIG: HardeningConfig = { - odis: { - rateLimit: PIN_HARDENING_RATE_LIMIT, - environment: ODIS_MAINNET_CONTEXT, - }, - circuitBreaker: { - environment: VALORA_MAINNET_CIRCUIT_BREAKER_ENVIRONMENT, - }, -} - -export const PIN_HARDENING_ALFAJORES_CONFIG: HardeningConfig = { - odis: { - rateLimit: PIN_HARDENING_RATE_LIMIT, - environment: ODIS_ALFAJORES_CONTEXT, - }, - circuitBreaker: { - environment: VALORA_ALFAJORES_CIRCUIT_BREAKER_ENVIRONMENT, - }, -} diff --git a/packages/sdk/encrypted-backup/src/errors.ts b/packages/sdk/encrypted-backup/src/errors.ts deleted file mode 100644 index 081f2dbead5..00000000000 --- a/packages/sdk/encrypted-backup/src/errors.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { RootError } from '@celo/base/lib/result' -import { CircuitBreakerError } from '@celo/identity/lib/odis/circuit-breaker' -import { ScryptOptions } from './utils' - -export enum BackupErrorTypes { - AUTHORIZATION_ERROR = 'AUTHORIZATION_ERROR', - DECODE_ERROR = 'DECODE_ERROR', - DECRYPTION_ERROR = 'DECRYPTION_ERROR', - ENCRYPTION_ERROR = 'ENCRYPTION_ERROR', - FETCH_ERROR = 'FETCH_ERROR', - INVALID_BACKUP_ERROR = 'INVALID_BACKUP_ERROR', - ODIS_SERVICE_ERROR = 'ODIS_SERVICE_ERROR', - ODIS_RATE_LIMITING_ERROR = 'ODIS_RATE_LIMITING_ERROR', - ODIS_VERIFICATION_ERROR = 'ODIS_VERIFICATION_ERROR', - PBKDF_ERROR = 'PBKDF_ERROR', - SCRYPT_ERROR = 'SCRYPT_ERROR', -} - -export class AuthorizationError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.AUTHORIZATION_ERROR) - } -} - -export class DecodeError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.DECODE_ERROR) - } -} - -export class DecryptionError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.DECRYPTION_ERROR) - } -} - -export class EncryptionError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.ENCRYPTION_ERROR) - } -} - -export class FetchError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.FETCH_ERROR) - } -} - -export class InvalidBackupError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.INVALID_BACKUP_ERROR) - } -} - -export class OdisServiceError extends RootError { - constructor(readonly error?: Error, readonly version?: string) { - super(BackupErrorTypes.ODIS_SERVICE_ERROR) - } -} - -export class OdisRateLimitingError extends RootError { - constructor(readonly notBefore?: number, readonly error?: Error) { - super(BackupErrorTypes.ODIS_RATE_LIMITING_ERROR) - } -} - -export class OdisVerificationError extends RootError { - constructor(readonly error?: Error) { - super(BackupErrorTypes.ODIS_VERIFICATION_ERROR) - } -} - -export class PbkdfError extends RootError { - constructor(readonly iterations: number, readonly error?: Error) { - super(BackupErrorTypes.PBKDF_ERROR) - } -} - -export class ScryptError extends RootError { - constructor(readonly options: ScryptOptions, readonly error?: Error) { - super(BackupErrorTypes.SCRYPT_ERROR) - } -} - -export type BackupError = - | AuthorizationError - | CircuitBreakerError - | DecodeError - | DecryptionError - | EncryptionError - | FetchError - | InvalidBackupError - | OdisServiceError - | OdisRateLimitingError - | OdisVerificationError - | PbkdfError - | ScryptError diff --git a/packages/sdk/encrypted-backup/src/globals.d.ts b/packages/sdk/encrypted-backup/src/globals.d.ts deleted file mode 100644 index 0318570a84f..00000000000 --- a/packages/sdk/encrypted-backup/src/globals.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare const fetchMock diff --git a/packages/sdk/encrypted-backup/src/index.ts b/packages/sdk/encrypted-backup/src/index.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/packages/sdk/encrypted-backup/src/odis.mock.ts b/packages/sdk/encrypted-backup/src/odis.mock.ts deleted file mode 100644 index e030674fcfd..00000000000 --- a/packages/sdk/encrypted-backup/src/odis.mock.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { ServiceContext as OdisServiceContext } from '@celo/identity/lib/odis/query' -import { - checkSequentialDelayRateLimit, - domainHash, - DomainQuotaStatusRequest, - DomainQuotaStatusResponse, - DomainRestrictedSignatureRequest, - DomainRestrictedSignatureResponse, - Endpoints, - SequentialDelayDomain, - SequentialDelayDomainState, - verifyDomainQuotaStatusRequestSignature, - verifyDomainRestrictedSignatureRequestSignature, -} from '@celo/phone-number-privacy-common' -import debugFactory from 'debug' - -const debug = debugFactory('kit:encrypted-backup:odis:mock') - -export const MOCK_ODIS_ENVIRONMENT: OdisServiceContext = { - odisUrl: 'https://mockodis.com', - odisPubKey: - '7FsWGsFnmVvRfMDpzz95Np76wf/1sPaK0Og9yiB+P8QbjiC8FV67NBans9hzZEkBaQMhiapzgMR6CkZIZPvgwQboAxl65JWRZecGe5V3XO4sdKeNemdAZ2TzQuWkuZoA', -} - -export class MockOdis { - static readonly environment = MOCK_ODIS_ENVIRONMENT - - state: Record = {} - - quota( - req: DomainQuotaStatusRequest - ): { status: number; body: DomainQuotaStatusResponse } { - const authorized = verifyDomainQuotaStatusRequestSignature(req) - if (!authorized) { - return { - status: 401, - body: { - success: false, - version: 'mock', - error: 'unauthorized', - }, - } - } - - const hash = domainHash(req.domain).toString('hex') - const domainState = this.state[hash] ?? { timer: 0, counter: 0, disabled: false } - return { - status: 200, - body: { - success: true, - version: 'mock', - status: domainState, - }, - } - } - - sign( - req: DomainRestrictedSignatureRequest - ): { status: number; body: DomainRestrictedSignatureResponse } { - const authorized = verifyDomainRestrictedSignatureRequestSignature(req) - if (!authorized) { - return { - status: 401, - body: { - success: false, - version: 'mock', - error: 'unauthorized', - }, - } - } - - const hash = domainHash(req.domain).toString('hex') - const domainState = this.state[hash] ?? { timer: 0, counter: 0, disabled: false } - const nonce = req.options.nonce.defined ? req.options.nonce.value : undefined - if (nonce !== domainState.counter) { - return { - status: 403, - body: { - success: false, - version: 'mock', - error: 'incorrect nonce', - }, - } - } - - const limitCheck = checkSequentialDelayRateLimit(req.domain, Date.now() / 1000, domainState) - if (!limitCheck.accepted || limitCheck.state === undefined) { - return { - status: 429, - body: { - success: false, - version: 'mock', - error: 'request limit exceeded', - }, - } - } - this.state[hash] = limitCheck.state - - return { - status: 200, - body: { - success: true, - version: 'mock', - signature: Buffer.from( - ``, - 'utf8' - ).toString('base64'), - }, - } - } - - installQuotaEndpoint(mock: typeof fetchMock, override?: any) { - mock.mock( - { - url: new URL(Endpoints.DOMAIN_QUOTA_STATUS, MockOdis.environment.odisUrl).href, - method: 'POST', - }, - override ?? - ((url: string, req: { body: string }) => { - const res = this.quota( - JSON.parse(req.body) as DomainQuotaStatusRequest - ) - debug('Mocking request', { url, req, res }) - return res - }) - ) - } - - installSignEndpoint(mock: typeof fetchMock, override?: any) { - mock.mock( - { - url: new URL(Endpoints.DOMAIN_SIGN, MockOdis.environment.odisUrl).href, - method: 'POST', - }, - override ?? - ((url: string, req: { body: string }) => { - const res = this.sign( - JSON.parse(req.body) as DomainRestrictedSignatureRequest - ) - debug('Mocking request', { url, req, res }) - return res - }) - ) - } - - install(mock: typeof fetchMock) { - this.installQuotaEndpoint(mock) - this.installSignEndpoint(mock) - } -} diff --git a/packages/sdk/encrypted-backup/src/odis.ts b/packages/sdk/encrypted-backup/src/odis.ts deleted file mode 100644 index 0a83285232b..00000000000 --- a/packages/sdk/encrypted-backup/src/odis.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { Address } from '@celo/base/lib/address' -import { Err, Ok, Result } from '@celo/base/lib/result' -import { WasmBlsBlindingClient } from '@celo/identity/lib/odis/bls-blinding-client' -import { - ErrorMessages, - queryOdis, - ServiceContext as OdisServiceContext, -} from '@celo/identity/lib/odis/query' -import { - AuthenticationMethod, - checkSequentialDelayRateLimit, - DomainIdentifiers, - DomainQuotaStatusRequest, - domainQuotaStatusRequestEIP712, - DomainQuotaStatusResponse, - DomainQuotaStatusResponseSuccess, - DomainRestrictedSignatureRequest, - domainRestrictedSignatureRequestEIP712, - DomainRestrictedSignatureResponse, - DomainRestrictedSignatureResponseSuccess, - Endpoints, - genSessionID, - SequentialDelayDomain, - SequentialDelayDomainState, -} from '@celo/phone-number-privacy-common' -import { defined, noNumber, noString } from '@celo/utils/lib/sign-typed-data-utils' -import { LocalWallet } from '@celo/wallet-local' -import * as crypto from 'crypto' -import { OdisHardeningConfig } from './config' -import { - AuthorizationError, - BackupError, - FetchError, - OdisRateLimitingError, - OdisServiceError, - OdisVerificationError, -} from './errors' -import { deriveKey, EIP712Wallet, KDFInfo } from './utils' - -/** - * Builds an ODIS SequentialDelayDomain with recommended rate limiting for a 6-digit PIN. - * - * @param authorizer Address of the key that should authorize requests to ODIS. - * @returns A SequentialDelayDomain with a recommended rate limiting configuration. - */ -export function buildOdisDomain( - config: OdisHardeningConfig, - authorizer: Address, - salt?: string -): SequentialDelayDomain { - return { - name: DomainIdentifiers.SequentialDelay, - version: '1', - stages: config.rateLimit, - address: defined(authorizer), - salt: salt ? defined(salt) : noString, - } -} - -export async function odisHardenKey( - key: Buffer, - domain: SequentialDelayDomain, - environment: OdisServiceContext, - wallet?: EIP712Wallet -): Promise> { - // Allow this function to be called in tests, but not in any other environment. This safety gate - // can be removed when the POPRF verification function is implemented and added below. - if (process?.env?.JEST_WORKER_ID === undefined && process?.env?.NODE_ENV !== 'test') { - throw new Error('ODIS POPRF function is not yet available') - } - - // Session ID for logging requsests. - const sessionID = genSessionID() - - // Request the quota status the domain to get the state, including the quota counter. - const quotaResp = await requestOdisQuotaStatus(domain, environment, sessionID, wallet) - if (!quotaResp.ok) { - return quotaResp - } - - // Check locally whether or not we should expect to be able to make a query to ODIS right now. - // TODO(victor) Using Date.now is actually not appropriate because mobile clients may have a large - // clock drift. Modify this to use a time returned from ODIS either in the status response, or as - // part of the 429 response upon rejecting the signature request. Risk with the latter approach is - // that unless replay handling is implemented, having the request accepted by half of the signers, - // but rejected by the other half can get the client into a bad state. - const quotaState = quotaResp.result.status as SequentialDelayDomainState - const { accepted, notBefore } = checkSequentialDelayRateLimit( - domain, - // Dividing by 1000 to convert ms to seconds for the rate limit check. - Date.now() / 1000, - quotaState - ) - if (!accepted) { - return Err( - new OdisRateLimitingError( - notBefore, - new Error('client does not currently have quota based on status repsonse.') - ) - ) - } - - // Instantiate a blinding client and blind the key, containing the users password to be hardended. - const blindingSeed = crypto.randomBytes(16) - const blindingClient = new WasmBlsBlindingClient(environment.odisPubKey) - const blindedMessage = await blindingClient.blindMessage(key.toString('base64'), blindingSeed) - - // Request the partial oblivious signature from ODIS. - // Note that making this request will, if successful, result in quota being used in the domain. - const signatureResp = await requestOdisDomainSignature( - blindedMessage, - quotaState.counter, - domain, - environment, - sessionID, - wallet - ) - if (!signatureResp.ok) { - return signatureResp - } - - // Unblind the signature response received from ODIS to get the POPRF output. - let odisOutput: Buffer - try { - // TODO(victor): Once the pOPRF implementation is available, use that instead. - const odisOutputBase64 = await blindingClient.unblindAndVerifyMessage( - signatureResp.result.signature - ) - odisOutput = Buffer.from(odisOutputBase64, 'base64') - } catch (error) { - return Err(new OdisVerificationError(error as Error)) - } - - // Mix the key with the output from ODIS to get the hardened key. - return Ok(deriveKey(KDFInfo.ODIS_KEY_HARDENING, [key, odisOutput])) -} - -/** - * Derive from the nonce a private key and use it to instanciate a wallet for request signing - * - * @remarks It is important that the auth key does not mix in entropy from the password value. If - * it did, then the derived address and signatures would act as a commitment to the underlying - * password value and would allow offline brute force attacks when combined with the other values - * mixed into the key value. - */ -export function odisQueryAuthorizer(nonce: Buffer): { address: Address; wallet: EIP712Wallet } { - // Derive the domain's request authorization key from the backup nonce. - const authKey = deriveKey(KDFInfo.ODIS_AUTH_KEY, [nonce]) - const wallet = new LocalWallet() - wallet.addAccount(authKey.toString('hex')) - const address = wallet.getAccounts()[0] - if (address === undefined) { - // Throw the error instead if returning it as this is more akin to a panic. - throw new Error('Implementation error: LocalWallet with an added account returned no accounts') - } - return { address, wallet } -} - -async function requestOdisQuotaStatus( - domain: SequentialDelayDomain, - environment: OdisServiceContext, - sessionID: string, - wallet?: EIP712Wallet -): Promise> { - const quotaStatusReq: DomainQuotaStatusRequest = { - domain, - options: { - signature: noString, - nonce: noNumber, - }, - sessionID: defined(sessionID), - } - - // If a query authorizer is defined in the domain, include a siganture over the request. - const authorizer = domain.address.defined ? domain.address.value : undefined - if (authorizer !== undefined) { - if (wallet === undefined || !wallet.hasAccount(authorizer)) { - return Err( - new AuthorizationError( - new Error('key for signing ODIS quota status request is unavailable') - ) - ) - } - quotaStatusReq.options.signature = defined( - await wallet.signTypedData(authorizer, domainQuotaStatusRequestEIP712(quotaStatusReq)) - ) - } - - let quotaResp: DomainQuotaStatusResponse - try { - quotaResp = await queryOdis( - { authenticationMethod: AuthenticationMethod.NONE }, - quotaStatusReq, - environment, - Endpoints.DOMAIN_QUOTA_STATUS - ) - } catch (error) { - if ((error as Error).message?.includes(ErrorMessages.ODIS_FETCH_ERROR)) { - return Err(new FetchError(error as Error)) - } - return Err(new OdisServiceError(error as Error)) - } - if (!quotaResp.success) { - return Err(new OdisServiceError(new Error(quotaResp.error), quotaResp.version)) - } - - return Ok(quotaResp) -} - -async function requestOdisDomainSignature( - blindedMessage: string, - counter: number, - domain: SequentialDelayDomain, - environment: OdisServiceContext, - sessionID: string, - wallet?: EIP712Wallet -): Promise> { - const signatureReq: DomainRestrictedSignatureRequest = { - domain, - options: { - signature: noString, - nonce: defined(counter), - }, - blindedMessage, - sessionID: defined(sessionID), - } - - // If a query authorizer is defined in the domain, include a siganture over the request. - const authorizer = domain.address.defined ? domain.address.value : undefined - if (authorizer !== undefined) { - if (wallet === undefined || !wallet.hasAccount(authorizer)) { - return Err( - new AuthorizationError( - new Error('key for signing ODIS domain signature request is unavailable') - ) - ) - } - signatureReq.options.signature = defined( - await wallet.signTypedData(authorizer, domainRestrictedSignatureRequestEIP712(signatureReq)) - ) - } - - let signatureResp: DomainRestrictedSignatureResponse - try { - signatureResp = await queryOdis( - { authenticationMethod: AuthenticationMethod.NONE }, - signatureReq, - environment, - Endpoints.DOMAIN_SIGN - ) - } catch (error) { - if ((error as Error).message?.includes(ErrorMessages.ODIS_FETCH_ERROR)) { - return Err(new FetchError(error as Error)) - } - if ((error as Error).message?.includes(ErrorMessages.ODIS_RATE_LIMIT_ERROR)) { - return Err(new OdisRateLimitingError(undefined, error as Error)) - } - return Err(new OdisServiceError(error as Error)) - } - if (!signatureResp.success) { - return Err(new OdisServiceError(new Error(signatureResp.error), signatureResp.version)) - } - - return Ok(signatureResp) -} diff --git a/packages/sdk/encrypted-backup/src/schema.ts b/packages/sdk/encrypted-backup/src/schema.ts deleted file mode 100644 index ff2f71079bd..00000000000 --- a/packages/sdk/encrypted-backup/src/schema.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Err, Ok, parseJsonAsResult, Result } from '@celo/base/lib/result' -import { SequentialDelayDomainSchema } from '@celo/phone-number-privacy-common/lib/domains' -import { chain, isLeft } from 'fp-ts/lib/Either' -import { pipe } from 'fp-ts/lib/pipeable' -import * as t from 'io-ts' -import { Backup } from './backup' -import { ComputationalHardeningFunction } from './config' -import { DecodeError } from './errors' - -const BASE64_REGEXP = /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ - -/** Utility type to leverage io-ts for encoding and decoding of buffers from base64 strings. */ -export const BufferFromBase64 = new t.Type( - 'BufferFromBase64', - Buffer.isBuffer, - (unk: unknown, context: t.Context) => - pipe( - t.string.validate(unk, context), - chain((str: string) => { - // Check that the string is base64 data and return the decoding if it is. - if (!BASE64_REGEXP.test(str)) { - return t.failure(unk, context) - } - return t.success(Buffer.from(str, 'base64')) - }) - ), - (buffer: Buffer) => buffer.toString('base64') -) - -/** io-ts codec used to encode and decode backups from JSON objects */ -export const BackupSchema: t.Type = t.intersection([ - // Required fields - t.type({ - encryptedData: BufferFromBase64, - nonce: BufferFromBase64, - version: t.string, - }), - // Optional fields - // https://github.com/gcanti/io-ts/blob/master/index.md#mixing-required-and-optional-props - t.partial({ - odisDomain: SequentialDelayDomainSchema, - metadata: t.UnknownRecord, - encryptedFuseKey: BufferFromBase64, - computationalHardening: t.union([ - t.type({ - function: t.literal(ComputationalHardeningFunction.PBKDF), - iterations: t.number, - }), - t.intersection([ - t.type({ - function: t.literal(ComputationalHardeningFunction.SCRYPT), - cost: t.number, - }), - t.partial({ - blockSize: t.number, - parallelization: t.number, - }), - ]), - ]), - environment: t.partial({ - odis: t.type({ - odisUrl: t.string, - odisPubKey: t.string, - }), - circuitBreaker: t.type({ - url: t.string, - publicKey: t.string, - }), - }), - }), -]) - -export function serializeBackup(backup: Backup): string { - return JSON.stringify(BackupSchema.encode(backup)) -} - -export function deserializeBackup(data: string): Result { - const jsonDecode = parseJsonAsResult(data) - if (!jsonDecode.ok) { - return Err(new DecodeError(jsonDecode.error)) - } - - const backup = BackupSchema.decode(jsonDecode.result) - if (isLeft(backup)) { - return Err(new DecodeError()) - } - - return Ok(backup.right) -} diff --git a/packages/sdk/encrypted-backup/src/utils.ts b/packages/sdk/encrypted-backup/src/utils.ts deleted file mode 100644 index b0aa6923e55..00000000000 --- a/packages/sdk/encrypted-backup/src/utils.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { Err, Ok, Result } from '@celo/base/lib/result' -import { ReadOnlyWallet } from '@celo/connect' -import * as crypto from 'crypto' -import { ComputationalHardeningFunction, ComputationalHardeningConfig } from './config' -import { DecryptionError, EncryptionError, PbkdfError, ScryptError } from './errors' - -// NOTE: This module is intended for use within the @celo/encrypted-backup package and so is not -// exported in the index.ts file. - -/** Pared down ReadOnlyWallet type that supports the required functions of EIP-712 signing. */ -export type EIP712Wallet = Pick - -/** Info strings to separate distinct usages of the key derivation function */ -export enum KDFInfo { - PASSWORD = 'Celo Backup Password and Nonce', - FUSE_KEY = 'Celo Backup Fuse Key', - ODIS_AUTH_KEY = 'Celo Backup ODIS Request Authorization Key', - ODIS_KEY_HARDENING = 'Celo Backup ODIS Key Hardening', - PBKDF = 'Celo Backup PBKDF Hardening', - SCRYPT = 'Celo Backup scrypt Hardening', - FINALIZE = 'Celo Backup Key Finalization', -} - -/** - * Key derivation function for mixing source keying material. - * @param info Fixed string value used for domain separation. - * @param sources An array of keying material source values (e.g. a password and a nonce). - */ -export function deriveKey(info: KDFInfo, sources: Buffer[], length: number = 32): Buffer { - // Hash each source keying material component, and the info value, to prevent hashing collisions - // that might result if the variable length data is simply concatenated. - const chunks = [Buffer.from(info, 'utf8'), ...sources].map((source: Buffer) => { - const hash = crypto.createHash('sha256') - hash.update(source) - return hash.digest() - }) - - return crypto.pbkdf2Sync(Buffer.concat(chunks), Buffer.alloc(0), 1, length, 'sha256') -} - -/** - * AES-128-GCM encrypt the given data with the given 16-byte key. - * Encode the ciphertext as { iv || data || auth tag } - */ -export function encrypt(key: Buffer, data: Buffer): Result { - try { - const iv = crypto.randomBytes(16) - const cipher = crypto.createCipheriv('aes-128-gcm', key, iv) - return Ok(Buffer.concat([iv, cipher.update(data), cipher.final(), cipher.getAuthTag()])) - } catch (error) { - return Err(new EncryptionError(error as Error)) - } -} - -/** - * AES-128-GCM decrypt the given data with the given 16-byte key. - * Ciphertext should be encoded as { iv || data || auth tag }. - */ -export function decrypt(key: Buffer, ciphertext: Buffer): Result { - try { - const len = ciphertext.length - const iv = ciphertext.slice(0, 16) - const ciphertextData = ciphertext.slice(16, len - 16) - const auth = ciphertext.slice(len - 16, len) - const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv) - decipher.setAuthTag(auth) - return Ok(Buffer.concat([decipher.update(ciphertextData), decipher.final()])) - } catch (error) { - return Err(new DecryptionError(error as Error)) - } -} - -/** - * PBKDF2-SHA256 computational key hardening. - * - * @remarks When possible, a memory hard function such as scrypt should be used instead. - * No salt parameter is provided as the intended use case of this function is to harden a - * key value which is derived from a password but already has the salt mixed in. - * - * @see { @link - * https://nodejs.org/api/crypto.html#cryptopbkdf2password-salt-iterations-keylen-digest-callback | - * NodeJS crypto.pbkdf2 API } - * - * @param key Key buffer to compute hardening against. Should have a salt or nonce mixed in. - * @param iterations Number of PBKDF2 iterations to execute for key hardening. - */ -export function pbkdf2(key: Buffer, iterations: number): Promise> { - return new Promise((resolve) => { - crypto.pbkdf2(key, KDFInfo.PBKDF, iterations, 32, 'sha256', (error, result) => { - if (error) { - resolve(Err(new PbkdfError(iterations, error))) - } - resolve(Ok(result)) - }) - }) -} - -/** Cost parameters for the scrypt computational hardening function. */ -export interface ScryptOptions { - cost: number - blockSize?: number - parallelization?: number -} - -/** - * scrypt computational key hardening. - * - * @remarks No salt parameter is provided as the intended use case of this function is to harden a - * key value which is derived from a password but already has the salt mixed in. - * - * @see { @link - * https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback | - * NodeJS crypto.scrypt API } - * - * @param key Key buffer to compute hardening against. Should have a salt or nonce mixed in. - * @param options Options to control the cost of the scrypt function. - */ -export function scrypt(key: Buffer, options: ScryptOptions): Promise> { - // Define the maxmem parameter to be large enough to accommodate the provided options. - // See the Node JS crypto implementation of scrypt for more detail. - const maxmem = Math.max(32 * 1024 * 1024, 128 * options.cost * (options.blockSize ?? 8)) - return new Promise((resolve) => { - crypto.scrypt(key, KDFInfo.SCRYPT, 32, { maxmem, ...options }, (error, result) => { - if (error) { - resolve(Err(new ScryptError(options, error))) - } - resolve(Ok(result)) - }) - }) -} - -export function computationalHardenKey( - key: Buffer, - config: ComputationalHardeningConfig -): Promise> { - switch (config.function) { - case ComputationalHardeningFunction.PBKDF: - return pbkdf2(key, config.iterations) - case ComputationalHardeningFunction.SCRYPT: - return scrypt(key, config) - } -} diff --git a/packages/sdk/encrypted-backup/tsconfig.json b/packages/sdk/encrypted-backup/tsconfig.json deleted file mode 100644 index 25d848de425..00000000000 --- a/packages/sdk/encrypted-backup/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@celo/typescript/tsconfig.library.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib" - }, - "include": ["src/**/*", "types/**/*"] -} diff --git a/packages/sdk/encrypted-backup/tslint.json b/packages/sdk/encrypted-backup/tslint.json deleted file mode 100644 index 036f000683b..00000000000 --- a/packages/sdk/encrypted-backup/tslint.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": ["@celo/typescript/tslint.json"], - "rules": { - "no-global-arrow-functions": false, - "no-console": false, - "member-ordering": false, - "max-classes-per-file": false - } -} diff --git a/packages/sdk/encrypted-backup/typedoc.json b/packages/sdk/encrypted-backup/typedoc.json deleted file mode 100644 index de3cb588c4f..00000000000 --- a/packages/sdk/encrypted-backup/typedoc.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "mode": "modules", - "exclude": ["**/generated/*.ts", "**/*+(index|.test).ts"], - "excludeNotExported": true, - "excludePrivate": true, - "excludeProtected": true, - "includeDeclarations": false, - "ignoreCompilerErrors": true, - "hideGenerator": "true", - "theme": "gitbook", - "out": "../../docs/developer-resources/identity/reference", - "gitRevision": "master" - } \ No newline at end of file diff --git a/packages/sdk/identity/src/odis/circuit-breaker.mock.ts b/packages/sdk/identity/src/odis/circuit-breaker.mock.ts deleted file mode 100644 index 4ee8a3c9460..00000000000 --- a/packages/sdk/identity/src/odis/circuit-breaker.mock.ts +++ /dev/null @@ -1,137 +0,0 @@ -import * as crypto from 'crypto' -import debugFactory from 'debug' -import { - BASE64_REGEXP, - CircuitBreakerEndpoints, - CircuitBreakerKeyStatus, - CircuitBreakerServiceContext, - CircuitBreakerStatusResponse, - CircuitBreakerUnwrapKeyRequest, - CircuitBreakerUnwrapKeyResponse, -} from './circuit-breaker' - -const debug = debugFactory('kit:identity:odis:circuit-breaker:mock') - -export const MOCK_CIRCUIT_BREAKER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- -MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDGtxUPljt+oHFBf3RrDZHN9TbT -iI0kK4bv02Z2WP7kU/PQCikWqNl9/VjGVXuGMlfwpcWZrjWwJa+kBlUYRXH/inXW -UKO5PqTnaUXS1ALasGAUvzRz3VvzCxpjKsjVS8/gAoJbY2Imwor432OLrOssNoK7 -jbl1TgaV47yGCKwF9wIDAQAB ------END PUBLIC KEY-----` - -export const MOCK_CIRCUIT_BREAKER_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- -MIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAMa3FQ+WO36gcUF/ -dGsNkc31NtOIjSQrhu/TZnZY/uRT89AKKRao2X39WMZVe4YyV/ClxZmuNbAlr6QG -VRhFcf+KddZQo7k+pOdpRdLUAtqwYBS/NHPdW/MLGmMqyNVLz+ACgltjYibCivjf -Y4us6yw2gruNuXVOBpXjvIYIrAX3AgMBAAECgYBGPqv8QZAweAjxLVv7B+X112wV -JN033wcpOiKrTVR1ZFP4w864iuGvTuKV4dvzmVJK6F7Mr6+c4AWRxwdHuCzOlwxj -O9RySFAXhoENu70zg8W2w4i8GMHsmdnNk045cF01Mb3GtQ6Y3uGb637XYTIwMEbC -Q74TbkrfPZPcSIpPEQJBAP4VModTr47oNvdyJITQ3fzIarRSDU0deZTpn6MXB3a1 -abOAzlqYK3CSvLyyM9GOB9C5wvIZev+aNU9SkqPzU38CQQDINu7nOqS2X8UXQ5sS -wFrnoBQcU78i7Jaopvw0kOvkvklHlKVvXVkWP8PaWYdUAO9fpEdKdRnfaOEnqBwT -aymJAkEAgTXmbEtyjAoracryJ1jQiyyglvLjMMQ8gC4OsLGVahj3mAF47zlTXfxB -XvSAxaCk+NB/Av9SPYn+ckhbqmSjoQJAYb6H1bVIkoyg0OG9hGMKPkhlaQrtpmQw -jTewqw0RTQQlDGAigALnqjgJKsFIkxc9xciS0WPn9KzkNxMYWdaYWQJBAI8asXXb -XF5Lg2AAM2xJ/SS+h+si4f70eZey4vo9pWB3Q+VKbtRZu2pCjlR1A1nIqigJxdlc -1jHX+4GiW+t0w8Q= ------END PRIVATE KEY-----` - -const MOCK_CIRCUIT_BREAKER_ENVIRONMENT: CircuitBreakerServiceContext = { - url: 'https://mockcircuitbreaker.com/', - publicKey: MOCK_CIRCUIT_BREAKER_PUBLIC_KEY, -} - -/** - * Mock circuit breaker implementation based on Valora implementaion - * github.com/valora-inc/wallet/tree/main/packages/cloud-functions/src/circuitBreaker/circuitBreaker.ts - */ -export class MockCircuitBreaker { - static readonly publicKey = MOCK_CIRCUIT_BREAKER_PUBLIC_KEY - static readonly privateKey = MOCK_CIRCUIT_BREAKER_PRIVATE_KEY - static readonly environment = MOCK_CIRCUIT_BREAKER_ENVIRONMENT - - public keyStatus: CircuitBreakerKeyStatus = CircuitBreakerKeyStatus.ENABLED - - status(): { status: number; body: CircuitBreakerStatusResponse } { - return { - status: 200, - body: { status: this.keyStatus }, - } - } - - unwrapKey( - req: CircuitBreakerUnwrapKeyRequest - ): { status: number; body: CircuitBreakerUnwrapKeyResponse } { - const { ciphertext } = req - if (!ciphertext) { - return { - status: 400, - body: { error: '"ciphertext" parameter must be provided' }, - } - } else if (!BASE64_REGEXP.test(ciphertext)) { - return { - status: 400, - body: { error: '"ciphertext" parameter must be a base64 encoded buffer' }, - } - } - - if (this.keyStatus !== CircuitBreakerKeyStatus.ENABLED) { - return { - status: 503, - body: { status: this.keyStatus }, - } - } - - let plaintext: Buffer - try { - plaintext = crypto.privateDecrypt( - // @ts-ignore support for OAEP hash option, was added in Node 12.9.0. - { key: MOCK_CIRCUIT_BREAKER_PRIVATE_KEY, oaepHash: 'sha256' }, - Buffer.from(ciphertext, 'base64') - ) - } catch (error) { - return { - status: 500, - body: { error: 'Error while decrypting ciphertext' }, - } - } - - return { - status: 200, - body: { plaintext: plaintext.toString('base64') }, - } - } - - installStatusEndpoint(mock: typeof fetchMock, override?: any) { - mock.mock( - { - url: new URL(CircuitBreakerEndpoints.STATUS, MockCircuitBreaker.environment.url).href, - method: 'GET', - }, - override ?? - ((url: string, req: unknown) => { - debug('Mocking request', { url, req }) - return this.status() - }) - ) - } - - installUnwrapKeyEndpoint(mock: typeof fetchMock, override?: any) { - mock.mock( - { - url: new URL(CircuitBreakerEndpoints.UNWRAP_KEY, MockCircuitBreaker.environment.url).href, - method: 'POST', - }, - override ?? - ((url: string, req: { body: string }) => { - debug('Mocking request', { url, req }) - return this.unwrapKey(JSON.parse(req.body) as CircuitBreakerUnwrapKeyRequest) - }) - ) - } - - install(mock: typeof fetchMock) { - this.installStatusEndpoint(mock) - this.installUnwrapKeyEndpoint(mock) - } -} diff --git a/packages/sdk/identity/src/odis/circuit-breaker.test.ts b/packages/sdk/identity/src/odis/circuit-breaker.test.ts deleted file mode 100644 index 5fc2bf40f3a..00000000000 --- a/packages/sdk/identity/src/odis/circuit-breaker.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -import * as crypto from 'crypto' -import { - CircuitBreakerClient, - CircuitBreakerErrorTypes, - CircuitBreakerKeyStatus, -} from './circuit-breaker' -import { MockCircuitBreaker } from './circuit-breaker.mock' - -describe('CircuitBreakerClient', () => { - const client = new CircuitBreakerClient(MockCircuitBreaker.environment) - let mockService: MockCircuitBreaker | undefined - - beforeEach(() => { - fetchMock.reset() - fetchMock.config.overwriteRoutes = true - - // Mock the circuit breaker service using the mock implementation defined above. - mockService = new MockCircuitBreaker() - mockService.install(fetchMock) - }) - - afterEach(() => { - fetchMock.reset() - }) - - describe('.status()', () => { - it('should fetch the current circuit breaker status', async () => { - for (const status of Object.values(CircuitBreakerKeyStatus)) { - mockService!.keyStatus = status - const result = await client.status() - expect(result.ok).toBe(true) - if (!result.ok) { - continue - } - expect(result.result).toEqual(status) - } - }) - - it('should return an error if fetch throws', async () => { - mockService!.installStatusEndpoint(fetchMock, { throws: new Error('fetch error') }) - const result = await client.status() - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.FETCH_ERROR) - }) - - it('should return an error if the fetch returns an HTTP error status', async () => { - mockService!.installStatusEndpoint(fetchMock, { status: 501 }) - const result = await client.status() - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - - it('should return an error if fetch results in invalid json', async () => { - mockService!.installStatusEndpoint(fetchMock, { status: 200, body: '' }) - const result = await client.status() - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - - it('should return an error if fetch results in an invalid status', async () => { - mockService!.installStatusEndpoint(fetchMock, { - status: 200, - body: { status: 'invalid status' }, - }) - const result = await client.status() - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - }) - - describe('.wrapKey()', () => { - it('should return an encryption of the given plaintext', async () => { - const testData = 'test circuit breaker plaintext' - const ciphertext = client.wrapKey(Buffer.from(testData)) - expect(ciphertext.ok).toBe(true) - if (!ciphertext.ok) { - return - } - - const plaintext = crypto.privateDecrypt( - //@ts-ignore support for OAEP hash option, was added in Node 12.9.0. - { key: MockCircuitBreaker.privateKey, oaepHash: 'sha256' }, - ciphertext.result - ) - expect(plaintext.toString('utf8')).toEqual(testData) - }) - }) - - describe('.unwrapKey()', () => { - const testData = 'test circuit breaker plaintext' - const wrapResult = client.wrapKey(Buffer.from(testData)) - if (!wrapResult.ok) { - throw new Error('failed to produce test ciphertext for unwrapKey') - } - const testCiphertext = wrapResult.result - - it('should decrypt the given ciphertext when the service is enabled', async () => { - const result = await client.unwrapKey(testCiphertext) - expect(result.ok).toBe(true) - if (!result.ok) { - return - } - expect(result.result.toString('utf8')).toEqual(testData) - }) - - it('should return an error status response when the service is disabled', async () => { - for (const status of Object.values(CircuitBreakerKeyStatus)) { - if (status === CircuitBreakerKeyStatus.ENABLED) { - continue - } - - mockService!.keyStatus = status - const result = await client.unwrapKey(testCiphertext) - expect(result.ok).toBe(false) - if (result.ok) { - continue - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.UNAVAILABLE_ERROR) - } - }) - - it('should return an error if fetch throws', async () => { - mockService!.installUnwrapKeyEndpoint(fetchMock, { throws: new Error('fetch error') }) - const result = await client.unwrapKey(testCiphertext) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.FETCH_ERROR) - }) - - it('should return an error if the fetch returns an HTTP error status', async () => { - mockService!.installUnwrapKeyEndpoint(fetchMock, { status: 501 }) - const result = await client.unwrapKey(testCiphertext) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - - it('should return an error if fetch results in invalid json', async () => { - mockService!.installUnwrapKeyEndpoint(fetchMock, { status: 200, body: '' }) - const result = await client.unwrapKey(testCiphertext) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - - it('should return an error if fetch results in an invalid plaintext', async () => { - mockService!.installUnwrapKeyEndpoint(fetchMock, { - status: 200, - body: { plaintext: '' }, - }) - const result = await client.unwrapKey(testCiphertext) - expect(result.ok).toBe(false) - if (result.ok) { - return - } - expect(result.error.errorType).toEqual(CircuitBreakerErrorTypes.SERVICE_ERROR) - }) - }) -}) diff --git a/packages/sdk/identity/src/odis/circuit-breaker.ts b/packages/sdk/identity/src/odis/circuit-breaker.ts deleted file mode 100644 index ae0a99e0277..00000000000 --- a/packages/sdk/identity/src/odis/circuit-breaker.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { Err, Ok, Result, RootError } from '@celo/base/lib/result' -import fetch from 'cross-fetch' -import * as crypto from 'crypto' - -export const BASE64_REGEXP = /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/ - -export interface CircuitBreakerServiceContext { - url: string - publicKey: string -} - -export const VALORA_ALFAJORES_CIRCUIT_BREAKER_ENVIRONMENT: CircuitBreakerServiceContext = { - url: 'https://us-central1-celo-mobile-alfajores.cloudfunctions.net/circuitBreaker/', - // DO NOT MERGE: These keys need to be removed or updated with the latest produciton keys. - publicKey: `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi+9UKUsVY5UGYwHFN2M2 -90RlNputQeJmSi1phRtQgpXP2RvZK/IFkIygiigXPcFlm7FK35A5qi1HqNTL/2sy -EH+9KnfS5zaUYX0sb2tBiEfzuIh+xLf/MXo1r8fC3MqiIUOZpEDK1XJTxt5XaKC8 -+gg1WUyuMw5Qj7ngaEwWaQGCijsJno3aDMuyvt4GceFYCzhj43LnaA3mhili7ghV -uOyKMIHCFd6wvMiSGUfIZRZ7md+zvlAZaWFHFMzbbSYvUIMRtkgfm2phRcXetoha -FCP4PD70/ogeKQswFCiOJo4JKYr3SHujFHq8HgKT3GqJ0JXu3Ry2J/qU29kge6R+ -wwIDAQAB ------END PUBLIC KEY-----`, -} - -export const VALORA_MAINNET_CIRCUIT_BREAKER_ENVIRONMENT: CircuitBreakerServiceContext = { - url: 'https://us-central1-celo-mobile-mainnet.cloudfunctions.net/circuitBreaker/', - // DO NOT MERGE: These keys need to be removed or updated with the latest produciton keys. - publicKey: `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsMd1OIdYfTcnYkIXPeym -KSiQmNCEn2DC2mUichrRpJFeb9VO65PeLjMXTIjKyp4TZ3PhXJyK9kEEF27E1wj8 -C1WqLIwSP97t1479UHaI7NzAV4nvqvziuP9Zq5fmbxourkMYoXMpZEYNK9OEwEvx -hSQXA1XvYqMALJwRx/8S6taAcJEYenraKiRvxteWqXB6R8HSTxyaOR9qfakZFp1f -d8B9/c3KDiue80yPng1W4AV5GnltoHCcwe97j5gabqztQl8K0yty73wmAFjDB3Ni -cOY/855BxdoOT2XQLs99ytPJJG5uoHKEZbHVzy7d/bagnD08w1/vaeTxyRYuGgfb -mQIDAQAB ------END PUBLIC KEY-----`, -} - -export enum CircuitBreakerKeyStatus { - ENABLED = 'ENABLED', - DISABLED = 'DISABLED', - DESTROYED = 'DESTROYED', - UNKNOWN = 'UNKNOWN', -} - -export enum CircuitBreakerEndpoints { - HEALTH = 'health', - STATUS = 'status', - UNWRAP_KEY = 'unwrap-key', -} - -export interface CircuitBreakerStatusResponse { - /** Status of the circuit breaker service */ - status: CircuitBreakerKeyStatus -} - -export interface CircuitBreakerUnwrapKeyRequest { - /** RSA-OAEP-256 encrypted data to be unwrapped by the circuit breaker. Encoded as base64 */ - ciphertext: string -} - -export interface CircuitBreakerUnwrapKeyResponse { - /** Decryption of the ciphertext provided to the circuit breaker service */ - plaintext?: string - - /** Error message indicating what went wrong if the ciphertext could not be decrypted */ - error?: string - - /** Status of the circuit breaker service. Included if the service is not enabled. */ - status?: CircuitBreakerKeyStatus -} - -export enum CircuitBreakerErrorTypes { - FETCH_ERROR = 'FETCH_ERROR', - SERVICE_ERROR = 'CIRCUIT_BREAKER_SERVICE_ERROR', - UNAVAILABLE_ERROR = 'CIRCUIT_BREAKER_UNAVAILABLE_ERROR', - ENCRYPTION_ERROR = 'ENCRYPTION_ERROR', -} - -export class CircuitBreakerServiceError extends RootError { - constructor(readonly status: number, readonly error?: Error) { - super(CircuitBreakerErrorTypes.SERVICE_ERROR) - } -} - -export class CircuitBreakerUnavailableError extends RootError { - constructor(readonly status: CircuitBreakerKeyStatus) { - super(CircuitBreakerErrorTypes.UNAVAILABLE_ERROR) - } -} - -export class EncryptionError extends RootError { - constructor(readonly error?: Error) { - super(CircuitBreakerErrorTypes.ENCRYPTION_ERROR) - } -} - -export class FetchError extends RootError { - constructor(readonly error?: Error) { - super(CircuitBreakerErrorTypes.FETCH_ERROR) - } -} - -export type CircuitBreakerError = - | CircuitBreakerServiceError - | CircuitBreakerUnavailableError - | EncryptionError - | FetchError - -/** - * Client for interacting with a circuit breaker service for encrypted cloud backups. - * - * @remarks A circuit breaker is a service supporting a public decryption function backed by an HSM - * key. If the need arises, the circuit breaker operator may take the decryption function offline. - * A client can encrypt data to the circuit breaker public key and store it in a non-public place. - * This data will then be available under normal circumstances, but become unavailable in the case - * of an emergency. - * - * It is intended for use in password-based key derivation when ODIS is used as a key hardening - * function. Clients may include in their key dervivation a random value which they encrypt to the - * circuit breaker public key. This allows the circuit breaker operator to disable key derivation, - * by restricting access to the encrypted keying material, in the event that ODIS is conpromised. - * This acts as a safety measure to allow wallet providers, or other users of ODIS key hardening, to - * prevent attackers from being able to brute force their users' derived keys in the event that - * ODIS is compromised such that it can no longer add to the key hardening. - * - * The circuit breaker service is designed for use in the encrypted cloud backup protocol. More - * information about encrypted cloud backup and the circuit breaker service can be found in the - * official {@link https://docs.celo.org/celo-codebase/protocol/identity/encrypted-cloud-backup | - * Celo documentation} - */ -export class CircuitBreakerClient { - constructor(readonly environment: CircuitBreakerServiceContext) {} - - protected url(endpoint: CircuitBreakerEndpoints): string { - // Note that if the result of this is an invalid URL, the URL constructor will throw. This is - // caught and reported as a fetch error, as a request could not be made. - return new URL(endpoint, this.environment.url).href - } - - /** - * Check the current status of the circuit breaker service. Result will reflect whether or not - * the circuit breaker keys are currently available. - */ - async status(): Promise> { - let response: Response - try { - response = await fetch(this.url(CircuitBreakerEndpoints.STATUS), { - method: 'GET', - headers: { - Accept: 'application/json', - }, - }) - } catch (error) { - return Err(new FetchError(error as Error)) - } - - let obj: any - try { - obj = await response.json() - } catch (error) { - return Err(new CircuitBreakerServiceError(response.status, error as Error)) - } - - // If the response was an error code, return an error to the user. - // We do not expect an error message to be included with the response from the status endpoint. - if (!response.ok) { - return Err(new CircuitBreakerServiceError(response.status)) - } - - if (!Object.values(CircuitBreakerKeyStatus).includes(obj.status)) { - return Err( - new CircuitBreakerServiceError( - response.status, - new Error(`circuit breaker service returned unexpected response: ${obj.status}`) - ) - ) - } - - return Ok(obj.status as CircuitBreakerKeyStatus) - } - - /** - * RSA-OAEP-256 Encrypt the provided key value against the public key of the circuit breaker. - * - * @remarks Note that this is an entirely local procedure and does not require interaction with - * the circuit breaker service. Encryption occurs only against the service public key. - */ - wrapKey(plaintext: Buffer): Result { - let ciphertext: Buffer - try { - ciphertext = crypto.publicEncrypt( - { - key: this.environment.publicKey, - // @ts-ignore support for OAEP hash option, was added in Node 12.9.0. - oaepHash: 'sha256', - encoding: 'pem', - }, - plaintext - ) - } catch (error) { - return Err(new EncryptionError(error as Error)) - } - return Ok(ciphertext) - } - - /** Request the circuit breaker service to decrypt the provided encrypted key value */ - async unwrapKey(ciphertext: Buffer): Promise> { - const request: CircuitBreakerUnwrapKeyRequest = { - ciphertext: ciphertext.toString('base64'), - } - - let response: Response - try { - response = await fetch(this.url(CircuitBreakerEndpoints.UNWRAP_KEY), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request), - }) - } catch (error) { - return Err(new FetchError(error as Error)) - } - - let obj: any - try { - obj = await response.json() - } catch (error) { - return Err(new CircuitBreakerServiceError(response.status, error as Error)) - } - - // If the response was an error code, return an error to the user after trying to parse the - // error from the service response. Either an error message or a status value may be returned. - if (!response.ok) { - if (obj.error !== undefined || obj.status === undefined) { - return Err(new CircuitBreakerServiceError(response.status, obj.error)) - } else { - return Err(new CircuitBreakerUnavailableError(obj.status)) - } - } - - const plaintext = obj.plaintext - if (plaintext === undefined || !BASE64_REGEXP.test(plaintext)) { - // Plaintext value is not returned in the error as it may has sensitive information. - const error = new Error('circuit breaker returned invalid plaintext response') - return Err(new CircuitBreakerServiceError(response.status, error)) - } - - return Ok(Buffer.from(plaintext, 'base64')) - } -}