diff --git a/backend/routes.js b/backend/routes.js index 97136c1d8..b61a06c8a 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -9,7 +9,7 @@ import { SERVER_INSTANCE } from './instance-keys.js' import path from 'path' import chalk from 'chalk' import './database.js' -import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt } from './zkppSalt.js' +import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt, redeemSaltUpdateToken } from './zkppSalt.js' import Bottleneck from 'bottleneck' const MEGABYTE = 1048576 // TODO: add settings for these @@ -116,7 +116,24 @@ route.POST('/event', { } } } + const saltUpdateToken = request.headers['shelter-salt-update-token'] + let updateSalts + if (saltUpdateToken) { + // If we've got a salt update token (i.e., a password change), fetch + // the username associated to the contract to see if they match, and + // then validate the token + const name = request.headers['shelter-name'] + const namedContractID = name && await sbp('backend/db/lookupName', name) + if (namedContractID !== deserializedHEAD.contractID) { + throw new Error('Mismatched contract ID and name') + } + updateSalts = await redeemSaltUpdateToken(name, saltUpdateToken) + } await sbp('backend/server/handleEntry', deserializedHEAD, request.payload) + // If it's a salt update, do it now after handling the message. This way + // we make it less likely that someone will end up locked out from their + // identity contract. + await updateSalts?.(deserializedHEAD.hash) if (deserializedHEAD.isFirstMessage) { // Store attribution information if (credentials?.billableContractID) { @@ -821,7 +838,7 @@ route.GET('/zkpp/{name}/contract_hash', { return Boom.internal('internal error') }) -route.POST('/zkpp/updatePasswordHash/{name}', { +route.POST('/zkpp/{name}/updatePasswordHash', { validate: { payload: Joi.object({ r: Joi.string().required(), @@ -841,7 +858,7 @@ route.POST('/zkpp/updatePasswordHash/{name}', { } } catch (e) { e.ip = req.headers['x-real-ip'] || req.info.remoteAddress - console.error(e, 'Error at POST /zkpp/updatePasswordHash/{name}: ' + e.message) + console.error(e, 'Error at POST /zkpp/{name}/updatePasswordHash: ' + e.message) } return Boom.internal('internal error') diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index 63794a907..96f49770d 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -1,7 +1,8 @@ import sbp from '@sbp/sbp' import { randomBytes, timingSafeEqual } from 'crypto' import nacl from 'tweetnacl' -import { base64ToBase64url, base64urlToBase64, boxKeyPair, computeCAndHc, encryptContractSalt, hash, hashRawStringArray, hashStringArray, parseRegisterSalt, randomNonce } from '~/shared/zkpp.js' +import { base64ToBase64url, base64urlToBase64, boxKeyPair, computeCAndHc, decryptSaltUpdate, encryptContractSalt, encryptSaltUpdate, hash, hashRawStringArray, hashStringArray, parseRegisterSalt, randomNonce } from '~/shared/zkpp.js' +import { AUTHSALT, CONTRACTSALT, SALT_LENGTH_IN_OCTETS, SU } from '~/shared/zkppConstants.js' // used to encrypt salts in database let recordSecret: string @@ -9,6 +10,8 @@ let recordSecret: string let challengeSecret: string // corresponds to a component of s in Step 3 of "Salt registration" let registrationSecret: string +// used to encrypt a stateless token for atomic hash updates +let hashUpdateSecret: string // Input keying material used to derive various secret keys used in this // protocol: recordSecret, challengeSecret and registrationSecret. @@ -60,10 +63,23 @@ export const initZkpp = async () => { recordSecret = Buffer.from(hashStringArray('private/recordSecret', IKM)).toString('base64') challengeSecret = Buffer.from(hashStringArray('private/challengeSecret', IKM)).toString('base64') registrationSecret = Buffer.from(hashStringArray('private/registrationSecret', IKM)).toString('base64') + hashUpdateSecret = Buffer.from(hashStringArray('private/hashUpdateSecret', IKM)).toString('base64') } const maxAge = 30 +const computeZkppSaltRecordId = async (contractID: string) => { + const recordId = `_private_rid_${contractID}` + const record = await sbp('chelonia/db/get', recordId) + + if (!record) { + return null + } + + const recordBuf = Buffer.concat([Buffer.from(contractID), Buffer.from(record)]) + return hash(recordBuf) +} + const getZkppSaltRecord = async (contractID: string) => { const recordId = `_private_rid_${contractID}` const record = await sbp('chelonia/db/get', recordId) @@ -85,17 +101,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) @@ -105,11 +127,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')) @@ -242,7 +264,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) @@ -251,10 +273,10 @@ 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 => { +export const updateContractSalt = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise => { if (!verifyChallenge(contract, r, s, sig)) { console.warn('update: Error validating challenge: ' + JSON.stringify({ contract, r, s, sig })) throw new Error('update: Bad challenge') @@ -266,7 +288,7 @@ export const updateContractSalt = async (contract: string, r: string, s: string, console.error('update: Error obtaining ZKPP salt record for contract ID ' + contract) return false } - const { hashedPassword } = record + const { hashedPassword, contractSalt: oldContractSalt } = record const c = contractSaltVerifyC(hashedPassword, r, s, hc) @@ -275,7 +297,7 @@ export const updateContractSalt = async (contract: string, r: string, s: string, throw new Error('update: Bad challenge') } - const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength) + const encryptionKey = hashRawStringArray(SU, c).slice(0, nacl.secretbox.keyLength) const encryptedArgsBuf = Buffer.from(base64urlToBase64(encryptedArgs), 'base64') const nonce = encryptedArgsBuf.slice(0, nacl.secretbox.nonceLength) const encryptedArgsCiphertext = encryptedArgsBuf.slice(nacl.secretbox.nonceLength) @@ -288,21 +310,50 @@ export const updateContractSalt = async (contract: string, r: string, s: string, } try { - const argsObj = JSON.parse(Buffer.from(args).toString()) + const hashedPassword = Buffer.from(args).toString() - if (!Array.isArray(argsObj) || argsObj.length !== 3 || !argsObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) { - console.error(`update: Error validating the encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + const recordId = await computeZkppSaltRecordId(contract) + if (!recordId) { + console.error(`update: Error obtaining record ID for contract ID ${contract}`) return false } - const [hashedPassword, authSalt, contractSalt] = argsObj + const authSalt = Buffer.from(hashStringArray(AUTHSALT, c)).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64') + const contractSalt = Buffer.from(hashStringArray(CONTRACTSALT, c)).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64') - await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt) + const token = encryptSaltUpdate( + hashUpdateSecret, + recordId, + JSON.stringify([Date.now(), hashedPassword, authSalt, contractSalt]) + ) - return true + return encryptContractSalt(c, JSON.stringify([oldContractSalt, token])) } catch { console.error(`update: Error parsing encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) } return false } + +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') + } + + const decryptedToken = decryptSaltUpdate( + hashUpdateSecret, + recordId, + token + ) + + const [timestamp, hashedPassword, authSalt, contractSalt] = JSON.parse(decryptedToken) + + if (timestamp < (Date.now() - 180e3)) { + throw new Error('ZKPP token expired') + } + + return (cid: ?string) => { + return setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt, cid) + } +} diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index 7592b4d30..840820dc8 100644 --- a/backend/zkppSalt.test.js +++ b/backend/zkppSalt.test.js @@ -5,13 +5,14 @@ import should from 'should' import initDB from './database.js' import 'should-sinon' +import { AUTHSALT, CONTRACTSALT, CS, SALT_LENGTH_IN_OCTETS, SU } from '~/shared/zkppConstants.js' import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt } from './zkppSalt.js' const saltsAndEncryptedHashedPassword = (p: string, secretKey: Uint8Array, hash: string) => { const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) const dhKey = nacl.hash(nacl.box.before(Buffer.from(p, 'base64url'), secretKey)) - const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('AUTHSALT')), dhKey]))).slice(0, 18).toString('base64') - const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('CONTRACTSALT')), dhKey]))).slice(0, 18).toString('base64') + const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(AUTHSALT)), dhKey]))).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64') + const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(CONTRACTSALT)), dhKey]))).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64') const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength) const encryptedHashedPassword = Buffer.concat([nonce, nacl.secretbox(Buffer.from(hash), nonce, encryptionKey)]).toString('base64url') @@ -75,8 +76,10 @@ describe('ZKPP Salt functions', () => { const saltBuf = Buffer.from(salt, 'base64url') const nonce = saltBuf.slice(0, nacl.secretbox.nonceLength) - const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('CS'), c])).slice(0, nacl.secretbox.keyLength) - const retrievedContractSalt = Buffer.from(nacl.secretbox.open(saltBuf.slice(nacl.secretbox.nonceLength), nonce, encryptionKey)).toString() + const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(CS), c])).slice(0, nacl.secretbox.keyLength) + const [retrievedContractSalt] = JSON.parse( + Buffer.from(nacl.secretbox.open(saltBuf.slice(nacl.secretbox.nonceLength), nonce, encryptionKey)).toString() + ) should(retrievedContractSalt).equal(contractSalt, 'mismatched contractSalt') }) @@ -103,7 +106,7 @@ describe('ZKPP Salt functions', () => { const c = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(hash)), nacl.hash(ħ)])) const hc = nacl.hash(c) - const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('SU'), c])).slice(0, nacl.secretbox.keyLength) + const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(SU), c])).slice(0, nacl.secretbox.keyLength) const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) const encryptedArgsCiphertext = nacl.secretbox(Buffer.from(JSON.stringify(['a', 'b', 'c'])), nonce, encryptionKey) @@ -111,6 +114,6 @@ describe('ZKPP Salt functions', () => { const encryptedArgs = Buffer.concat([nonce, encryptedArgsCiphertext]).toString('base64url') const updateRes = await updateContractSalt(contract, r, challenge.s, challenge.sig, Buffer.from(hc).toString('base64url'), encryptedArgs) - should(updateRes).equal(true, 'updateContractSalt should be successful') + should(!!updateRes).equal(true, 'updateContractSalt should be successful') }) }) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 723a4b256..92dc8ef23 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -14,17 +14,168 @@ import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' import { JOINED_CHATROOM, 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 { rawSignedIncomingData } from '~/shared/domains/chelonia/signedData.js' import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.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 { handleFetchResult } from '../utils/misc.js' import { encryptedAction, groupContractsByType, syncContractsInOrder } from './utils.js' +/** + * Decrypts the old IEK list using the provided contract ID and IEK. + * + * @param contractID - The ID of the contract. + * @param IEK - The encryption key object. + * @param encryptedData - The encrypted data string, or null if not available. + * @returns The decrypted old IEK list, or an empty array if decryption fails. + */ +const decryptOldIekList = (contractID: string, IEK: Key, encryptedData: ?string) => { + // Return an empty array if no encrypted data is provided + if (!encryptedData) return [] + + try { + // Parse the encrypted data from JSON format + const parsedData = JSON.parse(encryptedData) + + // Decrypt the incoming data using the IEK and contract ID + const decryptedData = encryptedIncomingDataWithRawKey(IEK, parsedData, `meta.private.oldKeys;${contractID}`) + + // Parse the decrypted data back into a JavaScript object + const oldKeysList = JSON.parse(decryptedData.valueOf()) + + return oldKeysList // Return the decrypted old keys + } catch (error) { + // Log any errors that occur during decryption + console.error('[decryptOldIekList] Error during decryption', error) + } + + // Don't return in case of error +} + +/** + * Decrypts the old IEK list using the provided contract ID and IEK. + * + * @param identityContractID - The identity contract ID + * @param oldKeysAnchorCid - The CID corresponding to the password change message + * @param IEK - The encryption key object. + * @returns The decrypted old IEK list, or an empty array if decryption fails. + */ +const processOldIekList = async (identityContractID: string, oldKeysAnchorCid: string, IEK: Key) => { + try { + // Fetch the old keys from the server + const result = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/file/${oldKeysAnchorCid}`).then(handleFetchResult('json')) + + // Parse the signed data in the OP_KEY_UPDATE payload to extract keys + const oldKeys = (() => { + const data = rawSignedIncomingData(result) + const head = JSON.parse(data.get('head')) + if (head.contractID !== identityContractID) { + throw new Error('Unexpected contract ID.') + } + if (![GIMessage.OP_ATOMIC, GIMessage.OP_KEY_UPDATE].includes(head.op)) { + throw new Error('Unsupported opcode: ' + head.op) + } + + // Normalize the payload as if it were `OP_ATOMIC` + const payload = (head.op === GIMessage.OP_KEY_UPDATE) + ? [[GIMessage.OP_KEY_UPDATE, data.valueOf()]] + : data.valueOf() + + // Find the key with the name 'iek', and the `meta.private.oldKeys` field + // within it + return payload + .filter(([op]) => op === GIMessage.OP_KEY_UPDATE) + .flatMap(([, keys]) => keys) + .find((key) => key.name === 'iek' && key.meta?.private?.oldKeys) + ?.meta.private.oldKeys + })() + + // Check if old keys are present in the metadata + if (!oldKeys) { + console.error('[processOldIekList] Error finding old IEKs, logging in will probably fail due to missing keys') + } + + // Decrypt the old IEK list + const decryptedKeys = decryptOldIekList(identityContractID, IEK, oldKeys) + + // Check if decryption was successful + if (!decryptedKeys) { + console.error('[processOldIekList] Error decrypting old IEKs, logging in will probably fail due to missing keys') + } else { + // Map the decrypted keys to the required format + const secretKeys = decryptedKeys.map(key => ({ key: deserializeKey(key), transient: true })) + + // Store the secret keys using the sbp function + await sbp('chelonia/storeSecretKeys', new Secret(secretKeys)) + } + } catch (error) { + console.error('[processOldIekList] Error fetching or processing old keys:', error) + } +} + +/** + * Appends a new IEK to the existing list of old IEKs. + * + * @param contractID - The ID of the contract. + * @param IEK - The encryption key object. + * @param oldIEK - The old IEK to be appended. + * @param encryptedData - The encrypted data string, or null if not available. + * @returns The updated encrypted data containing the new IEK. + * @throws {Error} - Throws an error if decryption of old IEK list fails. + */ +const appendToIekList = (contractID: string, IEK: Object, oldIEK: Object, encryptedData: ?string) => { + // Decrypt the old IEK list + const oldKeys = decryptOldIekList(contractID, oldIEK, encryptedData) + + // If decryption fails, throw an error to prevent data loss + if (!oldKeys) { + throw new Error('Error decrypting old IEK list') + } + + // Create a Set to store unique keys + const keysSet = new Set(oldKeys) + + // Serialize the old IEK and add it to the Set + const serializedOldIEK = serializeKey(oldIEK, true) + keysSet.add(serializedOldIEK) + + // Encrypt the updated list of keys and return the new encrypted data + const updatedKeysData = encryptedOutgoingDataWithRawKey( + IEK, + // Convert Set back to Array for serialization + JSON.stringify(Array.from(keysSet)) + ).toString(`meta.private.oldKeys;${contractID}`) + + return updatedKeysData // Return the updated encrypted data +} + +// Event handler to detect password updates +sbp('okTurtles.events/on', EVENT_HANDLED, (contractID, message) => { + const identityContractID = sbp('state/vuex/state').loggedIn?.identityContractID + // If the message isn't for our identity contract or it's not `OP_KEY_UPDATE` + // (possibly within `OP_ATOMIC`), we return early + if (contractID !== identityContractID || ![GIMessage.OP_ATOMIC, GIMessage.OP_KEY_UPDATE].includes(message.opType())) return + + // If this could have changed our CSK, let's try to get the key ID and see if + // we have the corresponding secret key + const hasNewCsk = sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk', true) + // If we do, we can still use the contract as normal + if (hasNewCsk) return + + // Otherwise, force a logout + console.warn('Likely password change for identity contract. Logging us out.', identityContractID) + sbp('gi.actions/identity/logout').catch(e => { + console.error('Error while automatically logging out', e) + }) +}) + export default (sbp('sbp/selectors/register', { 'gi.actions/identity/create': async function ({ - IPK, - IEK, + IPK: wIPK, + IEK: wIEK, publishOptions, username, email, @@ -36,8 +187,12 @@ export default (sbp('sbp/selectors/register', { }) { let finalPicture = `${self.location.origin}/assets/images/user-avatar-default.png` - IPK = typeof IPK === 'string' ? deserializeKey(IPK) : IPK - IEK = typeof IEK === 'string' ? deserializeKey(IEK) : IEK + // Unwrap secrets + wIPK = wIPK.valueOf() + wIEK = wIEK.valueOf() + + const IPK = typeof wIPK === 'string' ? deserializeKey(wIPK) : wIPK + const IEK = typeof wIEK === 'string' ? deserializeKey(wIEK) : wIEK // Create the necessary keys to initialise the contract const CSK = keygen(EDWARDS25519SHA512BATCH) @@ -217,9 +372,9 @@ export default (sbp('sbp/selectors/register', { namespaceRegistration: username }) - // After the contract has been created, store pesistent keys + // After the contract has been created, store persistent keys await sbp('chelonia/storeSecretKeys', - new Secret([CEK, CSK, PEK].map(key => ({ key }))) + new Secret([CEK, CSK, PEK, SAK].map(key => ({ key }))) ) // And remove transient keys, which require a user password sbp('chelonia/clearTransientSecretKeys', [IEKid, IPKid]) @@ -232,7 +387,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 @@ -243,7 +398,7 @@ export default (sbp('sbp/selectors/register', { // a queue. return sbp('okTurtles.eventQueue/queueEvent', 'ACTIONS-LOGIN', async () => { console.debug('[gi.actions/identity/login] Scheduled call starting', identityContractID) - transientSecretKeys = transientSecretKeys.map(k => ({ key: deserializeKey(k.valueOf()), transient: true })) + transientSecretKeys = transientSecretKeys.valueOf().map(k => ({ key: deserializeKey(k), transient: true })) // If running in a SW, start log capture here if (typeof WorkerGlobalScope === 'function') { @@ -252,6 +407,10 @@ export default (sbp('sbp/selectors/register', { await sbp('chelonia/reset', { ...cheloniaState, loggedIn: { identityContractID } }) await sbp('chelonia/storeSecretKeys', new Secret(transientSecretKeys)) + if (oldKeysAnchorCid) { + await processOldIekList(identityContractID, oldKeysAnchorCid, transientSecretKeys[0].key) + } + try { if (!state) { // Make sure we don't unsubscribe from our own identity contract @@ -678,6 +837,138 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/logout': (...params) => { return sbp('okTurtles.eventQueue/queueEvent', 'ACTIONS-LOGIN', ['gi.actions/identity/_private/logout', ...params]) }, + 'gi.actions/identity/changePassword': async ({ + identityContractID, + username, + oldIPK, + oldIEK, + newIPK: IPK, + newIEK: IEK, + updateToken, + hooks + }) => { + oldIPK = oldIPK.valueOf() + oldIEK = oldIEK.valueOf() + IPK = IPK.valueOf() + IEK = IEK.valueOf() + updateToken = updateToken.valueOf() + + // Create the necessary keys to initialise the contract + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CEK = keygen(CURVE25519XSALSA20POLY1305) + const SAK = keygen(EDWARDS25519SHA512BATCH) + + // Key IDs + const oldIPKid = keyId(oldIPK) + const oldIEKid = keyId(oldIEK) + const IPKid = keyId(IPK) + const IEKid = keyId(IEK) + const CSKid = keyId(CSK) + const CEKid = keyId(CEK) + const SAKid = keyId(SAK) + + // Public keys to be stored in the contract + const IPKp = serializeKey(IPK, false) + const IEKp = serializeKey(IEK, false) + const CSKp = serializeKey(CSK, false) + const CEKp = serializeKey(CEK, 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 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, SAK].map(key => ({ key, transient: true }))) + ) + + const oldKeysData = appendToIekList( + identityContractID, IEK, oldIEK, + state._vm.authorizedKeys[oldIEKid]?.meta?.private?.oldKeys + ) + + await sbp('chelonia/out/keyUpdate', { + contractID: identityContractID, + contractName: 'gi.contracts/identity', + data: [ + { + id: IPKid, + name: 'ipk', + oldKeyId: oldIPKid, + meta: { + private: { + transient: true + } + }, + data: IPKp + }, + { + id: IEKid, + name: 'iek', + oldKeyId: oldIEKid, + meta: { + private: { + transient: true, + oldKeys: oldKeysData + } + }, + data: IEKp + }, + { + id: CSKid, + name: 'csk', + oldKeyId: findKeyIdByName(state, 'csk'), + meta: { + private: { + content: CSKs + } + }, + data: CSKp + }, + { + id: CEKid, + name: 'cek', + oldKeyId: findKeyIdByName(state, 'cek'), + meta: { + private: { + content: CEKs + } + }, + data: CEKp + }, + { + id: SAKid, + name: '#sak', + oldKeyId: findKeyIdByName(state, '#sak'), + meta: { + private: { + content: SAKs + } + }, + data: SAKp + } + ], + signingKeyId: oldIPKid, + publishOptions: { + headers: { + 'shelter-name': username, + 'shelter-salt-update-token': updateToken + } + }, + hooks + }) + + // After the contract has been updated, store persistent keys + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK, SAK].map(key => ({ key }))) + ) + // And remove transient keys, which require a user password + sbp('chelonia/clearTransientSecretKeys', [oldIEKid, oldIPKid, IEKid, IPKid]) + }, ...encryptedAction('gi.actions/identity/saveFileDeleteToken', L('Failed to save delete tokens for the attachments.')), ...encryptedAction('gi.actions/identity/removeFileDeleteToken', L('Failed to remove delete tokens for the attachments.')), ...encryptedAction('gi.actions/identity/setGroupAttributes', L('Failed to set group attributes.')) diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index d416aa025..dc0bc521d 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -7,7 +7,7 @@ import Vue from 'vue' import { LOGIN, LOGIN_COMPLETE, LOGIN_ERROR, NEW_PREFERENCES, NEW_UNREAD_MESSAGES } from '~/frontend/utils/events.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' import { EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' -import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' +import { boxKeyPair, buildRegisterSaltRequest, buildUpdateSaltRequestEc, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import { handleFetchResult } from '../utils/misc.js' @@ -168,7 +168,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)}`) @@ -187,7 +187,41 @@ 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() + const b = hash(r) + const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) + .then(handleFetchResult('json')) + + const { authSalt, s, sig } = authHash + + const h = await hashPassword(oldPassword.valueOf(), authSalt) + + const [c, hc] = computeCAndHc(r, s, h) + + const [contractSalt, encryptedArgs] = await buildUpdateSaltRequestEc(newPassword.valueOf(), c) + + const response = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/updatePasswordHash`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + body: + `${(new URLSearchParams({ + 'r': r, + 's': s, + 'sig': sig, + 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, ''), + 'Ea': encryptedArgs + })).toString()}` + }).then(handleFetchResult('text')) + + const [oldContractSalt, updateToken] = JSON.parse(decryptContractSalt(c, response)) + + return [contractSalt, oldContractSalt, updateToken] }, 'gi.app/identity/create': async function ({ data: { username, email, password, picture }, @@ -220,9 +254,8 @@ export default (sbp('sbp/selectors/register', { // next create the identity contract itself try { const userID = await sbp('gi.actions/identity/create', { - // TODO: Wrap IPK and IEK in "Secret" - IPK: serializeKey(IPK, true), - IEK: serializeKey(IEK, true), + IPK: new Secret(serializeKey(IPK, true)), + IEK: new Secret(serializeKey(IEK, true)), publishOptions, username, email, @@ -286,14 +319,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')) @@ -343,7 +378,14 @@ 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: new Secret(transientSecretKeys.map(k => serializeKey(k, true))), + oldKeysAnchorCid + }) } else { try { await sbp('chelonia/contract/sync', identityContractID) @@ -434,5 +476,32 @@ export default (sbp('sbp/selectors/register', { }, 'gi.app/identity/logout': (...params) => { return sbp('okTurtles.eventQueue/queueEvent', 'APP-LOGIN', ['gi.app/identity/_private/logout', ...params]) + }, + 'gi.app/identity/changePassword': async (wOldPassword: Secret, wNewPassword: Secret) => { + const state = sbp('state/vuex/state') + if (!state.loggedIn) return + const getters = sbp('state/vuex/getters') + + const { identityContractID } = state.loggedIn + const username = getters.usernameFromID(identityContractID) + const oldPassword = wOldPassword.valueOf() + const newPassword = wNewPassword.valueOf() + + const [newContractSalt, oldContractSalt, updateToken] = await sbp('gi.app/identity/updateSaltRequest', username, wOldPassword, wNewPassword) + + const oldIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, oldPassword, oldContractSalt) + const oldIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, oldPassword, oldContractSalt) + const newIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt) + const newIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, newPassword, newContractSalt) + + return sbp('gi.actions/identity/changePassword', { + identityContractID, + username, + oldIPK: new Secret(oldIPK), + oldIEK: new Secret(oldIEK), + newIPK: new Secret(newIPK), + newIEK: new Secret(newIEK), + updateToken: new Secret(updateToken) + }) } }): string[]) diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index c4e3e7910..b83245b7b 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -169,6 +169,7 @@ sbp('sbp/selectors/register', { 'appLogs/save': () => sbp('swLogs/save') }) +sbp('okTurtles.data/set', 'API_URL', self.location.origin) setupRootState() const setupPromise = setupChelonia() diff --git a/frontend/setupChelonia.js b/frontend/setupChelonia.js index dde4dab1e..a3f5217fa 100644 --- a/frontend/setupChelonia.js +++ b/frontend/setupChelonia.js @@ -61,9 +61,6 @@ const setupChelonia = async (): Promise<*> => { } }) - // this is to ensure compatibility between frontend and test/backend.test.js - sbp('okTurtles.data/set', 'API_URL', self.location.origin) - // Used in 'chelonia/configure' hooks to emit an error notification. const errorNotification = (activity: string, error: Error, message: GIMessage) => { sbp('gi.notifications/emit', 'CHELONIA_ERROR', { createdDate: new Date().toISOString(), activity, error, message }) diff --git a/frontend/views/containers/access/PasswordModal.vue b/frontend/views/containers/access/PasswordModal.vue index fd5fb7d1f..71e40f451 100644 --- a/frontend/views/containers/access/PasswordModal.vue +++ b/frontend/views/containers/access/PasswordModal.vue @@ -1,5 +1,5 @@