Skip to content

Commit

Permalink
WIP Password change
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Dec 11, 2024
1 parent ffac7d8 commit bb86fa5
Show file tree
Hide file tree
Showing 7 changed files with 104 additions and 41 deletions.
2 changes: 1 addition & 1 deletion backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
26 changes: 16 additions & 10 deletions backend/zkppSalt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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'))
Expand Down Expand Up @@ -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)

Expand All @@ -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<boolean | string> => {
Expand Down Expand Up @@ -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<void>> => {
export const redeemSaltUpdateToken = async (contract: string, token: string): Promise<(cid: ?string) => Promise<void>> => {
const recordId = await computeZkppSaltRecordId(contract)
if (!recordId) {
throw new Error('Record ID not found')
Expand All @@ -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)
}
}
52 changes: 31 additions & 21 deletions frontend/controller/actions/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -703,30 +716,34 @@ 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
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 })))
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',
Expand All @@ -748,7 +765,8 @@ export default (sbp('sbp/selectors/register', {
oldKeyId: oldIEKid,
meta: {
private: {
transient: true
transient: true,
oldKeys: oldKeysData
}
},
data: IEKp
Expand All @@ -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',
Expand All @@ -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])
Expand Down
13 changes: 7 additions & 6 deletions frontend/controller/app/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>) => {
'gi.app/identity/retrieveSalt': async (username: string, password: Secret<string>): 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)}`)
Expand All @@ -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<string>, newPassword: Secret<string>) => {
const r = randomNonce()
Expand Down Expand Up @@ -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'))
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -468,7 +470,6 @@ export default (sbp('sbp/selectors/register', {
oldIEK,
newIPK,
newIEK,
newSAK,
updateToken
})
}
Expand Down
3 changes: 2 additions & 1 deletion frontend/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions shared/domains/chelonia/chelonia.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down
45 changes: 45 additions & 0 deletions shared/domains/chelonia/encryptedData.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,51 @@ export const encryptedIncomingForeignData = <T>(contractID: string, _0: any, dat
})
}

export const encryptedIncomingDataWithRawKey = <T>(key: Key, data: T, additionalData?: string): EncryptedData<T> => {
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')
Expand Down

0 comments on commit bb86fa5

Please sign in to comment.