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

[WIP] Signatures and encryption: Preliminary work #1257

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ebe6ee0
Signatures and encryption: Preliminary work
corrideat May 1, 2022
05976eb
Fix typo
corrideat May 1, 2022
c690fe3
Updated tests, added key ops, added keys to contracts
corrideat May 15, 2022
93274b3
New OP_KEYSHARE
corrideat Jun 5, 2022
b1e3763
Merge branch 'master' into feature/password-state-new
corrideat Jun 5, 2022
0e366f7
Broken changes
corrideat Jun 19, 2022
94a7601
ZKPP password salt
corrideat Jul 28, 2022
e9e3851
ZKPP: Frontend implementation
corrideat Aug 8, 2022
c6802d4
Merge branch 'master' into feature/password-state-new
corrideat Aug 15, 2022
1051639
Prevent Grunt from running
corrideat Aug 15, 2022
e86df63
OP_KEY_REQUEST
corrideat Aug 22, 2022
158b35d
Feedback on ZKPP and started moving invites into Chelonia
corrideat Sep 12, 2022
c6b0dfb
Error handling
corrideat Sep 19, 2022
742902e
Implemented OP_KEY_REQUEST
corrideat Sep 19, 2022
f2812fe
Bugfixes
corrideat Sep 26, 2022
55d126a
Merge branch 'master' into feature/password-state-new
corrideat Oct 10, 2022
750703b
Group joining (broken)
corrideat Oct 10, 2022
096f016
Internals: group joining
corrideat Oct 24, 2022
03bd885
Merge branch 'master' into feature/password-state-new
corrideat Oct 24, 2022
579474c
Invite link state, some bugfixes
corrideat Oct 31, 2022
64e34d3
- Implement feedback from PR
corrideat Nov 7, 2022
b44dcb2
Updates: better approach to withEnv as suggestion or code + Enforce m…
corrideat Nov 14, 2022
42df852
Bugfixes: login, group joining
corrideat Jan 9, 2023
ce5223e
Functionality for joining chat
corrideat Jan 16, 2023
2f70afa
State management: support chat
corrideat Jan 23, 2023
8fa39c1
Bugfixes and PoC for tests
corrideat Jan 30, 2023
a6a3ebd
Merge branch 'master' into feature/password-state-new
corrideat Jan 30, 2023
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
1 change: 0 additions & 1 deletion .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
.*/frontend/assets/.*
.*/frontend/controller/service-worker.js
.*/frontend/utils/blockies.js
.*/frontend/utils/crypto.js
.*/frontend/utils/vuexQueue.js
.*/frontend/model/contracts/misc/flowTyper.js
.*/historical/.*
Expand Down
2 changes: 2 additions & 0 deletions Gruntfile.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict'

if (process.env['CI']) process.exit(1)

