Zero shared mutable state, no I/O -- behavior-preserving, verified via the smoke-test harness (28/28 endpoints match baseline). - server/data/*: CITY_COORDS, RIR_DELEGATION_URLS, TIER1_ASNS, IX_CITY_MAP, RIR country-code sets, UA constant - server/aspa-verification/engine.js: the RFC ASPA verification engine (hopCheck, verifyUpstream, verifyDownstream, detectValleys, etc.) - server/resilience-score.js, server/route-leak.js: pure scoring/heuristic functions server.js: 6838 -> 6454 lines. Part of the module-split plan at ~/.claude/plans/linked-riding-newell.md.
225 lines
8.5 KiB
JavaScript
225 lines
8.5 KiB
JavaScript
// 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<number, Set<number>> (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,
|
|
};
|