b64u_enc(substr($sk, 0, 32)), 'x' => $x, 'kid' => jwk_thumbprint($x)]; } /** Accepts raw 32-byte key as base64url / base64 / hex, or PEM SPKI. Returns raw bytes or null. */ function parse_pubkey(string $in): ?string { $in = trim($in); if ($in === '') return null; if (str_contains($in, 'BEGIN')) { $der = base64_decode(preg_replace('/-----[^-]+-----|\s+/', '', $in) ?? '', true); if ($der !== false && strlen($der) === 44 && substr($der, 0, 12) === hex2bin('302a300506032b6570032100')) return substr($der, 12); return null; } if (preg_match('/^[0-9a-f]{64}$/i', $in)) return hex2bin($in); $b = b64u_dec($in); if ($b !== false && strlen($b) === 32) return $b; $b = base64_decode($in, true); if ($b !== false && strlen($b) === 32) return $b; return null; } /** * Message context: ['method'=>'GET','authority'=>'host','path'=>'/x','query'=>'?a=b', * 'scheme'=>'https','target_uri'=>'https://host/x?a=b','headers'=>['lower-name'=>'value']] */ function component_value(string $id, array $ctx): ?string { switch ($id) { case '@method': return strtoupper($ctx['method'] ?? ''); case '@authority': return strtolower($ctx['authority'] ?? ''); case '@path': return ($ctx['path'] ?? '') === '' ? '/' : $ctx['path']; case '@query': return ($ctx['query'] ?? '') === '' ? '?' : $ctx['query']; case '@scheme': return strtolower($ctx['scheme'] ?? 'https'); case '@target-uri': return $ctx['target_uri'] ?? (($ctx['scheme'] ?? 'https') . '://' . ($ctx['authority'] ?? '') . ($ctx['path'] ?? '/') . ($ctx['query'] ?? '')); case '@request-target': return (($ctx['path'] ?? '') === '' ? '/' : $ctx['path']) . ($ctx['query'] ?? ''); default: if ($id === '' || $id[0] === '@') return null; $v = $ctx['headers'][strtolower($id)] ?? null; if ($v === null) return null; if (is_array($v)) $v = implode(', ', array_map('trim', $v)); return trim(preg_replace('/[ \t]*\r?\n[ \t]+/', ' ', $v)); } } /** Build the RFC 9421 signature base. $params = everything after "label=" in Signature-Input. */ function signature_base(array $components, string $params, array $ctx): ?string { $lines = []; foreach ($components as $c) { $v = component_value($c, $ctx); if ($v === null) return null; $lines[] = '"' . $c . '": ' . $v; } $lines[] = '"@signature-params": ' . $params; return implode("\n", $lines); } /** Sign a request. Returns headers to add: Signature-Agent, Signature-Input, Signature. */ function sign(array $ctx, string $seed_b64u, string $kid, array $components = DEFAULT_COMPONENTS, int $ttl = 300, string $tag = 'web-bot-auth', string $signature_agent = DIRECTORY_URL, string $label = 'sig1'): array { $seed = b64u_dec($seed_b64u); if ($seed === false || strlen($seed) !== 32) throw new \InvalidArgumentException('bad seed'); $sk = sodium_crypto_sign_secretkey(sodium_crypto_sign_seed_keypair($seed)); $created = time(); $expires = $created + $ttl; $nonce = b64u_enc(random_bytes(16)); $out = []; if (in_array('signature-agent', $components, true)) { $ctx['headers']['signature-agent'] = '"' . $signature_agent . '"'; $out['Signature-Agent'] = $ctx['headers']['signature-agent']; } $params = '(' . implode(' ', array_map(fn($c) => '"' . $c . '"', $components)) . ')' . ';created=' . $created . ';expires=' . $expires . ';keyid="' . $kid . '";alg="ed25519";tag="' . $tag . '";nonce="' . $nonce . '"'; $base = signature_base($components, $params, $ctx); if ($base === null) throw new \RuntimeException('missing component for signing'); $out['Signature-Input'] = $label . '=' . $params; $out['Signature'] = $label . '=:' . base64_encode(sodium_crypto_sign_detached($base, $sk)) . ':'; return $out; } /** Parse Signature-Input into [label => ['components'=>[], 'params'=>[k=>v], 'raw'=>string]] */ function parse_signature_input(string $si): array { $out = []; if (!preg_match_all('/([A-Za-z0-9_\-\.\*]+)=(\([^)]*\)(?:;[^,]*)?)/', $si, $m, PREG_SET_ORDER)) return $out; foreach ($m as $mm) { if (!preg_match('/^\(([^)]*)\)(.*)$/s', $mm[2], $pm)) continue; $comps = preg_match_all('/"([^"]+)"/', $pm[1], $cm) ? $cm[1] : []; $p = []; foreach (explode(';', ltrim(trim($pm[2]), ';')) as $kv) { if ($kv === '') continue; [$k, $v] = array_pad(explode('=', $kv, 2), 2, null); $p[trim($k)] = $v === null ? true : trim(trim($v), '"'); } $out[$mm[1]] = ['components' => $comps, 'params' => $p, 'raw' => trim($mm[2])]; } return $out; } /** * Verify a request. $ctx as in component_value (headers must include signature, signature-input). * $resolver(string $kid): ?string -> raw 32-byte public key or null. * Options: tag (default web-bot-auth; null = any), skew (sec), require_authority (bool), require_expires (bool) */ function verify(array $ctx, callable $resolver, array $opt = []): array { $tag = array_key_exists('tag', $opt) ? $opt['tag'] : 'web-bot-auth'; $skew = $opt['skew'] ?? 60; $reqAuth = $opt['require_authority'] ?? true; $reqExp = $opt['require_expires'] ?? true; $h = []; foreach ($ctx['headers'] ?? [] as $k => $v) $h[strtolower($k)] = $v; $ctx['headers'] = $h; $fail = fn(string $r, array $extra = []) => ['ok' => false, 'reason' => $r] + $extra; $si = $h['signature-input'] ?? null; $sg = $h['signature'] ?? null; if (!$si || !$sg) return $fail('missing_signature_headers'); $inputs = parse_signature_input($si); if (!$inputs) return $fail('bad_signature_input'); // choose the label: first one whose tag matches (or first if tag=null) $label = null; foreach ($inputs as $l => $in) { if ($tag === null || ($in['params']['tag'] ?? null) === $tag) { $label = $l; break; } } if ($label === null) return $fail('no_matching_tag', ['labels' => array_keys($inputs)]); $in = $inputs[$label]; $p = $in['params']; if (!preg_match('/(?:^|,)\s*' . preg_quote($label, '/') . '=:([A-Za-z0-9+\/=]+):/', $sg, $sm)) return $fail('signature_label_missing', ['label' => $label]); $sig = base64_decode($sm[1], true); if ($sig === false || strlen($sig) !== 64) return $fail('bad_signature_encoding'); if (isset($p['alg']) && strtolower($p['alg']) !== 'ed25519') return $fail('unsupported_alg', ['alg' => $p['alg']]); $kid = $p['keyid'] ?? null; if (!$kid) return $fail('missing_keyid'); $now = time(); if (isset($p['created']) && (int)$p['created'] > $now + $skew) return $fail('created_in_future'); if ($reqExp && !isset($p['expires'])) return $fail('missing_expires'); if (isset($p['expires']) && (int)$p['expires'] < $now - $skew) return $fail('expired'); if ($reqAuth && !in_array('@authority', $in['components'], true)) return $fail('authority_not_covered'); $pk = $resolver($kid); if ($pk === null) return $fail('unknown_key', ['kid' => $kid]); if (strlen($pk) !== 32) return $fail('bad_public_key'); $base = signature_base($in['components'], $in['raw'], $ctx); if ($base === null) return $fail('missing_component'); $ok = sodium_crypto_sign_verify_detached($sig, $base, $pk); return ['ok' => $ok, 'reason' => $ok ? 'valid' : 'bad_signature', 'kid' => $kid, 'label' => $label, 'components' => $in['components'], 'created' => isset($p['created']) ? (int)$p['created'] : null, 'expires' => isset($p['expires']) ? (int)$p['expires'] : null, 'nonce' => $p['nonce'] ?? null, 'tag' => $p['tag'] ?? null, 'signature_agent' => isset($h['signature-agent']) ? trim($h['signature-agent'], '" ') : null]; } /** Build a message context from the current PHP request (for verifying incoming requests). */ function ctx_from_server(?array $server = null): array { $s = $server ?? $_SERVER; $headers = []; foreach ($s as $k => $v) if (str_starts_with($k, 'HTTP_')) $headers[strtolower(str_replace('_', '-', substr($k, 5)))] = $v; if (isset($s['CONTENT_TYPE'])) $headers['content-type'] = $s['CONTENT_TYPE']; $uri = $s['REQUEST_URI'] ?? '/'; $q = strpos($uri, '?'); $path = $q === false ? $uri : substr($uri, 0, $q); $query = $q === false ? '' : substr($uri, $q); $scheme = (($s['HTTPS'] ?? '') !== '' && $s['HTTPS'] !== 'off') || (($headers['x-forwarded-proto'] ?? '') === 'https') ? 'https' : 'http'; $authority = strtolower($headers['host'] ?? ($s['SERVER_NAME'] ?? '')); return ['method' => $s['REQUEST_METHOD'] ?? 'GET', 'authority' => $authority, 'path' => $path, 'query' => $query, 'scheme' => $scheme, 'target_uri' => $scheme . '://' . $authority . $uri, 'headers' => $headers]; } /** Resolver that fetches keys from the AgentPass directory with a small file cache. */ function directory_resolver(string $directory = DIRECTORY_URL, int $ttl = 300, ?string $cacheFile = null): callable { $cacheFile = $cacheFile ?: sys_get_temp_dir() . '/agentpass-dir-' . md5($directory) . '.json'; return function (string $kid) use ($directory, $ttl, $cacheFile): ?string { $keys = null; if (is_file($cacheFile) && filemtime($cacheFile) > time() - $ttl) $keys = json_decode((string)file_get_contents($cacheFile), true); if (!is_array($keys) || !isset($keys[$kid])) { $j = @file_get_contents(rtrim($directory, '/') . '/api/v1/keys/' . rawurlencode($kid), false, stream_context_create(['http' => ['timeout' => 5, 'header' => "Accept: application/json\r\n"]])); $d = $j ? json_decode($j, true) : null; if (!is_array($keys)) $keys = []; if (is_array($d) && isset($d['key']['x']) && ($d['agent']['status'] ?? 'active') === 'active') $keys[$kid] = $d['key']['x']; @file_put_contents($cacheFile, json_encode($keys), LOCK_EX); } if (!isset($keys[$kid])) return null; $raw = b64u_dec($keys[$kid]); return $raw === false ? null : $raw; }; }