Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Server signature validation #1871

Merged
merged 16 commits into from
Mar 8, 2024
2 changes: 1 addition & 1 deletion Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ module.exports = (grunt) => {
// The `--require` flag ensures custom Babel support in our test files.
test: {
cmd: 'node node_modules/mocha/bin/mocha --require ./scripts/mocha-helper.js --exit -R spec --bail "./{test/,!(node_modules|ignored|dist|historical|test)/**/}*.test.js"',
options: { env: { ...process.env, ENABLE_UNSAFE_NULL_CRYPTO: 'true' } }
options: { env: process.env }
},
chelDeployAll: 'find contracts -iname "*.manifest.json" | xargs -r ./node_modules/.bin/chel deploy ./data'
}
Expand Down
2 changes: 1 addition & 1 deletion backend/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ exports.plugin = {
return {
authenticate: function (request, h) {
const { authorization } = request.headers
if (!authorization) h.unauthenticated(Boom.unauthorized('Missing authorization'))
if (!authorization) return h.unauthenticated(Boom.unauthorized('Missing authorization'))
corrideat marked this conversation as resolved.
Show resolved Hide resolved

let [scheme, json] = authorization.split(/\s+/)
// NOTE: if you want to add any signature verification, do it here
Expand Down
16 changes: 10 additions & 6 deletions backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,27 @@ route.POST('/event', {
}, async function (request, h) {
try {
console.debug('/event handler')
const entry = GIMessage.deserialize(request.payload)
const deserializedHEAD = GIMessage.deserializeHEAD(request.payload)
try {
await sbp('backend/server/handleEntry', entry)
await sbp('backend/server/handleEntry', deserializedHEAD, request.payload)
} catch (err) {
if (err.name === 'ChelErrorDBBadPreviousHEAD') {
console.error(err, chalk.bold.yellow('ChelErrorDBBadPreviousHEAD'))
const HEADinfo = await sbp('chelonia/db/latestHEADinfo', entry.contractID()) ?? { HEAD: null, height: 0 }
console.error(err, chalk.bold.yellow(err.name))
if (err.name === 'ChelErrorDBBadPreviousHEAD' || err.name === 'ChelErrorAlreadyProcessed') {
const HEADinfo = await sbp('chelonia/db/latestHEADinfo', deserializedHEAD.contractID) ?? { HEAD: null, height: 0 }
const r = Boom.conflict(err.message, { HEADinfo })
Object.assign(r.output.headers, {
'shelter-headinfo-head': HEADinfo.HEAD,
'shelter-headinfo-height': HEADinfo.height
})
return r
} else if (err.name === 'ChelErrorSignatureError') {
return Boom.badData('Invalid signature')
} else if (err.name === 'ChelErrorSignatureKeyUnauthorized') {
return Boom.forbidden('Unauthorized signing key')
}
throw err // rethrow error
}
return entry.hash()
return deserializedHEAD.hash
} catch (err) {
logger.error(err, 'POST /event', err.message)
return err
Expand Down
97 changes: 87 additions & 10 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import sbp from '@sbp/sbp'
import Hapi from '@hapi/hapi'
import initDB from './database.js'
import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js'
import { SERVER_RUNNING } from './events.js'
import { SERVER_INSTANCE, PUBSUB_INSTANCE } from './instance-keys.js'
import {
Expand All @@ -16,6 +15,8 @@ import {
} from './pubsub.js'
import { pushServerActionhandlers } from './push.js'
import chalk from 'chalk'
import '~/shared/domains/chelonia/chelonia.js'
import { SERVER } from '~/shared/domains/chelonia/presets.js'

const { CONTRACTS_VERSION, GI_VERSION } = process.env

Expand Down Expand Up @@ -61,17 +62,75 @@ hapi.ext({
sbp('okTurtles.data/set', SERVER_INSTANCE, hapi)

sbp('sbp/selectors/register', {
'backend/server/broadcastEntry': async function (entry: GIMessage) {
'backend/server/persistState': async function (deserializedHEAD: Object, entry: string) {
const contractID = deserializedHEAD.contractID
const cheloniaState = sbp('chelonia/private/state')
// If the contract has been removed or the height hasn't been updated,
// there's nothing to persist
if (!cheloniaState.contracts[contractID] || cheloniaState.contracts[contractID].height < deserializedHEAD.head.height) {
return
}
// If the current HEAD is not what we expect, don't save (the state could
// have been updated by a later message). This ensures that we save the
// latest state and also reduces the number of write operations
if (cheloniaState.contracts[contractID].HEAD === deserializedHEAD.hash) {
// Extract the parts of the state relevant to this contract
const state = {
contractState: cheloniaState[contractID],
cheloniaContractInfo: cheloniaState.contracts[contractID]
}
// Save the state under a 'contract partition' key, so that updating a
// contract doesn't require saving the entire state.
// Although it's not important for the server right now, this will fail to
// persist changes to the state for other contracts.
// For example, when watching foreign keys, this happens: whenever a
// foreign key for contract A is added to contract B, the private state
// for both contract A and B is updated (when both contracts are being
// monitored by Chelonia). However, here in this case, the updated state
// for contract A will not be saved immediately here, and it will only be
// saved if some other event happens later on contract A.
// TODO: If, in the future, processing a message affects other contracts
// in a way that is meaningful to the server, there'll need to be a way
// to detect these changes as well. One example could be, expanding on the
// previous scenario, if we decide that the server should enforce key
// rotations, so that updating a foreign key 'locks' that contract until
// the foreign key is rotated or deleted. For this to work reliably, we'd
// need to ensure that the state for both contract B and contract A are
// saved when the foreign key gets added to contract B.
await sbp('chelonia/db/set', '_private_cheloniaState_' + contractID, JSON.stringify(state))
}
// If this is a new contract, we also need to add it to the index, which
// is used when starting up the server to know which keys to fetch.
// In the future, consider having a multi-level index, since the index can
// get pretty large.
if (contractID === deserializedHEAD.hash) {
// We want to ensure that the index is updated atomically (i.e., if there
// are multiple new contracts, all of them should be added), so a queue
// is needed for the load & store operation.
await sbp('okTurtles.eventQueue/queueEvent', 'update-contract-indices', async () => {
const currentIndex = await sbp('chelonia/db/get', '_private_cheloniaState_index')
// Add the current contract ID to the contract index. Entries in the
// index are separated by \x00 (NUL). The index itself is used to know
// which entries to load.
const updatedIndex = `${currentIndex ? `${currentIndex}\x00` : ''}${contractID}`
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
await sbp('chelonia/db/set', '_private_cheloniaState_index', updatedIndex)
})
}
},
'backend/server/broadcastEntry': async function (deserializedHEAD: Object, entry: string) {
const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE)
const pubsubMessage = createMessage(NOTIFICATION_TYPE.ENTRY, entry.serialize())
const subscribers = pubsub.enumerateSubscribers(entry.contractID())
console.debug(chalk.blue.bold(`[pubsub] Broadcasting ${entry.description()}`))
const pubsubMessage = createMessage(NOTIFICATION_TYPE.ENTRY, entry)
const subscribers = pubsub.enumerateSubscribers(deserializedHEAD.contractID)
console.debug(chalk.blue.bold(`[pubsub] Broadcasting ${deserializedHEAD.description()}`))
await pubsub.broadcast(pubsubMessage, { to: subscribers })
},
'backend/server/handleEntry': async function (entry: GIMessage) {
sbp('okTurtles.data/get', PUBSUB_INSTANCE).channels.add(entry.contractID())
await sbp('chelonia/db/addEntry', entry)
await sbp('backend/server/broadcastEntry', entry)
'backend/server/handleEntry': async function (deserializedHEAD: Object, entry: string) {
const contractID = deserializedHEAD.contractID
sbp('okTurtles.data/get', PUBSUB_INSTANCE).channels.add(contractID)
await sbp('chelonia/private/in/enqueueHandleEvent', contractID, entry)
// Persist the Chelonia state after processing a message
await sbp('backend/server/persistState', deserializedHEAD, entry)
await sbp('backend/server/broadcastEntry', deserializedHEAD, entry)
},
'backend/server/stop': function () {
return hapi.stop()
Expand Down Expand Up @@ -119,6 +178,25 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, {
}))

;(async function () {
await initDB()
await sbp('chelonia/configure', SERVER)
// Load the saved Chelonia state
// First, get the contract index
const savedStateIndex = await sbp('chelonia/db/get', '_private_cheloniaState_index')
if (savedStateIndex) {
// Now, we contract the contract state by reading each contract state
// partition
const recoveredState = Object.create(null)
recoveredState.contracts = Object.create(null)
await Promise.all(savedStateIndex.split('\x00').map(async (contractID) => {
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
const cpSerialized = await sbp('chelonia/db/get', `_private_cheloniaState_${contractID}`)
if (!cpSerialized) return
const cp = JSON.parse(cpSerialized)
recoveredState[contractID] = cp.contractState
recoveredState.contracts[contractID] = cp.cheloniaContractInfo
}))
Object.assign(sbp('chelonia/private/state'), recoveredState)
}
// https://hapi.dev/tutorials/plugins
await hapi.register([
{ plugin: require('./auth.js') },
Expand All @@ -130,7 +208,6 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, {
// }
// }
])
await initDB()
require('./routes.js')
await hapi.start()
console.info('Backend server running at:', hapi.info.uri)
Expand Down
11 changes: 10 additions & 1 deletion frontend/model/captureLogs.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,16 @@ function captureLogEntry (type, ...args) {
msg: args.map((arg) => {
try {
return JSON.parse(
JSON.stringify(arg instanceof Error ? (arg.stack ?? arg.message) : arg)
JSON.stringify(arg, (_, v) => {
if (v instanceof Error) {
return {
name: v.name,
message: v.message,
stack: v.stack
}
}
return v
})
corrideat marked this conversation as resolved.
Show resolved Hide resolved
)
} catch (e) {
return `[captureLogs failed to stringify argument of type '${typeof arg}'. Err: ${e.message}]`
Expand Down
7 changes: 6 additions & 1 deletion frontend/model/contracts/chatroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,11 @@ sbp('chelonia/defineContract', {
throw new Error('The new member must be given either explicitly or implcitly with an inner signature')
}
if (!state.onlyRenderMessage) {
if (state.members[memberID]) {
// For private chatrooms, group members can see the '/join' actions
// but nothing else. Because of this, `state.members` may be missing
if (!state.members) {
Vue.set(state, 'members', {})
} else if (state.members[memberID]) {
throw new Error(`Can not join the chatroom which ${memberID} is already part of`)
}

Expand Down Expand Up @@ -300,6 +304,7 @@ sbp('chelonia/defineContract', {
if (!state.onlyRenderMessage) {
if (!state.members) {
console.error('Missing state.members: ' + JSON.stringify(state))
throw new Error('Missing members state')
}
if (!state.members[memberID]) {
throw new Error(`Can not leave the chatroom ${contractID} which ${memberID} is not part of`)
Expand Down
5 changes: 3 additions & 2 deletions frontend/views/components/Avatar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export default ({
},
mounted () {
console.log(`Avatar under ${this.$parent.$vnode.tag} blobURL:`, this.blobURL, 'src:', this.src)
if (typeof this.src === 'object') {
// typeof null === 'object', so both checks are needed
if (this.src && typeof this.src === 'object') {
corrideat marked this conversation as resolved.
Show resolved Hide resolved
this.downloadFile(this.src).catch((e) => {
console.error('[Avatar.vue] Error in downloadFile', e)
})
Expand Down Expand Up @@ -81,7 +82,7 @@ export default ({
},
watch: {
src (to) {
if (typeof to === 'object') {
if (to && typeof to === 'object') {
this.downloadFile(to).catch((e) => {
console.error('[Avatar.vue] Error in downloadFile', e)
})
Expand Down
4 changes: 4 additions & 0 deletions shared/domains/chelonia/GIMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,10 @@ export class GIMessage {
},
get contractID () {
return result.head?.contractID ?? result.hash
},
description (): string {
const type = this.head.op
return `<op_${type}|${this.hash} of ${this.contractID}>`
}
}
return result
Expand Down
11 changes: 11 additions & 0 deletions shared/domains/chelonia/chelonia.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,19 @@ export default (sbp('sbp/selectors/register', {
whitelisted: (action: string): boolean => !!this.whitelistedActions[action],
reactiveSet: (obj, key, value) => { obj[key] = value; return value }, // example: set to Vue.set
reactiveDel: (obj, key) => { delete obj[key] },
// acceptAllMessages disables checking whether we are expecting a message
// or not for processing
acceptAllMessages: false,
skipActionProcessing: false,
skipSideEffects: false,
// Strict processing will treat all processing errors as unrecoverable
// This is useful, e.g., in the server, to prevent invalid messages from
// being added to the database
strictProcessing: false,
// Strict ordering will throw on past events with ChelErrorAlreadyProcessed
// Similarly, future events will not be reingested and will throw
// with ChelErrorDBBadPreviousHEAD
strictOrdering: false,
connectionOptions: {
maxRetries: Infinity, // See https://github.com/okTurtles/group-income/issues/1183
reconnectOnTimeout: true, // can be enabled since we are not doing auth via web sockets
Expand Down
4 changes: 4 additions & 0 deletions shared/domains/chelonia/encryptedData.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ export const isRawEncryptedData = (data: any): boolean => {

export const unwrapMaybeEncryptedData = (data: any): { encryptionKeyId: string | null, data: any } | void => {
if (isEncryptedData(data)) {
// If not running on a browser, we don't decrypt data to avoid filling the
// logs with unable to decrypt messages.
// This variable is set in Gruntfile.js for web builds
if (process.env.BUILD !== 'web') return
corrideat marked this conversation as resolved.
Show resolved Hide resolved
try {
return {
encryptionKeyId: data.encryptionKeyId,
Expand Down
2 changes: 2 additions & 0 deletions shared/domains/chelonia/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ export const ChelErrorGenerator = (
}: any): typeof Error)

export const ChelErrorWarning: typeof Error = ChelErrorGenerator('ChelErrorWarning')
export const ChelErrorAlreadyProcessed: typeof Error = ChelErrorGenerator('ChelErrorAlreadyProcessed')
export const ChelErrorDBBadPreviousHEAD: typeof Error = ChelErrorGenerator('ChelErrorDBBadPreviousHEAD')
export const ChelErrorDBConnection: typeof Error = ChelErrorGenerator('ChelErrorDBConnection')
export const ChelErrorUnexpected: typeof Error = ChelErrorGenerator('ChelErrorUnexpected')
export const ChelErrorUnrecoverable: typeof Error = ChelErrorGenerator('ChelErrorUnrecoverable')
export const ChelErrorDecryptionError: typeof Error = ChelErrorGenerator('ChelErrorDecryptionError')
export const ChelErrorDecryptionKeyNotFound: typeof Error = ChelErrorGenerator('ChelErrorDecryptionKeyNotFound', ChelErrorDecryptionError)
export const ChelErrorSignatureError: typeof Error = ChelErrorGenerator('ChelErrorSignatureError')
export const ChelErrorSignatureKeyUnauthorized: typeof Error = ChelErrorGenerator('ChelErrorSignatureKeyUnauthorized', ChelErrorSignatureError)
export const ChelErrorSignatureKeyNotFound: typeof Error = ChelErrorGenerator('ChelErrorSignatureKeyNotFound', ChelErrorSignatureError)
Loading
Loading