diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 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/apps/registry-portal/public/skill.md b/apps/registry-portal/public/skill.md index e33dceb..3082676 100644 --- a/apps/registry-portal/public/skill.md +++ b/apps/registry-portal/public/skill.md @@ -46,9 +46,9 @@ 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').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/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 || "—"} +

+
+
@@ -227,6 +374,199 @@ const AgentDetail = () => { + + +
+ Certificates + + Issued X.509 certificates for this agent key + +
+ +
+ +
+

+ Certificate issuance requires proof-of-possession and must be done via CLI. + 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 ?? ""}-private-key.json --token {""} + +

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

+
+ {certLoading ? ( +

Loading certificates...

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

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

+ ) : ( +
+ + + + 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 @@ -255,6 +595,42 @@ const AgentDetail = () => { + + { + if (!open && !revokingSerial) { + setRevokeDialogCert(null); + } + }} + > + + + Revoke certificate? + + This will revoke certificate{" "} + {revokeDialogCert ? shortText(revokeDialogCert.serial) : ""}. + This action cannot be undone. + + + + + Cancel + + { + event.preventDefault(); + if (revokeDialogCert) { + void revokeCertificate(revokeDialogCert.serial); + } + }} + > + {revokingSerial ? "Revoking..." : "Revoke"} + + + + ); 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/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/BREAKING_CHANGES.md b/docs/BREAKING_CHANGES.md new file mode 100644 index 0000000..84baa5f --- /dev/null +++ b/docs/BREAKING_CHANGES.md @@ -0,0 +1,40 @@ +# 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 are still accepted via compatibility aliases/fallback lookup. +- 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/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..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" } ``` @@ -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/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/infra/neon/migrations/009_pop_nonces.sql b/infra/neon/migrations/009_pop_nonces.sql new file mode 100644 index 0000000..3207e7c --- /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_count INTEGER; +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_count = ROW_COUNT; + + RETURN inserted_count > 0; +END; +$$ LANGUAGE plpgsql; 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..4e2e5e2 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(); @@ -43,11 +44,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', 'dict') .action(async (url, options) => { await fetchCommand(url, { method: options.method, body: options.body, verbose: options.verbose, + signatureAgentFormat: options.signatureAgentFormat, }); }); @@ -61,6 +64,27 @@ 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('--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, + }); + }); + /** * Examples */ @@ -80,6 +104,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 ` @@ -87,4 +114,3 @@ Examples: // Parse arguments program.parse(); - diff --git a/packages/bot-cli/src/commands/cert.ts b/packages/bot-cli/src/commands/cert.ts new file mode 100644 index 0000000..20b290c --- /dev/null +++ b/packages/bot-cli/src/commands/cert.ts @@ -0,0 +1,222 @@ +/** + * Certificate Commands + * + * 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"; + +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 { + 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 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, 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", + minimalJwk, + { 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; + token?: string; + privateKeyPath?: string; +}): Promise { + console.log("🔏 Issuing certificate with proof-of-possession...\n"); + + try { + // Load private key - either from explicit path or from KeyStorage + let privateKey: NodeCryptoKey; + + if (options.privateKeyPath) { + console.log(`Loading private key from: ${options.privateKeyPath}`); + let fileContent: string; + try { + 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) { + 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); + } + privateKey = await importPrivateKey(config.private_key); + } + + 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 messageBuffer = new TextEncoder().encode(message); + const signatureBuffer = await webcrypto.subtle.sign( + { name: "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 }))) as CertIssueErrorResponse; + console.error(`❌ Certificate issuance failed: ${error.error || response.statusText}`); + process.exit(1); + } + + const result = (await response.json()) as CertIssueResponse; + + 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/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..f861450 --- /dev/null +++ b/packages/bot-cli/src/request-signer.test.ts @@ -0,0 +1,64 @@ +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 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( + '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"'); + // 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 () => { + const signer = new RequestSigner(makeConfig()); + const signed = await signer.sign("GET", "https://example.com", undefined, { + signatureAgentFormat: "legacy", + }); + expect(signed.headers["Signature-Agent"]).toBe( + "https://example.com/jwks/test.json", + ); + // 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"'); + }); + + 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 1d080b3..53b11ea 100644 --- a/packages/bot-cli/src/request-signer.ts +++ b/packages/bot-cli/src/request-signer.ts @@ -10,11 +10,39 @@ 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) { + return `"${component}"`; + } + const componentName = component.slice(0, separatorIndex); + const componentParams = component.slice(separatorIndex + 1); + return `"${componentName}";${componentParams}`; + } + /** * 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 || + "dict"; + const signatureAgentValue = + signatureAgentFormat === "dict" + ? `${signatureLabel}="${this.config.jwks_url}"` + : this.config.jwks_url; // Generate signature parameters const params: SignatureParams = { @@ -23,7 +51,15 @@ export class RequestSigner { nonce: this.generateNonce(), keyId: this.config.kid, algorithm: 'ed25519', - headers: ['@method', '@path', '@authority'], + tag: 'web-bot-auth', + headers: [ + '@method', + '@path', + '@authority', + signatureAgentFormat === "legacy" + ? "signature-agent" + : `signature-agent;key="${signatureLabel}"`, + ], }; // Add content-type if there's a body @@ -38,6 +74,7 @@ export class RequestSigner { path: urlObj.pathname, authority: urlObj.host, contentType: body ? 'application/json' : undefined, + signatureAgent: signatureAgentValue, }); // Sign the base string @@ -45,9 +82,9 @@ export class RequestSigner { // Build headers 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', }; @@ -74,6 +111,7 @@ export class RequestSigner { path: string; authority: string; contentType?: string; + signatureAgent?: string; } ): string { const lines: string[] = []; @@ -94,20 +132,50 @@ 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; + } + // 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 + // 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": ${dictKey}=${this.serializeSfString(uriValue)}`, + ); + continue; + } + if (component === 'signature-agent') { + if (!request.signatureAgent) { + throw new Error('Missing covered header: signature-agent'); + } + lines.push(`"signature-agent": ${request.signatureAgent}`); } } } // 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}"`); paramParts.push(`keyid="${params.keyId}"`); paramParts.push(`alg="${params.algorithm}"`); + if (params.tag) { + paramParts.push(`tag="${params.tag}"`); + } lines.push(`"@signature-params": ${paramParts.join(';')}`); @@ -117,9 +185,18 @@ export class RequestSigner { /** * Build Signature-Input header value */ - private buildSignatureInput(params: SignatureParams): 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}"`; + private buildSignatureInput( + params: SignatureParams, + label: string, + ): string { + 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}"`; + } + return input; } /** @@ -168,11 +245,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 d75c1f5..4444ce5 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 { @@ -37,6 +38,6 @@ export interface SignatureParams { nonce: string; keyId: string; algorithm: string; + tag?: string; headers: string[]; } - diff --git a/packages/proxy/package.json b/packages/proxy/package.json index db9c403..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", @@ -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/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()); } } 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 bee9b1f..975a28f 100644 --- a/packages/registry-service/README.md +++ b/packages/registry-service/README.md @@ -26,6 +26,15 @@ 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 +OBA_CERT_MAX_ISSUES_PER_AGENT_PER_DAY=10 +OBA_CERT_MAX_ACTIVE_PER_KID=1 ``` ## Development @@ -70,6 +79,222 @@ 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` + +Serves a Signature Agent Card with optional `oba_*` fields and embedded JWKS. + +Query parameters: +- `agent_id` (optional) +- `username` (optional) + +### 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. + +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:{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 (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. + +**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. + +**Request:** +```json +{ + "agent_id": "uuid", + "proof": { + "message": "cert-issue:550e8400-e29b-41d4-a716-446655440000:1709251200", + "signature": "" + } +} +``` + +**CLI Usage (recommended):** + +Certificate issuance is best done via CLI to keep private keys secure: + +```bash +# 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 + +# With OPENBOTAUTH_TOKEN env var +OPENBOTAUTH_TOKEN= oba-bot cert issue --agent-id --private-key-path ./agent--private-key.json +``` + +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. + +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:write`. + +**Request:** +```json +{ + "serial": "hex-serial", + "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. + +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:read` (or `agents:write`). + +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. + +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:read` (or `agents:write`). + +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` + +Auth: +- Requires authenticated session or Bearer PAT. +- Required scope: `agents:read` (or `agents:write`). + +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 +} +``` + +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 (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 { createHash, X509Certificate } = require("node:crypto"); + +// From PEM string +const pem = "-----BEGIN CERTIFICATE-----..."; +const cert = new X509Certificate(pem); +const fingerprint = createHash("sha256").update(cert.raw).digest("hex"); +// fingerprint is 64 lowercase hex chars +``` + +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 +} +``` + +#### 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 + validity window metadata (`not_before`/`not_after`). + +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` @@ -223,4 +448,3 @@ Key tables: ## License MIT - diff --git a/packages/registry-service/package.json b/packages/registry-service/package.json index cec5613..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", @@ -32,6 +35,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/__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 new file mode 100644 index 0000000..924eee3 --- /dev/null +++ b/packages/registry-service/src/routes/__tests__/certs.test.ts @@ -0,0 +1,1229 @@ +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) { + const query = queryFn ?? vi.fn().mockResolvedValue({ rows: [] }); + const connect = vi.fn().mockResolvedValue({ + query, + release: vi.fn(), + }); + + return { + getPool: () => ({ + query, + connect, + }), + }; +} + +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: "token", + authScopes: ["agents:read", "agents:write"], + 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() + .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" }, + 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(2); + + 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); + }); + + it("applies status=active filter", async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ 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 [countSql] = query.mock.calls[0]; + 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_before <= now() AND c.not_after > now()"); + }); + + it("applies status=revoked filter", async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ 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 [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/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("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", + ) + ) { + 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).not.toHaveBeenCalledWith("BEGIN"); + expect(query).not.toHaveBeenCalledWith("ROLLBACK"); + expect(query).not.toHaveBeenCalledWith("COMMIT"); + }); + + 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"); + }); + + 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"); + + 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")) { + 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 }] }; + } + 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: { + 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(); + }); + + 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 * 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 }] }; + } + 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")) { + 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); + 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"), + ), + ).toBe(false); + }); + + 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"); + + 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")) { + 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: false }] }; + } + return { 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(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().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")) { + 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")) { + throw err; + } + return { rows: [] }; + }); + + 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().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")) { + 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 }] }; + } + if (sql.includes("c.created_at >= now() - interval '1 day'")) { + return { rows: [{ count: 10 }] }; + } + return { 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(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().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")) { + 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 }] }; + } + 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: { + 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"); + const activeCapQueryCall = agentQuery.mock.calls.find(([sql]) => + String(sql).includes("AND c.kid = $3"), + ); + expect(activeCapQueryCall?.[0]).toContain("c.not_before <= now()"); + }); +}); + +describe("POST /v1/certs/revoke", () => { + it("only updates certs that are not already revoked", async () => { + 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) } }, + }); + const res = mockRes(); + + await callRoute(certsRouter, "POST", "/v1/certs/revoke", req, res); + + expect(res.statusCode).toBe(200); + 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"); + }); +}); + +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 futureFingerprint = "a".repeat(64); + const query = vi.fn().mockResolvedValue({ + rows: [ + { + serial: "serial-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, + revoked_reason: null, + }, + ], + }); + const req = mockReq({ + query: { fingerprint_sha256: futureFingerprint }, + 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/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(); + + await callRoute(certsRouter, "GET", "/v1/certs/public-status", req, res); + + expect(res.statusCode).toBe(400); + 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 hex characters"); + }); + + 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 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 () => { + 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) + // Use valid 64-char hex fingerprint + 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); + + 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, + }, + ], + }); + // Use valid 64-char hex fingerprint + const req = { + headers: {}, + query: { fingerprint_sha256: "b".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 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", () => { + 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/agents-api.ts b/packages/registry-service/src/routes/agents-api.ts index eeda24a..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 */ @@ -37,7 +49,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 +82,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 +115,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 @@ -114,11 +138,27 @@ 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) - 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), + validatedObaAgentId, + validatedObaParentAgentId, + validatedObaPrincipal, + ] ); res.status(201).json(result.rows[0]); @@ -142,7 +182,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 +232,21 @@ agentsAPIRouter.put( values.push(status); paramIndex++; } + if (oba_agent_id !== undefined) { + updates.push(`oba_agent_id = $${paramIndex}`); + values.push(validateObaField(oba_agent_id)); + paramIndex++; + } + if (oba_parent_agent_id !== undefined) { + updates.push(`oba_parent_agent_id = $${paramIndex}`); + values.push(validateObaField(oba_parent_agent_id)); + paramIndex++; + } + if (oba_principal !== undefined) { + updates.push(`oba_principal = $${paramIndex}`); + values.push(validateObaField(oba_principal)); + paramIndex++; + } if (updates.length === 0) { res.status(400).json({ error: 'No fields to update' }); @@ -195,7 +259,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..d01265a --- /dev/null +++ b/packages/registry-service/src/routes/certs.ts @@ -0,0 +1,817 @@ +/** + * Certificate issuance endpoints (MVP) + */ + +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"; +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. + * Uses the check_pop_nonce Postgres function for atomic check-and-set. + */ +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 queryExecutor.query( + `SELECT check_pop_nonce($1, $2) AS is_new`, + [hash, POP_NONCE_TTL_SEC], + ); + return result.rows[0]?.is_new === true; + } catch (err: any) { + // 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; + } +} + +/** + * Verify proof-of-possession signature. + * The proof message format is: "cert-issue:{agent_id}:{timestamp}" + * Timestamp must be within 5 minutes in the past (30s future drift tolerated for clock skew). + */ +async function verifyProofOfPossession( + proof: unknown, + agentId: string, + publicKey: { kty?: string; crv?: string; x: string }, +): Promise<{ valid: boolean; error?: string }> { + try { + // 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:{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" }; + } + + // Validate timestamp: must be in the past, within 5 minutes + const timestamp = parseInt(timestampStr, 10); + const now = Math.floor(Date.now() / 1000); + if (timestamp > now + POP_PROOF_MAX_FUTURE_DRIFT_SEC) { + return { valid: false, error: "Proof timestamp is in the future" }; + } + if (now - timestamp > POP_PROOF_MAX_AGE_SEC) { + 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 + 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(message); + + const valid = await webcrypto.subtle.verify( + { name: "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) => { + if (!req.session) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + 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, + ); +} + +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; +} + +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, + requireScope("agents:write"), + async (req: Request, res: Response): Promise => { + const db: Database = req.app.locals.db; + let client: PoolClient | null = null; + let transactionOpen = false; + + const rollbackIfNeeded = async () => { + if (!transactionOpen || !client) return; + try { + await client.query("ROLLBACK"); + } catch (rollbackError) { + console.error("Certificate issuance rollback error:", rollbackError); + } finally { + transactionOpen = false; + } + }; + + try { + const { agent_id, proof } = req.body || {}; + + if (!agent_id) { + res.status(400).json({ + error: "Missing required input: agent_id", + }); + return; + } + + if (!proof) { + res.status(400).json({ + error: "Missing proof-of-possession. Provide proof: { message: 'cert-issue:{agent_id}:{timestamp}', signature: '' }", + }); + return; + } + + // 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 preflightAgent = preflightResult.rows[0] || null; + + if (!preflightAgent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + 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; + } + + const popResult = await verifyProofOfPossession( + proof, + preflightAgent.id, + preflightPk, + ); + if (!popResult.valid) { + res.status(403).json({ + error: `Proof-of-possession failed: ${popResult.error}`, + }); + return; + } + + // 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) { + res.status(403).json({ + error: + "Proof-of-possession replay detected or replay protection unavailable", + }); + 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 + : jwkThumbprint({ kty: "OKP", crv: "Ed25519", x: pk.x }); + + const maxDailyIssues = readPositiveEnvInt( + "OBA_CERT_MAX_ISSUES_PER_AGENT_PER_DAY", + 10, + ); + const dailyIssueCountResult = await client.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) { + await rollbackIfNeeded(); + 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 client.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_before <= now() + AND c.not_after > now()`, + [agent.id, req.session!.user.id, resolvedKid], + ); + 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`, + }); + return; + } + + 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, + ); + + const jwkForCert = { + kty: pk.kty || "OKP", + crv: pk.crv || "Ed25519", + x: pk.x, + kid: resolvedKid, + }; + + const issued = await issueCertificateForJwk( + jwkForCert, + subject, + validityDays, + typeof agent.oba_agent_id === "string" && isValidAgentId(agent.oba_agent_id) + ? agent.oba_agent_id + : null, + ); + + 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)`, + [ + agent.id, + resolvedKid, + issued.serial, + issued.certPem, + issued.chainPem, + issued.x5c, + issued.notBefore, + issued.notAfter, + issued.fingerprintSha256, + ], + ); + await client.query("COMMIT"); + transactionOpen = false; + + res.setHeader("Cache-Control", "no-store"); + 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) { + await rollbackIfNeeded(); + console.error("Certificate issuance error:", error); + res.status(500).json({ error: "Failed to issue certificate" }); + } finally { + client?.release(); + } + }, +); + +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 || {}; + 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 = ""; + + if (serial) { + params.push(serial); + condition = "c.serial = $2"; + } else if (kid) { + params.push(kid); + 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 + FROM agents a + WHERE c.agent_id = a.id + AND a.user_id = $1 + AND ${condition} + AND c.revoked_at IS NULL + RETURNING c.id`, + [...params, revocationReason], + ); + + 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: "Failed to revoke certificate" }); + } + }, +); + +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_before <= now() AND c.not_after > now()"); + } else if (statusRaw === "revoked") { + whereClauses.push("c.revoked_at IS NOT NULL"); + } + + 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_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 ")} + ORDER BY c.created_at DESC + LIMIT $${limitParam} + OFFSET $${offsetParam}`, + pageParams, + ); + + const items = result.rows; + + 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: "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.toLowerCase() + : null; + + if ((!serial && !fingerprint) || (serial && fingerprint)) { + res.status(400).json({ + error: "Provide exactly one lookup parameter: serial or fingerprint_sha256", + }); + 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( + `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} + 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 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, + serial: cert.serial, + fingerprint_sha256: cert.fingerprint_sha256, + }); + } catch (error: any) { + console.error("Certificate status error:", error); + res.status(500).json({ error: "Failed to check certificate status" }); + } + }, +); + +/** + * 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.toLowerCase() + : null; + + if (!fingerprint) { + res.status(400).json({ + error: "Missing required parameter: fingerprint_sha256", + }); + 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 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 + 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: "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_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: "Failed to fetch 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) { + console.error("CA fetch error:", error); + res.status(501).json({ error: "CA not configured" }); + } +}); diff --git a/packages/registry-service/src/routes/jwks.ts b/packages/registry-service/src/routes/jwks.ts index 25aa926..c82ed06 100644 --- a/packages/registry-service/src/routes/jwks.ts +++ b/packages/registry-service/src/routes/jwks.ts @@ -5,13 +5,58 @@ * 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 { + 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 * @@ -78,6 +123,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 agentJwkRefs: Array<{ agentId: string; kid: string; jwk: Record }> = []; for (const agent of agentsResult.rows) { const pk = agent.public_key; @@ -96,14 +142,10 @@ jwksRouter.get('/:username.json', async (req: Request, res: Response): Promise }); } if (jwks.length === 0) { @@ -145,8 +188,40 @@ 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 && agentIdsForCerts.length > 0) { + const certResult = await db.getPool().query( + `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.agent_id = ANY($1) + AND c.kid = ANY($2) + AND c.revoked_at IS NULL + AND c.not_before <= now() + AND c.not_after > now() + AND a.user_id = $3 + ORDER BY c.agent_id, c.kid, c.created_at DESC`, + [agentIdsForCerts, kidsForCerts, profile.id] + ); + + const certByAgentKid = new Map(); + for (const row of certResult.rows) { + certByAgentKid.set(`${row.agent_id}:${row.kid}`, row.x5c); + } + + for (const ref of agentJwkRefs) { + const certKey = `${ref.agentId}:${ref.kid}`; + if (certByAgentKid.has(certKey)) { + ref.jwk.x5c = certByAgentKid.get(certKey); + } + } + } + // Build Web Bot Auth response - const response = createWebBotAuthJWKS(jwks, { + const response = createWebBotAuthJWKS(withLegacyKidAliases(jwks as unknown as Array>) as any, { client_name: profile.client_name || profile.username, client_uri: profile.client_uri || undefined, logo_uri: profile.logo_uri || undefined, @@ -164,7 +239,7 @@ 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 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 AND status = 'active'`, + [agentId], + ); + agent = agentResult.rows[0] || null; + if (agent) { + 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( + `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 + : jwkThumbprint({ kty: "OKP", crv: "Ed25519", x: 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 + AND not_before <= now() + AND not_after > now() + ORDER BY created_at DESC + LIMIT 1`, + [agent.id, kid], + ); + if (certResult.rows.length > 0) { + 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: 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, + }; + + res.setHeader("Content-Type", "application/json"); + 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); + 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..c0f99ba 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'; @@ -92,7 +94,27 @@ 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); // Deprecated: agent-jwks endpoint removed in favor of user JWKS app.use('/agent-jwks', (_req, res) => { @@ -108,6 +130,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 +149,3 @@ process.on('SIGTERM', async () => { await pool.end(); process.exit(0); }); - 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..1ee65eb --- /dev/null +++ b/packages/registry-service/src/utils/ca.test.ts @@ -0,0 +1,155 @@ +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, beforeEach } from "vitest"; +import * as x509 from "@peculiar/x509"; +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"; + 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}`); + }); + + 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:"); + }); + + 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"); + 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 new file mode 100644 index 0000000..de23513 --- /dev/null +++ b/packages/registry-service/src/utils/ca.ts @@ -0,0 +1,339 @@ +/** + * Local Certificate Authority helper (dev/MVP) + */ + +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"; + +const { + X509CertificateGenerator, + PemConverter, + BasicConstraintsExtension, + KeyUsagesExtension, + ExtendedKeyUsageExtension, + ExtendedKeyUsage, + SubjectAlternativeNameExtension, + KeyUsageFlags, + URL: GENERAL_NAME_URL, +} = 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; + +// 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; + } + 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 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, + issuer: subject, + notBefore, + notAfter, + publicKey: keys.publicKey, + signingKey: keys.privateKey, + signingAlgorithm: { name: "Ed25519" }, + extensions, + }); + + const pem = encodeCertPem(cert); + const der = certDerBuffer(cert); + 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") { + 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: 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); + + 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"); + 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 }, + 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, + subjectAltUri?: string | null, +): Promise { + 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", + importableJwk, + { 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 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 (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 GENERAL_NAME_URL === "string" ? GENERAL_NAME_URL : "url"; + extensions.push( + new SubjectAlternativeNameExtension( + [{ type: sanType, value: subjectAltUri }], + false, + ), + ); + } + + const cert = await X509CertificateGenerator.create({ + serialNumber, + subject, + issuer: ca.subject, + notBefore, + notAfter, + publicKey, + signingKey: ca.privateKey, + signingAlgorithm: { name: "Ed25519" }, + extensions, + }); + + 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/registry-service/src/utils/jwk.ts b/packages/registry-service/src/utils/jwk.ts new file mode 100644 index 0000000..184ddf2 --- /dev/null +++ b/packages/registry-service/src/utils/jwk.ts @@ -0,0 +1,12 @@ +import { createHash } from "node:crypto"; + +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"); + return base64Url(hash); +} diff --git a/packages/registry-signer/README.md b/packages/registry-signer/README.md index 55bd6e6..4b5acd1 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 using pre-RFC7638 PEM-based hashing (kept for compatibility). + +#### `generateLegacyKidFromJWK(jwk: Partial): string` + +Generate the legacy 16-character kid using pre-RFC7638 JWK-field hashing (kept for compatibility). #### `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..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, @@ -11,6 +12,8 @@ import { // JWKS generateKid, generateKidFromJWK, + generateLegacyKid, + generateLegacyKidFromJWK, publicKeyToJWK, base64PublicKeyToJWK, createJWKS, @@ -141,11 +144,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 +170,43 @@ 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 legacyKid = generateLegacyKid(keyPair.publicKey); + const expectedLegacyKid = base64ToBase64Url( + createHash('sha256').update(pemToBase64(keyPair.publicKey)).digest('base64') + ).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 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(expectedLegacyKid); + 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 3fcbe56..eaa8d94 100644 --- a/packages/registry-signer/src/jwks.ts +++ b/packages/registry-signer/src/jwks.ts @@ -6,23 +6,75 @@ 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 - * 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 = canonicalOkpThumbprint('Ed25519', '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 = canonicalOkpThumbprint( + String(jwk.crv), + String(jwk.kty), + String(jwk.x), + ); + const hash = createHash('sha256').update(canonical).digest('base64'); + return base64ToBase64Url(hash); +} + +/** + * Legacy OpenBotAuth kid derived from PEM key material. + * Kept only for backwards compatibility with older clients. + */ +export function generateLegacyKid(publicKeyPem: string): string { + // 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 derived from JWK fields. + * Kept only for backwards compatibility with older clients. + */ +export function generateLegacyKidFromJWK(jwk: Partial): string { + // 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); } /** @@ -189,4 +241,3 @@ export function validateJWK(jwk: unknown): jwk is JWK { key.use === 'sig' ); } - 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-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/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 78cc25e..a17b62b 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 ) @@ -51,17 +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('"')) { - return s.slice(1, -1).toLowerCase(); + // 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'); } - return s.toLowerCase(); - }); + } else { + const semicolonPos = token.indexOf(';'); + if (semicolonPos !== -1) { + component = token.slice(0, semicolonPos); + } + } + + // 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/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 5db56d4..596630e 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,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` - JWKS URL +- `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-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 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 selects the matching labeled member that satisfies WBA constraints. **Response:** @@ -187,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 @@ -238,4 +258,3 @@ curl -X POST http://localhost:8081/cache/nonces/clear ## License MIT - diff --git a/packages/verifier-service/package.json b/packages/verifier-service/package.json index 46958f8..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", @@ -21,6 +24,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", @@ -37,4 +41,3 @@ "redis": "^4.6.13" } } - 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/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/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/__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/__tests__/signature-e2e.test.ts b/packages/verifier-service/src/__tests__/signature-e2e.test.ts new file mode 100644 index 0000000..ba1e811 --- /dev/null +++ b/packages/verifier-service/src/__tests__/signature-e2e.test.ts @@ -0,0 +1,689 @@ +/** + * 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), + }; +} + +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: webcrypto.CryptoKeyPair; + let publicJwk: any; + + beforeEach(async () => { + // Generate fresh Ed25519 keypair for each test + 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"; + + }); + + 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"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + + // 2. Build Signature-Input header + 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}`; + + // 3. Parse signature input to get components + const components = parseSignatureInput(signatureInput); + expect(components).not.toBeNull(); + + // 4. Build the signature base + const requestHeaders: Record = { + "signature-agent": signatureAgentHeader, + }; + 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 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": signatureAgentHeader, + }, + }); + + // 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("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( + "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", "@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)!; + const signatureBase = buildSignatureBase(components, { + method, + url, + headers: { + "signature-agent": signatureAgentHeader, + }, + }); + + // 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": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(false); + 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", "@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)!; + 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("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};expires=${created + 300};keyid="other-kid";nonce="${nonce}";alg="ed25519"`; + + 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}`; + + 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"; + 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"]; + 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: {}, + }); + 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("covered signature-agent"); + }); + + 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", "@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)!; + + // Sign the original URL + 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 = `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": signatureAgentHeader, + }, + }); + + 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"); + const signatureAgentHeader = 'sig1="https://trusted.example.com/jwks.json"'; + + // 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};expires=${created + 300};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)!; + 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": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(true); + 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", "@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)!; + 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", "@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)!; + 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 = `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": signatureAgentHeader, + }, + }); + + expect(result.verified).toBe(false); + expect(result.error).toContain("replay"); + }); +}); 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 2445b77..dc279b5 100644 --- a/packages/verifier-service/src/jwks-cache.ts +++ b/packages/verifier-service/src/jwks-cache.ts @@ -6,13 +6,54 @@ 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:"; +const DIRECTORY_ACCEPT_HEADER = + "application/http-message-signatures-directory+json, application/http-message-signatures-directory, application/jwk-set+json, application/json"; 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 */ @@ -64,7 +105,7 @@ export class JWKSCacheManager { const response = await fetch(jwksUrl, { method: "GET", headers: { - Accept: "application/json", + Accept: DIRECTORY_ACCEPT_HEADER, "User-Agent": "OpenBotAuth-Verifier/0.1.0", }, signal: AbortSignal.timeout(3000), // 3s timeout @@ -113,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`); } 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 48b80d6..26fb7ed 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'; @@ -25,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); @@ -81,12 +85,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) @@ -181,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({ @@ -208,6 +240,7 @@ app.post('/authorize', async (req, res) => { url, headers, body: req.body ? JSON.stringify(req.body) : undefined, + jwksUrl: outOfBandJwksUrl, }; // Verify signature @@ -283,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({ @@ -293,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({ @@ -306,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) { @@ -324,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); @@ -416,4 +454,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..af546b7 100644 --- a/packages/verifier-service/src/signature-parser.test.ts +++ b/packages/verifier-service/src/signature-parser.test.ts @@ -8,6 +8,8 @@ import { resolveJwksUrl, buildSignatureBase, parseSignatureInput, + parseSignature, + parseSignatureLabels, validateSafeUrl, } from "./signature-parser.js"; @@ -156,6 +158,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({ @@ -218,6 +242,99 @@ 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", () => { + it("should parse labels with dash and dot", () => { + const parsed = parseSignatureInput( + 'sig-1.test=("@method" "@path");created=123;expires=124;nonce="n";keyid="k1";alg="ed25519"', + ); + 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"', + ); + expect(parsed?.label).toBe("sig1"); + 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"', + ); + expect(parsed?.label).toBe("sig2"); + 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", () => { + it("should parse signature with dash/dot label", () => { + const parsed = parseSignature("sig-1.test=:Zm9vYmFyOg==:"); + 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:", + "sig2", + ); + expect(parsed).toBe("c2Vjb25k"); + }); +}); + +describe("parseSignatureLabels", () => { + it("returns signature labels in order", () => { + const labels = parseSignatureLabels("sig2=:Zm9vOg==:, sig1=:YmFyOg==:"); + expect(labels).toEqual(["sig2", "sig1"]); + }); }); describe("resolveJwksUrl", () => { @@ -233,7 +350,7 @@ describe("resolveJwksUrl", () => { 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({ @@ -243,13 +360,15 @@ 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({ - Accept: "application/json", + Accept: expect.stringContaining("http-message-signatures-directory"), }), }), ); @@ -272,9 +391,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 () => { @@ -376,7 +493,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 () => { @@ -419,11 +536,91 @@ 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", () => { it("should build signature base with derived components", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -449,6 +646,7 @@ describe("buildSignatureBase", () => { it("should include regular headers when present", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -473,8 +671,34 @@ describe("buildSignatureBase", () => { expect(result).toContain('"accept": application/json'); }); + it("serializes selected dictionary member as key=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": sig1="https://example.com/jwks.json"', + ); + }); + it("should throw error when covered header is missing", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -500,6 +724,7 @@ describe("buildSignatureBase", () => { it("should handle empty string header values", () => { const components = { + label: "sig1", keyId: "test-key", signature: "", algorithm: "ed25519", @@ -527,6 +752,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 abc98bd..a40829d 100644 --- a/packages/verifier-service/src/signature-parser.ts +++ b/packages/verifier-service/src/signature-parser.ts @@ -2,10 +2,192 @@ * 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-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})=\\(([^)]+)\\);(.+)$`); +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 = ""; + 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 (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 = {}; + const paramPairs = paramsStr.split(";"); + + for (const pair of paramPairs) { + 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 = { + 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, + }; + + if (typeof params.tag === "string" && params.tag.length > 0) { + parsed.tag = params.tag; + } + + return parsed; +} + /** * SSRF protection: validate that a URL is safe to fetch * @@ -96,57 +278,20 @@ export function validateSafeUrl(urlString: string): void { */ export function parseSignatureInput( signatureInput: string, + expectedLabel?: string, ): SignatureComponents | null { try { - // Extract the signature label and the raw params (everything after "label=") - const labelMatch = signatureInput.match(/^(\w+)=(.+)$/); - if (!labelMatch) { - return null; - } - - // 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+)=\(([^)]+)\);(.+)$/); - 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); + const members = splitTopLevelMembers(signatureInput); + for (const member of members) { + const parsed = parseSingleSignatureInputMember(member); + if (!parsed) { + continue; + } + if (!expectedLabel || parsed.label === expectedLabel) { + return parsed; } } - - return { - 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; @@ -159,20 +304,85 @@ 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(/^\w+=:([^:]+):$/); - 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[1]; + + return expectedLabel ? null : fallback; } catch (error) { console.error("Error parsing Signature:", error); return null; } } +/** + * 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" } + */ +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 }; +} + +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 * @@ -215,13 +425,34 @@ 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 + RFC 8941: serialize selected dictionary member as key=sf-string. + // Example: "signature-agent": sig2="https://signature-agent.test" + lines.push( + `"${headerName}": ${dictKey}=${serializeSfString(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}`); } } } @@ -233,22 +464,135 @@ 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(LABEL_CAPTURE_RE); + 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; +} + +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 * * 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 @@ -267,9 +611,16 @@ export function parseSignatureAgent( // Validate URL structure const url = new URL(cleaned); + const normalizedPath = url.pathname.replace(/\/+$/, ""); + const isDirectoryPath = normalizedPath.endsWith( + "/.well-known/http-message-signatures-directory", + ); + // Check if it's already a JWKS URL const isJwks = - url.pathname.endsWith(".json") || url.pathname.includes("/jwks/"); + normalizedPath.endsWith(".json") || + normalizedPath.includes("/jwks/") || + isDirectoryPath; return { url: cleaned, @@ -321,7 +672,7 @@ export async function resolveJwksUrl( const response = await fetch(candidateUrl, { method: "GET", headers: { - Accept: "application/json", + Accept: DIRECTORY_ACCEPT_HEADER, "User-Agent": "OpenBotAuth-Verifier/0.1.0", }, signal: AbortSignal.timeout(3000), // 3s timeout @@ -337,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 new file mode 100644 index 0000000..07fc0a2 --- /dev/null +++ b/packages/verifier-service/src/signature-verifier.test.ts @@ -0,0 +1,403 @@ +/** + * Tests for SignatureVerifier - specifically trusted directory validation + */ + +import { describe, it, expect, vi } 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 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( + ["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://localhost:9999/jwks.json") + ).toBe(false); + + 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); + }); + }); +}); + +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 8219a49..ebd290a 100644 --- a/packages/verifier-service/src/signature-verifier.ts +++ b/packages/verifier-service/src/signature-verifier.ts @@ -1,32 +1,59 @@ /** * 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'; 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, + parseSignatureLabels, parseSignatureAgent, + extractSignatureAgentDictionaryKey, resolveJwksUrl, buildSignatureBase, } from './signature-parser.js'; +function isSignatureAgentCovered(coveredComponents: string[]): boolean { + return coveredComponents.some((component) => + component === "signature-agent" || + component.startsWith("signature-agent;"), + ); +} + +function hasRequiredAuthorityBinding(coveredComponents: string[]): boolean { + return coveredComponents.includes("@authority") || coveredComponents.includes("@target-uri"); +} + 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; } /** @@ -38,43 +65,137 @@ 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) { + return { + verified: false, + error: 'Missing required signature headers (Signature-Input, Signature)', + }; + } + + // 2. Select Signature-Input member matching a Signature label. + const signatureLabels = parseSignatureLabels(signature); + const candidateLabels = signatureLabels.length > 0 ? signatureLabels : [undefined]; + let components: ReturnType = null; + + for (const label of candidateLabels) { + const parsed = parseSignatureInput(signatureInput, label); + if (!parsed) { + continue; + } + if (parsed.tag !== "web-bot-auth") { + continue; + } + if (!hasRequiredAuthorityBinding(parsed.headers)) { + continue; + } + const signatureAgentCovered = isSignatureAgentCovered(parsed.headers); + if (signatureAgent && !signatureAgentCovered) { + continue; + } + if (!signatureAgent && signatureAgentCovered) { + continue; + } + components = parsed; + break; + } + + if (!components) { + return { + verified: false, + error: + 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 (!signatureInput || !signature || !signatureAgent) { + if ( + !Number.isInteger(components.created) || + !Number.isInteger(components.expires) + ) { return { verified: false, - error: 'Missing required signature headers (Signature-Input, Signature, Signature-Agent)', + error: + 'Signature-Input must include integer created and expires parameters', }; } - // 2. Parse Signature-Agent (JWKS URL or identity URL) - const parsedAgent = parseSignatureAgent(signatureAgent); - 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.`, }; } - // 3. 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; + } } - // 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)); + const trusted = this.isTrustedDirectory(jwksUrl); if (!trusted) { return { verified: false, @@ -83,16 +204,7 @@ 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); + const signatureValue = parseSignature(signature, components.label); if (!signatureValue) { return { verified: false, @@ -103,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) { @@ -137,14 +253,42 @@ export class SignatureVerifier { // 8. Fetch JWKS and get the specific key const jwk = await this.jwksCache.getKey(jwksUrl, components.keyId); - // 9. Build signature base + 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, { + 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 +302,7 @@ export class SignatureVerifier { }; } - // 11. Success! Return verification result + // 12. Success! Return verification result const jwks = await this.jwksCache.getJWKS(jwksUrl); return { @@ -180,6 +324,74 @@ 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(); + const jwksPort = this.getEffectivePort(jwksUrlParsed); + + return this.trustedDirectories.some(dir => { + const rawDir = dir.trim(); + if (!rawDir) return false; + + try { + 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) + 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 = rawDir.toLowerCase(); + return jwksHostname === trustedHostname || + jwksHostname.endsWith('.' + trustedHostname); + } + }); + } catch { + // Invalid JWKS URL + return false; + } + } + + 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 */ @@ -221,4 +433,3 @@ export class SignatureVerifier { } } } - diff --git a/packages/verifier-service/src/types.ts b/packages/verifier-service/src/types.ts index d162299..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 { @@ -22,9 +34,13 @@ export interface VerificationResult { } export interface SignatureComponents { + /** Signature label from Signature-Input (e.g., "sig1") */ + label: string; 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; @@ -46,4 +62,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..f724d26 --- /dev/null +++ b/packages/verifier-service/src/x509.test.ts @@ -0,0 +1,333 @@ +import { readFileSync } from "node:fs"; +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), + "utf-8", +); +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 () => { + 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); + }); + + 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).toMatch(/not authorized|not a valid CA/); + }); + + 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"); + }); + + 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: webcrypto.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 webcrypto.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: webcrypto.CryptoKeyPair; jwk: any }> { + const keyPair = await webcrypto.subtle.generateKey( + { name: "Ed25519" } as any, + true, + ["sign", "verify"], + ) as webcrypto.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 new file mode 100644 index 0000000..8458306 --- /dev/null +++ b/packages/verifier-service/src/x509.ts @@ -0,0 +1,341 @@ +/** + * 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[]; + /** 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 { + valid: boolean; + error?: string; +} + +const MAX_X5U_CERT_BYTES = 1024 * 1024; + +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 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; + } + + // 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[], +): 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"; + } + 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; + } + } + + 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); + + 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) > MAX_X5U_CERT_BYTES) { + throw new Error("x5u certificate too large"); + } + + const buffer = await readResponseBufferLimited(response, MAX_X5U_CERT_BYTES); + 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 }; + } + + // 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 { + valid: false, + error: error?.message || "X.509 validation failed", + }; + } +} diff --git a/plugins/wordpress-openbotauth/includes/Verifier.php b/plugins/wordpress-openbotauth/includes/Verifier.php index 40e6768..6fd57ff 100644 --- a/plugins/wordpress-openbotauth/includes/Verifier.php +++ b/plugins/wordpress-openbotauth/includes/Verifier.php @@ -268,27 +268,55 @@ 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. 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 ) { - return trim( $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 ) { + $header_name = substr( $header_name, 0, $semicolon_pos ); + } + + return strtolower( $header_name ); }, - $headers + $tokens ); - return array_filter( $headers ); + return array_values( array_filter( $headers ) ); } return array(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 83d1ef6..3052d73 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 @@ -489,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 @@ -942,6 +948,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 +2212,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 +3363,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 +3505,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 +3754,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 +3765,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 +4302,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 +5679,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 +6862,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 +7017,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 +7298,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.20.6: @@ -7142,6 +7309,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 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/headers.py b/sdks/python/src/openbotauth_verifier/headers.py index 0930090..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 @@ -29,19 +30,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') [] """ @@ -55,12 +60,22 @@ 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' + semicolon_pos = item.find(';') + if semicolon_pos != -1: + item = item[:semicolon_pos] headers.append(item.lower()) return headers 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 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."""