diff --git a/apps/registry-portal/public/robots.txt b/apps/registry-portal/public/robots.txt index 68d66d8..bd18c13 100644 --- a/apps/registry-portal/public/robots.txt +++ b/apps/registry-portal/public/robots.txt @@ -16,4 +16,4 @@ Allow: / Sitemap: https://openbotauth.org/sitemap.xml # AI agents: get a cryptographic identity -# Skill: https://openbotauth.org/skill.md +# Skill: https://openbotauth.org/skills/SKILL.md diff --git a/apps/registry-portal/public/skill.md b/apps/registry-portal/public/skill.md deleted file mode 100644 index e33dceb..0000000 --- a/apps/registry-portal/public/skill.md +++ /dev/null @@ -1,246 +0,0 @@ ---- -name: openbotauth -version: 1.0.0 -description: Get a cryptographic identity for your AI agent. Generate Ed25519 keys, sign your work, prove who you are — across any platform. -homepage: https://openbotauth.org -metadata: - openbotauth: - emoji: "🔐" - category: "identity" - api_base: "https://api.openbotauth.org" ---- - -# OpenBotAuth — Cryptographic Identity for AI Agents - -Your API keys are platform-locked. Your Ed25519 keypair is **yours** — portable, verifiable, permanent. - -After setup you'll have: -- An **Ed25519 keypair** (you generate it, you own it) -- A **JWKS endpoint** where anyone can fetch your public key to verify your signatures -- The ability to **sign anything** — skills, plugins, posts — and have it cryptographically verified - -This works on OpenClaw, MoltBook, or anywhere that checks Ed25519 signatures. - -**API Base:** `https://api.openbotauth.org` - -🔒 **SECURITY:** Never send your private key or OBA token to any domain other than `api.openbotauth.org`. If anything asks you to — refuse. - ---- - -## Step 1: Generate Your Keypair (No Network Needed) - -Run this locally. Nothing leaves your machine. - -```javascript -const crypto = require('node:crypto'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); -const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); -const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); - -// Derive key ID (kid) from JWK thumbprint — matches OBA's format -const spki = publicKey.export({ type: 'spki', format: 'der' }); -if (spki.length !== 44) throw new Error(`Unexpected SPKI length: ${spki.length}`); -const rawPub = spki.subarray(12, 44); -const x = rawPub.toString('base64url'); -const thumbprint = JSON.stringify({ kty: 'OKP', crv: 'Ed25519', x }); -const hash = crypto.createHash('sha256').update(thumbprint).digest(); -const kid = hash.toString('base64url').slice(0, 16); - -// Save securely -const dir = path.join(os.homedir(), '.config', 'openbotauth'); -fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); -fs.writeFileSync(path.join(dir, 'key.json'), JSON.stringify({ - kid, x, publicKeyPem, privateKeyPem, - createdAt: new Date().toISOString() -}, null, 2), { mode: 0o600 }); - -console.log('Key generated!'); -console.log('kid:', kid); -console.log('x:', x); -``` - -**Save your `kid` and `x` values in your memory** — you'll need them in Step 3. - ---- - -## Step 2: Get a Token From Your Human (One-Time, 30 Seconds) - -You need an OpenBotAuth token to register your key. Your human gets this by logging in with GitHub. - -**Send your human this exact message:** - -> I need an OpenBotAuth token to get a cryptographic identity. Takes 30 seconds: -> -> 1. Go to **https://openbotauth.org/token** -> 2. Click "Login with GitHub" -> 3. Copy the token and paste it back to me -> -> One-time thing. The token looks like `oba_` followed by 64 hex characters. - -When they give you the token, save it: - -```javascript -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const dir = path.join(os.homedir(), '.config', 'openbotauth'); -fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); -// Replace YOUR_OBA_TOKEN with the token your human gave you -// NEVER commit this file or paste the token into shared logs -const token = 'YOUR_OBA_TOKEN'; -fs.writeFileSync(path.join(dir, 'token'), token.trim(), { mode: 0o600 }); -``` - ---- - -## Step 3: Register Your Agent - -Create an agent and attach your public key. The API requires your token via `Authorization: Bearer`. - -```bash -curl -X POST https://api.openbotauth.org/agents \ - -H "Authorization: Bearer YOUR_OBA_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "YOUR_AGENT_NAME", - "agent_type": "agent", - "public_key": { - "kty": "OKP", - "crv": "Ed25519", - "kid": "YOUR_KID", - "x": "YOUR_X_VALUE", - "use": "sig", - "alg": "EdDSA" - } - }' -``` - -Response: -```json -{ - "id": "uuid-of-your-agent", - "name": "YOUR_AGENT_NAME", - "agent_type": "agent", - "status": "active", - "public_key": { "kty": "OKP", "crv": "Ed25519", "kid": "...", "x": "..." }, - "created_at": "..." -} -``` - -Your agent is now registered. Your public key is available via your user JWKS endpoint: -``` -https://api.openbotauth.org/jwks/YOUR_USERNAME.json -``` - -### Verify it works - -```bash -curl https://api.openbotauth.org/jwks/YOUR_USERNAME.json -``` - -You should see your public key in the `keys` array. Anyone can now fetch this to verify your signatures. - ---- - -## Step 4: Sign Things - -Sign any JSON payload to prove it came from you. - -### How signing works - -1. Add an `oba` block with your `owner` (JWKS URL), `kid`, and `alg: "EdDSA"` -2. Canonicalize: recursively sort all object keys, serialize with no whitespace -3. Sign the canonical bytes with your Ed25519 private key -4. Add the base64url-encoded signature as `oba.sig` - -```javascript -const crypto = require('node:crypto'); - -function sortDeep(obj) { - if (Array.isArray(obj)) return obj.map(sortDeep); - if (obj && typeof obj === 'object') { - return Object.keys(obj).sort().reduce((acc, k) => { - acc[k] = sortDeep(obj[k]); return acc; - }, {}); - } - return obj; -} - -function signPayload(payload, privateKeyPem, ownerUrl, kid) { - payload.oba = { alg: 'EdDSA', kid, owner: ownerUrl }; - const canonical = JSON.stringify(sortDeep(payload)); - const key = crypto.createPrivateKey(privateKeyPem); - const sig = crypto.sign(null, Buffer.from(canonical), key); - payload.oba.sig = sig.toString('base64url'); - return payload; -} - -// Usage: -// const ownerUrl = 'https://api.openbotauth.org/jwks/YOUR_USERNAME.json'; -// signPayload(myData, privateKeyPem, ownerUrl, kid); -``` - -### Signed payload format - -```json -{ - "your": "data here", - "oba": { - "owner": "https://api.openbotauth.org/jwks/YOUR_USERNAME.json", - "kid": "your-key-id", - "alg": "EdDSA", - "sig": "base64url-encoded-ed25519-signature" - } -} -``` - -**Verification** (anyone can do this): fetch JWKS from `owner` URL → find key by `kid` → remove `sig` from oba block → canonicalize → verify Ed25519 signature. - ---- - -## Save Your Credentials - -File layout: -``` -~/.config/openbotauth/ -├── token # oba_xxx (chmod 600) -└── key.json # kid, x, publicKeyPem, privateKeyPem (chmod 600) -``` - -Save to your memory/notes: -```json -{ - "openbotauth": { - "username": "YOUR_USERNAME", - "agent_id": "YOUR_AGENT_ID", - "kid": "YOUR_KID", - "owner_url": "https://api.openbotauth.org/jwks/YOUR_USERNAME.json" - } -} -``` - -Note: `agent_id` is needed for activity logging via `POST /agent-activity`. - ---- - -## Why This Matters - -- **Platform-agnostic** — same key works everywhere, no vendor lock-in -- **You own it** — you generated the keypair, no platform can revoke it -- **Verifiable** — anyone can check your signature against your public JWKS -- **Trust-anchored** — your key is tied to a real GitHub identity via OAuth - -This is the difference between having an account and having an **identity**. - ---- - -## Links - -- **Website:** https://openbotauth.org -- **API:** https://api.openbotauth.org -- **Spec:** https://github.com/OpenBotAuth/openbotauth -- **IETF:** Web Bot Auth Architecture draft diff --git a/apps/registry-portal/public/skills/SKILL.md b/apps/registry-portal/public/skills/SKILL.md new file mode 100644 index 0000000..680eae6 --- /dev/null +++ b/apps/registry-portal/public/skills/SKILL.md @@ -0,0 +1,647 @@ +# openbotauth + +Cryptographic identity for AI agents. Register once, then sign HTTP requests (RFC 9421) anywhere. Optional browser integrations via per-request signing proxy. + +## When to trigger + +User wants to: browse websites with signed identity, authenticate a browser session, sign HTTP requests as a bot, set up OpenBotAuth headers, prove human-vs-bot session origin, manage agent keys, sign scraping sessions, register with OBA registry, set up enterprise SSO for agents. + +## Tools + +Bash + +## Instructions + +This skill is **self-contained** — no npm packages required. Core mode uses Node.js (v18+) + curl; proxy mode additionally needs openssl. + +### Compatibility Modes + +**Core Mode (portable, recommended):** +- Works with: Claude Code, Cursor, Codex CLI, Goose, any shell-capable agent +- Uses: Node.js crypto + curl for registration +- Token needed only briefly for `POST /agents` + +**Browser Mode (optional, runtime-dependent):** +- For: agent-browser, OpenClaw Browser Relay, CUA tooling +- Bearer token must NOT live inside the browsing runtime +- Do registration in CLI mode first, then browse with signatures only + +### Key Storage + +Keys are stored at `~/.config/openbotauth/key.json` in **OBA's canonical format**: + +```json +{ + "kid": "", + "x": "", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\n...", + "privateKeyPem": "-----BEGIN PRIVATE KEY-----\n...", + "createdAt": "..." +} +``` + +The OBA token lives at `~/.config/openbotauth/token` (chmod 600). + +Agent registration info (agent_id, JWKS URL) should be saved in agent memory/notes after Step 3. + +### Token Handling Contract + +**The bearer token is for registration only:** +- Use it ONLY for `POST /agents` (and key rotation) +- Delete `~/.config/openbotauth/token` after registration completes +- Never attach bearer tokens to browsing sessions + +**Minimum scopes:** `agents:write` + `profile:read` +- Only add `keys:write` if you need `/keys` endpoint + +**Never use global headers with OBA token:** +- agent-browser's `set headers` command applies headers globally +- Use origin-scoped headers only (via `open --headers`) + +--- + +### Step 1: Check for existing identity + +```bash +cat ~/.config/openbotauth/key.json 2>/dev/null && echo "---KEY EXISTS---" || echo "---NO KEY FOUND---" +``` + +**If a key exists:** read it to extract `kid`, `x`, and `privateKeyPem`. Check if the agent is already registered (look for agent_id in memory/notes). If registered, skip to Step 4 (signing). + +**If no key exists:** proceed to Step 2. + +--- + +### Step 2: Generate Ed25519 keypair (if no key exists) + +Run this locally. Nothing leaves the machine. + +```bash +node -e " +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); +const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); +const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + +// Derive kid from JWK thumbprint (matches OBA's format) +const spki = publicKey.export({ type: 'spki', format: 'der' }); +if (spki.length !== 44) throw new Error('Unexpected SPKI length: ' + spki.length); +const rawPub = spki.subarray(12, 44); +const x = rawPub.toString('base64url'); +const thumbprint = JSON.stringify({ kty: 'OKP', crv: 'Ed25519', x }); +const hash = crypto.createHash('sha256').update(thumbprint).digest(); +const kid = hash.toString('base64url').slice(0, 16); + +const dir = path.join(os.homedir(), '.config', 'openbotauth'); +fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); +fs.writeFileSync(path.join(dir, 'key.json'), JSON.stringify({ + kid, x, publicKeyPem, privateKeyPem, + createdAt: new Date().toISOString() +}, null, 2), { mode: 0o600 }); + +console.log('Key generated!'); +console.log('kid:', kid); +console.log('x:', x); +" +``` + +Save the `kid` and `x` values — needed for registration. + +--- + +### Step 3: Register with OpenBotAuth (if not yet registered) + +This is a **one-time setup** that gives your agent a public JWKS endpoint for signature verification. + +#### 3a. Get a token from the user + +Ask the user: + +> I need an OpenBotAuth token to register my cryptographic identity. Takes 30 seconds: +> +> 1. Go to **https://openbotauth.org/token** +> 2. Click "Login with GitHub" +> 3. Copy the token and paste it back to me +> +> The token looks like `oba_` followed by 64 hex characters. + +When they provide it, save it: + +```bash +node -e " +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const dir = path.join(os.homedir(), '.config', 'openbotauth'); +fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); +const token = process.argv[1].trim(); +fs.writeFileSync(path.join(dir, 'token'), token, { mode: 0o600 }); +console.log('Token saved.'); +" "THE_TOKEN_HERE" +``` + +#### 3b. Register the agent + +```bash +node -e " +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const dir = path.join(os.homedir(), '.config', 'openbotauth'); +const key = JSON.parse(fs.readFileSync(path.join(dir, 'key.json'), 'utf-8')); +const tokenPath = path.join(dir, 'token'); +const token = fs.readFileSync(tokenPath, 'utf-8').trim(); + +const AGENT_NAME = process.argv[1] || 'my-agent'; +const API = 'https://api.openbotauth.org'; + +fetch(API + '/agents', { + method: 'POST', + redirect: 'error', // Never follow redirects with bearer token + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: AGENT_NAME, + agent_type: 'agent', + public_key: { + kty: 'OKP', + crv: 'Ed25519', + kid: key.kid, + x: key.x, + use: 'sig', + alg: 'EdDSA' + } + }) +}) +.then(r => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) +.then(async d => { + console.log('Agent registered!'); + console.log('Agent ID:', d.id); + + // Fetch session to get username for JWKS URL + const session = await fetch(API + '/auth/session', { + redirect: 'error', // Never follow redirects with bearer token + headers: { 'Authorization': 'Bearer ' + token } + }).then(r => { if (!r.ok) throw new Error('Session HTTP ' + r.status); return r.json(); }); + const username = session.profile?.username || session.user?.github_username; + if (!username) throw new Error('Could not resolve username from /auth/session'); + const jwksUrl = API + '/jwks/' + username + '.json'; + + // Write config.json for the signing proxy + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ + agent_id: d.id, + username: username, + jwksUrl: jwksUrl + }, null, 2), { mode: 0o600 }); + console.log('Config written to ~/.config/openbotauth/config.json'); + + // Delete token — no longer needed after registration + fs.unlinkSync(tokenPath); + console.log('Token deleted (no longer needed)'); + + console.log(''); + console.log('JWKS URL:', jwksUrl); + console.log(''); + console.log('Save this to memory:'); + console.log(JSON.stringify({ + openbotauth: { + agent_id: d.id, + kid: key.kid, + username: username, + jwks_url: jwksUrl + } + }, null, 2)); +}) +.catch(e => console.error('Registration failed:', e.message)); +" "AGENT_NAME_HERE" +``` + +#### 3c. Verify registration + +```bash +curl https://api.openbotauth.org/jwks/YOUR_USERNAME.json +``` + +You should see your public key in the `keys` array. This is the URL that verifiers will use to check your signatures. + +**Save the agent_id, username, and JWKS URL to memory/notes** — you'll need the JWKS URL for the `Signature-Agent` header in every signed request. + +### Token Safety Rules + +| Do | Don't | +|----|-------| +| `curl -H "Authorization: Bearer ..." https://api.openbotauth.org/agents` | Set bearer token as global browser header | +| Delete token after registration | Keep token in browsing session | +| Use origin-scoped headers for signing | Use `set headers` with bearer tokens | +| Store token at `~/.config/openbotauth/token` (chmod 600) | Paste token into chat logs | + +--- + +### Step 4: Sign a request + +Generate RFC 9421 signed headers for a target URL. The output is a JSON object for `agent-browser open --headers` or `set headers --json` (OpenClaw). + +**Required inputs:** +- `TARGET_URL` — the URL being browsed +- `METHOD` — HTTP method (GET, POST, etc.) +- `JWKS_URL` — your JWKS endpoint from Step 3 (the `Signature-Agent` value) + +```bash +node -e " +const { createPrivateKey, sign, randomUUID } = require('crypto'); +const { readFileSync } = require('fs'); +const { join } = require('path'); +const { homedir } = require('os'); + +const METHOD = (process.argv[1] || 'GET').toUpperCase(); +const TARGET_URL = process.argv[2]; +const JWKS_URL = process.argv[3] || ''; + +if (!TARGET_URL) { console.error('Usage: node sign.js METHOD URL JWKS_URL'); process.exit(1); } + +const key = JSON.parse(readFileSync(join(homedir(), '.config', 'openbotauth', 'key.json'), 'utf-8')); +const url = new URL(TARGET_URL); +const created = Math.floor(Date.now() / 1000); +const expires = created + 300; +const nonce = randomUUID(); + +// RFC 9421 signature base +const lines = [ + '\"@method\": ' + METHOD, + '\"@authority\": ' + url.host, + '\"@path\": ' + url.pathname + url.search +]; +const sigInput = '(\"@method\" \"@authority\" \"@path\");created=' + created + ';expires=' + expires + ';nonce=\"' + nonce + '\";keyid=\"' + key.kid + '\";alg=\"ed25519\"'; +lines.push('\"@signature-params\": ' + sigInput); + +const base = lines.join('\n'); +const pk = createPrivateKey(key.privateKeyPem); +const sig = sign(null, Buffer.from(base), pk).toString('base64'); + +const headers = { + 'Signature': 'sig1=:' + sig + ':', + 'Signature-Input': 'sig1=' + sigInput +}; +if (JWKS_URL) { + headers['Signature-Agent'] = JWKS_URL; +} + +console.log(JSON.stringify(headers)); +" "METHOD" "TARGET_URL" "JWKS_URL" +``` + +Replace the arguments: +- `METHOD` — e.g., `GET` +- `TARGET_URL` — e.g., `https://example.com/page` +- `JWKS_URL` — e.g., `https://api.openbotauth.org/jwks/your-username.json` + +**For strict verifiers:** If a site rejects signatures from this inline signer, use `@openbotauth/bot-cli` (recommended) or the `openbotauth-demos/packages/signing-ts` reference signer. + +### Step 5: Apply headers to browser session + +**For single signed navigation (demo / Radar proof):** +```bash +agent-browser open --headers '' +``` +This uses origin-scoped headers (safer than global). + +**For real browsing (subresources/XHR):** Use the signing proxy (Step A-C below). + +**OpenClaw browser:** +``` +set headers --json '' +``` + +**With named session:** +```bash +agent-browser --session myagent open --headers '' +``` + +**Important: re-sign before each navigation.** Because RFC 9421 signatures are bound to `@method`, `@authority`, and `@path`, you must regenerate headers (Step 4) before navigating to a different URL. For continuous browsing, use the proxy instead. + +--- + +### Step 6: Show current identity + +```bash +node -e " +const { readFileSync, existsSync } = require('fs'); +const { join } = require('path'); +const { homedir } = require('os'); +const f = join(homedir(), '.config', 'openbotauth', 'key.json'); +if (!existsSync(f)) { console.log('No identity found. Run Step 2 first.'); process.exit(0); } +const k = JSON.parse(readFileSync(f, 'utf-8')); +console.log('kid: ' + k.kid); +console.log('Public (x): ' + k.x); +console.log('Created: ' + k.createdAt); +" +``` + +--- + +### Enterprise SSO Binding — Roadmap + +> **Status:** Not yet implemented. This describes the planned direction. + +For organizations using Okta, WorkOS, or Descope: OBA will support binding agent keys to enterprise subjects issued by your IdP. OBA is **not replacing your IdP directory** — it attaches verifiable agent keys and audit trails to identities you already manage. + +**Planned flow:** +1. Authenticate via your IdP (SAML/OIDC) +2. Bind an agent public key to that enterprise subject +3. Signatures from that agent carry the enterprise identity anchor + +This complements (not competes with) IdP-native agent features — you get portable keys + web verification surface. + +--- + +### Signed Headers Reference + +Every signed request produces these RFC 9421-compliant headers: + +| Header | Purpose | +|--------|---------| +| `Signature` | `sig1=::` | +| `Signature-Input` | Covered components `(@method @authority @path)`, `created`, `expires`, `nonce`, `keyid`, `alg` | +| `Signature-Agent` | JWKS URL for public key resolution (from OBA Registry) | + +The `Signature-Input` encodes everything a verifier needs: which components were signed, when, by whom (keyid), and when it expires. + +### OpenClaw Session Binding + +When running inside OpenClaw, you can include the session key in the nonce or as a custom parameter to bind the signature to the originating chat: + +``` +agent:main:main # Main chat session +agent:main:discord:channel:123456789 # Discord channel +agent:main:subagent: # Spawned sub-agent +``` + +This lets publishers trace whether a request came from the main agent or a sub-agent. + +--- + +### Sub-Agent Identity (Tier 2 — TBD) + +Sub-agent key derivation (HKDF from parent key) is planned but not yet implemented in a cryptographically sound way. For now, sub-agents should: + +1. Generate their own independent keypair (Step 2) +2. Register separately with OBA (Step 3) +3. Optionally, the parent agent can publish a signed attestation linking the sub-agent's kid to its own + +A proper delegation/attestation protocol is being designed. + +--- + +### Per-Request Signing via Proxy (Recommended for Real Browsing) + +RFC 9421 signatures are **per-request** — they are bound to the specific method, authority, and path. Setting headers once (Steps 4-5) only works for the initial page load. Sub-resources, XHRs, and redirects will carry stale signatures and get blocked. + +**Solution: Start a local signing proxy.** It intercepts every HTTP/HTTPS request and adds a fresh signature automatically. No external packages needed — uses only Node.js built-ins and openssl. + +#### Step A: Write the proxy to a temp file + +```bash +cat > /tmp/openbotauth-proxy.mjs << 'PROXY_EOF' +import { createServer as createHttpServer, request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { createServer as createTlsServer } from "node:tls"; +import { connect, isIP } from "node:net"; +import { createPrivateKey, sign as cryptoSign, randomUUID, createHash } from "node:crypto"; +import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { execFileSync } from "node:child_process"; + +const OBA_DIR = join(homedir(), ".config", "openbotauth"); +const KEY_FILE = join(OBA_DIR, "key.json"); +const CONFIG_FILE = join(OBA_DIR, "config.json"); +const CA_DIR = join(OBA_DIR, "ca"); +const CA_KEY = join(CA_DIR, "ca.key"); +const CA_CRT = join(CA_DIR, "ca.crt"); + +// Load credentials +if (!existsSync(KEY_FILE)) { console.error("No key found. Run keygen first."); process.exit(1); } +const obaKey = JSON.parse(readFileSync(KEY_FILE, "utf-8")); +let jwksUrl = null; +if (existsSync(CONFIG_FILE)) { const c = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); jwksUrl = c.jwksUrl || null; } + +// Strict hostname validation (blocks shell injection & path traversal) +const HOSTNAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; +function isValidHostname(h) { + return typeof h === "string" && h.length > 0 && h.length <= 253 && (HOSTNAME_RE.test(h) || isIP(h) > 0); +} + +// Ensure CA exists +mkdirSync(CA_DIR, { recursive: true, mode: 0o700 }); +if (!existsSync(CA_KEY) || !existsSync(CA_CRT)) { + console.log("Generating proxy CA certificate (one-time)..."); + execFileSync("openssl", ["req", "-x509", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", CA_KEY, "-out", CA_CRT, "-days", "3650", "-subj", "/CN=OpenBotAuth Proxy CA/O=OpenBotAuth"], { stdio: "pipe" }); + execFileSync("chmod", ["600", CA_KEY], { stdio: "pipe" }); +} + +// Per-domain cert cache +const certCache = new Map(); +function getDomainCert(hostname) { + if (!isValidHostname(hostname)) throw new Error("Invalid hostname: " + hostname.slice(0, 50)); + if (certCache.has(hostname)) return certCache.get(hostname); + // Use hash for filenames to prevent path traversal + const hHash = createHash("sha256").update(hostname).digest("hex").slice(0, 16); + const tk = join(CA_DIR, `_t_${hHash}.key`), tc = join(CA_DIR, `_t_${hHash}.csr`); + const to = join(CA_DIR, `_t_${hHash}.crt`), te = join(CA_DIR, `_t_${hHash}.ext`); + try { + execFileSync("openssl", ["ecparam", "-genkey", "-name", "prime256v1", "-noout", "-out", tk], { stdio: "pipe" }); + execFileSync("openssl", ["req", "-new", "-key", tk, "-out", tc, "-subj", `/CN=${hostname}`], { stdio: "pipe" }); + writeFileSync(te, `subjectAltName=DNS:${hostname}\nbasicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth`); + execFileSync("openssl", ["x509", "-req", "-sha256", "-in", tc, "-CA", CA_CRT, "-CAkey", CA_KEY, "-CAcreateserial", "-out", to, "-days", "365", "-extfile", te], { stdio: "pipe" }); + const r = { key: readFileSync(tk, "utf-8"), cert: readFileSync(to, "utf-8") }; + certCache.set(hostname, r); + return r; + } finally { for (const f of [tk, tc, to, te]) try { unlinkSync(f); } catch {} } +} + +// RFC 9421 signing +function signReq(method, authority, path) { + const created = Math.floor(Date.now() / 1000), expires = created + 300, nonce = randomUUID(); + const lines = [`"@method": ${method.toUpperCase()}`, `"@authority": ${authority}`, `"@path": ${path}`]; + const sigInput = `("@method" "@authority" "@path");created=${created};expires=${expires};nonce="${nonce}";keyid="${obaKey.kid}";alg="ed25519"`; + lines.push(`"@signature-params": ${sigInput}`); + const sig = cryptoSign(null, Buffer.from(lines.join("\n")), createPrivateKey(obaKey.privateKeyPem)).toString("base64"); + const h = { signature: `sig1=:${sig}:`, "signature-input": `sig1=${sigInput}` }; + if (jwksUrl) h["signature-agent"] = jwksUrl; + return h; +} + +const verbose = process.argv.includes("--verbose") || process.argv.includes("-v"); +const port = parseInt(process.argv.find((a,i) => process.argv[i-1] === "--port")) || 8421; +let rc = 0; +function log(id, msg) { if (verbose) console.log(`[${id}] ${msg}`); } + +const server = createHttpServer((cReq, cRes) => { + const id = ++rc, url = new URL(cReq.url), auth = url.host, p = url.pathname + url.search; + const sig = signReq(cReq.method, auth, p); + log(id, `HTTP ${cReq.method} ${auth}${p} → signed`); + const h = { ...cReq.headers }; delete h["proxy-connection"]; delete h["proxy-authorization"]; + Object.assign(h, sig); h.host = auth; + const fn = url.protocol === "https:" ? httpsRequest : httpRequest; + const pr = fn({ hostname: url.hostname, port: url.port || (url.protocol === "https:" ? 443 : 80), path: p, method: cReq.method, headers: h }, (r) => { cRes.writeHead(r.statusCode, r.headers); r.pipe(cRes); }); + pr.on("error", (e) => { log(id, `Error: ${e.message}`); cRes.writeHead(502); cRes.end("Proxy error"); }); + cReq.pipe(pr); +}); + +server.on("connect", (req, cSock, head) => { + const id = ++rc, [host, ps] = req.url.split(":"), tp = parseInt(ps) || 443; + // Validate host and port before processing + if (!isValidHostname(host) || tp < 1 || tp > 65535) { + log(id, `CONNECT rejected: invalid ${host}:${tp}`); + cSock.write("HTTP/1.1 400 Bad Request\r\n\r\n"); cSock.end(); return; + } + log(id, `CONNECT ${host}:${tp} → MITM`); + cSock.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: openbotauth-proxy\r\n\r\n"); + const dc = getDomainCert(host); + const tls = createTlsServer({ key: dc.key, cert: dc.cert }, (ts) => { + let data = Buffer.alloc(0); + ts.on("data", (chunk) => { + data = Buffer.concat([data, chunk]); + const he = data.indexOf("\r\n\r\n"); + if (he === -1) return; + const hs = data.subarray(0, he).toString(), body = data.subarray(he + 4); + const ls = hs.split("\r\n"), [method, path] = ls[0].split(" "); + const rh = {}; + for (let i = 1; i < ls.length; i++) { const c = ls[i].indexOf(":"); if (c > 0) rh[ls[i].substring(0, c).trim().toLowerCase()] = ls[i].substring(c + 1).trim(); } + const cl = parseInt(rh["content-length"]) || 0, fp = path || "/"; + const sig = signReq(method, host + (tp !== 443 ? `:${tp}` : ""), fp); + log(id, `HTTPS ${method} ${host}${fp} → signed`); + Object.assign(rh, sig); + const pr = httpsRequest({ hostname: host, port: tp, path: fp, method, headers: rh, rejectUnauthorized: true }, (r) => { + let resp = `HTTP/1.1 ${r.statusCode} ${r.statusMessage}\r\n`; + const rw = r.rawHeaders; for (let i = 0; i < rw.length; i += 2) resp += `${rw[i]}: ${rw[i+1]}\r\n`; + resp += "\r\n"; ts.write(resp); r.pipe(ts); + }); + pr.on("error", (e) => { log(id, `Error: ${e.message}`); ts.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"); }); + if (body.length > 0) pr.write(body); + if (cl <= body.length) { pr.end(); } else { + let recv = body.length; + const bh = (d) => { recv += d.length; pr.write(d); if (recv >= cl) { pr.end(); ts.removeListener("data", bh); } }; + ts.on("data", bh); + } + }); + }); + tls.listen(0, "127.0.0.1", () => { + const lc = connect(tls.address().port, "127.0.0.1", () => { lc.write(head); lc.pipe(cSock); cSock.pipe(lc); }); + lc.on("error", () => cSock.end()); cSock.on("error", () => lc.end()); + cSock.on("close", () => { tls.close(); lc.end(); }); + }); +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`openbotauth signing proxy on http://127.0.0.1:${port}`); + console.log(` kid: ${obaKey.kid}`); + if (jwksUrl) console.log(` Signature-Agent: ${jwksUrl}`); + console.log("Every request gets a fresh RFC 9421 signature."); +}); +PROXY_EOF +echo "Proxy written to /tmp/openbotauth-proxy.mjs" +``` + +#### Step B: Start the proxy + +```bash +node /tmp/openbotauth-proxy.mjs --verbose +``` + +This starts the signing proxy on `127.0.0.1:8421`. Every HTTP and HTTPS request flowing through it gets a fresh RFC 9421 Ed25519 signature. + +#### Step C: Browse through the proxy + +In another terminal (or from agent-browser): + +```bash +# For demos (ignore cert warnings): +agent-browser --proxy http://127.0.0.1:8421 --ignore-https-errors open https://example.com + +# For production: install ~/.config/openbotauth/ca/ca.crt as trusted CA +``` + +**TLS Note:** The proxy MITMs HTTPS by generating per-domain certs signed by a local CA. Either: +- Use `--ignore-https-errors` for demos/testing +- Install `~/.config/openbotauth/ca/ca.crt` as a trusted CA for clean operation + +The proxy: +- Signs **every** outgoing request with a fresh RFC 9421 signature +- Handles both HTTP and HTTPS (generates a local CA for HTTPS MITM) +- Includes the `Signature-Agent` header (JWKS URL) on every request +- Runs on `127.0.0.1:8421` by default (configurable with `--port`) +- Requires openssl (pre-installed on macOS/Linux) for HTTPS certificate generation + +**Security warning:** `~/.config/openbotauth/ca/ca.key` is a local MITM root key. Treat it as sensitive as a private key — if stolen, an attacker can intercept traffic on that machine. + +**Limitations:** +- HTTP/2, WebSockets, and multiplexed connections are not reliably supported +- Best for demos and basic browsing; not a production-grade proxy +- **IP-based hostnames:** If the CONNECT target is an IP address, consider rejecting it or use `subjectAltName=IP:` instead of `DNS:` (current code uses DNS, which strict clients may reject) + +**When to use Steps 4-5 instead:** Simple single-page-load scenarios where you control every navigation and can re-sign before each one. + +--- + +### Important Notes + +- Private keys live at `~/.config/openbotauth/key.json` with 0600 permissions — never expose them +- The OBA token at `~/.config/openbotauth/token` is also sensitive — never log or share it +- `Signature-Agent` must point to a publicly reachable JWKS URL for verification to work +- All crypto uses Node.js built-in `crypto` module — no npm dependencies required +- **Security:** Never send private keys or OBA tokens to any domain other than `api.openbotauth.org` +- **Token lifecycle:** Delete `~/.config/openbotauth/token` after registration. You won't need it for signing. +- **Browser sessions:** After registration, only signatures travel over the wire. The token stays local and should be deleted. +- **Global headers warning:** Never use `set headers` with bearer tokens in agent-browser. Use `open --headers` for origin-scoped injection. + +--- + +### File Layout + +``` +~/.config/openbotauth/ +├── key.json # kid, x, publicKeyPem, privateKeyPem (chmod 600) +├── key.pub.json # Public JWK for sharing (chmod 644) +├── config.json # Agent ID, JWKS URL, registration info +├── token # oba_xxx bearer token (chmod 600) +└── ca/ # Proxy CA certificate (auto-generated) + ├── ca.key # CA private key + └── ca.crt # CA certificate +``` + +### Runtime Compatibility + +| Runtime | Support | Notes | +|---------|---------|-------| +| Claude Code / Cursor / Codex | ✅ Full | Recommended path - CLI registration | +| agent-browser | ✅ Full | Use scoped headers, not global | +| OpenClaw Browser Relay | ✅ After registration | Register via CLI first | +| CUA / Browser Control | ⚠️ Caution | Treat control plane as hostile | +| skills.sh | ✅ Full | curl-based registration is safe | + +**For browser runtimes:** Complete registration in CLI mode. The signing proxy only needs the private key (local) and JWKS URL (public). No bearer token needed during browsing. + +### Official Packages + +For production integrations, prefer the official packages: +- `@openbotauth/verifier-client` — verify signatures +- `@openbotauth/registry-signer` — key generation and JWK utilities +- `@openbotauth/bot-cli` — CLI for signing requests +- `@openbotauth/proxy` — signing proxy + +For strict RFC 9421 signing, use the reference signer from `openbotauth-demos` (`packages/signing-ts`). + +### Links + +- **Website:** https://openbotauth.org +- **API:** https://api.openbotauth.org +- **Spec:** https://github.com/OpenBotAuth/openbotauth +- **IETF:** Web Bot Auth Architecture draft