Skip to content
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

Use identity contract ID for salt record #2497

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -497,7 +513,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