Skip to content

Commit

Permalink
Update salts and rotate keys
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Dec 7, 2024
1 parent 57eb477 commit 6a5a0d5
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 26 deletions.
14 changes: 13 additions & 1 deletion backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
58 changes: 53 additions & 5 deletions backend/zkppSalt.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
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
// corresponds to the key for the keyed Hash function in "Log in / session establishment"
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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<boolean> => {
export const updateContractSalt = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise<boolean | string> => {
if (!verifyChallenge(contract, r, s, sig)) {
console.warn('update: Error validating challenge: ' + JSON.stringify({ contract, r, s, sig }))
throw new Error('update: Bad challenge')
Expand All @@ -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)

Expand Down Expand Up @@ -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<void>> => {
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)
}
}
2 changes: 1 addition & 1 deletion backend/zkppSalt.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
132 changes: 131 additions & 1 deletion frontend/controller/actions/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.'))
Expand Down
42 changes: 28 additions & 14 deletions frontend/controller/app/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ export default (sbp('sbp/selectors/register', {

return decryptContractSalt(c, contractHash)
},
'gi.app/identity/updateSalt': async (username: string, oldPassword: Secret<string>, newPassword: Secret<string>) => {
'gi.app/identity/updateSaltRequest': async (username: string, oldPassword: Secret<string>, newPassword: Secret<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)}`)
Expand All @@ -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'
Expand All @@ -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 },
Expand Down Expand Up @@ -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[])
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 6a5a0d5

Please sign in to comment.