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

Changes to contract CIDs (requires updating the chel command) #2494

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
9 changes: 6 additions & 3 deletions Gruntfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,14 @@ module.exports = (grunt) => {
...pick(clone(esbuildOptionBags.default), [
'define', 'bundle', 'watch', 'incremental'
]),
// format: 'esm',
// Format must be 'iife' because we don't want 'import' in the output
format: 'iife',
// banner: {
banner: {
// This banner makes contracts easy to detect and harmless in the event
// of accidental execution
js: 'for(;;)"use shelter";'
// js: 'import { createRequire as topLevelCreateRequire } from "module"\nconst require = topLevelCreateRequire(import.meta.url)'
// },
},
splitting: false,
outdir: distContracts,
entryPoints: [`${contractsDir}/group.js`, `${contractsDir}/chatroom.js`, `${contractsDir}/identity.js`],
Expand Down
18 changes: 13 additions & 5 deletions backend/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import '@sbp/okturtles.data'
import { checkKey, parsePrefixableKey, prefixHandlers } from '~/shared/domains/chelonia/db.js'
import { CONTRACT_MANIFEST_REGEX, CONTRACT_SOURCE_REGEX } from '~/shared/domains/chelonia/utils.js'
import LRU from 'lru-cache'
import { initVapid } from './vapid.js'
import { initZkpp } from './zkppSalt.js'
Expand Down Expand Up @@ -109,9 +110,9 @@ sbp('sbp/selectors/register', {
await sbp('chelonia/db/set', namespaceKey(name), value)
return { name, value }
},
'backend/db/lookupName': async function (name: string): Promise<string | Error> {
'backend/db/lookupName': async function (name: string): Promise<string> {
const value = await sbp('chelonia/db/get', namespaceKey(name))
return value || Boom.notFound()
return value
}
})

Expand Down Expand Up @@ -173,7 +174,8 @@ export default async () => {
// TODO: Update this to only run when persistence is disabled when `chel deploy` can target SQLite.
if (persistence !== 'fs' || options.fs.dirname !== dbRootPath) {
// Remember to keep these values up-to-date.
const HASH_LENGTH = 52
const HASH_LENGTH = 56

const CONTRACT_MANIFEST_MAGIC = '{"head":"{\\"manifestVersion\\"'
const CONTRACT_SOURCE_MAGIC = '"use strict";'
// Preload contract source files and contract manifests into Chelonia DB.
Expand All @@ -184,7 +186,10 @@ export default async () => {
// TODO: Update this code when `chel deploy` no longer generates unprefixed keys.
const keys = (await readdir(dataFolder))
// Skip some irrelevant files.
.filter(k => k.length === HASH_LENGTH)
.filter(k =>
k.length === HASH_LENGTH &&
(CONTRACT_MANIFEST_REGEX.test(k) || CONTRACT_SOURCE_REGEX.test(k))
)
const numKeys = keys.length
let numVisitedKeys = 0
let numNewKeys = 0
Expand All @@ -196,7 +201,10 @@ export default async () => {
if (!persistence || !await sbp('chelonia/db/get', key)) {
const value = await readFile(path.join(dataFolder, key), 'utf8')
// Load only contract source files and contract manifests.
if (value.startsWith(CONTRACT_MANIFEST_MAGIC) || value.startsWith(CONTRACT_SOURCE_MAGIC)) {
if (
(CONTRACT_MANIFEST_REGEX.test(key) && value.startsWith(CONTRACT_MANIFEST_MAGIC)) ||
(CONTRACT_SOURCE_REGEX.test(key) && value.startsWith(CONTRACT_SOURCE_MAGIC))
) {
await sbp('chelonia/db/set', key, value)
numNewKeys++
}
Expand Down
86 changes: 65 additions & 21 deletions backend/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import sbp from '@sbp/sbp'
import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js'
import { createCID } from '~/shared/functions.js'
import { CONTRACT_DATA_REGEX, CONTRACT_MANIFEST_REGEX, CONTRACT_SOURCE_REGEX, FILE_CHUNK_REGEX, FILE_MANIFEST_REGEX } from '~/shared/domains/chelonia/utils.js'
import { createCID, multicodes } from '~/shared/functions.js'
import { SERVER_INSTANCE } from './instance-keys.js'
import path from 'path'
import chalk from 'chalk'
Expand Down Expand Up @@ -61,6 +62,10 @@ const staticServeConfig = {
redirect: isCheloniaDashboard ? '/dashboard/' : '/app/'
}

// We define a `Proxy` for route so that we can use `route.VERB` syntax for
// defining routes instead of calling `server.route` with an object, and to
// dynamically get the HAPI server object from the `SERVER_INSTANCE`, which is
// defined in `server.js`.
const route = new Proxy({}, {
get: function (obj, prop) {
return function (path: string, options: Object, handler: Function | Object) {
Expand All @@ -73,9 +78,6 @@ const route = new Proxy({}, {

// RESTful API routes

// TODO: Update this regex once `chel` uses prefixed manifests
const manifestRegex = /^z9brRu3V[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{44}$/

// NOTE: We could get rid of this RESTful API and just rely on pubsub.js to do this
// —BUT HTTP2 might be better than websockets and so we keep this around.
// See related TODO in pubsub.js and the reddit discussion link.
Expand All @@ -92,7 +94,7 @@ route.POST('/event', {
try {
const deserializedHEAD = GIMessage.deserializeHEAD(request.payload)
try {
if (!manifestRegex.test(deserializedHEAD.head.manifest)) {
if (!CONTRACT_MANIFEST_REGEX.test(deserializedHEAD.head.manifest)) {
return Boom.badData('Invalid manifest')
}
const credentials = request.auth.credentials
Expand Down Expand Up @@ -189,7 +191,13 @@ route.GET('/eventsAfter/{contractID}/{since}/{limit?}', {}, async function (requ
const { contractID, since, limit } = request.params
const ip = request.headers['x-real-ip'] || request.info.remoteAddress
try {
if (contractID.startsWith('_private') || since.startsWith('_private')) {
if (
!contractID ||
!CONTRACT_DATA_REGEX.test(contractID) ||
contractID.startsWith('_private') ||
!/^[0-9]+$/.test(since) ||
(limit && !/^[0-9]+$/.test(limit))
) {
return Boom.notFound()
}

Expand Down Expand Up @@ -261,8 +269,12 @@ route.POST('/name', {
route.GET('/name/{name}', {}, async function (request, h) {
const { name } = request.params
try {
return await sbp('backend/db/lookupName', name)
const lookupResult = await sbp('backend/db/lookupName', name)
return lookupResult
? h.response(lookupResult).type('text/plain')
: Boom.notFound()
} catch (err) {
console.error(err, '@@@@@err')
logger.error(err, `GET /name/${name}`, err.message)
return err
}
Expand All @@ -273,7 +285,12 @@ route.GET('/latestHEADinfo/{contractID}', {
}, async function (request, h) {
const { contractID } = request.params
try {
if (contractID.startsWith('_private')) return Boom.notFound()
if (
!contractID ||
!CONTRACT_DATA_REGEX.test(contractID) ||
contractID.startsWith('_private')
) return Boom.notFound()

const HEADinfo = await sbp('chelonia/db/latestHEADinfo', contractID)
if (!HEADinfo) {
console.warn(`[backend] latestHEADinfo not found for ${contractID}`)
Expand All @@ -287,7 +304,7 @@ route.GET('/latestHEADinfo/{contractID}', {
})

route.GET('/time', {}, function (request, h) {
return new Date().toISOString()
return h.response(new Date().toISOString()).type('text/plain')
})

// TODO: if the browser deletes our cache then not everyone
Expand Down Expand Up @@ -335,9 +352,9 @@ if (process.env.NODE_ENV === 'development') {
const { hash, data } = request.payload
if (!hash) return Boom.badRequest('missing hash')
if (!data) return Boom.badRequest('missing data')
const ourHash = createCID(data)
if (ourHash !== hash) {
console.error(`hash(${hash}) != ourHash(${ourHash})`)
const ourHashes = Object.values(multicodes).map((multicode) => createCID(data, ((multicode: any): number)))
if (ourHashes.includes(hash)) {
console.error(`hash(${hash}) != ourHash(${ourHashes.join(',')})`)
return Boom.badRequest('bad hash!')
}
await sbp('chelonia/db/set', hash, data)
Expand Down Expand Up @@ -407,7 +424,7 @@ route.POST('/file', {
if (!request.payload[i] || !(request.payload[i].payload instanceof Uint8Array)) {
throw Boom.badRequest('chunk missing in submitted data')
}
const ourHash = createCID(request.payload[i].payload)
const ourHash = createCID(request.payload[i].payload, multicodes.SHELTER_FILE_CHUNK)
if (request.payload[i].payload.byteLength !== chunk[0]) {
throw Boom.badRequest('bad chunk size')
}
Expand All @@ -421,7 +438,7 @@ route.POST('/file', {
// Finally, verify the size is correct
if (ourSize !== manifest.size) return Boom.badRequest('Mismatched total size')

const manifestHash = createCID(manifestMeta.payload)
const manifestHash = createCID(manifestMeta.payload, multicodes.SHELTER_FILE_MANIFEST)

// Check that we're not overwriting data. At best this is a useless operation
// since there is no need to write things that exist. However, overwriting
Expand Down Expand Up @@ -467,15 +484,38 @@ route.GET('/file/{hash}', {
}, async function (request, h) {
const { hash } = request.params

if (hash.startsWith('_private')) {
if (!hash || hash.startsWith('_private')) {
return Boom.notFound()
}

const blobOrString = await sbp('chelonia/db/get', `any:${hash}`)
if (!blobOrString) {
return Boom.notFound()
}
return h.response(blobOrString).etag(hash)
let type = 'application/octet-stream'

if (CONTRACT_DATA_REGEX.test(hash)) {
type = 'application/vnd.shelter.contractdata+json'
} else if (CONTRACT_MANIFEST_REGEX.test(hash)) {
type = 'application/vnd.shelter.contractmanifest+json'
} else if (CONTRACT_SOURCE_REGEX.test(hash)) {
type = 'application/vnd.shelter.contracttext'
} else if (FILE_MANIFEST_REGEX.test(hash)) {
type = 'application/vnd.shelter.filemanifest+json'
} else if (FILE_CHUNK_REGEX.test(hash)) {
type = 'application/vnd.shelter.filechunk+octet-stream'
} else if (hash.startsWith('name=')) {
type = 'text/plain'
}

return h
.response(blobOrString)
.etag(hash)
// CSP to disable everything -- this only affects direct navigation to the
// `/file` URL.
.header('content-security-policy', "default-src 'none'; frame-ancestors 'none'; form-action 'none'; upgrade-insecure-requests; sandbox")
.header('x-content-type-options', 'nosniff')
.type(type)
})

route.POST('/deleteFile/{hash}', {
Expand All @@ -488,7 +528,10 @@ route.POST('/deleteFile/{hash}', {
}, async function (request, h) {
const { hash } = request.params
const strategy = request.auth.strategy
if (!hash || hash.startsWith('_private')) return Boom.notFound()
if (!hash || !FILE_MANIFEST_REGEX.test(hash) || hash.startsWith('_private')) {
return Boom.notFound()
}

const owner = await sbp('chelonia/db/get', `_private_owner_${hash}`)
if (!owner) {
return Boom.notFound()
Expand Down Expand Up @@ -584,7 +627,8 @@ route.POST('/kv/{contractID}/{key}', {
}, async function (request, h) {
const { contractID, key } = request.params

if (key.startsWith('_private')) {
// The key is mandatory and we don't allow NUL in it as it's used for indexing
if (!CONTRACT_DATA_REGEX.test(contractID) || !key || key.includes('\x00') || key.startsWith('_private')) {
return Boom.notFound()
}

Expand Down Expand Up @@ -619,7 +663,7 @@ route.POST('/kv/{contractID}/{key}', {
// pass through
} else {
// "Quote" string (to match ETag format)
const cid = JSON.stringify(createCID(existing))
const cid = JSON.stringify(createCID(existing, multicodes.RAW))
if (!expectedEtag.split(',').map(v => v.trim()).includes(cid)) {
return Boom.preconditionFailed()
}
Expand Down Expand Up @@ -665,7 +709,7 @@ route.GET('/kv/{contractID}/{key}', {
}, async function (request, h) {
const { contractID, key } = request.params

if (key.startsWith('_private')) {
if (!CONTRACT_DATA_REGEX.test(contractID) || !key || key.includes('\x00') || key.startsWith('_private')) {
return Boom.notFound()
}

Expand All @@ -678,7 +722,7 @@ route.GET('/kv/{contractID}/{key}', {
return Boom.notFound()
}

return h.response(result).etag(createCID(result))
return h.response(result).etag(createCID(result, multicodes.RAW))
})

// SPA routes
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "group-income",
"version": "1.1.0",
"contractsVersion": "1.1.0",
"contractsVersion": "1.1.1",
taoeffect marked this conversation as resolved.
Show resolved Hide resolved
"private": true,
"description": "Group Income is a decentralized and private (end-to-end encrypted) financial safety net for you and your friends.",
"scripts": {
Expand Down Expand Up @@ -107,7 +107,7 @@
"@babel/preset-flow": "7.12.1",
"@babel/register": "7.23.7",
"@babel/runtime": "7.23.8",
"@chelonia/cli": "2.2.3",
"@chelonia/cli": "3.0.0",
"@exact-realty/multipart-parser": "1.0.12",
"@apeleghq/rfc8188": "1.0.7",
"@hapi/boom": "9.1.0",
Expand Down
10 changes: 5 additions & 5 deletions shared/domains/chelonia/GIMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// TODO: rename GIMessage to ChelMessage

import { has } from '~/frontend/model/contracts/shared/giLodash.js'
import { createCID } from '~/shared/functions.js'
import { createCID, multicodes } from '~/shared/functions.js'
import type { JSONObject, JSONType } from '~/shared/types.js'
import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, XSALSA20POLY1305, keyId } from './crypto.js'
import type { EncryptedData } from './encryptedData.js'
Expand Down Expand Up @@ -278,7 +278,7 @@ export class GIMessage {
if (!value) throw new Error(`deserialize bad value: ${value}`)
const { head: headJSON, ...parsedValue } = JSON.parse(value)
const head = JSON.parse(headJSON)
const contractID = head.op === GIMessage.OP_CONTRACT ? createCID(value) : head.contractID
const contractID = head.op === GIMessage.OP_CONTRACT ? createCID(value, multicodes.SHELTER_CONTRACT_DATA) : head.contractID

// Special case for OP_CONTRACT, since the keys are not yet present in the
// state
Expand All @@ -299,7 +299,7 @@ export class GIMessage {

return new this({
direction: 'incoming',
mapping: { key: createCID(value), value },
mapping: { key: createCID(value, multicodes.SHELTER_CONTRACT_DATA), value },
head,
signedMessageData
})
Expand All @@ -317,7 +317,7 @@ export class GIMessage {
},
get hash () {
if (!hash) {
hash = createCID(value)
hash = createCID(value, multicodes.SHELTER_CONTRACT_DATA)
}
return hash
},
Expand Down Expand Up @@ -513,7 +513,7 @@ function messageToParams (head: Object, message: SignedData<GIOpValue>): GIMsgPa
const value = JSON.stringify(messageJSON)

mapping = {
key: createCID(value),
key: createCID(value, multicodes.SHELTER_CONTRACT_DATA),
value
}
}
Expand Down
Loading