diff --git a/backend/routes.js b/backend/routes.js index c2cd2de0c..c04bad6cd 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -128,7 +128,7 @@ route.POST('/event', { updateSalts = await redeemSaltUpdateToken(name, saltUpdateToken) } await sbp('backend/server/handleEntry', deserializedHEAD, request.payload) - await updateSalts?.() + await updateSalts?.(deserializedHEAD.hash) if (deserializedHEAD.isFirstMessage) { // Store attribution information if (credentials?.billableContractID) { diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index a0b40859d..43da7718c 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -100,17 +100,23 @@ const getZkppSaltRecord = async (contractID: string) => { try { const recordObj = JSON.parse(recordString) - if (!Array.isArray(recordObj) || recordObj.length !== 3 || !recordObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) { + if ( + !Array.isArray(recordObj) || + (recordObj.length !== 3 && recordObj.length !== 4) || + recordObj.slice(0, 3).some((r) => !r || typeof r !== 'string') || + (recordObj[3] !== null && typeof recordObj[3] !== 'string') + ) { console.error('Error validating encrypted JSON object ' + recordId) return null } - const [hashedPassword, authSalt, contractSalt] = recordObj + const [hashedPassword, authSalt, contractSalt, cid] = recordObj return { hashedPassword, authSalt, - contractSalt + contractSalt, + cid } } catch { console.error('Error parsing encrypted JSON object ' + recordId) @@ -120,11 +126,11 @@ const getZkppSaltRecord = async (contractID: string) => { return null } -const setZkppSaltRecord = async (contractID: string, hashedPassword: string, authSalt: string, contractSalt: string) => { +const setZkppSaltRecord = async (contractID: string, hashedPassword: string, authSalt: string, contractSalt: string, cid: ?string) => { const recordId = `_private_rid_${contractID}` const encryptionKey = hashStringArray('REK', contractID, recordSecret).slice(0, nacl.secretbox.keyLength) const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) - const recordPlaintext = JSON.stringify([hashedPassword, authSalt, contractSalt]) + const recordPlaintext = JSON.stringify([hashedPassword, authSalt, contractSalt, cid]) const recordCiphertext = nacl.secretbox(Buffer.from(recordPlaintext), nonce, encryptionKey) const recordBuf = Buffer.concat([nonce, recordCiphertext]) const record = base64ToBase64url(recordBuf.toString('base64')) @@ -257,7 +263,7 @@ export const getContractSalt = async (contract: string, r: string, s: string, si return false } - const { hashedPassword, contractSalt } = record + const { hashedPassword, contractSalt, cid } = record const c = contractSaltVerifyC(hashedPassword, r, s, hc) @@ -266,7 +272,7 @@ export const getContractSalt = async (contract: string, r: string, s: string, si throw new Error('getContractSalt: Bad challenge') } - return encryptContractSalt(c, contractSalt) + return encryptContractSalt(c, JSON.stringify([contractSalt, cid])) } export const updateContractSalt = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise => { @@ -328,7 +334,7 @@ export const updateContractSalt = async (contract: string, r: string, s: string, return false } -export const redeemSaltUpdateToken = async (contract: string, token: string): Promise<() => Promise> => { +export const redeemSaltUpdateToken = async (contract: string, token: string): Promise<(cid: ?string) => Promise> => { const recordId = await computeZkppSaltRecordId(contract) if (!recordId) { throw new Error('Record ID not found') @@ -346,7 +352,7 @@ export const redeemSaltUpdateToken = async (contract: string, token: string): Pr throw new Error('ZKPP token expired') } - return () => { - return setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt) + return (cid: ?string) => { + return setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt, cid) } } diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index bc05af36f..14091dce9 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -14,12 +14,13 @@ import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' import { KV_QUEUE, LOGIN, LOGOUT } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' -import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' +import { encryptedIncomingDataWithRawKey, encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import type { Key } from '../../../shared/domains/chelonia/crypto.js' import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import { encryptedAction, groupContractsByType, syncContractsInOrder } from './utils.js' +import { handleFetchResult } from '../utils/misc.js' export default (sbp('sbp/selectors/register', { 'gi.actions/identity/create': async function ({ @@ -232,7 +233,7 @@ export default (sbp('sbp/selectors/register', { } return userID }, - 'gi.actions/identity/login': function ({ identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys }) { + 'gi.actions/identity/login': function ({ identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys, oldKeysAnchorCid }) { // This wrapper ensures that there is at most one login flow action executed // at any given time. Because of the async work done when logging in and out, // it could happen that, e.g., `gi.actions/identity/login` is called before @@ -248,6 +249,19 @@ export default (sbp('sbp/selectors/register', { await sbp('chelonia/reset', { ...cheloniaState, loggedIn: { identityContractID } }) await sbp('chelonia/storeSecretKeys', new Secret(transientSecretKeys)) + if (oldKeysAnchorCid) { + const r = await fetch(`/file/${oldKeysAnchorCid}`).then(handleFetchResult('json')) + const keys = JSON.parse(r._signedData[0]) + const iek = keys.find((k) => { + return k.name === 'iek' + }) + if (iek.meta?.private?.oldKeys) { + const xxx = encryptedIncomingDataWithRawKey(transientSecretKeys[0].key, JSON.parse(iek.meta.private.oldKeys), 'OLD_KEYS') + const yyy = JSON.parse(xxx.valueOf()).map(k => ({ key: deserializeKey(k.valueOf()), transient: true })) + await sbp('chelonia/storeSecretKeys', new Secret(yyy)) + } + } + try { if (!state) { // Make sure we don't unsubscribe from our own identity contract @@ -688,13 +702,12 @@ export default (sbp('sbp/selectors/register', { oldIEK, newIPK: IPK, newIEK: IEK, - newSAK: SAK, updateToken }) => { // Create the necessary keys to initialise the contract const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) - const PEK = keygen(CURVE25519XSALSA20POLY1305) + const SAK = keygen(EDWARDS25519SHA512BATCH) // Key IDs const oldIPKid = keyId(oldIPK) @@ -703,7 +716,6 @@ export default (sbp('sbp/selectors/register', { const IEKid = keyId(IEK) const CSKid = keyId(CSK) const CEKid = keyId(CEK) - const PEKid = keyId(PEK) const SAKid = keyId(SAK) // Public keys to be stored in the contract @@ -711,22 +723,27 @@ export default (sbp('sbp/selectors/register', { const IEKp = serializeKey(IEK, false) const CSKp = serializeKey(CSK, false) const CEKp = serializeKey(CEK, false) - const PEKp = serializeKey(PEK, false) const SAKp = serializeKey(SAK, false) // Secret keys to be stored encrypted in the contract const CSKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(CSK, true)) const CEKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(CEK, true)) - const PEKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(PEK, true)) const SAKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(SAK, true)) const state = sbp('chelonia/contract/state', identityContractID) // Before rotating keys the contract, put all keys into transient store await sbp('chelonia/storeSecretKeys', - new Secret([oldIPK, oldIEK, IPK, IEK, CEK, CSK, PEK, SAK].map(key => ({ key, transient: true }))) + new Secret([oldIPK, oldIEK, IPK, IEK, CEK, CSK, SAK].map(key => ({ key, transient: true }))) ) + const oldKeys = [] + ;[oldIEK].forEach((key) => { + const serialized = serializeKey(key, true) + oldKeys.push(serialized) + }) + const oldKeysData = encryptedOutgoingDataWithRawKey(IEK, JSON.stringify(oldKeys)).toString('OLD_KEYS') + await sbp('chelonia/out/keyUpdate', { contractID: identityContractID, contractName: 'gi.contracts/identity', @@ -748,7 +765,8 @@ export default (sbp('sbp/selectors/register', { oldKeyId: oldIEKid, meta: { private: { - transient: true + transient: true, + oldKeys: oldKeysData } }, data: IEKp @@ -775,17 +793,6 @@ export default (sbp('sbp/selectors/register', { }, data: CEKp }, - { - id: PEKid, - name: 'pek', - oldKeyId: findKeyIdByName(state, 'pek'), - meta: { - private: { - content: PEKs - } - }, - data: PEKp - }, { id: SAKid, name: '#sak', @@ -810,9 +817,12 @@ export default (sbp('sbp/selectors/register', { } */ }) + /* const x = await sbp('gi.actions/identity/kv/setOldKeys', [oldIEK]) + console.error('@@@@x', x) */ + // After the contract has been updated, store persistent keys await sbp('chelonia/storeSecretKeys', - new Secret([CEK, CSK, PEK, SAK].map(key => ({ key }))) + new Secret([CEK, CSK, SAK].map(key => ({ key }))) ) // And remove transient keys, which require a user password sbp('chelonia/clearTransientSecretKeys', [oldIEKid, oldIPKid, IEKid, IPKid]) diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index f4ffe11ca..493e3e794 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -156,7 +156,7 @@ sbp('okTurtles.events/on', LOGOUT, (a) => { */ export default (sbp('sbp/selectors/register', { - 'gi.app/identity/retrieveSalt': async (username: string, password: Secret) => { + 'gi.app/identity/retrieveSalt': async (username: string, password: Secret): Promise<[string, ?string]> => { const r = randomNonce() const b = hash(r) const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) @@ -175,7 +175,8 @@ export default (sbp('sbp/selectors/register', { 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') })).toString()}`).then(handleFetchResult('text')) - return decryptContractSalt(c, contractHash) + // [contractSalt, cid] + return JSON.parse(decryptContractSalt(c, contractHash)) }, 'gi.app/identity/updateSaltRequest': async (username: string, oldPassword: Secret, newPassword: Secret) => { const r = randomNonce() @@ -307,14 +308,16 @@ export default (sbp('sbp/selectors/register', { const password = wpassword?.valueOf() const transientSecretKeys = [] + let oldKeysAnchorCid // If we're creating a new session, here we derive the IEK. This key (not // the password) will be passed to the service worker. if (password) { try { - const salt = await sbp('gi.app/identity/retrieveSalt', username, wpassword) + const [salt, cid] = await sbp('gi.app/identity/retrieveSalt', username, wpassword) const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) transientSecretKeys.push(IEK) + oldKeysAnchorCid = cid } catch (e) { console.error('caught error calling retrieveSalt:', e) throw new GIErrorUIRuntimeError(L('Incorrect username or password')) @@ -364,7 +367,7 @@ export default (sbp('sbp/selectors/register', { // and `state` will be sent back to replace the current Vuex state // after login. When using a service worker, all tabs will receive // a new Vuex state to replace their state with. - await sbp('gi.actions/identity/login', { identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys: transientSecretKeys.map(k => new Secret(serializeKey(k, true))) }) + await sbp('gi.actions/identity/login', { identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys: transientSecretKeys.map(k => new Secret(serializeKey(k, true))), oldKeysAnchorCid }) } else { // If an existing session exists, we just emit the LOGIN event // to set the local Vuex state and signal we're ready. @@ -459,7 +462,6 @@ export default (sbp('sbp/selectors/register', { const oldIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, oldPassword, oldContractSalt) const newIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt) const newIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, newPassword, newContractSalt) - const newSAK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt) return sbp('gi.actions/identity/changePassword', { identityContractID, @@ -468,7 +470,6 @@ export default (sbp('sbp/selectors/register', { oldIEK, newIPK, newIEK, - newSAK, updateToken }) } diff --git a/frontend/utils/constants.js b/frontend/utils/constants.js index 3017444ab..54e3480c4 100644 --- a/frontend/utils/constants.js +++ b/frontend/utils/constants.js @@ -34,7 +34,8 @@ export const KV_KEYS = { UNREAD_MESSAGES: 'unreadMessages', LAST_LOGGED_IN: 'lastLoggedIn', PREFERENCES: 'preferences', - NOTIFICATIONS: 'notifications' + NOTIFICATIONS: 'notifications', + OLD_KEYS: 'old-keys' } export const MAX_LOG_ENTRIES = 2000 diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index f980b2f50..676c00b08 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -1524,10 +1524,10 @@ export default (sbp('sbp/selectors/register', { break } }, - 'chelonia/kv/get': async function (contractID: string, key: string) { + 'chelonia/kv/get': async function (contractID: string, key: string, state: ?Object) { const response = await fetch(`${this.config.connectionURL}/kv/${encodeURIComponent(contractID)}/${encodeURIComponent(key)}`, { headers: new Headers([[ - 'authorization', buildShelterAuthorizationHeader.call(this, contractID) + 'authorization', buildShelterAuthorizationHeader.call(this, contractID, state) ]]), signal: this.abortController.signal }) diff --git a/shared/domains/chelonia/encryptedData.js b/shared/domains/chelonia/encryptedData.js index cdd27cf6a..f4e1db719 100644 --- a/shared/domains/chelonia/encryptedData.js +++ b/shared/domains/chelonia/encryptedData.js @@ -277,6 +277,51 @@ export const encryptedIncomingForeignData = (contractID: string, _0: any, dat }) } +export const encryptedIncomingDataWithRawKey = (key: Key, data: T, additionalData?: string): EncryptedData => { + if (data === undefined || !key) throw new TypeError('Invalid invocation') + + let decryptedValue + const eKeyId = keyId(key) + const decryptedValueFn = (): any => { + if (decryptedValue) { + return decryptedValue + } + const state = { + _vm: { + authorizedKeys: { + [eKeyId]: { + purpose: ['enc'], + data: serializeKey(key, false), + _notBeforeHeight: 0, + _notAfterHeight: undefined + } + } + } + } + decryptedValue = decryptData.call(state, NaN, data, { [eKeyId]: key }, additionalData || '') + + return decryptedValue + } + + return wrapper({ + get encryptionKeyId () { + return encryptedDataKeyId(data) + }, + get serialize () { + return () => data + }, + get toString () { + return () => JSON.stringify(this.serialize()) + }, + get valueOf () { + return decryptedValueFn + }, + get toJSON () { + return this.serialize + } + }) +} + export const encryptedDataKeyId = (data: any): string => { if (!isRawEncryptedData(data)) { throw new ChelErrorDecryptionError('Invalid message format')