UNPKG

@agentcommunity/aid-engine

Version:

Core engine for Agent Identity & Discovery (AID) validation and discovery

463 lines (455 loc) 17.4 kB
'use strict'; var aid = require('@agentcommunity/aid'); var tls = require('tls'); var url = require('url'); var crypto = require('crypto'); function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; } var tls__default = /*#__PURE__*/_interopDefault(tls); // src/checker.ts async function runBaseDiscovery(domain, options) { try { const res = await aid.discover(domain, { protocol: options.protocol, timeout: options.timeoutMs, wellKnownFallback: options.allowFallback, wellKnownTimeoutMs: options.wellKnownTimeoutMs }); return { ok: true, value: res }; } catch (e) { return { ok: false, error: e }; } } async function inspectTls(uri, timeoutMs = 5e3) { const u = new url.URL(uri); const host = u.hostname; const port = u.port ? Number(u.port) : 443; return await new Promise((resolve, reject) => { const socket = tls__default.default.connect( { host, port, servername: host, timeout: timeoutMs, rejectUnauthorized: true }, () => { const peer = socket.getPeerCertificate(true); const issuer = peer?.issuer?.CN || peer?.issuer?.commonName || null; const san = typeof peer?.subjectaltname === "string" ? peer.subjectaltname.split(",").map((s) => s.trim()).filter(Boolean) : null; const validFrom = peer?.valid_from ? new Date(peer.valid_from).toISOString() : null; const validTo = peer?.valid_to ? new Date(peer.valid_to).toISOString() : null; let daysRemaining = null; if (validTo) { const ms = new Date(validTo).getTime() - Date.now(); daysRemaining = Math.floor(ms / (24 * 3600 * 1e3)); } resolve({ host, sni: host, issuer, san, validFrom, validTo, daysRemaining }); socket.end(); } ); socket.on("error", (err) => reject(err)); socket.on("timeout", () => { socket.destroy(); reject(new Error("TLS timeout")); }); }); } // src/dnssec.ts async function probeDnssecRrsigTxt(name, doh = "https://cloudflare-dns.com/dns-query", timeoutMs = 5e3) { const url = new URL(doh); url.searchParams.set("name", name); url.searchParams.set("type", "RRSIG"); const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url.toString(), { headers: { Accept: "application/dns-json" }, signal: controller.signal }); if (!res.ok) return { present: false, method: "RRSIG", proof: null }; const json = await res.json(); const answers = json.Answer ?? []; const hasRrsig = answers.some((a) => a.type === 46); return { present: hasRrsig, method: "RRSIG", proof: hasRrsig ? answers : null }; } catch { return { present: false, method: "RRSIG", proof: null }; } finally { clearTimeout(id); } } async function runProtocolProbe(domain, protocol, timeoutMs) { const name = `_agent._${protocol}.${domain}`; try { const res = await aid.discover(name, { timeout: timeoutMs, wellKnownFallback: false // Probes are DNS-only }); return { attempt: { name, type: "TXT", result: "NOERROR", ttl: res.ttl, byteLength: new TextEncoder().encode(res.raw).length } }; } catch (e) { const error = e; if (error.errorCode === "ERR_NO_RECORD") { return { attempt: { name, type: "TXT", result: "NXDOMAIN" }, error }; } return { attempt: { name, type: "TXT", result: "ERROR", reason: error.errorCode || "unknown" }, error }; } } // src/error_messages.ts var ERROR_MESSAGES = { // General UNKNOWN_ERROR: "An unexpected error occurred. Please check your input and try again.", // DNS DNS_LOOKUP_FAILED: "DNS lookup failed for the specified domain. Check network connectivity and domain spelling.", NO_RECORD_FOUND: "No AID TXT record found for the domain. Ensure the record exists at _agent.<domain>.", // Record validation INVALID_TXT_FORMAT: "The AID TXT record has an invalid format. Ensure it follows v=aid1;key=value;... structure.", UNSUPPORTED_PROTOCOL: "The specified protocol is not supported. See the official protocol registry for valid tokens.", DEPRECATED_RECORD: "The AID record has been deprecated. Check the deprecation date and update accordingly.", // Security SECURITY_VIOLATION: "A security check failed. The record or endpoint may be compromised.", TLS_VALIDATION_FAILED: "TLS certificate validation failed. Ensure the certificate is valid and not expired.", PKA_HANDSHAKE_FAILED: "PKA endpoint proof handshake failed. Verify the public key and private key configuration.", // Fallback FALLBACK_FAILED: "The .well-known fallback failed. Ensure the HTTPS endpoint returns valid JSON.", // Byte limits BYTE_LIMIT_EXCEEDED: "The record exceeds the 255-byte DNS limit. Use aliases and shorten fields.", // Warnings BYTE_LIMIT_WARNING: "Record size is close to the 255-byte limit. Consider using aliases.", TLS_EXPIRING_SOON: "TLS certificate expires soon. Renew to avoid interruptions.", DNSSEC_NOT_DETECTED: "DNSSEC not detected. Enable for better integrity.", PKA_NOT_PRESENT: "Endpoint proof (PKA) not present. Consider adding for security.", DOWNGRADE_DETECTED: "Security downgrade detected: a previously present PKA or KID has been removed.", // Recommendations ENABLE_DNSSEC: "Enable DNSSEC at your domain registrar to improve DNS integrity.", ADD_PKA: "Add PKA endpoint proof by running 'aid-doctor pka generate'.", RENEW_TLS: "Renew your TLS certificate soon to avoid expiration.", USE_ALIASES: "Use single-letter aliases (e.g., u for uri) to reduce record size." }; // src/checker.ts function initReport(domain, protocol) { return { domain, queried: { strategy: "base-first", hint: { proto: protocol, source: "cli", present: Boolean(protocol) }, attempts: [], wellKnown: { attempted: false, used: false, url: null, httpStatus: null, contentType: null, byteLength: null, status: null, snippet: null } }, record: { raw: null, parsed: null, valid: false, warnings: [], errors: [] }, dnssec: { present: false, method: "RRSIG", proof: null }, tls: { checked: false, valid: null, host: null, sni: null, issuer: null, san: null, validFrom: null, validTo: null, daysRemaining: null, redirectBlocked: null }, pka: { present: false, attempted: false, verified: null, kid: null, alg: null, createdSkewSec: null, covered: null }, downgrade: { checked: false, previous: null, status: null }, exitCode: 1, cacheEntry: null }; } async function runCheck(domain, opts) { const report = initReport(domain, opts.protocol); try { const dnsRes = await runBaseDiscovery(domain, { ...opts.protocol && { protocol: opts.protocol }, timeoutMs: opts.timeoutMs, allowFallback: opts.allowFallback, wellKnownTimeoutMs: opts.wellKnownTimeoutMs }); const value = dnsRes.value; const queryName = value.queryName; const attempt = { name: queryName, type: "TXT", result: "NOERROR", ttl: value.ttl }; report.queried.attempts.push(attempt); if (value.queryName.startsWith("https")) { report.queried.wellKnown.used = true; report.queried.wellKnown.attempted = true; report.queried.wellKnown.url = value.queryName; } if (opts.protocol && opts.probeProtoEvenIfBase) { const probeRes = await runProtocolProbe(domain, opts.protocol, opts.timeoutMs); report.queried.attempts.push(probeRes.attempt); if (!probeRes.error) { report.record.warnings.push({ code: "PROTOCOL_SUBDOMAIN_EXISTS", message: `A record exists at the protocol-specific subdomain _agent._${opts.protocol}.${domain}, which may differ from the base record.` }); } } const record = value.record; report.record.parsed = record; report.record.valid = true; report.record.raw = (() => { const parts = ["v=aid1"]; if (record.uri) parts.push(`u=${record.uri}`); if (record.proto) parts.push(`p=${record.proto}`); if (record.auth) parts.push(`a=${record.auth}`); if (record.desc) parts.push(`s=${record.desc}`); if (record.docs) parts.push(`d=${record.docs}`); if (record.dep) { const depDate = new Date(record.dep); if (depDate.getTime() < Date.now()) { report.record.errors.push({ code: "DEPRECATED", message: ERROR_MESSAGES.DEPRECATED_RECORD }); report.record.valid = false; report.exitCode = 1001; } else { report.record.warnings.push({ code: "DEPRECATION_SCHEDULED", message: `Record is scheduled for deprecation on ${record.dep}` }); } } if (record.pka) parts.push(`k=${record.pka}`); if (record.kid) parts.push(`i=${record.kid}`); return parts.join(";"); })(); const byteLen = new TextEncoder().encode(report.record.raw).length; if (byteLen > 255) { report.record.warnings.push({ code: "BYTE_LIMIT", message: ERROR_MESSAGES.BYTE_LIMIT_EXCEEDED }); } const skipSecurity = typeof process !== "undefined" && process.env && process.env.AID_SKIP_SECURITY === "1"; if (!skipSecurity && record.proto !== "local" && record.proto !== "zeroconf") { try { await aid.enforceRedirectPolicy(record.uri, opts.timeoutMs); if (record.uri.startsWith("https://")) { const tlsInfo = await inspectTls(record.uri, opts.timeoutMs); report.tls.checked = true; report.tls.valid = true; report.tls.host = tlsInfo.host; report.tls.sni = tlsInfo.sni; report.tls.issuer = tlsInfo.issuer; report.tls.san = tlsInfo.san; report.tls.validFrom = tlsInfo.validFrom; report.tls.validTo = tlsInfo.validTo; report.tls.daysRemaining = tlsInfo.daysRemaining; if (tlsInfo.daysRemaining !== null && tlsInfo.daysRemaining < 21) { report.record.warnings.push({ code: "TLS_EXPIRING", message: ERROR_MESSAGES.TLS_EXPIRING_SOON }); } } else { report.tls.checked = false; report.tls.valid = null; } } catch (e) { const err = e; report.tls.checked = true; report.tls.valid = false; report.record.errors.push({ code: err.errorCode ?? "ERR_SECURITY", message: err.message }); report.exitCode = err instanceof aid.AidError ? err.code : 1003; return report; } } try { const r = await probeDnssecRrsigTxt(value.queryName); report.dnssec.present = r.present; report.dnssec.proof = r.proof; } catch { } if (record.pka && record.kid) { report.pka.present = true; report.pka.kid = record.kid; report.pka.alg = "ed25519"; report.pka.attempted = true; try { await aid.performPKAHandshake(record.uri, record.pka, record.kid); report.pka.verified = true; } catch (e) { const err = e; report.pka.verified = false; report.record.errors.push({ code: err.errorCode ?? "ERR_SECURITY", message: ERROR_MESSAGES.PKA_HANDSHAKE_FAILED }); report.exitCode = err instanceof aid.AidError ? err.code : 1003; return report; } } if (opts.checkDowngrade) { report.downgrade.checked = true; if (opts.previousCacheEntry) { const prev = opts.previousCacheEntry; report.downgrade.previous = { pka: prev.pka, kid: prev.kid }; const nowPka = record.pka ?? null; const nowKid = record.kid ?? null; if (prev.pka && !nowPka) { report.downgrade.status = "downgrade"; report.record.warnings.push({ code: "DOWNGRADE", message: ERROR_MESSAGES.DOWNGRADE_DETECTED }); } else if (prev.pka && nowPka && prev.pka !== nowPka) { report.downgrade.status = "downgrade"; report.record.warnings.push({ code: "DOWNGRADE", message: ERROR_MESSAGES.DOWNGRADE_DETECTED }); } else if (prev.kid && nowKid && prev.kid !== nowKid) { report.downgrade.status = "downgrade"; report.record.warnings.push({ code: "DOWNGRADE", message: ERROR_MESSAGES.DOWNGRADE_DETECTED }); } else { report.downgrade.status = "no_change"; } } else { report.downgrade.status = "first_seen"; } report.cacheEntry = { lastSeen: (/* @__PURE__ */ new Date()).toISOString(), pka: record.pka ?? null, kid: record.kid ?? null }; } report.exitCode = 0; return report; } catch (e) { const err = e; report.exitCode = err instanceof aid.AidError ? err.code : 1e3; report.record.errors.push({ code: err.errorCode ?? "ERR_NO_RECORD", message: err.message }); if (err.errorCode === "ERR_FALLBACK_FAILED" && err.details) { report.queried.wellKnown.attempted = true; report.queried.wellKnown.httpStatus = err.details.httpStatus; report.queried.wellKnown.contentType = err.details.contentType; report.queried.wellKnown.snippet = err.details.snippet; report.queried.wellKnown.byteLength = err.details.byteLength; } if (opts.protocol && opts.probeProtoSubdomain) { const probeRes = await runProtocolProbe(domain, opts.protocol, opts.timeoutMs); report.queried.attempts.push(probeRes.attempt); } return report; } } function buildTxtRecordVariant(formData, useAliases) { const parts = ["v=aid1"]; const k = (full, alias) => useAliases ? alias : full; if (formData.uri) parts.push(`${k("uri", "u")}=${formData.uri}`); if (formData.proto) parts.push(`${k("proto", "p")}=${formData.proto}`); if (formData.auth) parts.push(`${k("auth", "a")}=${formData.auth}`); if (formData.desc) parts.push(`${k("desc", "s")}=${formData.desc}`); if (formData.docs) parts.push(`${k("docs", "d")}=${formData.docs}`); if (formData.dep) parts.push(`${k("dep", "e")}=${formData.dep}`); if (formData.pka) parts.push(`${k("pka", "k")}=${formData.pka}`); if (formData.kid) parts.push(`${k("kid", "i")}=${formData.kid}`); return parts.join(";"); } function buildTxtRecord(formData) { const full = buildTxtRecordVariant(formData, false); const alias = buildTxtRecordVariant(formData, true); return new TextEncoder().encode(alias).length <= new TextEncoder().encode(full).length ? alias : full; } function validateTxtRecord(record) { try { aid.parse(record); return { isValid: true }; } catch (error) { return { isValid: false, error: error instanceof Error ? error.message : "Invalid" }; } } function base58btcEncode(bytes) { const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; let zeros = 0; while (zeros < bytes.length && bytes[zeros] === 0) zeros++; const size = Math.ceil(bytes.length * Math.log(256) / Math.log(58)) + 1; const b = new Uint8Array(size); let length = 0; for (let i = zeros; i < bytes.length; i++) { let carry = bytes[i]; let j = size - 1; while (carry !== 0 || j >= size - length) { carry += 256 * b[j]; b[j] = carry % 58; carry = Math.floor(carry / 58); j--; } length = size - 1 - j; } let it = size - length; while (it < size && b[it] === 0) it++; let out = "1".repeat(zeros); for (let i = it; i < size; i++) out += ALPHABET[b[i]]; return out; } async function generateEd25519KeyPair() { const kp = await crypto.webcrypto.subtle.generateKey("Ed25519", true, [ "sign", "verify" ]); const rawPub = new Uint8Array(await crypto.webcrypto.subtle.exportKey("raw", kp.publicKey)); const pkcs8 = new Uint8Array(await crypto.webcrypto.subtle.exportKey("pkcs8", kp.privateKey)); const pubMb = "z" + base58btcEncode(rawPub); const pem = "-----BEGIN PRIVATE KEY-----\n" + Buffer.from(pkcs8).toString("base64") + "\n-----END PRIVATE KEY-----\n"; return { publicKey: pubMb, privateKeyPem: pem, privateKeyBytes: pkcs8 }; } function verifyPka(pka) { if (!pka || pka[0] !== "z") return { valid: false, reason: "Missing z multibase prefix" }; const s = pka.slice(1); const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; for (const c of s) if (!ALPHABET.includes(c)) return { valid: false, reason: "Invalid base58 character" }; const approx = Math.floor(s.length * Math.log(58) / Math.log(256)); if (approx !== 32 && approx !== 33) return { valid: false, reason: "Unexpected key length" }; return { valid: true }; } exports.ERROR_MESSAGES = ERROR_MESSAGES; exports.buildTxtRecord = buildTxtRecord; exports.buildTxtRecordVariant = buildTxtRecordVariant; exports.generateEd25519KeyPair = generateEd25519KeyPair; exports.runCheck = runCheck; exports.validateTxtRecord = validateTxtRecord; exports.verifyPka = verifyPka; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map