// =======================
// Entry point.
//
Expand Down
2 changes: 1 addition & 1 deletion backend/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export default (sbp('sbp/selectors/register', {
const json = `"${strToB64(entry.serialize())}"`
if (currentHEAD !== hash) {
this.push(prefix + json)
currentHEAD = entry.message().previousHEAD
currentHEAD = entry.head().previousHEAD
prefix = ','
} else {
this.push(prefix + json + ']')
Expand Down
95 changes: 95 additions & 0 deletions backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +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, update } from './zkppSalt.js'

const Boom = require('@hapi/boom')
const Joi = require('@hapi/joi')
Expand Down Expand Up @@ -247,3 +248,97 @@ route.GET('/app/{path*}', {}, {
route.GET('/', {}, function (req, h) {
return h.redirect('/app/')
})

route.POST('/zkpp/{contract}', {
validate: {
payload: Joi.alternatives([
{
b: Joi.string().required()
},
{
r: Joi.string().required(),
s: Joi.string().required(),
sig: Joi.string().required(),
Eh: Joi.string().required()
}
])
}
}, async function (req, h) {
if (req.payload['b']) {
const result = await registrationKey(req.params['contract'], req.payload['b'])

if (!result) {
return Boom.internal('internal error')
}

return result
} else {
const result = await register(req.params['contract'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['Eh'])

if (!result) {
return Boom.internal('internal error')
}

return result
}
})

route.GET('/zkpp/{contract}/auth_hash', {}, async function (req, h) {
if (!req.query['b']) {
return Boom.badRequest('b query param required')
}
taoeffect marked this conversation as resolved.
Show resolved Hide resolved

const challenge = await getChallenge(req.params['contract'], req.query['b'])

if (!challenge) {
return Boom.internal('internal error')
}

return challenge
})

route.GET('/zkpp/{contract}/contract_hash', {}, async function (req, h) {
if (!req.query['r']) {
return Boom.badRequest('r query param required')
}

if (!req.query['s']) {
return Boom.badRequest('s query param required')
}

if (!req.query['sig']) {
return Boom.badRequest('sig query param required')
}

if (!req.query['hc']) {
return Boom.badRequest('hc query param required')
}
taoeffect marked this conversation as resolved.
Show resolved Hide resolved

const salt = await getContractSalt(req.params['contract'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc'])

if (!salt) {
return Boom.internal('internal error')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whenever there's a strange error that happens on our end, we should be able to at least realize where the error is coming from. Here the code is immediately returning with a generic message and no additional logging that would tell us where the error came from. If our logging code shows the file and line number for all such errors, this might be OK, but please double-check that it actually does in this situation (I don't remember whether it does).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, this error happens when the salt cannot be fetched (because it didn't exist or because the protocol wasn't executed correctly). I'm a bit torn about what to do in this situation, as having a 'debug' mode can be useful, but in general we shouldn't fill up the logs when someone maliciously executes the protocol, especially if that can result in timing information. Should I add some log entry like 'unable to get contract salt'?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we shouldn't fill up the logs when someone maliciously executes the protocol, especially if that can result in timing information. Should I add some log entry like 'unable to get contract salt'?

If an error occurs we should always log it, and especially if the error could occur because of malicious activity. This is essential for things like fail2ban. We should also log it in a consistent way so that fail2ban rules can be written to extract the IP.

}

return salt
})

route.PUT('/zkpp/{contract}', {
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
validate: {
payload: Joi.object({
r: Joi.string().required(),
s: Joi.string().required(),
sig: Joi.string().required(),
hc: Joi.string().required(),
Ea: Joi.string().required()
})
}
}, async function (req, h) {
const result = await update(req.params['contract'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['hc'], req.payload['Ea'])

if (!result) {
return Boom.internal('internal error')
}

return result
})
229 changes: 229 additions & 0 deletions backend/zkppSalt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
import { blake32Hash } from '~/shared/functions.js'
import { timingSafeEqual } from 'crypto'
import nacl from 'tweetnacl'
import sbp from '@sbp/sbp'
import { boxKeyPair, encryptContractSalt, hashStringArray, hashRawStringArray, hash, parseRegisterSalt, randomNonce, computeCAndHc } from '~/shared/zkpp.js'

// TODO HARDCODED VALUES
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
const recordPepper = 'pepper'
const recordMasterKey = 'masterKey'
const challengeSecret = 'secret'
const registrationSecret = 'secret'
Comment on lines +9 to +12
Copy link
Member

@taoeffect taoeffect Feb 16, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want to have server admins dealing with anything related to cryptography unless for some reason they must and there's literally no way around it.

So what are these values and how can we avoid making the admin set them via configs?

It's unclear to me if these values must be different from server to server or whether they don't need to be. The consequences / explanations aren't explained anywhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what are these values and how can we avoid making the admin set them via configs?

These are secrets the server uses internally for deriving certain values that must be opaque to the client. As these values need to be persistent, I can't come up with a way of avoiding them entirely (although a solution could be for them to be set to some default value).9

const recordPepper = 'pepper'

Perhaps the least security-critical value. Used to derive a record ID which clients / 3rd parties cannot guess for storing the ZKPP

const recordMasterKey = 'masterKey'

Used to encrypt ZKPP records. If this value is leaked, 3rd parties with access to the database could defeat the protocol.

const challengeSecret = 'secret'

If this value is known, the protocol can be defeated by providing valid answers to challenges.

const registrationSecret = 'secret'

Similarly, but for registration.

Copy link
Member

@taoeffect taoeffect Mar 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what about making them random and storing them locally in files? Would that work?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, it could be a part of the installation process, and could be automated.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to use this convention to mark threads as needing their commentary transferred (as comments) to the code.

➡️ code comments

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note from call: we can get rid of recordPepper, just use private/rid/${contractID}

challengeSecret is the key to the keyed hash function in step 3 of the protocol.

const maxAge = 30

const getZkppSaltRecord = async (contract: string) => {
const recordId = blake32Hash(hashStringArray('RID', contract, recordPepper))
corrideat marked this conversation as resolved.
Show resolved Hide resolved
const record = await sbp('chelonia/db/get', recordId)

if (record) {
const encryptionKey = hashStringArray('REK', contract, recordMasterKey).slice(0, nacl.secretbox.keyLength)

const recordBuf = Buffer.from(record.replace(/_/g, '/').replace(/-/g, '+'), 'base64')
corrideat marked this conversation as resolved.
Show resolved Hide resolved
const nonce = recordBuf.slice(0, nacl.secretbox.nonceLength)
const recordCiphertext = recordBuf.slice(nacl.secretbox.nonceLength)
const recordPlaintext = nacl.secretbox.open(recordCiphertext, nonce, encryptionKey)

if (!recordPlaintext) {
return null
}

const recordString = Buffer.from(recordPlaintext).toString('utf-8')

try {
const recordObj = JSON.parse(recordString)

if (!Array.isArray(recordObj) || recordObj.length !== 3 || !recordObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) {
return null
}

const [hashedPassword, authSalt, contractSalt] = recordObj

return {
hashedPassword,
authSalt,
contractSalt
}
} catch {
// empty
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}
}

return null
}

const setZkppSaltRecord = async (contract: string, hashedPassword: string, authSalt: string, contractSalt: string) => {
const recordId = blake32Hash(hashStringArray('RID', contract, recordPepper))
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
const encryptionKey = hashStringArray('REK', contract, recordMasterKey).slice(0, nacl.secretbox.keyLength)
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)
const recordPlaintext = JSON.stringify([hashedPassword, authSalt, contractSalt])
const recordCiphertext = nacl.secretbox(Buffer.from(recordPlaintext), nonce, encryptionKey)
const recordBuf = Buffer.concat([nonce, recordCiphertext])
const record = recordBuf.toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '')
await sbp('chelonia/db/set', recordId, record)
}

export const getChallenge = async (contract: string, b: string): Promise<false | {authSalt: string; s: string; sig: string;}> => {
const record = await getZkppSaltRecord(contract)
if (!record) {
return false
}
const { authSalt } = record
const s = randomNonce()
const now = (Date.now() / 1000 | 0).toString(16)
const sig = [now, Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '')].join(',')

return {
authSalt,
s,
sig
}
}

const verifyChallenge = (contract: string, r: string, s: string, userSig: string): boolean => {
// Check sig has the right format
if (!/^[a-fA-F0-9]{1,11},[a-zA-Z0-9_-]{86}(?:==)?$/.test(userSig)) {
return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}

const [then, mac] = userSig.split(',')
const now = Date.now() / 1000 | 0
const iThen = Number.parseInt(then, 16)

// Check that sig is no older than Xs
if (!(iThen <= now) || !(iThen >= (now - maxAge))) {
return false
}

const b = hash(r)
const sig = hashStringArray(contract, b, s, then, challengeSecret)
const macBuf = Buffer.from(mac.replace(/_/g, '/').replace(/-/g, '+'), 'base64')

return sig.byteLength === macBuf.byteLength && timingSafeEqual(sig, macBuf)
}

export const registrationKey = async (contract: string, b: string): Promise<false | {s: string; p: string; sig: string;}> => {
const record = await getZkppSaltRecord(contract)
if (record) {
return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}

const encryptionKey = hashStringArray('REG', contract, registrationSecret).slice(0, nacl.secretbox.keyLength)
const nonce = nacl.randomBytes(nacl.secretbox.nonceLength)
const keyPair = boxKeyPair()
const s = Buffer.concat([nonce, nacl.secretbox(keyPair.secretKey, nonce, encryptionKey)]).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '')
const now = (Date.now() / 1000 | 0).toString(16)
const sig = [now, Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '')].join(',')

return {
s,
p: Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, ''),
sig
}
}

export const register = async (contract: string, clientPublicKey: string, encryptedSecretKey: string, userSig: string, encryptedHashedPassword: string): Promise<boolean> => {
if (!verifyChallenge(contract, clientPublicKey, encryptedSecretKey, userSig)) {
return false
}

const record = await getZkppSaltRecord(contract)

if (record) {
return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}

const encryptedSecretKeyBuf = Buffer.from(encryptedSecretKey.replace(/_/g, '/').replace(/-/g, '+'), 'base64')
const encryptionKey = hashStringArray('REG', contract, registrationSecret).slice(0, nacl.secretbox.keyLength)
const secretKeyBuf = nacl.secretbox.open(encryptedSecretKeyBuf.slice(nacl.secretbox.nonceLength), encryptedSecretKeyBuf.slice(0, nacl.secretbox.nonceLength), encryptionKey)

const parseRegisterSaltRes = parseRegisterSalt(clientPublicKey, secretKeyBuf, encryptedHashedPassword)

if (!parseRegisterSaltRes) {
return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}

const [authSalt, contractSalt, hashedPasswordBuf] = parseRegisterSaltRes

await setZkppSaltRecord(contract, Buffer.from(hashedPasswordBuf).toString(), authSalt, contractSalt)

return true
}

const contractSaltVerifyC = (h: string, r: string, s: string, userHc: string) => {
const [c, hc] = computeCAndHc(r, s, h)
const userHcBuf = Buffer.from(userHc.replace(/_/g, '/').replace(/-/g, '+'), 'base64')

if (hc.byteLength === userHcBuf.byteLength && timingSafeEqual(hc, userHcBuf)) {
return c
}

return false
}

export const getContractSalt = async (contract: string, r: string, s: string, sig: string, hc: string): Promise<false | string> => {
if (!verifyChallenge(contract, r, s, sig)) {
return false
}

const record = await getZkppSaltRecord(contract)
if (!record) {
return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}

const { hashedPassword, contractSalt } = record

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

if (!c) {
return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}

return encryptContractSalt(c, contractSalt)
}

export const update = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise<boolean> => {
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
if (!verifyChallenge(contract, r, s, sig)) {
return false
}

const record = await getZkppSaltRecord(contract)
if (!record) {
return false
}
const { hashedPassword } = record

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

if (!c) {
return false
}

const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength)
const encryptedArgsBuf = Buffer.from(encryptedArgs.replace(/_/g, '/').replace(/-/g, '+'), 'base64')
const nonce = encryptedArgsBuf.slice(0, nacl.secretbox.nonceLength)
const encrytedArgsCiphertext = encryptedArgsBuf.slice(nacl.secretbox.nonceLength)

const args = nacl.secretbox.open(encrytedArgsCiphertext, nonce, encryptionKey)

if (!args) {
return false
}

try {
const argsObj = JSON.parse(Buffer.from(args).toString())

if (!Array.isArray(argsObj) || argsObj.length !== 3 || !argsObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) {
return false
}

const [hashedPassword, authSalt, contractSalt] = argsObj

await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt)

return true
} catch {
// empty
}

return false
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
}
Loading