Skip to content

Commit

Permalink
Merge 353ac10 into 191962e
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat authored Jan 8, 2025
2 parents 191962e + 353ac10 commit 288e665
Show file tree
Hide file tree
Showing 6 changed files with 103 additions and 87 deletions.
40 changes: 9 additions & 31 deletions 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, redeemSaltUpdateToken } from './zkppSalt.js'
import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt, redeemSaltRegistrationToken, redeemSaltUpdateToken } from './zkppSalt.js'
import Bottleneck from 'bottleneck'

const MEGABYTE = 1048576 // TODO: add settings for these
Expand Down Expand Up @@ -119,15 +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) {
throw new Error('Mismatched contract ID and name')
}
updateSalts = await redeemSaltUpdateToken(name, saltUpdateToken)
// If we've got a salt update token (i.e., a password change),
// validate the token
updateSalts = await redeemSaltUpdateToken(deserializedHEAD.contractID, saltUpdateToken)
}
await sbp('backend/server/handleEntry', deserializedHEAD, request.payload)
// If it's a salt update, do it now after handling the message. This way
Expand All @@ -154,7 +148,12 @@ route.POST('/event', {
if (Boom.isBoom(r)) {
return r
}
const saltRegistrationToken = request.headers['shelter-salt-registration-token']
console.info(`new user: ${name}=${deserializedHEAD.contractID} (${ip})`)
if (saltRegistrationToken) {
// If we've got a salt registration token, redeem it
await redeemSaltRegistrationToken(name, deserializedHEAD.contractID, saltRegistrationToken)
}
}
}
}
Expand Down Expand Up @@ -727,10 +726,6 @@ route.GET('/', {}, function (req, h) {
})

