Skip to content

Commit 2932ea3

Browse files
authored
Password changes (#2446)
* Add gi.app/identity/changePassword * Update salts and rotate keys * Generate authSalt and contractSalt * Key storage * WIP Password change * Re-enable PW change form, better Secret class * Readability, encrypt all past IEKs * Types and comments * Feedback * Cypress test for password changes * Support OP_ATOMIC for password changes * Logout on password changes * Cleanup * Increase password salt length to 24 octets from 18 octets * Cosmetic improvements * Move set API_URL into sw-primary.js
1 parent 6b1ad58 commit 2932ea3

File tree

15 files changed

+690
-101
lines changed

15 files changed

+690
-101
lines changed

backend/routes.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { SERVER_INSTANCE } from './instance-keys.js'
99
import path from 'path'
1010
import chalk from 'chalk'
1111
import './database.js'
12-
import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt } from './zkppSalt.js'
12+
import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt, redeemSaltUpdateToken } from './zkppSalt.js'
1313
import Bottleneck from 'bottleneck'
1414

1515
const MEGABYTE = 1048576 // TODO: add settings for these
@@ -116,7 +116,24 @@ route.POST('/event', {
116116
}
117117
}
118118
}
119+
const saltUpdateToken = request.headers['shelter-salt-update-token']
120+
let updateSalts
121+
if (saltUpdateToken) {
122+
// If we've got a salt update token (i.e., a password change), fetch
123+
// the username associated to the contract to see if they match, and
124+
// then validate the token
125+
const name = request.headers['shelter-name']
126+
const namedContractID = name && await sbp('backend/db/lookupName', name)
127+
if (namedContractID !== deserializedHEAD.contractID) {
128+
throw new Error('Mismatched contract ID and name')
129+
}
130+
updateSalts = await redeemSaltUpdateToken(name, saltUpdateToken)
131+
}
119132
await sbp('backend/server/handleEntry', deserializedHEAD, request.payload)
133+
// If it's a salt update, do it now after handling the message. This way
134+
// we make it less likely that someone will end up locked out from their
135+
// identity contract.
136+
await updateSalts?.(deserializedHEAD.hash)
120137
if (deserializedHEAD.isFirstMessage) {
121138
// Store attribution information
122139
if (credentials?.billableContractID) {
@@ -821,7 +838,7 @@ route.GET('/zkpp/{name}/contract_hash', {
821838
return Boom.internal('internal error')
822839
})
823840

824-
route.POST('/zkpp/updatePasswordHash/{name}', {
841+
route.POST('/zkpp/{name}/updatePasswordHash', {
825842
validate: {
826843
payload: Joi.object({
827844
r: Joi.string().required(),
@@ -841,7 +858,7 @@ route.POST('/zkpp/updatePasswordHash/{name}', {
841858
}
842859
} catch (e) {
843860
e.ip = req.headers['x-real-ip'] || req.info.remoteAddress
844-
console.error(e, 'Error at POST /zkpp/updatePasswordHash/{name}: ' + e.message)
861+
console.error(e, 'Error at POST /zkpp/{name}/updatePasswordHash: ' + e.message)
845862
}
846863

847864
return Boom.internal('internal error')

backend/zkppSalt.js

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import sbp from '@sbp/sbp'
22
import { randomBytes, timingSafeEqual } from 'crypto'
33
import nacl from 'tweetnacl'
4-
import { base64ToBase64url, base64urlToBase64, boxKeyPair, computeCAndHc, encryptContractSalt, hash, hashRawStringArray, hashStringArray, parseRegisterSalt, randomNonce } from '~/shared/zkpp.js'
4+
import { base64ToBase64url, base64urlToBase64, boxKeyPair, computeCAndHc, decryptSaltUpdate, encryptContractSalt, encryptSaltUpdate, hash, hashRawStringArray, hashStringArray, parseRegisterSalt, randomNonce } from '~/shared/zkpp.js'
5+
import { AUTHSALT, CONTRACTSALT, SALT_LENGTH_IN_OCTETS, SU } from '~/shared/zkppConstants.js'
56

67
// used to encrypt salts in database
78
let recordSecret: string
89
// corresponds to the key for the keyed Hash function in "Log in / session establishment"
910
let challengeSecret: string
1011
// corresponds to a component of s in Step 3 of "Salt registration"
1112
let registrationSecret: string
13+
// used to encrypt a stateless token for atomic hash updates
14+
let hashUpdateSecret: string
1215

1316
// Input keying material used to derive various secret keys used in this
1417
// protocol: recordSecret, challengeSecret and registrationSecret.
@@ -60,10 +63,23 @@ export const initZkpp = async () => {
6063
recordSecret = Buffer.from(hashStringArray('private/recordSecret', IKM)).toString('base64')
6164
challengeSecret = Buffer.from(hashStringArray('private/challengeSecret', IKM)).toString('base64')
6265
registrationSecret = Buffer.from(hashStringArray('private/registrationSecret', IKM)).toString('base64')
66+
hashUpdateSecret = Buffer.from(hashStringArray('private/hashUpdateSecret', IKM)).toString('base64')
6367
}
6468

6569
const maxAge = 30
6670

71+
const computeZkppSaltRecordId = async (contractID: string) => {
72+
const recordId = `_private_rid_${contractID}`
73+
const record = await sbp('chelonia/db/get', recordId)
74+
75+
if (!record) {
76+
return null
77+
}
78+
79+
const recordBuf = Buffer.concat([Buffer.from(contractID), Buffer.from(record)])
80+
return hash(recordBuf)
81+
}
82+
6783
const getZkppSaltRecord = async (contractID: string) => {
6884
const recordId = `_private_rid_${contractID}`
6985
const record = await sbp('chelonia/db/get', recordId)
@@ -85,17 +101,23 @@ const getZkppSaltRecord = async (contractID: string) => {
85101
try {
86102
const recordObj = JSON.parse(recordString)
87103

88-
if (!Array.isArray(recordObj) || recordObj.length !== 3 || !recordObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) {
104+
if (
105+
!Array.isArray(recordObj) ||
106+
(recordObj.length !== 3 && recordObj.length !== 4) ||
107+
recordObj.slice(0, 3).some((r) => !r || typeof r !== 'string') ||
108+
(recordObj[3] !== null && typeof recordObj[3] !== 'string')
109+
) {
89110
console.error('Error validating encrypted JSON object ' + recordId)
90111
return null
91112
}
92113

93-
const [hashedPassword, authSalt, contractSalt] = recordObj
114+
const [hashedPassword, authSalt, contractSalt, cid] = recordObj
94115

95116
return {
96117
hashedPassword,
97118
authSalt,
98-
contractSalt
119+
contractSalt,
120+
cid
99121
}
100122
} catch {
101123
console.error('Error parsing encrypted JSON object ' + recordId)
@@ -105,11 +127,11 @@ const getZkppSaltRecord = async (contractID: string) => {
105127
return null
106128
}
107129

108-
const setZkppSaltRecord = async (contractID: string, hashedPassword: string, authSalt: string, contractSalt: string) => {
130+
const setZkppSaltRecord = async (contractID: string, hashedPassword: string, authSalt: string, contractSalt: string, cid: ?string) => {
109131
const recordId = `_private_rid_${contractID}`
110132
const encryptionKey = hashStringArray('REK', contractID, recordSecret).slice(0, nacl.secretbox.keyLength)
111133
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)
112-
const recordPlaintext = JSON.stringify([hashedPassword, authSalt, contractSalt])
134+
const recordPlaintext = JSON.stringify([hashedPassword, authSalt, contractSalt, cid])
113135
const recordCiphertext = nacl.secretbox(Buffer.from(recordPlaintext), nonce, encryptionKey)
114136
const recordBuf = Buffer.concat([nonce, recordCiphertext])
115137
const record = base64ToBase64url(recordBuf.toString('base64'))
@@ -242,7 +264,7 @@ export const getContractSalt = async (contract: string, r: string, s: string, si
242264
return false
243265
}
244266

245-
const { hashedPassword, contractSalt } = record
267+
const { hashedPassword, contractSalt, cid } = record
246268

247269
const c = contractSaltVerifyC(hashedPassword, r, s, hc)
248270

@@ -251,10 +273,10 @@ export const getContractSalt = async (contract: string, r: string, s: string, si
251273
throw new Error('getContractSalt: Bad challenge')
252274
}
253275

254-
return encryptContractSalt(c, contractSalt)
276+
return encryptContractSalt(c, JSON.stringify([contractSalt, cid]))
255277
}
256278

257-
export const updateContractSalt = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise<boolean> => {
279+
export const updateContractSalt = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise<boolean | string> => {
258280
if (!verifyChallenge(contract, r, s, sig)) {
259281
console.warn('update: Error validating challenge: ' + JSON.stringify({ contract, r, s, sig }))
260282
throw new Error('update: Bad challenge')
@@ -266,7 +288,7 @@ export const updateContractSalt = async (contract: string, r: string, s: string,
266288
console.error('update: Error obtaining ZKPP salt record for contract ID ' + contract)
267289
return false
268290
}
269-
const { hashedPassword } = record
291+
const { hashedPassword, contractSalt: oldContractSalt } = record
270292

271293
const c = contractSaltVerifyC(hashedPassword, r, s, hc)
272294

@@ -275,7 +297,7 @@ export const updateContractSalt = async (contract: string, r: string, s: string,
275297
throw new Error('update: Bad challenge')
276298
}
277299

278-
const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength)
300+
const encryptionKey = hashRawStringArray(SU, c).slice(0, nacl.secretbox.keyLength)
279301
const encryptedArgsBuf = Buffer.from(base64urlToBase64(encryptedArgs), 'base64')
280302
const nonce = encryptedArgsBuf.slice(0, nacl.secretbox.nonceLength)
281303
const encryptedArgsCiphertext = encryptedArgsBuf.slice(nacl.secretbox.nonceLength)
@@ -288,21 +310,50 @@ export const updateContractSalt = async (contract: string, r: string, s: string,
288310
}
289311

290312
try {
291-
const argsObj = JSON.parse(Buffer.from(args).toString())
313+
const hashedPassword = Buffer.from(args).toString()
292314

293-
if (!Array.isArray(argsObj) || argsObj.length !== 3 || !argsObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) {
294-
console.error(`update: Error validating the encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`)
315+
const recordId = await computeZkppSaltRecordId(contract)
316+
if (!recordId) {
317+
console.error(`update: Error obtaining record ID for contract ID ${contract}`)
295318
return false
296319
}
297320

298-
const [hashedPassword, authSalt, contractSalt] = argsObj
321+
const authSalt = Buffer.from(hashStringArray(AUTHSALT, c)).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64')
322+
const contractSalt = Buffer.from(hashStringArray(CONTRACTSALT, c)).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64')
299323

300-
await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt)
324+
const token = encryptSaltUpdate(
325+
hashUpdateSecret,
326+
recordId,
327+
JSON.stringify([Date.now(), hashedPassword, authSalt, contractSalt])
328+
)
301329

302-
return true
330+
return encryptContractSalt(c, JSON.stringify([oldContractSalt, token]))
303331
} catch {
304332
console.error(`update: Error parsing encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`)
305333
}
306334

307335
return false
308336
}
337+
338+
export const redeemSaltUpdateToken = async (contract: string, token: string): Promise<(cid: ?string) => Promise<void>> => {
339+
const recordId = await computeZkppSaltRecordId(contract)
340+
if (!recordId) {
341+
throw new Error('Record ID not found')
342+
}
343+
344+
const decryptedToken = decryptSaltUpdate(
345+
hashUpdateSecret,
346+
recordId,
347+
token
348+
)
349+
350+
const [timestamp, hashedPassword, authSalt, contractSalt] = JSON.parse(decryptedToken)
351+
352+
if (timestamp < (Date.now() - 180e3)) {
353+
throw new Error('ZKPP token expired')
354+
}
355+
356+
return (cid: ?string) => {
357+
return setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt, cid)
358+
}
359+
}

backend/zkppSalt.test.js

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import should from 'should'
55
import initDB from './database.js'
66
import 'should-sinon'
77

8+
import { AUTHSALT, CONTRACTSALT, CS, SALT_LENGTH_IN_OCTETS, SU } from '~/shared/zkppConstants.js'
89
import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt } from './zkppSalt.js'
910

1011
const saltsAndEncryptedHashedPassword = (p: string, secretKey: Uint8Array, hash: string) => {
1112
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)
1213
const dhKey = nacl.hash(nacl.box.before(Buffer.from(p, 'base64url'), secretKey))
13-
const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('AUTHSALT')), dhKey]))).slice(0, 18).toString('base64')
14-
const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('CONTRACTSALT')), dhKey]))).slice(0, 18).toString('base64')
14+
const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(AUTHSALT)), dhKey]))).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64')
15+
const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(CONTRACTSALT)), dhKey]))).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64')
1516
const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength)
1617
const encryptedHashedPassword = Buffer.concat([nonce, nacl.secretbox(Buffer.from(hash), nonce, encryptionKey)]).toString('base64url')
1718

