From 63c6642191c98691d0cc0df24b963351cec220c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Iv=C3=A1n=20Vieitez=20Parra?= <3857362+corrideat@users.noreply.github.com> Date: Fri, 29 Mar 2024 11:02:31 +0100 Subject: [PATCH] Accounting for files --- backend/routes.js | 103 ++++++++++++++---- frontend/controller/actions/chatroom.js | 23 +++- frontend/controller/actions/group.js | 12 +- frontend/controller/actions/identity.js | 76 +++++++------ frontend/utils/image.js | 4 +- frontend/views/components/AvatarUpload.vue | 2 +- .../views/containers/chatroom/ChatMain.vue | 2 +- shared/domains/chelonia/chelonia.js | 5 +- shared/domains/chelonia/files.js | 8 +- shared/domains/chelonia/internals.js | 2 +- test/avatar-caching.test.js | 70 ++++++++++++ test/backend.test.js | 14 ++- 12 files changed, 244 insertions(+), 77 deletions(-) diff --git a/backend/routes.js b/backend/routes.js index d92ba5ee19..d7d813821e 100644 --- a/backend/routes.js +++ b/backend/routes.js @@ -10,6 +10,8 @@ import path from 'path' import chalk from 'chalk' import './database.js' import { registrationKey, register, getChallenge, getContractSalt, updateContractSalt } from './zkppSalt.js' +import { webcrypto } from 'node:crypto' + const Boom = require('@hapi/boom') const Joi = require('@hapi/joi') const isCheloniaDashboard = process.env.IS_CHELONIA_DASHBOARD_DEV @@ -97,9 +99,9 @@ route.POST('/event', { const sizeKey = `_private_size_${deserializedHEAD.contractID}` // Use a queue to ensure atomic updates await sbp('okTurtles.eventQueue/queueEvent', sizeKey, async () => { - // Size is stored as a hex value - const existingSize = parseInt(await sbp('chelonia/db/get', sizeKey, 16)) || 0 - await sbp('chelonia/db/set', sizeKey, (existingSize + Buffer.byteLength(request.payload)).toString(16)) + // Size is stored as a decimal value + const existingSize = parseInt(await sbp('chelonia/db/get', sizeKey, 10)) || 0 + await sbp('chelonia/db/set', sizeKey, (existingSize + Buffer.byteLength(request.payload)).toString(10)) }) } catch (err) { console.error(err, chalk.bold.yellow(err.name)) @@ -233,7 +235,7 @@ 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: 'gi-auth', payload: { parse: true, output: 'stream', @@ -249,6 +251,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') @@ -300,6 +306,27 @@ route.POST('/file', { 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) + // Store attribution information + // Store the owner for the current resource + await sbp('chelonia/db/set', `_private_owner_${manifestHash}`, credentials.billableContractID) + const resourcesKey = `_private_resources_${credentials.billableContractID}` + // 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' : '') + manifestHash) + }) + // Store size information + // Size is stored as a decimal value + await sbp('chelonia/db/set', `_private_size_${manifestHash}`, (manifest.size + manifestMeta.payload.byteLength).toString(10)) + // Generate and store deletion token + 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_${manifestHash}`, deletionToken) return manifestHash } catch (err) { logger.error(err, 'POST /file', err.message) @@ -376,32 +403,60 @@ route.GET('/', {}, function (req, h) { return h.redirect(staticServeConfig.redirect) }) -route.POST('/zkpp/register/{contractID}', { +route.POST('/zkpp/register/{name}', { + auth: 'gi-auth', 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 @@ -409,31 +464,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(), @@ -443,22 +498,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(), @@ -469,16 +524,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/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 7d82df7628..3221bd2fc9 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -71,6 +71,11 @@ 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: { @@ -85,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, @@ -96,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, @@ -129,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 a5df082bb0..65318a6002 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -57,18 +57,18 @@ 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) @@ -180,8 +180,8 @@ export default (sbp('sbp/selectors/register', { { id: SAKid, name: '#sak', - purpose: ['sig'], - ringLevel: Number.MAX_SAFE_INTEGER, + purpose: ['sak'], + ringLevel: 0, permissions: [], allowedActions: [], meta: { diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index 62856c6103..8c6f406e7b 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) @@ -131,7 +106,7 @@ export default (sbp('sbp/selectors/register', { 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, @@ -213,8 +188,8 @@ export default (sbp('sbp/selectors/register', { { id: SAKid, name: '#sak', - purpose: ['sig'], - ringLevel: Number.MAX_SAFE_INTEGER, + purpose: ['sak'], + ringLevel: 0, permissions: [], allowedActions: [], meta: { @@ -225,14 +200,47 @@ export default (sbp('sbp/selectors/register', { 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 } + 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 })) diff --git a/frontend/utils/image.js b/frontend/utils/image.js index 0eaf4963c7..a7e08c59c8 100644 --- a/frontend/utils/image.js +++ b/frontend/utils/image.js @@ -22,8 +22,8 @@ export function imageDataURItoBlob (dataURI: string): Blob { return new Blob([ab], { type: imageType }) } -export const imageUpload = (imageFile: File): Promise => { +export const imageUpload = (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' }) + return sbp('chelonia/fileUpload', imageFile, { type: file.type, cipher: 'aes256gcm' }, params) } 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..1a003c911f 100644 --- a/frontend/views/containers/chatroom/ChatMain.vue +++ b/frontend/views/containers/chatroom/ChatMain.vue @@ -466,7 +466,7 @@ export default ({ const attachmentBlob = await objectURLtoBlob(url) const downloadData = await sbp('chelonia/fileUpload', attachmentBlob, { type: mimeType, cipher: 'aes256gcm' - }) + }, { billableContractID: contractID }) return { name, mimeType, downloadData } })) data = { ...data, attachments: attachmentsToSend } diff --git a/shared/domains/chelonia/chelonia.js b/shared/domains/chelonia/chelonia.js index a73da9841b..6a881b18ec 100644 --- a/shared/domains/chelonia/chelonia.js +++ b/shared/domains/chelonia/chelonia.js @@ -18,7 +18,7 @@ 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 { buildShelterAuthorizationHeader, eventsAfter, findForeignKeysByContractID, findKeyIdByName, findRevokedKeyIdsByName, findSuitableSecretKeyId, getContractIDfromKeyId } from './utils.js' // TODO: define ChelContractType for /defineContract @@ -502,6 +502,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 diff --git a/shared/domains/chelonia/files.js b/shared/domains/chelonia/files.js index 2ffbb285ac..94bd7483ee 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,7 +320,10 @@ 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' }) diff --git a/shared/domains/chelonia/internals.js b/shared/domains/chelonia/internals.js index 0ffb057676..02124f7b6e 100644 --- a/shared/domains/chelonia/internals.js +++ b/shared/domains/chelonia/internals.js @@ -503,7 +503,7 @@ export default (sbp('sbp/selectors/register', { signal: this.abortController.signal }) if (r.ok) { - hooks?.postpublish?.(entry) + await hooks?.postpublish?.(entry) return entry } try { 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 4424514112..6bca5b3b22 100644 --- a/test/backend.test.js +++ b/test/backend.test.js @@ -150,8 +150,8 @@ describe('Full walkthrough', function () { { id: SAKid, name: '#sak', - purpose: ['sig'], - ringLevel: Number.MAX_SAFE_INTEGER, + purpose: ['sak'], + ringLevel: 0, permissions: [], allowedActions: [], data: SAKp @@ -393,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)) })