From 7c5c8493555b7b28e1cb22c69e00f633157c0a5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Fri, 6 Dec 2024 22:13:48 +0000 Subject: [PATCH] Update salts and rotate keys --- backend/routes.js | 14 ++- backend/zkppSalt.js | 58 ++++++++++- frontend/controller/actions/identity.js | 132 +++++++++++++++++++++++- frontend/controller/app/identity.js | 42 +++++--- package-lock.json | 4 +- shared/zkpp.js | 27 ++++- 6 files changed, 252 insertions(+), 25 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index e255760078..c2cd2de0c0 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,19 @@ route.POST('/event', { } } } + const saltUpdateToken = request.headers['shelter-salt-update-token'] + let updateSalts + if (saltUpdateToken) { + // .. + 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) + await updateSalts?.() if (deserializedHEAD.isFirstMessage) { // Store attribution information if (credentials?.billableContractID) { diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index 63794a907f..225c54ca92 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -1,7 +1,7 @@ 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, encryptContractSalt, encryptSaltUpdate, decryptSaltUpdate, hash, hashRawStringArray, hashStringArray, parseRegisterSalt, randomNonce } from '~/shared/zkpp.js' // used to encrypt salts in database let recordSecret: string @@ -9,6 +9,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 +62,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) @@ -254,7 +269,7 @@ export const getContractSalt = async (contract: string, r: string, s: string, si return encryptContractSalt(c, contractSalt) } -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 +281,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) @@ -297,12 +312,45 @@ export const updateContractSalt = async (contract: string, r: string, s: string, const [hashedPassword, authSalt, contractSalt] = argsObj - await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt) + const recordId = await computeZkppSaltRecordId(contract) + if (!recordId) { + console.error(`update: Error obtaining record ID for contract ID ${contract}`) + return false + } + + 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<() => 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 () => { + return setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt) + } +} diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 3fcd3a745f..9b3ca3a1ec 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -9,12 +9,13 @@ import { import { cloneDeep, has, omit } from '@model/contracts/shared/giLodash.js' import { SETTING_CHELONIA_STATE } from '@model/database.js' import sbp from '@sbp/sbp' -import { imageUpload, objectURLtoBlob, compressImage } from '@utils/image.js' +import { compressImage, imageUpload, objectURLtoBlob } from '@utils/image.js' 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 { 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' @@ -680,6 +681,135 @@ 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, + newSAK: SAK, + updateToken + }) => { + // Create the necessary keys to initialise the contract + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CEK = keygen(CURVE25519XSALSA20POLY1305) + const PEK = keygen(CURVE25519XSALSA20POLY1305) + + // 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 PEKid = keyId(PEK) + 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 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 }))) + ) + + 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 + } + }, + 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: PEKid, + name: 'pek', + oldKeyId: findKeyIdByName(state, 'pek'), + meta: { + private: { + content: PEKs + } + }, + data: PEKp + }, + { + 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: { + preSendCheck + } */ + }) + }, ...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 064455028c..f4ffe11ca6 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -177,7 +177,7 @@ export default (sbp('sbp/selectors/register', { return decryptContractSalt(c, contractHash) }, - 'gi.app/identity/updateSalt': async (username: string, oldPassword: Secret, newPassword: Secret) => { + '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)}`) @@ -189,9 +189,9 @@ export default (sbp('sbp/selectors/register', { const [c, hc] = computeCAndHc(r, s, h) - const [contractHash, encryptedArgs] = await buildUpdateSaltRequestEa(newPassword.valueOf(), c) + const [contractSalt, encryptedArgs] = await buildUpdateSaltRequestEa(newPassword.valueOf(), c) - await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/updatePasswordHash`, { + const response = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/updatePasswordHash`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' @@ -202,11 +202,13 @@ export default (sbp('sbp/selectors/register', { 's': s, 'sig': sig, 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, ''), - 'Ea': encryptedArgs.toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') + 'Ea': encryptedArgs })).toString()}` }).then(handleFetchResult('text')) - return contractHash + const [oldContractSalt, updateToken] = JSON.parse(decryptContractSalt(c, response)) + + return [contractSalt, oldContractSalt, updateToken] }, 'gi.app/identity/create': async function ({ data: { username, email, password, picture }, @@ -448,14 +450,26 @@ export default (sbp('sbp/selectors/register', { const { identityContractID } = state.loggedIn const username = getters.usernameFromID(identityContractID) - // const oldPassword = woldPassword.valueOf() - // const newPassword = wnewPassword.valueOf() - - const contractSalt = await sbp('gi.app/identity/updateSalt', username, woldPassword, wnewPassword) - - return contractSalt - - /* const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, contractSalt) - const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, newPassword, contractSalt) */ + 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) + const newSAK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, newPassword, newContractSalt) + + return sbp('gi.actions/identity/changePassword', { + identityContractID, + username, + oldIPK, + oldIEK, + newIPK, + newIEK, + newSAK, + updateToken + }) } }): string[]) diff --git a/package-lock.json b/package-lock.json index 8b2b25b402..666ba223a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "group-income", - "version": "1.1.0", + "version": "1.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "group-income", - "version": "1.1.0", + "version": "1.0.7", "license": "AGPL-3.0", "dependencies": { "@babel/core": "7.23.7", diff --git a/shared/zkpp.js b/shared/zkpp.js index 044d34c693..01ef82c164 100644 --- a/shared/zkpp.js +++ b/shared/zkpp.js @@ -18,7 +18,7 @@ export const randomNonce = (): string => { return base64ToBase64url(Buffer.from(nacl.randomBytes(12)).toString('base64')) } -export const hash = (v: string): string => { +export const hash = (v: string | Buffer): string => { return base64ToBase64url(Buffer.from(nacl.hash(Buffer.from(v))).toString('base64')) } @@ -49,6 +49,27 @@ export const decryptContractSalt = (c: Uint8Array, encryptedContractSaltBox: str return Buffer.from(nacl.secretbox.open(encryptedContractSalt, nonce, encryptionKey)).toString() } +export const encryptSaltUpdate = (secret: string, recordId: string, record: string): string => { + // The nonce is also used to derive a single-use encryption key + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + const encryptionKey = hashRawStringArray('SU', secret, nonce, recordId).slice(0, nacl.secretbox.keyLength) + + const encryptedRecord = nacl.secretbox(Buffer.from(record), nonce, encryptionKey) + + return base64ToBase64url(Buffer.concat([nonce, encryptedRecord]).toString('base64')) +} + +export const decryptSaltUpdate = (secret: string, recordId: string, encryptedRecordBox: string): string => { + // The nonce is also used to derive a single-use encryption key + const encryptedRecordBoxBuf = Buffer.from(base64urlToBase64(encryptedRecordBox), 'base64') + const nonce = encryptedRecordBoxBuf.slice(0, nacl.secretbox.nonceLength) + const encryptionKey = hashRawStringArray('SU', secret, nonce, recordId).slice(0, nacl.secretbox.keyLength) + + const encryptedRecord = encryptedRecordBoxBuf.slice(nacl.secretbox.nonceLength) + + return Buffer.from(nacl.secretbox.open(encryptedRecord, nonce, encryptionKey)).toString() +} + export const hashPassword = (password: string, salt: string): Promise => { return new Promise(resolve => scrypt(password, salt, { N: 16384, @@ -130,7 +151,9 @@ export const buildUpdateSaltRequestEa = async (password: string, c: Uint8Array): // it matches p and would be used to derive S_A and S_C. const [authSalt, contractSalt] = ['a', 'b'] - const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('SU'), c])).slice(0, nacl.secretbox.keyLength) + const encryptionKey = nacl.hash(Buffer.concat([ + Buffer.from('SU'), Buffer.from(c) + ])).slice(0, nacl.secretbox.keyLength) const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) const hashedPassword = await hashPassword(password, authSalt)