/** * @module aspa/validator * draft-ietf-sidrops-aspa-verification — ASPA-based AS path validation algorithm. * * Implements the Autonomous System Provider Authorization (ASPA) path * validation procedure as defined in draft-ietf-sidrops-aspa-verification. ASPA enables detection * of route leaks and unauthorized path segments by verifying that each * AS in a BGP path has authorized its upstream provider relationship. * * @see https://datatracker.ietf.org/doc/draft-ietf-sidrops-aspa-verification/ (still an IETF draft, not yet an RFC) * * @example * ```typescript * const aspaObjects = new Map(); * aspaObjects.set(64501, { * customerAsn: 64501, * providers: [{ asn: 64500, afi: ["ipv4", "ipv6"] }], * }); * * const result = validatePath( * [13335, 64501, 64500, 174], * aspaObjects, * "upstream" * ); * console.log(result.status); // "valid" | "invalid" | "unknown" | "unverifiable" * ``` */ // ── Interfaces ────────────────────────────────────────── /** * An ASPA object as registered in the RPKI. * * Maps a customer AS to its authorized upstream providers, * optionally scoped to specific address families. * * @see draft-ietf-sidrops-aspa-profile Section 3 (object format, still an IETF draft) */ export interface ASPAObject { /** The customer AS that created this authorization */ readonly customerAsn: number; /** Authorized upstream provider ASNs with address family scope */ readonly providers: ReadonlyArray<{ readonly asn: number; readonly afi: ReadonlyArray<"ipv4" | "ipv6">; }>; } /** * Result of ASPA path validation. * * Contains the validation status, the analyzed path, any violations * found, and whether a route leak was detected. */ export interface ASPAValidationResult { /** Overall validation status per draft-ietf-sidrops-aspa-verification */ readonly status: "valid" | "invalid" | "unknown" | "unverifiable"; /** The AS path that was validated */ readonly path: ReadonlyArray; /** List of specific violations found in the path */ readonly violations: ReadonlyArray; /** Whether the path exhibits a route leak pattern */ readonly leakDetected: boolean; /** The ASN responsible for the leak, if detected */ readonly leakingAsn?: number; /** Confidence score from 0.0 to 1.0 based on ASPA coverage of the path */ readonly confidence: number; } /** * A specific ASPA violation at a position in the AS path. * * Indicates that a hop in the path was not authorized by the * customer's ASPA object. */ export interface ASPAViolation { /** Zero-based position in the AS path where the violation occurs */ readonly position: number; /** The ASN at this position */ readonly asn: number; /** ASNs that are authorized providers for this ASN */ readonly expectedProviders: ReadonlyArray; /** The actual next-hop ASN in the path */ readonly actualNextHop: number; /** Human-readable explanation of the violation */ readonly reason: string; } // ── Helper Functions ──────────────────────────────────── /** * Check whether `providerAsn` is an authorized provider of `customerAsn`. * * @param customerAsn - The customer ASN to check * @param providerAsn - The candidate provider ASN * @param aspaObjects - Map of all known ASPA objects * @param afi - Address family to check ("ipv4" or "ipv6") * @returns "provider" if authorized, "not-provider" if explicitly not listed, * or "no-attestation" if the customer has no ASPA object * * @see draft-ietf-sidrops-aspa-verification — Verification of Provider Authorization */ function checkProviderAuthorization( customerAsn: number, providerAsn: number, aspaObjects: ReadonlyMap, afi: "ipv4" | "ipv6" = "ipv4" ): "provider" | "not-provider" | "no-attestation" { const aspa = aspaObjects.get(customerAsn); if (!aspa) { return "no-attestation"; } const isAuthorized = aspa.providers.some( (p) => p.asn === providerAsn && p.afi.includes(afi) ); return isAuthorized ? "provider" : "not-provider"; } /** * Remove consecutive duplicate ASNs from a path (AS path prepending). * * BGP speakers may prepend their own ASN multiple times for traffic * engineering. For ASPA validation, consecutive duplicates are collapsed. * * @param path - The raw AS path * @returns The path with consecutive duplicates removed */ function deduplicatePath(path: ReadonlyArray): ReadonlyArray { return path.filter((asn, index) => index === 0 || asn !== path[index - 1]); } // ── Core Validation Functions ─────────────────────────── /** * Validate an AS path in the upstream direction per draft-ietf-sidrops-aspa-verification. * * Walks the path from the origin AS (rightmost) toward the validating AS * (leftmost). For each pair (customer, provider), verifies that the * customer has authorized the provider via an ASPA object. * * The upstream validation procedure (draft-ietf-sidrops-aspa-verification): * - If the path has 0 or 1 unique ASNs, the result is "valid". * - Walk from index N-1 (origin) toward index 0. * - At each hop, check if path[i] authorizes path[i-1] as its provider. * - If any hop yields "not-provider", the path is "invalid". * - If all hops yield "provider", the path is "valid". * - Otherwise the path is "unknown". * * @param path - AS path to validate (leftmost = closest to validator) * @param aspaObjects - Map of customer ASN to ASPA object * @returns Validation result with status, violations, and confidence */ export function validateUpstream( path: ReadonlyArray, aspaObjects: ReadonlyMap ): ASPAValidationResult { const dedupedPath = deduplicatePath(path); // Trivial paths are always valid if (dedupedPath.length <= 1) { return { status: "valid", path: [...dedupedPath], violations: [], leakDetected: false, confidence: 1.0, }; } const violations: ASPAViolation[] = []; let hasNoAttestation = false; let coveredHops = 0; const totalHops = dedupedPath.length - 1; // Walk from origin (rightmost) toward the validator (leftmost). // path[i] is the customer; path[i-1] is the alleged provider. for (let i = dedupedPath.length - 1; i >= 1; i--) { const customerAsn = dedupedPath[i]; const providerAsn = dedupedPath[i - 1]; const authResult = checkProviderAuthorization( customerAsn, providerAsn, aspaObjects ); if (authResult === "provider") { coveredHops++; } else if (authResult === "not-provider") { coveredHops++; const aspa = aspaObjects.get(customerAsn); violations.push({ position: i, asn: customerAsn, expectedProviders: aspa ? aspa.providers.map((p) => p.asn) : [], actualNextHop: providerAsn, reason: `AS${customerAsn} has an ASPA object but does not list AS${providerAsn} as an authorized provider. ` + `This indicates a potential route leak or unauthorized path segment.`, }); } else { hasNoAttestation = true; } } const confidence = totalHops > 0 ? coveredHops / totalHops : 1.0; // Determine overall status per draft-ietf-sidrops-aspa-verification if (violations.length > 0) { const leakingViolation = violations[0]; return { status: "invalid", path: [...dedupedPath], violations, leakDetected: true, leakingAsn: leakingViolation.asn, confidence, }; } if (hasNoAttestation) { return { status: "unknown", path: [...dedupedPath], violations: [], leakDetected: false, confidence, }; } return { status: "valid", path: [...dedupedPath], violations: [], leakDetected: false, confidence, }; } /** * Validate an AS path in the downstream direction per * draft-ietf-sidrops-aspa-verification Section 5.5. * * Unlike upstream validation, 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. A naive "reverse the path and reapply upstream * validation" does not implement this: it would reject any path with a * legitimate down-ramp segment. This computes the four ramp lengths the * draft actually defines (max/min up-ramp, max/min down-ramp) from two * independent scans and applies the draft's three halt conditions in order. * * Verified 2026-07-16 against a worked example fetched from the draft (an * N=4 path with a single unattested "kink" resolves to valid; a path with * definite authorization failures on both ends resolves to invalid). * * @param path - AS path to validate (leftmost = closest to validator) * @param aspaObjects - Map of customer ASN to ASPA object * @returns Validation result with status, violations, and confidence */ export function validateDownstream( path: ReadonlyArray, aspaObjects: ReadonlyMap ): ASPAValidationResult { const dedupedPath = deduplicatePath(path); const N = dedupedPath.length; if (N <= 2) { return { status: "valid", path: [...dedupedPath], violations: [], leakDetected: false, confidence: 1.0, }; } // Up-ramp scan: walk from the origin (index N-1) toward the validator // (index 0). dedupedPath[k] is the customer, dedupedPath[k-1] the // provider it's declaring -- authorized(A(I), A(I+1)) in draft terms. let maxUpRamp = N; let minUpRamp = N; let firstUpViolation: { position: number; asn: number; nextHop: number } | undefined; for (let k = N - 1; k >= 1; k--) { const check = checkProviderAuthorization(dedupedPath[k], dedupedPath[k - 1], aspaObjects); const I = N - k; if (check === "not-provider" && maxUpRamp === N) { maxUpRamp = I; firstUpViolation = { position: k, asn: dedupedPath[k], nextHop: dedupedPath[k - 1] }; } if (check !== "provider" && minUpRamp === N) minUpRamp = I; if (maxUpRamp !== N && minUpRamp !== N) break; } // Down-ramp scan: the mirror image, walking from the validator (index 0) // toward the origin -- authorized(A(J), A(J-1)) in draft terms. let maxDownRamp = N; let minDownRamp = N; let firstDownViolation: { position: number; asn: number; nextHop: number } | undefined; for (let k = 0; k <= N - 2; k++) { const check = checkProviderAuthorization(dedupedPath[k], dedupedPath[k + 1], aspaObjects); const rampLen = k + 1; if (check === "not-provider" && maxDownRamp === N) { maxDownRamp = rampLen; firstDownViolation = { position: k, asn: dedupedPath[k], nextHop: dedupedPath[k + 1] }; } if (check !== "provider" && minDownRamp === N) minDownRamp = rampLen; if (maxDownRamp !== N && minDownRamp !== N) break; } // Confidence: fraction of hops that returned a definitive (non-"no-attestation") result. const totalHops = N - 1; let attestedHops = 0; for (let k = N - 1; k >= 1; k--) { if (checkProviderAuthorization(dedupedPath[k], dedupedPath[k - 1], aspaObjects) !== "no-attestation") { attestedHops++; } } const confidence = totalHops > 0 ? attestedHops / totalHops : 1.0; if (maxUpRamp + maxDownRamp < N) { const violation = firstUpViolation ?? firstDownViolation; const violations: ASPAViolation[] = violation ? [ { position: violation.position, asn: violation.asn, expectedProviders: (aspaObjects.get(violation.asn)?.providers ?? []).map((p) => p.asn), actualNextHop: violation.nextHop, reason: `AS${violation.asn} has an ASPA object but does not list AS${violation.nextHop} as an authorized provider. ` + `No valid up-ramp/down-ramp split covers this path (max_up_ramp=${maxUpRamp}, max_down_ramp=${maxDownRamp}, N=${N}).`, }, ] : []; return { status: "invalid", path: [...dedupedPath], violations, leakDetected: true, leakingAsn: violation?.asn, confidence, }; } if (minUpRamp + minDownRamp < N) { return { status: "unknown", path: [...dedupedPath], violations: [], leakDetected: false, confidence, }; } return { status: "valid", path: [...dedupedPath], violations: [], leakDetected: false, confidence, }; } /** * Validate an AS path against ASPA objects. * * This is the main entry point for ASPA path validation. It dispatches * to either upstream or downstream validation based on the direction * parameter. * * @param path - The AS path to validate. Leftmost ASN is closest to the * validating router; rightmost is the origin. * @param aspaObjects - Map of customer ASN to its ASPA object * @param direction - "upstream" when receiving from a provider, * "downstream" when receiving from a customer * @returns Full validation result including status, violations, leak * detection, and confidence score * * @see draft-ietf-sidrops-aspa-verification — Procedure for Verifying the AS_PATH Attribute * * @example * ```typescript * // Upstream validation: AS174 -> AS13335 -> AS64501 (origin) * const result = validatePath( * [174, 13335, 64501], * aspaObjects, * "upstream" * ); * * if (result.leakDetected) { * console.log(`Route leak by AS${result.leakingAsn}`); * } * ``` */ export function validatePath( path: ReadonlyArray, aspaObjects: ReadonlyMap, direction: "upstream" | "downstream" ): ASPAValidationResult { if (path.length === 0) { return { status: "unverifiable", path: [], violations: [], leakDetected: false, confidence: 0, }; } return direction === "upstream" ? validateUpstream(path, aspaObjects) : validateDownstream(path, aspaObjects); }