From bc96421ca593b00026d5c2215554fbca43633621 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Sun, 22 Feb 2026 22:19:09 +0500 Subject: [PATCH 01/49] Implement agent identity profile v01 --- .../src/components/AddAgentModal.tsx | 48 ++++ apps/registry-portal/src/lib/api.ts | 7 +- .../src/pages/portal/AgentDetail.tsx | 20 ++ docs/ARCHITECTURE.md | 11 +- docs/REAL_KEYS_SETUP.md | 7 +- docs/TESTING_GUIDE.md | 5 +- .../007_agent_identity_and_certs.sql | 26 ++ packages/bot-cli/README.md | 8 +- packages/bot-cli/src/cli.ts | 3 +- packages/bot-cli/src/commands/fetch.ts | 7 +- packages/bot-cli/src/request-signer.test.ts | 34 +++ packages/bot-cli/src/request-signer.ts | 31 ++- packages/bot-cli/src/types.ts | 2 +- packages/registry-service/README.md | 47 +++- packages/registry-service/package.json | 1 + .../registry-service/src/routes/agents-api.ts | 67 ++++- packages/registry-service/src/routes/certs.ts | 181 +++++++++++++ packages/registry-service/src/routes/jwks.ts | 27 +- .../src/routes/signature-agent-card.ts | 127 +++++++++ packages/registry-service/src/server.ts | 5 +- packages/registry-service/src/utils/ca.ts | 250 ++++++++++++++++++ packages/verifier-service/README.md | 7 +- .../src/__fixtures__/x509/ca.pem | 9 + .../src/__fixtures__/x509/leaf.pem | 9 + packages/verifier-service/src/server.ts | 31 ++- .../src/signature-parser.test.ts | 26 ++ .../verifier-service/src/signature-parser.ts | 121 ++++++++- .../src/signature-verifier.ts | 55 ++-- packages/verifier-service/src/types.ts | 3 +- packages/verifier-service/src/x509.test.ts | 49 ++++ packages/verifier-service/src/x509.ts | 181 +++++++++++++ 31 files changed, 1337 insertions(+), 68 deletions(-) create mode 100644 infra/neon/migrations/007_agent_identity_and_certs.sql create mode 100644 packages/bot-cli/src/request-signer.test.ts create mode 100644 packages/registry-service/src/routes/certs.ts create mode 100644 packages/registry-service/src/routes/signature-agent-card.ts create mode 100644 packages/registry-service/src/utils/ca.ts create mode 100644 packages/verifier-service/src/__fixtures__/x509/ca.pem create mode 100644 packages/verifier-service/src/__fixtures__/x509/leaf.pem create mode 100644 packages/verifier-service/src/x509.test.ts create mode 100644 packages/verifier-service/src/x509.ts diff --git a/apps/registry-portal/src/components/AddAgentModal.tsx b/apps/registry-portal/src/components/AddAgentModal.tsx index c1a81f8..ff991ac 100644 --- a/apps/registry-portal/src/components/AddAgentModal.tsx +++ b/apps/registry-portal/src/components/AddAgentModal.tsx @@ -24,6 +24,9 @@ const AddAgentModal = ({ open, onOpenChange, onSuccess }: AddAgentModalProps) => const [privateKey, setPrivateKey] = useState(null); const [isGenerating, setIsGenerating] = useState(false); const [isCreating, setIsCreating] = useState(false); + const [obaAgentId, setObaAgentId] = useState(""); + const [obaParentAgentId, setObaParentAgentId] = useState(""); + const [obaPrincipal, setObaPrincipal] = useState(""); const generateKeyPair = async () => { setIsGenerating(true); @@ -111,6 +114,9 @@ const AddAgentModal = ({ open, onOpenChange, onSuccess }: AddAgentModalProps) => description: description || undefined, agent_type: agentType, public_key: publicKey, + oba_agent_id: obaAgentId || undefined, + oba_parent_agent_id: obaParentAgentId || undefined, + oba_principal: obaPrincipal || undefined, }); // Download private key @@ -127,6 +133,9 @@ const AddAgentModal = ({ open, onOpenChange, onSuccess }: AddAgentModalProps) => setAgentType(""); setPublicKey(null); setPrivateKey(null); + setObaAgentId(""); + setObaParentAgentId(""); + setObaPrincipal(""); onSuccess(); onOpenChange(false); @@ -220,6 +229,45 @@ const AddAgentModal = ({ open, onOpenChange, onSuccess }: AddAgentModalProps) => )} +
+ +
+
+ + setObaAgentId(e.target.value)} + /> +
+
+ + setObaParentAgentId(e.target.value)} + /> +
+
+ + setObaPrincipal(e.target.value)} + /> +
+
+
+
+
+
+

OBA Agent ID

+

+ {agent.oba_agent_id || "—"} +

+
+
+

OBA Parent Agent ID

+

+ {agent.oba_parent_agent_id || "—"} +

+
+
+

OBA Principal

+

+ {agent.oba_principal || "—"} +

+
+
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 45b623b..c4d8a17 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -95,7 +95,7 @@ Bot → Sign Request (RFC 9421) Add Headers: - Signature-Input - Signature - - Signature-Agent (JWKS URL) + - Signature-Agent (Structured Dictionary entry pointing to JWKS; legacy URL supported) ↓ HTTP Request → NGINX ↓ @@ -190,7 +190,7 @@ WordPress → Verify receipt 1. Extract signature headers from proxied request 2. Parse `Signature-Input` and `Signature` headers 3. Extract nonce and check for replay (Redis SET NX) -4. Fetch JWKS from `Signature-Agent` URL (with caching) +4. Fetch JWKS from `Signature-Agent` entry (dictionary member or legacy URL) with caching 5. Verify signature using public key 6. Check clock skew (±300s default) 7. Validate directory trust @@ -275,7 +275,7 @@ Link: ; rel="payment" 1. Load/generate Ed25519 keypair 2. Build signature base (RFC 9421) 3. Sign with private key -4. Add headers: Signature-Input, Signature, Signature-Agent +4. Add headers: Signature-Input, Signature, Signature-Agent (dictionary format preferred) 5. Send HTTP request 6. Handle 402: - Parse Link header @@ -357,7 +357,7 @@ Policy (Access Control) ### Directory Trust -1. Verifier extracts host from `Signature-Agent` URL +1. Verifier resolves JWKS URL from `Signature-Agent` (dictionary member or legacy URL) 2. Checks against `OB_TRUSTED_DIRECTORIES` env var 3. Only trusted directories allowed 4. Prevents rogue JWKS servers @@ -462,7 +462,7 @@ All services use structured JSON logging: - **JWKS**: 1 hour cache with ETag support - **Nonce**: 10 minute TTL in Redis - **Sessions**: 30 day expiration -- **Content**: Vary on Signature-Agent + Pay-State +- **Content**: Vary on Signature-Agent (dictionary/legacy) + Pay-State ### Database @@ -488,4 +488,3 @@ All services use structured JSON logging: - [Web Bot Auth Draft](https://github.com/web-bot-auth/spec) - [MCP Specification](https://modelcontextprotocol.io/) - [A2A Protocol](https://github.com/a2a-protocol/spec) - diff --git a/docs/REAL_KEYS_SETUP.md b/docs/REAL_KEYS_SETUP.md index 2ff4cd3..85ae804 100644 --- a/docs/REAL_KEYS_SETUP.md +++ b/docs/REAL_KEYS_SETUP.md @@ -145,7 +145,7 @@ Body: │ │ │ 1. Load private key from ~/.openbotauth/bot-config.json │ │ 2. Sign request with private key │ -│ 3. Add header: Signature-Agent: .../jwks/hammadtq.json │ +│ 3. Add header: Signature-Agent: sig1=".../jwks/hammadtq.json" │ │ 4. Send request │ └─────────────────────────────────────────────────────────────────┘ @@ -153,7 +153,7 @@ Body: │ Verifier Service │ │ │ │ 1. Receive signed request │ -│ 2. Extract JWKS URL from Signature-Agent header │ +│ 2. Extract JWKS URL from Signature-Agent header (dict or legacy)│ │ 3. Fetch public key from: .../jwks/hammadtq.json │ │ 4. Verify signature using public key │ │ 5. ✅ Valid! (because bot signed with matching private key) │ @@ -220,7 +220,7 @@ If you didn't save your private key: **What the bot CLI does:** 1. Loads config from ~/.openbotauth/bot-config.json 2. Signs requests with private key -3. Adds Signature-Agent header with JWKS URL +3. Adds Signature-Agent header with JWKS URL (dictionary format preferred) 4. Sends request **What the verifier does:** @@ -230,4 +230,3 @@ If you didn't save your private key: 4. Returns ✅ or ❌ 🎉 **Now you're using real keys!** - diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index 8938df6..e94f27c 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -104,7 +104,7 @@ Configuration: Signature Headers: Signature-Input: sig1=("@method" "@path" "@authority");created=1763282275;expires=1763282575;nonce="abc123";keyid="test-key-123";alg="ed25519" Signature: sig1=:K2qGT5srn2OGbOIDzQ6kYT+ruaycnDAAUpKv+ePFfD0=: - Signature-Agent: http://localhost:8080/jwks/testbot.json + Signature-Agent: sig1="http://localhost:8080/jwks/testbot.json" 📡 Sending request... @@ -387,7 +387,7 @@ curl http://localhost:8081/health | jq hey -n 100 -c 10 \ -H "Signature-Input: sig1=(\"@method\" \"@path\" \"@authority\");created=1763282275;expires=1763282575;nonce=\"test-123\";keyid=\"test-key-123\";alg=\"ed25519\"" \ -H "Signature: sig1=:K2qGT5srn2OGbOIDzQ6kYT+ruaycnDAAUpKv+ePFfD0=:" \ - -H "Signature-Agent: http://localhost:8080/jwks/testbot.json" \ + -H "Signature-Agent: sig1=\"http://localhost:8080/jwks/testbot.json\"" \ http://localhost:3000/protected ``` @@ -463,4 +463,3 @@ The OpenBotAuth system is now fully functional with: - ✅ Timestamp validation 🎉 **Ready for production integration!** - diff --git a/infra/neon/migrations/007_agent_identity_and_certs.sql b/infra/neon/migrations/007_agent_identity_and_certs.sql new file mode 100644 index 0000000..30f8fc0 --- /dev/null +++ b/infra/neon/migrations/007_agent_identity_and_certs.sql @@ -0,0 +1,26 @@ +-- Add optional OpenBotAuth agent identity fields +ALTER TABLE public.agents + ADD COLUMN IF NOT EXISTS oba_agent_id TEXT, + ADD COLUMN IF NOT EXISTS oba_parent_agent_id TEXT, + ADD COLUMN IF NOT EXISTS oba_principal TEXT; + +-- Store issued X.509 certificates for agent keys +CREATE TABLE IF NOT EXISTS public.agent_certificates ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + agent_id UUID NOT NULL REFERENCES public.agents(id) ON DELETE CASCADE, + kid TEXT NOT NULL, + serial TEXT NOT NULL UNIQUE, + cert_pem TEXT NOT NULL, + chain_pem TEXT NOT NULL, + x5c TEXT[] NOT NULL, + not_before TIMESTAMP WITH TIME ZONE NOT NULL, + not_after TIMESTAMP WITH TIME ZONE NOT NULL, + fingerprint_sha256 TEXT NOT NULL, + revoked_at TIMESTAMP WITH TIME ZONE, + revoked_reason TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_agent_certs_agent_id ON public.agent_certificates(agent_id); +CREATE INDEX IF NOT EXISTS idx_agent_certs_kid ON public.agent_certificates(kid); +CREATE INDEX IF NOT EXISTS idx_agent_certs_revoked ON public.agent_certificates(revoked_at); diff --git a/packages/bot-cli/README.md b/packages/bot-cli/README.md index 2f69404..9e0ab12 100644 --- a/packages/bot-cli/README.md +++ b/packages/bot-cli/README.md @@ -83,7 +83,7 @@ oba-bot config 5. **Add Headers** - Add signature headers to request: - `Signature-Input` - Signature parameters - `Signature` - Base64-encoded signature - - `Signature-Agent` - JWKS URL + - `Signature-Agent` - Structured Dictionary entry pointing to JWKS (legacy URL also accepted) 6. **Send Request** - Execute HTTP request with signature headers ### Signature Headers @@ -93,7 +93,7 @@ Example headers added to each request: ``` Signature-Input: sig1=("@method" "@path" "@authority");created=1763282275;expires=1763282575;nonce="abc123";keyid="my-key-123";alg="ed25519" Signature: sig1=:K2qGT5srn2OGbOIDzQ6kYT+ruaycnDAAUpKv+ePFfD0=: -Signature-Agent: http://localhost:8080/jwks/mybot.json +Signature-Agent: sig1="http://localhost:8080/jwks/mybot.json" User-Agent: OpenBotAuth-CLI/0.1.0 ``` @@ -195,6 +195,7 @@ oba-bot fetch [options] - `-m, --method ` - HTTP method (default: GET) - `-d, --body ` - Request body (JSON) - `-v, --verbose` - Verbose output +- `--signature-agent-format ` - Signature-Agent format (`legacy` or `dict`) ### `config` @@ -258,7 +259,7 @@ Configuration: Signature Headers: Signature-Input: sig1=("@method" "@path" "@authority");created=1763282275;... Signature: sig1=:K2qGT5srn2OGbOIDzQ6kYT+ruaycnDAAUpKv+ePFfD0=: - Signature-Agent: http://localhost:8080/jwks/mybot.json + Signature-Agent: sig1="http://localhost:8080/jwks/mybot.json" 📡 Sending request... @@ -296,4 +297,3 @@ Signatures expire after 5 minutes. Generate a new request. ## License MIT - diff --git a/packages/bot-cli/src/cli.ts b/packages/bot-cli/src/cli.ts index 3b77cf0..9c0b997 100644 --- a/packages/bot-cli/src/cli.ts +++ b/packages/bot-cli/src/cli.ts @@ -43,11 +43,13 @@ program .option('-m, --method ', 'HTTP method (default: GET)', 'GET') .option('-d, --body ', 'Request body (JSON)') .option('-v, --verbose', 'Verbose output') + .option('--signature-agent-format ', 'Signature-Agent format: legacy|dict', 'legacy') .action(async (url, options) => { await fetchCommand(url, { method: options.method, body: options.body, verbose: options.verbose, + signatureAgentFormat: options.signatureAgentFormat, }); }); @@ -87,4 +89,3 @@ Examples: // Parse arguments program.parse(); - diff --git a/packages/bot-cli/src/commands/fetch.ts b/packages/bot-cli/src/commands/fetch.ts index baa356f..5d9279d 100644 --- a/packages/bot-cli/src/commands/fetch.ts +++ b/packages/bot-cli/src/commands/fetch.ts @@ -14,6 +14,7 @@ export async function fetchCommand( method?: string; body?: string; verbose?: boolean; + signatureAgentFormat?: string; } ): Promise { const method = options.method || 'GET'; @@ -39,7 +40,10 @@ export async function fetchCommand( const client = new HttpClient(); // Sign request - const signedRequest = await signer.sign(method, url, options.body); + const signedRequest = await signer.sign(method, url, options.body, { + signatureAgentFormat: + options.signatureAgentFormat === "dict" ? "dict" : "legacy", + }); if (options.verbose) { console.log('Signature Headers:'); @@ -73,4 +77,3 @@ export async function fetchCommand( process.exit(1); } } - diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts new file mode 100644 index 0000000..c2f270c --- /dev/null +++ b/packages/bot-cli/src/request-signer.test.ts @@ -0,0 +1,34 @@ +import { generateKeyPairSync } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { RequestSigner } from "./request-signer.js"; + +function makeConfig() { + const { privateKey } = generateKeyPairSync("ed25519"); + const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + return { + jwks_url: "https://example.com/jwks/test.json", + kid: "test-kid", + private_key: privatePem, + public_key: "base64", + }; +} + +describe("RequestSigner", () => { + it("emits legacy Signature-Agent by default", async () => { + const signer = new RequestSigner(makeConfig()); + const signed = await signer.sign("GET", "https://example.com"); + expect(signed.headers["Signature-Agent"]).toBe( + "https://example.com/jwks/test.json", + ); + }); + + it("emits dictionary Signature-Agent when requested", async () => { + const signer = new RequestSigner(makeConfig()); + const signed = await signer.sign("GET", "https://example.com", undefined, { + signatureAgentFormat: "dict", + }); + expect(signed.headers["Signature-Agent"]).toBe( + 'sig1="https://example.com/jwks/test.json"', + ); + }); +}); diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index 1d080b3..154edc1 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -13,8 +13,18 @@ export class RequestSigner { /** * Sign an HTTP request */ - async sign(method: string, url: string, body?: string): Promise { + async sign( + method: string, + url: string, + body?: string, + options?: { signatureAgentFormat?: "legacy" | "dict"; signatureLabel?: string }, + ): Promise { const urlObj = new URL(url); + const signatureLabel = options?.signatureLabel || "sig1"; + const signatureAgentFormat = + options?.signatureAgentFormat || + this.config.signature_agent_format || + "legacy"; // Generate signature parameters const params: SignatureParams = { @@ -44,10 +54,15 @@ export class RequestSigner { const signature = await this.signString(signatureBase); // Build headers + const signatureAgentValue = + signatureAgentFormat === "dict" + ? `${signatureLabel}="${this.config.jwks_url}"` + : this.config.jwks_url; + const headers: Record = { - 'Signature-Input': this.buildSignatureInput(params), - 'Signature': `sig1=:${signature}:`, - 'Signature-Agent': this.config.jwks_url, + 'Signature-Input': this.buildSignatureInput(params, signatureLabel), + 'Signature': `${signatureLabel}=:${signature}:`, + 'Signature-Agent': signatureAgentValue, 'User-Agent': 'OpenBotAuth-CLI/0.1.0', }; @@ -117,9 +132,12 @@ export class RequestSigner { /** * Build Signature-Input header value */ - private buildSignatureInput(params: SignatureParams): string { + private buildSignatureInput( + params: SignatureParams, + label: string, + ): string { const components = params.headers.map(h => `"${h}"`).join(' '); - return `sig1=(${components});created=${params.created};expires=${params.expires};nonce="${params.nonce}";keyid="${params.keyId}";alg="${params.algorithm}"`; + return `${label}=(${components});created=${params.created};expires=${params.expires};nonce="${params.nonce}";keyid="${params.keyId}";alg="${params.algorithm}"`; } /** @@ -175,4 +193,3 @@ export class RequestSigner { return Buffer.from(bytes).toString('base64url'); } } - diff --git a/packages/bot-cli/src/types.ts b/packages/bot-cli/src/types.ts index d75c1f5..a8ee576 100644 --- a/packages/bot-cli/src/types.ts +++ b/packages/bot-cli/src/types.ts @@ -7,6 +7,7 @@ export interface BotConfig { kid: string; private_key: string; // PEM format public_key: string; // Base64 format + signature_agent_format?: "legacy" | "dict"; } export interface SignedRequest { @@ -39,4 +40,3 @@ export interface SignatureParams { algorithm: string; headers: string[]; } - diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index bee9b1f..41cb512 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -26,6 +26,13 @@ GITHUB_CLIENT_SECRET=your_github_client_secret GITHUB_CALLBACK_URL=http://localhost:8080/auth/github/callback FRONTEND_URL=http://localhost:5173 NODE_ENV=development +OBA_CA_MODE=local +OBA_CA_DIR=./.local/ca +OBA_CA_KEY_PATH=./.local/ca/ca.key.json +OBA_CA_CERT_PATH=./.local/ca/ca.pem +OBA_CA_SUBJECT="CN=OpenBotAuth Dev CA" +OBA_CA_VALID_DAYS=3650 +OBA_LEAF_CERT_VALID_DAYS=90 ``` ## Development @@ -70,6 +77,45 @@ Serve JWKS for a user's public keys. } ``` +### Signature Agent Card + +#### GET `/.well-known/signature-agent-card` + +Serves a Signature Agent Card with optional `oba_*` fields and embedded JWKS. + +Query parameters: +- `agent_id` (optional) +- `username` (optional) + +### Certificate Endpoints (MVP) + +#### POST `/v1/certs/issue` + +Issue an X.509 certificate for an agent key. + +**Request:** +```json +{ + "agent_id": "uuid" +} +``` + +#### POST `/v1/certs/revoke` + +Revoke an issued certificate. + +**Request:** +```json +{ + "serial": "hex-serial", + "reason": "key-rotation" +} +``` + +#### GET `/.well-known/ca.pem` + +Fetch the registry CA certificate (PEM). + ### Activity Endpoints #### POST `/agent-activity` @@ -223,4 +269,3 @@ Key tables: ## License MIT - diff --git a/packages/registry-service/package.json b/packages/registry-service/package.json index cec5613..bcce3fc 100644 --- a/packages/registry-service/package.json +++ b/packages/registry-service/package.json @@ -32,6 +32,7 @@ "@openbotauth/a2a-card": "workspace:*", "@openbotauth/github-connector": "workspace:*", "@openbotauth/registry-signer": "workspace:*", + "@peculiar/x509": "^1.12.4", "dotenv": "^17.2.3", "express": "^4.19.2", "pg": "^8.12.0", diff --git a/packages/registry-service/src/routes/agents-api.ts b/packages/registry-service/src/routes/agents-api.ts index eeda24a..a42b002 100644 --- a/packages/registry-service/src/routes/agents-api.ts +++ b/packages/registry-service/src/routes/agents-api.ts @@ -37,7 +37,9 @@ agentsAPIRouter.get( const db: Database = req.app.locals.db; const result = await db.getPool().query( - `SELECT id, user_id, name, description, agent_type, status, public_key, created_at, updated_at + `SELECT id, user_id, name, description, agent_type, status, public_key, + oba_agent_id, oba_parent_agent_id, oba_principal, + created_at, updated_at FROM agents WHERE user_id = $1 ORDER BY created_at DESC`, @@ -68,7 +70,9 @@ agentsAPIRouter.get( const db: Database = req.app.locals.db; const result = await db.getPool().query( - `SELECT id, user_id, name, description, agent_type, status, public_key, created_at, updated_at + `SELECT id, user_id, name, description, agent_type, status, public_key, + oba_agent_id, oba_parent_agent_id, oba_principal, + created_at, updated_at FROM agents WHERE id = $1 AND user_id = $2`, [agentId, session.user.id] @@ -99,7 +103,15 @@ agentsAPIRouter.post( async (req: Request, res: Response): Promise => { try { const session = req.session!; - const { name, description, agent_type, public_key } = req.body; + const { + name, + description, + agent_type, + public_key, + oba_agent_id, + oba_parent_agent_id, + oba_principal, + } = req.body; const db: Database = req.app.locals.db; // Validate required fields @@ -115,10 +127,21 @@ agentsAPIRouter.post( } const result = await db.getPool().query( - `INSERT INTO agents (user_id, name, description, agent_type, public_key) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, user_id, name, description, agent_type, status, public_key, created_at, updated_at`, - [session.user.id, name, description || null, agent_type, JSON.stringify(public_key)] + `INSERT INTO agents (user_id, name, description, agent_type, public_key, oba_agent_id, oba_parent_agent_id, oba_principal) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, user_id, name, description, agent_type, status, public_key, + oba_agent_id, oba_parent_agent_id, oba_principal, + created_at, updated_at`, + [ + session.user.id, + name, + description || null, + agent_type, + JSON.stringify(public_key), + oba_agent_id || null, + oba_parent_agent_id || null, + oba_principal || null, + ] ); res.status(201).json(result.rows[0]); @@ -142,7 +165,16 @@ agentsAPIRouter.put( try { const session = req.session!; const agentId = req.params.id; - const { name, description, agent_type, public_key, status } = req.body; + const { + name, + description, + agent_type, + public_key, + status, + oba_agent_id, + oba_parent_agent_id, + oba_principal, + } = req.body; const db: Database = req.app.locals.db; // Build update query dynamically @@ -183,6 +215,21 @@ agentsAPIRouter.put( values.push(status); paramIndex++; } + if (oba_agent_id !== undefined) { + updates.push(`oba_agent_id = $${paramIndex}`); + values.push(oba_agent_id || null); + paramIndex++; + } + if (oba_parent_agent_id !== undefined) { + updates.push(`oba_parent_agent_id = $${paramIndex}`); + values.push(oba_parent_agent_id || null); + paramIndex++; + } + if (oba_principal !== undefined) { + updates.push(`oba_principal = $${paramIndex}`); + values.push(oba_principal || null); + paramIndex++; + } if (updates.length === 0) { res.status(400).json({ error: 'No fields to update' }); @@ -195,7 +242,9 @@ agentsAPIRouter.put( `UPDATE agents SET ${updates.join(', ')} WHERE id = $1 AND user_id = $2 - RETURNING id, user_id, name, description, agent_type, status, public_key, created_at, updated_at`, + RETURNING id, user_id, name, description, agent_type, status, public_key, + oba_agent_id, oba_parent_agent_id, oba_principal, + created_at, updated_at`, values ); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts new file mode 100644 index 0000000..fec355e --- /dev/null +++ b/packages/registry-service/src/routes/certs.ts @@ -0,0 +1,181 @@ +/** + * Certificate issuance endpoints (MVP) + */ + +import { createHash } from "node:crypto"; +import { Router, type Request, type Response } from "express"; +import type { Database } from "@openbotauth/github-connector"; +import { issueCertificateForJwk, getCertificateAuthority } from "../utils/ca.js"; +import { requireScope } from "../middleware/scopes.js"; + +export const certsRouter: Router = Router(); + +const requireAuth = (req: Request, res: Response, next: Function) => { + if (!req.session) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +}; + +function deriveKidFromX(x: string): string { + const canonical = JSON.stringify({ crv: "Ed25519", kty: "OKP", x }); + const hashBase64 = createHash("sha256").update(canonical).digest("base64"); + return hashBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +certsRouter.post( + "/v1/certs/issue", + requireAuth, + requireScope("agents:write"), + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const { agent_id, kid } = req.body || {}; + + if (!agent_id && !kid) { + res.status(400).json({ + error: "Missing required input: agent_id or kid", + }); + return; + } + + let agent: any = null; + if (agent_id) { + const result = await db.getPool().query( + `SELECT * FROM agents WHERE id = $1 AND user_id = $2`, + [agent_id, req.session!.user.id], + ); + agent = result.rows[0] || null; + } else if (kid) { + const result = await db.getPool().query( + `SELECT * FROM agents + WHERE user_id = $1 AND public_key->>'kid' = $2 + ORDER BY created_at DESC + LIMIT 1`, + [req.session!.user.id, kid], + ); + agent = result.rows[0] || null; + } + + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + const pk = agent.public_key; + if (!pk || typeof pk !== "object" || typeof pk.x !== "string") { + res.status(400).json({ error: "Agent public key is invalid" }); + return; + } + + const resolvedKid = + typeof pk.kid === "string" && pk.kid.length > 0 + ? pk.kid + : deriveKidFromX(pk.x); + + const subject = `CN=${agent.name || "OpenBotAuth Agent"}`; + const validityDays = parseInt( + process.env.OBA_LEAF_CERT_VALID_DAYS || "90", + 10, + ); + + const jwkForCert = { + kty: pk.kty || "OKP", + crv: pk.crv || "Ed25519", + x: pk.x, + kid: resolvedKid, + }; + + const issued = await issueCertificateForJwk(jwkForCert, subject, validityDays); + + await db.getPool().query( + `INSERT INTO agent_certificates + (agent_id, kid, serial, cert_pem, chain_pem, x5c, not_before, not_after, fingerprint_sha256) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + agent.id, + resolvedKid, + issued.serial, + issued.certPem, + issued.chainPem, + issued.x5c, + issued.notBefore, + issued.notAfter, + issued.fingerprintSha256, + ], + ); + + res.json({ + serial: issued.serial, + not_before: issued.notBefore, + not_after: issued.notAfter, + fingerprint_sha256: issued.fingerprintSha256, + cert_pem: issued.certPem, + chain_pem: issued.chainPem, + x5c: issued.x5c, + }); + } catch (error: any) { + console.error("Certificate issuance error:", error); + res.status(500).json({ error: error.message || "Failed to issue certificate" }); + } + }, +); + +certsRouter.post( + "/v1/certs/revoke", + requireAuth, + requireScope("agents:write"), + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const { serial, kid, reason } = req.body || {}; + + if (!serial && !kid) { + res.status(400).json({ error: "Missing required input: serial or kid" }); + return; + } + + const params: any[] = [req.session!.user.id]; + let condition = ""; + + if (serial) { + params.push(serial); + condition = "c.serial = $2"; + } else if (kid) { + params.push(kid); + condition = "c.kid = $2"; + } + + const result = await db.getPool().query( + `UPDATE agent_certificates c + SET revoked_at = now(), revoked_reason = $3 + FROM agents a + WHERE c.agent_id = a.id AND a.user_id = $1 AND ${condition} + RETURNING c.id`, + [...params, reason || null], + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: "Certificate not found" }); + return; + } + + res.json({ success: true, revoked: result.rows.length }); + } catch (error: any) { + console.error("Certificate revocation error:", error); + res.status(500).json({ error: error.message || "Failed to revoke certificate" }); + } + }, +); + +certsRouter.get("/.well-known/ca.pem", async (_req, res: Response): Promise => { + try { + const ca = await getCertificateAuthority(); + res.setHeader("Content-Type", "application/x-pem-file"); + res.setHeader("Cache-Control", "public, max-age=86400"); + res.send(ca.certPem); + } catch (error: any) { + res.status(501).json({ error: error.message || "CA not configured" }); + } +}); diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index 25aa926..f235bf4 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -145,6 +145,32 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise k.kid) + .filter((k: any): k is string => typeof k === 'string' && k.length > 0); + + if (kidsForCerts.length > 0) { + const certResult = await db.getPool().query( + `SELECT DISTINCT ON (kid) kid, x5c + FROM agent_certificates + WHERE kid = ANY($1) AND revoked_at IS NULL + ORDER BY kid, created_at DESC`, + [kidsForCerts] + ); + + const certByKid = new Map(); + for (const row of certResult.rows) { + certByKid.set(row.kid, row.x5c); + } + + for (const jwk of jwks as any[]) { + if (jwk.kid && certByKid.has(jwk.kid)) { + jwk.x5c = certByKid.get(jwk.kid); + } + } + } + // Build Web Bot Auth response const response = createWebBotAuthJWKS(jwks, { client_name: profile.client_name || profile.username, @@ -172,4 +198,3 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const agentId = typeof req.query.agent_id === "string" ? req.query.agent_id : null; + const username = typeof req.query.username === "string" ? req.query.username : null; + + let agent: any = null; + let profile: any = null; + + if (agentId) { + const agentResult = await db.getPool().query( + `SELECT * FROM agents WHERE id = $1`, + [agentId], + ); + agent = agentResult.rows[0] || null; + if (agent) { + profile = await db.findProfileByUserId(agent.user_id); + } + } else if (username) { + profile = await db.findProfileByUsername(username); + if (profile) { + const agentResult = await db.getPool().query( + `SELECT * FROM agents + WHERE user_id = $1 AND status = 'active' + ORDER BY created_at DESC + LIMIT 1`, + [profile.id], + ); + agent = agentResult.rows[0] || null; + } + } else if (req.session) { + const agentResult = await db.getPool().query( + `SELECT * FROM agents + WHERE user_id = $1 AND status = 'active' + ORDER BY created_at DESC + LIMIT 1`, + [req.session.user.id], + ); + agent = agentResult.rows[0] || null; + if (agent) { + profile = await db.findProfileByUserId(agent.user_id); + } + } + + if (!agent) { + res.status(404).json({ + error: "Agent not found", + message: + "Provide ?agent_id=... or ?username=..., or authenticate to resolve a default agent", + }); + return; + } + + const pk = agent.public_key; + if (!pk || typeof pk !== "object" || typeof pk.x !== "string") { + res.status(400).json({ error: "Agent public key is invalid" }); + return; + } + + const kid = + typeof pk.kid === "string" && pk.kid.length > 0 + ? pk.kid + : deriveKidFromX(pk.x); + + const agentJwk: Record = { + kty: "OKP", + crv: "Ed25519", + x: pk.x, + kid, + use: "sig", + alg: "EdDSA", + }; + + // Include x5c if we have an issued cert for this kid + const certResult = await db.getPool().query( + `SELECT x5c + FROM agent_certificates + WHERE agent_id = $1 AND kid = $2 AND revoked_at IS NULL + ORDER BY created_at DESC + LIMIT 1`, + [agent.id, kid], + ); + if (certResult.rows.length > 0) { + agentJwk.x5c = certResult.rows[0].x5c; + } + + const card: Record = { + client_name: agent.name, + client_uri: profile?.client_uri || undefined, + contacts: profile?.contacts || undefined, + "rfc9309-product-token": profile?.rfc9309_product_token || undefined, + purpose: profile?.purpose ? [profile.purpose] : undefined, + keys: { keys: [agentJwk] }, + oba_agent_id: agent.oba_agent_id || undefined, + oba_parent_agent_id: agent.oba_parent_agent_id || undefined, + oba_principal: agent.oba_principal || undefined, + }; + + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "public, max-age=3600"); + res.json(card); + } catch (error) { + console.error("Error serving signature agent card:", error); + res.status(500).json({ error: "Failed to serve signature agent card" }); + } + }, +); diff --git a/packages/registry-service/src/server.ts b/packages/registry-service/src/server.ts index e421986..7929548 100644 --- a/packages/registry-service/src/server.ts +++ b/packages/registry-service/src/server.ts @@ -17,6 +17,8 @@ import { activityRouter } from './routes/activity.js'; import { profilesRouter } from './routes/profiles.js'; import { keysRouter } from './routes/keys.js'; import { telemetryRouter } from './routes/telemetry.js'; +import { signatureAgentCardRouter } from './routes/signature-agent-card.js'; +import { certsRouter } from './routes/certs.js'; import { tokenAuthMiddleware } from './middleware/token-auth.js'; import { sessionMiddleware } from './middleware/session.js'; @@ -93,6 +95,7 @@ app.get('/health', (_req: express.Request, res: express.Response) => { // Routes app.use('/jwks', jwksRouter); +app.use(signatureAgentCardRouter); // Deprecated: agent-jwks endpoint removed in favor of user JWKS app.use('/agent-jwks', (_req, res) => { @@ -108,6 +111,7 @@ app.use('/agent-activity', activityRouter); app.use('/profiles', profilesRouter); app.use('/keys', keysRouter); app.use('/telemetry', telemetryRouter); +app.use(certsRouter); // Error handler app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { @@ -126,4 +130,3 @@ process.on('SIGTERM', async () => { await pool.end(); process.exit(0); }); - diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts new file mode 100644 index 0000000..6302d0a --- /dev/null +++ b/packages/registry-service/src/utils/ca.ts @@ -0,0 +1,250 @@ +/** + * Local Certificate Authority helper (dev/MVP) + */ + +import { webcrypto, randomBytes, createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import * as x509 from "@peculiar/x509"; + +const { X509CertificateGenerator, PemConverter } = x509 as any; + +export interface CertificateAuthority { + subject: string; + privateKey: any; + publicKey: any; + certPem: string; + certDer: Buffer; +} + +export interface IssuedCertificate { + certPem: string; + chainPem: string; + x5c: string[]; + serial: string; + notBefore: Date; + notAfter: Date; + fingerprintSha256: string; +} + +let cachedCa: CertificateAuthority | null = null; + +function ensureCryptoProvider(): void { + if (!(globalThis as any).crypto) { + (globalThis as any).crypto = webcrypto as any; + } + if (x509?.cryptoProvider?.set) { + x509.cryptoProvider.set(webcrypto as any); + } +} + +function getCaPaths() { + const baseDir = + process.env.OBA_CA_DIR || join(process.cwd(), ".local", "ca"); + const keyPath = process.env.OBA_CA_KEY_PATH || join(baseDir, "ca.key.json"); + const certPath = process.env.OBA_CA_CERT_PATH || join(baseDir, "ca.pem"); + return { baseDir, keyPath, certPath }; +} + +async function generateCaKeyPair(): Promise { + return (await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + )) as any; +} + +function randomSerial(): string { + return randomBytes(16).toString("hex"); +} + +function encodeCertPem(cert: any): string { + if (typeof cert?.toString === "function") { + const maybePem = cert.toString(); + if (typeof maybePem === "string" && maybePem.includes("BEGIN CERTIFICATE")) { + return maybePem; + } + } + if (cert?.rawData && PemConverter?.encode) { + return PemConverter.encode(cert.rawData, "CERTIFICATE"); + } + throw new Error("Unable to encode certificate to PEM"); +} + +function certDerBuffer(cert: any): Buffer { + if (cert?.rawData) { + return Buffer.from(cert.rawData); + } + const pem = encodeCertPem(cert); + return Buffer.from( + pem + .replace(/-----BEGIN CERTIFICATE-----/g, "") + .replace(/-----END CERTIFICATE-----/g, "") + .replace(/\s+/g, ""), + "base64", + ); +} + +async function createSelfSignedCa( + keys: { publicKey: any; privateKey: any }, + subject: string, + validDays: number, +): Promise<{ certPem: string; certDer: Buffer }> { + const notBefore = new Date(); + const notAfter = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000); + const serialNumber = randomSerial(); + + const cert = await X509CertificateGenerator.create({ + serialNumber, + subject, + issuer: subject, + notBefore, + notAfter, + publicKey: keys.publicKey, + signingKey: keys.privateKey, + signingAlgorithm: { name: "Ed25519" }, + }); + + const pem = encodeCertPem(cert); + const der = certDerBuffer(cert); + return { certPem: pem, certDer: der }; +} + +async function loadOrCreateCa(): Promise { + const mode = process.env.OBA_CA_MODE || "local"; + if (mode !== "local") { + throw new Error("CA mode not supported (set OBA_CA_MODE=local)"); + } + + if (cachedCa) { + return cachedCa; + } + + ensureCryptoProvider(); + const { baseDir, keyPath, certPath } = getCaPaths(); + mkdirSync(baseDir, { recursive: true, mode: 0o700 }); + + let privateKey: CryptoKey; + let publicKey: CryptoKey; + const subject = process.env.OBA_CA_SUBJECT || "CN=OpenBotAuth Dev CA"; + const validDays = parseInt(process.env.OBA_CA_VALID_DAYS || "3650", 10); + + if (existsSync(keyPath)) { + const content = JSON.parse(readFileSync(keyPath, "utf-8")); + privateKey = await webcrypto.subtle.importKey( + "jwk", + content.privateKeyJwk, + { name: "Ed25519" } as any, + true, + ["sign"], + ); + publicKey = await webcrypto.subtle.importKey( + "jwk", + content.publicKeyJwk, + { name: "Ed25519" } as any, + true, + ["verify"], + ); + } else { + const keys = await generateCaKeyPair(); + privateKey = keys.privateKey; + publicKey = keys.publicKey; + const privateKeyJwk = await webcrypto.subtle.exportKey( + "jwk", + privateKey, + ); + const publicKeyJwk = await webcrypto.subtle.exportKey("jwk", publicKey); + writeFileSync( + keyPath, + JSON.stringify({ privateKeyJwk, publicKeyJwk }, null, 2), + { mode: 0o600 }, + ); + } + + let certPem: string; + let certDer: Buffer; + if (existsSync(certPath)) { + certPem = readFileSync(certPath, "utf-8"); + certDer = Buffer.from( + certPem + .replace(/-----BEGIN CERTIFICATE-----/g, "") + .replace(/-----END CERTIFICATE-----/g, "") + .replace(/\s+/g, ""), + "base64", + ); + } else { + const cert = await createSelfSignedCa( + { publicKey, privateKey }, + subject, + validDays, + ); + certPem = cert.certPem; + certDer = cert.certDer; + writeFileSync(certPath, certPem, { mode: 0o644 }); + } + + cachedCa = { + subject, + privateKey, + publicKey, + certPem, + certDer, + }; + + return cachedCa; +} + +export async function getCertificateAuthority(): Promise { + return await loadOrCreateCa(); +} + +export async function issueCertificateForJwk( + jwk: Record, + subject: string, + validityDays: number, +): Promise { + ensureCryptoProvider(); + const ca = await getCertificateAuthority(); + + const publicKey = await webcrypto.subtle.importKey( + "jwk", + jwk, + { name: "Ed25519" } as any, + true, + ["verify"], + ); + + const notBefore = new Date(); + const notAfter = new Date(Date.now() + validityDays * 24 * 60 * 60 * 1000); + const serialNumber = randomSerial(); + + const cert = await X509CertificateGenerator.create({ + serialNumber, + subject, + issuer: ca.subject, + notBefore, + notAfter, + publicKey, + signingKey: ca.privateKey, + signingAlgorithm: { name: "Ed25519" }, + }); + + const certPem = encodeCertPem(cert); + const certDer = certDerBuffer(cert); + const caPem = ca.certPem; + const caDer = ca.certDer; + + const fingerprintSha256 = createHash("sha256") + .update(certDer) + .digest("hex"); + + return { + certPem, + chainPem: `${certPem}\n${caPem}`, + x5c: [certDer.toString("base64"), caDer.toString("base64")], + serial: serialNumber, + notBefore, + notAfter, + fingerprintSha256, + }; +} diff --git a/packages/verifier-service/README.md b/packages/verifier-service/README.md index 5db56d4..c01c606 100644 --- a/packages/verifier-service/README.md +++ b/packages/verifier-service/README.md @@ -36,6 +36,10 @@ OB_MAX_SKEW_SEC=300 # Nonce TTL (seconds) OB_NONCE_TTL_SEC=600 + +# X.509 delegation validation (optional) +OBA_X509_ENABLED=false +OBA_X509_TRUST_ANCHORS=/path/to/ca.pem ``` ## Usage @@ -65,7 +69,7 @@ Main verification endpoint for NGINX `auth_request`. - `X-Original-Uri` - Request URI - `Signature-Input` - RFC 9421 signature input - `Signature` - RFC 9421 signature -- `Signature-Agent` - JWKS URL +- `Signature-Agent` - Structured Dictionary entry pointing to JWKS (legacy URL also accepted) **Response:** @@ -238,4 +242,3 @@ curl -X POST http://localhost:8081/cache/nonces/clear ## License MIT - diff --git a/packages/verifier-service/src/__fixtures__/x509/ca.pem b/packages/verifier-service/src/__fixtures__/x509/ca.pem new file mode 100644 index 0000000..eddaef0 --- /dev/null +++ b/packages/verifier-service/src/__fixtures__/x509/ca.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBQDCB86ADAgECAhRMekhvN3E5dQScpU3hkH6oqLtF2zAFBgMrZXAwFjEUMBIG +A1UEAwwLT0JBIFRlc3QgQ0EwHhcNMjYwMjIxMTUwNjI4WhcNMjcwMjIxMTUwNjI4 +WjAWMRQwEgYDVQQDDAtPQkEgVGVzdCBDQTAqMAUGAytlcAMhAJSt0xfK5Eyt/cui +g9e+Guyy8Yk3R5/gB//41JQXCgC4o1MwUTAdBgNVHQ4EFgQUvbaQpltVUwlJcEjz +zNMSMFoQCJowHwYDVR0jBBgwFoAUvbaQpltVUwlJcEjzzNMSMFoQCJowDwYDVR0T +AQH/BAUwAwEB/zAFBgMrZXADQQBnfC6/cTnslA5iYnQ87LHn5hx0iWjDbM/NtdRd +kV5PSQ7Mtr2EndoiOVbKvMnpldccpEi0NX2rvpSImGFbIp0B +-----END CERTIFICATE----- diff --git a/packages/verifier-service/src/__fixtures__/x509/leaf.pem b/packages/verifier-service/src/__fixtures__/x509/leaf.pem new file mode 100644 index 0000000..1000889 --- /dev/null +++ b/packages/verifier-service/src/__fixtures__/x509/leaf.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBMTCB5KADAgECAhRZW7HNFW0Wdq5bycIlYa2HLrfFiDAFBgMrZXAwFjEUMBIG +A1UEAwwLT0JBIFRlc3QgQ0EwHhcNMjYwMjIxMTUwNjI4WhcNMjYwMzIzMTUwNjI4 +WjAYMRYwFAYDVQQDDA1PQkEgVGVzdCBMZWFmMCowBQYDK2VwAyEA5Y/mRGu7O0Z8 +qJvXTOP6FUKHe1VeO+ijkmj1JcSYL0ujQjBAMB0GA1UdDgQWBBRJSIHkTiJ2hJpw +sWBLXbfFzkGXbzAfBgNVHSMEGDAWgBS9tpCmW1VTCUlwSPPM0xIwWhAImjAFBgMr +ZXADQQDzrlkZfdBudrFD2w9sZV66sZ5EO97RXaY9qxwjxYnmRZ5aXinNUHTnVmvo +0WtfjG1tdad9QjFj3Lp6QiGZkUcN +-----END CERTIFICATE----- diff --git a/packages/verifier-service/src/server.ts b/packages/verifier-service/src/server.ts index 48b80d6..1c57b4a 100644 --- a/packages/verifier-service/src/server.ts +++ b/packages/verifier-service/src/server.ts @@ -6,6 +6,7 @@ import 'dotenv/config'; import express from 'express'; +import { readFileSync } from 'node:fs'; import { createClient } from 'redis'; import { Pool } from 'pg'; import { JWKSCacheManager } from './jwks-cache.js'; @@ -81,12 +82,39 @@ const discoveryPaths = process.env.OB_JWKS_DISCOVERY_PATHS ? process.env.OB_JWKS_DISCOVERY_PATHS.split(',').map(p => p.trim()) : undefined; +const x509Enabled = process.env.OBA_X509_ENABLED === 'true'; + +const parsePemBundle = (pem: string): string[] => { + const matches = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g); + return matches ? matches.map((m) => m.trim()) : []; +}; + +const trustAnchorRefs = process.env.OBA_X509_TRUST_ANCHORS + ? process.env.OBA_X509_TRUST_ANCHORS.split(',').map((p) => p.trim()).filter(Boolean) + : []; + +const trustAnchors: string[] = []; +for (const ref of trustAnchorRefs) { + if (ref.includes('BEGIN CERTIFICATE')) { + trustAnchors.push(...parsePemBundle(ref)); + continue; + } + try { + const pem = readFileSync(ref, 'utf-8'); + trustAnchors.push(...parsePemBundle(pem)); + } catch (error) { + console.warn(`Failed to read trust anchor file: ${ref}`); + } +} + const verifier = new SignatureVerifier( jwksCache, nonceManager, trustedDirectories, maxSkewSec, - discoveryPaths + discoveryPaths, + x509Enabled, + trustAnchors ); // Initialize telemetry logger (only if DB is configured) @@ -416,4 +444,3 @@ process.on('SIGTERM', async () => { } process.exit(0); }); - diff --git a/packages/verifier-service/src/signature-parser.test.ts b/packages/verifier-service/src/signature-parser.test.ts index c1ac0aa..3648e84 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -156,6 +156,28 @@ describe("validateSafeUrl", () => { }); describe("parseSignatureAgent", () => { + it("should parse structured dictionary and select by label", () => { + const result = parseSignatureAgent( + 'sig1="https://registry.example/agents/pete/.well-known/http-message-signatures-directory", sig2="https://example.com/jwks/alt.json"', + "sig1", + ); + expect(result).toEqual({ + url: "https://registry.example/agents/pete/.well-known/http-message-signatures-directory", + isJwks: true, + }); + }); + + it("should fall back to first dictionary entry when label missing", () => { + const result = parseSignatureAgent( + 'sig1="https://example.com/jwks/a.json", sig2="https://example.com/jwks/b.json"', + "sigX", + ); + expect(result).toEqual({ + url: "https://example.com/jwks/a.json", + isJwks: true, + }); + }); + it("should parse direct JWKS URL with .json extension", () => { const result = parseSignatureAgent("https://example.com/jwks/user.json"); expect(result).toEqual({ @@ -424,6 +446,7 @@ describe("resolveJwksUrl", () => { describe("buildSignatureBase", () => { it("should build signature base with derived components", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -449,6 +472,7 @@ describe("buildSignatureBase", () => { it("should include regular headers when present", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -475,6 +499,7 @@ describe("buildSignatureBase", () => { it("should throw error when covered header is missing", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -500,6 +525,7 @@ describe("buildSignatureBase", () => { it("should handle empty string header values", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index abc98bd..93e4259 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -99,17 +99,19 @@ export function parseSignatureInput( ): SignatureComponents | null { try { // Extract the signature label and the raw params (everything after "label=") - const labelMatch = signatureInput.match(/^(\w+)=(.+)$/); + const labelMatch = signatureInput.match(/^([a-zA-Z0-9_-]+)=(.+)$/); if (!labelMatch) { return null; } + const label = labelMatch[1]; + // Store the raw signature params (everything after "sig1=") // This is used verbatim in the signature base per RFC 9421 const rawSignatureParams = labelMatch[2]; // Now parse for validation and component extraction - const match = signatureInput.match(/^(\w+)=\(([^)]+)\);(.+)$/); + const match = signatureInput.match(/^([a-zA-Z0-9_-]+)=\(([^)]+)\);(.+)$/); if (!match) { return null; } @@ -138,6 +140,7 @@ export function parseSignatureInput( } return { + label, keyId: params.keyid as string, algorithm: (params.alg as string) || "ed25519", created: params.created as number, @@ -233,22 +236,130 @@ export function buildSignatureBase( return lines.join("\n"); } +/** + * Parse RFC 8941 Structured Field Dictionary (string items only) + * + * Returns a map of dictionary keys to string item values. + * Ignores non-string values and parameters. + */ +function parseStructuredDictionaryStringItems( + headerValue: string, +): Record { + const result: Record = {}; + const parts: string[] = []; + let current = ""; + let inQuotes = false; + let escape = false; + + for (let i = 0; i < headerValue.length; i++) { + const ch = headerValue[i]; + if (escape) { + current += ch; + escape = false; + continue; + } + if (ch === "\\") { + if (inQuotes) { + escape = true; + } + current += ch; + continue; + } + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + if (ch === "," && !inQuotes) { + parts.push(current); + current = ""; + continue; + } + current += ch; + } + if (current.trim().length > 0) { + parts.push(current); + } + + for (const part of parts) { + const trimmed = part.trim(); + if (!trimmed) continue; + + const keyMatch = trimmed.match(/^([A-Za-z][A-Za-z0-9._-]*)/); + if (!keyMatch) continue; + const key = keyMatch[1]; + let rest = trimmed.slice(key.length).trimStart(); + if (!rest.startsWith("=")) { + // Bare key (boolean true) - ignore + continue; + } + rest = rest.slice(1).trimStart(); + if (!rest.startsWith('"')) { + // Not a string item + continue; + } + + // Parse quoted string item + let value = ""; + let i = 1; + let escaped = false; + for (; i < rest.length; i++) { + const ch = rest[i]; + if (escaped) { + value += ch; + escaped = false; + continue; + } + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === '"') { + break; + } + value += ch; + } + + if (i >= rest.length || rest[i] !== '"') { + // Unterminated string + continue; + } + + result[key] = value; + } + + return result; +} + /** * Extract JWKS URL from Signature-Agent header * * Handles both direct JWKS URLs and agent identity URLs that need discovery. + * Also supports RFC 8941 Structured Field Dictionary format. * * Examples: * - Direct JWKS: https://openbotregistry.example.com/jwks/hammadtq.json * - Identity URL: https://chatgpt.com (needs discovery) * - Quoted: "https://example.com/jwks.json" * - Angle brackets: + * - Structured Dictionary: sig1="https://.../.well-known/http-message-signatures-directory" */ export function parseSignatureAgent( signatureAgent: string, + signatureLabel?: string, ): { url: string; isJwks: boolean } | null { try { - // Trim whitespace + // First, try Structured Field Dictionary format + const dict = parseStructuredDictionaryStringItems(signatureAgent); + const dictKeys = Object.keys(dict); + if (dictKeys.length > 0) { + const selected = + (signatureLabel && dict[signatureLabel]) || + dict[dictKeys[0]]; + return parseSignatureAgent(selected); + } + + // Trim whitespace (legacy URL format) let cleaned = signatureAgent.trim(); // Strip wrapping quotes if present @@ -269,7 +380,9 @@ export function parseSignatureAgent( // Check if it's already a JWKS URL const isJwks = - url.pathname.endsWith(".json") || url.pathname.includes("/jwks/"); + url.pathname.endsWith(".json") || + url.pathname.includes("/jwks/") || + url.pathname.includes("http-message-signatures-directory"); return { url: cleaned, diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index 8219a49..1b2dd8e 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -8,6 +8,7 @@ import { webcrypto } from 'node:crypto'; import type { JWKSCacheManager } from './jwks-cache.js'; import type { NonceManager } from './nonce-manager.js'; import type { VerificationRequest, VerificationResult } from './types.js'; +import { validateJwkX509 } from './x509.js'; import { parseSignatureInput, parseSignature, @@ -18,15 +19,21 @@ import { export class SignatureVerifier { private discoveryPaths: string[] | undefined; + private x509Enabled: boolean; + private x509TrustAnchors: string[]; constructor( private jwksCache: JWKSCacheManager, private nonceManager: NonceManager, private trustedDirectories: string[] = [], private maxSkewSec: number = 300, - discoveryPaths?: string[] + discoveryPaths?: string[], + x509Enabled: boolean = false, + x509TrustAnchors: string[] = [] ) { this.discoveryPaths = discoveryPaths; + this.x509Enabled = x509Enabled; + this.x509TrustAnchors = x509TrustAnchors; } /** @@ -46,8 +53,17 @@ export class SignatureVerifier { }; } - // 2. Parse Signature-Agent (JWKS URL or identity URL) - const parsedAgent = parseSignatureAgent(signatureAgent); + // 2. Parse signature components (needed for label selection) + const components = parseSignatureInput(signatureInput); + if (!components) { + return { + verified: false, + error: 'Failed to parse Signature-Input header', + }; + } + + // 3. Parse Signature-Agent (Structured Dictionary or legacy URL) + const parsedAgent = parseSignatureAgent(signatureAgent, components.label); if (!parsedAgent) { return { verified: false, @@ -55,7 +71,7 @@ export class SignatureVerifier { }; } - // 3. Resolve JWKS URL (with discovery if needed) + // 4. Resolve JWKS URL (with discovery if needed) let jwksUrl: string; if (parsedAgent.isJwks) { // Already a JWKS URL @@ -72,7 +88,7 @@ export class SignatureVerifier { jwksUrl = discoveredUrl; } - // 4. Check if JWKS URL is from a trusted directory + // 5. Check if JWKS URL is from a trusted directory if (this.trustedDirectories.length > 0) { const trusted = this.trustedDirectories.some(dir => jwksUrl.includes(dir)); if (!trusted) { @@ -83,15 +99,6 @@ export class SignatureVerifier { } } - // 5. Parse signature components - const components = parseSignatureInput(signatureInput); - if (!components) { - return { - verified: false, - error: 'Failed to parse Signature-Input header', - }; - } - const signatureValue = parseSignature(signature); if (!signatureValue) { return { @@ -137,14 +144,27 @@ export class SignatureVerifier { // 8. Fetch JWKS and get the specific key const jwk = await this.jwksCache.getKey(jwksUrl, components.keyId); - // 9. Build signature base + // 9. Optional X.509 delegation validation (x5c/x5u) + if (this.x509Enabled && (jwk?.x5c || jwk?.x5u)) { + const x509Result = await validateJwkX509(jwk, { + trustAnchors: this.x509TrustAnchors, + }); + if (!x509Result.valid) { + return { + verified: false, + error: x509Result.error || 'X.509 validation failed', + }; + } + } + + // 10. Build signature base const signatureBase = buildSignatureBase(components, { method: request.method, url: request.url, headers: request.headers, }); - // 10. Verify signature + // 11. Verify signature const isValid = await this.verifyEd25519Signature( signatureBase, components.signature, @@ -158,7 +178,7 @@ export class SignatureVerifier { }; } - // 11. Success! Return verification result + // 12. Success! Return verification result const jwks = await this.jwksCache.getJWKS(jwksUrl); return { @@ -221,4 +241,3 @@ export class SignatureVerifier { } } } - diff --git a/packages/verifier-service/src/types.ts b/packages/verifier-service/src/types.ts index d162299..e1f41c9 100644 --- a/packages/verifier-service/src/types.ts +++ b/packages/verifier-service/src/types.ts @@ -22,6 +22,8 @@ export interface VerificationResult { } export interface SignatureComponents { + /** Signature label from Signature-Input (e.g., "sig1") */ + label: string; keyId: string; signature: string; algorithm: string; @@ -46,4 +48,3 @@ export interface PolicyVerdict { pay_url?: string; retry_after?: number; } - diff --git a/packages/verifier-service/src/x509.test.ts b/packages/verifier-service/src/x509.test.ts new file mode 100644 index 0000000..e86b3e0 --- /dev/null +++ b/packages/verifier-service/src/x509.test.ts @@ -0,0 +1,49 @@ +import { readFileSync } from "node:fs"; +import { X509Certificate } from "node:crypto"; +import { describe, it, expect } from "vitest"; +import { validateJwkX509 } from "./x509.js"; + +const caPem = readFileSync( + new URL("./__fixtures__/x509/ca.pem", import.meta.url), + "utf-8", +); +const leafPem = readFileSync( + new URL("./__fixtures__/x509/leaf.pem", import.meta.url), + "utf-8", +); + +const caCert = new X509Certificate(caPem); +const leafCert = new X509Certificate(leafPem); + +const leafDerBase64 = leafCert.raw.toString("base64"); +const caDerBase64 = caCert.raw.toString("base64"); + +const leafJwk = leafCert.publicKey.export({ format: "jwk" }); +const caJwk = caCert.publicKey.export({ format: "jwk" }); + +describe("validateJwkX509", () => { + it("validates a proper x5c chain", async () => { + const result = await validateJwkX509( + { ...leafJwk, x5c: [leafDerBase64, caDerBase64] }, + { trustAnchors: [caPem] }, + ); + expect(result.valid).toBe(true); + }); + + it("rejects when leaf cert does not match JWK", async () => { + const result = await validateJwkX509( + { ...caJwk, x5c: [leafDerBase64, caDerBase64] }, + { trustAnchors: [caPem] }, + ); + expect(result.valid).toBe(false); + expect(result.error).toContain("key mismatch"); + }); + + it("rejects when trust anchors are missing", async () => { + const result = await validateJwkX509( + { ...leafJwk, x5c: [leafDerBase64, caDerBase64] }, + { trustAnchors: [] }, + ); + expect(result.valid).toBe(false); + }); +}); diff --git a/packages/verifier-service/src/x509.ts b/packages/verifier-service/src/x509.ts new file mode 100644 index 0000000..dea06ea --- /dev/null +++ b/packages/verifier-service/src/x509.ts @@ -0,0 +1,181 @@ +/** + * X.509 delegation validation for JWKS keys (x5c / x5u) + */ + +import { X509Certificate, createPublicKey } from "node:crypto"; +import { validateSafeUrl } from "./signature-parser.js"; + +export interface X509ValidationOptions { + trustAnchors: string[]; +} + +export interface X509ValidationResult { + valid: boolean; + error?: string; +} + +function parseCertificate(input: Buffer | string): X509Certificate { + return new X509Certificate(input); +} + +function isPem(data: Buffer): boolean { + const text = data.toString("utf-8"); + return text.includes("-----BEGIN CERTIFICATE-----"); +} + +function normalizePem(input: string): string { + return input.includes("-----BEGIN CERTIFICATE-----") + ? input + : `-----BEGIN CERTIFICATE-----\n${input}\n-----END CERTIFICATE-----`; +} + +function jwkMatchesCertPublicKey(jwk: any, cert: X509Certificate): boolean { + const jwkKey = createPublicKey({ key: jwk, format: "jwk" }); + const jwkSpki = jwkKey.export({ type: "spki", format: "der" }) as Buffer; + const certSpki = cert.publicKey.export({ type: "spki", format: "der" }) as Buffer; + return jwkSpki.equals(certSpki); +} + +function parseTrustAnchors(pems: string[]): X509Certificate[] { + return pems + .map((pem) => normalizePem(pem)) + .map((pem) => parseCertificate(pem)); +} + +function validateCertificateTimes(certs: X509Certificate[]): string | null { + const now = Date.now(); + for (const cert of certs) { + const notBefore = Date.parse(cert.validFrom); + const notAfter = Date.parse(cert.validTo); + if (Number.isNaN(notBefore) || Number.isNaN(notAfter)) { + return "X.509 validation failed: invalid certificate validity window"; + } + if (now < notBefore || now > notAfter) { + return "X.509 validation failed: certificate expired or not yet valid"; + } + } + return null; +} + +function validateChain( + certs: X509Certificate[], + trustAnchors: X509Certificate[], +): string | null { + if (certs.length === 0) { + return "X.509 validation failed: empty certificate chain"; + } + + const timeError = validateCertificateTimes(certs); + if (timeError) return timeError; + + // Verify each cert is signed by its issuer (next cert in chain) + for (let i = 0; i < certs.length - 1; i++) { + const cert = certs[i]; + const issuer = certs[i + 1]; + if (!cert.verify(issuer.publicKey)) { + return "X.509 validation failed: certificate chain signature mismatch"; + } + } + + // Verify last cert against trust anchors + const last = certs[certs.length - 1]; + for (const anchor of trustAnchors) { + if (anchor.fingerprint256 === last.fingerprint256) { + return null; + } + if (last.verify(anchor.publicKey)) { + return null; + } + } + + return "X.509 validation failed: chain does not terminate at a trusted anchor"; +} + +async function fetchX5uCert(x5u: string): Promise { + validateSafeUrl(x5u); + + const response = await fetch(x5u, { + method: "GET", + headers: { + Accept: + "application/pkix-cert, application/x-x509-ca-cert, application/octet-stream, application/x-pem-file", + }, + signal: AbortSignal.timeout(3000), + redirect: "manual", + }); + + if (response.status >= 300 && response.status < 400) { + throw new Error("Redirect not allowed for x5u"); + } + + if (!response.ok) { + throw new Error(`Failed to fetch x5u certificate: ${response.status}`); + } + + const contentLength = response.headers.get("content-length"); + if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) { + throw new Error("x5u certificate too large"); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + if (isPem(buffer)) { + return parseCertificate(buffer.toString("utf-8")); + } + return parseCertificate(buffer); +} + +export async function validateJwkX509( + jwk: any, + options: X509ValidationOptions, +): Promise { + try { + if (!options.trustAnchors || options.trustAnchors.length === 0) { + return { + valid: false, + error: "X.509 validation failed: trust anchors not configured", + }; + } + + let certs: X509Certificate[] = []; + if (Array.isArray(jwk?.x5c) && jwk.x5c.length > 0) { + certs = jwk.x5c + .filter((entry: any) => typeof entry === "string" && entry.length > 0) + .map((entry: string) => + parseCertificate(Buffer.from(entry, "base64")), + ); + } else if (typeof jwk?.x5u === "string" && jwk.x5u.length > 0) { + const cert = await fetchX5uCert(jwk.x5u); + certs = [cert]; + } else { + return { valid: true }; + } + + if (certs.length === 0) { + return { + valid: false, + error: "X.509 validation failed: no certificates provided", + }; + } + + // Bind leaf cert public key to JWK + if (!jwkMatchesCertPublicKey(jwk, certs[0])) { + return { + valid: false, + error: "X.509 validation failed: leaf certificate key mismatch", + }; + } + + const trustAnchors = parseTrustAnchors(options.trustAnchors); + const chainError = validateChain(certs, trustAnchors); + if (chainError) { + return { valid: false, error: chainError }; + } + + return { valid: true }; + } catch (error: any) { + return { + valid: false, + error: error?.message || "X.509 validation failed", + }; + } +} From 7ac3d468733382999f241bb0a47f0902f3c029da Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Sun, 22 Feb 2026 22:52:11 +0500 Subject: [PATCH 02/49] Fix X.509 extensions, SAN, and JWKS detection --- packages/registry-service/src/routes/certs.ts | 17 ++++--- .../src/routes/signature-agent-card.ts | 10 +---- packages/registry-service/src/utils/ca.ts | 44 ++++++++++++++++++- packages/registry-service/src/utils/jwk.ts | 11 +++++ .../verifier-service/src/signature-parser.ts | 10 +++-- 5 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 packages/registry-service/src/utils/jwk.ts diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index fec355e..8a166f8 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -2,11 +2,11 @@ * Certificate issuance endpoints (MVP) */ -import { createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; import type { Database } from "@openbotauth/github-connector"; import { issueCertificateForJwk, getCertificateAuthority } from "../utils/ca.js"; import { requireScope } from "../middleware/scopes.js"; +import { jwkThumbprint } from "../utils/jwk.js"; export const certsRouter: Router = Router(); @@ -18,12 +18,6 @@ const requireAuth = (req: Request, res: Response, next: Function) => { next(); }; -function deriveKidFromX(x: string): string { - const canonical = JSON.stringify({ crv: "Ed25519", kty: "OKP", x }); - const hashBase64 = createHash("sha256").update(canonical).digest("base64"); - return hashBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - certsRouter.post( "/v1/certs/issue", requireAuth, @@ -72,7 +66,7 @@ certsRouter.post( const resolvedKid = typeof pk.kid === "string" && pk.kid.length > 0 ? pk.kid - : deriveKidFromX(pk.x); + : jwkThumbprint({ kty: "OKP", crv: "Ed25519", x: pk.x }); const subject = `CN=${agent.name || "OpenBotAuth Agent"}`; const validityDays = parseInt( @@ -87,7 +81,12 @@ certsRouter.post( kid: resolvedKid, }; - const issued = await issueCertificateForJwk(jwkForCert, subject, validityDays); + const issued = await issueCertificateForJwk( + jwkForCert, + subject, + validityDays, + agent.oba_agent_id || null, + ); await db.getPool().query( `INSERT INTO agent_certificates diff --git a/packages/registry-service/src/routes/signature-agent-card.ts b/packages/registry-service/src/routes/signature-agent-card.ts index db3a469..f0269a0 100644 --- a/packages/registry-service/src/routes/signature-agent-card.ts +++ b/packages/registry-service/src/routes/signature-agent-card.ts @@ -4,18 +4,12 @@ * Serves /.well-known/signature-agent-card */ -import { createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; import type { Database } from "@openbotauth/github-connector"; +import { jwkThumbprint } from "../utils/jwk.js"; export const signatureAgentCardRouter: Router = Router(); -function deriveKidFromX(x: string): string { - const canonical = JSON.stringify({ crv: "Ed25519", kty: "OKP", x }); - const hashBase64 = createHash("sha256").update(canonical).digest("base64"); - return hashBase64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); -} - signatureAgentCardRouter.get( "/.well-known/signature-agent-card", async (req: Request, res: Response): Promise => { @@ -80,7 +74,7 @@ signatureAgentCardRouter.get( const kid = typeof pk.kid === "string" && pk.kid.length > 0 ? pk.kid - : deriveKidFromX(pk.x); + : jwkThumbprint({ kty: "OKP", crv: "Ed25519", x: pk.x }); const agentJwk: Record = { kty: "OKP", diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index 6302d0a..7994ab4 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -7,7 +7,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as x509 from "@peculiar/x509"; -const { X509CertificateGenerator, PemConverter } = x509 as any; +const { + X509CertificateGenerator, + PemConverter, + BasicConstraintsExtension, + KeyUsagesExtension, + SubjectAlternativeNameExtension, + GeneralName, + KeyUsageFlags, + URL: GENERAL_NAME_URL, +} = x509 as any; export interface CertificateAuthority { subject: string; @@ -94,6 +103,18 @@ async function createSelfSignedCa( const notAfter = new Date(Date.now() + validDays * 24 * 60 * 60 * 1000); const serialNumber = randomSerial(); + const extensions: any[] = []; + if (BasicConstraintsExtension) { + extensions.push(new BasicConstraintsExtension(true, undefined, true)); + } + if (KeyUsagesExtension && KeyUsageFlags) { + const usage = + (KeyUsageFlags.keyCertSign || 0) | (KeyUsageFlags.cRLSign || 0); + if (usage > 0) { + extensions.push(new KeyUsagesExtension(usage, true)); + } + } + const cert = await X509CertificateGenerator.create({ serialNumber, subject, @@ -103,6 +124,7 @@ async function createSelfSignedCa( publicKey: keys.publicKey, signingKey: keys.privateKey, signingAlgorithm: { name: "Ed25519" }, + extensions, }); const pem = encodeCertPem(cert); @@ -202,6 +224,7 @@ export async function issueCertificateForJwk( jwk: Record, subject: string, validityDays: number, + subjectAltUri?: string | null, ): Promise { ensureCryptoProvider(); const ca = await getCertificateAuthority(); @@ -218,6 +241,24 @@ export async function issueCertificateForJwk( const notAfter = new Date(Date.now() + validityDays * 24 * 60 * 60 * 1000); const serialNumber = randomSerial(); + const extensions: any[] = []; + if (BasicConstraintsExtension) { + extensions.push(new BasicConstraintsExtension(false, undefined, true)); + } + if (KeyUsagesExtension && KeyUsageFlags) { + const usage = KeyUsageFlags.digitalSignature || 0; + if (usage > 0) { + extensions.push(new KeyUsagesExtension(usage, true)); + } + } + if (subjectAltUri && SubjectAlternativeNameExtension) { + const generalNameType = GENERAL_NAME_URL || "url"; + const generalName = GeneralName + ? new GeneralName(generalNameType, subjectAltUri) + : { type: generalNameType, value: subjectAltUri }; + extensions.push(new SubjectAlternativeNameExtension([generalName], false)); + } + const cert = await X509CertificateGenerator.create({ serialNumber, subject, @@ -227,6 +268,7 @@ export async function issueCertificateForJwk( publicKey, signingKey: ca.privateKey, signingAlgorithm: { name: "Ed25519" }, + extensions, }); const certPem = encodeCertPem(cert); diff --git a/packages/registry-service/src/utils/jwk.ts b/packages/registry-service/src/utils/jwk.ts new file mode 100644 index 0000000..5d19781 --- /dev/null +++ b/packages/registry-service/src/utils/jwk.ts @@ -0,0 +1,11 @@ +import { createHash } from "node:crypto"; + +function base64Url(input: string): string { + return input.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +export function jwkThumbprint(jwk: { kty: string; crv: string; x: string }): string { + const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }); + const hash = createHash("sha256").update(canonical).digest("base64"); + return base64Url(hash); +} diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index 93e4259..98de5a1 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -378,11 +378,15 @@ export function parseSignatureAgent( // Validate URL structure const url = new URL(cleaned); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + const isDirectoryPath = + normalizedPath === "/.well-known/http-message-signatures-directory"; + // Check if it's already a JWKS URL const isJwks = - url.pathname.endsWith(".json") || - url.pathname.includes("/jwks/") || - url.pathname.includes("http-message-signatures-directory"); + normalizedPath.endsWith(".json") || + normalizedPath.includes("/jwks/") || + isDirectoryPath; return { url: cleaned, From 84a4b3599ad0e8f9bc0d99036cfb49ac127dd954 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 01:08:34 +0500 Subject: [PATCH 03/49] Fix SAN URI type and document x5u limitations --- packages/registry-service/src/utils/ca.ts | 8 +++++++- packages/registry-service/src/utils/jwk.ts | 1 + packages/verifier-service/README.md | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index 7994ab4..ea36779 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -16,6 +16,8 @@ const { GeneralName, KeyUsageFlags, URL: GENERAL_NAME_URL, + URI: GENERAL_NAME_URI, + UniformResourceIdentifier: GENERAL_NAME_UNIFORM_RESOURCE_IDENTIFIER, } = x509 as any; export interface CertificateAuthority { @@ -252,7 +254,11 @@ export async function issueCertificateForJwk( } } if (subjectAltUri && SubjectAlternativeNameExtension) { - const generalNameType = GENERAL_NAME_URL || "url"; + const generalNameType = + GENERAL_NAME_URI || + GENERAL_NAME_UNIFORM_RESOURCE_IDENTIFIER || + GENERAL_NAME_URL || + "uniformResourceIdentifier"; const generalName = GeneralName ? new GeneralName(generalNameType, subjectAltUri) : { type: generalNameType, value: subjectAltUri }; diff --git a/packages/registry-service/src/utils/jwk.ts b/packages/registry-service/src/utils/jwk.ts index 5d19781..184ddf2 100644 --- a/packages/registry-service/src/utils/jwk.ts +++ b/packages/registry-service/src/utils/jwk.ts @@ -4,6 +4,7 @@ function base64Url(input: string): string { return input.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } +// RFC 7638 OKP thumbprint over the canonical {crv,kty,x} members export function jwkThumbprint(jwk: { kty: string; crv: string; x: string }): string { const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }); const hash = createHash("sha256").update(canonical).digest("base64"); diff --git a/packages/verifier-service/README.md b/packages/verifier-service/README.md index c01c606..47f1b5c 100644 --- a/packages/verifier-service/README.md +++ b/packages/verifier-service/README.md @@ -71,6 +71,10 @@ Main verification endpoint for NGINX `auth_request`. - `Signature` - RFC 9421 signature - `Signature-Agent` - Structured Dictionary entry pointing to JWKS (legacy URL also accepted) +**X.509 delegation notes:** +- `x5c` chains are validated to configured trust anchors when `OBA_X509_ENABLED=true` +- `x5u` currently fetches only the leaf certificate; without AIA chain building, validation succeeds only if the leaf chains directly to a trust anchor (or the anchor is an intermediate) + **Response:** ```json From edcae9cd753584e1193d7a9e658018f9b18d5515 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 01:13:08 +0500 Subject: [PATCH 04/49] Harden SAN URI type and add issuance test --- .../registry-service/src/utils/ca.test.ts | 36 +++++++++++++++++++ packages/registry-service/src/utils/ca.ts | 9 +---- 2 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 packages/registry-service/src/utils/ca.test.ts diff --git a/packages/registry-service/src/utils/ca.test.ts b/packages/registry-service/src/utils/ca.test.ts new file mode 100644 index 0000000..c2ed2b3 --- /dev/null +++ b/packages/registry-service/src/utils/ca.test.ts @@ -0,0 +1,36 @@ +import { webcrypto, X509Certificate } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; +import { issueCertificateForJwk } from "./ca.js"; + +describe("issueCertificateForJwk", () => { + it("encodes SAN URI when subjectAltUri is provided", async () => { + const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}`); + process.env.OBA_CA_MODE = "local"; + process.env.OBA_CA_DIR = baseDir; + process.env.OBA_CA_KEY_PATH = join(baseDir, "ca.key.json"); + process.env.OBA_CA_CERT_PATH = join(baseDir, "ca.pem"); + + const keyPair = (await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + )) as any; + const publicJwk = await webcrypto.subtle.exportKey( + "jwk", + keyPair.publicKey, + ); + + const subjectAltUri = "agent:tester@example.com/voice"; + const issued = await issueCertificateForJwk( + publicJwk as any, + "CN=OBA Test Leaf", + 1, + subjectAltUri, + ); + + const cert = new X509Certificate(issued.certPem); + expect(cert.subjectAltName).toContain(`URI:${subjectAltUri}`); + }); +}); diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index ea36779..6d6170c 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -15,9 +15,6 @@ const { SubjectAlternativeNameExtension, GeneralName, KeyUsageFlags, - URL: GENERAL_NAME_URL, - URI: GENERAL_NAME_URI, - UniformResourceIdentifier: GENERAL_NAME_UNIFORM_RESOURCE_IDENTIFIER, } = x509 as any; export interface CertificateAuthority { @@ -254,11 +251,7 @@ export async function issueCertificateForJwk( } } if (subjectAltUri && SubjectAlternativeNameExtension) { - const generalNameType = - GENERAL_NAME_URI || - GENERAL_NAME_UNIFORM_RESOURCE_IDENTIFIER || - GENERAL_NAME_URL || - "uniformResourceIdentifier"; + const generalNameType = "uniformResourceIdentifier"; const generalName = GeneralName ? new GeneralName(generalNameType, subjectAltUri) : { type: generalNameType, value: subjectAltUri }; From ef4c494d2f8f778bea4938405d244b8e48fcc97a Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 01:17:39 +0500 Subject: [PATCH 05/49] Stabilize CA SAN tests --- .../registry-service/src/utils/ca.test.ts | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/registry-service/src/utils/ca.test.ts b/packages/registry-service/src/utils/ca.test.ts index c2ed2b3..4d64c47 100644 --- a/packages/registry-service/src/utils/ca.test.ts +++ b/packages/registry-service/src/utils/ca.test.ts @@ -1,9 +1,11 @@ -import { webcrypto, X509Certificate } from "node:crypto"; +import * as crypto from "node:crypto"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, it, expect } from "vitest"; import { issueCertificateForJwk } from "./ca.js"; +const { webcrypto, X509Certificate } = crypto; + describe("issueCertificateForJwk", () => { it("encodes SAN URI when subjectAltUri is provided", async () => { const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}`); @@ -33,4 +35,32 @@ describe("issueCertificateForJwk", () => { const cert = new X509Certificate(issued.certPem); expect(cert.subjectAltName).toContain(`URI:${subjectAltUri}`); }); + + it("does not include SAN URI when subjectAltUri is omitted", async () => { + const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}-nosan`); + process.env.OBA_CA_MODE = "local"; + process.env.OBA_CA_DIR = baseDir; + process.env.OBA_CA_KEY_PATH = join(baseDir, "ca.key.json"); + process.env.OBA_CA_CERT_PATH = join(baseDir, "ca.pem"); + + const keyPair = (await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + )) as any; + const publicJwk = await webcrypto.subtle.exportKey( + "jwk", + keyPair.publicKey, + ); + + const issued = await issueCertificateForJwk( + publicJwk as any, + "CN=OBA Test Leaf", + 1, + null, + ); + + const cert = new X509Certificate(issued.certPem); + expect(cert.subjectAltName).not.toContain("URI:"); + }); }); From 1ffbc5aa38caf56f8739e2ee46e47b88bd3be461 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 01:32:42 +0500 Subject: [PATCH 06/49] Fix JWKS directory detection and doc notes --- packages/registry-service/README.md | 4 ++++ packages/registry-service/src/utils/ca.test.ts | 2 +- packages/verifier-service/README.md | 1 + packages/verifier-service/src/jwks-cache.ts | 3 ++- packages/verifier-service/src/signature-parser.ts | 5 +++-- 5 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 41cb512..3263bb7 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -93,6 +93,10 @@ Query parameters: Issue an X.509 certificate for an agent key. +Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf +certificate as an informational hint. This value is user-supplied unless you +enforce registry-side issuance rules. + **Request:** ```json { diff --git a/packages/registry-service/src/utils/ca.test.ts b/packages/registry-service/src/utils/ca.test.ts index 4d64c47..68dc9dc 100644 --- a/packages/registry-service/src/utils/ca.test.ts +++ b/packages/registry-service/src/utils/ca.test.ts @@ -61,6 +61,6 @@ describe("issueCertificateForJwk", () => { ); const cert = new X509Certificate(issued.certPem); - expect(cert.subjectAltName).not.toContain("URI:"); + expect(cert.subjectAltName ?? "").not.toContain("URI:"); }); }); diff --git a/packages/verifier-service/README.md b/packages/verifier-service/README.md index 47f1b5c..5d52501 100644 --- a/packages/verifier-service/README.md +++ b/packages/verifier-service/README.md @@ -74,6 +74,7 @@ Main verification endpoint for NGINX `auth_request`. **X.509 delegation notes:** - `x5c` chains are validated to configured trust anchors when `OBA_X509_ENABLED=true` - `x5u` currently fetches only the leaf certificate; without AIA chain building, validation succeeds only if the leaf chains directly to a trust anchor (or the anchor is an intermediate) +- Verifier does not currently enforce EKU/basicConstraints or bind certificate identity to the Signature-Agent URL **Response:** diff --git a/packages/verifier-service/src/jwks-cache.ts b/packages/verifier-service/src/jwks-cache.ts index 2445b77..8b75692 100644 --- a/packages/verifier-service/src/jwks-cache.ts +++ b/packages/verifier-service/src/jwks-cache.ts @@ -64,7 +64,8 @@ export class JWKSCacheManager { const response = await fetch(jwksUrl, { method: "GET", headers: { - Accept: "application/json", + Accept: + "application/jwk-set+json, application/http-message-signatures-directory, application/json", "User-Agent": "OpenBotAuth-Verifier/0.1.0", }, signal: AbortSignal.timeout(3000), // 3s timeout diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index 98de5a1..ea3b64b 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -379,8 +379,9 @@ export function parseSignatureAgent( const url = new URL(cleaned); const normalizedPath = url.pathname.replace(/\/+$/, ""); - const isDirectoryPath = - normalizedPath === "/.well-known/http-message-signatures-directory"; + const isDirectoryPath = normalizedPath.endsWith( + "/.well-known/http-message-signatures-directory", + ); // Check if it's already a JWKS URL const isJwks = From a93cb3c7858d47fcd9d4759629bc0543dd96e1e1 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 01:46:12 +0500 Subject: [PATCH 07/49] Align label parsing and harden cert/JWKS handling --- packages/registry-service/src/routes/certs.ts | 30 +++++++++++++++++-- packages/registry-service/src/routes/jwks.ts | 21 +++++++------ .../src/signature-parser.test.ts | 19 +++++++++++- .../verifier-service/src/signature-parser.ts | 19 ++++++++---- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 8a166f8..0efec52 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -18,6 +18,24 @@ const requireAuth = (req: Request, res: Response, next: Function) => { next(); }; +function sanitizeDnValue(value: string, fallback: string): string { + const cleaned = value + .replace(/[\r\n\t\0]/g, " ") + .replace(/[=,+<>#;"\\]/g, " "); + const compact = cleaned.replace(/\s+/g, " ").trim(); + if (!compact) return fallback; + return compact.slice(0, 64); +} + +function isValidAgentId(value: string): boolean { + if (value.length > 255) return false; + if (!value.startsWith("agent:")) return false; + if (/\s/.test(value)) return false; + return /^agent:[A-Za-z0-9._-]+@[A-Za-z0-9.-]+(\/[A-Za-z0-9._-]+)?$/.test( + value, + ); +} + certsRouter.post( "/v1/certs/issue", requireAuth, @@ -68,7 +86,11 @@ certsRouter.post( ? pk.kid : jwkThumbprint({ kty: "OKP", crv: "Ed25519", x: pk.x }); - const subject = `CN=${agent.name || "OpenBotAuth Agent"}`; + const subjectCn = sanitizeDnValue( + agent.name || "OpenBotAuth Agent", + "OpenBotAuth Agent", + ); + const subject = `CN=${subjectCn}`; const validityDays = parseInt( process.env.OBA_LEAF_CERT_VALID_DAYS || "90", 10, @@ -85,7 +107,9 @@ certsRouter.post( jwkForCert, subject, validityDays, - agent.oba_agent_id || null, + typeof agent.oba_agent_id === "string" && isValidAgentId(agent.oba_agent_id) + ? agent.oba_agent_id + : null, ); await db.getPool().query( @@ -105,6 +129,7 @@ certsRouter.post( ], ); + res.setHeader("Cache-Control", "no-store"); res.json({ serial: issued.serial, not_before: issued.notBefore, @@ -160,6 +185,7 @@ certsRouter.post( return; } + res.setHeader("Cache-Control", "no-store"); res.json({ success: true, revoked: result.rows.length }); } catch (error: any) { console.error("Certificate revocation error:", error); diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index f235bf4..f674996 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -78,6 +78,7 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise k.kid).filter((k): k is string => typeof k === 'string' && k.length > 0) ); + const agentKids = new Set(); for (const agent of agentsResult.rows) { const pk = agent.public_key; @@ -117,6 +118,7 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise k.kid) - .filter((k: any): k is string => typeof k === 'string' && k.length > 0); + const kidsForCerts = Array.from(agentKids); if (kidsForCerts.length > 0) { const certResult = await db.getPool().query( - `SELECT DISTINCT ON (kid) kid, x5c - FROM agent_certificates - WHERE kid = ANY($1) AND revoked_at IS NULL - ORDER BY kid, created_at DESC`, - [kidsForCerts] + `SELECT DISTINCT ON (c.kid) c.kid, c.x5c + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE c.kid = ANY($1) + AND c.revoked_at IS NULL + AND a.user_id = $2 + ORDER BY c.kid, c.created_at DESC`, + [kidsForCerts, profile.id] ); const certByKid = new Map(); @@ -165,7 +168,7 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise { }); }); +describe("parseSignatureInput", () => { + it("should parse labels with dash and dot", () => { + const parsed = parseSignatureInput( + 'sig-1.test=("@method" "@path");created=123;keyid="k1"', + ); + expect(parsed?.label).toBe("sig-1.test"); + }); +}); + +describe("parseSignature", () => { + it("should parse signature with dash/dot label", () => { + const parsed = parseSignature("sig-1.test=:Zm9vYmFyOg==:"); + expect(parsed).toBe("Zm9vYmFyOg=="); + }); +}); + describe("resolveJwksUrl", () => { let fetchMock: any; @@ -271,7 +288,7 @@ describe("resolveJwksUrl", () => { expect.objectContaining({ method: "GET", headers: expect.objectContaining({ - Accept: "application/json", + Accept: expect.stringContaining("http-message-signatures-directory"), }), }), ); diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index ea3b64b..c07c31f 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -6,6 +6,12 @@ import type { SignatureComponents } from "./types.js"; +const LABEL_PATTERN = "[A-Za-z][A-Za-z0-9._-]*"; +const LABEL_REGEX = new RegExp(`^${LABEL_PATTERN}`); +const SIGNATURE_INPUT_LABEL_RE = new RegExp(`^(${LABEL_PATTERN})=(.+)$`); +const SIGNATURE_INPUT_RE = new RegExp(`^(${LABEL_PATTERN})=\\(([^)]+)\\);(.+)$`); +const SIGNATURE_RE = new RegExp(`^(${LABEL_PATTERN})=:([^:]+):$`); + /** * SSRF protection: validate that a URL is safe to fetch * @@ -99,7 +105,7 @@ export function parseSignatureInput( ): SignatureComponents | null { try { // Extract the signature label and the raw params (everything after "label=") - const labelMatch = signatureInput.match(/^([a-zA-Z0-9_-]+)=(.+)$/); + const labelMatch = signatureInput.match(SIGNATURE_INPUT_LABEL_RE); if (!labelMatch) { return null; } @@ -111,7 +117,7 @@ export function parseSignatureInput( const rawSignatureParams = labelMatch[2]; // Now parse for validation and component extraction - const match = signatureInput.match(/^([a-zA-Z0-9_-]+)=\(([^)]+)\);(.+)$/); + const match = signatureInput.match(SIGNATURE_INPUT_RE); if (!match) { return null; } @@ -165,11 +171,11 @@ export function parseSignatureInput( export function parseSignature(signature: string): string | null { try { // Extract base64 signature from sig1=:...: - const match = signature.match(/^\w+=:([^:]+):$/); + const match = signature.match(SIGNATURE_RE); if (!match) { return null; } - return match[1]; + return match[2]; } catch (error) { console.error("Error parsing Signature:", error); return null; @@ -285,7 +291,7 @@ function parseStructuredDictionaryStringItems( const trimmed = part.trim(); if (!trimmed) continue; - const keyMatch = trimmed.match(/^([A-Za-z][A-Za-z0-9._-]*)/); + const keyMatch = trimmed.match(LABEL_REGEX); if (!keyMatch) continue; const key = keyMatch[1]; let rest = trimmed.slice(key.length).trimStart(); @@ -439,7 +445,8 @@ export async function resolveJwksUrl( const response = await fetch(candidateUrl, { method: "GET", headers: { - Accept: "application/json", + Accept: + "application/jwk-set+json, application/http-message-signatures-directory, application/json", "User-Agent": "OpenBotAuth-Verifier/0.1.0", }, signal: AbortSignal.timeout(3000), // 3s timeout From dd5bc35619d8d1f54e0636a6b14fa13833cd507b Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 01:54:29 +0500 Subject: [PATCH 08/49] Fix structured dict key capture and label test --- packages/verifier-service/src/signature-parser.test.ts | 2 +- packages/verifier-service/src/signature-parser.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/verifier-service/src/signature-parser.test.ts b/packages/verifier-service/src/signature-parser.test.ts index 766e25d..abf84a1 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -246,7 +246,7 @@ describe("parseSignatureAgent", () => { describe("parseSignatureInput", () => { it("should parse labels with dash and dot", () => { const parsed = parseSignatureInput( - 'sig-1.test=("@method" "@path");created=123;keyid="k1"', + 'sig-1.test=("@method" "@path");created=123;expires=124;nonce="n";keyid="k1";alg="ed25519"', ); expect(parsed?.label).toBe("sig-1.test"); }); diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index c07c31f..8dabeba 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -8,6 +8,7 @@ import type { SignatureComponents } from "./types.js"; const LABEL_PATTERN = "[A-Za-z][A-Za-z0-9._-]*"; const LABEL_REGEX = new RegExp(`^${LABEL_PATTERN}`); +const LABEL_CAPTURE_RE = new RegExp(`^(${LABEL_PATTERN})`); const SIGNATURE_INPUT_LABEL_RE = new RegExp(`^(${LABEL_PATTERN})=(.+)$`); const SIGNATURE_INPUT_RE = new RegExp(`^(${LABEL_PATTERN})=\\(([^)]+)\\);(.+)$`); const SIGNATURE_RE = new RegExp(`^(${LABEL_PATTERN})=:([^:]+):$`); @@ -291,7 +292,7 @@ function parseStructuredDictionaryStringItems( const trimmed = part.trim(); if (!trimmed) continue; - const keyMatch = trimmed.match(LABEL_REGEX); + const keyMatch = trimmed.match(LABEL_CAPTURE_RE); if (!keyMatch) continue; const key = keyMatch[1]; let rest = trimmed.slice(key.length).trimStart(); From c82adcf0869c223a9a0a90ffa6c7cf2e16eba64e Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 02:06:16 +0500 Subject: [PATCH 09/49] Fix parser cleanup and agent kid cert edge case --- packages/registry-service/src/routes/jwks.ts | 4 +++- .../verifier-service/src/signature-parser.test.ts | 15 ++++++++------- packages/verifier-service/src/signature-parser.ts | 1 - 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index f674996..9e23b82 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -110,6 +110,9 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise { vi.restoreAllMocks(); }); - it("should discover JWKS at /.well-known/jwks.json", async () => { + it("should discover JWKS at http-message-signatures-directory", async () => { const validJwks = { keys: [{ kid: "test", kty: "OKP" }] }; fetchMock.mockResolvedValueOnce({ @@ -282,9 +282,11 @@ describe("resolveJwksUrl", () => { }); const result = await resolveJwksUrl("https://chatgpt.com"); - expect(result).toBe("https://chatgpt.com/.well-known/jwks.json"); + expect(result).toBe( + "https://chatgpt.com/.well-known/http-message-signatures-directory", + ); expect(fetchMock).toHaveBeenCalledWith( - "https://chatgpt.com/.well-known/jwks.json", + "https://chatgpt.com/.well-known/http-message-signatures-directory", expect.objectContaining({ method: "GET", headers: expect.objectContaining({ @@ -311,9 +313,7 @@ describe("resolveJwksUrl", () => { }); const result = await resolveJwksUrl("https://example.com"); - expect(result).toBe( - "https://example.com/.well-known/openbotauth/jwks.json", - ); + expect(result).toBe("https://example.com/.well-known/jwks.json"); }); it("should return null if no valid JWKS found", async () => { @@ -415,7 +415,7 @@ describe("resolveJwksUrl", () => { const result = await resolveJwksUrl("https://example.com"); expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(3); // Tries all 3 default paths + expect(fetchMock).toHaveBeenCalledTimes(4); // Tries all 4 default paths }); it("should block 301 redirects (SSRF protection)", async () => { @@ -570,6 +570,7 @@ describe("parseSignatureInput", () => { const result = parseSignatureInput(input); expect(result).toEqual({ + label: "sig1", keyId: "test-key-ed25519", algorithm: "ed25519", created: 1618884473, diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index 8dabeba..f330522 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -7,7 +7,6 @@ import type { SignatureComponents } from "./types.js"; const LABEL_PATTERN = "[A-Za-z][A-Za-z0-9._-]*"; -const LABEL_REGEX = new RegExp(`^${LABEL_PATTERN}`); const LABEL_CAPTURE_RE = new RegExp(`^(${LABEL_PATTERN})`); const SIGNATURE_INPUT_LABEL_RE = new RegExp(`^(${LABEL_PATTERN})=(.+)$`); const SIGNATURE_INPUT_RE = new RegExp(`^(${LABEL_PATTERN})=\\(([^)]+)\\);(.+)$`); From 01046ce1ff1f236f50112dc61e5c2f77f3b70bf2 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 02:26:35 +0500 Subject: [PATCH 10/49] Fix lockfile and tighten agent cert attachment --- packages/registry-service/src/routes/jwks.ts | 43 +++-- packages/registry-service/src/utils/ca.ts | 18 +- pnpm-lock.yaml | 168 +++++++++++++++++++ 3 files changed, 198 insertions(+), 31 deletions(-) diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index 9e23b82..dbf5750 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -5,10 +5,10 @@ * Implements the behavior from supabase/functions/jwks/index.ts */ -import { createHash } from 'node:crypto'; import { Router, type Request, type Response } from 'express'; import type { Database } from '@openbotauth/github-connector'; import { base64PublicKeyToJWK, createWebBotAuthJWKS, generateKidFromJWK } from '@openbotauth/registry-signer'; +import { jwkThumbprint } from '../utils/jwk.js'; export const jwksRouter: Router = Router(); @@ -78,7 +78,7 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise k.kid).filter((k): k is string => typeof k === 'string' && k.length > 0) ); - const agentKids = new Set(); + const agentJwkRefs: Array<{ agentId: string; kid: string; jwk: Record }> = []; for (const agent of agentsResult.rows) { const pk = agent.public_key; @@ -97,22 +97,15 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise }); } if (jwks.length === 0) { @@ -150,28 +144,31 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise ref.kid))); + const agentIdsForCerts = Array.from(new Set(agentJwkRefs.map(ref => ref.agentId))); - if (kidsForCerts.length > 0) { + if (kidsForCerts.length > 0 && agentIdsForCerts.length > 0) { const certResult = await db.getPool().query( - `SELECT DISTINCT ON (c.kid) c.kid, c.x5c + `SELECT DISTINCT ON (c.agent_id, c.kid) c.agent_id, c.kid, c.x5c FROM agent_certificates c JOIN agents a ON a.id = c.agent_id - WHERE c.kid = ANY($1) + WHERE c.agent_id = ANY($1) + AND c.kid = ANY($2) AND c.revoked_at IS NULL - AND a.user_id = $2 - ORDER BY c.kid, c.created_at DESC`, - [kidsForCerts, profile.id] + AND a.user_id = $3 + ORDER BY c.agent_id, c.kid, c.created_at DESC`, + [agentIdsForCerts, kidsForCerts, profile.id] ); - const certByKid = new Map(); + const certByAgentKid = new Map(); for (const row of certResult.rows) { - certByKid.set(row.kid, row.x5c); + certByAgentKid.set(`${row.agent_id}:${row.kid}`, row.x5c); } - for (const jwk of jwks as any[]) { - if (jwk.kid && agentKids.has(jwk.kid) && certByKid.has(jwk.kid)) { - jwk.x5c = certByKid.get(jwk.kid); + for (const ref of agentJwkRefs) { + const certKey = `${ref.agentId}:${ref.kid}`; + if (certByAgentKid.has(certKey)) { + ref.jwk.x5c = certByAgentKid.get(certKey); } } } diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index 6d6170c..143b210 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -13,7 +13,6 @@ const { BasicConstraintsExtension, KeyUsagesExtension, SubjectAlternativeNameExtension, - GeneralName, KeyUsageFlags, } = x509 as any; @@ -145,8 +144,8 @@ async function loadOrCreateCa(): Promise { const { baseDir, keyPath, certPath } = getCaPaths(); mkdirSync(baseDir, { recursive: true, mode: 0o700 }); - let privateKey: CryptoKey; - let publicKey: CryptoKey; + let privateKey: any; + let publicKey: any; const subject = process.env.OBA_CA_SUBJECT || "CN=OpenBotAuth Dev CA"; const validDays = parseInt(process.env.OBA_CA_VALID_DAYS || "3650", 10); @@ -251,11 +250,14 @@ export async function issueCertificateForJwk( } } if (subjectAltUri && SubjectAlternativeNameExtension) { - const generalNameType = "uniformResourceIdentifier"; - const generalName = GeneralName - ? new GeneralName(generalNameType, subjectAltUri) - : { type: generalNameType, value: subjectAltUri }; - extensions.push(new SubjectAlternativeNameExtension([generalName], false)); + const sanType = + typeof (x509 as any).URL === "string" ? (x509 as any).URL : "url"; + extensions.push( + new SubjectAlternativeNameExtension( + [{ type: sanType, value: subjectAltUri }], + false, + ), + ); } const cert = await X509CertificateGenerator.create({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83d1ef6..23e633c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,6 +405,9 @@ importers: '@openbotauth/registry-signer': specifier: workspace:* version: link:../registry-signer + '@peculiar/x509': + specifier: ^1.12.4 + version: 1.14.3 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -942,6 +945,40 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@peculiar/asn1-cms@2.6.1': + resolution: {integrity: sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==} + + '@peculiar/asn1-csr@2.6.1': + resolution: {integrity: sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==} + + '@peculiar/asn1-ecc@2.6.1': + resolution: {integrity: sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==} + + '@peculiar/asn1-pfx@2.6.1': + resolution: {integrity: sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==} + + '@peculiar/asn1-pkcs8@2.6.1': + resolution: {integrity: sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==} + + '@peculiar/asn1-pkcs9@2.6.1': + resolution: {integrity: sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==} + + '@peculiar/asn1-rsa@2.6.1': + resolution: {integrity: sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==} + + '@peculiar/asn1-schema@2.6.0': + resolution: {integrity: sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==} + + '@peculiar/asn1-x509-attr@2.6.1': + resolution: {integrity: sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==} + + '@peculiar/asn1-x509@2.6.1': + resolution: {integrity: sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2172,6 +2209,10 @@ packages: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} + asn1js@3.0.7: + resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} + engines: {node: '>=12.0.0'} + assertion-error@1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} @@ -3319,6 +3360,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + qs@6.13.0: resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} engines: {node: '>=0.6'} @@ -3454,6 +3502,9 @@ packages: redis@4.7.1: resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3700,6 +3751,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -3708,6 +3762,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4241,6 +4299,96 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 + '@peculiar/asn1-cms@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.6.1': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-pfx': 2.6.1 + '@peculiar/asn1-pkcs8': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + '@peculiar/asn1-x509-attr': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.6.0': + dependencies: + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + asn1js: 3.0.7 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.6.1': + dependencies: + '@peculiar/asn1-schema': 2.6.0 + asn1js: 3.0.7 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.6.1 + '@peculiar/asn1-csr': 2.6.1 + '@peculiar/asn1-ecc': 2.6.1 + '@peculiar/asn1-pkcs9': 2.6.1 + '@peculiar/asn1-rsa': 2.6.1 + '@peculiar/asn1-schema': 2.6.0 + '@peculiar/asn1-x509': 2.6.1 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + '@pkgjs/parseargs@0.11.0': optional: true @@ -5528,6 +5676,12 @@ snapshots: array-union@2.1.0: {} + asn1js@3.0.7: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + assertion-error@1.1.0: {} assertion-error@2.0.1: {} @@ -6705,6 +6859,12 @@ snapshots: punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.5: {} + qs@6.13.0: dependencies: side-channel: 1.1.0 @@ -6854,6 +7014,8 @@ snapshots: '@redis/search': 1.2.0(@redis/client@1.6.1) '@redis/time-series': 1.1.0(@redis/client@1.6.1) + reflect-metadata@0.2.2: {} + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -7133,6 +7295,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.20.6: @@ -7142,6 +7306,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 From 4391f8b8fb803769262da284a10d9e632e524b1e Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 02:40:56 +0500 Subject: [PATCH 11/49] Disable public cache for session-derived agent cards --- .../src/routes/signature-agent-card.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/registry-service/src/routes/signature-agent-card.ts b/packages/registry-service/src/routes/signature-agent-card.ts index f0269a0..50de6fa 100644 --- a/packages/registry-service/src/routes/signature-agent-card.ts +++ b/packages/registry-service/src/routes/signature-agent-card.ts @@ -17,11 +17,13 @@ signatureAgentCardRouter.get( const db: Database = req.app.locals.db; const agentId = typeof req.query.agent_id === "string" ? req.query.agent_id : null; const username = typeof req.query.username === "string" ? req.query.username : null; + let resolutionSource: "agent_id" | "username" | "session" = "session"; let agent: any = null; let profile: any = null; if (agentId) { + resolutionSource = "agent_id"; const agentResult = await db.getPool().query( `SELECT * FROM agents WHERE id = $1`, [agentId], @@ -31,6 +33,7 @@ signatureAgentCardRouter.get( profile = await db.findProfileByUserId(agent.user_id); } } else if (username) { + resolutionSource = "username"; profile = await db.findProfileByUsername(username); if (profile) { const agentResult = await db.getPool().query( @@ -111,7 +114,13 @@ signatureAgentCardRouter.get( }; res.setHeader("Content-Type", "application/json"); - res.setHeader("Cache-Control", "public, max-age=3600"); + if (resolutionSource === "session") { + // Session-derived cards must not be cached by shared intermediaries. + res.setHeader("Cache-Control", "private, no-store"); + res.setHeader("Vary", "Cookie"); + } else { + res.setHeader("Cache-Control", "public, max-age=3600"); + } res.json(card); } catch (error) { console.error("Error serving signature agent card:", error); From 87fe9df71ce51696d7bb8fc0b8d731a4e575b24a Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 02:55:42 +0500 Subject: [PATCH 12/49] Add cert read APIs and portal certificate lifecycle UI --- apps/registry-portal/src/lib/api.ts | 112 ++++++ .../src/pages/portal/AgentDetail.tsx | 323 +++++++++++++++++- .../008_agent_cert_fingerprint_index.sql | 3 + packages/registry-service/README.md | 56 +++ .../src/routes/__tests__/certs.test.ts | 238 +++++++++++++ packages/registry-service/src/routes/certs.ts | 205 +++++++++++ 6 files changed, 934 insertions(+), 3 deletions(-) create mode 100644 infra/neon/migrations/008_agent_cert_fingerprint_index.sql create mode 100644 packages/registry-service/src/routes/__tests__/certs.test.ts diff --git a/apps/registry-portal/src/lib/api.ts b/apps/registry-portal/src/lib/api.ts index 0ea9b27..4ea668e 100644 --- a/apps/registry-portal/src/lib/api.ts +++ b/apps/registry-portal/src/lib/api.ts @@ -65,6 +65,43 @@ export interface Session { profile: Profile; } +export interface AgentCertificate { + id: string; + agent_id: string; + kid: string; + serial: string; + fingerprint_sha256: string; + not_before: string; + not_after: string; + revoked_at: string | null; + revoked_reason: string | null; + created_at: string; + is_active?: boolean; +} + +export interface AgentCertificateDetail extends AgentCertificate { + cert_pem: string; + chain_pem: string; + x5c: string[]; +} + +export interface ListCertsResponse { + items: AgentCertificate[]; + total: number; + limit: number; + offset: number; +} + +export interface IssueCertResponse { + serial: string; + not_before: string; + not_after: string; + fingerprint_sha256: string; + cert_pem: string; + chain_pem: string; + x5c: string[]; +} + // Radar types export interface RadarOverview { window: 'today' | '7d'; @@ -310,6 +347,81 @@ class RegistryAPI { } } + /** + * List issued certificates for the current user (optionally filtered) + */ + async listAgentCerts(params: { + agent_id?: string; + kid?: string; + status?: 'active' | 'revoked' | 'all'; + limit?: number; + offset?: number; + } = {}): Promise { + const searchParams = new URLSearchParams(); + if (params.agent_id) searchParams.set('agent_id', params.agent_id); + if (params.kid) searchParams.set('kid', params.kid); + if (params.status) searchParams.set('status', params.status); + if (typeof params.limit === 'number') searchParams.set('limit', String(params.limit)); + if (typeof params.offset === 'number') searchParams.set('offset', String(params.offset)); + + const query = searchParams.toString(); + const response = await this.fetch(query ? `/v1/certs?${query}` : '/v1/certs'); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to list certificates'); + } + return await response.json(); + } + + /** + * Get one certificate (metadata + PEM chain + x5c) by serial. + */ + async getCertBySerial(serial: string): Promise { + const response = await this.fetch(`/v1/certs/${encodeURIComponent(serial)}`); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to fetch certificate'); + } + return await response.json(); + } + + /** + * Issue a certificate for an agent key. + */ + async issueCert(data: { + agent_id?: string; + kid?: string; + }): Promise { + const response = await this.fetch('/v1/certs/issue', { + method: 'POST', + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to issue certificate'); + } + return await response.json(); + } + + /** + * Revoke one or more certificates by serial or kid. + */ + async revokeCert(data: { + serial?: string; + kid?: string; + reason?: string; + }): Promise<{ success: boolean; revoked: number }> { + const response = await this.fetch('/v1/certs/revoke', { + method: 'POST', + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + throw new Error(error.error || 'Failed to revoke certificate'); + } + return await response.json(); + } + /** * Get JWKS URL for user */ diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index d026413..d04081b 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -1,10 +1,10 @@ -import { useEffect, useState } from "react"; +import { Fragment, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { ArrowLeft, Copy, Trash2 } from "lucide-react"; +import { ArrowLeft, Copy, Download, RefreshCw, Trash2 } from "lucide-react"; import { toast } from "@/hooks/use-toast"; import AuthenticatedNav from "@/components/AuthenticatedNav"; import { @@ -18,7 +18,7 @@ import { AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; -import { api, Agent } from "@/lib/api"; +import { api, Agent, AgentCertificate, AgentCertificateDetail } from "@/lib/api"; interface Activity { id: string; @@ -36,7 +36,49 @@ const AgentDetail = () => { const navigate = useNavigate(); const [agent, setAgent] = useState(null); const [activities, setActivities] = useState([]); + const [certificates, setCertificates] = useState([]); const [loading, setLoading] = useState(true); + const [certLoading, setCertLoading] = useState(false); + const [issuingCert, setIssuingCert] = useState(false); + const [revokingSerial, setRevokingSerial] = useState(null); + const [advancedSerial, setAdvancedSerial] = useState(null); + const [detailLoadingSerial, setDetailLoadingSerial] = useState(null); + const [certDetails, setCertDetails] = useState>({}); + + const shortText = (value: string, prefix = 10, suffix = 8) => { + if (!value || value.length <= prefix + suffix + 3) return value; + return `${value.slice(0, prefix)}...${value.slice(-suffix)}`; + }; + + const getCertificateStatus = (cert: AgentCertificate): "active" | "revoked" | "expired" => { + if (cert.revoked_at) return "revoked"; + if (new Date(cert.not_after).getTime() <= Date.now()) return "expired"; + return "active"; + }; + + const fetchCertificates = async (targetAgentId: string) => { + setCertLoading(true); + try { + const response = await api.listAgentCerts({ + agent_id: targetAgentId, + status: "all", + limit: 100, + offset: 0, + }); + setCertificates(response.items || []); + setAdvancedSerial(null); + setCertDetails({}); + } catch (error: any) { + console.error("Error fetching certificates:", error); + toast({ + title: "Error", + description: "Failed to load certificates", + variant: "destructive", + }); + } finally { + setCertLoading(false); + } + }; const fetchAgentData = async () => { try { @@ -53,6 +95,7 @@ const AgentDetail = () => { // Fetch activities const activitiesData = await api.getAgentActivity(agentId!, 50, 0); setActivities(activitiesData || []); + await fetchCertificates(agentData.id); } catch (error: any) { console.error("Error fetching agent data:", error); toast({ @@ -98,6 +141,112 @@ const AgentDetail = () => { } }; + const issueCertificate = async () => { + if (!agent) return; + setIssuingCert(true); + try { + const issued = await api.issueCert({ agent_id: agent.id }); + toast({ + title: "Certificate Issued", + description: `Serial ${shortText(issued.serial)} issued successfully`, + }); + await fetchCertificates(agent.id); + } catch (error: any) { + console.error("Error issuing certificate:", error); + toast({ + title: "Error", + description: error.message || "Failed to issue certificate", + variant: "destructive", + }); + } finally { + setIssuingCert(false); + } + }; + + const revokeCertificate = async (serial: string) => { + if (!agent) return; + if (!window.confirm("Revoke this certificate? This action cannot be undone.")) { + return; + } + + setRevokingSerial(serial); + try { + await api.revokeCert({ serial, reason: "manual-revoke" }); + toast({ + title: "Certificate Revoked", + description: `Serial ${shortText(serial)} was revoked`, + }); + await fetchCertificates(agent.id); + } catch (error: any) { + console.error("Error revoking certificate:", error); + toast({ + title: "Error", + description: error.message || "Failed to revoke certificate", + variant: "destructive", + }); + } finally { + setRevokingSerial(null); + } + }; + + const ensureCertDetail = async (serial: string): Promise => { + const existing = certDetails[serial]; + if (existing) return existing; + + setDetailLoadingSerial(serial); + try { + const detail = await api.getCertBySerial(serial); + setCertDetails((prev) => ({ ...prev, [serial]: detail })); + return detail; + } finally { + setDetailLoadingSerial((current) => (current === serial ? null : current)); + } + }; + + const toggleAdvanced = async (serial: string) => { + if (advancedSerial === serial) { + setAdvancedSerial(null); + return; + } + setAdvancedSerial(serial); + try { + await ensureCertDetail(serial); + } catch (error: any) { + console.error("Error loading certificate details:", error); + toast({ + title: "Error", + description: error.message || "Failed to load certificate details", + variant: "destructive", + }); + } + }; + + const downloadChainPem = async (serial: string) => { + try { + const detail = await ensureCertDetail(serial); + const blob = new Blob([detail.chain_pem], { type: "application/x-pem-file" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${agent?.name || "agent"}-${serial}.chain.pem`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + toast({ + title: "Downloaded", + description: "Certificate chain PEM downloaded", + }); + } catch (error: any) { + console.error("Error downloading chain PEM:", error); + toast({ + title: "Error", + description: error.message || "Failed to download chain PEM", + variant: "destructive", + }); + } + }; + if (loading) { return (
@@ -247,6 +396,174 @@ const AgentDetail = () => { + + +
+ Certificates + + Issued X.509 certificates for this agent key + +
+
+ + +
+
+ + {certLoading ? ( +

Loading certificates...

+ ) : certificates.length === 0 ? ( +

+ No certificates issued yet +

+ ) : ( +
+ + + + Serial + Kid + Fingerprint + Expires + Status + Issued + Revoked + Actions + + + + {certificates.map((cert) => { + const status = getCertificateStatus(cert); + const detail = certDetails[cert.serial]; + return ( + + + + {shortText(cert.serial)} + + + {shortText(cert.kid)} + + + {shortText(cert.fingerprint_sha256)} + + + {new Date(cert.not_after).toLocaleString()} + + + + {status} + + + + {new Date(cert.created_at).toLocaleString()} + + + {cert.revoked_at ? new Date(cert.revoked_at).toLocaleString() : "—"} + + +
+ + + + {!cert.revoked_at && ( + + )} +
+
+
+ {advancedSerial === cert.serial && ( + + + {detailLoadingSerial === cert.serial ? ( +

+ Loading certificate details... +

+ ) : detail ? ( +
+
+

+ Certificate PEM +

+
+                                        {detail.cert_pem}
+                                      
+
+
+

+ Chain PEM +

+
+                                        {detail.chain_pem}
+                                      
+
+
+ ) : ( +

+ No details available. +

+ )} +
+
+ )} +
+ ); + })} +
+
+
+ )} +
+
+ Activity Logging API diff --git a/infra/neon/migrations/008_agent_cert_fingerprint_index.sql b/infra/neon/migrations/008_agent_cert_fingerprint_index.sql new file mode 100644 index 0000000..45e23c7 --- /dev/null +++ b/infra/neon/migrations/008_agent_cert_fingerprint_index.sql @@ -0,0 +1,3 @@ +-- Speed up certificate status lookups by fingerprint +CREATE INDEX IF NOT EXISTS idx_agent_certs_fingerprint_sha256 + ON public.agent_certificates(fingerprint_sha256); diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 3263bb7..c25ac7b 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -116,10 +116,66 @@ Revoke an issued certificate. } ``` +#### GET `/v1/certs` + +List issued certificates owned by the authenticated user. + +Query parameters: +- `agent_id` (optional) +- `kid` (optional) +- `status` (optional: `active` | `revoked` | `all`, default `all`) +- `limit` (optional, default `50`, max `200`) +- `offset` (optional, default `0`) + +#### GET `/v1/certs/{serial}` + +Fetch one certificate by serial, including PEM and chain data. + +Response includes metadata fields plus: +- `cert_pem` +- `chain_pem` +- `x5c` + +#### GET `/v1/certs/status` + +Check certificate validity metadata by one identifier: +- `serial` **or** +- `fingerprint_sha256` + +Response shape: +```json +{ + "valid": true, + "revoked": false, + "not_after": "2026-05-01T00:00:00.000Z", + "revoked_at": null, + "revoked_reason": null +} +``` + #### GET `/.well-known/ca.pem` Fetch the registry CA certificate (PEM). +### mTLS Integration Notes (ClawAuth / relying parties) + +For OpenBotAuth-issued client certificates in mTLS: + +1. Fetch and trust OBA CA: + - `GET /.well-known/ca.pem` + - Configure your TLS server trust store with this CA (or intermediate, depending on deployment). +2. Provision agent certificate: + - Call `POST /v1/certs/issue` for the target `agent_id`. + - Store `cert_pem` / `chain_pem` alongside the agent private key. +3. Revoke when needed: + - Call `POST /v1/certs/revoke`. +4. Optional status checks: + - Call `GET /v1/certs/status` to evaluate revoked/not-after metadata. + +Current limitation: +- TLS stacks do not automatically consult OpenBotAuth revocation status in this MVP. +- Use short-lived certs and/or explicit status checks if you need revocation awareness. + ### Activity Endpoints #### POST `/agent-activity` diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts new file mode 100644 index 0000000..001bafa --- /dev/null +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function mockDb(queryFn?: (...args: any[]) => any) { + return { + getPool: () => ({ + query: queryFn ?? vi.fn().mockResolvedValue({ rows: [] }), + }), + }; +} + +function mockReq(overrides: Record = {}): any { + return { + headers: {}, + ip: "127.0.0.1", + socket: { remoteAddress: "127.0.0.1" }, + session: { + user: { + id: "u-1", + email: "a@b.com", + github_username: "gh", + avatar_url: null, + }, + profile: { id: "u-1", username: "testuser", client_name: null }, + }, + authMethod: "session", + params: {}, + query: {}, + body: {}, + app: { locals: { db: mockDb() } }, + ...overrides, + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + headers: {} as Record, + body: null as any, + headersSent: false, + _onSend: null as (() => void) | null, + setHeader(key: string, val: string) { + res.headers[key] = val; + return res; + }, + status(code: number) { + res.statusCode = code; + return res; + }, + json(data: any) { + res.body = data; + res.headersSent = true; + if (res._onSend) res._onSend(); + return res; + }, + send(data: any) { + res.body = data; + res.headersSent = true; + if (res._onSend) res._onSend(); + return res; + }, + }; + return res; +} + +async function callRoute( + router: any, + method: string, + path: string, + req: any, + res: any, +) { + for (const layer of router.stack) { + if (!layer.route) continue; + if (layer.route.path !== path) continue; + const methodHandlers = layer.route.methods; + if (!methodHandlers[method.toLowerCase()]) continue; + + for (const stackLayer of layer.route.stack) { + if (res.headersSent) break; + await new Promise((resolve, reject) => { + res._onSend = resolve; + try { + const result = stackLayer.handle(req, res, (err?: any) => { + res._onSend = null; + if (err) reject(err); + else resolve(); + }); + if (result && typeof result.then === "function") { + result.then(() => resolve()).catch(reject); + } + } catch (err) { + reject(err); + } + }); + } + return; + } + + throw new Error(`No route found: ${method} ${path}`); +} + +let certsRouter: any; + +beforeEach(async () => { + const mod = await import("../certs.js"); + certsRouter = mod.certsRouter; +}); + +describe("GET /v1/certs", () => { + it("enforces owner scoping and returns no-store metadata", async () => { + const query = vi.fn().mockResolvedValue({ + rows: [ + { + id: "c-1", + agent_id: "a-1", + kid: "kid-1", + serial: "serial-1", + fingerprint_sha256: "fp-1", + not_before: "2026-01-01T00:00:00.000Z", + not_after: "2026-03-01T00:00:00.000Z", + revoked_at: null, + revoked_reason: null, + created_at: "2026-01-01T00:00:00.000Z", + is_active: true, + total_count: "1", + }, + ], + }); + + const req = mockReq({ + query: { agent_id: "a-1", limit: "50", offset: "0" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs", req, res); + + expect(res.statusCode).toBe(200); + expect(res.headers["Cache-Control"]).toBe("no-store"); + expect(query).toHaveBeenCalledTimes(1); + + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain("JOIN agents a ON a.id = c.agent_id"); + expect(sql).toContain("a.user_id = $1"); + expect(params[0]).toBe("u-1"); + + expect(res.body.total).toBe(1); + expect(res.body.items).toHaveLength(1); + expect(res.body.items[0]).not.toHaveProperty("total_count"); + }); + + it("applies status=active filter", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const req = mockReq({ + query: { status: "active" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs", req, res); + + expect(res.statusCode).toBe(200); + const [sql] = query.mock.calls[0]; + expect(sql).toContain("c.revoked_at IS NULL AND c.not_after > now()"); + }); + + it("applies status=revoked filter", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const req = mockReq({ + query: { status: "revoked" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs", req, res); + + expect(res.statusCode).toBe(200); + const [sql] = query.mock.calls[0]; + expect(sql).toContain("c.revoked_at IS NOT NULL"); + }); +}); + +describe("GET /v1/certs/:serial", () => { + it("does not leak certs outside owner scope", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const req = mockReq({ + params: { serial: "serial-other-user" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/:serial", req, res); + + expect(res.statusCode).toBe(404); + expect(query).toHaveBeenCalledTimes(1); + + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain("JOIN agents a ON a.id = c.agent_id"); + expect(sql).toContain("a.user_id = $1 AND c.serial = $2"); + expect(params).toEqual(["u-1", "serial-other-user"]); + }); + + it("returns cert metadata and PEM data for owned cert", async () => { + const query = vi.fn().mockResolvedValue({ + rows: [ + { + id: "c-1", + agent_id: "a-1", + kid: "kid-1", + serial: "serial-1", + fingerprint_sha256: "fp-1", + not_before: "2026-01-01T00:00:00.000Z", + not_after: "2026-03-01T00:00:00.000Z", + revoked_at: null, + revoked_reason: null, + created_at: "2026-01-01T00:00:00.000Z", + cert_pem: "-----BEGIN CERTIFICATE-----...", + chain_pem: "-----BEGIN CERTIFICATE-----...", + x5c: ["abc", "def"], + is_active: true, + }, + ], + }); + + const req = mockReq({ + params: { serial: "serial-1" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/:serial", req, res); + + expect(res.statusCode).toBe(200); + expect(res.headers["Cache-Control"]).toBe("no-store"); + expect(res.body.serial).toBe("serial-1"); + expect(res.body.chain_pem).toContain("BEGIN CERTIFICATE"); + }); +}); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 0efec52..1c7ef94 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -36,6 +36,20 @@ function isValidAgentId(value: string): boolean { ); } +function parsePositiveInt( + input: unknown, + defaultValue: number, + min: number, + max: number, +): number | null { + if (input === undefined) return defaultValue; + if (typeof input !== "string") return null; + const parsed = Number.parseInt(input, 10); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null; + if (parsed < min || parsed > max) return null; + return parsed; +} + certsRouter.post( "/v1/certs/issue", requireAuth, @@ -194,6 +208,197 @@ certsRouter.post( }, ); +certsRouter.get( + "/v1/certs", + requireAuth, + requireScope("agents:read"), + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const agentId = + typeof req.query.agent_id === "string" && req.query.agent_id.length > 0 + ? req.query.agent_id + : null; + const kid = + typeof req.query.kid === "string" && req.query.kid.length > 0 + ? req.query.kid + : null; + const statusRaw = + typeof req.query.status === "string" + ? req.query.status.toLowerCase() + : "all"; + if (!["active", "revoked", "all"].includes(statusRaw)) { + res + .status(400) + .json({ error: "Invalid status. Use active, revoked, or all." }); + return; + } + + const limit = parsePositiveInt(req.query.limit, 50, 1, 200); + const offset = parsePositiveInt(req.query.offset, 0, 0, 1000000); + if (limit === null || offset === null) { + res + .status(400) + .json({ error: "Invalid pagination. limit=1..200, offset>=0." }); + return; + } + + const whereClauses: string[] = ["a.user_id = $1"]; + const params: unknown[] = [req.session!.user.id]; + let paramIndex = 2; + + if (agentId) { + whereClauses.push(`c.agent_id = $${paramIndex}`); + params.push(agentId); + paramIndex++; + } + if (kid) { + whereClauses.push(`c.kid = $${paramIndex}`); + params.push(kid); + paramIndex++; + } + if (statusRaw === "active") { + whereClauses.push("c.revoked_at IS NULL AND c.not_after > now()"); + } else if (statusRaw === "revoked") { + whereClauses.push("c.revoked_at IS NOT NULL"); + } + + params.push(limit, offset); + const limitParam = paramIndex; + const offsetParam = paramIndex + 1; + + const result = await db.getPool().query( + `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, + c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, + (c.revoked_at IS NULL AND c.not_after > now()) AS is_active, + COUNT(*) OVER() AS total_count + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE ${whereClauses.join(" AND ")} + ORDER BY c.created_at DESC + LIMIT $${limitParam} + OFFSET $${offsetParam}`, + params, + ); + + const total = + result.rows.length > 0 ? Number(result.rows[0].total_count || 0) : 0; + const items = result.rows.map((row) => { + const { total_count, ...rest } = row; + return rest; + }); + + res.setHeader("Cache-Control", "no-store"); + res.json({ items, total, limit, offset }); + } catch (error: any) { + console.error("Certificate list error:", error); + res.status(500).json({ error: error.message || "Failed to list certificates" }); + } + }, +); + +certsRouter.get( + "/v1/certs/status", + requireAuth, + requireScope("agents:read"), + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const serial = + typeof req.query.serial === "string" && req.query.serial.length > 0 + ? req.query.serial + : null; + const fingerprint = + typeof req.query.fingerprint_sha256 === "string" && + req.query.fingerprint_sha256.length > 0 + ? req.query.fingerprint_sha256 + : null; + + if ((!serial && !fingerprint) || (serial && fingerprint)) { + res.status(400).json({ + error: "Provide exactly one lookup parameter: serial or fingerprint_sha256", + }); + return; + } + + const condition = serial ? "c.serial = $2" : "c.fingerprint_sha256 = $2"; + const lookupValue = serial ?? fingerprint!; + const result = await db.getPool().query( + `SELECT c.serial, c.fingerprint_sha256, c.not_after, c.revoked_at, c.revoked_reason + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE a.user_id = $1 AND ${condition} + ORDER BY c.created_at DESC + LIMIT 1`, + [req.session!.user.id, lookupValue], + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: "Certificate not found" }); + return; + } + + const cert = result.rows[0]; + const revoked = Boolean(cert.revoked_at); + const valid = !revoked && new Date(cert.not_after).getTime() > Date.now(); + + res.setHeader("Cache-Control", "no-store"); + res.json({ + valid, + revoked, + not_after: cert.not_after, + revoked_at: cert.revoked_at, + revoked_reason: cert.revoked_reason, + serial: cert.serial, + fingerprint_sha256: cert.fingerprint_sha256, + }); + } catch (error: any) { + console.error("Certificate status error:", error); + res.status(500).json({ error: error.message || "Failed to check certificate status" }); + } + }, +); + +certsRouter.get( + "/v1/certs/:serial", + requireAuth, + requireScope("agents:read"), + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const serial = req.params.serial; + if (!serial) { + res.status(400).json({ error: "Missing serial" }); + return; + } + + const result = await db.getPool().query( + `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, + c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, + c.cert_pem, c.chain_pem, c.x5c, + (c.revoked_at IS NULL AND c.not_after > now()) AS is_active + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE a.user_id = $1 AND c.serial = $2 + ORDER BY c.created_at DESC + LIMIT 1`, + [req.session!.user.id, serial], + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: "Certificate not found" }); + return; + } + + res.setHeader("Cache-Control", "no-store"); + res.json(result.rows[0]); + } catch (error: any) { + console.error("Certificate get error:", error); + res.status(500).json({ error: error.message || "Failed to fetch certificate" }); + } + }, +); + certsRouter.get("/.well-known/ca.pem", async (_req, res: Response): Promise => { try { const ca = await getCertificateAuthority(); From c6a8fd68cac1f3e6772bf530ec29a161121a515c Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 03:10:59 +0500 Subject: [PATCH 13/49] Fix cert revoke idempotency and pagination totals --- .../src/routes/__tests__/certs.test.ts | 111 +++++++++++++----- packages/registry-service/src/routes/certs.ts | 28 +++-- 2 files changed, 99 insertions(+), 40 deletions(-) diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 001bafa..f3f32ad 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -108,24 +108,26 @@ beforeEach(async () => { describe("GET /v1/certs", () => { it("enforces owner scoping and returns no-store metadata", async () => { - const query = vi.fn().mockResolvedValue({ - rows: [ - { - id: "c-1", - agent_id: "a-1", - kid: "kid-1", - serial: "serial-1", - fingerprint_sha256: "fp-1", - not_before: "2026-01-01T00:00:00.000Z", - not_after: "2026-03-01T00:00:00.000Z", - revoked_at: null, - revoked_reason: null, - created_at: "2026-01-01T00:00:00.000Z", - is_active: true, - total_count: "1", - }, - ], - }); + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 1 }] }) + .mockResolvedValueOnce({ + rows: [ + { + id: "c-1", + agent_id: "a-1", + kid: "kid-1", + serial: "serial-1", + fingerprint_sha256: "fp-1", + not_before: "2026-01-01T00:00:00.000Z", + not_after: "2026-03-01T00:00:00.000Z", + revoked_at: null, + revoked_reason: null, + created_at: "2026-01-01T00:00:00.000Z", + is_active: true, + }, + ], + }); const req = mockReq({ query: { agent_id: "a-1", limit: "50", offset: "0" }, @@ -137,20 +139,29 @@ describe("GET /v1/certs", () => { expect(res.statusCode).toBe(200); expect(res.headers["Cache-Control"]).toBe("no-store"); - expect(query).toHaveBeenCalledTimes(1); + expect(query).toHaveBeenCalledTimes(2); - const [sql, params] = query.mock.calls[0]; - expect(sql).toContain("JOIN agents a ON a.id = c.agent_id"); - expect(sql).toContain("a.user_id = $1"); - expect(params[0]).toBe("u-1"); + const [countSql, countParams] = query.mock.calls[0]; + expect(countSql).toContain("SELECT COUNT(*)::int AS total"); + expect(countSql).toContain("JOIN agents a ON a.id = c.agent_id"); + expect(countSql).toContain("a.user_id = $1"); + expect(countParams[0]).toBe("u-1"); + + const [pageSql, pageParams] = query.mock.calls[1]; + expect(pageSql).toContain("ORDER BY c.created_at DESC"); + expect(pageSql).toContain("LIMIT $3"); + expect(pageSql).toContain("OFFSET $4"); + expect(pageParams).toEqual(["u-1", "a-1", 50, 0]); expect(res.body.total).toBe(1); expect(res.body.items).toHaveLength(1); - expect(res.body.items[0]).not.toHaveProperty("total_count"); }); it("applies status=active filter", async () => { - const query = vi.fn().mockResolvedValue({ rows: [] }); + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); const req = mockReq({ query: { status: "active" }, app: { locals: { db: mockDb(query) } }, @@ -160,12 +171,17 @@ describe("GET /v1/certs", () => { await callRoute(certsRouter, "GET", "/v1/certs", req, res); expect(res.statusCode).toBe(200); - const [sql] = query.mock.calls[0]; - expect(sql).toContain("c.revoked_at IS NULL AND c.not_after > now()"); + const [countSql] = query.mock.calls[0]; + expect(countSql).toContain("c.revoked_at IS NULL AND c.not_after > now()"); + const [pageSql] = query.mock.calls[1]; + expect(pageSql).toContain("c.revoked_at IS NULL AND c.not_after > now()"); }); it("applies status=revoked filter", async () => { - const query = vi.fn().mockResolvedValue({ rows: [] }); + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); const req = mockReq({ query: { status: "revoked" }, app: { locals: { db: mockDb(query) } }, @@ -174,9 +190,46 @@ describe("GET /v1/certs", () => { await callRoute(certsRouter, "GET", "/v1/certs", req, res); + expect(res.statusCode).toBe(200); + const [countSql] = query.mock.calls[0]; + expect(countSql).toContain("c.revoked_at IS NOT NULL"); + const [pageSql] = query.mock.calls[1]; + expect(pageSql).toContain("c.revoked_at IS NOT NULL"); + }); + + it("returns non-zero total when requested page is empty", async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 3 }] }) + .mockResolvedValueOnce({ rows: [] }); + const req = mockReq({ + query: { limit: "2", offset: "10" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.total).toBe(3); + expect(res.body.items).toEqual([]); + }); +}); + +describe("POST /v1/certs/revoke", () => { + it("only updates certs that are not already revoked", async () => { + const query = vi.fn().mockResolvedValue({ rows: [{ id: "c-1" }] }); + const req = mockReq({ + body: { serial: "serial-1", reason: "key-compromise" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/revoke", req, res); + expect(res.statusCode).toBe(200); const [sql] = query.mock.calls[0]; - expect(sql).toContain("c.revoked_at IS NOT NULL"); + expect(sql).toContain("AND c.revoked_at IS NULL"); }); }); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 1c7ef94..b9d6b0d 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -189,7 +189,10 @@ certsRouter.post( `UPDATE agent_certificates c SET revoked_at = now(), revoked_reason = $3 FROM agents a - WHERE c.agent_id = a.id AND a.user_id = $1 AND ${condition} + WHERE c.agent_id = a.id + AND a.user_id = $1 + AND ${condition} + AND c.revoked_at IS NULL RETURNING c.id`, [...params, reason || null], ); @@ -263,30 +266,33 @@ certsRouter.get( whereClauses.push("c.revoked_at IS NOT NULL"); } - params.push(limit, offset); + const countResult = await db.getPool().query( + `SELECT COUNT(*)::int AS total + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE ${whereClauses.join(" AND ")}`, + params, + ); + + const total = Number(countResult.rows[0]?.total ?? 0); + const pageParams = [...params, limit, offset]; const limitParam = paramIndex; const offsetParam = paramIndex + 1; const result = await db.getPool().query( `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, - (c.revoked_at IS NULL AND c.not_after > now()) AS is_active, - COUNT(*) OVER() AS total_count + (c.revoked_at IS NULL AND c.not_after > now()) AS is_active FROM agent_certificates c JOIN agents a ON a.id = c.agent_id WHERE ${whereClauses.join(" AND ")} ORDER BY c.created_at DESC LIMIT $${limitParam} OFFSET $${offsetParam}`, - params, + pageParams, ); - const total = - result.rows.length > 0 ? Number(result.rows[0].total_count || 0) : 0; - const items = result.rows.map((row) => { - const { total_count, ...rest } = row; - return rest; - }); + const items = result.rows; res.setHeader("Cache-Control", "no-store"); res.json({ items, total, limit, offset }); From 67f2c2a7442ca2121f7e5f9cda734aeeeb9c1336 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 03:14:56 +0500 Subject: [PATCH 14/49] Improve cert status validity and portal revoke UX --- .../src/pages/portal/AgentDetail.tsx | 60 +++++++++++++++-- packages/registry-service/README.md | 22 ++++++- .../src/routes/__tests__/auth-cli.test.ts | 11 +++- .../src/routes/__tests__/certs.test.ts | 66 ++++++++++++++++++- packages/registry-service/src/routes/certs.ts | 9 ++- 5 files changed, 157 insertions(+), 11 deletions(-) diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index d04081b..eb9951e 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -41,6 +41,7 @@ const AgentDetail = () => { const [certLoading, setCertLoading] = useState(false); const [issuingCert, setIssuingCert] = useState(false); const [revokingSerial, setRevokingSerial] = useState(null); + const [revokeDialogCert, setRevokeDialogCert] = useState(null); const [advancedSerial, setAdvancedSerial] = useState(null); const [detailLoadingSerial, setDetailLoadingSerial] = useState(null); const [certDetails, setCertDetails] = useState>({}); @@ -165,9 +166,6 @@ const AgentDetail = () => { const revokeCertificate = async (serial: string) => { if (!agent) return; - if (!window.confirm("Revoke this certificate? This action cannot be undone.")) { - return; - } setRevokingSerial(serial); try { @@ -186,6 +184,7 @@ const AgentDetail = () => { }); } finally { setRevokingSerial(null); + setRevokeDialogCert(null); } }; @@ -482,6 +481,22 @@ const AgentDetail = () => {
+ +
+ + { + if (!open && !revokingSerial) { + setRevokeDialogCert(null); + } + }} + > + + + Revoke certificate? + + This will revoke certificate{" "} + {revokeDialogCert ? shortText(revokeDialogCert.serial) : ""}. + This action cannot be undone. + + + + + Cancel + + { + if (revokeDialogCert) { + void revokeCertificate(revokeDialogCert.serial); + } + }} + > + {revokingSerial ? "Revoking..." : "Revoke"} + + + +
); diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index c25ac7b..c87757b 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -93,6 +93,10 @@ Query parameters: Issue an X.509 certificate for an agent key. +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:write`. + Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf certificate as an informational hint. This value is user-supplied unless you enforce registry-side issuance rules. @@ -108,6 +112,10 @@ enforce registry-side issuance rules. Revoke an issued certificate. +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:write`. + **Request:** ```json { @@ -120,6 +128,10 @@ Revoke an issued certificate. List issued certificates owned by the authenticated user. +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:read` (or `agents:write`). + Query parameters: - `agent_id` (optional) - `kid` (optional) @@ -131,6 +143,10 @@ Query parameters: Fetch one certificate by serial, including PEM and chain data. +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:read` (or `agents:write`). + Response includes metadata fields plus: - `cert_pem` - `chain_pem` @@ -142,6 +158,10 @@ Check certificate validity metadata by one identifier: - `serial` **or** - `fingerprint_sha256` +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:read` (or `agents:write`). + Response shape: ```json { @@ -170,7 +190,7 @@ For OpenBotAuth-issued client certificates in mTLS: 3. Revoke when needed: - Call `POST /v1/certs/revoke`. 4. Optional status checks: - - Call `GET /v1/certs/status` to evaluate revoked/not-after metadata. + - Call `GET /v1/certs/status` to evaluate revoked + validity window metadata (`not_before`/`not_after`). Current limitation: - TLS stacks do not automatically consult OpenBotAuth revocation status in this MVP. diff --git a/packages/registry-service/src/routes/__tests__/auth-cli.test.ts b/packages/registry-service/src/routes/__tests__/auth-cli.test.ts index 5b73e95..b13c4cb 100644 --- a/packages/registry-service/src/routes/__tests__/auth-cli.test.ts +++ b/packages/registry-service/src/routes/__tests__/auth-cli.test.ts @@ -38,6 +38,7 @@ function mockDb(overrides: Record = {}) { client_name: null, }), getPool: () => ({ + query: vi.fn().mockResolvedValue({ rows: [] }), connect: vi.fn().mockResolvedValue({ query: vi.fn().mockResolvedValue({ rows: [] }), release: vi.fn(), @@ -194,7 +195,10 @@ describe('GET /auth/github/callback (cli mode)', () => { .mockResolvedValueOnce({}); // ROLLBACK const client = { query, release: vi.fn() }; const db = mockDb({ - getPool: () => ({ connect: vi.fn().mockResolvedValue(client) }), + getPool: () => ({ + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn().mockResolvedValue(client), + }), }); // Seed OAuth state via /auth/cli @@ -233,7 +237,10 @@ describe('GET /auth/github/callback (cli mode)', () => { .mockResolvedValueOnce({}); // COMMIT const client = { query, release: vi.fn() }; const db = mockDb({ - getPool: () => ({ connect: vi.fn().mockResolvedValue(client) }), + getPool: () => ({ + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn().mockResolvedValue(client), + }), }); const cliReq = mockReq({ diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index f3f32ad..a99f2dd 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -22,7 +22,8 @@ function mockReq(overrides: Record = {}): any { }, profile: { id: "u-1", username: "testuser", client_name: null }, }, - authMethod: "session", + authMethod: "token", + authScopes: ["agents:write"], params: {}, query: {}, body: {}, @@ -233,6 +234,69 @@ describe("POST /v1/certs/revoke", () => { }); }); +describe("GET /v1/certs/status", () => { + it("includes not_before and reports valid only within not_before/not_after window", async () => { + const now = Date.now(); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + serial: "serial-1", + fingerprint_sha256: "fp-1", + not_before: new Date(now - 60_000).toISOString(), + not_after: new Date(now + 60_000).toISOString(), + revoked_at: null, + revoked_reason: null, + }, + ], + }); + const req = mockReq({ + query: { serial: "serial-1" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/status", req, res); + + expect(res.statusCode).toBe(200); + expect(res.headers["Cache-Control"]).toBe("no-store"); + expect(res.body.valid).toBe(true); + expect(res.body.revoked).toBe(false); + expect(res.body.not_before).toBeDefined(); + + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain("c.not_before"); + expect(params).toEqual(["u-1", "serial-1"]); + }); + + it("returns valid=false when cert is not yet valid", async () => { + const now = Date.now(); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + serial: "serial-future", + fingerprint_sha256: "fp-future", + not_before: new Date(now + 3_600_000).toISOString(), + not_after: new Date(now + 7_200_000).toISOString(), + revoked_at: null, + revoked_reason: null, + }, + ], + }); + const req = mockReq({ + query: { fingerprint_sha256: "fp-future" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/status", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.revoked).toBe(false); + expect(res.body.not_before).toBeDefined(); + }); +}); + describe("GET /v1/certs/:serial", () => { it("does not leak certs outside owner scope", async () => { const query = vi.fn().mockResolvedValue({ rows: [] }); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index b9d6b0d..58b50f4 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -330,7 +330,7 @@ certsRouter.get( const condition = serial ? "c.serial = $2" : "c.fingerprint_sha256 = $2"; const lookupValue = serial ?? fingerprint!; const result = await db.getPool().query( - `SELECT c.serial, c.fingerprint_sha256, c.not_after, c.revoked_at, c.revoked_reason + `SELECT c.serial, c.fingerprint_sha256, c.not_before, c.not_after, c.revoked_at, c.revoked_reason FROM agent_certificates c JOIN agents a ON a.id = c.agent_id WHERE a.user_id = $1 AND ${condition} @@ -346,12 +346,17 @@ certsRouter.get( const cert = result.rows[0]; const revoked = Boolean(cert.revoked_at); - const valid = !revoked && new Date(cert.not_after).getTime() > Date.now(); + const nowMs = Date.now(); + const valid = + !revoked && + new Date(cert.not_before).getTime() <= nowMs && + new Date(cert.not_after).getTime() > nowMs; res.setHeader("Cache-Control", "no-store"); res.json({ valid, revoked, + not_before: cert.not_before, not_after: cert.not_after, revoked_at: cert.revoked_at, revoked_reason: cert.revoked_reason, From a986e1d079770762c8751633cd333d60dddf11b5 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 03:23:40 +0500 Subject: [PATCH 15/49] Polish cert scope docs and revoke dialog behavior --- apps/registry-portal/src/pages/portal/AgentDetail.tsx | 3 ++- packages/registry-service/README.md | 3 +++ packages/registry-service/src/routes/__tests__/certs.test.ts | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index eb9951e..60e4b22 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -631,7 +631,8 @@ const AgentDetail = () => { { + onClick={(event) => { + event.preventDefault(); if (revokeDialogCert) { void revokeCertificate(revokeDialogCert.serial); } diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index c87757b..7cd260c 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -89,6 +89,9 @@ Query parameters: ### Certificate Endpoints (MVP) +Scope note: +- In current middleware, `agents:write` satisfies `agents:read`. + #### POST `/v1/certs/issue` Issue an X.509 certificate for an agent key. diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index a99f2dd..5e07b27 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -23,7 +23,7 @@ function mockReq(overrides: Record = {}): any { profile: { id: "u-1", username: "testuser", client_name: null }, }, authMethod: "token", - authScopes: ["agents:write"], + authScopes: ["agents:read", "agents:write"], params: {}, query: {}, body: {}, From 9eb30516217577241b97c1a2f45150a7725df577 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 03:32:08 +0500 Subject: [PATCH 16/49] Fix trusted directory check to prevent substring bypass Replace insecure String.includes() check with proper URL hostname validation to prevent authorization bypass via crafted URLs like evil-trusted.com.attacker.com or attacker.com/trusted.com/fake.json. The new isTrustedDirectory() method: - Parses URLs to extract and compare hostnames - Validates exact match or proper subdomain boundary - Case-insensitive comparison - Handles trusted directories with or without scheme prefix --- .../src/signature-verifier.test.ts | 222 ++++++++++++++++++ .../src/signature-verifier.ts | 36 ++- 2 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 packages/verifier-service/src/signature-verifier.test.ts diff --git a/packages/verifier-service/src/signature-verifier.test.ts b/packages/verifier-service/src/signature-verifier.test.ts new file mode 100644 index 0000000..99542fb --- /dev/null +++ b/packages/verifier-service/src/signature-verifier.test.ts @@ -0,0 +1,222 @@ +/** + * Tests for SignatureVerifier - specifically trusted directory validation + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { SignatureVerifier } from "./signature-verifier.js"; + +// Mock dependencies +const mockJwksCache = { + getKey: vi.fn(), + getJWKS: vi.fn(), +}; + +const mockNonceManager = { + checkTimestamp: vi.fn(), + checkNonce: vi.fn(), +}; + +describe("SignatureVerifier - Trusted Directory Validation", () => { + // Access the private method via prototype for testing + function isTrustedDirectory( + trustedDirectories: string[], + jwksUrl: string + ): boolean { + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + trustedDirectories + ); + // Access private method for testing + return (verifier as any).isTrustedDirectory(jwksUrl); + } + + describe("exact hostname matching", () => { + it("should accept exact hostname match", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://openbotregistry.example.com/jwks.json" + ) + ).toBe(true); + }); + + it("should accept exact match with different path", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://openbotregistry.example.com/.well-known/jwks.json" + ) + ).toBe(true); + }); + + it("should accept exact match with port", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://openbotregistry.example.com:443/jwks.json" + ) + ).toBe(true); + }); + + it("should be case insensitive", () => { + expect( + isTrustedDirectory( + ["OpenBotRegistry.Example.COM"], + "https://openbotregistry.example.com/jwks.json" + ) + ).toBe(true); + }); + }); + + describe("subdomain matching", () => { + it("should accept subdomain of trusted directory", () => { + expect( + isTrustedDirectory( + ["example.com"], + "https://openbotregistry.example.com/jwks.json" + ) + ).toBe(true); + }); + + it("should accept nested subdomain", () => { + expect( + isTrustedDirectory( + ["example.com"], + "https://api.registry.example.com/jwks.json" + ) + ).toBe(true); + }); + }); + + describe("security: substring bypass prevention", () => { + it("should reject URL with trusted string in path", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://attacker.com/openbotregistry.example.com/fake.json" + ) + ).toBe(false); + }); + + it("should reject URL with trusted string in query parameter", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://attacker.com/jwks.json?domain=openbotregistry.example.com" + ) + ).toBe(false); + }); + + it("should reject hostname containing trusted string as suffix", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://evil-openbotregistry.example.com.attacker.com/jwks.json" + ) + ).toBe(false); + }); + + it("should reject hostname containing trusted string as prefix", () => { + expect( + isTrustedDirectory( + ["example.com"], + "https://example.com.attacker.com/jwks.json" + ) + ).toBe(false); + }); + + it("should reject partial hostname match without dot boundary", () => { + expect( + isTrustedDirectory( + ["example.com"], + "https://notexample.com/jwks.json" + ) + ).toBe(false); + }); + + it("should reject completely different domain", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://attacker.com/jwks.json" + ) + ).toBe(false); + }); + }); + + describe("trusted directory format handling", () => { + it("should handle trusted directory with https:// prefix", () => { + expect( + isTrustedDirectory( + ["https://openbotregistry.example.com"], + "https://openbotregistry.example.com/jwks.json" + ) + ).toBe(true); + }); + + it("should handle trusted directory with http:// prefix", () => { + expect( + isTrustedDirectory( + ["http://localhost:8080"], + "http://localhost:8080/jwks.json" + ) + ).toBe(true); + }); + + it("should handle trusted directory without scheme", () => { + expect( + isTrustedDirectory( + ["openbotregistry.example.com"], + "https://openbotregistry.example.com/jwks.json" + ) + ).toBe(true); + }); + + it("should handle multiple trusted directories", () => { + const trustedDirs = [ + "registry.openbotauth.com", + "chatgpt.com", + "localhost:8080", + ]; + + expect( + isTrustedDirectory(trustedDirs, "https://registry.openbotauth.com/jwks.json") + ).toBe(true); + + expect( + isTrustedDirectory(trustedDirs, "https://chatgpt.com/.well-known/jwks.json") + ).toBe(true); + + expect( + isTrustedDirectory(trustedDirs, "https://localhost:8080/jwks.json") + ).toBe(true); + + expect( + isTrustedDirectory(trustedDirs, "https://attacker.com/jwks.json") + ).toBe(false); + }); + }); + + describe("edge cases", () => { + it("should return false for invalid JWKS URL", () => { + expect( + isTrustedDirectory( + ["example.com"], + "not-a-valid-url" + ) + ).toBe(false); + }); + + it("should handle empty trusted directories array", () => { + // When there are no trusted directories, the check in verify() is skipped + // but isTrustedDirectory itself should return false for empty array + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + [] + ); + expect((verifier as any).isTrustedDirectory("https://example.com/jwks.json")).toBe(false); + }); + }); +}); diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index 1b2dd8e..2ed2f49 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -90,7 +90,7 @@ export class SignatureVerifier { // 5. Check if JWKS URL is from a trusted directory if (this.trustedDirectories.length > 0) { - const trusted = this.trustedDirectories.some(dir => jwksUrl.includes(dir)); + const trusted = this.isTrustedDirectory(jwksUrl); if (!trusted) { return { verified: false, @@ -200,6 +200,40 @@ export class SignatureVerifier { } } + /** + * Check if a JWKS URL is from a trusted directory using proper hostname validation. + * + * This validates the URL's hostname against configured trusted directories, + * preventing substring-based bypasses (e.g., evil-trusted.com.attacker.com). + */ + private isTrustedDirectory(jwksUrl: string): boolean { + try { + const jwksUrlParsed = new URL(jwksUrl); + const jwksHostname = jwksUrlParsed.hostname.toLowerCase(); + + return this.trustedDirectories.some(dir => { + try { + // Normalize the trusted directory - add scheme if missing + const normalizedDir = dir.includes('://') ? dir : `https://${dir}`; + const trustedUrl = new URL(normalizedDir); + const trustedHostname = trustedUrl.hostname.toLowerCase(); + + // Exact match or subdomain match (e.g., api.example.com matches example.com) + return jwksHostname === trustedHostname || + jwksHostname.endsWith('.' + trustedHostname); + } catch { + // Treat as hostname pattern if URL parsing fails + const trustedHostname = dir.toLowerCase(); + return jwksHostname === trustedHostname || + jwksHostname.endsWith('.' + trustedHostname); + } + }); + } catch { + // Invalid JWKS URL + return false; + } + } + /** * Verify Ed25519 signature using Web Crypto API */ From fb24028ccfb6831d35433d546508cc8fbd8a9446 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 04:01:48 +0500 Subject: [PATCH 17/49] Fix GPT feedback issues for agent identity PR - Fix is_active to check not_before <= now() in addition to not_after - Add /v1/certs/public-status endpoint for relying party revocation checks - Fix x5c attachment to only serve valid certificates (not expired/not-yet-valid) - Require status='active' for agent_id lookup in signature-agent-card - Change CLI default Signature-Agent format from legacy to dict - Add .nvmrc file (Node 20) - Update README with not_before field and public-status endpoint docs --- .nvmrc | 1 + packages/bot-cli/src/cli.ts | 2 +- packages/bot-cli/src/request-signer.test.ts | 10 +-- packages/bot-cli/src/request-signer.ts | 2 +- packages/registry-service/README.md | 24 ++++++ .../src/routes/__tests__/certs.test.ts | 77 ++++++++++++++++++- packages/registry-service/src/routes/certs.ts | 67 +++++++++++++++- packages/registry-service/src/routes/jwks.ts | 2 + .../src/routes/signature-agent-card.ts | 7 +- 9 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 .nvmrc diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/packages/bot-cli/src/cli.ts b/packages/bot-cli/src/cli.ts index 9c0b997..f9d4060 100644 --- a/packages/bot-cli/src/cli.ts +++ b/packages/bot-cli/src/cli.ts @@ -43,7 +43,7 @@ program .option('-m, --method ', 'HTTP method (default: GET)', 'GET') .option('-d, --body ', 'Request body (JSON)') .option('-v, --verbose', 'Verbose output') - .option('--signature-agent-format ', 'Signature-Agent format: legacy|dict', 'legacy') + .option('--signature-agent-format ', 'Signature-Agent format: legacy|dict', 'dict') .action(async (url, options) => { await fetchCommand(url, { method: options.method, diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts index c2f270c..658825c 100644 --- a/packages/bot-cli/src/request-signer.test.ts +++ b/packages/bot-cli/src/request-signer.test.ts @@ -14,21 +14,21 @@ function makeConfig() { } describe("RequestSigner", () => { - it("emits legacy Signature-Agent by default", async () => { + it("emits dictionary Signature-Agent by default", async () => { const signer = new RequestSigner(makeConfig()); const signed = await signer.sign("GET", "https://example.com"); expect(signed.headers["Signature-Agent"]).toBe( - "https://example.com/jwks/test.json", + 'sig1="https://example.com/jwks/test.json"', ); }); - it("emits dictionary Signature-Agent when requested", async () => { + it("emits legacy Signature-Agent when explicitly requested", async () => { const signer = new RequestSigner(makeConfig()); const signed = await signer.sign("GET", "https://example.com", undefined, { - signatureAgentFormat: "dict", + signatureAgentFormat: "legacy", }); expect(signed.headers["Signature-Agent"]).toBe( - 'sig1="https://example.com/jwks/test.json"', + "https://example.com/jwks/test.json", ); }); }); diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index 154edc1..c9b15f9 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -24,7 +24,7 @@ export class RequestSigner { const signatureAgentFormat = options?.signatureAgentFormat || this.config.signature_agent_format || - "legacy"; + "dict"; // Generate signature parameters const params: SignatureParams = { diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 7cd260c..7b77f6c 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -170,6 +170,30 @@ Response shape: { "valid": true, "revoked": false, + "not_before": "2026-02-01T00:00:00.000Z", + "not_after": "2026-05-01T00:00:00.000Z", + "revoked_at": null, + "revoked_reason": null +} +``` + +Note: `valid` is true only when the certificate is not revoked AND the current time is within the `not_before`/`not_after` validity window. + +#### GET `/v1/certs/public-status` + +Public endpoint for relying parties (e.g., ClawAuth) to check certificate revocation status. + +**No authentication required.** + +Query parameters: +- `fingerprint_sha256` (required) - SHA-256 fingerprint of the certificate + +Response shape: +```json +{ + "valid": true, + "revoked": false, + "not_before": "2026-02-01T00:00:00.000Z", "not_after": "2026-05-01T00:00:00.000Z", "revoked_at": null, "revoked_reason": null diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 5e07b27..29f6a8a 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -173,9 +173,9 @@ describe("GET /v1/certs", () => { expect(res.statusCode).toBe(200); const [countSql] = query.mock.calls[0]; - expect(countSql).toContain("c.revoked_at IS NULL AND c.not_after > now()"); + expect(countSql).toContain("c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()"); const [pageSql] = query.mock.calls[1]; - expect(pageSql).toContain("c.revoked_at IS NULL AND c.not_after > now()"); + expect(pageSql).toContain("c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()"); }); it("applies status=revoked filter", async () => { @@ -297,6 +297,79 @@ describe("GET /v1/certs/status", () => { }); }); +describe("GET /v1/certs/public-status", () => { + it("requires fingerprint_sha256 parameter", async () => { + const req = mockReq({ query: {} }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("fingerprint_sha256"); + }); + + it("returns validity status without authentication", async () => { + const now = Date.now(); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + not_before: new Date(now - 60_000).toISOString(), + not_after: new Date(now + 60_000).toISOString(), + revoked_at: null, + revoked_reason: null, + }, + ], + }); + // Create request without session (public endpoint) + const req = { + headers: {}, + query: { fingerprint_sha256: "abc123" }, + app: { locals: { db: mockDb(query) } }, + } as any; + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(200); + expect(res.headers["Cache-Control"]).toBe("no-store"); + expect(res.body.valid).toBe(true); + expect(res.body.revoked).toBe(false); + expect(res.body.not_before).toBeDefined(); + expect(res.body.not_after).toBeDefined(); + + // Verify query does NOT include user_id filter + const [sql] = query.mock.calls[0]; + expect(sql).not.toContain("user_id"); + expect(sql).toContain("fingerprint_sha256 = $1"); + }); + + it("returns valid=false for future not_before", async () => { + const now = Date.now(); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + not_before: new Date(now + 3_600_000).toISOString(), + not_after: new Date(now + 7_200_000).toISOString(), + revoked_at: null, + revoked_reason: null, + }, + ], + }); + const req = { + headers: {}, + query: { fingerprint_sha256: "future-cert" }, + app: { locals: { db: mockDb(query) } }, + } as any; + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.revoked).toBe(false); + }); +}); + describe("GET /v1/certs/:serial", () => { it("does not leak certs outside owner scope", async () => { const query = vi.fn().mockResolvedValue({ rows: [] }); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 58b50f4..2297d8b 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -261,7 +261,7 @@ certsRouter.get( paramIndex++; } if (statusRaw === "active") { - whereClauses.push("c.revoked_at IS NULL AND c.not_after > now()"); + whereClauses.push("c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()"); } else if (statusRaw === "revoked") { whereClauses.push("c.revoked_at IS NOT NULL"); } @@ -282,7 +282,7 @@ certsRouter.get( const result = await db.getPool().query( `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, - (c.revoked_at IS NULL AND c.not_after > now()) AS is_active + (c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()) AS is_active FROM agent_certificates c JOIN agents a ON a.id = c.agent_id WHERE ${whereClauses.join(" AND ")} @@ -387,7 +387,7 @@ certsRouter.get( `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, c.cert_pem, c.chain_pem, c.x5c, - (c.revoked_at IS NULL AND c.not_after > now()) AS is_active + (c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()) AS is_active FROM agent_certificates c JOIN agents a ON a.id = c.agent_id WHERE a.user_id = $1 AND c.serial = $2 @@ -410,6 +410,67 @@ certsRouter.get( }, ); +/** + * Public certificate status endpoint for relying parties (e.g., ClawAuth). + * No authentication required - allows external services to check revocation. + * Only supports fingerprint_sha256 lookup (not serial) for security. + */ +certsRouter.get( + "/v1/certs/public-status", + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const fingerprint = + typeof req.query.fingerprint_sha256 === "string" && + req.query.fingerprint_sha256.length > 0 + ? req.query.fingerprint_sha256 + : null; + + if (!fingerprint) { + res.status(400).json({ + error: "Missing required parameter: fingerprint_sha256", + }); + return; + } + + const result = await db.getPool().query( + `SELECT c.not_before, c.not_after, c.revoked_at, c.revoked_reason + FROM agent_certificates c + WHERE c.fingerprint_sha256 = $1 + ORDER BY c.created_at DESC + LIMIT 1`, + [fingerprint], + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: "Certificate not found" }); + return; + } + + const cert = result.rows[0]; + const revoked = Boolean(cert.revoked_at); + const nowMs = Date.now(); + const valid = + !revoked && + new Date(cert.not_before).getTime() <= nowMs && + new Date(cert.not_after).getTime() > nowMs; + + res.setHeader("Cache-Control", "no-store"); + res.json({ + valid, + revoked, + not_before: cert.not_before, + not_after: cert.not_after, + revoked_at: cert.revoked_at, + revoked_reason: cert.revoked_reason, + }); + } catch (error: any) { + console.error("Public certificate status error:", error); + res.status(500).json({ error: error.message || "Failed to check certificate status" }); + } + }, +); + certsRouter.get("/.well-known/ca.pem", async (_req, res: Response): Promise => { try { const ca = await getCertificateAuthority(); diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index dbf5750..948e8fe 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -155,6 +155,8 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise now() AND a.user_id = $3 ORDER BY c.agent_id, c.kid, c.created_at DESC`, [agentIdsForCerts, kidsForCerts, profile.id] diff --git a/packages/registry-service/src/routes/signature-agent-card.ts b/packages/registry-service/src/routes/signature-agent-card.ts index 50de6fa..09457a3 100644 --- a/packages/registry-service/src/routes/signature-agent-card.ts +++ b/packages/registry-service/src/routes/signature-agent-card.ts @@ -25,7 +25,7 @@ signatureAgentCardRouter.get( if (agentId) { resolutionSource = "agent_id"; const agentResult = await db.getPool().query( - `SELECT * FROM agents WHERE id = $1`, + `SELECT * FROM agents WHERE id = $1 AND status = 'active'`, [agentId], ); agent = agentResult.rows[0] || null; @@ -92,7 +92,10 @@ signatureAgentCardRouter.get( const certResult = await db.getPool().query( `SELECT x5c FROM agent_certificates - WHERE agent_id = $1 AND kid = $2 AND revoked_at IS NULL + WHERE agent_id = $1 AND kid = $2 + AND revoked_at IS NULL + AND not_before <= now() + AND not_after > now() ORDER BY created_at DESC LIMIT 1`, [agent.id, kid], From 6f5db9a0301228edaf4ed005d5a0355cc02e832e Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 04:11:38 +0500 Subject: [PATCH 18/49] Add proof-of-possession and fingerprint validation - Require PoP signature for cert issuance (prevents issuing certs for keys you do not control) - Validate fingerprint_sha256 format (64 hex chars) on public-status endpoint - Add fingerprint computation guidance to README for mTLS integrators --- packages/registry-service/README.md | 46 +++++++++- .../src/routes/__tests__/certs.test.ts | 26 +++++- packages/registry-service/src/routes/certs.ts | 88 ++++++++++++++++++- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 7b77f6c..2ee868f 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -100,6 +100,14 @@ Auth: - Requires authenticated session or Bearer PAT. - Required scope: `agents:write`. +**Proof-of-Possession Required:** + +To prevent certificate issuance for keys you don't control, you must provide a signed proof: + +1. Generate a message: `cert-issue:{unix_timestamp}` (timestamp must be within 5 minutes) +2. Sign the message with your Ed25519 private key +3. Include the proof in the request body + Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf certificate as an informational hint. This value is user-supplied unless you enforce registry-side issuance rules. @@ -107,10 +115,26 @@ enforce registry-side issuance rules. **Request:** ```json { - "agent_id": "uuid" + "agent_id": "uuid", + "proof": { + "message": "cert-issue:1709251200", + "signature": "" + } } ``` +Example proof generation (Node.js): +```javascript +const crypto = require('crypto'); +const timestamp = Math.floor(Date.now() / 1000); +const message = `cert-issue:${timestamp}`; +const signature = crypto.sign(null, Buffer.from(message), privateKey); +const proof = { + message, + signature: signature.toString('base64') +}; +``` + #### POST `/v1/certs/revoke` Revoke an issued certificate. @@ -186,7 +210,25 @@ Public endpoint for relying parties (e.g., ClawAuth) to check certificate revoca **No authentication required.** Query parameters: -- `fingerprint_sha256` (required) - SHA-256 fingerprint of the certificate +- `fingerprint_sha256` (required) - SHA-256 fingerprint of the certificate (64 lowercase hex characters) + +**Computing the fingerprint:** + +For mTLS integration, compute the SHA-256 fingerprint over the **DER-encoded** client certificate (not PEM text). Example in Node.js: + +```javascript +const crypto = require('crypto'); +const forge = require('node-forge'); + +// From PEM string +const pem = '-----BEGIN CERTIFICATE-----...'; +const cert = forge.pki.certificateFromPem(pem); +const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); +const fingerprint = crypto.createHash('sha256') + .update(Buffer.from(der, 'binary')) + .digest('hex'); +// fingerprint is 64 lowercase hex chars +``` Response shape: ```json diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 29f6a8a..780f3a3 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -308,6 +308,26 @@ describe("GET /v1/certs/public-status", () => { expect(res.body.error).toContain("fingerprint_sha256"); }); + it("rejects invalid fingerprint format", async () => { + const req = mockReq({ query: { fingerprint_sha256: "not-valid-hex" } }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("64 lowercase hex"); + }); + + it("rejects fingerprint with wrong length", async () => { + const req = mockReq({ query: { fingerprint_sha256: "abcd1234" } }); + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("64 lowercase hex"); + }); + it("returns validity status without authentication", async () => { const now = Date.now(); const query = vi.fn().mockResolvedValue({ @@ -321,9 +341,10 @@ describe("GET /v1/certs/public-status", () => { ], }); // Create request without session (public endpoint) + // Use valid 64-char hex fingerprint const req = { headers: {}, - query: { fingerprint_sha256: "abc123" }, + query: { fingerprint_sha256: "a".repeat(64) }, app: { locals: { db: mockDb(query) } }, } as any; const res = mockRes(); @@ -355,9 +376,10 @@ describe("GET /v1/certs/public-status", () => { }, ], }); + // Use valid 64-char hex fingerprint const req = { headers: {}, - query: { fingerprint_sha256: "future-cert" }, + query: { fingerprint_sha256: "b".repeat(64) }, app: { locals: { db: mockDb(query) } }, } as any; const res = mockRes(); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 2297d8b..c51ff70 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -2,12 +2,73 @@ * Certificate issuance endpoints (MVP) */ +import { webcrypto } from "node:crypto"; import { Router, type Request, type Response } from "express"; import type { Database } from "@openbotauth/github-connector"; import { issueCertificateForJwk, getCertificateAuthority } from "../utils/ca.js"; import { requireScope } from "../middleware/scopes.js"; import { jwkThumbprint } from "../utils/jwk.js"; +/** + * Verify proof-of-possession signature. + * The proof message format is: "cert-issue:{timestamp}" + * Timestamp must be within 5 minutes of current time. + */ +async function verifyProofOfPossession( + proof: { message: string; signature: string }, + publicKey: { kty?: string; crv?: string; x: string }, +): Promise<{ valid: boolean; error?: string }> { + try { + // Parse and validate message format + const match = proof.message.match(/^cert-issue:(\d+)$/); + if (!match) { + return { valid: false, error: "Invalid proof message format. Expected: cert-issue:{timestamp}" }; + } + + const timestamp = parseInt(match[1], 10); + const now = Math.floor(Date.now() / 1000); + const maxSkew = 300; // 5 minutes + + if (Math.abs(now - timestamp) > maxSkew) { + return { valid: false, error: "Proof timestamp expired or too far in the future" }; + } + + // Import public key + const jwk = { + kty: publicKey.kty || "OKP", + crv: publicKey.crv || "Ed25519", + x: publicKey.x, + }; + + const key = await webcrypto.subtle.importKey( + "jwk", + jwk, + { name: "Ed25519" }, + false, + ["verify"], + ); + + // Verify signature + const messageBuffer = new TextEncoder().encode(proof.message); + const signatureBuffer = Buffer.from(proof.signature, "base64"); + + const valid = await webcrypto.subtle.verify( + "Ed25519", + key, + signatureBuffer, + messageBuffer, + ); + + if (!valid) { + return { valid: false, error: "Signature verification failed" }; + } + + return { valid: true }; + } catch (err: any) { + return { valid: false, error: err.message || "Proof verification error" }; + } +} + export const certsRouter: Router = Router(); const requireAuth = (req: Request, res: Response, next: Function) => { @@ -95,6 +156,23 @@ certsRouter.post( return; } + // Verify proof-of-possession: caller must prove they have the private key + const { proof } = req.body || {}; + if (!proof || typeof proof !== "object" || !proof.message || !proof.signature) { + res.status(400).json({ + error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{timestamp}', signature: '' }", + }); + return; + } + + const popResult = await verifyProofOfPossession(proof, pk); + if (!popResult.valid) { + res.status(403).json({ + error: `Proof-of-possession failed: ${popResult.error}`, + }); + return; + } + const resolvedKid = typeof pk.kid === "string" && pk.kid.length > 0 ? pk.kid @@ -423,7 +501,7 @@ certsRouter.get( const fingerprint = typeof req.query.fingerprint_sha256 === "string" && req.query.fingerprint_sha256.length > 0 - ? req.query.fingerprint_sha256 + ? req.query.fingerprint_sha256.toLowerCase() : null; if (!fingerprint) { @@ -433,6 +511,14 @@ certsRouter.get( return; } + // Validate fingerprint format: must be 64 hex characters (SHA-256) + if (!/^[a-f0-9]{64}$/.test(fingerprint)) { + res.status(400).json({ + error: "Invalid fingerprint_sha256: must be 64 lowercase hex characters", + }); + return; + } + const result = await db.getPool().query( `SELECT c.not_before, c.not_after, c.revoked_at, c.revoked_reason FROM agent_certificates c From 0ad69b484d7f1c84ccacf67c10ccdee054e438ac Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 04:25:25 +0500 Subject: [PATCH 19/49] Harden PoP and make cert issuance CLI-only - Add type checks and signature length validation (64 bytes for Ed25519) - Disallow future timestamps (30s drift tolerance, 5min max age) - Bind proof to agent_id: cert-issue:{agent_id}:{timestamp} - Use object form for webcrypto.subtle.verify - Fix fingerprint error message (accepts uppercase, normalizes to lowercase) - Update README with native crypto fingerprint example - Remove Issue Certificate button from portal (add CLI instructions) - Add oba-bot cert issue command with PoP support --- .../src/pages/portal/AgentDetail.tsx | 56 +++---- packages/bot-cli/src/cli.ts | 23 +++ packages/bot-cli/src/commands/cert.ts | 118 ++++++++++++++ packages/registry-service/README.md | 36 ++--- .../src/routes/__tests__/certs.test.ts | 147 +++++++++++++++++- packages/registry-service/src/routes/certs.ts | 61 ++++++-- 6 files changed, 364 insertions(+), 77 deletions(-) create mode 100644 packages/bot-cli/src/commands/cert.ts diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index 60e4b22..90ca267 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -39,7 +39,6 @@ const AgentDetail = () => { const [certificates, setCertificates] = useState([]); const [loading, setLoading] = useState(true); const [certLoading, setCertLoading] = useState(false); - const [issuingCert, setIssuingCert] = useState(false); const [revokingSerial, setRevokingSerial] = useState(null); const [revokeDialogCert, setRevokeDialogCert] = useState(null); const [advancedSerial, setAdvancedSerial] = useState(null); @@ -142,28 +141,6 @@ const AgentDetail = () => { } }; - const issueCertificate = async () => { - if (!agent) return; - setIssuingCert(true); - try { - const issued = await api.issueCert({ agent_id: agent.id }); - toast({ - title: "Certificate Issued", - description: `Serial ${shortText(issued.serial)} issued successfully`, - }); - await fetchCertificates(agent.id); - } catch (error: any) { - console.error("Error issuing certificate:", error); - toast({ - title: "Error", - description: error.message || "Failed to issue certificate", - variant: "destructive", - }); - } finally { - setIssuingCert(false); - } - }; - const revokeCertificate = async (serial: string) => { if (!agent) return; @@ -403,27 +380,30 @@ const AgentDetail = () => { Issued X.509 certificates for this agent key -
- - -
+ +
+

+ Certificate issuance requires proof-of-possession and must be done via CLI: +

+ + oba-bot cert issue --agent-id {agent.id} + +
{certLoading ? (

Loading certificates...

) : certificates.length === 0 ? (

- No certificates issued yet + No certificates issued yet. Use the CLI command above to issue one.

) : (
diff --git a/packages/bot-cli/src/cli.ts b/packages/bot-cli/src/cli.ts index f9d4060..63002c7 100644 --- a/packages/bot-cli/src/cli.ts +++ b/packages/bot-cli/src/cli.ts @@ -10,6 +10,7 @@ import { Command } from 'commander'; import { keygenCommand } from './commands/keygen.js'; import { fetchCommand } from './commands/fetch.js'; import { configCommand } from './commands/config.js'; +import { certIssueCommand } from './commands/cert.js'; const program = new Command(); @@ -63,6 +64,25 @@ program await configCommand(); }); +/** + * cert command - Certificate management + */ +const certCmd = program.command('cert').description('Certificate management'); + +certCmd + .command('issue') + .description('Issue an X.509 certificate for an agent (requires proof-of-possession)') + .requiredOption('--agent-id ', 'Agent ID to issue certificate for') + .option('--registry-url ', 'Registry URL (default: https://registry.openbotauth.com)') + .option('--token ', 'Auth token (or set OPENBOTAUTH_TOKEN env var)') + .action(async (options) => { + await certIssueCommand({ + agentId: options.agentId, + registryUrl: options.registryUrl, + token: options.token, + }); + }); + /** * Examples */ @@ -82,6 +102,9 @@ Examples: # Show configuration $ oba-bot config + # Issue an X.509 certificate for an agent + $ oba-bot cert issue --agent-id --token + # Verbose mode $ oba-bot fetch https://example.com/api/data -v ` diff --git a/packages/bot-cli/src/commands/cert.ts b/packages/bot-cli/src/commands/cert.ts new file mode 100644 index 0000000..9149d79 --- /dev/null +++ b/packages/bot-cli/src/commands/cert.ts @@ -0,0 +1,118 @@ +/** + * Certificate Commands + * + * Issue and manage X.509 certificates for agents + */ + +import { webcrypto } from "node:crypto"; +import { KeyStorage } from "../key-storage.js"; + +const DEFAULT_REGISTRY_URL = + process.env.OPENBOTAUTH_REGISTRY_URL || "https://registry.openbotauth.com"; + +export async function certIssueCommand(options: { + agentId: string; + registryUrl?: string; + token?: string; +}): Promise { + console.log("🔏 Issuing certificate with proof-of-possession...\n"); + + try { + // Load config to get private key + const config = await KeyStorage.load(); + if (!config) { + console.error( + "❌ No configuration found. Run 'oba-bot keygen' first to generate keys." + ); + process.exit(1); + } + + const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL; + const token = options.token || process.env.OPENBOTAUTH_TOKEN; + + if (!token) { + console.error( + "❌ No auth token provided. Set OPENBOTAUTH_TOKEN or use --token" + ); + process.exit(1); + } + + // Generate proof-of-possession + const timestamp = Math.floor(Date.now() / 1000); + const message = `cert-issue:${options.agentId}:${timestamp}`; + + console.log(`Generating proof for agent: ${options.agentId}`); + console.log(`Proof message: ${message}\n`); + + // Sign the message + const privateKey = await webcrypto.subtle.importKey( + "pkcs8", + pemToBuffer(config.private_key), + { name: "Ed25519" }, + false, + ["sign"] + ); + + const messageBuffer = new TextEncoder().encode(message); + const signatureBuffer = await webcrypto.subtle.sign( + "Ed25519", + privateKey, + messageBuffer + ); + + const signature = Buffer.from(signatureBuffer).toString("base64"); + + // Make API request + const response = await fetch(`${registryUrl}/v1/certs/issue`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + agent_id: options.agentId, + proof: { + message, + signature, + }, + }), + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })); + console.error(`❌ Certificate issuance failed: ${error.error || response.statusText}`); + process.exit(1); + } + + const result = await response.json(); + + console.log("✅ Certificate issued successfully!\n"); + console.log("Certificate details:"); + console.log(` Serial: ${result.serial}`); + console.log(` Fingerprint: ${result.fingerprint_sha256}`); + console.log(` Not Before: ${result.not_before}`); + console.log(` Not After: ${result.not_after}`); + console.log(""); + + if (result.cert_pem) { + console.log("Certificate PEM:"); + console.log(result.cert_pem); + } + } catch (error: any) { + console.error("❌ Error issuing certificate:", error.message); + process.exit(1); + } +} + +function pemToBuffer(pem: string): ArrayBuffer { + const base64 = pem + .replace(/-----BEGIN PRIVATE KEY-----/, "") + .replace(/-----END PRIVATE KEY-----/, "") + .replace(/\s/g, ""); + + const buffer = Buffer.from(base64, "base64"); + return buffer.buffer.slice( + buffer.byteOffset, + buffer.byteOffset + buffer.byteLength + ); +} diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 2ee868f..32a039e 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -104,10 +104,12 @@ Auth: To prevent certificate issuance for keys you don't control, you must provide a signed proof: -1. Generate a message: `cert-issue:{unix_timestamp}` (timestamp must be within 5 minutes) +1. Generate a message: `cert-issue:{agent_id}:{unix_timestamp}` 2. Sign the message with your Ed25519 private key 3. Include the proof in the request body +The timestamp must be within 5 minutes in the past (no future timestamps allowed). + Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf certificate as an informational hint. This value is user-supplied unless you enforce registry-side issuance rules. @@ -117,24 +119,22 @@ enforce registry-side issuance rules. { "agent_id": "uuid", "proof": { - "message": "cert-issue:1709251200", + "message": "cert-issue:550e8400-e29b-41d4-a716-446655440000:1709251200", "signature": "" } } ``` -Example proof generation (Node.js): -```javascript -const crypto = require('crypto'); -const timestamp = Math.floor(Date.now() / 1000); -const message = `cert-issue:${timestamp}`; -const signature = crypto.sign(null, Buffer.from(message), privateKey); -const proof = { - message, - signature: signature.toString('base64') -}; +**CLI Usage (recommended):** + +Certificate issuance is best done via CLI to keep private keys secure: + +```bash +oba-bot cert issue --agent-id ``` +The CLI will generate the proof automatically using your local private key. + #### POST `/v1/certs/revoke` Revoke an issued certificate. @@ -217,16 +217,12 @@ Query parameters: For mTLS integration, compute the SHA-256 fingerprint over the **DER-encoded** client certificate (not PEM text). Example in Node.js: ```javascript -const crypto = require('crypto'); -const forge = require('node-forge'); +const { createHash, X509Certificate } = require("node:crypto"); // From PEM string -const pem = '-----BEGIN CERTIFICATE-----...'; -const cert = forge.pki.certificateFromPem(pem); -const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); -const fingerprint = crypto.createHash('sha256') - .update(Buffer.from(der, 'binary')) - .digest('hex'); +const pem = "-----BEGIN CERTIFICATE-----..."; +const cert = new X509Certificate(pem); +const fingerprint = createHash("sha256").update(cert.raw).digest("hex"); // fingerprint is 64 lowercase hex chars ``` diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 780f3a3..4b208de 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -217,6 +217,132 @@ describe("GET /v1/certs", () => { }); }); +describe("POST /v1/certs/issue - PoP validation", () => { + const mockAgentQuery = vi.fn().mockResolvedValue({ + rows: [ + { + id: "agent-123", + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + }, + }, + ], + }); + + it("rejects missing proof", async () => { + const req = mockReq({ + body: { agent_id: "agent-123" }, + app: { locals: { db: mockDb(mockAgentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("proof-of-possession"); + }); + + it("rejects invalid proof message format", async () => { + const req = mockReq({ + body: { + agent_id: "agent-123", + proof: { message: "invalid-format", signature: "abc" }, + }, + app: { locals: { db: mockDb(mockAgentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("Invalid proof message format"); + }); + + it("rejects expired timestamp", async () => { + const expiredTs = Math.floor(Date.now() / 1000) - 400; // 6+ minutes ago + const req = mockReq({ + body: { + agent_id: "agent-123", + proof: { + message: `cert-issue:agent-123:${expiredTs}`, + signature: Buffer.alloc(64).toString("base64"), + }, + }, + app: { locals: { db: mockDb(mockAgentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("expired"); + }); + + it("rejects future timestamp beyond drift tolerance", async () => { + const futureTs = Math.floor(Date.now() / 1000) + 120; // 2 minutes in future + const req = mockReq({ + body: { + agent_id: "agent-123", + proof: { + message: `cert-issue:agent-123:${futureTs}`, + signature: Buffer.alloc(64).toString("base64"), + }, + }, + app: { locals: { db: mockDb(mockAgentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("future"); + }); + + it("rejects agent_id mismatch in proof", async () => { + const ts = Math.floor(Date.now() / 1000); + const req = mockReq({ + body: { + agent_id: "agent-123", + proof: { + message: `cert-issue:different-agent:${ts}`, + signature: Buffer.alloc(64).toString("base64"), + }, + }, + app: { locals: { db: mockDb(mockAgentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("does not match"); + }); + + it("rejects invalid signature length", async () => { + const ts = Math.floor(Date.now() / 1000); + const req = mockReq({ + body: { + agent_id: "agent-123", + proof: { + message: `cert-issue:agent-123:${ts}`, + signature: Buffer.alloc(32).toString("base64"), // wrong length + }, + }, + app: { locals: { db: mockDb(mockAgentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("64 bytes"); + }); +}); + describe("POST /v1/certs/revoke", () => { it("only updates certs that are not already revoked", async () => { const query = vi.fn().mockResolvedValue({ rows: [{ id: "c-1" }] }); @@ -315,7 +441,7 @@ describe("GET /v1/certs/public-status", () => { await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); expect(res.statusCode).toBe(400); - expect(res.body.error).toContain("64 lowercase hex"); + expect(res.body.error).toContain("64 hex characters"); }); it("rejects fingerprint with wrong length", async () => { @@ -325,7 +451,24 @@ describe("GET /v1/certs/public-status", () => { await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); expect(res.statusCode).toBe(400); - expect(res.body.error).toContain("64 lowercase hex"); + expect(res.body.error).toContain("64 hex characters"); + }); + + it("normalizes uppercase fingerprint to lowercase", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const req = { + headers: {}, + query: { fingerprint_sha256: "A".repeat(64) }, + app: { locals: { db: mockDb(query) } }, + } as any; + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + // Should reach DB query (not rejected as invalid format) + expect(query).toHaveBeenCalled(); + const [, params] = query.mock.calls[0]; + expect(params[0]).toBe("a".repeat(64)); // lowercased }); it("returns validity status without authentication", async () => { diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index c51ff70..adb810d 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -11,26 +11,54 @@ import { jwkThumbprint } from "../utils/jwk.js"; /** * Verify proof-of-possession signature. - * The proof message format is: "cert-issue:{timestamp}" - * Timestamp must be within 5 minutes of current time. + * The proof message format is: "cert-issue:{agent_id}:{timestamp}" + * Timestamp must be within 5 minutes in the past (no future timestamps). */ async function verifyProofOfPossession( - proof: { message: string; signature: string }, + proof: unknown, + agentId: string, publicKey: { kty?: string; crv?: string; x: string }, ): Promise<{ valid: boolean; error?: string }> { try { - // Parse and validate message format - const match = proof.message.match(/^cert-issue:(\d+)$/); + // Type validation + if (!proof || typeof proof !== "object") { + return { valid: false, error: "Proof must be an object" }; + } + const { message, signature } = proof as Record; + if (typeof message !== "string" || typeof signature !== "string") { + return { valid: false, error: "Proof message and signature must be strings" }; + } + + // Parse and validate message format: cert-issue:{agent_id}:{timestamp} + const match = message.match(/^cert-issue:([^:]+):(\d+)$/); if (!match) { - return { valid: false, error: "Invalid proof message format. Expected: cert-issue:{timestamp}" }; + return { valid: false, error: "Invalid proof message format. Expected: cert-issue:{agent_id}:{timestamp}" }; + } + + const [, proofAgentId, timestampStr] = match; + + // Verify agent_id matches + if (proofAgentId !== agentId) { + return { valid: false, error: "Proof agent_id does not match requested agent" }; } - const timestamp = parseInt(match[1], 10); + // Validate timestamp: must be in the past, within 5 minutes + const timestamp = parseInt(timestampStr, 10); const now = Math.floor(Date.now() / 1000); - const maxSkew = 300; // 5 minutes + const maxAge = 300; // 5 minutes + const maxDrift = 30; // 30 seconds future tolerance for clock skew - if (Math.abs(now - timestamp) > maxSkew) { - return { valid: false, error: "Proof timestamp expired or too far in the future" }; + if (timestamp > now + maxDrift) { + return { valid: false, error: "Proof timestamp is in the future" }; + } + if (now - timestamp > maxAge) { + return { valid: false, error: "Proof timestamp expired (older than 5 minutes)" }; + } + + // Validate signature length (Ed25519 signatures are always 64 bytes) + const signatureBuffer = Buffer.from(signature, "base64"); + if (signatureBuffer.length !== 64) { + return { valid: false, error: "Invalid signature length (Ed25519 signatures must be 64 bytes)" }; } // Import public key @@ -49,11 +77,10 @@ async function verifyProofOfPossession( ); // Verify signature - const messageBuffer = new TextEncoder().encode(proof.message); - const signatureBuffer = Buffer.from(proof.signature, "base64"); + const messageBuffer = new TextEncoder().encode(message); const valid = await webcrypto.subtle.verify( - "Ed25519", + { name: "Ed25519" }, key, signatureBuffer, messageBuffer, @@ -158,14 +185,14 @@ certsRouter.post( // Verify proof-of-possession: caller must prove they have the private key const { proof } = req.body || {}; - if (!proof || typeof proof !== "object" || !proof.message || !proof.signature) { + if (!proof) { res.status(400).json({ - error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{timestamp}', signature: '' }", + error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{agent_id}:{timestamp}', signature: '' }", }); return; } - const popResult = await verifyProofOfPossession(proof, pk); + const popResult = await verifyProofOfPossession(proof, agent.id, pk); if (!popResult.valid) { res.status(403).json({ error: `Proof-of-possession failed: ${popResult.error}`, @@ -514,7 +541,7 @@ certsRouter.get( // Validate fingerprint format: must be 64 hex characters (SHA-256) if (!/^[a-f0-9]{64}$/.test(fingerprint)) { res.status(400).json({ - error: "Invalid fingerprint_sha256: must be 64 lowercase hex characters", + error: "Invalid fingerprint_sha256: must be 64 hex characters", }); return; } From 6e0f4fdcd4d70a164e297f37602da2762a915e36 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 04:41:29 +0500 Subject: [PATCH 20/49] Fix PoP workflow and add private-key-path support - Remove kid-only cert issuance (require agent_id for PoP binding) - Add --private-key-path to CLI for portal-created agents - Update portal snippet with full command (token + private-key-path) - Clarify README: 30s future drift tolerated for clock skew - Add happy-path PoP test with real Ed25519 crypto verification --- .../src/pages/portal/AgentDetail.tsx | 14 ++-- packages/bot-cli/src/cli.ts | 2 + packages/bot-cli/src/commands/cert.ts | 35 ++++++--- packages/registry-service/README.md | 10 ++- .../src/routes/__tests__/certs.test.ts | 73 +++++++++++++++++++ packages/registry-service/src/routes/certs.ts | 28 ++----- 6 files changed, 125 insertions(+), 37 deletions(-) diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index 90ca267..5f1c347 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -391,13 +391,17 @@ const AgentDetail = () => { -
-

- Certificate issuance requires proof-of-possession and must be done via CLI: +

+

+ Certificate issuance requires proof-of-possession and must be done via CLI. + Use the private key you downloaded when creating this agent:

- - oba-bot cert issue --agent-id {agent.id} + + oba-bot cert issue --agent-id {agent?.id ?? ""} --private-key-path /path/to/private-key.pem --token {""} +

+ Or set OPENBOTAUTH_TOKEN env var instead of --token +

{certLoading ? (

Loading certificates...

diff --git a/packages/bot-cli/src/cli.ts b/packages/bot-cli/src/cli.ts index 63002c7..4e2e5e2 100644 --- a/packages/bot-cli/src/cli.ts +++ b/packages/bot-cli/src/cli.ts @@ -73,11 +73,13 @@ certCmd .command('issue') .description('Issue an X.509 certificate for an agent (requires proof-of-possession)') .requiredOption('--agent-id ', 'Agent ID to issue certificate for') + .option('--private-key-path ', 'Path to private key PEM file (if not using KeyStorage)') .option('--registry-url ', 'Registry URL (default: https://registry.openbotauth.com)') .option('--token ', 'Auth token (or set OPENBOTAUTH_TOKEN env var)') .action(async (options) => { await certIssueCommand({ agentId: options.agentId, + privateKeyPath: options.privateKeyPath, registryUrl: options.registryUrl, token: options.token, }); diff --git a/packages/bot-cli/src/commands/cert.ts b/packages/bot-cli/src/commands/cert.ts index 9149d79..9cd5cda 100644 --- a/packages/bot-cli/src/commands/cert.ts +++ b/packages/bot-cli/src/commands/cert.ts @@ -4,6 +4,7 @@ * Issue and manage X.509 certificates for agents */ +import { readFile } from "node:fs/promises"; import { webcrypto } from "node:crypto"; import { KeyStorage } from "../key-storage.js"; @@ -14,17 +15,33 @@ export async function certIssueCommand(options: { agentId: string; registryUrl?: string; token?: string; + privateKeyPath?: string; }): Promise { console.log("🔏 Issuing certificate with proof-of-possession...\n"); try { - // Load config to get private key - const config = await KeyStorage.load(); - if (!config) { - console.error( - "❌ No configuration found. Run 'oba-bot keygen' first to generate keys." - ); - process.exit(1); + // Load private key - either from explicit path or from KeyStorage + let privateKeyPem: string; + + if (options.privateKeyPath) { + console.log(`Loading private key from: ${options.privateKeyPath}`); + try { + privateKeyPem = await readFile(options.privateKeyPath, "utf-8"); + } catch (err: any) { + console.error(`❌ Failed to read private key file: ${err.message}`); + process.exit(1); + } + } else { + const config = await KeyStorage.load(); + if (!config) { + console.error( + "❌ No configuration found. Either:\n" + + " - Use --private-key-path to specify the agent's private key, or\n" + + " - Run 'oba-bot keygen' first to generate keys" + ); + process.exit(1); + } + privateKeyPem = config.private_key; } const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL; @@ -47,7 +64,7 @@ export async function certIssueCommand(options: { // Sign the message const privateKey = await webcrypto.subtle.importKey( "pkcs8", - pemToBuffer(config.private_key), + pemToBuffer(privateKeyPem), { name: "Ed25519" }, false, ["sign"] @@ -55,7 +72,7 @@ export async function certIssueCommand(options: { const messageBuffer = new TextEncoder().encode(message); const signatureBuffer = await webcrypto.subtle.sign( - "Ed25519", + { name: "Ed25519" }, privateKey, messageBuffer ); diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 32a039e..1653ab4 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -108,7 +108,7 @@ To prevent certificate issuance for keys you don't control, you must provide a s 2. Sign the message with your Ed25519 private key 3. Include the proof in the request body -The timestamp must be within 5 minutes in the past (no future timestamps allowed). +The timestamp must be within 5 minutes in the past (up to 30 seconds future drift is tolerated for clock skew). Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf certificate as an informational hint. This value is user-supplied unless you @@ -130,10 +130,14 @@ enforce registry-side issuance rules. Certificate issuance is best done via CLI to keep private keys secure: ```bash -oba-bot cert issue --agent-id +# Using the private key downloaded when creating the agent +oba-bot cert issue --agent-id --private-key-path /path/to/private-key.pem --token + +# Or with OPENBOTAUTH_TOKEN env var +OPENBOTAUTH_TOKEN= oba-bot cert issue --agent-id --private-key-path /path/to/private-key.pem ``` -The CLI will generate the proof automatically using your local private key. +The CLI generates the proof-of-possession signature automatically using the specified private key. #### POST `/v1/certs/revoke` diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 4b208de..c4cad20 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -1,5 +1,20 @@ +import { webcrypto } from "node:crypto"; import { beforeEach, describe, expect, it, vi } from "vitest"; +// Mock the CA module to avoid actual certificate issuance in tests +vi.mock("../../utils/ca.js", () => ({ + issueCertificateForJwk: vi.fn().mockResolvedValue({ + serial: "test-serial-123", + certPem: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", + chainPem: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----", + x5c: ["dGVzdA=="], + notBefore: new Date().toISOString(), + notAfter: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(), + fingerprintSha256: "a".repeat(64), + }), + getCertificateAuthority: vi.fn(), +})); + function mockDb(queryFn?: (...args: any[]) => any) { return { getPool: () => ({ @@ -341,6 +356,64 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.statusCode).toBe(403); expect(res.body.error).toContain("64 bytes"); }); + + it("accepts valid proof and issues certificate", async () => { + // Generate a real Ed25519 keypair + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + ); + + // Export public key as JWK + const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + // Create proof message and sign it + const agentId = "agent-real-123"; + const ts = Math.floor(Date.now() / 1000); + const message = `cert-issue:${agentId}:${ts}`; + const messageBuffer = new TextEncoder().encode(message); + const signatureBuffer = await webcrypto.subtle.sign( + { name: "Ed25519" }, + keyPair.privateKey, + messageBuffer, + ); + const signature = Buffer.from(signatureBuffer).toString("base64"); + + // Mock agent query to return our generated public key + const agentQuery = vi.fn() + .mockResolvedValueOnce({ + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }) + // Mock the INSERT query for certificate + .mockResolvedValueOnce({ rows: [] }); + + const req = mockReq({ + body: { + agent_id: agentId, + proof: { message, signature }, + }, + app: { locals: { db: mockDb(agentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.serial).toBe("test-serial-123"); + expect(res.body.fingerprint_sha256).toBeDefined(); + }); }); describe("POST /v1/certs/revoke", () => { diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index adb810d..f0a639e 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -145,32 +145,20 @@ certsRouter.post( async (req: Request, res: Response): Promise => { try { const db: Database = req.app.locals.db; - const { agent_id, kid } = req.body || {}; + const { agent_id } = req.body || {}; - if (!agent_id && !kid) { + if (!agent_id) { res.status(400).json({ - error: "Missing required input: agent_id or kid", + error: "Missing required input: agent_id", }); return; } - let agent: any = null; - if (agent_id) { - const result = await db.getPool().query( - `SELECT * FROM agents WHERE id = $1 AND user_id = $2`, - [agent_id, req.session!.user.id], - ); - agent = result.rows[0] || null; - } else if (kid) { - const result = await db.getPool().query( - `SELECT * FROM agents - WHERE user_id = $1 AND public_key->>'kid' = $2 - ORDER BY created_at DESC - LIMIT 1`, - [req.session!.user.id, kid], - ); - agent = result.rows[0] || null; - } + const result = await db.getPool().query( + `SELECT * FROM agents WHERE id = $1 AND user_id = $2`, + [agent_id, req.session!.user.id], + ); + const agent = result.rows[0] || null; if (!agent) { res.status(404).json({ error: "Agent not found" }); From 8d093916697c519935a1ca97ddfa122ca3759dc1 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 04:54:53 +0500 Subject: [PATCH 21/49] Support both JWK JSON and PEM private key formats in CLI - Auto-detect key format: JWK JSON (portal agent creation) or PEM (Setup page) - Extract PEM from .txt bundle files (Setup.tsx downloads keys in .txt) - Update portal snippet to show correct .json extension - Update README to document both supported formats --- .../src/pages/portal/AgentDetail.tsx | 4 +- packages/bot-cli/src/commands/cert.ts | 80 ++++++++++++++++--- packages/registry-service/README.md | 11 ++- 3 files changed, 78 insertions(+), 17 deletions(-) diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index 5f1c347..9122bc4 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -394,10 +394,10 @@ const AgentDetail = () => {

Certificate issuance requires proof-of-possession and must be done via CLI. - Use the private key you downloaded when creating this agent: + Use the private key file (agent-*-private-key.json) you downloaded when creating this agent:

- oba-bot cert issue --agent-id {agent?.id ?? ""} --private-key-path /path/to/private-key.pem --token {""} + oba-bot cert issue --agent-id {agent?.id ?? ""} --private-key-path ./agent-{agent?.id ? agent.id.slice(0, 8) : ""}...-private-key.json --token {""}

Or set OPENBOTAUTH_TOKEN env var instead of --token diff --git a/packages/bot-cli/src/commands/cert.ts b/packages/bot-cli/src/commands/cert.ts index 9cd5cda..1d3db3a 100644 --- a/packages/bot-cli/src/commands/cert.ts +++ b/packages/bot-cli/src/commands/cert.ts @@ -11,6 +11,64 @@ import { KeyStorage } from "../key-storage.js"; const DEFAULT_REGISTRY_URL = process.env.OPENBOTAUTH_REGISTRY_URL || "https://registry.openbotauth.com"; +/** + * Import a private key from either JWK JSON or PEM format. + * Detects format automatically based on content. + */ +async function importPrivateKey(content: string): Promise { + const trimmed = content.trim(); + + // Check if it's JSON (JWK format from AddAgentModal) + if (trimmed.startsWith("{")) { + try { + const jwk = JSON.parse(trimmed); + + // Validate it's an Ed25519 private key JWK + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.d) { + throw new Error( + "Invalid JWK: must be an Ed25519 private key (kty=OKP, crv=Ed25519, d=...)" + ); + } + + return await webcrypto.subtle.importKey( + "jwk", + jwk, + { name: "Ed25519" }, + false, + ["sign"] + ); + } catch (err: any) { + if (err.message.includes("Invalid JWK")) throw err; + throw new Error(`Failed to parse JWK JSON: ${err.message}`); + } + } + + // Check if it's PEM format (from Setup.tsx or KeyStorage) + if (trimmed.includes("-----BEGIN PRIVATE KEY-----")) { + // Extract PEM from potentially larger file (like the .txt bundle from Setup.tsx) + const pemMatch = trimmed.match( + /-----BEGIN PRIVATE KEY-----[\s\S]*?-----END PRIVATE KEY-----/ + ); + if (!pemMatch) { + throw new Error("Could not find valid PEM private key block"); + } + + return await webcrypto.subtle.importKey( + "pkcs8", + pemToBuffer(pemMatch[0]), + { name: "Ed25519" }, + false, + ["sign"] + ); + } + + throw new Error( + "Unrecognized private key format. Expected either:\n" + + " - JWK JSON file (from portal agent creation), or\n" + + " - PEM file with -----BEGIN PRIVATE KEY----- block" + ); +} + export async function certIssueCommand(options: { agentId: string; registryUrl?: string; @@ -21,16 +79,24 @@ export async function certIssueCommand(options: { try { // Load private key - either from explicit path or from KeyStorage - let privateKeyPem: string; + let privateKey: CryptoKey; if (options.privateKeyPath) { console.log(`Loading private key from: ${options.privateKeyPath}`); + let fileContent: string; try { - privateKeyPem = await readFile(options.privateKeyPath, "utf-8"); + fileContent = await readFile(options.privateKeyPath, "utf-8"); } catch (err: any) { console.error(`❌ Failed to read private key file: ${err.message}`); process.exit(1); } + + try { + privateKey = await importPrivateKey(fileContent); + } catch (err: any) { + console.error(`❌ Failed to import private key: ${err.message}`); + process.exit(1); + } } else { const config = await KeyStorage.load(); if (!config) { @@ -41,7 +107,7 @@ export async function certIssueCommand(options: { ); process.exit(1); } - privateKeyPem = config.private_key; + privateKey = await importPrivateKey(config.private_key); } const registryUrl = options.registryUrl || DEFAULT_REGISTRY_URL; @@ -62,14 +128,6 @@ export async function certIssueCommand(options: { console.log(`Proof message: ${message}\n`); // Sign the message - const privateKey = await webcrypto.subtle.importKey( - "pkcs8", - pemToBuffer(privateKeyPem), - { name: "Ed25519" }, - false, - ["sign"] - ); - const messageBuffer = new TextEncoder().encode(message); const signatureBuffer = await webcrypto.subtle.sign( { name: "Ed25519" }, diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 1653ab4..3df60ff 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -130,14 +130,17 @@ enforce registry-side issuance rules. Certificate issuance is best done via CLI to keep private keys secure: ```bash -# Using the private key downloaded when creating the agent +# Using the JWK JSON file downloaded when creating the agent in the portal +oba-bot cert issue --agent-id --private-key-path ./agent--private-key.json --token + +# Or using a PEM file (from Setup page or other tooling) oba-bot cert issue --agent-id --private-key-path /path/to/private-key.pem --token -# Or with OPENBOTAUTH_TOKEN env var -OPENBOTAUTH_TOKEN= oba-bot cert issue --agent-id --private-key-path /path/to/private-key.pem +# With OPENBOTAUTH_TOKEN env var +OPENBOTAUTH_TOKEN= oba-bot cert issue --agent-id --private-key-path ./agent--private-key.json ``` -The CLI generates the proof-of-possession signature automatically using the specified private key. +The CLI auto-detects the key format (JWK JSON or PEM) and generates the proof-of-possession signature. #### POST `/v1/certs/revoke` From 7f4c9f55e5a4811301b4f96107ff277e077e9721 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:02:06 +0500 Subject: [PATCH 22/49] Fix portal command filename and tighten JWK validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Portal: use full agent ID in private key filename example (was truncated with slice(0,8) which didnt match actual filename) - CLI: validate jwk.x field exists (not just d) - CLI: import minimal JWK object (kty, crv, x, d only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/pages/portal/AgentDetail.tsx | 4 ++-- packages/bot-cli/src/commands/cert.ts | 21 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index 9122bc4..5de6ab5 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -394,10 +394,10 @@ const AgentDetail = () => {

Certificate issuance requires proof-of-possession and must be done via CLI. - Use the private key file (agent-*-private-key.json) you downloaded when creating this agent: + Use the private key file you downloaded when creating this agent:

- oba-bot cert issue --agent-id {agent?.id ?? ""} --private-key-path ./agent-{agent?.id ? agent.id.slice(0, 8) : ""}...-private-key.json --token {""} + oba-bot cert issue --agent-id {agent?.id ?? ""} --private-key-path ./agent-{agent?.id ?? ""}-private-key.json --token {""}

Or set OPENBOTAUTH_TOKEN env var instead of --token diff --git a/packages/bot-cli/src/commands/cert.ts b/packages/bot-cli/src/commands/cert.ts index 1d3db3a..213537e 100644 --- a/packages/bot-cli/src/commands/cert.ts +++ b/packages/bot-cli/src/commands/cert.ts @@ -23,16 +23,29 @@ async function importPrivateKey(content: string): Promise { try { const jwk = JSON.parse(trimmed); - // Validate it's an Ed25519 private key JWK - if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.d) { + // Validate it's an Ed25519 private key JWK with required fields + if ( + jwk.kty !== "OKP" || + jwk.crv !== "Ed25519" || + typeof jwk.x !== "string" || + typeof jwk.d !== "string" + ) { throw new Error( - "Invalid JWK: must be an Ed25519 private key (kty=OKP, crv=Ed25519, d=...)" + "Invalid JWK: must be an Ed25519 private key (kty=OKP, crv=Ed25519, x=..., d=...)" ); } + // Import only the minimal required JWK fields (avoid passing extra fields) + const minimalJwk = { + kty: "OKP" as const, + crv: "Ed25519" as const, + x: jwk.x, + d: jwk.d, + }; + return await webcrypto.subtle.importKey( "jwk", - jwk, + minimalJwk, { name: "Ed25519" }, false, ["sign"] From 0920c8c25227516cb80808d5b384db62f4ad1bd1 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:07:51 +0500 Subject: [PATCH 23/49] Add missing public-status endpoint tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test valid=false for revoked certs (includes revoked_at, revoked_reason) - Test valid=false for expired certs (not_after in the past) - Test 404 when cert not found by fingerprint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/routes/__tests__/certs.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index c4cad20..b7de727 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -606,6 +606,75 @@ describe("GET /v1/certs/public-status", () => { expect(res.body.valid).toBe(false); expect(res.body.revoked).toBe(false); }); + + it("returns valid=false for revoked certs", async () => { + const now = Date.now(); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + not_before: new Date(now - 60_000).toISOString(), + not_after: new Date(now + 60_000).toISOString(), + revoked_at: new Date(now - 30_000).toISOString(), + revoked_reason: "key-compromise", + }, + ], + }); + const req = { + headers: {}, + query: { fingerprint_sha256: "c".repeat(64) }, + app: { locals: { db: mockDb(query) } }, + } as any; + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.revoked).toBe(true); + expect(res.body.revoked_at).toBeDefined(); + expect(res.body.revoked_reason).toBe("key-compromise"); + }); + + it("returns valid=false for expired certs", async () => { + const now = Date.now(); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + not_before: new Date(now - 7_200_000).toISOString(), + not_after: new Date(now - 3_600_000).toISOString(), // expired 1 hour ago + revoked_at: null, + revoked_reason: null, + }, + ], + }); + const req = { + headers: {}, + query: { fingerprint_sha256: "d".repeat(64) }, + app: { locals: { db: mockDb(query) } }, + } as any; + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.valid).toBe(false); + expect(res.body.revoked).toBe(false); + }); + + it("returns 404 when cert not found", async () => { + const query = vi.fn().mockResolvedValue({ rows: [] }); + const req = { + headers: {}, + query: { fingerprint_sha256: "e".repeat(64) }, + app: { locals: { db: mockDb(query) } }, + } as any; + const res = mockRes(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(404); + expect(res.body.error).toContain("not found"); + }); }); describe("GET /v1/certs/:serial", () => { From 160cc30b86c5ecccf0cd3588ec14a51559f4c413 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:14:11 +0500 Subject: [PATCH 24/49] Fix CA key-cert mismatch handling and trusted origin checks --- .../registry-service/src/utils/ca.test.ts | 60 ++++++++++++++++++- packages/registry-service/src/utils/ca.ts | 44 +++++++++++--- .../src/signature-verifier.test.ts | 24 +++++++- .../src/signature-verifier.ts | 44 ++++++++++++-- 4 files changed, 156 insertions(+), 16 deletions(-) diff --git a/packages/registry-service/src/utils/ca.test.ts b/packages/registry-service/src/utils/ca.test.ts index 68dc9dc..8eb585a 100644 --- a/packages/registry-service/src/utils/ca.test.ts +++ b/packages/registry-service/src/utils/ca.test.ts @@ -1,12 +1,20 @@ import * as crypto from "node:crypto"; +import { readFileSync, unlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, it, expect } from "vitest"; -import { issueCertificateForJwk } from "./ca.js"; +import { describe, it, expect, beforeEach } from "vitest"; +import { + issueCertificateForJwk, + resetCertificateAuthorityCacheForTests, +} from "./ca.js"; const { webcrypto, X509Certificate } = crypto; describe("issueCertificateForJwk", () => { + beforeEach(() => { + resetCertificateAuthorityCacheForTests(); + }); + it("encodes SAN URI when subjectAltUri is provided", async () => { const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}`); process.env.OBA_CA_MODE = "local"; @@ -63,4 +71,52 @@ describe("issueCertificateForJwk", () => { const cert = new X509Certificate(issued.certPem); expect(cert.subjectAltName ?? "").not.toContain("URI:"); }); + + it("regenerates CA cert when persisted key and cert are mismatched", async () => { + const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}-mismatch`); + const keyPath = join(baseDir, "ca.key.json"); + const certPath = join(baseDir, "ca.pem"); + process.env.OBA_CA_MODE = "local"; + process.env.OBA_CA_DIR = baseDir; + process.env.OBA_CA_KEY_PATH = keyPath; + process.env.OBA_CA_CERT_PATH = certPath; + + const keyPair = (await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + )) as any; + const publicJwk = await webcrypto.subtle.exportKey( + "jwk", + keyPair.publicKey, + ); + + await issueCertificateForJwk( + publicJwk as any, + "CN=OBA Test Leaf", + 1, + null, + ); + const originalCaPem = readFileSync(certPath, "utf-8").trim(); + + // Simulate partial state loss: key is removed while stale cert remains. + unlinkSync(keyPath); + resetCertificateAuthorityCacheForTests(); + + const secondIssued = await issueCertificateForJwk( + publicJwk as any, + "CN=OBA Test Leaf", + 1, + null, + ); + const regeneratedCaPem = readFileSync(certPath, "utf-8").trim(); + expect(regeneratedCaPem).not.toBe(originalCaPem); + + const certsInChain = + secondIssued.chainPem.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g, + ) ?? []; + expect(certsInChain).toHaveLength(2); + expect(certsInChain[1].trim()).toBe(regeneratedCaPem); + }); }); diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index 143b210..72d9980 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -2,7 +2,7 @@ * Local Certificate Authority helper (dev/MVP) */ -import { webcrypto, randomBytes, createHash } from "node:crypto"; +import { webcrypto, randomBytes, createHash, X509Certificate } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as x509 from "@peculiar/x509"; @@ -36,6 +36,11 @@ export interface IssuedCertificate { let cachedCa: CertificateAuthority | null = null; +// Test helper: allows deterministic reloading from disk across scenarios. +export function resetCertificateAuthorityCacheForTests(): void { + cachedCa = null; +} + function ensureCryptoProvider(): void { if (!(globalThis as any).crypto) { (globalThis as any).crypto = webcrypto as any; @@ -130,6 +135,17 @@ async function createSelfSignedCa( return { certPem: pem, certDer: der }; } +async function certMatchesPublicKey(certPem: string, publicKey: any): Promise { + try { + const cert = new X509Certificate(certPem); + const certSpki = cert.publicKey.export({ type: "spki", format: "der" }) as Buffer; + const keySpki = Buffer.from(await webcrypto.subtle.exportKey("spki", publicKey)); + return certSpki.equals(keySpki); + } catch { + return false; + } +} + async function loadOrCreateCa(): Promise { const mode = process.env.OBA_CA_MODE || "local"; if (mode !== "local") { @@ -185,13 +201,25 @@ async function loadOrCreateCa(): Promise { let certDer: Buffer; if (existsSync(certPath)) { certPem = readFileSync(certPath, "utf-8"); - certDer = Buffer.from( - certPem - .replace(/-----BEGIN CERTIFICATE-----/g, "") - .replace(/-----END CERTIFICATE-----/g, "") - .replace(/\s+/g, ""), - "base64", - ); + const certIsMatching = await certMatchesPublicKey(certPem, publicKey); + if (!certIsMatching) { + const regenerated = await createSelfSignedCa( + { publicKey, privateKey }, + subject, + validDays, + ); + certPem = regenerated.certPem; + certDer = regenerated.certDer; + writeFileSync(certPath, certPem, { mode: 0o644 }); + } else { + certDer = Buffer.from( + certPem + .replace(/-----BEGIN CERTIFICATE-----/g, "") + .replace(/-----END CERTIFICATE-----/g, "") + .replace(/\s+/g, ""), + "base64", + ); + } } else { const cert = await createSelfSignedCa( { publicKey, privateKey }, diff --git a/packages/verifier-service/src/signature-verifier.test.ts b/packages/verifier-service/src/signature-verifier.test.ts index 99542fb..d928254 100644 --- a/packages/verifier-service/src/signature-verifier.test.ts +++ b/packages/verifier-service/src/signature-verifier.test.ts @@ -2,7 +2,7 @@ * Tests for SignatureVerifier - specifically trusted directory validation */ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { SignatureVerifier } from "./signature-verifier.js"; // Mock dependencies @@ -155,6 +155,24 @@ describe("SignatureVerifier - Trusted Directory Validation", () => { ).toBe(true); }); + it("should enforce scheme when trusted directory includes scheme", () => { + expect( + isTrustedDirectory( + ["https://openbotregistry.example.com"], + "http://openbotregistry.example.com/jwks.json" + ) + ).toBe(false); + }); + + it("should enforce default port when scheme is pinned", () => { + expect( + isTrustedDirectory( + ["https://openbotregistry.example.com"], + "https://openbotregistry.example.com:8443/jwks.json" + ) + ).toBe(false); + }); + it("should handle trusted directory with http:// prefix", () => { expect( isTrustedDirectory( @@ -192,6 +210,10 @@ describe("SignatureVerifier - Trusted Directory Validation", () => { isTrustedDirectory(trustedDirs, "https://localhost:8080/jwks.json") ).toBe(true); + expect( + isTrustedDirectory(trustedDirs, "https://localhost:9999/jwks.json") + ).toBe(false); + expect( isTrustedDirectory(trustedDirs, "https://attacker.com/jwks.json") ).toBe(false); diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index 2ed2f49..842c5b4 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -210,20 +210,47 @@ export class SignatureVerifier { try { const jwksUrlParsed = new URL(jwksUrl); const jwksHostname = jwksUrlParsed.hostname.toLowerCase(); + const jwksPort = this.getEffectivePort(jwksUrlParsed); return this.trustedDirectories.some(dir => { + const rawDir = dir.trim(); + if (!rawDir) return false; + try { - // Normalize the trusted directory - add scheme if missing - const normalizedDir = dir.includes('://') ? dir : `https://${dir}`; + const hasScheme = rawDir.includes('://'); + // Normalize trusted directory for URL parsing. + const normalizedDir = hasScheme ? rawDir : `https://${rawDir}`; const trustedUrl = new URL(normalizedDir); const trustedHostname = trustedUrl.hostname.toLowerCase(); + const trustedPort = this.getEffectivePort(trustedUrl); + const hasExplicitPort = trustedUrl.port.length > 0; // Exact match or subdomain match (e.g., api.example.com matches example.com) - return jwksHostname === trustedHostname || - jwksHostname.endsWith('.' + trustedHostname); + const hostnameMatches = + jwksHostname === trustedHostname || + jwksHostname.endsWith('.' + trustedHostname); + if (!hostnameMatches) return false; + + // If scheme is configured, enforce exact scheme match. + if (hasScheme && jwksUrlParsed.protocol !== trustedUrl.protocol) { + return false; + } + + // If a trusted entry specifies a port, enforce exact port match. + if (hasExplicitPort && jwksPort !== trustedPort) { + return false; + } + + // If scheme is configured without explicit port, treat it as origin pinning + // and enforce default/effective port for that scheme as well. + if (hasScheme && !hasExplicitPort && jwksPort !== trustedPort) { + return false; + } + + return true; } catch { // Treat as hostname pattern if URL parsing fails - const trustedHostname = dir.toLowerCase(); + const trustedHostname = rawDir.toLowerCase(); return jwksHostname === trustedHostname || jwksHostname.endsWith('.' + trustedHostname); } @@ -234,6 +261,13 @@ export class SignatureVerifier { } } + private getEffectivePort(url: URL): string { + if (url.port) return url.port; + if (url.protocol === 'https:') return '443'; + if (url.protocol === 'http:') return '80'; + return ''; + } + /** * Verify Ed25519 signature using Web Crypto API */ From 0d83ac3f9a13f37e1e8ec3b6ad2ba9bd57a9e583 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:31:06 +0500 Subject: [PATCH 25/49] Fix public-status route ordering to avoid shadowing --- .../src/routes/__tests__/certs.test.ts | 13 ++ packages/registry-service/src/routes/certs.ts | 115 +++++++++++------- 2 files changed, 87 insertions(+), 41 deletions(-) diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index b7de727..2ead7eb 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -497,6 +497,19 @@ describe("GET /v1/certs/status", () => { }); describe("GET /v1/certs/public-status", () => { + it("is registered before /v1/certs/:serial to avoid route shadowing", () => { + const routePaths = certsRouter.stack + .filter((layer: any) => Boolean(layer.route)) + .map((layer: any) => layer.route.path); + + const publicStatusIndex = routePaths.indexOf("/v1/certs/public-status"); + const serialIndex = routePaths.indexOf("/v1/certs/:serial"); + + expect(publicStatusIndex).toBeGreaterThanOrEqual(0); + expect(serialIndex).toBeGreaterThanOrEqual(0); + expect(publicStatusIndex).toBeLessThan(serialIndex); + }); + it("requires fingerprint_sha256 parameter", async () => { const req = mockReq({ query: {} }); const res = mockRes(); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index f0a639e..fa7598a 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -2,13 +2,37 @@ * Certificate issuance endpoints (MVP) */ -import { webcrypto } from "node:crypto"; +import { webcrypto, createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; import type { Database } from "@openbotauth/github-connector"; import { issueCertificateForJwk, getCertificateAuthority } from "../utils/ca.js"; import { requireScope } from "../middleware/scopes.js"; import { jwkThumbprint } from "../utils/jwk.js"; +/** + * Check if a PoP nonce has been used and mark it as used. + * Returns true if the nonce is new (not a replay), false if already used. + * Uses the check_pop_nonce Postgres function for atomic check-and-set. + */ +async function checkPopNonce(db: Database, message: string): Promise { + const hash = createHash("sha256").update(message).digest("hex"); + try { + const result = await db.getPool().query( + `SELECT check_pop_nonce($1, 300) AS is_new`, + [hash], + ); + return result.rows[0]?.is_new === true; + } catch (err: any) { + // If function doesn't exist (migration not run), allow the request + // but log a warning. This provides graceful degradation. + if (err.message?.includes("check_pop_nonce")) { + console.warn("PoP nonce check skipped: migration 009 not applied"); + return true; + } + throw err; + } +} + /** * Verify proof-of-possession signature. * The proof message format is: "cert-issue:{agent_id}:{timestamp}" @@ -188,6 +212,15 @@ certsRouter.post( return; } + // Check for replay attack: ensure this proof hasn't been used before + const isNewNonce = await checkPopNonce(db, proof.message); + if (!isNewNonce) { + res.status(403).json({ + error: "Proof-of-possession replay detected: this proof has already been used", + }); + return; + } + const resolvedKid = typeof pk.kid === "string" && pk.kid.length > 0 ? pk.kid @@ -463,46 +496,6 @@ certsRouter.get( }, ); -certsRouter.get( - "/v1/certs/:serial", - requireAuth, - requireScope("agents:read"), - async (req: Request, res: Response): Promise => { - try { - const db: Database = req.app.locals.db; - const serial = req.params.serial; - if (!serial) { - res.status(400).json({ error: "Missing serial" }); - return; - } - - const result = await db.getPool().query( - `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, - c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, - c.cert_pem, c.chain_pem, c.x5c, - (c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()) AS is_active - FROM agent_certificates c - JOIN agents a ON a.id = c.agent_id - WHERE a.user_id = $1 AND c.serial = $2 - ORDER BY c.created_at DESC - LIMIT 1`, - [req.session!.user.id, serial], - ); - - if (result.rows.length === 0) { - res.status(404).json({ error: "Certificate not found" }); - return; - } - - res.setHeader("Cache-Control", "no-store"); - res.json(result.rows[0]); - } catch (error: any) { - console.error("Certificate get error:", error); - res.status(500).json({ error: error.message || "Failed to fetch certificate" }); - } - }, -); - /** * Public certificate status endpoint for relying parties (e.g., ClawAuth). * No authentication required - allows external services to check revocation. @@ -572,6 +565,46 @@ certsRouter.get( }, ); +certsRouter.get( + "/v1/certs/:serial", + requireAuth, + requireScope("agents:read"), + async (req: Request, res: Response): Promise => { + try { + const db: Database = req.app.locals.db; + const serial = req.params.serial; + if (!serial) { + res.status(400).json({ error: "Missing serial" }); + return; + } + + const result = await db.getPool().query( + `SELECT c.id, c.agent_id, c.kid, c.serial, c.fingerprint_sha256, + c.not_before, c.not_after, c.revoked_at, c.revoked_reason, c.created_at, + c.cert_pem, c.chain_pem, c.x5c, + (c.revoked_at IS NULL AND c.not_before <= now() AND c.not_after > now()) AS is_active + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE a.user_id = $1 AND c.serial = $2 + ORDER BY c.created_at DESC + LIMIT 1`, + [req.session!.user.id, serial], + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: "Certificate not found" }); + return; + } + + res.setHeader("Cache-Control", "no-store"); + res.json(result.rows[0]); + } catch (error: any) { + console.error("Certificate get error:", error); + res.status(500).json({ error: error.message || "Failed to fetch certificate" }); + } + }, +); + certsRouter.get("/.well-known/ca.pem", async (_req, res: Response): Promise => { try { const ca = await getCertificateAuthority(); From 85d5531f438e8ba214dceb7b8b2d6fc1ad6cc0f4 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:34:20 +0500 Subject: [PATCH 26/49] Add PoP nonce deduplication for replay protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration 009 with pop_nonces table and check_pop_nonce function - Check nonce before issuing certificate (rejects replayed proofs) - Graceful degradation if migration not applied - Add test for replay detection - Document replay protection in README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- infra/neon/migrations/009_pop_nonces.sql | 34 +++++++++++ packages/registry-service/README.md | 2 + .../src/routes/__tests__/certs.test.ts | 57 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 infra/neon/migrations/009_pop_nonces.sql diff --git a/infra/neon/migrations/009_pop_nonces.sql b/infra/neon/migrations/009_pop_nonces.sql new file mode 100644 index 0000000..63ef84d --- /dev/null +++ b/infra/neon/migrations/009_pop_nonces.sql @@ -0,0 +1,34 @@ +-- PoP nonce tracking for replay prevention +-- Nonces are stored for 5 minutes to match the PoP timestamp window + +CREATE TABLE IF NOT EXISTS pop_nonces ( + hash TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Index for cleanup queries +CREATE INDEX IF NOT EXISTS idx_pop_nonces_expires_at ON pop_nonces (expires_at); + +-- Function to check and store a nonce atomically (returns true if nonce is new) +CREATE OR REPLACE FUNCTION check_pop_nonce(nonce_hash TEXT, ttl_seconds INT DEFAULT 300) +RETURNS BOOLEAN AS $$ +DECLARE + inserted BOOLEAN; +BEGIN + -- Clean up expired nonces (limit to avoid long locks) + DELETE FROM pop_nonces WHERE expires_at < now() AND ctid IN ( + SELECT ctid FROM pop_nonces WHERE expires_at < now() LIMIT 100 + ); + + -- Try to insert the nonce + INSERT INTO pop_nonces (hash, expires_at) + VALUES (nonce_hash, now() + (ttl_seconds || ' seconds')::INTERVAL) + ON CONFLICT (hash) DO NOTHING; + + -- Check if we inserted (FOUND is true if INSERT affected a row) + GET DIAGNOSTICS inserted = ROW_COUNT; + + RETURN inserted > 0; +END; +$$ LANGUAGE plpgsql; diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index 3df60ff..dce96b8 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -110,6 +110,8 @@ To prevent certificate issuance for keys you don't control, you must provide a s The timestamp must be within 5 minutes in the past (up to 30 seconds future drift is tolerated for clock skew). +**Replay Protection:** Each proof message can only be used once. The server tracks used proofs for 5 minutes to prevent replay attacks. Clients must generate a fresh timestamp for each issuance request. + Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf certificate as an informational hint. This value is user-supplied unless you enforce registry-side issuance rules. diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 2ead7eb..cede52e 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -396,6 +396,8 @@ describe("POST /v1/certs/issue - PoP validation", () => { }, ], }) + // Mock the nonce check (new nonce, not a replay) + .mockResolvedValueOnce({ rows: [{ is_new: true }] }) // Mock the INSERT query for certificate .mockResolvedValueOnce({ rows: [] }); @@ -414,6 +416,61 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.body.serial).toBe("test-serial-123"); expect(res.body.fingerprint_sha256).toBeDefined(); }); + + it("rejects replayed proof (same message used twice)", async () => { + // Generate a real Ed25519 keypair + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + ); + + const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + const agentId = "agent-replay-test"; + const ts = Math.floor(Date.now() / 1000); + const message = `cert-issue:${agentId}:${ts}`; + const messageBuffer = new TextEncoder().encode(message); + const signatureBuffer = await webcrypto.subtle.sign( + { name: "Ed25519" }, + keyPair.privateKey, + messageBuffer, + ); + const signature = Buffer.from(signatureBuffer).toString("base64"); + + // Mock: agent query succeeds, nonce check returns false (already used) + const agentQuery = vi.fn() + .mockResolvedValueOnce({ + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }) + // Nonce check returns false (replay) + .mockResolvedValueOnce({ rows: [{ is_new: false }] }); + + const req = mockReq({ + body: { + agent_id: agentId, + proof: { message, signature }, + }, + app: { locals: { db: mockDb(agentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("replay"); + }); }); describe("POST /v1/certs/revoke", () => { From 04b1da6ec32a2349b14335c0c88fb55acbe2895e Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:39:59 +0500 Subject: [PATCH 27/49] Fix bot-cli cert command TypeScript typings --- packages/bot-cli/src/commands/cert.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/bot-cli/src/commands/cert.ts b/packages/bot-cli/src/commands/cert.ts index 213537e..20b290c 100644 --- a/packages/bot-cli/src/commands/cert.ts +++ b/packages/bot-cli/src/commands/cert.ts @@ -11,11 +11,25 @@ import { KeyStorage } from "../key-storage.js"; const DEFAULT_REGISTRY_URL = process.env.OPENBOTAUTH_REGISTRY_URL || "https://registry.openbotauth.com"; +type NodeCryptoKey = webcrypto.CryptoKey; + +interface CertIssueErrorResponse { + error?: string; +} + +interface CertIssueResponse { + serial?: string; + fingerprint_sha256?: string; + not_before?: string; + not_after?: string; + cert_pem?: string; +} + /** * Import a private key from either JWK JSON or PEM format. * Detects format automatically based on content. */ -async function importPrivateKey(content: string): Promise { +async function importPrivateKey(content: string): Promise { const trimmed = content.trim(); // Check if it's JSON (JWK format from AddAgentModal) @@ -92,7 +106,7 @@ export async function certIssueCommand(options: { try { // Load private key - either from explicit path or from KeyStorage - let privateKey: CryptoKey; + let privateKey: NodeCryptoKey; if (options.privateKeyPath) { console.log(`Loading private key from: ${options.privateKeyPath}`); @@ -167,12 +181,14 @@ export async function certIssueCommand(options: { }); if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); + const error = (await response + .json() + .catch(() => ({ error: response.statusText }))) as CertIssueErrorResponse; console.error(`❌ Certificate issuance failed: ${error.error || response.statusText}`); process.exit(1); } - const result = await response.json(); + const result = (await response.json()) as CertIssueResponse; console.log("✅ Certificate issued successfully!\n"); console.log("Certificate details:"); From 7dfd26aefabee3b4ac9e0d6b382c6d45224337b2 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 05:47:40 +0500 Subject: [PATCH 28/49] Address GPT feedback round 2 for agent identity PR - Remove dead issueCert() from portal API (lacks PoP proof param) - Add oba_* field validation in agents-api.ts (type check, length limit) - Normalize fingerprint in /v1/certs/status (lowercase, 64 hex chars) - Fix timestamp drift comment to match code (+30s tolerance) - Update Node engine to >=20.0.0 in proxy and verifier-client - Add E2E signature verification tests (5 tests) - Fix portal UI cert status logic to check not_before (add "pending" state) --- apps/registry-portal/src/lib/api.ts | 28 -- .../src/pages/portal/AgentDetail.tsx | 10 +- packages/proxy/package.json | 2 +- .../src/routes/__tests__/certs.test.ts | 5 +- .../registry-service/src/routes/agents-api.ts | 29 +- packages/registry-service/src/routes/certs.ts | 12 +- packages/verifier-client/package.json | 2 +- .../src/__tests__/signature-e2e.test.ts | 326 ++++++++++++++++++ 8 files changed, 371 insertions(+), 43 deletions(-) create mode 100644 packages/verifier-service/src/__tests__/signature-e2e.test.ts diff --git a/apps/registry-portal/src/lib/api.ts b/apps/registry-portal/src/lib/api.ts index 4ea668e..7507b9e 100644 --- a/apps/registry-portal/src/lib/api.ts +++ b/apps/registry-portal/src/lib/api.ts @@ -92,16 +92,6 @@ export interface ListCertsResponse { offset: number; } -export interface IssueCertResponse { - serial: string; - not_before: string; - not_after: string; - fingerprint_sha256: string; - cert_pem: string; - chain_pem: string; - x5c: string[]; -} - // Radar types export interface RadarOverview { window: 'today' | '7d'; @@ -385,24 +375,6 @@ class RegistryAPI { return await response.json(); } - /** - * Issue a certificate for an agent key. - */ - async issueCert(data: { - agent_id?: string; - kid?: string; - }): Promise { - const response = await this.fetch('/v1/certs/issue', { - method: 'POST', - body: JSON.stringify(data), - }); - if (!response.ok) { - const error = await response.json().catch(() => ({ error: response.statusText })); - throw new Error(error.error || 'Failed to issue certificate'); - } - return await response.json(); - } - /** * Revoke one or more certificates by serial or kid. */ diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index 5de6ab5..8c8bd58 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -50,9 +50,11 @@ const AgentDetail = () => { return `${value.slice(0, prefix)}...${value.slice(-suffix)}`; }; - const getCertificateStatus = (cert: AgentCertificate): "active" | "revoked" | "expired" => { + const getCertificateStatus = (cert: AgentCertificate): "active" | "revoked" | "expired" | "pending" => { if (cert.revoked_at) return "revoked"; - if (new Date(cert.not_after).getTime() <= Date.now()) return "expired"; + const now = Date.now(); + if (new Date(cert.not_before).getTime() > now) return "pending"; + if (new Date(cert.not_after).getTime() <= now) return "expired"; return "active"; }; @@ -450,7 +452,9 @@ const AgentDetail = () => { ? "default" : status === "revoked" ? "destructive" - : "secondary" + : status === "pending" + ? "outline" + : "secondary" } className="capitalize" > diff --git a/packages/proxy/package.json b/packages/proxy/package.json index db9c403..66a7027 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -65,7 +65,7 @@ "url": "https://github.com/OpenBotAuth/openbotauth/issues" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index cede52e..27af0b0 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -526,11 +526,12 @@ describe("GET /v1/certs/status", () => { it("returns valid=false when cert is not yet valid", async () => { const now = Date.now(); + const futureFingerprint = "a".repeat(64); const query = vi.fn().mockResolvedValue({ rows: [ { serial: "serial-future", - fingerprint_sha256: "fp-future", + fingerprint_sha256: futureFingerprint, not_before: new Date(now + 3_600_000).toISOString(), not_after: new Date(now + 7_200_000).toISOString(), revoked_at: null, @@ -539,7 +540,7 @@ describe("GET /v1/certs/status", () => { ], }); const req = mockReq({ - query: { fingerprint_sha256: "fp-future" }, + query: { fingerprint_sha256: futureFingerprint }, app: { locals: { db: mockDb(query) } }, }); const res = mockRes(); diff --git a/packages/registry-service/src/routes/agents-api.ts b/packages/registry-service/src/routes/agents-api.ts index a42b002..6e904f3 100644 --- a/packages/registry-service/src/routes/agents-api.ts +++ b/packages/registry-service/src/routes/agents-api.ts @@ -11,6 +11,18 @@ import { requireScope } from '../middleware/scopes.js'; export const agentsAPIRouter: Router = Router(); +/** + * Validate and sanitize oba_* fields. + * Returns null for invalid/missing values, trimmed string otherwise. + */ +function validateObaField(value: unknown, maxLen = 512): string | null { + if (value === undefined || value === null) return null; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > maxLen) return null; + return trimmed; +} + /** * Middleware to check authentication */ @@ -126,6 +138,11 @@ agentsAPIRouter.post( return; } + // Validate and sanitize oba_* fields + const validatedObaAgentId = validateObaField(oba_agent_id); + const validatedObaParentAgentId = validateObaField(oba_parent_agent_id); + const validatedObaPrincipal = validateObaField(oba_principal); + const result = await db.getPool().query( `INSERT INTO agents (user_id, name, description, agent_type, public_key, oba_agent_id, oba_parent_agent_id, oba_principal) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) @@ -138,9 +155,9 @@ agentsAPIRouter.post( description || null, agent_type, JSON.stringify(public_key), - oba_agent_id || null, - oba_parent_agent_id || null, - oba_principal || null, + validatedObaAgentId, + validatedObaParentAgentId, + validatedObaPrincipal, ] ); @@ -217,17 +234,17 @@ agentsAPIRouter.put( } if (oba_agent_id !== undefined) { updates.push(`oba_agent_id = $${paramIndex}`); - values.push(oba_agent_id || null); + values.push(validateObaField(oba_agent_id)); paramIndex++; } if (oba_parent_agent_id !== undefined) { updates.push(`oba_parent_agent_id = $${paramIndex}`); - values.push(oba_parent_agent_id || null); + values.push(validateObaField(oba_parent_agent_id)); paramIndex++; } if (oba_principal !== undefined) { updates.push(`oba_principal = $${paramIndex}`); - values.push(oba_principal || null); + values.push(validateObaField(oba_principal)); paramIndex++; } diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index fa7598a..053ef37 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -36,7 +36,7 @@ async function checkPopNonce(db: Database, message: string): Promise { /** * Verify proof-of-possession signature. * The proof message format is: "cert-issue:{agent_id}:{timestamp}" - * Timestamp must be within 5 minutes in the past (no future timestamps). + * Timestamp must be within 5 minutes in the past (30s future drift tolerated for clock skew). */ async function verifyProofOfPossession( proof: unknown, @@ -443,7 +443,7 @@ certsRouter.get( const fingerprint = typeof req.query.fingerprint_sha256 === "string" && req.query.fingerprint_sha256.length > 0 - ? req.query.fingerprint_sha256 + ? req.query.fingerprint_sha256.toLowerCase() : null; if ((!serial && !fingerprint) || (serial && fingerprint)) { @@ -453,6 +453,14 @@ certsRouter.get( return; } + // Validate fingerprint format if provided + if (fingerprint && !/^[a-f0-9]{64}$/.test(fingerprint)) { + res.status(400).json({ + error: "Invalid fingerprint_sha256: must be 64 hex characters", + }); + return; + } + const condition = serial ? "c.serial = $2" : "c.fingerprint_sha256 = $2"; const lookupValue = serial ?? fingerprint!; const result = await db.getPool().query( diff --git a/packages/verifier-client/package.json b/packages/verifier-client/package.json index 856844f..150f3a6 100644 --- a/packages/verifier-client/package.json +++ b/packages/verifier-client/package.json @@ -53,7 +53,7 @@ "url": "https://github.com/OpenBotAuth/openbotauth/issues" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/verifier-service/src/__tests__/signature-e2e.test.ts b/packages/verifier-service/src/__tests__/signature-e2e.test.ts new file mode 100644 index 0000000..a93e638 --- /dev/null +++ b/packages/verifier-service/src/__tests__/signature-e2e.test.ts @@ -0,0 +1,326 @@ +/** + * End-to-end tests for RFC 9421 signature verification + * + * These tests construct real signatures and verify them through + * SignatureVerifier.verify() to ensure the full pipeline works correctly. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { webcrypto } from "node:crypto"; +import { SignatureVerifier } from "../signature-verifier.js"; +import { buildSignatureBase, parseSignatureInput } from "../signature-parser.js"; + +// Mock JWKS cache that returns keys we control +function createMockJwksCache(publicJwk: any) { + return { + getKey: vi.fn().mockResolvedValue(publicJwk), + getJWKS: vi.fn().mockResolvedValue({ + keys: [publicJwk], + client_name: "test-agent", + }), + }; +} + +// Mock nonce manager that always accepts +function createMockNonceManager() { + return { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(true), + }; +} + +describe("E2E Signature Verification", () => { + let keyPair: CryptoKeyPair; + let publicJwk: any; + let privateJwk: any; + + beforeEach(async () => { + // Generate fresh Ed25519 keypair for each test + keyPair = await webcrypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]); + + // Export keys to JWK format + publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + publicJwk.kid = "test-key-e2e"; + publicJwk.use = "sig"; + + privateJwk = await webcrypto.subtle.exportKey("jwk", keyPair.privateKey); + }); + + it("verifies a real RFC 9421 signature end-to-end", async () => { + // 1. Set up test request + const method = "GET"; + const url = "https://example.com/api/resource?query=value"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + + // 2. Build Signature-Input header + const coveredHeaders = ["@method", "@path", "@authority"]; + const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const signatureInput = `sig1=${signatureParams}`; + + // 3. Parse signature input to get components + const components = parseSignatureInput(signatureInput); + expect(components).not.toBeNull(); + + // 4. Build the signature base + const requestHeaders: Record = {}; + const signatureBase = buildSignatureBase(components!, { + method, + url, + headers: requestHeaders, + }); + + // 5. Sign the signature base + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureB64 = Buffer.from(signatureBytes).toString("base64"); + const signatureHeader = `sig1=:${signatureB64}:`; + + // 6. Set up verifier with mocks + const jwksUrl = "https://trusted.example.com/jwks.json"; + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = createMockNonceManager(); + + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"], + 300 // maxSkewSec + ); + + // 7. Verify the signature + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": jwksUrl, + }, + }); + + // 8. Assert verification succeeded + expect(result.verified).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.agent?.kid).toBe(publicJwk.kid); + expect(result.agent?.client_name).toBe("test-agent"); + }); + + it("rejects signature with wrong key", async () => { + // Generate a different keypair for signing (simulates attacker) + const attackerKeyPair = await webcrypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ]); + + const method = "GET"; + const url = "https://example.com/api/resource"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + + const coveredHeaders = ["@method", "@path"]; + const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: {}, + }); + + // Sign with attacker's key + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + attackerKeyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureB64 = Buffer.from(signatureBytes).toString("base64"); + const signatureHeader = `sig1=:${signatureB64}:`; + + // Verifier has the legitimate public key + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = createMockNonceManager(); + + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"] + ); + + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": "https://trusted.example.com/jwks.json", + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("Signature verification failed"); + }); + + it("rejects tampered message", async () => { + const method = "GET"; + const url = "https://example.com/api/resource"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + + const coveredHeaders = ["@method", "@path"]; + const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + + // Sign the original URL + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: {}, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureB64 = Buffer.from(signatureBytes).toString("base64"); + const signatureHeader = `sig1=:${signatureB64}:`; + + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = createMockNonceManager(); + + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"] + ); + + // Verify with a DIFFERENT URL (tampered) + const result = await verifier.verify({ + method, + url: "https://example.com/api/TAMPERED", + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": "https://trusted.example.com/jwks.json", + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("Signature verification failed"); + }); + + it("verifies signature with content-type header", async () => { + const method = "POST"; + const url = "https://example.com/api/submit"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + + // Include content-type in covered headers + const coveredHeaders = ["@method", "@path", "@authority", "content-type"]; + const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const signatureInput = `sig1=${signatureParams}`; + + const requestHeaders: Record = { + "content-type": "application/json", + }; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: requestHeaders, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureB64 = Buffer.from(signatureBytes).toString("base64"); + const signatureHeader = `sig1=:${signatureB64}:`; + + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = createMockNonceManager(); + + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"] + ); + + const result = await verifier.verify({ + method, + url, + headers: { + ...requestHeaders, + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": "https://trusted.example.com/jwks.json", + }, + }); + + expect(result.verified).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("rejects replay when nonce check fails", async () => { + const method = "GET"; + const url = "https://example.com/api/resource"; + const created = Math.floor(Date.now() / 1000); + const nonce = "replayed-nonce"; + + const coveredHeaders = ["@method", "@path"]; + const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: {}, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureB64 = Buffer.from(signatureBytes).toString("base64"); + const signatureHeader = `sig1=:${signatureB64}:`; + + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(false), // Nonce already used + }; + + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"] + ); + + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": "https://trusted.example.com/jwks.json", + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("replay"); + }); +}); From 891bd1c5e97c343a4438e6bdd6166b10bd978301 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 17:29:12 +0500 Subject: [PATCH 29/49] Fix PoP nonce migration ROW_COUNT type --- infra/neon/migrations/009_pop_nonces.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/neon/migrations/009_pop_nonces.sql b/infra/neon/migrations/009_pop_nonces.sql index 63ef84d..3207e7c 100644 --- a/infra/neon/migrations/009_pop_nonces.sql +++ b/infra/neon/migrations/009_pop_nonces.sql @@ -14,7 +14,7 @@ CREATE INDEX IF NOT EXISTS idx_pop_nonces_expires_at ON pop_nonces (expires_at); CREATE OR REPLACE FUNCTION check_pop_nonce(nonce_hash TEXT, ttl_seconds INT DEFAULT 300) RETURNS BOOLEAN AS $$ DECLARE - inserted BOOLEAN; + inserted_count INTEGER; BEGIN -- Clean up expired nonces (limit to avoid long locks) DELETE FROM pop_nonces WHERE expires_at < now() AND ctid IN ( @@ -27,8 +27,8 @@ BEGIN ON CONFLICT (hash) DO NOTHING; -- Check if we inserted (FOUND is true if INSERT affected a row) - GET DIAGNOSTICS inserted = ROW_COUNT; + GET DIAGNOSTICS inserted_count = ROW_COUNT; - RETURN inserted > 0; + RETURN inserted_count > 0; END; $$ LANGUAGE plpgsql; From 4d1554bc03ed7dffbaf28b403ca70e0721fa6dc4 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 17:42:02 +0500 Subject: [PATCH 30/49] Harden x509 chain checks and x5u body limits --- .../__fixtures__/x509/intermediate-not-ca.pem | 10 +++ .../x509/leaf-via-nonca-intermediate.pem | 10 +++ .../src/__fixtures__/x509/root-nonca-test.pem | 9 +++ packages/verifier-service/src/x509.test.ts | 69 ++++++++++++++++++- packages/verifier-service/src/x509.ts | 49 ++++++++++++- 5 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 packages/verifier-service/src/__fixtures__/x509/intermediate-not-ca.pem create mode 100644 packages/verifier-service/src/__fixtures__/x509/leaf-via-nonca-intermediate.pem create mode 100644 packages/verifier-service/src/__fixtures__/x509/root-nonca-test.pem diff --git a/packages/verifier-service/src/__fixtures__/x509/intermediate-not-ca.pem b/packages/verifier-service/src/__fixtures__/x509/intermediate-not-ca.pem new file mode 100644 index 0000000..c77cede --- /dev/null +++ b/packages/verifier-service/src/__fixtures__/x509/intermediate-not-ca.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBVzCCAQmgAwIBAgIUd/mRcknD18GBPMMT19axZhjx5JwwBQYDK2VwMBcxFTAT +BgNVBAMMDFRlc3QgUm9vdCBDQTAeFw0yNjAyMjMxMjQwMDNaFw0yNzAyMjMxMjQw +MDNaMB4xHDAaBgNVBAMME05vbi1DQSBJbnRlcm1lZGlhdGUwKjAFBgMrZXADIQAT +MkTdzr1zMFlu/T4vlV79Ti9KcHqP4Un1nhEiS5Pnt6NgMF4wDAYDVR0TAQH/BAIw +ADAOBgNVHQ8BAf8EBAMCB4AwHQYDVR0OBBYEFBtCgxxvhGpzC9g21WfX2xplg7RL +MB8GA1UdIwQYMBaAFPMQWH02JFbLRTbIvydzNpGEJjTJMAUGAytlcANBAOztEehQ +qECK5ulb2H8mA9bpreDkP088yOEC7N1mndeItz5y2SsEMTt9V2ts0J+8EkEFn9+W +6YEJyehTICsvDgg= +-----END CERTIFICATE----- diff --git a/packages/verifier-service/src/__fixtures__/x509/leaf-via-nonca-intermediate.pem b/packages/verifier-service/src/__fixtures__/x509/leaf-via-nonca-intermediate.pem new file mode 100644 index 0000000..749b9fb --- /dev/null +++ b/packages/verifier-service/src/__fixtures__/x509/leaf-via-nonca-intermediate.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE----- +MIIBWzCCAQ2gAwIBAgIUDBdK9PifRiG+rsd9pRPOo/a79kwwBQYDK2VwMB4xHDAa +BgNVBAMME05vbi1DQSBJbnRlcm1lZGlhdGUwHhcNMjYwMjIzMTI0MDAzWhcNMjcw +MjIzMTI0MDAzWjAbMRkwFwYDVQQDDBBMZWFmIFVuZGVyIE5vbkNBMCowBQYDK2Vw +AyEA3t99+nmHJgGuE4zPOMWwMW1uspGTkfo7555fVTTcnXKjYDBeMAwGA1UdEwEB +/wQCMAAwDgYDVR0PAQH/BAQDAgeAMB0GA1UdDgQWBBQdlFs1V2zim2VPI0LafQ06 +prM5gzAfBgNVHSMEGDAWgBQbQoMcb4RqcwvYNtVn19saZYO0SzAFBgMrZXADQQCv +AYeA7XDDNIHIHz8/iS9aiJ7llQso6AWON43wxHGF7+AZJrNhXUaUy/qHp46yriXi +vlkdt6lNns85uHTlPn8I +-----END CERTIFICATE----- diff --git a/packages/verifier-service/src/__fixtures__/x509/root-nonca-test.pem b/packages/verifier-service/src/__fixtures__/x509/root-nonca-test.pem new file mode 100644 index 0000000..9db635b --- /dev/null +++ b/packages/verifier-service/src/__fixtures__/x509/root-nonca-test.pem @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIBQjCB9aADAgECAhQZ5II7j8I+WEnVKgLd3vTrdsyP0TAFBgMrZXAwFzEVMBMG +A1UEAwwMVGVzdCBSb290IENBMB4XDTI2MDIyMzEyNDAwM1oXDTI3MDIyMzEyNDAw +M1owFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMCowBQYDK2VwAyEAfxGEet6RE7mh +gN8FuUUaXxs0VjogcDWotZvVVbpYsuajUzBRMB0GA1UdDgQWBBTzEFh9NiRWy0U2 +yL8nczaRhCY0yTAfBgNVHSMEGDAWgBTzEFh9NiRWy0U2yL8nczaRhCY0yTAPBgNV +HRMBAf8EBTADAQH/MAUGAytlcANBAFNa2O8rQWUyTwL3+MrP3RPZtUx3EU7INlIQ +hQyoavwq2J2i7hB9lJvnDXfj/GKu6ABB+be7ddU90ZxgNnCg+g4= +-----END CERTIFICATE----- diff --git a/packages/verifier-service/src/x509.test.ts b/packages/verifier-service/src/x509.test.ts index e86b3e0..80f344f 100644 --- a/packages/verifier-service/src/x509.test.ts +++ b/packages/verifier-service/src/x509.test.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { X509Certificate } from "node:crypto"; -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { validateJwkX509 } from "./x509.js"; const caPem = readFileSync( @@ -11,15 +11,36 @@ const leafPem = readFileSync( new URL("./__fixtures__/x509/leaf.pem", import.meta.url), "utf-8", ); +const nonCaRootPem = readFileSync( + new URL("./__fixtures__/x509/root-nonca-test.pem", import.meta.url), + "utf-8", +); +const nonCaIntermediatePem = readFileSync( + new URL("./__fixtures__/x509/intermediate-not-ca.pem", import.meta.url), + "utf-8", +); +const nonCaLeafPem = readFileSync( + new URL("./__fixtures__/x509/leaf-via-nonca-intermediate.pem", import.meta.url), + "utf-8", +); const caCert = new X509Certificate(caPem); const leafCert = new X509Certificate(leafPem); +const nonCaIntermediateCert = new X509Certificate(nonCaIntermediatePem); +const nonCaLeafCert = new X509Certificate(nonCaLeafPem); const leafDerBase64 = leafCert.raw.toString("base64"); const caDerBase64 = caCert.raw.toString("base64"); +const nonCaIntermediateDerBase64 = nonCaIntermediateCert.raw.toString("base64"); +const nonCaLeafDerBase64 = nonCaLeafCert.raw.toString("base64"); const leafJwk = leafCert.publicKey.export({ format: "jwk" }); const caJwk = caCert.publicKey.export({ format: "jwk" }); +const nonCaLeafJwk = nonCaLeafCert.publicKey.export({ format: "jwk" }); + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("validateJwkX509", () => { it("validates a proper x5c chain", async () => { @@ -46,4 +67,50 @@ describe("validateJwkX509", () => { ); expect(result.valid).toBe(false); }); + + it("rejects chain when intermediate certificate is not a CA", async () => { + const result = await validateJwkX509( + { + ...nonCaLeafJwk, + x5c: [nonCaLeafDerBase64, nonCaIntermediateDerBase64], + }, + { trustAnchors: [nonCaRootPem] }, + ); + expect(result.valid).toBe(false); + expect(result.error).toContain("not authorized"); + }); + + it("rejects oversized x5u response without content-length", async () => { + const chunk = new Uint8Array(256 * 1024); + const stream = new ReadableStream({ + start(controller) { + // 5 chunks => 1.25 MB > 1 MB cap + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.enqueue(chunk); + controller.close(); + }, + }); + + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(stream, { + status: 200, + headers: { + "content-type": "application/pkix-cert", + }, + }), + ), + ); + + const result = await validateJwkX509( + { ...leafJwk, x5u: "https://example.com/cert.der" }, + { trustAnchors: [caPem] }, + ); + expect(result.valid).toBe(false); + expect(result.error).toContain("too large"); + }); }); diff --git a/packages/verifier-service/src/x509.ts b/packages/verifier-service/src/x509.ts index dea06ea..0a12755 100644 --- a/packages/verifier-service/src/x509.ts +++ b/packages/verifier-service/src/x509.ts @@ -14,6 +14,8 @@ export interface X509ValidationResult { error?: string; } +const MAX_X5U_CERT_BYTES = 1024 * 1024; + function parseCertificate(input: Buffer | string): X509Certificate { return new X509Certificate(input); } @@ -75,6 +77,9 @@ function validateChain( if (!cert.verify(issuer.publicKey)) { return "X.509 validation failed: certificate chain signature mismatch"; } + if (!cert.checkIssued(issuer)) { + return "X.509 validation failed: issuer certificate is not authorized to sign child certificates"; + } } // Verify last cert against trust anchors @@ -84,6 +89,9 @@ function validateChain( return null; } if (last.verify(anchor.publicKey)) { + if (!last.checkIssued(anchor)) { + return "X.509 validation failed: trust anchor is not authorized to sign child certificates"; + } return null; } } @@ -91,6 +99,43 @@ function validateChain( return "X.509 validation failed: chain does not terminate at a trusted anchor"; } +async function readResponseBufferLimited( + response: Response, + maxBytes: number, +): Promise { + if (!response.body) { + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > maxBytes) { + throw new Error("x5u certificate too large"); + } + return Buffer.from(arrayBuffer); + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let total = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + const chunk = Buffer.from(value); + total += chunk.length; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch { + // Ignore stream cancellation errors. + } + throw new Error("x5u certificate too large"); + } + chunks.push(chunk); + } + + return Buffer.concat(chunks, total); +} + async function fetchX5uCert(x5u: string): Promise { validateSafeUrl(x5u); @@ -113,11 +158,11 @@ async function fetchX5uCert(x5u: string): Promise { } const contentLength = response.headers.get("content-length"); - if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) { + if (contentLength && parseInt(contentLength, 10) > MAX_X5U_CERT_BYTES) { throw new Error("x5u certificate too large"); } - const buffer = Buffer.from(await response.arrayBuffer()); + const buffer = await readResponseBufferLimited(response, MAX_X5U_CERT_BYTES); if (isPem(buffer)) { return parseCertificate(buffer.toString("utf-8")); } From 82cd8b35207ce6f122a10bc44d35d54852eb54b2 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 17:57:29 +0500 Subject: [PATCH 31/49] fix: harden cert PoP, issuance limits, and x509 chain checks --- packages/registry-service/README.md | 16 +- .../src/routes/__tests__/certs.test.ts | 210 +++++++++++++++++- packages/registry-service/src/routes/certs.ts | 156 +++++++++++-- .../registry-service/src/utils/ca.test.ts | 33 +++ packages/registry-service/src/utils/ca.ts | 10 +- packages/verifier-service/src/x509.test.ts | 2 +- packages/verifier-service/src/x509.ts | 30 +++ 7 files changed, 433 insertions(+), 24 deletions(-) diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index dce96b8..c3b69a4 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -33,6 +33,8 @@ OBA_CA_CERT_PATH=./.local/ca/ca.pem OBA_CA_SUBJECT="CN=OpenBotAuth Dev CA" OBA_CA_VALID_DAYS=3650 OBA_LEAF_CERT_VALID_DAYS=90 +OBA_CERT_MAX_ISSUES_PER_AGENT_PER_DAY=10 +OBA_CERT_MAX_ACTIVE_PER_KID=1 ``` ## Development @@ -112,6 +114,10 @@ The timestamp must be within 5 minutes in the past (up to 30 seconds future drif **Replay Protection:** Each proof message can only be used once. The server tracks used proofs for 5 minutes to prevent replay attacks. Clients must generate a fresh timestamp for each issuance request. +**Issuance Limits:** +- Per-agent issuance rate limit (default: `10` certs / 24h, configurable via `OBA_CERT_MAX_ISSUES_PER_AGENT_PER_DAY`). +- Active cert cap per key `kid` (default: `1`, configurable via `OBA_CERT_MAX_ACTIVE_PER_KID`). + Note: if the agent has `oba_agent_id`, it is included as a SAN URI in the leaf certificate as an informational hint. This value is user-supplied unless you enforce registry-side issuance rules. @@ -144,6 +150,10 @@ OPENBOTAUTH_TOKEN= oba-bot cert issue --agent-id --private-key-path The CLI auto-detects the key format (JWK JSON or PEM) and generates the proof-of-possession signature. +CA topology note: current MVP uses a single self-signed online CA for signing +leaf certificates. Offline root + online intermediate separation is planned for +later phases. + #### POST `/v1/certs/revoke` Revoke an issued certificate. @@ -156,10 +166,14 @@ Auth: ```json { "serial": "hex-serial", - "reason": "key-rotation" + "reason": "key_compromise" } ``` +`reason` is optional; when provided it must be one of the RFC 5280 reason +identifiers (for example `key_compromise`, `cessation_of_operation`, +`superseded`). + #### GET `/v1/certs` List issued certificates owned by the authenticated user. diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 27af0b0..53fa5b1 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -398,6 +398,10 @@ describe("POST /v1/certs/issue - PoP validation", () => { }) // Mock the nonce check (new nonce, not a replay) .mockResolvedValueOnce({ rows: [{ is_new: true }] }) + // Daily issuance count + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) + // Active cert count for this kid + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) // Mock the INSERT query for certificate .mockResolvedValueOnce({ rows: [] }); @@ -471,11 +475,179 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.statusCode).toBe(403); expect(res.body.error).toContain("replay"); }); + + it("fails closed when replay-protection function is unavailable", async () => { + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + ); + const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + const agentId = "agent-migration-missing"; + const ts = Math.floor(Date.now() / 1000); + const message = `cert-issue:${agentId}:${ts}`; + const signatureBuffer = await webcrypto.subtle.sign( + { name: "Ed25519" }, + keyPair.privateKey, + new TextEncoder().encode(message), + ); + + const err = new Error("function check_pop_nonce(text, integer) does not exist") as Error & { + code?: string; + }; + err.code = "42883"; + + const agentQuery = vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }) + .mockRejectedValueOnce(err); + + const req = mockReq({ + body: { + agent_id: agentId, + proof: { + message, + signature: Buffer.from(signatureBuffer).toString("base64"), + }, + }, + app: { locals: { db: mockDb(agentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(403); + expect(res.body.error).toContain("replay protection unavailable"); + }); + + it("enforces per-agent daily issuance limit", async () => { + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + ); + const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + const agentId = "agent-rate-limit"; + const ts = Math.floor(Date.now() / 1000); + const message = `cert-issue:${agentId}:${ts}`; + const signature = Buffer.from( + await webcrypto.subtle.sign( + { name: "Ed25519" }, + keyPair.privateKey, + new TextEncoder().encode(message), + ), + ).toString("base64"); + + const agentQuery = vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ is_new: true }] }) + .mockResolvedValueOnce({ rows: [{ count: 10 }] }); + + const req = mockReq({ + body: { + agent_id: agentId, + proof: { message, signature }, + }, + app: { locals: { db: mockDb(agentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(429); + expect(res.body.error).toContain("Daily certificate issuance limit exceeded"); + }); + + it("enforces active certificate cap per key", async () => { + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + ); + const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + const agentId = "agent-active-cap"; + const ts = Math.floor(Date.now() / 1000); + const message = `cert-issue:${agentId}:${ts}`; + const signature = Buffer.from( + await webcrypto.subtle.sign( + { name: "Ed25519" }, + keyPair.privateKey, + new TextEncoder().encode(message), + ), + ).toString("base64"); + + const agentQuery = vi + .fn() + .mockResolvedValueOnce({ + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }) + .mockResolvedValueOnce({ rows: [{ is_new: true }] }) + .mockResolvedValueOnce({ rows: [{ count: 0 }] }) + .mockResolvedValueOnce({ rows: [{ count: 1 }] }); + + const req = mockReq({ + body: { + agent_id: agentId, + proof: { message, signature }, + }, + app: { locals: { db: mockDb(agentQuery) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(409); + expect(res.body.error).toContain("Active certificate limit reached"); + }); }); describe("POST /v1/certs/revoke", () => { it("only updates certs that are not already revoked", async () => { - const query = vi.fn().mockResolvedValue({ rows: [{ id: "c-1" }] }); + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 1, revocable: 1 }] }) + .mockResolvedValueOnce({ rows: [{ id: "c-1" }] }); const req = mockReq({ body: { serial: "serial-1", reason: "key-compromise" }, app: { locals: { db: mockDb(query) } }, @@ -485,8 +657,42 @@ describe("POST /v1/certs/revoke", () => { await callRoute(certsRouter, "POST", "/v1/certs/revoke", req, res); expect(res.statusCode).toBe(200); - const [sql] = query.mock.calls[0]; + expect(query).toHaveBeenCalledTimes(2); + const [sql] = query.mock.calls[1]; expect(sql).toContain("AND c.revoked_at IS NULL"); + const [, params] = query.mock.calls[1]; + expect(params[2]).toBe("key_compromise"); + }); + + it("returns already_revoked=true when matching certs are already revoked", async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 2, revocable: 0 }] }); + const req = mockReq({ + body: { kid: "kid-1" }, + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/revoke", req, res); + + expect(res.statusCode).toBe(200); + expect(res.body.already_revoked).toBe(true); + expect(res.body.revoked).toBe(0); + expect(query).toHaveBeenCalledTimes(1); + }); + + it("rejects unsupported revocation reason values", async () => { + const req = mockReq({ + body: { serial: "serial-1", reason: "because-i-feel-like-it" }, + app: { locals: { db: mockDb() } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/revoke", req, res); + + expect(res.statusCode).toBe(400); + expect(res.body.error).toContain("Invalid revocation reason"); }); }); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 053ef37..987710d 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -23,11 +23,12 @@ async function checkPopNonce(db: Database, message: string): Promise { ); return result.rows[0]?.is_new === true; } catch (err: any) { - // If function doesn't exist (migration not run), allow the request - // but log a warning. This provides graceful degradation. - if (err.message?.includes("check_pop_nonce")) { - console.warn("PoP nonce check skipped: migration 009 not applied"); - return true; + // Fail closed if migration 009 is missing. Replay protection is required. + if (err?.code === "42883" || err.message?.includes("check_pop_nonce")) { + console.error( + "PoP nonce check unavailable: migration 009 not applied or function missing", + ); + return false; } throw err; } @@ -162,6 +163,49 @@ function parsePositiveInt( return parsed; } +function readPositiveEnvInt(envName: string, fallback: number): number { + const raw = process.env[envName]; + if (!raw) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) { + return fallback; + } + return parsed; +} + +const REVOCATION_REASONS = new Set([ + "unspecified", + "key_compromise", + "ca_compromise", + "affiliation_changed", + "superseded", + "cessation_of_operation", + "certificate_hold", + "privilege_withdrawn", + "remove_from_crl", + "aa_compromise", +]); + +function parseRevocationReason(value: unknown): string | null | undefined { + if (value === undefined || value === null || value === "") { + return null; + } + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim().toLowerCase().replace(/-/g, "_"); + if (!normalized) { + return null; + } + if (normalized.length > 64) { + return undefined; + } + if (!REVOCATION_REASONS.has(normalized)) { + return undefined; + } + return normalized; +} + certsRouter.post( "/v1/certs/issue", requireAuth, @@ -216,7 +260,8 @@ certsRouter.post( const isNewNonce = await checkPopNonce(db, proof.message); if (!isNewNonce) { res.status(403).json({ - error: "Proof-of-possession replay detected: this proof has already been used", + error: + "Proof-of-possession replay detected or replay protection unavailable", }); return; } @@ -226,6 +271,50 @@ certsRouter.post( ? pk.kid : jwkThumbprint({ kty: "OKP", crv: "Ed25519", x: pk.x }); + const maxDailyIssues = readPositiveEnvInt( + "OBA_CERT_MAX_ISSUES_PER_AGENT_PER_DAY", + 10, + ); + const dailyIssueCountResult = await db.getPool().query( + `SELECT COUNT(*)::int AS count + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE c.agent_id = $1 + AND a.user_id = $2 + AND c.created_at >= now() - interval '1 day'`, + [agent.id, req.session!.user.id], + ); + const dailyIssueCount = Number(dailyIssueCountResult.rows[0]?.count ?? 0); + if (dailyIssueCount >= maxDailyIssues) { + res.status(429).json({ + error: `Daily certificate issuance limit exceeded (${maxDailyIssues} per 24h)`, + }); + return; + } + + const maxActivePerKid = readPositiveEnvInt( + "OBA_CERT_MAX_ACTIVE_PER_KID", + 1, + ); + const activeForKidResult = await db.getPool().query( + `SELECT COUNT(*)::int AS count + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE c.agent_id = $1 + AND a.user_id = $2 + AND c.kid = $3 + AND c.revoked_at IS NULL + AND c.not_after > now()`, + [agent.id, req.session!.user.id, resolvedKid], + ); + const activeForKidCount = Number(activeForKidResult.rows[0]?.count ?? 0); + if (activeForKidCount >= maxActivePerKid) { + res.status(409).json({ + error: `Active certificate limit reached for key ${resolvedKid}; revoke existing certificates before issuing a new one`, + }); + return; + } + const subjectCn = sanitizeDnValue( agent.name || "OpenBotAuth Agent", "OpenBotAuth Agent", @@ -281,7 +370,7 @@ certsRouter.post( }); } catch (error: any) { console.error("Certificate issuance error:", error); - res.status(500).json({ error: error.message || "Failed to issue certificate" }); + res.status(500).json({ error: "Failed to issue certificate" }); } }, ); @@ -294,11 +383,19 @@ certsRouter.post( try { const db: Database = req.app.locals.db; const { serial, kid, reason } = req.body || {}; + const revocationReason = parseRevocationReason(reason); if (!serial && !kid) { res.status(400).json({ error: "Missing required input: serial or kid" }); return; } + if (revocationReason === undefined) { + res.status(400).json({ + error: + "Invalid revocation reason. Use RFC 5280 reason names (e.g. key_compromise, cessation_of_operation).", + }); + return; + } const params: any[] = [req.session!.user.id]; let condition = ""; @@ -311,6 +408,31 @@ certsRouter.post( condition = "c.kid = $2"; } + const matchResult = await db.getPool().query( + `SELECT COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE c.revoked_at IS NULL)::int AS revocable + FROM agent_certificates c + JOIN agents a ON a.id = c.agent_id + WHERE a.user_id = $1 + AND ${condition}`, + params, + ); + const total = Number(matchResult.rows[0]?.total ?? 0); + const revocable = Number(matchResult.rows[0]?.revocable ?? 0); + if (total === 0) { + res.status(404).json({ error: "Certificate not found" }); + return; + } + if (revocable === 0) { + res.setHeader("Cache-Control", "no-store"); + res.json({ + success: true, + revoked: 0, + already_revoked: true, + }); + return; + } + const result = await db.getPool().query( `UPDATE agent_certificates c SET revoked_at = now(), revoked_reason = $3 @@ -320,19 +442,14 @@ certsRouter.post( AND ${condition} AND c.revoked_at IS NULL RETURNING c.id`, - [...params, reason || null], + [...params, revocationReason], ); - if (result.rows.length === 0) { - res.status(404).json({ error: "Certificate not found" }); - return; - } - res.setHeader("Cache-Control", "no-store"); res.json({ success: true, revoked: result.rows.length }); } catch (error: any) { console.error("Certificate revocation error:", error); - res.status(500).json({ error: error.message || "Failed to revoke certificate" }); + res.status(500).json({ error: "Failed to revoke certificate" }); } }, ); @@ -424,7 +541,7 @@ certsRouter.get( res.json({ items, total, limit, offset }); } catch (error: any) { console.error("Certificate list error:", error); - res.status(500).json({ error: error.message || "Failed to list certificates" }); + res.status(500).json({ error: "Failed to list certificates" }); } }, ); @@ -499,7 +616,7 @@ certsRouter.get( }); } catch (error: any) { console.error("Certificate status error:", error); - res.status(500).json({ error: error.message || "Failed to check certificate status" }); + res.status(500).json({ error: "Failed to check certificate status" }); } }, ); @@ -568,7 +685,7 @@ certsRouter.get( }); } catch (error: any) { console.error("Public certificate status error:", error); - res.status(500).json({ error: error.message || "Failed to check certificate status" }); + res.status(500).json({ error: "Failed to check certificate status" }); } }, ); @@ -608,7 +725,7 @@ certsRouter.get( res.json(result.rows[0]); } catch (error: any) { console.error("Certificate get error:", error); - res.status(500).json({ error: error.message || "Failed to fetch certificate" }); + res.status(500).json({ error: "Failed to fetch certificate" }); } }, ); @@ -620,6 +737,7 @@ certsRouter.get("/.well-known/ca.pem", async (_req, res: Response): Promise { expect(cert.subjectAltName ?? "").not.toContain("URI:"); }); + it("includes clientAuth EKU on issued leaf certificates", async () => { + const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}-eku`); + process.env.OBA_CA_MODE = "local"; + process.env.OBA_CA_DIR = baseDir; + process.env.OBA_CA_KEY_PATH = join(baseDir, "ca.key.json"); + process.env.OBA_CA_CERT_PATH = join(baseDir, "ca.pem"); + + const keyPair = (await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + )) as any; + const publicJwk = await webcrypto.subtle.exportKey( + "jwk", + keyPair.publicKey, + ); + + const issued = await issueCertificateForJwk( + publicJwk as any, + "CN=OBA Test Leaf", + 1, + null, + ); + + const cert = new (x509 as any).X509Certificate(issued.certPem); + const eku = cert.getExtension((x509 as any).ExtendedKeyUsageExtension); + expect(eku).toBeTruthy(); + expect(eku.usages).toContain( + (x509 as any).ExtendedKeyUsage.clientAuth || "1.3.6.1.5.5.7.3.2", + ); + }); + it("regenerates CA cert when persisted key and cert are mismatched", async () => { const baseDir = join(tmpdir(), `oba-ca-test-${Date.now()}-mismatch`); const keyPath = join(baseDir, "ca.key.json"); diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index 72d9980..2225877 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -12,8 +12,11 @@ const { PemConverter, BasicConstraintsExtension, KeyUsagesExtension, + ExtendedKeyUsageExtension, + ExtendedKeyUsage, SubjectAlternativeNameExtension, KeyUsageFlags, + URL: GENERAL_NAME_URL, } = x509 as any; export interface CertificateAuthority { @@ -277,9 +280,14 @@ export async function issueCertificateForJwk( extensions.push(new KeyUsagesExtension(usage, true)); } } + if (ExtendedKeyUsageExtension) { + const clientAuthUsage = + ExtendedKeyUsage?.clientAuth || "1.3.6.1.5.5.7.3.2"; + extensions.push(new ExtendedKeyUsageExtension([clientAuthUsage], false)); + } if (subjectAltUri && SubjectAlternativeNameExtension) { const sanType = - typeof (x509 as any).URL === "string" ? (x509 as any).URL : "url"; + typeof GENERAL_NAME_URL === "string" ? GENERAL_NAME_URL : "url"; extensions.push( new SubjectAlternativeNameExtension( [{ type: sanType, value: subjectAltUri }], diff --git a/packages/verifier-service/src/x509.test.ts b/packages/verifier-service/src/x509.test.ts index 80f344f..7a25fb3 100644 --- a/packages/verifier-service/src/x509.test.ts +++ b/packages/verifier-service/src/x509.test.ts @@ -77,7 +77,7 @@ describe("validateJwkX509", () => { { trustAnchors: [nonCaRootPem] }, ); expect(result.valid).toBe(false); - expect(result.error).toContain("not authorized"); + expect(result.error).toMatch(/not authorized|not a valid CA/); }); it("rejects oversized x5u response without content-length", async () => { diff --git a/packages/verifier-service/src/x509.ts b/packages/verifier-service/src/x509.ts index 0a12755..735edfe 100644 --- a/packages/verifier-service/src/x509.ts +++ b/packages/verifier-service/src/x509.ts @@ -59,6 +59,27 @@ function validateCertificateTimes(certs: X509Certificate[]): string | null { return null; } +function normalizeKeyUsage(value: string): string { + return value.toLowerCase().replace(/[^a-z]/g, ""); +} + +function certCanSignChildren(cert: X509Certificate): boolean { + if (!cert.ca) { + return false; + } + + // Some runtimes return undefined for keyUsage even when extension exists. + // Enforce keyCertSign when keyUsage is available, otherwise fall back to CA bit. + const usages = cert.keyUsage; + if (!Array.isArray(usages) || usages.length === 0) { + return true; + } + + return usages + .map((usage) => normalizeKeyUsage(usage)) + .some((usage) => usage === "keycertsign" || usage === "certificatesign"); +} + function validateChain( certs: X509Certificate[], trustAnchors: X509Certificate[], @@ -80,18 +101,27 @@ function validateChain( if (!cert.checkIssued(issuer)) { return "X.509 validation failed: issuer certificate is not authorized to sign child certificates"; } + if (!certCanSignChildren(issuer)) { + return "X.509 validation failed: intermediate certificate is not a valid CA"; + } } // Verify last cert against trust anchors const last = certs[certs.length - 1]; for (const anchor of trustAnchors) { if (anchor.fingerprint256 === last.fingerprint256) { + if (!certCanSignChildren(anchor)) { + return "X.509 validation failed: trust anchor is not a valid CA"; + } return null; } if (last.verify(anchor.publicKey)) { if (!last.checkIssued(anchor)) { return "X.509 validation failed: trust anchor is not authorized to sign child certificates"; } + if (!certCanSignChildren(anchor)) { + return "X.509 validation failed: trust anchor is not a valid CA"; + } return null; } } From 24d6b9908f7f702c5c85f10274fd893cb1f78b7b Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 18:54:40 +0500 Subject: [PATCH 32/49] fix: make cert issuance caps atomic and portal revoke valid --- .../src/pages/portal/AgentDetail.tsx | 2 +- .../src/routes/__tests__/certs.test.ts | 238 +++++++++++------- packages/registry-service/src/routes/certs.ts | 51 +++- 3 files changed, 189 insertions(+), 102 deletions(-) diff --git a/apps/registry-portal/src/pages/portal/AgentDetail.tsx b/apps/registry-portal/src/pages/portal/AgentDetail.tsx index 8c8bd58..be516f8 100644 --- a/apps/registry-portal/src/pages/portal/AgentDetail.tsx +++ b/apps/registry-portal/src/pages/portal/AgentDetail.tsx @@ -148,7 +148,7 @@ const AgentDetail = () => { setRevokingSerial(serial); try { - await api.revokeCert({ serial, reason: "manual-revoke" }); + await api.revokeCert({ serial, reason: "unspecified" }); toast({ title: "Certificate Revoked", description: `Serial ${shortText(serial)} was revoked`, diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 53fa5b1..2325ebb 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -16,9 +16,16 @@ vi.mock("../../utils/ca.js", () => ({ })); function mockDb(queryFn?: (...args: any[]) => any) { + const query = queryFn ?? vi.fn().mockResolvedValue({ rows: [] }); + const connect = vi.fn().mockResolvedValue({ + query, + release: vi.fn(), + }); + return { getPool: () => ({ - query: queryFn ?? vi.fn().mockResolvedValue({ rows: [] }), + query, + connect, }), }; } @@ -380,30 +387,40 @@ describe("POST /v1/certs/issue - PoP validation", () => { ); const signature = Buffer.from(signatureBuffer).toString("base64"); - // Mock agent query to return our generated public key - const agentQuery = vi.fn() - .mockResolvedValueOnce({ - rows: [ - { - id: agentId, - user_id: "u-1", - name: "Test Agent", - public_key: { - kty: "OKP", - crv: "Ed25519", - x: publicJwk.x, + const agentQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { + return { rows: [] }; + } + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, }, - }, - ], - }) - // Mock the nonce check (new nonce, not a replay) - .mockResolvedValueOnce({ rows: [{ is_new: true }] }) - // Daily issuance count - .mockResolvedValueOnce({ rows: [{ count: 0 }] }) - // Active cert count for this kid - .mockResolvedValueOnce({ rows: [{ count: 0 }] }) - // Mock the INSERT query for certificate - .mockResolvedValueOnce({ rows: [] }); + ], + }; + } + if (sql.includes("SELECT check_pop_nonce")) { + return { rows: [{ is_new: true }] }; + } + if (sql.includes("c.created_at >= now() - interval '1 day'")) { + return { rows: [{ count: 0 }] }; + } + if (sql.includes("AND c.kid = $3")) { + return { rows: [{ count: 0 }] }; + } + if (sql.includes("INSERT INTO agent_certificates")) { + return { rows: [] }; + } + return { rows: [] }; + }); const req = mockReq({ body: { @@ -442,24 +459,31 @@ describe("POST /v1/certs/issue - PoP validation", () => { ); const signature = Buffer.from(signatureBuffer).toString("base64"); - // Mock: agent query succeeds, nonce check returns false (already used) - const agentQuery = vi.fn() - .mockResolvedValueOnce({ - rows: [ - { - id: agentId, - user_id: "u-1", - name: "Test Agent", - public_key: { - kty: "OKP", - crv: "Ed25519", - x: publicJwk.x, + const agentQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { + return { rows: [] }; + } + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, }, - }, - ], - }) - // Nonce check returns false (replay) - .mockResolvedValueOnce({ rows: [{ is_new: false }] }); + ], + }; + } + if (sql.includes("SELECT check_pop_nonce")) { + return { rows: [{ is_new: false }] }; + } + return { rows: [] }; + }); const req = mockReq({ body: { @@ -498,23 +522,31 @@ describe("POST /v1/certs/issue - PoP validation", () => { }; err.code = "42883"; - const agentQuery = vi - .fn() - .mockResolvedValueOnce({ - rows: [ - { - id: agentId, - user_id: "u-1", - name: "Test Agent", - public_key: { - kty: "OKP", - crv: "Ed25519", - x: publicJwk.x, + const agentQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { + return { rows: [] }; + } + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, }, - }, - ], - }) - .mockRejectedValueOnce(err); + ], + }; + } + if (sql.includes("SELECT check_pop_nonce")) { + throw err; + } + return { rows: [] }; + }); const req = mockReq({ body: { @@ -553,24 +585,34 @@ describe("POST /v1/certs/issue - PoP validation", () => { ), ).toString("base64"); - const agentQuery = vi - .fn() - .mockResolvedValueOnce({ - rows: [ - { - id: agentId, - user_id: "u-1", - name: "Test Agent", - public_key: { - kty: "OKP", - crv: "Ed25519", - x: publicJwk.x, + const agentQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { + return { rows: [] }; + } + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, }, - }, - ], - }) - .mockResolvedValueOnce({ rows: [{ is_new: true }] }) - .mockResolvedValueOnce({ rows: [{ count: 10 }] }); + ], + }; + } + if (sql.includes("SELECT check_pop_nonce")) { + return { rows: [{ is_new: true }] }; + } + if (sql.includes("c.created_at >= now() - interval '1 day'")) { + return { rows: [{ count: 10 }] }; + } + return { rows: [] }; + }); const req = mockReq({ body: { @@ -606,25 +648,37 @@ describe("POST /v1/certs/issue - PoP validation", () => { ), ).toString("base64"); - const agentQuery = vi - .fn() - .mockResolvedValueOnce({ - rows: [ - { - id: agentId, - user_id: "u-1", - name: "Test Agent", - public_key: { - kty: "OKP", - crv: "Ed25519", - x: publicJwk.x, + const agentQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { + return { rows: [] }; + } + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, }, - }, - ], - }) - .mockResolvedValueOnce({ rows: [{ is_new: true }] }) - .mockResolvedValueOnce({ rows: [{ count: 0 }] }) - .mockResolvedValueOnce({ rows: [{ count: 1 }] }); + ], + }; + } + if (sql.includes("SELECT check_pop_nonce")) { + return { rows: [{ is_new: true }] }; + } + if (sql.includes("c.created_at >= now() - interval '1 day'")) { + return { rows: [{ count: 0 }] }; + } + if (sql.includes("AND c.kid = $3")) { + return { rows: [{ count: 1 }] }; + } + return { rows: [] }; + }); const req = mockReq({ body: { diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 987710d..2573911 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -14,10 +14,17 @@ import { jwkThumbprint } from "../utils/jwk.js"; * Returns true if the nonce is new (not a replay), false if already used. * Uses the check_pop_nonce Postgres function for atomic check-and-set. */ -async function checkPopNonce(db: Database, message: string): Promise { +interface SqlQueryExecutor { + query: (text: string, params?: unknown[]) => Promise<{ rows: any[] }>; +} + +async function checkPopNonce( + queryExecutor: SqlQueryExecutor, + message: string, +): Promise { const hash = createHash("sha256").update(message).digest("hex"); try { - const result = await db.getPool().query( + const result = await queryExecutor.query( `SELECT check_pop_nonce($1, 300) AS is_new`, [hash], ); @@ -211,8 +218,22 @@ certsRouter.post( requireAuth, requireScope("agents:write"), async (req: Request, res: Response): Promise => { + const db: Database = req.app.locals.db; + const client = await db.getPool().connect(); + let transactionOpen = false; + + const rollbackIfNeeded = async () => { + if (!transactionOpen) return; + try { + await client.query("ROLLBACK"); + } catch (rollbackError) { + console.error("Certificate issuance rollback error:", rollbackError); + } finally { + transactionOpen = false; + } + }; + try { - const db: Database = req.app.locals.db; const { agent_id } = req.body || {}; if (!agent_id) { @@ -222,13 +243,17 @@ certsRouter.post( return; } - const result = await db.getPool().query( - `SELECT * FROM agents WHERE id = $1 AND user_id = $2`, + await client.query("BEGIN"); + transactionOpen = true; + + const result = await client.query( + `SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE`, [agent_id, req.session!.user.id], ); const agent = result.rows[0] || null; if (!agent) { + await rollbackIfNeeded(); res.status(404).json({ error: "Agent not found" }); return; } @@ -257,8 +282,9 @@ certsRouter.post( } // Check for replay attack: ensure this proof hasn't been used before - const isNewNonce = await checkPopNonce(db, proof.message); + const isNewNonce = await checkPopNonce(client, proof.message); if (!isNewNonce) { + await rollbackIfNeeded(); res.status(403).json({ error: "Proof-of-possession replay detected or replay protection unavailable", @@ -275,7 +301,7 @@ certsRouter.post( "OBA_CERT_MAX_ISSUES_PER_AGENT_PER_DAY", 10, ); - const dailyIssueCountResult = await db.getPool().query( + const dailyIssueCountResult = await client.query( `SELECT COUNT(*)::int AS count FROM agent_certificates c JOIN agents a ON a.id = c.agent_id @@ -286,6 +312,7 @@ certsRouter.post( ); const dailyIssueCount = Number(dailyIssueCountResult.rows[0]?.count ?? 0); if (dailyIssueCount >= maxDailyIssues) { + await rollbackIfNeeded(); res.status(429).json({ error: `Daily certificate issuance limit exceeded (${maxDailyIssues} per 24h)`, }); @@ -296,7 +323,7 @@ certsRouter.post( "OBA_CERT_MAX_ACTIVE_PER_KID", 1, ); - const activeForKidResult = await db.getPool().query( + const activeForKidResult = await client.query( `SELECT COUNT(*)::int AS count FROM agent_certificates c JOIN agents a ON a.id = c.agent_id @@ -309,6 +336,7 @@ certsRouter.post( ); const activeForKidCount = Number(activeForKidResult.rows[0]?.count ?? 0); if (activeForKidCount >= maxActivePerKid) { + await rollbackIfNeeded(); res.status(409).json({ error: `Active certificate limit reached for key ${resolvedKid}; revoke existing certificates before issuing a new one`, }); @@ -341,7 +369,7 @@ certsRouter.post( : null, ); - await db.getPool().query( + await client.query( `INSERT INTO agent_certificates (agent_id, kid, serial, cert_pem, chain_pem, x5c, not_before, not_after, fingerprint_sha256) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, @@ -357,6 +385,8 @@ certsRouter.post( issued.fingerprintSha256, ], ); + await client.query("COMMIT"); + transactionOpen = false; res.setHeader("Cache-Control", "no-store"); res.json({ @@ -369,8 +399,11 @@ certsRouter.post( x5c: issued.x5c, }); } catch (error: any) { + await rollbackIfNeeded(); console.error("Certificate issuance error:", error); res.status(500).json({ error: "Failed to issue certificate" }); + } finally { + client.release(); } }, ); From 6fa49e03a05c127e9d357044d0ec4e0a78f79e80 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 19:06:51 +0500 Subject: [PATCH 33/49] feat: sign signature-agent and align directory media types --- packages/bot-cli/src/request-signer.test.ts | 2 + packages/bot-cli/src/request-signer.ts | 25 ++- packages/registry-service/src/routes/jwks.ts | 2 +- packages/verifier-service/README.md | 5 +- packages/verifier-service/src/jwks-cache.ts | 5 +- .../src/signature-parser.test.ts | 16 ++ .../verifier-service/src/signature-parser.ts | 195 ++++++++++++------ .../src/signature-verifier.ts | 2 +- 8 files changed, 182 insertions(+), 70 deletions(-) diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts index 658825c..4810b72 100644 --- a/packages/bot-cli/src/request-signer.test.ts +++ b/packages/bot-cli/src/request-signer.test.ts @@ -20,6 +20,7 @@ describe("RequestSigner", () => { expect(signed.headers["Signature-Agent"]).toBe( 'sig1="https://example.com/jwks/test.json"', ); + expect(signed.headers["Signature-Input"]).toContain('"signature-agent"'); }); it("emits legacy Signature-Agent when explicitly requested", async () => { @@ -30,5 +31,6 @@ describe("RequestSigner", () => { expect(signed.headers["Signature-Agent"]).toBe( "https://example.com/jwks/test.json", ); + expect(signed.headers["Signature-Input"]).toContain('"signature-agent"'); }); }); diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index c9b15f9..433d904 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -25,6 +25,10 @@ export class RequestSigner { options?.signatureAgentFormat || this.config.signature_agent_format || "dict"; + const signatureAgentValue = + signatureAgentFormat === "dict" + ? `${signatureLabel}="${this.config.jwks_url}"` + : this.config.jwks_url; // Generate signature parameters const params: SignatureParams = { @@ -33,7 +37,7 @@ export class RequestSigner { nonce: this.generateNonce(), keyId: this.config.kid, algorithm: 'ed25519', - headers: ['@method', '@path', '@authority'], + headers: ['@method', '@path', '@authority', 'signature-agent'], }; // Add content-type if there's a body @@ -48,17 +52,13 @@ export class RequestSigner { path: urlObj.pathname, authority: urlObj.host, contentType: body ? 'application/json' : undefined, + signatureAgent: signatureAgentValue, }); // Sign the base string const signature = await this.signString(signatureBase); // Build headers - const signatureAgentValue = - signatureAgentFormat === "dict" - ? `${signatureLabel}="${this.config.jwks_url}"` - : this.config.jwks_url; - const headers: Record = { 'Signature-Input': this.buildSignatureInput(params, signatureLabel), 'Signature': `${signatureLabel}=:${signature}:`, @@ -89,6 +89,7 @@ export class RequestSigner { path: string; authority: string; contentType?: string; + signatureAgent?: string; } ): string { const lines: string[] = []; @@ -109,8 +110,18 @@ export class RequestSigner { } } else { // Regular headers - if (component === 'content-type' && request.contentType) { + if (component === 'content-type') { + if (!request.contentType) { + throw new Error('Missing covered header: content-type'); + } lines.push(`"content-type": ${request.contentType}`); + continue; + } + if (component === 'signature-agent') { + if (!request.signatureAgent) { + throw new Error('Missing covered header: signature-agent'); + } + lines.push(`"signature-agent": ${request.signatureAgent}`); } } } diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index 948e8fe..5376e9a 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -194,7 +194,7 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise { ); expect(parsed?.label).toBe("sig-1.test"); }); + + it("should parse first member when multiple signature-input members are present", () => { + const parsed = parseSignatureInput( + 'sig1=("@method");created=1;keyid="k1";alg="ed25519", sig2=("@path");created=2;keyid="k2";alg="ed25519"', + ); + expect(parsed?.label).toBe("sig1"); + expect(parsed?.keyId).toBe("k1"); + }); }); describe("parseSignature", () => { @@ -257,6 +265,14 @@ describe("parseSignature", () => { const parsed = parseSignature("sig-1.test=:Zm9vYmFyOg==:"); expect(parsed).toBe("Zm9vYmFyOg=="); }); + + it("should select matching signature by label when multiple members are present", () => { + const parsed = parseSignature( + "sig1=:Zmlyc3Q=:, sig2=:c2Vjb25k:", + "sig2", + ); + expect(parsed).toBe("c2Vjb25k"); + }); }); describe("resolveJwksUrl", () => { diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index f330522..7c91822 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -11,6 +11,113 @@ const LABEL_CAPTURE_RE = new RegExp(`^(${LABEL_PATTERN})`); const SIGNATURE_INPUT_LABEL_RE = new RegExp(`^(${LABEL_PATTERN})=(.+)$`); const SIGNATURE_INPUT_RE = new RegExp(`^(${LABEL_PATTERN})=\\(([^)]+)\\);(.+)$`); const SIGNATURE_RE = new RegExp(`^(${LABEL_PATTERN})=:([^:]+):$`); +const DIRECTORY_ACCEPT_HEADER = + "application/http-message-signatures-directory+json, application/http-message-signatures-directory, application/jwk-set+json, application/json"; + +function splitTopLevelMembers(input: string): string[] { + const members: string[] = []; + let current = ""; + let inQuotes = false; + let escape = false; + let parenDepth = 0; + + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + + if (escape) { + current += ch; + escape = false; + continue; + } + + if (ch === "\\" && inQuotes) { + current += ch; + escape = true; + continue; + } + + if (ch === '"') { + inQuotes = !inQuotes; + current += ch; + continue; + } + + if (!inQuotes) { + if (ch === "(") { + parenDepth++; + } else if (ch === ")" && parenDepth > 0) { + parenDepth--; + } + } + + if (ch === "," && !inQuotes && parenDepth === 0) { + const trimmed = current.trim(); + if (trimmed) members.push(trimmed); + current = ""; + continue; + } + + current += ch; + } + + const finalMember = current.trim(); + if (finalMember) { + members.push(finalMember); + } + + return members; +} + +function parseSingleSignatureInputMember( + signatureInputMember: string, +): SignatureComponents | null { + const labelMatch = signatureInputMember.match(SIGNATURE_INPUT_LABEL_RE); + if (!labelMatch) { + return null; + } + + const label = labelMatch[1]; + const rawSignatureParams = labelMatch[2]; + const match = signatureInputMember.match(SIGNATURE_INPUT_RE); + if (!match) { + return null; + } + + const [, , headersList, paramsStr] = match; + + // Parse covered headers + const headers = headersList + .split(/\s+/) + .map((h) => h.replace(/"/g, "").trim()) + .filter(Boolean); + + // Parse parameters + const params: Record = {}; + const paramPairs = paramsStr.split(";"); + + for (const pair of paramPairs) { + const [key, value] = pair.split("=").map((s) => s.trim()); + if (key && value) { + // Remove quotes and parse numbers + const cleanValue = value.replace(/"/g, ""); + params[key] = isNaN(Number(cleanValue)) + ? cleanValue + : Number(cleanValue); + } + } + + return { + label, + keyId: params.keyid as string, + algorithm: (params.alg as string) || "ed25519", + created: params.created as number, + expires: params.expires as number, + nonce: params.nonce as string, + headers, + signature: "", // Will be filled from Signature header + rawSignatureParams, + }; +} /** * SSRF protection: validate that a URL is safe to fetch @@ -104,58 +211,15 @@ export function parseSignatureInput( signatureInput: string, ): SignatureComponents | null { try { - // Extract the signature label and the raw params (everything after "label=") - const labelMatch = signatureInput.match(SIGNATURE_INPUT_LABEL_RE); - if (!labelMatch) { - return null; - } - - const label = labelMatch[1]; - - // Store the raw signature params (everything after "sig1=") - // This is used verbatim in the signature base per RFC 9421 - const rawSignatureParams = labelMatch[2]; - - // Now parse for validation and component extraction - const match = signatureInput.match(SIGNATURE_INPUT_RE); - if (!match) { - return null; - } - - const [, , headersList, paramsStr] = match; - - // Parse covered headers - const headers = headersList - .split(/\s+/) - .map((h) => h.replace(/"/g, "").trim()) - .filter(Boolean); - - // Parse parameters - const params: Record = {}; - const paramPairs = paramsStr.split(";"); - - for (const pair of paramPairs) { - const [key, value] = pair.split("=").map((s) => s.trim()); - if (key && value) { - // Remove quotes and parse numbers - const cleanValue = value.replace(/"/g, ""); - params[key] = isNaN(Number(cleanValue)) - ? cleanValue - : Number(cleanValue); + // MVP behavior: when multiple labels are present, verify the first parsable member. + const members = splitTopLevelMembers(signatureInput); + for (const member of members) { + const parsed = parseSingleSignatureInputMember(member); + if (parsed) { + return parsed; } } - - return { - label, - keyId: params.keyid as string, - algorithm: (params.alg as string) || "ed25519", - created: params.created as number, - expires: params.expires as number, - nonce: params.nonce as string, - headers, - signature: "", // Will be filled from Signature header - rawSignatureParams, - }; + return null; } catch (error) { console.error("Error parsing Signature-Input:", error); return null; @@ -168,14 +232,30 @@ export function parseSignatureInput( * Example: * Signature: sig1=:K2qGT5srn2OGbOIDzQ6kYT+ruaycnDAAUpKv+ePFfD0RAxn/1BUeZx/Kdrq32DrfakQ6bPsvB9aqZqognNT6be4olHROIkeV879RrsrObury8L9SCEibeoHyqU/yCjphSmEdd7WD+zrchK57quskKwRefy2iEC5S2uAH0EPyOZKWlvbKmKu5q4CaB8X/I5/+HLZLGvDiezqi6/7p2Gngf5hwZ0lSdy39vyNMaaAT0tKo6nuVw0S1MVg1Q7MpWYZs0soHjttq0uLIA3DIbQfLiIvK6/l0BdWTU7+2uQj7lBkQAsFZHoA96ZZgFquQrXRlmYOh+Hx5D4m8eNqsKzeDQg==: */ -export function parseSignature(signature: string): string | null { +export function parseSignature( + signature: string, + expectedLabel?: string, +): string | null { try { - // Extract base64 signature from sig1=:...: - const match = signature.match(SIGNATURE_RE); - if (!match) { - return null; + const members = splitTopLevelMembers(signature); + let fallback: string | null = null; + + for (const member of members) { + const match = member.match(SIGNATURE_RE); + if (!match) { + continue; + } + const label = match[1]; + const value = match[2]; + if (!fallback) { + fallback = value; + } + if (!expectedLabel || label === expectedLabel) { + return value; + } } - return match[2]; + + return expectedLabel ? null : fallback; } catch (error) { console.error("Error parsing Signature:", error); return null; @@ -445,8 +525,7 @@ export async function resolveJwksUrl( const response = await fetch(candidateUrl, { method: "GET", headers: { - Accept: - "application/jwk-set+json, application/http-message-signatures-directory, application/json", + Accept: DIRECTORY_ACCEPT_HEADER, "User-Agent": "OpenBotAuth-Verifier/0.1.0", }, signal: AbortSignal.timeout(3000), // 3s timeout diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index 842c5b4..5879965 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -99,7 +99,7 @@ export class SignatureVerifier { } } - const signatureValue = parseSignature(signature); + const signatureValue = parseSignature(signature, components.label); if (!signatureValue) { return { verified: false, From d5b3894cd42fc35949b25ea9af978343cc93e7c5 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 19:42:03 +0500 Subject: [PATCH 34/49] feat: IETF draft conformance fixes for RFC 9421 signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add tag="web-bot-auth" to Signature-Input (MUST per IETF draft) - Add signature-agent;key="label" covered component format for dictionary member selection (RFC 8941) - Increase nonce from 16 to 64 bytes (RECOMMENDED per IETF draft) - Fix keyid to use full JWK thumbprint without truncation - Add relabeling support in verifier: parse ;key= parameter from covered components to select correct dictionary member 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/bot-cli/src/request-signer.ts | 27 +++++-- packages/bot-cli/src/types.ts | 1 + packages/registry-signer/src/jwks.ts | 33 ++++++--- .../verifier-service/src/signature-parser.ts | 70 ++++++++++++++++--- 4 files changed, 108 insertions(+), 23 deletions(-) diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index 433d904..da52144 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -37,7 +37,8 @@ export class RequestSigner { nonce: this.generateNonce(), keyId: this.config.kid, algorithm: 'ed25519', - headers: ['@method', '@path', '@authority', 'signature-agent'], + tag: 'web-bot-auth', + headers: ['@method', '@path', '@authority', `signature-agent;key="${signatureLabel}"`], }; // Add content-type if there's a body @@ -117,6 +118,20 @@ export class RequestSigner { lines.push(`"content-type": ${request.contentType}`); continue; } + // Handle signature-agent with ;key= parameter (RFC 8941 dictionary member selection) + const sigAgentMatch = component.match(/^signature-agent;key="([^"]+)"$/); + if (sigAgentMatch) { + if (!request.signatureAgent) { + throw new Error('Missing covered header: signature-agent'); + } + // Extract the value for the specific dictionary member key + // The signature base uses the raw URI value, not the dict format + const dictKey = sigAgentMatch[1]; + const dictMatch = request.signatureAgent.match(new RegExp(`${dictKey}="([^"]+)"`)); + const uriValue = dictMatch ? dictMatch[1] : request.signatureAgent; + lines.push(`"signature-agent";key="${dictKey}": ${uriValue}`); + continue; + } if (component === 'signature-agent') { if (!request.signatureAgent) { throw new Error('Missing covered header: signature-agent'); @@ -148,7 +163,11 @@ export class RequestSigner { label: string, ): string { const components = params.headers.map(h => `"${h}"`).join(' '); - return `${label}=(${components});created=${params.created};expires=${params.expires};nonce="${params.nonce}";keyid="${params.keyId}";alg="${params.algorithm}"`; + let input = `${label}=(${components});created=${params.created};expires=${params.expires};nonce="${params.nonce}";keyid="${params.keyId}";alg="${params.algorithm}"`; + if (params.tag) { + input += `;tag="${params.tag}"`; + } + return input; } /** @@ -197,10 +216,10 @@ export class RequestSigner { } /** - * Generate a random nonce + * Generate a random nonce (64 bytes per IETF draft recommendation) */ private generateNonce(): string { - const bytes = webcrypto.getRandomValues(new Uint8Array(16)); + const bytes = webcrypto.getRandomValues(new Uint8Array(64)); return Buffer.from(bytes).toString('base64url'); } } diff --git a/packages/bot-cli/src/types.ts b/packages/bot-cli/src/types.ts index a8ee576..4444ce5 100644 --- a/packages/bot-cli/src/types.ts +++ b/packages/bot-cli/src/types.ts @@ -38,5 +38,6 @@ export interface SignatureParams { nonce: string; keyId: string; algorithm: string; + tag?: string; headers: string[]; } diff --git a/packages/registry-signer/src/jwks.ts b/packages/registry-signer/src/jwks.ts index 3fcbe56..880f3e6 100644 --- a/packages/registry-signer/src/jwks.ts +++ b/packages/registry-signer/src/jwks.ts @@ -7,22 +7,39 @@ import { pemToBase64, base64ToBase64Url } from './keygen.js'; import type { JWK, JWKS, WebBotAuthJWKS } from './types.js'; /** - * Generate a key ID (kid) from a public key - * Uses SHA-256 hash of the key material + * Generate a key ID (kid) from a public key using RFC 7638 JWK Thumbprint. + * Returns the full base64url-encoded SHA-256 hash (no truncation). */ export function generateKid(publicKeyPem: string): string { + // Extract raw Ed25519 public key from SPKI PEM const base64 = pemToBase64(publicKeyPem); - const hash = createHash('sha256').update(base64).digest('base64'); - return base64ToBase64Url(hash).substring(0, 16); + const buffer = Buffer.from(base64, 'base64'); + + // Ed25519 SPKI format: 12 bytes header + 32 bytes raw key + let x: string; + if (buffer.length === 44) { + x = base64ToBase64Url(buffer.slice(12).toString('base64')); + } else if (buffer.length === 32) { + x = base64ToBase64Url(base64); + } else { + x = base64ToBase64Url(base64); + } + + // RFC 7638: canonical JSON with members in lexicographic order + const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x }); + const hash = createHash('sha256').update(canonical).digest('base64'); + return base64ToBase64Url(hash); } /** - * Generate a kid from JWK + * Generate a kid from JWK using RFC 7638 JWK Thumbprint. + * Returns the full base64url-encoded SHA-256 hash (no truncation). */ export function generateKidFromJWK(jwk: Partial): string { - const data = JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }); - const hash = createHash('sha256').update(data).digest('base64'); - return base64ToBase64Url(hash).substring(0, 16); + // RFC 7638: canonical JSON with members in lexicographic order (crv, kty, x for OKP) + const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }); + const hash = createHash('sha256').update(canonical).digest('base64'); + return base64ToBase64Url(hash); } /** diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index 7c91822..e8245cc 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -85,11 +85,26 @@ function parseSingleSignatureInputMember( const [, , headersList, paramsStr] = match; - // Parse covered headers - const headers = headersList - .split(/\s+/) - .map((h) => h.replace(/"/g, "").trim()) - .filter(Boolean); + // Parse covered headers (may include parameters like "signature-agent";key="sig1") + // RFC 9421: covered components can have parameters, e.g., "signature-agent";key="sig1" + const headers: string[] = []; + // Match quoted component name followed by optional parameters + const componentRegex = /"([^"]+)"(;[^"\s]+="[^"]*")*/g; + let componentMatch; + while ((componentMatch = componentRegex.exec(headersList)) !== null) { + // Capture the full component including parameters (e.g., signature-agent;key="sig1") + const componentName = componentMatch[1]; + const params = componentMatch[2] || ""; + headers.push(componentName + params); + } + // Fallback if regex didn't match (simple space-separated quoted strings) + if (headers.length === 0) { + const simpleHeaders = headersList + .split(/\s+/) + .map((h) => h.replace(/"/g, "").trim()) + .filter(Boolean); + headers.push(...simpleHeaders); + } // Parse parameters const params: Record = {}; @@ -262,6 +277,21 @@ export function parseSignature( } } +/** + * Extract dictionary key from component with ;key= parameter + * e.g., "signature-agent;key=\"sig1\"" -> { headerName: "signature-agent", dictKey: "sig1" } + */ +function parseComponentWithKeyParam(component: string): { + headerName: string; + dictKey: string | null; +} { + const keyMatch = component.match(/^([^;]+);key="([^"]+)"$/); + if (keyMatch) { + return { headerName: keyMatch[1], dictKey: keyMatch[2] }; + } + return { headerName: component, dictKey: null }; +} + /** * Build the signature base string according to RFC 9421 * @@ -304,13 +334,31 @@ export function buildSignatureBase( console.warn(`Unknown derived component: ${component}`); } } else { - // Regular headers - use raw value as-is per RFC 9421 - const headerValue = request.headers[component.toLowerCase()]; - if (headerValue !== undefined) { - lines.push(`"${component}": ${headerValue}`); + // Check for dictionary key selection parameter (RFC 9421 Section 2.1.2) + const { headerName, dictKey } = parseComponentWithKeyParam(component); + const headerValue = request.headers[headerName.toLowerCase()]; + + if (headerValue === undefined) { + throw new Error(`Missing covered header: ${headerName}`); + } + + if (dictKey) { + // Handle dictionary member selection (e.g., signature-agent;key="sig1") + // Extract the value for the specific dictionary key + const dict = parseStructuredDictionaryStringItems(headerValue); + const memberValue = dict[dictKey]; + + if (memberValue === undefined) { + throw new Error( + `Missing dictionary key "${dictKey}" in header "${headerName}"`, + ); + } + + // RFC 9421: component identifier includes the ;key= parameter + lines.push(`"${headerName}";key="${dictKey}": ${memberValue}`); } else { - // Header is in Signature-Input but not provided in request - throw new Error(`Missing covered header: ${component}`); + // Regular headers - use raw value as-is per RFC 9421 + lines.push(`"${headerName}": ${headerValue}`); } } } From 715e21f9062435ba17cb99dfaa72c0a20ae3c181 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 19:45:38 +0500 Subject: [PATCH 35/49] test: update bot-cli tests for IETF draft conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update test assertions to expect the new IETF-compliant format: - signature-agent;key="sig1" covered component format - tag="web-bot-auth" in Signature-Input 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/bot-cli/src/request-signer.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts index 4810b72..f59d098 100644 --- a/packages/bot-cli/src/request-signer.test.ts +++ b/packages/bot-cli/src/request-signer.test.ts @@ -20,7 +20,10 @@ describe("RequestSigner", () => { expect(signed.headers["Signature-Agent"]).toBe( 'sig1="https://example.com/jwks/test.json"', ); - expect(signed.headers["Signature-Input"]).toContain('"signature-agent"'); + // RFC 9421: covered component uses ;key= parameter for dictionary member selection + expect(signed.headers["Signature-Input"]).toContain('signature-agent;key="sig1"'); + // IETF draft: tag="web-bot-auth" is mandatory + expect(signed.headers["Signature-Input"]).toContain('tag="web-bot-auth"'); }); it("emits legacy Signature-Agent when explicitly requested", async () => { @@ -31,6 +34,9 @@ describe("RequestSigner", () => { expect(signed.headers["Signature-Agent"]).toBe( "https://example.com/jwks/test.json", ); - expect(signed.headers["Signature-Input"]).toContain('"signature-agent"'); + // Even with legacy format, the covered component uses ;key= for consistency + expect(signed.headers["Signature-Input"]).toContain('signature-agent;key="sig1"'); + // IETF draft: tag="web-bot-auth" is mandatory + expect(signed.headers["Signature-Input"]).toContain('tag="web-bot-auth"'); }); }); From f7b165e35b1c009d225fdf9372c28d4cc8c05443 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 20:00:28 +0500 Subject: [PATCH 36/49] fix: handle ;key= component parameters in covered headers parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update parse_covered_headers in all clients to handle the IETF draft signature-agent;key="label" format. When parsing covered components, extract only the base header name (before ;) for lookup purposes. Files updated: - packages/verifier-client/src/headers.ts - plugins/wordpress-openbotauth/includes/Verifier.php - sdks/python/src/openbotauth_verifier/headers.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/verifier-client/src/headers.ts | 19 +++++++++++++------ .../includes/Verifier.php | 19 +++++++++++++++---- .../src/openbotauth_verifier/headers.py | 15 ++++++++++++--- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/packages/verifier-client/src/headers.ts b/packages/verifier-client/src/headers.ts index 78cc25e..68d91e6 100644 --- a/packages/verifier-client/src/headers.ts +++ b/packages/verifier-client/src/headers.ts @@ -25,17 +25,18 @@ const SIGNATURE_AGENT_HEADER = 'signature-agent'; * Parse covered headers from Signature-Input header value. * * The Signature-Input header format (RFC 9421) is: - * sig1=("@method" "@target-uri" "content-type");created=... + * sig1=("@method" "@target-uri" "content-type" "signature-agent;key=\"sig1\"");created=... * * This extracts the list inside the first parentheses, splits by whitespace, - * and trims quotes from each component. + * trims quotes from each component, and extracts the base header name + * (removing any parameters like ;key="sig1"). * * @param signatureInput - The Signature-Input header value - * @returns Array of covered component/header names (lowercase, without quotes) + * @returns Array of covered component/header names (lowercase, base names without parameters) * * @example - * parseCoveredHeaders('sig1=("@method" "@target-uri" "content-type");created=1234') - * // Returns: ["@method", "@target-uri", "content-type"] + * parseCoveredHeaders('sig1=("@method" "@target-uri" "signature-agent;key=\\"sig1\\"");created=1234') + * // Returns: ["@method", "@target-uri", "signature-agent"] */ export function parseCoveredHeaders(signatureInput: string): string[] { // Find content between first ( and matching ) @@ -58,7 +59,13 @@ export function parseCoveredHeaders(signatureInput: string): string[] { .map((s) => { // Remove surrounding quotes if (s.startsWith('"') && s.endsWith('"')) { - return s.slice(1, -1).toLowerCase(); + s = s.slice(1, -1); + } + // Extract base header name before any ;key= parameter + // e.g., 'signature-agent;key="sig1"' -> 'signature-agent' + const semicolonPos = s.indexOf(';'); + if (semicolonPos !== -1) { + s = s.slice(0, semicolonPos); } return s.toLowerCase(); }); diff --git a/plugins/wordpress-openbotauth/includes/Verifier.php b/plugins/wordpress-openbotauth/includes/Verifier.php index 40e6768..1097ed2 100644 --- a/plugins/wordpress-openbotauth/includes/Verifier.php +++ b/plugins/wordpress-openbotauth/includes/Verifier.php @@ -268,11 +268,14 @@ private function get_signature_headers() { /** * Parse Signature-Input header to extract covered components. * - * Example: sig1=("@method" "@path" "content-type" "accept");created=... - * Returns: ["@method", "@path", "content-type", "accept"] + * Example: sig1=("@method" "@path" "content-type" "signature-agent;key=\"sig1\"");created=... + * Returns: ["@method", "@path", "content-type", "signature-agent"] + * + * Note: Components may have parameters like ;key="sig1" for dictionary member selection. + * We extract only the base header name for lookup purposes. * * @param string $signature_input The Signature-Input header value. - * @return array Array of covered header names. + * @return array Array of covered header names (base names without parameters). */ private function parse_covered_headers( $signature_input ) { // Extract the parenthesized list of headers. @@ -283,7 +286,15 @@ private function parse_covered_headers( $signature_input ) { $headers = preg_split( '/\s+/', $headers_str ); $headers = array_map( function ( $h ) { - return trim( $h, '"' ); + // Remove surrounding quotes. + $h = trim( $h, '"' ); + // Extract base header name before any ;key= parameter. + // e.g., 'signature-agent;key="sig1"' -> 'signature-agent' + $semicolon_pos = strpos( $h, ';' ); + if ( false !== $semicolon_pos ) { + $h = substr( $h, 0, $semicolon_pos ); + } + return $h; }, $headers ); diff --git a/sdks/python/src/openbotauth_verifier/headers.py b/sdks/python/src/openbotauth_verifier/headers.py index 0930090..b22fe28 100644 --- a/sdks/python/src/openbotauth_verifier/headers.py +++ b/sdks/python/src/openbotauth_verifier/headers.py @@ -29,19 +29,23 @@ def parse_covered_headers(signature_input: str) -> list[str]: Parse the covered headers from a Signature-Input header value. RFC 9421 Signature-Input format: - sig1=("@method" "@target-uri" "host" "content-type");created=...;keyid=... + sig1=("@method" "@target-uri" "host" "signature-agent;key=\\"sig1\\"");created=...;keyid=... - This extracts the identifiers inside the parentheses. + This extracts the identifiers inside the parentheses. Components may have + parameters like ;key="sig1" for dictionary member selection; we extract + only the base header name for lookup purposes. Args: signature_input: The Signature-Input header value Returns: - List of covered header/component names (lowercase, without quotes) + List of covered header/component names (lowercase, base names without parameters) Examples: >>> parse_covered_headers('sig1=("@method" "@target-uri" "host");created=123') ['@method', '@target-uri', 'host'] + >>> parse_covered_headers('sig=("signature-agent;key=\\"sig1\\"");created=123') + ['signature-agent'] >>> parse_covered_headers('sig=();created=123') [] """ @@ -61,6 +65,11 @@ def parse_covered_headers(signature_input: str) -> list[str]: # Remove surrounding quotes item = item.strip('"') if item: + # Extract base header name before any ;key= parameter + # e.g., 'signature-agent;key="sig1"' -> 'signature-agent' + semicolon_pos = item.find(';') + if semicolon_pos != -1: + item = item[:semicolon_pos] headers.append(item.lower()) return headers From da0f922751d001a0fa1bae2d2a926b1be10a89ea Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 20:14:10 +0500 Subject: [PATCH 37/49] feat: enforce WBA tag and signature-agent coverage semantics --- apps/registry-portal/public/skill.md | 2 +- .../src/pages/portal/Setup.tsx | 30 ++- packages/bot-cli/src/request-signer.test.ts | 4 +- packages/bot-cli/src/request-signer.ts | 19 +- packages/verifier-service/README.md | 4 + .../src/__tests__/signature-e2e.test.ts | 221 +++++++++++++++--- .../src/signature-parser.test.ts | 9 + .../verifier-service/src/signature-parser.ts | 20 +- .../src/signature-verifier.ts | 29 ++- packages/verifier-service/src/types.ts | 2 + 10 files changed, 298 insertions(+), 42 deletions(-) diff --git a/apps/registry-portal/public/skill.md b/apps/registry-portal/public/skill.md index e33dceb..26a7b8f 100644 --- a/apps/registry-portal/public/skill.md +++ b/apps/registry-portal/public/skill.md @@ -48,7 +48,7 @@ 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 kid = hash.toString('base64url'); // Save securely const dir = path.join(os.homedir(), '.config', 'openbotauth'); diff --git a/apps/registry-portal/src/pages/portal/Setup.tsx b/apps/registry-portal/src/pages/portal/Setup.tsx index 213c915..6ebb786 100644 --- a/apps/registry-portal/src/pages/portal/Setup.tsx +++ b/apps/registry-portal/src/pages/portal/Setup.tsx @@ -52,16 +52,36 @@ const Setup = () => { checkAuth(); }, [navigate]); - // Generate KID from public key (SHA-256 hash, first 16 chars) + // Generate KID from JWK thumbprint (RFC 7638 for OKP/Ed25519) const generateKid = async (publicKeyBase64: string): Promise => { + const toBase64Url = (base64: string): string => + base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + + const base64ToBytes = (base64: string): Uint8Array => { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; + }; + + // Extract raw Ed25519 public key bytes from SPKI DER when possible. + const spkiBytes = base64ToBytes(publicKeyBase64); + let rawKeyBase64 = publicKeyBase64; + if (spkiBytes.length === 44) { + const raw = spkiBytes.slice(12); + rawKeyBase64 = btoa(String.fromCharCode(...raw)); + } + + const x = toBase64Url(rawKeyBase64); + const canonical = JSON.stringify({ crv: "Ed25519", kty: "OKP", x }); const encoder = new TextEncoder(); - const data = encoder.encode(publicKeyBase64); + const data = encoder.encode(canonical); const hashBuffer = await window.crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const hashBase64 = btoa(String.fromCharCode(...hashArray)); - // Convert to base64url and take first 16 chars - const base64url = hashBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); - return base64url.substring(0, 16); + return toBase64Url(hashBase64); }; const generateKeyPair = async () => { diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts index f59d098..77979ac 100644 --- a/packages/bot-cli/src/request-signer.test.ts +++ b/packages/bot-cli/src/request-signer.test.ts @@ -21,7 +21,7 @@ describe("RequestSigner", () => { 'sig1="https://example.com/jwks/test.json"', ); // RFC 9421: covered component uses ;key= parameter for dictionary member selection - expect(signed.headers["Signature-Input"]).toContain('signature-agent;key="sig1"'); + expect(signed.headers["Signature-Input"]).toContain('"signature-agent";key="sig1"'); // IETF draft: tag="web-bot-auth" is mandatory expect(signed.headers["Signature-Input"]).toContain('tag="web-bot-auth"'); }); @@ -35,7 +35,7 @@ describe("RequestSigner", () => { "https://example.com/jwks/test.json", ); // Even with legacy format, the covered component uses ;key= for consistency - expect(signed.headers["Signature-Input"]).toContain('signature-agent;key="sig1"'); + expect(signed.headers["Signature-Input"]).toContain('"signature-agent";key="sig1"'); // IETF draft: tag="web-bot-auth" is mandatory expect(signed.headers["Signature-Input"]).toContain('tag="web-bot-auth"'); }); diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index da52144..56ba6f8 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -10,6 +10,16 @@ import type { BotConfig, SignedRequest, SignatureParams } from './types.js'; export class RequestSigner { constructor(private config: BotConfig) {} + private formatCoveredComponent(component: string): string { + const separatorIndex = component.indexOf(";"); + if (separatorIndex === -1) { + return `"${component}"`; + } + const componentName = component.slice(0, separatorIndex); + const componentParams = component.slice(separatorIndex + 1); + return `"${componentName}";${componentParams}`; + } + /** * Sign an HTTP request */ @@ -127,7 +137,8 @@ export class RequestSigner { // Extract the value for the specific dictionary member key // The signature base uses the raw URI value, not the dict format const dictKey = sigAgentMatch[1]; - const dictMatch = request.signatureAgent.match(new RegExp(`${dictKey}="([^"]+)"`)); + const escapedDictKey = dictKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const dictMatch = request.signatureAgent.match(new RegExp(`${escapedDictKey}="([^"]+)"`)); const uriValue = dictMatch ? dictMatch[1] : request.signatureAgent; lines.push(`"signature-agent";key="${dictKey}": ${uriValue}`); continue; @@ -143,7 +154,7 @@ export class RequestSigner { // Add signature parameters const paramParts: string[] = []; - paramParts.push(`(${params.headers.map(h => `"${h}"`).join(' ')})`); + paramParts.push(`(${params.headers.map((h) => this.formatCoveredComponent(h)).join(' ')})`); paramParts.push(`created=${params.created}`); paramParts.push(`expires=${params.expires}`); paramParts.push(`nonce="${params.nonce}"`); @@ -162,7 +173,9 @@ export class RequestSigner { params: SignatureParams, label: string, ): string { - const components = params.headers.map(h => `"${h}"`).join(' '); + const components = params.headers + .map((h) => this.formatCoveredComponent(h)) + .join(" "); let input = `${label}=(${components});created=${params.created};expires=${params.expires};nonce="${params.nonce}";keyid="${params.keyId}";alg="${params.algorithm}"`; if (params.tag) { input += `;tag="${params.tag}"`; diff --git a/packages/verifier-service/README.md b/packages/verifier-service/README.md index 4f636ff..b769fc6 100644 --- a/packages/verifier-service/README.md +++ b/packages/verifier-service/README.md @@ -71,6 +71,10 @@ Main verification endpoint for NGINX `auth_request`. - `Signature` - RFC 9421 signature - `Signature-Agent` - Structured Dictionary entry pointing to JWKS (legacy URL also accepted) +**WBA requirements enforced:** +- `Signature-Input` must include `tag="web-bot-auth"` +- `Signature-Agent` must be a covered component in `Signature-Input` (prefer `signature-agent;key="sigX"`) + **X.509 delegation notes:** - `x5c` chains are validated to configured trust anchors when `OBA_X509_ENABLED=true` - `x5u` currently fetches only the leaf certificate; without AIA chain building, validation succeeds only if the leaf chains directly to a trust anchor (or the anchor is an intermediate) diff --git a/packages/verifier-service/src/__tests__/signature-e2e.test.ts b/packages/verifier-service/src/__tests__/signature-e2e.test.ts index a93e638..d90cb36 100644 --- a/packages/verifier-service/src/__tests__/signature-e2e.test.ts +++ b/packages/verifier-service/src/__tests__/signature-e2e.test.ts @@ -29,24 +29,32 @@ function createMockNonceManager() { }; } +function formatCoveredComponent(component: string): string { + const separatorIndex = component.indexOf(";"); + if (separatorIndex === -1) { + return `"${component}"`; + } + const name = component.slice(0, separatorIndex); + const params = component.slice(separatorIndex + 1); + return `"${name}";${params}`; +} + describe("E2E Signature Verification", () => { - let keyPair: CryptoKeyPair; + let keyPair: webcrypto.CryptoKeyPair; let publicJwk: any; - let privateJwk: any; beforeEach(async () => { // Generate fresh Ed25519 keypair for each test - keyPair = await webcrypto.subtle.generateKey("Ed25519", true, [ + keyPair = (await webcrypto.subtle.generateKey("Ed25519", true, [ "sign", "verify", - ]); + ])) as webcrypto.CryptoKeyPair; // Export keys to JWK format publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); publicJwk.kid = "test-key-e2e"; publicJwk.use = "sig"; - privateJwk = await webcrypto.subtle.exportKey("jwk", keyPair.privateKey); }); it("verifies a real RFC 9421 signature end-to-end", async () => { @@ -55,10 +63,11 @@ describe("E2E Signature Verification", () => { const url = "https://example.com/api/resource?query=value"; const created = Math.floor(Date.now() / 1000); const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; // 2. Build Signature-Input header - const coveredHeaders = ["@method", "@path", "@authority"]; - const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; // 3. Parse signature input to get components @@ -66,7 +75,9 @@ describe("E2E Signature Verification", () => { expect(components).not.toBeNull(); // 4. Build the signature base - const requestHeaders: Record = {}; + const requestHeaders: Record = { + "signature-agent": signatureAgentHeader, + }; const signatureBase = buildSignatureBase(components!, { method, url, @@ -83,7 +94,6 @@ describe("E2E Signature Verification", () => { const signatureHeader = `sig1=:${signatureB64}:`; // 6. Set up verifier with mocks - const jwksUrl = "https://trusted.example.com/jwks.json"; const mockJwksCache = createMockJwksCache(publicJwk); const mockNonceManager = createMockNonceManager(); @@ -101,7 +111,7 @@ describe("E2E Signature Verification", () => { headers: { "signature-input": signatureInput, "signature": signatureHeader, - "signature-agent": jwksUrl, + "signature-agent": signatureAgentHeader, }, }); @@ -114,25 +124,29 @@ describe("E2E Signature Verification", () => { it("rejects signature with wrong key", async () => { // Generate a different keypair for signing (simulates attacker) - const attackerKeyPair = await webcrypto.subtle.generateKey("Ed25519", true, [ - "sign", - "verify", - ]); + const attackerKeyPair = (await webcrypto.subtle.generateKey( + "Ed25519", + true, + ["sign", "verify"], + )) as webcrypto.CryptoKeyPair; const method = "GET"; const url = "https://example.com/api/resource"; const created = Math.floor(Date.now() / 1000); const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path"]; - const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; const signatureBase = buildSignatureBase(components, { method, url, - headers: {}, + headers: { + "signature-agent": signatureAgentHeader, + }, }); // Sign with attacker's key @@ -160,7 +174,7 @@ describe("E2E Signature Verification", () => { headers: { "signature-input": signatureInput, "signature": signatureHeader, - "signature-agent": "https://trusted.example.com/jwks.json", + "signature-agent": signatureAgentHeader, }, }); @@ -168,14 +182,101 @@ describe("E2E Signature Verification", () => { expect(result.error).toContain("Signature verification failed"); }); + it("rejects signatures without web-bot-auth tag", async () => { + const method = "GET"; + const url = "https://example.com/api/no-tag"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: { + "signature-agent": signatureAgentHeader, + }, + }); + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureHeader = `sig1=:${Buffer.from(signatureBytes).toString("base64")}:`; + + const verifier = new SignatureVerifier( + createMockJwksCache(publicJwk) as any, + createMockNonceManager() as any, + ["trusted.example.com"] + ); + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain('tag="web-bot-auth"'); + }); + + it("rejects signatures that do not cover signature-agent", async () => { + const method = "GET"; + const url = "https://example.com/api/no-sig-agent-cover"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + const coveredHeaders = ["@method", "@path"]; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: {}, + }); + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureHeader = `sig1=:${Buffer.from(signatureBytes).toString("base64")}:`; + + const verifier = new SignatureVerifier( + createMockJwksCache(publicJwk) as any, + createMockNonceManager() as any, + ["trusted.example.com"] + ); + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain('"signature-agent" must be a covered component'); + }); + it("rejects tampered message", async () => { const method = "GET"; const url = "https://example.com/api/resource"; const created = Math.floor(Date.now() / 1000); const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path"]; - const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; @@ -184,7 +285,9 @@ describe("E2E Signature Verification", () => { const signatureBase = buildSignatureBase(components, { method, url, - headers: {}, + headers: { + "signature-agent": signatureAgentHeader, + }, }); const signatureBytes = await webcrypto.subtle.sign( @@ -211,7 +314,7 @@ describe("E2E Signature Verification", () => { headers: { "signature-input": signatureInput, "signature": signatureHeader, - "signature-agent": "https://trusted.example.com/jwks.json", + "signature-agent": signatureAgentHeader, }, }); @@ -224,14 +327,16 @@ describe("E2E Signature Verification", () => { const url = "https://example.com/api/submit"; const created = Math.floor(Date.now() / 1000); const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; // Include content-type in covered headers - const coveredHeaders = ["@method", "@path", "@authority", "content-type"]; - const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const coveredHeaders = ["@method", "@path", "@authority", "content-type", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const requestHeaders: Record = { "content-type": "application/json", + "signature-agent": signatureAgentHeader, }; const components = parseSignatureInput(signatureInput)!; @@ -265,7 +370,7 @@ describe("E2E Signature Verification", () => { ...requestHeaders, "signature-input": signatureInput, "signature": signatureHeader, - "signature-agent": "https://trusted.example.com/jwks.json", + "signature-agent": signatureAgentHeader, }, }); @@ -273,21 +378,79 @@ describe("E2E Signature Verification", () => { expect(result.error).toBeUndefined(); }); + it("supports relabeling by resolving Signature-Agent member from ;key parameter", async () => { + const method = "GET"; + const url = "https://example.com/api/relabel"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureLabel = "sig2"; + const signatureAgentHeader = + 'sig1="https://trusted.example.com/jwks.json", sig2="https://attacker.example/jwks.json"'; + const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureInput = `${signatureLabel}=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: { + "signature-agent": signatureAgentHeader, + }, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureB64 = Buffer.from(signatureBytes).toString("base64"); + const signatureHeader = `${signatureLabel}=:${signatureB64}:`; + + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = createMockNonceManager(); + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"] + ); + + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(true); + expect(result.error).toBeUndefined(); + expect(mockJwksCache.getKey).toHaveBeenCalledWith( + "https://trusted.example.com/jwks.json", + publicJwk.kid, + ); + }); + it("rejects replay when nonce check fails", async () => { const method = "GET"; const url = "https://example.com/api/resource"; const created = Math.floor(Date.now() / 1000); const nonce = "replayed-nonce"; + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path"]; - const signatureParams = `("${coveredHeaders.join('" "')}");created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; const signatureBase = buildSignatureBase(components, { method, url, - headers: {}, + headers: { + "signature-agent": signatureAgentHeader, + }, }); const signatureBytes = await webcrypto.subtle.sign( @@ -316,7 +479,7 @@ describe("E2E Signature Verification", () => { headers: { "signature-input": signatureInput, "signature": signatureHeader, - "signature-agent": "https://trusted.example.com/jwks.json", + "signature-agent": signatureAgentHeader, }, }); diff --git a/packages/verifier-service/src/signature-parser.test.ts b/packages/verifier-service/src/signature-parser.test.ts index 16024a8..f0b9723 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -258,6 +258,15 @@ describe("parseSignatureInput", () => { expect(parsed?.label).toBe("sig1"); expect(parsed?.keyId).toBe("k1"); }); + + it("should parse tag and signature-agent key parameter", () => { + const parsed = parseSignatureInput( + 'sig2=("@method" "signature-agent";key="sig1");created=1700000000;expires=1700000300;nonce="n";keyid="k2";alg="ed25519";tag="web-bot-auth"', + ); + expect(parsed?.label).toBe("sig2"); + expect(parsed?.tag).toBe("web-bot-auth"); + expect(parsed?.headers).toContain('signature-agent;key="sig1"'); + }); }); describe("parseSignature", () => { diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index e8245cc..93728b0 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -121,7 +121,7 @@ function parseSingleSignatureInputMember( } } - return { + const parsed: SignatureComponents = { label, keyId: params.keyid as string, algorithm: (params.alg as string) || "ed25519", @@ -132,6 +132,12 @@ function parseSingleSignatureInputMember( signature: "", // Will be filled from Signature header rawSignatureParams, }; + + if (typeof params.tag === "string" && params.tag.length > 0) { + parsed.tag = params.tag; + } + + return parsed; } /** @@ -292,6 +298,18 @@ function parseComponentWithKeyParam(component: string): { return { headerName: component, dictKey: null }; } +export function extractSignatureAgentDictionaryKey( + coveredComponents: string[], +): string | null { + for (const component of coveredComponents) { + const { headerName, dictKey } = parseComponentWithKeyParam(component); + if (headerName.toLowerCase() === "signature-agent" && dictKey) { + return dictKey; + } + } + return null; +} + /** * Build the signature base string according to RFC 9421 * diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index 5879965..c919a10 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -13,10 +13,18 @@ import { parseSignatureInput, parseSignature, parseSignatureAgent, + extractSignatureAgentDictionaryKey, resolveJwksUrl, buildSignatureBase, } from './signature-parser.js'; +function isSignatureAgentCovered(coveredComponents: string[]): boolean { + return coveredComponents.some((component) => + component === "signature-agent" || + component.startsWith("signature-agent;"), + ); +} + export class SignatureVerifier { private discoveryPaths: string[] | undefined; private x509Enabled: boolean; @@ -62,8 +70,27 @@ export class SignatureVerifier { }; } + // 2.1 Enforce WBA tag semantics. + if (components.tag !== "web-bot-auth") { + return { + verified: false, + error: + 'Invalid Signature-Input tag (expected tag="web-bot-auth")', + }; + } + + if (!isSignatureAgentCovered(components.headers)) { + return { + verified: false, + error: + 'Invalid Signature-Input: "signature-agent" must be a covered component', + }; + } + // 3. Parse Signature-Agent (Structured Dictionary or legacy URL) - const parsedAgent = parseSignatureAgent(signatureAgent, components.label); + const signatureAgentKey = + extractSignatureAgentDictionaryKey(components.headers) || components.label; + const parsedAgent = parseSignatureAgent(signatureAgent, signatureAgentKey); if (!parsedAgent) { return { verified: false, diff --git a/packages/verifier-service/src/types.ts b/packages/verifier-service/src/types.ts index e1f41c9..a401205 100644 --- a/packages/verifier-service/src/types.ts +++ b/packages/verifier-service/src/types.ts @@ -27,6 +27,8 @@ export interface SignatureComponents { keyId: string; signature: string; algorithm: string; + /** RFC 9421/WBA tag parameter from Signature-Input (e.g., web-bot-auth) */ + tag?: string; created?: number; expires?: number; nonce?: string; From 9e0723fe38a81e56e5c2650e37df2da16e9d9278 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 20:24:14 +0500 Subject: [PATCH 38/49] fix: rollback cert issue tx on early returns and legacy signing --- packages/bot-cli/src/request-signer.test.ts | 5 ++- packages/bot-cli/src/request-signer.ts | 9 +++- .../src/routes/__tests__/certs.test.ts | 42 +++++++++++++++++++ packages/registry-service/src/routes/certs.ts | 3 ++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts index 77979ac..26d0944 100644 --- a/packages/bot-cli/src/request-signer.test.ts +++ b/packages/bot-cli/src/request-signer.test.ts @@ -34,8 +34,9 @@ describe("RequestSigner", () => { expect(signed.headers["Signature-Agent"]).toBe( "https://example.com/jwks/test.json", ); - // Even with legacy format, the covered component uses ;key= for consistency - expect(signed.headers["Signature-Input"]).toContain('"signature-agent";key="sig1"'); + // Legacy mode covers the whole header field directly (no dictionary key selector) + expect(signed.headers["Signature-Input"]).toContain('"signature-agent"'); + expect(signed.headers["Signature-Input"]).not.toContain('";key='); // IETF draft: tag="web-bot-auth" is mandatory expect(signed.headers["Signature-Input"]).toContain('tag="web-bot-auth"'); }); diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index 56ba6f8..bc7a72f 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -48,7 +48,14 @@ export class RequestSigner { keyId: this.config.kid, algorithm: 'ed25519', tag: 'web-bot-auth', - headers: ['@method', '@path', '@authority', `signature-agent;key="${signatureLabel}"`], + headers: [ + '@method', + '@path', + '@authority', + signatureAgentFormat === "legacy" + ? "signature-agent" + : `signature-agent;key="${signatureLabel}"`, + ], }; // Add content-type if there's a body diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 2325ebb..1bc425f 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -268,6 +268,48 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.body.error).toContain("proof-of-possession"); }); + it("rolls back transaction when validation fails after BEGIN", async () => { + const query = vi.fn().mockImplementation(async (sql: string) => { + if (sql === "BEGIN" || sql === "ROLLBACK") { + return { rows: [] }; + } + if ( + sql.includes( + "SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE", + ) + ) { + return { + rows: [ + { + id: "agent-123", + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + }, + }, + ], + }; + } + return { rows: [] }; + }); + + const req = mockReq({ + body: { agent_id: "agent-123" }, // Missing proof on purpose + app: { locals: { db: mockDb(query) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(400); + expect(query).toHaveBeenCalledWith("BEGIN"); + expect(query).toHaveBeenCalledWith("ROLLBACK"); + expect(query).not.toHaveBeenCalledWith("COMMIT"); + }); + it("rejects invalid proof message format", async () => { const req = mockReq({ body: { diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 2573911..51dafce 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -260,6 +260,7 @@ certsRouter.post( const pk = agent.public_key; if (!pk || typeof pk !== "object" || typeof pk.x !== "string") { + await rollbackIfNeeded(); res.status(400).json({ error: "Agent public key is invalid" }); return; } @@ -267,6 +268,7 @@ certsRouter.post( // Verify proof-of-possession: caller must prove they have the private key const { proof } = req.body || {}; if (!proof) { + await rollbackIfNeeded(); res.status(400).json({ error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{agent_id}:{timestamp}', signature: '' }", }); @@ -275,6 +277,7 @@ certsRouter.post( const popResult = await verifyProofOfPossession(proof, agent.id, pk); if (!popResult.valid) { + await rollbackIfNeeded(); res.status(403).json({ error: `Proof-of-possession failed: ${popResult.error}`, }); From 9ecb3739d1af805ebff4531f07f3b3c5edb0ee4e Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 20:40:59 +0500 Subject: [PATCH 39/49] fix: parse parameterized covered components across clients --- README.md | 3 +- docs/BREAKING_CHANGES.md | 41 ++++++++++++++++++ packages/verifier-client/src/headers.test.ts | 7 +++ packages/verifier-client/src/headers.ts | 40 +++++++++++------ .../includes/Verifier.php | 43 +++++++++++++------ .../src/openbotauth_verifier/headers.py | 14 ++++-- sdks/python/tests/test_headers.py | 6 +++ 7 files changed, 122 insertions(+), 32 deletions(-) create mode 100644 docs/BREAKING_CHANGES.md diff --git a/README.md b/README.md index 2a184d5..d31d4c0 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,8 @@ openbotauth/ │ └─ neon/ ✅ Neon migrations └─ docs/ ├─ ARCHITECTURE.md ✅ System architecture - └─ A2A_CARD.md ✅ A2A discovery documentation + ├─ A2A_CARD.md ✅ A2A discovery documentation + └─ BREAKING_CHANGES.md ✅ Behavior changes by PR/release ``` --- diff --git a/docs/BREAKING_CHANGES.md b/docs/BREAKING_CHANGES.md new file mode 100644 index 0000000..99cd3aa --- /dev/null +++ b/docs/BREAKING_CHANGES.md @@ -0,0 +1,41 @@ +# Breaking Changes + +## PR #50 (Agent Identity / X.509 MVP) + +These changes are intentional and may break clients built against older OpenBotAuth behavior. + +### 1) `kid` / `keyid` format now uses full RFC 7638 thumbprint + +- Old behavior: truncated 16-character identifier in some paths. +- New behavior: full RFC 7638 SHA-256 JWK thumbprint (base64url). + +Impact: + +- Existing clients hardcoded to old short `kid` values will fail key lookup until re-provisioned. +- Any local configs/scripts that assume fixed 16-char `kid` length must be updated. + +Recommended migration: + +- Regenerate or re-export signer config so `keyid` matches the published JWKS `kid`. +- Re-sync integrations that persist `kid` externally. + +### 2) `Signature-Agent` is now signed and dictionary-aware by default + +- New default signer behavior emits RFC 8941 dictionary format: + - `Signature-Agent: sig1="https://.../.well-known/http-message-signatures-directory"` +- Covered component now includes dictionary key selector in dict mode: + - `"signature-agent";key="sig1"` +- Legacy mode is still supported, but is covered as plain `"signature-agent"` (no `;key=`). + +Impact: + +- Middlewares/parsers that incorrectly split parameterized covered components (for example `"signature-agent";key="sig1"`) will reject valid requests. + +### 3) `Signature-Input` now includes WBA tag + +- Signatures include `;tag="web-bot-auth"` to align with draft semantics. + +Impact: + +- Strict custom verifiers expecting old parameter sets may need updates. + diff --git a/packages/verifier-client/src/headers.test.ts b/packages/verifier-client/src/headers.test.ts index 50a84f8..810586a 100644 --- a/packages/verifier-client/src/headers.test.ts +++ b/packages/verifier-client/src/headers.test.ts @@ -53,6 +53,13 @@ describe('parseCoveredHeaders', () => { const result = parseCoveredHeaders(input); expect(result).toEqual(['content-type', 'accept']); }); + + it('parses parameterized covered components', () => { + const input = + 'sig1=("@method" "signature-agent";key="sig1" "content-type");created=1234'; + const result = parseCoveredHeaders(input); + expect(result).toEqual(['@method', 'signature-agent', 'content-type']); + }); }); describe('extractForwardedHeaders', () => { diff --git a/packages/verifier-client/src/headers.ts b/packages/verifier-client/src/headers.ts index 68d91e6..a17b62b 100644 --- a/packages/verifier-client/src/headers.ts +++ b/packages/verifier-client/src/headers.ts @@ -52,23 +52,35 @@ export function parseCoveredHeaders(signatureInput: string): string[] { const content = signatureInput.slice(openParen + 1, closeParen); - // Split by whitespace and trim quotes - return content - .split(/\s+/) - .filter((s) => s.length > 0) - .map((s) => { - // Remove surrounding quotes - if (s.startsWith('"') && s.endsWith('"')) { - s = s.slice(1, -1); + // Match tokens while keeping parameterized quoted components intact, e.g.: + // "signature-agent";key="sig1" + const tokens = + content.match(/"[^"\\]*(?:\\.[^"\\]*)*"(?:;[^\s]+)?|[^\s]+/g) ?? []; + + return tokens.map((token) => { + let component = token; + + if (token.startsWith('"')) { + // Extract the quoted component name before any ;param tail. + const quotedMatch = token.match(/^"((?:\\.|[^"\\])*)"/); + if (quotedMatch) { + component = quotedMatch[1].replace(/\\(["\\])/g, '$1'); } - // Extract base header name before any ;key= parameter - // e.g., 'signature-agent;key="sig1"' -> 'signature-agent' - const semicolonPos = s.indexOf(';'); + } else { + const semicolonPos = token.indexOf(';'); if (semicolonPos !== -1) { - s = s.slice(0, semicolonPos); + component = token.slice(0, semicolonPos); } - return s.toLowerCase(); - }); + } + + // Be defensive for malformed inputs where ';' may survive. + const semicolonPos = component.indexOf(';'); + if (semicolonPos !== -1) { + component = component.slice(0, semicolonPos); + } + + return component.toLowerCase(); + }); } /** diff --git a/plugins/wordpress-openbotauth/includes/Verifier.php b/plugins/wordpress-openbotauth/includes/Verifier.php index 1097ed2..6fd57ff 100644 --- a/plugins/wordpress-openbotauth/includes/Verifier.php +++ b/plugins/wordpress-openbotauth/includes/Verifier.php @@ -280,26 +280,43 @@ private function get_signature_headers() { private function parse_covered_headers( $signature_input ) { // Extract the parenthesized list of headers. if ( preg_match( '/\(([^)]+)\)/', $signature_input, $matches ) ) { - $headers_str = $matches[1]; + $headers_str = trim( $matches[1] ); + if ( '' === $headers_str ) { + return array(); + } - // Split by whitespace and remove quotes. - $headers = preg_split( '/\s+/', $headers_str ); + // Keep quoted components with optional parameter tails intact, e.g.: + // "signature-agent";key="sig1" + preg_match_all( '/"[^"]+"(?:;[^\s]+)?|[^\s]+/', $headers_str, $token_matches ); + $tokens = $token_matches[0]; $headers = array_map( - function ( $h ) { - // Remove surrounding quotes. - $h = trim( $h, '"' ); - // Extract base header name before any ;key= parameter. - // e.g., 'signature-agent;key="sig1"' -> 'signature-agent' - $semicolon_pos = strpos( $h, ';' ); + function ( $token ) { + $token = trim( $token ); + if ( '' === $token ) { + return ''; + } + + $header_name = $token; + if ( '"' === $token[0] ) { + $quote_end = strpos( $token, '"', 1 ); + if ( false !== $quote_end ) { + $header_name = substr( $token, 1, $quote_end - 1 ); + } else { + $header_name = trim( $token, '"' ); + } + } + + $semicolon_pos = strpos( $header_name, ';' ); if ( false !== $semicolon_pos ) { - $h = substr( $h, 0, $semicolon_pos ); + $header_name = substr( $header_name, 0, $semicolon_pos ); } - return $h; + + return strtolower( $header_name ); }, - $headers + $tokens ); - return array_filter( $headers ); + return array_values( array_filter( $headers ) ); } return array(); diff --git a/sdks/python/src/openbotauth_verifier/headers.py b/sdks/python/src/openbotauth_verifier/headers.py index b22fe28..018ceea 100644 --- a/sdks/python/src/openbotauth_verifier/headers.py +++ b/sdks/python/src/openbotauth_verifier/headers.py @@ -5,6 +5,7 @@ from __future__ import annotations import re +import shlex from typing import Mapping @@ -59,11 +60,16 @@ def parse_covered_headers(signature_input: str) -> list[str]: if not content: return [] - # Split on whitespace and remove quotes + # Split on whitespace while keeping quoted items and parameter tails together. + # shlex handles: + # "signature-agent";key="sig1" -> "signature-agent;key=sig1" + try: + items = shlex.split(content) + except ValueError: + return [] + headers = [] - for item in content.split(): - # Remove surrounding quotes - item = item.strip('"') + for item in items: if item: # Extract base header name before any ;key= parameter # e.g., 'signature-agent;key="sig1"' -> 'signature-agent' diff --git a/sdks/python/tests/test_headers.py b/sdks/python/tests/test_headers.py index e5b1182..2f0118d 100644 --- a/sdks/python/tests/test_headers.py +++ b/sdks/python/tests/test_headers.py @@ -53,6 +53,12 @@ def test_derived_components(self): result = parse_covered_headers(sig_input) assert result == ["@method", "@authority", "@path", "@query"] + def test_parameterized_component(self): + """Parameterized components parse to base header name.""" + sig_input = 'sig=("signature-agent";key="sig1" "host");created=123' + result = parse_covered_headers(sig_input) + assert result == ["signature-agent", "host"] + class TestExtractForwardedHeaders: """Tests for extract_forwarded_headers function.""" From 475c39503865cc227169f47dc88c5b213fba0076 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 20:54:48 +0500 Subject: [PATCH 40/49] fix: add legacy kid compatibility aliases and lookup fallback --- apps/registry-portal/public/skill.md | 2 +- docs/BREAKING_CHANGES.md | 3 +- packages/registry-service/src/routes/jwks.ts | 49 ++++++++++++- .../src/routes/signature-agent-card.ts | 17 ++++- packages/registry-signer/README.md | 15 ++-- packages/registry-signer/src/index.test.ts | 31 ++++++-- packages/registry-signer/src/jwks.ts | 32 ++++++++- .../verifier-service/src/jwks-cache.test.ts | 70 +++++++++++++++++++ packages/verifier-service/src/jwks-cache.ts | 43 +++++++++++- 9 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 packages/verifier-service/src/jwks-cache.test.ts diff --git a/apps/registry-portal/public/skill.md b/apps/registry-portal/public/skill.md index 26a7b8f..3082676 100644 --- a/apps/registry-portal/public/skill.md +++ b/apps/registry-portal/public/skill.md @@ -46,7 +46,7 @@ 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 thumbprint = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x }); const hash = crypto.createHash('sha256').update(thumbprint).digest(); const kid = hash.toString('base64url'); diff --git a/docs/BREAKING_CHANGES.md b/docs/BREAKING_CHANGES.md index 99cd3aa..84baa5f 100644 --- a/docs/BREAKING_CHANGES.md +++ b/docs/BREAKING_CHANGES.md @@ -11,7 +11,7 @@ These changes are intentional and may break clients built against older OpenBotA Impact: -- Existing clients hardcoded to old short `kid` values will fail key lookup until re-provisioned. +- Existing clients hardcoded to old short `kid` values are still accepted via compatibility aliases/fallback lookup. - Any local configs/scripts that assume fixed 16-char `kid` length must be updated. Recommended migration: @@ -38,4 +38,3 @@ Impact: Impact: - Strict custom verifiers expecting old parameter sets may need updates. - diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index 5376e9a..c82ed06 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -7,11 +7,56 @@ import { Router, type Request, type Response } from 'express'; import type { Database } from '@openbotauth/github-connector'; -import { base64PublicKeyToJWK, createWebBotAuthJWKS, generateKidFromJWK } from '@openbotauth/registry-signer'; +import { + base64PublicKeyToJWK, + createWebBotAuthJWKS, + generateKidFromJWK, + generateLegacyKidFromJWK, +} from '@openbotauth/registry-signer'; import { jwkThumbprint } from '../utils/jwk.js'; export const jwksRouter: Router = Router(); +function withLegacyKidAliases( + keys: Array>, +): Array> { + const result: Array> = [...keys]; + const seenKids = new Set( + keys + .map((key) => (typeof key.kid === "string" ? key.kid : null)) + .filter((kid): kid is string => !!kid), + ); + + for (const key of keys) { + if ( + key.kty !== "OKP" || + key.crv !== "Ed25519" || + typeof key.x !== "string" || + typeof key.kid !== "string" + ) { + continue; + } + + const thumbprintInput = { kty: "OKP" as const, crv: "Ed25519" as const, x: key.x }; + const fullKid = generateKidFromJWK(thumbprintInput); + const legacyKid = generateLegacyKidFromJWK(thumbprintInput); + const aliasKid = + key.kid === fullKid ? legacyKid : key.kid === legacyKid ? fullKid : null; + + if (!aliasKid || seenKids.has(aliasKid)) { + continue; + } + + seenKids.add(aliasKid); + result.push({ + ...key, + kid: aliasKid, + }); + } + + return result; +} + /** * GET /jwks/{username}.json * @@ -176,7 +221,7 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise>) as any, { client_name: profile.client_name || profile.username, client_uri: profile.client_uri || undefined, logo_uri: profile.logo_uri || undefined, diff --git a/packages/registry-service/src/routes/signature-agent-card.ts b/packages/registry-service/src/routes/signature-agent-card.ts index 09457a3..1afca9c 100644 --- a/packages/registry-service/src/routes/signature-agent-card.ts +++ b/packages/registry-service/src/routes/signature-agent-card.ts @@ -6,6 +6,7 @@ import { Router, type Request, type Response } from "express"; import type { Database } from "@openbotauth/github-connector"; +import { generateKidFromJWK, generateLegacyKidFromJWK } from "@openbotauth/registry-signer"; import { jwkThumbprint } from "../utils/jwk.js"; export const signatureAgentCardRouter: Router = Router(); @@ -104,13 +105,27 @@ signatureAgentCardRouter.get( agentJwk.x5c = certResult.rows[0].x5c; } + const jwksKeys: Array> = [agentJwk]; + const thumbprintInput = { kty: "OKP" as const, crv: "Ed25519" as const, x: pk.x }; + const fullKid = generateKidFromJWK(thumbprintInput); + const legacyKid = generateLegacyKidFromJWK(thumbprintInput); + const aliasKid = + kid === fullKid ? legacyKid : kid === legacyKid ? fullKid : null; + + if (aliasKid) { + jwksKeys.push({ + ...agentJwk, + kid: aliasKid, + }); + } + const card: Record = { client_name: agent.name, client_uri: profile?.client_uri || undefined, contacts: profile?.contacts || undefined, "rfc9309-product-token": profile?.rfc9309_product_token || undefined, purpose: profile?.purpose ? [profile.purpose] : undefined, - keys: { keys: [agentJwk] }, + keys: { keys: jwksKeys }, oba_agent_id: agent.oba_agent_id || undefined, oba_parent_agent_id: agent.oba_parent_agent_id || undefined, oba_principal: agent.oba_principal || undefined, diff --git a/packages/registry-signer/README.md b/packages/registry-signer/README.md index 55bd6e6..2980f2b 100644 --- a/packages/registry-signer/README.md +++ b/packages/registry-signer/README.md @@ -92,7 +92,7 @@ const backToBase64 = base64UrlToBase64(base64url); import { generateKid } from '@openbotauth/registry-signer'; const kid = generateKid(publicKey); -console.log(kid); // 16-character base64url string +console.log(kid); // 43-char RFC 7638 thumbprint (base64url) ``` ## API Reference @@ -154,11 +154,19 @@ Convert a base64-encoded public key to JWK format (used when reading from databa #### `generateKid(publicKeyPem: string): string` -Generate a key ID (kid) from a public key using SHA-256. +Generate a key ID (kid) from a public key using RFC 7638 JWK thumbprint (SHA-256, base64url). #### `generateKidFromJWK(jwk: Partial): string` -Generate a kid from JWK properties. +Generate a kid from JWK properties using RFC 7638. + +#### `generateLegacyKid(publicKeyPem: string): string` + +Generate the legacy 16-character kid (first 16 chars of the RFC 7638 thumbprint). + +#### `generateLegacyKidFromJWK(jwk: Partial): string` + +Generate the legacy 16-character kid from JWK properties. #### `validateJWK(jwk: unknown): jwk is JWK` @@ -223,4 +231,3 @@ interface WebBotAuthJWKS extends JWKS { ## License MIT - diff --git a/packages/registry-signer/src/index.test.ts b/packages/registry-signer/src/index.test.ts index d693bc8..252a21b 100644 --- a/packages/registry-signer/src/index.test.ts +++ b/packages/registry-signer/src/index.test.ts @@ -11,6 +11,8 @@ import { // JWKS generateKid, generateKidFromJWK, + generateLegacyKid, + generateLegacyKidFromJWK, publicKeyToJWK, base64PublicKeyToJWK, createJWKS, @@ -141,11 +143,11 @@ describe('Key Extraction', () => { describe('JWK Functions', () => { describe('generateKid', () => { - it('should generate a 16-character key ID', () => { + it('should generate a full RFC 7638 thumbprint kid', () => { const keyPair = generateKeyPair(); const kid = generateKid(keyPair.publicKey); - expect(kid.length).toBe(16); + expect(kid.length).toBe(43); // Should be base64url safe expect(kid).toMatch(/^[A-Za-z0-9_-]+$/); }); @@ -167,16 +169,37 @@ describe('JWK Functions', () => { }); describe('generateKidFromJWK', () => { - it('should generate a 16-character key ID from JWK', () => { + it('should generate a full RFC 7638 thumbprint kid from JWK', () => { const keyPair = generateKeyPair(); const jwk = publicKeyToJWK(keyPair.publicKey); const kid = generateKidFromJWK(jwk); - expect(kid.length).toBe(16); + expect(kid.length).toBe(43); expect(kid).toMatch(/^[A-Za-z0-9_-]+$/); }); }); + describe('legacy kid helpers', () => { + it('should derive 16-char legacy kid from public key', () => { + const keyPair = generateKeyPair(); + const fullKid = generateKid(keyPair.publicKey); + const legacyKid = generateLegacyKid(keyPair.publicKey); + + expect(legacyKid).toBe(fullKid.slice(0, 16)); + expect(legacyKid.length).toBe(16); + }); + + it('should derive 16-char legacy kid from JWK', () => { + const keyPair = generateKeyPair(); + const jwk = publicKeyToJWK(keyPair.publicKey); + const fullKid = generateKidFromJWK(jwk); + const legacyKid = generateLegacyKidFromJWK(jwk); + + expect(legacyKid).toBe(fullKid.slice(0, 16)); + expect(legacyKid.length).toBe(16); + }); + }); + describe('publicKeyToJWK', () => { it('should create a valid JWK from public key PEM', () => { const keyPair = generateKeyPair(); diff --git a/packages/registry-signer/src/jwks.ts b/packages/registry-signer/src/jwks.ts index 880f3e6..2ac29e0 100644 --- a/packages/registry-signer/src/jwks.ts +++ b/packages/registry-signer/src/jwks.ts @@ -6,6 +6,13 @@ import { createHash } from 'crypto'; import { pemToBase64, base64ToBase64Url } from './keygen.js'; import type { JWK, JWKS, WebBotAuthJWKS } from './types.js'; +const LEGACY_KID_LENGTH = 16; + +function canonicalOkpThumbprint(crv: string, kty: string, x: string): string { + // RFC 7638 canonical members in lexicographic order for OKP. + return JSON.stringify({ crv, kty, x }); +} + /** * Generate a key ID (kid) from a public key using RFC 7638 JWK Thumbprint. * Returns the full base64url-encoded SHA-256 hash (no truncation). @@ -26,7 +33,7 @@ export function generateKid(publicKeyPem: string): string { } // RFC 7638: canonical JSON with members in lexicographic order - const canonical = JSON.stringify({ crv: 'Ed25519', kty: 'OKP', x }); + const canonical = canonicalOkpThumbprint('Ed25519', 'OKP', x); const hash = createHash('sha256').update(canonical).digest('base64'); return base64ToBase64Url(hash); } @@ -37,11 +44,31 @@ export function generateKid(publicKeyPem: string): string { */ export function generateKidFromJWK(jwk: Partial): string { // RFC 7638: canonical JSON with members in lexicographic order (crv, kty, x for OKP) - const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }); + const canonical = canonicalOkpThumbprint( + String(jwk.crv), + String(jwk.kty), + String(jwk.x), + ); const hash = createHash('sha256').update(canonical).digest('base64'); return base64ToBase64Url(hash); } +/** + * Legacy OpenBotAuth kid (first 16 chars of the RFC 7638 thumbprint). + * Kept only for backwards compatibility with older clients. + */ +export function generateLegacyKid(publicKeyPem: string): string { + return generateKid(publicKeyPem).slice(0, LEGACY_KID_LENGTH); +} + +/** + * Legacy OpenBotAuth kid (first 16 chars of the RFC 7638 thumbprint). + * Kept only for backwards compatibility with older clients. + */ +export function generateLegacyKidFromJWK(jwk: Partial): string { + return generateKidFromJWK(jwk).slice(0, LEGACY_KID_LENGTH); +} + /** * Convert an Ed25519 public key (PEM) to JWK format */ @@ -206,4 +233,3 @@ export function validateJWK(jwk: unknown): jwk is JWK { key.use === 'sig' ); } - diff --git a/packages/verifier-service/src/jwks-cache.test.ts b/packages/verifier-service/src/jwks-cache.test.ts new file mode 100644 index 0000000..a411332 --- /dev/null +++ b/packages/verifier-service/src/jwks-cache.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from "vitest"; +import { JWKSCacheManager } from "./jwks-cache.js"; +import { + generateKidFromJWK, + generateLegacyKidFromJWK, +} from "@openbotauth/registry-signer"; + +describe("JWKSCacheManager.getKey", () => { + const thumbprintInput = { + kty: "OKP" as const, + crv: "Ed25519" as const, + x: Buffer.alloc(32, 7).toString("base64url"), + }; + const fullKid = generateKidFromJWK(thumbprintInput); + const legacyKid = generateLegacyKidFromJWK(thumbprintInput); + + const key = { + ...thumbprintInput, + kid: fullKid, + use: "sig", + }; + + function createManagerWithJwks(jwks: any): JWKSCacheManager { + const manager = new JWKSCacheManager({ + get: vi.fn(), + setEx: vi.fn(), + del: vi.fn(), + keys: vi.fn(), + } as any); + vi.spyOn(manager, "getJWKS").mockResolvedValue(jwks); + return manager; + } + + it("returns exact kid match", async () => { + const manager = createManagerWithJwks({ keys: [key] }); + await expect(manager.getKey("https://example.com/jwks.json", fullKid)).resolves.toEqual( + key, + ); + }); + + it("matches legacy 16-char kid against full-thumbprint JWKS key", async () => { + const manager = createManagerWithJwks({ keys: [key] }); + await expect( + manager.getKey("https://example.com/jwks.json", legacyKid), + ).resolves.toEqual(key); + }); + + it("matches full-thumbprint kid against legacy JWKS key", async () => { + const manager = createManagerWithJwks({ + keys: [{ ...key, kid: legacyKid }], + }); + await expect( + manager.getKey("https://example.com/jwks.json", fullKid), + ).resolves.toEqual({ ...key, kid: legacyKid }); + }); + + it("throws when compatibility lookup is ambiguous", async () => { + const manager = createManagerWithJwks({ + keys: [ + { ...key, kid: "legacy-a" }, + { ...key, kid: "legacy-b" }, + ], + }); + + await expect( + manager.getKey("https://example.com/jwks.json", fullKid), + ).rejects.toThrow(/Ambiguous kid/); + }); +}); + diff --git a/packages/verifier-service/src/jwks-cache.ts b/packages/verifier-service/src/jwks-cache.ts index 552271a..dc279b5 100644 --- a/packages/verifier-service/src/jwks-cache.ts +++ b/packages/verifier-service/src/jwks-cache.ts @@ -6,6 +6,7 @@ import type { JWKSCache } from "./types.js"; import { validateSafeUrl } from "./signature-parser.js"; +import { generateKidFromJWK, generateLegacyKidFromJWK } from "@openbotauth/registry-signer"; const JWKS_CACHE_TTL = 3600; // 1 hour default const JWKS_CACHE_PREFIX = "jwks:"; @@ -15,6 +16,44 @@ const DIRECTORY_ACCEPT_HEADER = export class JWKSCacheManager { constructor(private redis: any) {} + private findKeyByCompatibleKid(keys: any[], requestedKid: string): any { + const matches: any[] = []; + + for (const key of keys || []) { + if ( + key?.kty !== "OKP" || + key?.crv !== "Ed25519" || + typeof key?.x !== "string" + ) { + continue; + } + + const thumbprintInput = { + kty: "OKP" as const, + crv: "Ed25519" as const, + x: key.x, + }; + const fullKid = generateKidFromJWK(thumbprintInput); + const legacyKid = generateLegacyKidFromJWK(thumbprintInput); + + if (requestedKid === fullKid || requestedKid === legacyKid) { + matches.push(key); + } + } + + if (matches.length === 1) { + return matches[0]; + } + + if (matches.length > 1) { + throw new Error( + `Ambiguous kid ${requestedKid}: multiple keys matched compatibility lookup`, + ); + } + + return null; + } + /** * Get JWKS from cache or fetch from URL */ @@ -115,7 +154,9 @@ export class JWKSCacheManager { async getKey(jwksUrl: string, kid: string): Promise { const jwks: any = await this.getJWKS(jwksUrl); - const key = jwks.keys?.find((k: any) => k.kid === kid); + const key = + jwks.keys?.find((k: any) => k.kid === kid) || + this.findKeyByCompatibleKid(jwks.keys, kid); if (!key) { throw new Error(`Key with kid ${kid} not found in JWKS`); } From b9a6df0996b9a7738a2b65efc0ac1dc27279fe3b Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 21:11:09 +0500 Subject: [PATCH 41/49] fix: support multi-signature selection and sf-string dictionary serialization --- packages/bot-cli/src/request-signer.ts | 10 +++- .../src/__tests__/signature-e2e.test.ts | 52 ++++++++++++++++++- .../src/signature-parser.test.ts | 43 +++++++++++++++ .../verifier-service/src/signature-parser.ts | 40 ++++++++++++-- .../src/signature-verifier.ts | 37 +++++++------ 5 files changed, 158 insertions(+), 24 deletions(-) diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index bc7a72f..fc30b92 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -10,6 +10,10 @@ import type { BotConfig, SignedRequest, SignatureParams } from './types.js'; export class RequestSigner { constructor(private config: BotConfig) {} + private serializeSfString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + private formatCoveredComponent(component: string): string { const separatorIndex = component.indexOf(";"); if (separatorIndex === -1) { @@ -142,12 +146,14 @@ export class RequestSigner { throw new Error('Missing covered header: signature-agent'); } // Extract the value for the specific dictionary member key - // The signature base uses the raw URI value, not the dict format + // Serialize selected dictionary member as RFC 8941 sf-string. const dictKey = sigAgentMatch[1]; const escapedDictKey = dictKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const dictMatch = request.signatureAgent.match(new RegExp(`${escapedDictKey}="([^"]+)"`)); const uriValue = dictMatch ? dictMatch[1] : request.signatureAgent; - lines.push(`"signature-agent";key="${dictKey}": ${uriValue}`); + lines.push( + `"signature-agent";key="${dictKey}": ${this.serializeSfString(uriValue)}`, + ); continue; } if (component === 'signature-agent') { diff --git a/packages/verifier-service/src/__tests__/signature-e2e.test.ts b/packages/verifier-service/src/__tests__/signature-e2e.test.ts index d90cb36..2aebc1e 100644 --- a/packages/verifier-service/src/__tests__/signature-e2e.test.ts +++ b/packages/verifier-service/src/__tests__/signature-e2e.test.ts @@ -226,6 +226,56 @@ describe("E2E Signature Verification", () => { expect(result.error).toContain('tag="web-bot-auth"'); }); + it("selects matching web-bot-auth member when multiple signatures are present", async () => { + const method = "GET"; + const url = "https://example.com/api/multi"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + + const badCovered = ["@method", "@path"]; + const badParams = `(${badCovered.map(formatCoveredComponent).join(" ")});created=${created};keyid="other-kid";nonce="${nonce}";alg="ed25519"`; + + const goodCovered = ["@method", "@path", 'signature-agent;key="sig1"']; + const goodParams = `(${goodCovered.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + + const signatureInput = `sig0=${badParams}, sig1=${goodParams}`; + + const components = parseSignatureInput(signatureInput, "sig1")!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: { + "signature-agent": signatureAgentHeader, + }, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase) + ); + const signatureHeader = `sig0=:ZmFrZQ==:, sig1=:${Buffer.from(signatureBytes).toString("base64")}:`; + + const verifier = new SignatureVerifier( + createMockJwksCache(publicJwk) as any, + createMockNonceManager() as any, + ["trusted.example.com"] + ); + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + "signature": signatureHeader, + "signature-agent": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(true); + expect(result.error).toBeUndefined(); + }); + it("rejects signatures that do not cover signature-agent", async () => { const method = "GET"; const url = "https://example.com/api/no-sig-agent-cover"; @@ -265,7 +315,7 @@ describe("E2E Signature Verification", () => { }); expect(result.verified).toBe(false); - expect(result.error).toContain('"signature-agent" must be a covered component'); + expect(result.error).toContain("covered signature-agent"); }); it("rejects tampered message", async () => { diff --git a/packages/verifier-service/src/signature-parser.test.ts b/packages/verifier-service/src/signature-parser.test.ts index f0b9723..d12d984 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -9,6 +9,7 @@ import { buildSignatureBase, parseSignatureInput, parseSignature, + parseSignatureLabels, validateSafeUrl, } from "./signature-parser.js"; @@ -259,6 +260,16 @@ describe("parseSignatureInput", () => { expect(parsed?.keyId).toBe("k1"); }); + it("should parse the matching member when expected label is provided", () => { + const parsed = parseSignatureInput( + 'sig1=("@method");created=1;keyid="k1";alg="ed25519", sig2=("@path");created=2;keyid="k2";alg="ed25519";tag="web-bot-auth"', + "sig2", + ); + expect(parsed?.label).toBe("sig2"); + expect(parsed?.keyId).toBe("k2"); + expect(parsed?.tag).toBe("web-bot-auth"); + }); + it("should parse tag and signature-agent key parameter", () => { const parsed = parseSignatureInput( 'sig2=("@method" "signature-agent";key="sig1");created=1700000000;expires=1700000300;nonce="n";keyid="k2";alg="ed25519";tag="web-bot-auth"', @@ -284,6 +295,13 @@ describe("parseSignature", () => { }); }); +describe("parseSignatureLabels", () => { + it("returns signature labels in order", () => { + const labels = parseSignatureLabels("sig2=:Zm9vOg==:, sig1=:YmFyOg==:"); + expect(labels).toEqual(["sig2", "sig1"]); + }); +}); + describe("resolveJwksUrl", () => { let fetchMock: any; @@ -539,6 +557,31 @@ describe("buildSignatureBase", () => { expect(result).toContain('"accept": application/json'); }); + it("serializes selected dictionary member as quoted sf-string", () => { + const components = { + label: "sig1", + keyId: "test-key", + signature: "", + algorithm: "ed25519", + headers: ['signature-agent;key="sig1"'], + rawSignatureParams: + '("signature-agent";key="sig1");keyid="test-key";alg="ed25519"', + }; + + const request = { + method: "GET", + url: "https://example.com/test", + headers: { + "signature-agent": 'sig1="https://example.com/jwks.json"', + }, + }; + + const result = buildSignatureBase(components, request); + expect(result).toContain( + '"signature-agent";key="sig1": "https://example.com/jwks.json"', + ); + }); + it("should throw error when covered header is missing", () => { const components = { label: "sig1", diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index 93728b0..04af5c0 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -230,13 +230,16 @@ export function validateSafeUrl(urlString: string): void { */ export function parseSignatureInput( signatureInput: string, + expectedLabel?: string, ): SignatureComponents | null { try { - // MVP behavior: when multiple labels are present, verify the first parsable member. const members = splitTopLevelMembers(signatureInput); for (const member of members) { const parsed = parseSingleSignatureInputMember(member); - if (parsed) { + if (!parsed) { + continue; + } + if (!expectedLabel || parsed.label === expectedLabel) { return parsed; } } @@ -283,6 +286,28 @@ export function parseSignature( } } +/** + * Parse signature labels from Signature header members in-order. + * + * Example: + * Signature: sig1=:abc:, sig2=:def: + * Returns: ["sig1", "sig2"] + */ +export function parseSignatureLabels(signature: string): string[] { + try { + const labels: string[] = []; + for (const member of splitTopLevelMembers(signature)) { + const match = member.match(SIGNATURE_RE); + if (match) { + labels.push(match[1]); + } + } + return labels; + } catch { + return []; + } +} + /** * Extract dictionary key from component with ;key= parameter * e.g., "signature-agent;key=\"sig1\"" -> { headerName: "signature-agent", dictKey: "sig1" } @@ -372,8 +397,10 @@ export function buildSignatureBase( ); } - // RFC 9421: component identifier includes the ;key= parameter - lines.push(`"${headerName}";key="${dictKey}": ${memberValue}`); + // RFC 9421 + RFC 8941: dictionary string item must be serialized as sf-string. + lines.push( + `"${headerName}";key="${dictKey}": ${serializeSfString(memberValue)}`, + ); } else { // Regular headers - use raw value as-is per RFC 9421 lines.push(`"${headerName}": ${headerValue}`); @@ -483,6 +510,11 @@ function parseStructuredDictionaryStringItems( return result; } +function serializeSfString(value: string): string { + // RFC 8941 sf-string serialization for signature-base construction. + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + /** * Extract JWKS URL from Signature-Agent header * diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index c919a10..a67a717 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -12,6 +12,7 @@ import { validateJwkX509 } from './x509.js'; import { parseSignatureInput, parseSignature, + parseSignatureLabels, parseSignatureAgent, extractSignatureAgentDictionaryKey, resolveJwksUrl, @@ -61,29 +62,31 @@ export class SignatureVerifier { }; } - // 2. Parse signature components (needed for label selection) - const components = parseSignatureInput(signatureInput); - if (!components) { - return { - verified: false, - error: 'Failed to parse Signature-Input header', - }; - } + // 2. Select Signature-Input member matching a Signature label. + const signatureLabels = parseSignatureLabels(signature); + const candidateLabels = signatureLabels.length > 0 ? signatureLabels : [undefined]; + let components: ReturnType = null; - // 2.1 Enforce WBA tag semantics. - if (components.tag !== "web-bot-auth") { - return { - verified: false, - error: - 'Invalid Signature-Input tag (expected tag="web-bot-auth")', - }; + for (const label of candidateLabels) { + const parsed = parseSignatureInput(signatureInput, label); + if (!parsed) { + continue; + } + if (parsed.tag !== "web-bot-auth") { + continue; + } + if (!isSignatureAgentCovered(parsed.headers)) { + continue; + } + components = parsed; + break; } - if (!isSignatureAgentCovered(components.headers)) { + if (!components) { return { verified: false, error: - 'Invalid Signature-Input: "signature-agent" must be a covered component', + "No matching Signature-Input member with tag=\"web-bot-auth\" and covered signature-agent", }; } From 45b0aa79b1790c2da8c36d71b793d3c22ed72bed Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 21:24:21 +0500 Subject: [PATCH 42/49] fix: persist PoP nonce outside cert issuance transaction --- .../src/routes/__tests__/certs.test.ts | 97 +++++++++++++++++++ packages/registry-service/src/routes/certs.ts | 5 +- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 1bc425f..766b889 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -480,6 +480,103 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.body.fingerprint_sha256).toBeDefined(); }); + it("checks PoP nonce through pool query (outside issuance transaction)", async () => { + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" }, + true, + ["sign", "verify"], + ); + const publicJwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + const agentId = "agent-nonce-outside-tx"; + const ts = Math.floor(Date.now() / 1000); + const message = `cert-issue:${agentId}:${ts}`; + const signature = Buffer.from( + await webcrypto.subtle.sign( + { name: "Ed25519" }, + keyPair.privateKey, + new TextEncoder().encode(message), + ), + ).toString("base64"); + + const poolQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql.includes("SELECT check_pop_nonce")) { + return { rows: [{ is_new: true }] }; + } + return { rows: [] }; + }); + + const clientQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql.includes("SELECT check_pop_nonce")) { + throw new Error("check_pop_nonce must not run on transaction client"); + } + if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { + return { rows: [] }; + } + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }; + } + if (sql.includes("c.created_at >= now() - interval '1 day'")) { + return { rows: [{ count: 0 }] }; + } + if (sql.includes("AND c.kid = $3")) { + return { rows: [{ count: 0 }] }; + } + if (sql.includes("INSERT INTO agent_certificates")) { + return { rows: [] }; + } + return { rows: [] }; + }); + + const connect = vi.fn().mockResolvedValue({ + query: clientQuery, + release: vi.fn(), + }); + + const db = { + getPool: () => ({ + query: poolQuery, + connect, + }), + }; + + const req = mockReq({ + body: { + agent_id: agentId, + proof: { message, signature }, + }, + app: { locals: { db } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); + + expect(res.statusCode).toBe(200); + expect( + poolQuery.mock.calls.some(([sql]) => + String(sql).includes("SELECT check_pop_nonce"), + ), + ).toBe(true); + expect( + clientQuery.mock.calls.some(([sql]) => + String(sql).includes("SELECT check_pop_nonce"), + ), + ).toBe(false); + }); + it("rejects replayed proof (same message used twice)", async () => { // Generate a real Ed25519 keypair const keyPair = await webcrypto.subtle.generateKey( diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 51dafce..b1fc3de 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -284,8 +284,9 @@ certsRouter.post( return; } - // Check for replay attack: ensure this proof hasn't been used before - const isNewNonce = await checkPopNonce(client, proof.message); + // Check for replay attack using an independent query executor so nonce + // persistence is not rolled back with the issuance transaction. + const isNewNonce = await checkPopNonce(db.getPool(), proof.message); if (!isNewNonce) { await rollbackIfNeeded(); res.status(403).json({ From f3e990766f4ef43697fc14f7a33fe9abde78d1e9 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 21:42:35 +0500 Subject: [PATCH 43/49] fix: restore legacy kid derivation and align PoP nonce TTL --- .../src/routes/__tests__/certs.test.ts | 4 ++++ packages/registry-service/src/routes/certs.ts | 15 ++++++++------- packages/registry-signer/README.md | 4 ++-- packages/registry-signer/src/index.test.ts | 15 +++++++++++---- packages/registry-signer/src/jwks.ts | 16 ++++++++++++---- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index 766b889..c788522 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -570,6 +570,10 @@ describe("POST /v1/certs/issue - PoP validation", () => { String(sql).includes("SELECT check_pop_nonce"), ), ).toBe(true); + const nonceCall = poolQuery.mock.calls.find(([sql]) => + String(sql).includes("SELECT check_pop_nonce"), + ); + expect(nonceCall?.[1]?.[1]).toBe(330); expect( clientQuery.mock.calls.some(([sql]) => String(sql).includes("SELECT check_pop_nonce"), diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index b1fc3de..f1eb18c 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -9,6 +9,10 @@ import { issueCertificateForJwk, getCertificateAuthority } from "../utils/ca.js" import { requireScope } from "../middleware/scopes.js"; import { jwkThumbprint } from "../utils/jwk.js"; +const POP_PROOF_MAX_AGE_SEC = 300; +const POP_PROOF_MAX_FUTURE_DRIFT_SEC = 30; +const POP_NONCE_TTL_SEC = POP_PROOF_MAX_AGE_SEC + POP_PROOF_MAX_FUTURE_DRIFT_SEC; + /** * Check if a PoP nonce has been used and mark it as used. * Returns true if the nonce is new (not a replay), false if already used. @@ -25,8 +29,8 @@ async function checkPopNonce( const hash = createHash("sha256").update(message).digest("hex"); try { const result = await queryExecutor.query( - `SELECT check_pop_nonce($1, 300) AS is_new`, - [hash], + `SELECT check_pop_nonce($1, $2) AS is_new`, + [hash, POP_NONCE_TTL_SEC], ); return result.rows[0]?.is_new === true; } catch (err: any) { @@ -77,13 +81,10 @@ async function verifyProofOfPossession( // Validate timestamp: must be in the past, within 5 minutes const timestamp = parseInt(timestampStr, 10); const now = Math.floor(Date.now() / 1000); - const maxAge = 300; // 5 minutes - const maxDrift = 30; // 30 seconds future tolerance for clock skew - - if (timestamp > now + maxDrift) { + if (timestamp > now + POP_PROOF_MAX_FUTURE_DRIFT_SEC) { return { valid: false, error: "Proof timestamp is in the future" }; } - if (now - timestamp > maxAge) { + if (now - timestamp > POP_PROOF_MAX_AGE_SEC) { return { valid: false, error: "Proof timestamp expired (older than 5 minutes)" }; } diff --git a/packages/registry-signer/README.md b/packages/registry-signer/README.md index 2980f2b..4b5acd1 100644 --- a/packages/registry-signer/README.md +++ b/packages/registry-signer/README.md @@ -162,11 +162,11 @@ Generate a kid from JWK properties using RFC 7638. #### `generateLegacyKid(publicKeyPem: string): string` -Generate the legacy 16-character kid (first 16 chars of the RFC 7638 thumbprint). +Generate the legacy 16-character kid using pre-RFC7638 PEM-based hashing (kept for compatibility). #### `generateLegacyKidFromJWK(jwk: Partial): string` -Generate the legacy 16-character kid from JWK properties. +Generate the legacy 16-character kid using pre-RFC7638 JWK-field hashing (kept for compatibility). #### `validateJWK(jwk: unknown): jwk is JWK` diff --git a/packages/registry-signer/src/index.test.ts b/packages/registry-signer/src/index.test.ts index 252a21b..37dfa09 100644 --- a/packages/registry-signer/src/index.test.ts +++ b/packages/registry-signer/src/index.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'vitest'; +import { createHash } from 'crypto'; import { // Key generation generateKeyPair, @@ -182,20 +183,26 @@ describe('JWK Functions', () => { describe('legacy kid helpers', () => { it('should derive 16-char legacy kid from public key', () => { const keyPair = generateKeyPair(); - const fullKid = generateKid(keyPair.publicKey); const legacyKid = generateLegacyKid(keyPair.publicKey); + const expectedLegacyKid = base64ToBase64Url( + createHash('sha256').update(pemToBase64(keyPair.publicKey)).digest('base64') + ).slice(0, 16); - expect(legacyKid).toBe(fullKid.slice(0, 16)); + expect(legacyKid).toBe(expectedLegacyKid); expect(legacyKid.length).toBe(16); }); it('should derive 16-char legacy kid from JWK', () => { const keyPair = generateKeyPair(); const jwk = publicKeyToJWK(keyPair.publicKey); - const fullKid = generateKidFromJWK(jwk); const legacyKid = generateLegacyKidFromJWK(jwk); + const expectedLegacyKid = base64ToBase64Url( + createHash('sha256') + .update(JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x })) + .digest('base64') + ).slice(0, 16); - expect(legacyKid).toBe(fullKid.slice(0, 16)); + expect(legacyKid).toBe(expectedLegacyKid); expect(legacyKid.length).toBe(16); }); }); diff --git a/packages/registry-signer/src/jwks.ts b/packages/registry-signer/src/jwks.ts index 2ac29e0..eaa8d94 100644 --- a/packages/registry-signer/src/jwks.ts +++ b/packages/registry-signer/src/jwks.ts @@ -54,19 +54,27 @@ export function generateKidFromJWK(jwk: Partial): string { } /** - * Legacy OpenBotAuth kid (first 16 chars of the RFC 7638 thumbprint). + * Legacy OpenBotAuth kid derived from PEM key material. * Kept only for backwards compatibility with older clients. */ export function generateLegacyKid(publicKeyPem: string): string { - return generateKid(publicKeyPem).slice(0, LEGACY_KID_LENGTH); + // Historical behavior (pre-RFC7638 migration): + // SHA-256 over PEM body base64, then base64url-truncate to 16 chars. + const base64 = pemToBase64(publicKeyPem); + const hash = createHash("sha256").update(base64).digest("base64"); + return base64ToBase64Url(hash).slice(0, LEGACY_KID_LENGTH); } /** - * Legacy OpenBotAuth kid (first 16 chars of the RFC 7638 thumbprint). + * Legacy OpenBotAuth kid derived from JWK fields. * Kept only for backwards compatibility with older clients. */ export function generateLegacyKidFromJWK(jwk: Partial): string { - return generateKidFromJWK(jwk).slice(0, LEGACY_KID_LENGTH); + // Historical behavior (pre-RFC7638 migration): + // SHA-256 over JSON stringified {kty,crv,x}, then base64url-truncate to 16 chars. + const legacyInput = JSON.stringify({ kty: jwk.kty, crv: jwk.crv, x: jwk.x }); + const hash = createHash("sha256").update(legacyInput).digest("base64"); + return base64ToBase64Url(hash).slice(0, LEGACY_KID_LENGTH); } /** From 71fe8bde7839bf663548c1b1b36f303efd97a14b Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 21:49:47 +0500 Subject: [PATCH 44/49] chore: tighten node engine and parser/CA robustness --- packages/registry-service/package.json | 3 +++ packages/registry-service/src/utils/ca.ts | 12 +++++++++++- .../verifier-service/src/signature-parser.test.ts | 11 +++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/registry-service/package.json b/packages/registry-service/package.json index bcce3fc..d1881b0 100644 --- a/packages/registry-service/package.json +++ b/packages/registry-service/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/server.js", "types": "./dist/server.d.ts", + "engines": { + "node": ">=20.0.0" + }, "scripts": { "build": "tsc", "dev": "tsx watch --env-file=../../.env src/server.ts", diff --git a/packages/registry-service/src/utils/ca.ts b/packages/registry-service/src/utils/ca.ts index 2225877..de23513 100644 --- a/packages/registry-service/src/utils/ca.ts +++ b/packages/registry-service/src/utils/ca.ts @@ -258,9 +258,19 @@ export async function issueCertificateForJwk( ensureCryptoProvider(); const ca = await getCertificateAuthority(); + const importableJwk = { + kty: typeof jwk.kty === "string" ? jwk.kty : "OKP", + crv: typeof jwk.crv === "string" ? jwk.crv : "Ed25519", + x: typeof jwk.x === "string" ? jwk.x : "", + } as any; + + if (!importableJwk.x) { + throw new Error("Missing JWK x parameter for certificate issuance"); + } + const publicKey = await webcrypto.subtle.importKey( "jwk", - jwk, + importableJwk, { name: "Ed25519" } as any, true, ["verify"], diff --git a/packages/verifier-service/src/signature-parser.test.ts b/packages/verifier-service/src/signature-parser.test.ts index d12d984..1fc33b2 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -242,6 +242,17 @@ describe("parseSignatureAgent", () => { const result = parseSignatureAgent("not-a-url"); expect(result).toBeNull(); }); + + it("should parse dictionary string item with parameters", () => { + const result = parseSignatureAgent( + 'sig1="https://example.com/.well-known/http-message-signatures-directory";v=1', + "sig1", + ); + expect(result).toEqual({ + url: "https://example.com/.well-known/http-message-signatures-directory", + isJwks: true, + }); + }); }); describe("parseSignatureInput", () => { From 998bd7bad3d7e15e84ad8a3fb86b4758e75b26f6 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Mon, 23 Feb 2026 22:10:31 +0500 Subject: [PATCH 45/49] Add X.509 EKU clientAuth and SAN URI binding validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enforce leaf certificate EKU includes clientAuth (1.3.6.1.5.5.7.3.2) when EKU extension is present (default: enabled) - Add optional SAN URI binding validation for soft identity pinning - Add comprehensive tests for EKU and SAN validation scenarios - Note: Node.js exposes EKU OIDs via `keyUsage` property (not `extKeyUsage`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/verifier-service/package.json | 1 + packages/verifier-service/src/x509.test.ts | 221 ++++++++++++++++++++- packages/verifier-service/src/x509.ts | 85 ++++++++ pnpm-lock.yaml | 3 + 4 files changed, 308 insertions(+), 2 deletions(-) diff --git a/packages/verifier-service/package.json b/packages/verifier-service/package.json index 46958f8..72b6e4d 100644 --- a/packages/verifier-service/package.json +++ b/packages/verifier-service/package.json @@ -21,6 +21,7 @@ "author": "", "license": "Apache-2.0", "devDependencies": { + "@peculiar/x509": "^1.12.4", "@types/express": "^4.17.21", "@types/node": "^20.14.0", "@types/pg": "^8.15.6", diff --git a/packages/verifier-service/src/x509.test.ts b/packages/verifier-service/src/x509.test.ts index 7a25fb3..f6b434e 100644 --- a/packages/verifier-service/src/x509.test.ts +++ b/packages/verifier-service/src/x509.test.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; -import { X509Certificate } from "node:crypto"; -import { describe, it, expect, vi, afterEach } from "vitest"; +import { X509Certificate, webcrypto } from "node:crypto"; +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; import { validateJwkX509 } from "./x509.js"; +import * as x509Lib from "@peculiar/x509"; const caPem = readFileSync( new URL("./__fixtures__/x509/ca.pem", import.meta.url), @@ -113,4 +114,220 @@ describe("validateJwkX509", () => { expect(result.valid).toBe(false); expect(result.error).toContain("too large"); }); + + it("passes when leaf cert has no EKU (not constrained)", async () => { + // Existing leaf cert has no EKU extension + const result = await validateJwkX509( + { ...leafJwk, x5c: [leafDerBase64, caDerBase64] }, + { trustAnchors: [caPem], requireClientAuthEku: true }, + ); + expect(result.valid).toBe(true); + }); + + it("passes when EKU check is explicitly disabled", async () => { + const result = await validateJwkX509( + { ...leafJwk, x5c: [leafDerBase64, caDerBase64] }, + { trustAnchors: [caPem], requireClientAuthEku: false }, + ); + expect(result.valid).toBe(true); + }); +}); + +describe("validateJwkX509 EKU and SAN validation", () => { + let testCaKeyPair: CryptoKeyPair; + let testCaCert: any; + let testCaPem: string; + + beforeAll(async () => { + // Set up crypto provider for @peculiar/x509 + if ((x509Lib as any).cryptoProvider?.set) { + (x509Lib as any).cryptoProvider.set(webcrypto as any); + } + + // Generate test CA + testCaKeyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + ) as CryptoKeyPair; + + const notBefore = new Date(); + const notAfter = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); + + testCaCert = await (x509Lib as any).X509CertificateGenerator.create({ + serialNumber: "01", + subject: "CN=Test CA for EKU/SAN", + issuer: "CN=Test CA for EKU/SAN", + notBefore, + notAfter, + publicKey: testCaKeyPair.publicKey, + signingKey: testCaKeyPair.privateKey, + signingAlgorithm: { name: "Ed25519" }, + extensions: [ + new (x509Lib as any).BasicConstraintsExtension(true, undefined, true), + new (x509Lib as any).KeyUsagesExtension( + (x509Lib as any).KeyUsageFlags.keyCertSign | + (x509Lib as any).KeyUsageFlags.cRLSign, + true, + ), + ], + }); + + testCaPem = testCaCert.toString(); + }); + + async function generateLeafCert(options: { + includeClientAuthEku?: boolean; + includeServerAuthEku?: boolean; + sanUri?: string; + }): Promise<{ cert: any; keyPair: CryptoKeyPair; jwk: any }> { + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + ) as CryptoKeyPair; + + const extensions: any[] = [ + new (x509Lib as any).BasicConstraintsExtension(false, undefined, true), + new (x509Lib as any).KeyUsagesExtension( + (x509Lib as any).KeyUsageFlags.digitalSignature, + true, + ), + ]; + + // Add EKU if specified + const ekuOids: string[] = []; + if (options.includeClientAuthEku) { + ekuOids.push("1.3.6.1.5.5.7.3.2"); // clientAuth + } + if (options.includeServerAuthEku) { + ekuOids.push("1.3.6.1.5.5.7.3.1"); // serverAuth + } + if (ekuOids.length > 0) { + extensions.push( + new (x509Lib as any).ExtendedKeyUsageExtension(ekuOids, false), + ); + } + + // Add SAN URI if specified + if (options.sanUri) { + extensions.push( + new (x509Lib as any).SubjectAlternativeNameExtension( + [{ type: "url", value: options.sanUri }], + false, + ), + ); + } + + const notBefore = new Date(); + const notAfter = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); + + const cert = await (x509Lib as any).X509CertificateGenerator.create({ + serialNumber: Math.floor(Math.random() * 1000000).toString(16), + subject: "CN=Test Leaf", + issuer: "CN=Test CA for EKU/SAN", + notBefore, + notAfter, + publicKey: keyPair.publicKey, + signingKey: testCaKeyPair.privateKey, + signingAlgorithm: { name: "Ed25519" }, + extensions, + }); + + const jwk = await webcrypto.subtle.exportKey("jwk", keyPair.publicKey); + + return { cert, keyPair, jwk }; + } + + it("passes when leaf cert has clientAuth EKU", async () => { + const { cert, jwk } = await generateLeafCert({ includeClientAuthEku: true }); + const leafDer = Buffer.from(cert.rawData).toString("base64"); + const caDer = Buffer.from(testCaCert.rawData).toString("base64"); + + const result = await validateJwkX509( + { ...jwk, x5c: [leafDer, caDer] }, + { trustAnchors: [testCaPem], requireClientAuthEku: true }, + ); + + expect(result.valid).toBe(true); + }); + + it("rejects when leaf cert has EKU but not clientAuth", async () => { + const { cert, jwk } = await generateLeafCert({ + includeServerAuthEku: true, + includeClientAuthEku: false, + }); + const leafDer = Buffer.from(cert.rawData).toString("base64"); + const caDer = Buffer.from(testCaCert.rawData).toString("base64"); + + const result = await validateJwkX509( + { ...jwk, x5c: [leafDer, caDer] }, + { trustAnchors: [testCaPem], requireClientAuthEku: true }, + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain("clientAuth"); + }); + + it("passes SAN URI binding when URIs match", async () => { + const expectedUri = "agent:test-agent@example.com"; + const { cert, jwk } = await generateLeafCert({ + includeClientAuthEku: true, + sanUri: expectedUri, + }); + const leafDer = Buffer.from(cert.rawData).toString("base64"); + const caDer = Buffer.from(testCaCert.rawData).toString("base64"); + + const result = await validateJwkX509( + { ...jwk, x5c: [leafDer, caDer] }, + { + trustAnchors: [testCaPem], + requireClientAuthEku: true, + expectedSanUri: expectedUri, + }, + ); + + expect(result.valid).toBe(true); + }); + + it("rejects SAN URI binding when URIs do not match", async () => { + const { cert, jwk } = await generateLeafCert({ + includeClientAuthEku: true, + sanUri: "agent:wrong-agent@example.com", + }); + const leafDer = Buffer.from(cert.rawData).toString("base64"); + const caDer = Buffer.from(testCaCert.rawData).toString("base64"); + + const result = await validateJwkX509( + { ...jwk, x5c: [leafDer, caDer] }, + { + trustAnchors: [testCaPem], + requireClientAuthEku: true, + expectedSanUri: "agent:expected-agent@example.com", + }, + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain("SAN URI mismatch"); + }); + + it("rejects SAN URI binding when cert has no SAN URI", async () => { + const { cert, jwk } = await generateLeafCert({ + includeClientAuthEku: true, + // No SAN URI + }); + const leafDer = Buffer.from(cert.rawData).toString("base64"); + const caDer = Buffer.from(testCaCert.rawData).toString("base64"); + + const result = await validateJwkX509( + { ...jwk, x5c: [leafDer, caDer] }, + { + trustAnchors: [testCaPem], + expectedSanUri: "agent:expected-agent@example.com", + }, + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain("no SAN URI"); + }); }); diff --git a/packages/verifier-service/src/x509.ts b/packages/verifier-service/src/x509.ts index 735edfe..8458306 100644 --- a/packages/verifier-service/src/x509.ts +++ b/packages/verifier-service/src/x509.ts @@ -7,6 +7,10 @@ import { validateSafeUrl } from "./signature-parser.js"; export interface X509ValidationOptions { trustAnchors: string[]; + /** Require leaf certificate EKU to include clientAuth (default: true) */ + requireClientAuthEku?: boolean; + /** Expected SAN URI to match against leaf certificate (optional soft binding) */ + expectedSanUri?: string; } export interface X509ValidationResult { @@ -63,6 +67,70 @@ function normalizeKeyUsage(value: string): string { return value.toLowerCase().replace(/[^a-z]/g, ""); } +const CLIENT_AUTH_OID = "1.3.6.1.5.5.7.3.2"; + +/** + * Validate that leaf certificate EKU includes clientAuth. + * If no EKU extension is present, the certificate is not constrained (allowed). + * + * Note: Node.js exposes Extended Key Usage OIDs via the `keyUsage` property + * (confusingly named - it's not the standard Key Usage extension). + */ +function validateLeafEku(cert: X509Certificate): string | null { + // Node.js exposes EKU OIDs in the `keyUsage` property (array of OID strings) + const ekuOids = cert.keyUsage; + if (!Array.isArray(ekuOids) || ekuOids.length === 0) { + // No EKU extension means certificate is not constrained to specific usages + return null; + } + + // Check if any OID looks like an EKU OID (starts with 1.3.6.1.5.5.7.3.) + const hasEkuExtension = ekuOids.some((oid) => oid.startsWith("1.3.6.1.5.5.7.3.")); + if (!hasEkuExtension) { + // keyUsage contains Key Usage flags, not EKU - certificate is not constrained + return null; + } + + if (!ekuOids.includes(CLIENT_AUTH_OID)) { + return "X.509 validation failed: leaf certificate EKU does not include clientAuth (1.3.6.1.5.5.7.3.2)"; + } + + return null; +} + +/** + * Extract URI from certificate Subject Alternative Name extension. + * Returns null if no URI SAN is present. + */ +function extractSanUri(cert: X509Certificate): string | null { + const san = cert.subjectAltName; + if (!san) return null; + + // Node.js formats SAN as: "URI:agent:foo@example.com, DNS:example.com" + const uriMatch = san.match(/URI:([^,]+)/i); + return uriMatch ? uriMatch[1].trim() : null; +} + +/** + * Validate that leaf certificate SAN URI matches expected identity. + * This provides soft binding between certificate and agent identity. + */ +function validateSanUriBinding( + cert: X509Certificate, + expectedUri: string, +): string | null { + const actualUri = extractSanUri(cert); + if (!actualUri) { + return "X.509 validation failed: leaf certificate has no SAN URI for identity binding"; + } + + if (actualUri !== expectedUri) { + return `X.509 validation failed: SAN URI mismatch (expected: ${expectedUri}, got: ${actualUri})`; + } + + return null; +} + function certCanSignChildren(cert: X509Certificate): boolean { if (!cert.ca) { return false; @@ -246,6 +314,23 @@ export async function validateJwkX509( return { valid: false, error: chainError }; } + // Validate leaf certificate EKU includes clientAuth (default: enabled) + const requireEku = options.requireClientAuthEku !== false; + if (requireEku) { + const ekuError = validateLeafEku(certs[0]); + if (ekuError) { + return { valid: false, error: ekuError }; + } + } + + // Validate SAN URI binding if expected URI is provided + if (options.expectedSanUri) { + const sanError = validateSanUriBinding(certs[0], options.expectedSanUri); + if (sanError) { + return { valid: false, error: sanError }; + } + } + return { valid: true }; } catch (error: any) { return { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23e633c..3052d73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -492,6 +492,9 @@ importers: specifier: ^4.6.13 version: 4.7.1 devDependencies: + '@peculiar/x509': + specifier: ^1.12.4 + version: 1.14.3 '@types/express': specifier: ^4.17.21 version: 4.17.25 From 665602eb609aa80df24e7277a77088701e4a50d5 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Tue, 24 Feb 2026 17:56:12 +0500 Subject: [PATCH 46/49] fix: require not_before for active cert cap checks --- packages/registry-service/src/routes/__tests__/certs.test.ts | 4 ++++ packages/registry-service/src/routes/certs.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index c788522..ccedb23 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -836,6 +836,10 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.statusCode).toBe(409); expect(res.body.error).toContain("Active certificate limit reached"); + const activeCapQueryCall = agentQuery.mock.calls.find(([sql]) => + String(sql).includes("AND c.kid = $3"), + ); + expect(activeCapQueryCall?.[0]).toContain("c.not_before <= now()"); }); }); diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index f1eb18c..9a8d9b9 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -336,6 +336,7 @@ certsRouter.post( AND a.user_id = $2 AND c.kid = $3 AND c.revoked_at IS NULL + AND c.not_before <= now() AND c.not_after > now()`, [agent.id, req.session!.user.id, resolvedKid], ); From dd41064fb3364d9664d9b92e687dbfca80b68368 Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Tue, 24 Feb 2026 18:08:34 +0500 Subject: [PATCH 47/49] Fix proxy header parsing for signature-agent;key= format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update parseCoveredHeaders regex to handle RFC 9421 component parameters like "signature-agent";key="sig1" - Add tests for new IETF format with ;key= parameters - Bump version to 0.1.7 (requires npm republish) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/proxy/package.json | 2 +- packages/proxy/src/headers.test.ts | 15 +++++++++++++++ packages/proxy/src/headers.ts | 22 ++++++++++++++++------ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/packages/proxy/package.json b/packages/proxy/package.json index 66a7027..c3d7192 100644 --- a/packages/proxy/package.json +++ b/packages/proxy/package.json @@ -1,6 +1,6 @@ { "name": "@openbotauth/proxy", - "version": "0.1.6", + "version": "0.1.7", "description": "Web Bot Auth / OpenBotAuth reverse proxy for verifying RFC 9421 HTTP signatures", "type": "module", "main": "./dist/server.js", diff --git a/packages/proxy/src/headers.test.ts b/packages/proxy/src/headers.test.ts index 35994dc..655372a 100644 --- a/packages/proxy/src/headers.test.ts +++ b/packages/proxy/src/headers.test.ts @@ -83,6 +83,21 @@ describe('parseCoveredHeaders', () => { const input = 'sig1=( "@method" "@path" );created=1618884473'; expect(parseCoveredHeaders(input)).toEqual(['@method', '@path']); }); + + it('handles signature-agent with ;key= parameter (new IETF format)', () => { + const input = 'sig1=("@method" "@path" "signature-agent";key="sig1");created=1618884473;tag="web-bot-auth"'; + expect(parseCoveredHeaders(input)).toEqual(['@method', '@path', 'signature-agent']); + }); + + it('handles multiple headers with ;key= parameters', () => { + const input = 'sig1=("@method" "@authority" "content-type";req "signature-agent";key="sig1");created=1618884473'; + expect(parseCoveredHeaders(input)).toEqual(['@method', '@authority', 'content-type', 'signature-agent']); + }); + + it('handles plain signature-agent without parameters (legacy format)', () => { + const input = 'sig1=("@method" "@path" "signature-agent");created=1618884473'; + expect(parseCoveredHeaders(input)).toEqual(['@method', '@path', 'signature-agent']); + }); }); describe('getSensitiveCoveredHeader', () => { diff --git a/packages/proxy/src/headers.ts b/packages/proxy/src/headers.ts index 3b92554..77c7c1d 100644 --- a/packages/proxy/src/headers.ts +++ b/packages/proxy/src/headers.ts @@ -61,6 +61,10 @@ export function hasSignatureHeaders(headers: IncomingHttpHeaders): boolean { * Example input: sig1=("@method" "@path" "@authority" "content-type");created=1618884473;... * Returns: ['@method', '@path', '@authority', 'content-type'] * + * Also handles RFC 9421 component parameters like: + * sig1=("@method" "@path" "signature-agent";key="sig1");created=... + * Returns: ['@method', '@path', 'signature-agent'] + * * Handles both quoted and unquoted tokens, extra whitespace, and invalid input. */ export function parseCoveredHeaders(signatureInput: string): string[] { @@ -77,16 +81,22 @@ export function parseCoveredHeaders(signatureInput: string): string[] { const result: string[] = []; - // Match both quoted ("header") and unquoted (header) tokens - // Quoted: "header-name" - // Unquoted: header-name (letters, digits, hyphens, @) - const tokenRegex = /"([^"]+)"|([a-zA-Z@][a-zA-Z0-9-]*)/g; + // Match quoted components with optional parameter tails: "header-name";key="value" + // Or unquoted tokens (less common but valid) + // Group 1: quoted content (may include params after closing quote) + // Group 2: unquoted token + const tokenRegex = /"([^"\\]*(?:\\.[^"\\]*)*)"(?:;[^\s]+)?|([a-zA-Z@][a-zA-Z0-9-]*)/g; let tokenMatch; while ((tokenMatch = tokenRegex.exec(content)) !== null) { - // Group 1 is quoted content, group 2 is unquoted - const token = tokenMatch[1] || tokenMatch[2]; + // Group 1 is quoted content (without params), group 2 is unquoted + let token = tokenMatch[1] || tokenMatch[2]; if (token) { + // Strip any params that might have been captured (defensive) + const semiPos = token.indexOf(';'); + if (semiPos !== -1) { + token = token.slice(0, semiPos); + } result.push(token.toLowerCase()); } } From 647a1080a07419f74e615141553d2524f0236eaf Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Tue, 24 Feb 2026 18:23:38 +0500 Subject: [PATCH 48/49] chore: enforce node20 in verifier-service and fix crypto key types --- packages/verifier-service/package.json | 4 +++- packages/verifier-service/src/x509.test.ts | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/verifier-service/package.json b/packages/verifier-service/package.json index 72b6e4d..4de6da5 100644 --- a/packages/verifier-service/package.json +++ b/packages/verifier-service/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/server.js", "types": "./dist/server.d.ts", + "engines": { + "node": ">=20.0.0" + }, "scripts": { "build": "tsc", "dev": "tsx watch --env-file=../../.env src/server.ts", @@ -38,4 +41,3 @@ "redis": "^4.6.13" } } - diff --git a/packages/verifier-service/src/x509.test.ts b/packages/verifier-service/src/x509.test.ts index f6b434e..f724d26 100644 --- a/packages/verifier-service/src/x509.test.ts +++ b/packages/verifier-service/src/x509.test.ts @@ -134,7 +134,7 @@ describe("validateJwkX509", () => { }); describe("validateJwkX509 EKU and SAN validation", () => { - let testCaKeyPair: CryptoKeyPair; + let testCaKeyPair: webcrypto.CryptoKeyPair; let testCaCert: any; let testCaPem: string; @@ -149,7 +149,7 @@ describe("validateJwkX509 EKU and SAN validation", () => { { name: "Ed25519" } as any, true, ["sign", "verify"], - ) as CryptoKeyPair; + ) as webcrypto.CryptoKeyPair; const notBefore = new Date(); const notAfter = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000); @@ -180,12 +180,12 @@ describe("validateJwkX509 EKU and SAN validation", () => { includeClientAuthEku?: boolean; includeServerAuthEku?: boolean; sanUri?: string; - }): Promise<{ cert: any; keyPair: CryptoKeyPair; jwk: any }> { + }): Promise<{ cert: any; keyPair: webcrypto.CryptoKeyPair; jwk: any }> { const keyPair = await webcrypto.subtle.generateKey( { name: "Ed25519" } as any, true, ["sign", "verify"], - ) as CryptoKeyPair; + ) as webcrypto.CryptoKeyPair; const extensions: any[] = [ new (x509Lib as any).BasicConstraintsExtension(false, undefined, true), From 82c04b112a6195d09b91d800826a205d9a5959ba Mon Sep 17 00:00:00 2001 From: Hammad Tariq Date: Tue, 24 Feb 2026 19:18:33 +0500 Subject: [PATCH 49/49] fix: align WBA signing/verifier behavior and cert issuance flow --- docs/TESTING_GUIDE.md | 2 +- packages/bot-cli/src/request-signer.test.ts | 21 ++ packages/bot-cli/src/request-signer.ts | 5 +- packages/proxy/src/server.ts | 1 + packages/proxy/src/types.ts | 1 + packages/registry-service/README.md | 11 ++ .../src/routes/__tests__/certs.test.ts | 36 +++- packages/registry-service/src/routes/certs.ts | 89 ++++++--- packages/registry-service/src/server.ts | 19 ++ packages/verifier-client/src/client.ts | 1 + packages/verifier-client/src/types.ts | 1 + packages/verifier-service/README.md | 14 +- .../src/__tests__/signature-e2e.test.ts | 184 ++++++++++++++++-- .../verifier-service/src/nonce-manager.ts | 11 +- packages/verifier-service/src/server.ts | 20 +- .../src/signature-parser.test.ts | 107 +++++++++- .../verifier-service/src/signature-parser.ts | 78 +++++++- .../src/signature-verifier.test.ts | 159 +++++++++++++++ .../src/signature-verifier.ts | 162 +++++++++++---- packages/verifier-service/src/types.ts | 12 ++ .../python/src/openbotauth_verifier/client.py | 8 + .../python/src/openbotauth_verifier/models.py | 2 + 22 files changed, 829 insertions(+), 115 deletions(-) diff --git a/docs/TESTING_GUIDE.md b/docs/TESTING_GUIDE.md index e94f27c..94ba848 100644 --- a/docs/TESTING_GUIDE.md +++ b/docs/TESTING_GUIDE.md @@ -76,7 +76,7 @@ curl http://localhost:3000/protected **Expected:** ```json { - "error": "Missing required signature headers (Signature-Input, Signature, Signature-Agent)", + "error": "Missing required signature headers (Signature-Input, Signature)", "message": "Signature verification failed" } ``` diff --git a/packages/bot-cli/src/request-signer.test.ts b/packages/bot-cli/src/request-signer.test.ts index 26d0944..f861450 100644 --- a/packages/bot-cli/src/request-signer.test.ts +++ b/packages/bot-cli/src/request-signer.test.ts @@ -40,4 +40,25 @@ describe("RequestSigner", () => { // IETF draft: tag="web-bot-auth" is mandatory expect(signed.headers["Signature-Input"]).toContain('tag="web-bot-auth"'); }); + + it("includes tag in signed @signature-params base", () => { + const signer = new RequestSigner(makeConfig()); + const params = { + created: 1700000000, + expires: 1700000300, + nonce: "nonce123", + keyId: "test-kid", + algorithm: "ed25519", + tag: "web-bot-auth", + headers: ["@method", "@path", "@authority"], + }; + + const signatureBase = (signer as any).buildSignatureBase(params, { + method: "GET", + path: "/v1/test", + authority: "example.com", + }); + + expect(signatureBase).toContain(';tag="web-bot-auth"'); + }); }); diff --git a/packages/bot-cli/src/request-signer.ts b/packages/bot-cli/src/request-signer.ts index fc30b92..53b11ea 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -152,7 +152,7 @@ export class RequestSigner { const dictMatch = request.signatureAgent.match(new RegExp(`${escapedDictKey}="([^"]+)"`)); const uriValue = dictMatch ? dictMatch[1] : request.signatureAgent; lines.push( - `"signature-agent";key="${dictKey}": ${this.serializeSfString(uriValue)}`, + `"signature-agent": ${dictKey}=${this.serializeSfString(uriValue)}`, ); continue; } @@ -173,6 +173,9 @@ export class RequestSigner { paramParts.push(`nonce="${params.nonce}"`); paramParts.push(`keyid="${params.keyId}"`); paramParts.push(`alg="${params.algorithm}"`); + if (params.tag) { + paramParts.push(`tag="${params.tag}"`); + } lines.push(`"@signature-params": ${paramParts.join(';')}`); diff --git a/packages/proxy/src/server.ts b/packages/proxy/src/server.ts index f63552d..7d055fc 100644 --- a/packages/proxy/src/server.ts +++ b/packages/proxy/src/server.ts @@ -143,6 +143,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise method: req.method || 'GET', url: reconstructUrl(req), headers: forwardedHeaders, + jwksUrl: getHeaderString(req.headers, 'x-obauth-jwks-url'), }; // Call verifier diff --git a/packages/proxy/src/types.ts b/packages/proxy/src/types.ts index e2595fe..c78aaa4 100644 --- a/packages/proxy/src/types.ts +++ b/packages/proxy/src/types.ts @@ -18,6 +18,7 @@ export interface VerifierRequest { url: string; headers: Record; body?: string; + jwksUrl?: string; } /** diff --git a/packages/registry-service/README.md b/packages/registry-service/README.md index c3b69a4..975a28f 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -79,6 +79,17 @@ Serve JWKS for a user's public keys. } ``` +#### GET `/.well-known/http-message-signatures-directory` + +Multi-tenant convenience discovery endpoint. + +Query parameters: +- `username` (required) + +Behavior: +- Redirects to `/jwks/{username}.json`. +- Returns `400` if `username` is missing. + ### Signature Agent Card #### GET `/.well-known/signature-agent-card` diff --git a/packages/registry-service/src/routes/__tests__/certs.test.ts b/packages/registry-service/src/routes/__tests__/certs.test.ts index ccedb23..924eee3 100644 --- a/packages/registry-service/src/routes/__tests__/certs.test.ts +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -268,14 +268,14 @@ describe("POST /v1/certs/issue - PoP validation", () => { expect(res.body.error).toContain("proof-of-possession"); }); - it("rolls back transaction when validation fails after BEGIN", async () => { + it("returns early before opening transaction when proof is missing", async () => { const query = vi.fn().mockImplementation(async (sql: string) => { if (sql === "BEGIN" || sql === "ROLLBACK") { return { rows: [] }; } if ( sql.includes( - "SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE", + "SELECT * FROM agents WHERE id = $1 AND user_id = $2", ) ) { return { @@ -305,8 +305,8 @@ describe("POST /v1/certs/issue - PoP validation", () => { await callRoute(certsRouter, "POST", "/v1/certs/issue", req, res); expect(res.statusCode).toBe(400); - expect(query).toHaveBeenCalledWith("BEGIN"); - expect(query).toHaveBeenCalledWith("ROLLBACK"); + expect(query).not.toHaveBeenCalledWith("BEGIN"); + expect(query).not.toHaveBeenCalledWith("ROLLBACK"); expect(query).not.toHaveBeenCalledWith("COMMIT"); }); @@ -433,7 +433,7 @@ describe("POST /v1/certs/issue - PoP validation", () => { if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { return { rows: [] }; } - if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { return { rows: [ { @@ -500,6 +500,22 @@ describe("POST /v1/certs/issue - PoP validation", () => { ).toString("base64"); const poolQuery = vi.fn().mockImplementation(async (sql: string) => { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { + return { + rows: [ + { + id: agentId, + user_id: "u-1", + name: "Test Agent", + public_key: { + kty: "OKP", + crv: "Ed25519", + x: publicJwk.x, + }, + }, + ], + }; + } if (sql.includes("SELECT check_pop_nonce")) { return { rows: [{ is_new: true }] }; } @@ -513,7 +529,7 @@ describe("POST /v1/certs/issue - PoP validation", () => { if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { return { rows: [] }; } - if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { return { rows: [ { @@ -606,7 +622,7 @@ describe("POST /v1/certs/issue - PoP validation", () => { if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { return { rows: [] }; } - if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { return { rows: [ { @@ -669,7 +685,7 @@ describe("POST /v1/certs/issue - PoP validation", () => { if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { return { rows: [] }; } - if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { return { rows: [ { @@ -732,7 +748,7 @@ describe("POST /v1/certs/issue - PoP validation", () => { if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { return { rows: [] }; } - if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { return { rows: [ { @@ -795,7 +811,7 @@ describe("POST /v1/certs/issue - PoP validation", () => { if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") { return { rows: [] }; } - if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE")) { + if (sql.includes("SELECT * FROM agents WHERE id = $1 AND user_id = $2")) { return { rows: [ { diff --git a/packages/registry-service/src/routes/certs.ts b/packages/registry-service/src/routes/certs.ts index 9a8d9b9..d01265a 100644 --- a/packages/registry-service/src/routes/certs.ts +++ b/packages/registry-service/src/routes/certs.ts @@ -4,6 +4,7 @@ import { webcrypto, createHash } from "node:crypto"; import { Router, type Request, type Response } from "express"; +import type { PoolClient } from "pg"; import type { Database } from "@openbotauth/github-connector"; import { issueCertificateForJwk, getCertificateAuthority } from "../utils/ca.js"; import { requireScope } from "../middleware/scopes.js"; @@ -220,11 +221,11 @@ certsRouter.post( requireScope("agents:write"), async (req: Request, res: Response): Promise => { const db: Database = req.app.locals.db; - const client = await db.getPool().connect(); + let client: PoolClient | null = null; let transactionOpen = false; const rollbackIfNeeded = async () => { - if (!transactionOpen) return; + if (!transactionOpen || !client) return; try { await client.query("ROLLBACK"); } catch (rollbackError) { @@ -235,7 +236,7 @@ certsRouter.post( }; try { - const { agent_id } = req.body || {}; + const { agent_id, proof } = req.body || {}; if (!agent_id) { res.status(400).json({ @@ -244,41 +245,42 @@ certsRouter.post( return; } - await client.query("BEGIN"); - transactionOpen = true; + if (!proof) { + res.status(400).json({ + error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{agent_id}:{timestamp}', signature: '' }", + }); + return; + } - const result = await client.query( - `SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE`, + // Preflight fetch/verification before opening a transaction so replay-check + // can run without requiring a second pool checkout while a txn client is held. + const preflightResult = await db.getPool().query( + `SELECT * FROM agents WHERE id = $1 AND user_id = $2`, [agent_id, req.session!.user.id], ); - const agent = result.rows[0] || null; + const preflightAgent = preflightResult.rows[0] || null; - if (!agent) { - await rollbackIfNeeded(); + if (!preflightAgent) { res.status(404).json({ error: "Agent not found" }); return; } - const pk = agent.public_key; - if (!pk || typeof pk !== "object" || typeof pk.x !== "string") { - await rollbackIfNeeded(); + const preflightPk = preflightAgent.public_key; + if ( + !preflightPk || + typeof preflightPk !== "object" || + typeof preflightPk.x !== "string" + ) { res.status(400).json({ error: "Agent public key is invalid" }); return; } - // Verify proof-of-possession: caller must prove they have the private key - const { proof } = req.body || {}; - if (!proof) { - await rollbackIfNeeded(); - res.status(400).json({ - error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{agent_id}:{timestamp}', signature: '' }", - }); - return; - } - - const popResult = await verifyProofOfPossession(proof, agent.id, pk); + const popResult = await verifyProofOfPossession( + proof, + preflightAgent.id, + preflightPk, + ); if (!popResult.valid) { - await rollbackIfNeeded(); res.status(403).json({ error: `Proof-of-possession failed: ${popResult.error}`, }); @@ -289,7 +291,6 @@ certsRouter.post( // persistence is not rolled back with the issuance transaction. const isNewNonce = await checkPopNonce(db.getPool(), proof.message); if (!isNewNonce) { - await rollbackIfNeeded(); res.status(403).json({ error: "Proof-of-possession replay detected or replay protection unavailable", @@ -297,6 +298,40 @@ certsRouter.post( return; } + client = await db.getPool().connect(); + await client.query("BEGIN"); + transactionOpen = true; + + const result = await client.query( + `SELECT * FROM agents WHERE id = $1 AND user_id = $2 FOR UPDATE`, + [agent_id, req.session!.user.id], + ); + const agent = result.rows[0] || null; + + if (!agent) { + await rollbackIfNeeded(); + res.status(404).json({ error: "Agent not found" }); + return; + } + + const pk = agent.public_key; + if (!pk || typeof pk !== "object" || typeof pk.x !== "string") { + await rollbackIfNeeded(); + res.status(400).json({ error: "Agent public key is invalid" }); + return; + } + + // Re-check proof against locked key material to prevent key-update races + // between preflight verification and transaction start. + const lockedPopResult = await verifyProofOfPossession(proof, agent.id, pk); + if (!lockedPopResult.valid) { + await rollbackIfNeeded(); + res.status(403).json({ + error: `Proof-of-possession failed: ${lockedPopResult.error}`, + }); + return; + } + const resolvedKid = typeof pk.kid === "string" && pk.kid.length > 0 ? pk.kid @@ -409,7 +444,7 @@ certsRouter.post( console.error("Certificate issuance error:", error); res.status(500).json({ error: "Failed to issue certificate" }); } finally { - client.release(); + client?.release(); } }, ); diff --git a/packages/registry-service/src/server.ts b/packages/registry-service/src/server.ts index 7929548..c0f99ba 100644 --- a/packages/registry-service/src/server.ts +++ b/packages/registry-service/src/server.ts @@ -94,6 +94,25 @@ app.get('/health', (_req: express.Request, res: express.Response) => { }); // Routes +app.get('/.well-known/http-message-signatures-directory', (req, res) => { + // Multi-tenant registry convenience endpoint: + // map well-known discovery to a user's JWKS directory via query parameter. + const username = + typeof req.query.username === 'string' ? req.query.username.trim() : ''; + + if (!username) { + res.status(400).json({ + error: 'Missing username query parameter', + hint: + 'Use /.well-known/http-message-signatures-directory?username= or /jwks/.json', + }); + return; + } + + const encodedUsername = encodeURIComponent(username); + res.redirect(302, `/jwks/${encodedUsername}.json`); +}); + app.use('/jwks', jwksRouter); app.use(signatureAgentCardRouter); diff --git a/packages/verifier-client/src/client.ts b/packages/verifier-client/src/client.ts index 6498690..4dd247f 100644 --- a/packages/verifier-client/src/client.ts +++ b/packages/verifier-client/src/client.ts @@ -105,6 +105,7 @@ export class VerifierClient { url: input.url, headers: extractResult.headers, ...(input.body !== undefined && { body: input.body }), + ...(input.jwksUrl !== undefined && { jwksUrl: input.jwksUrl }), }; const controller = new AbortController(); diff --git a/packages/verifier-client/src/types.ts b/packages/verifier-client/src/types.ts index 127e8a4..383c158 100644 --- a/packages/verifier-client/src/types.ts +++ b/packages/verifier-client/src/types.ts @@ -6,6 +6,7 @@ export interface VerificationRequest { url: string; headers: Record; body?: string; + jwksUrl?: string; } /** diff --git a/packages/verifier-service/README.md b/packages/verifier-service/README.md index b769fc6..596630e 100644 --- a/packages/verifier-service/README.md +++ b/packages/verifier-service/README.md @@ -69,19 +69,23 @@ Main verification endpoint for NGINX `auth_request`. - `X-Original-Uri` - Request URI - `Signature-Input` - RFC 9421 signature input - `Signature` - RFC 9421 signature -- `Signature-Agent` - Structured Dictionary entry pointing to JWKS (legacy URL also accepted) +- `Signature-Agent` - Optional Structured Dictionary entry pointing to JWKS (legacy URL also accepted) +- `X-OBAuth-JWKS-URL` - Optional out-of-band JWKS/directory URL when `Signature-Agent` is omitted **WBA requirements enforced:** - `Signature-Input` must include `tag="web-bot-auth"` -- `Signature-Agent` must be a covered component in `Signature-Input` (prefer `signature-agent;key="sigX"`) +- `Signature-Input` must include integer `created` and `expires` +- Covered components must include at least one of `@authority` or `@target-uri` +- If `Signature-Agent` is present, it must be a covered component in `Signature-Input` (prefer `signature-agent;key="sigX"`) **X.509 delegation notes:** - `x5c` chains are validated to configured trust anchors when `OBA_X509_ENABLED=true` - `x5u` currently fetches only the leaf certificate; without AIA chain building, validation succeeds only if the leaf chains directly to a trust anchor (or the anchor is an intermediate) -- Verifier enforces issuer CA constraints for chain signers; it does not currently enforce EKU on leaf certs or bind certificate identity to the Signature-Agent URL +- Verifier enforces issuer CA constraints for chain signers and leaf EKU (`clientAuth`) by default +- Verifier can optionally enforce SAN URI identity binding when `expectedSanUri` is configured by the caller **Signature parsing scope (MVP):** -- If multiple labels are present in `Signature-Input`/`Signature`, verifier currently validates the first parsable `Signature-Input` member and matches `Signature` by that label. +- If multiple labels are present in `Signature-Input`/`Signature`, verifier selects the matching labeled member that satisfies WBA constraints. **Response:** @@ -203,7 +207,7 @@ server { ## How It Works 1. **Request arrives** with RFC 9421 signature headers -2. **Parse headers** - Extract `Signature-Input`, `Signature`, `Signature-Agent` +2. **Parse headers** - Extract `Signature-Input`, `Signature`, and optional `Signature-Agent` 3. **Validate JWKS URL** - Check against trusted directories (if configured) 4. **Fetch public key** - Get JWKS from cache or fetch from URL 5. **Check timestamp** - Validate `created` and `expires` within clock skew diff --git a/packages/verifier-service/src/__tests__/signature-e2e.test.ts b/packages/verifier-service/src/__tests__/signature-e2e.test.ts index 2aebc1e..ba1e811 100644 --- a/packages/verifier-service/src/__tests__/signature-e2e.test.ts +++ b/packages/verifier-service/src/__tests__/signature-e2e.test.ts @@ -67,7 +67,7 @@ describe("E2E Signature Verification", () => { // 2. Build Signature-Input header const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; // 3. Parse signature input to get components @@ -122,6 +122,156 @@ describe("E2E Signature Verification", () => { expect(result.agent?.client_name).toBe("test-agent"); }); + it("verifies when Signature-Agent is omitted but jwksUrl is provided out-of-band", async () => { + const method = "GET"; + const url = "https://example.com/api/out-of-band"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from( + webcrypto.getRandomValues(new Uint8Array(16)), + ).toString("base64url"); + + const coveredHeaders = ["@method", "@path", "@authority"]; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput); + expect(components).not.toBeNull(); + + const signatureBase = buildSignatureBase(components!, { + method, + url, + headers: {}, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase), + ); + const signatureHeader = `sig1=:${Buffer.from(signatureBytes).toString("base64")}:`; + + const mockJwksCache = createMockJwksCache(publicJwk); + const mockNonceManager = createMockNonceManager(); + const verifier = new SignatureVerifier( + mockJwksCache as any, + mockNonceManager as any, + ["trusted.example.com"], + ); + + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + signature: signatureHeader, + }, + jwksUrl: "https://trusted.example.com/jwks.json", + }); + + expect(result.verified).toBe(true); + expect(result.error).toBeUndefined(); + expect(mockJwksCache.getKey).toHaveBeenCalledWith( + "https://trusted.example.com/jwks.json", + publicJwk.kid, + ); + }); + + it("rejects signatures missing required expires parameter", async () => { + const method = "GET"; + const url = "https://example.com/api/missing-expires"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from( + webcrypto.getRandomValues(new Uint8Array(16)), + ).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: { + "signature-agent": signatureAgentHeader, + }, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase), + ); + const signatureHeader = `sig1=:${Buffer.from(signatureBytes).toString("base64")}:`; + + const verifier = new SignatureVerifier( + createMockJwksCache(publicJwk) as any, + createMockNonceManager() as any, + ["trusted.example.com"], + ); + + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + signature: signatureHeader, + "signature-agent": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("created and expires"); + }); + + it("rejects signatures missing both @authority and @target-uri coverage", async () => { + const method = "GET"; + const url = "https://example.com/api/missing-authority-binding"; + const created = Math.floor(Date.now() / 1000); + const nonce = Buffer.from( + webcrypto.getRandomValues(new Uint8Array(16)), + ).toString("base64url"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureInput = `sig1=${signatureParams}`; + + const components = parseSignatureInput(signatureInput)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: { + "signature-agent": signatureAgentHeader, + }, + }); + + const signatureBytes = await webcrypto.subtle.sign( + "Ed25519", + keyPair.privateKey, + new TextEncoder().encode(signatureBase), + ); + const signatureHeader = `sig1=:${Buffer.from(signatureBytes).toString("base64")}:`; + + const verifier = new SignatureVerifier( + createMockJwksCache(publicJwk) as any, + createMockNonceManager() as any, + ["trusted.example.com"], + ); + + const result = await verifier.verify({ + method, + url, + headers: { + "signature-input": signatureInput, + signature: signatureHeader, + "signature-agent": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("@authority/@target-uri"); + }); + it("rejects signature with wrong key", async () => { // Generate a different keypair for signing (simulates attacker) const attackerKeyPair = (await webcrypto.subtle.generateKey( @@ -136,8 +286,8 @@ describe("E2E Signature Verification", () => { const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; @@ -188,8 +338,8 @@ describe("E2E Signature Verification", () => { const created = Math.floor(Date.now() / 1000); const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; @@ -234,10 +384,10 @@ describe("E2E Signature Verification", () => { const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; const badCovered = ["@method", "@path"]; - const badParams = `(${badCovered.map(formatCoveredComponent).join(" ")});created=${created};keyid="other-kid";nonce="${nonce}";alg="ed25519"`; + const badParams = `(${badCovered.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="other-kid";nonce="${nonce}";alg="ed25519"`; - const goodCovered = ["@method", "@path", 'signature-agent;key="sig1"']; - const goodParams = `(${goodCovered.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const goodCovered = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const goodParams = `(${goodCovered.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig0=${badParams}, sig1=${goodParams}`; @@ -282,8 +432,8 @@ describe("E2E Signature Verification", () => { const created = Math.floor(Date.now() / 1000); const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path"]; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const coveredHeaders = ["@method", "@path", "@authority"]; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; @@ -325,8 +475,8 @@ describe("E2E Signature Verification", () => { const nonce = Buffer.from(webcrypto.getRandomValues(new Uint8Array(16))).toString("base64url"); const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; @@ -381,7 +531,7 @@ describe("E2E Signature Verification", () => { // Include content-type in covered headers const coveredHeaders = ["@method", "@path", "@authority", "content-type", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const requestHeaders: Record = { @@ -436,8 +586,8 @@ describe("E2E Signature Verification", () => { const signatureLabel = "sig2"; const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json", sig2="https://attacker.example/jwks.json"'; - const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `${signatureLabel}=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; @@ -490,8 +640,8 @@ describe("E2E Signature Verification", () => { const nonce = "replayed-nonce"; const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; - const coveredHeaders = ["@method", "@path", 'signature-agent;key="sig1"']; - const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; + const coveredHeaders = ["@method", "@path", "@authority", 'signature-agent;key="sig1"']; + const signatureParams = `(${coveredHeaders.map(formatCoveredComponent).join(" ")});created=${created};expires=${created + 300};keyid="${publicJwk.kid}";nonce="${nonce}";alg="ed25519";tag="web-bot-auth"`; const signatureInput = `sig1=${signatureParams}`; const components = parseSignatureInput(signatureInput)!; diff --git a/packages/verifier-service/src/nonce-manager.ts b/packages/verifier-service/src/nonce-manager.ts index a57baa5..79c3bb7 100644 --- a/packages/verifier-service/src/nonce-manager.ts +++ b/packages/verifier-service/src/nonce-manager.ts @@ -17,7 +17,12 @@ export class NonceManager { * Check if a nonce has been used * Returns true if nonce is fresh (not used) */ - async checkNonce(nonce: string, jwksUrl: string, kid: string): Promise { + async checkNonce( + nonce: string, + jwksUrl: string, + kid: string, + ttlSec?: number, + ): Promise { const key = this.buildNonceKey(nonce, jwksUrl, kid); // Check if nonce exists @@ -29,7 +34,8 @@ export class NonceManager { } // Mark nonce as used - await this.redis.setEx(key, this.ttl, '1'); + const effectiveTtl = Math.max(1, Math.ceil(ttlSec ?? this.ttl)); + await this.redis.setEx(key, effectiveTtl, '1'); return true; } @@ -100,4 +106,3 @@ export class NonceManager { } } } - diff --git a/packages/verifier-service/src/server.ts b/packages/verifier-service/src/server.ts index 1c57b4a..26fb7ed 100644 --- a/packages/verifier-service/src/server.ts +++ b/packages/verifier-service/src/server.ts @@ -26,7 +26,10 @@ app.use(express.raw({ type: 'application/octet-stream', limit: '10mb' })); app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Signature-Input, Signature, Signature-Agent'); + res.setHeader( + 'Access-Control-Allow-Headers', + 'Content-Type, Authorization, Signature-Input, Signature, Signature-Agent, X-OBAuth-JWKS-URL', + ); if (req.method === 'OPTIONS') { res.sendStatus(200); @@ -209,6 +212,7 @@ app.post('/authorize', async (req, res) => { const url = `${protocol}://${host}${uri}`; const signatureAgent = req.headers['signature-agent'] as string | undefined; + const outOfBandJwksUrl = req.headers['x-obauth-jwks-url'] as string | undefined; // Check if this is a signed request (for Radar telemetry) const isSigned = hasSignatureHeaders({ @@ -236,6 +240,7 @@ app.post('/authorize', async (req, res) => { url, headers, body: req.body ? JSON.stringify(req.body) : undefined, + jwksUrl: outOfBandJwksUrl, }; // Verify signature @@ -311,7 +316,7 @@ app.post('/authorize', async (req, res) => { */ app.post('/verify', async (req, res) => { try { - const { method, url, headers, body } = req.body; + const { method, url, headers, body, jwksUrl, jwks_url } = req.body; if (!method || !url || !headers) { res.status(400).json({ @@ -321,6 +326,10 @@ app.post('/verify', async (req, res) => { } const signatureAgent = headers['signature-agent'] as string | undefined; + const outOfBandJwksUrl = + (typeof jwksUrl === 'string' && jwksUrl.length > 0 ? jwksUrl : undefined) || + (typeof jwks_url === 'string' && jwks_url.length > 0 ? jwks_url : undefined) || + (headers['x-obauth-jwks-url'] as string | undefined); // Check if this is a signed request (for Radar telemetry) const isSigned = hasSignatureHeaders({ @@ -334,14 +343,15 @@ app.post('/verify', async (req, res) => { url, headers, body, + jwksUrl: outOfBandJwksUrl, }; const result = await verifier.verify(verificationRequest); // Compute values for telemetry const targetOrigin = new URL(url).origin; - const jwksUrl = result.agent?.jwks_url || signatureAgent || null; - const username = extractUsernameFromJwksUrl(jwksUrl); + const resolvedJwksUrl = result.agent?.jwks_url || signatureAgent || outOfBandJwksUrl || null; + const username = extractUsernameFromJwksUrl(resolvedJwksUrl); // Log signed attempt for Radar (both success and failure) if (isSigned && app.locals.telemetryLogger) { @@ -352,7 +362,7 @@ app.post('/verify', async (req, res) => { verified: result.verified, failureReason: result.verified ? null : mapErrorToFailureReason(result.error), username, - jwksUrl, + jwksUrl: resolvedJwksUrl, clientName: result.agent?.client_name || null, }).catch((err: Error) => { console.error('Signed attempt logging failed:', err); diff --git a/packages/verifier-service/src/signature-parser.test.ts b/packages/verifier-service/src/signature-parser.test.ts index 1fc33b2..af546b7 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -263,6 +263,18 @@ describe("parseSignatureInput", () => { expect(parsed?.label).toBe("sig-1.test"); }); + it("should parse labels that start with digit or asterisk", () => { + const digitLabel = parseSignatureInput( + '1sig=("@method");created=1;expires=2;keyid="k1";alg="ed25519"', + ); + expect(digitLabel?.label).toBe("1sig"); + + const asteriskLabel = parseSignatureInput( + '*sig=("@method");created=1;expires=2;keyid="k1";alg="ed25519"', + ); + expect(asteriskLabel?.label).toBe("*sig"); + }); + it("should parse first member when multiple signature-input members are present", () => { const parsed = parseSignatureInput( 'sig1=("@method");created=1;keyid="k1";alg="ed25519", sig2=("@path");created=2;keyid="k2";alg="ed25519"', @@ -289,6 +301,13 @@ describe("parseSignatureInput", () => { expect(parsed?.tag).toBe("web-bot-auth"); expect(parsed?.headers).toContain('signature-agent;key="sig1"'); }); + + it("should keep '=' characters inside quoted parameter values", () => { + const parsed = parseSignatureInput( + 'sig1=("@authority");created=1;expires=2;nonce="abc==";keyid="k1";alg="ed25519";tag="web-bot-auth"', + ); + expect(parsed?.nonce).toBe("abc=="); + }); }); describe("parseSignature", () => { @@ -297,6 +316,11 @@ describe("parseSignature", () => { expect(parsed).toBe("Zm9vYmFyOg=="); }); + it("should parse signature labels that start with digit or asterisk", () => { + expect(parseSignature("1sig=:Zm9vOg==:")).toBe("Zm9vOg=="); + expect(parseSignature("*sig=:YmFyOg==:")).toBe("YmFyOg=="); + }); + it("should select matching signature by label when multiple members are present", () => { const parsed = parseSignature( "sig1=:Zmlyc3Q=:, sig2=:c2Vjb25k:", @@ -512,6 +536,85 @@ describe("resolveJwksUrl", () => { }), ); }); + + it("should accept valid JWKS Content-Type: application/json", async () => { + const validJwks = { keys: [{ kid: "test", kty: "OKP" }] }; + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Map([ + ["content-type", "application/json"], + ["content-length", "100"], + ]), + json: async () => validJwks, + }); + + const result = await resolveJwksUrl("https://example.com"); + expect(result).toBe( + "https://example.com/.well-known/http-message-signatures-directory", + ); + }); + + it("should accept valid JWKS Content-Type: application/jwk-set+json", async () => { + const validJwks = { keys: [{ kid: "test", kty: "OKP" }] }; + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Map([ + ["content-type", "application/jwk-set+json"], + ["content-length", "100"], + ]), + json: async () => validJwks, + }); + + const result = await resolveJwksUrl("https://example.com"); + expect(result).toBe( + "https://example.com/.well-known/http-message-signatures-directory", + ); + }); + + it("should accept Content-Type with charset parameter", async () => { + const validJwks = { keys: [{ kid: "test", kty: "OKP" }] }; + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Map([ + ["content-type", "application/json; charset=utf-8"], + ["content-length", "100"], + ]), + json: async () => validJwks, + }); + + const result = await resolveJwksUrl("https://example.com"); + expect(result).toBe( + "https://example.com/.well-known/http-message-signatures-directory", + ); + }); + + it("should reject invalid Content-Type: text/html", async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Map([ + ["content-type", "text/html"], + ["content-length", "100"], + ]), + json: async () => ({ keys: [{ kid: "test" }] }), + }); + + const result = await resolveJwksUrl("https://example.com"); + expect(result).toBeNull(); + }); + + it("should accept missing Content-Type for compatibility", async () => { + const validJwks = { keys: [{ kid: "test", kty: "OKP" }] }; + fetchMock.mockResolvedValueOnce({ + ok: true, + headers: new Map([["content-length", "100"]]), + json: async () => validJwks, + }); + + const result = await resolveJwksUrl("https://example.com"); + expect(result).toBe( + "https://example.com/.well-known/http-message-signatures-directory", + ); + }); }); describe("buildSignatureBase", () => { @@ -568,7 +671,7 @@ describe("buildSignatureBase", () => { expect(result).toContain('"accept": application/json'); }); - it("serializes selected dictionary member as quoted sf-string", () => { + it("serializes selected dictionary member as key=sf-string", () => { const components = { label: "sig1", keyId: "test-key", @@ -589,7 +692,7 @@ describe("buildSignatureBase", () => { const result = buildSignatureBase(components, request); expect(result).toContain( - '"signature-agent";key="sig1": "https://example.com/jwks.json"', + '"signature-agent": sig1="https://example.com/jwks.json"', ); }); diff --git a/packages/verifier-service/src/signature-parser.ts b/packages/verifier-service/src/signature-parser.ts index 04af5c0..a40829d 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -2,11 +2,22 @@ * RFC 9421 HTTP Message Signatures Parser * * Parses Signature-Input and Signature headers according to RFC 9421 + * with extensions for the IETF Web Bot Auth draft specification. + * + * References: + * - RFC 9421: HTTP Message Signatures + * https://www.rfc-editor.org/rfc/rfc9421.html + * - RFC 8941: Structured Field Values for HTTP + * https://www.rfc-editor.org/rfc/rfc8941.html + * - IETF Web Bot Auth (draft-meunier-web-bot-auth) + * https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth/ + * - HTTP Message Signatures Directory (draft-meunier-http-message-signatures-directory) + * https://datatracker.ietf.org/doc/draft-meunier-http-message-signatures-directory/ */ import type { SignatureComponents } from "./types.js"; -const LABEL_PATTERN = "[A-Za-z][A-Za-z0-9._-]*"; +const LABEL_PATTERN = "[A-Za-z0-9*][A-Za-z0-9._*-]*"; const LABEL_CAPTURE_RE = new RegExp(`^(${LABEL_PATTERN})`); const SIGNATURE_INPUT_LABEL_RE = new RegExp(`^(${LABEL_PATTERN})=(.+)$`); const SIGNATURE_INPUT_RE = new RegExp(`^(${LABEL_PATTERN})=\\(([^)]+)\\);(.+)$`); @@ -14,6 +25,31 @@ const SIGNATURE_RE = new RegExp(`^(${LABEL_PATTERN})=:([^:]+):$`); const DIRECTORY_ACCEPT_HEADER = "application/http-message-signatures-directory+json, application/http-message-signatures-directory, application/jwk-set+json, application/json"; +/** + * Valid Content-Type values for JWKS/directory responses per IETF specs. + * We accept these media types (with optional parameters like charset). + */ +const VALID_JWKS_CONTENT_TYPES = [ + "application/http-message-signatures-directory+json", + "application/http-message-signatures-directory", + "application/jwk-set+json", + "application/json", +]; + +/** + * Check if a Content-Type header value is valid for JWKS/directory responses. + * Handles media type parameters (e.g., "application/json; charset=utf-8"). + */ +function isValidJwksContentType(contentType: string | null): boolean { + if (!contentType) { + // Allow missing Content-Type for compatibility with some servers + return true; + } + // Extract media type (before any semicolon for parameters) + const mediaType = contentType.split(";")[0].trim().toLowerCase(); + return VALID_JWKS_CONTENT_TYPES.includes(mediaType); +} + function splitTopLevelMembers(input: string): string[] { const members: string[] = []; let current = ""; @@ -111,14 +147,26 @@ function parseSingleSignatureInputMember( const paramPairs = paramsStr.split(";"); for (const pair of paramPairs) { - const [key, value] = pair.split("=").map((s) => s.trim()); - if (key && value) { - // Remove quotes and parse numbers - const cleanValue = value.replace(/"/g, ""); - params[key] = isNaN(Number(cleanValue)) - ? cleanValue - : Number(cleanValue); + const trimmed = pair.trim(); + if (!trimmed) continue; + + // Split only on the first '=' so quoted values like nonce="abc==" + // retain all trailing '=' characters. + const separatorIndex = trimmed.indexOf("="); + if (separatorIndex <= 0) continue; + + const key = trimmed.slice(0, separatorIndex).trim(); + const rawValue = trimmed.slice(separatorIndex + 1).trim(); + if (!key || !rawValue) continue; + + if (rawValue.startsWith('"') && rawValue.endsWith('"') && rawValue.length >= 2) { + const inner = rawValue.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\"); + params[key] = inner; + continue; } + + const numeric = Number(rawValue); + params[key] = Number.isNaN(numeric) ? rawValue : numeric; } const parsed: SignatureComponents = { @@ -397,9 +445,10 @@ export function buildSignatureBase( ); } - // RFC 9421 + RFC 8941: dictionary string item must be serialized as sf-string. + // RFC 9421 + RFC 8941: serialize selected dictionary member as key=sf-string. + // Example: "signature-agent": sig2="https://signature-agent.test" lines.push( - `"${headerName}";key="${dictKey}": ${serializeSfString(memberValue)}`, + `"${headerName}": ${dictKey}=${serializeSfString(memberValue)}`, ); } else { // Regular headers - use raw value as-is per RFC 9421 @@ -639,6 +688,15 @@ export async function resolveJwksUrl( continue; // Try next path } + // Validate Content-Type is appropriate for JWKS/directory response + const contentType = response.headers.get("content-type"); + if (!isValidJwksContentType(contentType)) { + console.warn( + `JWKS discovery: ${candidateUrl} has invalid Content-Type: ${contentType}`, + ); + continue; + } + // Limit response size to prevent huge payloads const contentLength = response.headers.get("content-length"); if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) { diff --git a/packages/verifier-service/src/signature-verifier.test.ts b/packages/verifier-service/src/signature-verifier.test.ts index d928254..07fc0a2 100644 --- a/packages/verifier-service/src/signature-verifier.test.ts +++ b/packages/verifier-service/src/signature-verifier.test.ts @@ -242,3 +242,162 @@ describe("SignatureVerifier - Trusted Directory Validation", () => { }); }); }); + +describe("SignatureVerifier - Algorithm Validation", () => { + it("should reject unsupported signature algorithm in Signature-Input", async () => { + const mockJwksCacheForAlg = { + getKey: vi.fn().mockResolvedValue({ kty: "OKP", crv: "Ed25519", x: "abc" }), + getJWKS: vi.fn().mockResolvedValue({ keys: [] }), + }; + const mockNonceManagerForAlg = { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(true), + }; + + const verifier = new SignatureVerifier( + mockJwksCacheForAlg as any, + mockNonceManagerForAlg as any, + [] + ); + + // This test checks that verify() rejects non-ed25519 algorithms + // We need to construct a request that would get past header parsing + const result = await verifier.verify({ + method: "GET", + url: "https://example.com/test", + headers: { + "signature-input": 'sig1=("@method" "@authority");created=1700000000;expires=1700000300;keyid="k1";alg="rsa-sha256";tag="web-bot-auth"', + "signature": "sig1=:dGVzdA==:", + }, + jwksUrl: "https://example.com/jwks.json", + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("Unsupported signature algorithm"); + expect(result.error).toContain("rsa-sha256"); + }); + + it("should accept ed25519 algorithm (case insensitive)", async () => { + const mockJwksCacheForAlg = { + getKey: vi.fn().mockResolvedValue({ kty: "OKP", crv: "Ed25519", x: "abc" }), + getJWKS: vi.fn().mockResolvedValue({ keys: [] }), + }; + const mockNonceManagerForAlg = { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(true), + }; + + const verifier = new SignatureVerifier( + mockJwksCacheForAlg as any, + mockNonceManagerForAlg as any, + [] + ); + + // This should pass algorithm validation (Ed25519 uppercase) + const result = await verifier.verify({ + method: "GET", + url: "https://example.com/test", + headers: { + "signature-input": 'sig1=("@method" "@authority");created=1700000000;expires=1700000300;keyid="k1";alg="Ed25519";tag="web-bot-auth"', + "signature": "sig1=:dGVzdA==:", + }, + jwksUrl: "https://example.com/jwks.json", + }); + + // It should fail later (signature verification) but not on algorithm check + expect(result.error).not.toContain("Unsupported signature algorithm"); + }); + + it("should reject JWK with non-Ed25519 key type", async () => { + const mockJwksCacheForAlg = { + getKey: vi.fn().mockResolvedValue({ kty: "RSA", n: "abc", e: "AQAB" }), + getJWKS: vi.fn().mockResolvedValue({ keys: [] }), + }; + const mockNonceManagerForAlg = { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(true), + }; + + const verifier = new SignatureVerifier( + mockJwksCacheForAlg as any, + mockNonceManagerForAlg as any, + [] + ); + + const result = await verifier.verify({ + method: "GET", + url: "https://example.com/test", + headers: { + "signature-input": 'sig1=("@method" "@authority");created=1700000000;expires=1700000300;keyid="k1";alg="ed25519";tag="web-bot-auth"', + "signature": "sig1=:dGVzdA==:", + }, + jwksUrl: "https://example.com/jwks.json", + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("JWK must be Ed25519"); + expect(result.error).toContain("kty=RSA"); + }); + + it("should reject JWK with wrong curve", async () => { + const mockJwksCacheForAlg = { + getKey: vi.fn().mockResolvedValue({ kty: "OKP", crv: "Ed448", x: "abc" }), + getJWKS: vi.fn().mockResolvedValue({ keys: [] }), + }; + const mockNonceManagerForAlg = { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(true), + }; + + const verifier = new SignatureVerifier( + mockJwksCacheForAlg as any, + mockNonceManagerForAlg as any, + [] + ); + + const result = await verifier.verify({ + method: "GET", + url: "https://example.com/test", + headers: { + "signature-input": 'sig1=("@method" "@authority");created=1700000000;expires=1700000300;keyid="k1";alg="ed25519";tag="web-bot-auth"', + "signature": "sig1=:dGVzdA==:", + }, + jwksUrl: "https://example.com/jwks.json", + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("JWK must be Ed25519"); + expect(result.error).toContain("crv=Ed448"); + }); + + it("should return error when key not found in JWKS", async () => { + const mockJwksCacheForAlg = { + getKey: vi.fn().mockResolvedValue(null), + getJWKS: vi.fn().mockResolvedValue({ keys: [] }), + }; + const mockNonceManagerForAlg = { + checkTimestamp: vi.fn().mockReturnValue({ valid: true }), + checkNonce: vi.fn().mockResolvedValue(true), + }; + + const verifier = new SignatureVerifier( + mockJwksCacheForAlg as any, + mockNonceManagerForAlg as any, + [] + ); + + const result = await verifier.verify({ + method: "GET", + url: "https://example.com/test", + headers: { + "signature-input": 'sig1=("@method" "@authority");created=1700000000;expires=1700000300;keyid="unknown-key";alg="ed25519";tag="web-bot-auth"', + "signature": "sig1=:dGVzdA==:", + }, + jwksUrl: "https://example.com/jwks.json", + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("Key not found in JWKS"); + expect(result.error).toContain("unknown-key"); + }); +}); diff --git a/packages/verifier-service/src/signature-verifier.ts b/packages/verifier-service/src/signature-verifier.ts index a67a717..ebd290a 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -1,7 +1,14 @@ /** * RFC 9421 Signature Verifier - * - * Verifies HTTP message signatures using Ed25519 + * + * Verifies HTTP message signatures using Ed25519 per the IETF Web Bot Auth + * draft specification. + * + * References: + * - RFC 9421: HTTP Message Signatures + * https://www.rfc-editor.org/rfc/rfc9421.html + * - IETF Web Bot Auth (draft-meunier-web-bot-auth) + * https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth/ */ import { webcrypto } from 'node:crypto'; @@ -26,6 +33,10 @@ function isSignatureAgentCovered(coveredComponents: string[]): boolean { ); } +function hasRequiredAuthorityBinding(coveredComponents: string[]): boolean { + return coveredComponents.includes("@authority") || coveredComponents.includes("@target-uri"); +} + export class SignatureVerifier { private discoveryPaths: string[] | undefined; private x509Enabled: boolean; @@ -54,11 +65,12 @@ export class SignatureVerifier { const signatureInput = request.headers['signature-input']; const signature = request.headers['signature']; const signatureAgent = request.headers['signature-agent']; + const outOfBandJwksUrl = request.jwksUrl; - if (!signatureInput || !signature || !signatureAgent) { + if (!signatureInput || !signature) { return { verified: false, - error: 'Missing required signature headers (Signature-Input, Signature, Signature-Agent)', + error: 'Missing required signature headers (Signature-Input, Signature)', }; } @@ -75,7 +87,14 @@ export class SignatureVerifier { if (parsed.tag !== "web-bot-auth") { continue; } - if (!isSignatureAgentCovered(parsed.headers)) { + if (!hasRequiredAuthorityBinding(parsed.headers)) { + continue; + } + const signatureAgentCovered = isSignatureAgentCovered(parsed.headers); + if (signatureAgent && !signatureAgentCovered) { + continue; + } + if (!signatureAgent && signatureAgentCovered) { continue; } components = parsed; @@ -86,36 +105,92 @@ export class SignatureVerifier { return { verified: false, error: - "No matching Signature-Input member with tag=\"web-bot-auth\" and covered signature-agent", + signatureAgent + ? "No matching Signature-Input member with tag=\"web-bot-auth\", required @authority/@target-uri coverage, and covered signature-agent" + : "No matching Signature-Input member with tag=\"web-bot-auth\" and required @authority/@target-uri coverage (or Signature-Agent was covered without a matching header)", + }; + } + + if ( + !Number.isInteger(components.created) || + !Number.isInteger(components.expires) + ) { + return { + verified: false, + error: + 'Signature-Input must include integer created and expires parameters', }; } - // 3. Parse Signature-Agent (Structured Dictionary or legacy URL) - const signatureAgentKey = - extractSignatureAgentDictionaryKey(components.headers) || components.label; - const parsedAgent = parseSignatureAgent(signatureAgent, signatureAgentKey); - if (!parsedAgent) { + // Validate algorithm is Ed25519 (the only supported algorithm per Web Bot Auth spec) + const algorithm = components.algorithm?.toLowerCase(); + if (algorithm && algorithm !== "ed25519") { return { verified: false, - error: 'Invalid Signature-Agent header', + error: `Unsupported signature algorithm: ${components.algorithm}. Only ed25519 is supported.`, }; } - // 4. Resolve JWKS URL (with discovery if needed) let jwksUrl: string; - if (parsedAgent.isJwks) { - // Already a JWKS URL - jwksUrl = parsedAgent.url; + if (signatureAgent) { + // 3. Parse Signature-Agent (Structured Dictionary or legacy URL) + const signatureAgentKey = + extractSignatureAgentDictionaryKey(components.headers) || components.label; + const parsedAgent = parseSignatureAgent(signatureAgent, signatureAgentKey); + if (!parsedAgent) { + return { + verified: false, + error: 'Invalid Signature-Agent header', + }; + } + + // 4. Resolve JWKS URL (with discovery if needed) + if (parsedAgent.isJwks) { + // Already a JWKS URL + jwksUrl = parsedAgent.url; + } else { + // Attempt JWKS discovery + const discoveredUrl = await resolveJwksUrl(parsedAgent.url, this.discoveryPaths); + if (!discoveredUrl) { + return { + verified: false, + error: `JWKS discovery failed for agent: ${parsedAgent.url}`, + }; + } + jwksUrl = discoveredUrl; + } } else { - // Attempt JWKS discovery - const discoveredUrl = await resolveJwksUrl(parsedAgent.url, this.discoveryPaths); - if (!discoveredUrl) { + if (!outOfBandJwksUrl) { + return { + verified: false, + error: + "Missing Signature-Agent header; provide an out-of-band jwksUrl for key discovery", + }; + } + + const parsedOutOfBand = parseSignatureAgent(outOfBandJwksUrl); + if (!parsedOutOfBand) { return { verified: false, - error: `JWKS discovery failed for agent: ${parsedAgent.url}`, + error: "Invalid out-of-band jwksUrl", }; } - jwksUrl = discoveredUrl; + + if (parsedOutOfBand.isJwks) { + jwksUrl = parsedOutOfBand.url; + } else { + const discoveredUrl = await resolveJwksUrl( + parsedOutOfBand.url, + this.discoveryPaths, + ); + if (!discoveredUrl) { + return { + verified: false, + error: `JWKS discovery failed for out-of-band jwksUrl: ${parsedOutOfBand.url}`, + }; + } + jwksUrl = discoveredUrl; + } } // 5. Check if JWKS URL is from a trusted directory @@ -140,27 +215,31 @@ export class SignatureVerifier { components.signature = signatureValue; // 6. Validate timestamp - if (components.created) { - const timestampCheck = this.nonceManager.checkTimestamp( - components.created, - components.expires, - this.maxSkewSec - ); + const timestampCheck = this.nonceManager.checkTimestamp( + components.created as number, + components.expires as number, + this.maxSkewSec + ); - if (!timestampCheck.valid) { - return { - verified: false, - error: timestampCheck.error, - }; - } + if (!timestampCheck.valid) { + return { + verified: false, + error: timestampCheck.error, + }; } // 7. Check nonce for replay protection if (components.nonce) { + const nowSec = Math.floor(Date.now() / 1000); + const replayRetentionSec = Math.max( + 1, + Math.ceil((components.expires as number) - nowSec + this.maxSkewSec), + ); const nonceValid = await this.nonceManager.checkNonce( components.nonce, jwksUrl, - components.keyId + components.keyId, + replayRetentionSec, ); if (!nonceValid) { @@ -174,6 +253,21 @@ export class SignatureVerifier { // 8. Fetch JWKS and get the specific key const jwk = await this.jwksCache.getKey(jwksUrl, components.keyId); + if (!jwk) { + return { + verified: false, + error: `Key not found in JWKS: kid=${components.keyId}`, + }; + } + + // 8b. Validate JWK is Ed25519 (OKP with crv=Ed25519) + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519") { + return { + verified: false, + error: `JWK must be Ed25519 (OKP with crv=Ed25519), got kty=${jwk.kty} crv=${jwk.crv}`, + }; + } + // 9. Optional X.509 delegation validation (x5c/x5u) if (this.x509Enabled && (jwk?.x5c || jwk?.x5u)) { const x509Result = await validateJwkX509(jwk, { diff --git a/packages/verifier-service/src/types.ts b/packages/verifier-service/src/types.ts index a401205..4755ab3 100644 --- a/packages/verifier-service/src/types.ts +++ b/packages/verifier-service/src/types.ts @@ -1,5 +1,12 @@ /** * Type definitions for the verifier service + * + * Implements types per RFC 9421 (HTTP Message Signatures) and the + * IETF Web Bot Auth draft specification. + * + * References: + * - RFC 9421: https://www.rfc-editor.org/rfc/rfc9421.html + * - IETF Web Bot Auth: https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth/ */ export interface VerificationRequest { @@ -7,6 +14,11 @@ export interface VerificationRequest { url: string; headers: Record; body?: string; + /** + * Optional out-of-band JWKS reference used when Signature-Agent is omitted. + * Can be a direct directory/JWKS URL or an origin that supports discovery. + */ + jwksUrl?: string; } export interface VerificationResult { diff --git a/sdks/python/src/openbotauth_verifier/client.py b/sdks/python/src/openbotauth_verifier/client.py index 6ab699b..63ab218 100644 --- a/sdks/python/src/openbotauth_verifier/client.py +++ b/sdks/python/src/openbotauth_verifier/client.py @@ -87,6 +87,7 @@ async def verify( url: str, headers: Mapping[str, str], body: str | None = None, + jwks_url: str | None = None, ) -> VerificationResult: """ Verify a signed HTTP request asynchronously. @@ -96,6 +97,7 @@ async def verify( url: Full request URL headers: Request headers body: Optional request body + jwks_url: Optional out-of-band JWKS URL when Signature-Agent is omitted Returns: VerificationResult with verified status and agent info @@ -119,6 +121,8 @@ async def verify( } if body is not None: payload["body"] = body + if jwks_url is not None: + payload["jwksUrl"] = jwks_url async with httpx.AsyncClient(timeout=self.timeout_s) as client: response = await client.post( @@ -134,6 +138,7 @@ def verify_sync( url: str, headers: Mapping[str, str], body: str | None = None, + jwks_url: str | None = None, ) -> VerificationResult: """ Verify a signed HTTP request synchronously. @@ -143,6 +148,7 @@ def verify_sync( url: Full request URL headers: Request headers body: Optional request body + jwks_url: Optional out-of-band JWKS URL when Signature-Agent is omitted Returns: VerificationResult with verified status and agent info @@ -166,6 +172,8 @@ def verify_sync( } if body is not None: payload["body"] = body + if jwks_url is not None: + payload["jwksUrl"] = jwks_url with httpx.Client(timeout=self.timeout_s) as client: response = client.post( diff --git a/sdks/python/src/openbotauth_verifier/models.py b/sdks/python/src/openbotauth_verifier/models.py index abfea39..87beb6b 100644 --- a/sdks/python/src/openbotauth_verifier/models.py +++ b/sdks/python/src/openbotauth_verifier/models.py @@ -18,11 +18,13 @@ class VerificationRequest: url: Full request URL headers: Request headers as a dict body: Optional request body (for POST/PUT) + jwks_url: Optional out-of-band JWKS URL when Signature-Agent is omitted """ method: str url: str headers: dict[str, str] body: str | None = None + jwks_url: str | None = None @dataclass