route.POST('/zkpp/register/{name}', {
auth: {
strategy: 'chel-shelter',
mode: 'optional'
},
validate: {
payload: Joi.alternatives([
{
Expand All @@ -756,23 +751,6 @@ route.POST('/zkpp/register/{name}', {
])
}
}, async function (req, h) {
if (!req.payload['b']) {
const credentials = req.auth.credentials
if (!credentials?.billableContractID) {
return Boom.unauthorized('Registering a salt requires ownership information', 'shelter')
}
if (req.params['name'].startsWith('_private')) return Boom.notFound()
const contractID = await sbp('backend/db/lookupName', req.params['name'])
if (contractID !== credentials.billableContractID) {
// This ensures that only the owner of the contract can set a salt for it,
// closing a small window of opportunity(*) during which an attacker could
// potentially lock out a new user from their account by registering a
// different salt.
// (*) This is right between the moment an OP_CONTRACT is sent and the
// time this endpoint is called, which should follow almost immediately after.
return Boom.forbidden('Only the owner of this resource may set a password hash')
}
}
try {
if (req.payload['b']) {
const result = await registrationKey(req.params['name'], req.payload['b'])
Expand Down
28 changes: 24 additions & 4 deletions backend/zkppSalt.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export const registrationKey = async (contractID: string, b: string): Promise<fa
}
}

export const register = async (contractID: string, clientPublicKey: string, encryptedSecretKey: string, userSig: string, encryptedHashedPassword: string): Promise<boolean> => {
export const register = async (contractID: string, clientPublicKey: string, encryptedSecretKey: string, userSig: string, encryptedHashedPassword: string): Promise<string | false> => {
if (!verifyChallenge(contractID, clientPublicKey, encryptedSecretKey, userSig)) {
console.warn('register: Error validating challenge: ' + JSON.stringify({ contract: contractID, clientPublicKey, userSig }))
throw new Error('register: Invalid challenge')
Expand Down Expand Up @@ -233,11 +233,15 @@ export const register = async (contractID: string, clientPublicKey: string, encr
return false
}

const [authSalt, contractSalt, hashedPasswordBuf] = parseRegisterSaltRes
const [authSalt, contractSalt, hashedPasswordBuf, sharedEncryptionKey] = parseRegisterSaltRes

await setZkppSaltRecord(contractID, Buffer.from(hashedPasswordBuf).toString(), authSalt, contractSalt)
const token = encryptSaltUpdate(
hashUpdateSecret,
contractID,
JSON.stringify([Date.now(), Buffer.from(hashedPasswordBuf).toString(), authSalt, contractSalt])
)

return true
return encryptContractSalt(sharedEncryptionKey, token)
}

const contractSaltVerifyC = (h: string, r: string, s: string, userHc: string) => {
Expand Down Expand Up @@ -335,6 +339,22 @@ export const updateContractSalt = async (contract: string, r: string, s: string,
return false
}

export const redeemSaltRegistrationToken = async (provisoryRegistrationKey: string, contract: string, token: string): Promise<void> => {
const decryptedToken = decryptSaltUpdate(
hashUpdateSecret,
provisoryRegistrationKey,
token
)

const [timestamp, hashedPassword, authSalt, contractSalt] = JSON.parse(decryptedToken)

if (timestamp < (Date.now() - 180e3)) {
throw new Error('ZKPP token expired')
}

await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt)
}

export const redeemSaltUpdateToken = async (contract: string, token: string): Promise<(cid: ?string) => Promise<void>> => {
const recordId = await computeZkppSaltRecordId(contract)
if (!recordId) {
Expand Down
33 changes: 26 additions & 7 deletions backend/zkppSalt.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/* eslint-env mocha */

import nacl from 'tweetnacl'
import should from 'should'
import initDB from './database.js'
import 'should-sinon'
import nacl from 'tweetnacl'
import initDB from './database.js'

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

const saltsAndEncryptedHashedPassword = (p: string, secretKey: Uint8Array, hash: string) => {
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)
Expand All @@ -19,6 +19,19 @@ const saltsAndEncryptedHashedPassword = (p: string, secretKey: Uint8Array, hash:
return [authSalt, contractSalt, encryptedHashedPassword]
}

const decryptRegistrationRedemptionToken = (p: string, secretKey: Uint8Array, encryptedToken: string) => {
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, SALT_LENGTH_IN_OCTETS).toString('base64')
const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from(CONTRACTSALT)), dhKey]))).slice(0, SALT_LENGTH_IN_OCTETS).toString('base64')
const encryptionKey = nacl.hash(Buffer.concat([Buffer.from(CS), nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength)])).slice(0, nacl.secretbox.keyLength)
const encryptedTokenBuf = Buffer.from(encryptedToken, 'base64url')
const nonce = encryptedTokenBuf.slice(0, nacl.secretbox.nonceLength)
const ciphertext = encryptedTokenBuf.slice(nacl.secretbox.nonceLength)
const token = Buffer.from(nacl.secretbox.open(ciphertext, nonce, encryptionKey)).toString()

return token
}

before(async () => {
await initDB()
})
Expand All @@ -35,7 +48,9 @@ describe('ZKPP Salt functions', () => {
should(regKeyAlice2).be.of.type('object')
const [, , encryptedHashedPasswordAlice1] = saltsAndEncryptedHashedPassword(regKeyAlice1.p, keyPair.secretKey, 'hash')
const res1 = await register('alice', publicKey, regKeyAlice1.s, regKeyAlice1.sig, encryptedHashedPasswordAlice1)
should(res1).equal(true, 'register should allow new entry (alice)')
should(typeof res1).equal('string', 'register should return a token (alice)')
const token = decryptRegistrationRedemptionToken(regKeyAlice1.p, keyPair.secretKey, res1)
await redeemSaltRegistrationToken('alice', 'alice', token)

const [, , encryptedHashedPasswordAlice2] = saltsAndEncryptedHashedPassword(regKeyAlice1.p, keyPair.secretKey, 'hash')
const res2 = await register('alice', publicKey, regKeyAlice2.s, regKeyAlice2.sig, encryptedHashedPasswordAlice2)
Expand All @@ -45,7 +60,7 @@ describe('ZKPP Salt functions', () => {
should(regKeyBob1).be.of.type('object')
const [, , encryptedHashedPasswordBob1] = saltsAndEncryptedHashedPassword(regKeyBob1.p, keyPair.secretKey, 'hash')
const res3 = await register('bob', publicKey, regKeyBob1.s, regKeyBob1.sig, encryptedHashedPasswordBob1)
should(res3).equal(true, 'register should allow new entry (bob)')
should(typeof res3).equal('string', 'register should return a token (bob)')
})

it('getContractSalt() conforms to the API to obtain salt', async () => {
Expand All @@ -60,7 +75,9 @@ describe('ZKPP Salt functions', () => {
const [authSalt, contractSalt, encryptedHashedPassword] = saltsAndEncryptedHashedPassword(regKey.p, keyPair.secretKey, hash)

const res = await register(contract, publicKey, regKey.s, regKey.sig, encryptedHashedPassword)
should(res).equal(true, 'register should allow new entry (' + contract + ')')
should(typeof res).equal('string', 'register should allow new entry (' + contract + ')')
const token = decryptRegistrationRedemptionToken(regKey.p, keyPair.secretKey, res)
await redeemSaltRegistrationToken(contract, contract, token)

const b = Buffer.from(nacl.hash(Buffer.from(r))).toString('base64url')
const challenge = await getChallenge(contract, b)
Expand Down Expand Up @@ -95,7 +112,9 @@ describe('ZKPP Salt functions', () => {
const [authSalt, , encryptedHashedPassword] = saltsAndEncryptedHashedPassword(regKey.p, keyPair.secretKey, hash)

const res = await register(contract, publicKey, regKey.s, regKey.sig, encryptedHashedPassword)
should(res).equal(true, 'register should allow new entry (' + contract + ')')
should(typeof res).equal('string', 'register should allow new entry (' + contract + ')')
const token = decryptRegistrationRedemptionToken(regKey.p, keyPair.secretKey, res)
await redeemSaltRegistrationToken(contract, contract, token)

const b = Buffer.from(nacl.hash(Buffer.from(r))).toString('base64url')
const challenge = await getChallenge(contract, b)
Expand Down
37 changes: 10 additions & 27 deletions frontend/controller/actions/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,10 +180,7 @@ export default (sbp('sbp/selectors/register', {
username,
email,
picture,
r,
s,
sig,
Eh
token
}) {
let finalPicture = `${self.location.origin}/assets/images/user-avatar-default.png`

Expand Down Expand Up @@ -232,7 +229,14 @@ export default (sbp('sbp/selectors/register', {
try {
await sbp('chelonia/out/registerContract', {
contractName: 'gi.contracts/identity',
publishOptions,
publishOptions: {
...publishOptions,
headers: {
...publishOptions?.headers,
'shelter-namespace-registration': username,
'shelter-salt-registration-token': token.valueOf()
}
},
signingKeyId: IPKid,
actionSigningKeyId: CSKid,
actionEncryptionKeyId: PEKid,
Expand Down Expand Up @@ -330,25 +334,6 @@ export default (sbp('sbp/selectors/register', {
await sbp('chelonia/contract/retain', message.contractID(), { ephemeral: true })

try {
// Register password salt
const res = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, {
method: 'POST',
headers: {
'authorization': await sbp('chelonia/shelterAuthorizationHeader', message.contractID()),
'content-type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'r': r,
's': s,
'sig': sig,
'Eh': Eh
})
})

if (!res.ok) {
throw new Error('Unable to register hash')
}

userID = message.contractID()
if (picture) {
try {
Expand All @@ -368,8 +353,7 @@ export default (sbp('sbp/selectors/register', {
// calling 'chelonia/out/registerContract' here. We use a getter for
// `picture` so that the action sent has the correct value
attributes: { username, email, get picture () { return finalPicture } }
},
namespaceRegistration: username
}
})

// After the contract has been created, store persistent keys
Expand Down Expand Up @@ -955,7 +939,6 @@ export default (sbp('sbp/selectors/register', {
signingKeyId: oldIPKid,
publishOptions: {
headers: {
'shelter-name': username,
'shelter-salt-update-token': updateToken
}
},
Expand Down
44 changes: 30 additions & 14 deletions frontend/controller/app/identity.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,10 @@ sbp('okTurtles.events/on', LOGOUT, (a) => {
*/

export default (sbp('sbp/selectors/register', {
'gi.app/identity/retrieveSalt': async (username: string, password: Secret<string>): Promise<[string, ?string]> => {
'gi.app/identity/retrieveSalt': async (identityContractID: 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)}`)
const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(identityContractID)}/auth_hash?b=${encodeURIComponent(b)}`)
.then(handleFetchResult('json'))

const { authSalt, s, sig } = authHash
Expand All @@ -180,7 +180,7 @@ export default (sbp('sbp/selectors/register', {

const [c, hc] = computeCAndHc(r, s, h)

const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({
const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(identityContractID)}/contract_hash?${(new URLSearchParams({
'r': r,
's': s,
'sig': sig,
Expand All @@ -190,10 +190,10 @@ export default (sbp('sbp/selectors/register', {
// [contractSalt, cid]
return JSON.parse(decryptContractSalt(c, contractHash))
},
'gi.app/identity/updateSaltRequest': async (username: string, oldPassword: Secret<string>, newPassword: Secret<string>) => {
'gi.app/identity/updateSaltRequest': async (identityContractID: 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)}`)
const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(identityContractID)}/auth_hash?b=${encodeURIComponent(b)}`)
.then(handleFetchResult('json'))

const { authSalt, s, sig } = authHash
Expand All @@ -204,7 +204,7 @@ export default (sbp('sbp/selectors/register', {

const [contractSalt, encryptedArgs] = await buildUpdateSaltRequestEc(newPassword.valueOf(), c)

const response = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/updatePasswordHash`, {
const response = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(identityContractID)}/updatePasswordHash`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded'
Expand Down Expand Up @@ -233,7 +233,6 @@ export default (sbp('sbp/selectors/register', {
const keyPair = boxKeyPair()
const r = Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-')
const b = hash(r)
// TODO: use the contractID instead, and move this code down below the registration
const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, {
method: 'POST',
headers: {
Expand All @@ -245,7 +244,27 @@ export default (sbp('sbp/selectors/register', {

const { p, s, sig } = registrationRes

const [contractSalt, Eh] = await buildRegisterSaltRequest(p, keyPair.secretKey, password)
const [contractSalt, Eh, encryptionKey] = await buildRegisterSaltRequest(p, keyPair.secretKey, password)

const saltRegistrationTokenReq = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
'r': r,
's': s,
'sig': sig,
'Eh': Eh
})
})

if (!saltRegistrationTokenReq.ok) {
throw new Error('Unable to register hash')
}

const encryptedToken = await saltRegistrationTokenReq.text()
const token = decryptContractSalt(encryptionKey, encryptedToken)

// Create the necessary keys to initialise the contract
const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt)
Expand All @@ -260,10 +279,7 @@ export default (sbp('sbp/selectors/register', {
username,
email,
picture,
r,
s,
sig,
Eh
token: new Secret(token)
})

return userID
Expand Down Expand Up @@ -325,7 +341,7 @@ export default (sbp('sbp/selectors/register', {
// the password) will be passed to the service worker.
if (password) {
try {
const [salt, cid] = await sbp('gi.app/identity/retrieveSalt', username, wpassword)
const [salt, cid] = await sbp('gi.app/identity/retrieveSalt', identityContractID, wpassword)
const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt)
transientSecretKeys.push(IEK)
oldKeysAnchorCid = cid
Expand Down Expand Up @@ -487,7 +503,7 @@ export default (sbp('sbp/selectors/register', {
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', identityContractID, wOldPassword, wNewPassword)

const oldIPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, oldPassword, oldContractSalt)
const oldIEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, oldPassword, oldContractSalt)
Expand Down
Loading

0 comments on commit 288e665

Please sign in to comment.