From ebe6ee0c1cf6759f9e4a03a184c7e9806a9c8aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Sun, 1 May 2022 17:08:24 +0200 Subject: [PATCH 01/21] Signatures and encryption: Preliminary work --- .flowconfig | 2 +- backend/database.js | 2 +- frontend/controller/actions/chatroom.js | 1 + frontend/controller/actions/group.js | 1 + frontend/controller/actions/identity.js | 112 +++++++- frontend/controller/actions/mailbox.js | 3 +- frontend/controller/actions/types.js | 12 +- frontend/controller/actions/utils.js | 6 +- frontend/utils/crypto.js | 333 +++++++++++++++++++++--- frontend/utils/crypto.test.js | 123 +++++++++ shared/domains/chelonia/GIMessage.js | 63 +++-- shared/domains/chelonia/chelonia.js | 77 +++++- shared/domains/chelonia/db.js | 2 +- shared/domains/chelonia/internals.js | 55 +++- 14 files changed, 706 insertions(+), 86 deletions(-) create mode 100644 frontend/utils/crypto.test.js diff --git a/.flowconfig b/.flowconfig index c58898f162..193370d984 100644 --- a/.flowconfig +++ b/.flowconfig @@ -11,7 +11,7 @@ .*/frontend/assets/.* .*/frontend/controller/service-worker.js .*/frontend/utils/blockies.js -.*/frontend/utils/crypto.js +#.*/frontend/utils/crypto.js .*/frontend/utils/flowTyper.js .*/frontend/utils/vuexQueue.js .*/historical/.* diff --git a/backend/database.js b/backend/database.js index 67236b4624..38774fe428 100644 --- a/backend/database.js +++ b/backend/database.js @@ -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 + ']') diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 962120dacd..895577f567 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -11,6 +11,7 @@ export default (sbp('sbp/selectors/register', { try { return await sbp('chelonia/out/registerContract', { ...omit(params, ['options']), // any 'options' are for this action, not for Chelonia + keys: [], contractName: 'gi.contracts/chatroom' }) } catch (e) { diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 2fa4a7ad7a..993d0d1164 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -93,6 +93,7 @@ export default (sbp('sbp/selectors/register', { const message = await sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/group', publishOptions, + keys: [], data: { invites: { [initialInvite.inviteSecret]: initialInvite diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 0516ee5118..2301a51aa9 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -1,14 +1,23 @@ 'use strict' import sbp from '@sbp/sbp' +import { keyId, keygen, deriveKeyFromPassword, serializeKey, encrypt } from '@utils/crypto.js' import { GIErrorUIRuntimeError } from '@model/errors.js' import L, { LError } from '@view-utils/translations.js' import { imageUpload } from '@utils/image.js' import './mailbox.js' import { encryptedAction } from './utils.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' + +// eslint-disable-next-line camelcase +const salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC = 'SALT CHANGEME' export default (sbp('sbp/selectors/register', { + 'gi.actions/identity/retrieveSalt': async (username: string, password: string) => { + // TODO RETRIEVE FROM SERVER + return await Promise.resolve(salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC) + }, 'gi.actions/identity/create': async function ({ data: { username, email, password, picture }, options: { sync = true } = {}, @@ -39,22 +48,106 @@ export default (sbp('sbp/selectors/register', { // and do this outside of a try block so that if it throws the error just gets passed up const mailbox = await sbp('gi.actions/mailbox/create', { options: { sync: true } }) const mailboxID = mailbox.contractID() + + // Create the necessary keys to initialise the contract + // TODO: The salt needs to be dynamically generated + // eslint-disable-next-line camelcase + const salt = salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC + const IPK = await deriveKeyFromPassword('edwards25519sha512batch', password, salt) + const IEK = await deriveKeyFromPassword('curve25519xsalsa20poly1305', password, salt) + 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 { - const user = await sbp('chelonia/out/registerContract', { + const user = await sbp('chelonia/with-env', '', { + additionalKeys: { + [IPKid]: IPK, + [CSKid]: CSK, + [CEKid]: CEK + } + }, ['chelonia/out/registerContract', { contractName: 'gi.contracts/identity', publishOptions, + signingKeyId: IPKid, + actionSigningKeyId: CSKid, + actionEncryptionKeyId: CEKid, data: { attributes: { username, email, picture: finalPicture } - } - }) + }, + keys: [ + { + id: IPKid, + type: IPK.type, + data: IPKp, + perm: [GIMessage.OP_CONTRACT, GIMessage.OP_KEY_ADD, GIMessage.OP_KEY_DEL], + meta: { + type: 'ipk' + } + }, + { + id: IEKid, + type: IEK.type, + data: IEKp, + perm: ['gi.contracts/identity/keymeta'], + meta: { + type: 'iek' + } + }, + { + id: CSKid, + type: CSK.type, + data: CSKp, + perm: [GIMessage.OP_ACTION_UNENCRYPTED, GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ATOMIC, GIMessage.OP_CONTRACT_AUTH, GIMessage.OP_CONTRACT_DEAUTH], + meta: { + type: 'csk', + private: { + keyId: IEKid, + content: CSKs + } + } + }, + { + id: CEKid, + type: CEK.type, + data: CEKp, + perm: [GIMessage.OP_ACTION_ENCRYPTED], + meta: { + type: 'cek', + private: { + keyId: IEKid, + content: CEKs + } + } + } + ] + }]) userID = user.contractID() if (sync) { - await sbp('chelonia/contract/sync', userID) + await sbp('chelonia/with-env', userID, { additionalKeys: { [IEKid]: IEK } }, ['chelonia/contract/sync', userID]) } await sbp('gi.actions/identity/setAttributes', { - contractID: userID, data: { mailbox: mailboxID } + contractID: userID, + data: { mailbox: mailboxID }, + signingKeyId: CSKid, + encryptionKeyId: CEKid }) } catch (e) { console.error('gi.actions/identity/create failed!', e) @@ -98,17 +191,22 @@ export default (sbp('sbp/selectors/register', { }) { // TODO: Insert cryptography here const userId = await sbp('namespace/lookup', username) + if (!userId) { throw new GIErrorUIRuntimeError(L('Invalid username or password')) } + const salt = await sbp('gi.actions/identity/retrieveSalt', username, password) + const IEK = await deriveKeyFromPassword('curve25519xsalsa20poly1305', password, salt) + const IEKid = keyId(IEK) + try { console.debug(`Retrieved identity ${userId}`) // TODO: move the login vuex action code into this function (see #804) - await sbp('state/vuex/dispatch', 'login', { username, identityContractID: userId }) + await sbp('chelonia/with-env', userId, { additionalKeys: { [IEKid]: IEK } }, ['state/vuex/dispatch', 'login', { username, identityContractID: userId }]) if (sync) { - await sbp('chelonia/contract/sync', userId) + await sbp('chelonia/with-env', userId, { additionalKeys: { [IEKid]: IEK } }, ['chelonia/contract/sync', userId]) } return userId diff --git a/frontend/controller/actions/mailbox.js b/frontend/controller/actions/mailbox.js index 8783f631ee..796d74627e 100644 --- a/frontend/controller/actions/mailbox.js +++ b/frontend/controller/actions/mailbox.js @@ -14,8 +14,9 @@ export default (sbp('sbp/selectors/register', { }): Promise { try { const mailbox = await sbp('chelonia/out/registerContract', { - contractName: 'gi.contracts/mailbox', publishOptions, data + contractName: 'gi.contracts/mailbox', publishOptions, keys: [], data }) + console.log('gi.actions/mailbox/create', { mailbox }) if (sync) { await sbp('chelonia/contract/sync', mailbox.contractID()) } diff --git a/frontend/controller/actions/types.js b/frontend/controller/actions/types.js index d624b3b3ab..5d38b93e14 100644 --- a/frontend/controller/actions/types.js +++ b/frontend/controller/actions/types.js @@ -11,9 +11,15 @@ 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 2ddbd7999d..7c80fd5db2 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -9,8 +9,12 @@ export function encryptedAction (action: string, humanError: string | Function): 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') + signingKeyId: (state?._vm?.authorizedKeys?.find((k) => k.meta?.type === 'csk')?.id: ?string), + encryptionKeyId: (state?._vm?.authorizedKeys?.find((k) => k.meta?.type === 'cek')?.id: ?string), + ...params, + action: action.replace('gi.actions', 'gi.contracts') }) } catch (e) { console.error(`${action} failed!`, e) diff --git a/frontend/utils/crypto.js b/frontend/utils/crypto.js index 7ca84479d0..f4dd828eb5 100644 --- a/frontend/utils/crypto.js +++ b/frontend/utils/crypto.js @@ -1,51 +1,324 @@ 'use strict' +import nacl from 'tweetnacl' + +import { blake32Hash, bytesToB64, b64ToBuf, strToBuf } from '~/shared/functions.js' + import scrypt from 'scrypt-async' -export class Key { - constructor (privKey, pubKey, salt) { - this.privKey = privKey - this.pubKey = pubKey // optional - this.salt = salt // optional +export type Key = { + type: string; + secretKey?: any; + publicKey?: any; +} + +export const keygen = (type: string): Key => { + if (type === 'edwards25519sha512batch') { + const key = nacl.sign.keyPair() + + const res: Key = { + type: type, + publicKey: key.publicKey + } + + Object.defineProperty(res, 'secretKey', { value: key.secretKey }) + + return res + } else if (type === 'curve25519xsalsa20poly1305') { + const key = nacl.box.keyPair() + + const res: Key = { + type: type, + publicKey: key.publicKey + } + + Object.defineProperty(res, 'secretKey', { value: key.secretKey }) + + return res + } else if (type === 'xsalsa20poly1305') { + const res: Key = { + type: type + } + + Object.defineProperty(res, 'secretKey', { value: nacl.randomBytes(nacl.secretbox.keyLength) }) + + return res + } + + throw new Error('Unsupported key type') +} +export const generateSalt = (): string => { + return bytesToB64(nacl.randomBytes(18)) +} +export const deriveKeyFromPassword = (type: string, password: string, salt: string): Promise => { + if (!['edwards25519sha512batch', 'curve25519xsalsa20poly1305', 'xsalsa20poly1305'].includes(type)) { + return Promise.reject(new Error('Unsupported type')) } - encrypt (data) {} + return new Promise((resolve) => { + scrypt(password, salt, { + N: 16384, + r: 8, + p: 1, + dkLen: type === 'edwards25519sha512batch' ? nacl.sign.keyLength : type === 'curve25519xsalsa20poly1305' ? nacl.box.keyLength : type === 'xsalsa20poly1305' ? nacl.secretbox.keyLength : 0, + encoding: 'binary' + }, (derivedKey) => { + if (type === 'edwards25519sha512batch') { + const key = nacl.sign.keyPair.fromSeed(derivedKey) + + resolve({ + type: type, + secretKey: key.secretKey, + publicKey: key.publicKey + }) + } else if (type === 'curve25519xsalsa20poly1305') { + const key = nacl.box.keyPair.fromSecretKey(derivedKey) + + resolve({ + type: type, + secretKey: key.secretKey, + publicKey: key.publicKey + }) + } else if (type === 'xsalsa20poly1305') { + resolve({ + type: type, + secretKey: derivedKey + }) + } + }) + }) +} +export const serializeKey = (key: Key, savePrivKey: boolean): string => { + if (key.type === 'edwards25519sha512batch' || key.type === 'curve25519xsalsa20poly1305') { + if (!savePrivKey) { + if (!key.publicKey) { + throw new Error('Unsupported operation: no public key to export') + } + + return JSON.stringify({ + type: key.type, + publicKey: bytesToB64(key.publicKey) + }) + } - decrypt (data) {} + if (!key.secretKey) { + throw new Error('Unsupported operation: no secret key to export') + } - signMessage (msg) {} + return JSON.stringify({ + type: key.type, + secretKey: bytesToB64(key.secretKey) + }) + } else if (key.type === 'xsalsa20poly1305') { + if (!savePrivKey) { + throw new Error('Unsupported operation: no public key to export') + } - verifySignature (msg, sig) {} + if (!key.secretKey) { + throw new Error('Unsupported operation: no secret key to export') + } - // serialization - serialize (savePrivKey = false) { + return JSON.stringify({ + type: key.type, + secretKey: bytesToB64(key.secretKey) + }) } + + throw new Error('Unsupported key type') } +export const deserializeKey = (data: string): Key => { + const keyData = JSON.parse(data) -// 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() + if (!keyData || !keyData.type) { + throw new Error('Invalid key object') } - static randomKey () { - // return randomly generated symmetric key via new Key() + if (keyData.type === 'edwards25519sha512batch') { + if (keyData.secretKey) { + const key = nacl.sign.keyPair.fromSecretKey(b64ToBuf(keyData.secretKey)) + + const res: Key = { + type: keyData.type, + publicKey: key.publicKey + } + + Object.defineProperty(res, 'secretKey', { value: key.secretKey }) + + return res + } else if (keyData.publicKey) { + return { + type: keyData.type, + publicKey: new Uint8Array(b64ToBuf(keyData.publicKey)) + } + } + + throw new Error('Missing secret or public key') + } else if (keyData.type === 'curve25519xsalsa20poly1305') { + if (keyData.secretKey) { + const key = nacl.box.keyPair.fromSecretKey(b64ToBuf(keyData.secretKey)) + + const res: Key = { + type: keyData.type, + publicKey: key.publicKey + } + + Object.defineProperty(res, 'secretKey', { value: key.secretKey }) + + return res + } else if (keyData.publicKey) { + return { + type: keyData.type, + publicKey: new Uint8Array(b64ToBuf(keyData.publicKey)) + } + } + + throw new Error('Missing secret or public key') + } else if (keyData.type === 'xsalsa20poly1305') { + if (!keyData.secretKey) { + throw new Error('Secret key missing') + } + + const res: Key = { + type: keyData.type + } + + Object.defineProperty(res, 'secretKey', { value: new Uint8Array(b64ToBuf(keyData.secretKey)) }) + + return res } - static randomSalt () { - // return random salt + throw new Error('Unsupported key type') +} +export const keyId = (inKey: Key | string): string => { + const key = (Object(inKey) instanceof String) ? deserializeKey(((inKey: any): string)) : ((inKey: any): Key) + + const serializedKey = serializeKey(key, !key.publicKey) + return blake32Hash(serializedKey) +} +export const sign = (inKey: Key | string, data: string): string => { + const key = (Object(inKey) instanceof String) ? deserializeKey(((inKey: any): string)) : ((inKey: any): Key) + + if (key.type !== 'edwards25519sha512batch') { + throw new Error('Unsupported algorithm') } - // 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)) + if (!key.secretKey) { + throw new Error('Secret key missing') } + + const messageUint8 = strToBuf(data) + const signature = nacl.sign.detached(messageUint8, key.secretKey) + const base64Signature = bytesToB64(signature) + + return base64Signature +} +export const verifySignature = (inKey: Key | string, data: string, signature: string): void => { + const key = (Object(inKey) instanceof String) ? deserializeKey(((inKey: any): string)) : ((inKey: any): Key) + + if (key.type !== 'edwards25519sha512batch') { + throw new Error('Unsupported algorithm') + } + + if (!key.publicKey) { + throw new Error('Public key missing') + } + + const decodedSignature = b64ToBuf(signature) + const messageUint8 = strToBuf(data) + + const result = nacl.sign.detached.verify(messageUint8, decodedSignature, key.publicKey) + + if (!result) { + throw new Error('Invalid signature') + } +} +export const encrypt = (inKey: Key | string, data: string): string => { + const key = (Object(inKey) instanceof String) ? deserializeKey(((inKey: any): string)) : ((inKey: any): Key) + + if (key.type === 'xsalsa20poly1305') { + if (!key.secretKey) { + throw new Error('Secret key missing') + } + + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + + const messageUint8 = strToBuf(data) + const box = nacl.secretbox(messageUint8, nonce, key.secretKey) + + const fullMessage = new Uint8Array(nonce.length + box.length) + + fullMessage.set(nonce) + fullMessage.set(box, nonce.length) + + const base64FullMessage = bytesToB64(fullMessage) + + return base64FullMessage + } else if (key.type === 'curve25519xsalsa20poly1305') { + if (!key.secretKey || !key.publicKey) { + throw new Error('Keypair missing') + } + + const nonce = nacl.randomBytes(nacl.box.nonceLength) + + const messageUint8 = strToBuf(data) + const box = nacl.box(messageUint8, nonce, key.publicKey, key.secretKey) + + const fullMessage = new Uint8Array(nonce.length + box.length) + + fullMessage.set(nonce) + fullMessage.set(box, nonce.length) + + const base64FullMessage = bytesToB64(fullMessage) + + return base64FullMessage + } + + throw new Error('Unsupported algorithm') +} +export const decrypt = (inKey: Key | string, data: string): string => { + const key = (Object(inKey) instanceof String) ? deserializeKey(((inKey: any): string)) : ((inKey: any): Key) + + if (key.type === 'xsalsa20poly1305') { + if (!key.secretKey) { + throw new Error('Secret key missing') + } + + const messageWithNonceAsUint8Array = b64ToBuf(data) + + const nonce = messageWithNonceAsUint8Array.slice(0, nacl.secretbox.nonceLength) + const message = messageWithNonceAsUint8Array.slice( + nacl.secretbox.nonceLength, + messageWithNonceAsUint8Array.length + ) + + const decrypted = nacl.secretbox.open(message, nonce, key.secretKey) + + if (!decrypted) { + throw new Error('Could not decrypt message') + } + + return Buffer.from(decrypted).toString('utf-8') + } else if (key.type === 'curve25519xsalsa20poly1305') { + if (!key.secretKey || !key.publicKey) { + throw new Error('Keypair missing') + } + + const messageWithNonceAsUint8Array = b64ToBuf(data) + + const nonce = messageWithNonceAsUint8Array.slice(0, nacl.box.nonceLength) + const message = messageWithNonceAsUint8Array.slice( + nacl.box.nonceLength, + messageWithNonceAsUint8Array.length + ) + + const decrypted = nacl.box.open(message, nonce, key.publicKey, key.secretKey) + + if (!decrypted) { + throw new Error('Could not decrypt message') + } + + return Buffer.from(decrypted).toString('utf-8') + } + + throw new Error('Unsupported algorithm') } diff --git a/frontend/utils/crypto.test.js b/frontend/utils/crypto.test.js new file mode 100644 index 0000000000..b7ad1b4508 --- /dev/null +++ b/frontend/utils/crypto.test.js @@ -0,0 +1,123 @@ +/* eslint-env mocha */ + +import should from 'should' +import 'should-sinon' +import { keygen, deriveKeyFromPassword, generateSalt, serializeKey, deserializeKey, encrypt, decrypt, sign, verifySignature } from './crypto.js' + +describe('Crypto suite', () => { + it('should deserialize to the same contents as when serializing', () => { + for (const type of ['edwards25519sha512batch', 'curve25519xsalsa20poly1305', 'xsalsa20poly1305']) { + const key = keygen(type) + const serializedKey = serializeKey(key, true) + const deserializedKey = deserializeKey(serializedKey) + should(key).deepEqual(deserializedKey) + } + }) + + it('should deserialize to the same contents as when serializing (public)', () => { + for (const type of ['edwards25519sha512batch', 'curve25519xsalsa20poly1305']) { + const key = keygen(type) + const serializedKey = serializeKey(key, false) + delete key.secretKey + const deserializedKey = deserializeKey(serializedKey) + should(key).deepEqual(deserializedKey) + } + }) + + it('should derive the same key for the same password/salt combination', async () => { + for (const type of ['edwards25519sha512batch', 'curve25519xsalsa20poly1305', 'xsalsa20poly1305']) { + const salt = generateSalt() + const invocation1 = await deriveKeyFromPassword(type, 'password123', salt) + const invocation2 = await deriveKeyFromPassword(type, 'password123', salt) + + should(invocation1).deepEqual(invocation2) + } + }) + + it('should derive different keys for the different password/salt combination', async () => { + const salt1 = 'salt1' + const salt2 = 'salt2' + + for (const type of ['edwards25519sha512batch', 'curve25519xsalsa20poly1305', 'xsalsa20poly1305']) { + const invocation1 = await deriveKeyFromPassword(type, 'password123', salt1) + const invocation2 = await deriveKeyFromPassword(type, 'password123', salt2) + const invocation3 = await deriveKeyFromPassword(type, 'p4ssw0rd321', salt1) + + should(invocation1).not.deepEqual(invocation2) + should(invocation2).not.deepEqual(invocation3) + should(invocation1).not.deepEqual(invocation3) + } + }) + + it('should correctly sign and verify messages', () => { + const key = keygen('edwards25519sha512batch') + const data = 'data' + + const signature = sign(key, data) + + should(() => verifySignature(key, data, signature)).not.throw() + }) + + it('should not verify signatures made with a different key', () => { + const key1 = keygen('edwards25519sha512batch') + const key2 = keygen('edwards25519sha512batch') + const data = 'data' + + const signature = sign(key1, data) + + should(() => verifySignature(key2, data, signature)).throw() + }) + + it('should not verify signatures made with different data', () => { + const key = keygen('edwards25519sha512batch') + const data1 = 'data1' + const data2 = 'data2' + + const signature = sign(key, data1) + + should(() => verifySignature(key, data2, signature)).throw() + }) + + it('should not verify invalid signatures', () => { + const key = keygen('edwards25519sha512batch') + const data = 'data' + + should(() => verifySignature(key, data, 'INVALID SIGNATURE')).throw() + }) + + it('should correctly encrypt and decrypt messages', () => { + const data = 'data' + + for (const type of ['curve25519xsalsa20poly1305', 'xsalsa20poly1305']) { + const key = keygen(type) + const encryptedMessage = encrypt(key, data) + + should(encryptedMessage).not.equal(data) + + const result = decrypt(key, encryptedMessage) + + should(result).equal(data) + } + }) + + it('should not decrypt messages encrypted with a different key', () => { + const data = 'data' + + for (const type of ['curve25519xsalsa20poly1305', 'xsalsa20poly1305']) { + const key1 = keygen(type) + const key2 = keygen(type) + const encryptedMessage = encrypt(key1, data) + + should(encryptedMessage).not.equal(data) + + should(() => decrypt(key2, encryptedMessage)).throw() + } + }) + + it('should not decrypt invalid messages', () => { + for (const type of ['curve25519xsalsa20poly1305', 'xsalsa20poly1305']) { + const key = keygen(type) + should(() => decrypt(key, 'Invalid message')).throw() + } + }) +}) diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index a2d5940509..022e7be57f 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -5,16 +5,18 @@ import { blake32Hash } from '~/shared/functions.js' import type { JSONType, JSONObject } from '~/shared/types.js' -export type GIKeyType = '' +export type GIKeyType = 'edwards25519sha512batch' | 'curve25519xsalsa20poly1305' | 'xsalsa20poly1305' export type GIKey = { + id: string; type: GIKeyType; - data: Object; // based on GIKeyType this will change + data: string; + perm: string[]; meta: Object; } // Allows server to check if the user is allowed to register this type of contract // TODO: rename 'type' to 'contractName': -export type GIOpContract = { type: string; keyJSON: string, parentContract?: string } +export type GIOpContract = { type: string; keys: GIKey[], parentContract?: string } export type GIOpActionEncrypted = string // encrypted version of GIOpActionUnencrypted export type GIOpActionUnencrypted = { action: string; data: JSONType; meta: JSONObject } export type GIOpKeyAdd = { keyHash: string, keyJSON: ?string, context: string } @@ -28,7 +30,10 @@ export class GIMessage { // flow type annotations to make flow happy _decrypted: GIOpValue _mapping: Object + _head: Object _message: Object + _signature: string + _signedPayload: string static OP_CONTRACT: 'c' = 'c' static OP_ACTION_ENCRYPTED: 'ae' = 'ae' // e2e-encrypted action @@ -38,6 +43,9 @@ export class GIMessage { static OP_PROTOCOL_UPGRADE: 'pu' = 'pu' static OP_PROP_SET: 'ps' = 'ps' // set a public key/value pair static OP_PROP_DEL: 'pd' = 'pd' // delete a public key/value pair + static OP_CONTRACT_AUTH: 'ca' = 'ca' // authorize a contract + static OP_CONTRACT_DEAUTH: 'cd' = 'cd' // deauthorize a contract + static OP_ATOMIC: 'at' = 'at' // atomic op // eslint-disable-next-line camelcase static createV1_0 ( @@ -46,44 +54,57 @@ export class GIMessage { op: GIOp, signatureFn?: Function = defaultSignatureFn ): this { - const message = { + const head = { version: '1.0.0', previousHEAD, contractID, - op, - // the nonce makes it difficult to predict message contents - // and makes it easier to prevent conflicts during development - nonce: Math.random() + op: op[0] } + console.log('createV1_0', { op, head }) + const message = op[1] // NOTE: the JSON strings generated here must be preserved forever. // do not ever regenerate this message using the contructor. // instead store it using serialize() and restore it using // deserialize(). + const headJSON = JSON.stringify(head) const messageJSON = JSON.stringify(message) + const signedPayload = blake32Hash(`${blake32Hash(headJSON)}${blake32Hash(messageJSON)}`) + const signature = signatureFn(signedPayload) const value = JSON.stringify({ + head: headJSON, message: messageJSON, - sig: signatureFn(messageJSON) + sig: signature }) return new this({ mapping: { key: blake32Hash(value), value }, - message + head, + message, + signature, + signedPayload }) } // TODO: we need signature verification upon decryption somewhere... static deserialize (value: string): this { if (!value) throw new Error(`deserialize bad value: ${value}`) + const parsedValue = JSON.parse(value) return new this({ mapping: { key: blake32Hash(value), value }, - message: JSON.parse(JSON.parse(value).message) + head: JSON.parse(parsedValue.head), + message: JSON.parse(parsedValue.message), + signature: parsedValue.sig, + signedPayload: blake32Hash(`${blake32Hash(parsedValue.head)}${blake32Hash(parsedValue.message)}`) }) } - constructor ({ mapping, message }: { mapping: Object, message: Object }) { + constructor ({ mapping, head, message, signature, signedPayload }: { mapping: Object, head: Object, message: Object, signature: string, signedPayload: string }) { this._mapping = mapping + this._head = head this._message = message + this._signature = signature + this._signedPayload = signedPayload // perform basic sanity check - const [type] = this.message().op + const type = this.opType() switch (type) { case GIMessage.OP_CONTRACT: if (!this.isFirstMessage()) throw new Error('OP_CONTRACT: must be first message') @@ -107,13 +128,19 @@ export class GIMessage { return this._decrypted } + head (): Object { return this._head } + message (): Object { return this._message } - op (): GIOp { return this.message().op } + op (): GIOp { return [this.head().op, this.message()] } + + opType (): GIOpType { return this.head().op } + + opValue (): GIOpValue { return this.message() } - opType (): GIOpType { return this.op()[0] } + signature (): Object { return this._signature } - opValue (): GIOpValue { return this.op()[1] } + signedPayload (): string { return this._signedPayload } description (): string { const type = this.opType() @@ -132,9 +159,9 @@ export class GIMessage { return `${desc}|${this.hash()} of ${this.contractID()}>` } - isFirstMessage (): boolean { return !this.message().previousHEAD } + isFirstMessage (): boolean { return !this.head().previousHEAD } - contractID (): string { return this.message().contractID || this.hash() } + contractID (): string { return this.head().contractID || this.hash() } serialize (): string { return this._mapping.value } diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index c1389c49c4..a23d9999f9 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -10,13 +10,18 @@ import { merge, cloneDeep, randomHexString, intersection, difference } from '~/f // TODO: rename this to ChelMessage import { GIMessage } from './GIMessage.js' import { ChelErrorUnrecoverable } from './errors.js' -import type { GIOpContract, GIOpActionUnencrypted } from './GIMessage.js' +import type { GIKey, GIOpContract, GIOpActionUnencrypted } from './GIMessage.js' +import { keyId, sign, encrypt, decrypt } from '@utils/crypto.js' // TODO: define ChelContractType for /defineContract export type ChelRegParams = { contractName: string; data: Object; + signingKeyId: string; + actionSigningKeyId: string; + actionEncryptionKeyId: ?string; + keys: GIKey[]; hooks?: { prepublishContract?: (GIMessage) => void; prepublish?: (GIMessage) => void; @@ -29,6 +34,8 @@ export type ChelActionParams = { action: string; contractID: string; data: Object; + signingKeyId: string; + encryptionKeyId: ?string; hooks?: { prepublishContract?: (GIMessage) => void; prepublish?: (GIMessage) => void; @@ -48,13 +55,43 @@ export const ACTION_REGEX: RegExp = /^((([\w.]+)\/([^/]+))(?:\/(?:([^/]+)\/)?)?) // 4 => 'group' // 5 => 'payment' +const signatureFnBuilder = (key) => { + return (data) => { + return { + type: key.type, + keyId: keyId(key), + data: sign(key, data) + } + } +} + sbp('sbp/selectors/register', { // https://www.wordnik.com/words/chelonia // https://gitlab.okturtles.org/okturtles/group-income/-/wikis/E2E-Protocol/Framework.md#alt-names 'chelonia/_init': function () { this.config = { - decryptFn: JSON.parse, // override! - encryptFn: JSON.stringify, // override! + decryptFn: function (message: Object, state: ?Object) { + if (Object(message) instanceof String) { + return JSON.parse(message) + } + + const keyId = message.keyId + const key = this.env.additionalKeys?.[keyId] || state?._volatile?.keys?.[keyId] + + return JSON.parse(decrypt(key, message.content)) + }, + encryptFn: function (message: Object, eKeyId: string, state: ?Object) { + const key = this.env.additionalKeys?.[eKeyId] || state?._volatile?.keys?.[eKeyId] + + if (!key) { + return JSON.stringify(message) + } + + return { + keyId: keyId(key), + content: encrypt(key, JSON.stringify(message)) + } + }, stateSelector: 'chelonia/private/state', // override to integrate with, for example, vuex whitelisted: (action: string): boolean => !!this.whitelistedActions[action], reactiveSet: (obj, key, value) => { obj[key] = value; return value }, // example: set to Vue.set @@ -83,6 +120,7 @@ sbp('sbp/selectors/register', { this.contracts = {} this.whitelistedActions = {} this.sideEffectStacks = {} // [contractID]: Array<*> + this.env = {} this.sideEffectStack = (contractID: string): Array<*> => { let stack = this.sideEffectStacks[contractID] if (!stack) { @@ -91,6 +129,13 @@ sbp('sbp/selectors/register', { return stack } }, + 'chelonia/with-env': async function (contractID: string, env: Object, sbpInvocation: Array<*>) { + const savedEnv = this.env + this.env = env + const res = await sbp('okTurtles.eventQueue/queueEvent', `chelonia/env/${contractID}`, sbpInvocation) + this.env = savedEnv + return res + }, 'chelonia/configure': function (config: Object) { merge(this.config, config) // merge will strip the hooks off of config.hooks when merging from the root of the object @@ -224,7 +269,7 @@ sbp('sbp/selectors/register', { // but after it's finished. This is used in tandem with // queuing the 'chelonia/private/in/handleEvent' selector, defined below. // This prevents handleEvent getting called with the wrong previousHEAD for an event. - return sbp('okTurtles.eventQueue/queueEvent', contractID, [ + return sbp('okTurtles.eventQueue/queueEvent', `chelonia/${contractID}`, [ 'chelonia/private/in/syncContract', contractID ]) })) @@ -233,7 +278,7 @@ sbp('sbp/selectors/register', { 'chelonia/contract/remove': function (contractIDs: string | string[]): Promise<*> { const listOfIds = typeof contractIDs === 'string' ? [contractIDs] : contractIDs return Promise.all(listOfIds.map(contractID => { - return sbp('okTurtles.eventQueue/queueEvent', contractID, [ + return sbp('okTurtles.eventQueue/queueEvent', `chelonia/${contractID}`, [ 'chelonia/contract/removeImmediately', contractID ]) })) @@ -273,22 +318,27 @@ sbp('sbp/selectors/register', { }, // 'chelonia/out' - selectors that send data out to the server 'chelonia/out/registerContract': async function (params: ChelRegParams) { - const { contractName, hooks, publishOptions } = params + const { contractName, keys, hooks, publishOptions, signingKeyId, actionSigningKeyId, actionEncryptionKeyId } = params const contract = this.contracts[contractName] if (!contract) throw new Error(`contract not defined: ${contractName}`) + const signingKey = this.env.additionalKeys?.[signingKeyId] + const signingFn = signingKey ? signatureFnBuilder(signingKey) : undefined const contractMsg = GIMessage.createV1_0(null, null, [ GIMessage.OP_CONTRACT, ({ type: contractName, - keyJSON: 'TODO: add group public key here' + keys: keys }: GIOpContract) - ]) + ], signingFn) hooks && hooks.prepublishContract && hooks.prepublishContract(contractMsg) - await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions) + await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, signingFn) + const contractID = contractMsg.hash() const msg = await sbp('chelonia/out/actionEncrypted', { action: contractName, - contractID: contractMsg.hash(), + contractID, data: params.data, + signingKeyId: actionSigningKeyId, + encryptionKeyId: actionEncryptionKeyId, hooks, publishOptions }) @@ -339,11 +389,12 @@ async function outEncryptedOrUnencryptedAction ( contract.metadata.validate(meta, { state, ...gProxy, contractID }) contract.actions[action].validate(data, { state, ...gProxy, meta, contractID }) const unencMessage = ({ action, data, meta }: GIOpActionUnencrypted) + const signingKey = this.env.additionalKeys?.[params.signingKeyId] || state?._volatile?.keys[params.signingKeyId] + const payload = opType === GIMessage.OP_ACTION_UNENCRYPTED ? unencMessage : this.config.encryptFn.call(this, unencMessage, params.encryptionKeyId, state) const message = GIMessage.createV1_0(contractID, previousHEAD, [ opType, - opType === GIMessage.OP_ACTION_UNENCRYPTED ? unencMessage : this.config.encryptFn(unencMessage) - ] - // TODO: add the signature function here to sign the message whether encrypted or not + payload + ], signingKey ? signatureFnBuilder(signingKey) : undefined ) hooks && hooks.prepublish && hooks.prepublish(message) await sbp('chelonia/private/out/publishEvent', message, publishOptions) diff --git a/shared/domains/chelonia/db.js b/shared/domains/chelonia/db.js index f2d9a3f225..e8fc2ceb0e 100644 --- a/shared/domains/chelonia/db.js +++ b/shared/domains/chelonia/db.js @@ -55,7 +55,7 @@ export default (sbp('sbp/selectors/register', { }, 'chelonia/db/addEntry': async function (entry: GIMessage): Promise { try { - const { previousHEAD } = entry.message() + const { previousHEAD } = entry.head() const contractID: string = entry.contractID() if (await sbp('chelonia/db/get', entry.hash())) { console.warn(`[chelonia.db] entry exists: ${entry.hash()}`) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index fc821ad848..abd4d823f9 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -8,6 +8,7 @@ import { b64ToStr } from '~/shared/functions.js' import { randomIntFromRange, delay, cloneDeep, debounce, pick } from '~/frontend/utils/giLodash.js' import { ChelErrorDBBadPreviousHEAD, ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.js' import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from './events.js' +import { decrypt, verifySignature } from '@utils/crypto.js' import type { GIOpContract, GIOpType, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpPropSet, GIOpKeyAdd } from './GIMessage.js' @@ -16,7 +17,7 @@ sbp('sbp/selectors/register', { 'chelonia/private/state': function () { return this.state }, - 'chelonia/private/out/publishEvent': async function (entry: GIMessage, { maxAttempts = 2 } = {}) { + 'chelonia/private/out/publishEvent': async function (entry: GIMessage, { maxAttempts = 2 } = {}, signatureFn?: Function) { const contractID = entry.contractID() let attempt = 1 // auto resend after short random delay @@ -46,7 +47,7 @@ sbp('sbp/selectors/register', { // if this isn't OP_CONTRACT, get latestHash, recreate and resend message if (!entry.isFirstMessage()) { const previousHEAD = await sbp('chelonia/private/out/latestHash', contractID) - entry = GIMessage.createV1_0(contractID, previousHEAD, entry.op()) + entry = GIMessage.createV1_0(contractID, previousHEAD, entry.op(), signatureFn) } } else { const message = (await r.json())?.message @@ -74,17 +75,37 @@ sbp('sbp/selectors/register', { const hash = message.hash() const contractID = message.contractID() const config = this.config + const contracts = this.contracts + const signature = message.signature() + const signedPayload = message.signedPayload() + const env = this.env + const self = this if (!state._vm) state._vm = {} const opFns: { [GIOpType]: (any) => void } = { [GIMessage.OP_CONTRACT] (v: GIOpContract) { - // TODO: shouldn't each contract have its own set of authorized keys? - if (!state._vm.authorizedKeys) state._vm.authorizedKeys = [] - // TODO: we probably want to be pushing the de-JSON-ified key here - state._vm.authorizedKeys.push({ key: v.keyJSON, context: 'owner' }) + const keys = { ...env.additionalKeys, ...state._volatile?.keys } + const { type } = v + if (!contracts[type]) { + throw new Error(`chelonia: contract not recognized: '${type}'`) + } + state._vm.authorizedKeys = v.keys + + for (const key of v.keys) { + if (key.meta?.private) { + if (key.id && key.meta.private.keyId in keys && key.meta.private.content) { + if (!state._volatile) state._volatile = { keys: {} } + try { + state._volatile.keys[key.id] = decrypt(keys[key.meta.private.keyId], key.meta.private.content) + } catch (e) { + console.error('Decryption error', e) + } + } + } + } }, [GIMessage.OP_ACTION_ENCRYPTED] (v: GIOpActionEncrypted) { if (!config.skipActionProcessing) { - const decrypted = message.decryptedValue(config.decryptFn) + const decrypted = config.decryptFn.call(self, message.opValue(), state) opFns[GIMessage.OP_ACTION_UNENCRYPTED](decrypted) } }, @@ -115,6 +136,20 @@ sbp('sbp/selectors/register', { if (config.preOp) { processOp = config.preOp(message, state) !== false && processOp } + + // Signature verification + // TODO: Temporary. Skip verifying default signatures + if (signature.type !== 'default') { + const authorizedKeys = opT === GIMessage.OP_CONTRACT ? ((opV: any): GIOpContract).keys : state._vm.authorizedKeys + const signingKey = authorizedKeys?.find((k) => k.id === signature.keyId && Array.isArray(k.perm) && k.perm.includes(opT)) + + if (!si gningKey) { + throw new Error('No matching signing key was defined') + } + + verifySignature(signingKey.data, signedPayload, signature.data) + } + if (config[`preOp_${opT}`]) { processOp = config[`preOp_${opT}`](message, state) !== false && processOp } @@ -216,7 +251,7 @@ sbp('sbp/selectors/register', { if (!processingErrored) { try { if (!this.config.skipActionProcessing && !this.config.skipSideEffects) { - await handleEvent.processSideEffects.call(this, message) + await handleEvent.processSideEffects.call(this, message, state[contractID]) } postHandleEvent && await postHandleEvent(message) sbp('okTurtles.events/emit', hash, contractID, message) @@ -287,11 +322,11 @@ const handleEvent = { await Promise.resolve() // TODO: load any unloaded contract code sbp('chelonia/private/in/processMessage', message, state[contractID]) }, - async processSideEffects (message: GIMessage) { + async processSideEffects (message: GIMessage, state: Object) { if ([GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_ACTION_UNENCRYPTED].includes(message.opType())) { const contractID = message.contractID() const hash = message.hash() - const { action, data, meta } = message.decryptedValue() + const { action, data, meta } = this.config.decryptFn.call(this, message.opValue(), state) const mutation = { data, meta, hash, contractID } await sbp(`${action}/sideEffect`, mutation) } From 05976eb2551b5d433ad32385e6646d6c6fe03392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Sun, 1 May 2022 17:57:07 +0200 Subject: [PATCH 02/21] Fix typo --- shared/domains/chelonia/internals.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index abd4d823f9..bdb8a5a330 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -143,7 +143,7 @@ sbp('sbp/selectors/register', { const authorizedKeys = opT === GIMessage.OP_CONTRACT ? ((opV: any): GIOpContract).keys : state._vm.authorizedKeys const signingKey = authorizedKeys?.find((k) => k.id === signature.keyId && Array.isArray(k.perm) && k.perm.includes(opT)) - if (!si gningKey) { + if (!signingKey) { throw new Error('No matching signing key was defined') } From 93274b3c3ec559d4ef2a6bbe3437f06eff2ece31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Sun, 5 Jun 2022 21:52:36 +0200 Subject: [PATCH 03/21] New OP_KEYSHARE --- frontend/controller/actions/chatroom.js | 5 + frontend/controller/actions/group.js | 7 +- frontend/controller/actions/identity.js | 42 +++++++- frontend/controller/actions/mailbox.js | 8 +- package-lock.json | 114 +++++++++++++-------- shared/domains/chelonia/GIMessage.js | 27 +++-- shared/domains/chelonia/chelonia.js | 126 +++++++++++++++++++----- shared/domains/chelonia/internals.js | 65 ++++++++++-- 8 files changed, 298 insertions(+), 96 deletions(-) diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 624799c88e..51a442d4db 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -28,6 +28,8 @@ export default (sbp('sbp/selectors/register', { const CSKs = encrypt(CEK, serializeKey(CSK, true)) const CEKs = encrypt(CEK, serializeKey(CEK, true)) + const rootState = sbp('state/vuex/state') + const chatroom = await sbp('chelonia/with-env', '', { additionalKeys: { [CSKid]: CSK, @@ -73,6 +75,9 @@ export default (sbp('sbp/selectors/register', { await sbp('chelonia/with-env', contractID, { additionalKeys: { [CEKid]: CEK } }, ['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) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index fa221e4dcb..95f50a0756 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -98,6 +98,8 @@ export default (sbp('sbp/selectors/register', { const CSKs = encrypt(CEK, serializeKey(CSK, true)) const CEKs = encrypt(CEK, serializeKey(CEK, true)) + const rootState = sbp('state/vuex/state') + try { const initialInvite = createInvite({ quantity: 60, creator: INVITE_INITIAL_CREATOR }) const proposalSettings = { @@ -144,7 +146,7 @@ export default (sbp('sbp/selectors/register', { id: CEKid, type: CEK.type, data: CEKp, - permissions: [GIMessage.OP_ACTION_ENCRYPTED], + permissions: [GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_KEYSHARE], meta: { type: 'cek', private: { @@ -213,6 +215,9 @@ export default (sbp('sbp/selectors/register', { 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) diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 94fabba978..715f9e4373 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -1,7 +1,7 @@ 'use strict' import sbp from '@sbp/sbp' -import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, deriveKeyFromPassword, serializeKey, encrypt } from '~/shared/domains/chelonia/crypto.js' +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, deriveKeyFromPassword, deserializeKey, serializeKey, encrypt } from '~/shared/domains/chelonia/crypto.js' import { GIErrorUIRuntimeError } from '@model/errors.js' import L, { LError } from '@view-utils/translations.js' import { imageUpload } from '@utils/image.js' @@ -13,6 +13,7 @@ import './mailbox.js' import { encryptedAction } from './utils.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import type { GIKey } from '~/shared/domains/chelonia/GIMessage.js' // eslint-disable-next-line camelcase const salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC = 'SALT CHANGEME' @@ -134,7 +135,7 @@ export default (sbp('sbp/selectors/register', { 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], + 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: { @@ -147,7 +148,7 @@ export default (sbp('sbp/selectors/register', { id: CEKid, type: CEK.type, data: CEKp, - permissions: [GIMessage.OP_ACTION_ENCRYPTED], + permissions: [GIMessage.OP_ACTION_ENCRYPTED, GIMessage.OP_KEYSHARE], meta: { type: 'cek', private: { @@ -168,12 +169,47 @@ export default (sbp('sbp/selectors/register', { signingKeyId: CSKid, encryptionKeyId: CEKid }]) + + await sbp('gi.actions/identity/shareKeysWithSelf', { userID, contractID: mailboxID }) } catch (e) { console.error('gi.actions/identity/create failed!', e) throw new GIErrorUIRuntimeError(L('Failed to create user identity: {reportError}', LError(e))) } 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') diff --git a/frontend/controller/actions/mailbox.js b/frontend/controller/actions/mailbox.js index 15d0ff1abf..b67c7c1161 100644 --- a/frontend/controller/actions/mailbox.js +++ b/frontend/controller/actions/mailbox.js @@ -8,7 +8,7 @@ import { encryptedAction } from './utils.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' export default (sbp('sbp/selectors/register', { - '_gi.actions/mailbox/create': async function ({ + 'gi.actions/mailbox/create': async function ({ data = {}, options: { sync = true } = {}, publishOptions }): Promise { try { @@ -81,11 +81,5 @@ export default (sbp('sbp/selectors/register', { throw new GIErrorUIRuntimeError(L('Failed to create mailbox: {reportError}', LError(e))) } }, - get 'gi.actions/mailbox/create' () { - return this['_gi.actions/mailbox/create'] - }, - set 'gi.actions/mailbox/create' (value) { - this['_gi.actions/mailbox/create'] = value - }, ...encryptedAction('gi.actions/mailbox/postMessage', L('Failed to post message to mailbox.')) }): string[]) diff --git a/package-lock.json b/package-lock.json index d20ffd89a5..331c77d642 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10199,29 +10199,6 @@ "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", "dev": true }, - "node_modules/htmlparser2/node_modules/readable-stream": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", - "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/htmlparser2/node_modules/string_decoder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.2.0.tgz", - "integrity": "sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==", - "dev": true, - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/http-errors": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", @@ -14359,6 +14336,20 @@ "node": ">=8" } }, + "node_modules/readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", @@ -15562,6 +15553,35 @@ "node": ">= 0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", @@ -26000,26 +26020,6 @@ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", "dev": true - }, - "readable-stream": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz", - "integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==", - "dev": true, - "requires": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - } - }, - "string_decoder": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.2.0.tgz", - "integrity": "sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w==", - "dev": true, - "requires": { - "safe-buffer": "~5.1.0" - } } } }, @@ -29248,6 +29248,17 @@ } } }, + "readable-stream": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz", + "integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + } + }, "readdirp": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.5.0.tgz", @@ -30236,6 +30247,23 @@ "limiter": "^1.0.5" } }, + "string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "requires": { + "safe-buffer": "~5.2.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true + } + } + }, "string-hash": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index 065114f630..57b505cab0 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -23,9 +23,10 @@ export type GIOpActionUnencrypted = { action: string; data: JSONType; meta: JSON export type GIOpKeyAdd = GIKey[] export type GIOpKeyDel = string[] export type GIOpPropSet = { key: string, value: JSONType } +export type GIOpKeyShare = { contractID: string, keys: GIKey[] } -export type GIOpType = 'c' | 'ae' | 'au' | 'ka' | 'kd' | 'pu' | 'ps' | 'pd' -export type GIOpValue = GIOpContract | GIOpActionEncrypted | GIOpActionUnencrypted | GIOpKeyAdd | GIOpKeyDel | GIOpPropSet +export type GIOpType = 'c' | 'ae' | 'au' | 'ka' | 'kd' | 'pu' | 'ps' | 'pd' | 'ks' +export type GIOpValue = GIOpContract | GIOpActionEncrypted | GIOpActionUnencrypted | GIOpKeyAdd | GIOpKeyDel | GIOpPropSet | GIOpKeyShare export type GIOp = [GIOpType, GIOpValue] export class GIMessage { @@ -48,18 +49,29 @@ export class GIMessage { static OP_CONTRACT_AUTH: 'ca' = 'ca' // authorize a contract static OP_CONTRACT_DEAUTH: 'cd' = 'cd' // deauthorize a contract static OP_ATOMIC: 'at' = 'at' // atomic op + static OP_KEYSHARE: 'ks' = 'ks' // key share // eslint-disable-next-line camelcase static createV1_0 ( - contractID: string | null = null, - previousHEAD: string | null = null, - op: GIOp, - signatureFn?: Function = defaultSignatureFn + { + contractID, + originatingContractID, + previousHEAD = null, + op, + signatureFn = defaultSignatureFn + }: { + contractID: string | null, + originatingContractID?: string, + previousHEAD?: string | null, + op: GIOp, + signatureFn?: Function + } ): this { const head = { version: '1.0.0', previousHEAD, contractID, + originatingContractID, op: op[0] } console.log('createV1_0', { op, head }) @@ -111,6 +123,7 @@ export class GIMessage { case GIMessage.OP_CONTRACT: if (!this.isFirstMessage()) throw new Error('OP_CONTRACT: must be first message') break + case GIMessage.OP_KEYSHARE: case GIMessage.OP_ACTION_ENCRYPTED: // nothing for now break @@ -165,6 +178,8 @@ export class GIMessage { contractID (): string { return this.head().contractID || this.hash() } + originatingContractID (): string { return this.head().originatingContractID || this.contractID() } + serialize (): string { return this._mapping.value } hash (): string { return this._mapping.key } diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index f084d479bb..0168551c79 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -10,7 +10,7 @@ import { merge, cloneDeep, randomHexString, intersection, difference } from '~/f // TODO: rename this to ChelMessage import { GIMessage } from './GIMessage.js' import { ChelErrorUnrecoverable } from './errors.js' -import type { GIKey, GIOpContract, GIOpActionUnencrypted, GIOpKeyAdd, GIOpKeyDel } from './GIMessage.js' +import type { GIKey, GIOpContract, GIOpActionUnencrypted, GIOpKeyAdd, GIOpKeyDel, GIOpKeyShare } from './GIMessage.js' import { keyId, sign, encrypt, decrypt, generateSalt } from './crypto.js' // TODO: define ChelContractType for /defineContract @@ -70,6 +70,21 @@ export type ChelKeyDelParams = { publishOptions?: { maxAttempts: number }; } +export type ChelKeyShareParams = { + originatingContractID?: string; + originatingContractName?: string; + destinationContractID: string; + destinationContractName: string; + data: GIOpKeyShare; + signingKeyId: string; + hooks?: { + prepublishContract?: (GIMessage) => void; + prepublish?: (GIMessage) => void; + postpublish?: (GIMessage) => void; + }; + publishOptions?: { maxAttempts: number }; +} + export { GIMessage } export const ACTION_REGEX: RegExp = /^((([\w.]+)\/([^/]+))(?:\/(?:([^/]+)\/)?)?)\w*/ @@ -373,17 +388,22 @@ sbp('sbp/selectors/register', { const contract = this.contracts[contractName] if (!contract) throw new Error(`contract not defined: ${contractName}`) const signingKey = this.env.additionalKeys?.[signingKeyId] - const signingFn = signingKey ? signatureFnBuilder(signingKey) : undefined - const contractMsg = GIMessage.createV1_0(null, null, [ - GIMessage.OP_CONTRACT, - ({ - type: contractName, - keys: keys, - nonce: generateSalt() - }: GIOpContract) - ], signingFn) + const signatureFn = signingKey ? signatureFnBuilder(signingKey) : undefined + const contractMsg = GIMessage.createV1_0({ + contractID: null, + previousHEAD: null, + op: [ + GIMessage.OP_CONTRACT, + ({ + type: contractName, + keys: keys, + nonce: generateSalt() + }: GIOpContract) + ], + signatureFn + }) hooks && hooks.prepublishContract && hooks.prepublishContract(contractMsg) - await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, signingFn) + await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, signatureFn) const contractID = contractMsg.hash() const msg = await sbp('chelonia/out/actionEncrypted', { action: contractName, @@ -404,6 +424,48 @@ sbp('sbp/selectors/register', { 'chelonia/out/actionUnencrypted': function (params: ChelActionParams): Promise { return outEncryptedOrUnencryptedAction.call(this, GIMessage.OP_ACTION_UNENCRYPTED, params) }, + 'chelonia/out/keyShare': async function (params: ChelKeyShareParams): Promise { + const { originatingContractName, originatingContractID, destinationContractName, destinationContractID, data, hooks, publishOptions } = params + const originatingContract = originatingContractID ? this.contracts[originatingContractName] : undefined + const destinationContract = this.contracts[destinationContractName] + let originatingState + + if ((originatingContractID && !originatingContract) || !destinationContract) { + throw new Error('Contract name not found') + } + + if (originatingContractID && originatingContract) { + originatingState = originatingContract.state(originatingContractID) + const originatingGProxy = gettersProxy(originatingState, originatingContract.getters) + const originatingMeta = originatingContract.metadata.create() + originatingContract.metadata.validate(originatingMeta, { state: originatingState, ...originatingGProxy, originatingContractID }) + } + + const destinationState = destinationContract.state(destinationContractID) + const previousHEAD = await sbp('chelonia/private/out/latestHash', destinationContractID) + + const destinationGProxy = gettersProxy(destinationState, destinationContract.getters) + const destinationMeta = destinationContract.metadata.create() + destinationContract.metadata.validate(destinationMeta, { state: destinationState, ...destinationGProxy, destinationContractID }) + const payload = (data: GIOpKeyShare) + + const signingKey = this.env.additionalKeys?.[params.signingKeyId] || ((originatingContractID ? originatingState : destinationState)?._volatile?.keys[params.signingKeyId]) + + const msg = GIMessage.createV1_0({ + contractID: destinationContractID, + originatingContractID, + previousHEAD, + op: [ + GIMessage.OP_KEYSHARE, + payload + ], + signatureFn: signingKey ? signatureFnBuilder(signingKey) : undefined + }) + hooks && hooks.prepublish && hooks.prepublish(msg) + await sbp('chelonia/private/out/publishEvent', msg, publishOptions) + hooks && hooks.postpublish && hooks.postpublish(msg) + return msg + }, 'chelonia/out/keyAdd': async function (params: ChelKeyAddParams): Promise { const { contractID, contractName, data, hooks, publishOptions } = params const contract = this.contracts[contractName] @@ -417,11 +479,15 @@ sbp('sbp/selectors/register', { contract.metadata.validate(meta, { state, ...gProxy, contractID }) const payload = (data: GIOpKeyAdd) const signingKey = this.env.additionalKeys?.[params.signingKeyId] || state?._volatile?.keys[params.signingKeyId] - const msg = GIMessage.createV1_0(contractID, previousHEAD, [ - GIMessage.OP_KEY_ADD, - payload - ], signingKey ? signatureFnBuilder(signingKey) : undefined - ) + const msg = GIMessage.createV1_0({ + contractID, + previousHEAD, + op: [ + GIMessage.OP_KEY_ADD, + payload + ], + signatureFn: signingKey ? signatureFnBuilder(signingKey) : undefined + }) hooks && hooks.prepublish && hooks.prepublish(msg) await sbp('chelonia/private/out/publishEvent', msg, publishOptions) hooks && hooks.postpublish && hooks.postpublish(msg) @@ -440,11 +506,15 @@ sbp('sbp/selectors/register', { contract.metadata.validate(meta, { state, ...gProxy, contractID }) const payload = (data: GIOpKeyDel) const signingKey = this.env.additionalKeys?.[params.signingKeyId] || state?._volatile?.keys[params.signingKeyId] - const msg = GIMessage.createV1_0(contractID, previousHEAD, [ - GIMessage.OP_KEY_DEL, - payload - ], signingKey ? signatureFnBuilder(signingKey) : undefined - ) + const msg = GIMessage.createV1_0({ + contractID, + previousHEAD, + op: [ + GIMessage.OP_KEY_DEL, + payload + ], + signatureFn: signingKey ? signatureFnBuilder(signingKey) : undefined + }) hooks && hooks.prepublish && hooks.prepublish(msg) await sbp('chelonia/private/out/publishEvent', msg, publishOptions) hooks && hooks.postpublish && hooks.postpublish(msg) @@ -483,11 +553,15 @@ async function outEncryptedOrUnencryptedAction ( const unencMessage = ({ action, data, meta }: GIOpActionUnencrypted) const signingKey = this.env.additionalKeys?.[params.signingKeyId] || state?._volatile?.keys[params.signingKeyId] const payload = opType === GIMessage.OP_ACTION_UNENCRYPTED ? unencMessage : this.config.encryptFn.call(this, unencMessage, params.encryptionKeyId, state) - const message = GIMessage.createV1_0(contractID, previousHEAD, [ - opType, - payload - ], signingKey ? signatureFnBuilder(signingKey) : undefined - ) + const message = GIMessage.createV1_0({ + contractID, + previousHEAD, + op: [ + opType, + payload + ], + signatureFn: signingKey ? signatureFnBuilder(signingKey) : undefined + }) hooks && hooks.prepublish && hooks.prepublish(message) await sbp('chelonia/private/out/publishEvent', message, publishOptions) hooks && hooks.postpublish && hooks.postpublish(message) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 40185c1ec0..cbcb3b888f 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -1,16 +1,15 @@ 'use strict' import sbp from '@sbp/sbp' -import './db.js' -import { GIMessage } from './GIMessage.js' import { handleFetchResult } from '~/frontend/controller/utils/misc.js' +import { cloneDeep, debounce, delay, pick, randomIntFromRange } from '~/frontend/utils/giLodash.js' import { b64ToStr } from '~/shared/functions.js' -import { randomIntFromRange, delay, cloneDeep, debounce, pick } from '~/frontend/utils/giLodash.js' -import { ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.js' -import { CONTRACT_IS_SYNCING, CONTRACTS_MODIFIED, EVENT_HANDLED } from './events.js' import { decrypt, verifySignature } from './crypto.js' - -import type { GIKey, GIOpContract, GIOpType, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpPropSet, GIOpKeyAdd, GIOpKeyDel } from './GIMessage.js' +import './db.js' +import { ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.js' +import { CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, EVENT_HANDLED } from './events.js' +import type { GIKey, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpContract, GIOpKeyAdd, GIOpKeyDel, GIOpKeyShare, GIOpPropSet, GIOpType } from './GIMessage.js' +import { GIMessage } from './GIMessage.js' const keysToMap = (keys: GIKey[]): Object => { return Object.fromEntries(keys.map(key => [key.id, key])) @@ -53,7 +52,7 @@ sbp('sbp/selectors/register', { // if this isn't OP_CONTRACT, get latestHash, recreate and resend message if (!entry.isFirstMessage()) { const previousHEAD = await sbp('chelonia/private/out/latestHash', contractID) - entry = GIMessage.createV1_0(contractID, previousHEAD, entry.op(), signatureFn) + entry = GIMessage.createV1_0({ contractID, previousHEAD, op: entry.op(), signatureFn }) } } else { const message = (await r.json())?.message @@ -77,7 +76,7 @@ sbp('sbp/selectors/register', { return events.reverse().map(b64ToStr) } }, - 'chelonia/private/in/processMessage': function (message: GIMessage, state: Object) { + 'chelonia/private/in/processMessage': async function (message: GIMessage, state: Object) { const [opT, opV] = message.op() const hash = message.hash() const contractID = message.contractID() @@ -125,6 +124,33 @@ sbp('sbp/selectors/register', { sbp(`${action}/process`, { data, meta, hash, contractID }, state) } }, + [GIMessage.OP_KEYSHARE] (v: GIOpKeyShare) { + if (message.originatingContractID() !== contractID && v.contractID !== message.originatingContractID()) { + throw new Error('External contracts can only set keys for themselves') + } + + const cheloniaState = sbp(self.config.stateSelector) + + if (!cheloniaState[v.contractID]) { + cheloniaState[v.contractID] = Object.create(null) + } + const targetState = cheloniaState[v.contractID] + + const keys = { ...env.additionalKeys, ...state._volatile?.keys } + + for (const key of v.keys) { + if (key.meta?.private) { + if (key.id && key.meta.private.keyId in keys && key.meta.private.content) { + if (!targetState._volatile) targetState._volatile = { keys: {} } + try { + targetState._volatile.keys[key.id] = decrypt(keys[key.meta.private.keyId], key.meta.private.content) + } catch (e) { + console.error('Decryption error', e) + } + } + } + } + }, [GIMessage.OP_PROP_DEL]: notImplemented, [GIMessage.OP_PROP_SET] (v: GIOpPropSet) { if (!state._vm.props) state._vm.props = {} @@ -166,7 +192,23 @@ sbp('sbp/selectors/register', { // Signature verification // TODO: Temporary. Skip verifying default signatures if (signature.type !== 'default') { - const authorizedKeys = opT === GIMessage.OP_CONTRACT ? keysToMap(((opV: any): GIOpContract).keys) : state._vm.authorizedKeys + // This sync code has potential issues + // The first issue is that it can deadlock if there are circular references + // The second issue is that it doesn't handle key rotation. If the key used for signing is invalidated / removed from the originating contract, we won't have it in the state + // Both of these issues can be resolved by introducing a parameter with the message ID the state is based on. This requires implementing a separate, ephemeral, state container for operations that refer to a different contract. + // The difficulty of this is how to securely determine the message ID to use. + // The server can assist with this. + if (message.originatingContractID() !== message.contractID()) { + await sbp('okTurtles.eventQueue/queueEvent', `chelonia/${message.originatingContractID()}`, [ + 'chelonia/private/in/syncContract', message.originatingContractID() + ]) + } + + const contractState = message.originatingContractID() === message.contractID() + ? state + : sbp(this.config.stateSelector).contracts[message.originatingContractID()] + + const authorizedKeys = opT === GIMessage.OP_CONTRACT ? keysToMap(((opV: any): GIOpContract).keys) : contractState._vm.authorizedKeys const signingKey = authorizedKeys?.[signature.keyId] if (!signingKey || !Array.isArray(signingKey.permissions) || !signingKey.permissions.includes(opT)) { @@ -273,6 +315,9 @@ sbp('sbp/selectors/register', { } // whether or not there was an exception, we proceed ahead with updating the head // you can prevent this by throwing an exception in the processError hook + if (!state.contracts[contractID]) { + state.contracts[contractID] = Object.create(null) + } state.contracts[contractID].HEAD = hash // process any side-effects (these must never result in any mutation to the contract state!) if (!processingErrored) { From 0e366f724cb743f6b7ad09e8faf512679af857e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Sun, 19 Jun 2022 20:21:05 +0200 Subject: [PATCH 04/21] Broken changes --- frontend/controller/actions/chatroom.js | 36 +++++++++++++++++++++++++ frontend/controller/actions/utils.js | 2 +- frontend/model/contracts/identity.js | 8 +++--- shared/domains/chelonia/chelonia.js | 21 ++++++++++++--- shared/domains/chelonia/crypto.js | 27 ++++++++++--------- shared/domains/chelonia/internals.js | 16 +++++++---- 6 files changed, 85 insertions(+), 25 deletions(-) diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index c71024501e..a5c118ae46 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -30,6 +30,42 @@ export default (sbp('sbp/selectors/register', { const rootState = sbp('state/vuex/state') + 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], + 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 + } + } + } + ], + contractName: 'gi.contracts/chatroom' + }) + const chatroom = await sbp('chelonia/with-env', '', { additionalKeys: { [CSKid]: CSK, diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index 91dd4b9c48..e6d55c4629 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -12,9 +12,9 @@ export function encryptedAction (action: string, humanError: string | Function): try { const state = await sbp('chelonia/latestContractState', params.contractID) return await sbp('chelonia/out/actionEncrypted', { + ...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), - ...params, action: action.replace('gi.actions', 'gi.contracts') }) } catch (e) { diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index c51d01a328..af90fd0626 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -6,7 +6,7 @@ import Vue from 'vue' import '~/shared/domains/chelonia/chelonia.js' import { objectOf, objectMaybeOf, arrayOf, string, object } from '~/frontend/utils/flowTyper.js' import { merge } from '~/frontend/utils/giLodash.js' -import L from '~/frontend/views/utils/translations.js' +// import L from '~/frontend/views/utils/translations.js' sbp('chelonia/defineContract', { name: 'gi.contracts/identity', @@ -69,8 +69,8 @@ sbp('chelonia/defineContract', { Vue.set(state, 'loginState', data) }, async sideEffect () { - try { - await sbp('gi.actions/identity/updateLoginStateUponLogin') + /* try { + sbp('okTurtles.eventQueue/queueEvent', , ['gi.actions/identity/updateLoginStateUponLogin']) } catch (e) { sbp('gi.notifications/emit', 'ERROR', { message: L("Failed to join groups we're part of on another device. Not catastrophic, but could lead to problems. {errName}: '{errMsg}'", { @@ -78,7 +78,7 @@ sbp('chelonia/defineContract', { errMsg: e.message || '?' }) }) - } + } */ } } } diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 0672a0c65f..875cf7f85f 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -130,6 +130,7 @@ const decryptFn = function (message: Object, state: ?Object) { const key = this.env.additionalKeys?.[keyId] || state?._volatile?.keys?.[keyId] if (!key) { + console.log({ message, state, keyId, env: this.env }) throw new Error(`Key ${keyId} not found`) } @@ -184,7 +185,7 @@ sbp('sbp/selectors/register', { const savedEnv = this.env this.env = env try { - return await sbp('okTurtles.eventQueue/queueEvent', `chelonia/env/${contractID}`, sbpInvocation) + return await sbp('okTurtles.eventQueue/queueEvent', `chelonia/with-env/${contractID}`, sbpInvocation) } finally { this.env = savedEnv } @@ -332,7 +333,7 @@ sbp('sbp/selectors/register', { // but after it's finished. This is used in tandem with // queuing the 'chelonia/private/in/handleEvent' selector, defined below. // This prevents handleEvent getting called with the wrong previousHEAD for an event. - return sbp('okTurtles.eventQueue/queueEvent', `chelonia/${contractID}`, [ + return sbp('okTurtles.eventQueue/queueEvent', contractID, [ 'chelonia/private/in/syncContract', contractID ]).catch((err) => { console.error(`[chelonia] failed to sync ${contractID}:`, err) @@ -345,7 +346,7 @@ sbp('sbp/selectors/register', { 'chelonia/contract/remove': function (contractIDs: string | string[]): Promise<*> { const listOfIds = typeof contractIDs === 'string' ? [contractIDs] : contractIDs return Promise.all(listOfIds.map(contractID => { - return sbp('okTurtles.eventQueue/queueEvent', `chelonia/${contractID}`, [ + return sbp('okTurtles.eventQueue/queueEvent', contractID, [ 'chelonia/contract/removeImmediately', contractID ]) })) @@ -413,6 +414,7 @@ sbp('sbp/selectors/register', { }, // 'chelonia/out' - selectors that send data out to the server 'chelonia/out/registerContract': async function (params: ChelRegParams) { + console.log('Register contract', { params }) const { contractName, keys, hooks, publishOptions, signingKeyId, actionSigningKeyId, actionEncryptionKeyId } = params const contract = this.contracts[contractName] if (!contract) throw new Error(`contract not defined: ${contractName}`) @@ -434,6 +436,18 @@ sbp('sbp/selectors/register', { hooks && hooks.prepublishContract && hooks.prepublishContract(contractMsg) await sbp('chelonia/private/out/publishEvent', contractMsg, publishOptions, signatureFn) const contractID = contractMsg.hash() + console.log('Register contract, sednig action', { + params, + xx: { + action: contractName, + contractID, + data: params.data, + signingKeyId: actionSigningKeyId, + encryptionKeyId: actionEncryptionKeyId, + hooks, + publishOptions + } + }) const msg = await sbp('chelonia/out/actionEncrypted', { action: contractName, contractID, @@ -582,6 +596,7 @@ async function outEncryptedOrUnencryptedAction ( const unencMessage = ({ action, data, meta }: GIOpActionUnencrypted) const signingKey = this.env.additionalKeys?.[params.signingKeyId] || state?._volatile?.keys[params.signingKeyId] const payload = opType === GIMessage.OP_ACTION_UNENCRYPTED ? unencMessage : this.config.encryptFn.call(this, unencMessage, params.encryptionKeyId, state) + console.log({ unencMessage, ekid: params.encryptionKeyId, state, payload }) const message = GIMessage.createV1_0({ contractID, previousHEAD, diff --git a/shared/domains/chelonia/crypto.js b/shared/domains/chelonia/crypto.js index 7fc7766422..2a7bea80d1 100644 --- a/shared/domains/chelonia/crypto.js +++ b/shared/domains/chelonia/crypto.js @@ -258,19 +258,22 @@ export const encrypt = (inKey: Key | string, data: string): string => { return base64FullMessage } else if (key.type === CURVE25519XSALSA20POLY1305) { - if (!key.secretKey || !key.publicKey) { - throw new Error('Keypair missing') + if (!key.publicKey) { + throw new Error('Public key missing') } const nonce = nacl.randomBytes(nacl.box.nonceLength) const messageUint8 = strToBuf(data) - const box = nacl.box(messageUint8, nonce, key.publicKey, key.secretKey) + const ephemeralKey = nacl.box.keyPair() + const box = nacl.box(messageUint8, nonce, key.publicKey, ephemeralKey.secretKey) + ephemeralKey.secretKey.fill(0) - const fullMessage = new Uint8Array(nonce.length + box.length) + const fullMessage = new Uint8Array(nacl.box.publicKeyLength + nonce.length + box.length) - fullMessage.set(nonce) - fullMessage.set(box, nonce.length) + fullMessage.set(ephemeralKey.publicKey) + fullMessage.set(nonce, nacl.box.publicKeyLength) + fullMessage.set(box, nacl.box.publicKeyLength + nonce.length) const base64FullMessage = bytesToB64(fullMessage) @@ -303,19 +306,19 @@ export const decrypt = (inKey: Key | string, data: string): string => { return Buffer.from(decrypted).toString('utf-8') } else if (key.type === CURVE25519XSALSA20POLY1305) { - if (!key.secretKey || !key.publicKey) { - throw new Error('Keypair missing') + if (!key.secretKey) { + throw new Error('Secret key missing') } const messageWithNonceAsUint8Array = b64ToBuf(data) - const nonce = messageWithNonceAsUint8Array.slice(0, nacl.box.nonceLength) + const ephemeralPublicKey = messageWithNonceAsUint8Array.slice(0, nacl.box.publicKeyLength) + const nonce = messageWithNonceAsUint8Array.slice(nacl.box.publicKeyLength, nacl.box.publicKeyLength + nacl.box.nonceLength) const message = messageWithNonceAsUint8Array.slice( - nacl.box.nonceLength, - messageWithNonceAsUint8Array.length + nacl.box.publicKeyLength + nacl.box.nonceLength ) - const decrypted = nacl.box.open(message, nonce, key.publicKey, key.secretKey) + const decrypted = nacl.box.open(message, nonce, ephemeralPublicKey, key.secretKey) if (!decrypted) { throw new Error('Could not decrypt message') diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 8b7927cbd4..bf5bb8f7ae 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -143,7 +143,11 @@ sbp('sbp/selectors/register', { if (key.id && key.meta.private.keyId in keys && key.meta.private.content) { if (!targetState._volatile) targetState._volatile = { keys: {} } try { - targetState._volatile.keys[key.id] = decrypt(keys[key.meta.private.keyId], key.meta.private.content) + const decrypted = decrypt(keys[key.meta.private.keyId], key.meta.private.content) + targetState._volatile.keys[key.id] = decrypted + if (env.additionalKeys) { + env.additionalKeys[key.id] = decrypted + } } catch (e) { console.error('Decryption error', e) } @@ -191,7 +195,7 @@ sbp('sbp/selectors/register', { // Signature verification // TODO: Temporary. Skip verifying default signatures - if (signature.type !== 'default') { + if (isNaN(1) && signature.type !== 'default') { // This sync code has potential issues // The first issue is that it can deadlock if there are circular references // The second issue is that it doesn't handle key rotation. If the key used for signing is invalidated / removed from the originating contract, we won't have it in the state @@ -199,7 +203,7 @@ sbp('sbp/selectors/register', { // The difficulty of this is how to securely determine the message ID to use. // The server can assist with this. if (message.originatingContractID() !== message.contractID()) { - await sbp('okTurtles.eventQueue/queueEvent', `chelonia/${message.originatingContractID()}`, [ + await sbp('okTurtles.eventQueue/queueEvent', message.originatingContractID(), [ 'chelonia/private/in/syncContract', message.originatingContractID() ]) } @@ -315,10 +319,12 @@ sbp('sbp/selectors/register', { } // whether or not there was an exception, we proceed ahead with updating the head // you can prevent this by throwing an exception in the processError hook - if (!state.contracts[contractID]) { + /* if (!state.contracts[contractID]) { state.contracts[contractID] = Object.create(null) + } */ + if (state.contracts[contractID]) { + state.contracts[contractID].HEAD = hash } - state.contracts[contractID].HEAD = hash // process any side-effects (these must never result in any mutation to the contract state!) if (!processingErrored) { try { From 94a76011e68674af2782f4a5d609c0a843bbf078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Thu, 28 Jul 2022 10:21:29 +0200 Subject: [PATCH 05/21] ZKPP password salt --- backend/routes.js | 95 +++++++++++++++ backend/zkppSalt.js | 253 +++++++++++++++++++++++++++++++++++++++ backend/zkppSalt.test.js | 108 +++++++++++++++++ 3 files changed, 456 insertions(+) create mode 100644 backend/zkppSalt.js create mode 100644 backend/zkppSalt.test.js diff --git a/backend/routes.js b/backend/routes.js index b030e29696..8683deb431 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') @@ -230,3 +231,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') + } + + 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') + } + + 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') + } + + return salt +}) + +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) { + 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 +}) diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js new file mode 100644 index 0000000000..107b2e0a11 --- /dev/null +++ b/backend/zkppSalt.js @@ -0,0 +1,253 @@ +import { blake32Hash } from '~/shared/functions.js' +import { timingSafeEqual } from 'crypto' +import nacl from 'tweetnacl' +import sbp from '@sbp/sbp' + +// TODO HARDCODED VALUES +const recordPepper = 'pepper' +const recordMasterKey = 'masterKey' +const challengeSecret = 'secret' +const registrationSecret = 'secret' +const maxAge = 30 + +const hashStringArray = (...args: Array) => { + return nacl.hash(Buffer.concat(args.map((s) => nacl.hash(Buffer.from(s))))) +} + +const hashRawStringArray = (...args: Array) => { + return nacl.hash(Buffer.concat(args.map((s) => Buffer.from(s)))) +} + +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(record, 'base64url') + 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 + } + } + + 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 = recordBuf.toString('base64url') + await sbp('chelonia/db/set', recordId, record) +} + +export const getChallenge = async (contract: string, b: string): Promise => { + const record = await getZkppSaltRecord(contract) + if (!record) { + return false + } + const { authSalt } = record + const s = Buffer.from(nacl.randomBytes(12)).toString('base64url') + const now = (Date.now() / 1000 | 0).toString(16) + const sig = [now, Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64url')].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 = Buffer.from(nacl.hash(Buffer.from(r))).toString('base64url') + const sig = hashStringArray(contract, b, s, then, challengeSecret) + const macBuf = Buffer.from(mac, 'base64url') + + return sig.byteLength === macBuf.byteLength && timingSafeEqual(sig, macBuf) +} + +export const registrationKey = async (contract: string, b: string): Promise => { + const record = await getZkppSaltRecord(contract) + if (record) { + return false + } + + const encryptionKey = hashStringArray('REG', contract, registrationSecret).slice(0, nacl.secretbox.keyLength) + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + const keyPair = nacl.box.keyPair() + const s = Buffer.concat([nonce, nacl.secretbox(keyPair.secretKey, nonce, encryptionKey)]).toString('base64url') + const now = (Date.now() / 1000 | 0).toString(16) + const sig = [now, Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64url')].join(',') + + return { + s, + p: Buffer.from(keyPair.publicKey).toString('base64url'), + sig + } +} + +export const register = async (contract: string, clientPublicKey: string, encryptedSecretKey: string, userSig: string, encryptedHashedPassword: string): Promise => { + if (!verifyChallenge(contract, clientPublicKey, encryptedSecretKey, userSig)) { + return false + } + + const record = await getZkppSaltRecord(contract) + + if (record) { + return false + } + + const clientPublicKeyBuf = Buffer.from(clientPublicKey, 'base64url') + const encryptedSecretKeyBuf = Buffer.from(encryptedSecretKey, 'base64url') + 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) + + if (clientPublicKeyBuf.byteLength !== nacl.box.publicKeyLength || !secretKeyBuf || secretKeyBuf.byteLength !== nacl.box.secretKeyLength) { + return false + } + + const dhKey = nacl.box.before(clientPublicKeyBuf, secretKeyBuf) + + const encryptedHashedPasswordBuf = Buffer.from(encryptedHashedPassword, 'base64url') + + const hashedPasswordBuf = nacl.box.open.after(encryptedHashedPasswordBuf.slice(nacl.box.nonceLength), encryptedHashedPasswordBuf.slice(0, nacl.box.nonceLength), dhKey) + + if (!hashedPasswordBuf) { + return false + } + + const authSalt = Buffer.from(hashStringArray('AUTHSALT', dhKey)).slice(0, 18).toString('base64url') + const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', dhKey)).slice(0, 18).toString('base64url') + + await setZkppSaltRecord(contract, Buffer.from(hashedPasswordBuf).toString(), authSalt, contractSalt) + + return true +} + +const contractSaltVerifyC = (h: string, r: string, s: string, userHc: string) => { + const ħ = hashStringArray(r, s) + const c = hashStringArray(h, ħ) + const hc = nacl.hash(c) + const userHcBuf = Buffer.from(userHc, 'base64url') + + 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)) { + return false + } + + const record = await getZkppSaltRecord(contract) + if (!record) { + return false + } + + const { hashedPassword, contractSalt } = record + + const c = contractSaltVerifyC(hashedPassword, r, s, hc) + + if (!c) { + return false + } + + const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + + const encryptedContractSalt = nacl.secretbox(Buffer.from(contractSalt), nonce, encryptionKey) + + return Buffer.concat([nonce, encryptedContractSalt]).toString('base64url') +} + +export const update = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise => { + 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, 'base64url') + 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 +} diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js new file mode 100644 index 0000000000..42a53ed3d4 --- /dev/null +++ b/backend/zkppSalt.test.js @@ -0,0 +1,108 @@ +/* eslint-env mocha */ + +import nacl from 'tweetnacl' +import should from 'should' +import 'should-sinon' + +import { registrationKey, register, getChallenge, getContractSalt, update } from './zkppSalt.js' + +describe('ZKPP Salt functions', () => { + it('register', async () => { + const keyPair = nacl.box.keyPair() + const nonce = nacl.randomBytes(nacl.box.nonceLength) + 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 = Buffer.concat([nonce, nacl.box(Buffer.from('hash'), nonce, Buffer.from(regKeyAlice1.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + const res1 = await register('alice', publicKey, regKeyAlice1.s, regKeyAlice1.sig, encryptedHashedPasswordAlice1) + should(res1).equal(true, 'register should allow new entry (alice)') + + const encryptedHashedPasswordAlice2 = Buffer.concat([nonce, nacl.box(Buffer.from('hash'), nonce, Buffer.from(regKeyAlice2.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + 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 = Buffer.concat([nonce, nacl.box(Buffer.from('hash'), nonce, Buffer.from(regKeyBob1.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + const res3 = await register('bob', publicKey, regKeyBob1.s, regKeyBob1.sig, encryptedHashedPasswordBob1) + should(res3).equal(true, 'register should allow new entry (bob)') + }) + + it('getContractSalt', async () => { + const keyPair = nacl.box.keyPair() + const eNonce = nacl.randomBytes(nacl.box.nonceLength) + 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 encryptedHashedPassword = Buffer.concat([eNonce, nacl.box(Buffer.from('hash'), eNonce, Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + const res = await register(contract, publicKey, regKey.s, regKey.sig, encryptedHashedPassword) + should(res).equal(true, 'register should allow new entry (' + contract + ')') + + const dhKey = nacl.hash(nacl.box.before(Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)) + const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('AUTHSALT')), dhKey]))).slice(0, 18).toString('base64url') + const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('CONTRACTSALT')), dhKey]))).slice(0, 18).toString('base64url') + + 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', async () => { + const keyPair = nacl.box.keyPair() + const eNonce = nacl.randomBytes(nacl.box.nonceLength) + 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 encryptedHashedPassword = Buffer.concat([eNonce, nacl.box(Buffer.from('hash'), eNonce, Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + const res = await register(contract, publicKey, regKey.s, regKey.sig, encryptedHashedPassword) + should(res).equal(true, 'register should allow new entry (' + contract + ')') + + const dhKey = nacl.hash(nacl.box.before(Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)) + const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('AUTHSALT')), dhKey]))).slice(0, 18).toString('base64url') + + 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') + }) +}) From e9e3851c71817cd155477bf01c38cefb91d6ffab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Tue, 9 Aug 2022 01:18:57 +0200 Subject: [PATCH 06/21] ZKPP: Frontend implementation --- backend/zkppSalt.js | 62 ++++--------- backend/zkppSalt.test.js | 33 +++---- frontend/controller/actions/identity.js | 66 +++++++++++--- shared/zkpp.js | 111 ++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 68 deletions(-) create mode 100644 shared/zkpp.js diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index 107b2e0a11..c19965126b 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -2,6 +2,7 @@ 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 const recordPepper = 'pepper' @@ -10,14 +11,6 @@ const challengeSecret = 'secret' const registrationSecret = 'secret' const maxAge = 30 -const hashStringArray = (...args: Array) => { - return nacl.hash(Buffer.concat(args.map((s) => nacl.hash(Buffer.from(s))))) -} - -const hashRawStringArray = (...args: Array) => { - return nacl.hash(Buffer.concat(args.map((s) => Buffer.from(s)))) -} - const getZkppSaltRecord = async (contract: string) => { const recordId = blake32Hash(hashStringArray('RID', contract, recordPepper)) const record = await sbp('chelonia/db/get', recordId) @@ -25,7 +18,7 @@ const getZkppSaltRecord = async (contract: string) => { if (record) { const encryptionKey = hashStringArray('REK', contract, recordMasterKey).slice(0, nacl.secretbox.keyLength) - const recordBuf = Buffer.from(record, 'base64url') + const recordBuf = Buffer.from(record.replace(/_/g, '/').replace(/-/g, '+'), 'base64') const nonce = recordBuf.slice(0, nacl.secretbox.nonceLength) const recordCiphertext = recordBuf.slice(nacl.secretbox.nonceLength) const recordPlaintext = nacl.secretbox.open(recordCiphertext, nonce, encryptionKey) @@ -65,7 +58,7 @@ const setZkppSaltRecord = async (contract: string, hashedPassword: string, authS 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('base64url') + const record = recordBuf.toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') await sbp('chelonia/db/set', recordId, record) } @@ -75,9 +68,9 @@ export const getChallenge = async (contract: string, b: string): Promise { - const ħ = hashStringArray(r, s) - const c = hashStringArray(h, ħ) - const hc = nacl.hash(c) - const userHcBuf = Buffer.from(userHc, 'base64url') + 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 @@ -197,12 +178,7 @@ export const getContractSalt = async (contract: string, r: string, s: string, si return false } - const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) - const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) - - const encryptedContractSalt = nacl.secretbox(Buffer.from(contractSalt), nonce, encryptionKey) - - return Buffer.concat([nonce, encryptedContractSalt]).toString('base64url') + return encryptContractSalt(c, contractSalt) } export const update = async (contract: string, r: string, s: string, sig: string, hc: string, encryptedArgs: string): Promise => { @@ -223,7 +199,7 @@ export const update = async (contract: string, r: string, s: string, sig: string } const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength) - const encryptedArgsBuf = Buffer.from(encryptedArgs, 'base64url') + 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) diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index 42a53ed3d4..3a13c7dbf9 100644 --- a/backend/zkppSalt.test.js +++ b/backend/zkppSalt.test.js @@ -6,10 +6,20 @@ 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', async () => { const keyPair = nacl.box.keyPair() - const nonce = nacl.randomBytes(nacl.box.nonceLength) const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') @@ -17,24 +27,23 @@ describe('ZKPP Salt functions', () => { const regKeyAlice2 = await registrationKey('alice', publicKeyHash) should(regKeyAlice1).be.of.type('object') should(regKeyAlice2).be.of.type('object') - const encryptedHashedPasswordAlice1 = Buffer.concat([nonce, nacl.box(Buffer.from('hash'), nonce, Buffer.from(regKeyAlice1.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + 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 = Buffer.concat([nonce, nacl.box(Buffer.from('hash'), nonce, Buffer.from(regKeyAlice2.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + 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 = Buffer.concat([nonce, nacl.box(Buffer.from('hash'), nonce, Buffer.from(regKeyBob1.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + 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', async () => { const keyPair = nacl.box.keyPair() - const eNonce = nacl.randomBytes(nacl.box.nonceLength) const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') @@ -42,14 +51,11 @@ describe('ZKPP Salt functions', () => { const regKey = await registrationKey(contract, publicKeyHash) should(regKey).be.of.type('object') - const encryptedHashedPassword = Buffer.concat([eNonce, nacl.box(Buffer.from('hash'), eNonce, Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + 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 dhKey = nacl.hash(nacl.box.before(Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)) - const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('AUTHSALT')), dhKey]))).slice(0, 18).toString('base64url') - const contractSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('CONTRACTSALT')), dhKey]))).slice(0, 18).toString('base64url') - 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') @@ -71,7 +77,6 @@ describe('ZKPP Salt functions', () => { it('update', async () => { const keyPair = nacl.box.keyPair() - const eNonce = nacl.randomBytes(nacl.box.nonceLength) const publicKey = Buffer.from(keyPair.publicKey).toString('base64url') const publicKeyHash = Buffer.from(nacl.hash(Buffer.from(publicKey))).toString('base64url') @@ -79,13 +84,11 @@ describe('ZKPP Salt functions', () => { const regKey = await registrationKey(contract, publicKeyHash) should(regKey).be.of.type('object') - const encryptedHashedPassword = Buffer.concat([eNonce, nacl.box(Buffer.from('hash'), eNonce, Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)]).toString('base64url') + 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 dhKey = nacl.hash(nacl.box.before(Buffer.from(regKey.p, 'base64url'), keyPair.secretKey)) - const authSalt = Buffer.from(nacl.hash(Buffer.concat([nacl.hash(Buffer.from('AUTHSALT')), dhKey]))).slice(0, 18).toString('base64url') - 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') diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 715f9e4373..a1056289d7 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -12,11 +12,10 @@ import { LOGIN, LOGOUT } from '~/frontend/utils/events.js' import './mailbox.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' - -// eslint-disable-next-line camelcase -const salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC = 'SALT CHANGEME' +import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' function generatedLoginState () { const { contracts } = sbp('state/vuex/state') @@ -36,8 +35,25 @@ function diffLoginStates (s1: ?Object, s2: ?Object) { export default (sbp('sbp/selectors/register', { 'gi.actions/identity/retrieveSalt': async (username: string, password: string) => { - // TODO RETRIEVE FROM SERVER - return await Promise.resolve(salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC) + 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 }, @@ -69,12 +85,42 @@ export default (sbp('sbp/selectors/register', { const mailbox = await sbp('gi.actions/mailbox/create', { 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 - // TODO: The salt needs to be dynamically generated - // eslint-disable-next-line camelcase - const salt = salt_TODO_CHANGEME_NEEDS_TO_BE_DYNAMIC - const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, salt) - const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) + const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) diff --git a/shared/zkpp.js b/shared/zkpp.js new file mode 100644 index 0000000000..e41a6bd761 --- /dev/null +++ b/shared/zkpp.js @@ -0,0 +1,111 @@ +import nacl from 'tweetnacl' +import scrypt from 'scrypt-async' + +export const hashStringArray = (...args: Array): Uint8Array => { + return nacl.hash(Buffer.concat(args.map((s) => nacl.hash(Buffer.from(s))))) +} + +export const hashRawStringArray = (...args: Array): Uint8Array => { + return nacl.hash(Buffer.concat(args.map((s) => Buffer.from(s)))) +} + +export const randomNonce = (): string => { + return Buffer.from(nacl.randomBytes(12)).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') +} + +export const hash = (v: string): string => { + return Buffer.from(nacl.hash(Buffer.from(v))).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') +} + +export const computeCAndHc = (r: string, s: string, h: string): [Uint8Array, Uint8Array] => { + const ħ = hashStringArray(r, s) + const c = hashStringArray(h, ħ) + const hc = nacl.hash(c) + + return [c, hc] +} + +export const encryptContractSalt = (c: Uint8Array, contractSalt: string): string => { + const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) + const nonce = nacl.randomBytes(nacl.secretbox.nonceLength) + + const encryptedContractSalt = nacl.secretbox(Buffer.from(contractSalt), nonce, encryptionKey) + + return Buffer.concat([nonce, encryptedContractSalt]).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') +} + +export const decryptContractSalt = (c: Uint8Array, encryptedContractSaltBox: string): string => { + const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) + const encryptedContractSaltBoxBuf = Buffer.from(encryptedContractSaltBox.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + + const nonce = encryptedContractSaltBoxBuf.slice(0, nacl.secretbox.nonceLength) + const encryptedContractSalt = encryptedContractSaltBoxBuf.slice(nacl.secretbox.nonceLength) + + return Buffer.from(nacl.secretbox.open(encryptedContractSalt, nonce, encryptionKey)).toString() +} + +export const hashPassword = (password: string, salt: string): Promise => { + return new Promise(resolve => scrypt(password, salt, { + N: 16384, + r: 8, + p: 1, + dkLen: 32, + encoding: 'hex' + }, resolve)) +} + +export const boxKeyPair = (): any => { + return nacl.box.keyPair() +} + +export const saltAgreement = (publicKey: string, secretKey: Uint8Array): false | [string, string] => { + const publicKeyBuf = Buffer.from(publicKey.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + const dhKey = nacl.box.before(publicKeyBuf, secretKey) + + if (!publicKeyBuf || publicKeyBuf.byteLength !== nacl.box.publicKeyLength) { + return false + } + + const authSalt = Buffer.from(hashStringArray('AUTHSALT', dhKey)).slice(0, 18).toString('base64') + const contractSalt = Buffer.from(hashStringArray('CONTRACTSALT', dhKey)).slice(0, 18).toString('base64') + + return [authSalt, contractSalt] +} + +export const parseRegisterSalt = (publicKey: string, secretKey: Uint8Array, encryptedHashedPassword: string): false | [string, string, Uint8Array] => { + const saltAgreementRes = saltAgreement(publicKey, secretKey) + if (!saltAgreementRes) { + return false + } + + const [authSalt, contractSalt] = saltAgreementRes + + const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength) + const encryptedHashedPasswordBuf = Buffer.from(encryptedHashedPassword.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + + const hashedPasswordBuf = nacl.secretbox.open(encryptedHashedPasswordBuf.slice(nacl.box.nonceLength), encryptedHashedPasswordBuf.slice(0, nacl.box.nonceLength), encryptionKey) + + if (!hashedPasswordBuf) { + return false + } + + return [authSalt, contractSalt, hashedPasswordBuf] +} + +export const buildRegisterSaltRequest = async (publicKey: string, secretKey: Uint8Array, password: string): Promise<[string, string]> => { + const saltAgreementRes = saltAgreement(publicKey, secretKey) + if (!saltAgreementRes) { + throw new Error('Invalid public or secret key') + } + + const [authSalt, contractSalt] = saltAgreementRes + + const hashedPassword = await hashPassword(password, authSalt) + + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength) + + const encryptedHashedPasswordBuf = nacl.secretbox(Buffer.from(hashedPassword), nonce, encryptionKey) + + return [contractSalt, Buffer.concat([nonce, encryptedHashedPasswordBuf]).toString('base64').replace(/\//g, '_').replace(/\+/g, '-')] +} From 1051639e64ea3292fddb56ffcdc1e03d3c804175 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Mon, 15 Aug 2022 14:18:27 +0200 Subject: [PATCH 07/21] Prevent Grunt from running --- Gruntfile.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gruntfile.js b/Gruntfile.js index 40eed34676..0eb58308e8 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,5 +1,7 @@ 'use strict' +if (process.env['CI']) process.exit(1) + // ======================= // Entry point. // From e86df63a015a3fc3e56fe98fc9063f62f9f55644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Mon, 22 Aug 2022 15:48:17 +0200 Subject: [PATCH 08/21] OP_KEY_REQUEST --- frontend/controller/actions/group.js | 8 +++ frontend/model/contracts/chatroom.js | 1 + shared/domains/chelonia/GIMessage.js | 12 +++- shared/domains/chelonia/chelonia.js | 44 ++++++++++++- shared/domains/chelonia/internals.js | 97 ++++++++++++++++++++++++++-- 5 files changed, 155 insertions(+), 7 deletions(-) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 63c169250b..4f601f771d 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -25,6 +25,7 @@ import { dateToPeriodStamp, addTimeToDate, DAYS_MILLIS } from '@model/contracts/ 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' export async function leaveAllChatRooms (groupContractID: string, member: string) { @@ -227,6 +228,13 @@ export default (sbp('sbp/selectors/register', { 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()) diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 9e936577d9..fbbd04c56f 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -20,6 +20,7 @@ import { objectOf, string, optional } from '~/frontend/model/contracts/misc/flow function createNotificationData ( notificationType: string, + moreParams: Object = {} ): Object { return { diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index 3af012f29f..0706a52696 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -24,9 +24,15 @@ export type GIOpKeyAdd = GIKey[] export type GIOpKeyDel = string[] export type GIOpPropSet = { key: string, value: JSONType } export type GIOpKeyShare = { contractID: string, keys: GIKey[] } +export type GIOpKeyRequest = { + keyId: string; + encryptionKeyId: string; + data: string; +} +export type GIOpKeyRequestResponse = string -export type GIOpType = 'c' | 'ae' | 'au' | 'ka' | 'kd' | 'pu' | 'ps' | 'pd' | 'ks' -export type GIOpValue = GIOpContract | GIOpActionEncrypted | GIOpActionUnencrypted | GIOpKeyAdd | GIOpKeyDel | GIOpPropSet | GIOpKeyShare +export type GIOpType = 'c' | 'ae' | 'au' | 'ka' | 'kd' | 'pu' | 'ps' | 'pd' | 'ks' | 'kr' | 'krr' +export type GIOpValue = GIOpContract | GIOpActionEncrypted | GIOpActionUnencrypted | GIOpKeyAdd | GIOpKeyDel | GIOpPropSet | GIOpKeyShare | GIOpKeyRequest | GIOpKeyRequestResponse export type GIOp = [GIOpType, GIOpValue] export class GIMessage { @@ -50,6 +56,8 @@ export class GIMessage { static OP_CONTRACT_DEAUTH: 'cd' = 'cd' // deauthorize a contract static OP_ATOMIC: 'at' = 'at' // atomic op static OP_KEYSHARE: 'ks' = 'ks' // key share + static OP_KEY_REQUEST: 'kr' = 'kr' // key request + static OP_KEY_REQUEST_RESPONSE: 'krr' = 'krr' // key request response // eslint-disable-next-line camelcase static createV1_0 ( diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 810d034eb2..8f00bda302 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -12,7 +12,7 @@ import { handleFetchResult } from '~/frontend/controller/utils/misc.js' // TODO: rename this to ChelMessage import { GIMessage } from './GIMessage.js' import { ChelErrorUnrecoverable } from './errors.js' -import type { GIKey, GIOpContract, GIOpActionUnencrypted, GIOpKeyAdd, GIOpKeyDel, GIOpKeyShare } from './GIMessage.js' +import type { GIKey, GIOpContract, GIOpActionUnencrypted, GIOpKeyAdd, GIOpKeyDel, GIOpKeyShare, GIOpKeyRequestResponse } from './GIMessage.js' import { keyId, sign, encrypt, decrypt, generateSalt } from './crypto.js' // TODO: define ChelContractType for /defineContract @@ -89,6 +89,19 @@ export type ChelKeyShareParams = { publishOptions?: { maxAttempts: number }; } +export type ChelKeyRequestResponseParams = { + contractName: string; + contractID: string; + data: GIOpKeyRequestResponse; + signingKeyId: string; + hooks?: { + prepublishContract?: (GIMessage) => void; + prepublish?: (GIMessage) => void; + postpublish?: (GIMessage) => void; + }; + publishOptions?: { maxAttempts: number }; +} + export { GIMessage } export const ACTION_REGEX: RegExp = /^((([\w.]+)\/([^/]+))(?:\/(?:([^/]+)\/)?)?)\w*/ @@ -621,6 +634,35 @@ export default (sbp('sbp/selectors/register', { hooks && hooks.postpublish && hooks.postpublish(msg) return msg }, + 'chelonia/out/keyRequestResponse': async function (params: ChelKeyRequestResponseParams): Promise { + const { contractID, contractName, data, hooks, publishOptions } = params + const manifestHash = this.config.contracts.manifests[contractName] + const contract = this.manifestToContract[manifestHash]?.contract + if (!contract) { + throw new Error('Contract name not found') + } + const state = contract.state(contractID) + const previousHEAD = await sbp('chelonia/private/out/latestHash', contractID) + const meta = contract.metadata.create() + const gProxy = gettersProxy(state, contract.getters) + contract.metadata.validate(meta, { state, ...gProxy, contractID }) + const payload = (data: GIOpKeyRequestResponse) + const signingKey = this.env.additionalKeys?.[params.signingKeyId] || state?._volatile?.keys[params.signingKeyId] + const msg = GIMessage.createV1_0({ + contractID, + previousHEAD, + op: [ + GIMessage.OP_KEY_REQUEST_RESPONSE, + payload + ], + manifest: manifestHash, + signatureFn: signingKey ? signatureFnBuilder(signingKey) : undefined + }) + hooks && hooks.prepublish && hooks.prepublish(msg) + await sbp('chelonia/private/out/publishEvent', msg, publishOptions) + hooks && hooks.postpublish && hooks.postpublish(msg) + return msg + }, 'chelonia/out/protocolUpgrade': async function () { }, diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index ba776b1e13..e139864ca8 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -2,8 +2,8 @@ import sbp, { domainFromSelector } from '@sbp/sbp' import './db.js' -import { decrypt, verifySignature } from './crypto.js' -import type { GIKey, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpContract, GIOpKeyAdd, GIOpKeyDel, GIOpKeyShare, GIOpPropSet, GIOpType } from './GIMessage.js' +import { encrypt, decrypt, verifySignature } from './crypto.js' +import type { GIKey, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpContract, GIOpKeyAdd, GIOpKeyDel, GIOpKeyShare, GIOpPropSet, GIOpType, GIOpKeyRequest, GIOpKeyRequestResponse } from './GIMessage.js' import { GIMessage } from './GIMessage.js' import { randomIntFromRange, delay, cloneDeep, debounce, pick } from '~/frontend/model/contracts/shared/giLodash.js' import { ChelErrorUnexpected, ChelErrorUnrecoverable } from './errors.js' @@ -198,6 +198,7 @@ export default (sbp('sbp/selectors/register', { } }, [GIMessage.OP_KEYSHARE] (v: GIOpKeyShare) { + // TODO: Prompt to user if contract not in pending if (message.originatingContractID() !== contractID && v.contractID !== message.originatingContractID()) { throw new Error('External contracts can only set keys for themselves') } @@ -228,6 +229,19 @@ export default (sbp('sbp/selectors/register', { } } }, + [GIMessage.OP_KEY_REQUEST] (v: GIOpKeyRequest) { + if (!state._vm.pending_key_requests) state._vm.pending_key_requests = Object.create(null) + state._vm.pending_key_requests[message.hash()] = [ + message.originatingContractID(), + message.head().previousHEAD, + v + ] + }, + [GIMessage.OP_KEY_REQUEST_RESPONSE] (v: GIOpKeyRequestResponse) { + if (state._vm.pending_key_requests && v in state._vm.pending_key_requests) { + delete state._vm.pending_key_requests[v] + } + }, [GIMessage.OP_PROP_DEL]: notImplemented, [GIMessage.OP_PROP_SET] (v: GIOpPropSet) { if (!state._vm.props) state._vm.props = {} @@ -346,6 +360,7 @@ export default (sbp('sbp/selectors/register', { console.debug(`[chelonia] contract ${contractID} was already synchronized`) } sbp('okTurtles.events/emit', CONTRACT_IS_SYNCING, contractID, false) + await sbp('chelonia/private/respondToKeyRequests', contractID) } catch (e) { console.error(`[chelonia] syncContract error: ${e.message}`, e) sbp('okTurtles.events/emit', CONTRACT_IS_SYNCING, contractID, false) @@ -353,6 +368,80 @@ export default (sbp('sbp/selectors/register', { throw e } }, + 'chelonia/private/respondToKeyRequests': async function (contractID: string) { + const state = sbp(this.config.stateSelector) + const contractState = state.contracts[contractID] ?? {} + + if (!contractState._vm || !contractState._vm.pending_key_requests) { + return + } + + const pending = contractState._vm.pending_key_requests + + delete contractState._vm.pending_key_requests + + await Promise.all(Object.entries(pending).map(async ([hash, entry]) => { + if (!Array.isArray(entry) || entry.length !== 3) { + return + } + + const [originatingContractID, previousHEAD, v] = ((entry: any): [string, string, GIOpKeyRequest]) + + // 1. Sync (originating) identity contract + await sbp('okTurtles.eventQueue/queueEvent', originatingContractID, [ + 'chelonia/private/in/syncContract', originatingContractID + ]) + + const contractName = this.state.contracts[contractID].type + const recipientContractName = this.state.contracts[originatingContractID].type + + try { + // 2. Verify 'data' + const { data, keyId, encryptionKeyId } = v + + const originatingState = sbp(self.config.stateSelector)[originatingContractID] + + const signingKey = originatingState._vm.authorizedKeys[keyId] + + if (!signingKey) { + throw new Error('Unable to find signing key') + } + + // sign(originatingContractID + GIMessage.OP_KEY_REQUEST + contractID + HEAD) + verifySignature(signingKey, [originatingContractID, GIMessage.OP_KEY_REQUEST, contractID, previousHEAD].map(encodeURIComponent).join('|'), data) + + const encryptionKey = originatingState._vm.authorizedKeys[encryptionKeyId] + + if (!encryptionKey) { + throw new Error('Unable to find encryption key') + } + + const { keys, signingKeyId } = sbp(`${contractName}/getShareableKeys`, contractID) + + // 3. Send OP_KEYSHARE to identity contract + await sbp('chelonia/out/keyShare', { + destinationContractID: originatingContractID, + destinationContractName: recipientContractName, + data: { + contractID: contractID, + keys: Object.entries(keys).map(([keyId, key]: [string, mixed]) => ({ + id: keyId, + meta: { + private: { + keyId: encryptionKeyId, + content: encrypt(encryptionKey, (key: any)) + } + } + })) + }, + signingKeyId + }) + } finally { + // 4. Update contract with information + await sbp('chelonia/out/keyRequestResponse', { contractID, contractName, data: hash }) + } + })) + }, 'chelonia/private/in/handleEvent': async function (message: GIMessage) { const state = sbp(this.config.stateSelector) const contractID = message.contractID() @@ -535,11 +624,11 @@ function loadScript (file, source, hash) { // script.type = 'application/javascript' script.type = 'module' // problem with this is that scripts will step on each other's feet - script.innerHTML = source + script.text = source // NOTE: this will work if the file route adds .header('Content-Type', 'application/javascript') // script.src = `${this.config.connectionURL}/file/${hash}` // this results in: "SyntaxError: import declarations may only appear at top level of a module" - // script.innerHTML = `(function () { + // script.text = `(function () { // ${source} // })()` script.onload = () => resolve(script) From 158b35df6e3a6de155ba2e766fb18af2d74f4165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Mon, 12 Sep 2022 16:54:43 +0200 Subject: [PATCH 09/21] Feedback on ZKPP and started moving invites into Chelonia --- backend/zkppSalt.js | 47 ++++++++++++++----- backend/zkppSalt.test.js | 6 +-- frontend/controller/actions/chatroom.js | 4 +- frontend/controller/actions/group.js | 38 ++++++++++----- frontend/controller/actions/identity.js | 8 ++-- frontend/controller/actions/mailbox.js | 4 +- frontend/model/contracts/group.js | 8 ++-- frontend/model/contracts/manifests.json | 4 +- frontend/model/contracts/shared/functions.js | 27 +---------- .../group-settings/InvitationLinkModal.vue | 12 ++++- .../group-settings/InvitationsTable.vue | 2 +- .../proposals/ProposalVoteOptions.vue | 5 +- frontend/views/pages/Join.vue | 4 +- shared/domains/chelonia/chelonia.js | 9 ++-- shared/domains/chelonia/internals.js | 20 +++++++- shared/zkpp.js | 18 ++++--- test/backend.test.js | 10 ++-- 17 files changed, 131 insertions(+), 95 deletions(-) diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index c19965126b..8d53207e2f 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -2,9 +2,10 @@ 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' +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' @@ -18,7 +19,7 @@ const getZkppSaltRecord = async (contract: string) => { if (record) { const encryptionKey = hashStringArray('REK', contract, recordMasterKey).slice(0, nacl.secretbox.keyLength) - const recordBuf = Buffer.from(record.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + 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) @@ -33,6 +34,7 @@ const getZkppSaltRecord = async (contract: string) => { const recordObj = JSON.parse(recordString) if (!Array.isArray(recordObj) || recordObj.length !== 3 || !recordObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) { + console.log('Error validating encryped JSON object ' + recordId) return null } @@ -44,6 +46,7 @@ const getZkppSaltRecord = async (contract: string) => { contractSalt } } catch { + console.log('Error parsing encrypted JSON object ' + recordId) // empty } } @@ -58,19 +61,20 @@ const setZkppSaltRecord = async (contract: string, hashedPassword: string, authS 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(/=*$/, '') + 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, Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '')].join(',') + const sig = [now, base64ToBase64url(Buffer.from(hashStringArray(contract, b, s, now, challengeSecret)).toString('base64'))].join(',') return { authSalt, @@ -96,7 +100,7 @@ const verifyChallenge = (contract: string, r: string, s: string, userSig: string const b = hash(r) const sig = hashStringArray(contract, b, s, then, challengeSecret) - const macBuf = Buffer.from(mac.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + const macBuf = Buffer.from(base64urlToBase64(mac), 'base64') return sig.byteLength === macBuf.byteLength && timingSafeEqual(sig, macBuf) } @@ -110,35 +114,43 @@ export const registrationKey = async (contract: string, b: string): Promise => { if (!verifyChallenge(contract, clientPublicKey, encryptedSecretKey, userSig)) { + console.debug('register: Error validating challenge: ' + JSON.stringify({ contract, clientPublicKey, userSig })) return false } const record = await getZkppSaltRecord(contract) if (record) { + console.debug('register: Error obtaining ZKPP salt record for contract ID ' + contract) return false } - const encryptedSecretKeyBuf = Buffer.from(encryptedSecretKey.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + 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) + if (!secretKeyBuf) { + console.debug(`register: Error decrypting arguments for contract ID ${contract} (${JSON.stringify({ clientPublicKey, userSig })})`) + return false + } + const parseRegisterSaltRes = parseRegisterSalt(clientPublicKey, secretKeyBuf, encryptedHashedPassword) if (!parseRegisterSaltRes) { + console.debug(`register: Error parsing registration salt for contract ID ${contract} (${JSON.stringify({ clientPublicKey, userSig })})`) return false } @@ -151,7 +163,7 @@ export const register = async (contract: string, clientPublicKey: string, encryp 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') + const userHcBuf = Buffer.from(base64urlToBase64(userHc), 'base64') if (hc.byteLength === userHcBuf.byteLength && timingSafeEqual(hc, userHcBuf)) { return c @@ -162,11 +174,13 @@ const contractSaltVerifyC = (h: string, r: string, s: string, userHc: string) => 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 })) return false } const record = await getZkppSaltRecord(contract) if (!record) { + console.debug('getContractSalt: Error obtaining ZKPP salt record for contract ID ' + contract) return false } @@ -175,6 +189,7 @@ export const getContractSalt = async (contract: string, r: string, s: string, si const c = contractSaltVerifyC(hashedPassword, r, s, hc) if (!c) { + console.debug(`getContractSalt: Error verifying challenge for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) return false } @@ -183,11 +198,13 @@ export const getContractSalt = async (contract: string, r: string, s: string, si 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 })) return false } const record = await getZkppSaltRecord(contract) if (!record) { + console.debug('update: Error obtaining ZKPP salt record for contract ID ' + contract) return false } const { hashedPassword } = record @@ -195,17 +212,19 @@ export const update = async (contract: string, r: string, s: string, sig: string const c = contractSaltVerifyC(hashedPassword, r, s, hc) if (!c) { + console.debug(`update: Error verifying challenge for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) return false } const encryptionKey = hashRawStringArray('SU', c).slice(0, nacl.secretbox.keyLength) - const encryptedArgsBuf = Buffer.from(encryptedArgs.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + const encryptedArgsBuf = Buffer.from(base64urlToBase64(encryptedArgs), 'base64') const nonce = encryptedArgsBuf.slice(0, nacl.secretbox.nonceLength) - const encrytedArgsCiphertext = encryptedArgsBuf.slice(nacl.secretbox.nonceLength) + const encryptedArgsCiphertext = encryptedArgsBuf.slice(nacl.secretbox.nonceLength) - const args = nacl.secretbox.open(encrytedArgsCiphertext, nonce, encryptionKey) + const args = nacl.secretbox.open(encryptedArgsCiphertext, nonce, encryptionKey) if (!args) { + console.debug(`update: Error decrypting arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) return false } @@ -213,6 +232,7 @@ export const update = async (contract: string, r: string, s: string, sig: string 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.debug(`update: Error validating the encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) return false } @@ -222,6 +242,7 @@ export const update = async (contract: string, r: string, s: string, sig: string return true } catch { + console.debug(`update: Error parsing encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) // empty } diff --git a/backend/zkppSalt.test.js b/backend/zkppSalt.test.js index 3a13c7dbf9..12e1cac75d 100644 --- a/backend/zkppSalt.test.js +++ b/backend/zkppSalt.test.js @@ -18,7 +18,7 @@ const saltsAndEncryptedHashedPassword = (p: string, secretKey: Uint8Array, hash: } describe('ZKPP Salt functions', () => { - it('register', async () => { + 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') @@ -42,7 +42,7 @@ describe('ZKPP Salt functions', () => { should(res3).equal(true, 'register should allow new entry (bob)') }) - it('getContractSalt', async () => { + 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') @@ -75,7 +75,7 @@ describe('ZKPP Salt functions', () => { should(retrievedContractSalt).equal(contractSalt, 'mismatched contractSalt') }) - it('update', async () => { + 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') diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index f564d0cc30..699e3fe0a8 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -67,7 +67,7 @@ export default (sbp('sbp/selectors/register', { contractName: 'gi.contracts/chatroom' }) - const chatroom = await sbp('chelonia/with-env', '', { + const chatroom = await sbp('chelonia/withEnv', '', { additionalKeys: { [CSKid]: CSK, [CEKid]: CEK @@ -110,7 +110,7 @@ export default (sbp('sbp/selectors/register', { const contractID = chatroom.contractID() - await sbp('chelonia/with-env', contractID, { additionalKeys: { [CEKid]: CEK } }, ['chelonia/contract/sync', contractID]) + await sbp('chelonia/withEnv', contractID, { additionalKeys: { [CEKid]: CEK } }, ['chelonia/contract/sync', contractID]) const userID = rootState.loggedIn.identityContractID await sbp('gi.actions/identity/shareKeysWithSelf', { userID, contractID }) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 4f601f771d..75ef781aac 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -3,7 +3,6 @@ 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 { createInvite } from '@model/contracts/shared/functions.js' import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { INVITE_INITIAL_CREATOR, @@ -85,27 +84,26 @@ export default (sbp('sbp/selectors/register', { // 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: { @@ -121,10 +119,11 @@ 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)) } - const message = await sbp('chelonia/with-env', '', { + const message = await sbp('chelonia/withEnv', '', { additionalKeys: { [CSKid]: CSK, - [CEKid]: CEK + [CEKid]: CEK, + [inviteKeyId]: inviteKey } }, ['chelonia/out/registerContract', { contractName: 'gi.contracts/group', @@ -158,12 +157,25 @@ export default (sbp('sbp/selectors/register', { 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: { - invites: { - [initialInvite.inviteSecret]: initialInvite - }, settings: { // authorizations: [contracts.CanModifyAuths.dummyAuth()], // TODO: this groupName: name, @@ -201,11 +213,11 @@ export default (sbp('sbp/selectors/register', { const contractID = message.contractID() - await sbp('chelonia/with-env', contractID, { additionalKeys: { [CEKid]: CEK } }, ['chelonia/contract/sync', contractID]) + await sbp('chelonia/withEnv', contractID, { additionalKeys: { [CEKid]: CEK } }, ['chelonia/contract/sync', contractID]) saveLoginState('creating', contractID) // create a 'General' chatroom contract and let the creator join - await sbp('chelonia/with-env', contractID, { additionalKeys: { [CEKid]: CEK } }, ['gi.actions/group/addAndJoinChatRoom', { + await sbp('chelonia/withEnv', contractID, { additionalKeys: { [CEKid]: CEK } }, ['gi.actions/group/addAndJoinChatRoom', { contractID, data: { attributes: { diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index de19fbfe22..568e3dbb0f 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -140,7 +140,7 @@ export default (sbp('sbp/selectors/register', { let userID // next create the identity contract itself and associate it with the mailbox try { - const user = await sbp('chelonia/with-env', '', { + const user = await sbp('chelonia/withEnv', '', { additionalKeys: { [IPKid]: IPK, [CSKid]: CSK, @@ -205,8 +205,8 @@ export default (sbp('sbp/selectors/register', { userID = user.contractID() - await sbp('chelonia/with-env', userID, { additionalKeys: { [IEKid]: IEK } }, ['chelonia/contract/sync', userID]) - await sbp('chelonia/with-env', userID, { additionalKeys: { [IEKid]: IEK } }, ['gi.actions/identity/setAttributes', { + await sbp('chelonia/withEnv', userID, { additionalKeys: { [IEKid]: IEK } }, ['chelonia/contract/sync', userID]) + await sbp('chelonia/withEnv', userID, { additionalKeys: { [IEKid]: IEK } }, ['gi.actions/identity/setAttributes', { contractID: userID, data: { mailbox: mailboxID }, signingKeyId: CSKid, @@ -378,7 +378,7 @@ export default (sbp('sbp/selectors/register', { sbp('state/vuex/commit', 'login', { username, identityContractID }) // IMPORTANT: we avoid using 'await' on the syncs so that Vue.js can proceed // loading the website instead of stalling out. - sbp('chelonia/with-env', identityContractID, { additionalKeys }, ['chelonia/contract/sync', contractIDs]).then(async function () { + sbp('chelonia/withEnv', identityContractID, { additionalKeys }, ['chelonia/contract/sync', contractIDs]).then(async function () { // contract sync might've triggered an async call to /remove, so wait before proceeding await sbp('chelonia/contract/wait', contractIDs) // similarly, since removeMember may have triggered saveOurLoginState asynchronously, diff --git a/frontend/controller/actions/mailbox.js b/frontend/controller/actions/mailbox.js index 328e31d388..9f06e490cc 100644 --- a/frontend/controller/actions/mailbox.js +++ b/frontend/controller/actions/mailbox.js @@ -29,7 +29,7 @@ export default (sbp('sbp/selectors/register', { const CSKs = encrypt(CEK, serializeKey(CSK, true)) const CEKs = encrypt(CEK, serializeKey(CEK, true)) - const mailbox = await sbp('chelonia/with-env', '', { + const mailbox = await sbp('chelonia/withEnv', '', { additionalKeys: { [CSKid]: CSK, [CEKid]: CEK @@ -73,7 +73,7 @@ export default (sbp('sbp/selectors/register', { console.log('gi.actions/mailbox/create', { mailbox }) const contractID = mailbox.contractID() if (sync) { - await sbp('chelonia/with-env', contractID, { additionalKeys: { [CEKid]: CEK } }, ['chelonia/contract/sync', contractID]) + await sbp('chelonia/withEnv', contractID, { additionalKeys: { [CEKid]: CEK } }, ['chelonia/contract/sync', contractID]) } return mailbox } catch (e) { diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index 5cd9056683..329c27daaf 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -14,7 +14,7 @@ import { addTimeToDate, dateToPeriodStamp, dateFromPeriodStamp, isPeriodStamp, c 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' function vueFetchInitKV (obj: Object, key: string, initialValue: any): any { let value = obj[key] @@ -269,7 +269,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] @@ -389,7 +389,6 @@ sbp('chelonia/defineContract', { // this is the constructor 'gi.contracts/group': { validate: objectMaybeOf({ - invites: mapOf(string, inviteType), settings: objectMaybeOf({ // TODO: add 'groupPubkey' groupName: string, @@ -414,7 +413,6 @@ sbp('chelonia/defineContract', { const initialState = merge({ payments: {}, paymentsByPeriod: {}, - invites: {}, proposals: {}, // hashes => {} TODO: this, see related TODOs in GroupProposal settings: { groupCreator: meta.username, @@ -772,7 +770,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.')) } }, diff --git a/frontend/model/contracts/manifests.json b/frontend/model/contracts/manifests.json index 26a30cd0d1..9d6b407ec3 100644 --- a/frontend/model/contracts/manifests.json +++ b/frontend/model/contracts/manifests.json @@ -1,7 +1,7 @@ { "manifests": { - "gi.contracts/chatroom": "21XWnNRhSzQ6PmBC6KKmz52xXAv3s5WEopuCoF9GxWnV2A3rwe", - "gi.contracts/group": "21XWnNJkGh4JEBXhbEz1QZWfi5CsnD3HkU3x3ZcBZeRvN82SQF", + "gi.contracts/chatroom": "21XWnNMKa4YUogqvc2A5kfHFC39742U1KtQUzYKmcC2nVRvXfP", + "gi.contracts/group": "21XWnNGyZotAKkMzfYQ6RwKJtRcNExFQ71g1cdeXUogMyToCoM", "gi.contracts/identity": "21XWnNPSaj9HtLJmg7DMGc3cAHdqcmReuJfCvuM8CroxYd2bxx", "gi.contracts/mailbox": "21XWnNKoSm7e2vnGJvoFSRB7heFTeg24FwDCkb6JAazkzhpNdY" } diff --git a/frontend/model/contracts/shared/functions.js b/frontend/model/contracts/shared/functions.js index d62288ad49..8a0d2bc30f 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 { MESSAGE_TYPES } from './constants.js' import { logExceptNavigationDuplicated } from '~/frontend/views/utils/misc.js' // !!!!!!!!!!!!!!! @@ -20,30 +19,6 @@ import { logExceptNavigationDuplicated } from '~/frontend/views/utils/misc.js' // DIRECTLY IN YOUR CONTRACT DEFINITION FILE. THEN YOU CAN MODIFY // THEM AS MUCH AS YOU LIKE (and generate new contract versions out of them). -// group.js related - -export function createInvite ({ quantity = 1, creator, expires, invitee }: { - quantity: number, creator: string, expires: number, invitee?: string -}): {| - creator: string, - expires: number, - inviteSecret: string, - invitee: void | string, - quantity: number, - responses: {...}, - status: string, -|} { - return { - inviteSecret: `${parseInt(Math.random() * 10000)}`, // TODO: this - quantity, - creator, - invitee, - status: INVITE_STATUS.VALID, - responses: {}, // { bob: true } list of usernames that accepted the invite. - expires: Date.now() + DAYS_MILLIS * expires - } -} - // chatroom.js related export function createMessage ({ meta, data, hash, state }: { diff --git a/frontend/views/containers/group-settings/InvitationLinkModal.vue b/frontend/views/containers/group-settings/InvitationLinkModal.vue index 145b08e1ee..da26741eca 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,15 @@ 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 (key) { + return serializeKey(key, true) + } }, 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..43ee9280d7 100644 --- a/frontend/views/containers/group-settings/InvitationsTable.vue +++ b/frontend/views/containers/group-settings/InvitationsTable.vue @@ -152,7 +152,7 @@ export default ({ 'currentGroupId' ]), invitesToShow () { - const { invites } = this.currentGroupState + const { invites } = this.currentGroupState._vm if (!invites) { return [] } 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/Join.vue b/frontend/views/pages/Join.vue index 4ad31dba15..2c69aad84e 100644 --- a/frontend/views/pages/Join.vue +++ b/frontend/views/pages/Join.vue @@ -115,7 +115,9 @@ export default ({ return } const state = await sbp('chelonia/latestContractState', this.ephemeral.query.groupId) - const invite = state.invites[this.ephemeral.query.secret] + // TODO: Derive public key from secret + const publicKey = this.ephemeral.query.secret + const invite = state._vm.invites[publicKey] if (!invite || invite.status !== INVITE_STATUS.VALID) { console.error('Join.vue error: Link is not valid.') this.ephemeral.errorMsg = L('You should ask for a new one. Sorry about that!') diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 8f00bda302..0076432bd6 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -209,11 +209,11 @@ export default (sbp('sbp/selectors/register', { return stack } }, - 'chelonia/with-env': async function (contractID: string, env: Object, sbpInvocation: Array<*>) { + 'chelonia/withEnv': async function (contractID: string, env: Object, sbpInvocation: Array<*>) { const savedEnv = this.env this.env = env try { - return await sbp('okTurtles.eventQueue/queueEvent', `chelonia/with-env/${contractID}`, sbpInvocation) + return await sbp('okTurtles.eventQueue/queueEvent', `chelonia/withEnv/${contractID}`, sbpInvocation) } finally { this.env = savedEnv } @@ -450,7 +450,10 @@ export default (sbp('sbp/selectors/register', { }, 'chelonia/latestContractState': async function (contractID: string) { const events = await sbp('chelonia/private/out/eventsSince', contractID, contractID) - let state = cloneDeep(sbp(this.config.stateSelector)[contractID] || Object.create(null)) + if (sbp(this.config.stateSelector)[contractID]) { + return cloneDeep(sbp(this.config.stateSelector)[contractID]) + } + let state = Object.create(null) // fast-path try { for (const event of events) { diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index e139864ca8..33e4515ce2 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -163,7 +163,7 @@ export default (sbp('sbp/selectors/register', { const signedPayload = message.signedPayload() const env = this.env const self = this - if (!state._vm) state._vm = {} + if (!state._vm) state._vm = Object.create(null) const opFns: { [GIOpType]: (any) => void } = { [GIMessage.OP_CONTRACT] (v: GIOpContract) { const keys = { ...env.additionalKeys, ...state._volatile?.keys } @@ -180,6 +180,14 @@ export default (sbp('sbp/selectors/register', { } } } + if (key.meta?.type === 'inviteKey') { + if (!state._vm.invites) state._vm.invites = Object.create(null) + state._vm.invites[key.id] = { + creator: key.meta.creator, + quantity: key.meta.quantity, + expires: key.meta.expires + } + } } }, [GIMessage.OP_ACTION_ENCRYPTED] (v: GIOpActionEncrypted) { @@ -236,6 +244,7 @@ export default (sbp('sbp/selectors/register', { message.head().previousHEAD, v ] + // TODO: Update count on _vm.invites }, [GIMessage.OP_KEY_REQUEST_RESPONSE] (v: GIOpKeyRequestResponse) { if (state._vm.pending_key_requests && v in state._vm.pending_key_requests) { @@ -264,6 +273,14 @@ export default (sbp('sbp/selectors/register', { } } } + if (key.meta?.type === 'inviteKey') { + if (state._vm.invites) state._vm.invites = Object.create(null) + state._vm.invites[key.id] = { + creator: key.meta.creator, + quantity: key.meta.quantity, + expires: key.meta.expires + } + } } }, [GIMessage.OP_KEY_DEL] (v: GIOpKeyDel) { @@ -272,6 +289,7 @@ export default (sbp('sbp/selectors/register', { delete state._vm.authorizedKeys[v] if (state._volatile?.keys) { delete state._volatile.keys[v] } }) + // TODO: Revoke invite keys if (key.meta?.type === 'inviteKey') }, [GIMessage.OP_PROTOCOL_UPGRADE]: notImplemented } diff --git a/shared/zkpp.js b/shared/zkpp.js index e41a6bd761..324274e596 100644 --- a/shared/zkpp.js +++ b/shared/zkpp.js @@ -1,6 +1,10 @@ import nacl from 'tweetnacl' import scrypt from 'scrypt-async' +export const base64ToBase64url = (s: string): string => s.replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') + +export const base64urlToBase64 = (s: string): string => s.replace(/_/g, '/').replace(/-/g, '+') + '='.repeat((4 - s.length % 4) % 4) + export const hashStringArray = (...args: Array): Uint8Array => { return nacl.hash(Buffer.concat(args.map((s) => nacl.hash(Buffer.from(s))))) } @@ -10,11 +14,11 @@ export const hashRawStringArray = (...args: Array): Uint8Ar } export const randomNonce = (): string => { - return Buffer.from(nacl.randomBytes(12)).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') + return base64ToBase64url(Buffer.from(nacl.randomBytes(12)).toString('base64')) } export const hash = (v: string): string => { - return Buffer.from(nacl.hash(Buffer.from(v))).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') + return base64ToBase64url(Buffer.from(nacl.hash(Buffer.from(v))).toString('base64')) } export const computeCAndHc = (r: string, s: string, h: string): [Uint8Array, Uint8Array] => { @@ -31,12 +35,12 @@ export const encryptContractSalt = (c: Uint8Array, contractSalt: string): string const encryptedContractSalt = nacl.secretbox(Buffer.from(contractSalt), nonce, encryptionKey) - return Buffer.concat([nonce, encryptedContractSalt]).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') + return base64ToBase64url(Buffer.concat([nonce, encryptedContractSalt]).toString('base64')) } export const decryptContractSalt = (c: Uint8Array, encryptedContractSaltBox: string): string => { const encryptionKey = hashRawStringArray('CS', c).slice(0, nacl.secretbox.keyLength) - const encryptedContractSaltBoxBuf = Buffer.from(encryptedContractSaltBox.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + const encryptedContractSaltBoxBuf = Buffer.from(base64urlToBase64(encryptedContractSaltBox), 'base64') const nonce = encryptedContractSaltBoxBuf.slice(0, nacl.secretbox.nonceLength) const encryptedContractSalt = encryptedContractSaltBoxBuf.slice(nacl.secretbox.nonceLength) @@ -59,7 +63,7 @@ export const boxKeyPair = (): any => { } export const saltAgreement = (publicKey: string, secretKey: Uint8Array): false | [string, string] => { - const publicKeyBuf = Buffer.from(publicKey.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + const publicKeyBuf = Buffer.from(base64urlToBase64(publicKey), 'base64') const dhKey = nacl.box.before(publicKeyBuf, secretKey) if (!publicKeyBuf || publicKeyBuf.byteLength !== nacl.box.publicKeyLength) { @@ -81,7 +85,7 @@ export const parseRegisterSalt = (publicKey: string, secretKey: Uint8Array, encr const [authSalt, contractSalt] = saltAgreementRes const encryptionKey = nacl.hash(Buffer.from(authSalt + contractSalt)).slice(0, nacl.secretbox.keyLength) - const encryptedHashedPasswordBuf = Buffer.from(encryptedHashedPassword.replace(/_/g, '/').replace(/-/g, '+'), 'base64') + const encryptedHashedPasswordBuf = Buffer.from(base64urlToBase64(encryptedHashedPassword), 'base64') const hashedPasswordBuf = nacl.secretbox.open(encryptedHashedPasswordBuf.slice(nacl.box.nonceLength), encryptedHashedPasswordBuf.slice(0, nacl.box.nonceLength), encryptionKey) @@ -107,5 +111,5 @@ export const buildRegisterSaltRequest = async (publicKey: string, secretKey: Uin const encryptedHashedPasswordBuf = nacl.secretbox(Buffer.from(hashedPassword), nonce, encryptionKey) - return [contractSalt, Buffer.concat([nonce, encryptedHashedPasswordBuf]).toString('base64').replace(/\//g, '_').replace(/\+/g, '-')] + return [contractSalt, base64ToBase64url(Buffer.concat([nonce, encryptedHashedPasswordBuf]).toString('base64'))] } diff --git a/test/backend.test.js b/test/backend.test.js index eaf5b23f43..393bdfaac1 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -10,8 +10,7 @@ import { blake32Hash } from '~/shared/functions.js' import * as Common from '@common/common.js' import proposals from '~/frontend/model/contracts/shared/voting/proposals.js' import { PAYMENT_PENDING, PAYMENT_TYPE_MANUAL } from '~/frontend/model/contracts/shared/payments/index.js' -import { INVITE_INITIAL_CREATOR, INVITE_EXPIRES_IN_DAYS, MAIL_TYPE_MESSAGE, PROPOSAL_INVITE_MEMBER, PROPOSAL_REMOVE_MEMBER, PROPOSAL_GROUP_SETTING_CHANGE, PROPOSAL_PROPOSAL_SETTING_CHANGE, PROPOSAL_GENERIC } from '~/frontend/model/contracts/shared/constants.js' -import { createInvite } from '~/frontend/model/contracts/shared/functions.js' +import { MAIL_TYPE_MESSAGE, PROPOSAL_INVITE_MEMBER, PROPOSAL_REMOVE_MEMBER, PROPOSAL_GROUP_SETTING_CHANGE, PROPOSAL_PROPOSAL_SETTING_CHANGE, PROPOSAL_GENERIC } from '~/frontend/model/contracts/shared/constants.js' import '~/frontend/controller/namespace.js' import chalk from 'chalk' import { THEME_LIGHT } from '~/frontend/utils/themes.js' @@ -138,18 +137,15 @@ describe('Full walkthrough', function () { return msg } function createGroup (name: string, hooks: Object = {}): Promise { - const initialInvite = createInvite({ + /* const initialInvite = createInvite({ quantity: 60, creator: INVITE_INITIAL_CREATOR, expires: INVITE_EXPIRES_IN_DAYS.ON_BOARDING - }) + }) */ return sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/group', keys: [], data: { - invites: { - [initialInvite.inviteSecret]: initialInvite - }, settings: { // authorizations: [Events.CanModifyAuths.dummyAuth(name)], groupName: name, From c6b0dfb4105fa499f3105dd186d7667b8f1d314c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Mon, 19 Sep 2022 15:28:55 +0200 Subject: [PATCH 10/21] Error handling --- backend/routes.js | 68 ++++++++++++++++++++++++++++----------------- backend/zkppSalt.js | 34 +++++++++++++---------- 2 files changed, 62 insertions(+), 40 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index 8845d152cd..5e54a76045 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -264,23 +264,26 @@ route.POST('/zkpp/{contract}', { ]) } }, 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') - } + try { + if (req.payload['b']) { + const result = await registrationKey(req.params['contract'], req.payload['b']) - 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 + } + } 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') + if (result) { + return result + } } - - return result + } catch (e) { + const ip = req.info.remoteAddress + console.error(e.message, { ip }) } + + return Boom.internal('internal error') }) route.GET('/zkpp/{contract}/auth_hash', {}, async function (req, h) { @@ -288,13 +291,18 @@ route.GET('/zkpp/{contract}/auth_hash', {}, async function (req, h) { return Boom.badRequest('b query param required') } - const challenge = await getChallenge(req.params['contract'], req.query['b']) + try { + const challenge = await getChallenge(req.params['contract'], req.query['b']) - if (!challenge) { - return Boom.internal('internal error') + if (challenge) { + return challenge + } + } catch (e) { + const ip = req.info.remoteAddress + console.error(e.message, { ip }) } - return challenge + return Boom.internal('internal error') }) route.GET('/zkpp/{contract}/contract_hash', {}, async function (req, h) { @@ -314,13 +322,18 @@ route.GET('/zkpp/{contract}/contract_hash', {}, async function (req, h) { return Boom.badRequest('hc query param required') } - const salt = await getContractSalt(req.params['contract'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc']) + try { + 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') + if (salt) { + return salt + } + } catch (e) { + const ip = req.info.remoteAddress + console.error(e.message, { ip }) } - return salt + return Boom.internal('internal error') }) route.PUT('/zkpp/{contract}', { @@ -334,11 +347,16 @@ route.PUT('/zkpp/{contract}', { }) } }, 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']) + 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 Boom.internal('internal error') + if (result) { + return result + } + } catch (e) { + const ip = req.info.remoteAddress + console.error(e.message, { ip }) } - return result + return Boom.internal('internal error') }) diff --git a/backend/zkppSalt.js b/backend/zkppSalt.js index 8d53207e2f..08d8c396a2 100644 --- a/backend/zkppSalt.js +++ b/backend/zkppSalt.js @@ -34,7 +34,7 @@ const getZkppSaltRecord = async (contract: string) => { const recordObj = JSON.parse(recordString) if (!Array.isArray(recordObj) || recordObj.length !== 3 || !recordObj.reduce((acc, cv) => acc && typeof cv === 'string', true)) { - console.log('Error validating encryped JSON object ' + recordId) + console.error('Error validating encrypted JSON object ' + recordId) return null } @@ -46,7 +46,7 @@ const getZkppSaltRecord = async (contract: string) => { contractSalt } } catch { - console.log('Error parsing encrypted JSON object ' + recordId) + console.error('Error parsing encrypted JSON object ' + recordId) // empty } } @@ -108,7 +108,7 @@ const verifyChallenge = (contract: string, r: string, s: string, userSig: string export const registrationKey = async (contract: string, b: string): Promise => { const record = await getZkppSaltRecord(contract) if (record) { - return false + throw new Error('registrationKey: User record already exists') } const encryptionKey = hashStringArray('REG', contract, registrationSecret).slice(0, nacl.secretbox.keyLength) @@ -128,7 +128,7 @@ export const registrationKey = async (contract: string, b: string): Promise => { if (!verifyChallenge(contract, clientPublicKey, encryptedSecretKey, userSig)) { console.debug('register: Error validating challenge: ' + JSON.stringify({ contract, clientPublicKey, userSig })) - return false + throw new Error('register: Invalid challenge') } const record = await getZkppSaltRecord(contract) @@ -142,6 +142,7 @@ export const register = async (contract: string, clientPublicKey: string, encryp 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 @@ -149,6 +150,7 @@ export const register = async (contract: string, clientPublicKey: string, encryp 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 @@ -175,12 +177,13 @@ const contractSaltVerifyC = (h: string, r: string, s: string, userHc: string) => 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 })) - return false + throw new Error('getContractSalt: Bad challenge') } const record = await getZkppSaltRecord(contract) if (!record) { - console.debug('getContractSalt: Error obtaining ZKPP salt record for contract ID ' + contract) + // 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 } @@ -189,8 +192,8 @@ export const getContractSalt = async (contract: string, r: string, s: string, si const c = contractSaltVerifyC(hashedPassword, r, s, hc) if (!c) { - console.debug(`getContractSalt: Error verifying challenge for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) - return false + 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) @@ -199,12 +202,13 @@ export const getContractSalt = async (contract: string, r: string, s: string, si 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 })) - return false + throw new Error('update: Bad challenge') } const record = await getZkppSaltRecord(contract) if (!record) { - console.debug('update: Error obtaining ZKPP salt record for contract ID ' + contract) + // 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 @@ -212,8 +216,8 @@ export const update = async (contract: string, r: string, s: string, sig: string const c = contractSaltVerifyC(hashedPassword, r, s, hc) if (!c) { - console.debug(`update: Error verifying challenge for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) - return false + 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) @@ -224,7 +228,7 @@ export const update = async (contract: string, r: string, s: string, sig: string const args = nacl.secretbox.open(encryptedArgsCiphertext, nonce, encryptionKey) if (!args) { - console.debug(`update: Error decrypting arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + console.error(`update: Error decrypting arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) return false } @@ -232,7 +236,7 @@ export const update = async (contract: string, r: string, s: string, sig: string 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.debug(`update: Error validating the encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + console.error(`update: Error validating the encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) return false } @@ -242,7 +246,7 @@ export const update = async (contract: string, r: string, s: string, sig: string return true } catch { - console.debug(`update: Error parsing encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) + console.error(`update: Error parsing encrypted arguments for contract ID ${contract} (${JSON.stringify({ r, s, hc })})`) // empty } From 742902e96f084b7b2cc8f6712f548cbd8aad7ee5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= Date: Mon, 19 Sep 2022 15:30:29 +0200 Subject: [PATCH 11/21] Implemented OP_KEY_REQUEST --- frontend/controller/actions/group.js | 8 +-- .../contracts/shared/voting/proposals.js | 2 +- .../group-settings/InvitationsTable.vue | 13 +++-- frontend/views/pages/Join.vue | 18 ++++-- shared/domains/chelonia/GIMessage.js | 2 + shared/domains/chelonia/chelonia.js | 56 ++++++++++++++++++- shared/domains/chelonia/internals.js | 8 ++- 7 files changed, 91 insertions(+), 16 deletions(-) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index 75ef781aac..7e3b3eb954 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -26,6 +26,7 @@ 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 { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' export async function leaveAllChatRooms (groupContractID: string, member: string) { // let user leaves all the chatrooms before leaving group @@ -252,15 +253,14 @@ export default (sbp('sbp/selectors/register', { 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) // 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 @@ -322,7 +322,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) diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index 3645ed97f3..79703651d4 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -25,7 +25,7 @@ export function archiveProposal (state: Object, proposalHash: string): void { } 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 })}` } export const proposalSettingsType: any = objectOf({ diff --git a/frontend/views/containers/group-settings/InvitationsTable.vue b/frontend/views/containers/group-settings/InvitationsTable.vue index 43ee9280d7..4cf9cc495f 100644 --- a/frontend/views/containers/group-settings/InvitationsTable.vue +++ b/frontend/views/containers/group-settings/InvitationsTable.vue @@ -159,12 +159,17 @@ export default ({ 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: { @@ -225,7 +230,7 @@ export default ({ const isAnyoneLink = creator === INVITE_INITIAL_CREATOR const isInviteExpired = expiryTime < Date.now() const isInviteRevoked = status === INVITE_STATUS.REVOKED - const numberOfResponses = Object.keys(responses).length + const numberOfResponses = responses ? Object.keys(responses).length : 0 const isAllInviteUsed = numberOfResponses === quantity return { diff --git a/frontend/views/pages/Join.vue b/frontend/views/pages/Join.vue index 2c69aad84e..bbe83a3c71 100644 --- a/frontend/views/pages/Join.vue +++ b/frontend/views/pages/Join.vue @@ -48,7 +48,7 @@ div