feat: add snjs package
This commit is contained in:
124
packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts
Normal file
124
packages/snjs/lib/Migrations/Applicators/TagsToFolders.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { ItemManager } from '@Lib/Services'
|
||||
import { TagsToFoldersMigrationApplicator } from './TagsToFolders'
|
||||
|
||||
const itemManagerMock = (tagTitles: string[]) => {
|
||||
const mockTag = (title: string) => ({
|
||||
title,
|
||||
uuid: title,
|
||||
parentId: undefined,
|
||||
})
|
||||
|
||||
const mock = {
|
||||
getItems: jest.fn().mockReturnValue(tagTitles.map(mockTag)),
|
||||
findOrCreateTagParentChain: jest.fn(),
|
||||
changeItem: jest.fn(),
|
||||
}
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
describe('folders component to hierarchy', () => {
|
||||
it('should produce a valid hierarchy in the simple case', async () => {
|
||||
const titles = ['a', 'a.b', 'a.b.c']
|
||||
|
||||
const itemManager = itemManagerMock(titles)
|
||||
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
|
||||
|
||||
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
|
||||
const changeItemCalls = itemManager.changeItem.mock.calls
|
||||
|
||||
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
|
||||
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a'])
|
||||
expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['a', 'b'])
|
||||
|
||||
expect(changeItemCalls.length).toEqual(2)
|
||||
expect(changeItemCalls[0][0].uuid).toEqual('a.b')
|
||||
expect(changeItemCalls[1][0].uuid).toEqual('a.b.c')
|
||||
})
|
||||
|
||||
it('should not touch flat hierarchies', async () => {
|
||||
const titles = ['a', 'x', 'y', 'z']
|
||||
|
||||
const itemManager = itemManagerMock(titles)
|
||||
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
|
||||
|
||||
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
|
||||
const changeItemCalls = itemManager.changeItem.mock.calls
|
||||
|
||||
expect(findOrCreateTagParentChainCalls.length).toEqual(0)
|
||||
|
||||
expect(changeItemCalls.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('should work despite cloned tags', async () => {
|
||||
const titles = ['a.b', 'c', 'a.b']
|
||||
|
||||
const itemManager = itemManagerMock(titles)
|
||||
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
|
||||
|
||||
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
|
||||
const changeItemCalls = itemManager.changeItem.mock.calls
|
||||
|
||||
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
|
||||
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a'])
|
||||
expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['a'])
|
||||
|
||||
expect(changeItemCalls.length).toEqual(2)
|
||||
expect(changeItemCalls[0][0].uuid).toEqual('a.b')
|
||||
expect(changeItemCalls[0][0].uuid).toEqual('a.b')
|
||||
})
|
||||
|
||||
it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async () => {
|
||||
const titles = ['y.2', 'w.3', 'y']
|
||||
|
||||
const itemManager = itemManagerMock(titles)
|
||||
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
|
||||
|
||||
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
|
||||
const changeItemCalls = itemManager.changeItem.mock.calls
|
||||
|
||||
expect(findOrCreateTagParentChainCalls.length).toEqual(2)
|
||||
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['w'])
|
||||
expect(findOrCreateTagParentChainCalls[1][0]).toEqual(['y'])
|
||||
|
||||
expect(changeItemCalls.length).toEqual(2)
|
||||
expect(changeItemCalls[0][0].uuid).toEqual('w.3')
|
||||
expect(changeItemCalls[1][0].uuid).toEqual('y.2')
|
||||
})
|
||||
|
||||
it('skip prefixed names', async () => {
|
||||
const titles = ['.something', '.something...something']
|
||||
|
||||
const itemManager = itemManagerMock(titles)
|
||||
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
|
||||
|
||||
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
|
||||
const changeItemCalls = itemManager.changeItem.mock.calls
|
||||
|
||||
expect(findOrCreateTagParentChainCalls.length).toEqual(0)
|
||||
expect(changeItemCalls.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('skip not-supported names', async () => {
|
||||
const titles = [
|
||||
'something.',
|
||||
'something..',
|
||||
'something..another.thing',
|
||||
'a.b.c',
|
||||
'a',
|
||||
'something..another.thing..anyway',
|
||||
]
|
||||
|
||||
const itemManager = itemManagerMock(titles)
|
||||
await TagsToFoldersMigrationApplicator.run(itemManager as unknown as ItemManager)
|
||||
|
||||
const findOrCreateTagParentChainCalls = itemManager.findOrCreateTagParentChain.mock.calls
|
||||
const changeItemCalls = itemManager.changeItem.mock.calls
|
||||
|
||||
expect(findOrCreateTagParentChainCalls.length).toEqual(1)
|
||||
expect(findOrCreateTagParentChainCalls[0][0]).toEqual(['a', 'b'])
|
||||
|
||||
expect(changeItemCalls.length).toEqual(1)
|
||||
expect(changeItemCalls[0][0].uuid).toEqual('a.b.c')
|
||||
})
|
||||
})
|
||||
50
packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts
Normal file
50
packages/snjs/lib/Migrations/Applicators/TagsToFolders.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { SNTag, TagMutator, TagFolderDelimitter } from '@standardnotes/models'
|
||||
import { ItemManager } from '@Lib/Services'
|
||||
import { lastElement, sortByKey, withoutLastElement } from '@standardnotes/utils'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
|
||||
export class TagsToFoldersMigrationApplicator {
|
||||
public static isApplicableToCurrentData(itemManager: ItemManager): boolean {
|
||||
const tags = itemManager.getItems<SNTag>(ContentType.Tag)
|
||||
for (const tag of tags) {
|
||||
if (tag.title.includes(TagFolderDelimitter) && !tag.parentId) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public static async run(itemManager: ItemManager): Promise<void> {
|
||||
const tags = itemManager.getItems(ContentType.Tag) as SNTag[]
|
||||
const sortedTags = sortByKey(tags, 'title')
|
||||
|
||||
for (const tag of sortedTags) {
|
||||
const hierarchy = tag.title.split(TagFolderDelimitter)
|
||||
const hasSimpleTitle = hierarchy.length === 1
|
||||
const hasParent = !!tag.parentId
|
||||
const hasUnsupportedTitle = hierarchy.some((title) => title.length === 0)
|
||||
|
||||
if (hasParent || hasSimpleTitle || hasUnsupportedTitle) {
|
||||
continue
|
||||
}
|
||||
|
||||
const parents = withoutLastElement(hierarchy)
|
||||
const newTitle = lastElement(hierarchy)
|
||||
|
||||
if (!newTitle) {
|
||||
return
|
||||
}
|
||||
|
||||
const parent = await itemManager.findOrCreateTagParentChain(parents)
|
||||
|
||||
await itemManager.changeItem(tag, (mutator: TagMutator) => {
|
||||
mutator.title = newTitle
|
||||
|
||||
if (parent) {
|
||||
mutator.makeChildOf(parent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
247
packages/snjs/lib/Migrations/Base.ts
Normal file
247
packages/snjs/lib/Migrations/Base.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { AnyKeyParamsContent } from '@standardnotes/common'
|
||||
import { SNLog } from '@Lib/Log'
|
||||
import { EncryptedPayload, EncryptedTransferPayload, isErrorDecryptingPayload } from '@standardnotes/models'
|
||||
import { Challenge } from '../Services/Challenge'
|
||||
import { KeychainRecoveryStrings, SessionStrings } from '../Services/Api/Messages'
|
||||
import { PreviousSnjsVersion1_0_0, PreviousSnjsVersion2_0_0, SnjsVersion } from '../Version'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import {
|
||||
RawStorageKey,
|
||||
namespacedKey,
|
||||
ApplicationStage,
|
||||
ChallengeValidation,
|
||||
ChallengeReason,
|
||||
ChallengePrompt,
|
||||
} from '@standardnotes/services'
|
||||
import { isNullOrUndefined } from '@standardnotes/utils'
|
||||
import { CreateReader } from './StorageReaders/Functions'
|
||||
import { StorageReader } from './StorageReaders/Reader'
|
||||
import { ContentTypeUsesRootKeyEncryption } from '@standardnotes/encryption'
|
||||
|
||||
/** 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 = !isNullOrUndefined(migrationValue)
|
||||
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 = !isNullOrUndefined(rawAccountParams)
|
||||
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 version = (await this.getStoredVersion()) as string
|
||||
const rawAccountParams = await this.reader.getAccountKeyParams()
|
||||
|
||||
/** Challenge for account password */
|
||||
const challenge = new Challenge(
|
||||
[new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.PasswordInputPlaceholder, true)],
|
||||
ChallengeReason.Custom,
|
||||
false,
|
||||
KeychainRecoveryStrings.Title,
|
||||
KeychainRecoveryStrings.Text,
|
||||
)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.services.challengeService.addChallengeObserver(challenge, {
|
||||
onNonvalidatedSubmit: async (challengeResponse) => {
|
||||
const password = challengeResponse.values[0].value as string
|
||||
const accountParams = this.services.protocolService.createKeyParams(rawAccountParams as AnyKeyParamsContent)
|
||||
const rootKey = await this.services.protocolService.computeRootKey(password, accountParams)
|
||||
|
||||
/** Choose an item to decrypt */
|
||||
const allItems = (
|
||||
await this.services.deviceInterface.getAllRawDatabasePayloads<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, just choose any item */
|
||||
itemToDecrypt = allItems[0]
|
||||
}
|
||||
|
||||
if (!itemToDecrypt) {
|
||||
throw SNLog.error(Error('Attempting keychain recovery validation but no items present.'))
|
||||
}
|
||||
|
||||
const decryptedPayload = await this.services.protocolService.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,
|
||||
* either in top-level keychain in 1.0.0, and namespaced location in 2.0.0+.
|
||||
*/
|
||||
if (version === PreviousSnjsVersion1_0_0) {
|
||||
/** Store in top level keychain */
|
||||
await this.services.deviceInterface.setLegacyRawKeychainValue({
|
||||
mk: rootKey.masterKey,
|
||||
ak: rootKey.dataAuthenticationKey as string,
|
||||
version: accountParams.version,
|
||||
})
|
||||
} else {
|
||||
/** Store in namespaced location */
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
60
packages/snjs/lib/Migrations/Migration.ts
Normal file
60
packages/snjs/lib/Migrations/Migration.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Challenge } from '../Services/Challenge'
|
||||
import { MigrationServices } from './MigrationServices'
|
||||
import { ApplicationStage, ChallengeValidation, ChallengeReason, ChallengePrompt } from '@standardnotes/services'
|
||||
|
||||
type StageHandler = () => Promise<void>
|
||||
|
||||
export abstract class Migration {
|
||||
private stageHandlers: Partial<Record<ApplicationStage, StageHandler>> = {}
|
||||
private onDoneHandler?: () => void
|
||||
|
||||
constructor(protected services: MigrationServices) {
|
||||
this.registerStageHandlers()
|
||||
}
|
||||
|
||||
public static version(): string {
|
||||
throw 'Must override migration version'
|
||||
}
|
||||
|
||||
protected abstract registerStageHandlers(): void
|
||||
|
||||
protected registerStageHandler(stage: ApplicationStage, handler: StageHandler) {
|
||||
this.stageHandlers[stage] = handler
|
||||
}
|
||||
|
||||
protected markDone() {
|
||||
this.onDoneHandler?.()
|
||||
this.onDoneHandler = undefined
|
||||
}
|
||||
|
||||
protected async promptForPasscodeUntilCorrect(validationCallback: (passcode: string) => Promise<boolean>) {
|
||||
const challenge = new Challenge([new ChallengePrompt(ChallengeValidation.None)], ChallengeReason.Migration, false)
|
||||
return new Promise((resolve) => {
|
||||
this.services.challengeService.addChallengeObserver(challenge, {
|
||||
onNonvalidatedSubmit: async (challengeResponse) => {
|
||||
const value = challengeResponse.values[0]
|
||||
const passcode = value.value as string
|
||||
const valid = await validationCallback(passcode)
|
||||
if (valid) {
|
||||
this.services.challengeService.completeChallenge(challenge)
|
||||
resolve(passcode)
|
||||
} else {
|
||||
this.services.challengeService.setValidationStatusForChallenge(challenge, value, false)
|
||||
}
|
||||
},
|
||||
})
|
||||
void this.services.challengeService.promptForChallengeResponse(challenge)
|
||||
})
|
||||
}
|
||||
|
||||
onDone(callback: () => void) {
|
||||
this.onDoneHandler = callback
|
||||
}
|
||||
|
||||
async handleStage(stage: ApplicationStage): Promise<void> {
|
||||
const handler = this.stageHandlers[stage]
|
||||
if (handler) {
|
||||
await handler()
|
||||
}
|
||||
}
|
||||
}
|
||||
20
packages/snjs/lib/Migrations/MigrationServices.ts
Normal file
20
packages/snjs/lib/Migrations/MigrationServices.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { SNSessionManager } from '../Services/Session/SessionManager'
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { EncryptionService } from '@standardnotes/encryption'
|
||||
import { DeviceInterface, InternalEventBusInterface, Environment } from '@standardnotes/services'
|
||||
import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services'
|
||||
|
||||
export type MigrationServices = {
|
||||
protocolService: EncryptionService
|
||||
deviceInterface: DeviceInterface
|
||||
storageService: DiskStorageService
|
||||
challengeService: ChallengeService
|
||||
sessionManager: SNSessionManager
|
||||
itemManager: ItemManager
|
||||
singletonManager: SNSingletonManager
|
||||
featuresService: SNFeaturesService
|
||||
environment: Environment
|
||||
identifier: ApplicationIdentifier
|
||||
internalEventBus: InternalEventBusInterface
|
||||
}
|
||||
34
packages/snjs/lib/Migrations/StorageReaders/Functions.ts
Normal file
34
packages/snjs/lib/Migrations/StorageReaders/Functions.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { compareSemVersions, isRightVersionGreaterThanLeft } from '@Lib/Version'
|
||||
import { DeviceInterface, Environment } from '@standardnotes/services'
|
||||
import { StorageReader } from './Reader'
|
||||
import * as ReaderClasses from './Versions'
|
||||
|
||||
function ReaderClassForVersion(
|
||||
version: string,
|
||||
): typeof ReaderClasses.StorageReader2_0_0 | typeof ReaderClasses.StorageReader1_0_0 {
|
||||
/** Sort readers by newest first */
|
||||
const allReaders = Object.values(ReaderClasses).sort((a, b) => {
|
||||
return compareSemVersions(a.version(), b.version()) * -1
|
||||
})
|
||||
for (const reader of allReaders) {
|
||||
if (reader.version() === version) {
|
||||
return reader
|
||||
}
|
||||
if (isRightVersionGreaterThanLeft(reader.version(), version)) {
|
||||
return reader
|
||||
}
|
||||
}
|
||||
|
||||
throw Error(`Cannot find reader for version ${version}`)
|
||||
}
|
||||
|
||||
export function CreateReader(
|
||||
version: string,
|
||||
deviceInterface: DeviceInterface,
|
||||
identifier: ApplicationIdentifier,
|
||||
environment: Environment,
|
||||
): StorageReader {
|
||||
const readerClass = ReaderClassForVersion(version)
|
||||
return new readerClass(deviceInterface, identifier, environment)
|
||||
}
|
||||
31
packages/snjs/lib/Migrations/StorageReaders/Reader.ts
Normal file
31
packages/snjs/lib/Migrations/StorageReaders/Reader.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ApplicationIdentifier } from '@standardnotes/common'
|
||||
import { DeviceInterface, Environment } from '@standardnotes/services'
|
||||
|
||||
/**
|
||||
* A storage reader reads storage via a device interface
|
||||
* given a specific version of SNJS
|
||||
*/
|
||||
export abstract class StorageReader {
|
||||
constructor(
|
||||
protected deviceInterface: DeviceInterface,
|
||||
protected identifier: ApplicationIdentifier,
|
||||
protected environment: Environment,
|
||||
) {}
|
||||
|
||||
public static version(): string {
|
||||
throw Error('Must override')
|
||||
}
|
||||
|
||||
public abstract getAccountKeyParams(): Promise<unknown | undefined>
|
||||
|
||||
/**
|
||||
* Returns true if the state of storage has account keys present
|
||||
* in version-specific storage (either keychain or raw storage)
|
||||
*/
|
||||
public abstract hasNonWrappedAccountKeys(): Promise<boolean>
|
||||
|
||||
public abstract hasPasscode(): Promise<boolean>
|
||||
|
||||
/** Whether this version used the keychain to store keys */
|
||||
public abstract usesKeychain(): boolean
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { isNullOrUndefined } from '@standardnotes/utils'
|
||||
import { isEnvironmentMobile } from '@Lib/Application/Platforms'
|
||||
import { PreviousSnjsVersion1_0_0 } from '../../../Version'
|
||||
import { isMobileDevice, LegacyKeys1_0_0 } from '@standardnotes/services'
|
||||
import { StorageReader } from '../Reader'
|
||||
|
||||
export class StorageReader1_0_0 extends StorageReader {
|
||||
static override version() {
|
||||
return PreviousSnjsVersion1_0_0
|
||||
}
|
||||
|
||||
public async getAccountKeyParams() {
|
||||
return this.deviceInterface.getJsonParsedRawStorageValue(LegacyKeys1_0_0.AllAccountKeyParamsKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* In 1.0.0, web uses raw storage for unwrapped account key, and mobile uses
|
||||
* the keychain
|
||||
*/
|
||||
public async hasNonWrappedAccountKeys() {
|
||||
if (isMobileDevice(this.deviceInterface)) {
|
||||
const value = await this.deviceInterface.getRawKeychainValue()
|
||||
return !isNullOrUndefined(value)
|
||||
} else {
|
||||
const value = await this.deviceInterface.getRawStorageValue('mk')
|
||||
return !isNullOrUndefined(value)
|
||||
}
|
||||
}
|
||||
|
||||
public async hasPasscode() {
|
||||
if (isEnvironmentMobile(this.environment)) {
|
||||
const rawPasscodeParams = await this.deviceInterface.getJsonParsedRawStorageValue(
|
||||
LegacyKeys1_0_0.MobilePasscodeParamsKey,
|
||||
)
|
||||
return !isNullOrUndefined(rawPasscodeParams)
|
||||
} else {
|
||||
const encryptedStorage = await this.deviceInterface.getJsonParsedRawStorageValue(
|
||||
LegacyKeys1_0_0.WebEncryptedStorageKey,
|
||||
)
|
||||
return !isNullOrUndefined(encryptedStorage)
|
||||
}
|
||||
}
|
||||
|
||||
/** Keychain was not used on desktop/web in 1.0.0 */
|
||||
public usesKeychain() {
|
||||
return isEnvironmentMobile(this.environment) ? true : false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { isNullOrUndefined } from '@standardnotes/utils'
|
||||
import { RawStorageKey, StorageKey, namespacedKey, ValueModesKeys } from '@standardnotes/services'
|
||||
import { StorageReader } from '../Reader'
|
||||
import { PreviousSnjsVersion2_0_0 } from '@Lib/Version'
|
||||
|
||||
export class StorageReader2_0_0 extends StorageReader {
|
||||
static override version() {
|
||||
return PreviousSnjsVersion2_0_0
|
||||
}
|
||||
|
||||
private async getStorage() {
|
||||
const storageKey = namespacedKey(this.identifier, RawStorageKey.StorageObject)
|
||||
const storage = await this.deviceInterface.getRawStorageValue(storageKey)
|
||||
const values = storage ? JSON.parse(storage) : undefined
|
||||
return values
|
||||
}
|
||||
|
||||
private async getNonWrappedValue(key: string) {
|
||||
const values = await this.getStorage()
|
||||
if (!values) {
|
||||
return undefined
|
||||
}
|
||||
return values[ValueModesKeys.Nonwrapped]?.[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* In 2.0.0+, account key params are stored in NonWrapped storage
|
||||
*/
|
||||
public async getAccountKeyParams() {
|
||||
return this.getNonWrappedValue(StorageKey.RootKeyParams)
|
||||
}
|
||||
|
||||
public async hasNonWrappedAccountKeys() {
|
||||
const value = await this.deviceInterface.getNamespacedKeychainValue(this.identifier)
|
||||
return !isNullOrUndefined(value)
|
||||
}
|
||||
|
||||
public async hasPasscode() {
|
||||
const wrappedRootKey = await this.getNonWrappedValue(StorageKey.WrappedRootKey)
|
||||
return !isNullOrUndefined(wrappedRootKey)
|
||||
}
|
||||
|
||||
public usesKeychain() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { StorageReader2_0_0 } from './Reader2_0_0'
|
||||
export { StorageReader1_0_0 } from './Reader1_0_0'
|
||||
725
packages/snjs/lib/Migrations/Versions/2_0_0.ts
Normal file
725
packages/snjs/lib/Migrations/Versions/2_0_0.ts
Normal file
@@ -0,0 +1,725 @@
|
||||
import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common'
|
||||
import { JwtSession } from '../../Services/Session/Sessions/JwtSession'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { MigrationServices } from '../MigrationServices'
|
||||
import { PreviousSnjsVersion2_0_0 } from '../../Version'
|
||||
import { SNRootKey, CreateNewRootKey } from '@standardnotes/encryption'
|
||||
import { DiskStorageService } from '../../Services/Storage/DiskStorageService'
|
||||
import { StorageReader1_0_0 } from '../StorageReaders/Versions/Reader1_0_0'
|
||||
import * as Models from '@standardnotes/models'
|
||||
import * as Services from '@standardnotes/services'
|
||||
import * as Utils from '@standardnotes/utils'
|
||||
import { isEnvironmentMobile, isEnvironmentWebOrDesktop } from '@Lib/Application/Platforms'
|
||||
import {
|
||||
getIncrementedDirtyIndex,
|
||||
LegacyMobileKeychainStructure,
|
||||
PayloadTimestampDefaults,
|
||||
} from '@standardnotes/models'
|
||||
import { isMobileDevice } from '@standardnotes/services'
|
||||
|
||||
interface LegacyStorageContent extends Models.ItemContent {
|
||||
storage: unknown
|
||||
}
|
||||
|
||||
interface LegacyAccountKeysValue {
|
||||
ak: string
|
||||
mk: string
|
||||
version: string
|
||||
jwt: string
|
||||
}
|
||||
|
||||
interface LegacyRootKeyContent extends Models.RootKeyContent {
|
||||
accountKeys?: LegacyAccountKeysValue
|
||||
}
|
||||
|
||||
const LEGACY_SESSION_TOKEN_KEY = 'jwt'
|
||||
|
||||
export class Migration2_0_0 extends Migration {
|
||||
private legacyReader!: StorageReader1_0_0
|
||||
|
||||
constructor(services: MigrationServices) {
|
||||
super(services)
|
||||
this.legacyReader = new StorageReader1_0_0(
|
||||
this.services.deviceInterface,
|
||||
this.services.identifier,
|
||||
this.services.environment,
|
||||
)
|
||||
}
|
||||
|
||||
static override version() {
|
||||
return PreviousSnjsVersion2_0_0
|
||||
}
|
||||
|
||||
protected registerStageHandlers() {
|
||||
this.registerStageHandler(Services.ApplicationStage.PreparingForLaunch_0, async () => {
|
||||
if (isEnvironmentWebOrDesktop(this.services.environment)) {
|
||||
await this.migrateStorageStructureForWebDesktop()
|
||||
} else if (isEnvironmentMobile(this.services.environment)) {
|
||||
await this.migrateStorageStructureForMobile()
|
||||
}
|
||||
})
|
||||
this.registerStageHandler(Services.ApplicationStage.StorageDecrypted_09, async () => {
|
||||
await this.migrateArbitraryRawStorageToManagedStorageAllPlatforms()
|
||||
if (isEnvironmentMobile(this.services.environment)) {
|
||||
await this.migrateMobilePreferences()
|
||||
}
|
||||
await this.migrateSessionStorage()
|
||||
await this.deleteLegacyStorageValues()
|
||||
})
|
||||
this.registerStageHandler(Services.ApplicationStage.LoadingDatabase_11, async () => {
|
||||
await this.createDefaultItemsKeyForAllPlatforms()
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Web
|
||||
* Migrates legacy storage structure into new managed format.
|
||||
* If encrypted storage exists, we need to first decrypt it with the passcode.
|
||||
* Then extract the account key from it. Then, encrypt storage with the
|
||||
* account key. Then encrypt the account key with the passcode and store it
|
||||
* within the new storage format.
|
||||
*
|
||||
* Generate note: We do not use the keychain if passcode is available.
|
||||
*/
|
||||
private async migrateStorageStructureForWebDesktop() {
|
||||
const deviceInterface = this.services.deviceInterface
|
||||
const newStorageRawStructure: Services.StorageValuesObject = {
|
||||
[Services.ValueModesKeys.Wrapped]: {} as Models.LocalStorageEncryptedContextualPayload,
|
||||
[Services.ValueModesKeys.Unwrapped]: {},
|
||||
[Services.ValueModesKeys.Nonwrapped]: {},
|
||||
}
|
||||
const rawAccountKeyParams = (await this.legacyReader.getAccountKeyParams()) as AnyKeyParamsContent
|
||||
/** Could be null if no account, or if account and storage is encrypted */
|
||||
if (rawAccountKeyParams) {
|
||||
newStorageRawStructure.nonwrapped[Services.StorageKey.RootKeyParams] = rawAccountKeyParams
|
||||
}
|
||||
const encryptedStorage = (await deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.WebEncryptedStorageKey,
|
||||
)) as Models.EncryptedTransferPayload
|
||||
|
||||
if (encryptedStorage) {
|
||||
const encryptedStoragePayload = new Models.EncryptedPayload(encryptedStorage)
|
||||
|
||||
const passcodeResult = await this.webDesktopHelperGetPasscodeKeyAndDecryptEncryptedStorage(
|
||||
encryptedStoragePayload,
|
||||
)
|
||||
|
||||
const passcodeKey = passcodeResult.key
|
||||
const decryptedStoragePayload = passcodeResult.decryptedStoragePayload
|
||||
const passcodeParams = passcodeResult.keyParams
|
||||
|
||||
newStorageRawStructure.nonwrapped[Services.StorageKey.RootKeyWrapperKeyParams] = passcodeParams.getPortableValue()
|
||||
|
||||
const rawStorageValueStore = Utils.Copy(decryptedStoragePayload.content.storage)
|
||||
const storageValueStore: Record<string, unknown> = Utils.jsonParseEmbeddedKeys(rawStorageValueStore)
|
||||
/** Store previously encrypted auth_params into new nonwrapped value key */
|
||||
|
||||
const accountKeyParams = storageValueStore[Services.LegacyKeys1_0_0.AllAccountKeyParamsKey] as AnyKeyParamsContent
|
||||
newStorageRawStructure.nonwrapped[Services.StorageKey.RootKeyParams] = accountKeyParams
|
||||
|
||||
let keyToEncryptStorageWith = passcodeKey
|
||||
/** Extract account key (mk, pw, ak) if it exists */
|
||||
const hasAccountKeys = !Utils.isNullOrUndefined(storageValueStore.mk)
|
||||
|
||||
if (hasAccountKeys) {
|
||||
const { accountKey, wrappedKey } = await this.webDesktopHelperExtractAndWrapAccountKeysFromValueStore(
|
||||
passcodeKey,
|
||||
accountKeyParams,
|
||||
storageValueStore,
|
||||
)
|
||||
keyToEncryptStorageWith = accountKey
|
||||
newStorageRawStructure.nonwrapped[Services.StorageKey.WrappedRootKey] = wrappedKey
|
||||
}
|
||||
|
||||
/** Encrypt storage with proper key */
|
||||
newStorageRawStructure.wrapped = await this.webDesktopHelperEncryptStorage(
|
||||
keyToEncryptStorageWith,
|
||||
decryptedStoragePayload,
|
||||
storageValueStore,
|
||||
)
|
||||
} else {
|
||||
/**
|
||||
* No encrypted storage, take account keys (if they exist) out of raw storage
|
||||
* and place them in the keychain. */
|
||||
const ak = await this.services.deviceInterface.getRawStorageValue('ak')
|
||||
const mk = await this.services.deviceInterface.getRawStorageValue('mk')
|
||||
|
||||
if (ak || mk) {
|
||||
const version = rawAccountKeyParams.version || (await this.getFallbackRootKeyVersion())
|
||||
|
||||
const accountKey = CreateNewRootKey({
|
||||
masterKey: mk as string,
|
||||
dataAuthenticationKey: ak as string,
|
||||
version: version,
|
||||
keyParams: rawAccountKeyParams,
|
||||
})
|
||||
await this.services.deviceInterface.setNamespacedKeychainValue(
|
||||
accountKey.getKeychainValue(),
|
||||
this.services.identifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist storage under new key and structure */
|
||||
await this.allPlatformHelperSetStorageStructure(newStorageRawStructure)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper
|
||||
* All platforms
|
||||
*/
|
||||
private async allPlatformHelperSetStorageStructure(rawStructure: Services.StorageValuesObject) {
|
||||
const newStructure = DiskStorageService.DefaultValuesObject(
|
||||
rawStructure.wrapped,
|
||||
rawStructure.unwrapped,
|
||||
rawStructure.nonwrapped,
|
||||
) as Partial<Services.StorageValuesObject>
|
||||
|
||||
newStructure[Services.ValueModesKeys.Unwrapped] = undefined
|
||||
|
||||
await this.services.deviceInterface.setRawStorageValue(
|
||||
Services.namespacedKey(this.services.identifier, Services.RawStorageKey.StorageObject),
|
||||
JSON.stringify(newStructure),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper
|
||||
* Web/desktop only
|
||||
*/
|
||||
private async webDesktopHelperGetPasscodeKeyAndDecryptEncryptedStorage(
|
||||
encryptedPayload: Models.EncryptedPayloadInterface,
|
||||
) {
|
||||
const rawPasscodeParams = (await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.WebPasscodeParamsKey,
|
||||
)) as AnyKeyParamsContent
|
||||
const passcodeParams = this.services.protocolService.createKeyParams(rawPasscodeParams)
|
||||
|
||||
/** Decrypt it with the passcode */
|
||||
let decryptedStoragePayload:
|
||||
| Models.DecryptedPayloadInterface<LegacyStorageContent>
|
||||
| Models.EncryptedPayloadInterface = encryptedPayload
|
||||
let passcodeKey: SNRootKey | undefined
|
||||
|
||||
await this.promptForPasscodeUntilCorrect(async (candidate: string) => {
|
||||
passcodeKey = await this.services.protocolService.computeRootKey(candidate, passcodeParams)
|
||||
decryptedStoragePayload = await this.services.protocolService.decryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [encryptedPayload],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
|
||||
return !Models.isErrorDecryptingPayload(decryptedStoragePayload)
|
||||
})
|
||||
|
||||
return {
|
||||
decryptedStoragePayload:
|
||||
decryptedStoragePayload as unknown as Models.DecryptedPayloadInterface<LegacyStorageContent>,
|
||||
key: passcodeKey as SNRootKey,
|
||||
keyParams: passcodeParams,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper
|
||||
* Web/desktop only
|
||||
*/
|
||||
private async webDesktopHelperExtractAndWrapAccountKeysFromValueStore(
|
||||
passcodeKey: SNRootKey,
|
||||
accountKeyParams: AnyKeyParamsContent,
|
||||
storageValueStore: Record<string, unknown>,
|
||||
) {
|
||||
const version = accountKeyParams?.version || (await this.getFallbackRootKeyVersion())
|
||||
const accountKey = CreateNewRootKey({
|
||||
masterKey: storageValueStore.mk as string,
|
||||
dataAuthenticationKey: storageValueStore.ak as string,
|
||||
version: version,
|
||||
keyParams: accountKeyParams,
|
||||
})
|
||||
|
||||
delete storageValueStore.mk
|
||||
delete storageValueStore.pw
|
||||
delete storageValueStore.ak
|
||||
|
||||
const accountKeyPayload = accountKey.payload
|
||||
|
||||
/** Encrypt account key with passcode */
|
||||
const encryptedAccountKey = await this.services.protocolService.encryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [accountKeyPayload],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
return {
|
||||
accountKey: accountKey,
|
||||
wrappedKey: Models.CreateEncryptedLocalStorageContextPayload(encryptedAccountKey),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper
|
||||
* Web/desktop only
|
||||
* Encrypt storage with account key
|
||||
*/
|
||||
async webDesktopHelperEncryptStorage(
|
||||
key: SNRootKey,
|
||||
decryptedStoragePayload: Models.DecryptedPayloadInterface,
|
||||
storageValueStore: Record<string, unknown>,
|
||||
) {
|
||||
const wrapped = await this.services.protocolService.encryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [
|
||||
decryptedStoragePayload.copy({
|
||||
content_type: ContentType.EncryptedStorage,
|
||||
content: storageValueStore as unknown as Models.ItemContent,
|
||||
}),
|
||||
],
|
||||
key: key,
|
||||
},
|
||||
})
|
||||
|
||||
return Models.CreateEncryptedLocalStorageContextPayload(wrapped)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile
|
||||
* On mobile legacy structure is mostly similar to new structure,
|
||||
* in that the account key is encrypted with the passcode. But mobile did
|
||||
* not have encrypted storage, so we simply need to transfer all existing
|
||||
* storage values into new managed structure.
|
||||
*
|
||||
* In version <= 3.0.16 on mobile, encrypted account keys were stored in the keychain
|
||||
* under `encryptedAccountKeys`. In 3.0.17 a migration was introduced that moved this value
|
||||
* to storage under key `encrypted_account_keys`. We need to anticipate the keys being in
|
||||
* either location.
|
||||
*
|
||||
* If no account but passcode only, the only thing we stored on mobile
|
||||
* previously was keys.offline.pw and keys.offline.timing in the keychain
|
||||
* that we compared against for valid decryption.
|
||||
* In the new version, we know a passcode is correct if it can decrypt storage.
|
||||
* As part of the migration, we’ll need to request the raw passcode from user,
|
||||
* compare it against the keychain offline.pw value, and if correct,
|
||||
* migrate storage to new structure, and encrypt with passcode key.
|
||||
*
|
||||
* If account only, take the value in the keychain, and rename the values
|
||||
* (i.e mk > masterKey).
|
||||
* @access private
|
||||
*/
|
||||
async migrateStorageStructureForMobile() {
|
||||
Utils.assert(isMobileDevice(this.services.deviceInterface))
|
||||
|
||||
const keychainValue =
|
||||
(await this.services.deviceInterface.getRawKeychainValue()) as unknown as LegacyMobileKeychainStructure
|
||||
|
||||
const wrappedAccountKey = ((await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobileWrappedRootKeyKey,
|
||||
)) || keychainValue?.encryptedAccountKeys) as Models.EncryptedTransferPayload
|
||||
|
||||
const rawAccountKeyParams = (await this.legacyReader.getAccountKeyParams()) as AnyKeyParamsContent
|
||||
|
||||
const rawPasscodeParams = (await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobilePasscodeParamsKey,
|
||||
)) as AnyKeyParamsContent
|
||||
|
||||
const firstRunValue = await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.NonwrappedStorageKey.MobileFirstRun,
|
||||
)
|
||||
|
||||
const rawStructure: Services.StorageValuesObject = {
|
||||
[Services.ValueModesKeys.Nonwrapped]: {
|
||||
[Services.StorageKey.WrappedRootKey]: wrappedAccountKey,
|
||||
/** A 'hash' key may be present from legacy versions that should be deleted */
|
||||
[Services.StorageKey.RootKeyWrapperKeyParams]: Utils.omitByCopy(rawPasscodeParams, ['hash' as never]),
|
||||
[Services.StorageKey.RootKeyParams]: rawAccountKeyParams,
|
||||
[Services.NonwrappedStorageKey.MobileFirstRun]: firstRunValue,
|
||||
},
|
||||
[Services.ValueModesKeys.Unwrapped]: {},
|
||||
[Services.ValueModesKeys.Wrapped]: {} as Models.LocalStorageDecryptedContextualPayload,
|
||||
}
|
||||
|
||||
const biometricPrefs = (await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobileBiometricsPrefs,
|
||||
)) as { enabled: boolean; timing: unknown }
|
||||
|
||||
if (biometricPrefs) {
|
||||
rawStructure.nonwrapped[Services.StorageKey.BiometricsState] = biometricPrefs.enabled
|
||||
rawStructure.nonwrapped[Services.StorageKey.MobileBiometricsTiming] = biometricPrefs.timing
|
||||
}
|
||||
|
||||
const passcodeKeyboardType = await this.services.deviceInterface.getRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobilePasscodeKeyboardType,
|
||||
)
|
||||
|
||||
if (passcodeKeyboardType) {
|
||||
rawStructure.nonwrapped[Services.StorageKey.MobilePasscodeKeyboardType] = passcodeKeyboardType
|
||||
}
|
||||
|
||||
if (rawPasscodeParams) {
|
||||
const passcodeParams = this.services.protocolService.createKeyParams(rawPasscodeParams)
|
||||
const getPasscodeKey = async () => {
|
||||
let passcodeKey: SNRootKey | undefined
|
||||
|
||||
await this.promptForPasscodeUntilCorrect(async (candidate: string) => {
|
||||
passcodeKey = await this.services.protocolService.computeRootKey(candidate, passcodeParams)
|
||||
|
||||
const pwHash = keychainValue?.offline?.pw
|
||||
|
||||
if (pwHash) {
|
||||
return passcodeKey.serverPassword === pwHash
|
||||
} else {
|
||||
/**
|
||||
* Fallback decryption if keychain is missing for some reason. If account,
|
||||
* validate by attempting to decrypt wrapped account key. Otherwise, validate
|
||||
* by attempting to decrypt random item.
|
||||
* */
|
||||
if (wrappedAccountKey) {
|
||||
const decryptedAcctKey = await this.services.protocolService.decryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [new Models.EncryptedPayload(wrappedAccountKey)],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
return !Models.isErrorDecryptingPayload(decryptedAcctKey)
|
||||
} else {
|
||||
const item = (
|
||||
await this.services.deviceInterface.getAllRawDatabasePayloads(this.services.identifier)
|
||||
)[0] as Models.EncryptedTransferPayload
|
||||
|
||||
if (!item) {
|
||||
throw Error('Passcode only migration aborting due to missing keychain.offline.pw')
|
||||
}
|
||||
|
||||
const decryptedPayload = await this.services.protocolService.decryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [new Models.EncryptedPayload(item)],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
return !Models.isErrorDecryptingPayload(decryptedPayload)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return passcodeKey as SNRootKey
|
||||
}
|
||||
|
||||
rawStructure.nonwrapped[Services.StorageKey.MobilePasscodeTiming] = keychainValue?.offline?.timing
|
||||
|
||||
if (wrappedAccountKey) {
|
||||
/**
|
||||
* Account key is encrypted with passcode. Inside, the accountKey is located inside
|
||||
* content.accountKeys. We want to unembed these values to main content, rename
|
||||
* with proper property names, wrap again, and store in new rawStructure.
|
||||
*/
|
||||
const passcodeKey = await getPasscodeKey()
|
||||
const payload = new Models.EncryptedPayload(wrappedAccountKey)
|
||||
const unwrappedAccountKey = await this.services.protocolService.decryptSplitSingle<LegacyRootKeyContent>({
|
||||
usesRootKey: {
|
||||
items: [payload],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
|
||||
if (Models.isErrorDecryptingPayload(unwrappedAccountKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
const accountKeyContent = unwrappedAccountKey.content.accountKeys as LegacyAccountKeysValue
|
||||
|
||||
const version =
|
||||
accountKeyContent.version || rawAccountKeyParams?.version || (await this.getFallbackRootKeyVersion())
|
||||
|
||||
const newAccountKey = unwrappedAccountKey.copy({
|
||||
content: Models.FillItemContent<LegacyRootKeyContent>({
|
||||
masterKey: accountKeyContent.mk,
|
||||
dataAuthenticationKey: accountKeyContent.ak,
|
||||
version: version as ProtocolVersion,
|
||||
keyParams: rawAccountKeyParams,
|
||||
accountKeys: undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const newWrappedAccountKey = await this.services.protocolService.encryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [newAccountKey],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
rawStructure.nonwrapped[Services.StorageKey.WrappedRootKey] =
|
||||
Models.CreateEncryptedLocalStorageContextPayload(newWrappedAccountKey)
|
||||
|
||||
if (accountKeyContent.jwt) {
|
||||
/** Move the jwt to raw storage so that it can be migrated in `migrateSessionStorage` */
|
||||
void this.services.deviceInterface.setRawStorageValue(LEGACY_SESSION_TOKEN_KEY, accountKeyContent.jwt)
|
||||
}
|
||||
await this.services.deviceInterface.clearRawKeychainValue()
|
||||
} else if (!wrappedAccountKey) {
|
||||
/** Passcode only, no account */
|
||||
const passcodeKey = await getPasscodeKey()
|
||||
const payload = new Models.DecryptedPayload({
|
||||
uuid: Utils.UuidGenerator.GenerateUuid(),
|
||||
content: Models.FillItemContent(rawStructure.unwrapped),
|
||||
content_type: ContentType.EncryptedStorage,
|
||||
...PayloadTimestampDefaults(),
|
||||
})
|
||||
|
||||
/** Encrypt new storage.unwrapped structure with passcode */
|
||||
const wrapped = await this.services.protocolService.encryptSplitSingle({
|
||||
usesRootKey: {
|
||||
items: [payload],
|
||||
key: passcodeKey,
|
||||
},
|
||||
})
|
||||
rawStructure.wrapped = Models.CreateEncryptedLocalStorageContextPayload(wrapped)
|
||||
|
||||
await this.services.deviceInterface.clearRawKeychainValue()
|
||||
}
|
||||
} else {
|
||||
/** No passcode, potentially account. Migrate keychain property keys. */
|
||||
const hasAccount = !Utils.isNullOrUndefined(keychainValue?.mk)
|
||||
if (hasAccount) {
|
||||
const accountVersion =
|
||||
(keychainValue.version as ProtocolVersion) ||
|
||||
rawAccountKeyParams?.version ||
|
||||
(await this.getFallbackRootKeyVersion())
|
||||
|
||||
const accountKey = CreateNewRootKey({
|
||||
masterKey: keychainValue.mk,
|
||||
dataAuthenticationKey: keychainValue.ak,
|
||||
version: accountVersion,
|
||||
keyParams: rawAccountKeyParams,
|
||||
})
|
||||
|
||||
await this.services.deviceInterface.setNamespacedKeychainValue(
|
||||
accountKey.getKeychainValue(),
|
||||
this.services.identifier,
|
||||
)
|
||||
|
||||
if (keychainValue.jwt) {
|
||||
/** Move the jwt to raw storage so that it can be migrated in `migrateSessionStorage` */
|
||||
void this.services.deviceInterface.setRawStorageValue(LEGACY_SESSION_TOKEN_KEY, keychainValue.jwt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Move encrypted account key into place where it is now expected */
|
||||
await this.allPlatformHelperSetStorageStructure(rawStructure)
|
||||
}
|
||||
|
||||
/**
|
||||
* If we are unable to determine a root key's version, due to missing version
|
||||
* parameter from key params due to 001 or 002, we need to fallback to checking
|
||||
* any encrypted payload and retrieving its version.
|
||||
*
|
||||
* If we are unable to garner any meaningful information, we will default to 002.
|
||||
*
|
||||
* (Previously we attempted to discern version based on presence of keys.ak; if ak,
|
||||
* then 003, otherwise 002. However, late versions of 002 also inluded an ak, so this
|
||||
* method can't be used. This method also didn't account for 001 versions.)
|
||||
*/
|
||||
private async getFallbackRootKeyVersion() {
|
||||
const anyItem = (
|
||||
await this.services.deviceInterface.getAllRawDatabasePayloads(this.services.identifier)
|
||||
)[0] as Models.EncryptedTransferPayload
|
||||
|
||||
if (!anyItem) {
|
||||
return ProtocolVersion.V002
|
||||
}
|
||||
|
||||
const payload = new Models.EncryptedPayload(anyItem)
|
||||
return payload.version || ProtocolVersion.V002
|
||||
}
|
||||
|
||||
/**
|
||||
* All platforms
|
||||
* Migrate all previously independently stored storage keys into new
|
||||
* managed approach.
|
||||
*/
|
||||
private async migrateArbitraryRawStorageToManagedStorageAllPlatforms() {
|
||||
const allKeyValues = await this.services.deviceInterface.getAllRawStorageKeyValues()
|
||||
const legacyKeys = Utils.objectToValueArray(Services.LegacyKeys1_0_0)
|
||||
|
||||
const tryJsonParse = (value: string) => {
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch (e) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const applicationIdentifier = this.services.identifier
|
||||
|
||||
for (const keyValuePair of allKeyValues) {
|
||||
const key = keyValuePair.key
|
||||
const value = keyValuePair.value
|
||||
const isNameSpacedKey =
|
||||
applicationIdentifier && applicationIdentifier.length > 0 && key.startsWith(applicationIdentifier)
|
||||
if (legacyKeys.includes(key) || isNameSpacedKey) {
|
||||
continue
|
||||
}
|
||||
if (!Utils.isNullOrUndefined(value)) {
|
||||
/**
|
||||
* Raw values should always have been json stringified.
|
||||
* New values should always be objects/parsed.
|
||||
*/
|
||||
const newValue = tryJsonParse(value as string)
|
||||
this.services.storageService.setValue(key, newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All platforms
|
||||
* Deletes all StorageKey and LegacyKeys1_0_0 from root raw storage.
|
||||
* @access private
|
||||
*/
|
||||
async deleteLegacyStorageValues() {
|
||||
const miscKeys = [
|
||||
'mk',
|
||||
'ak',
|
||||
'pw',
|
||||
/** v1 unused key */
|
||||
'encryptionKey',
|
||||
/** v1 unused key */
|
||||
'authKey',
|
||||
'jwt',
|
||||
'ephemeral',
|
||||
'cachedThemes',
|
||||
]
|
||||
|
||||
const managedKeys = [
|
||||
...Utils.objectToValueArray(Services.StorageKey),
|
||||
...Utils.objectToValueArray(Services.LegacyKeys1_0_0),
|
||||
...miscKeys,
|
||||
]
|
||||
|
||||
for (const key of managedKeys) {
|
||||
await this.services.deviceInterface.removeRawStorageValue(key)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile
|
||||
* Migrate mobile preferences
|
||||
*/
|
||||
private async migrateMobilePreferences() {
|
||||
const lastExportDate = await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobileLastExportDate,
|
||||
)
|
||||
const doNotWarnUnsupportedEditors = await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobileDoNotWarnUnsupportedEditors,
|
||||
)
|
||||
const legacyOptionsState = (await this.services.deviceInterface.getJsonParsedRawStorageValue(
|
||||
Services.LegacyKeys1_0_0.MobileOptionsState,
|
||||
)) as Record<string, unknown>
|
||||
|
||||
let migratedOptionsState = {}
|
||||
|
||||
if (legacyOptionsState) {
|
||||
const legacySortBy = legacyOptionsState.sortBy
|
||||
migratedOptionsState = {
|
||||
sortBy:
|
||||
legacySortBy === 'updated_at' || legacySortBy === 'client_updated_at'
|
||||
? Models.CollectionSort.UpdatedAt
|
||||
: legacySortBy,
|
||||
sortReverse: legacyOptionsState.sortReverse ?? false,
|
||||
hideNotePreview: legacyOptionsState.hidePreviews ?? false,
|
||||
hideDate: legacyOptionsState.hideDates ?? false,
|
||||
hideTags: legacyOptionsState.hideTags ?? false,
|
||||
}
|
||||
}
|
||||
const preferences = {
|
||||
...migratedOptionsState,
|
||||
lastExportDate: lastExportDate ?? undefined,
|
||||
doNotShowAgainUnsupportedEditors: doNotWarnUnsupportedEditors ?? false,
|
||||
}
|
||||
await this.services.storageService.setValue(Services.StorageKey.MobilePreferences, preferences)
|
||||
}
|
||||
|
||||
/**
|
||||
* All platforms
|
||||
* Migrate previously stored session string token into object
|
||||
* On mobile, JWTs were previously stored in storage, inside of the user object,
|
||||
* but then custom-migrated to be stored in the keychain. We must account for
|
||||
* both scenarios here in case a user did not perform the custom platform migration.
|
||||
* On desktop/web, JWT was stored in storage.
|
||||
*/
|
||||
private migrateSessionStorage() {
|
||||
const USER_OBJECT_KEY = 'user'
|
||||
let currentToken = this.services.storageService.getValue<string | undefined>(LEGACY_SESSION_TOKEN_KEY)
|
||||
const user = this.services.storageService.getValue<{ jwt: string; server: string }>(USER_OBJECT_KEY)
|
||||
|
||||
if (!currentToken) {
|
||||
/** Try the user object */
|
||||
if (user) {
|
||||
currentToken = user.jwt
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentToken) {
|
||||
/**
|
||||
* If we detect that a user object is present, but the jwt is missing,
|
||||
* we'll fill the jwt value with a junk value just so we create a session.
|
||||
* When the client attempts to talk to the server, the server will reply
|
||||
* with invalid token error, and the client will automatically prompt to reauthenticate.
|
||||
*/
|
||||
const hasAccount = !Utils.isNullOrUndefined(user)
|
||||
if (hasAccount) {
|
||||
currentToken = 'junk-value'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const session = new JwtSession(currentToken)
|
||||
this.services.storageService.setValue(Services.StorageKey.Session, session)
|
||||
|
||||
/** Server has to be migrated separately on mobile */
|
||||
if (isEnvironmentMobile(this.services.environment)) {
|
||||
if (user && user.server) {
|
||||
this.services.storageService.setValue(Services.StorageKey.ServerHost, user.server)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All platforms
|
||||
* Create new default items key from root key.
|
||||
* Otherwise, when data is loaded, we won't be able to decrypt it
|
||||
* without existence of an item key. This will mean that if this migration
|
||||
* is run on two different platforms for the same user, they will create
|
||||
* two new items keys. Which one they use to decrypt past items and encrypt
|
||||
* future items doesn't really matter.
|
||||
* @access private
|
||||
*/
|
||||
async createDefaultItemsKeyForAllPlatforms() {
|
||||
const rootKey = this.services.protocolService.getRootKey()
|
||||
if (rootKey) {
|
||||
const rootKeyParams = await this.services.protocolService.getRootKeyParams()
|
||||
/** If params are missing a version, it must be 001 */
|
||||
const fallbackVersion = ProtocolVersion.V001
|
||||
|
||||
const payload = new Models.DecryptedPayload({
|
||||
uuid: Utils.UuidGenerator.GenerateUuid(),
|
||||
content_type: ContentType.ItemsKey,
|
||||
content: Models.FillItemContentSpecialized<Models.ItemsKeyContentSpecialized, Models.ItemsKeyContent>({
|
||||
itemsKey: rootKey.masterKey,
|
||||
dataAuthenticationKey: rootKey.dataAuthenticationKey,
|
||||
version: rootKeyParams?.version || fallbackVersion,
|
||||
}),
|
||||
dirty: true,
|
||||
dirtyIndex: getIncrementedDirtyIndex(),
|
||||
...PayloadTimestampDefaults(),
|
||||
})
|
||||
|
||||
const itemsKey = Models.CreateDecryptedItemFromPayload(payload)
|
||||
|
||||
await this.services.itemManager.emitItemFromPayload(
|
||||
itemsKey.payloadRepresentation(),
|
||||
Models.PayloadEmitSource.LocalChanged,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/snjs/lib/Migrations/Versions/2_0_15.ts
Normal file
21
packages/snjs/lib/Migrations/Versions/2_0_15.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
|
||||
export class Migration2_0_15 extends Migration {
|
||||
static override version(): string {
|
||||
return '2.0.15'
|
||||
}
|
||||
|
||||
protected registerStageHandlers(): void {
|
||||
this.registerStageHandler(ApplicationStage.LoadedDatabase_12, async () => {
|
||||
await this.createNewDefaultItemsKeyIfNecessary()
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
private async createNewDefaultItemsKeyIfNecessary() {
|
||||
if (this.services.protocolService.needsNewRootKeyBasedItemsKey()) {
|
||||
await this.services.protocolService.createNewDefaultItemsKey()
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/snjs/lib/Migrations/Versions/2_20_0.ts
Normal file
26
packages/snjs/lib/Migrations/Versions/2_20_0.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
|
||||
export class Migration2_20_0 extends Migration {
|
||||
static override version(): string {
|
||||
return '2.20.0'
|
||||
}
|
||||
|
||||
protected registerStageHandlers(): void {
|
||||
this.registerStageHandler(ApplicationStage.LoadedDatabase_12, async () => {
|
||||
await this.deleteMfaItems()
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
private async deleteMfaItems(): Promise<void> {
|
||||
const contentType = 'SF|MFA' as ContentType
|
||||
const items = this.services.itemManager.getItems(contentType)
|
||||
|
||||
for (const item of items) {
|
||||
this.services.itemManager.removeItemLocally(item)
|
||||
await this.services.storageService.deletePayloadWithId(item.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
packages/snjs/lib/Migrations/Versions/2_36_0.ts
Normal file
26
packages/snjs/lib/Migrations/Versions/2_36_0.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
|
||||
export class Migration2_36_0 extends Migration {
|
||||
static override version(): string {
|
||||
return '2.36.0'
|
||||
}
|
||||
|
||||
protected registerStageHandlers(): void {
|
||||
this.registerStageHandler(ApplicationStage.LoadedDatabase_12, async () => {
|
||||
await this.removeServerExtensionsLocally()
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
private async removeServerExtensionsLocally(): Promise<void> {
|
||||
const contentType = 'SF|Extension' as ContentType
|
||||
const items = this.services.itemManager.getItems(contentType)
|
||||
|
||||
for (const item of items) {
|
||||
this.services.itemManager.removeItemLocally(item)
|
||||
await this.services.storageService.deletePayloadWithId(item.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
30
packages/snjs/lib/Migrations/Versions/2_42_0.ts
Normal file
30
packages/snjs/lib/Migrations/Versions/2_42_0.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
import { FeatureIdentifier } from '@standardnotes/features'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { SNTheme } from '@standardnotes/models'
|
||||
|
||||
const NoDistractionIdentifier = 'org.standardnotes.theme-no-distraction' as FeatureIdentifier
|
||||
|
||||
export class Migration2_42_0 extends Migration {
|
||||
static override version(): string {
|
||||
return '2.42.0'
|
||||
}
|
||||
|
||||
protected registerStageHandlers(): void {
|
||||
this.registerStageHandler(ApplicationStage.FullSyncCompleted_13, async () => {
|
||||
await this.deleteNoDistraction()
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
private async deleteNoDistraction(): Promise<void> {
|
||||
const themes = (this.services.itemManager.getItems(ContentType.Theme) as SNTheme[]).filter((theme) => {
|
||||
return theme.identifier === NoDistractionIdentifier
|
||||
})
|
||||
|
||||
for (const theme of themes) {
|
||||
await this.services.itemManager.setItemToBeDeleted(theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
32
packages/snjs/lib/Migrations/Versions/2_7_0.ts
Normal file
32
packages/snjs/lib/Migrations/Versions/2_7_0.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CompoundPredicate, Predicate, SNComponent } from '@standardnotes/models'
|
||||
import { Migration } from '@Lib/Migrations/Migration'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { ApplicationStage } from '@standardnotes/services'
|
||||
|
||||
export class Migration2_7_0 extends Migration {
|
||||
static override version(): string {
|
||||
return '2.7.0'
|
||||
}
|
||||
|
||||
protected registerStageHandlers(): void {
|
||||
this.registerStageHandler(ApplicationStage.FullSyncCompleted_13, async () => {
|
||||
await this.deleteBatchManagerSingleton()
|
||||
this.markDone()
|
||||
})
|
||||
}
|
||||
|
||||
private async deleteBatchManagerSingleton() {
|
||||
const batchMgrId = 'org.standardnotes.batch-manager'
|
||||
|
||||
const batchMgrPred = new CompoundPredicate('and', [
|
||||
new Predicate<SNComponent>('content_type', '=', ContentType.Component),
|
||||
new Predicate<SNComponent>('identifier', '=', batchMgrId),
|
||||
])
|
||||
|
||||
const batchMgrSingleton = this.services.singletonManager.findSingleton(ContentType.Component, batchMgrPred)
|
||||
|
||||
if (batchMgrSingleton) {
|
||||
await this.services.itemManager.setItemToBeDeleted(batchMgrSingleton)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
packages/snjs/lib/Migrations/Versions/index.ts
Normal file
17
packages/snjs/lib/Migrations/Versions/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Migration2_0_0 } from './2_0_0'
|
||||
import { Migration2_0_15 } from './2_0_15'
|
||||
import { Migration2_7_0 } from './2_7_0'
|
||||
import { Migration2_20_0 } from './2_20_0'
|
||||
import { Migration2_36_0 } from './2_36_0'
|
||||
import { Migration2_42_0 } from './2_42_0'
|
||||
|
||||
export const MigrationClasses = [
|
||||
Migration2_0_0,
|
||||
Migration2_0_15,
|
||||
Migration2_7_0,
|
||||
Migration2_20_0,
|
||||
Migration2_36_0,
|
||||
Migration2_42_0,
|
||||
]
|
||||
|
||||
export { Migration2_0_0, Migration2_0_15, Migration2_7_0, Migration2_20_0, Migration2_36_0, Migration2_42_0 }
|
||||
2
packages/snjs/lib/Migrations/index.ts
Normal file
2
packages/snjs/lib/Migrations/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { BaseMigration } from './Base'
|
||||
export * from './Versions'
|
||||
Reference in New Issue
Block a user