// RFC-Compliant ASPA Verification Engine // Pure functions, no I/O -- see draft-ietf-sidrops-aspa-verification. // Check if AS path contains AS_SET segments (curly braces indicate sets) function hasAsSet(asPath) { if (typeof asPath === "string") { return asPath.includes("{") || asPath.includes("}"); } return false; } // Hop Check function (core of ASPA verification) // aspaStore = Map> (CAS -> provider set) // hopCheck(asI, asJ): is asJ an attested provider of CUSTOMER asI? Caller must pass // the customer first, provider second -- see draft-ietf-sidrops-aspa-verification // section 5.3's authorized(A(I), A(I+1)), where A(I) is always the customer side. function hopCheck(asI, asJ, aspaStore) { const providers = aspaStore.get(asI); if (!providers) return "NoAttestation"; return providers.has(asJ) ? "ProviderPlus" : "NotProviderPlus"; } // Collapse AS path prepends (remove consecutive duplicates) function collapsePrepends(path) { return path.filter((as, i) => i === 0 || as !== path[i - 1]); } // Upstream Verification (RFC Section 6.1) function verifyUpstream(asPath, aspaStore, rawPathStr) { if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; const collapsed = collapsePrepends(asPath); if (collapsed.length <= 1) return { result: "Valid", reason: "Single-hop path" }; const hops = []; let hasNoAttestation = false; for (let i = 1; i < collapsed.length; i++) { // collapsed[i] (closer to origin) is the customer; collapsed[i - 1] (closer to // the validator) is the provider it's declaring -- customer goes first in hopCheck. const check = hopCheck(collapsed[i], collapsed[i - 1], aspaStore); hops.push({ from: collapsed[i - 1], to: collapsed[i], result: check, }); if (check === "NotProviderPlus") { return { result: "Invalid", reason: "Hop AS" + collapsed[i - 1] + " -> AS" + collapsed[i] + " is NotProviderPlus", hops }; } if (check === "NoAttestation") hasNoAttestation = true; } return { result: hasNoAttestation ? "Unknown" : "Valid", reason: hasNoAttestation ? "Some hops lack ASPA attestation" : "All hops verified as ProviderPlus", hops, }; } // Downstream Verification -- draft-ietf-sidrops-aspa-verification Section 5.5. // Unlike upstream verification, a downstream path may legitimately contain both // an up-ramp (customer->provider hops, near the origin) AND a down-ramp // (provider->customer hops, near the validator), transitioning at most once -- // e.g. origin -> its providers -> a peering exchange -> validator's providers -> // validator. Section 5.3 defines four ramp lengths computed from two independent // scans, verified 2026-07-16 against a worked example fetched from the draft // (N=4 path with a single unattested/no-attestation "kink" correctly resolves // to Valid, and a path with definite failures on both ends correctly resolves // to Invalid, matching this implementation). function verifyDownstream(asPath, aspaStore, rawPathStr) { if (rawPathStr && hasAsSet(rawPathStr)) return { result: "Invalid", reason: "Path contains AS_SET" }; const collapsed = collapsePrepends(asPath); const N = collapsed.length; if (N <= 2) return { result: "Valid", reason: "Path length <= 2" }; const hops = []; for (let i = 1; i < N; i++) { hops.push({ from: collapsed[i - 1], to: collapsed[i], result: hopCheck(collapsed[i], collapsed[i - 1], aspaStore), }); } // Up-ramp scan: walk from the origin (collapsed[N-1]) toward the validator // (collapsed[0]), i.e. authorized(A(I), A(I+1)) for I = 1..N-1 ascending. // maxUpRamp stops at the first DEFINITE violation ("NotProviderPlus"); // minUpRamp stops at the first hop that isn't confirmed ProviderPlus // (including unattested "NoAttestation" hops, which don't disprove a leak // but also don't prove the path is clean). let maxUpRamp = N; let minUpRamp = N; for (let k = N - 1; k >= 1; k--) { const check = hopCheck(collapsed[k], collapsed[k - 1], aspaStore); const I = N - k; if (check === "NotProviderPlus" && maxUpRamp === N) maxUpRamp = I; if (check !== "ProviderPlus" && minUpRamp === N) minUpRamp = I; if (maxUpRamp !== N && minUpRamp !== N) break; } // Down-ramp scan: the mirror image, walking from the validator (collapsed[0]) // toward the origin, i.e. authorized(A(J), A(J-1)) for J = N..2 descending. let maxDownRamp = N; let minDownRamp = N; for (let k = 0; k <= N - 2; k++) { const check = hopCheck(collapsed[k], collapsed[k + 1], aspaStore); const rampLen = k + 1; // = N - J + 1 for the corresponding J if (check === "NotProviderPlus" && maxDownRamp === N) maxDownRamp = rampLen; if (check !== "ProviderPlus" && minDownRamp === N) minDownRamp = rampLen; if (maxDownRamp !== N && minDownRamp !== N) break; } if (maxUpRamp + maxDownRamp < N) { return { result: "Invalid", reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") < " + N + ": no valid up/down split covers the path", hops, }; } if (minUpRamp + minDownRamp < N) { return { result: "Unknown", reason: "min_up_ramp(" + minUpRamp + ") + min_down_ramp(" + minDownRamp + ") < " + N + ": unattested hops prevent full confirmation", hops, }; } return { result: "Valid", reason: "max_up_ramp(" + maxUpRamp + ") + max_down_ramp(" + maxDownRamp + ") >= " + N + " and min_up_ramp + min_down_ramp >= " + N, hops, }; } // Valley Detection: scan path for up-down-up pattern (route leak indicator) function detectValleys(asPath, aspaStore) { const collapsed = collapsePrepends(asPath); if (collapsed.length < 4) return []; const valleys = []; // Walk the path and look at relationship transitions const relationships = []; for (let i = 1; i < collapsed.length; i++) { const fwd = hopCheck(collapsed[i - 1], collapsed[i], aspaStore); const rev = hopCheck(collapsed[i], collapsed[i - 1], aspaStore); let rel = "unknown"; if (fwd === "ProviderPlus") rel = "customer-to-provider"; else if (rev === "ProviderPlus") rel = "provider-to-customer"; else if (fwd === "NotProviderPlus" && rev === "NotProviderPlus") rel = "peer-to-peer"; relationships.push({ from: collapsed[i - 1], to: collapsed[i], rel }); } // Detect c2p -> p2c -> c2p pattern for (let i = 0; i < relationships.length - 2; i++) { if ( relationships[i].rel === "customer-to-provider" && relationships[i + 1].rel === "provider-to-customer" && relationships[i + 2].rel === "customer-to-provider" ) { valleys.push({ position: i, path_segment: [ relationships[i].from, relationships[i].to, relationships[i + 1].to, relationships[i + 2].to, ].map((a) => "AS" + a), description: "Route leak: AS" + relationships[i].from + " -> AS" + relationships[i].to + " (c2p) -> AS" + relationships[i + 1].to + " (p2c) -> AS" + relationships[i + 2].to + " (c2p)", }); } } return valleys; } // Build ASPA store from detected provider relationships function buildAspaStore(detectedProviders, targetAsn) { const store = new Map(); // Add the target ASN's providers if (detectedProviders.length > 0) { const providerSet = new Set(detectedProviders.map((p) => p.asn)); store.set(targetAsn, providerSet); } return store; } // Calculate ASPA Readiness Score (0-100) function calculateAspaReadinessScore(params) { const { rpkiCoverage, aspaObjectExists, providerCompleteness, pathValidationPct } = params; // ROA coverage (0-25 points) const roaScore = Math.round((Math.min(rpkiCoverage, 100) / 100) * 25); // ASPA object exists (0-25 points) const aspaScore = aspaObjectExists ? 25 : 0; // Provider completeness (0-25 points) const provScore = Math.round((Math.min(providerCompleteness, 100) / 100) * 25); // Path validation results (0-25 points) const pathScore = Math.round((Math.min(pathValidationPct, 100) / 100) * 25); return { total: roaScore + aspaScore + provScore + pathScore, breakdown: { roa_coverage: { score: roaScore, max: 25, value: rpkiCoverage }, aspa_object: { score: aspaScore, max: 25, value: aspaObjectExists }, provider_completeness: { score: provScore, max: 25, value: providerCompleteness }, path_validation: { score: pathScore, max: 25, value: pathValidationPct }, }, }; } module.exports = { hasAsSet, hopCheck, collapsePrepends, verifyUpstream, verifyDownstream, detectValleys, buildAspaStore, calculateAspaReadinessScore, };