-
-
Notifications
You must be signed in to change notification settings - Fork 44
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Password changes #2446
Password changes #2446
Changes from all commits
57eb477
6a5a0d5
140c826
355528c
ffac7d8
e88c818
2285233
882d00e
3572c04
355eb78
58549ed
7b10144
b382bf1
59ab4fe
7cc4af4
0d95bc6
cda2313
be3d7d7
e8f5201
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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,24 @@ 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) { | ||
throw new Error('Mismatched contract ID and name') | ||
} | ||
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) | ||
Comment on lines
+120
to
+136
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Claude:
Is he full of it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potentially it could happen, however, not really, because the token only applies to one contract and we can't write to the same contract simultaneously (or shouldn't be able to), so |
||
if (deserializedHEAD.isFirstMessage) { | ||
// Store attribution information | ||
if (credentials?.billableContractID) { | ||
|
@@ -821,7 +838,7 @@ route.GET('/zkpp/{name}/contract_hash', { | |
return Boom.internal('internal error') | ||
}) | ||
|
||
route.POST('/zkpp/updatePasswordHash/{name}', { | ||
route.POST('/zkpp/{name}/updatePasswordHash', { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Renamed for consistency |
||
validate: { | ||
payload: Joi.object({ | ||
r: Joi.string().required(), | ||
|
@@ -841,7 +858,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') | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,14 +1,17 @@ | ||
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, decryptSaltUpdate, encryptContractSalt, encryptSaltUpdate, hash, hashRawStringArray, hashStringArray, parseRegisterSalt, randomNonce } from '~/shared/zkpp.js' | ||
import { AUTHSALT, CONTRACTSALT, SALT_LENGTH_IN_OCTETS, SU } from '~/shared/zkppConstants.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. | ||
|
@@ -60,10 +63,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') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Used for encrypting the update token. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are these strings like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Probably not, since they're only used here for initialisation purposes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They also have no particular significance with regard to the protocol. |
||
} | ||
|
||
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) | ||
|
@@ -85,17 +101,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) || | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is because before the record was a triplet, and now it can be a triplet or a 4-tuple (with the 4th entry being a CID, if there's been a password update) |
||
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) | ||
|
@@ -105,11 +127,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')) | ||
|
@@ -242,7 +264,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) | ||
|
||
|
@@ -251,10 +273,10 @@ 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])) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We now return the contractSalt and the CID. |
||
} | ||
|
||
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') | ||
|
@@ -266,7 +288,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) | ||
|
||
|
@@ -275,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) | ||
|
@@ -288,21 +310,50 @@ export const updateContractSalt = async (contract: string, r: string, s: string, | |
} | ||
|
||
try { | ||
const argsObj = JSON.parse(Buffer.from(args).toString()) | ||
const hashedPassword = 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 })})`) | ||
const recordId = await computeZkppSaltRecordId(contract) | ||
if (!recordId) { | ||
console.error(`update: Error obtaining record ID for contract ID ${contract}`) | ||
return false | ||
} | ||
|
||
const [hashedPassword, authSalt, contractSalt] = argsObj | ||
const authSalt = Buffer.from(hashStringArray(AUTHSALT, c)).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64') | ||
const contractSalt = Buffer.from(hashStringArray(CONTRACTSALT, c)).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64') | ||
|
||
await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt) | ||
const token = encryptSaltUpdate( | ||
hashUpdateSecret, | ||
recordId, | ||
JSON.stringify([Date.now(), hashedPassword, authSalt, contractSalt]) | ||
) | ||
|
||
return true | ||
return encryptContractSalt(c, JSON.stringify([oldContractSalt, token])) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of returning |
||
} 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<(cid: ?string) => 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 (cid: ?string) => { | ||
return setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt, cid) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two-step process to allow validating the token first before making the change. |
||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This implements the atomic update using a token.