diff --git a/.flowconfig b/.flowconfig index 81200dce27..631759874d 100644 --- a/.flowconfig +++ b/.flowconfig @@ -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/.* diff --git a/Gruntfile.js b/Gruntfile.js index 588e36ecae..ec57935222 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,5 +1,7 @@ 'use strict' +if (process.env['CI']) process.exit(1) + // ======================= // Entry point. // diff --git a/backend/database.js b/backend/database.js index c47eb9e689..b97468f479 100644 --- a/backend/database.js +++ b/backend/database.js @@ -38,7 +38,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 + ']') @@ -74,7 +74,7 @@ export default (sbp('sbp/selectors/register', { this.push(prefix + json) prefix = ',' limit-- - currentHEAD = entry.message().previousHEAD + currentHEAD = entry.head().previousHEAD } } catch (e) { // TODO: properly return an error to caller, see https://nodejs.org/api/stream.html#errors-while-reading @@ -109,7 +109,7 @@ export default (sbp('sbp/selectors/register', { offset-- } - currentHEAD = entry.message().previousHEAD + currentHEAD = entry.head().previousHEAD if (!currentHEAD || (isMet && !offset)) { this.push(']') this.push(null) diff --git a/backend/routes.js b/backend/routes.js index be6114b222..689e764abf 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -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') @@ -247,3 +248,115 @@ 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) { + try { + if (req.payload['b']) { + const result = await registrationKey(req.params['contract'], req.payload['b']) + + if (result) { + 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 result + } + } + } catch (e) { + const ip = req.info.remoteAddress + console.error('Error at POST /zkpp/{contract}: ' + e.message, { ip }) + } + + return Boom.internal('internal error') +}) + +route.GET('/zkpp/{contract}/auth_hash', {}, async function (req, h) { + if (!req.query['b']) { + return Boom.badRequest('b query param required') + } + + try { + const challenge = await getChallenge(req.params['contract'], req.query['b']) + + if (challenge) { + return challenge + } + } catch (e) { + const ip = req.info.remoteAddress + console.error('Error at GET /zkpp/{contract}/auth_hash: ' + e.message, { ip }) + } + + return Boom.internal('internal error') +}) + +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') + } + + try { + const salt = await getContractSalt(req.params['contract'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc']) + + if (salt) { + return salt + } + } catch (e) { + const ip = req.info.remoteAddress + console.error('Error at GET /zkpp/{contract}/contract_hash: ' + e.message, { ip }) + } + + return Boom.internal('internal error') +}) + +route.PUT('/zkpp/{contract}', { + 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) { + try { + 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 result + } + } catch (e) { + const ip = req.info.remoteAddress + console.error('Error at GET /zkpp/{contract}/contract_hash: ' + e.message, { ip }) + } + + return Boom.internal('internal error') +}) diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js new file mode 100644 index 0000000000..c0da218d34 --- /dev/null +++ b/backend/zkppSalt.js @@ -0,0 +1,252 @@ +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, base64urlToBase64, base64ToBase64url } from '~/shared/zkpp.js' + +// TODO HARDCODED VALUES +// These values will eventually come from server the configuration +const recordPepper = 'pepper' +const recordMasterKey = 'masterKey' +const challengeSecret = 'secret' +const registrationSecret = 'secret' +const maxAge = 30 + +const getZkppSaltRecord = async (contract: string) => { + const recordId = blake32Hash(hashStringArray('RID', contract, recordPepper)) + 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(base64urlToBase64(record), 'base64') + 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)) { + console.error('Error validating encrypted JSON object ' + recordId) + return null + } + + const [hashedPassword, authSalt, contractSalt] = recordObj + + return { + hashedPassword, + authSalt, + contractSalt + } + } catch { + console.error('Error parsing encrypted JSON object ' + recordId) + } + } + + return null +} + +const setZkppSaltRecord = async (contract: string, hashedPassword: string, authSalt: string, contractSalt: string) => { + const recordId = blake32Hash(hashStringArray('RID', contract, recordPepper)) + 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 = base64ToBase64url(recordBuf.toString('base64')) + await sbp('chelonia/db/set', recordId, record) +} + +export const getChallenge = async (contract: string, b: string): Promise => { + const record = await getZkppSaltRecord(contract) + if (!record) { + console.debug('getChallenge: Error obtaining ZKPP salt record for contract ID ' + contract) + return false + } + const { authSalt } = record + const s = randomNonce() + const now = (Date.now() / 1000 | 0).toString(16) + const sig = [now, base64ToBase64url(Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64'))].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 + } + + 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(base64urlToBase64(mac), 'base64') + + return sig.byteLength === macBuf.byteLength && timingSafeEqual(sig, macBuf) +} + +export const registrationKey = async (contract: string, b: string): Promise => { + const record = await getZkppSaltRecord(contract) + if (record) { + throw new Error('registrationKey: User record already exists') + } + + const encryptionKey = hashStringArray('REG', contract, registrationSecret).slice(0, nacl.secretbox.keyLength) + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + const keyPair = boxKeyPair() + const s = base64ToBase64url(Buffer.concat([nonce, nacl.secretbox(keyPair.secretKey, nonce, encryptionKey)]).toString('base64')) + const now = (Date.now() / 1000 | 0).toString(16) + const sig = [now, base64ToBase64url(Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64'))].join(',') + + return { + s, + p: base64ToBase64url(Buffer.from(keyPair.publicKey).toString('base64')), + sig + } +} + +export const register = async (contract: string, clientPublicKey: string, encryptedSecretKey: string, userSig: string, encryptedHashedPassword: string): Promise => { + if (!verifyChallenge(contract, clientPublicKey, encryptedSecretKey, userSig)) { + console.debug('register: Error validating challenge: ' + JSON.stringify({ contract, clientPublicKey, userSig })) + throw new Error('register: Invalid challenge') + } + + const record = await getZkppSaltRecord(contract) + + if (record) { + console.error('register: Error: ZKPP salt record for contract ID ' + contract + ' already exists') + return false + } + + const encryptedSecretKeyBuf = Buffer.from(base64urlToBase64(encryptedSecretKey), '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) + + // Likely a bad implementation on the client side + if (!secretKeyBuf) { + console.debug(`register: Error decrypting arguments for contract ID ${contract} (${JSON.stringify({ clientPublicKey, userSig })})`) + return false + } + + const parseRegisterSaltRes = parseRegisterSalt(clientPublicKey, secretKeyBuf, encryptedHashedPassword) + + // Likely a bad implementation on the client side + if (!parseRegisterSaltRes) { + console.debug(`register: Error parsing registration salt for contract ID ${contract} (${JSON.stringify({ clientPublicKey, userSig })})`) + return false + } + + 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(base64urlToBase64(userHc), '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 => { + if (!verifyChallenge(contract, r, s, sig)) { + console.debug('getContractSalt: Error validating challenge: ' + JSON.stringify({ contract, r, s, sig })) + throw new Error('getContractSalt: Bad challenge') + } + + const record = await getZkppSaltRecord(contract) + if (!record) { + // This shouldn't happen at this stage as the record was already obtained + console.error('getContractSalt: Error obtaining ZKPP salt record for contract ID ' + contract) + return false + } + + const { hashedPassword, contractSalt } = record + + const c = contractSaltVerifyC(hashedPassword, r, s, hc) + + if (!c) { + console.error(`getContractSalt: Error verifying challenge for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + throw new Error('getContractSalt: Bad challenge') + } + + return encryptContractSalt(c, contractSalt) +} + +export const update = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise => { + if (!verifyChallenge(contract, r, s, sig)) { + console.debug('update: Error validating challenge: ' + JSON.stringify({ contract, r, s, sig })) + throw new Error('update: Bad challenge') + } + + const record = await getZkppSaltRecord(contract) + if (!record) { + // This shouldn't happen at this stage as the record was already obtained + console.error('update: Error obtaining ZKPP salt record for contract ID ' + contract) + return false + } + const { hashedPassword } = record + + const c = contractSaltVerifyC(hashedPassword, r, s, hc) + + if (!c) { + console.error(`update: Error verifying challenge for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + throw new Error('update: Bad challenge') + } + + 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) + + const args = nacl.secretbox.open(encryptedArgsCiphertext, nonce, encryptionKey) + + if (!args) { + console.error(`update: Error decrypting arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + 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)) { + console.error(`update: Error validating the encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + return false + } + + const [hashedPassword, authSalt, contractSalt] = argsObj + + await setZkppSaltRecord(contract, hashedPassword, authSalt, contractSalt) + + return true + } catch { + console.error(`update: Error parsing encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + } + + return false +} diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js new file mode 100644 index 0000000000..12e1cac75d --- /dev/null +++ b/backend/zkppSalt.test.js @@ -0,0 +1,111 @@ +/* eslint-env mocha */ + +import nacl from 'tweetnacl' +import should from 'should' +import 'should-sinon' + +import { registrationKey, register, getChallenge, getContractSalt, update } from './zkppSalt.js' + +const saltsAndEncryptedHashedPassword = (p: string, secretKey: Uint8Array, hash: string) => { + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + 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, 18).toString('base64') + const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('CONTRACTSALT')), dhKey]))).slice(0, 18).toString('base64') + const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength) + const encryptedHashedPassword = Buffer.concat([nonce, nacl.secretbox(Buffer.from(hash), nonce, encryptionKey)]).toString('base64url') + + return [authSalt, contractSalt, encryptedHashedPassword] +} + +describe('ZKPP Salt functions', () => { + it('register() conforms to the API to register a new salt', async () => { + const keyPair = nacl.box.keyPair() + const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') + + const regKeyAlice1 = await registrationKey('alice', publicKeyHash) + const regKeyAlice2 = await registrationKey('alice', publicKeyHash) + should(regKeyAlice1).be.of.type('object') + 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)') + + const [, , encryptedHashedPasswordAlice2] = saltsAndEncryptedHashedPassword(regKeyAlice1.p, keyPair.secretKey, 'hash') + const res2 = await register('alice', publicKey, regKeyAlice2.s, regKeyAlice2.sig, encryptedHashedPasswordAlice2) + should(res2).equal(false, 'register should not overwrite entry (alice)') + + const regKeyBob1 = await registrationKey('bob', publicKeyHash) + 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)') + }) + + it('getContractSalt() conforms to the API to obtain salt', async () => { + const keyPair = nacl.box.keyPair() + const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') + + const [contract, hash, r] = ['getContractSalt', 'hash', 'r'] + const regKey = await registrationKey(contract, publicKeyHash) + should(regKey).be.of.type('object') + + 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 + ')') + + const b = Buffer.from(nacl.hash(Buffer.from(r))).toString('base64url') + const challenge = await getChallenge(contract, b) + should(challenge).be.of.type('object', 'challenge should be object') + should(challenge.authSalt).equal(authSalt, 'mismatched authSalt') + + const ħ = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(r)), nacl.hash(Buffer.from(challenge.s))])) + const c = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(hash)), nacl.hash(ħ)])) + const hc = nacl.hash(c) + + const salt = await getContractSalt(contract, r, challenge.s, challenge.sig, Buffer.from(hc).toString('base64url')) + should(salt).be.of.type('string', 'salt response should be string') + + const saltBuf = Buffer.from(salt, 'base64url') + const nonce = saltBuf.slice(0, nacl.secretbox.nonceLength) + const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('CS'), c])).slice(0, nacl.secretbox.keyLength) + const retrievedContractSalt = Buffer.from(nacl.secretbox.open(saltBuf.slice(nacl.secretbox.nonceLength), nonce, encryptionKey)).toString() + should(retrievedContractSalt).equal(contractSalt, 'mismatched contractSalt') + }) + + it('update() conforms to the API to update salt', async () => { + const keyPair = nacl.box.keyPair() + const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') + const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') + + const [contract, hash, r] = ['update', 'hash', 'r'] + const regKey = await registrationKey(contract, publicKeyHash) + should(regKey).be.of.type('object') + + 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 + ')') + + const b = Buffer.from(nacl.hash(Buffer.from(r))).toString('base64url') + const challenge = await getChallenge(contract, b) + should(challenge).be.of.type('object', 'challenge should be object') + should(challenge.authSalt).equal(authSalt, 'mismatched authSalt') + + const ħ = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(r)), nacl.hash(Buffer.from(challenge.s))])) + const c = nacl.hash(Buffer.concat([nacl.hash(Buffer.from(hash)), nacl.hash(ħ)])) + const hc = nacl.hash(c) + + const encryptionKey = nacl.hash(Buffer.concat([Buffer.from('SU'), c])).slice(0, nacl.secretbox.keyLength) + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + + const encryptedArgsCiphertext = nacl.secretbox(Buffer.from(JSON.stringify(['a', 'b', 'c'])), nonce, encryptionKey) + + const encryptedArgs = Buffer.concat([nonce, encryptedArgsCiphertext]).toString('base64url') + + const updateRes = await update(contract, r, challenge.s, challenge.sig, Buffer.from(hc).toString('base64url'), encryptedArgs) + should(updateRes).equal(true, 'update should be successful') + }) +}) diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 54e2bc33f2..29fb1ca00b 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -1,22 +1,161 @@ 'use strict' import sbp from '@sbp/sbp' + +// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey, encrypt } from '../../../shared/domains/chelonia/crypto.js' import { L, GIErrorUIRuntimeError } from '@common/common.js' import { omit } from '@model/contracts/shared/giLodash.js' import { encryptedAction } from './utils.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import type { GIKey } from '~/shared/domains/chelonia/GIMessage.js' import type { GIRegParams } from './types.js' +import { ChelErrorUnexpected } from '../../../shared/domains/chelonia/errors.js' export default (sbp('sbp/selectors/register', { 'gi.actions/chatroom/create': async function (params: GIRegParams) { try { - return await sbp('chelonia/out/registerContract', { + // Create the necessary keys to initialise the contract + // eslint-disable-next-line camelcase + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CEK = keygen(CURVE25519XSALSA20POLY1305) + + // Key IDs + const CSKid = keyId(CSK) + const CEKid = keyId(CEK) + + // Public keys to be stored in the contract + const CSKp = serializeKey(CSK, false) + const CEKp = serializeKey(CEK, false) + + // Secret keys to be stored encrypted in the contract + const CSKs = encrypt(CEK, serializeKey(CSK, true)) + const CEKs = encrypt(CEK, serializeKey(CEK, true)) + + const rootState = sbp('state/vuex/state') + + const joinKey = params.options?.joinKey + + if (!joinKey) { + throw new ChelErrorUnexpected('joinKey is required to create a chatroom') + } + + console.log('Chatroom create', { + ...omit(params, ['options']), // any 'options' are for this action, not for Chelonia + signingKeyId: CSKid, + actionSigningKeyId: CSKid, + actionEncryptionKeyId: CEKid, + keys: [ + { + id: CSKid, + type: CSK.type, + data: CSKp, + permissions: [GIMessage.OP_CONTRACT, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL, GIMessage.OP_ACTION_UNENCRYPTED, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ATOMIC, GIMessage.OP_CONTRACT_AUTH, GIMessage.OP_CONTRACT_DEAUTH, GIMessage.OP_KEY_REQUEST_RESPONSE], + meta: { + type: 'csk', + private: { + keyId: CEKid, + content: CSKs + } + } + }, + { + id: CEKid, + type: CEK.type, + data: CEKp, + permissions: [GIMessage.OP_ACTION_ENCRYPTED], + meta: { + type: 'cek', + private: { + keyId: CEKid, + content: CEKs + } + } + }, + { + id: joinKey.id, + type: joinKey.type, + data: joinKey.data, + permissions: [GIMessage.OP_KEY_REQUEST], + meta: { + type: 'joinKey' + } + } + ], + contractName: 'gi.contracts/chatroom' + }) + + await sbp('chelonia/configure', { + transientSecretKeys: { + [CSKid]: CSK, + [CEKid]: CEK + } + }) + + const chatroom = await sbp('chelonia/out/registerContract', { ...omit(params, ['options']), // any 'options' are for this action, not for Chelonia + signingKeyId: CSKid, + actionSigningKeyId: CSKid, + actionEncryptionKeyId: CEKid, + keys: [ + { + id: CSKid, + type: CSK.type, + data: CSKp, + permissions: [GIMessage.OP_CONTRACT, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL, GIMessage.OP_ACTION_UNENCRYPTED, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ATOMIC, GIMessage.OP_CONTRACT_AUTH, GIMessage.OP_CONTRACT_DEAUTH, GIMessage.OP_KEY_REQUEST_RESPONSE], + meta: { + type: 'csk', + private: { + keyId: CEKid, + content: CSKs + } + } + }, + { + id: CEKid, + type: CEK.type, + data: CEKp, + permissions: [GIMessage.OP_ACTION_ENCRYPTED], + meta: { + type: 'cek', + private: { + keyId: CEKid, + content: CEKs + } + } + }, + { + id: joinKey.id, + type: joinKey.type, + data: joinKey.data, + permissions: [GIMessage.OP_KEY_REQUEST], + meta: { + type: 'joinKey' + } + } + ], contractName: 'gi.contracts/chatroom' }) + + const contractID = chatroom.contractID() + + await sbp('chelonia/contract/sync', contractID) + + const userID = rootState.loggedIn.identityContractID + await sbp('gi.actions/identity/shareKeysWithSelf', { userID, contractID }) + + return chatroom } catch (e) { console.error('gi.actions/chatroom/register failed!', e) throw new GIErrorUIRuntimeError(L('Failed to create chat channel.')) } }, + 'gi.contracts/chatroom/getShareableKeys': async function (contractID) { + const state = await sbp('chelonia/currentContractState', contractID) + return { + signingKeyId: (((Object.values(Object(state?._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'csk')?.id: ?string), + keys: state._volatile?.keys + } + }, ...encryptedAction('gi.actions/chatroom/addMessage', L('Failed to add message.')), ...encryptedAction('gi.actions/chatroom/editMessage', L('Failed to edit message.')), ...encryptedAction('gi.actions/chatroom/deleteMessage', L('Failed to delete message.')), diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 7abf382bc4..2ee9252bed 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -1,8 +1,9 @@ 'use strict' import sbp from '@sbp/sbp' +// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey, encrypt } from '../../../shared/domains/chelonia/crypto.js' import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' -import { createInvite } from '@model/contracts/shared/functions.js' import { INVITE_INITIAL_CREATOR, INVITE_EXPIRES_IN_DAYS, @@ -23,9 +24,11 @@ import { imageUpload } from '@utils/image.js' import { merge, omit, randomIntFromRange } from '@model/contracts/shared/giLodash.js' import { dateToPeriodStamp, addTimeToDate, DAYS_MILLIS } from '@model/contracts/shared/time.js' import { encryptedAction } from './utils.js' +import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' import { VOTE_FOR } from '@model/contracts/shared/voting/rules.js' +import type { GIKey } from '~/shared/domains/chelonia/GIMessage.js' import type { GIActionParams } from './types.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' import { REPLACE_MODAL } from '@utils/events.js' export async function leaveAllChatRooms (groupContractID: string, member: string) { @@ -81,12 +84,30 @@ export default (sbp('sbp/selectors/register', { } } + // Create the necessary keys to initialise the contract + // eslint-disable-next-line camelcase + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CEK = keygen(CURVE25519XSALSA20POLY1305) + const inviteKey = keygen(EDWARDS25519SHA512BATCH) + + // Key IDs + const CSKid = keyId(CSK) + const CEKid = keyId(CEK) + const inviteKeyId = keyId(inviteKey) + + // Public keys to be stored in the contract + const CSKp = serializeKey(CSK, false) + const CEKp = serializeKey(CEK, false) + const inviteKeyP = serializeKey(inviteKey, false) + + // Secret keys to be stored encrypted in the contract + const CSKs = encrypt(CEK, serializeKey(CSK, true)) + const CEKs = encrypt(CEK, serializeKey(CEK, true)) + const inviteKeyS = encrypt(CEK, serializeKey(inviteKey, true)) + + const rootState = sbp('state/vuex/state') + try { - const initialInvite = createInvite({ - quantity: 60, - creator: INVITE_INITIAL_CREATOR, - expires: INVITE_EXPIRES_IN_DAYS.ON_BOARDING - }) const proposalSettings = { rule: ruleName, ruleSettings: { @@ -102,13 +123,66 @@ export default (sbp('sbp/selectors/register', { // handle Flowtype annotations, even though our .babelrc should make it work. distributionDate = dateToPeriodStamp(addTimeToDate(new Date(), 3 * DAYS_MILLIS)) } + + await sbp('chelonia/configure', { + transientSecretKeys: { + [CSKid]: CSK, + [CEKid]: CEK, + [inviteKeyId]: inviteKey + } + }) + const message = await sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/group', publishOptions, - data: { - invites: { - [initialInvite.inviteSecret]: initialInvite + signingKeyId: CSKid, + actionSigningKeyId: CSKid, + actionEncryptionKeyId: CEKid, + keys: [ + { + id: CSKid, + type: CSK.type, + data: CSKp, + permissions: [GIMessage.OP_CONTRACT, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL, GIMessage.OP_ACTION_UNENCRYPTED, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ATOMIC, GIMessage.OP_CONTRACT_AUTH, GIMessage.OP_CONTRACT_DEAUTH, GIMessage.OP_KEYSHARE, GIMessage.OP_KEY_REQUEST_RESPONSE], + meta: { + type: 'csk', + private: { + keyId: CEKid, + content: CSKs + } + } + }, + { + id: CEKid, + type: CEK.type, + data: CEKp, + permissions: [GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_KEYSHARE], + meta: { + type: 'cek', + private: { + keyId: CEKid, + content: CEKs + } + } }, + { + id: inviteKeyId, + type: inviteKey.type, + data: inviteKeyP, + permissions: [GIMessage.OP_KEY_REQUEST], + meta: { + type: 'inviteKey', + quantity: 60, + creator: INVITE_INITIAL_CREATOR, + expires: Date.now() + DAYS_MILLIS * INVITE_EXPIRES_IN_DAYS.ON_BOARDING, + private: { + keyId: CEKid, + content: inviteKeyS + } + } + } + ], + data: { settings: { // authorizations: [contracts.CanModifyAuths.dummyAuth()], // TODO: this groupName: name, @@ -144,12 +218,14 @@ export default (sbp('sbp/selectors/register', { } }) - await sbp('chelonia/contract/sync', message.contractID()) - saveLoginState('creating', message.contractID()) + const contractID = message.contractID() + + await sbp('chelonia/contract/sync', contractID) + saveLoginState('creating', contractID) // create a 'General' chatroom contract and let the creator join await sbp('gi.actions/group/addAndJoinChatRoom', { - contractID: message.contractID(), + contractID, data: { attributes: { name: CHATROOM_GENERAL_NAME, @@ -157,40 +233,59 @@ export default (sbp('sbp/selectors/register', { description: '', privacyLevel: CHATROOM_PRIVACY_LEVEL.GROUP } - } + }, + options: { + joinKey: { + id: CSKid, + type: CSK.type, + data: CSKp + } + }, + signingKeyId: CSKid, + encryptionKeyId: CEKid }) + const userID = rootState.loggedIn.identityContractID + await sbp('gi.actions/identity/shareKeysWithSelf', { userID, contractID }) + return message } catch (e) { console.error('gi.actions/group/create failed!', e) throw new GIErrorUIRuntimeError(L('Failed to create the group: {reportError}', LError(e))) } }, + 'gi.contracts/group/getShareableKeys': async function (contractID) { + const state = await sbp('chelonia/latestContractState', contractID) + return { + signingKeyId: (((Object.values(Object(state?._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'csk')?.id: ?string), + keys: state._volatile?.keys + } + }, 'gi.actions/group/createAndSwitch': async function (params: GIActionParams) { const message = await sbp('gi.actions/group/create', params) sbp('gi.actions/group/switch', message.contractID()) return message }, - 'gi.actions/group/join': async function (params: $Exact) { + 'gi.actions/group/join': async function (params: $Exact & { options?: { skipInviteAccept: boolean } }) { try { sbp('okTurtles.data/set', 'JOINING_GROUP', true) + // sync the group's contract state + await sbp('chelonia/withEnv', params.contractID, { skipActionProcessing: !params?.options?.skipInviteAccept }, ['chelonia/contract/sync', params.contractID]) // post acceptance event to the group contract, unless this is being called // by the loginState synchronization via the identity contract if (!params.options?.skipInviteAccept) { - await sbp('chelonia/out/actionEncrypted', { + await sbp('chelonia/out/keyRequest', { ...omit(params, ['options']), - action: 'gi.contracts/group/inviteAccept', hooks: { prepublish: params.hooks?.prepublish, postpublish: null } }) } - // sync the group's contract state - await sbp('chelonia/contract/sync', params.contractID) const rootState = sbp('state/vuex/state') if (!params.options?.skipInviteAccept) { + /* // join the 'General' chatroom by default const generalChatRoomId = rootState[params.contractID].generalChatRoomId if (generalChatRoomId) { @@ -206,7 +301,7 @@ export default (sbp('sbp/selectors/register', { }) } else { alert(L("Couldn't join the #{chatroomName} in the group. Doesn't exist.", { chatroomName: CHATROOM_GENERAL_NAME })) - } + } */ saveLoginState('joining', params.contractID) } else { @@ -216,8 +311,8 @@ export default (sbp('sbp/selectors/register', { * but he should sync all the contracts he was syncing in the previous device */ const me = rootState.loggedIn.username - const chatRoomIds = Object.keys(rootState[params.contractID].chatRooms) - .filter(cId => rootState[params.contractID].chatRooms[cId].users.includes(me)) + const chatRoomIds = Object.keys(rootState[params.contractID].chatRooms ?? {}) + .filter(cId => rootState[params.contractID].chatRooms?.[cId].users.includes(me)) await sbp('chelonia/contract/sync', chatRoomIds) sbp('state/vuex/commit', 'setCurrentChatRoomId', { @@ -232,7 +327,7 @@ export default (sbp('sbp/selectors/register', { throw new GIErrorUIRuntimeError(L('Failed to join the group: {codeError}', { codeError: e.message })) } }, - 'gi.actions/group/joinAndSwitch': async function (params: GIActionParams) { + 'gi.actions/group/joinAndSwitch': async function (params: $Exact & { options?: { skipInviteAccept: boolean } }) { await sbp('gi.actions/group/join', params) // after joining, we can set the current group sbp('gi.actions/group/switch', params.contractID) @@ -251,6 +346,7 @@ export default (sbp('sbp/selectors/register', { const message = await sbp('gi.actions/chatroom/create', { data: params.data, + options: params.options, hooks: { prepublish: params.hooks?.prepublish, postpublish: null @@ -321,6 +417,7 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/group/addAndJoinChatRoom': async function (params: GIActionParams) { const message = await sbp('gi.actions/group/addChatRoom', { ...omit(params, ['options']), + options: { joinKey: params.options?.joinKey }, hooks: { prepublish: params.hooks?.prepublish, postpublish: null diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 584dd014a3..3060d73fae 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -1,12 +1,18 @@ 'use strict' import sbp from '@sbp/sbp' +// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, deriveKeyFromPassword, deserializeKey, serializeKey, encrypt } from '../../../shared/domains/chelonia/crypto.js' import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { imageUpload } from '@utils/image.js' import { pickWhere, difference } from '@model/contracts/shared/giLodash.js' import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' import { LOGIN, LOGOUT } from '~/frontend/utils/events.js' import { encryptedAction } from './utils.js' +import { handleFetchResult } from '../utils/misc.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import type { GIKey } from '~/shared/domains/chelonia/GIMessage.js' +import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' function generatedLoginState () { const { contracts } = sbp('state/vuex/state') @@ -25,6 +31,27 @@ function diffLoginStates (s1: ?Object, s2: ?Object) { } export default (sbp('sbp/selectors/register', { + 'gi.actions/identity/retrieveSalt': async (username: string, password: string) => { + const r = randomNonce() + const b = hash(r) + const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/user=${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) + .then(handleFetchResult('json')) + + const { authSalt, s, sig } = authHash + + const h = await hashPassword(password, authSalt) + + const [c, hc] = computeCAndHc(r, s, h) + + const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/user=${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({ + 'r': r, + 's': s, + 'sig': sig, + 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') + })).toString()}`).then(handleFetchResult('text')) + + return decryptContractSalt(c, contractHash) + }, 'gi.actions/identity/create': async function ({ data: { username, email, password, picture }, publishOptions @@ -57,17 +84,133 @@ export default (sbp('sbp/selectors/register', { options: { sync: true } }) const mailboxID = mailbox.contractID() + + const keyPair = boxKeyPair() + const r = Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') + const b = hash(r) + const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/user=${encodeURIComponent(username)}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + body: `b=${encodeURIComponent(b)}` + }) + .then(handleFetchResult('json')) + + const { p, s, sig } = registrationRes + + const [contractSalt, Eh] = await buildRegisterSaltRequest(p, keyPair.secretKey, password) + + const res = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/user=${encodeURIComponent(username)}`, { + method: 'POST', + headers: { + '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') + } + + // Create the necessary keys to initialise the contract + const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CEK = keygen(CURVE25519XSALSA20POLY1305) + + // Key IDs + const IPKid = keyId(IPK) + const IEKid = keyId(IEK) + const CSKid = keyId(CSK) + const CEKid = keyId(CEK) + + // Public keys to be stored in the contract + const IPKp = serializeKey(IPK, false) + const IEKp = serializeKey(IEK, false) + const CSKp = serializeKey(CSK, false) + const CEKp = serializeKey(CEK, false) + + // Secret keys to be stored encrypted in the contract + const CSKs = encrypt(IEK, serializeKey(CSK, true)) + const CEKs = encrypt(IEK, serializeKey(CEK, true)) + let userID // next create the identity contract itself and associate it with the mailbox try { + await sbp('chelonia/configure', { + transientSecretKeys: { + [IPKid]: IPK, + [IEKid]: IEK, + [CSKid]: CSK, + [CEKid]: CEK + } + }) + const user = await sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/identity', publishOptions, + signingKeyId: IPKid, + actionSigningKeyId: CSKid, + actionEncryptionKeyId: CEKid, + keys: [ + { + id: IPKid, + type: IPK.type, + data: IPKp, + permissions: [GIMessage.OP_CONTRACT, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL], + meta: { + type: 'ipk' + } + }, + { + id: IEKid, + type: IEK.type, + data: IEKp, + permissions: ['gi.contracts/identity/keymeta'], + meta: { + type: 'iek' + } + }, + { + id: CSKid, + type: CSK.type, + data: CSKp, + permissions: [GIMessage.OP_ACTION_UNENCRYPTED, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ATOMIC, GIMessage.OP_CONTRACT_AUTH, GIMessage.OP_CONTRACT_DEAUTH, GIMessage.OP_KEYSHARE], + meta: { + type: 'csk', + private: { + keyId: IEKid, + content: CSKs + } + } + }, + { + id: CEKid, + type: CEK.type, + data: CEKp, + permissions: [GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_KEYSHARE], + meta: { + type: 'cek', + private: { + keyId: IEKid, + content: CEKs + } + } + } + ], data: { attributes: { username, email, picture: finalPicture, mailbox: mailboxID } } }) + userID = user.contractID() + await sbp('chelonia/contract/sync', userID) } catch (e) { console.error('gi.actions/identity/create failed!', e) @@ -75,6 +218,39 @@ export default (sbp('sbp/selectors/register', { } return [userID, mailboxID] }, + 'gi.actions/identity/shareKeysWithSelf': async function ({ userID, contractID }) { + if (userID === contractID) { + return + } + + const contractState = await sbp('chelonia/latestContractState', contractID) + + if (contractState?._volatile?.keys) { + const state = await sbp('chelonia/latestContractState', userID) + + const CEKid = (((Object.values(Object(state?._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'cek')?.id: ?string) + const CSKid = (((Object.values(Object(state?._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'csk')?.id: ?string) + const CEK = deserializeKey(state?._volatile?.keys?.[CEKid]) + + await sbp('chelonia/out/keyShare', { + destinationContractID: userID, + destinationContractName: 'gi.contracts/identity', + data: { + contractID: contractID, + keys: Object.entries(contractState._volatile.keys).map(([keyId, key]: [string, mixed]) => ({ + id: keyId, + meta: { + private: { + keyId: CEKid, + content: encrypt(CEK, (key: any)) + } + } + })) + }, + signingKeyId: CSKid + }) + } + }, 'gi.actions/identity/signup': async function ({ username, email, password }, publishOptions) { try { const randomAvatar = sbp('gi.utils/avatar/create') @@ -169,13 +345,25 @@ export default (sbp('sbp/selectors/register', { } }, 'gi.actions/identity/login': async function ({ username, password }: { - username: string, password?: string + username: string, password: ?string }) { // TODO: Insert cryptography here const identityContractID = await sbp('namespace/lookup', username) + if (!identityContractID) { throw new GIErrorUIRuntimeError(L('Invalid username or password')) } + + const transientSecretKeys = password + ? await (async () => { + const salt = await sbp('gi.actions/identity/retrieveSalt', username, password) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) + const IEKid = keyId(IEK) + + return { [IEKid]: IEK } + })() + : {} + try { sbp('appLogs/startCapture', username) const state = await sbp('gi.db/settings/load', username) @@ -194,6 +382,7 @@ export default (sbp('sbp/selectors/register', { } await sbp('gi.db/settings/save', SETTING_CURRENT_USER, username) sbp('state/vuex/commit', 'login', { username, identityContractID }) + await sbp('chelonia/configure', { transientSecretKeys }) // IMPORTANT: we avoid using 'await' on the syncs so that Vue.js can proceed // loading the website instead of stalling out. sbp('chelonia/contract/sync', contractIDs).then(async function () { @@ -232,8 +421,11 @@ export default (sbp('sbp/selectors/register', { console.info('logging out, waiting for any events to finish...') await sbp('chelonia/contract/wait') await sbp('state/vuex/save') + const username = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) await sbp('gi.db/settings/save', SETTING_CURRENT_USER, null) await sbp('chelonia/contract/remove', Object.keys(state.contracts)) + await sbp('gi.db/settings/delete', username) + await sbp('chelonia/configure', { transientSecretKeys: null }) console.info('successfully logged out') } catch (e) { console.error(`${e.name} during logout: ${e.message}`, e) diff --git a/frontend/controller/actions/mailbox.js b/frontend/controller/actions/mailbox.js index 16137a47a0..164b05ebce 100644 --- a/frontend/controller/actions/mailbox.js +++ b/frontend/controller/actions/mailbox.js @@ -1,25 +1,84 @@ 'use strict' import sbp from '@sbp/sbp' +// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey, encrypt } from '../../../shared/domains/chelonia/crypto.js' import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { omit } from '@model/contracts/shared/giLodash.js' import { CHATROOM_PRIVACY_LEVEL, CHATROOM_TYPES } from '@model/contracts/shared/constants.js' import { encryptedAction } from './utils.js' +import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' import type { GIActionParams } from './types.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' export default (sbp('sbp/selectors/register', { 'gi.actions/mailbox/create': async function ({ - data = {}, - options: { sync = true } = {}, - publishOptions + data = {}, options: { sync = true } = {}, publishOptions }): Promise { try { + // Create the necessary keys to initialise the contract + // eslint-disable-next-line camelcase + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CEK = keygen(CURVE25519XSALSA20POLY1305) + + // Key IDs + const CSKid = keyId(CSK) + const CEKid = keyId(CEK) + + // Public keys to be stored in the contract + const CSKp = serializeKey(CSK, false) + const CEKp = serializeKey(CEK, false) + + // Secret keys to be stored encrypted in the contract + const CSKs = encrypt(CEK, serializeKey(CSK, true)) + const CEKs = encrypt(CEK, serializeKey(CEK, true)) + + await sbp('chelonia/configure', { + transientSecretKeys: { + [CSKid]: CSK, + [CEKid]: CEK + } + }) + const mailbox = await sbp('chelonia/out/registerContract', { - contractName: 'gi.contracts/mailbox', publishOptions, data + contractName: 'gi.contracts/mailbox', + publishOptions, + signingKeyId: CSKid, + actionSigningKeyId: CSKid, + actionEncryptionKeyId: CEKid, + keys: [ + { + id: CSKid, + type: CSK.type, + data: CSKp, + permissions: [GIMessage.OP_CONTRACT, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL, GIMessage.OP_ACTION_UNENCRYPTED, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ATOMIC, GIMessage.OP_CONTRACT_AUTH, GIMessage.OP_CONTRACT_DEAUTH], + meta: { + type: 'csk', + private: { + keyId: CEKid, + content: CSKs + } + } + }, + { + id: CEKid, + type: CEK.type, + data: CEKp, + permissions: [GIMessage.OP_ACTION_ENCRYPTED], + meta: { + type: 'cek', + private: { + keyId: CEKid, + content: CEKs + } + } + } + ], + data }) + console.log('gi.actions/mailbox/create', { mailbox }) + const contractID = mailbox.contractID() if (sync) { - await sbp('chelonia/contract/sync', mailbox.contractID()) + await sbp('chelonia/contract/sync', contractID) } return mailbox } catch (e) { diff --git a/frontend/controller/actions/types.js b/frontend/controller/actions/types.js index d624b3b3ab..f456b3f8f6 100644 --- a/frontend/controller/actions/types.js +++ b/frontend/controller/actions/types.js @@ -11,9 +11,16 @@ export type GIRegParams = { // keep in sync with ChelActionParams export type GIActionParams = { + action: string; contractID: string; data: Object; options?: Object; // these are options for the action wrapper - hooks?: Object; - publishOptions?: Object + signingKeyId: string; + encryptionKeyId: ?string; + hooks?: { + prepublishContract?: (Object) => void; + prepublish?: (Object) => void; + postpublish?: (Object) => void; + }; + publishOptions?: { maxAttempts: number }; } diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index 68389be984..803b73404a 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -3,13 +3,18 @@ import sbp from '@sbp/sbp' import { GIErrorUIRuntimeError, LError } from '@common/common.js' import type { GIActionParams } from './types.js' +import type { GIKey } from '~/shared/domains/chelonia/GIMessage.js' export function encryptedAction (action: string, humanError: string | Function): Object { return { [action]: async function (params: GIActionParams) { try { + const state = await sbp('chelonia/latestContractState', params.contractID) return await sbp('chelonia/out/actionEncrypted', { - ...params, action: action.replace('gi.actions', 'gi.contracts') + ...params, + signingKeyId: (((Object.values(Object(state?._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'csk')?.id: ?string), + encryptionKeyId: (((Object.values(Object(state?._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'cek')?.id: ?string), + action: action.replace('gi.actions', 'gi.contracts') }) } catch (e) { console.error(`${action} failed!`, e) diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 85ca337985..40abbf0fd2 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -23,6 +23,7 @@ import { objectOf, string, optional } from '~/frontend/model/contracts/misc/flow function createNotificationData ( notificationType: string, + moreParams: Object = {} ): Object { return { diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 3ecc0b503b..22ffeb8315 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -18,7 +18,8 @@ import { addTimeToDate, dateToPeriodStamp, compareISOTimestamps, dateFromPeriodS import { unadjustedDistribution, adjustedDistribution } from './shared/distribution/distribution.js' import currencies, { saferFloat } from './shared/currencies.js' import { inviteType, chatRoomAttributesType } from './shared/types.js' -import { arrayOf, mapOf, objectOf, objectMaybeOf, optional, string, number, boolean, object, unionOf, tupleOf } from '~/frontend/model/contracts/misc/flowTyper.js' +import { arrayOf, objectOf, objectMaybeOf, optional, string, number, boolean, object, unionOf, tupleOf } from '~/frontend/model/contracts/misc/flowTyper.js' +import type { GIKey } from '~/shared/domains/chelonia/GIMessage.js' function vueFetchInitKV (obj: Object, key: string, initialValue: any): any { let value = obj[key] @@ -395,7 +396,7 @@ sbp('chelonia/defineContract', { return getters.groupMembersByUsername.length }, groupMembersPending (state, getters) { - const invites = getters.currentGroupState.invites + const invites = getters.currentGroupState._vm.invites const pendingMembers = {} for (const inviteId in invites) { const invite = invites[inviteId] @@ -517,7 +518,6 @@ sbp('chelonia/defineContract', { // this is the constructor 'gi.contracts/group': { validate: objectMaybeOf({ - invites: mapOf(string, inviteType), settings: objectMaybeOf({ // TODO: add 'groupPubkey' groupName: string, @@ -1022,7 +1022,7 @@ sbp('chelonia/defineContract', { inviteSecret: string // NOTE: simulate the OP_KEY_* stuff for now })(data) - if (!state.invites[data.inviteSecret]) { + if (!state._vm.invites[data.inviteSecret]) { throw new TypeError(L('The link does not exist.')) } }, @@ -1147,6 +1147,31 @@ sbp('chelonia/defineContract', { if (!state.generalChatRoomId) { Vue.set(state, 'generalChatRoomId', data.chatRoomID) } + }, + async sideEffect ({ data, meta, contractID }, { state: Rstate }) { + const rootState = sbp('state/vuex/state') + const contracts = rootState.contracts || {} + const { identityContractID } = rootState.loggedIn + const userState = rootState[identityContractID] + const state = rootState[contractID] + + if (rootState[data.chatRoomID]?._volatile) { + return + } + + await sbp('chelonia/out/keyRequest', { + originatingContractID: identityContractID, + originatingContractName: contracts[identityContractID].type, + contractID: data.chatRoomID, + contractName: 'gi.contracts/chatroom', + signingKey: state._volatile?.keys?.[(((Object.values(Object(state._vm?.authorizedKeys)): any): GIKey[]).find((k) => k?.meta?.type === 'csk')?.id: ?string)], + innerSigningKeyId: ((Object.values(userState._vm.authorizedKeys): any): GIKey[]).find((k) => k.meta?.type === 'csk')?.id, + encryptionKeyId: ((Object.values(userState._vm.authorizedKeys): any): GIKey[]).find((k) => k.meta?.type === 'cek')?.id, + hooks: { + prepublish: null, + postpublish: null + } + }) } }, 'gi.contracts/group/deleteChatRoom': { diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index fed324908a..9dff1e85d5 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -1,7 +1,6 @@ 'use strict' import sbp from '@sbp/sbp' - import { Vue, L } from '@common/common.js' import { merge } from './shared/giLodash.js' import { objectOf, objectMaybeOf, arrayOf, string, object } from '~/frontend/model/contracts/misc/flowTyper.js' diff --git a/frontend/model/contracts/manifests.json b/frontend/model/contracts/manifests.json index 98fad76b62..fdd9a43379 100644 --- a/frontend/model/contracts/manifests.json +++ b/frontend/model/contracts/manifests.json @@ -1,8 +1,8 @@ { "manifests": { - "gi.contracts/chatroom": "21XWnNY7UtqVpH4GFi7MeXLHwY9wNjXqYtcLs88Dw2YdPMTfTf", - "gi.contracts/group": "21XWnNSk3XLo5oH2ad1ptE4jX6xu8XaYbNxs14srPsbFoU8yNi", + "gi.contracts/chatroom": "21XWnNH52jSfnEAR6QaLhtQHPdTKjFcupcimH2ZM2PUiwaCoSZ", + "gi.contracts/group": "21XWnNPdGDsza8tr4mQRJKJPwdbiR2kpCWNmDuNmL6UzV6F1cf", "gi.contracts/identity": "21XWnNQcokaP76xh4SVnW5wE1KjuwCYgiPFmqEvr2BEnMuMSHZ", - "gi.contracts/mailbox": "21XWnNKPBQz9LSHLWk41VzqCj3JHfgujkgyVjgPcArtFSXtNyY" + "gi.contracts/mailbox": "21XWnNJj8jep4qPprXVjVeDYyDiErrRGZFFV37Zr5h2bUMW7SG" } } diff --git a/frontend/model/contracts/shared/constants.js b/frontend/model/contracts/shared/constants.js index 584d8981be..ae28818e84 100644 --- a/frontend/model/contracts/shared/constants.js +++ b/frontend/model/contracts/shared/constants.js @@ -7,6 +7,8 @@ export const IDENTITY_USERNAME_MAX_CHARS = 80 // group.js related +export const DAYS_MILLIS = 86400e3 + export const INVITE_INITIAL_CREATOR = 'invite-initial-creator' export const INVITE_STATUS = { REVOKED: 'revoked', diff --git a/frontend/model/contracts/shared/functions.js b/frontend/model/contracts/shared/functions.js index c06ae2b85b..a858403bf7 100644 --- a/frontend/model/contracts/shared/functions.js +++ b/frontend/model/contracts/shared/functions.js @@ -1,8 +1,7 @@ 'use strict' import sbp from '@sbp/sbp' -import { INVITE_STATUS, MESSAGE_TYPES } from './constants.js' -import { DAYS_MILLIS } from './time.js' +import { INVITE_STATUS, MESSAGE_TYPES, DAYS_MILLIS } from './constants.js' import { logExceptNavigationDuplicated } from '~/frontend/views/utils/misc.js' // !!!!!!!!!!!!!!! diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index f3afd9aa40..9576f1067b 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -30,7 +30,7 @@ export function archiveProposal ( } export function buildInvitationUrl (groupId: string, inviteSecret: string): string { - return `${location.origin}/app/join?groupId=${groupId}&secret=${inviteSecret}` + return `${location.origin}/app/join?${(new URLSearchParams({ groupId: groupId, secret: inviteSecret })).toString()}` } export const proposalSettingsType: any = objectOf({ diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index 83ac4dea1b..eb51dd880f 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -12,7 +12,7 @@ const contractName = (contractID) => sbp('state/vuex/state').contracts[contractI // Note: this escaping is not intended as a protection against XSS. // It is only done to enable correct rendering of special characters in usernames. // To guard against XSS when rendering usernames, use the `v-safe-html` directive. -const escapeForHtml = (text) => text.replace(/[<>&]/g, '\\$&') +const escapeForHtml = (text) => text.replace(/[<>&]/g, (c) => ('&#' + c.codePointAt(0) + ';')) const strong = (text) => `${escapeForHtml(text)}` export default ({ diff --git a/frontend/model/state.js b/frontend/model/state.js index 5bcb3f0758..a769959334 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -529,6 +529,12 @@ const getters = { currentChatRoomId (state, getters) { return state.currentChatRoomIDs[state.currentGroupId] || null }, + currentChatVolatile (state, getters) { + return state?.[getters.currentChatRoomId]?._volatile || null + }, + currentChatVm (state, getters) { + return state?.[getters.currentChatRoomId]?._vm || null + }, currentChatRoomScrollPosition (state, getters) { return state.chatRoomScrollPosition[getters.currentChatRoomId] // undefined means to the latest }, @@ -571,7 +577,7 @@ const getters = { }, isPrivateChatRoom (state, getters) { return (chatRoomId: string) => { - return state[chatRoomId]?.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE + return state[chatRoomId]?.attributes?.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE } }, isJoinedChatRoom (state, getters) { diff --git a/frontend/utils/crypto.js b/frontend/utils/crypto.js deleted file mode 100644 index 7ca84479d0..0000000000 --- a/frontend/utils/crypto.js +++ /dev/null @@ -1,51 +0,0 @@ -'use strict' - -import scrypt from 'scrypt-async' - -export class Key { - constructor (privKey, pubKey, salt) { - this.privKey = privKey - this.pubKey = pubKey // optional - this.salt = salt // optional - } - - encrypt (data) {} - - decrypt (data) {} - - signMessage (msg) {} - - verifySignature (msg, sig) {} - - // serialization - serialize (savePrivKey = false) { - } -} - -// To store user's private key: -// var keys = Crypto.randomKeypair() -// var passKey = Crypto.keyFromPassword(password) -// var encryptedKeys = passKey.encrypt(keys.serialize(true)) -export class Crypto { - // TODO: make sure to NEVER store private key to the log. - static randomKeypair () { - // return randomly generated asymettric keypair via new Key() - } - - static randomKey () { - // return randomly generated symmetric key via new Key() - } - - static randomSalt () { - // return random salt - } - - // we use dchest/scrypt-async-js in browser - // TODO: use barrysteyn/node-scrypt in node/electrum - static keyFromPassword (password) { - const salt = Crypto.randomSalt() - // TODO: use proper parameters. https://github.com/dchest/scrypt-async-js - const opts = { N: 16384, r: 8, p: 1 } - return new Promise(resolve => scrypt(password, salt, opts, resolve)) - } -} diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index 30adefd9ea..98cf552496 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -196,7 +196,9 @@ export default ({ 'currentChatRoomScrollPosition', 'currentChatRoomUnreadSince', 'currentGroupNotifications', - 'currentChatRoomUnreadMentions' + 'currentChatRoomUnreadMentions', + 'currentChatVolatile', + 'currentChatVm' ]), currentUserAttr () { return { @@ -276,7 +278,7 @@ export default ({ data: !replyingMessage ? data : { ...data, replyingMessage }, hooks: { prepublish: (message) => { - const msgValue = JSON.parse(message.opValue()) + const msgValue = message.decryptedValue() const { meta, data } = msgValue this.messages.push({ ...createMessage({ meta, data, hash: message.hash() }), @@ -412,7 +414,9 @@ export default ({ attributes: cloneDeep(this.chatRoomAttributes), users: cloneDeep(this.chatRoomUsers), messages: initialize ? [] : this.messages, - saveMessage: true + saveMessage: true, + _volatile: cloneDeep(this.currentChatVolatile), + _vm: cloneDeep(this.currentChatVm) } }, async renderMoreMessages (refresh = false) { diff --git a/frontend/views/containers/chatroom/ChatroomMixin.js b/frontend/views/containers/chatroom/ChatroomMixin.js index 83d0b6b55e..fdd25f6fb4 100644 --- a/frontend/views/containers/chatroom/ChatroomMixin.js +++ b/frontend/views/containers/chatroom/ChatroomMixin.js @@ -169,13 +169,18 @@ const ChatroomMixin: Object = { this.ephemeral.loadedDetails = initChatChannelDetails const { chatRoomId } = this.$route.params const state = await sbp('chelonia/latestContractState', chatRoomId) - const { name, type, description, creator, privacyLevel } = state.attributes + + if (!state.attributes || !state.users) { + return + } + + const { name, type, description, creator, privacyLevel } = state.attributes ?? {} this.ephemeral.loadedSummary = { type, title: name, description, - private: state.attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE, + private: state.attributes?.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE, privacyLevel, joined: false, creator @@ -186,7 +191,7 @@ const ChatroomMixin: Object = { for (const username in this.currentGroupState.profiles) { const { displayName, picture, email } = this.globalProfile(username) || {} participants[username] = { - ...state.users[username], + ...state.users?.[username], username: username || '', displayName: displayName || '', picture: picture || '', diff --git a/frontend/views/containers/group-settings/InvitationLinkModal.vue b/frontend/views/containers/group-settings/InvitationLinkModal.vue index 145b08e1ee..3bf25a268b 100644 --- a/frontend/views/containers/group-settings/InvitationLinkModal.vue +++ b/frontend/views/containers/group-settings/InvitationLinkModal.vue @@ -19,6 +19,7 @@ import ModalTemplate from '@components/modal/ModalTemplate.vue' import LinkToCopy from '@components/LinkToCopy.vue' import { INVITE_INITIAL_CREATOR } from '@model/contracts/shared/constants.js' import { buildInvitationUrl } from '@model/contracts/shared/voting/proposals.js' +import { serializeKey } from '../../../../shared/domains/chelonia/crypto.js' export default ({ name: 'InvitationLinkModal', @@ -31,8 +32,17 @@ export default ({ 'currentGroupState' ]), welcomeInviteSecret () { - const invites = this.currentGroupState.invites - return Object.keys(invites).find(invite => invites[invite].creator === INVITE_INITIAL_CREATOR) + const invites = this.currentGroupState._vm.invites + console.log({ invites }) + const initialInvite = Object.keys(invites).find(invite => invites[invite].creator === INVITE_INITIAL_CREATOR) + console.log({ initialInvite }) + const key = this.currentGroupState._volatile.keys[initialInvite] + console.log({ key }) + if (typeof key !== 'string') { + return serializeKey(key, true) + } else { + return key + } }, link () { return buildInvitationUrl(this.$store.state.currentGroupId, this.welcomeInviteSecret) diff --git a/frontend/views/containers/group-settings/InvitationsTable.vue b/frontend/views/containers/group-settings/InvitationsTable.vue index f9f43e2efb..61e15c34bb 100644 --- a/frontend/views/containers/group-settings/InvitationsTable.vue +++ b/frontend/views/containers/group-settings/InvitationsTable.vue @@ -152,19 +152,24 @@ export default ({ 'currentGroupId' ]), invitesToShow () { - const { invites } = this.currentGroupState + const { invites } = this.currentGroupState._vm if (!invites) { return [] } const invitesList = Object.values(invites) .filter(invite => invite.creator === INVITE_INITIAL_CREATOR || invite.creator === this.ourUsername) .map(this.mapInvite) - const options = { + + return invitesList + + // TODO: Make active and all work + + /* const options = { Active: () => invitesList.filter(invite => invite.status.isActive || (invite.status.isRevoked && invite.inviteSecret === this.ephemeral.inviteRevokedNow)), All: () => invitesList - } + } */ - return options[this.ephemeral.selectbox.selectedOption]() + // return options[this.ephemeral.selectbox.selectedOption]() } }, methods: { @@ -218,15 +223,15 @@ export default ({ expires: expiryTime, invitee, inviteSecret, + initialQuantity, quantity, - responses, status }) { const isAnyoneLink = creator === INVITE_INITIAL_CREATOR const isInviteExpired = expiryTime < Date.now() const isInviteRevoked = status === INVITE_STATUS.REVOKED - const numberOfResponses = Object.keys(responses).length - const isAllInviteUsed = numberOfResponses === quantity + const numberOfResponses = initialQuantity - quantity + const isAllInviteUsed = (quantity === 0) return { isAnyoneLink, @@ -234,7 +239,7 @@ export default ({ inviteSecret, inviteLink: buildInvitationUrl(this.currentGroupId, inviteSecret), description: this.inviteStatusDescription({ - isAnyoneLink, isInviteExpired, isInviteRevoked, isAllInviteUsed, quantity, numberOfResponses + isAnyoneLink, isInviteExpired, isInviteRevoked, isAllInviteUsed, quantity: initialQuantity, numberOfResponses }), expiryInfo: isInviteExpired ? L('Expired') : isInviteRevoked ? L('Revoked') : isAllInviteUsed ? '' : this.readableExpiryInfo(expiryTime), status: { diff --git a/frontend/views/containers/proposals/ProposalVoteOptions.vue b/frontend/views/containers/proposals/ProposalVoteOptions.vue index 7f536c53dd..b8c8c09d45 100644 --- a/frontend/views/containers/proposals/ProposalVoteOptions.vue +++ b/frontend/views/containers/proposals/ProposalVoteOptions.vue @@ -29,7 +29,6 @@ import { L } from '@common/common.js' import { VOTE_FOR, VOTE_AGAINST } from '@model/contracts/shared/voting/rules.js' import { oneVoteToPass } from '@model/contracts/shared/voting/proposals.js' import { PROPOSAL_INVITE_MEMBER, PROPOSAL_REMOVE_MEMBER } from '@model/contracts/shared/constants.js' -import { createInvite } from '@model/contracts/shared/functions.js' import ButtonSubmit from '@components/ButtonSubmit.vue' import { leaveAllChatRooms } from '@controller/actions/group.js' @@ -107,11 +106,11 @@ export default ({ if (oneVoteToPass(proposalHash)) { if (this.type === PROPOSAL_INVITE_MEMBER) { - passPayload = createInvite({ + /* passPayload = createInvite({ invitee: this.proposal.data.proposalData.member, creator: this.proposal.meta.username, expires: this.currentGroupState.settings.inviteExpiryProposal - }) + }) */ } else if (this.type === PROPOSAL_REMOVE_MEMBER) { passPayload = { secret: `${parseInt(Math.random() * 10000)}` // TODO: this diff --git a/frontend/views/pages/GroupChat.vue b/frontend/views/pages/GroupChat.vue index e6517af63f..316f542557 100644 --- a/frontend/views/pages/GroupChat.vue +++ b/frontend/views/pages/GroupChat.vue @@ -190,7 +190,7 @@ export default ({ }) if (this.isDirectMessage(chatRoomId)) { this.updateCurrentChatRoomID(chatRoomId) - } else if (chatRoomId && chatRoomId !== this.currentChatRoomId) { + } else if (chatRoomId) { if (!this.isJoinedChatRoom(chatRoomId) && this.isPrivateChatRoom(chatRoomId)) { this.redirectChat('GroupChatConversation') } else { diff --git a/frontend/views/pages/Join.vue b/frontend/views/pages/Join.vue index ba1f468903..2e75535adb 100644 --- a/frontend/views/pages/Join.vue +++ b/frontend/views/pages/Join.vue @@ -48,7 +48,7 @@ div