@@ -75,8 +76,10 @@ describe('ZKPP Salt functions', () => {
7576

7677
const saltBuf = Buffer.from(salt, 'base64url')
7778
const nonce = saltBuf.slice(0, nacl.secretbox.nonceLength)
78-
const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('CS'), c])).slice(0, nacl.secretbox.keyLength)
79-
const retrievedContractSalt = Buffer.from(nacl.secretbox.open(saltBuf.slice(nacl.secretbox.nonceLength), nonce, encryptionKey)).toString()
79+
const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(CS), c])).slice(0, nacl.secretbox.keyLength)
80+
const [retrievedContractSalt] = JSON.parse(
81+
Buffer.from(nacl.secretbox.open(saltBuf.slice(nacl.secretbox.nonceLength), nonce, encryptionKey)).toString()
82+
)
8083
should(retrievedContractSalt).equal(contractSalt, 'mismatched contractSalt')
8184
})
8285

@@ -103,14 +106,14 @@ describe('ZKPP Salt functions', () => {
103106
const c = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(hash)), nacl.hash(ħ)]))
104107
const hc = nacl.hash(c)
105108

106-
const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('SU'), c])).slice(0, nacl.secretbox.keyLength)
109+
const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(SU), c])).slice(0, nacl.secretbox.keyLength)
107110
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)
108111

109112
const encryptedArgsCiphertext = nacl.secretbox(Buffer.from(JSON.stringify(['a', 'b', 'c'])), nonce, encryptionKey)
110113

111114
const encryptedArgs = Buffer.concat([nonce, encryptedArgsCiphertext]).toString('base64url')
112115

113116
const updateRes = await updateContractSalt(contract, r, challenge.s, challenge.sig, Buffer.from(hc).toString('base64url'), encryptedArgs)
114-
should(updateRes).equal(true, 'updateContractSalt should be successful')
117+
should(!!updateRes).equal(true, 'updateContractSalt should be successful')
115118
})
116119
})

0 commit comments

Comments
 (0)