openvibers/openvibe.network Commits
Network: project secrets — admins write a value once, services sign with it or receive it for a granted origin, nobody reads it back (ADR-056) (#197)
Changes
@@@ -27,6 +27,10 @@ INDEXNOW_KEY=SETUP_TOKEN=change-me-to-a-second-random-secretBOOTSTRAP_PROFILE=local-dev++# Project secrets (ADR-056, server/developer/secrets.js): 32 random bytes, base64 or hex (openssl rand -base64 32).++# Unset = project secrets are off (503 secrets.unavailable). Changing it makes every stored secret unreadable.++# NETWORK_SECRETS_KEY=++# Developer projects (docs/developer-projects.md)# Audiences that accept sandbox app tokens, comma-separated. Leave it UNSET for the code default# (openvibe.media,openvibe.events,openvibe.tools); set it to an empty value to accept none.@@@ -57,12 +57,12 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA## Capabilities--Implemented here (the service manifest's 20 `capabilities`, audience `openvibe.network`):++Implemented here (the service manifest's `capabilities`, audience `openvibe.network`):`identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`,`network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`,`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.integration.github.read`,`network.modules.read|write`, `network.notifications.push`, `network.operator.alert`,--`network.project.manage|read`, `network.staff.read` and `network.status.incident`. The grants other++`network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants otherservices hold are seeded in [server/identity/principals.js](server/identity/principals.js).Called elsewhere: Network is the issuer, so it signs its own `svc:network` tokens: `events.event.publish`@@@ -402,6 +402,26 @@ Provider secrets are read from the environment only (`/etc/openvibe/network.env`key is ignored and no admin form stores one. The database fallback and its copy-out script were retired on 2026-10-10,once production held no database copy of any secret.++## Project secrets (ADR-056)++++A project keeps its own secrets here, per environment, written once and used by name (`server/developer/secrets.js`).++These are the project's, not Network's provider secrets above.++++- **People** (Bearer user tokens on `/api/v1/projects/:project/secrets`):++ - admins and owners set a value (`PUT …/:env/:name { value }`; setting it again adds one to its version), delete it,++ and grant it to a service (`PUT …/:name/grants/:service { purposes, origins }`);++ - members and staff read metadata only (`network.project-secret@1`): name, version, a keyed fingerprint and grants.++ No route answers a value to a person.++- **Services** (`POST /internal/secrets/use`, `network.secret.use`): a service the secret is granted to either signs a++ message with it (`sign`: HMAC-SHA256, the value never leaves Network) or receives it for an https origin the grant++ lists (`reveal`). A missing secret, grant, purpose or origin, and an archived project, all answer 404++ `secret.not_found`. Actor holds the capability by default, to sign webhook deliveries with a task's `secret_ref`.++- **Storage:** AES-256-GCM under a key derived from `NETWORK_SECRETS_KEY`, with the row's identity as authenticated data,++ so a ciphertext moved onto another row does not open. Without the key the feature is off (503), never plaintext.++ Archiving a project, or deleting its owner's account with no heir, shreds its secrets.++- **Audit (`dev_audit`):** sets, deletions, grant changes, and the first use per secret, service, purpose and day.++ Values and messages are never logged.++---## Anonymous Usersnnew file mode 100644@@@ -0,0 +1,37 @@++-- phase: expand++-- ADR-056: project secrets, per project and environment, written once and used by name. The value is only ever stored++-- encrypted (AES-256-GCM under NETWORK_SECRETS_KEY; key_id names the key, the row's identity is the authenticated data);++-- archiving a project shreds it (ciphertext, iv and tag set to NULL), the metadata stays for the audit. A grant lets one++-- service use one secret for sign and/or reveal (reveal only for the origins it lists). Additive: an older release never++-- reads these tables.++CREATE TABLE IF NOT EXISTS dev_project_secrets (++ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),++ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),++ name text COLLATE "C" NOT NULL,++ version bigint NOT NULL,++ key_id text COLLATE "C" NOT NULL,++ iv text COLLATE "C",++ tag text COLLATE "C",++ ciphertext text COLLATE "C",++ fingerprint text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ updated_at text COLLATE "C" NOT NULL,++ updated_by text COLLATE "C" NOT NULL,++ PRIMARY KEY (project_id, environment, name)++);++++CREATE TABLE IF NOT EXISTS dev_project_secret_grants (++ project_id text COLLATE "C" NOT NULL,++ environment text COLLATE "C" NOT NULL,++ name text COLLATE "C" NOT NULL,++ service text COLLATE "C" NOT NULL,++ purposes text COLLATE "C" NOT NULL,++ origins text COLLATE "C" NOT NULL DEFAULT '[]',++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ last_used_at text COLLATE "C",++ last_audit_day text COLLATE "C",++ PRIMARY KEY (project_id, environment, name, service),++ FOREIGN KEY (project_id, environment, name) REFERENCES dev_project_secrets(project_id, environment, name) ON DELETE CASCADE++);@@@ -20,7 +20,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7","pg": "^8.23.0",@@@ -1320,9 +1320,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.176.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",-- "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",++ "version": "0.177.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",++ "integrity": "sha512-MargGRsErmonGrjiJWY3Nz1SgB16CBqx8iJZOcFeoQ8o1zZOUC2glagejMWjpB9QSiMN6jECG/SzUqdPmGxP9A==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -24,7 +24,7 @@"iovalkey": "^0.4.0","jsonwebtoken": "^9.0.0","multer": "^1.4.5-lts.1",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7","pg": "^8.23.0",@@@ -26,6 +26,12 @@ module.exports = {},++ // Project secrets (ADR-056, server/developer/secrets.js): 32 random bytes, base64 or hex. Unset = the feature is off++ // (503 secrets.unavailable); values are only ever stored encrypted under keys derived from it.++ secrets: {++ key: process.env.NETWORK_SECRETS_KEY || '',++ },++// Developer projects (server/developer, ADR-014)developer: {// Audiences that accept sandbox app tokens (comma list). Unset = the code default@@@ -56,6 +56,11 @@ const cache = require('openvibe-shared/cache-policy');* DELETE /:project/quotas/:capability staff* GET /:project/usage[?days=30&env=all] owner/admin or staff: usage per day, quotas and* errors from the services' rollups (usage.js)++ * GET /:project/secrets[?environment=] viewer+ and staff: metadata only, never a value (secrets.js, ADR-056)++ * PUT /:project/secrets/:env/:name { value } admin+ (not staff): set or replace; answers the metadata++ * DELETE /:project/secrets/:env/:name admin+++ * PUT /:project/secrets/:env/:name/grants/:service { purposes, origins } admin+: let a service sign or reveal++ * DELETE /:project/secrets/:env/:name/grants/:service admin+* GET /:project/audit[?before=&limit=] admin+ or staff* POST /:project/export-tokens { audience, env } owner or admin member (not staff as such):* a 5-minute read-only export token (tokens.js)@@@ -69,6 +74,7 @@ const confirmations = require('./confirmations');const policy = require('./policy');const tokens = require('./tokens');const usage = require('./usage');++const secrets = require('./secrets');/*** The base of a user API: problems with request ids, JSON bodies, private no-store, and Bearer Network user tokens@@@ -184,6 +190,13 @@ function router() {return await usage.summary(db, project.id, usage.parseQuery(req.query));}));++ // Project secrets (ADR-056): metadata for members, values written by admins and never read back.++ r.get('/:project/secrets', handle(async (db, a, req) => ({ secrets: await secrets.list(db, a, req.params.project, { environment: req.query.environment }) })));++ r.put('/:project/secrets/:env/:name', handle(async (db, a, req, o) => await secrets.put(db, a, req.params.project, req.params.env, req.params.name, req.body || {}, { ctx: o.ctx, config: req.app.locals.config })));++ r.delete('/:project/secrets/:env/:name', handle(async (db, a, req, o) => { await secrets.remove(db, a, req.params.project, req.params.env, req.params.name, o); }));++ r.put('/:project/secrets/:env/:name/grants/:service', handle(async (db, a, req, o) => await secrets.putGrant(db, a, req.params.project, req.params.env, req.params.name, req.params.service, req.body || {}, o)));++ r.delete('/:project/secrets/:env/:name/grants/:service', handle(async (db, a, req, o) => { await secrets.removeGrant(db, a, req.params.project, req.params.env, req.params.name, req.params.service, o); }));++r.get('/:project/audit', handle(async (db, a, req) => {const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });return await store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });nnew file mode 100644@@@ -0,0 +1,259 @@++'use strict';++/**++ * Project secrets (ADR-056): per project and environment, written once and used by name, never read back.++ *++ * people (project routes, Bearer user tokens; developer/routes.js)++ * list viewer+ and staff: metadata only (network.project-secret@1): name, version, fingerprint, grants++ * put admin+ (not staff as such): set or replace the value; the version goes up by one++ * remove admin+: the secret and its grants++ * putGrant admin+: let one service use it for sign and/or reveal (reveal names the https origins)++ * removeGrant admin+++ * services (POST /internal/secrets/use, network.secret.use; internal/routes.js)++ * use sign: HMAC-SHA256 of the message under the secret, the value never leaves; reveal: the value,++ * for an origin the grant lists. Anything refused is 404 secret.not_found, so a guess learns nothing.++ *++ * The value is stored only encrypted: AES-256-GCM under a key derived from NETWORK_SECRETS_KEY (32 bytes, base64 or hex)++ * with the row's identity (project, environment, name, version) as authenticated data, so a ciphertext copied onto++ * another row does not open. The fingerprint is a keyed hash (another key derived from the same secret): equal values++ * match, and it reveals nothing else. Without the key the feature is off (503 secrets.unavailable), never plaintext.++ * Archiving a project shreds its secrets (shredProject: the ciphertext, iv and tag are dropped).++ *++ * Audit (dev_audit): every value set, deletion and grant change, and the first use of a secret per service, purpose and++ * UTC day (a grant records when it was last used). Values and messages are never written anywhere else, logs included.++ */++const crypto = require('crypto');++const { validate } = require('openvibe-contracts');++const store = require('./store');++++const ENVS = ['sandbox', 'production'];++const PURPOSES = ['sign', 'reveal'];++const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;++const ORIGIN_RE = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;++const MAX_VALUE_BYTES = 8192;++const MAX_PER_ENV = 50;++const MAX_GRANTS = 20;++const MAX_ORIGINS = 10;++const KEY_ID = 'k1';++++const { DevError } = store;++const fail = (status, code, detail) => { throw new DevError(status, code, detail); };++const nowIso = () => new Date().toISOString();++const notFound = () => fail(404, 'secret.not_found', 'no such secret for this caller');++++/** The keys, derived once per configured secret: { id, enc, fp }, or null when NETWORK_SECRETS_KEY is unset or malformed. */++const keyCache = new Map();++function keyring(config) {++ const raw = String((config && config.secrets && config.secrets.key) || '').trim();++ if (!raw) return null;++ if (keyCache.has(raw)) return keyCache.get(raw);++ const bytes = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');++ const kr = bytes.length === 32 ? {++ id: KEY_ID,++ enc: Buffer.from(crypto.hkdfSync('sha256', bytes, Buffer.alloc(0), 'openvibe-network project-secret encryption', 32)),++ fp: Buffer.from(crypto.hkdfSync('sha256', bytes, Buffer.alloc(0), 'openvibe-network project-secret fingerprint', 32)),++ } : null;++ keyCache.set(raw, kr);++ return kr;++}++function requireKeys(config) {++ const kr = keyring(config);++ if (!kr) fail(503, 'secrets.unavailable', 'project secrets are not configured on this Network (NETWORK_SECRETS_KEY)');++ return kr;++}++++const aadOf = (row) => Buffer.from(`${row.project_id}\n${row.environment}\n${row.name}\n${row.version}`, 'utf8');++function seal(kr, row, value) {++ const iv = crypto.randomBytes(12);++ const c = crypto.createCipheriv('aes-256-gcm', kr.enc, iv);++ c.setAAD(aadOf(row));++ const ciphertext = Buffer.concat([c.update(value, 'utf8'), c.final()]);++ return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };++}++function open(kr, row) {++ if (!row.ciphertext || row.key_id !== kr.id) return null;++ const d = crypto.createDecipheriv('aes-256-gcm', kr.enc, Buffer.from(row.iv, 'base64'));++ d.setAAD(aadOf(row));++ d.setAuthTag(Buffer.from(row.tag, 'base64'));++ return Buffer.concat([d.update(Buffer.from(row.ciphertext, 'base64')), d.final()]).toString('utf8');++}++const fingerprintOf = (kr, value) => crypto.createHmac('sha256', kr.fp).update(value, 'utf8').digest('hex').slice(0, 12);++++function cleanEnv(v) { if (!ENVS.includes(v)) fail(422, 'secret.invalid', 'environment is sandbox or production'); return v; }++function cleanName(v) { if (!NAME_RE.test(String(v || ''))) fail(422, 'secret.invalid', 'a name is 1-64 characters: a lowercase letter, then lowercase letters, digits, _ or -'); return String(v); }++function cleanValue(v) {++ if (typeof v !== 'string' || !v.length) fail(422, 'secret.invalid', 'value must be a non-empty string');++ if (Buffer.byteLength(v, 'utf8') > MAX_VALUE_BYTES) fail(422, 'secret.too_large', `a value is at most ${MAX_VALUE_BYTES} bytes`);++ if (/\u0000/.test(v)) fail(422, 'secret.invalid', 'value must not contain NUL');++ return v;++}++const subjectOf = (label) => String(label || '').replace(/^user:/, '');++++function grantView(g) {++ return { service: g.service, purposes: JSON.parse(g.purposes), origins: JSON.parse(g.origins || '[]'), created_at: g.created_at, created_by: subjectOf(g.created_by), last_used_at: g.last_used_at || null };++}++function secretView(row, grants) {++ return {++ project_id: row.project_id, environment: row.environment, name: row.name, version: Number(row.version), fingerprint: row.fingerprint,++ grants: grants.map(grantView), created_at: row.created_at, created_by: subjectOf(row.created_by), updated_at: row.updated_at, updated_by: subjectOf(row.updated_by),++ };++}++++async function grantsOf(db, row) {++ return await db.prepare('SELECT * FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? ORDER BY service')++ .all(row.project_id, row.environment, row.name);++}++async function getRow(db, projectId, environment, name) {++ return await db.prepare('SELECT * FROM dev_project_secrets WHERE project_id = ? AND environment = ? AND name = ?').get(projectId, environment, name);++}++async function mustRow(db, projectId, environment, name) {++ const row = await getRow(db, projectId, environment, name);++ if (!row) fail(404, 'secret.not_found', 'no such secret');++ return row;++}++++/** Members (viewer+) and staff: the project's secrets, metadata only; ?environment= narrows it. */++async function list(db, actor, projectId, { environment } = {}) {++ const { project } = await store.access(db, actor, projectId, { allowArchived: true });++ const env = environment ? cleanEnv(environment) : null;++ const rows = env++ ? await db.prepare('SELECT * FROM dev_project_secrets WHERE project_id = ? AND environment = ? ORDER BY environment, name').all(project.id, env)++ : await db.prepare('SELECT * FROM dev_project_secrets WHERE project_id = ? ORDER BY environment, name').all(project.id);++ const out = [];++ for (const r of rows) out.push(secretView(r, await grantsOf(db, r)));++ return out;++}++++/** Admin+: set or replace a value (the version goes up by one). Answers the metadata, never the value. */++async function put(db, actor, projectId, environment, name, body, { ctx, config }) {++ const kr = requireKeys(config);++ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false });++ const env = cleanEnv(environment);++ const n = cleanName(name);++ const value = cleanValue(body && body.value);++ let row;++ await db.tx(async () => {++ await store.lockProject(db, project.id);++ const existing = await getRow(db, project.id, env, n);++ if (!existing) {++ const { c } = await db.prepare('SELECT COUNT(*) AS c FROM dev_project_secrets WHERE project_id = ? AND environment = ?').get(project.id, env);++ if (Number(c) >= MAX_PER_ENV) fail(409, 'secret.limit', `a project environment holds at most ${MAX_PER_ENV} secrets`);++ }++ const at = nowIso();++ const version = existing ? Number(existing.version) + 1 : 1;++ const ident = { project_id: project.id, environment: env, name: n, version };++ const sealed = seal(kr, ident, value);++ const fingerprint = fingerprintOf(kr, value);++ if (existing) {++ await db.prepare('UPDATE dev_project_secrets SET version = ?, key_id = ?, iv = ?, tag = ?, ciphertext = ?, fingerprint = ?, updated_at = ?, updated_by = ? WHERE project_id = ? AND environment = ? AND name = ?')++ .run(version, kr.id, sealed.iv, sealed.tag, sealed.ciphertext, fingerprint, at, actor.label, project.id, env, n);++ } else {++ await db.prepare(`INSERT INTO dev_project_secrets (project_id, environment, name, version, key_id, iv, tag, ciphertext, fingerprint, created_at, created_by, updated_at, updated_by)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(project.id, env, n, version, kr.id, sealed.iv, sealed.tag, sealed.ciphertext, fingerprint, at, actor.label, at, actor.label);++ }++ await store.audit(db, { projectId: project.id, actor: actor.label, action: existing ? 'secret.replaced' : 'secret.created', target: `${env}/${n}`, detail: { environment: env, name: n, version, fingerprint }, ctx });++ row = await getRow(db, project.id, env, n);++ });++ return secretView(row, await grantsOf(db, row));++}++++/** Admin+: delete a secret and its grants. */++async function remove(db, actor, projectId, environment, name, { ctx }) {++ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });++ const env = cleanEnv(environment);++ const n = cleanName(name);++ await db.tx(async () => {++ const row = await mustRow(db, project.id, env, n);++ await db.prepare('DELETE FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ?').run(project.id, env, n);++ await db.prepare('DELETE FROM dev_project_secrets WHERE project_id = ? AND environment = ? AND name = ?').run(project.id, env, n);++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'secret.deleted', target: `${env}/${n}`, detail: { environment: env, name: n, version: Number(row.version) }, ctx });++ });++}++++/** Admin+: grant one service the secret for sign and/or reveal; reveal needs the https origins it may be sent to. */++async function putGrant(db, actor, projectId, environment, name, service, body, { ctx }) {++ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false });++ const env = cleanEnv(environment);++ const n = cleanName(name);++ const svc = String(service || '');++ if (!SERVICE_RE.test(svc) || svc === 'network') fail(422, 'secret.invalid', 'service is an OpenVibe service id (svc:<id>), not network');++ const purposes = Array.isArray(body && body.purposes) ? [...new Set(body.purposes.map(String))].sort() : [];++ if (!purposes.length || purposes.some((p) => !PURPOSES.includes(p))) fail(422, 'secret.invalid', 'purposes is a non-empty list of sign and reveal');++ const origins = Array.isArray(body && body.origins) ? [...new Set(body.origins.map((o) => String(o).toLowerCase().replace(/\/+$/, '')))].sort() : [];++ if (origins.length > MAX_ORIGINS || origins.some((o) => !ORIGIN_RE.test(o))) fail(422, 'secret.invalid', `origins are at most ${MAX_ORIGINS} https origins (scheme, host and an optional port)`);++ if (purposes.includes('reveal') && !origins.length) fail(422, 'secret.invalid', 'reveal names the origins the value may be sent to');++ if (!purposes.includes('reveal') && origins.length) fail(422, 'secret.invalid', 'origins apply to reveal only');++ let out;++ await db.tx(async () => {++ const row = await mustRow(db, project.id, env, n);++ const existing = await db.prepare('SELECT * FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? AND service = ?').get(project.id, env, n, svc);++ if (!existing) {++ const { c } = await db.prepare('SELECT COUNT(*) AS c FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ?').get(project.id, env, n);++ if (Number(c) >= MAX_GRANTS) fail(409, 'secret.limit', `a secret has at most ${MAX_GRANTS} grants`);++ await db.prepare('INSERT INTO dev_project_secret_grants (project_id, environment, name, service, purposes, origins, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')++ .run(project.id, env, n, svc, JSON.stringify(purposes), JSON.stringify(origins), nowIso(), actor.label);++ } else {++ await db.prepare('UPDATE dev_project_secret_grants SET purposes = ?, origins = ? WHERE project_id = ? AND environment = ? AND name = ? AND service = ?')++ .run(JSON.stringify(purposes), JSON.stringify(origins), project.id, env, n, svc);++ }++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'secret.granted', target: `${env}/${n}`, detail: { environment: env, name: n, service: svc, purposes, origins }, ctx });++ out = secretView(row, await grantsOf(db, row));++ });++ return out;++}++++/** Admin+: take a service's grant away. */++async function removeGrant(db, actor, projectId, environment, name, service, { ctx }) {++ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });++ const env = cleanEnv(environment);++ const n = cleanName(name);++ await db.tx(async () => {++ await mustRow(db, project.id, env, n);++ const r = await db.prepare('DELETE FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? AND service = ?').run(project.id, env, n, String(service || ''));++ if (!r.changes) fail(404, 'secret.grant_not_found', 'that service holds no grant on this secret');++ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'secret.grant_revoked', target: `${env}/${n}`, detail: { environment: env, name: n, service: String(service) }, ctx });++ });++}++++/**++ * A service uses a secret (network.secret.use): `principal` is the verified token's principal (sub svc:<id>), `body` a++ * network.secret-use-request@1. → network.secret-use-result@1. Every refusal past validation is 404 secret.not_found.++ */++async function use(db, principal, body, { ctx, config }) {++ const v = validate('network.secret-use-request@1', body);++ if (!v.valid) fail(400, 'request.invalid', v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 300));++ const kr = requireKeys(config);++ const service = String((principal && principal.sub) || '').replace(/^svc:/, '');++ if (!SERVICE_RE.test(service)) notFound();++ const project = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(body.project_id);++ if (!project || project.archived_at) notFound();++ const row = await getRow(db, project.id, body.environment, body.name);++ if (!row || !row.ciphertext) notFound();++ const grant = await db.prepare('SELECT * FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? AND service = ?').get(project.id, body.environment, body.name, service);++ if (!grant || !JSON.parse(grant.purposes).includes(body.purpose)) notFound();++ if (body.purpose === 'reveal' && !JSON.parse(grant.origins || '[]').includes(String(body.origin).toLowerCase())) notFound();++ let value;++ try { value = open(kr, row); } catch { value = null; }++ if (value == null) fail(503, 'secrets.unavailable', 'this secret cannot be opened with the configured key');++ const at = nowIso();++ const day = at.slice(0, 10);++ await db.prepare('UPDATE dev_project_secret_grants SET last_used_at = ?, last_audit_day = ? WHERE project_id = ? AND environment = ? AND name = ? AND service = ?')++ .run(at, day, project.id, body.environment, body.name, service);++ if (grant.last_audit_day !== day) {++ await store.audit(db, { projectId: project.id, actor: `service:${service}`, action: 'secret.used', target: `${body.environment}/${body.name}`,++ detail: { environment: body.environment, name: body.name, service, purpose: body.purpose, version: Number(row.version), ...(body.origin ? { origin: body.origin } : {}) }, ctx });++ }++ const result = { name: row.name, version: Number(row.version), purpose: body.purpose };++ if (body.purpose === 'sign') result.signature = crypto.createHmac('sha256', value).update(body.message, 'utf8').digest('hex');++ else result.value = value;++ return result;++}++++/** Archiving a project (or deleting its owner's account) shreds its secrets: the ciphertexts go, the metadata stays. → count */++async function shredProject(db, projectId) {++ const r = await db.prepare('UPDATE dev_project_secrets SET ciphertext = NULL, iv = NULL, tag = NULL WHERE project_id = ? AND ciphertext IS NOT NULL').run(projectId);++ return r.changes || 0;++}++++module.exports = { list, put, remove, putGrant, removeGrant, use, shredProject, keyring, fingerprintOf, MAX_VALUE_BYTES, MAX_PER_ENV };@@@ -246,7 +246,9 @@ async function archiveProject(db, actor, projectId, { ctx, settings }) {await require('./agents').revokeWhere(db, 'project_id = ?', [project.id], { actor: actor.label, reason: 'project_archived', ctx });for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);-- await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });++ // ADR-056: an archived project's secrets are shredded (the ciphertexts go; names and versions stay in the audit).++ const shredded = await require('./secrets').shredProject(db, project.id);++ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, detail: shredded ? { secrets_shredded: shredded } : undefined, ctx });});return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);}@@@ -360,6 +360,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {}if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, 'project_id = ?', [p.id], { actor: 'system:network', reason: 'project_archived' }));await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);++ if (await tableExists(db, 'dev_project_secrets')) count('secrets_shredded', await require('../developer/secrets').shredProject(db, p.id));count('projects_archived', 1);}await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');@@@ -440,6 +440,9 @@ const DEFAULT_GRANTS = [// check, Watch#54). Recording usage only.['space', 'billing.usage.record', 'openvibe.billing', []],['watch', 'billing.usage.record', 'openvibe.billing', []],++ // Project secrets (ADR-056): Actor signs a task's webhook deliveries with the project secret its secret_ref names,++ // when the project granted that secret to actor for sign. The capability is the door; each secret's grant decides.++ ['actor', 'network.secret.use', SELF_AUDIENCE, []],];// Grants withdrawn by decision; applied at every boot so an old default can't come back.@@@ -52,6 +52,22 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolvrouter.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);router.use('/identity', require('../identity/internal-routes'));++// ── Project secrets (ADR-056) ────────────────────────────────++// A service the project granted a secret to signs with it (the value never leaves) or receives it for an origin the++// grant lists. network.secret.use is the door; the secret's own grant decides, and anything refused is 404.++router.post('/secrets/use', principals.guard('network.secret.use'), express.json({ limit: '1100kb' }), async (req, res) => {++ const { http } = require('openvibe-contracts');++ const secrets = require('../developer/secrets');++ res.set('Cache-Control', 'no-store');++ try {++ res.json(await secrets.use(getDb(req), req.principal, req.body || {}, { ctx: req.ov, config: getConfig(req) }));++ } catch (err) {++ if (err && err.status && err.code) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });++ console.error('[secrets] use failed:', err && err.message ? err.message.slice(0, 200) : err);++ return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });++ }++});++// ── Sync Linked Account ──────────────────────────────────────// When a user connects their OpenVibe.Live or OpenVibe.Games account,// the service reports the link here.@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++// Project secrets (ADR-056, server/developer/secrets.js): admins write a value once and nobody reads it back; members++// and staff see metadata (version, a keyed fingerprint, grants); a service uses a secret only through++// POST /internal/secrets/use with network.secret.use AND the project's grant of that secret to it, to sign (HMAC, the++// value never leaves) or to reveal it for a listed origin; every refusal past validation is the same 404; values are++// stored only encrypted, bound to their row; archiving shreds them; nothing secret reaches the audit.++// node test/project-secrets.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const jwt = require('jsonwebtoken');++const { serviceAuth, validate } = require('openvibe-contracts');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++++(async () => {++ const quiet = console.log;++ console.log = () => {};++ const db = getDb();++ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES++ (10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();++ const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));++++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const ISSUER = 'https://openvibe.network';++ const KEY = crypto.randomBytes(32).toString('base64');++ const app = express();++ app.locals.db = db;++ app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '' }, secrets: { key: KEY } };++ app.locals.privateKey = keys.privateKey;++ app.locals.publicKey = keys.publicKey;++ app.use('/api/v1/projects', require('../server/developer/routes').router());++ app.use('/internal', require('../server/internal/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });++ const T = { owner: userToken(10), admin: userToken(11), dev: userToken(12), stranger: userToken(13), staff: userToken(14) };++ const now = Math.floor(Date.now() / 1000);++ const svc = (id, cap = ['network.secret.use']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${id}`, actor_type: 'service', aud: ['openvibe.network'], cap, iat: now, exp: now + 300, jti: `tok_${id}_${crypto.randomBytes(4).toString('hex')}` }, keys.privateKey);++ const api = async (who, method, p, body) => {++ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${T[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });++ const text = await r.text();++ return { status: r.status, body: text ? JSON.parse(text) : null, text };++ };++ const use = async (token, body) => {++ const r = await fetch(`${base}/internal/secrets/use`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });++ const text = await r.text();++ return { status: r.status, body: text ? JSON.parse(text) : null, cache: r.headers.get('cache-control') };++ };++ let failures = 0;++ const check = async (name, fn) => { try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); } };++++ try {++ const P = (await api('owner', 'POST', '/', { name: 'Secrets probe' })).body.id;++ assert.ok(/^prj_/.test(P), 'project created');++ assert.strictEqual((await api('owner', 'POST', `/${P}/members`, { subject_id: await sid(11), role: 'admin' })).status, 201);++ assert.strictEqual((await api('owner', 'POST', `/${P}/members`, { subject_id: await sid(12), role: 'developer' })).status, 201);++ const VALUE = `whk_${crypto.randomBytes(24).toString('hex')}`;++ const VALUE2 = `whk_${crypto.randomBytes(24).toString('hex')}`;++ const TOKEN = 't'.repeat(48);++++ await check('an admin sets a value; the answer is metadata only, and members and staff read it, strangers do not', async () => {++ const r = await api('admin', 'PUT', `/${P}/secrets/production/webhook-signing`, { value: VALUE });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(!r.text.includes(VALUE), 'the value is never answered');++ assert.strictEqual(r.body.version, 1);++ assert.match(r.body.fingerprint, /^[0-9a-f]{12}$/);++ assert.strictEqual(validate('network.project-secret@1', r.body).valid, true, JSON.stringify(validate('network.project-secret@1', r.body).errors));++ const list = await api('dev', 'GET', `/${P}/secrets`);++ assert.strictEqual(list.status, 200);++ assert.deepStrictEqual(list.body.secrets.map((s) => [s.environment, s.name, s.version]), [['production', 'webhook-signing', 1]]);++ assert.ok(!list.text.includes(VALUE));++ assert.strictEqual((await api('staff', 'GET', `/${P}/secrets`)).status, 200, 'staff read the metadata');++ assert.strictEqual((await api('stranger', 'GET', `/${P}/secrets`)).status, 404, 'a stranger learns nothing');++ });++++ await check('only admins and owners write: a developer and staff are refused; bad names, environments and sizes too', async () => {++ assert.strictEqual((await api('dev', 'PUT', `/${P}/secrets/production/x`, { value: 'v' })).status, 403);++ assert.strictEqual((await api('staff', 'PUT', `/${P}/secrets/production/x`, { value: 'v' })).status, 403, 'staff read metadata but never write a value');++ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/production/Upper`, { value: 'v' })).status, 422);++ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/staging/x`, { value: 'v' })).status, 422);++ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/production/x`, { value: '' })).status, 422);++ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/production/x`, { value: 'v'.repeat(8193) })).body.code, 'secret.too_large');++ });++++ await check('stored only encrypted: no plaintext in the table nor the audit', async () => {++ const row = await db.prepare("SELECT * FROM dev_project_secrets WHERE project_id = ? AND name = 'webhook-signing'").get(P);++ assert.ok(row.ciphertext && row.iv && row.tag, 'sealed');++ const dump = JSON.stringify(await db.prepare('SELECT * FROM dev_project_secrets').all());++ assert.ok(!dump.includes(VALUE) && !dump.includes(Buffer.from(VALUE).toString('base64')), 'no plaintext, nor its base64');++ const audit = JSON.stringify(await db.prepare('SELECT * FROM dev_audit WHERE project_id = ?').all(P));++ assert.ok(!audit.includes(VALUE), 'the audit holds no value');++ assert.ok(audit.includes('secret.created'), 'the set is audited');++ });++++ await check('a service without the project\'s grant gets 404, as for a secret that does not exist', async () => {++ const sign = { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message: '1760504400.{"state":"succeeded"}' };++ const a = await use(svc('actor'), sign);++ assert.deepStrictEqual([a.status, a.body.code], [404, 'secret.not_found']);++ const b = await use(svc('actor'), { ...sign, name: 'no-such-secret' });++ assert.deepStrictEqual([b.status, b.body.code], [404, 'secret.not_found'], 'indistinguishable');++ assert.strictEqual((await use(svc('actor', ['identity.subject.resolve']), sign)).status, 403, 'the capability is the door');++ });++++ await check('granted for sign: the signature is HMAC-SHA256 under the value, which never leaves; reveal stays refused', async () => {++ const g = await api('admin', 'PUT', `/${P}/secrets/production/webhook-signing/grants/actor`, { purposes: ['sign'] });++ assert.strictEqual(g.status, 200, g.text);++ assert.deepStrictEqual(g.body.grants.map((x) => [x.service, x.purposes, x.origins]), [['actor', ['sign'], []]]);++ const message = '1760504400.{"state":"succeeded"}';++ const r = await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.strictEqual(r.cache, 'no-store');++ assert.strictEqual(r.body.signature, crypto.createHmac('sha256', VALUE).update(message).digest('hex'));++ assert.ok(!JSON.stringify(r.body).includes(VALUE));++ assert.strictEqual(validate('network.secret-use-result@1', r.body).valid, true);++ const rev = await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'reveal', origin: 'https://hooks.example.com' });++ assert.strictEqual(rev.status, 404, 'sign does not imply reveal');++ assert.strictEqual((await use(svc('watch'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message })).status, 404, 'another service');++ assert.strictEqual((await use(svc('actor'), { project_id: P, environment: 'sandbox', name: 'webhook-signing', purpose: 'sign', message })).status, 404, 'another environment');++ assert.ok((await api('dev', 'GET', `/${P}/secrets`)).body.secrets[0].grants[0].last_used_at, 'the grant records its use');++ });++++ await check('reveal only for an origin the grant lists; a grant for reveal must name origins', async () => {++ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token`, { value: TOKEN })).status, 200);++ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token/grants/watch`, { purposes: ['reveal'] })).status, 422, 'no origins');++ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token/grants/watch`, { purposes: ['reveal'], origins: ['http://api.example.com'] })).status, 422, 'https only');++ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token/grants/watch`, { purposes: ['reveal'], origins: ['https://API.example.com/'] })).status, 200);++ const ok = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://api.example.com' });++ assert.deepStrictEqual([ok.status, ok.body.value], [200, TOKEN]);++ const other = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://evil.example.com' });++ assert.strictEqual(other.status, 404, 'an origin the grant does not list');++ const mixed = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://api.example.com', message: 'x' });++ assert.strictEqual(mixed.status, 400, 'the request schema refuses a reveal with a message');++ // A ciphertext copied onto another row does not open there: the row's identity is the authenticated data.++ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/moved`, { value: 'other' })).status, 200);++ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/moved/grants/watch`, { purposes: ['reveal'], origins: ['https://api.example.com'] })).status, 200);++ const src = await db.prepare("SELECT iv, tag, ciphertext FROM dev_project_secrets WHERE project_id = ? AND name = 'api_token'").get(P);++ await db.prepare("UPDATE dev_project_secrets SET iv = ?, tag = ?, ciphertext = ? WHERE project_id = ? AND name = 'moved'").run(src.iv, src.tag, src.ciphertext, P);++ const moved = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'moved', purpose: 'reveal', origin: 'https://api.example.com' });++ assert.deepStrictEqual([moved.status, moved.body.code], [503, 'secrets.unavailable'], 'a moved ciphertext is refused, never revealed');++ assert.strictEqual((await api('admin', 'DELETE', `/${P}/secrets/sandbox/moved`)).status, 204);++ });++++ await check('replacing a value bumps the version and the fingerprint; signatures follow the new value', async () => {++ const before = (await api('dev', 'GET', `/${P}/secrets?environment=production`)).body.secrets[0];++ const r = await api('owner', 'PUT', `/${P}/secrets/production/webhook-signing`, { value: VALUE2 });++ assert.strictEqual(r.body.version, 2);++ assert.notStrictEqual(r.body.fingerprint, before.fingerprint);++ assert.deepStrictEqual(r.body.grants.map((x) => x.service), ['actor'], 'grants survive a replacement');++ const s = await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message: 'm' });++ assert.deepStrictEqual([s.body.version, s.body.signature], [2, crypto.createHmac('sha256', VALUE2).update('m').digest('hex')]);++ const same = await api('owner', 'PUT', `/${P}/secrets/sandbox/again`, { value: VALUE2 });++ assert.strictEqual(same.body.fingerprint, r.body.fingerprint, 'equal values, equal fingerprints');++ });++++ await check('revoking the grant stops the service; deleting the secret removes it and its grants', async () => {++ assert.strictEqual((await api('admin', 'DELETE', `/${P}/secrets/production/webhook-signing/grants/actor`)).status, 204);++ assert.strictEqual((await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message: 'm' })).status, 404);++ assert.strictEqual((await api('admin', 'DELETE', `/${P}/secrets/sandbox/again`)).status, 204);++ assert.deepStrictEqual((await api('dev', 'GET', `/${P}/secrets`)).body.secrets.map((s) => s.name).sort(), ['api_token', 'webhook-signing']);++ });++++ await check('archiving the project shreds its secrets: no ciphertext remains and no service can use them', async () => {++ assert.strictEqual((await api('owner', 'POST', `/${P}/archive`)).status, 200);++ const rows = await db.prepare('SELECT ciphertext, iv, tag FROM dev_project_secrets WHERE project_id = ?').all(P);++ assert.ok(rows.length && rows.every((r) => r.ciphertext === null && r.iv === null && r.tag === null), 'shredded');++ assert.strictEqual((await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://api.example.com' })).status, 404);++ assert.strictEqual((await api('dev', 'GET', `/${P}/secrets`)).status, 200, 'the metadata stays readable');++ });++++ await check('without NETWORK_SECRETS_KEY the feature is off (503), never plaintext', async () => {++ const P2 = (await api('owner', 'POST', '/', { name: 'No key' })).body.id;++ app.locals.config.secrets.key = '';++ const r = await api('owner', 'PUT', `/${P2}/secrets/production/x`, { value: 'v' });++ app.locals.config.secrets.key = KEY;++ assert.deepStrictEqual([r.status, r.body.code], [503, 'secrets.unavailable']);++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS c FROM dev_project_secrets WHERE project_id = ?').get(P2)).c * 1, 0);++ });++ } finally {++ server.close();++ console.log = quiet;++ }++ console.log(failures ? `\n${failures} failed` : '\nproject secrets: all checks passed');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -27,6 +27,10 @@ INDEXNOW_KEY=
@@ -27,6 +27,10 @@ INDEXNOW_KEY=
SETUP_TOKEN=change-me-to-a-second-random-secret
SETUP_TOKEN=change-me-to-a-second-random-secret
BOOTSTRAP_PROFILE=local-dev
BOOTSTRAP_PROFILE=local-dev
+# Project secrets (ADR-056, server/developer/secrets.js): 32 random bytes, base64 or hex (openssl rand -base64 32).
+# Unset = project secrets are off (503 secrets.unavailable). Changing it makes every stored secret unreadable.
+# NETWORK_SECRETS_KEY=
+
# Developer projects (docs/developer-projects.md)
# Developer projects (docs/developer-projects.md)
# Audiences that accept sandbox app tokens, comma-separated. Leave it UNSET for the code default
# Audiences that accept sandbox app tokens, comma-separated. Leave it UNSET for the code default
# (openvibe.media,openvibe.events,openvibe.tools); set it to an empty value to accept none.
# (openvibe.media,openvibe.events,openvibe.tools); set it to an empty value to accept none.
@@ -57,12 +57,12 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -57,12 +57,12 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Capabilities
## Capabilities
-Implemented here (the service manifest's 20 `capabilities`, audience `openvibe.network`):
+Implemented here (the service manifest's `capabilities`, audience `openvibe.network`):
`identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`,
`identity.subject.resolve`, `mods.grant.manage`, `network.account.deletion.confirm`,
`network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`,
`network.account.export.contribute`, `network.analytics.creator.read`, `network.blocks.read`,
`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.integration.github.read`,
`network.coins.credit|debit|transfer`, `network.follows.read|write`, `network.integration.github.read`,
`network.modules.read|write`, `network.notifications.push`, `network.operator.alert`,
`network.modules.read|write`, `network.notifications.push`, `network.operator.alert`,
-`network.project.manage|read`, `network.staff.read` and `network.status.incident`. The grants other
+`network.project.manage|read`, `network.secret.use`, `network.staff.read` and `network.status.incident`. The grants other
services hold are seeded in [server/identity/principals.js](server/identity/principals.js).
services hold are seeded in [server/identity/principals.js](server/identity/principals.js).
Called elsewhere: Network is the issuer, so it signs its own `svc:network` tokens: `events.event.publish`
Called elsewhere: Network is the issuer, so it signs its own `svc:network` tokens: `events.event.publish`
@@ -402,6 +402,26 @@ Provider secrets are read from the environment only (`/etc/openvibe/network.env`
@@ -402,6 +402,26 @@ Provider secrets are read from the environment only (`/etc/openvibe/network.env`
key is ignored and no admin form stores one. The database fallback and its copy-out script were retired on 2026-10-10,
key is ignored and no admin form stores one. The database fallback and its copy-out script were retired on 2026-10-10,
once production held no database copy of any secret.
once production held no database copy of any secret.
+## Project secrets (ADR-056)
+
+A project keeps its own secrets here, per environment, written once and used by name (`server/developer/secrets.js`).
+These are the project's, not Network's provider secrets above.
+
+- **People** (Bearer user tokens on `/api/v1/projects/:project/secrets`):
+ - admins and owners set a value (`PUT …/:env/:name { value }`; setting it again adds one to its version), delete it,
+ and grant it to a service (`PUT …/:name/grants/:service { purposes, origins }`);
+ - members and staff read metadata only (`network.project-secret@1`): name, version, a keyed fingerprint and grants.
+ No route answers a value to a person.
+- **Services** (`POST /internal/secrets/use`, `network.secret.use`): a service the secret is granted to either signs a
+ message with it (`sign`: HMAC-SHA256, the value never leaves Network) or receives it for an https origin the grant
+ lists (`reveal`). A missing secret, grant, purpose or origin, and an archived project, all answer 404
+ `secret.not_found`. Actor holds the capability by default, to sign webhook deliveries with a task's `secret_ref`.
+- **Storage:** AES-256-GCM under a key derived from `NETWORK_SECRETS_KEY`, with the row's identity as authenticated data,
+ so a ciphertext moved onto another row does not open. Without the key the feature is off (503), never plaintext.
+ Archiving a project, or deleting its owner's account with no heir, shreds its secrets.
+- **Audit (`dev_audit`):** sets, deletions, grant changes, and the first use per secret, service, purpose and day.
+ Values and messages are never logged.
+
---
---
## Anonymous Users
## Anonymous Users
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+-- phase: expand
+-- ADR-056: project secrets, per project and environment, written once and used by name. The value is only ever stored
+-- encrypted (AES-256-GCM under NETWORK_SECRETS_KEY; key_id names the key, the row's identity is the authenticated data);
+-- archiving a project shreds it (ciphertext, iv and tag set to NULL), the metadata stays for the audit. A grant lets one
+-- service use one secret for sign and/or reveal (reveal only for the origins it lists). Additive: an older release never
+-- reads these tables.
+CREATE TABLE IF NOT EXISTS dev_project_secrets (
+ project_id text COLLATE "C" NOT NULL REFERENCES dev_projects(id),
+ environment text COLLATE "C" NOT NULL CHECK (environment IN ('sandbox', 'production')),
+ name text COLLATE "C" NOT NULL,
+ version bigint NOT NULL,
+ key_id text COLLATE "C" NOT NULL,
+ iv text COLLATE "C",
+ tag text COLLATE "C",
+ ciphertext text COLLATE "C",
+ fingerprint text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ updated_at text COLLATE "C" NOT NULL,
+ updated_by text COLLATE "C" NOT NULL,
+ PRIMARY KEY (project_id, environment, name)
+);
+
+CREATE TABLE IF NOT EXISTS dev_project_secret_grants (
+ project_id text COLLATE "C" NOT NULL,
+ environment text COLLATE "C" NOT NULL,
+ name text COLLATE "C" NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ purposes text COLLATE "C" NOT NULL,
+ origins text COLLATE "C" NOT NULL DEFAULT '[]',
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ last_used_at text COLLATE "C",
+ last_audit_day text COLLATE "C",
+ PRIMARY KEY (project_id, environment, name, service),
+ FOREIGN KEY (project_id, environment, name) REFERENCES dev_project_secrets(project_id, environment, name) ON DELETE CASCADE
+);
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1320,9 +1320,9 @@
@@ -1320,9 +1320,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.176.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
- "integrity": "sha512-COLEoRbjs6he+Tb2LvazezNWvHzB6CEWflebrgEyMaTd1hhE5zWJAAL/mdvR0LOzVDAX6vb1GPdTw83AulLfCg==",
+ "version": "0.177.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",
+ "integrity": "sha512-MargGRsErmonGrjiJWY3Nz1SgB16CBqx8iJZOcFeoQ8o1zZOUC2glagejMWjpB9QSiMN6jECG/SzUqdPmGxP9A==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.176.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.177.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.7",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -26,6 +26,12 @@ module.exports = {
@@ -26,6 +26,12 @@ module.exports = {
},
},
+ // Project secrets (ADR-056, server/developer/secrets.js): 32 random bytes, base64 or hex. Unset = the feature is off
+ // (503 secrets.unavailable); values are only ever stored encrypted under keys derived from it.
+ secrets: {
+ key: process.env.NETWORK_SECRETS_KEY || '',
+ },
+
// Developer projects (server/developer, ADR-014)
// Developer projects (server/developer, ADR-014)
developer: {
developer: {
// Audiences that accept sandbox app tokens (comma list). Unset = the code default
// Audiences that accept sandbox app tokens (comma list). Unset = the code default
@@ -56,6 +56,11 @@ const cache = require('openvibe-shared/cache-policy');
@@ -56,6 +56,11 @@ const cache = require('openvibe-shared/cache-policy');
* DELETE /:project/quotas/:capability staff
* DELETE /:project/quotas/:capability staff
* GET /:project/usage[?days=30&env=all] owner/admin or staff: usage per day, quotas and
* GET /:project/usage[?days=30&env=all] owner/admin or staff: usage per day, quotas and
* errors from the services' rollups (usage.js)
* errors from the services' rollups (usage.js)
+ * GET /:project/secrets[?environment=] viewer+ and staff: metadata only, never a value (secrets.js, ADR-056)
+ * PUT /:project/secrets/:env/:name { value } admin+ (not staff): set or replace; answers the metadata
+ * DELETE /:project/secrets/:env/:name admin+
+ * PUT /:project/secrets/:env/:name/grants/:service { purposes, origins } admin+: let a service sign or reveal
+ * DELETE /:project/secrets/:env/:name/grants/:service admin+
* GET /:project/audit[?before=&limit=] admin+ or staff
* GET /:project/audit[?before=&limit=] admin+ or staff
* POST /:project/export-tokens { audience, env } owner or admin member (not staff as such):
* POST /:project/export-tokens { audience, env } owner or admin member (not staff as such):
* a 5-minute read-only export token (tokens.js)
* a 5-minute read-only export token (tokens.js)
@@ -69,6 +74,7 @@ const confirmations = require('./confirmations');
@@ -69,6 +74,7 @@ const confirmations = require('./confirmations');
const policy = require('./policy');
const policy = require('./policy');
const tokens = require('./tokens');
const tokens = require('./tokens');
const usage = require('./usage');
const usage = require('./usage');
+const secrets = require('./secrets');
/**
/**
* The base of a user API: problems with request ids, JSON bodies, private no-store, and Bearer Network user tokens
* The base of a user API: problems with request ids, JSON bodies, private no-store, and Bearer Network user tokens
@@ -184,6 +190,13 @@ function router() {
@@ -184,6 +190,13 @@ function router() {
return await usage.summary(db, project.id, usage.parseQuery(req.query));
return await usage.summary(db, project.id, usage.parseQuery(req.query));
}));
}));
+ // Project secrets (ADR-056): metadata for members, values written by admins and never read back.
+ r.get('/:project/secrets', handle(async (db, a, req) => ({ secrets: await secrets.list(db, a, req.params.project, { environment: req.query.environment }) })));
+ r.put('/:project/secrets/:env/:name', handle(async (db, a, req, o) => await secrets.put(db, a, req.params.project, req.params.env, req.params.name, req.body || {}, { ctx: o.ctx, config: req.app.locals.config })));
+ r.delete('/:project/secrets/:env/:name', handle(async (db, a, req, o) => { await secrets.remove(db, a, req.params.project, req.params.env, req.params.name, o); }));
+ r.put('/:project/secrets/:env/:name/grants/:service', handle(async (db, a, req, o) => await secrets.putGrant(db, a, req.params.project, req.params.env, req.params.name, req.params.service, req.body || {}, o)));
+ r.delete('/:project/secrets/:env/:name/grants/:service', handle(async (db, a, req, o) => { await secrets.removeGrant(db, a, req.params.project, req.params.env, req.params.name, req.params.service, o); }));
+
r.get('/:project/audit', handle(async (db, a, req) => {
r.get('/:project/audit', handle(async (db, a, req) => {
const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
const { project } = await store.access(db, a, req.params.project, { need: 'admin', allowArchived: true });
return await store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
return await store.listAudit(db, project.id, { before: req.query.before, limit: req.query.limit });
new file mode 100644
new file mode 100644
@@ -0,0 +1,259 @@
@@ -0,0 +1,259 @@
+'use strict';
+/**
+ * Project secrets (ADR-056): per project and environment, written once and used by name, never read back.
+ *
+ * people (project routes, Bearer user tokens; developer/routes.js)
+ * list viewer+ and staff: metadata only (network.project-secret@1): name, version, fingerprint, grants
+ * put admin+ (not staff as such): set or replace the value; the version goes up by one
+ * remove admin+: the secret and its grants
+ * putGrant admin+: let one service use it for sign and/or reveal (reveal names the https origins)
+ * removeGrant admin+
+ * services (POST /internal/secrets/use, network.secret.use; internal/routes.js)
+ * use sign: HMAC-SHA256 of the message under the secret, the value never leaves; reveal: the value,
+ * for an origin the grant lists. Anything refused is 404 secret.not_found, so a guess learns nothing.
+ *
+ * The value is stored only encrypted: AES-256-GCM under a key derived from NETWORK_SECRETS_KEY (32 bytes, base64 or hex)
+ * with the row's identity (project, environment, name, version) as authenticated data, so a ciphertext copied onto
+ * another row does not open. The fingerprint is a keyed hash (another key derived from the same secret): equal values
+ * match, and it reveals nothing else. Without the key the feature is off (503 secrets.unavailable), never plaintext.
+ * Archiving a project shreds its secrets (shredProject: the ciphertext, iv and tag are dropped).
+ *
+ * Audit (dev_audit): every value set, deletion and grant change, and the first use of a secret per service, purpose and
+ * UTC day (a grant records when it was last used). Values and messages are never written anywhere else, logs included.
+ */
+const crypto = require('crypto');
+const { validate } = require('openvibe-contracts');
+const store = require('./store');
+
+const ENVS = ['sandbox', 'production'];
+const PURPOSES = ['sign', 'reveal'];
+const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
+const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
+const ORIGIN_RE = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;
+const MAX_VALUE_BYTES = 8192;
+const MAX_PER_ENV = 50;
+const MAX_GRANTS = 20;
+const MAX_ORIGINS = 10;
+const KEY_ID = 'k1';
+
+const { DevError } = store;
+const fail = (status, code, detail) => { throw new DevError(status, code, detail); };
+const nowIso = () => new Date().toISOString();
+const notFound = () => fail(404, 'secret.not_found', 'no such secret for this caller');
+
+/** The keys, derived once per configured secret: { id, enc, fp }, or null when NETWORK_SECRETS_KEY is unset or malformed. */
+const keyCache = new Map();
+function keyring(config) {
+ const raw = String((config && config.secrets && config.secrets.key) || '').trim();
+ if (!raw) return null;
+ if (keyCache.has(raw)) return keyCache.get(raw);
+ const bytes = /^[0-9a-f]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
+ const kr = bytes.length === 32 ? {
+ id: KEY_ID,
+ enc: Buffer.from(crypto.hkdfSync('sha256', bytes, Buffer.alloc(0), 'openvibe-network project-secret encryption', 32)),
+ fp: Buffer.from(crypto.hkdfSync('sha256', bytes, Buffer.alloc(0), 'openvibe-network project-secret fingerprint', 32)),
+ } : null;
+ keyCache.set(raw, kr);
+ return kr;
+}
+function requireKeys(config) {
+ const kr = keyring(config);
+ if (!kr) fail(503, 'secrets.unavailable', 'project secrets are not configured on this Network (NETWORK_SECRETS_KEY)');
+ return kr;
+}
+
+const aadOf = (row) => Buffer.from(`${row.project_id}\n${row.environment}\n${row.name}\n${row.version}`, 'utf8');
+function seal(kr, row, value) {
+ const iv = crypto.randomBytes(12);
+ const c = crypto.createCipheriv('aes-256-gcm', kr.enc, iv);
+ c.setAAD(aadOf(row));
+ const ciphertext = Buffer.concat([c.update(value, 'utf8'), c.final()]);
+ return { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), ciphertext: ciphertext.toString('base64') };
+}
+function open(kr, row) {
+ if (!row.ciphertext || row.key_id !== kr.id) return null;
+ const d = crypto.createDecipheriv('aes-256-gcm', kr.enc, Buffer.from(row.iv, 'base64'));
+ d.setAAD(aadOf(row));
+ d.setAuthTag(Buffer.from(row.tag, 'base64'));
+ return Buffer.concat([d.update(Buffer.from(row.ciphertext, 'base64')), d.final()]).toString('utf8');
+}
+const fingerprintOf = (kr, value) => crypto.createHmac('sha256', kr.fp).update(value, 'utf8').digest('hex').slice(0, 12);
+
+function cleanEnv(v) { if (!ENVS.includes(v)) fail(422, 'secret.invalid', 'environment is sandbox or production'); return v; }
+function cleanName(v) { if (!NAME_RE.test(String(v || ''))) fail(422, 'secret.invalid', 'a name is 1-64 characters: a lowercase letter, then lowercase letters, digits, _ or -'); return String(v); }
+function cleanValue(v) {
+ if (typeof v !== 'string' || !v.length) fail(422, 'secret.invalid', 'value must be a non-empty string');
+ if (Buffer.byteLength(v, 'utf8') > MAX_VALUE_BYTES) fail(422, 'secret.too_large', `a value is at most ${MAX_VALUE_BYTES} bytes`);
+ if (/\u0000/.test(v)) fail(422, 'secret.invalid', 'value must not contain NUL');
+ return v;
+}
+const subjectOf = (label) => String(label || '').replace(/^user:/, '');
+
+function grantView(g) {
+ return { service: g.service, purposes: JSON.parse(g.purposes), origins: JSON.parse(g.origins || '[]'), created_at: g.created_at, created_by: subjectOf(g.created_by), last_used_at: g.last_used_at || null };
+}
+function secretView(row, grants) {
+ return {
+ project_id: row.project_id, environment: row.environment, name: row.name, version: Number(row.version), fingerprint: row.fingerprint,
+ grants: grants.map(grantView), created_at: row.created_at, created_by: subjectOf(row.created_by), updated_at: row.updated_at, updated_by: subjectOf(row.updated_by),
+ };
+}
+
+async function grantsOf(db, row) {
+ return await db.prepare('SELECT * FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? ORDER BY service')
+ .all(row.project_id, row.environment, row.name);
+}
+async function getRow(db, projectId, environment, name) {
+ return await db.prepare('SELECT * FROM dev_project_secrets WHERE project_id = ? AND environment = ? AND name = ?').get(projectId, environment, name);
+}
+async function mustRow(db, projectId, environment, name) {
+ const row = await getRow(db, projectId, environment, name);
+ if (!row) fail(404, 'secret.not_found', 'no such secret');
+ return row;
+}
+
+/** Members (viewer+) and staff: the project's secrets, metadata only; ?environment= narrows it. */
+async function list(db, actor, projectId, { environment } = {}) {
+ const { project } = await store.access(db, actor, projectId, { allowArchived: true });
+ const env = environment ? cleanEnv(environment) : null;
+ const rows = env
+ ? await db.prepare('SELECT * FROM dev_project_secrets WHERE project_id = ? AND environment = ? ORDER BY environment, name').all(project.id, env)
+ : await db.prepare('SELECT * FROM dev_project_secrets WHERE project_id = ? ORDER BY environment, name').all(project.id);
+ const out = [];
+ for (const r of rows) out.push(secretView(r, await grantsOf(db, r)));
+ return out;
+}
+
+/** Admin+: set or replace a value (the version goes up by one). Answers the metadata, never the value. */
+async function put(db, actor, projectId, environment, name, body, { ctx, config }) {
+ const kr = requireKeys(config);
+ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false });
+ const env = cleanEnv(environment);
+ const n = cleanName(name);
+ const value = cleanValue(body && body.value);
+ let row;
+ await db.tx(async () => {
+ await store.lockProject(db, project.id);
+ const existing = await getRow(db, project.id, env, n);
+ if (!existing) {
+ const { c } = await db.prepare('SELECT COUNT(*) AS c FROM dev_project_secrets WHERE project_id = ? AND environment = ?').get(project.id, env);
+ if (Number(c) >= MAX_PER_ENV) fail(409, 'secret.limit', `a project environment holds at most ${MAX_PER_ENV} secrets`);
+ }
+ const at = nowIso();
+ const version = existing ? Number(existing.version) + 1 : 1;
+ const ident = { project_id: project.id, environment: env, name: n, version };
+ const sealed = seal(kr, ident, value);
+ const fingerprint = fingerprintOf(kr, value);
+ if (existing) {
+ await db.prepare('UPDATE dev_project_secrets SET version = ?, key_id = ?, iv = ?, tag = ?, ciphertext = ?, fingerprint = ?, updated_at = ?, updated_by = ? WHERE project_id = ? AND environment = ? AND name = ?')
+ .run(version, kr.id, sealed.iv, sealed.tag, sealed.ciphertext, fingerprint, at, actor.label, project.id, env, n);
+ } else {
+ await db.prepare(`INSERT INTO dev_project_secrets (project_id, environment, name, version, key_id, iv, tag, ciphertext, fingerprint, created_at, created_by, updated_at, updated_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(project.id, env, n, version, kr.id, sealed.iv, sealed.tag, sealed.ciphertext, fingerprint, at, actor.label, at, actor.label);
+ }
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: existing ? 'secret.replaced' : 'secret.created', target: `${env}/${n}`, detail: { environment: env, name: n, version, fingerprint }, ctx });
+ row = await getRow(db, project.id, env, n);
+ });
+ return secretView(row, await grantsOf(db, row));
+}
+
+/** Admin+: delete a secret and its grants. */
+async function remove(db, actor, projectId, environment, name, { ctx }) {
+ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });
+ const env = cleanEnv(environment);
+ const n = cleanName(name);
+ await db.tx(async () => {
+ const row = await mustRow(db, project.id, env, n);
+ await db.prepare('DELETE FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ?').run(project.id, env, n);
+ await db.prepare('DELETE FROM dev_project_secrets WHERE project_id = ? AND environment = ? AND name = ?').run(project.id, env, n);
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'secret.deleted', target: `${env}/${n}`, detail: { environment: env, name: n, version: Number(row.version) }, ctx });
+ });
+}
+
+/** Admin+: grant one service the secret for sign and/or reveal; reveal needs the https origins it may be sent to. */
+async function putGrant(db, actor, projectId, environment, name, service, body, { ctx }) {
+ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false });
+ const env = cleanEnv(environment);
+ const n = cleanName(name);
+ const svc = String(service || '');
+ if (!SERVICE_RE.test(svc) || svc === 'network') fail(422, 'secret.invalid', 'service is an OpenVibe service id (svc:<id>), not network');
+ const purposes = Array.isArray(body && body.purposes) ? [...new Set(body.purposes.map(String))].sort() : [];
+ if (!purposes.length || purposes.some((p) => !PURPOSES.includes(p))) fail(422, 'secret.invalid', 'purposes is a non-empty list of sign and reveal');
+ const origins = Array.isArray(body && body.origins) ? [...new Set(body.origins.map((o) => String(o).toLowerCase().replace(/\/+$/, '')))].sort() : [];
+ if (origins.length > MAX_ORIGINS || origins.some((o) => !ORIGIN_RE.test(o))) fail(422, 'secret.invalid', `origins are at most ${MAX_ORIGINS} https origins (scheme, host and an optional port)`);
+ if (purposes.includes('reveal') && !origins.length) fail(422, 'secret.invalid', 'reveal names the origins the value may be sent to');
+ if (!purposes.includes('reveal') && origins.length) fail(422, 'secret.invalid', 'origins apply to reveal only');
+ let out;
+ await db.tx(async () => {
+ const row = await mustRow(db, project.id, env, n);
+ const existing = await db.prepare('SELECT * FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? AND service = ?').get(project.id, env, n, svc);
+ if (!existing) {
+ const { c } = await db.prepare('SELECT COUNT(*) AS c FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ?').get(project.id, env, n);
+ if (Number(c) >= MAX_GRANTS) fail(409, 'secret.limit', `a secret has at most ${MAX_GRANTS} grants`);
+ await db.prepare('INSERT INTO dev_project_secret_grants (project_id, environment, name, service, purposes, origins, created_at, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
+ .run(project.id, env, n, svc, JSON.stringify(purposes), JSON.stringify(origins), nowIso(), actor.label);
+ } else {
+ await db.prepare('UPDATE dev_project_secret_grants SET purposes = ?, origins = ? WHERE project_id = ? AND environment = ? AND name = ? AND service = ?')
+ .run(JSON.stringify(purposes), JSON.stringify(origins), project.id, env, n, svc);
+ }
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'secret.granted', target: `${env}/${n}`, detail: { environment: env, name: n, service: svc, purposes, origins }, ctx });
+ out = secretView(row, await grantsOf(db, row));
+ });
+ return out;
+}
+
+/** Admin+: take a service's grant away. */
+async function removeGrant(db, actor, projectId, environment, name, service, { ctx }) {
+ const { project } = await store.access(db, actor, projectId, { need: 'admin', staffOk: false, allowArchived: true });
+ const env = cleanEnv(environment);
+ const n = cleanName(name);
+ await db.tx(async () => {
+ await mustRow(db, project.id, env, n);
+ const r = await db.prepare('DELETE FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? AND service = ?').run(project.id, env, n, String(service || ''));
+ if (!r.changes) fail(404, 'secret.grant_not_found', 'that service holds no grant on this secret');
+ await store.audit(db, { projectId: project.id, actor: actor.label, action: 'secret.grant_revoked', target: `${env}/${n}`, detail: { environment: env, name: n, service: String(service) }, ctx });
+ });
+}
+
+/**
+ * A service uses a secret (network.secret.use): `principal` is the verified token's principal (sub svc:<id>), `body` a
+ * network.secret-use-request@1. → network.secret-use-result@1. Every refusal past validation is 404 secret.not_found.
+ */
+async function use(db, principal, body, { ctx, config }) {
+ const v = validate('network.secret-use-request@1', body);
+ if (!v.valid) fail(400, 'request.invalid', v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 300));
+ const kr = requireKeys(config);
+ const service = String((principal && principal.sub) || '').replace(/^svc:/, '');
+ if (!SERVICE_RE.test(service)) notFound();
+ const project = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(body.project_id);
+ if (!project || project.archived_at) notFound();
+ const row = await getRow(db, project.id, body.environment, body.name);
+ if (!row || !row.ciphertext) notFound();
+ const grant = await db.prepare('SELECT * FROM dev_project_secret_grants WHERE project_id = ? AND environment = ? AND name = ? AND service = ?').get(project.id, body.environment, body.name, service);
+ if (!grant || !JSON.parse(grant.purposes).includes(body.purpose)) notFound();
+ if (body.purpose === 'reveal' && !JSON.parse(grant.origins || '[]').includes(String(body.origin).toLowerCase())) notFound();
+ let value;
+ try { value = open(kr, row); } catch { value = null; }
+ if (value == null) fail(503, 'secrets.unavailable', 'this secret cannot be opened with the configured key');
+ const at = nowIso();
+ const day = at.slice(0, 10);
+ await db.prepare('UPDATE dev_project_secret_grants SET last_used_at = ?, last_audit_day = ? WHERE project_id = ? AND environment = ? AND name = ? AND service = ?')
+ .run(at, day, project.id, body.environment, body.name, service);
+ if (grant.last_audit_day !== day) {
+ await store.audit(db, { projectId: project.id, actor: `service:${service}`, action: 'secret.used', target: `${body.environment}/${body.name}`,
+ detail: { environment: body.environment, name: body.name, service, purpose: body.purpose, version: Number(row.version), ...(body.origin ? { origin: body.origin } : {}) }, ctx });
+ }
+ const result = { name: row.name, version: Number(row.version), purpose: body.purpose };
+ if (body.purpose === 'sign') result.signature = crypto.createHmac('sha256', value).update(body.message, 'utf8').digest('hex');
+ else result.value = value;
+ return result;
+}
+
+/** Archiving a project (or deleting its owner's account) shreds its secrets: the ciphertexts go, the metadata stays. → count */
+async function shredProject(db, projectId) {
+ const r = await db.prepare('UPDATE dev_project_secrets SET ciphertext = NULL, iv = NULL, tag = NULL WHERE project_id = ? AND ciphertext IS NOT NULL').run(projectId);
+ return r.changes || 0;
+}
+
+module.exports = { list, put, remove, putGrant, removeGrant, use, shredProject, keyring, fingerprintOf, MAX_VALUE_BYTES, MAX_PER_ENV };
@@ -246,7 +246,9 @@ async function archiveProject(db, actor, projectId, { ctx, settings }) {
@@ -246,7 +246,9 @@ async function archiveProject(db, actor, projectId, { ctx, settings }) {
await require('./agents').revokeWhere(db, 'project_id = ?', [project.id], { actor: actor.label, reason: 'project_archived', ctx });
await require('./agents').revokeWhere(db, 'project_id = ?', [project.id], { actor: actor.label, reason: 'project_archived', ctx });
for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');
for (const app of await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? AND revoked_at IS NULL').all(project.id)) await revokeAppTx(db, actor, project, app, ctx, 'project archived');
await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
await db.prepare('UPDATE dev_projects SET archived_at = ?, archived_by = ? WHERE id = ?').run(nowIso(), actor.label, project.id);
- await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, ctx });
+ // ADR-056: an archived project's secrets are shredded (the ciphertexts go; names and versions stay in the audit).
+ const shredded = await require('./secrets').shredProject(db, project.id);
+ await audit(db, { projectId: project.id, actor: actor.label, action: 'project.archived', target: project.id, detail: shredded ? { secrets_shredded: shredded } : undefined, ctx });
});
});
return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
return await projectView(db, await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(project.id), role, settings);
}
}
@@ -360,6 +360,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -360,6 +360,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
}
}
if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, 'project_id = ?', [p.id], { actor: 'system:network', reason: 'project_archived' }));
if (await tableExists(db, 'dev_agents')) count('agents_revoked', await agents.revokeWhere(db, 'project_id = ?', [p.id], { actor: 'system:network', reason: 'project_archived' }));
await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
await db.prepare("UPDATE dev_projects SET archived_at = ?, archived_by = 'account_deleted' WHERE id = ?").run(at, p.id);
+ if (await tableExists(db, 'dev_project_secrets')) count('secrets_shredded', await require('../developer/secrets').shredProject(db, p.id));
count('projects_archived', 1);
count('projects_archived', 1);
}
}
await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
@@ -440,6 +440,9 @@ const DEFAULT_GRANTS = [
@@ -440,6 +440,9 @@ const DEFAULT_GRANTS = [
// check, Watch#54). Recording usage only.
// check, Watch#54). Recording usage only.
['space', 'billing.usage.record', 'openvibe.billing', []],
['space', 'billing.usage.record', 'openvibe.billing', []],
['watch', 'billing.usage.record', 'openvibe.billing', []],
['watch', 'billing.usage.record', 'openvibe.billing', []],
+ // Project secrets (ADR-056): Actor signs a task's webhook deliveries with the project secret its secret_ref names,
+ // when the project granted that secret to actor for sign. The capability is the door; each secret's grant decides.
+ ['actor', 'network.secret.use', SELF_AUDIENCE, []],
];
];
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
// Grants withdrawn by decision; applied at every boot so an old default can't come back.
@@ -52,6 +52,22 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
@@ -52,6 +52,22 @@ router.post('/identity/resolve-batch', principals.guard('identity.subject.resolv
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
router.use('/identity', require('../identity/internal-routes'));
router.use('/identity', require('../identity/internal-routes'));
+// ── Project secrets (ADR-056) ────────────────────────────────
+// A service the project granted a secret to signs with it (the value never leaves) or receives it for an origin the
+// grant lists. network.secret.use is the door; the secret's own grant decides, and anything refused is 404.
+router.post('/secrets/use', principals.guard('network.secret.use'), express.json({ limit: '1100kb' }), async (req, res) => {
+ const { http } = require('openvibe-contracts');
+ const secrets = require('../developer/secrets');
+ res.set('Cache-Control', 'no-store');
+ try {
+ res.json(await secrets.use(getDb(req), req.principal, req.body || {}, { ctx: req.ov, config: getConfig(req) }));
+ } catch (err) {
+ if (err && err.status && err.code) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });
+ console.error('[secrets] use failed:', err && err.message ? err.message.slice(0, 200) : err);
+ return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
+ }
+});
+
// ── Sync Linked Account ──────────────────────────────────────
// ── Sync Linked Account ──────────────────────────────────────
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// the service reports the link here.
// the service reports the link here.
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+// Project secrets (ADR-056, server/developer/secrets.js): admins write a value once and nobody reads it back; members
+// and staff see metadata (version, a keyed fingerprint, grants); a service uses a secret only through
+// POST /internal/secrets/use with network.secret.use AND the project's grant of that secret to it, to sign (HMAC, the
+// value never leaves) or to reveal it for a listed origin; every refusal past validation is the same 404; values are
+// stored only encrypted, bound to their row; archiving shreds them; nothing secret reaches the audit.
+// node test/project-secrets.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const jwt = require('jsonwebtoken');
+const { serviceAuth, validate } = require('openvibe-contracts');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+
+(async () => {
+ const quiet = console.log;
+ console.log = () => {};
+ const db = getDb();
+ await db.prepare(`INSERT INTO users (id, username, password_hash, role) VALUES
+ (10, 'owner', 'x', 'user'), (11, 'admin', 'x', 'user'), (12, 'dev', 'x', 'user'), (13, 'stranger', 'x', 'user'), (14, 'staff', 'x', 'admin')`).run();
+ const sid = async (id) => await subjects.ensureUserSubject(db, await db.prepare('SELECT * FROM users WHERE id = ?').get(id));
+
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const ISSUER = 'https://openvibe.network';
+ const KEY = crypto.randomBytes(32).toString('base64');
+ const app = express();
+ app.locals.db = db;
+ app.locals.config = { baseUrl: ISSUER, jwt: { issuer: ISSUER, accessTokenExpiry: '1h' }, developer: { sandboxAllowance: '' }, secrets: { key: KEY } };
+ app.locals.privateKey = keys.privateKey;
+ app.locals.publicKey = keys.publicKey;
+ app.use('/api/v1/projects', require('../server/developer/routes').router());
+ app.use('/internal', require('../server/internal/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const userToken = (id) => jwt.sign({ sub: id, id }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
+ const T = { owner: userToken(10), admin: userToken(11), dev: userToken(12), stranger: userToken(13), staff: userToken(14) };
+ const now = Math.floor(Date.now() / 1000);
+ const svc = (id, cap = ['network.secret.use']) => serviceAuth.signServiceToken({ iss: ISSUER, sub: `svc:${id}`, actor_type: 'service', aud: ['openvibe.network'], cap, iat: now, exp: now + 300, jti: `tok_${id}_${crypto.randomBytes(4).toString('hex')}` }, keys.privateKey);
+ const api = async (who, method, p, body) => {
+ const r = await fetch(`${base}/api/v1/projects${p}`, { method, headers: { authorization: `Bearer ${T[who]}`, ...(body ? { 'content-type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined });
+ const text = await r.text();
+ return { status: r.status, body: text ? JSON.parse(text) : null, text };
+ };
+ const use = async (token, body) => {
+ const r = await fetch(`${base}/internal/secrets/use`, { method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, body: JSON.stringify(body) });
+ const text = await r.text();
+ return { status: r.status, body: text ? JSON.parse(text) : null, cache: r.headers.get('cache-control') };
+ };
+ let failures = 0;
+ const check = async (name, fn) => { try { await fn(); quiet(' ✓', name); } catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); } };
+
+ try {
+ const P = (await api('owner', 'POST', '/', { name: 'Secrets probe' })).body.id;
+ assert.ok(/^prj_/.test(P), 'project created');
+ assert.strictEqual((await api('owner', 'POST', `/${P}/members`, { subject_id: await sid(11), role: 'admin' })).status, 201);
+ assert.strictEqual((await api('owner', 'POST', `/${P}/members`, { subject_id: await sid(12), role: 'developer' })).status, 201);
+ const VALUE = `whk_${crypto.randomBytes(24).toString('hex')}`;
+ const VALUE2 = `whk_${crypto.randomBytes(24).toString('hex')}`;
+ const TOKEN = 't'.repeat(48);
+
+ await check('an admin sets a value; the answer is metadata only, and members and staff read it, strangers do not', async () => {
+ const r = await api('admin', 'PUT', `/${P}/secrets/production/webhook-signing`, { value: VALUE });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(!r.text.includes(VALUE), 'the value is never answered');
+ assert.strictEqual(r.body.version, 1);
+ assert.match(r.body.fingerprint, /^[0-9a-f]{12}$/);
+ assert.strictEqual(validate('network.project-secret@1', r.body).valid, true, JSON.stringify(validate('network.project-secret@1', r.body).errors));
+ const list = await api('dev', 'GET', `/${P}/secrets`);
+ assert.strictEqual(list.status, 200);
+ assert.deepStrictEqual(list.body.secrets.map((s) => [s.environment, s.name, s.version]), [['production', 'webhook-signing', 1]]);
+ assert.ok(!list.text.includes(VALUE));
+ assert.strictEqual((await api('staff', 'GET', `/${P}/secrets`)).status, 200, 'staff read the metadata');
+ assert.strictEqual((await api('stranger', 'GET', `/${P}/secrets`)).status, 404, 'a stranger learns nothing');
+ });
+
+ await check('only admins and owners write: a developer and staff are refused; bad names, environments and sizes too', async () => {
+ assert.strictEqual((await api('dev', 'PUT', `/${P}/secrets/production/x`, { value: 'v' })).status, 403);
+ assert.strictEqual((await api('staff', 'PUT', `/${P}/secrets/production/x`, { value: 'v' })).status, 403, 'staff read metadata but never write a value');
+ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/production/Upper`, { value: 'v' })).status, 422);
+ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/staging/x`, { value: 'v' })).status, 422);
+ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/production/x`, { value: '' })).status, 422);
+ assert.strictEqual((await api('owner', 'PUT', `/${P}/secrets/production/x`, { value: 'v'.repeat(8193) })).body.code, 'secret.too_large');
+ });
+
+ await check('stored only encrypted: no plaintext in the table nor the audit', async () => {
+ const row = await db.prepare("SELECT * FROM dev_project_secrets WHERE project_id = ? AND name = 'webhook-signing'").get(P);
+ assert.ok(row.ciphertext && row.iv && row.tag, 'sealed');
+ const dump = JSON.stringify(await db.prepare('SELECT * FROM dev_project_secrets').all());
+ assert.ok(!dump.includes(VALUE) && !dump.includes(Buffer.from(VALUE).toString('base64')), 'no plaintext, nor its base64');
+ const audit = JSON.stringify(await db.prepare('SELECT * FROM dev_audit WHERE project_id = ?').all(P));
+ assert.ok(!audit.includes(VALUE), 'the audit holds no value');
+ assert.ok(audit.includes('secret.created'), 'the set is audited');
+ });
+
+ await check('a service without the project\'s grant gets 404, as for a secret that does not exist', async () => {
+ const sign = { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message: '1760504400.{"state":"succeeded"}' };
+ const a = await use(svc('actor'), sign);
+ assert.deepStrictEqual([a.status, a.body.code], [404, 'secret.not_found']);
+ const b = await use(svc('actor'), { ...sign, name: 'no-such-secret' });
+ assert.deepStrictEqual([b.status, b.body.code], [404, 'secret.not_found'], 'indistinguishable');
+ assert.strictEqual((await use(svc('actor', ['identity.subject.resolve']), sign)).status, 403, 'the capability is the door');
+ });
+
+ await check('granted for sign: the signature is HMAC-SHA256 under the value, which never leaves; reveal stays refused', async () => {
+ const g = await api('admin', 'PUT', `/${P}/secrets/production/webhook-signing/grants/actor`, { purposes: ['sign'] });
+ assert.strictEqual(g.status, 200, g.text);
+ assert.deepStrictEqual(g.body.grants.map((x) => [x.service, x.purposes, x.origins]), [['actor', ['sign'], []]]);
+ const message = '1760504400.{"state":"succeeded"}';
+ const r = await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.strictEqual(r.cache, 'no-store');
+ assert.strictEqual(r.body.signature, crypto.createHmac('sha256', VALUE).update(message).digest('hex'));
+ assert.ok(!JSON.stringify(r.body).includes(VALUE));
+ assert.strictEqual(validate('network.secret-use-result@1', r.body).valid, true);
+ const rev = await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'reveal', origin: 'https://hooks.example.com' });
+ assert.strictEqual(rev.status, 404, 'sign does not imply reveal');
+ assert.strictEqual((await use(svc('watch'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message })).status, 404, 'another service');
+ assert.strictEqual((await use(svc('actor'), { project_id: P, environment: 'sandbox', name: 'webhook-signing', purpose: 'sign', message })).status, 404, 'another environment');
+ assert.ok((await api('dev', 'GET', `/${P}/secrets`)).body.secrets[0].grants[0].last_used_at, 'the grant records its use');
+ });
+
+ await check('reveal only for an origin the grant lists; a grant for reveal must name origins', async () => {
+ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token`, { value: TOKEN })).status, 200);
+ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token/grants/watch`, { purposes: ['reveal'] })).status, 422, 'no origins');
+ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token/grants/watch`, { purposes: ['reveal'], origins: ['http://api.example.com'] })).status, 422, 'https only');
+ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/api_token/grants/watch`, { purposes: ['reveal'], origins: ['https://API.example.com/'] })).status, 200);
+ const ok = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://api.example.com' });
+ assert.deepStrictEqual([ok.status, ok.body.value], [200, TOKEN]);
+ const other = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://evil.example.com' });
+ assert.strictEqual(other.status, 404, 'an origin the grant does not list');
+ const mixed = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://api.example.com', message: 'x' });
+ assert.strictEqual(mixed.status, 400, 'the request schema refuses a reveal with a message');
+ // A ciphertext copied onto another row does not open there: the row's identity is the authenticated data.
+ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/moved`, { value: 'other' })).status, 200);
+ assert.strictEqual((await api('admin', 'PUT', `/${P}/secrets/sandbox/moved/grants/watch`, { purposes: ['reveal'], origins: ['https://api.example.com'] })).status, 200);
+ const src = await db.prepare("SELECT iv, tag, ciphertext FROM dev_project_secrets WHERE project_id = ? AND name = 'api_token'").get(P);
+ await db.prepare("UPDATE dev_project_secrets SET iv = ?, tag = ?, ciphertext = ? WHERE project_id = ? AND name = 'moved'").run(src.iv, src.tag, src.ciphertext, P);
+ const moved = await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'moved', purpose: 'reveal', origin: 'https://api.example.com' });
+ assert.deepStrictEqual([moved.status, moved.body.code], [503, 'secrets.unavailable'], 'a moved ciphertext is refused, never revealed');
+ assert.strictEqual((await api('admin', 'DELETE', `/${P}/secrets/sandbox/moved`)).status, 204);
+ });
+
+ await check('replacing a value bumps the version and the fingerprint; signatures follow the new value', async () => {
+ const before = (await api('dev', 'GET', `/${P}/secrets?environment=production`)).body.secrets[0];
+ const r = await api('owner', 'PUT', `/${P}/secrets/production/webhook-signing`, { value: VALUE2 });
+ assert.strictEqual(r.body.version, 2);
+ assert.notStrictEqual(r.body.fingerprint, before.fingerprint);
+ assert.deepStrictEqual(r.body.grants.map((x) => x.service), ['actor'], 'grants survive a replacement');
+ const s = await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message: 'm' });
+ assert.deepStrictEqual([s.body.version, s.body.signature], [2, crypto.createHmac('sha256', VALUE2).update('m').digest('hex')]);
+ const same = await api('owner', 'PUT', `/${P}/secrets/sandbox/again`, { value: VALUE2 });
+ assert.strictEqual(same.body.fingerprint, r.body.fingerprint, 'equal values, equal fingerprints');
+ });
+
+ await check('revoking the grant stops the service; deleting the secret removes it and its grants', async () => {
+ assert.strictEqual((await api('admin', 'DELETE', `/${P}/secrets/production/webhook-signing/grants/actor`)).status, 204);
+ assert.strictEqual((await use(svc('actor'), { project_id: P, environment: 'production', name: 'webhook-signing', purpose: 'sign', message: 'm' })).status, 404);
+ assert.strictEqual((await api('admin', 'DELETE', `/${P}/secrets/sandbox/again`)).status, 204);
+ assert.deepStrictEqual((await api('dev', 'GET', `/${P}/secrets`)).body.secrets.map((s) => s.name).sort(), ['api_token', 'webhook-signing']);
+ });
+
+ await check('archiving the project shreds its secrets: no ciphertext remains and no service can use them', async () => {
+ assert.strictEqual((await api('owner', 'POST', `/${P}/archive`)).status, 200);
+ const rows = await db.prepare('SELECT ciphertext, iv, tag FROM dev_project_secrets WHERE project_id = ?').all(P);
+ assert.ok(rows.length && rows.every((r) => r.ciphertext === null && r.iv === null && r.tag === null), 'shredded');
+ assert.strictEqual((await use(svc('watch'), { project_id: P, environment: 'sandbox', name: 'api_token', purpose: 'reveal', origin: 'https://api.example.com' })).status, 404);
+ assert.strictEqual((await api('dev', 'GET', `/${P}/secrets`)).status, 200, 'the metadata stays readable');
+ });
+
+ await check('without NETWORK_SECRETS_KEY the feature is off (503), never plaintext', async () => {
+ const P2 = (await api('owner', 'POST', '/', { name: 'No key' })).body.id;
+ app.locals.config.secrets.key = '';
+ const r = await api('owner', 'PUT', `/${P2}/secrets/production/x`, { value: 'v' });
+ app.locals.config.secrets.key = KEY;
+ assert.deepStrictEqual([r.status, r.body.code], [503, 'secrets.unavailable']);
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS c FROM dev_project_secrets WHERE project_id = ?').get(P2)).c * 1, 0);
+ });
+ } finally {
+ server.close();
+ console.log = quiet;
+ }
+ console.log(failures ? `\n${failures} failed` : '\nproject secrets: all checks passed');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');