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)

OP OpenVibers committed 29 days ago · 9aeac0abb2 · 15 files, +558 −10

**Network keeps project secrets: written once, used by name, never read back (ADR-056, Contracts 0.177.0).**

**For people** (`/api/v1/projects/:project/secrets`, Bearer user tokens):
- Admins and owners set a value (`PUT …/:env/:name`); setting it again bumps the version. They can also delete a secret and grant it to a service for `sign` and/or `reveal` (reveal names the https origins).
- Members (viewer and up) and staff read metadata only: name, version, a keyed fingerprint, grants and last use. No route answers a value to a person. Staff can read but never write.
- Limits: names `^[a-z][a-z0-9_-]{0,63}$`; values 1–8192 bytes; 50 secrets per project environment; 20 grants per secret; 10 origins per grant.

**For services** (`POST /internal/secrets/use`, capability `network.secret.use`, `network.secret-use-request@1`):
- `sign` answers HMAC-SHA256 of the message under the secret; the value never leaves Network.
- `reveal` answers the value, only to the granted service and only for an origin the grant lists.
- Everything refused past validation (no secret, no grant, wrong purpose or origin, archived project) is the same 404 `secret.not_found`. Answers are `Cache-Control: no-store`.
- Actor holds the capability by default (DEFAULT_GRANTS), to sign webhook deliveries with a task's `secret_ref`. Each secret's grant still decides.

**Storage** (`migrations/0023_project_secrets.sql`, expand):
- AES-256-GCM under a key derived (HKDF) from `NETWORK_SECRETS_KEY`, with the row's identity (project, environment, name, version) as authenticated data, so a ciphertext copied onto another row refuses to open.
- The fingerprint is HMAC under a second derived key.
- Without the key the feature answers 503 `secrets.unavailable`, never plaintext.

**Lifecycle:**
- Archiving a project shreds its secrets (ciphertext, iv and tag dropped; the metadata stays). So does deleting an owner's account when no heir takes the project.
- Audit: sets, replacements, deletions, grant changes, and the first use per secret, service, purpose and UTC day. Values and messages never reach the audit or the logs.

**Tests:** `project-secrets.test.js` (new, ten checks):
- metadata-only answers, and schema validation of the views;
- role checks (developer 403, staff 403, stranger 404);
- no plaintext or base64 in the table or the audit;
- the same 404 for an ungranted service and a missing secret, and the capability gate;
- sign is HMAC under the value, and sign does not imply reveal;
- other services and environments refused;
- reveal only for a listed origin, a schema-refused mixed request, and a moved ciphertext refused;
- replacing bumps the version and fingerprint while grants survive, and equal values give equal fingerprints;
- revocation and deletion;
- archive shreds;
- no key gives 503.

`agent-schema` and `user-owned-trust` list migration 0023. The tests this reaches pass (62). Contracts pinned to v0.177.0. README section and `.env.example` entry added.

The production key is set on the host before deploying.

Changes

ddiff --git a/.env.example b/.env.example
iindex 6e66d9ca..ac275909 100644
---- a/.env.example
++++ b/.env.example
@@@ -27,6 +27,10 @@ INDEXNOW_KEY=
SETUP_TOKEN=change-me-to-a-second-random-secret
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)
# 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.
ddiff --git a/README.md b/README.md
iindex 637fd8f5..7d5f7142 100644
---- a/README.md
++++ b/README.md
@@@ -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 other
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`
@@@ -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 Users
ddiff --git a/migrations/0023_project_secrets.sql b/migrations/0023_project_secrets.sql
nnew file mode 100644
iindex 00000000..ae64f87c
---- /dev/null
++++ b/migrations/0023_project_secrets.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 85e7de0f..fde9ec33 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 053cccb4..423cd5b3 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/config.js b/server/config.js
iindex b05b786d..4e2021d3 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/developer/routes.js b/server/developer/routes.js
iindex c7699429..101d3fa4 100644
---- a/server/developer/routes.js
++++ b/server/developer/routes.js
@@@ -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 });
ddiff --git a/server/developer/secrets.js b/server/developer/secrets.js
nnew file mode 100644
iindex 00000000..1de0bc8a
---- /dev/null
++++ b/server/developer/secrets.js
@@@ -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 };
ddiff --git a/server/developer/store.js b/server/developer/store.js
iindex fe90b9c5..25e45b25 100644
---- a/server/developer/store.js
++++ b/server/developer/store.js
@@@ -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);
}
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 71bf7c2f..50c9ab50 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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');
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 4764f3fa..ca248d3d 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -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.
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 98c84a1a..a0fe4b35 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -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.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.
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 55dead69..05ce6628 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/project-secrets.test.js b/test/project-secrets.test.js
nnew file mode 100644
iindex 00000000..5b89bf51
---- /dev/null
++++ b/test/project-secrets.test.js
@@@ -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); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 7efacda0..82e03f3c 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');