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
18 changes: 12 additions & 6 deletions backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,29 @@ 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 }
if (err.name === 'ChelErrorDBBadPreviousHEAD' || err.name === 'ChelErrorAlreadyProcessed') {
console.error(err, chalk.bold.yellow(err.name))
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') {
console.error(err, chalk.bold.yellow(err.name))
return Boom.badData('Invalid signature')
} else if (err.name === 'ChelErrorSignatureKeyUnauthorized') {
console.error(err, chalk.bold.yellow(err.name))
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
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
29 changes: 19 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,19 @@ hapi.ext({
sbp('okTurtles.data/set', SERVER_INSTANCE, hapi)

sbp('sbp/selectors/register', {
'backend/server/broadcastEntry': async function (entry: GIMessage) {
'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) {
sbp('okTurtles.data/get', PUBSUB_INSTANCE).channels.add(deserializedHEAD.contractID)
await sbp('chelonia/private/in/enqueueHandleEvent', deserializedHEAD.contractID, entry)
// Persist the Chelonia state after processing a message
await sbp('chelonia/db/set', '_private_cheloniaState', JSON.stringify(sbp('chelonia/private/state')))
corrideat marked this conversation as resolved.
Show resolved Hide resolved
await sbp('backend/server/broadcastEntry', deserializedHEAD, entry)
},
'backend/server/stop': function () {
return hapi.stop()
Expand Down Expand Up @@ -119,6 +122,13 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, {
}))

;(async function () {
await initDB()
await sbp('chelonia/configure', SERVER)
// Load the saved Chelonia state
const savedState = await sbp('chelonia/db/get', '_private_cheloniaState')
if (savedState) {
Object.assign(sbp('chelonia/private/state'), JSON.parse(savedState))
}
// https://hapi.dev/tutorials/plugins
await hapi.register([
{ plugin: require('./auth.js') },
Expand All @@ -130,7 +140,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