246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
import { AnyKeyParamsContent, KeyParamsContent004 } from '@standardnotes/common'
|
|
import {
|
|
EncryptedPayload,
|
|
EncryptedTransferPayload,
|
|
isErrorDecryptingPayload,
|
|
ContentTypeUsesRootKeyEncryption,
|
|
} from '@standardnotes/models'
|
|
import { PreviousSnjsVersion1_0_0, PreviousSnjsVersion2_0_0, SnjsVersion } from '../Version'
|
|
import { Migration } from '@Lib/Migrations/Migration'
|
|
import {
|
|
RawStorageKey,
|
|
namespacedKey,
|
|
ApplicationStage,
|
|
ChallengeValidation,
|
|
ChallengeReason,
|
|
ChallengePrompt,
|
|
KeychainRecoveryStrings,
|
|
SessionStrings,
|
|
Challenge,
|
|
} from '@standardnotes/services'
|
|
import { assert } from '@standardnotes/utils'
|
|
import { CreateReader } from './StorageReaders/Functions'
|
|
import { StorageReader } from './StorageReaders/Reader'
|
|
|
|
/** A key that was briefly present in Snjs version 2.0.0 but removed in 2.0.1 */
|
|
const LastMigrationTimeStampKey2_0_0 = 'last_migration_timestamp'
|
|
|
|
/**
|
|
* The base migration always runs during app initialization. It is meant as a way
|
|
* to set up all other migrations.
|
|
*/
|
|
export class BaseMigration extends Migration {
|
|
private reader!: StorageReader
|
|
private didPreRun = false
|
|
private memoizedNeedsKeychainRepair?: boolean
|
|
|
|
public async preRun() {
|
|
await this.storeVersionNumber()
|
|
this.didPreRun = true
|
|
}
|
|
|
|
protected registerStageHandlers() {
|
|
this.registerStageHandler(ApplicationStage.PreparingForLaunch_0, async () => {
|
|
if (await this.needsKeychainRepair()) {
|
|
await this.repairMissingKeychain()
|
|
}
|
|
this.markDone()
|
|
})
|
|
}
|
|
|
|
private getStoredVersion() {
|
|
const storageKey = namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion)
|
|
return this.services.deviceInterface.getRawStorageValue(storageKey)
|
|
}
|
|
|
|
/**
|
|
* In Snjs 1.x, and Snjs 2.0.0, version numbers were not stored (as they were introduced
|
|
* in 2.0.1). Because migrations can now rely on this value, we want to establish a base
|
|
* value if we do not find it in storage.
|
|
*/
|
|
private async storeVersionNumber() {
|
|
const storageKey = namespacedKey(this.services.identifier, RawStorageKey.SnjsVersion)
|
|
const version = await this.getStoredVersion()
|
|
if (!version) {
|
|
/** Determine if we are 1.0.0 or 2.0.0 */
|
|
/** If any of these keys exist in raw storage, we are coming from a 1.x architecture */
|
|
const possibleLegacyKeys = ['migrations', 'ephemeral', 'user', 'cachedThemes', 'syncToken', 'encryptedStorage']
|
|
let hasLegacyValue = false
|
|
for (const legacyKey of possibleLegacyKeys) {
|
|
const value = await this.services.deviceInterface.getRawStorageValue(legacyKey)
|
|
if (value) {
|
|
hasLegacyValue = true
|
|
break
|
|
}
|
|
}
|
|
if (hasLegacyValue) {
|
|
/** Coming from 1.0.0 */
|
|
await this.services.deviceInterface.setRawStorageValue(storageKey, PreviousSnjsVersion1_0_0)
|
|
} else {
|
|
/** Coming from 2.0.0 (which did not store version) OR is brand new application */
|
|
const migrationKey = namespacedKey(this.services.identifier, LastMigrationTimeStampKey2_0_0)
|
|
const migrationValue = await this.services.deviceInterface.getRawStorageValue(migrationKey)
|
|
const is_2_0_0_application = migrationValue != undefined
|
|
if (is_2_0_0_application) {
|
|
await this.services.deviceInterface.setRawStorageValue(storageKey, PreviousSnjsVersion2_0_0)
|
|
await this.services.deviceInterface.removeRawStorageValue(LastMigrationTimeStampKey2_0_0)
|
|
} else {
|
|
/** Is new application, use current version as not to run any migrations */
|
|
await this.services.deviceInterface.setRawStorageValue(storageKey, SnjsVersion)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async loadReader() {
|
|
if (this.reader) {
|
|
return
|
|
}
|
|
|
|
const version = (await this.getStoredVersion()) as string
|
|
this.reader = CreateReader(
|
|
version,
|
|
this.services.deviceInterface,
|
|
this.services.identifier,
|
|
this.services.environment,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* If the keychain is empty, and the user does not have a passcode,
|
|
* AND there appear to be stored account key params, this indicates
|
|
* a launch where the keychain was wiped due to restoring device
|
|
* from cloud backup which did not include keychain. This typically occurs
|
|
* on mobile when restoring from iCloud, but we'll also follow this same behavior
|
|
* on desktop/web as well, since we recently introduced keychain to desktop.
|
|
*
|
|
* We must prompt user for account password, and validate based on ability to decrypt
|
|
* an item. We cannot validate based on storage because 1.x mobile applications did
|
|
* not use encrypted storage, although we did on 2.x. But instead of having two methods
|
|
* of validations best to use one that works on both.
|
|
*
|
|
* The item is randomly chosen, but for 2.x applications, it must be an items key item
|
|
* (since only item keys are encrypted directly with account password)
|
|
*/
|
|
|
|
public async needsKeychainRepair() {
|
|
if (this.memoizedNeedsKeychainRepair != undefined) {
|
|
return this.memoizedNeedsKeychainRepair
|
|
}
|
|
|
|
if (!this.didPreRun) {
|
|
throw Error('Attempting to access specialized function before prerun')
|
|
}
|
|
|
|
if (!this.reader) {
|
|
await this.loadReader()
|
|
}
|
|
|
|
const usesKeychain = this.reader.usesKeychain
|
|
if (!usesKeychain) {
|
|
/** Doesn't apply if this version did not use a keychain to begin with */
|
|
this.memoizedNeedsKeychainRepair = false
|
|
return this.memoizedNeedsKeychainRepair
|
|
}
|
|
|
|
const rawAccountParams = await this.reader.getAccountKeyParams()
|
|
const hasAccountKeyParams = rawAccountParams != undefined
|
|
if (!hasAccountKeyParams) {
|
|
/** Doesn't apply if account is not involved */
|
|
this.memoizedNeedsKeychainRepair = false
|
|
return this.memoizedNeedsKeychainRepair
|
|
}
|
|
|
|
const hasPasscode = await this.reader.hasPasscode()
|
|
if (hasPasscode) {
|
|
/** Doesn't apply if using passcode, as keychain would be bypassed in that case */
|
|
this.memoizedNeedsKeychainRepair = false
|
|
return this.memoizedNeedsKeychainRepair
|
|
}
|
|
|
|
const accountKeysMissing = !(await this.reader.hasNonWrappedAccountKeys())
|
|
if (!accountKeysMissing) {
|
|
this.memoizedNeedsKeychainRepair = false
|
|
return this.memoizedNeedsKeychainRepair
|
|
}
|
|
|
|
this.memoizedNeedsKeychainRepair = true
|
|
return this.memoizedNeedsKeychainRepair
|
|
}
|
|
|
|
private async repairMissingKeychain() {
|
|
const rawAccountParams = (await this.reader.getAccountKeyParams()) as AnyKeyParamsContent
|
|
|
|
/** Choose an item to decrypt against */
|
|
const allItems = (
|
|
await this.services.deviceInterface.getAllDatabaseEntries<EncryptedTransferPayload>(this.services.identifier)
|
|
).map((p) => new EncryptedPayload(p))
|
|
|
|
let itemToDecrypt = allItems.find((item) => {
|
|
return ContentTypeUsesRootKeyEncryption(item.content_type)
|
|
})
|
|
|
|
if (!itemToDecrypt) {
|
|
/** If no root key encrypted item, choose any item */
|
|
itemToDecrypt = allItems[0]
|
|
}
|
|
|
|
if (!itemToDecrypt) {
|
|
/**
|
|
* No items to decrypt, user probably cleared their browser data. Only choice is to clear storage
|
|
* as any remainign account data is useless without items
|
|
*/
|
|
await this.services.storageService.clearValues()
|
|
return
|
|
}
|
|
|
|
/** Prompt for account password */
|
|
const challenge = new Challenge(
|
|
[new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.PasswordInputPlaceholder, true)],
|
|
ChallengeReason.Custom,
|
|
false,
|
|
KeychainRecoveryStrings.Title,
|
|
KeychainRecoveryStrings.Text((rawAccountParams as KeyParamsContent004).identifier),
|
|
)
|
|
|
|
return new Promise((resolve) => {
|
|
this.services.challengeService.addChallengeObserver(challenge, {
|
|
onNonvalidatedSubmit: async (challengeResponse) => {
|
|
const password = challengeResponse.values[0].value as string
|
|
const accountParams = this.services.encryptionService.createKeyParams(rawAccountParams)
|
|
const rootKey = await this.services.encryptionService.computeRootKey(password, accountParams)
|
|
|
|
/** TS can't detect we returned early above if itemToDecrypt is null */
|
|
assert(itemToDecrypt)
|
|
|
|
const decryptedPayload = await this.services.encryptionService.decryptSplitSingle({
|
|
usesRootKey: {
|
|
items: [itemToDecrypt],
|
|
key: rootKey,
|
|
},
|
|
})
|
|
|
|
if (isErrorDecryptingPayload(decryptedPayload)) {
|
|
/** Wrong password, try again */
|
|
this.services.challengeService.setValidationStatusForChallenge(
|
|
challenge,
|
|
challengeResponse.values[0],
|
|
false,
|
|
)
|
|
} else {
|
|
/**
|
|
* If decryption succeeds, store the generated account key where it is expected.
|
|
*/
|
|
const rawKey = rootKey.getKeychainValue()
|
|
await this.services.deviceInterface.setNamespacedKeychainValue(rawKey, this.services.identifier)
|
|
resolve(true)
|
|
this.services.challengeService.completeChallenge(challenge)
|
|
}
|
|
},
|
|
})
|
|
|
|
void this.services.challengeService.promptForChallengeResponse(challenge)
|
|
})
|
|
}
|
|
}
|