import EventEmitter from "eventemitter3" ; import { ChannelQueue } from "@buttercup/channel-queue" ; import { Layerr } from "layerr" ; import Vault from "./Vault" ; import Credentials from "../credentials/Credentials" ; import { getCredentials } from "../credentials/channel" ; import { getUniqueID } from "../tools/encoding" ; import { getSourceOfflineArchive , sourceHasOfflineCopy , storeSourceOfflineCopy } from "../tools/vaultManagement" ; import { credentialsToDatasource , prepareDatasourceCredentials } from "../datasources/register" ; import { generateVaultInsights } from "../insight/vault" ; import AttachmentManager from "../attachments/AttachmentManager" ; import TextDatasource from "../datasources/TextDatasource" ; import VaultManager from "./VaultManager" ; import { VaultSourceID , VaultSourceStatus } from "../types" ; interface StateChangeEnqueuedFunction { ( ) : void | Promise < any > ; } export interface VaultSourceConfig { colour ? : string ; id ? : VaultSourceID ; order ? : number ; meta ? : VaultSourceMetadata ; } export interface VaultSourceUnlockOptions { initialiseRemote ? : boolean ; loadOfflineCopy ? : boolean ; storeOfflineCopy ? : boolean ; } export interface VaultSourceMetadata { [ property ] : any ; } const COLOUR_TEST = /^#([a-f0-9]{3}|[a-f0-9]{6})$/i ; const DEFAULT_COLOUR = "#000000" ; const DEFAULT_ORDER = 1000 ; function processDehydratedCredentials ( credentialsString , masterPassword ) { if ( /^v1\n/ . test ( credentialsString ) ) { const [ , sourceCredStr ] = credentialsString . split ( "\n" ) ; return Credentials . fromSecureString ( sourceCredStr , masterPassword ) ; } return Credentials . fromSecureString ( credentialsString , masterPassword ) ; } augments EventEmitter memberof export default class VaultSource extends EventEmitter { static STATUS_LOCKED = VaultSourceStatus . Locked ; static STATUS_PENDING = VaultSourceStatus . Pending ; static STATUS_UNLOCKED = VaultSourceStatus . Unlocked ; param dehydratedString returns memberof static static rehydrate ( dehydratedString ) { const target = JSON . parse ( dehydratedString ) ; let credentials = target . credentials ; if ( target . v !== 2 ) { const { sourceCredentials , archiveCredentials } = target ; if ( ! sourceCredentials || ! archiveCredentials ) { throw new Error ( "Invalid legacy vault state: missing credentials" ) ; } credentials = `v1\n${ sourceCredentials }\n${ archiveCredentials }` ; } const { id , name , type , colour = DEFAULT_COLOUR , order = DEFAULT_ORDER , meta = { } } = target ; return new VaultSource ( name , type , credentials , { id , colour , order , meta } ) ; } _attachmentManager : AttachmentManager = null ; _colour : string ; _credentials : string | Credentials ; _datasource : TextDatasource = null ; _id : VaultSourceID ; _meta : VaultSourceMetadata ; _name : string ; _order : number ; _queue : ChannelQueue ; _shares : Array < any > = [ ] ; _status : VaultSourceStatus ; _type : string ; _vault : Vault = null ; _vaultManager : VaultManager = null ; constructor ( name , type , credentialsString , config = { } ) { super ( ) ; const { colour = DEFAULT_COLOUR , id = getUniqueID ( ) , order = DEFAULT_ORDER , meta = { } } = config ; this . _queue = new ChannelQueue ( ) ; this . _credentials = credentialsString ; this . _status = VaultSource . STATUS_LOCKED ; this . _id = id ; this . _name = name ; this . _type = type ; this . _colour = colour ; this . _order = order ; this . _meta = meta ; } memberof readonly get attachmentManager ( ) : AttachmentManager { return this . _attachmentManager ; } memberof get colour ( ) : string { return this . _colour ; } memberof readonly get id ( ) : VaultSourceID { return this . _id ; } memberof readonly get meta ( ) : VaultSourceMetadata { return { ... this . _meta } ; } memberof readonly get name ( ) { return this . _name ; } memberof readonly get order ( ) : number { return this . _order ; } memberof readonly get status ( ) : VaultSourceStatus { return this . _status ; } memberof readonly get type ( ) : string { return this . _type ; } memberof readonly get vault ( ) : Vault { return this . _vault ; } set colour ( newColour ) { if ( COLOUR_TEST . test ( newColour ) !== true ) { throw new Layerr ( `Failed setting colour: Invalid format (expected hex): ${ newColour }` ) ; } this . _colour = newColour ; this . emit ( "updated" ) ; } set order ( newOrder ) { if ( isNaN ( newOrder ) || typeof newOrder !== "number" || newOrder < 0 ) { throw new Layerr ( `Failed setting order: Order must be greater than or equal to 0: ${ newOrder }` ) ; } this . _order = newOrder ; this . emit ( "updated" ) ; } param oldPassword param newPassword param meta memberof async changeMasterPassword ( oldPassword , newPassword , meta = { } ) { if ( oldPassword === newPassword ) { throw new Error ( "New password cannot be the same as the previous one" ) ; } else if ( ! newPassword ) { throw new Error ( "New password must be specified" ) ; } const datasourceSupportsChange = this . _datasource . supportsPasswordChange ( ) ; const newMasterCreds = new Credentials ( meta , newPassword ) ; let wasLocked = false ; if ( this . status !== VaultSource . STATUS_UNLOCKED ) { wasLocked = true ; await this . unlock ( Credentials . fromPassword ( oldPassword ) ) ; } else { const credentials = getCredentials ( ( < Credentials > this . _credentials ) . id ) ; if ( credentials . masterPassword !== oldPassword ) { throw new Error ( "Old password does not match current unlocked instance value" ) ; } await this . update ( ) ; } if ( datasourceSupportsChange ) { const isReady = await this . _datasource . changePassword ( prepareDatasourceCredentials ( newMasterCreds , this . _datasource . type ) , true ) ; if ( ! isReady ) { throw new Error ( "Datasource not capable of changing password at this time" ) ; } } await storeSourceOfflineCopy ( this . _vaultManager . _cacheStorage , this . id , null ) ; const newCredentials = Credentials . fromCredentials ( this . _credentials as Credentials , oldPassword ) ; const newCreds = getCredentials ( newCredentials . id ) ; newCreds . masterPassword = newPassword ; await this . _updateVaultCredentials ( newCredentials ) ; if ( wasLocked ) { await this . lock ( ) ; } if ( datasourceSupportsChange ) { await this . _datasource . changePassword ( prepareDatasourceCredentials ( newMasterCreds , this . _datasource . type ) , false ) ; } this . emit ( "passwordChanged" ) ; this . emit ( "updated" ) ; } memberof canBeUpdated ( ) { return this . status === VaultSource . STATUS_UNLOCKED && this . _vault . format . dirty === false ; } returns { Promise . < Boolean > } memberof checkOfflineCopy ( ) { return sourceHasOfflineCopy ( this . _vaultManager . _cacheStorage , this . id ) ; } memberof dehydrate ( ) { return this . _enqueueStateChange ( async ( ) => { const payload = { v : 2 , id : this . id , name : this . name , type : this . type , status : VaultSource . STATUS_LOCKED , colour : this . colour , order : this . order , meta : this . meta , credentials : null } ; if ( this . status === VaultSource . STATUS_PENDING ) { throw new Layerr ( `Failed dehydrating source: Source in pending state: ${ this . id }` ) ; } else if ( this . status === VaultSource . STATUS_LOCKED ) { payload . credentials = this . _credentials ; } else { payload . credentials = await ( < Credentials > this . _credentials ) . toSecureString ( ) ; } return JSON . stringify ( payload ) ; } ) ; } returns memberof getOfflineContent ( ) { return this . checkOfflineCopy ( ) . then ( hasContent => hasContent ? getSourceOfflineArchive ( this . _vaultManager . _cacheStorage , this . id ) : null ) ; } returns memberof localDiffersFromRemote ( ) { if ( this . status !== VaultSource . STATUS_UNLOCKED ) { return Promise . reject ( new Layerr ( `Failed diffing source: Source not unlocked (${ this . status }): ${ this . id }` ) ) ; } if ( typeof ( < any > this . _datasource ) . localDiffersFromRemote === "function" ) { return ( < any > this . _datasource ) . localDiffersFromRemote ( prepareDatasourceCredentials ( this . _credentials as Credentials , this . _datasource . type ) , this . vault . format . history ) ; } if ( this . _datasource . type !== "text" ) { this . _datasource . setContent ( "" ) ; } return this . _datasource . load ( prepareDatasourceCredentials ( this . _credentials as Credentials , this . _datasource . type ) ) . then ( ( { Format , history } ) => { if ( Format !== this . vault . format . getFormat ( ) ) { throw new Error ( "Loaded format does not match that of current vault" ) ; } return Format . historiesDiffer ( this . vault . format . history , history ) ; } ) ; } memberof async lock ( ) { if ( this . status !== VaultSource . STATUS_UNLOCKED ) { throw new Layerr ( `Failed locking source: Source in invalid state (${ this . status }): ${ this . id }` ) ; } await this . _enqueueStateChange ( async ( ) => { this . _status = VaultSource . STATUS_PENDING ; const currentCredentials = this . _credentials ; const currentVault = this . _vault ; const currentDatasource = this . _datasource ; const currentAttachmentMgr = this . _attachmentManager ; try { const credentialsStr = await ( < Credentials > this . _credentials ) . toSecureString ( ) ; this . _credentials = credentialsStr ; this . _datasource = null ; this . _vault = null ; this . _attachmentManager = null ; this . _status = VaultSource . STATUS_LOCKED ; this . emit ( "locked" ) ; } catch ( err ) { this . _credentials = currentCredentials ; this . _datasource = currentDatasource ; this . _vault = currentVault ; this . _status = VaultSource . STATUS_UNLOCKED ; this . _attachmentManager = currentAttachmentMgr ; throw new Layerr ( err , "Failed locking source" ) ; } } ) ; } returns memberof async mergeFromRemote ( ) { if ( this . _datasource . type !== "text" ) { this . _datasource . setContent ( "" ) ; } const { Format , history } = await this . _datasource . load ( prepareDatasourceCredentials ( this . _credentials as Credentials , this . _datasource . type ) ) ; if ( Format !== this . _vault . format . getFormat ( ) ) { throw new Error ( "Format loaded during merge did not match current" ) ; } const newVault = Format . vaultFromMergedHistories ( this . _vault . format . history , history ) ; this . _vault . _updateFormat ( newVault . format ) ; return this . _vault ; } param name memberof rename ( name ) { this . _name = name ; this . emit ( "updated" ) ; } memberof async save ( ) { await this . _enqueueStateChange ( async ( ) => { if ( await this . localDiffersFromRemote ( ) ) { await this . mergeFromRemote ( ) ; } await this . _datasource . save ( this . _vault . format . history , prepareDatasourceCredentials ( this . _credentials as Credentials , this . _datasource . type ) ) ; this . _vault . format . dirty = false ; await this . _updateInsights ( ) ; } , "saving" ) ; this . emit ( "updated" ) ; } supportsAttachments ( ) { if ( this . status !== VaultSource . STATUS_UNLOCKED ) return false ; return this . _datasource . supportsAttachments ( ) ; } async testMasterPassword ( password ) { if ( this . status !== VaultSourceStatus . Locked && this . status !== VaultSourceStatus . Unlocked ) { throw new Error ( `Source in invalid state for password test: ${ this . status }` ) ; } const credStr = this . status === VaultSourceStatus . Locked ? ( this . _credentials as string ) : await ( < Credentials > this . _credentials ) . toSecureString ( ) ; try { await processDehydratedCredentials ( credStr , password ) ; return true ; } catch ( err ) { return false ; } } async unlock ( vaultCredentials , config = { } ) { if ( ! Credentials . isCredentials ( vaultCredentials ) ) { throw new Layerr ( `Failed unlocking source: Invalid credentials passed to source: ${ this . id }` ) ; } const { initialiseRemote = false , loadOfflineCopy = false , storeOfflineCopy = true } = config ; if ( this . status !== VaultSource . STATUS_LOCKED ) { throw new Layerr ( `Failed unlocking source: Source in invalid state (${ this . status }): ${ this . id }` ) ; } const { masterPassword } = getCredentials ( vaultCredentials . id ) ; const originalCredentials = this . _credentials ; this . _status = VaultSource . STATUS_PENDING ; await this . _enqueueStateChange ( ( ) => { let offlineContent = null ; return this . getOfflineContent ( ) . then ( availableOfflineContent => { if ( availableOfflineContent && loadOfflineCopy ) { offlineContent = availableOfflineContent ; } return processDehydratedCredentials ( this . _credentials as string , masterPassword ) ; } ) . then ( ( newCredentials ) => { const credentials = ( this . _credentials = newCredentials ) ; const datasource = ( this . _datasource = credentialsToDatasource ( Credentials . fromCredentials ( credentials , masterPassword ) ) ) ; if ( typeof offlineContent === "string" ) { datasource . setContent ( offlineContent ) ; } datasource . on ( "updated" , ( ) => { this . _waitNonPending ( ) . then ( async ( ) => { if ( this . status === VaultSource . STATUS_UNLOCKED ) { await this . _updateCredentialsFromDatasource ( ) ; } this . emit ( "updated" ) ; } ) . catch ( err => { console . error ( `Error updating datasource credentials for vault: ${ this . id }` , err ) ; } ) ; } ) ; const defaultVault = Vault . createWithDefaults ( ) ; const loadWork = initialiseRemote ? datasource . save ( defaultVault . format . history , credentials ) . then ( ( ) => { this . _vault = defaultVault ; } ) : datasource . load ( credentials ) . then ( ( { Format , history } ) => { this . _vault = Vault . createFromHistory ( history , Format ) ; } ) ; return loadWork . then ( ( ) => { if ( storeOfflineCopy ) { return storeSourceOfflineCopy ( this . _vaultManager . _cacheStorage , this . id , datasource . _content ) ; } if ( loadOfflineCopy ) { this . vault . format . _readOnly = true ; } } ) . then ( ( ) => { this . _status = VaultSource . STATUS_UNLOCKED ; this . emit ( "unlocked" ) ; this . _attachmentManager = new AttachmentManager ( this ) ; } ) ; } ) . catch ( err => { this . _status = VaultSource . STATUS_LOCKED ; this . _vault = null ; this . _datasource = null ; this . _credentials = originalCredentials ; this . _attachmentManager = null ; throw new Layerr ( err , "Failed unlocking source" ) ; } ) ; } ) ; } returns memberof async update ( { skipDiff = false } = { } ) { const didUpdate = await this . _enqueueStateChange ( ( ) => ( skipDiff ? Promise . resolve ( false ) : this . localDiffersFromRemote ( ) ) . then ( differs => { if ( differs ) { return this . mergeFromRemote ( ) . then ( ( ) => true ) ; } return false ; } ) , "updating" ) ; if ( didUpdate ) { this . emit ( "updated" ) ; } } returns memberof async write ( ) { await this . _enqueueStateChange ( async ( ) => { await this . _datasource . save ( this . _vault . format . history , prepareDatasourceCredentials ( this . _credentials as Credentials , this . _datasource . type ) ) ; this . _vault . format . dirty = false ; await this . _updateInsights ( ) ; } , "saving" ) ; this . emit ( "updated" ) ; } _applyShares ( ) { } _enqueueStateChange ( cb , stack ? ) { const channel = this . _queue . channel ( "state" ) ; return stack ? channel . enqueue ( cb , undefined , stack ) : channel . enqueue ( cb ) ; } _unloadShares ( ) { const Format = this . vault . format . getFormat ( ) ; const extractedShares = Format . extractSharesFromHistory ( this . vault . format . history ) ; const { base } = extractedShares ; delete extractedShares . base ; this . vault . format . erase ( ) ; this . vault . format . execute ( base ) ; Object . keys ( extractedShares ) . forEach ( shareID => { const share = this . _shares . find ( share => share . id === shareID ) ; if ( ! share ) { throw new Error ( `Failed updating extracted share: No share found in workspace for ID: ${ shareID }` ) ; } share . updateHistory ( extractedShares [ shareID ] ) ; } ) ; } async _updateCredentialsFromDatasource ( ) { if ( this . status !== VaultSource . STATUS_UNLOCKED ) { throw new Layerr ( `Failed updating source credentials: Source is not unlocked: ${ this . id }` ) ; } const { masterPassword } = getCredentials ( ( < Credentials > this . _credentials ) . id ) ; this . _credentials = Credentials . fromCredentials ( this . _datasource . credentials , masterPassword ) ; } async _updateInsights ( ) { if ( this . status !== VaultSource . STATUS_UNLOCKED ) { throw new Layerr ( `Failed updating vault insights: Source is not unlocked: ${ this . id }` ) ; } const insights = generateVaultInsights ( this . vault ) ; await this . _datasource . updateInsights ( insights ) ; } async _updateVaultCredentials ( newCredentials ) { if ( this . status !== VaultSource . STATUS_UNLOCKED ) { throw new Layerr ( `Failed updating vault credentials: Source is not unlocked: ${ this . id }` ) ; } this . _credentials = newCredentials ; await this . write ( ) ; } _waitNonPending ( ) { return new Promise < void > ( resolve => { if ( this . status !== VaultSource . STATUS_PENDING ) return resolve ( ) ; const handleChange = ( ) => { this . removeListener ( "unlocked" , handleChange ) ; this . removeListener ( "locked" , handleChange ) ; resolve ( ) ; } ; this . on ( "unlocked" , handleChange ) ; this . on ( "locked" , handleChange ) ; } ) ; } }