From 4a3466a380b0bf96f8c33fc1f43bcf38010e485d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Wed, 14 Feb 2024 16:53:26 +0100 Subject: [PATCH] Signature verification --- .gitignore | 2 + Gruntfile.js | 11 ++- backend/database.js | 2 +- frontend/model/contracts/manifests.json | 6 +- frontend/model/state.js | 3 +- package-lock.json | 8 +- package.json | 2 +- shared/domains/chelonia/chelonia.js | 2 +- shared/domains/chelonia/internals.js | 100 ++++++++++++++++++++++-- test/backend.test.js | 3 +- 10 files changed, 119 insertions(+), 20 deletions(-) diff --git a/.gitignore b/.gitignore index 3530601131..fc85fd4bb0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ /dist/ /dist-dashboard/ *?rollup-plugin-vue=script.js +key.json +key.pub.json # prevent accidental addition of yarn.lock from making Travis builds longer /yarn.lock diff --git a/Gruntfile.js b/Gruntfile.js index b21e7789cc..323b00d4ac 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -111,9 +111,18 @@ module.exports = (grunt) => { async function generateManifests (dir, version) { if (development) { + const keyFile = process.env.KEY_FILE || 'key.json' + const pubKeyFile = process.env.PUB_KEY_FILE || 'key.pub.json' + if (fs.existsSync(keyFile)) { + grunt.log.writeln(chalk.underline(`Key file ${keyFile} exists, using that.`)) + } else { + grunt.log.writeln(chalk.underline(`\nRunning 'chel keygen --pubout ${pubKeyFile} --out ${keyFile}'`)) + const { stdout } = await execWithErrMsg(`chel keygen --pubout ${pubKeyFile} --out ${keyFile}`) + console.log(stdout) + } grunt.log.writeln(chalk.underline("\nRunning 'chel manifest'")) // TODO: do this with JS instead of POSIX commands for Windows support - const { stdout } = await execWithErrMsg(`ls ${dir}/*-slim.js | sed -En 's/.*\\/(.*)-slim.js/\\1/p' | xargs -I {} node_modules/.bin/chel manifest -v ${version} -s ${dir}/{}-slim.js key.json ${dir}/{}.js`, 'error generating manifests') + const { stdout } = await execWithErrMsg(`ls ${dir}/*-slim.js | sed -En 's/.*\\/(.*)-slim.js/\\1/p' | xargs -I {} node_modules/.bin/chel manifest -v ${version} -s ${dir}/{}-slim.js ${keyFile} ${dir}/{}.js`, 'error generating manifests') console.log(stdout) } else { // Only run these in NODE_ENV=development so that production servers diff --git a/backend/database.js b/backend/database.js index c0773305d6..91a874d57f 100644 --- a/backend/database.js +++ b/backend/database.js @@ -212,7 +212,7 @@ export default async () => { if (persistence !== 'fs' || options.fs.dirname !== './data') { // Remember to keep these values up-to-date. const HASH_LENGTH = 52 - const CONTRACT_MANIFEST_MAGIC = '{"head":{"manifestVersion"' + const CONTRACT_MANIFEST_MAGIC = '{"head":"{\\"manifestVersion\\"' const CONTRACT_SOURCE_MAGIC = '"use strict";' // Preload contract source files and contract manifests into Chelonia DB. // Note: the data folder may contain other files if the `fs` persistence mode diff --git a/frontend/model/contracts/manifests.json b/frontend/model/contracts/manifests.json index 8fd47c2cf7..1e9c9ac980 100644 --- a/frontend/model/contracts/manifests.json +++ b/frontend/model/contracts/manifests.json @@ -1,7 +1,7 @@ { "manifests": { - "gi.contracts/chatroom": "z9brRu3VRwA9WQ6nhRbJz8KvZ3W6a43vhXrx1iES6yRsExsXzTpG", - "gi.contracts/group": "z9brRu3VNeHviRAc7DLPBQqyagTrpUHoXF9YdMFr1gtrgYGMzfSB", - "gi.contracts/identity": "z9brRu3VSVRddCxKg3YRMP8niKH7gwNq5TexdjvBEDfoLRv97DBh" + "gi.contracts/chatroom": "z9brRu3VRqLWG3PqVa1Yu7bJne7hJfZZdETwpvrKjRf1pAw3E15r", + "gi.contracts/group": "z9brRu3VJyMk1PnyLYgG3JU78Xh3pZWmxzA3JP26iiRww5wfevYj", + "gi.contracts/identity": "z9brRu3VSZFsefXNQ2RtjYbfGCe5KGUZxqeU8cQWbD8bmSu2ZtFM" } } diff --git a/frontend/model/state.js b/frontend/model/state.js index ede5017e96..004e29f975 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -29,7 +29,8 @@ const initialState = { contracts: {}, // contractIDs => { type:string, HEAD:string, height:number } (for contracts we've successfully subscribed to) loggedIn: false, // false | { username: string, identityContractID: string } namespaceLookups: Object.create(null), // { [username]: sbp('namespace/lookup') } - periodicNotificationAlreadyFiredMap: {} // { notificationKey: boolean } + periodicNotificationAlreadyFiredMap: {}, // { notificationKey: boolean }, + contractSiginingKeys: Object.create(null) } if (window.matchMedia) { diff --git a/package-lock.json b/package-lock.json index 516c7794e1..8da4a5cea2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,7 +56,7 @@ "@babel/preset-flow": "7.12.1", "@babel/register": "7.23.7", "@babel/runtime": "7.23.8", - "@chelonia/cli": "2.0.1", + "@chelonia/cli": "2.1.1", "@vue/component-compiler": "4.2.4", "acorn": "8.0.4", "babel-plugin-module-resolver": "5.0.0", @@ -2113,9 +2113,9 @@ } }, "node_modules/@chelonia/cli": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@chelonia/cli/-/cli-2.0.1.tgz", - "integrity": "sha512-kSnqvB01uMXyQIQdMYj+6aD+bemHQPpYNunUAe/NSpd/0KGXzkK32+RwAeDGNWHXmkERq07zEfWfWYV7WARkAA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@chelonia/cli/-/cli-2.1.1.tgz", + "integrity": "sha512-2XCe4F96tDcBkm8d1OQDK7K+pRIVd9/fU2DXvLeE49YENRgBP/keLKZ584S5huXMfl+P/lOtQYUXfa09Mqb8Pw==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/package.json b/package.json index 60ed38d809..758d516444 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "@babel/preset-flow": "7.12.1", "@babel/register": "7.23.7", "@babel/runtime": "7.23.8", - "@chelonia/cli": "2.0.1", + "@chelonia/cli": "2.1.1", "@vue/component-compiler": "4.2.4", "acorn": "8.0.4", "babel-plugin-module-resolver": "5.0.0", diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index bcc10dd0a8..474b3183b2 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -311,7 +311,7 @@ export default (sbp('sbp/selectors/register', { const manifests = this.config.contracts.manifests console.debug('[chelonia] preloading manifests:', Object.keys(manifests)) for (const contractName in manifests) { - await sbp('chelonia/private/loadManifest', manifests[contractName]) + await sbp('chelonia/private/loadManifest', contractName, manifests[contractName]) } } }, diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 5934632dd1..48fa6953bc 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -7,7 +7,7 @@ import { b64ToStr, createCID } from '~/shared/functions.js' import type { GIKey, GIOpActionEncrypted, GIOpActionUnencrypted, GIOpAtomic, GIOpContract, GIOpKeyAdd, GIOpKeyDel, GIOpKeyRequest, GIOpKeyRequestSeen, GIOpKeyShare, GIOpKeyUpdate, GIOpPropSet, GIOpType, ProtoGIOpKeyRequestSeen, ProtoGIOpKeyShare } from './GIMessage.js' import { GIMessage } from './GIMessage.js' import { INVITE_STATUS } from './constants.js' -import { deserializeKey, keyId } from './crypto.js' +import { deserializeKey, keyId, verifySignature } from './crypto.js' import './db.js' import { encryptedIncomingData, encryptedOutgoingData, unwrapMaybeEncryptedData } from './encryptedData.js' import type { EncryptedData } from './encryptedData.js' @@ -169,14 +169,90 @@ export default (sbp('sbp/selectors/register', { 'chelonia/private/queueEvent': function (queueName, invocation) { return sbp('okTurtles.eventQueue/queueEvent', queueName, ['chelonia/private/invoke', this._instance, invocation]) }, - 'chelonia/private/loadManifest': async function (manifestHash: string) { + 'chelonia/private/verifyManifestSignature': function (contractName: string, manifestHash: string, manifest: Object) { + // We check that the manifest contains a 'signature' field with the correct + // shape + if (!has(manifest, 'signature') || typeof manifest.signature.keyId !== 'string' || typeof manifest.signature.value !== 'string') { + throw new Error(`Invalid or missing signature field for manifest ${manifestHash} (named ${contractName})`) + } + + // Now, start the signature verification process + const rootState = sbp(this.config.stateSelector) + if (!has(rootState, 'contractSiginingKeys')) { + this.config.reactiveSet(rootState, 'contractSiginingKeys', Object.create(null)) + } + // Because `contractName` comes from potentially unsafe sources (for + // instance, from `processMessage`), the key isn't used directly because + // it could overlap with current or future 'special' key names in JavaScript, + // such as `prototype`, `__proto__`, etc. We also can't guarantee that the + // `contractSiginingKeys` always has a null prototype, and, because of the + // way we manage state, neither can we use `Map`. So, we use prefix for the + // lookup key that's unlikely to ever be part of a special JS name. + const contractNameLookupKey = `name:${contractName}` + // If the contract name has been seen before, validate its signature now + let signatureValidated = false + if (has(rootState.contractSiginingKeys, contractNameLookupKey)) { + console.info(`[chelonia] verifying signature for ${manifestHash} with an existing key`) + if (!has(rootState.contractSiginingKeys[contractNameLookupKey], manifest.signature.keyId)) { + console.error(`The manifest with ${manifestHash} (named ${contractName}) claims to be signed with a key with ID ${manifest.signature.keyId}, which is not trusted. The trusted key IDs for this name are:`, Object.keys(rootState.contractSiginingKeys[contractNameLookupKey])) + throw new Error(`Invalid or missing signature in manifest ${manifestHash} (named ${contractName}). It claims to be signed with a key with ID ${manifest.signature.keyId}, which has not been authorized for this contract before.`) + } + const signingKey = rootState.contractSiginingKeys[contractNameLookupKey][manifest.signature.keyId] + verifySignature(signingKey, manifest.body + manifest.head, manifest.signature.value) + console.info(`[chelonia] successful signature verification for ${manifestHash} (named ${contractName}) using the already-trusted key ${manifest.signature.keyId}.`) + signatureValidated = true + } + // Otherwise, when this is a yet-unseen contract, we parse the body to + // see its allowed signers to trust on first-use (TOFU) + const body = JSON.parse(manifest.body) + // If we don't have a list of authorized signatures yet, verify this + // contract's signature and set the auhorized signing keys + if (!signatureValidated) { + console.info(`[chelonia] verifying signature for ${manifestHash} (named ${contractName}) for the first time`) + if (!has(body, 'signingKeys') || !Array.isArray(body.signingKeys)) { + throw new Error(`Invalid manifest file ${manifestHash} (named ${contractName}). Its body doesn't contain a 'signingKeys' list'`) + } + let contractSigningKeys: { [idx: string]: string} + try { + contractSigningKeys = Object.fromEntries(body.signingKeys.map((serializedKey) => { + return [ + keyId(serializedKey), + serializedKey + ] + })) + } catch (e) { + console.error(`[chelonia] Error parsing the public keys list for ${manifestHash} (named ${contractName})`, e) + throw e + } + if (!has(contractSigningKeys, manifest.signature.keyId)) { + throw new Error(`Invalid or missing signature in manifest ${manifestHash} (named ${contractName}). It claims to be signed with a key with ID ${manifest.signature.keyId}, which is not listed in its 'signingKeys' field.`) + } + verifySignature(contractSigningKeys[manifest.signature.keyId], manifest.body + manifest.head, manifest.signature.value) + console.info(`[chelonia] successful signature verification for ${manifestHash} (named ${contractName}) using ${manifest.signature.keyId}. The following key IDs will now be trusted for this contract name`, Object.keys(contractSigningKeys)) + signatureValidated = true + rootState.contractSiginingKeys[contractNameLookupKey] = contractSigningKeys + } + + // If verification was successful, return the parsed body to make the newly- + // loaded contract available + return body + }, + 'chelonia/private/loadManifest': async function (contractName: string, manifestHash: string) { + if (!contractName || typeof contractName !== 'string') { + throw new Error('Invalid or missing contract name') + } if (this.manifestToContract[manifestHash]) { console.warn('[chelonia]: already loaded manifest', manifestHash) return } const manifestURL = `${this.config.connectionURL}/file/${manifestHash}` - const manifest = await fetch(manifestURL, { signal: this.abortController.signal }).then(handleFetchResult('json')) - const body = JSON.parse(manifest.body) + const manifestSource = await fetch(manifestURL, { signal: this.abortController.signal }).then(handleFetchResult('text')) + const manifestHashOurs = createCID(manifestSource) + if (manifestHashOurs !== manifestHash) { + throw new Error(`expected manifest hash ${manifestHash}. Got: ${manifestHashOurs}`) + } + const manifest = JSON.parse(manifestSource) + const body = sbp('chelonia/private/verifyManifestSignature', contractName, manifestHash, manifest) const contractInfo = (this.config.contracts.defaults.preferSlim && body.contractSlim) || body.contract console.info(`[chelonia] loading contract '${contractInfo.file}'@'${body.version}' from manifest: ${manifestHash}`) const source = await fetch(`${this.config.connectionURL}/file/${contractInfo.hash}`, { signal: this.abortController.signal }) @@ -191,7 +267,6 @@ export default (sbp('sbp/selectors/register', { .reduce(reduceAllow, {}) const allowedDoms = this.config.contracts.defaults.allowedDomains .reduce(reduceAllow, {}) - let contractName: string // eslint-disable-line prefer-const const contractSBP = (selector: string, ...args) => { const domain = domainFromSelector(selector) if (selector.startsWith(contractName + '/')) { @@ -292,7 +367,9 @@ export default (sbp('sbp/selectors/register', { return fetch(`${this.config.connectionURL}/time`, { signal: this.abortController.signal }).then(handleFetchResult('text')) } }) - contractName = this.defContract.name + if (contractName !== this.defContract.name) { + throw new Error(`Invalid contract name for manifest ${manifestHash}. Expected ${contractName} but got ${this.defContract.name}`) + } this.defContractSelectors.forEach(s => { allowedSels[s] = true }) this.manifestToContract[manifestHash] = { slim: contractInfo === body.contractSlim, @@ -946,7 +1023,16 @@ export default (sbp('sbp/selectors/register', { [GIMessage.OP_PROTOCOL_UPGRADE]: notImplemented } if (!this.manifestToContract[manifestHash]) { - await sbp('chelonia/private/loadManifest', manifestHash) + const rootState = sbp(this.config.stateSelector) + const contractName = has(rootState.contracts, contractID) + ? rootState.contracts[contractID].type + : opT === GIMessage.OP_CONTRACT + ? ((opV: any): GIOpContract).type + : '' + if (!contractName) { + throw new Error(`Unable to determine the name for a contract and refusing to load it (contract ID was ${contractID} and its manifest hash was ${manifestHash})`) + } + await sbp('chelonia/private/loadManifest', contractName, manifestHash) } let processOp = true if (config.preOp) { diff --git a/test/backend.test.js b/test/backend.test.js index d5bb3a3fa2..960a3fc0d1 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -52,7 +52,8 @@ const vuexState = { increasedContrast: false, namespaceLookups: Object.create(null), reducedMotion: false, - appLogsFilter: ['error', 'info', 'warn'] + appLogsFilter: ['error', 'info', 'warn'], + contractSiginingKeys: Object.create(null) } // this is to ensure compatibility between frontend and test/backend.test.js