UNPKG

@i3m/cloud-vault-client

Version:
488 lines (409 loc) 15.8 kB
import type { ConnectedEvent, StorageUpdatedEvent } from '@i3m/cloud-vault-server' import type { OpenApiComponents, OpenApiPaths } from '@i3m/cloud-vault-server/types/openapi' import { randomBytes } from 'crypto' import { EventEmitter } from 'events' import EventSource from 'eventsource' import { apiVersion } from './config' import { VaultError } from './error' import { KeyManager } from './key-manager' import { Request, RetryOptions } from './request' import type { ArgsForEvent, VaultEventName } from './events' import { VAULT_STATE, VaultState, stateFromError } from './vault-state' import { JWK, jweEncrypt } from '@i3m/non-repudiation-library' import { passwordCheck, PasswordStrengthOptions } from './password-checker' export type CbOnEventFn<T extends VaultEventName> = (...args: ArgsForEvent<T>) => void export interface VaultStorage { storage: Buffer timestamp?: number // milliseconds elapsed since epoch of the last downloaded storage } export interface VaultClientOpts { name?: string defaultRetryOptions?: RetryOptions passwordStrengthOptions?: PasswordStrengthOptions } interface LoginOptions { username: string password: string timestamp?: number } export class VaultClient extends EventEmitter { timestamp?: number token?: string name: string serverUrl?: string wellKnownCvsConfiguration?: OpenApiComponents.Schemas.CvsConfiguration state: Promise<VaultState> private readonly request: Request private keyManager?: KeyManager private es?: EventSource private switchingState: Promise<void> constructor (opts?: VaultClientOpts) { super({ captureRejections: true }) this.name = opts?.name ?? randomBytes(16).toString('hex') this.request = new Request({ retryOptions: { retries: 1200 * 24, // will retry for 24 hours retryDelay: 3000, ...opts?.defaultRetryOptions }, defaultCallOptions: { sequential: true } }) this.state = new Promise((resolve, reject) => { resolve(VAULT_STATE.NOT_INITIALIZED) }) this.switchingState = new Promise((resolve, reject) => { resolve() }) } emit<T extends VaultEventName>(eventName: T, ...args: ArgsForEvent<T>): boolean emit (eventName: string | symbol, ...args: any[]): boolean { return super.emit(eventName, ...args) } on<T extends VaultEventName>(event: T, cb: CbOnEventFn<T>): this on (eventName: string | symbol, listener: (...args: any[]) => void): this { return super.on(eventName, listener) } once<T extends VaultEventName>(event: T, cb: CbOnEventFn<T>): this once (eventName: string | symbol, listener: (...args: any[]) => void): this { return super.once(eventName, listener) } protected async switchToState (newState: VaultState, opts?: LoginOptions): Promise<VaultState> { if (newState < VAULT_STATE.LOGGED_IN) { await this.request.stop() } await this.switchingState let error: VaultError | undefined let state: VaultState | undefined this.switchingState = new Promise((resolve, reject) => { this._switchToStatePromise(newState, opts) .then((finalState) => { state = finalState }) .catch((err) => { error = VaultError.from(err) }) .finally(() => { resolve() }) }) await this.switchingState if (error !== undefined) { throw error } return state as VaultState } private async _switchToStatePromise (newState: VaultState, opts?: LoginOptions): Promise<VaultState> { let currentState = await this.state if (currentState === newState) { return currentState } if (newState < VAULT_STATE.NOT_INITIALIZED || newState > VAULT_STATE.CONNECTED) { throw new VaultError('error', new Error('invalid state')) } const i = (newState > currentState) ? 1 : -1 while (currentState !== newState) { let error this.state = new Promise((resolve, reject) => { this._switchToState(currentState, currentState + i as VaultState, opts).then((state) => { resolve(state) this.emit('state-changed', state) }).catch((err) => { error = err resolve(currentState) }) }) currentState = await this.state if (error !== undefined) { throw VaultError.from(error) } } return currentState } private async _switchToState (currentState: VaultState, newState: VaultState, opts?: LoginOptions): Promise<VaultState> { switch (newState) { case VAULT_STATE.NOT_INITIALIZED: // Only option is to come from INITIALIZED delete this.serverUrl delete this.wellKnownCvsConfiguration this.state = new Promise((resolve, reject) => { resolve(VAULT_STATE.NOT_INITIALIZED) }) break case VAULT_STATE.INITIALIZED: if (currentState === VAULT_STATE.NOT_INITIALIZED) { this.wellKnownCvsConfiguration = await this.request.get<OpenApiComponents.Schemas.CvsConfiguration>(this.serverUrl as string + '/.well-known/cvs-configuration', { responseStatus: 200 }).catch(err => { throw new VaultError('not-initialized', err) }) } else { // this.state === VAULT_STATE.LOGGED_IN await this.request?.stop() delete this.token delete this.timestamp delete this.keyManager this.es?.close() delete this.es } break case VAULT_STATE.LOGGED_IN: if (currentState === VAULT_STATE.INITIALIZED) { if (opts === undefined || opts.username === undefined || opts.password === undefined) { throw new VaultError('invalid-credentials', new Error('you need credentials to log in')) } await this._initKeyManager(opts.username, opts.password) const reqBody: OpenApiPaths.ApiV2VaultToken.Post.RequestBody = { username: opts.username, authkey: (this.keyManager as KeyManager).authKey } const cvsConf = this.wellKnownCvsConfiguration as OpenApiComponents.Schemas.CvsConfiguration const data = await this.request.post<OpenApiPaths.ApiV2VaultToken.Post.Responses.$200>( cvsConf.vault_configuration.v2.token_endpoint, reqBody, { responseStatus: 200 } ) this.token = data.token this.request.defaultUrl = cvsConf.vault_configuration.v2.vault_endpoint this.timestamp = opts.timestamp this._initEventSourceClient().catch(err => { throw err }) } break case VAULT_STATE.CONNECTED: // this.state === VAULT_STATE.LOGGED_IN break default: break } return newState } private async _initEventSourceClient (): Promise<void> { if (this.es !== undefined) { return } const cvsConf = this.wellKnownCvsConfiguration as OpenApiComponents.Schemas.CvsConfiguration const esUrl = cvsConf.vault_configuration[apiVersion].events_endpoint this.es = new EventSource(esUrl, { headers: { Authorization: 'Bearer ' + (this.token as string) } }) this.es.addEventListener('connected', (e) => { const msg = JSON.parse(e.data) as ConnectedEvent['data'] if (msg.timestamp === undefined) { this.emit('empty-storage') } else if (msg.timestamp !== this.timestamp) { this.timestamp = msg.timestamp this.emit('storage-updated', this.timestamp) } this.switchToState(VAULT_STATE.CONNECTED).catch(err => { throw err }) }) this.es.addEventListener('storage-updated', (e) => { const vaultRequest = this.request vaultRequest.waitForOngoingRequestsToFinsh().finally(() => { const msg = JSON.parse(e.data) as StorageUpdatedEvent['data'] if (msg.timestamp !== this.timestamp) { this.timestamp = msg.timestamp this.emit('storage-updated', this.timestamp) } }).catch(reason => {}) }) this.es.addEventListener('storage-deleted', (e) => { const vaultRequest = this.request vaultRequest.waitForOngoingRequestsToFinsh().finally(() => { this.logout().catch(err => { throw err }) this.emit('storage-deleted') }).catch(reason => {}) }) this.es.onerror = (e) => { this.state.then((state) => { this.switchToState(stateFromError(state, e)).catch((reason) => { console.error(reason) }) }).catch(reason => { console.error(reason) }) } this.es.onmessage = (m) => { console.log(m) } } private async _initKeyManager (username: string, password: string): Promise<void> { const cvsConf = this.wellKnownCvsConfiguration as OpenApiComponents.Schemas.CvsConfiguration this.keyManager = new KeyManager(username, password, cvsConf.vault_configuration[apiVersion].key_derivation) await this.keyManager.initialized } async init (serverUrl: string): Promise<string> { const url = new URL(serverUrl) const serverRootUrl = url.origin const serverPrefix = url.pathname.endsWith('/') ? url.pathname.slice(0, -1) : url.pathname this.serverUrl = serverRootUrl + serverPrefix if (await this.state > VAULT_STATE.INITIALIZED) { throw new VaultError('error', new Error('to init the client, it should NOT be INITIALIZED')) } await this.switchToState(VAULT_STATE.INITIALIZED) return this.serverUrl } async login (username: string, password: string, timestamp?: number): Promise<void> { const currentState = await this.state if (currentState !== VAULT_STATE.INITIALIZED && currentState !== VAULT_STATE.LOGGED_IN) { throw new VaultError('error', new Error('in order to login you should be in state INITIALIZED or LOGGED IN but not receiving SSE events')) } await this.switchToState(VAULT_STATE.LOGGED_IN, { username, password, timestamp }) } async logout (): Promise<void> { if (await this.state < VAULT_STATE.LOGGED_IN) { throw new VaultError('error', new Error('in order to log out you should be in state LOGGED IN or CONNECTED')) } await this.switchToState(VAULT_STATE.INITIALIZED) } async close (): Promise<void> { await this.switchToState(VAULT_STATE.NOT_INITIALIZED) } async getRemoteStorageTimestamp (): Promise<number | null> { if (await this.state < VAULT_STATE.LOGGED_IN) { throw new VaultError('unauthorized', 'you must be logged in') } const cvsConf = this.wellKnownCvsConfiguration as OpenApiComponents.Schemas.CvsConfiguration try { const data = await this.request.get<OpenApiPaths.ApiV2VaultTimestamp.Get.Responses.$200>( cvsConf.vault_configuration[apiVersion].timestamp_endpoint, { responseStatus: 200, bearerToken: this.token } ) if ((this.timestamp ?? 0) < data.timestamp) { this.timestamp = data.timestamp } return data.timestamp } catch (error) { await this.switchToState(stateFromError(await this.state, error)) throw error } } async getStorage (): Promise<VaultStorage> { if (await this.state < VAULT_STATE.LOGGED_IN) { throw new VaultError('unauthorized', undefined) } const startTs = Date.now() this.emit('sync-start', startTs) try { const data = await this.request.get<OpenApiPaths.ApiV2Vault.Get.Responses.$200>( { responseStatus: 200, bearerToken: this.token } ) if (data.timestamp < (this.timestamp ?? 0)) { throw new VaultError('validation', { description: 'WEIRD!!! Received timestamp is older than the one received in previous events' }) } const storage = (this.keyManager as KeyManager).encKey.decrypt(Buffer.from(data.ciphertext, 'base64url')) this.timestamp = data.timestamp this.emit('sync-stop', startTs, Date.now()) return { storage, timestamp: data.timestamp } } catch (error) { this.emit('sync-stop', startTs, Date.now()) const newState = stateFromError(await this.state, error) await this.switchToState(newState) throw VaultError.from(error) } } async updateStorage (storage: VaultStorage, force: boolean = false): Promise<number> { if (await this.state < VAULT_STATE.LOGGED_IN) { throw new VaultError('unauthorized', undefined) } const startTs = Date.now() this.emit('sync-start', startTs) try { if (force) { const remoteTimestamp = await this.getRemoteStorageTimestamp() storage.timestamp = (remoteTimestamp !== null) ? remoteTimestamp : undefined } if (this.timestamp !== undefined && (storage.timestamp ?? 0) < this.timestamp) { throw new VaultError('conflict', { localTimestamp: storage.timestamp, remoteTimestamp: this.timestamp }) } const encryptedStorage = (this.keyManager as KeyManager).encKey.encrypt(storage.storage) const requestBody: OpenApiPaths.ApiV2Vault.Post.RequestBody = { ciphertext: encryptedStorage.toString('base64url'), timestamp: storage.timestamp } const data = await this.request.post<OpenApiPaths.ApiV2Vault.Post.Responses.$201>(requestBody, { responseStatus: 201, bearerToken: this.token, beforeRequestFinish: async (data) => { this.timestamp = data.timestamp } }) this.emit('sync-stop', startTs, Date.now()) return data.timestamp } catch (error) { this.emit('sync-stop', startTs, Date.now()) await this.switchToState(stateFromError(await this.state, error)) throw VaultError.from(error) } } async deleteStorage (): Promise<void> { if (await this.state < VAULT_STATE.LOGGED_IN) { throw new VaultError('unauthorized', new Error('you must be logged in')) } try { await this.request.stop() await this.request.delete<OpenApiPaths.ApiV2Vault.Delete.Responses.$204>( { bearerToken: this.token, responseStatus: 204 } ) await this.logout() } catch (error) { if (error instanceof VaultError && error.message === 'unauthorized') { await this.logout() } throw error } } async getRegistrationUrl (username: string, password: string, did: string, passwordStrengthOptions?: PasswordStrengthOptions): Promise<string> { const cvsConf = this.wellKnownCvsConfiguration as OpenApiComponents.Schemas.CvsConfiguration passwordCheck(password, passwordStrengthOptions) const responseData = await this.request.get<OpenApiPaths.ApiV2RegistrationPublicJwk.Get.Responses.$200>( cvsConf.registration_configuration.public_jwk_endpoint, { responseStatus: 200 } ) const publicJwk = responseData.jwk const userData = { did, username, authkey: await this.computeAuthKey(username, password) } const regData = await jweEncrypt( Buffer.from(JSON.stringify(userData)), publicJwk as JWK, 'A256GCM' ) return cvsConf.registration_configuration.registration_endpoint.replace('{data}', regData) } private async computeAuthKey (username: string, password: string): Promise<string> { if (await this.state < VAULT_STATE.INITIALIZED) { throw new VaultError('not-initialized', undefined) } const cvsConf = this.wellKnownCvsConfiguration as OpenApiComponents.Schemas.CvsConfiguration const keyManager = new KeyManager(username, password, cvsConf.vault_configuration[apiVersion].key_derivation) await keyManager.initialized return keyManager.authKey } }