@kya-os/agentshield-nextjs
Version:
Next.js middleware for AgentShield AI agent detection
354 lines (352 loc) • 11.3 kB
JavaScript
import * as ed25519 from '@noble/ed25519';
import { sha512 } from '@noble/hashes/sha2.js';
// src/signature-verifier.ts
ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
var KNOWN_KEYS = {
chatgpt: [
{
kid: "otMqcjr17mGyruktGvJU8oojQTSMHlVm7uO-lrcqbdg",
// ChatGPT's current Ed25519 public key (base64)
// Source: https://chatgpt.com/.well-known/http-message-signatures-directory
publicKey: "7F_3jDlxaquwh291MiACkcS3Opq88NksyHiakzS-Y1g",
validFrom: 1735689600,
// Jan 1, 2025 (nbf from OpenAI)
validUntil: 1769029093
// Jan 21, 2026 (exp from OpenAI)
}
]
};
var keyCache = /* @__PURE__ */ new Map();
var CACHE_TTL_MS = 5 * 60 * 1e3;
var CACHE_MAX_SIZE = 100;
function getApiBaseUrl() {
if (typeof window !== "undefined") {
return "/api/internal";
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : null);
if (baseUrl) {
return baseUrl.replace(/\/$/, "") + "/api/internal";
}
if (process.env.NODE_ENV !== "production") {
console.warn(
"[Signature] No base URL configured for server-side fetch. Using localhost fallback."
);
return "http://localhost:3000/api/internal";
}
console.error(
"[Signature] CRITICAL: No base URL configured for server-side fetch in production!"
);
return "/api/internal";
}
function cleanupExpiredCache() {
const now = Date.now();
const entriesToDelete = [];
for (const [agent, cached] of keyCache.entries()) {
if (now - cached.cachedAt > CACHE_TTL_MS) {
entriesToDelete.push(agent);
}
}
for (const agent of entriesToDelete) {
keyCache.delete(agent);
}
if (keyCache.size > CACHE_MAX_SIZE) {
const entries = Array.from(keyCache.entries()).map(([agent, cached]) => ({
agent,
cachedAt: cached.cachedAt
}));
entries.sort((a, b) => a.cachedAt - b.cachedAt);
const toRemove = entries.slice(0, keyCache.size - CACHE_MAX_SIZE);
for (const entry of toRemove) {
keyCache.delete(entry.agent);
}
}
}
async function fetchKeysFromApi(agent) {
if (keyCache.size > CACHE_MAX_SIZE) {
cleanupExpiredCache();
}
const cached = keyCache.get(agent);
if (cached && Date.now() - cached.cachedAt < CACHE_TTL_MS) {
return cached.keys;
}
if (typeof fetch === "undefined") {
console.warn("[Signature] fetch() not available in this environment");
return null;
}
try {
const apiBaseUrl = getApiBaseUrl();
const url = `${apiBaseUrl}/signature-keys?agent=${encodeURIComponent(agent)}`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json"
},
// 5 second timeout
signal: AbortSignal.timeout(5e3)
});
if (!response.ok) {
console.warn(`[Signature] Failed to fetch keys from API: ${response.status}`);
return null;
}
const data = await response.json();
if (!data.keys || !Array.isArray(data.keys) || data.keys.length === 0) {
console.warn(`[Signature] No keys returned from API for agent: ${agent}`);
return null;
}
keyCache.set(agent, {
keys: data.keys,
cachedAt: Date.now()
});
return data.keys;
} catch (error) {
console.warn("[Signature] Error fetching keys from API, using fallback", {
error: error instanceof Error ? error.message : "Unknown error",
agent
});
return null;
}
}
function isValidAgent(agent) {
return agent in KNOWN_KEYS;
}
async function getKeysForAgent(agent) {
const apiKeys = await fetchKeysFromApi(agent);
if (apiKeys && apiKeys.length > 0) {
return apiKeys;
}
if (isValidAgent(agent)) {
return KNOWN_KEYS[agent];
}
return [];
}
function parseSignatureInput(signatureInput) {
try {
const match = signatureInput.match(/sig1=\((.*?)\);(.+)/);
if (!match) return null;
const [, headersList, params] = match;
const signedHeaders = headersList ? headersList.split(" ").map((h) => h.replace(/"/g, "").trim()).filter((h) => h.length > 0) : [];
const keyidMatch = params ? params.match(/keyid="([^"]+)"/) : null;
const createdMatch = params ? params.match(/created=(\d+)/) : null;
const expiresMatch = params ? params.match(/expires=(\d+)/) : null;
if (!keyidMatch || !keyidMatch[1]) return null;
return {
keyid: keyidMatch[1],
created: createdMatch && createdMatch[1] ? parseInt(createdMatch[1]) : void 0,
expires: expiresMatch && expiresMatch[1] ? parseInt(expiresMatch[1]) : void 0,
signedHeaders
};
} catch (error) {
console.error("[Signature] Failed to parse Signature-Input:", error);
return null;
}
}
function buildSignatureBase(method, path, headers, signedHeaders) {
const components = [];
for (const headerName of signedHeaders) {
let value;
switch (headerName) {
case "@method":
value = method.toUpperCase();
break;
case "@path":
value = path;
break;
case "@authority":
value = headers["host"] || headers["Host"] || "";
break;
default: {
const key = Object.keys(headers).find((k) => k.toLowerCase() === headerName.toLowerCase());
value = key ? headers[key] || "" : "";
break;
}
}
components.push(`"${headerName}": ${value}`);
}
return components.join("\n");
}
function base64ToBytes(base64) {
let standardBase64 = base64.replace(/-/g, "+").replace(/_/g, "/");
const padding = standardBase64.length % 4;
if (padding) {
standardBase64 += "=".repeat(4 - padding);
}
const binaryString = atob(standardBase64);
return Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
}
async function verifyEd25519Signature(publicKeyBase64, signatureBase64, message) {
try {
const publicKeyBytes = base64ToBytes(publicKeyBase64);
const signatureBytes = base64ToBytes(signatureBase64);
const messageBytes = new TextEncoder().encode(message);
if (publicKeyBytes.length !== 32) {
console.error("[Signature] Invalid public key length:", publicKeyBytes.length);
return false;
}
if (signatureBytes.length !== 64) {
console.error("[Signature] Invalid signature length:", signatureBytes.length);
return false;
}
return ed25519.verify(signatureBytes, messageBytes, publicKeyBytes);
} catch (nobleError) {
console.warn("[Signature] @noble/ed25519 failed, trying Web Crypto fallback:", nobleError);
try {
const publicKeyBytes = base64ToBytes(publicKeyBase64);
const signatureBytes = base64ToBytes(signatureBase64);
const messageBytes = new TextEncoder().encode(message);
const publicKey = await crypto.subtle.importKey(
"raw",
publicKeyBytes.buffer,
{
name: "Ed25519",
namedCurve: "Ed25519"
},
false,
["verify"]
);
return await crypto.subtle.verify(
"Ed25519",
publicKey,
signatureBytes.buffer,
messageBytes
);
} catch (cryptoError) {
console.error("[Signature] Both @noble/ed25519 and Web Crypto failed:", {
nobleError: nobleError instanceof Error ? nobleError.message : "Unknown",
cryptoError: cryptoError instanceof Error ? cryptoError.message : "Unknown"
});
return false;
}
}
}
async function verifyAgentSignature(method, path, headers) {
const signature = headers["signature"] || headers["Signature"];
const signatureInput = headers["signature-input"] || headers["Signature-Input"];
const signatureAgent = headers["signature-agent"] || headers["Signature-Agent"];
if (!signature || !signatureInput) {
return {
isValid: false,
confidence: 0,
reason: "No signature headers present",
verificationMethod: "none"
};
}
const parsed = parseSignatureInput(signatureInput);
if (!parsed) {
return {
isValid: false,
confidence: 0,
reason: "Invalid Signature-Input header",
verificationMethod: "none"
};
}
if (parsed.created) {
const now2 = Math.floor(Date.now() / 1e3);
const age = now2 - parsed.created;
if (age > 300) {
return {
isValid: false,
confidence: 0,
reason: "Signature expired (older than 5 minutes)",
verificationMethod: "none"
};
}
if (age < -30) {
return {
isValid: false,
confidence: 0,
reason: "Signature timestamp is in the future",
verificationMethod: "none"
};
}
}
let agent;
let agentKey;
if (signatureAgent === '"https://chatgpt.com"' || signatureAgent?.includes("chatgpt.com")) {
agent = "ChatGPT";
agentKey = "chatgpt";
}
if (!agent || !agentKey) {
return {
isValid: false,
confidence: 0,
reason: "Unknown signature agent",
verificationMethod: "none"
};
}
const knownKeys = await getKeysForAgent(agentKey);
if (knownKeys.length === 0) {
return {
isValid: false,
confidence: 0,
reason: "No keys available for agent",
verificationMethod: "none"
};
}
const key = knownKeys.find((k) => k.kid === parsed.keyid);
if (!key) {
return {
isValid: false,
confidence: 0,
reason: `Unknown key ID: ${parsed.keyid}`,
verificationMethod: "none"
};
}
const now = Math.floor(Date.now() / 1e3);
if (now < key.validFrom || now > key.validUntil) {
return {
isValid: false,
confidence: 0,
reason: "Key is not valid at current time",
verificationMethod: "none"
};
}
const signatureBase = buildSignatureBase(method, path, headers, parsed.signedHeaders);
let signatureValue = signature;
if (signatureValue.startsWith("sig1=:")) {
signatureValue = signatureValue.substring(6);
}
if (signatureValue.endsWith(":")) {
signatureValue = signatureValue.slice(0, -1);
}
const isValid = await verifyEd25519Signature(key.publicKey, signatureValue, signatureBase);
if (isValid) {
return {
isValid: true,
agent,
keyid: parsed.keyid,
confidence: 1,
// 100% confidence for valid signature
verificationMethod: "signature"
};
} else {
return {
isValid: false,
confidence: 0,
reason: "Signature verification failed",
verificationMethod: "none"
};
}
}
function hasSignatureHeaders(headers) {
return !!((headers["signature"] || headers["Signature"]) && (headers["signature-input"] || headers["Signature-Input"]));
}
function isChatGPTSignature(headers) {
const signatureAgent = headers["signature-agent"] || headers["Signature-Agent"];
if (!signatureAgent) {
return false;
}
const agentUrlStr = signatureAgent.replace(/^"+|"+$/g, "");
if (agentUrlStr === "https://chatgpt.com") {
return true;
}
try {
const agentUrl = new URL(agentUrlStr);
const allowedHosts = ["chatgpt.com", "www.chatgpt.com"];
return allowedHosts.includes(agentUrl.host);
} catch {
return false;
}
}
export { hasSignatureHeaders, isChatGPTSignature, verifyAgentSignature };
//# sourceMappingURL=signature-verifier.mjs.map
//# sourceMappingURL=signature-verifier.mjs.map