mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
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:
@@ -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"]
|
||||
@@ -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.
|
||||
+1330
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; }
|
||||
});
|
||||
Reference in New Issue
Block a user