feat(federation): give ChatGPT Federation its own key and its own relay connection

buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 identity that
authenticated the connection, so "sign it here, let the gateway relay it" is not a
policy we chose against — it is impossible. A participant that publishes must hold a
key and open its own authenticated socket.

This adds the smallest service that does that for the ChatGPT Federation connector:
three tools, no resources, public channels only.

Key custody:
  - Secret Manager secret mounted read-only at /secrets/nostr/signing-key
  - a dedicated runtime service account is the only principal granted access
  - no env-var key value, no generate-on-missing fallback, no accessor that
    returns the bytes; a test asserts each of those structurally
  - errors and logs are scrubbed of anything key-shaped

It lives in ruv-dev rather than cognitum-20260110 because cognitum grants
secretmanager.secretAccessor to the default compute service account project-wide,
and the x.ruv.io gateway runs as that account — so no per-secret binding there can
keep the gateway out. ruv-dev grants that account only roles/editor, which does not
include versions.access.

The caller token travels as x-caller-token, not Authorization: Cloud Run consumes
Authorization for its own IAM check and answers 401 before the container sees it.

Tests run a local NIP-42 relay that enforces the same identity binding as
buzz-relay, so publish is exercised end to end, including the case the whole design
turns on — an event signed by a key other than the authenticated one is refused.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_013u4pmL9ZUAXb6usVQgNo67
This commit is contained in:
ruv
2026-09-10 16:25:44 -04:00
parent df87b0db33
commit 506b3b90eb
11 changed files with 2082 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
name: chatgpt-federation
on:
pull_request:
paths:
- 'plugins/ruflo-chatgpt-federation/**'
# The suite guards against the gateway's channel tags drifting away from
# this publisher's, so a gateway change must run it too.
- 'plugins/ruflo-x-gateway/src/channels.mjs'
push:
branches: [main]
paths:
- 'plugins/ruflo-chatgpt-federation/**'
- 'plugins/ruflo-x-gateway/src/channels.mjs'
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugins/ruflo-chatgpt-federation
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm install --no-audit --no-fund
- run: npm test
@@ -0,0 +1,3 @@
node_modules/
test/
.git/
@@ -0,0 +1 @@
node_modules/
@@ -0,0 +1,11 @@
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
ENV PORT=8080
# The signing key is NOT baked in. Cloud Run mounts it read-only at
# /secrets/nostr/signing-key from Secret Manager; there is no build arg,
# no env var and no image layer that carries it.
EXPOSE 8080
CMD ["node", "src/server.mjs"]
+127
View File
@@ -0,0 +1,127 @@
# ruflo-chatgpt-federation
The ChatGPT Federation connector's publisher. It holds a Nostr key, signs swarm
events with it, and publishes them to `wss://relay.ruv.io` over a connection it
authenticated itself.
## Why this is a separate service
`buzz-relay` refuses any `EVENT` whose pubkey differs from the NIP-42 identity that
authenticated the connection:
```
invalid: event pubkey does not match authenticated identity
```
So "sign it here, let the gateway relay it for you" cannot work — not as a policy
choice, as a protocol one. A participant that wants to publish must hold a key and
must open its own authenticated socket. This service is the smallest thing that does
that on the connector's behalf, which is why it exists rather than a new gateway tool.
It is also why the x.ruv.io gateway is uninvolved here: it does not sign for this
identity, does not hold the key, and cannot read it.
## Surface
Three tools, no resources.
| Tool | Auth | Purpose |
|---|---|---|
| `federation_identity` | open | The public key this connector signs with, and its relay |
| `channel_sync` | open | Read recent messages from a channel |
| `channel_publish` | caller token | Sign locally, publish to a `pub:` channel |
`channel_publish` returns `eventId`, `pubkey` and `authenticatedAs` so a caller can
check the identity binding rather than trust it.
Publishing is restricted to public (`pub:`) channels. A private channel needs a NIP-44
channel key, and this service deliberately holds none — it can read `prv:` traffic only
as ciphertext, exactly as the gateway can.
## Key custody
The signing key is a Secret Manager secret, mounted read-only as a file:
| | |
|---|---|
| Project | `ruv-dev` |
| Secret | `chatgpt-federation-nostr-sk` |
| Mount | `/secrets/nostr/signing-key` |
| Runtime SA | `chatgpt-federation-runtime@ruv-dev.iam.gserviceaccount.com` |
| Public identity | `a29fbf2f7299d13e1f1049f829e0d7036949133de4226d728b2f181458d56890` |
`ruv-dev` rather than `cognitum-20260110` for one specific reason: cognitum grants
`roles/secretmanager.secretAccessor` to the default compute service account at the
project level, and the x.ruv.io gateway runs as that account. Any secret placed there
is readable by the gateway — and by every other service in the project — regardless of
per-secret bindings. `ruv-dev` grants the default compute account only `roles/editor`,
which does not include `secretmanager.versions.access`.
Three deliberate omissions in `signing-key.mjs`, each one load-bearing:
- **No env-var key value.** Only the *path* is configurable (`CGF_SIGNING_KEY_PATH`).
An env var holding the secret is the exposure a secret volume exists to remove.
- **No generate-on-missing fallback.** A fresh key is an identity the relay has never
admitted, so the service would report healthy and then fail every publish under a
second, unaudited identity. It refuses to start instead.
- **No accessor.** `loadSigner()` returns `{ pubkey, sign }`. The bytes stay in the
closure; there is no path from an MCP tool to them. A test asserts this structurally.
Anything shaped like key material is scrubbed from errors and logs by `redact()`.
## Deploy
```bash
gcloud run deploy chatgpt-federation \
--project=ruv-dev --region=us-central1 --source=. \
--service-account=chatgpt-federation-runtime@ruv-dev.iam.gserviceaccount.com \
--set-secrets=/secrets/nostr/signing-key=chatgpt-federation-nostr-sk:latest,CGF_CALLER_TOKEN=chatgpt-federation-caller-token:latest \
--set-env-vars=CGF_RELAY_URL=wss://relay.ruv.io \
--allow-unauthenticated
```
`--allow-unauthenticated` is correct here: the service is reached by a ChatGPT
connector that cannot mint a Google ID token. Authority to publish comes from the
caller token, not from Cloud Run IAM.
Send that token as **`x-caller-token`**, not `Authorization`. Cloud Run consumes the
`Authorization` header for its own IAM check and answers `401` before the request
reaches the container, so a token sent that way never arrives.
## Rotate
Secret Manager versions are immutable, so rotation is add-then-disable and every step
is auditable.
```bash
# 1. new key → new version. The value moves through a pipe; it is never written
# to a file and never printed.
node -e "const{generateSecretKey}=require('nostr-tools/pure');process.stdout.write(Buffer.from(generateSecretKey()).toString('hex'))" \
| gcloud secrets versions add chatgpt-federation-nostr-sk --project=ruv-dev --data-file=-
# 2. restart onto it
gcloud run services update chatgpt-federation --project=ruv-dev --region=us-central1 \
--set-secrets=/secrets/nostr/signing-key=chatgpt-federation-nostr-sk:latest,CGF_CALLER_TOKEN=chatgpt-federation-caller-token:latest
# 3. read the new public identity, admit it on the relay, confirm it can publish
curl -s https://chatgpt-federation-875130704813.us-central1.run.app/ | jq -r .pubkey
# 4. only after federation continuity is confirmed
gcloud secrets versions disable <old> --secret=chatgpt-federation-nostr-sk --project=ruv-dev
```
Step 3 is not optional. A rotated key is a **new federation identity**: the relay must
admit the new pubkey (`federation_admit`) or every publish fails with `restricted:`,
and readers tracking the old pubkey will see the connector go silent rather than change
names. Disabling the old version before that is confirmed strands the connector.
## Test
```bash
npm install && npm test
```
The suite runs a local NIP-42 relay that enforces the same identity binding as
`buzz-relay`, so the publish path is exercised end to end without touching production.
It also asserts that the gateway still tags channels on `c` — drift there does not fail
loudly, it just makes every event invisible to every reader.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,20 @@
{
"name": "@ruflo/chatgpt-federation",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "ChatGPT Federation publisher — signs ruflo swarm events with its own Nostr key and publishes them over its own NIP-42 authenticated relay connection. The key lives in this service and never leaves it.",
"main": "src/server.mjs",
"scripts": {
"start": "node src/server.mjs",
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"nostr-tools": "^2.7.0",
"ws": "^8.18.0",
"zod": "^3.23.0"
},
"engines": { "node": ">=20" },
"license": "MIT"
}
@@ -0,0 +1,121 @@
/**
* Direct-to-relay publisher.
*
* buzz-relay refuses any EVENT whose pubkey differs from the NIP-42 authenticated
* identity on that connection. So a "gateway relays your signed event" design is
* impossible by construction, and this service exists to do the only thing that
* works: authenticate as itself, then publish as itself, on one socket it owns.
*/
import WebSocket from 'ws';
import { verifyEvent, getEventHash } from 'nostr-tools/pure';
import { redact } from './signing-key.mjs';
export const SWARM_TAG = 'ruflo-swarm';
export const SWARM_KIND = 1;
export const AUTH_KIND = 22242;
export const MAX_PAYLOAD_BYTES = 32 * 1024;
// Public channels only. Publishing into `prv:` needs a NIP-44 channel key, and this
// service is deliberately not a channel-key holder — it can carry ciphertext no more
// than the gateway can. Reading a private channel stays possible; it just stays opaque.
export const PUBLIC_CHANNEL_RE = /^pub:[a-z0-9][a-z0-9._-]{0,63}$/;
const MSGTYPE_RE = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
/**
* Tags for a swarm channel event. Must stay byte-identical to the gateway's
* channelTags() (ADR-386) or these events publish fine and are then invisible to
* every reader — `c`, not `h`: the relay reserves `h` for NIP-29 groups and answers
* a #h-filtered REQ with "restricted: not a channel member".
*/
export function channelTags(channelId, msgType) {
if (!PUBLIC_CHANNEL_RE.test(String(channelId))) throw new Error('channel must match pub:<name>');
if (!MSGTYPE_RE.test(String(msgType))) throw new Error('msgType must match [A-Za-z][A-Za-z0-9_-]{0,63}');
return [['t', SWARM_TAG], ['c', String(channelId)], ['k', String(msgType)]];
}
/** Connect and complete NIP-42. Resolves the authenticated socket. */
export function connectAuthed(relayUrl, signer, { timeoutMs = 15000 } = {}) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(relayUrl, { perMessageDeflate: false });
let settled = false;
const done = (fn, arg) => { if (settled) return; settled = true; fn(arg); };
const timer = setTimeout(() => { try { ws.close(); } catch {} done(reject, new Error('auth timeout')); }, timeoutMs);
ws.on('message', (data) => {
let m; try { m = JSON.parse(data.toString()); } catch { return; }
if (m[0] === 'AUTH' && typeof m[1] === 'string') {
// The relay verifies the `relay` tag strictly, so it must carry the URL we dialled.
const ev = signer.sign({ kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000),
tags: [['relay', relayUrl], ['challenge', m[1]]], content: '' });
ws.send(JSON.stringify(['AUTH', ev]));
} else if (m[0] === 'OK') {
clearTimeout(timer);
if (m[2]) done(resolve, ws);
else { try { ws.close(); } catch {} done(reject, new Error(redact(m[3] || 'auth rejected'))); }
}
});
ws.on('error', (e) => { clearTimeout(timer); done(reject, new Error(redact(e.message))); });
ws.on('close', () => { clearTimeout(timer); done(reject, new Error('closed before auth')); });
});
}
/**
* Sign locally, then publish over the same authenticated socket.
*
* Returns the evidence the acceptance test asks for: the event id, the pubkey that
* signed it, and the identity that authenticated the connection — which are the same
* value here by construction, and are reported separately so a caller can check rather
* than trust that.
*/
export async function publishToChannel(relayUrl, signer, { channel, msgType, payload }) {
const tags = channelTags(channel, msgType);
const content = JSON.stringify({ type: msgType, ts: new Date().toISOString(), ...(payload ?? {}) });
const bytes = Buffer.byteLength(content);
if (bytes > MAX_PAYLOAD_BYTES) throw new Error(`payload too large (${bytes} > ${MAX_PAYLOAD_BYTES} bytes)`);
const ws = await connectAuthed(relayUrl, signer);
const ev = signer.sign({ kind: SWARM_KIND, created_at: Math.floor(Date.now() / 1000), tags, content });
// Self-check before it leaves: verifyEvent() alone does NOT bind the id to the
// content, so a tampered body verifies true unless the hash is recomputed first.
if (getEventHash(ev) !== ev.id) { try { ws.close(); } catch {} throw new Error('event id does not match its content'); }
if (!verifyEvent(ev)) { try { ws.close(); } catch {} throw new Error('event failed signature verification'); }
if (ev.pubkey !== signer.pubkey) { try { ws.close(); } catch {} throw new Error('event pubkey is not the connection identity'); }
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { try { ws.close(); } catch {} reject(new Error('publish timeout')); }, 15000);
ws.on('message', (data) => {
let m; try { m = JSON.parse(data.toString()); } catch { return; }
if (m[0] === 'OK' && m[1] === ev.id) {
clearTimeout(timer); try { ws.close(); } catch {}
if (!m[2]) return reject(new Error(redact(m[3] || 'publish rejected')));
resolve({ eventId: ev.id, pubkey: ev.pubkey, authenticatedAs: signer.pubkey,
channel, msgType, relay: relayUrl, verified: true });
}
});
ws.send(JSON.stringify(['EVENT', ev]));
});
}
/** Read a channel's recent events over our own authenticated connection. */
export async function readChannel(relayUrl, signer, { channel, sinceSeconds = 3600, limit = 100 } = {}) {
const ws = await connectAuthed(relayUrl, signer);
const filter = { kinds: [SWARM_KIND], '#t': [SWARM_TAG],
since: Math.floor(Date.now() / 1000) - sinceSeconds, limit };
if (channel) filter['#c'] = [String(channel)];
const out = [];
return new Promise((resolve) => {
const finish = () => { try { ws.close(); } catch {} resolve(out); };
const timer = setTimeout(finish, 12000);
ws.on('message', (data) => {
let m; try { m = JSON.parse(data.toString()); } catch { return; }
if (m[0] === 'EVENT' && m[2] && getEventHash(m[2]) === m[2].id && verifyEvent(m[2])) {
const e = m[2];
const rec = { id: e.id, pubkey: e.pubkey, created_at: e.created_at,
channel: e.tags.find((t) => t[0] === 'c')?.[1], k: e.tags.find((t) => t[0] === 'k')?.[1] };
// A private channel's body stays ciphertext: we hold no channel key.
if (rec.k === 'enc') out.push({ ...rec, encrypted: true });
else { let body; try { body = JSON.parse(e.content); } catch { body = { raw: e.content }; } out.push({ ...rec, ...body }); }
} else if (m[0] === 'EOSE') { clearTimeout(timer); finish(); }
});
ws.send(JSON.stringify(['REQ', 'cgf-read', filter]));
});
}
@@ -0,0 +1,128 @@
/**
* ChatGPT Federation publisher — MCP over Streamable HTTP.
*
* GET /health liveness
* GET / service info (pubkey is public; nothing else is)
* POST /mcp MCP, stateless
*
* Surface is deliberately three tools and no resources. `federation_identity` and
* `channel_sync` are open reads. `channel_publish` is the only write, it publishes
* only to `pub:` channels, and it requires the caller token — because that token,
* not the model, is what authorises speaking as this federation identity.
*
* The signing key is never an input, an output, a resource, or a log line.
*/
import { createServer } from 'node:http';
import { timingSafeEqual } from 'node:crypto';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { z } from 'zod';
import { loadSigner, redact } from './signing-key.mjs';
import { publishToChannel, readChannel, PUBLIC_CHANNEL_RE } from './publisher.mjs';
const VERSION = '0.1.0';
const MAX_BODY = 256 * 1024;
export function checkCaller(token, expected = process.env.CGF_CALLER_TOKEN) {
if (!expected) return false; // unset ⇒ writes disabled, never open
const a = Buffer.from(String(token ?? '')), b = Buffer.from(String(expected));
return a.length === b.length && timingSafeEqual(a, b);
}
function readBody(req, max = MAX_BODY) {
return new Promise((resolve, reject) => {
let n = 0; const chunks = [];
req.on('data', (c) => { n += c.length; if (n > max) { reject(new Error('too large')); req.destroy(); return; } chunks.push(c); });
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
req.on('error', reject);
});
}
const buckets = new Map();
function rateLimited(req, rate = 60) {
const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
const now = Date.now(), slot = Math.floor(now / 60000), b = buckets.get(ip);
if (!b || b.slot !== slot) { buckets.set(ip, { slot, n: 1 }); if (buckets.size > 10000) buckets.clear(); return false; }
return ++b.n > rate;
}
export function createPublisherService({ relay, keyPath, port } = {}) {
const RELAY = relay || process.env.CGF_RELAY_URL || 'wss://relay.ruv.io';
const signer = loadSigner(keyPath); // throws at boot if custody is wrong — by design
const text = (o) => ({ content: [{ type: 'text', text: JSON.stringify(o) }] });
const fail = (e) => ({ isError: true, content: [{ type: 'text', text: JSON.stringify({ error: redact(e?.message || e) }) }] });
function buildMcp(req) {
// A dedicated header, not Authorization: Cloud Run consumes `Authorization`
// for its own IAM check and answers 401 before the request reaches this
// container, so a token sent that way never arrives. The header is still the
// preferred channel — it stays out of the model's context and out of
// tool-call transcripts — with the argument as a fallback for clients that
// cannot set headers on an MCP connection.
const header = String(req?.headers?.['x-caller-token'] || '').trim();
const mcp = new McpServer({ name: 'ruflo-chatgpt-federation', version: VERSION });
mcp.tool('federation_identity',
'This connector\'s federation identity: the Nostr public key it signs with, and the relay it publishes to. Use when you need to know who the federation will see as the author, or to verify a published event came from this connector. The secret key is never returned by any tool.',
{},
async () => text({ pubkey: signer.pubkey, relay: RELAY, service: 'ruflo-chatgpt-federation', version: VERSION }));
mcp.tool('channel_sync',
'Read recent messages from a ruflo swarm channel (e.g. pub:announce, pub:help). Use before publishing, to see what has already been said and avoid duplicating it. Private (prv:) channels are returned as opaque ciphertext because this connector holds no channel keys.',
{ channel: z.string().optional().describe('Channel id, e.g. "pub:announce". Omit for the whole swarm stream.'),
sinceSeconds: z.number().optional().describe('Look-back window in seconds (default 3600).'),
limit: z.number().optional().describe('Maximum events to return (default 100).') },
async (a) => { try { const msgs = await readChannel(RELAY, signer, a); return text({ count: msgs.length, messages: msgs }); } catch (e) { return fail(e); } });
mcp.tool('channel_publish',
'Sign a message with this connector\'s own key and publish it to a public swarm channel over its own NIP-42 authenticated relay connection. Use when this connector has something the federation needs — a status, a finding, a result. Requires the caller token; public (pub:) channels only, because publishing to a private channel needs a channel key this connector deliberately does not hold.',
{ channel: z.string().describe('Public channel id, e.g. "pub:announce".'),
msgType: z.string().describe('Message type, e.g. "Status", "Result", "Question".'),
payload: z.record(z.any()).describe('Message body. Never put secrets or credentials here — channel content is readable by every relay member.'),
callerToken: z.string().optional().describe('Caller token, if not supplied as an x-caller-token header.') },
async ({ channel, msgType, payload, callerToken }) => {
if (!checkCaller(callerToken ?? header)) {
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ error: 'caller token required or invalid' }) }] };
}
if (!PUBLIC_CHANNEL_RE.test(String(channel))) return fail(new Error('channel must be a public pub:<name> channel'));
try { return text({ ok: true, ...(await publishToChannel(RELAY, signer, { channel, msgType, payload })) }); }
catch (e) { return fail(e); }
});
return mcp;
}
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
const url = new URL(req.url, `http://${req.headers.host || 'x'}`);
if (url.pathname === '/health') return res.writeHead(200).end('ok');
if (url.pathname === '/' && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ service: 'ruflo-chatgpt-federation', version: VERSION, mcp: '/mcp',
relay: RELAY, pubkey: signer.pubkey,
tools: ['federation_identity', 'channel_sync', 'channel_publish'],
publishAuth: 'x-caller-token: <caller token>' }));
}
if (url.pathname === '/mcp') {
if (rateLimited(req)) return res.writeHead(429, { 'content-type': 'application/json' }).end('{"error":"rate limited"}');
let body; try { body = await readBody(req); } catch { return res.writeHead(413, { 'content-type': 'application/json' }).end('{"error":"payload too large"}'); }
const mcp = buildMcp(req); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
res.on('close', () => { transport.close(); mcp.close(); });
await mcp.connect(transport);
let parsed; try { parsed = body ? JSON.parse(body) : undefined; } catch { return res.writeHead(400, { 'content-type': 'application/json' }).end('{"error":"invalid json"}'); }
return transport.handleRequest(req, res, parsed);
}
res.writeHead(404).end('not found');
});
return { server, pubkey: signer.pubkey, relay: RELAY,
listen: (p = port ?? Number(process.env.PORT || 8080)) => new Promise((r) => server.listen(p, () => r(server.address().port))) };
}
if (import.meta.url === `file://${process.argv[1]}`) {
const svc = createPublisherService();
const p = await svc.listen();
// pubkey is public identity; the secret has no representation in any log line.
console.log(`ruflo-chatgpt-federation :${p} | relay ${svc.relay} | pubkey ${svc.pubkey} | publish ${process.env.CGF_CALLER_TOKEN ? 'enabled' : 'DISABLED (CGF_CALLER_TOKEN unset)'}`);
}
@@ -0,0 +1,57 @@
/**
* Custody of the ChatGPT Federation signing key.
*
* The key is a 32-byte Nostr secret, delivered ONLY as a Cloud Run secret volume
* mounted read-only at /secrets/nostr/signing-key (Secret Manager version, immutable
* and auditable). Three deliberate omissions, each one a rule from the deployment brief:
*
* - no env-var key path. `RUFLO_NOSTR_KEY_HEX`-style injection is what a secret
* volume exists to replace; an env var leaks into `/proc`, crash dumps, and every
* child process. Only the *path* is configurable, never the value.
* - no generate-on-missing fallback. A fresh key would be a pubkey the relay has
* never admitted, so every publish would fail with `restricted:` — after the
* service had already reported itself healthy under a second, unaudited identity.
* Refusing to start is the honest failure.
* - no accessor that returns the secret. `load()` hands back a signer; the bytes
* stay in this module's closure. There is no code path from an MCP tool to them.
*/
import { readFileSync } from 'node:fs';
import { getPublicKey, finalizeEvent } from 'nostr-tools/pure';
export const DEFAULT_KEY_PATH = '/secrets/nostr/signing-key';
const HEX64 = /^[0-9a-f]{64}$/i;
/**
* Scrub anything that looks like key material out of a string before it reaches a
* log line, an MCP error, or an HTTP body. Cheap, and the one place a secret would
* plausibly escape is an exception message quoting what it failed to parse.
*/
export function redact(s) {
return String(s ?? '').replace(/[0-9a-fA-F]{64}/g, '[redacted]');
}
/**
* Read the mounted key and return a signer.
* @returns {{ pubkey: string, sign: (template: object) => object }}
*/
export function loadSigner(keyPath = process.env.CGF_SIGNING_KEY_PATH || DEFAULT_KEY_PATH) {
let raw;
try {
raw = readFileSync(keyPath, 'utf8').trim();
} catch (e) {
// e.message contains the path, never the contents — but redact regardless.
throw new Error(`signing key unreadable at ${keyPath}: ${redact(e.code || e.message)}`);
}
// Accept hex (what Secret Manager holds) and tolerate a trailing newline the
// console adds when a secret is created by paste rather than by file.
if (!HEX64.test(raw)) {
throw new Error(`signing key at ${keyPath} is not 32 bytes of hex (got ${raw.length} chars)`);
}
const sk = Uint8Array.from(Buffer.from(raw, 'hex'));
const pubkey = getPublicKey(sk);
// `sk` is reachable only from the closure below. Nothing returns it.
return {
pubkey,
sign: (template) => finalizeEvent(template, sk),
};
}
@@ -0,0 +1,256 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { WebSocketServer } from 'ws';
import { generateSecretKey, getPublicKey, verifyEvent, getEventHash } from 'nostr-tools/pure';
import { loadSigner, redact, DEFAULT_KEY_PATH } from '../src/signing-key.mjs';
import { publishToChannel, readChannel, channelTags, connectAuthed, PUBLIC_CHANNEL_RE } from '../src/publisher.mjs';
import { createPublisherService, checkCaller } from '../src/server.mjs';
const dir = mkdtempSync(join(tmpdir(), 'cgf-'));
const skHex = Buffer.from(generateSecretKey()).toString('hex');
const keyPath = join(dir, 'signing-key');
writeFileSync(keyPath, skHex, { mode: 0o600 });
// ---- custody ----
test('loadSigner refuses a missing key instead of generating one', () => {
// A generated fallback would be an unadmitted identity that reports healthy and
// then fails every publish with "restricted:". Refusing to start is the honest failure.
assert.throws(() => loadSigner(join(dir, 'nope')), /signing key unreadable/);
});
test('loadSigner refuses a key that is not 32 bytes of hex', () => {
const bad = join(dir, 'bad'); writeFileSync(bad, 'not-a-key');
assert.throws(() => loadSigner(bad), /not 32 bytes of hex/);
});
test('loadSigner tolerates the trailing newline a console paste adds', () => {
const nl = join(dir, 'nl'); writeFileSync(nl, skHex + '\n');
assert.equal(loadSigner(nl).pubkey, getPublicKey(Uint8Array.from(Buffer.from(skHex, 'hex'))));
});
test('the signer exposes a public key and a sign function, and nothing else', () => {
const signer = loadSigner(keyPath);
// Structural, not a string match: no property may carry the secret out.
assert.deepEqual(Object.keys(signer).sort(), ['pubkey', 'sign']);
const serialized = JSON.stringify(signer);
assert.ok(!serialized.includes(skHex), 'secret must not survive serialization');
});
test('redact scrubs anything shaped like key material', () => {
assert.equal(redact(`leaked ${skHex} here`), 'leaked [redacted] here');
assert.equal(redact(undefined), '');
});
test('the default key path is the read-only Cloud Run secret mount', () => {
assert.equal(DEFAULT_KEY_PATH, '/secrets/nostr/signing-key');
});
test('no environment variable can supply the key value', async () => {
// Only the *path* is configurable. An env var holding the secret itself is the
// exposure a secret volume exists to remove, so the loader must not read one.
const src = await import('node:fs').then((fs) => fs.readFileSync(new URL('../src/signing-key.mjs', import.meta.url), 'utf8'));
const envReads = [...src.matchAll(/process\.env\.([A-Z0-9_]+)/g)].map((m) => m[1]);
assert.deepEqual(envReads, ['CGF_SIGNING_KEY_PATH']);
});
// ---- tag compatibility with the gateway (ADR-386) ----
test('channelTags emits the ADR-386 shape', () => {
assert.deepEqual(channelTags('pub:announce', 'Status'),
[['t', 'ruflo-swarm'], ['c', 'pub:announce'], ['k', 'Status']]);
});
test('the gateway still builds channel tags the same way', async () => {
// Read the gateway's source rather than importing it: this guard must run in a
// bare checkout, and the gateway is a separate container with its own deps.
// Drift here does not fail loudly — events publish fine and become invisible to
// every reader, because the relay indexes `c` and reserves `h` for NIP-29 groups.
const fs = await import('node:fs');
const src = fs.readFileSync(new URL('../../ruflo-x-gateway/src/channels.mjs', import.meta.url), 'utf8');
const body = src.slice(src.indexOf('export function channelTags'));
assert.match(body, /\['t', 'ruflo-swarm'\]/, 'gateway must still tag t=ruflo-swarm');
assert.match(body, /\['c', String\(channelId\)\]/, 'gateway must still tag the channel on `c`, not `h`');
});
test('channelTags refuses private channels and malformed types', () => {
assert.throws(() => channelTags('prv:0123456789abcdef', 'Status'), /pub:/);
assert.throws(() => channelTags('pub:announce', 'bad type!'), /msgType/);
assert.ok(!PUBLIC_CHANNEL_RE.test('prv:0123456789abcdef'));
});
// ---- caller token ----
test('publishing is disabled, not open, when no caller token is configured', () => {
assert.equal(checkCaller('anything', undefined), false);
assert.equal(checkCaller(undefined, undefined), false);
});
test('caller token compare accepts the match and rejects near misses', () => {
assert.equal(checkCaller('s3cret', 's3cret'), true);
assert.equal(checkCaller('s3cres', 's3cret'), false);
assert.equal(checkCaller('s3cret-longer', 's3cret'), false);
});
// ---- a relay that enforces what buzz-relay enforces ----
/**
* Minimal NIP-42 relay: issues a challenge, binds the connection to the AUTH
* identity, and refuses any EVENT signed by a different key — which is the exact
* rule that makes a "gateway relays your signed event" design impossible.
*/
function fakeRelay() {
const wss = new WebSocketServer({ port: 0 });
const seen = [];
wss.on('connection', (ws) => {
let authed = null;
ws.send(JSON.stringify(['AUTH', 'challenge-' + Math.random().toString(36).slice(2)]));
ws.on('message', (raw) => {
const m = JSON.parse(raw.toString());
if (m[0] === 'AUTH') {
const ev = m[1];
const ok = ev.kind === 22242 && getEventHash(ev) === ev.id && verifyEvent(ev);
if (ok) authed = ev.pubkey;
return ws.send(JSON.stringify(['OK', ev.id, ok, ok ? '' : 'auth: bad event']));
}
if (m[0] === 'EVENT') {
const ev = m[1];
if (!authed) return ws.send(JSON.stringify(['OK', ev.id, false, 'auth-required: authenticate first']));
if (ev.pubkey !== authed) return ws.send(JSON.stringify(['OK', ev.id, false, 'invalid: event pubkey does not match authenticated identity']));
if (getEventHash(ev) !== ev.id || !verifyEvent(ev)) return ws.send(JSON.stringify(['OK', ev.id, false, 'invalid: bad signature']));
seen.push(ev);
return ws.send(JSON.stringify(['OK', ev.id, true, '']));
}
if (m[0] === 'REQ') {
for (const ev of seen) ws.send(JSON.stringify(['EVENT', m[1], ev]));
ws.send(JSON.stringify(['EOSE', m[1]]));
}
});
});
return { url: () => `ws://127.0.0.1:${wss.address().port}`, seen, close: () => wss.close() };
}
test('publish authenticates, signs locally, and the event pubkey is the authenticated identity', async () => {
const relay = fakeRelay();
try {
const signer = loadSigner(keyPath);
const r = await publishToChannel(relay.url(), signer, {
channel: 'pub:announce', msgType: 'Status', payload: { note: 'probe' } });
// The acceptance bar: pubkey == authenticated identity, and the event id
// independently verifies against the content.
assert.equal(r.pubkey, r.authenticatedAs);
assert.equal(r.pubkey, signer.pubkey);
const ev = relay.seen.find((e) => e.id === r.eventId);
assert.ok(ev, 'relay accepted and stored the event');
assert.equal(getEventHash(ev), ev.id);
assert.ok(verifyEvent(ev));
assert.equal(JSON.parse(ev.content).note, 'probe');
} finally { relay.close(); }
});
test('the relay refuses an event signed by a key other than the authenticated one', async () => {
// Proves the constraint this whole service exists to satisfy.
const relay = fakeRelay();
try {
const signer = loadSigner(keyPath);
const ws = await connectAuthed(relay.url(), signer);
const otherSk = generateSecretKey();
const { finalizeEvent } = await import('nostr-tools/pure');
const foreign = finalizeEvent({ kind: 1, created_at: Math.floor(Date.now() / 1000),
tags: channelTags('pub:announce', 'Status'), content: '{}' }, otherSk);
const reason = await new Promise((resolve) => {
ws.on('message', (d) => { const m = JSON.parse(d.toString()); if (m[0] === 'OK' && m[1] === foreign.id) resolve(m[3]); });
ws.send(JSON.stringify(['EVENT', foreign]));
});
assert.match(reason, /does not match authenticated identity/);
ws.close();
} finally { relay.close(); }
});
test('readChannel returns what was published, verified', async () => {
const relay = fakeRelay();
try {
const signer = loadSigner(keyPath);
await publishToChannel(relay.url(), signer, { channel: 'pub:help', msgType: 'Question', payload: { q: 'how' } });
const msgs = await readChannel(relay.url(), signer, { channel: 'pub:help' });
assert.equal(msgs.length, 1);
assert.equal(msgs[0].q, 'how');
assert.equal(msgs[0].pubkey, signer.pubkey);
} finally { relay.close(); }
});
// ---- MCP surface ----
async function rpc(port, body, headers = {}) {
const res = await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...headers },
body: JSON.stringify(body) });
const raw = await res.text();
const line = raw.split('\n').find((l) => l.startsWith('data: '));
return JSON.parse(line ? line.slice(6) : raw);
}
test('the MCP surface is exactly three tools, and none of them can return the key', async () => {
const relay = fakeRelay();
const svc = createPublisherService({ relay: relay.url(), keyPath });
const port = await svc.listen(0);
try {
const out = await rpc(port, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} });
const names = out.result.tools.map((t) => t.name).sort();
assert.deepEqual(names, ['channel_publish', 'channel_sync', 'federation_identity']);
assert.ok(!JSON.stringify(out).includes(skHex));
const id = await rpc(port, { jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'federation_identity', arguments: {} } });
const body = JSON.parse(id.result.content[0].text);
assert.equal(body.pubkey, svc.pubkey);
assert.ok(!JSON.stringify(id).includes(skHex), 'identity must not leak the secret');
} finally { svc.server.close(); relay.close(); }
});
test('channel_publish refuses without the caller token and publishes with it', async () => {
const relay = fakeRelay();
const prev = process.env.CGF_CALLER_TOKEN;
process.env.CGF_CALLER_TOKEN = 'test-caller-token';
const svc = createPublisherService({ relay: relay.url(), keyPath });
const port = await svc.listen(0);
try {
const denied = await rpc(port, { jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'channel_publish', arguments: { channel: 'pub:announce', msgType: 'Status', payload: {} } } });
assert.match(denied.result.content[0].text, /caller token required/);
// Authorization is NOT the channel: Cloud Run consumes it upstream, so a
// client that sends the token there must be told no, not silently allowed.
const wrongHeader = await rpc(port, { jsonrpc: '2.0', id: 3, method: 'tools/call',
params: { name: 'channel_publish', arguments: { channel: 'pub:announce', msgType: 'Status', payload: {} } } },
{ authorization: 'Bearer test-caller-token' });
assert.match(wrongHeader.result.content[0].text, /caller token required/);
const ok = await rpc(port, { jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'channel_publish', arguments: { channel: 'pub:announce', msgType: 'Status', payload: { note: 'hi' } } } },
{ 'x-caller-token': 'test-caller-token' });
const body = JSON.parse(ok.result.content[0].text);
assert.equal(body.ok, true);
assert.equal(body.pubkey, body.authenticatedAs);
assert.equal(relay.seen.length, 1);
} finally { svc.server.close(); relay.close(); if (prev === undefined) delete process.env.CGF_CALLER_TOKEN; else process.env.CGF_CALLER_TOKEN = prev; }
});
test('channel_publish refuses a private channel even with a valid token', async () => {
const relay = fakeRelay();
const prev = process.env.CGF_CALLER_TOKEN;
process.env.CGF_CALLER_TOKEN = 'test-caller-token';
const svc = createPublisherService({ relay: relay.url(), keyPath });
const port = await svc.listen(0);
try {
const out = await rpc(port, { jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'channel_publish', arguments: { channel: 'prv:0123456789abcdef', msgType: 'Status', payload: {} } } },
{ 'x-caller-token': 'test-caller-token' });
assert.match(out.result.content[0].text, /public pub:/);
assert.equal(relay.seen.length, 0);
} finally { svc.server.close(); relay.close(); if (prev === undefined) delete process.env.CGF_CALLER_TOKEN; else process.env.CGF_CALLER_TOKEN = prev; }
});