Skip to content

Commit

Permalink
Merge pull request #1850 from okTurtles/contract-signature-verification
Browse files Browse the repository at this point in the history
Signature verification
  • Loading branch information
taoeffect authored Feb 14, 2024
2 parents b29d8dd + 4a3466a commit 2408a8b
Show file tree
Hide file tree
Showing 10 changed files with 119 additions and 20 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions frontend/model/contracts/manifests.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
3 changes: 2 additions & 1 deletion frontend/model/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion shared/domains/chelonia/chelonia.js
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
},
Expand Down
100 changes: 93 additions & 7 deletions shared/domains/chelonia/internals.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 })
Expand All @@ -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 + '/')) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion test/backend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 2408a8b

Please sign in to comment.