Files
standardnotes-app-web/packages/snjs/lib/Services/KeyRecovery/KeyRecoveryService.ts
2022-07-06 14:04:30 +02:00

536 lines
17 KiB
TypeScript

import { KeyRecoveryOperation } from './KeyRecoveryOperation'
import {
SNRootKeyParams,
EncryptionService,
SNRootKey,
KeyParamsFromApiResponse,
KeyRecoveryStrings,
} from '@standardnotes/encryption'
import { UserService } from '../User/UserService'
import {
isErrorDecryptingPayload,
EncryptedPayloadInterface,
EncryptedPayload,
isDecryptedPayload,
DecryptedPayloadInterface,
PayloadEmitSource,
EncryptedItemInterface,
getIncrementedDirtyIndex,
} from '@standardnotes/models'
import { SNSyncService } from '../Sync/SyncService'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { PayloadManager } from '../Payloads/PayloadManager'
import { Challenge, ChallengeService } from '../Challenge'
import { SNApiService } from '@Lib/Services/Api/ApiService'
import { ContentType, Uuid } from '@standardnotes/common'
import { ItemManager } from '../Items/ItemManager'
import { removeFromArray, Uuids } from '@standardnotes/utils'
import { ClientDisplayableError, KeyParamsResponse } from '@standardnotes/responses'
import {
AlertService,
AbstractService,
InternalEventBusInterface,
StorageValueModes,
ApplicationStage,
StorageKey,
DiagnosticInfo,
ChallengeValidation,
ChallengeReason,
ChallengePrompt,
} from '@standardnotes/services'
import {
UndecryptableItemsStorage,
DecryptionQueueItem,
KeyRecoveryEvent,
isSuccessResult,
KeyRecoveryOperationResult,
} from './Types'
import { serverKeyParamsAreSafe } from './Utils'
/**
* The key recovery service listens to items key changes to detect any that cannot be decrypted.
* If it detects an items key that is not properly decrypted, it will present a key recovery
* wizard (using existing UI like Challenges and AlertService) that will attempt to recover
* the root key for those keys.
*
* When we encounter an items key we cannot decrypt, this is a sign that the user's password may
* have recently changed (even though their session is still valid). If the user has been
* previously signed in, we take this opportunity to reach out to the server to get the
* user's current key_params. We ensure these key params' version is equal to or greater than our own.
* - If this key's key params are equal to the retrieved parameters,
and this keys created date is greater than any existing valid items key,
or if we do not have any items keys:
1. Use the decryption of this key as a source of validation
2. If valid, replace our local root key with this new root key and emit the decrypted items key
* - Else, if the key params are not equal,
or its created date is less than an existing valid items key
1. Attempt to decrypt this key using its attached key paramas
2. If valid, emit decrypted items key. DO NOT replace local root key.
* - If by the end we did not find an items key with matching key params to the retrieved
key params, AND the retrieved key params are newer than what we have locally, we must
issue a sign in request to the server.
* If the user is not signed in and we detect an undecryptable items key, we present a detached
* recovery wizard that doesn't affect our local root key.
*
* When an items key is emitted, protocol service will automatically try to decrypt any
* related items that are in an errored state.
*
* In the item observer, `ignored` items represent items who have encrypted overwrite
* protection enabled (only items keys). This means that if the incoming payload is errored,
* but our current copy is not, we will ignore the incoming value until we can properly
* decrypt it.
*/
export class SNKeyRecoveryService extends AbstractService<KeyRecoveryEvent, DecryptedPayloadInterface[]> {
private removeItemObserver: () => void
private decryptionQueue: DecryptionQueueItem[] = []
private isProcessingQueue = false
constructor(
private itemManager: ItemManager,
private payloadManager: PayloadManager,
private apiService: SNApiService,
private protocolService: EncryptionService,
private challengeService: ChallengeService,
private alertService: AlertService,
private storageService: DiskStorageService,
private syncService: SNSyncService,
private userService: UserService,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.removeItemObserver = this.payloadManager.addObserver(
[ContentType.ItemsKey],
({ changed, inserted, ignored, source }) => {
if (source === PayloadEmitSource.LocalChanged) {
return
}
const changedOrInserted = changed.concat(inserted).filter(isErrorDecryptingPayload)
if (changedOrInserted.length > 0) {
void this.handleUndecryptableItemsKeys(changedOrInserted)
}
if (ignored.length > 0) {
void this.handleIgnoredItemsKeys(ignored)
}
},
)
}
public override deinit(): void {
;(this.itemManager as unknown) = undefined
;(this.payloadManager as unknown) = undefined
;(this.apiService as unknown) = undefined
;(this.protocolService as unknown) = undefined
;(this.challengeService as unknown) = undefined
;(this.alertService as unknown) = undefined
;(this.storageService as unknown) = undefined
;(this.syncService as unknown) = undefined
;(this.userService as unknown) = undefined
this.removeItemObserver()
;(this.removeItemObserver as unknown) = undefined
super.deinit()
}
// eslint-disable-next-line @typescript-eslint/require-await
override async handleApplicationStage(stage: ApplicationStage): Promise<void> {
void super.handleApplicationStage(stage)
if (stage === ApplicationStage.LoadedDatabase_12) {
void this.processPersistedUndecryptables()
}
}
/**
* Ignored items keys are items keys which arrived from a remote source, which we were
* not able to decrypt, and for which we already had an existing items key that was
* properly decrypted. Since items keys key contents are immutable, if we already have a
* successfully decrypted version, yet we can't decrypt the new version, we should
* temporarily ignore the new version until we can properly decrypt it (through the recovery flow),
* and not overwrite the local copy.
*
* Ignored items are persisted to disk in isolated storage so that they may be decrypted
* whenever. When they are finally decryptable, we will emit them and update our database
* with the new decrypted value.
*
* When the app first launches, we will query the isolated storage to see if there are any
* keys we need to decrypt.
*/
private async handleIgnoredItemsKeys(keys: EncryptedPayloadInterface[], persistIncoming = true) {
/**
* Persist the keys locally in isolated storage, so that if we don't properly decrypt
* them in this app session, the user has a chance to later. If there already exists
* the same items key in this storage, replace it with this latest incoming value.
*/
if (persistIncoming) {
this.saveToUndecryptables(keys)
}
this.addKeysToQueue(keys)
await this.beginKeyRecovery()
}
private async handleUndecryptableItemsKeys(keys: EncryptedPayloadInterface[]) {
this.addKeysToQueue(keys)
await this.beginKeyRecovery()
}
public presentKeyRecoveryWizard(): void {
const invalidKeys = this.itemManager.invalidItems
.filter((i) => i.content_type === ContentType.ItemsKey)
.map((i) => i.payload)
void this.handleIgnoredItemsKeys(invalidKeys, false)
}
public canAttemptDecryptionOfItem(item: EncryptedItemInterface): ClientDisplayableError | true {
const keyId = item.payload.items_key_id
if (!keyId) {
return new ClientDisplayableError('This item cannot be recovered.')
}
const key = this.payloadManager.findOne(keyId)
if (!key) {
return new ClientDisplayableError(
`Unable to find key ${keyId} for this item. You may try signing out and back in; if that doesn't help, check your backup files for a key with this ID and import it.`,
)
}
return true
}
public async processPersistedUndecryptables() {
const record = this.getUndecryptables()
const rawPayloads = Object.values(record)
if (rawPayloads.length === 0) {
return
}
const keys = rawPayloads.map((raw) => new EncryptedPayload(raw))
return this.handleIgnoredItemsKeys(keys, false)
}
private getUndecryptables(): UndecryptableItemsStorage {
return this.storageService.getValue<UndecryptableItemsStorage>(
StorageKey.KeyRecoveryUndecryptableItems,
StorageValueModes.Default,
{},
)
}
private persistUndecryptables(record: UndecryptableItemsStorage) {
this.storageService.setValue(StorageKey.KeyRecoveryUndecryptableItems, record)
}
private saveToUndecryptables(keys: EncryptedPayloadInterface[]) {
const record = this.getUndecryptables()
for (const key of keys) {
record[key.uuid] = key.ejected()
}
this.persistUndecryptables(record)
}
private removeFromUndecryptables(keyIds: Uuid[]) {
const record = this.getUndecryptables()
for (const id of keyIds) {
delete record[id]
}
this.persistUndecryptables(record)
}
private getClientKeyParams() {
return this.protocolService.getAccountKeyParams()
}
private async performServerSignIn(): Promise<SNRootKey | undefined> {
const accountPasswordChallenge = new Challenge(
[new ChallengePrompt(ChallengeValidation.None, undefined, undefined, true)],
ChallengeReason.Custom,
true,
KeyRecoveryStrings.KeyRecoveryLoginFlowReason,
)
const challengeResponse = await this.challengeService.promptForChallengeResponse(accountPasswordChallenge)
if (!challengeResponse) {
return undefined
}
this.challengeService.completeChallenge(accountPasswordChallenge)
const password = challengeResponse.values[0].value as string
const clientParams = this.getClientKeyParams() as SNRootKeyParams
const serverParams = await this.getLatestKeyParamsFromServer(clientParams.identifier)
if (!serverParams || !serverKeyParamsAreSafe(serverParams, clientParams)) {
return
}
const rootKey = await this.protocolService.computeRootKey(password, serverParams)
const signInResponse = await this.userService.correctiveSignIn(rootKey)
if (!signInResponse.error) {
void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryRootKeyReplaced)
return rootKey
} else {
await this.alertService.alert(KeyRecoveryStrings.KeyRecoveryLoginFlowInvalidPassword)
return this.performServerSignIn()
}
}
private async getWrappingKeyIfApplicable(): Promise<SNRootKey | undefined> {
if (!this.protocolService.hasPasscode()) {
return undefined
}
const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable()
if (canceled) {
await this.alertService.alert(
KeyRecoveryStrings.KeyRecoveryPasscodeRequiredText,
KeyRecoveryStrings.KeyRecoveryPasscodeRequiredTitle,
)
return this.getWrappingKeyIfApplicable()
}
return wrappingKey
}
private addKeysToQueue(keys: EncryptedPayloadInterface[]) {
for (const key of keys) {
const keyParams = this.protocolService.getKeyEmbeddedKeyParams(key)
if (!keyParams) {
continue
}
const queueItem: DecryptionQueueItem = {
encryptedKey: key,
keyParams,
}
this.decryptionQueue.push(queueItem)
}
}
private readdQueueItem(queueItem: DecryptionQueueItem) {
this.decryptionQueue.unshift(queueItem)
}
private async getLatestKeyParamsFromServer(identifier: string): Promise<SNRootKeyParams | undefined> {
const paramsResponse = await this.apiService.getAccountKeyParams({
email: identifier,
})
if (!paramsResponse.error && paramsResponse.data) {
return KeyParamsFromApiResponse(paramsResponse as KeyParamsResponse)
} else {
return undefined
}
}
private async beginKeyRecovery() {
if (this.isProcessingQueue) {
return
}
this.isProcessingQueue = true
const clientParams = this.getClientKeyParams()
let serverParams: SNRootKeyParams | undefined = undefined
if (clientParams) {
serverParams = await this.getLatestKeyParamsFromServer(clientParams.identifier)
}
const deallocedAfterNetworkRequest = this.protocolService == undefined
if (deallocedAfterNetworkRequest) {
return
}
const credentialsMissing = !this.protocolService.hasAccount() && !this.protocolService.hasPasscode()
if (credentialsMissing) {
const rootKey = await this.performServerSignIn()
if (rootKey) {
const replaceLocalRootKeyWithResult = true
await this.handleDecryptionOfAllKeysMatchingCorrectRootKey(rootKey, replaceLocalRootKeyWithResult, serverParams)
}
}
await this.processQueue(serverParams)
if (serverParams) {
await this.potentiallyPerformFallbackSignInToUpdateOutdatedLocalRootKey(serverParams)
}
if (this.syncService.isOutOfSync()) {
void this.syncService.sync({ checkIntegrity: true })
}
}
private async potentiallyPerformFallbackSignInToUpdateOutdatedLocalRootKey(serverParams: SNRootKeyParams) {
const latestClientParamsAfterAllRecoveryOperations = this.getClientKeyParams()
if (!latestClientParamsAfterAllRecoveryOperations) {
return
}
const serverParamsDiffer = !serverParams.compare(latestClientParamsAfterAllRecoveryOperations)
if (serverParamsDiffer && serverKeyParamsAreSafe(serverParams, latestClientParamsAfterAllRecoveryOperations)) {
await this.performServerSignIn()
}
}
private async processQueue(serverParams?: SNRootKeyParams): Promise<void> {
let queueItem = this.decryptionQueue[0]
while (queueItem) {
const result = await this.processQueueItem(queueItem, serverParams)
removeFromArray(this.decryptionQueue, queueItem)
if (!isSuccessResult(result) && result.aborted) {
this.isProcessingQueue = false
return
}
queueItem = this.decryptionQueue[0]
}
this.isProcessingQueue = false
}
private async processQueueItem(
queueItem: DecryptionQueueItem,
serverParams?: SNRootKeyParams,
): Promise<KeyRecoveryOperationResult> {
const clientParams = this.getClientKeyParams()
const operation = new KeyRecoveryOperation(
queueItem,
this.itemManager,
this.protocolService,
this.challengeService,
clientParams,
serverParams,
)
const result = await operation.run()
if (!isSuccessResult(result)) {
if (!result.aborted) {
await this.alertService.alert(KeyRecoveryStrings.KeyRecoveryUnableToRecover)
this.readdQueueItem(queueItem)
}
return result
}
await this.handleDecryptionOfAllKeysMatchingCorrectRootKey(
result.rootKey,
result.replaceLocalRootKeyWithResult,
serverParams,
)
return result
}
private async handleDecryptionOfAllKeysMatchingCorrectRootKey(
rootKey: SNRootKey,
replacesRootKey: boolean,
serverParams?: SNRootKeyParams,
): Promise<void> {
if (replacesRootKey) {
const wrappingKey = await this.getWrappingKeyIfApplicable()
await this.protocolService.setRootKey(rootKey, wrappingKey)
}
const clientKeyParams = this.getClientKeyParams()
const clientParamsMatchServer = clientKeyParams && serverParams && clientKeyParams.compare(serverParams)
const matchingKeys = this.removeElementsFromQueueForMatchingKeyParams(rootKey.keyParams).map((qItem) => {
const needsResync = clientParamsMatchServer && !serverParams.compare(qItem.keyParams)
return needsResync
? qItem.encryptedKey.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() })
: qItem.encryptedKey
})
const matchingResults = await this.protocolService.decryptSplit({
usesRootKey: {
items: matchingKeys,
key: rootKey,
},
})
const decryptedMatching = matchingResults.filter(isDecryptedPayload)
void this.payloadManager.emitPayloads(decryptedMatching, PayloadEmitSource.LocalChanged)
await this.storageService.savePayloads(decryptedMatching)
if (replacesRootKey) {
void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryRootKeyReplaced)
} else {
void this.alertService.alert(KeyRecoveryStrings.KeyRecoveryKeyRecovered)
}
if (decryptedMatching.some((p) => p.dirty)) {
await this.syncService.sync()
}
await this.notifyEvent(KeyRecoveryEvent.KeysRecovered, decryptedMatching)
void this.removeFromUndecryptables(Uuids(decryptedMatching))
}
private removeElementsFromQueueForMatchingKeyParams(keyParams: SNRootKeyParams) {
const matching = []
const nonmatching = []
for (const queueItem of this.decryptionQueue) {
if (queueItem.keyParams.compare(keyParams)) {
matching.push(queueItem)
} else {
nonmatching.push(queueItem)
}
}
this.decryptionQueue = nonmatching
return matching
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
keyRecovery: {
queueLength: this.decryptionQueue.length,
isProcessingQueue: this.isProcessingQueue,
},
})
}
}