Files
standardnotes-app-web/packages/snjs/mocha/key_recovery_service.test.js
2023-08-06 15:23:31 -05:00

524 lines
18 KiB
JavaScript

import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('key recovery service', function () {
this.timeout(Factory.TwentySecondTimeout)
beforeEach(function () {
localStorage.clear()
})
afterEach(function () {
localStorage.clear()
sinon.restore()
})
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.encryption.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await context.operators.defaultOperator().createItemsKey()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
const errored = await application.encryption.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
expect(errored.errorDecrypting).to.equal(true)
await application.payloads.emitPayload(errored, PayloadEmitSource.LocalInserted)
await context.resolveWhenKeyRecovered(errored.uuid)
expect(application.items.findItem(errored.uuid).errorDecrypting).to.not.be.ok
expect(application.sync.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.encryption.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await context.operators.defaultOperator().createItemsKey()
await application.payloads.emitPayload(
randomItemsKey.payload.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() }),
PayloadEmitSource.LocalInserted,
)
await context.sync()
const originalSyncTime = application.payloads.findOne(randomItemsKey.uuid).lastSyncEnd.getTime()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
const errored = await application.encryption.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
await application.payloads.emitPayload(errored, PayloadEmitSource.LocalInserted)
const recoveryPromise = context.resolveWhenKeyRecovered(errored.uuid)
await context.sync()
await recoveryPromise
expect(application.payloads.findOne(errored.uuid).lastSyncEnd.getTime()).to.be.above(originalSyncTime)
await context.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.encryption.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await context.operators.defaultOperator().createItemsKey()
const randomItemsKey2 = await context.operators.defaultOperator().createItemsKey()
const encrypted = await application.encryption.encryptSplit({
usesRootKey: {
items: [randomItemsKey.payload, randomItemsKey2.payload],
key: randomRootKey,
},
})
/** Attempt decryption and insert into rotation in errored state */
const decrypted = await application.encryption.decryptSplit({
usesRootKeyWithKeyLookup: {
items: encrypted,
},
})
await application.payloads.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.sync.isOutOfSync()).to.equal(false)
await context.deinit()
})
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.encryption.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.encryption.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const signInFunction = sinon.spy(context.keyRecovery, 'performServerSignIn')
await application.encryption.setRootKey(randomRootKey)
const correctItemsKey = await context.operators.defaultOperator().createItemsKey()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKey: {
items: [correctItemsKey.payload],
key: randomRootKey,
},
})
const resolvePromise = Promise.all([
context.awaitSignInEvent(),
context.resolveWhenKeyRecovered(correctItemsKey.uuid),
])
await application.payloads.emitPayload(
encrypted.copy({
errorDecrypting: true,
dirty: true,
}),
PayloadEmitSource.LocalInserted,
)
await context.sync()
await resolvePromise
expect(signInFunction.callCount).to.equal(1)
const clientRootKey = await application.encryption.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.sync.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.encryption.getSureDefaultItemsKey()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
const newUpdated = new Date()
const errored = encrypted.copy({
content: '004:...',
errorDecrypting: true,
updated_at: newUpdated,
})
context.disableKeyRecovery()
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.sync.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.encryption.getSureDefaultItemsKey()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
const newUpdated = new Date()
const errored = encrypted.copy({
errorDecrypting: true,
updated_at: newUpdated,
})
await application.payloads.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.sync.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.encryption.getSureDefaultItemsKey()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
context.disableKeyRecovery()
await application.payloads.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.sync.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.encryption.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
ProtocolVersion.V003,
)
const randomItemsKey = await context.operators.operatorForVersion(ProtocolVersion.V003).createItemsKey()
const encrypted = await application.encryption.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
/** Attempt decryption and insert into rotation in errored state */
const decrypted = await application.encryption.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
/** Expect to be errored */
expect(decrypted.errorDecrypting).to.equal(true)
/** Insert into rotation */
await application.payloads.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.sync.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.encryption.getSureDefaultItemsKey()
const encrypted = await appB.encryption.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.payloads.emitPayload(
encrypted.copy({
errorDecrypting: true,
}),
PayloadEmitSource.LocalInserted,
)
await Factory.awaitFunctionInvokation(contextA.keyRecovery, 'handleDecryptionOfAllKeysMatchingCorrectRootKey')
/** Stored version of items key should use new root key */
const stored = (await appA.device.getAllDatabaseEntries(appA.identifier)).find(
(payload) => payload.uuid === newDefaultKey.uuid,
)
const storedParams = await appA.encryption.getKeyEmbeddedKeyParamsFromItemsKey(new EncryptedPayload(stored))
const correctStored = (await appB.device.getAllDatabaseEntries(appB.identifier)).find(
(payload) => payload.uuid === newDefaultKey.uuid,
)
const correctParams = await appB.encryption.getKeyEmbeddedKeyParamsFromItemsKey(new EncryptedPayload(correctStored))
expect(storedParams).to.eql(correctParams)
await contextA.deinit()
await contextB.deinit()
}).timeout(80000)
})