openvibers/openvibe.network Commits

Subject assertions: Network signs the person's own answer (#584)

OP OpenVibers committed 7 days ago · 18228cbc45 · 12 files, +482 −20

* Subject assertions: Network signs a 60-second proof that the person allowed the call

* The wallet's journal read carries the person's own assertion

* Only a Network token for Network's own API may ask for an assertion

* Merge remote-tracking branch 'origin/main' into feat/h1-subject-assertion

* Pin openvibe-sdk v0.90.0 and openvibe-contracts v0.297.0

Changes

ddiff --git a/README.md b/README.md
iindex 38584b33..d6e7dd44 100644
---- a/README.md
++++ b/README.md
@@@ -95,7 +95,7 @@ person's username and picture (a service token, `identity.subject.resolve`).
## Depends on
--- `openvibe-contracts` v0.294.0 (registry manifests, capability checks), `openvibe-sdk` v0.82.0 (PostgreSQL
++- `openvibe-contracts` v0.297.0 (registry manifests, capability checks), `openvibe-sdk` v0.90.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v4.30.0, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
@@@ -1025,7 +1025,7 @@ Every change also sends the person a Network notification, so a swap nobody aske
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
--- openvibe-contracts: v0.294.0
--- openvibe-sdk: v0.82.0
++- openvibe-contracts: v0.297.0
++- openvibe-sdk: v0.90.0
- openvibe-shared: v4.115.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex c4fd20c0..5cce2210 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,8 +6,8 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.294.0",
-- "sdk": "openvibe-sdk v0.82.0",
++ "contracts": "openvibe-contracts v0.297.0",
++ "sdk": "openvibe-sdk v0.90.0",
"shared": "openvibe-shared v4.115.1",
"capabilities": [
"identity.subject.resolve",
ddiff --git a/package-lock.json b/package-lock.json
iindex e4adb51a..b835444c 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,8 +20,8 @@
"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.294.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0",
"qrcode": "1.5.4",
@@@ -1722,9 +1722,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.294.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
-- "integrity": "sha512-CBDymrE4VrgslAAgCTCvecNrVcaevhBcIrb4nU4ApkJasst+6xt3RdR9lktsdHuhBeipdlKAfaxLAFmRadkKqA==",
++ "version": "0.297.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "integrity": "sha512-FycRElRNzeFLEfzVUKIyE3ieKphUrQbAbsprnvs0Uu7ZdlBKJG6wQV/9gD77Neu1Etcsyezf9Boyq+MsjuraVA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1738,9 +1738,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.86.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
-- "integrity": "sha512-APn4qK1ErX9f/gR6l2AhGfj+n8/3ul87Otm69xN4wyJUt43WF6A2b0lMKDG9Ds6oQiOoCZJCjxCSanP0wtMuGA==",
++ "version": "0.90.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",
++ "integrity": "sha512-uOcYG+w5BVKePmSmCsmhFe6E1CNTr7/vLUqyOLSzsB9Z8D3VNbWsPCwOd5W0eY+3Dd4YhYIf4iT0QTIj9dinGg==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 2d5a32a9..2d2871fe 100644
---- a/package.json
++++ b/package.json
@@@ -24,8 +24,8 @@
"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.294.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.86.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.297.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.90.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
"pg": "^8.23.0",
"qrcode": "1.5.4",
ddiff --git a/server/config.js b/server/config.js
iindex 75b9d91f..fc3a8263 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -19,6 +19,11 @@ module.exports = {
// openssl rsa -in data/keys/private.pem -pubout -out data/keys/public.pem
privateKeyPath: process.env.JWT_PRIVATE_KEY || 'data/keys/private.pem',
publicKeyPath: process.env.JWT_PUBLIC_KEY || 'data/keys/public.pem',
++ // The EC (P-256) key that signs the subject assertions (ADR-003, the 2026-11-06 amendment;
++ // plan H1). It is published in the JWKS next to the RS256 key, so Billing and Tips can verify
++ // an assertion offline. It is generated and kept on first boot when the files are not there.
++ assertionPrivateKeyPath: process.env.JWT_ASSERTION_PRIVATE_KEY || 'data/keys/assertion-private.pem',
++ assertionPublicKeyPath: process.env.JWT_ASSERTION_PUBLIC_KEY || 'data/keys/assertion-public.pem',
accessTokenExpiry: '7d',
refreshTokenExpiry: '30d',
// issuer is the canonical public network URL — updated at runtime from registry
@@@ -26,6 +31,13 @@ module.exports = {
},
++ // Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1): the audiences Network signs an
++ // assertion for. The money services by default; a deployment may narrow or widen the list.
++ subjectAssertions: {
++ audiences: String(process.env.NETWORK_SUBJECT_ASSERTION_AUDIENCES || 'openvibe.billing,openvibe.tips')
++ .split(',').map((s) => s.trim()).filter(Boolean),
++ },
++
// Sign in on another device (owner, 2026-11-04; server/auth/qr-signin.js): a QR on the source device, a
// scan on the target and a two-digit match. Off unless NETWORK_QR_SIGNIN is explicitly on; off means the routes and /q are not mounted.
qrSignin: {
ddiff --git a/server/identity/subject-assertions.js b/server/identity/subject-assertions.js
nnew file mode 100644
iindex 00000000..280e9bb7
---- /dev/null
++++ b/server/identity/subject-assertions.js
@@@ -0,0 +1,237 @@
++'use strict';
++
++/**
++ * Subject assertions (ADR-003, amendment 2026-11-06; plan H1).
++ *
++ * A subject assertion is a short-lived, Network-signed proof that a person authorized the calling
++ * service to act for them on ONE other service (the audience). It is what a money call carries
++ * beside the name of the person:
++ *
++ * Authorization: Bearer <the service's own token> who is calling
++ * X-OV-Subject: usr_… who the service says it acts for
++ * X-OV-Subject-Assertion: <the assertion> that the person allowed it
++ *
++ * The assertion is minted here, and only here: POST /internal/subject-assertions takes the
++ * person's OWN Network access token (the service has it from their session) and signs it into an
++ * ES256 JWT that lives 60 seconds. Without the person's token nothing is signed: a service that
++ * knows a person's id cannot say the person allowed anything.
++ *
++ * The key is Network's own EC (P-256) key, published in the JWKS next to the RS256 session key
++ * (kid `ov-network-assertion-1`), so Billing and Tips can verify the signature offline.
++ *
++ * The claims (contract identity.subject-assertion-claims@1):
++ *
++ * iss the Network issuer
++ * sub the person: usr_…
++ * aud the service the assertion is for (openvibe.billing)
++ * act the service it was minted for (svc:live)
++ * jti unique per assertion, so a replay can be refused
++ * iat, exp 60 seconds
++ */
++const crypto = require('node:crypto');
++const fs = require('node:fs');
++const path = require('node:path');
++const jwt = require('jsonwebtoken');
++const { http, serviceAuth } = require('openvibe-contracts');
++
++const KID = 'ov-network-assertion-1';
++/** How long an assertion lives. */
++const TTL_S = 60;
++/** The audiences an assertion may be minted for: the money services (H1). */
++const DEFAULT_AUDIENCES = ['openvibe.billing', 'openvibe.tips'];
++const AUDIENCE_RE = /^openvibe\.[a-z0-9][a-z0-9.-]*$/;
++const SERVICE_RE = /^svc:[a-z][a-z0-9-]{1,39}$/;
++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++/**
++ * The EC key pair, loaded from disk or generated once and kept there.
++ * `data/keys/assertion-private.pem` (0600) and `data/keys/assertion-public.pem`. An environment
++ * pair wins, so a deployment may keep the key outside the repository.
++ */
++function loadAssertionKeys({ privateKeyPath, publicKeyPath, log = console } = {}) {
++ const priv = privateKeyPath ? path.resolve(privateKeyPath) : null;
++ const pub = publicKeyPath ? path.resolve(publicKeyPath) : null;
++ let privateKey = null;
++ let publicKey = null;
++ try {
++ if (priv) privateKey = fs.readFileSync(priv, 'utf8');
++ } catch { /* not there yet */ }
++ try {
++ if (pub) publicKey = fs.readFileSync(pub, 'utf8');
++ } catch { /* not there yet */ }
++ if (privateKey && !publicKey) {
++ // The private key is enough: the public half is derived from it.
++ try { publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' }).toString(); } catch { /* unreadable key */ }
++ }
++ if (!privateKey || !publicKey) {
++ const pair = crypto.generateKeyPairSync('ec', {
++ namedCurve: 'P-256',
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++ });
++ privateKey = pair.privateKey;
++ publicKey = pair.publicKey;
++ // Keep it: an assertion signed before a restart must still verify after it.
++ for (const [file, key] of [[priv, privateKey], [pub, publicKey]]) {
++ if (!file) continue;
++ try {
++ fs.mkdirSync(path.dirname(file), { recursive: true });
++ fs.writeFileSync(file, key, { mode: 0o600 });
++ } catch (err) {
++ (log && log.warn) ? log.warn(`[Assertions] the EC key could not be written to ${file}: ${err.message}; it will change on every restart`) : null;
++ }
++ }
++ }
++ return { privateKey, publicKey, kid: KID };
++}
++
++/** The JWK of the assertion key, as the JWKS publishes it. */
++function assertionJwk(publicKey) {
++ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
++ return { ...jwk, use: 'sig', alg: 'ES256', kid: KID };
++}
++
++/**
++ * Sign an assertion for a person.
++ *
++ * `act` is the service that vouched for the person (it held their token), `for` the services that
++ * may carry the assertion (the minting service alone by default) and `audience` the service (or the
++ * services, when the assertion travels a chain) the assertion is for.
++ */
++function signAssertion({ privateKey, subject, audience, act, for: forServices, issuer, ttlS = TTL_S, now = () => Date.now() }) {
++ const iat = Math.floor(now() / 1000);
++ const jti = `as_${crypto.randomBytes(12).toString('hex')}`;
++ const token = jwt.sign(
++ { sub: subject, act, ...(forServices && forServices.length ? { for: forServices } : {}) },
++ privateKey,
++ {
++ algorithm: 'ES256', keyid: KID, issuer,
++ audience: Array.isArray(audience) ? audience : [audience],
++ expiresIn: ttlS, jwtid: jti, noTimestamp: false,
++ },
++ );
++ return { assertion: token, jti, issued_at: iat, expires_at: iat + ttlS };
++}
++
++/** A small in-process limiter: one service may not mint assertions without bound. */
++function createLimiter({ perMinute = 120, now = () => Date.now() } = {}) {
++ const hits = new Map(); // key -> { count, at }
++ return function allow(key) {
++ const t = now();
++ const row = hits.get(key);
++ if (!row || t - row.at > 60000) { hits.set(key, { count: 1, at: t }); return true; }
++ row.count += 1;
++ if (hits.size > 1000) { for (const [k, v] of hits) if (t - v.at > 60000) hits.delete(k); }
++ return row.count <= perMinute;
++ };
++}
++
++/**
++ * The route: POST /internal/subject-assertions.
++ *
++ * A first-party service (a service token, never an app or a mod) hands over the person's own
++ * access token and the audience it needs an assertion for. Network checks that token is a live
++ * session of a person, that the person is the one the caller says it acts for, and signs a
++ * 60-second assertion naming the caller as `act`.
++ */
++function subjectAssertionRouter({ log = console } = {}) {
++ const limit = createLimiter();
++
++ /** The caller's own service token: the route takes nothing else. */
++ function requireService(req, res, next) {
++ const auth = String(req.headers.authorization || '');
++ if (!auth.startsWith('Bearer ')) {
++ return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx: req.ov });
++ }
++ const r = serviceAuth.verifyServiceToken(auth.slice(7).trim(), {
++ publicKey: req.app.locals.publicKey,
++ issuer: (req.app.locals.config || {}).jwt && req.app.locals.config.jwt.issuer,
++ // The token must be one Network itself issued for its own API: a token minted for another
++ // audience (a service talking to Billing, say) may not ask Network to sign anything.
++ audience: 'openvibe.network',
++ });
++ if (!r.ok) return http.sendProblem(res, 401, r.code, { detail: r.reason, ctx: req.ov });
++ // Only a first-party service may ask for an assertion. A developer app, a mod or an agent
++ // never gets one: their own authorization (on_behalf_of) is what they act on.
++ if (r.claims.actor_type !== 'service' || !/^svc:[a-z][a-z0-9-]{1,39}$/.test(String(r.claims.sub || ''))) {
++ return http.sendProblem(res, 403, 'assertion.not_a_service', { detail: 'only a first-party service may ask for a subject assertion', ctx: req.ov });
++ }
++ req.principal = { sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti };
++ return next();
++ }
++
++ async function handle(req, res) {
++ try {
++ const config = req.app.locals.config || {};
++ const audiences_allowed = (config.subjectAssertions && Array.isArray(config.subjectAssertions.audiences) && config.subjectAssertions.audiences.length)
++ ? config.subjectAssertions.audiences : DEFAULT_AUDIENCES;
++ const body = req.body || {};
++ const userToken = String(body.user_token || '').trim();
++ // The audience is one service, or a list when the assertion travels a chain (a tip goes
++ // from the site to Tips and on to Billing).
++ const audiences = (Array.isArray(body.audience) ? body.audience : [body.audience]).map((a) => String(a || '').trim()).filter(Boolean);
++ // The services that may carry it. The minting service is always one of them.
++ const forServices = [...new Set([req.principal.sub, ...(Array.isArray(body.for) ? body.for : body.for ? [body.for] : []).map((f) => String(f || '').trim()).filter(Boolean)])];
++ if (!userToken) return http.sendProblem(res, 401, 'assertion.no_user_token', { detail: "the person's own access token is required", ctx: req.ov });
++ if (!audiences.length || audiences.some((a) => !AUDIENCE_RE.test(a))) {
++ return http.sendProblem(res, 422, 'assertion.invalid_audience', { detail: 'the audience must be an openvibe.<service> id', ctx: req.ov });
++ }
++ if (audiences.some((a) => !audiences_allowed.includes(a))) {
++ return http.sendProblem(res, 403, 'assertion.audience_not_allowed', { detail: `no assertion is minted for ${audiences.join(', ')}`, ctx: req.ov });
++ }
++ if (forServices.some((f) => !SERVICE_RE.test(f))) {
++ return http.sendProblem(res, 422, 'assertion.invalid_for', { detail: 'the services that may carry the assertion are svc:<id>', ctx: req.ov });
++ }
++ if (!limit(req.principal.sub)) {
++ return http.sendProblem(res, 429, 'assertion.rate_limited', { detail: 'too many assertions for this service', ctx: req.ov });
++ }
++
++ // The person's own token: a live session, not a service or app token.
++ const session = await require('../auth/session').verifySession(userToken, { db: req.app.locals.db, publicKey: req.app.locals.publicKey, config });
++ if (session.error) {
++ return http.sendProblem(res, session.status || 401, 'assertion.invalid_user_token', { detail: session.error, ctx: req.ov });
++ }
++ const subject = await require('./subjects').ensureUserSubject(req.app.locals.db, session.user);
++ if (!subject || !USER_RE.test(String(subject))) {
++ return http.sendProblem(res, 403, 'assertion.no_subject', { detail: 'that account has no canonical subject yet', ctx: req.ov });
++ }
++ // The caller may only assert for the person it says it acts for.
++ const asked = body.subject ? String(body.subject).trim() : null;
++ if (asked && asked !== subject) {
++ return http.sendProblem(res, 403, 'assertion.subject_mismatch', { detail: 'that token names another person', ctx: req.ov });
++ }
++
++ const assertionKeys = req.app.locals.assertionKeys || {};
++ if (!assertionKeys.privateKey) {
++ return http.sendProblem(res, 503, 'assertion.unavailable', { detail: 'the assertion key is not loaded yet', ctx: req.ov });
++ }
++ const out = signAssertion({
++ privateKey: assertionKeys.privateKey,
++ subject,
++ audience: audiences.length === 1 ? audiences[0] : audiences,
++ act: req.principal.sub,
++ for: forServices,
++ issuer: config.jwt.issuer,
++ });
++ res.set('Cache-Control', 'no-store');
++ return res.json({
++ assertion: out.assertion,
++ subject,
++ audience: audiences.length === 1 ? audiences[0] : audiences,
++ act: req.principal.sub,
++ for: forServices,
++ jti: out.jti,
++ expires_at: new Date(out.expires_at * 1000).toISOString(),
++ expires_in: TTL_S,
++ });
++ } catch (err) {
++ (log && log.error) ? log.error('[Assertions] mint failed:', err && err.stack ? err.stack : err) : null;
++ return http.sendProblem(res, 500, 'internal.error', { detail: 'unexpected error', ctx: req.ov });
++ }
++ }
++
++ // The two middlewares: the caller's own service token, then the mint.
++ return [requireService, handle];
++}
++
++module.exports = { KID, TTL_S, DEFAULT_AUDIENCES, loadAssertionKeys, assertionJwk, signAssertion, subjectAssertionRouter, createLimiter };
ddiff --git a/server/index.js b/server/index.js
iindex 7e0f423b..dd68678b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -371,8 +371,17 @@ try {
console.warn('[Auth] Using ephemeral HS256 key — DO NOT use in production');
}
++// The EC key that signs the subject assertions (ADR-003, the 2026-11-06 amendment; plan H1). It is
++// published in the JWKS beside the RS256 key, so Billing and Tips verify an assertion offline.
++const assertionKeys = require('./identity/subject-assertions').loadAssertionKeys({
++ privateKeyPath: config.jwt.assertionPrivateKeyPath,
++ publicKeyPath: config.jwt.assertionPublicKeyPath,
++});
++console.log('[Auth] ES256 assertion keypair loaded (kid ' + assertionKeys.kid + ')');
++
// Make keys available to route modules
app.locals.db = db;
++app.locals.assertionKeys = assertionKeys;
app.locals.privateKey = privateKey;
// Network's own service tokens for its calls to Live, Media and Tools (identity/self-token.js).
const selfToken = require('./identity/self-token').createSelfTokens({ privateKey, issuer: config.jwt.issuer });
@@@ -635,12 +644,19 @@ app.get('/api/.well-known/jwks', (_req, res) => {
// anyone sign sessions. Only a real public key is ever served.
if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });
const out = { public_key: publicKey, algorithm: 'RS256' };
++ const keys = [];
if (publicKey.includes('BEGIN')) {
try {
const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });
-- out.keys = [{ ...jwk, use: 'sig', alg: 'RS256', kid: 'ov-network-1' }];
++ keys.push({ ...jwk, use: 'sig', alg: 'RS256', kid: 'ov-network-1' });
} catch { /* legacy shape still served */ }
}
++ // The subject-assertion key (ES256): Billing and Tips verify a subject assertion with it.
++ // It is a public key, like the RS256 one, and it never signs anything else.
++ try {
++ keys.push(require('./identity/subject-assertions').assertionJwk(app.locals.assertionKeys.publicKey));
++ } catch { /* the assertion key is not loaded */ }
++ if (keys.length) out.keys = keys;
res.json(out);
});
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex cb4d9d55..9d528bf4 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -55,6 +55,15 @@ 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'));
++// ── Subject assertions (ADR-003, the 2026-11-06 amendment; plan H1) ───────────────────
++// A first-party service hands over the person's own access token and gets back a 60-second
++// Network-signed assertion naming the person, the service it was minted for and the audience it is
++// for. Without the person's token nothing is signed: a service that knows a person's id cannot say
++// the person allowed anything (that is the H1 narrowing of "the service vouches for its person").
++router.post('/subject-assertions',
++ express.json({ limit: '16kb' }),
++ ...require('../identity/subject-assertions').subjectAssertionRouter());
++
// ── 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.
ddiff --git a/server/wallet/routes.js b/server/wallet/routes.js
iindex 2c28bad4..954c9411 100644
---- a/server/wallet/routes.js
++++ b/server/wallet/routes.js
@@@ -54,10 +54,14 @@ async function readJson(r) {
}
/** One request to Billing. The person's own bearer token unless `token` says otherwise. */
--async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS } = {}) {
++async function ask(url, { authorization, method = 'GET', body, timeoutMs = TIMEOUT_MS, assertion } = {}) {
const headers = { accept: 'application/json' };
if (authorization) headers.authorization = authorization;
if (body !== undefined) headers['content-type'] = 'application/json';
++ // The person's own subject assertion (ADR-003, the 2026-11-06 amendment; plan H1): Billing checks it
++ // against the person the read is for. Only a service-token read needs it; the person's own token is
++ // the proof already.
++ if (assertion) headers['x-ov-subject-assertion'] = String(assertion);
const r = await fetch(url, {
method, headers,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
@@@ -308,6 +312,34 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
* marked TEMPORARY FALLBACK reads the journal with Network's service token. The fallback is not seeded as a
* default grant: without an operator's explicit billing.balance.read grant it reads nothing.
*/
++ /**
++ * The person's own subject assertion for a service-token read of their money (ADR-003, the 2026-11-06
++ * amendment; plan H1). Network asks its own mint route for it: the person's Network token is on the
++ * request, and without it nothing is signed (so a read for a person whose token is not here has no
++ * assertion and travels the old way).
++ */
++ async function mintAssertion(req, subject) {
++ if (!selfToken) return null;
++ const auth = personToken(req);
++ if (!auth) return null;
++ const token = selfToken('openvibe.network', ['identity.subject.resolve']);
++ if (!token) return null;
++ const net = String(opts.networkInternalUrl || config.internalUrl || `http://127.0.0.1:${config.port || 4000}`).replace(/\/+$/, '');
++ try {
++ const res = await fetch(`${net}/internal/subject-assertions`, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
++ body: JSON.stringify({ user_token: auth.replace(/^Bearer\s+/i, '').trim(), audience: 'openvibe.billing', subject }),
++ signal: AbortSignal.timeout(2500),
++ });
++ if (!res.ok) return null;
++ const out = await res.json().catch(() => null);
++ return (out && out.assertion) || null;
++ } catch {
++ return null;
++ }
++ }
++
async function activityOf(req) {
const subject = req.user && req.user.subject_id;
if (!subject) return { rows: [], source: 'none' };
@@@ -328,7 +360,10 @@ module.exports = function createWalletRoutes(db, requireAuth, opts = {}) {
const token = selfToken(SERVICE_AUDIENCE, BALANCE_READ);
if (!token) return { rows: [], source: 'none' };
try {
-- const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}` });
++ // The person's own assertion: Network reads their journal with its service token, so it
++ // shows Billing the person's own answer instead of Network's word (ADR-003, plan H1).
++ const assertion = await mintAssertion(req, subject);
++ const out = await ask(`${billingUrl}/api/v1/transactions?subject=${encodeURIComponent(subject)}&limit=80`, { authorization: `Bearer ${token}`, assertion });
if (out.status !== 200 || !out.body) return { rows: [], source: 'none' };
return { rows: mapActivity(subject, out.body.transactions), source: 'journal' };
} catch {
ddiff --git a/test/security-secrets-reads.test.js b/test/security-secrets-reads.test.js
iindex d7882ba1..da390352 100644
---- a/test/security-secrets-reads.test.js
++++ b/test/security-secrets-reads.test.js
@@@ -41,12 +41,20 @@ withWorld('secrets-reads', async (w) => {
const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
assert.strictEqual(jwks.status, 200);
-- assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
++ // Two keys are published: the RS256 session key and the ES256 key the subject assertions are
++ // signed with (ADR-003, the 2026-11-06 amendment). Neither half of either private key is here.
++ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 2);
for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
const jwk = jwks.body.keys[0];
assert.strictEqual(jwk.kty, 'RSA');
assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
++ const assertionJwk = jwks.body.keys[1];
++ assert.strictEqual(assertionJwk.kty, 'EC', 'the assertion key is the EC one');
++ assert.strictEqual(assertionJwk.alg, 'ES256');
++ assert.strictEqual(assertionJwk.kid, 'ov-network-assertion-1');
++ assert.ok(assertionJwk.x && assertionJwk.y, 'the public halves are published');
++ assert.ok(!('d' in assertionJwk), 'the private half of the assertion key is not published');
assert.ok(!/PRIVATE/.test(jwks.text));
await checkSecretsStored(w, needlesFor);
}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 69758594..09565643 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -121,7 +121,7 @@ const REVIEWED = {
'server/domains/catalog.js fetch': [3, 'the tools catalog URL from operator config'],
'server/today/live-follows.js fetch': [1, 'Live\'s internal URL from config (OV_LIVE_INTERNAL_URL): its public list of live streams, fixed path, 2.5 s timeout, held 10 s; never a request value'],
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
-- 'server/wallet/routes.js fetch': [1, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host'],
++ 'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
'server/frame/service.js fetch': [3, 'Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],
ddiff --git a/test/subject-assertions.test.js b/test/subject-assertions.test.js
nnew file mode 100644
iindex 00000000..680f2cc4
---- /dev/null
++++ b/test/subject-assertions.test.js
@@@ -0,0 +1,145 @@
++'use strict';
++// Subject assertions (server/identity/subject-assertions.js): a first-party service signs the
++// person's own access token into a 60-second assertion, and nothing is signed without it.
++// ADR-003, the 2026-11-06 amendment; plan H1.
++// node test/subject-assertions.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const jwt = require('jsonwebtoken');
++const { serviceAuth } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const assertions = require('../server/identity/subject-assertions');
++
++(async () => {
++const db = await getDb();
++const ISSUER = 'https://openvibe.network';
++
++await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (77, 'payer', 'x') ON CONFLICT (id) DO NOTHING").run();
++const person = await db.prepare('SELECT id, subject_id FROM users WHERE id = 77').get();
++person.subject_id = await require('../server/identity/subjects').ensureUserSubject(db, person);
++assert.ok(person.subject_id, 'the person has a canonical subject');
++
++const rsa = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++const keys = assertions.loadAssertionKeys({ privateKeyPath: null, publicKeyPath: null, log: { warn: () => {} } });
++
++const app = express();
++app.use(express.json());
++app.locals.db = db;
++app.locals.privateKey = rsa.privateKey;
++app.locals.publicKey = rsa.publicKey;
++app.locals.assertionKeys = keys;
++app.locals.config = { jwt: { issuer: ISSUER }, subjectAssertions: { audiences: ['openvibe.billing', 'openvibe.tips'] } };
++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}`;
++
++/** A service token (client_credentials, signed here) for the service `svc:<id>`. */
++const serviceToken = (id, over = {}) => serviceAuth.signServiceToken({
++ iss: ISSUER, sub: `svc:${id}`, actor_type: 'service', aud: ['openvibe.network'], cap: [], ns: [],
++ iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...over,
++}, rsa.privateKey);
++/** The person's own access token (a Network session). */
++const personToken = () => jwt.sign({ sub: '77' }, rsa.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '1h' });
++
++const post = (body, headers) => fetch(`${base}/internal/subject-assertions`, {
++ method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body),
++}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++
++try {
++ // ── The happy path ──
++ const out = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(out.status, 200, JSON.stringify(out.body));
++ assert.ok(out.body.assertion, 'an assertion comes back');
++ assert.strictEqual(out.body.subject, person.subject_id, 'it names the person');
++ assert.strictEqual(out.body.audience, 'openvibe.billing');
++ assert.strictEqual(out.body.act, 'svc:tips', 'it names the service it was minted for');
++ assert.strictEqual(out.body.expires_in, 60);
++
++ // The assertion verifies with Network's published key and carries the H1 claims.
++ const decoded = jwt.decode(out.body.assertion);
++ assert.strictEqual(decoded.iss, ISSUER);
++ assert.strictEqual(decoded.sub, person.subject_id);
++ assert.deepStrictEqual(decoded.aud, ['openvibe.billing']);
++ assert.strictEqual(decoded.act, 'svc:tips');
++ assert.ok(decoded.jti, 'it has a jti');
++ assert.strictEqual(decoded.exp - decoded.iat, 60, 'it lives 60 seconds');
++ const header = JSON.parse(Buffer.from(out.body.assertion.split('.')[0], 'base64url').toString('utf8'));
++ assert.strictEqual(header.alg, 'ES256');
++ assert.strictEqual(header.kid, 'ov-network-assertion-1');
++ const verified = jwt.verify(out.body.assertion, keys.publicKey, { algorithms: ['ES256'], issuer: ISSUER, audience: 'openvibe.billing' });
++ assert.strictEqual(verified.sub, person.subject_id);
++
++ // The key is published in the JWKS, so a receiver can verify it offline.
++ const jwk = assertions.assertionJwk(keys.publicKey);
++ assert.strictEqual(jwk.kty, 'EC');
++ assert.strictEqual(jwk.alg, 'ES256');
++ assert.strictEqual(jwk.kid, 'ov-network-assertion-1');
++ assert.ok(jwk.x && jwk.y && !jwk.d, 'the private half is not published');
++
++ // ── Without the person's token, nothing is signed ──
++ const noToken = await post({ audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(noToken.status, 401);
++ assert.strictEqual(noToken.body.code, 'assertion.no_user_token');
++
++ const badToken = await post({ user_token: 'not-a-token', audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(badToken.status, 401);
++ assert.strictEqual(badToken.body.code, 'assertion.invalid_user_token');
++
++ // A service token of a service, naming no person: the service may not vouch for itself.
++ const noService = await post({ user_token: personToken(), audience: 'openvibe.billing' }, {});
++ assert.strictEqual(noService.status, 401);
++ assert.strictEqual(noService.body.code, 'token.missing');
++
++ // A developer app token is not a first-party service: it never gets an assertion.
++ const appToken = serviceToken('live', { sub: 'app:app_01H8XYZABCDEFGHJKMNPQRRSTV', actor_type: 'app', project_id: 'prj_01H8XYZABCDEFGHJKMNPQRRSTV', env: 'production' });
++ const notService = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${appToken}` });
++ assert.strictEqual(notService.status, 403);
++ assert.strictEqual(notService.body.code, 'assertion.not_a_service');
++
++ // A token minted for another audience (a service talking to Billing) may not ask Network to sign.
++ const wrongAudience = await post({ user_token: personToken(), audience: 'openvibe.billing' }, { authorization: `Bearer ${serviceToken('tips', { aud: ['openvibe.billing'] })}` });
++ assert.strictEqual(wrongAudience.status, 401);
++ assert.strictEqual(wrongAudience.body.code, 'token.wrong_audience');
++
++ // ── The person is the one the token names ──
++ await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (78, 'other', 'x') ON CONFLICT (id) DO NOTHING").run();
++ const otherSubject = await require('../server/identity/subjects').ensureUserSubject(db, await db.prepare('SELECT id, subject_id FROM users WHERE id = 78').get());
++ const mismatch = await post({ user_token: personToken(), audience: 'openvibe.billing', subject: otherSubject }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(mismatch.status, 403);
++ assert.strictEqual(mismatch.body.code, 'assertion.subject_mismatch');
++
++ const same = await post({ user_token: personToken(), audience: 'openvibe.billing', subject: person.subject_id }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(same.status, 200, JSON.stringify(same.body));
++
++ // ── The audience is one Network signs assertions for ──
++ const badAudience = await post({ user_token: personToken(), audience: 'openvibe.somethingelse' }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(badAudience.status, 403);
++ assert.strictEqual(badAudience.body.code, 'assertion.audience_not_allowed');
++
++ const noAudience = await post({ user_token: personToken() }, { authorization: `Bearer ${serviceToken('tips')}` });
++ assert.strictEqual(noAudience.status, 422);
++ assert.strictEqual(noAudience.body.code, 'assertion.invalid_audience');
++
++ // ── The chain: an assertion that travels from the site to Tips and on to Billing ──
++ const chained = await post({ user_token: personToken(), audience: ['openvibe.tips', 'openvibe.billing'], for: ['svc:tips'] }, { authorization: `Bearer ${serviceToken('live')}` });
++ assert.strictEqual(chained.status, 200, JSON.stringify(chained.body));
++ const chainClaims = jwt.decode(chained.body.assertion);
++ assert.deepStrictEqual(chainClaims.aud, ['openvibe.tips', 'openvibe.billing']);
++ assert.deepStrictEqual(chainClaims.for, ['svc:live', 'svc:tips'], 'the minting service is always one that may carry it');
++ assert.strictEqual(chainClaims.act, 'svc:live', 'it names the service that vouched for the person');
++ assert.strictEqual(chainClaims.sub, person.subject_id);
++
++ // A service that is not named may not carry it.
++ const stranger = await post({ user_token: personToken(), audience: ['openvibe.tips'], for: ['nope'] }, { authorization: `Bearer ${serviceToken('live')}` });
++ assert.strictEqual(stranger.status, 422);
++ assert.strictEqual(stranger.body.code, 'assertion.invalid_for');
++
++ console.log('ok - subject assertions: minted with the person\'s own token, refused without it');
++} finally {
++ server.close();
++ await db.close();
++}
++})().catch((err) => { console.error(err); process.exit(1); });