openvibers/openvibe.actor Commits

Actor: a task's webhook can be signed with the project's own secret (secret_ref; Network signs, Actor never holds it) (#71)

OP OpenVibers committed 29 days ago · 0fc07c48a4 · 13 files, +224 −22

**A task's webhook can be signed with the project's own secret (ADR-056, Contracts 0.178.0).**

The plan's "Next: `secret_ref` (project secrets by reference) waits on a project secret store" is unblocked: Network keeps project secrets now (Network#197).

**What changes:**
- **Creating a task:** a webhook may name `secret_ref` when the task is made by a project's app or agent token. That token's `project_id` and `env` say where the secret lives, and they are recorded on the webhook. A person's own task is refused 422 `actor.webhook.secret_unavailable`. A `webhook_secret` is minted only when some webhook does not name a project secret.
- **Delivering:** a delivery remembers the secret's name and environment (migration `0008`, expand). At send time Actor asks Network (`POST /internal/secrets/use`, sign, with its own `network.secret.use` token; `server/net/secret-signer.js`) for HMAC-SHA256 of `"<t>.<raw body>"`, and sends it as `X-OpenVibe-Signature-V2: t=<t>,v2=<hex>` with `X-OpenVibe-Timestamp`. A receiver checks it with `verifyDeliveryV2` and its own secret, and Actor never holds the value.
- **Failures:**
  - Network's 404 (no such secret, or not granted to actor) fails the delivery at once, saying why.
  - Network unreachable takes the same backoff as a silent receiver (the attempt counts), and nothing unsigned is sent.
  - Receiver failures now share that one retry helper.
- **Reads:** a task's webhooks show `secret_ref` (the name) and valid `platform.task@1`.
- **Docs:** README and `/docs#webhooks` describe it. The SSRF ratchet lists the new call, to the configured Network internal address.

**Test:** `webhook-secret-ref.test.js` (new, four checks, with a stand-in signer holding a project secret):
- nothing minted, and the task shows the name;
- the delivery verifies under the project secret, and the signed message is exactly the sent timestamp and body;
- a refused secret fails at once, and Network down retries and then delivers;
- a person's task is refused, and a mixed task still gets its own secret.

`webhooks.test.js` and `tasks.test.js` are unchanged and pass. Contracts pinned to v0.178.0; 27 reached test files pass.

Network already grants Actor `network.secret.use` (Network#197); each project's grant decides per secret.

Changes

ddiff --git a/README.md b/README.md
iindex 883448a..429803b 100644
---- a/README.md
++++ b/README.md
@@@ -69,7 +69,13 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **Signing:** the answer that creates the task carries `webhook_secret`, minted for that task. It is in no read or list, never exported, and erased once the task has ended and its deliveries are done. Each delivery is `actor.task-webhook@1` (`{ type: "actor.task.state", delivery_id, state, task }`, the task as it was then), signed like an Events delivery (`X-OpenVibe-Timestamp` + `X-OpenVibe-Signature-V2`), so a receiver checks it with openvibe-sdk/events `verifyDeliveryV2(rawBody, headers, secret)`. The delivery id stays the same across retries; dedupe on it.
- **Outcomes:** 2xx is delivered; 410, or a URL refused at send time, ends the delivery; anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h, then fails.
- **Where Actor may connect:** a webhook URL is the one address a caller chooses (`server/net/webhook-post.js`): https on port 443 or 8443, no credentials, public addresses only. It is checked at creation (`actor.webhook.refused`) and again at send time through openvibe-shared/egress, so a name that later resolves to a private address is refused. Redirects are never followed.
--- **Not yet:** a project secret named by reference (`secret_ref`) is refused until project secrets exist.
++- **A project secret instead (ADR-056):** a webhook may name one (`secret_ref`, a lower-case name) when the task is made
++ by a project's app or agent token. The secret is looked up in the task's project and environment, and the project must
++ have granted it to `actor` for `sign` (on openvibe.services, the project's Secrets page). OpenVibe.Network then signs
++ each delivery over `"<t>.<raw body>"` in the same two headers, so the receiver's check is the same and Actor never
++ holds the value. No `webhook_secret` is minted when every webhook names one. A secret Network refuses (missing, or not
++ granted) fails that delivery at once; Network unreachable is retried on the usual backoff. A person's own task cannot
++ name one (422 `actor.webhook.secret_unavailable`).
**Errors:** RFC 9457 problems. The codes are on [openvibe.actor/docs](https://openvibe.actor/docs#errors).
ddiff --git a/migrations/0008_webhook_secret_ref.sql b/migrations/0008_webhook_secret_ref.sql
nnew file mode 100644
iindex 0000000..1ccbbe9
---- /dev/null
++++ b/migrations/0008_webhook_secret_ref.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- ADR-056: a task webhook may name a project secret (secret_ref) that signs its deliveries instead of the task's own
++-- secret; OpenVibe.Network computes each signature, so Actor never holds the value. A delivery records the secret's
++-- name and the project environment to look it up in, as they were when it was queued. Additive: an older release
++-- ignores the columns and keeps signing with the task's secret.
++ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS secret_ref text COLLATE "C";
++ALTER TABLE webhook_deliveries ADD COLUMN IF NOT EXISTS secret_env text COLLATE "C";
ddiff --git a/package-lock.json b/package-lock.json
iindex 7a64339..b4f3def 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.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"
@@@ -871,9 +871,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.169.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
-- "integrity": "sha512-Py2z8UaKGvLzG3atuIAnJKnRe74Te9cJRTaxqFq6m4fov6gaMXeS6rn7lH0tSIo0qBGD5trrT618ARXnklkp+A==",
++ "version": "0.178.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.0",
++ "integrity": "sha512-86Q1Mmo+BvMnF2GG2IZrbHq8iZ2FUfED28rnDcFlgZTtIzHxDQdi2dY+Xe8WbXc4EebjGUYhEl5J3LReA2wiqg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 49a8a2f..b7c3941 100644
---- a/package.json
++++ b/package.json
@@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.169.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.178.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/app.js b/server/app.js
iindex 7d12660..b15346a 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -60,8 +60,10 @@ async function createApp(opts = {}) {
const stream = createTaskStream({ log });
const adapters = opts.adapters || createAdapters({ config, rates: catalog.RATES, fetchImpl: opts.providerFetch || globalThis.fetch, log });
// Task webhooks (plan T17): the only connections to an address a caller chose, through server/net/webhook-post.js.
++ // A webhook naming a project secret (ADR-056) is signed by OpenVibe.Network (server/net/secret-signer.js).
++ const signer = opts.secretSigner || require('./net/secret-signer').createSecretSigner({ config, fetchImpl });
const webhooks = createWebhooks({
-- s, config, now: s.now, log,
++ s, config, now: s.now, log, signer,
post: opts.webhookPost || createWebhookPoster({ userAgent: `OpenVibe.Actor/${VERSION} (+https://openvibe.actor/docs#webhooks)`, timeoutMs: config.webhooks.timeoutMs }),
});
const engine = createEngine({ config, s, adapters, stream, webhooks, now: s.now, log });
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex ee2f43b..3232bb3 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -410,7 +410,7 @@ data: {"task_id":"tsk_…","seq":3,"kind":"output","agent":"openvibe-runtime","s
<li><strong>An app or agent</strong>: a Network token for audience <code>openvibe.actor</code> with the <code>actor.task.*</code> capability the route needs, granted on <a href="https://openvibe.services">OpenVibe.Services</a>. Tasks belong to the app's project.</li>
<li><strong>Anyone</strong>: <code>GET /api/v1/agents</code> and <code>POST /api/v1/route</code> need no token.</li></ul>
${table(['Route', 'Capability', 'What it does'], [
-- [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key?, webhooks?: [{ url, events }] }</code>. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once (<a href="#webhooks">webhooks</a>).`],
++ [html`<code>POST /api/v1/tasks</code>`, html`<code>actor.task.create</code>`, html`Create a task: <code>{ task, mode?, budget?: { per_task_usd, per_day_usd }, agent?, idempotency_key?, webhooks?: [{ url, events, secret_ref? }] }</code>. Answers 201 with the task (queued); with webhooks, also its <code>webhook_secret</code>, this once, unless every webhook names a project secret (<a href="#webhooks">webhooks</a>).`],
[html`<code>GET /api/v1/tasks/:id</code>`, html`<code>actor.task.read</code>`, 'The task: state, result, cost, explanation, error.'],
[html`<code>GET /api/v1/tasks/:id/events</code>`, html`<code>actor.task.read</code>`, html`Server-sent events (<code>actor.task-event@1</code>): <code>state</code>, <code>output</code> (each step), <code>end</code>. Resumes with <code>Last-Event-ID</code>.`],
[html`<code>POST /api/v1/tasks/:id/cancel</code>`, html`<code>actor.task.create</code>`, 'Stop it. What was spent stays spent.'],
@@@ -422,6 +422,7 @@ ${raw(showcase.code({ title: 'Create a task, then watch it', samples: [{ label:
<h2 id="webhooks">Webhooks</h2>
<p>Give a task up to 8 HTTPS endpoints and the state changes each wants (<code>queued</code>, <code>running</code>, <code>verifying</code>, <code>succeeded</code>, <code>failed</code>, <code>cancelled</code>). Actor POSTs <a href="https://openvibe.services/docs/contracts/actor.task-webhook@1"><code>actor.task-webhook@1</code></a> to each: <code>{ type: "actor.task.state", delivery_id, state, task }</code>, with the task as it was at that moment.</p>
<ul><li><strong>Signed per task.</strong> The answer that creates the task carries <code>webhook_secret</code>, and nothing else ever shows it. Each delivery has <code>X-OpenVibe-Timestamp</code> and <code>X-OpenVibe-Signature-V2</code>, exactly like an OpenVibe.Events delivery, so <code>verifyDeliveryV2(rawBody, headers, secret)</code> from <code>openvibe-sdk/events</code> checks it (and the five-minute window).</li>
++<li><strong>Or with your project's secret.</strong> A project's app or agent may name a project secret instead (<code>secret_ref</code>): grant it to <code>actor</code> for signing on <a href="https://openvibe.services/projects">openvibe.services</a>, and OpenVibe.Network signs each delivery with it in the same headers. Actor never sees the value, and you verify with your own secret.</li>
<li><strong>Retried.</strong> A 2xx answer is delivered. 410 Gone ends it. Anything else is retried after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h and 12 h. <code>delivery_id</code> (also <code>X-OpenVibe-Delivery-Id</code>) is the same on every attempt: dedupe on it.</li>
<li><strong>Public endpoints only.</strong> HTTPS on port 443 or 8443, no credentials in the URL, and only public addresses, checked when the task is created and again when each delivery is sent. Redirects are not followed.</li></ul>
<h2 id="limits">Limits and money</h2>
ddiff --git a/server/net/secret-signer.js b/server/net/secret-signer.js
nnew file mode 100644
iindex 0000000..f32673b
---- /dev/null
++++ b/server/net/secret-signer.js
@@@ -0,0 +1,38 @@
++'use strict';
++/**
++ * Signing with a project secret (ADR-056): OpenVibe.Network's POST /internal/secrets/use, purpose sign, with Actor's own
++ * service token (network.secret.use, audience openvibe.network). Network answers HMAC-SHA256 of the message under the
++ * secret the project granted to actor; the value never reaches Actor.
++ *
++ * sign({ project_id, environment, name, message }) → { signature, version }
++ * throws SecretRefused (404 secret.not_found: no such secret, or not granted to actor for sign; final)
++ * throws any other error for a transient failure (retried by the caller)
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++class SecretRefused extends Error {}
++
++function createSecretSigner({ config, fetchImpl = globalThis.fetch }) {
++ const enabled = Boolean(config.networkInternalUrl && config.oauth && config.oauth.clientSecret);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope: 'network.secret.use', fetchImpl,
++ }) : null;
++ async function sign({ project_id, environment, name, message }) {
++ if (!tokens) throw new Error('project secrets are unavailable: Actor has no Network client secret');
++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/secrets/use`, {
++ method: 'POST',
++ headers: { ...(await tokens.authHeaders()), 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ project_id, environment, name, purpose: 'sign', message }),
++ signal: AbortSignal.timeout(5000),
++ });
++ const data = await res.json().catch(() => null);
++ if (res.status === 401 && tokens.invalidate) tokens.invalidate();
++ if (res.status === 404) throw new SecretRefused(`project secret ${name} (${environment}) does not exist or is not granted to actor for sign`);
++ if (!res.ok || !data || !/^[0-9a-f]{64}$/.test(String(data.signature || ''))) throw new Error(`Network answered ${res.status}${data && data.code ? ` ${data.code}` : ''}`);
++ return { signature: data.signature, version: data.version };
++ }
++ return { enabled, sign };
++}
++
++module.exports = { createSecretSigner, SecretRefused };
ddiff --git a/server/tasks/create.js b/server/tasks/create.js
iindex bd877cf..9826b14 100644
---- a/server/tasks/create.js
++++ b/server/tasks/create.js
@@@ -30,10 +30,17 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
if (body.agent && !catalog.byId(body.agent)) return { status: 422, code: 'actor.agent.unknown', detail: `no agent "${body.agent}"; GET /api/v1/agents lists them` };
// Webhooks (plan T17): each URL must be one Actor may deliver to (https, port 443/8443, a public address); the
// send-time check repeats it against what the name resolves to then.
-- const webhooks = (body.webhooks || []).map((h) => ({ url: h.url, events: h.events }));
++ // A webhook may name a project secret (ADR-056, secret_ref): Network signs its deliveries with it. The secret lives in
++ // the task's project and environment, so the task must come from a project's app or agent token.
++ const env = principal.claims && ['sandbox', 'production'].includes(principal.claims.env) ? principal.claims.env : null;
++ const webhooks = (body.webhooks || []).map((h) => ({ url: h.url, events: h.events, ...(h.secret_ref ? { secret_ref: h.secret_ref, environment: env } : {}) }));
for (const h of webhooks) {
try { checkWebhookUrl(h.url); } catch (err) { return { status: 422, code: 'actor.webhook.refused', detail: `${h.url.slice(0, 200)}: ${err.message}` }; }
++ if (h.secret_ref && (!principal.project || !env)) {
++ return { status: 422, code: 'actor.webhook.secret_unavailable', detail: `secret_ref names a project secret: make the task with a project's app or agent token (it carries project_id and env); this one has ${principal.project ? 'no env' : 'no project'}.` };
++ }
}
++ const ownSecret = webhooks.some((h) => !h.secret_ref);
const hash = crypto.createHash('sha256').update(JSON.stringify({ task: body.task, mode: body.mode || 'balanced', budget: [perTask, perDay], agent: body.agent || null, ...(webhooks.length ? { webhooks } : {}) })).digest('hex');
if (body.idempotency_key) {
@@@ -62,7 +69,7 @@ async function createTask({ s, config, engine, principal, body, webhooks: webhoo
const row = await store.insertTask(s, {
id: s.newId('tsk'), requester: principal.requester, via: principal.via || null, project_id: principal.project || null, task: body.task, mode: body.mode || 'balanced', agent: body.agent || null,
budget_task: perTask, budget_day: perDay, idem_key: body.idempotency_key || null, idem_hash: body.idempotency_key ? hash : null, created_at: s.iso(),
-- webhooks, webhook_secret: webhooks.length ? mintSecret() : null,
++ webhooks, webhook_secret: ownSecret ? mintSecret() : null,
});
return { status: 201, task: row };
});
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex 6e8eb6d..ac0b013 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -156,7 +156,7 @@ function toWire(row, { baseUrl }) {
if (t.explanation && !t.explanation.length) t.explanation = null;
// What the task registered; the signing secret is never part of the task as read (only the creating answer has it).
const hooks = parse(row.webhooks);
-- if (hooks && hooks.length) t.webhooks = hooks.map((h) => ({ url: h.url, events: h.events }));
++ if (hooks && hooks.length) t.webhooks = hooks.map((h) => ({ url: h.url, events: h.events, ...(h.secret_ref ? { secret_ref: h.secret_ref } : {}) }));
return t;
}
ddiff --git a/server/tasks/webhooks.js b/server/tasks/webhooks.js
iindex b2f2ab6..6fbca24 100644
---- a/server/tasks/webhooks.js
++++ b/server/tasks/webhooks.js
@@@ -9,6 +9,11 @@
* 410, or a refused URL failed at once (the receiver is gone, or the URL is not a public address any more)
* anything else retried: 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then failed (about a day)
*
++ * A webhook that names a project secret (secret_ref, ADR-056) is signed by OpenVibe.Network instead, in the same headers:
++ * Actor sends Network "<t>.<raw body>" and puts the signature it answers in X-OpenVibe-Signature-V2, so the receiver's
++ * check is the same and Actor never holds the value. A secret that does not exist or is not granted to actor fails that
++ * delivery at once; Network being unreachable is retried like any other failure.
++ *
* A delivery never delays or changes the task. Once a task has ended and none of its deliveries is pending, its secret
* is erased. The delivery id is stable across attempts (dedupe on it). One Actor process sends: rows are claimed with
* FOR UPDATE SKIP LOCKED all the same, and a row a crash left 'sending' is pending again at the next start.
@@@ -17,6 +22,7 @@ const crypto = require('crypto');
const { signDeliveryHeaders } = require('openvibe-sdk/events');
const store = require('./store');
const { WebhookRefused } = require('../net/webhook-post');
++const { SecretRefused } = require('../net/secret-signer');
const BACKOFF_MS = [10_000, 60_000, 300_000, 1_800_000, 7_200_000, 21_600_000, 43_200_000];
const BATCH = 20;
@@@ -24,7 +30,7 @@ const BATCH = 20;
/** A fresh per-task secret: whsec_ + 32 random bytes, base64url (43 characters). */
const mintSecret = () => `whsec_${crypto.randomBytes(32).toString('base64url')}`;
--function createWebhooks({ s, config, post, now = () => Date.now(), log = console }) {
++function createWebhooks({ s, config, post, signer = null, now = () => Date.now(), log = console }) {
let timer = null;
let running = null;
@@@ -38,8 +44,8 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
for (const h of wanted) {
const id = s.newId('whd');
const body = { type: 'actor.task.state', delivery_id: id, state, task };
-- await s.db.query(`INSERT INTO webhook_deliveries (id, task_id, url, state, body, status, attempts, next_at, created_at)
-- VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 0, $6, $7)`, [id, taskId, h.url, state, JSON.stringify(body), now(), s.iso()]);
++ await s.db.query(`INSERT INTO webhook_deliveries (id, task_id, url, state, body, status, attempts, next_at, created_at, secret_ref, secret_env)
++ VALUES ($1, $2, $3, $4, $5::jsonb, 'pending', 0, $6, $7, $8, $9)`, [id, taskId, h.url, state, JSON.stringify(body), now(), s.iso(), h.secret_ref || null, h.secret_ref ? h.environment : null]);
}
return wanted.length;
}
@@@ -62,13 +68,39 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
AND NOT EXISTS (SELECT 1 FROM webhook_deliveries WHERE task_id = $1 AND status IN ('pending', 'sending'))`, [taskId]);
}
++ /** The signature headers: the task's own secret, or Network's signature with the project secret the webhook names. */
++ async function signatureHeaders(d, body) {
++ if (!d.secret_ref) {
++ const secret = await s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [d.task_id]);
++ return secret ? signDeliveryHeaders(body, secret, { now: now() }) : null;
++ }
++ const project = await s.db.value('SELECT project_id FROM tasks WHERE id = $1', [d.task_id]);
++ if (!project || !signer) throw new SecretRefused('the task has no project to look the secret up in');
++ const t = Math.floor(now() / 1000);
++ const { signature } = await signer.sign({ project_id: project, environment: d.secret_env, name: d.secret_ref, message: `${t}.${body}` });
++ return { 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': `t=${t},v2=${signature}` };
++ }
++
++ /** Try again on the backoff, or give up once it is spent. */
++ async function retryLater(d, attempts, lastStatus, why) {
++ if (attempts > BACKOFF_MS.length) return finish(d, 'failed', { lastStatus, error: why, attempts });
++ await s.db.query(`UPDATE webhook_deliveries SET status = 'pending', attempts = $2, next_at = $3, last_status = $4, last_error = $5 WHERE id = $1`,
++ [d.id, attempts, now() + BACKOFF_MS[attempts - 1], lastStatus, why]);
++ return undefined;
++ }
++
async function sendOne(d) {
const attempts = Number(d.attempts) + 1;
-- const secret = await s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [d.task_id]);
-- if (!secret) return finish(d, 'failed', { error: 'the task has no signing secret any more', attempts });
const body = JSON.stringify(store.parseJson(d.body));
++ let signed;
++ try { signed = await signatureHeaders(d, body); } catch (err) {
++ if (err instanceof SecretRefused) return finish(d, 'failed', { error: err.message.slice(0, 300), attempts });
++ // Network could not sign just now: the same backoff as a receiver that did not answer.
++ return retryLater(d, attempts, null, `signing with the project secret: ${String(err.message || err).slice(0, 250)}`);
++ }
++ if (!signed) return finish(d, 'failed', { error: 'the task has no signing secret any more', attempts });
const headers = {
-- ...signDeliveryHeaders(body, secret, { now: now() }),
++ ...signed,
'X-OpenVibe-Delivery-Id': d.id,
'X-OpenVibe-Task-Id': d.task_id,
'X-OpenVibe-Task-State': d.state,
@@@ -82,10 +114,7 @@ function createWebhooks({ s, config, post, now = () => Date.now(), log = console
return finish(d, 'failed', { lastStatus: res ? res.status : null, error: error ? error.message : 'the receiver answered 410 Gone', attempts });
}
const why = error ? String(error.message || error).slice(0, 300) : `HTTP ${res.status}`;
-- if (attempts > BACKOFF_MS.length) return finish(d, 'failed', { lastStatus: res ? res.status : null, error: why, attempts });
-- await s.db.query(`UPDATE webhook_deliveries SET status = 'pending', attempts = $2, next_at = $3, last_status = $4, last_error = $5 WHERE id = $1`,
-- [d.id, attempts, now() + BACKOFF_MS[attempts - 1], res ? res.status : null, why]);
-- return undefined;
++ return retryLater(d, attempts, res ? res.status : null, why);
}
/** One pass over what is due. One pass at a time. */
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex d9b5b78..0e8f9e7 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -42,7 +42,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, bot: opts.bot, projects: opts.projects });
++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, adapters: opts.adapters, accountSend: opts.accountSend, webhookPost: opts.webhookPost, secretSigner: opts.secretSigner, bot: opts.bot, projects: opts.projects });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4337bba..b2feb1c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -112,6 +112,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
++ 'server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',
};
const root = path.join(__dirname, '..');
ddiff --git a/test/webhook-secret-ref.test.js b/test/webhook-secret-ref.test.js
nnew file mode 100644
iindex 0000000..c237414
---- /dev/null
++++ b/test/webhook-secret-ref.test.js
@@@ -0,0 +1,111 @@
++'use strict';
++/**
++ * Task webhooks signed with a project secret (ADR-056, secret_ref): a project's app names the secret; Actor mints no
++ * secret of its own for that webhook and never sees the value; each delivery is signed by OpenVibe.Network (here a
++ * stand-in signer holding the secret) over "<t>.<raw body>" and verifies with openvibe-sdk/events verifyDeliveryV2
++ * under the project secret; a secret Network refuses fails the delivery at once; Network being unreachable is retried
++ * on the backoff; a person's task (no project) cannot name one. (An app token always carries env: Network refuses one without.)
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const contracts = require('openvibe-contracts');
++const { verifyDeliveryV2 } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++const { checkWebhookUrl } = require('../server/net/webhook-post');
++const { SecretRefused } = require('../server/net/secret-signer');
++
++const HOOK = 'https://hooks.example.com/openvibe/actor';
++const PROJECT = 'prj_01JAB2C3D4E5F6G7H8J9K0MNPR';
++const SECRET = `ovs_${crypto.randomBytes(24).toString('hex')}`;
++
++(async () => {
++ let clock = Date.now();
++ const sent = [];
++ const webhookPost = async (url, { headers, body }) => { checkWebhookUrl(url); sent.push({ url, headers, body }); return { status: 200 }; };
++ // OpenVibe.Network's sign, as the project granted it: one secret, in production, to actor.
++ const signs = [];
++ let networkDown = false;
++ const secretSigner = {
++ enabled: true,
++ async sign({ project_id, environment, name, message }) {
++ signs.push({ project_id, environment, name, message });
++ if (networkDown) throw new Error('connect ECONNREFUSED 127.0.0.1:4000');
++ if (project_id !== PROJECT || environment !== 'production' || name !== 'webhook-signing') throw new SecretRefused(`project secret ${name} (${environment}) does not exist or is not granted to actor for sign`);
++ return { signature: crypto.createHmac('sha256', SECRET).update(message).digest('hex'), version: 3 };
++ },
++ };
++ const t = await boot({ webhookPost, secretSigner, now: () => clock });
++ const kim = t.network.addUser('kim');
++ const now = Math.floor(Date.now() / 1000);
++ const appToken = (extra = {}) => contracts.serviceAuth.signServiceToken({ iss: t.network.url, sub: 'app:app_01JAB2C3D4E5F6G7H8J9K0MNPT', actor_type: 'app', aud: ['openvibe.actor'], cap: ['actor.task.create', 'actor.task.read'], ns: [PROJECT], project_id: PROJECT, env: 'production', iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(6).toString('hex')}`, ...extra }, t.network.privatePem);
++ const deliveries = (taskId) => t.ctx.s.db.many('SELECT * FROM webhook_deliveries WHERE task_id = $1 ORDER BY created_at, id', [taskId]);
++ async function queued(taskId, n) {
++ for (let i = 0; i < 200; i++) { if ((await deliveries(taskId)).length >= n) return; await new Promise((res) => setTimeout(res, 20)); }
++ throw new Error(`${taskId}: ${n} deliveries never queued`);
++ }
++
++ let task;
++ await check('a project\'s app names a project secret: no webhook_secret is minted, and the task shows the name only', async () => {
++ t.providers.reset();
++ const r = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Say hello from an app', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ task = r.json();
++ assert.strictEqual('webhook_secret' in task, false, 'nothing minted');
++ assert.deepStrictEqual(task.webhooks, [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }]);
++ assert.ok(contracts.validate('platform.task@1', task).valid, JSON.stringify(contracts.validate('platform.task@1', task).errors));
++ assert.strictEqual(await t.ctx.s.db.value('SELECT webhook_secret FROM tasks WHERE id = $1', [task.id]), null);
++ });
++
++ await check('the delivery is signed by Network over "<t>.<raw body>" and verifies under the project secret', async () => {
++ await t.waitFor(task.id);
++ await queued(task.id, 1);
++ await t.ctx.webhooks.tick();
++ assert.strictEqual(sent.length, 1);
++ const d = sent[0];
++ assert.ok(verifyDeliveryV2(d.body, d.headers, SECRET, { now: clock }), 'verifyDeliveryV2 accepts it with the project secret');
++ assert.ok(!verifyDeliveryV2(d.body, d.headers, 'whsec_someone-elses', { now: clock }));
++ assert.deepStrictEqual([signs[0].project_id, signs[0].environment, signs[0].name], [PROJECT, 'production', 'webhook-signing']);
++ assert.strictEqual(signs[0].message, `${d.headers['X-OpenVibe-Timestamp']}.${d.body}`, 'Network signed exactly the timestamp and the body sent');
++ assert.strictEqual((await deliveries(task.id))[0].status, 'delivered');
++ });
++
++ await check('a secret Network refuses fails that delivery at once; Network unreachable is retried on the backoff', async () => {
++ t.providers.reset();
++ const r = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Say hello again', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'not-granted' }] } });
++ assert.strictEqual(r.status, 201, r.text);
++ await t.waitFor(r.json().id);
++ await queued(r.json().id, 1);
++ await t.ctx.webhooks.tick();
++ const refused = (await deliveries(r.json().id))[0];
++ assert.deepStrictEqual([refused.status, refused.attempts], ['failed', 1]);
++ assert.match(refused.last_error, /not granted to actor/);
++
++ t.providers.reset();
++ const r2 = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'And once more', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] } });
++ await t.waitFor(r2.json().id);
++ await queued(r2.json().id, 1);
++ networkDown = true;
++ const before = sent.length;
++ await t.ctx.webhooks.tick();
++ let row = (await deliveries(r2.json().id))[0];
++ assert.deepStrictEqual([row.status, row.attempts], ['pending', 1], 'retried later, with the attempt counted');
++ assert.match(row.last_error, /signing with the project secret/);
++ assert.strictEqual(sent.length, before, 'nothing unsigned was sent');
++ networkDown = false;
++ clock += 11_000;
++ await t.ctx.webhooks.tick();
++ row = (await deliveries(r2.json().id))[0];
++ assert.strictEqual(row.status, 'delivered');
++ });
++
++ await check('a person\'s own task cannot name a project secret; a webhook without one still gets the task\'s own secret', async () => {
++ const person = await t.get('/api/v1/tasks', { as: kim, json: { task: 'x', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }] }, headers: { 'sec-fetch-site': 'same-origin' } });
++ assert.deepStrictEqual([person.status, person.json().code], [422, 'actor.webhook.secret_unavailable']);
++ const mixed = await t.get('/api/v1/tasks', { bearer: appToken(), json: { task: 'Mixed hooks', webhooks: [{ url: HOOK, events: ['succeeded'], secret_ref: 'webhook-signing' }, { url: `${HOOK}/2`, events: ['succeeded'] }] } });
++ assert.strictEqual(mixed.status, 201, mixed.text);
++ assert.match(mixed.json().webhook_secret, /^whsec_/, 'a webhook without secret_ref still gets the task\'s own secret');
++ });
++
++ await t.close();
++ done();
++})();