diff --git a/Gruntfile.js b/Gruntfile.js index ff46586ccf..e430cf04e2 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -123,7 +123,7 @@ module.exports = (grunt) => { } 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 ${keyFile} ${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 -n gi.contracts/{} -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/auth.js b/backend/auth.js index e2bf0b263e..d557d3f361 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -2,44 +2,55 @@ // https://hapijs.com/tutorials/auth // https://hapijs.com/tutorials/plugins -import { verify, b64ToStr } from '~/shared/functions.js' - +import { verifyShelterAuthorizationHeader } from '~/shared/domains/chelonia/utils.js' const Boom = require('@hapi/boom') exports.plugin = { - name: 'gi-auth', + name: 'chel-auth', register: function (server: Object, opts: Object) { - server.auth.scheme('gi-auth', function (server, options) { + server.auth.scheme('chel-bearer', function (server, options) { return { authenticate: function (request, h) { const { authorization } = request.headers - if (!authorization) return h.unauthenticated(Boom.unauthorized('Missing authorization')) - - let [scheme, json] = authorization.split(/\s+/) - // NOTE: if you want to add any signature verification, do it here - // eslint-disable-next-line no-constant-condition - if (false) { - if (!scheme.includes('gi')) h.unauthenticated(Boom.badRequest('Bad authentication')) - - try { - json = JSON.parse(b64ToStr(json)) - } catch (e) { - return h.unauthenticated(Boom.badRequest('Invalid token format')) - } - // http://hapijs.com/api/#serverauthschemename-scheme - const isValid = verify(json.msg, json.key, json.sig) - json.userId = json.key - const credentials = { credentials: json } - if (!isValid) return h.unauthenticated(Boom.unauthorized('Bad credentials'), credentials) - return h.authenticated(credentials) - } else { - // remove this if you decide to implement it - return h.authenticated({ credentials: 'TODO: delete me' }) + if (!authorization) { + return h.unauthenticated(Boom.unauthorized(null, 'bearer')) + } + // Space after 'bearer' is intentional and must be there as it + // acts as a separator + const thisScheme = 'bearer ' + if (authorization.slice(0, thisScheme.length) !== thisScheme) { + return h.unauthenticated(Boom.unauthorized(null, 'bearer')) + } + const token = authorization.slice(thisScheme.length) + return h.authenticated({ credentials: { token } }) + } + } + }) + server.auth.scheme('chel-shelter', function (server, options) { + return { + authenticate: function (request, h) { + const { authorization } = request.headers + if (!authorization) { + return h.unauthenticated(Boom.unauthorized(null, 'shelter')) + } + // Space after 'shelter' is intentional and must be there as it + // acts as a separator + const thisScheme = 'shelter ' + if (authorization.slice(0, thisScheme.length) !== thisScheme) { + return h.unauthenticated(Boom.unauthorized(null, 'shelter')) + } + try { + const billableContractID = verifyShelterAuthorizationHeader(authorization) + return h.authenticated({ credentials: { billableContractID } }) + } catch (e) { + console.warn(e, 'Shelter authorization failed') + return h.unauthenticated(Boom.unauthorized('Authentication failed', 'shelter')) } } } }) - server.auth.strategy('gi-auth', 'gi-auth') + server.auth.strategy('chel-bearer', 'chel-bearer') + server.auth.strategy('chel-shelter', 'chel-shelter') } } diff --git a/backend/routes.js b/backend/routes.js index 634db587c9..80a2d06491 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -10,6 +10,16 @@ import path from 'path' import chalk from 'chalk' import './database.js' import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt } from './zkppSalt.js' + +// Constant-time equal +const ctEq = (expected: string, actual: string) => { + let r = actual.length ^ expected.length + for (let i = 0; i < actual.length; i++) { + r |= actual.codePointAt(i) ^ expected.codePointAt(i) + } + return r === 0 +} + const Boom = require('@hapi/boom') const Joi = require('@hapi/joi') const isCheloniaDashboard = process.env.IS_CHELONIA_DASHBOARD_DEV @@ -36,28 +46,55 @@ const route = new Proxy({}, { // —BUT HTTP2 might be better than websockets and so we keep this around. // See related TODO in pubsub.js and the reddit discussion link. route.POST('/event', { - auth: 'gi-auth', + auth: { + strategy: 'chel-shelter', + mode: 'optional' + }, validate: { payload: Joi.string().required() } }, async function (request, h) { + // TODO: Update this regex once `chel` uses prefixed manifests + const manifestRegex = /^z9brRu3V[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz]{44}$/ try { console.debug('/event handler') const deserializedHEAD = GIMessage.deserializeHEAD(request.payload) try { + if (!manifestRegex.test(deserializedHEAD.head.manifest)) { + return Boom.badData('Invalid manifest') + } + const credentials = request.auth.credentials + // Only allow identity contracts to be created without attribution + if (!credentials?.billableContractID && deserializedHEAD.isFirstMessage) { + const manifest = await sbp('chelonia/db/get', deserializedHEAD.head.manifest) + const parsedManifest = JSON.parse(manifest) + const { name } = JSON.parse(parsedManifest.body) + if (name !== 'gi.contracts/identity') return Boom.unauthorized('This contract type requires ownership information', 'shelter') + } await sbp('backend/server/handleEntry', deserializedHEAD, request.payload) - const name = request.headers['shelter-namespace-registration'] - // If this is the first message in a contract and the - // `shelter-namespace-registration` header is present, proceed with also - // registering a name for the new contract - if (deserializedHEAD.contractID === deserializedHEAD.hash && name && !name.startsWith('_private')) { + if (deserializedHEAD.isFirstMessage) { + // Store attribution information + if (credentials?.billableContractID) { + await sbp('backend/server/saveOwner', credentials.billableContractID, deserializedHEAD.contractID) + // A billable entity has been created + } else { + await sbp('backend/server/registerBillableEntity', deserializedHEAD.contractID) + } + // If this is the first message in a contract and the + // `shelter-namespace-registration` header is present, proceed with also + // registering a name for the new contract + const name = request.headers['shelter-namespace-registration'] + if (name && !name.startsWith('_private')) { // Name registation is enabled only for identity contracts - const cheloniaState = sbp('chelonia/private/state') - if (cheloniaState.contracts[deserializedHEAD.contractID]?.type === 'gi.contracts/identity') { - const r = await sbp('backend/db/registerName', name, deserializedHEAD.contractID) - if (Boom.isBoom(r)) { - return r + const cheloniaState = sbp('chelonia/rootState') + if (cheloniaState.contracts[deserializedHEAD.contractID]?.type === 'gi.contracts/identity') { + const r = await sbp('backend/db/registerName', name, deserializedHEAD.contractID) + if (Boom.isBoom(r)) { + return r + } } } } + // Store size information + await sbp('backend/server/updateSize', deserializedHEAD.contractID, Buffer.byteLength(request.payload)) } catch (err) { console.error(err, chalk.bold.yellow(err.name)) if (err.name === 'ChelErrorDBBadPreviousHEAD' || err.name === 'ChelErrorAlreadyProcessed') { @@ -190,7 +227,10 @@ function (request, h) { // File upload route. // If accepted, the file will be stored in Chelonia DB. route.POST('/file', { - // TODO: only allow uploads from registered users + auth: { + strategies: ['chel-shelter'], + mode: 'required' + }, payload: { parse: true, output: 'stream', @@ -206,6 +246,10 @@ route.POST('/file', { }, async function (request, h) { try { console.info('FILE UPLOAD!') + const credentials = request.auth.credentials + if (!credentials?.billableContractID) { + return Boom.unauthorized('Uploading files requires ownership information', 'shelter') + } const manifestMeta = request.payload['manifest'] if (typeof manifestMeta !== 'object') return Boom.badRequest('missing manifest') if (manifestMeta.filename !== 'manifest.json') return Boom.badRequest('wrong manifest filename') @@ -253,11 +297,35 @@ route.POST('/file', { // Finally, verify the size is correct if (ourSize !== manifest.size) return Boom.badRequest('Mismatched total size') + const manifestHash = createCID(manifestMeta.payload) + + // 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 + // data would also make it ambiguous in terms of ownership. For example, + // someone could upload a file F1 using some existing chunks (from a + // different file F2) and then request to delete their file F1, which would + // result in corrupting F2. + // Ensure that the manifest doesn't exist + if (await sbp('chelonia/db/get', manifestHash)) { + throw new Error(`Manifest ${manifestHash} already exists`) + } + // Ensure that the chunks do not exist + await Promise.all(chunks.map(async ([cid]) => { + const exists = !!(await sbp('chelonia/db/get', cid)) + if (exists) { + throw new Error(`Chunk ${cid} already exists`) + } + })) // Now, store all chunks and the manifest await Promise.all(chunks.map(([cid, data]) => sbp('chelonia/db/set', cid, data))) - const manifestHash = createCID(manifestMeta.payload) await sbp('chelonia/db/set', manifestHash, manifestMeta.payload) - return manifestHash + // Store attribution information + await sbp('backend/server/saveOwner', credentials.billableContractID, manifestHash) + // Store size information + await sbp('backend/server/updateSize', manifestHash, manifest.size + manifestMeta.payload.byteLength) + // Generate and store deletion token + const deletionToken = sbp('backend/server/saveDeletionToken', manifestHash) + return h.response(manifestHash).header('shelter-deletion-token', deletionToken) } catch (err) { logger.error(err, 'POST /file', err.message) return err @@ -287,6 +355,98 @@ route.GET('/file/{hash}', { return h.response(blobOrString).etag(hash) }) +route.POST('/deleteFile/{hash}', { + auth: { + // Allow file deletion, and allow either the bearer of the deletion token or + // the file owner to delete it + strategies: ['chel-shelter', 'chel-bearer'], + mode: 'required' + } +}, async function (request, h) { + const { hash } = request.params + const strategy = request.auth.strategy + if (!hash || hash.startsWith('_private')) return Boom.notFound() + const owner = await sbp('chelonia/db/get', `_private_owner_${hash}`) + if (!owner) { + return Boom.notFound() + } + + switch (strategy) { + case 'chel-shelter': { + let ultimateOwner = owner + let count = 0 + // Walk up the ownership tree + do { + const owner = await sbp('chelonia/db/get', `_private_owner_${ultimateOwner}`) + if (owner) { + ultimateOwner = owner + count++ + } else { + break + } + // Prevent an infinite loop + } while (count < 128) + // Check that the user making the request is the ultimate owner (i.e., + // that they have permission to delete this file) + if (!ctEq(request.auth.credentials.billableContractID, ultimateOwner)) { + return Boom.unauthorized('Invalid token', 'bearer') + } + break + } + case 'chel-bearer': { + const expectedToken = await sbp('chelonia/db/get', `_private_deletionToken_${hash}`) + if (!expectedToken) { + return Boom.notFound() + } + const token = request.auth.credentials.token + // Constant-time comparison + // Check that the token provided matches the deletion token for this file + if (!ctEq(expectedToken, token)) { + return Boom.unauthorized('Invalid token', 'bearer') + } + break + } + default: + return Boom.unauthorized('Missing or invalid auth strategy') + } + + // Authentication passed, now proceed to delete the file and its associated + // keys + const rawManifest = await sbp('chelonia/db/get', hash) + if (!rawManifest) return Boom.notFound() + try { + const manifest = JSON.parse(rawManifest) + if (!manifest || typeof manifest !== 'object') return Boom.badData('manifest format is invalid') + if (manifest.version !== '1.0.0') return Boom.badData('unsupported manifest version') + if (!Array.isArray(manifest.chunks) || !manifest.chunks.length) return Boom.badData('missing chunks') + // Delete all chunks + await Promise.all(manifest.chunks.map(([, cid]) => sbp('chelonia/db/delete', cid))) + } catch (e) { + console.warn(e, `Error parsing manifest for ${hash}. It's probably not a file manifest.`) + return Boom.notFound() + } + // The keys to be deleted are not read from or updated, so they can be deleted + // without using a queue + await sbp('chelonia/db/delete', hash) + await sbp('chelonia/db/delete', `_private_owner_${hash}`) + await sbp('chelonia/db/delete', `_private_size_${hash}`) + const resourcesKey = `_private_resources_${owner}` + // Use a queue for atomicity + await sbp('okTurtles.eventQueue/queueEvent', resourcesKey, async () => { + const existingResources = await sbp('chelonia/db/get', resourcesKey) + if (!existingResources) return + if (existingResources.endsWith(hash)) { + await sbp('chelonia/db/set', resourcesKey, existingResources.slice(0, -hash.length - 1)) + return + } + const hashIndex = existingResources.indexOf(hash + '\x00') + if (hashIndex === -1) return + await sbp('chelonia/db/set', resourcesKey, existingResources.slice(0, hashIndex) + existingResources.slice(hashIndex + hash.length + 1)) + }) + + return h.response() +}) + // SPA routes route.GET('/assets/{subpath*}', { @@ -333,32 +493,63 @@ route.GET('/', {}, function (req, h) { return h.redirect(staticServeConfig.redirect) }) -route.POST('/zkpp/register/{contractID}', { +route.POST('/zkpp/register/{name}', { + auth: { + strategy: 'chel-shelter', + mode: 'optional' + }, validate: { payload: Joi.alternatives([ { - // what b is + // b is a hash of a random public key (`g^r`) with secret key `r`, + // which is used by the requester to commit to that particular `r` b: Joi.string().required() }, { - r: Joi.string().required(), // what r is - s: Joi.string().required(), // what s is + // `r` is the value used to derive `b` (in this case, it's the public + // key `g^r`) + r: Joi.string().required(), + // `s` is an opaque (to the client) value that was earlier returned by + // the server + s: Joi.string().required(), + // `sig` is an opaque (to the client) value returned by the server + // to validate the request (ensuring that (`r`, `s`) come from a + // previous request sig: Joi.string().required(), + // `Eh` is the Eh = E_{S_A + S_C}(h), where S_A and S_C are salts and + // h = H\_{S_A}(P) Eh: Joi.string().required() } ]) } }, async function (req, h) { - if (req.params['contractID'].startsWith('_private')) return Boom.notFound() + if (!req.payload['b']) { + const credentials = req.auth.credentials + if (!credentials?.billableContractID) { + return Boom.unauthorized('Registering a salt requires ownership information', 'shelter') + } + if (req.params['name'].startsWith('_private')) return Boom.notFound() + console.error({ name: req.params.name, x: 'foo' }) + const contractID = await sbp('backend/db/lookupName', req.params['name']) + if (contractID !== credentials.billableContractID) { + // This ensures that only the owner of the contract can set a salt for it, + // closing a small window of opportunity(*) during which an attacker could + // potentially lock out a new user from their account by registering a + // different salt. + // (*) This is right between the moment an OP_CONTRACT is sent and the + // time this endpoint is called, which should follow almost immediately after. + return Boom.forbidden('Only the owner of this resource may set a password hash') + } + } try { if (req.payload['b']) { - const result = await registrationKey(req.params['contractID'], req.payload['b']) + const result = await registrationKey(req.params['name'], req.payload['b']) if (result) { return result } } else { - const result = await register(req.params['contractID'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['Eh']) + const result = await register(req.params['name'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['Eh']) if (result) { return result @@ -366,31 +557,31 @@ route.POST('/zkpp/register/{contractID}', { } } catch (e) { const ip = req.info.remoteAddress - console.error(e, 'Error at POST /zkpp/{contractID}: ' + e.message, { ip }) + console.error(e, 'Error at POST /zkpp/{name}: ' + e.message, { ip }) } return Boom.internal('internal error') }) -route.GET('/zkpp/{contractID}/auth_hash', { +route.GET('/zkpp/{name}/auth_hash', { validate: { query: Joi.object({ b: Joi.string().required() }) } }, async function (req, h) { - if (req.params['contractID'].startsWith('_private')) return Boom.notFound() + if (req.params['name'].startsWith('_private')) return Boom.notFound() try { - const challenge = await getChallenge(req.params['contractID'], req.query['b']) + const challenge = await getChallenge(req.params['name'], req.query['b']) return challenge || Boom.notFound() } catch (e) { const ip = req.info.remoteAddress - console.error(e, 'Error at GET /zkpp/{contractID}/auth_hash: ' + e.message, { ip }) + console.error(e, 'Error at GET /zkpp/{name}/auth_hash: ' + e.message, { ip }) } return Boom.internal('internal error') }) -route.GET('/zkpp/{contractID}/contract_hash', { +route.GET('/zkpp/{name}/contract_hash', { validate: { query: Joi.object({ r: Joi.string().required(), @@ -400,22 +591,22 @@ route.GET('/zkpp/{contractID}/contract_hash', { }) } }, async function (req, h) { - if (req.params['contractID'].startsWith('_private')) return Boom.notFound() + if (req.params['name'].startsWith('_private')) return Boom.notFound() try { - const salt = await getContractSalt(req.params['contractID'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc']) + const salt = await getContractSalt(req.params['name'], req.query['r'], req.query['s'], req.query['sig'], req.query['hc']) if (salt) { return salt } } catch (e) { const ip = req.info.remoteAddress - console.error(e, 'Error at GET /zkpp/{contractID}/contract_hash: ' + e.message, { ip }) + console.error(e, 'Error at GET /zkpp/{name}/contract_hash: ' + e.message, { ip }) } return Boom.internal('internal error') }) -route.POST('/zkpp/updatePasswordHash/{contractID}', { +route.POST('/zkpp/updatePasswordHash/{name}', { validate: { payload: Joi.object({ r: Joi.string().required(), @@ -426,16 +617,16 @@ route.POST('/zkpp/updatePasswordHash/{contractID}', { }) } }, async function (req, h) { - if (req.params['contractID'].startsWith('_private')) return Boom.notFound() + if (req.params['name'].startsWith('_private')) return Boom.notFound() try { - const result = await updateContractSalt(req.params['contract'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['hc'], req.payload['Ea']) + const result = await updateContractSalt(req.params['name'], req.payload['r'], req.payload['s'], req.payload['sig'], req.payload['hc'], req.payload['Ea']) if (result) { return result } } catch (e) { const ip = req.info.remoteAddress - console.error(e, 'Error at POST /zkpp/updatePasswordHash/{contract}: ' + e.message, { ip }) + console.error(e, 'Error at POST /zkpp/updatePasswordHash/{name}: ' + e.message, { ip }) } return Boom.internal('internal error') diff --git a/backend/server.js b/backend/server.js index 70e0c9e72d..8d9f6e1ef6 100644 --- a/backend/server.js +++ b/backend/server.js @@ -17,6 +17,8 @@ import { pushServerActionhandlers } from './push.js' import chalk from 'chalk' import '~/shared/domains/chelonia/chelonia.js' import { SERVER } from '~/shared/domains/chelonia/presets.js' +// $FlowFixMe[cannot-resolve-module] +import { webcrypto } from 'node:crypto' const { CONTRACTS_VERSION, GI_VERSION } = process.env @@ -64,7 +66,7 @@ sbp('okTurtles.data/set', SERVER_INSTANCE, hapi) sbp('sbp/selectors/register', { 'backend/server/persistState': async function (deserializedHEAD: Object, entry: string) { const contractID = deserializedHEAD.contractID - const cheloniaState = sbp('chelonia/private/state') + const cheloniaState = sbp('chelonia/rootState') // 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) { @@ -132,6 +134,49 @@ sbp('sbp/selectors/register', { await sbp('backend/server/persistState', deserializedHEAD, entry) await sbp('backend/server/broadcastEntry', deserializedHEAD, entry) }, + 'backend/server/saveOwner': async function (ownerID: string, resourceID: string) { + // Store the owner for the current resource + await sbp('chelonia/db/set', `_private_owner_${resourceID}`, ownerID) + const resourcesKey = `_private_resources_${ownerID}` + // Store the resource in the resource index key + // This is done in a queue to handle several simultaneous requests + // reading and writing to the same key + await sbp('okTurtles.eventQueue/queueEvent', resourcesKey, async () => { + const existingResources = await sbp('chelonia/db/get', resourcesKey) + await sbp('chelonia/db/set', resourcesKey, (existingResources ? existingResources + '\x00' : '') + resourceID) + }) + }, + 'backend/server/registerBillableEntity': async function (resourceID: string) { + // Use a queue to ensure atomic updates + await sbp('okTurtles.eventQueue/queueEvent', '_private_billable_entities', async () => { + const existingBillableEntities = await sbp('chelonia/db/get', '_private_billable_entities') + await sbp('chelonia/db/set', '_private_billable_entities', (existingBillableEntities ? existingBillableEntities + '\x00' : '') + resourceID) + }) + }, + 'backend/server/updateSize': async function (resourceID: string, size: number) { + const sizeKey = `_private_size_${resourceID}` + if (!(size >= 0)) { + throw new TypeError(`Invalid given size ${size} for ${resourceID}`) + } + // Use a queue to ensure atomic updates + await sbp('okTurtles.eventQueue/queueEvent', sizeKey, async () => { + // Size is stored as a decimal value + const existingSize = parseInt(await sbp('chelonia/db/get', sizeKey, 10) ?? '0') + if (!(existingSize >= 0)) { + throw new TypeError(`Invalid stored size ${existingSize} for ${resourceID}`) + } + await sbp('chelonia/db/set', sizeKey, (existingSize + size).toString(10)) + }) + }, + 'backend/server/saveDeletionToken': async function (resourceID: string) { + const deletionTokenRaw = new Uint8Array(18) + // $FlowFixMe[cannot-resolve-name] + webcrypto.getRandomValues(deletionTokenRaw) + // $FlowFixMe[incompatible-call] + const deletionToken = Buffer.from(deletionTokenRaw).toString('base64url') + await sbp('chelonia/db/set', `_private_deletionToken_${resourceID}`, deletionToken) + return deletionToken + }, 'backend/server/stop': function () { return hapi.stop() } @@ -195,7 +240,7 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, { recoveredState[contractID] = cp.contractState recoveredState.contracts[contractID] = cp.cheloniaContractInfo })) - Object.assign(sbp('chelonia/private/state'), recoveredState) + Object.assign(sbp('chelonia/rootState'), recoveredState) } // https://hapi.dev/tutorials/plugins await hapi.register([ diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 5353d20a02..3221bd2fc9 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -12,7 +12,7 @@ import type { GIRegParams } from './types.js' import { encryptedAction, encryptedNotification } from './utils.js' export default (sbp('sbp/selectors/register', { - 'gi.actions/chatroom/create': async function (params: GIRegParams) { + 'gi.actions/chatroom/create': async function (params: GIRegParams, billableContractID: string) { try { let cskOpts = params.options?.csk let cekOpts = params.options?.cek @@ -71,8 +71,17 @@ export default (sbp('sbp/selectors/register', { const userCSKid = findKeyIdByName(rootState[userID], 'csk') if (!userCSKid) throw new Error('User CSK id not found') + const SAK = keygen(EDWARDS25519SHA512BATCH) + const SAKid = keyId(SAK) + const SAKp = serializeKey(SAK, false) + const SAKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(SAK, true)) + const chatroom = await sbp('chelonia/out/registerContract', { ...omit(params, ['options']), // any 'options' are for this action, not for Chelonia + publishOptions: { + billableContractID, + ...params.publishOptions + }, signingKeyId: cskOpts.id, actionSigningKeyId: cskOpts.id, actionEncryptionKeyId: cekOpts.id, @@ -81,7 +90,7 @@ export default (sbp('sbp/selectors/register', { id: cskOpts.id, name: 'csk', purpose: ['sig'], - ringLevel: 1, + ringLevel: 0, permissions: '*', allowedActions: '*', foreignKey: cskOpts.foreignKey, @@ -92,7 +101,7 @@ export default (sbp('sbp/selectors/register', { id: cekOpts.id, name: 'cek', purpose: ['enc'], - ringLevel: 1, + ringLevel: 0, permissions: [GIMessage.OP_ACTION_ENCRYPTED], allowedActions: '*', foreignKey: cekOpts.foreignKey, @@ -125,6 +134,20 @@ export default (sbp('sbp/selectors/register', { } ] : []), + { + id: SAKid, + name: '#sak', + purpose: ['sak'], + ringLevel: 0, + permissions: [], + allowedActions: [], + meta: { + private: { + content: SAKs + } + }, + data: SAKp + }, // TODO: Find a way to have this wrapping be done by Chelonia directly encryptedOutgoingDataWithRawKey(CEK, { foreignKey: `sp:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`, diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index ad5f30e8f3..65318a6002 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -57,38 +57,42 @@ export default (sbp('sbp/selectors/register', { }) { let finalPicture = `${window.location.origin}/assets/images/group-avatar-default.png` + const rootState = sbp('state/vuex/state') + const userID = rootState.loggedIn.identityContractID + if (picture) { try { - finalPicture = await imageUpload(picture) + finalPicture = await imageUpload(picture, { billableContractID: userID }) } catch (e) { console.error('actions/group.js failed to upload the group picture', e) throw new GIErrorUIRuntimeError(L('Failed to upload the group picture. {codeError}', { codeError: e.message })) } } - const rootState = sbp('state/vuex/state') - const userID = rootState.loggedIn.identityContractID - // Create the necessary keys to initialise the contract // eslint-disable-next-line camelcase const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) const inviteKey = keygen(EDWARDS25519SHA512BATCH) + const SAK = keygen(EDWARDS25519SHA512BATCH) // Key IDs const CSKid = keyId(CSK) const CEKid = keyId(CEK) const inviteKeyId = keyId(inviteKey) + const SAKid = keyId(SAK) // Public keys to be stored in the contract const CSKp = serializeKey(CSK, false) const CEKp = serializeKey(CEK, false) const inviteKeyP = serializeKey(inviteKey, false) + const SAKp = serializeKey(SAK, false) // Secret keys to be stored encrypted in the contract const CSKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(CSK, true)) const CEKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(CEK, true)) const inviteKeyS = encryptedOutgoingDataWithRawKey(CEK, serializeKey(inviteKey, true)) + const SAKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(SAK, true)) try { const proposalSettings = { @@ -120,7 +124,10 @@ export default (sbp('sbp/selectors/register', { const message = await sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/group', - publishOptions, + publishOptions: { + billableContractID: userID, + ...publishOptions + }, signingKeyId: CSKid, actionSigningKeyId: CSKid, actionEncryptionKeyId: CEKid, @@ -169,6 +176,20 @@ export default (sbp('sbp/selectors/register', { } }, data: inviteKeyP + }, + { + id: SAKid, + name: '#sak', + purpose: ['sak'], + ringLevel: 0, + permissions: [], + allowedActions: [], + meta: { + private: { + content: SAKs + } + }, + data: SAKp } ], data: { @@ -587,7 +608,7 @@ export default (sbp('sbp/selectors/register', { prepublish: params.hooks?.prepublish, postpublish: null } - }) + }, params.contractID) // When creating a public chatroom, that chatroom's secret keys are shared // with the group (i.e., they are literally the same keys, using the diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 068b54c22c..ead9dc9790 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -25,7 +25,7 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/retrieveSalt': async (username: string, passwordFn: () => string) => { const r = randomNonce() const b = hash(r) - const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/user=${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) + const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) .then(handleFetchResult('json')) const { authSalt, s, sig } = authHash @@ -34,7 +34,7 @@ export default (sbp('sbp/selectors/register', { const [c, hc] = computeCAndHc(r, s, h) - const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/user=${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({ + const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({ 'r': r, 's': s, 'sig': sig, @@ -50,20 +50,12 @@ export default (sbp('sbp/selectors/register', { const password = passwordFn() let finalPicture = `${window.location.origin}/assets/images/user-avatar-default.png` - if (picture) { - try { - finalPicture = await imageUpload(picture) - } catch (e) { - console.error('actions/identity.js picture upload error:', e) - throw new GIErrorUIRuntimeError(L('Failed to upload the profile picture. {codeError}', { codeError: e.message })) - } - } // proceed with creation const keyPair = boxKeyPair() const r = Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') const b = hash(r) // TODO: use the contractID instead, and move this code down below the registration - const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/user=${encodeURIComponent(username)}`, { + const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' @@ -75,23 +67,6 @@ export default (sbp('sbp/selectors/register', { const { p, s, sig } = registrationRes const [contractSalt, Eh] = await buildRegisterSaltRequest(p, keyPair.secretKey, password) - // TODO: use the contractID instead, and move this code down below the registration - const res = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/user=${encodeURIComponent(username)}`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - }, - body: new URLSearchParams({ - 'r': r, - 's': s, - 'sig': sig, - 'Eh': Eh - }) - }) - - if (!res.ok) { - throw new Error('Unable to register hash') - } // Create the necessary keys to initialise the contract const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) @@ -99,6 +74,7 @@ export default (sbp('sbp/selectors/register', { const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) const PEK = keygen(CURVE25519XSALSA20POLY1305) + const SAK = keygen(EDWARDS25519SHA512BATCH) // Key IDs const IPKid = keyId(IPK) @@ -106,6 +82,7 @@ export default (sbp('sbp/selectors/register', { const CSKid = keyId(CSK) const CEKid = keyId(CEK) const PEKid = keyId(PEK) + const SAKid = keyId(SAK) // Public keys to be stored in the contract const IPKp = serializeKey(IPK, false) @@ -113,21 +90,23 @@ export default (sbp('sbp/selectors/register', { const CSKp = serializeKey(CSK, false) const CEKp = serializeKey(CEK, false) const PEKp = serializeKey(PEK, false) + const SAKp = serializeKey(SAK, false) // Secret keys to be stored encrypted in the contract const CSKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(CSK, true)) const CEKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(CEK, true)) const PEKs = encryptedOutgoingDataWithRawKey(CEK, serializeKey(PEK, true)) + const SAKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(SAK, true)) // Before creating the contract, put all keys into transient store sbp('chelonia/storeSecretKeys', - () => [IPK, IEK, CEK, CSK, PEK].map(key => ({ key, transient: true })) + () => [IPK, IEK, CEK, CSK, PEK, SAK].map(key => ({ key, transient: true })) ) let userID // next create the identity contract itself try { - const user = await sbp('chelonia/out/registerContract', { + await sbp('chelonia/out/registerContract', { contractName: 'gi.contracts/identity', publishOptions, signingKeyId: IPKid, @@ -205,16 +184,66 @@ export default (sbp('sbp/selectors/register', { } }, data: PEKp + }, + { + id: SAKid, + name: '#sak', + purpose: ['sak'], + ringLevel: 0, + permissions: [], + allowedActions: [], + meta: { + private: { + content: SAKs + } + }, + data: SAKp } ], + hooks: { + postpublishContract: async (message) => { + // We need to get the contract state + await sbp('chelonia/contract/sync', message.contractID()) + + // Register password salt + const res = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { + method: 'POST', + headers: { + 'authorization': sbp('chelonia/shelterAuthorizationHeader', message.contractID()), + 'content-type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + 'r': r, + 's': s, + 'sig': sig, + 'Eh': Eh + }) + }) + + if (!res.ok) { + throw new Error('Unable to register hash') + } + + userID = message.contractID() + if (picture) { + try { + finalPicture = await imageUpload(picture, { billableContractID: userID }) + } catch (e) { + console.error('actions/identity.js picture upload error:', e) + throw new GIErrorUIRuntimeError(L('Failed to upload the profile picture. {codeError}', { codeError: e.message })) + } + } + } + }, data: { - attributes: { username, email, picture: finalPicture } + // finalPicture is set after OP_CONTRACT is sent, which is after + // calling 'chelonia/out/registerContract' here. We use a getter for + // `picture` so that the action sent has the correct value + attributes: { username, email, get picture () { return finalPicture } } }, namespaceRegistration: username }) - userID = user.contractID() - // After the contract has been created, store pesistent keys sbp('chelonia/storeSecretKeys', () => [CEK, CSK, PEK].map(key => ({ key })) @@ -606,7 +635,7 @@ export default (sbp('sbp/selectors/register', { prepublish: params.hooks?.prepublish, postpublish: null } - }) + }, rootState.loggedIn.identityContractID) // Share the keys to the newly created chatroom with ourselves await sbp('gi.actions/out/shareVolatileKeys', { diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 0eaf4963c7..faf4a1b335 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -22,8 +22,9 @@ export function imageDataURItoBlob (dataURI: string): Blob { return new Blob([ab], { type: imageType }) } -export const imageUpload = (imageFile: File): Promise => { +export const imageUpload = async (imageFile: File, params: ?Object): Promise => { const file = imageFile console.debug('will upload a picture of type:', file.type) - return sbp('chelonia/fileUpload', imageFile, { type: file.type, cipher: 'aes256gcm' }) + const { download } = await sbp('chelonia/fileUpload', imageFile, { type: file.type, cipher: 'aes256gcm' }, params) + return download } diff --git a/frontend/views/components/AvatarUpload.vue b/frontend/views/components/AvatarUpload.vue index 0cdada8a99..cef711a131 100644 --- a/frontend/views/components/AvatarUpload.vue +++ b/frontend/views/components/AvatarUpload.vue @@ -51,7 +51,7 @@ export default ({ let picture try { - picture = await imageUpload(blob) + picture = await imageUpload(blob, { billableContractID: this.sbpParams.contractID }) } catch (e) { console.error('AvatarUpload imageUpload() error:', e) this.$refs.formMsg.danger(L('Failed to upload avatar. {reportError}', LError(e))) diff --git a/frontend/views/containers/chatroom/ChatMain.vue b/frontend/views/containers/chatroom/ChatMain.vue index 13cb5bae58..84ec563457 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -464,9 +464,9 @@ export default ({ const { mimeType, url, name } = attachment // url here is an instance of URL.createObjectURL(), which needs to be converted to a 'Blob' const attachmentBlob = await objectURLtoBlob(url) - const downloadData = await sbp('chelonia/fileUpload', attachmentBlob, { + const { download: downloadData } = await sbp('chelonia/fileUpload', attachmentBlob, { type: mimeType, cipher: 'aes256gcm' - }) + }, { billableContractID: contractID }) return { name, mimeType, downloadData } })) data = { ...data, attachments: attachmentsToSend } diff --git a/package-lock.json b/package-lock.json index 1a37a16280..06708f7844 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,7 +58,7 @@ "@babel/preset-flow": "7.12.1", "@babel/register": "7.23.7", "@babel/runtime": "7.23.8", - "@chelonia/cli": "2.1.1", + "@chelonia/cli": "2.2.1", "@exact-realty/multipart-parser": "1.0.12", "@exact-realty/rfc8188": "1.0.5", "@vue/component-compiler": "4.2.4", @@ -2110,9 +2110,9 @@ } }, "node_modules/@chelonia/cli": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@chelonia/cli/-/cli-2.1.1.tgz", - "integrity": "sha512-2XCe4F96tDcBkm8d1OQDK7K+pRIVd9/fU2DXvLeE49YENRgBP/keLKZ584S5huXMfl+P/lOtQYUXfa09Mqb8Pw==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@chelonia/cli/-/cli-2.2.1.tgz", + "integrity": "sha512-A07JaT2lBKVsDrOsed16O/2g5tPD/sS7kHH98+k+h6HULGzGHr+hRCFqc6PE5kIVmao7YYLHDeUBFfKMyTMBvQ==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/package.json b/package.json index 2d41225999..8dd6aa0eec 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "@babel/preset-flow": "7.12.1", "@babel/register": "7.23.7", "@babel/runtime": "7.23.8", - "@chelonia/cli": "2.1.1", + "@chelonia/cli": "2.2.1", "@exact-realty/multipart-parser": "1.0.12", "@exact-realty/rfc8188": "1.0.5", "@vue/component-compiler": "4.2.4", diff --git a/shared/domains/chelonia/GIMessage.js b/shared/domains/chelonia/GIMessage.js index 8dcf69bab2..987d553169 100644 --- a/shared/domains/chelonia/GIMessage.js +++ b/shared/domains/chelonia/GIMessage.js @@ -306,7 +306,7 @@ export class GIMessage { }) } - static deserializeHEAD (value: string): { head: Object; hash: string; contractID: string } { + static deserializeHEAD (value: string): { head: Object; hash: string; contractID: string; isFirstMessage: boolean; description: () => string } { if (!value) throw new Error(`deserialize bad value: ${value}`) let head, hash const result = { @@ -328,6 +328,9 @@ export class GIMessage { description (): string { const type = this.head.op return `` + }, + get isFirstMessage (): boolean { + return !result.head?.contractID } } return result diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index 03f070b980..88a3fac929 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -18,7 +18,8 @@ import type { EncryptedData } from './encryptedData.js' import { isSignedData, signedIncomingData, signedOutgoingData, signedOutgoingDataWithRawKey } from './signedData.js' import './internals.js' import './files.js' -import { eventsAfter, findForeignKeysByContractID, findKeyIdByName, findRevokedKeyIdsByName, findSuitableSecretKeyId, getContractIDfromKeyId } from './utils.js' +import './time-sync.js' +import { buildShelterAuthorizationHeader, eventsAfter, findForeignKeysByContractID, findKeyIdByName, findRevokedKeyIdsByName, findSuitableSecretKeyId, getContractIDfromKeyId } from './utils.js' // TODO: define ChelContractType for /defineContract @@ -40,7 +41,7 @@ export type ChelRegParams = { postpublish?: (GIMessage) => void; onprocessed?: (GIMessage) => void; }; - publishOptions?: { headers: ?Object, maxAttempts: number }; + publishOptions?: { headers: ?Object, billableContractID: ?string, maxAttempts: number }; } export type ChelActionParams = { @@ -328,6 +329,7 @@ export default (sbp('sbp/selectors/register', { } }, 'chelonia/reset': async function (postCleanupFn) { + sbp('chelonia/private/stopClockSync') // wait for any pending sync operations to finish before saving await sbp('chelonia/contract/waitPublish') await sbp('chelonia/contract/wait') @@ -349,6 +351,7 @@ export default (sbp('sbp/selectors/register', { this.subscriptionSet.clear() sbp('chelonia/clearTransientSecretKeys') sbp('okTurtles.events/emit', CONTRACTS_MODIFIED, this.subscriptionSet) + sbp('chelonia/private/startClockSync') }, 'chelonia/storeSecretKeys': function (keysFn: () => {key: Key, transient?: boolean}[]) { const rootState = sbp(this.config.stateSelector) @@ -502,6 +505,9 @@ export default (sbp('sbp/selectors/register', { const keyId = findSuitableSecretKeyId(contractIDOrState, permissions, purposes, ringLevel, allowedActions) return keyId }, + 'chelonia/shelterAuthorizationHeader' (contractID: string) { + return buildShelterAuthorizationHeader.call(this, contractID) + }, // The purpose of the 'chelonia/crypto/*' selectors is so that they can be called // from contracts without including the crypto code (i.e., importing crypto.js) // This function takes a function as a parameter that returns a string @@ -523,6 +529,7 @@ export default (sbp('sbp/selectors/register', { // its console output until we have a better solution. Do not use for auth. pubsubURL += `?debugID=${randomHexString(6)}` } + sbp('chelonia/private/startClockSync') this.pubsub = createClient(pubsubURL, { ...this.config.connectionOptions, messageHandlers: { diff --git a/shared/domains/chelonia/files.js b/shared/domains/chelonia/files.js index 2ffbb285ac..a545fd697b 100644 --- a/shared/domains/chelonia/files.js +++ b/shared/domains/chelonia/files.js @@ -5,6 +5,7 @@ import encrypt from '@exact-realty/rfc8188/encrypt' import sbp from '@sbp/sbp' import { blake32Hash, createCID, createCIDfromStream } from '~/shared/functions.js' import { coerce } from '~/shared/multiformats/bytes.js' +import { buildShelterAuthorizationHeader } from './utils.js' // Snippet from // Node.js supports request streams, but also this check isn't meant for Node.js @@ -257,7 +258,7 @@ const cipherHandlers = { } export default (sbp('sbp/selectors/register', { - 'chelonia/fileUpload': async function (chunks: Blob | Blob[], manifestOptions: Object) { + 'chelonia/fileUpload': async function (chunks: Blob | Blob[], manifestOptions: Object, { billableContractID }: { billableContractID: string } = {}) { if (!Array.isArray(chunks)) chunks = [chunks] const chunkDescriptors: Promise<[number, string]>[] = [] const cipherHandler = await cipherHandlers[manifestOptions.cipher]?.upload?.(this, manifestOptions) @@ -319,14 +320,20 @@ export default (sbp('sbp/selectors/register', { method: 'POST', signal: this.abortController.signal, body: await ArrayBufferToUint8ArrayStream(this.config.connectionURL, stream), - headers: new Headers([['content-type', `multipart/form-data; boundary=${boundary}`]]), + headers: new Headers([ + ...(billableContractID ? [['authorization', buildShelterAuthorizationHeader.call(this, billableContractID)]] : []), + ['content-type', `multipart/form-data; boundary=${boundary}`] + ]), duplex: 'half' }) if (!uploadResponse.ok) throw new Error('Error uploading file') return { - manifestCid: await uploadResponse.text(), - downloadParams: cipherHandler.downloadParams + download: { + manifestCid: await uploadResponse.text(), + downloadParams: cipherHandler.downloadParams + }, + delete: uploadResponse.headers.get('shelter-deletion-token') } }, 'chelonia/fileDownload': async function ({ manifestCid, downloadParams }: { manifestCid: string, downloadParams: Object }, manifestChecker?: (manifest: Object) => boolean | Promise) { @@ -353,5 +360,27 @@ export default (sbp('sbp/selectors/register', { if (!cipherHandler) throw new Error('Unsupported cipher') return cipherHandler.payloadHandler() + }, + 'chelonia/fileDelete': async function ({ manifestCid }: { manifestCid: string }, { billableContractID, token }: { token: ?string, billableContractID: ?string } = {}) { + if (!manifestCid) { + throw new TypeError('A manifest CID must be provided') + } + if (!token !== !billableContractID) { + throw new TypeError('Either a token or a billable contract ID must be provided') + } + const response = await fetch(`${this.config.connectionURL}/deleteFile/${manifestCid}`, { + method: 'POST', + signal: this.abortController.signal, + headers: new Headers([ + ['authorization', + token + ? `bearer ${token}` + // $FlowFixMe[incompatible-call] + : buildShelterAuthorizationHeader.call(this, billableContractID)] + ]) + }) + if (!response.ok) { + throw new Error('Unable to delete file') + } } }): string[]) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 732b7d9969..02124f7b6e 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -13,7 +13,7 @@ import { encryptedIncomingData, encryptedOutgoingData, unwrapMaybeEncryptedData import type { EncryptedData } from './encryptedData.js' import { ChelErrorUnrecoverable, ChelErrorWarning, ChelErrorDBBadPreviousHEAD, ChelErrorAlreadyProcessed } from './errors.js' import { CONTRACTS_MODIFIED, CONTRACT_HAS_RECEIVED_KEYS, CONTRACT_IS_SYNCING, EVENT_HANDLED, EVENT_PUBLISHED, EVENT_PUBLISHING_ERROR } from './events.js' -import { findKeyIdByName, findSuitablePublicKeyIds, findSuitableSecretKeyId, getContractIDfromKeyId, keyAdditionProcessor, recreateEvent, validateKeyPermissions, validateKeyAddPermissions, validateKeyDelPermissions, validateKeyUpdatePermissions } from './utils.js' +import { buildShelterAuthorizationHeader, findKeyIdByName, findSuitablePublicKeyIds, findSuitableSecretKeyId, getContractIDfromKeyId, keyAdditionProcessor, recreateEvent, validateKeyPermissions, validateKeyAddPermissions, validateKeyDelPermissions, validateKeyUpdatePermissions } from './utils.js' import { isSignedData, signedIncomingData } from './signedData.js' // import 'ses' @@ -253,6 +253,9 @@ export default (sbp('sbp/selectors/register', { } const manifest = JSON.parse(manifestSource) const body = sbp('chelonia/private/verifyManifestSignature', contractName, manifestHash, manifest) + if (body.name !== contractName) { + throw new Error(`Mismatched contract name. Expected ${contractName} but got ${body.name}`) + } 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 }) @@ -409,7 +412,7 @@ export default (sbp('sbp/selectors/register', { }, // used by, e.g. 'chelonia/contract/wait' 'chelonia/private/noop': function () {}, - 'chelonia/private/out/publishEvent': function (entry: GIMessage, { maxAttempts = 5, headers } = {}, hooks) { + 'chelonia/private/out/publishEvent': function (entry: GIMessage, { maxAttempts = 5, headers, billableContractID, bearer } = {}, hooks) { const contractID = entry.contractID() const originalEntry = entry @@ -489,13 +492,18 @@ export default (sbp('sbp/selectors/register', { body: entry.serialize(), headers: { ...headers, - 'Content-Type': 'text/plain', - 'Authorization': 'gi TODO - signature - if needed here - goes here' + ...bearer && { + 'Authorization': `Bearer ${bearer}` + }, + ...billableContractID && { + 'Authorization': buildShelterAuthorizationHeader.call(this, billableContractID) + }, + 'Content-Type': 'text/plain' }, signal: this.abortController.signal }) if (r.ok) { - hooks?.postpublish?.(entry) + await hooks?.postpublish?.(entry) return entry } try { diff --git a/shared/domains/chelonia/time-sync.js b/shared/domains/chelonia/time-sync.js new file mode 100644 index 0000000000..e1d3077338 --- /dev/null +++ b/shared/domains/chelonia/time-sync.js @@ -0,0 +1,112 @@ +import sbp from '@sbp/sbp' + +// `wallBase` is the base used to calculate wall time (i.e., time elapsed as one +// would get from, e.g., looking a clock hanging from a wall). +// Although optimistically +// it has a default value to local time, it'll be updated to the server's time +// once `chelonia/private/startClockSync` is called +// From Wikipedia: 'walltime is the actual time taken from the start of a +// computer program to the end. In other words, it is the difference between +// the time at which a task finishes and the time at which the task started.' +let wallBase = Date.now() +// `monotonicBase` is the base used to calculate an offset to apply to `wallBase` +// to estimate the server's current wall time. +let monotonicBase = performance.now() +// `undefined` means the sync process has been stopped, `null` that the current +// request has finished +let resyncTimeout +let watchdog + +const syncServerTime = async function () { + // Get our current monotonic time + const newMonotonicBase = performance.now() + // Now, ask the server for the time + const time = await fetch(`${this.config.connectionURL}/time`, { signal: this.abortController.signal }) + const requestTimeElapsed = performance.now() + if (requestTimeElapsed - newMonotonicBase > 1000) { + throw new Error('Error fetching server time: request took too long') + } + // If the request didn't succeed, report it + if (!time.ok) throw new Error('Error fetching server time') + const serverTime = (new Date(await time.text())).valueOf() + // If the value could not be parsed, report that as well + if (Number.isNaN(serverTime)) throw new Error('Unable to parse server time') + // Adjust `wallBase` based on the elapsed request time. We can't know + // how long it took for the server to respond, but we can estimate that it's + // about half the time from the moment we made the request. + wallBase = serverTime + (requestTimeElapsed - newMonotonicBase) / 2 + monotonicBase = newMonotonicBase +} + +export default (sbp('sbp/selectors/register', { + 'chelonia/private/startClockSync': function () { + // Default re-sync every 5 minutes + const resync = (delay: number = 300000) => { + // If there's another time sync process in progress, don't do anything + if (resyncTimeout !== null) return + const timeout = setTimeout(() => { + // Get the server time + syncServerTime.call(this).then(() => { + // Mark the process as finished + if (resyncTimeout === timeout) resyncTimeout = null + // And then restart the listener + resync() + }).catch(e => { + // If there was an error, log it and possibly attempt again + if (resyncTimeout === timeout) { + // In this case, it was the current task that failed + resyncTimeout = null + console.error('Error re-syncing server time; will re-attempt in 5s', e) + // Call resync again, with a shorter delay + setTimeout(() => resync(0), 5000) + } else { + // If there is already another attempt, just log it + console.error('Error re-syncing server time; another attempt is in progress', e) + } + }) + }, delay) + resyncTimeout = timeout + } + + let wallLast = Date.now() + let monotonicLast = performance.now() + + // Watchdog to ensure our time doesn't drift. Periodically check for + // differences between the elapsed wall time and the elapsed monotonic + // time + watchdog = setInterval(() => { + const wallNow = Date.now() + const monotonicNow = performance.now() + const difference = Math.abs(Math.abs((wallNow - wallLast)) - Math.abs((monotonicNow - monotonicLast))) + // Tolerate up to a 10ms difference + if (difference > 10) { + clearTimeout(resyncTimeout) + resyncTimeout = null + resync(0) + } + wallLast = wallNow + monotonicLast = monotonicNow + }, 10000) + + // Start the sync process + resyncTimeout = null + resync(0) + }, + 'chelonia/private/stopClockSync': () => { + if (resyncTimeout !== undefined) { + clearInterval(watchdog) + clearTimeout(resyncTimeout) + watchdog = undefined + resyncTimeout = undefined + } + }, + // Get an estimate of the server's current time based on the time elapsed as + // measured locally (using a monotonic clock), which is used as an offset, and + // a previously retrieved server time. The time value is returned as a UNIX + // timestamp (seconds since 1 Jan 1970 00:00:00 UTC) + 'chelonia/time': function () { + const monotonicNow = performance.now() + const wallNow = wallBase - monotonicBase + monotonicNow + return (wallNow / 1e3 | 0) + } +}): string[]) diff --git a/shared/domains/chelonia/utils.js b/shared/domains/chelonia/utils.js index 6b880ce5c7..d445220f39 100644 --- a/shared/domains/chelonia/utils.js +++ b/shared/domains/chelonia/utils.js @@ -4,7 +4,7 @@ import { b64ToStr } from '~/shared/functions.js' import type { GIKey, GIKeyPurpose, GIKeyUpdate, GIOpActionUnencrypted, GIOpAtomic, GIOpKeyAdd, GIOpKeyUpdate, GIOpValue, ProtoGIOpActionUnencrypted } from './GIMessage.js' import { GIMessage } from './GIMessage.js' import { INVITE_STATUS } from './constants.js' -import { deserializeKey, serializeKey } from './crypto.js' +import { deserializeKey, serializeKey, sign, verifySignature } from './crypto.js' import type { EncryptedData } from './encryptedData.js' import { unwrapMaybeEncryptedData } from './encryptedData.js' import { ChelErrorWarning } from './errors.js' @@ -296,6 +296,22 @@ export const keyAdditionProcessor = function (hash: string, keys: (GIKey | Encry } } + // Is this a #sak + if (key.name === '#sak') { + if (data.encryptionKeyId) { + throw new Error('#sak may not be encrypted') + } + if (key.permissions && (!Array.isArray(key.permissions) || key.permissions.length !== 0)) { + throw new Error('#sak may not have permissions') + } + if (!Array.isArray(key.purpose) || key.purpose.length !== 1 || key.purpose[0] !== 'sak') { + throw new Error("#sak must have exactly one purpose: 'sak'") + } + if (key.ringLevel !== 0) { + throw new Error('#sak must have ringLevel 0') + } + } + // Is this a an invite key? If so, run logic for invite keys and invitation // accounting if (key.name.startsWith('#inviteKey-')) { @@ -705,3 +721,59 @@ export function eventsAfter (contractID: string, sinceHeight: number, limit?: nu } }) } + +export function buildShelterAuthorizationHeader (contractID: string, state?: Object): string { + if (!state) state = sbp(this.config.stateSelector)[contractID] + const SAKid = findKeyIdByName(state, '#sak') + if (!SAKid) { + throw new Error(`Missing #sak in ${contractID}`) + } + const SAK = this.transientSecretKeys[SAKid] + if (!SAK) { + throw new Error(`Missing secret #sak (${SAKid}) in ${contractID}`) + } + const deserializedSAK = typeof SAK === 'string' ? deserializeKey(SAK) : SAK + + const nonceBytes = new Uint8Array(15) + // $FlowFixMe[cannot-resolve-name] + globalThis.crypto.getRandomValues(nonceBytes) + + // . + const data = `${contractID} ${sbp('chelonia/time')}.${Buffer.from(nonceBytes).toString('base64')}` + + // shelter .. + return `shelter ${data}.${sign(deserializedSAK, data)}` +} + +export function verifyShelterAuthorizationHeader (authorization: string, rootState?: Object): string { + const regex = /^shelter (([a-zA-Z0-9]+) ([0-9]+)\.([a-zA-Z0-9+/=]{20}))\.([a-zA-Z0-9+/=]+)$/i + if (authorization.length > 1024) { + throw new Error('Authorization header too long') + } + const matches = authorization.match(regex) + if (!matches) { + throw new Error('Unable to parse shelter authorization header') + } + // TODO: Remember nonces and reject already used ones + const [, data, contractID, timestamp, , signature] = matches + if (Math.abs(parseInt(timestamp) - (Date.now() / 1e3 | 0)) > 2) { + throw new Error('Invalid signature time range') + } + if (!rootState) rootState = sbp('chelonia/rootState') + if (!has(rootState, contractID)) { + throw new Error(`Contract ${contractID} from shelter authorization header not found`) + } + const SAKid = findKeyIdByName(rootState[contractID], '#sak') + if (!SAKid) { + throw new Error(`Missing #sak in ${contractID}`) + } + const SAK = rootState[contractID]._vm.authorizedKeys[SAKid].data + if (!SAK) { + throw new Error(`Missing secret #sak (${SAKid}) in ${contractID}`) + } + const deserializedSAK = deserializeKey(SAK) + + verifySignature(deserializedSAK, data, signature) + + return contractID +} diff --git a/test/avatar-caching.test.js b/test/avatar-caching.test.js index 78cffb49ff..25d3a1ab36 100644 --- a/test/avatar-caching.test.js +++ b/test/avatar-caching.test.js @@ -1,11 +1,62 @@ /* eslint-env mocha */ import '~/shared/domains/chelonia/chelonia.js' +import sbp from '@sbp/sbp' +import manifests from '~/frontend/model/contracts/manifests.json' +import * as Common from '@common/common.js' +// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug +import { EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey } from '../shared/domains/chelonia/crypto.js' const assert = require('node:assert') // Remove this when dropping support for Node versions lower than v20. const File = require('buffer').File const { readFile } = require('node:fs/promises') +async function createIdentity (username) { + const CSK = keygen(EDWARDS25519SHA512BATCH) + const CSKid = keyId(CSK) + const CSKp = serializeKey(CSK, false) + const SAK = keygen(EDWARDS25519SHA512BATCH) + const SAKid = keyId(SAK) + const SAKp = serializeKey(SAK, false) + + sbp('chelonia/storeSecretKeys', + () => [CSK, SAK].map(key => ({ key, transient: true })) + ) + + // append random id to username to prevent conflict across runs + // when GI_PERSIST environment variable is defined + username = `${username}-${performance.now().toFixed(20).replace('.', '')}` + const msg = await sbp('chelonia/out/registerContract', { + contractName: 'gi.contracts/identity', + keys: [ + { + id: CSKid, + name: 'csk', + purpose: ['sig'], + ringLevel: 0, + permissions: '*', + allowedActions: '*', + data: CSKp + }, + { + id: SAKid, + name: '#sak', + purpose: ['sak'], + ringLevel: 0, + permissions: [], + allowedActions: [], + data: SAKp + } + ], + data: { + attributes: { username, email: 'test@email.example' } + }, + signingKeyId: CSKid, + namespaceRegistration: username + }) + return msg +} + describe('avatar file serving', function () { const apiURL = process.env.API_URL const manifestCid = 'z9brRu3VKCKeHshQtQfeLjY9j9kMdSbxMtr3nMPgKeGatsDwL2Mn' @@ -13,6 +64,22 @@ describe('avatar file serving', function () { let retPath = '' before('manually upload a test avatar to the file database', async () => { + await sbp('chelonia/configure', { + connectionURL: process.env.API_URL, + skipSideEffects: true, + contracts: { + ...manifests, + defaults: { + allowedSelectors: [ + 'chelonia/contract/sync', 'chelonia/contract/remove', + 'chelonia/queueInvocation' + ], + modules: { '@common/common.js': Common }, + preferSlim: true + } + } + }) + const owner = await createIdentity('avatar-caching-test') const fd = new FormData() fd.append( '0', @@ -32,6 +99,9 @@ describe('avatar file serving', function () { ) retPath = await fetch(`${apiURL}/file`, { method: 'POST', + headers: { + authorization: sbp('chelonia/shelterAuthorizationHeader', owner.contractID()) + }, body: fd }).then(r => r.text()) diff --git a/test/backend.test.js b/test/backend.test.js index 3d8f8cd48e..6bca5b3b22 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -124,9 +124,12 @@ describe('Full walkthrough', function () { const CSK = keygen(EDWARDS25519SHA512BATCH) const CSKid = keyId(CSK) const CSKp = serializeKey(CSK, false) + const SAK = keygen(EDWARDS25519SHA512BATCH) + const SAKid = keyId(SAK) + const SAKp = serializeKey(SAK, false) sbp('chelonia/storeSecretKeys', - () => [CSK].map(key => ({ key, transient: true })) + () => [CSK, SAK].map(key => ({ key, transient: true })) ) // append random id to username to prevent conflict across runs @@ -143,6 +146,15 @@ describe('Full walkthrough', function () { permissions: '*', allowedActions: '*', data: CSKp + }, + { + id: SAKid, + name: '#sak', + purpose: ['sak'], + ringLevel: 0, + permissions: [], + allowedActions: [], + data: SAKp } ], data: { @@ -225,7 +237,10 @@ describe('Full walkthrough', function () { } }, signingKeyId: CSKid, - hooks + hooks, + publishOptions: { + billableContractID: creator.contractID() + } }) } function createPaymentTo (from, to, amount, contractID, signingKeyId, currency = 'USD'): Promise { @@ -378,7 +393,15 @@ describe('Full walkthrough', function () { { type: 'application/vnd.shelter.manifest' } ) ) - await fetch(`${process.env.API_URL}/file`, { method: 'POST', body: form }) + await fetch(`${process.env.API_URL}/file`, + { + method: 'POST', + headers: { + authorization: sbp('chelonia/shelterAuthorizationHeader', users.alice.contractID()) + }, + body: form + } + ) .then(handleFetchResult('text')) .then(r => should(r).equal(manifestCid)) })