From 57eb477d2d97b21a1e6367b479466f1de0d94364 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 20:00:36 +0000 Subject: [PATCH 01/16] Add gi.app/identity/changePassword --- backend/routes.js | 4 +-- frontend/controller/app/identity.js | 50 ++++++++++++++++++++++++++++- package-lock.json | 4 +-- shared/zkpp.js | 26 +++++++++++++++ 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index 80476a344d..e255760078 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -821,7 +821,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 +841,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/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index 6d2ede4a80..064455028c 100644 --- a/frontend/controller/app/identity.js +++ b/frontend/controller/app/identity.js @@ -6,7 +6,7 @@ import sbp from '@sbp/sbp' import Vue from 'vue' import { LOGIN, LOGIN_COMPLETE, LOGIN_ERROR } from '~/frontend/utils/events.js' import { Secret } from '~/shared/domains/chelonia/Secret.js' -import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' +import { boxKeyPair, buildRegisterSaltRequest, buildUpdateSaltRequestEa, 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' @@ -177,6 +177,37 @@ export default (sbp('sbp/selectors/register', { return decryptContractSalt(c, contractHash) }, + 'gi.app/identity/updateSalt': 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 [contractHash, encryptedArgs] = await buildUpdateSaltRequestEa(newPassword.valueOf(), c) + + 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('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') + })).toString()}` + }).then(handleFetchResult('text')) + + return contractHash + }, 'gi.app/identity/create': async function ({ data: { username, email, password, picture }, publishOptions @@ -409,5 +440,22 @@ 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 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) */ } }): string[]) diff --git a/package-lock.json b/package-lock.json index 666ba223a7..8b2b25b402 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "group-income", - "version": "1.0.7", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "group-income", - "version": "1.0.7", + "version": "1.1.0", "license": "AGPL-3.0", "dependencies": { "@babel/core": "7.23.7", diff --git a/shared/zkpp.js b/shared/zkpp.js index 55ce56bf24..044d34c693 100644 --- a/shared/zkpp.js +++ b/shared/zkpp.js @@ -114,3 +114,29 @@ export const buildRegisterSaltRequest = async (publicKey: string, secretKey: Uin return [contractSalt, base64ToBase64url(Buffer.concat([nonce, encryptedHashedPasswordBuf]).toString('base64'))] } + +export const buildUpdateSaltRequestEa = async (password: string, c: Uint8Array): Promise<[string, string]> => { + // TODO: Derive S_A and S_C as follows: + // -> q -< random + // -> r -< SHA-512(SHA-512('SU') + SHA-512(q)) + // -> b -< SHA-512(r) // as it's now + // Then, + // -> S_T -< BASE64(SHA-512(SHA-512(T) + SHA-512(q))[0..18]) with T being + // `AUTHSALT` or `CONTRACTSALT` + // This way, we ensure both the server and the client contribute to the + // salts' entropy. + // When sending the encrypted data, the encrypted information would be + // `[hashedPassword, q]`, which needs to be verified server-side to verify + // 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 nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + + const hashedPassword = await hashPassword(password, authSalt) + const encryptedArgsCiphertext = nacl.secretbox(Buffer.from(JSON.stringify([hashedPassword, authSalt, contractSalt])), nonce, encryptionKey) + + const encryptedArgs = Buffer.concat([nonce, encryptedArgsCiphertext]) + + return [contractSalt, base64ToBase64url(encryptedArgs.toString('base64'))] +} From 6a5a0d54ca17b98613cab26ead15247281c9ff98 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 02/16] Update salts and rotate keys --- backend/routes.js | 14 ++- backend/zkppSalt.js | 58 ++++++++++- backend/zkppSalt.test.js | 2 +- frontend/controller/actions/identity.js | 132 +++++++++++++++++++++++- frontend/controller/app/identity.js | 42 +++++--- package-lock.json | 4 +- shared/zkpp.js | 27 ++++- 7 files changed, 253 insertions(+), 26 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/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index 7592b4d309..ecdd56c6d7 100644 --- a/backend/zkppSalt.test.js +++ b/backend/zkppSalt.test.js @@ -111,6 +111,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 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) From 140c826dfce4e34572979ca5aeceaf31c616d034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sat, 7 Dec 2024 19:10:23 +0000 Subject: [PATCH 03/16] Generate authSalt and contractSalt --- backend/zkppSalt.js | 12 ++++-------- shared/zkpp.js | 19 +++++++------------ 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index 225c54ca92..a0b40859df 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -303,14 +303,7 @@ export const updateContractSalt = async (contract: string, r: string, s: string, } try { - const argsObj = JSON.parse(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 })})`) - return false - } - - const [hashedPassword, authSalt, contractSalt] = argsObj + const hashedPassword = Buffer.from(args).toString() const recordId = await computeZkppSaltRecordId(contract) if (!recordId) { @@ -318,6 +311,9 @@ export const updateContractSalt = async (contract: string, r: string, s: string, return false } + const authSalt = Buffer.from(hashStringArray('AUTHSALT', c)).slice(0, 18).toString('base64') + const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', c)).slice(0, 18).toString('base64') + const token = encryptSaltUpdate( hashUpdateSecret, recordId, diff --git a/shared/zkpp.js b/shared/zkpp.js index 01ef82c164..28efbb46c4 100644 --- a/shared/zkpp.js +++ b/shared/zkpp.js @@ -137,27 +137,22 @@ export const buildRegisterSaltRequest = async (publicKey: string, secretKey: Uin } export const buildUpdateSaltRequestEa = async (password: string, c: Uint8Array): Promise<[string, string]> => { - // TODO: Derive S_A and S_C as follows: - // -> q -< random - // -> r -< SHA-512(SHA-512('SU') + SHA-512(q)) - // -> b -< SHA-512(r) // as it's now - // Then, - // -> S_T -< BASE64(SHA-512(SHA-512(T) + SHA-512(q))[0..18]) with T being + // Derive S_A and S_C as follows: + // -> S_T -< BASE64(SHA-512(SHA-512(T) + SHA-512(c))[0..18]) with T being // `AUTHSALT` or `CONTRACTSALT` // This way, we ensure both the server and the client contribute to the // salts' entropy. // When sending the encrypted data, the encrypted information would be - // `[hashedPassword, q]`, which needs to be verified server-side to verify + // `hashedPassword`, which needs to be verified server-side to verify // it matches p and would be used to derive S_A and S_C. - const [authSalt, contractSalt] = ['a', 'b'] + const authSalt = Buffer.from(hashStringArray('AUTHSALT', c)).slice(0, 18).toString('base64') + const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', c)).slice(0, 18).toString('base64') - const encryptionKey = nacl.hash(Buffer.concat([ - Buffer.from('SU'), Buffer.from(c) - ])).slice(0, nacl.secretbox.keyLength) + const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength) const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) const hashedPassword = await hashPassword(password, authSalt) - const encryptedArgsCiphertext = nacl.secretbox(Buffer.from(JSON.stringify([hashedPassword, authSalt, contractSalt])), nonce, encryptionKey) + const encryptedArgsCiphertext = nacl.secretbox(Buffer.from(hashedPassword), nonce, encryptionKey) const encryptedArgs = Buffer.concat([nonce, encryptedArgsCiphertext]) From 355528caf4456e0fd00091d49719d08a80c5723b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sat, 7 Dec 2024 19:50:12 +0000 Subject: [PATCH 04/16] Key storage --- frontend/controller/actions/identity.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 9b3ca3a1ec..bc05af36f2 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -217,9 +217,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]) @@ -809,6 +809,13 @@ export default (sbp('sbp/selectors/register', { preSendCheck } */ }) + + // After the contract has been updated, store persistent keys + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK, PEK, 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.')), From e88c818da3ac1b419a1563988fec86961f4c253d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 11 Dec 2024 13:20:03 +0000 Subject: [PATCH 05/16] WIP Password change --- backend/routes.js | 2 +- backend/zkppSalt.js | 26 +++++++----- backend/zkppSalt.test.js | 4 +- frontend/controller/actions/identity.js | 52 ++++++++++++++---------- frontend/controller/app/identity.js | 13 +++--- frontend/utils/constants.js | 3 +- shared/domains/chelonia/chelonia.js | 4 +- shared/domains/chelonia/encryptedData.js | 45 ++++++++++++++++++++ 8 files changed, 107 insertions(+), 42 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index c2cd2de0c0..c04bad6cd3 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 a0b40859df..43da7718c9 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/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index ecdd56c6d7..f6d7914e92 100644 --- a/backend/zkppSalt.test.js +++ b/backend/zkppSalt.test.js @@ -76,7 +76,9 @@ 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 [retrievedContractSalt] = JSON.parse( + Buffer.from(nacl.secretbox.open(saltBuf.slice(nacl.secretbox.nonceLength), nonce, encryptionKey)).toString() + ) should(retrievedContractSalt).equal(contractSalt, 'mismatched contractSalt') }) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index bc05af36f2..14091dce9d 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 f4ffe11ca6..493e3e7944 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 3017444abf..54e3480c42 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 f980b2f50f..676c00b08e 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 cdd27cf6a5..f4e1db7199 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') From 882d00e1a81cf5e555ec5c7de7b0152c68e609c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 11 Dec 2024 20:55:13 +0000 Subject: [PATCH 06/16] Re-enable PW change form, better Secret class --- .../views/containers/access/PasswordModal.vue | 44 +++++++++---------- .../containers/user-settings/UserProfile.vue | 12 +---- shared/domains/chelonia/Secret.js | 10 ++--- shared/domains/chelonia/encryptedData.js | 2 +- 4 files changed, 30 insertions(+), 38 deletions(-) diff --git a/frontend/views/containers/access/PasswordModal.vue b/frontend/views/containers/access/PasswordModal.vue index fd5fb7d1f8..c8302ca7e8 100644 --- a/frontend/views/containers/access/PasswordModal.vue +++ b/frontend/views/containers/access/PasswordModal.vue @@ -56,7 +56,7 @@ modal-template(class='is-centered is-left-aligned' back-on-mobile=true ref='moda i18n.is-success( tag='button' @click='changePassword' - :disabled='$v.form.$invalid' + :disabled='$v.form.$invalid || processing' ) Change password template(slot='errors') {{ form.response }} @@ -65,9 +65,11 @@ modal-template(class='is-centered is-left-aligned' back-on-mobile=true ref='moda import { validationMixin } from 'vuelidate' import ModalTemplate from '@components/modal/ModalTemplate.vue' import PasswordForm from '@containers/access/PasswordForm.vue' +import sbp from '@sbp/sbp' import { required, minLength } from 'vuelidate/lib/validators' import sameAs from 'vuelidate/lib/validators/sameAs.js' import { L } from '@common/common.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' export default ({ name: 'PasswordModal', @@ -78,25 +80,14 @@ export default ({ current: null, newPassword: null, confirm: null - } + }, + processing: false } }, validations: { form: { current: { - required, - checkOldPassword: value => { - // TODO - console.log('Todo: check password') - if (value === '') return false - - // simulate async call, fail for all logins with even length - return new Promise((resolve, reject) => { - setTimeout(() => { - resolve(typeof value === 'string' && value.length % 2 !== 0) - }, 350 + Math.random() * 300) - }) - } + required }, newPassword: { required, @@ -119,13 +110,22 @@ export default ({ this.$refs.modalTemplate.close() }, changePassword () { - try { - // TODO check password - this.closeModal() - } catch (error) { - this.form.response = L('Invalid password') - console.error(error) - } + if (this.processing) return + this.processing = true + ;(async () => { + try { + await sbp('gi.app/identity/changePassword', + new Secret(this.form.current), + new Secret(this.form.newPassword) + ) + this.closeModal() + } catch (error) { + this.form.response = L('Invalid password') + console.error('[PasswordModal.vue]', error) + } + })().finally(() => { + this.processing = false + }) } } }: Object) diff --git a/frontend/views/containers/user-settings/UserProfile.vue b/frontend/views/containers/user-settings/UserProfile.vue index cca6141e97..76744f0a43 100644 --- a/frontend/views/containers/user-settings/UserProfile.vue +++ b/frontend/views/containers/user-settings/UserProfile.vue @@ -57,7 +57,7 @@ tag='button' type='button' data-test='passwordBtn' - @click.prevent='onUpdatePasswordClick' + @click.prevent='openModal("PasswordModal")' ) Update Password banner-scoped(ref='formMsg' data-test='profileMsg') @@ -95,7 +95,7 @@ import BannerScoped from '@components/banners/BannerScoped.vue' import AvatarUpload from '@components/AvatarUpload.vue' import ButtonSubmit from '@components/ButtonSubmit.vue' import CharLengthIndicator from '@components/CharLengthIndicator.vue' -import { L, LTags } from '@common/common.js' +import { L } from '@common/common.js' import { IDENTITY_BIO_MAX_CHARS, IDENTITY_USERNAME_MAX_CHARS } from '@model/contracts/shared/constants.js' export default ({ name: 'UserProfile', @@ -154,14 +154,6 @@ export default ({ sbp('okTurtles.events/emit', OPEN_MODAL, mode) return false }, - onUpdatePasswordClick () { - // TODO: use 'PasswordModal.vue' instead, once the password update is implemented in the app. - sbp('gi.ui/prompt', { - heading: L('Feature coming soon'), - question: L('Sorry, this feature has not been implemented yet.{br_}Please check back later.', { ...LTags() }), - primaryButton: L('OK') - }) - }, async saveProfile () { this.$refs.formMsg.clean() const attrs = {} diff --git a/shared/domains/chelonia/Secret.js b/shared/domains/chelonia/Secret.js index 5277b61aec..27620860d9 100644 --- a/shared/domains/chelonia/Secret.js +++ b/shared/domains/chelonia/Secret.js @@ -4,9 +4,9 @@ import { serdesDeserializeSymbol, serdesSerializeSymbol, serdesTagSymbol } from /* Wrapper class for secrets, which identifies them as such and prevents them from being logged */ -export class Secret { - _content: T +const wm = new WeakMap() +export class Secret { // $FlowFixMe[unsupported-syntax] static [serdesDeserializeSymbol] (secret) { return new this(secret) @@ -14,7 +14,7 @@ export class Secret { // $FlowFixMe[unsupported-syntax] static [serdesSerializeSymbol] (secret: Secret) { - return secret._content + return wm.get(secret) } // $FlowFixMe[unsupported-syntax] @@ -23,10 +23,10 @@ export class Secret { } constructor (value: T) { - this._content = value + wm.set(this, value) } valueOf (): T { - return this._content + return wm.get(this) } } diff --git a/shared/domains/chelonia/encryptedData.js b/shared/domains/chelonia/encryptedData.js index f4e1db7199..5b678b6391 100644 --- a/shared/domains/chelonia/encryptedData.js +++ b/shared/domains/chelonia/encryptedData.js @@ -277,7 +277,7 @@ export const encryptedIncomingForeignData = (contractID: string, _0: any, dat }) } -export const encryptedIncomingDataWithRawKey = (key: Key, data: T, additionalData?: string): EncryptedData => { +export const encryptedIncomingDataWithRawKey = (key: Key, data: any, additionalData?: string): EncryptedData => { if (data === undefined || !key) throw new TypeError('Invalid invocation') let decryptedValue From 3572c04bc9fdd88f35339b8539de9bbd9780e7c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:21:27 +0000 Subject: [PATCH 07/16] Readability, encrypt all past IEKs --- frontend/controller/actions/identity.js | 117 ++++++++++++++++++++---- frontend/utils/constants.js | 3 +- shared/domains/chelonia/chelonia.js | 4 +- 3 files changed, 102 insertions(+), 22 deletions(-) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 75a25b075a..0f94ddb890 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -23,6 +23,73 @@ import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, ke 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: Object, 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 +} + +/** + * 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 +} + export default (sbp('sbp/selectors/register', { 'gi.actions/identity/create': async function ({ IPK, @@ -255,15 +322,34 @@ export default (sbp('sbp/selectors/register', { 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 { + // 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 keys = JSON.parse(result._signedData[0]) + + // Find the key with the name 'iek' + const iekObj = keys.find((key) => key.name === 'iek') + + // Check if old keys are present in the metadata + if (iekObj.meta?.private?.oldKeys) { + // Decrypt the old IEK list + const decryptedKeys = decryptOldIekList(identityContractID, transientSecretKeys[0].key, iekObj.meta.private.oldKeys) + + // Check if decryption was successful + if (!decryptedKeys) { + console.error('[gi.actions/identity/login] 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('[gi.actions/identity/login] Error fetching or processing old keys:', error) } } @@ -746,12 +832,10 @@ export default (sbp('sbp/selectors/register', { 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') + const oldKeysData = appendToIekList( + identityContractID, IEK, oldIEK, + state._vm.authorizedKeys[oldIEKid]?.meta?.private?.oldKeys + ) await sbp('chelonia/out/keyUpdate', { contractID: identityContractID, @@ -826,9 +910,6 @@ 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, SAK].map(key => ({ key }))) diff --git a/frontend/utils/constants.js b/frontend/utils/constants.js index 54e3480c42..3017444abf 100644 --- a/frontend/utils/constants.js +++ b/frontend/utils/constants.js @@ -34,8 +34,7 @@ export const KV_KEYS = { UNREAD_MESSAGES: 'unreadMessages', LAST_LOGGED_IN: 'lastLoggedIn', PREFERENCES: 'preferences', - NOTIFICATIONS: 'notifications', - OLD_KEYS: 'old-keys' + NOTIFICATIONS: 'notifications' } export const MAX_LOG_ENTRIES = 2000 diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 7247833a79..aa96b58ab9 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -1537,10 +1537,10 @@ export default (sbp('sbp/selectors/register', { break } }, - 'chelonia/kv/get': async function (contractID: string, key: string, state: ?Object) { + 'chelonia/kv/get': async function (contractID: string, key: string) { const response = await fetch(`${this.config.connectionURL}/kv/${encodeURIComponent(contractID)}/${encodeURIComponent(key)}`, { headers: new Headers([[ - 'authorization', buildShelterAuthorizationHeader.call(this, contractID, state) + 'authorization', buildShelterAuthorizationHeader.call(this, contractID) ]]), signal: this.abortController.signal }) From 355eb782b862a84a973eccefe47e9f38addb30ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:43:19 +0000 Subject: [PATCH 08/16] Types and comments --- backend/routes.js | 7 ++++++- shared/domains/chelonia/Secret.js | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/routes.js b/backend/routes.js index d2c51c0843..b61a06c8ad 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -119,7 +119,9 @@ 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) { @@ -128,6 +130,9 @@ route.POST('/event', { 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 diff --git a/shared/domains/chelonia/Secret.js b/shared/domains/chelonia/Secret.js index 27620860d9..da7e2b4ab2 100644 --- a/shared/domains/chelonia/Secret.js +++ b/shared/domains/chelonia/Secret.js @@ -23,10 +23,13 @@ export class Secret { } constructor (value: T) { + // $FlowFixMe[escaped-generic] wm.set(this, value) } valueOf (): T { + // $FlowFixMe[escaped-generic] + // $FlowFixMe[incompatible-return] return wm.get(this) } } From 58549ed392d3d5effafeefa1b4c0d70305651d15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sat, 14 Dec 2024 11:26:54 +0000 Subject: [PATCH 09/16] Feedback --- backend/zkppSalt.js | 9 ++++--- backend/zkppSalt.test.js | 9 ++++--- frontend/controller/app/identity.js | 12 ++++----- shared/domains/chelonia/Secret.js | 4 +++ shared/domains/chelonia/encryptedData.js | 10 +++++++ shared/domains/chelonia/signedData.js | 15 +++++++++++ shared/zkpp.js | 33 ++++++++++++++---------- shared/zkppConstants.js | 4 +++ 8 files changed, 69 insertions(+), 27 deletions(-) create mode 100644 shared/zkppConstants.js diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index 43da7718c9..0356b525e1 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, encryptSaltUpdate, decryptSaltUpdate, 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, SU } from '~/shared/zkppConstants.js' // used to encrypt salts in database let recordSecret: string @@ -296,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) @@ -317,8 +318,8 @@ export const updateContractSalt = async (contract: string, r: string, s: string, return false } - const authSalt = Buffer.from(hashStringArray('AUTHSALT', c)).slice(0, 18).toString('base64') - const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', c)).slice(0, 18).toString('base64') + const authSalt = Buffer.from(hashStringArray(AUTHSALT, c)).slice(0, 18).toString('base64') + const contractSalt = Buffer.from(hashStringArray(CONTRACTSALT, c)).slice(0, 18).toString('base64') const token = encryptSaltUpdate( hashUpdateSecret, diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index f6d7914e92..80e53a1a97 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, 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, 18).toString('base64') + const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(CONTRACTSALT)), dhKey]))).slice(0, 18).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,7 +76,7 @@ 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 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() ) @@ -105,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) diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js index b03bb5d0fc..cc10d7607c 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, buildUpdateSaltRequestEa, 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' @@ -202,7 +202,7 @@ export default (sbp('sbp/selectors/register', { const [c, hc] = computeCAndHc(r, s, h) - const [contractSalt, encryptedArgs] = await buildUpdateSaltRequestEa(newPassword.valueOf(), c) + const [contractSalt, encryptedArgs] = await buildUpdateSaltRequestEc(newPassword.valueOf(), c) const response = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/updatePasswordHash`, { method: 'POST', @@ -471,17 +471,17 @@ 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) => { + '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 oldPassword = wOldPassword.valueOf() + const newPassword = wNewPassword.valueOf() - const [newContractSalt, oldContractSalt, updateToken] = await sbp('gi.app/identity/updateSaltRequest', username, woldPassword, wnewPassword) + 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) diff --git a/shared/domains/chelonia/Secret.js b/shared/domains/chelonia/Secret.js index da7e2b4ab2..71254e4e95 100644 --- a/shared/domains/chelonia/Secret.js +++ b/shared/domains/chelonia/Secret.js @@ -5,6 +5,10 @@ import { serdesDeserializeSymbol, serdesSerializeSymbol, serdesTagSymbol } from /* Wrapper class for secrets, which identifies them as such and prevents them from being logged */ +// Use a `WeakMap` to store the actual secret outside of the returned `Secret` +// object. This ensures that the only way to access the secret is via the +// `.valueOf()` method, and it prevents accidentally logging things that +// shouldn't be logged. const wm = new WeakMap() export class Secret { // $FlowFixMe[unsupported-syntax] diff --git a/shared/domains/chelonia/encryptedData.js b/shared/domains/chelonia/encryptedData.js index 5b678b6391..3375ecd6fb 100644 --- a/shared/domains/chelonia/encryptedData.js +++ b/shared/domains/chelonia/encryptedData.js @@ -8,10 +8,20 @@ import { isRawSignedData, signedIncomingData } from './signedData.js' const rootStateFn = () => sbp('chelonia/rootState') export interface EncryptedData { + // The ID of the encryption key used encryptionKeyId: string, + // The unencrypted data. For outgoing data, this is the original data given + // as input. For incoming data, decryption will be attempted. valueOf: () => T, + // The serialized _encrypted_ data. For outgoing data, encryption will be + // attempted. For incoming data, this is the original data given as input. + // The `additionalData` parameter is only used for outgoing data, and binds + // the encrypted payload to additional information. serialize: (additionalData: ?string) => [string, string], + // A string version of the serialized encrypted data (i.e., `JSON.stringify()`) toString: (additionalData: ?string) => string, + // For incoming data, this is an alias of `serialize`. Undefined for outgoing + // data. toJSON?: () => [string, string] } diff --git a/shared/domains/chelonia/signedData.js b/shared/domains/chelonia/signedData.js index 8e42c9aa63..e7bb693c2e 100644 --- a/shared/domains/chelonia/signedData.js +++ b/shared/domains/chelonia/signedData.js @@ -8,13 +8,28 @@ import { ChelErrorSignatureError, ChelErrorSignatureKeyNotFound, ChelErrorSignat const rootStateFn = () => sbp('chelonia/rootState') export interface SignedData { + // The ID of the signing key used signingKeyId: string, + // The unsigned data. For outgoing data, this is the original data given + // as input. For incoming data, signature verification will be attempted. valueOf: () => T, + // The serialized _signed_ data. For outgoing data, signing will be + // attempted. For incoming data, this is the original data given as input. + // The `additionalData` parameter is only used for outgoing data, and binds + // the signed payload to additional information. serialize: (additionalData: ?string) => { _signedData: [string, string, string] }, + // Data needed to recreate signed data. + // [contractID, data, height, additionalData] context?: [string, Object, number, string], + // A string version of the serialized signed data (i.e., `JSON.stringify()`) toString: (additionalData: ?string) => string, + // For outgoing data, recreate SignedData using different data and the same + // parameters recreate?: (data: T) => SignedData, + // For incoming data, this is an alias of `serialize`. Undefined for outgoing + // data. toJSON?: () => [string, string], + // `get` and `set` can set additional (unsigned) fields within `SignedData` get: (k: string) => any, set?: (k: string, v: any) => void } diff --git a/shared/zkpp.js b/shared/zkpp.js index 28efbb46c4..ee9e3764b1 100644 --- a/shared/zkpp.js +++ b/shared/zkpp.js @@ -1,5 +1,6 @@ import nacl from 'tweetnacl' import scrypt from 'scrypt-async' +import { AUTHSALT, CONTRACTSALT, CS, SU } from './zkppConstants.js' // .toString('base64url') only works in Node.js export const base64ToBase64url = (s: string): string => s.replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') @@ -31,7 +32,7 @@ export const computeCAndHc = (r: string, s: string, h: string): [Uint8Array, Uin } export const encryptContractSalt = (c: Uint8Array, contractSalt: string): string => { - const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) + const encryptionKey = hashRawStringArray(CS, c).slice(0, nacl.secretbox.keyLength) const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) const encryptedContractSalt = nacl.secretbox(Buffer.from(contractSalt), nonce, encryptionKey) @@ -40,7 +41,7 @@ export const encryptContractSalt = (c: Uint8Array, contractSalt: string): string } export const decryptContractSalt = (c: Uint8Array, encryptedContractSaltBox: string): string => { - const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) + const encryptionKey = hashRawStringArray(CS, c).slice(0, nacl.secretbox.keyLength) const encryptedContractSaltBoxBuf = Buffer.from(base64urlToBase64(encryptedContractSaltBox), 'base64') const nonce = encryptedContractSaltBoxBuf.slice(0, nacl.secretbox.nonceLength) @@ -52,7 +53,7 @@ export const decryptContractSalt = (c: Uint8Array, encryptedContractSaltBox: str 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 encryptionKey = hashRawStringArray(SU, secret, nonce, recordId).slice(0, nacl.secretbox.keyLength) const encryptedRecord = nacl.secretbox(Buffer.from(record), nonce, encryptionKey) @@ -63,7 +64,7 @@ export const decryptSaltUpdate = (secret: string, recordId: string, encryptedRec // 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 encryptionKey = hashRawStringArray(SU, secret, nonce, recordId).slice(0, nacl.secretbox.keyLength) const encryptedRecord = encryptedRecordBoxBuf.slice(nacl.secretbox.nonceLength) @@ -92,8 +93,8 @@ export const saltAgreement = (publicKey: string, secretKey: Uint8Array): false | return false } - const authSalt = Buffer.from(hashStringArray('AUTHSALT', dhKey)).slice(0, 18).toString('base64') - const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', dhKey)).slice(0, 18).toString('base64') + const authSalt = Buffer.from(hashStringArray(AUTHSALT, dhKey)).slice(0, 18).toString('base64') + const contractSalt = Buffer.from(hashStringArray(CONTRACTSALT, dhKey)).slice(0, 18).toString('base64') return [authSalt, contractSalt] } @@ -136,19 +137,25 @@ export const buildRegisterSaltRequest = async (publicKey: string, secretKey: Uin return [contractSalt, base64ToBase64url(Buffer.concat([nonce, encryptedHashedPasswordBuf]).toString('base64'))] } -export const buildUpdateSaltRequestEa = async (password: string, c: Uint8Array): Promise<[string, string]> => { - // Derive S_A and S_C as follows: +// Build the `E_c` (encrypted arguments) to send to the server to negotiate a +// password change. `password` corresponds to the raw user password, `c` is a +// negotiated shared secret between the server and the client. The encrypted +// payload contains the salted user password (using `authSalt`). +// The return value includes the derived contract salt and the `E_c`. +export const buildUpdateSaltRequestEc = async (password: string, c: Uint8Array): Promise<[string, string]> => { + // Derive S_A (authentication salt) and S_C (contract salt) as follows: // -> S_T -< BASE64(SHA-512(SHA-512(T) + SHA-512(c))[0..18]) with T being - // `AUTHSALT` or `CONTRACTSALT` + // `AUTHSALT` (for S_A) or `CONTRACTSALT` (for S_C). // This way, we ensure both the server and the client contribute to the - // salts' entropy. + // salts' entropy. Having more sources of entropy contributes to higher + // randomness in the result. // When sending the encrypted data, the encrypted information would be // `hashedPassword`, which needs to be verified server-side to verify // it matches p and would be used to derive S_A and S_C. - const authSalt = Buffer.from(hashStringArray('AUTHSALT', c)).slice(0, 18).toString('base64') - const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', c)).slice(0, 18).toString('base64') + const authSalt = Buffer.from(hashStringArray(AUTHSALT, c)).slice(0, 18).toString('base64') + const contractSalt = Buffer.from(hashStringArray(CONTRACTSALT, c)).slice(0, 18).toString('base64') - const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength) + const encryptionKey = hashRawStringArray(SU, c).slice(0, nacl.secretbox.keyLength) const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) const hashedPassword = await hashPassword(password, authSalt) diff --git a/shared/zkppConstants.js b/shared/zkppConstants.js new file mode 100644 index 0000000000..26a9cfda93 --- /dev/null +++ b/shared/zkppConstants.js @@ -0,0 +1,4 @@ +export const AUTHSALT = 'AUTHSALT' +export const CONTRACTSALT = 'CONTRACTSALT' +export const CS = 'CS' +export const SU = 'SU' From 7b10144a23f3888c1eba06efbfa5d8381323596e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Sat, 14 Dec 2024 12:33:55 +0000 Subject: [PATCH 10/16] Cypress test for password changes --- .../views/containers/access/PasswordModal.vue | 4 ++- .../integration/signup-and-login.spec.js | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/frontend/views/containers/access/PasswordModal.vue b/frontend/views/containers/access/PasswordModal.vue index c8302ca7e8..c13d802a80 100644 --- a/frontend/views/containers/access/PasswordModal.vue +++ b/frontend/views/containers/access/PasswordModal.vue @@ -1,5 +1,5 @@