Files
standardnotes-app-web/packages/snjs/mocha/key_recovery_service.test.js

684 lines
24 KiB
JavaScript

/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('key recovery service', function () {
this.timeout(Factory.TwentySecondTimeout)
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(function () {
localStorage.clear()
})
afterEach(function () {
localStorage.clear()
})
it('when encountering an undecryptable items key, should recover through recovery wizard', async function () {
const namespace = Factory.randomString()
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
const application = context.application
await context.launch({
receiveChallenge: (challenge) => {
application.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], unassociatedPassword),
])
},
})
await context.register()
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
const errored = await application.protocolService.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
expect(errored.errorDecrypting).to.equal(true)
await application.payloadManager.emitPayload(errored, PayloadEmitSource.LocalInserted)
await context.resolveWhenKeyRecovered(errored.uuid)
expect(application.items.findItem(errored.uuid).errorDecrypting).to.not.be.ok
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('recovered keys with key params not matching servers should be synced if local root key does matches server', async function () {
/**
* This helps ensure server always has the most valid state,
* in case the recovery is being initiated from a server value in the first place
*/
const context = await Factory.createAppContextWithFakeCrypto()
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
const application = context.application
await context.launch({
receiveChallenge: (challenge) => {
application.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], unassociatedPassword),
])
},
})
await context.register()
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
await application.payloadManager.emitPayload(
randomItemsKey.payload.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() }),
PayloadEmitSource.LocalInserted,
)
await context.sync()
const originalSyncTime = application.payloadManager.findOne(randomItemsKey.uuid).lastSyncEnd.getTime()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
const errored = await application.protocolService.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
await application.payloadManager.emitPayload(errored, PayloadEmitSource.LocalInserted)
const recoveryPromise = context.resolveWhenKeyRecovered(errored.uuid)
await context.sync()
await recoveryPromise
expect(application.payloadManager.findOne(errored.uuid).lastSyncEnd.getTime()).to.be.above(originalSyncTime)
await context.deinit()
})
it('recovered keys with key params not matching servers should not be synced if local root key does not match server', async function () {
/**
* Assume Application A has been through these states:
* 1. Registration + Items Key A + Root Key A
* 2. Password change + Items Key B + Root Key B
* 3. Password change + Items Key C + Root Key C + Failure to correctly re-encrypt Items Key A and B with Root Key C
*
* Application B is not correctly in sync, and is only at State 1 (Registration + Items Key A)
*
* Application B receives Items Key B of Root Key B but for whatever reason ignores Items Key C of Root Key C.
*
* When it recovers Items Key B, it should not re-upload it to the server, because Application B's Root Key is not
* the current account's root key.
*/
const contextA = await Factory.createAppContextWithFakeCrypto()
await contextA.launch()
await contextA.register()
contextA.preventKeyRecoveryOfKeys()
const contextB = await Factory.createAppContextWithFakeCrypto('app-b', contextA.email, contextA.password)
await contextB.launch()
await contextB.signIn()
await contextA.changePassword('new-password-1')
const itemsKeyARootKeyB = contextA.itemsKeys[0]
const itemsKeyBRootKeyB = contextA.itemsKeys[1]
contextA.disableSyncingOfItems([itemsKeyARootKeyB.uuid, itemsKeyBRootKeyB.uuid])
await contextA.changePassword('new-password-2')
const itemsKeyCRootKeyC = contextA.itemsKeys[2]
contextB.disableKeyRecoveryServerSignIn()
contextB.preventKeyRecoveryOfKeys([itemsKeyCRootKeyC.uuid])
contextB.respondToAccountPasswordChallengeWith('new-password-1')
const recoveryPromise = Promise.all([
contextB.resolveWhenKeyRecovered(itemsKeyARootKeyB.uuid),
contextB.resolveWhenKeyRecovered(itemsKeyBRootKeyB.uuid),
])
const observedDirtyItemUuids = []
contextB.spyOnChangedItems((changed) => {
const dirty = changed.filter((i) => i.dirty)
extendArray(observedDirtyItemUuids, Uuids(dirty))
})
await contextB.sync()
await recoveryPromise
expect(observedDirtyItemUuids.includes(itemsKeyARootKeyB.uuid)).to.equal(false)
expect(observedDirtyItemUuids.includes(itemsKeyBRootKeyB.uuid)).to.equal(false)
await contextA.deinit()
await contextB.deinit()
})
it('when encountering many undecryptable items key with same key params, should only prompt once', async function () {
const namespace = Factory.randomString()
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
let totalPromptCount = 0
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const application = context.application
const receiveChallenge = (challenge) => {
totalPromptCount++
/** Give unassociated password when prompted */
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)])
}
await application.prepareForLaunch({ receiveChallenge })
await application.launch(true)
await Factory.registerUserToApplication({
application: application,
email: context.email,
password: context.password,
})
/** Create items key associated with a random root key */
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const randomItemsKey2 = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const encrypted = await application.protocolService.encryptSplit({
usesRootKey: {
items: [randomItemsKey.payload, randomItemsKey2.payload],
key: randomRootKey,
},
})
/** Attempt decryption and insert into rotation in errored state */
const decrypted = await application.protocolService.decryptSplit({
usesRootKeyWithKeyLookup: {
items: encrypted,
},
})
await application.payloadManager.emitPayloads(decrypted, PayloadEmitSource.LocalInserted)
/** Wait and allow recovery wizard to complete */
await Factory.sleep(1.5)
/** Should be decrypted now */
expect(application.items.findItem(randomItemsKey.uuid).errorDecrypting).not.be.ok
expect(application.items.findItem(randomItemsKey2.uuid).errorDecrypting).not.be.ok
expect(totalPromptCount).to.equal(1)
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('when changing password on client B, client A should perform recovery flow', async function () {
const contextA = await Factory.createAppContextWithFakeCrypto()
await contextA.launch()
await contextA.register()
const originalItemsKey = contextA.application.items.getDisplayableItemsKeys()[0]
const contextB = await Factory.createAppContextWithFakeCrypto(
'another-namespace',
contextA.email,
contextA.password,
)
contextB.ignoreChallenges()
await contextB.launch()
await contextB.signIn()
const newPassword = `${Math.random()}`
const result = await contextB.application.changePassword(contextA.password, newPassword)
expect(result.error).to.not.be.ok
expect(contextB.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2)
const newItemsKey = contextB.application.items.getDisplayableItemsKeys().find((k) => k.uuid !== originalItemsKey.uuid)
const note = await Factory.createSyncedNote(contextB.application)
const recoveryPromise = contextA.resolveWhenKeyRecovered(newItemsKey.uuid)
contextA.password = newPassword
await contextA.sync(syncOptions)
await recoveryPromise
/** Same previously errored key should now no longer be errored, */
expect(contextA.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2)
for (const key of contextA.application.itemManager.getDisplayableItemsKeys()) {
expect(key.errorDecrypting).to.not.be.ok
}
const aKey = await contextA.application.protocolService.getRootKey()
const bKey = await contextB.application.protocolService.getRootKey()
expect(aKey.compare(bKey)).to.equal(true)
expect(contextA.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok
expect(contextB.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok
expect(contextA.application.syncService.isOutOfSync()).to.equal(false)
expect(contextB.application.syncService.isOutOfSync()).to.equal(false)
await contextA.deinit()
await contextB.deinit()
}).timeout(80000)
it('when items key associated with item is errored, item should be marked waiting for key', async function () {
const namespace = Factory.randomString()
const newPassword = `${Math.random()}`
const contextA = await Factory.createAppContextWithFakeCrypto(namespace)
const appA = contextA.application
await appA.prepareForLaunch({ receiveChallenge: () => {} })
await appA.launch(true)
await Factory.registerUserToApplication({
application: appA,
email: contextA.email,
password: contextA.password,
})
expect(appA.items.getItems(ContentType.ItemsKey).length).to.equal(1)
/** Create simultaneous appB signed into same account */
const appB = await Factory.createApplicationWithFakeCrypto('another-namespace')
await appB.prepareForLaunch({ receiveChallenge: () => {} })
await appB.launch(true)
await Factory.loginToApplication({
application: appB,
email: contextA.email,
password: contextA.password,
})
/** Change password on appB */
await appB.changePassword(contextA.password, newPassword)
const note = await Factory.createSyncedNote(appB)
await appB.sync.sync()
/** We expect the item in appA to be errored at this point, but we do not want it to recover */
await appA.sync.sync()
expect(appA.payloadManager.findOne(note.uuid).waitingForKey).to.equal(true)
console.warn('Expecting exceptions below as we destroy app during key recovery')
await Factory.safeDeinit(appA)
await Factory.safeDeinit(appB)
const recreatedAppA = await Factory.createApplicationWithFakeCrypto(namespace)
await recreatedAppA.prepareForLaunch({ receiveChallenge: () => {} })
await recreatedAppA.launch(true)
expect(recreatedAppA.payloadManager.findOne(note.uuid).errorDecrypting).to.equal(true)
expect(recreatedAppA.payloadManager.findOne(note.uuid).waitingForKey).to.equal(true)
await Factory.safeDeinit(recreatedAppA)
})
it('when client key params differ from server, and no matching items key exists to compare against, should perform sign in flow', async function () {
/**
* When a user changes password/email on client A, client B must update their root key to the new one.
* To do this, we can potentially avoid making a new sign in request (and creating a new session) by instead
* reading one of the undecryptable items key (which is potentially the new one that client A created). If the keyParams
* of that items key matches the servers, it means we can use those key params to compute our new local root key,
* instead of having to sign in.
*/
const unassociatedPassword = 'randfoo'
const context = await Factory.createAppContextWithFakeCrypto('some-namespace')
const application = context.application
const receiveChallenge = (challenge) => {
const isKeyRecoveryPrompt = challenge.subheading?.includes(KeyRecoveryStrings.KeyRecoveryPasswordRequired)
application.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], isKeyRecoveryPrompt ? unassociatedPassword : context.password),
])
}
await application.prepareForLaunch({ receiveChallenge })
await application.launch(true)
await context.register()
const correctRootKey = await application.protocolService.getRootKey()
/**
* 1. Change our root key locally so that its keys params doesn't match the server's
* 2. Create an items key payload that is set to errorDecrypting, and which is encrypted
* with the incorrect root key, so that it cannot be used to validate the user's password
*/
const unassociatedIdentifier = 'foorand'
/** Create items key associated with a random root key */
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const signInFunction = sinon.spy(application.keyRecoveryService, 'performServerSignIn')
await application.protocolService.setRootKey(randomRootKey)
const correctItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [correctItemsKey.payload],
key: randomRootKey,
},
})
const resolvePromise = Promise.all([
context.awaitSignInEvent(),
context.resolveWhenKeyRecovered(correctItemsKey.uuid),
])
await application.payloadManager.emitPayload(
encrypted.copy({
errorDecrypting: true,
dirty: true,
}),
PayloadEmitSource.LocalInserted,
)
await context.sync()
await resolvePromise
expect(signInFunction.callCount).to.equal(1)
const clientRootKey = await application.protocolService.getRootKey()
expect(clientRootKey.compare(correctRootKey)).to.equal(true)
const decryptedKey = application.items.findItem(correctItemsKey.uuid)
expect(decryptedKey).to.be.ok
expect(decryptedKey.content.itemsKey).to.equal(correctItemsKey.content.itemsKey)
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it(`when encountering an items key that cannot be decrypted for which we already have a decrypted value,
it should be emitted as ignored`, async function () {
const context = await Factory.createAppContextWithFakeCrypto()
const application = context.application
await context.launch()
await context.register()
/** Create and emit errored encrypted items key payload */
const itemsKey = await application.protocolService.getSureDefaultItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
const newUpdated = new Date()
const errored = encrypted.copy({
content: '004:...',
errorDecrypting: true,
updated_at: newUpdated,
})
await context.receiveServerResponse({ retrievedItems: [errored.ejected()] })
/** Our current items key should not be overwritten */
const currentItemsKey = application.items.findItem(itemsKey.uuid)
expect(currentItemsKey.errorDecrypting).to.not.be.ok
expect(currentItemsKey.itemsKey).to.equal(itemsKey.itemsKey)
/** The timestamp of our current key should be updated however so we do not enter out of sync state */
expect(currentItemsKey.serverUpdatedAt.getTime()).to.equal(newUpdated.getTime())
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it(`ignored key payloads should be added to undecryptables and recovered`, async function () {
const context = await Factory.createAppContextWithFakeCrypto()
const application = context.application
await context.launch()
await context.register()
const itemsKey = await application.protocolService.getSureDefaultItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
const newUpdated = new Date()
const errored = encrypted.copy({
errorDecrypting: true,
updated_at: newUpdated,
})
await application.payloadManager.emitDeltaEmit({
emits: [],
ignored: [errored],
source: PayloadEmitSource.RemoteRetrieved,
})
await context.resolveWhenKeyRecovered(itemsKey.uuid)
const latestItemsKey = application.items.findItem(itemsKey.uuid)
expect(latestItemsKey.errorDecrypting).to.not.be.ok
expect(latestItemsKey.itemsKey).to.equal(itemsKey.itemsKey)
expect(latestItemsKey.serverUpdatedAt.getTime()).to.equal(newUpdated.getTime())
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('application should prompt to recover undecryptables on launch', async function () {
const namespace = Factory.randomString()
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const application = context.application
await context.launch()
await context.register()
/** Create and emit errored encrypted items key payload */
const itemsKey = await application.protocolService.getSureDefaultItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
context.disableKeyRecovery()
await application.payloadManager.emitDeltaEmit({
emits: [],
ignored: [
encrypted.copy({
errorDecrypting: true,
}),
],
source: PayloadEmitSource.RemoteRetrieved,
})
/** Allow enough time to persist to disk, but not enough to complete recovery wizard */
console.warn('Expecting some error below because we are destroying app in the middle of processing.')
await Factory.sleep(0.1)
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
const recreatedContext = await Factory.createAppContextWithFakeCrypto(namespace, context.email, context.password)
const recreatedApp = recreatedContext.application
const promise = recreatedContext.resolveWhenKeyRecovered(itemsKey.uuid)
await recreatedContext.launch()
await promise
await Factory.safeDeinit(recreatedApp)
})
it('when encountering an undecryptable 003 items key, should recover through recovery wizard', async function () {
const namespace = Factory.randomString()
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const application = context.application
const receiveChallenge = (challenge) => {
/** Give unassociated password when prompted */
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)])
}
await application.prepareForLaunch({ receiveChallenge })
await application.launch(true)
await Factory.registerOldUser({
application: application,
email: context.email,
password: context.password,
version: ProtocolVersion.V003,
})
/** Create items key associated with a random root key */
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
ProtocolVersion.V003,
)
const randomItemsKey = await application.protocolService.operatorManager
.operatorForVersion(ProtocolVersion.V003)
.createItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
/** Attempt decryption and insert into rotation in errored state */
const decrypted = await application.protocolService.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
/** Expect to be errored */
expect(decrypted.errorDecrypting).to.equal(true)
/** Insert into rotation */
await application.payloadManager.emitPayload(decrypted, PayloadEmitSource.LocalInserted)
/** Wait and allow recovery wizard to complete */
await Factory.sleep(0.3)
/** Should be decrypted now */
expect(application.items.findItem(encrypted.uuid).errorDecrypting).to.not.be.ok
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('when replacing root key, new root key should be set before items key are re-saved to disk', async function () {
const contextA = await Factory.createAppContextWithFakeCrypto()
await contextA.launch()
await contextA.register()
const newPassword = 'new-password'
/** Create simultaneous appB signed into same account */
const contextB = await Factory.createAppContextWithFakeCrypto(
'another-namespace',
contextA.email,
contextA.password,
)
contextB.ignoreChallenges()
await contextB.launch()
await contextB.signIn()
const appB = contextB.application
/** Change password on appB */
const result = await appB.changePassword(contextA.password, newPassword)
expect(result.error).to.not.be.ok
contextA.password = newPassword
await appB.sync.sync()
const newDefaultKey = appB.protocolService.getSureDefaultItemsKey()
const encrypted = await appB.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [newDefaultKey.payload],
},
})
/** Insert foreign items key into appA, which shouldn't be able to decrypt it yet */
const appA = contextA.application
await appA.payloadManager.emitPayload(
encrypted.copy({
errorDecrypting: true,
}),
PayloadEmitSource.LocalInserted,
)
await Factory.awaitFunctionInvokation(appA.keyRecoveryService, 'handleDecryptionOfAllKeysMatchingCorrectRootKey')
/** Stored version of items key should use new root key */
const stored = (await appA.deviceInterface.getAllDatabaseEntries(appA.identifier)).find(
(payload) => payload.uuid === newDefaultKey.uuid,
)
const storedParams = await appA.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(stored))
const correctStored = (await appB.deviceInterface.getAllDatabaseEntries(appB.identifier)).find(
(payload) => payload.uuid === newDefaultKey.uuid,
)
const correctParams = await appB.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(correctStored))
expect(storedParams).to.eql(correctParams)
await contextA.deinit()
await contextB.deinit()
}).timeout(80000)
})