diff --git a/specs/00_common_types.md b/specs/00_common_types.md index cabf293..c5d8c70 100644 --- a/specs/00_common_types.md +++ b/specs/00_common_types.md @@ -182,6 +182,8 @@ RFC 引用:RFC 5280 §4.2.2.1;RFC 5280 §4.2.2.2;RFC 5280 §4.2.1.6。 | `1.2.840.113549.1.9.5` | CMS signedAttrs: signing-time | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) | | `1.2.840.113549.1.9.16.1.24` | ROA eContentType: id-ct-routeOriginAuthz | RFC 9582 §3 | | `1.2.840.113549.1.9.16.1.26` | Manifest eContentType: id-ct-rpkiManifest | RFC 9286 §4.1 | +| `1.2.840.113549.1.9.16.1.35` | Ghostbusters eContentType: id-ct-rpkiGhostbusters | RFC 6493 §6;RFC 6493 §9.1 | +| `1.2.840.113549.1.9.16.1.49` | ASPA eContentType: id-ct-ASPA | `draft-ietf-sidrops-aspa-profile-21` §2 | | `1.3.6.1.5.5.7.1.1` | X.509 v3 扩展:authorityInfoAccess | RFC 5280 §4.2.2.1 | | `1.3.6.1.5.5.7.1.11` | X.509 v3 扩展:subjectInfoAccess | RFC 5280 §4.2.2.2;RPKI 约束见 RFC 6487 §4.8.8 | | `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 | diff --git a/specs/06_manifest_mft.md b/specs/06_manifest_mft.md index 9c8d9b9..e2abaac 100644 --- a/specs/06_manifest_mft.md +++ b/specs/06_manifest_mft.md @@ -102,5 +102,9 @@ FileAndHash ::= SEQUENCE { Manifest 使用“one-time-use EE certificate”进行签名验证,规范对该 EE 证书的使用方式给出约束: - Manifest 相关 EE 证书应为 one-time-use(每次新 manifest 生成新密钥对/新 EE)。RFC 9286 §4(Section 4 前导段落)。 +- 用于签名/验证 manifest 的 EE 证书在 RFC 3779 的资源扩展中描述 INRs 时,**MUST** 使用 `inherit`(而不是显式列出资源集合)。RFC 9286 §5.1(生成步骤 2)。 + - 若证书包含 **IP Address Delegation Extension**(IP prefix delegation):内容必须为 `inherit`(不得显式列 prefix/range)。RFC 9286 §5.1;RFC 3779。 + - 若证书包含 **AS Identifier Delegation Extension**(ASN delegation):内容必须为 `inherit`(不得显式列 ASID/range)。RFC 9286 §5.1;RFC 3779。 + - 另外按资源证书 profile:资源证书 **MUST** 至少包含上述两类扩展之一(也可两者都有),且这些扩展 **MUST** 标记为 critical。RFC 6487 §2。 - 用于验证 manifest 的 EE 证书 **MUST** 具有与 `thisUpdate..nextUpdate` 区间一致的有效期,以避免 CRL 无谓增长。RFC 9286 §4.2.1(manifestNumber 段落前的说明)。 - 替换 manifest 时,CA 必须撤销旧 manifest 对应 EE 证书;且若新 manifest 早于旧 manifest 的 nextUpdate 发行,则 CA **MUST** 同时发行新 CRL 撤销旧 manifest EE。RFC 9286 §4.2.1(nextUpdate 段落末);RFC 9286 §5.1(生成步骤)。 diff --git a/specs/08_aspa.md b/specs/08_aspa.md new file mode 100644 index 0000000..fd8b150 --- /dev/null +++ b/specs/08_aspa.md @@ -0,0 +1,100 @@ +# 08. ASPA(Autonomous System Provider Authorization) + +## 8.1 对象定位 + +ASPA(Autonomous System Provider Authorization)是一种 RPKI Signed Object,用于由“客户 AS”(Customer AS, CAS)签名声明其上游“提供者 AS”(Provider AS, PAS)集合,以支持路由泄漏(route leak)检测/缓解。`draft-ietf-sidrops-aspa-profile-21`;`draft-ietf-sidrops-aspa-verification`。 + +ASPA 由 CMS 外壳 + ASPA eContent 组成: + +- 外壳:RFC 6488(更新:RFC 9589) +- eContent:`draft-ietf-sidrops-aspa-profile-21` + +## 8.2 原始载体与编码 + +- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6488 §2-§3;RFC 9589 §4。 +- eContentType:`id-ct-ASPA`,OID `1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。 +- eContent:DER 编码 ASN.1 `ASProviderAttestation`。`draft-ietf-sidrops-aspa-profile-21` §3。 + +### 8.2.1 eContentType 与 eContent 的 ASN.1 定义(`draft-ietf-sidrops-aspa-profile-21` §2-§3) + +ASPA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent`(payload)的 ASN.1 定义见 `draft-ietf-sidrops-aspa-profile-21` §2-§3。 + +**eContentType(OID)**:`draft-ietf-sidrops-aspa-profile-21` §2。 + +```asn1 +id-ct-ASPA OBJECT IDENTIFIER ::= + { iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1) + pkcs-9(9) id-smime(16) id-ct(1) aspa(49) } +``` + +**eContent(ASPA ASN.1 模块)**:`draft-ietf-sidrops-aspa-profile-21` §3。 + +```asn1 +ASProviderAttestation ::= SEQUENCE { + version [0] INTEGER DEFAULT 0, + customerASID ASID, + providers ProviderASSet } + +ProviderASSet ::= SEQUENCE (SIZE(1..MAX)) OF ASID + +ASID ::= INTEGER (0..4294967295) +``` + +编码/解码要点(与上面 ASN.1 结构直接对应): + +- `version`:规范要求 **MUST 为 1** 且 **MUST 显式编码**(不得依赖 DEFAULT 省略)。`draft-ietf-sidrops-aspa-profile-21` §3.1。 +- `customerASID`:客户 AS 号(CAS)。`draft-ietf-sidrops-aspa-profile-21` §3.2。 +- `providers`:授权的提供者 AS 集合(SPAS)。并对“自包含/排序/去重”施加额外约束(见下)。`draft-ietf-sidrops-aspa-profile-21` §3.3。 + +## 8.3 解析规则(eContent 语义层) + +输入:`RpkiSignedObject`。 + +1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。 +2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.49`。`draft-ietf-sidrops-aspa-profile-21` §2。 +3) 将 `econtent_der` 以 DER 解析为 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。 +4) 将 `providers` 映射为语义字段 `provider_as_ids: list[int]`,并对其执行“约束检查/(可选)归一化”。`draft-ietf-sidrops-aspa-profile-21` §3.3。 + +## 8.4 抽象数据模型(接口) + +### 8.4.1 `AspaObject` + +| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 | +|---|---|---|---|---| +| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6488 §3;RFC 9589 §4 | +| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.49` | `draft-ietf-sidrops-aspa-profile-21` §2 | +| `aspa` | `AspaEContent` | eContent 语义对象 | 见下 | `draft-ietf-sidrops-aspa-profile-21` §3 | + +### 8.4.2 `AspaEContent`(ASProviderAttestation) + +| 字段 | 类型 | 语义 | 约束/解析规则 | 规范引用 | +|---|---|---|---|---| +| `version` | `int` | ASPA 版本 | MUST 为 `1` 且 MUST 显式编码(字段不可省略) | `draft-ietf-sidrops-aspa-profile-21` §3.1 | +| `customer_as_id` | `int` | Customer ASID | 0..4294967295 | `draft-ietf-sidrops-aspa-profile-21` §3.2(ASID 定义) | +| `provider_as_ids` | `list[int]` | Provider ASID 列表(SPAS) | 长度 `>= 1`;且满足“不得包含 customer、升序、去重” | `draft-ietf-sidrops-aspa-profile-21` §3.3 | + +## 8.5 字段级约束清单(实现对照) + +- eContentType 必须为 `id-ct-ASPA`(OID `1.2.840.113549.1.9.16.1.49`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。`draft-ietf-sidrops-aspa-profile-21` §2(引用 RFC 6488)。 +- eContent 必须 DER 编码并符合 `ASProviderAttestation` ASN.1。`draft-ietf-sidrops-aspa-profile-21` §3。 +- `version` 必须为 1,且必须显式编码(缺失视为不合规)。`draft-ietf-sidrops-aspa-profile-21` §3.1。 +- `providers`(`provider_as_ids`)必须满足: + - `customer_as_id` **MUST NOT** 出现在 `provider_as_ids` 中; + - `provider_as_ids` 必须按数值 **升序排序**; + - `provider_as_ids` 中每个 ASID 必须 **唯一**。`draft-ietf-sidrops-aspa-profile-21` §3.3。 + +## 8.6 与 EE 证书的语义约束(为后续验证准备) + +ASPA 的外壳包含一个 EE 证书,用于验证 ASPA 签名;规范对该 EE 证书与 ASPA payload 的匹配关系提出要求: + +- EE 证书必须包含 AS 资源扩展(Autonomous System Identifier Delegation Extension),且 `customer_as_id` 必须与该扩展中的 ASId 匹配。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。 +- EE 证书的 AS 资源扩展 **必须**: + - 恰好包含 1 个 `id` 元素; + - **不得**包含 `inherit` 元素; + - **不得**包含 `range` 元素。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779 §3.2.3.3 / §3.2.3.6 / §3.2.3.7)。 +- EE 证书 **不得**包含 IP 资源扩展(IP Address Delegation Extension)。`draft-ietf-sidrops-aspa-profile-21` §4(引用 RFC 3779)。 + +## 8.7 实现建议(非规范约束) + +`draft-ietf-sidrops-aspa-profile-21` 给出了一条 RP 侧建议:实现可对单个 `customer_as_id` 的 `provider_as_ids` 数量施加上界(例如 4,000~10,000),超过阈值时建议将该 `customer_as_id` 的所有 ASPA 视为无效并记录错误日志。`draft-ietf-sidrops-aspa-profile-21` §6。 + diff --git a/specs/09_ghostbusters_gbr.md b/specs/09_ghostbusters_gbr.md new file mode 100644 index 0000000..16d473d --- /dev/null +++ b/specs/09_ghostbusters_gbr.md @@ -0,0 +1,87 @@ +# 09. Ghostbusters Record(GBR) + +## 9.1 对象定位 + +Ghostbusters Record(GBR)是一个可选的 RPKI Signed Object,用于承载“联系人信息”(人类可读的联系渠道),以便在证书过期、CRL 失效、密钥轮换等事件中能够联系到维护者。RFC 6493 §1。 + +GBR 由 CMS 外壳 + vCard 载荷组成: + +- 外壳:RFC 6488(更新:RFC 9589) +- 载荷(payload):RFC 6493 定义的 vCard profile(基于 RFC 6350 vCard 4.0 的严格子集)。RFC 6493 §5。 + +## 9.2 原始载体与编码 + +- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 6493 §6(引用 RFC 6488)。 +- eContentType:`id-ct-rpkiGhostbusters`,OID `1.2.840.113549.1.9.16.1.35`。RFC 6493 §6;RFC 6493 §9.1。 +- eContent:一个 OCTET STRING,其 octets 是 vCard 文本(vCard 4.0,且受 RFC 6493 的 profile 约束)。RFC 6493 §5;RFC 6493 §6。 + +> 说明:与 ROA/MFT 这类“eContent 内部再 DER 解码为 ASN.1 结构”的对象不同,GBR 的 eContent 语义上就是“vCard 文本内容本身”(由 Signed Object Template 的 `eContent OCTET STRING` 承载)。RFC 6493 §6。 + +## 9.3 vCard profile(RFC 6493 §5) + +GBR 的 vCard payload 是 RFC 6350 vCard 4.0 的严格子集,仅允许以下属性(properties): + +- `BEGIN`:必须为第一行,值必须为 `BEGIN:VCARD`。RFC 6493 §5。 +- `VERSION`:必须为第二行,值必须为 `VERSION:4.0`。RFC 6493 §5(引用 RFC 6350 §3.7.9)。 +- `FN`:联系人姓名或角色名。RFC 6493 §5(引用 RFC 6350 §6.2.1)。 +- `ORG`:组织信息(可选)。RFC 6493 §5(引用 RFC 6350 §6.6.4)。 +- `ADR`:邮寄地址(可选)。RFC 6493 §5(引用 RFC 6350 §6.3)。 +- `TEL`:语音/传真电话(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.1)。 +- `EMAIL`:邮箱(可选)。RFC 6493 §5(引用 RFC 6350 §6.4.2)。 +- `END`:必须为最后一行,值必须为 `END:VCARD`。RFC 6493 §5。 + +额外约束: + +- `BEGIN`、`VERSION`、`FN`、`END` 必须包含。RFC 6493 §5。 +- 为保证可用性,`ADR`/`TEL`/`EMAIL` 三者中至少一个必须包含。RFC 6493 §5。 +- 除上述属性外,**其他属性 MUST NOT** 出现。RFC 6493 §5。 + +## 9.4 解析规则(payload 语义层) + +输入:`RpkiSignedObject`。 + +1) 解析 CMS 外壳,得到 `econtent_type` 与 `econtent_bytes`。RFC 6488 §3;RFC 9589 §4。 +2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.35`。RFC 6493 §6。 +3) 将 `econtent_bytes` 解析为 vCard 文本,并按 RFC 6493 §5 的 profile 校验(属性集合、必选项、行首/行尾约束)。RFC 6493 §5;RFC 6493 §7。 +4) 通过校验后,将允许属性映射为 `GhostbustersVCard` 语义对象(见下)。 + +## 9.5 抽象数据模型(接口) + +### 9.5.1 `GhostbustersObject` + +| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 | +|---|---|---|---|---| +| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6493 §6;RFC 6488 §3;RFC 9589 §4 | +| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.35` | RFC 6493 §6 | +| `vcard` | `GhostbustersVCard` | vCard 语义对象 | 由 eContent 文本解析并校验 profile | RFC 6493 §5;RFC 6493 §7 | + +### 9.5.2 `GhostbustersVCard`(vCard 4.0 profile) + +| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 | +|---|---|---|---|---| +| `raw_text` | `string` | 原始 vCard 文本 | 由 eContent bytes 解码得到;用于保留原文/诊断 | RFC 6493 §5-§7 | +| `fn` | `string` | 联系人姓名/角色名 | `FN` 必须存在 | RFC 6493 §5 | +| `org` | `optional[string]` | 组织 | `ORG` 可选 | RFC 6493 §5 | +| `adrs` | `list[string]` | 邮寄地址(原始 ADR value) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 | +| `tels` | `list[Uri]` | 电话 URI(从 TEL 提取的 `tel:` 等 URI) | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 | +| `emails` | `list[string]` | 邮箱地址 | 允许 0..N;至少满足“ADR/TEL/EMAIL 至少一项存在” | RFC 6493 §5 | + +> 说明:RFC 6493 并未要求 RP 必须完整解析 vCard 参数(例如 `TYPE=WORK`、`VALUE=uri`),因此此处将 `ADR`/`TEL`/`EMAIL` 建模为“足以联络”的最小语义集合;实现可在保留 `raw_text` 的同时按 RFC 6350 扩展解析能力。 + +## 9.6 字段级约束清单(实现对照) + +- eContentType 必须为 `id-ct-rpkiGhostbusters`(OID `1.2.840.113549.1.9.16.1.35`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 6493 §6(引用 RFC 6488)。 +- eContent 必须是 vCard 4.0 文本,且必须满足 RFC 6493 §5 的 profile: + - 第一行 `BEGIN:VCARD`; + - 第二行 `VERSION:4.0`; + - 末行 `END:VCARD`; + - 必须包含 `FN`; + - `ADR`/`TEL`/`EMAIL` 至少一个存在; + - 除允许集合外不得出现其他属性。RFC 6493 §5;RFC 6493 §7。 + +## 9.7 与 EE 证书的语义约束(为后续验证准备) + +GBR 使用 CMS 外壳内的 EE 证书验证签名。RFC 6493 对该 EE 证书提出一条资源扩展约束: + +- 用于验证 GBR 的 EE 证书在描述 Internet Number Resources 时,必须使用 `inherit`,而不是显式资源集合。RFC 6493 §6(引用 RFC 3779)。 + diff --git a/src/data_model/aspa.rs b/src/data_model/aspa.rs new file mode 100644 index 0000000..22ac46c --- /dev/null +++ b/src/data_model/aspa.rs @@ -0,0 +1,223 @@ +use crate::data_model::oid::OID_CT_ASPA; +use crate::data_model::signed_object::{RpkiSignedObject, SignedObjectDecodeError}; +use der_parser::ber::{Class}; +use der_parser::der::{parse_der, DerObject, Tag}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AspaObject { + pub signed_object: RpkiSignedObject, + pub econtent_type: String, + pub aspa: AspaEContent, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AspaEContent { + pub version: u32, + pub customer_as_id: u32, + pub provider_as_ids: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum AspaDecodeError { + #[error("SignedObject decode error: {0}")] + SignedObjectDecode(#[from] SignedObjectDecodeError), + + #[error("ASPA eContentType must be {OID_CT_ASPA}, got {0}")] + InvalidEContentType(String), + + #[error("ASPA parse error: {0}")] + Parse(String), + + #[error("ASPA trailing bytes: {0} bytes")] + TrailingBytes(usize), + + #[error("ASProviderAttestation must be a SEQUENCE of 3 elements")] + InvalidAttestationSequence, + + #[error("ASPA version must be 1 and MUST be explicitly encoded")] + VersionMustBeExplicitOne, + + #[error("ASPA customerASID out of range (0..=4294967295), got {0}")] + CustomerAsIdOutOfRange(u64), + + #[error("ASPA providers must contain at least one ASID")] + EmptyProviders, + + #[error("ASPA provider ASID out of range (0..=4294967295), got {0}")] + ProviderAsIdOutOfRange(u64), + + #[error("ASPA providers must be in strictly increasing order")] + ProvidersNotStrictlyIncreasing, + + #[error("ASPA providers contains the customerASID ({0}) which is not allowed")] + ProvidersContainCustomer(u32), +} + +#[derive(Debug, thiserror::Error)] +pub enum AspaValidateError { + #[error("ASPA EE certificate must contain AS resources extension (RFC 3779)")] + EeAsResourcesMissing, + + #[error("ASPA EE certificate AS resources must not use inherit")] + EeAsResourcesInherit, + + #[error("ASPA EE certificate AS resources must not include ranges")] + EeAsResourcesRangePresent, + + #[error("ASPA customerASID ({customer_as_id}) does not match EE AS resources ({ee_as_id})")] + CustomerAsIdMismatch { customer_as_id: u32, ee_as_id: u32 }, + + #[error("ASPA EE certificate must not contain IP resources extension (RFC 3779)")] + EeIpResourcesPresent, +} + +/// Minimal EE resource information required to validate ASPA payload semantics. +/// +/// The caller is responsible for extracting this from the EE certificate (RFC 3779). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EeAspaResources { + /// The single ASID carried in EE's AS resources "id" element (profile requires exactly one). + pub as_id: Option, + /// Whether EE's AS resources uses inherit (must be false). + pub as_resources_inherit: bool, + /// Whether EE's AS resources includes any range elements (must be false). + pub as_resources_range_present: bool, + /// Whether EE certificate contains IP resources extension (must be false). + pub ip_resources_present: bool, +} + +impl AspaObject { + pub fn decode_der(der: &[u8]) -> Result { + let signed_object = RpkiSignedObject::decode_der(der)?; + Self::from_signed_object(signed_object) + } + + pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result { + let econtent_type = signed_object + .signed_data + .encap_content_info + .econtent_type + .clone(); + if econtent_type != OID_CT_ASPA { + return Err(AspaDecodeError::InvalidEContentType(econtent_type)); + } + + let aspa = AspaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?; + Ok(Self { + aspa, + signed_object, + econtent_type: OID_CT_ASPA.to_string(), + }) + } +} + +impl AspaEContent { + /// Decode the DER-encoded ASProviderAttestation defined in draft-ietf-sidrops-aspa-profile-21 §3. + pub fn decode_der(der: &[u8]) -> Result { + let (rem, obj) = parse_der(der).map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if !rem.is_empty() { + return Err(AspaDecodeError::TrailingBytes(rem.len())); + } + + let seq = obj + .as_sequence() + .map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if seq.len() != 3 { + return Err(AspaDecodeError::InvalidAttestationSequence); + } + + // version [0] EXPLICIT INTEGER MUST be present and MUST be 1. + let v_obj = &seq[0]; + if v_obj.class() != Class::ContextSpecific || v_obj.tag() != Tag(0) { + return Err(AspaDecodeError::VersionMustBeExplicitOne); + } + let inner_der = v_obj + .as_slice() + .map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + let (rem, inner) = + parse_der(inner_der).map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if !rem.is_empty() { + return Err(AspaDecodeError::Parse( + "trailing bytes inside ASProviderAttestation.version".into(), + )); + } + let v = inner + .as_u64() + .map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if v != 1 { + return Err(AspaDecodeError::VersionMustBeExplicitOne); + } + + let customer_u64 = seq[1] + .as_u64() + .map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if customer_u64 > u32::MAX as u64 { + return Err(AspaDecodeError::CustomerAsIdOutOfRange(customer_u64)); + } + let customer_as_id = customer_u64 as u32; + + let providers = parse_providers(&seq[2], customer_as_id)?; + + Ok(Self { + version: 1, + customer_as_id, + provider_as_ids: providers, + }) + } + + /// Validate ASPA payload against EE resources extracted by the caller. + /// + /// This implements the EE/payload semantic checks described in + /// `draft-ietf-sidrops-aspa-profile-21` §4 (as summarized in `rpki/specs/08_aspa.md`). + pub fn validate_against_ee_resources(&self, ee: &EeAspaResources) -> Result<(), AspaValidateError> { + if ee.ip_resources_present { + return Err(AspaValidateError::EeIpResourcesPresent); + } + if ee.as_resources_inherit { + return Err(AspaValidateError::EeAsResourcesInherit); + } + if ee.as_resources_range_present { + return Err(AspaValidateError::EeAsResourcesRangePresent); + } + let ee_as_id = ee.as_id.ok_or(AspaValidateError::EeAsResourcesMissing)?; + if ee_as_id != self.customer_as_id { + return Err(AspaValidateError::CustomerAsIdMismatch { + customer_as_id: self.customer_as_id, + ee_as_id, + }); + } + Ok(()) + } +} + +fn parse_providers(obj: &DerObject<'_>, customer_as_id: u32) -> Result, AspaDecodeError> { + let seq = obj + .as_sequence() + .map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if seq.is_empty() { + return Err(AspaDecodeError::EmptyProviders); + } + + let mut out: Vec = Vec::with_capacity(seq.len()); + let mut prev: Option = None; + for item in seq { + let v = item + .as_u64() + .map_err(|e| AspaDecodeError::Parse(e.to_string()))?; + if v > u32::MAX as u64 { + return Err(AspaDecodeError::ProviderAsIdOutOfRange(v)); + } + let asn = v as u32; + if asn == customer_as_id { + return Err(AspaDecodeError::ProvidersContainCustomer(customer_as_id)); + } + if let Some(p) = prev { + if asn <= p { + return Err(AspaDecodeError::ProvidersNotStrictlyIncreasing); + } + } + prev = Some(asn); + out.push(asn); + } + Ok(out) +} diff --git a/src/data_model/mod.rs b/src/data_model/mod.rs index 6ca8b67..e7e1e4d 100644 --- a/src/data_model/mod.rs +++ b/src/data_model/mod.rs @@ -8,3 +8,5 @@ mod oids; pub mod oid; pub mod signed_object; pub mod manifest; +pub mod roa; +pub mod aspa; diff --git a/src/data_model/oid.rs b/src/data_model/oid.rs index b3eb09c..cd1025f 100644 --- a/src/data_model/oid.rs +++ b/src/data_model/oid.rs @@ -14,6 +14,8 @@ pub const OID_CRL_NUMBER: &str = "2.5.29.20"; pub const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14"; pub const OID_CT_RPKI_MANIFEST: &str = "1.2.840.113549.1.9.16.1.26"; +pub const OID_CT_ROUTE_ORIGIN_AUTHZ: &str = "1.2.840.113549.1.9.16.1.24"; +pub const OID_CT_ASPA: &str = "1.2.840.113549.1.9.16.1.49"; // X.509 extensions / access methods (RFC 5280 / RFC 6487) pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11"; diff --git a/src/data_model/roa.rs b/src/data_model/roa.rs new file mode 100644 index 0000000..b69192d --- /dev/null +++ b/src/data_model/roa.rs @@ -0,0 +1,466 @@ +use crate::data_model::oid::OID_CT_ROUTE_ORIGIN_AUTHZ; +use crate::data_model::signed_object::{RpkiSignedObject, SignedObjectDecodeError}; +use der_parser::ber::{BerObjectContent, Class}; +use der_parser::der::{parse_der, DerObject, Tag}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoaObject { + pub signed_object: RpkiSignedObject, + pub econtent_type: String, + pub roa: RoaEContent, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoaEContent { + pub version: u32, + pub as_id: u32, + pub ip_addr_blocks: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum RoaDecodeError { + #[error("SignedObject decode error: {0}")] + SignedObjectDecode(#[from] SignedObjectDecodeError), + + #[error("ROA eContentType must be {OID_CT_ROUTE_ORIGIN_AUTHZ}, got {0}")] + InvalidEContentType(String), + + #[error("ROA parse error: {0}")] + Parse(String), + + #[error("ROA trailing bytes: {0} bytes")] + TrailingBytes(usize), + + #[error("RouteOriginAttestation must be a SEQUENCE of 2 or 3 elements, got {0}")] + InvalidAttestationSequenceLen(usize), + + #[error("ROA version must be 0, got {0}")] + InvalidVersion(u64), + + #[error("ROA asID out of range (0..=4294967295), got {0}")] + AsIdOutOfRange(u64), + + #[error("ROA ipAddrBlocks must have length 1..2, got {0}")] + InvalidIpAddrBlocksLen(usize), + + #[error("ROAIPAddressFamily must be a SEQUENCE of 2 elements")] + InvalidIpAddressFamily, + + #[error("ROA addressFamily must be an OCTET STRING of 2 bytes")] + InvalidAddressFamily, + + #[error("ROA addressFamily AFI not supported: {0:02X?}")] + UnsupportedAfi(Vec), + + #[error("ROA contains duplicate AFI {0:?}")] + DuplicateAfi(RoaAfi), + + #[error("ROAAddresses must have at least one entry")] + EmptyAddressList, + + #[error("ROAIPAddress must be a SEQUENCE of 1..2 elements")] + InvalidRoaIpAddress, + + #[error("ROAIPAddress.address must be a BIT STRING")] + InvalidPrefixBitString, + + #[error("ROAIPAddress.address has invalid unused bits encoding")] + InvalidPrefixUnusedBits, + + #[error("ROAIPAddress.address prefix length {prefix_len} out of range for {afi:?}")] + PrefixLenOutOfRange { afi: RoaAfi, prefix_len: u16 }, + + #[error("ROAIPAddress.maxLength out of range for {afi:?}: prefix_len={prefix_len}, max_len={max_len}")] + InvalidMaxLength { + afi: RoaAfi, + prefix_len: u16, + max_len: u16, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum RoaValidateError { + #[error("ROA EE certificate must not contain AS resources extension (RFC 9582 §5)")] + EeAsResourcesPresent, + + #[error("ROA EE certificate IP resources must not use inherit (RFC 9582 §5)")] + EeIpResourcesInherit, + + #[error("ROA prefix not covered by EE certificate IP resources: {afi:?} {addr:?}/{prefix_len}")] + PrefixNotInEeResources { + afi: RoaAfi, + addr: Vec, + prefix_len: u16, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IpResourceSet { + pub prefixes: Vec, +} + +impl IpResourceSet { + pub fn contains_prefix(&self, p: &IpPrefix) -> bool { + self.prefixes + .iter() + .any(|r| r.afi == p.afi && prefix_covers(r, p)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EeResources { + pub ip_resources: IpResourceSet, + pub ip_resources_inherit: bool, + pub as_resources_present: bool, +} + +impl RoaObject { + pub fn decode_der(der: &[u8]) -> Result { + let signed_object = RpkiSignedObject::decode_der(der)?; + Self::from_signed_object(signed_object) + } + + pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result { + let econtent_type = signed_object + .signed_data + .encap_content_info + .econtent_type + .clone(); + if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ { + return Err(RoaDecodeError::InvalidEContentType(econtent_type)); + } + + let roa = RoaEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?; + Ok(Self { + roa, + signed_object, + econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(), + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum RoaAfi { + Ipv4, + Ipv6, +} + +impl RoaAfi { + fn ub(self) -> u16 { + match self { + RoaAfi::Ipv4 => 32, + RoaAfi::Ipv6 => 128, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoaIpAddressFamily { + pub afi: RoaAfi, + pub addresses: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RoaIpAddress { + pub prefix: IpPrefix, + pub max_length: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct IpPrefix { + pub afi: RoaAfi, + /// Prefix length in bits. + pub prefix_len: u16, + /// Network order address bytes (IPv4 4 bytes / IPv6 16 bytes), with host bits cleared. + pub addr: Vec, +} + +impl RoaEContent { + /// Decode the DER-encoded RouteOriginAttestation defined in RFC 9582 §4. + pub fn decode_der(der: &[u8]) -> Result { + let (rem, obj) = parse_der(der).map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if !rem.is_empty() { + return Err(RoaDecodeError::TrailingBytes(rem.len())); + } + + let seq = obj + .as_sequence() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if seq.len() != 2 && seq.len() != 3 { + return Err(RoaDecodeError::InvalidAttestationSequenceLen(seq.len())); + } + + let mut idx = 0; + let mut version: u32 = 0; + if seq.len() == 3 { + let v_obj = &seq[0]; + if v_obj.class() != Class::ContextSpecific || v_obj.tag() != Tag(0) { + return Err(RoaDecodeError::Parse( + "RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(), + )); + } + let inner_der = v_obj + .as_slice() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + let (rem, inner) = + parse_der(inner_der).map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if !rem.is_empty() { + return Err(RoaDecodeError::Parse( + "trailing bytes inside RouteOriginAttestation.version".into(), + )); + } + let v = inner + .as_u64() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if v != 0 { + return Err(RoaDecodeError::InvalidVersion(v)); + } + version = 0; + idx = 1; + } + + let as_id_u64 = seq[idx] + .as_u64() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if as_id_u64 > u32::MAX as u64 { + return Err(RoaDecodeError::AsIdOutOfRange(as_id_u64)); + } + let as_id = as_id_u64 as u32; + idx += 1; + + let ip_addr_blocks = parse_ip_addr_blocks(&seq[idx])?; + + let mut out = Self { + version, + as_id, + ip_addr_blocks, + }; + out.canonicalize(); + Ok(out) + } + + pub fn canonicalize(&mut self) { + self.ip_addr_blocks.sort_by_key(|f| f.afi); + for fam in &mut self.ip_addr_blocks { + fam.addresses.sort(); + fam.addresses.dedup(); + } + } + + /// Validate ROA payload against EE resources extracted by the caller. + /// + /// This implements the ROA/EE semantic checks from RFC 9582 §5 that do not + /// require certificate path validation. + pub fn validate_against_ee_resources(&self, ee: &EeResources) -> Result<(), RoaValidateError> { + if ee.as_resources_present { + return Err(RoaValidateError::EeAsResourcesPresent); + } + if ee.ip_resources_inherit { + return Err(RoaValidateError::EeIpResourcesInherit); + } + + for fam in &self.ip_addr_blocks { + for entry in &fam.addresses { + if !ee.ip_resources.contains_prefix(&entry.prefix) { + return Err(RoaValidateError::PrefixNotInEeResources { + afi: entry.prefix.afi, + addr: entry.prefix.addr.clone(), + prefix_len: entry.prefix.prefix_len, + }); + } + } + } + Ok(()) + } +} + +fn parse_ip_addr_blocks(obj: &DerObject<'_>) -> Result, RoaDecodeError> { + let seq = obj + .as_sequence() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if seq.is_empty() || seq.len() > 2 { + return Err(RoaDecodeError::InvalidIpAddrBlocksLen(seq.len())); + } + + let mut out: Vec = Vec::new(); + for fam in seq { + let family = parse_ip_address_family(fam)?; + if out.iter().any(|f| f.afi == family.afi) { + return Err(RoaDecodeError::DuplicateAfi(family.afi)); + } + out.push(family); + } + Ok(out) +} + +fn parse_ip_address_family(obj: &DerObject<'_>) -> Result { + let seq = obj + .as_sequence() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if seq.len() != 2 { + return Err(RoaDecodeError::InvalidIpAddressFamily); + } + + let afi = parse_afi(&seq[0])?; + let addresses = parse_roa_addresses(afi, &seq[1])?; + if addresses.is_empty() { + return Err(RoaDecodeError::EmptyAddressList); + } + + Ok(RoaIpAddressFamily { afi, addresses }) +} + +fn parse_afi(obj: &DerObject<'_>) -> Result { + let bytes = obj + .as_slice() + .map_err(|_e| RoaDecodeError::InvalidAddressFamily)?; + if bytes.len() != 2 { + return Err(RoaDecodeError::InvalidAddressFamily); + } + match bytes { + [0x00, 0x01] => Ok(RoaAfi::Ipv4), + [0x00, 0x02] => Ok(RoaAfi::Ipv6), + _ => Err(RoaDecodeError::UnsupportedAfi(bytes.to_vec())), + } +} + +fn parse_roa_addresses(afi: RoaAfi, obj: &DerObject<'_>) -> Result, RoaDecodeError> { + let seq = obj + .as_sequence() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + let mut out = Vec::with_capacity(seq.len()); + for entry in seq { + out.push(parse_roa_ip_address(afi, entry)?); + } + Ok(out) +} + +fn parse_roa_ip_address(afi: RoaAfi, obj: &DerObject<'_>) -> Result { + let seq = obj + .as_sequence() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + if seq.is_empty() || seq.len() > 2 { + return Err(RoaDecodeError::InvalidRoaIpAddress); + } + + let prefix = parse_prefix_bits(afi, &seq[0])?; + let max_length = match seq.get(1) { + None => None, + Some(m) => { + let v = m + .as_u64() + .map_err(|e| RoaDecodeError::Parse(e.to_string()))?; + let max_len: u16 = v + .try_into() + .map_err(|_e| RoaDecodeError::InvalidMaxLength { + afi, + prefix_len: prefix.prefix_len, + max_len: u16::MAX, + })?; + Some(max_len) + } + }; + + if let Some(max_len) = max_length { + let ub = afi.ub(); + if max_len > ub || max_len < prefix.prefix_len { + return Err(RoaDecodeError::InvalidMaxLength { + afi, + prefix_len: prefix.prefix_len, + max_len, + }); + } + } + + Ok(RoaIpAddress { prefix, max_length }) +} + +fn parse_prefix_bits(afi: RoaAfi, obj: &DerObject<'_>) -> Result { + let (unused_bits, bytes) = match &obj.content { + BerObjectContent::BitString(unused, bso) => (*unused, bso.data.to_vec()), + _ => return Err(RoaDecodeError::InvalidPrefixBitString), + }; + + if unused_bits > 7 { + return Err(RoaDecodeError::InvalidPrefixUnusedBits); + } + if bytes.is_empty() { + if unused_bits != 0 { + return Err(RoaDecodeError::InvalidPrefixUnusedBits); + } + } else if unused_bits != 0 { + let mask = (1u8 << unused_bits) - 1; + if (bytes[bytes.len() - 1] & mask) != 0 { + return Err(RoaDecodeError::InvalidPrefixUnusedBits); + } + } + + let prefix_len = (bytes.len() * 8) + .checked_sub(unused_bits as usize) + .ok_or(RoaDecodeError::InvalidPrefixUnusedBits)? as u16; + if prefix_len > afi.ub() { + return Err(RoaDecodeError::PrefixLenOutOfRange { afi, prefix_len }); + } + + let addr = canonicalize_prefix_addr(afi, prefix_len, &bytes); + Ok(IpPrefix { + afi, + prefix_len, + addr, + }) +} + +fn canonicalize_prefix_addr(afi: RoaAfi, prefix_len: u16, bytes: &[u8]) -> Vec { + let full_len = match afi { + RoaAfi::Ipv4 => 4, + RoaAfi::Ipv6 => 16, + }; + let mut addr = vec![0u8; full_len]; + let copy_len = bytes.len().min(full_len); + addr[..copy_len].copy_from_slice(&bytes[..copy_len]); + + if prefix_len == 0 { + return addr; + } + + let last_prefix_bit = (prefix_len - 1) as usize; + let last_prefix_byte = last_prefix_bit / 8; + let rem = (prefix_len % 8) as u8; + if rem != 0 { + let mask: u8 = 0xFF << (8 - rem); + if last_prefix_byte < addr.len() { + addr[last_prefix_byte] &= mask; + } + } + addr +} + +fn prefix_covers(resource: &IpPrefix, subject: &IpPrefix) -> bool { + if resource.afi != subject.afi { + return false; + } + if resource.prefix_len > subject.prefix_len { + return false; + } + + let full_len = match resource.afi { + RoaAfi::Ipv4 => 4, + RoaAfi::Ipv6 => 16, + }; + if resource.addr.len() != full_len || subject.addr.len() != full_len { + return false; + } + + let n = resource.prefix_len as usize; + let whole = n / 8; + let rem = (n % 8) as u8; + + if resource.addr[..whole] != subject.addr[..whole] { + return false; + } + if rem == 0 { + return true; + } + let mask = 0xFFu8 << (8 - rem); + (resource.addr[whole] & mask) == (subject.addr[whole] & mask) +} diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa new file mode 100644 index 0000000..f6112d8 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa new file mode 100644 index 0000000..83ded42 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer new file mode 100644 index 0000000..8f2d9a7 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa new file mode 100644 index 0000000..f4bd855 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa new file mode 100644 index 0000000..773266f Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer new file mode 100644 index 0000000..2795311 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl new file mode 100644 index 0000000..72755f7 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft new file mode 100644 index 0000000..215819d Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa new file mode 100644 index 0000000..009e866 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa new file mode 100644 index 0000000..14fb352 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa new file mode 100644 index 0000000..3efb0d3 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr new file mode 100644 index 0000000..9a938f6 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl new file mode 100644 index 0000000..1a87a92 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft new file mode 100644 index 0000000..a08bcd9 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa new file mode 100644 index 0000000..65a21e3 Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa differ diff --git a/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa new file mode 100644 index 0000000..770f12c Binary files /dev/null and b/tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/111wEGLfsrCC1YXvRd90Mcgv3so.roa b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/111wEGLfsrCC1YXvRd90Mcgv3so.roa new file mode 100644 index 0000000..736f0b4 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/111wEGLfsrCC1YXvRd90Mcgv3so.roa differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa new file mode 100644 index 0000000..08d7661 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/9X0AhXWTJDl8lJhfOwvnac-42CA.spl b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/9X0AhXWTJDl8lJhfOwvnac-42CA.spl new file mode 100644 index 0000000..f3540c9 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/9X0AhXWTJDl8lJhfOwvnac-42CA.spl differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/D53F-thyu-FB_Q-Qnu54dMG6kUc.roa b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/D53F-thyu-FB_Q-Qnu54dMG6kUc.roa new file mode 100644 index 0000000..f64e9c8 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/D53F-thyu-FB_Q-Qnu54dMG6kUc.roa differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/euv64En05073B5-r95s2Uu_UwJg.roa b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/euv64En05073B5-r95s2Uu_UwJg.roa new file mode 100644 index 0000000..bee24c3 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/euv64En05073B5-r95s2Uu_UwJg.roa differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.crl b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.crl new file mode 100644 index 0000000..44dbd93 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.crl differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.mft b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.mft new file mode 100644 index 0000000..dde9b49 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.mft differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/zIlYN-tDLuEj0BDwZSi5PS1eIDI.roa b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/zIlYN-tDLuEj0BDwZSi5PS1eIDI.roa new file mode 100644 index 0000000..aa556c3 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/zIlYN-tDLuEj0BDwZSi5PS1eIDI.roa differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl new file mode 100644 index 0000000..b9b5bec Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl differ diff --git a/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft new file mode 100644 index 0000000..f629855 Binary files /dev/null and b/tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft differ diff --git a/tests/test_aspa_decode.rs b/tests/test_aspa_decode.rs new file mode 100644 index 0000000..e193678 --- /dev/null +++ b/tests/test_aspa_decode.rs @@ -0,0 +1,25 @@ +use rpki::data_model::aspa::{AspaDecodeError, AspaObject}; + +#[test] +fn decode_aspa_fixture_smoke() { + let der = std::fs::read( + "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa", + ) + .expect("read ASPA fixture"); + let aspa = AspaObject::decode_der(&der).expect("decode aspa"); + assert_eq!(aspa.econtent_type, rpki::data_model::oid::OID_CT_ASPA); + assert_eq!(aspa.aspa.version, 1); + assert_ne!(aspa.aspa.customer_as_id, 0); + assert!(!aspa.aspa.provider_as_ids.is_empty()); + println!("{aspa:#?}"); +} + +#[test] +fn decode_rejects_non_aspa_econtent_type() { + let der = std::fs::read( + "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa", + ) + .expect("read ROA fixture"); + let err = AspaObject::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::InvalidEContentType(_))); +} diff --git a/tests/test_aspa_econtent_decode_errors.rs b/tests/test_aspa_econtent_decode_errors.rs new file mode 100644 index 0000000..801888f --- /dev/null +++ b/tests/test_aspa_econtent_decode_errors.rs @@ -0,0 +1,111 @@ +use rpki::data_model::aspa::{AspaDecodeError, AspaEContent}; + +fn len_bytes(len: usize) -> Vec { + if len < 128 { + vec![len as u8] + } else { + let mut tmp = Vec::new(); + let mut n = len; + while n > 0 { + tmp.push((n & 0xFF) as u8); + n >>= 8; + } + tmp.reverse(); + let mut out = vec![0x80 | (tmp.len() as u8)]; + out.extend(tmp); + out + } +} + +fn tlv(tag: u8, content: &[u8]) -> Vec { + let mut out = vec![tag]; + out.extend(len_bytes(content.len())); + out.extend_from_slice(content); + out +} + +fn der_integer_u64(v: u64) -> Vec { + let mut bytes = Vec::new(); + let mut n = v; + if n == 0 { + bytes.push(0); + } else { + while n > 0 { + bytes.push((n & 0xFF) as u8); + n >>= 8; + } + bytes.reverse(); + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + } + tlv(0x02, &bytes) +} + +fn der_sequence(children: Vec>) -> Vec { + let mut content = Vec::new(); + for c in children { + content.extend(c); + } + tlv(0x30, &content) +} + +fn cs_explicit(tag_no: u8, inner_der: Vec) -> Vec { + tlv(0xA0 | (tag_no & 0x1F), &inner_der) +} + +fn aspa_attestation_explicit_version(version: u64, customer: u64, providers: Vec) -> Vec { + let providers_der = der_sequence(providers.into_iter().map(der_integer_u64).collect()); + der_sequence(vec![ + cs_explicit(0, der_integer_u64(version)), + der_integer_u64(customer), + providers_der, + ]) +} + +fn aspa_attestation_missing_version(customer: u64, providers: Vec) -> Vec { + let providers_der = der_sequence(providers.into_iter().map(der_integer_u64).collect()); + der_sequence(vec![der_integer_u64(customer), providers_der]) +} + +#[test] +fn version_must_be_explicit_and_equal_to_one() { + let der = aspa_attestation_missing_version(64496, vec![64497]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::InvalidAttestationSequence)); + + let der = aspa_attestation_explicit_version(0, 64496, vec![64497]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::VersionMustBeExplicitOne)); +} + +#[test] +fn customer_asid_out_of_range_is_rejected() { + let der = aspa_attestation_explicit_version(1, (u32::MAX as u64) + 1, vec![64497]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::CustomerAsIdOutOfRange(_))); +} + +#[test] +fn providers_constraints_are_enforced() { + // empty providers + let der = aspa_attestation_explicit_version(1, 64496, vec![]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::EmptyProviders)); + + // not strictly increasing (duplicate) + let der = aspa_attestation_explicit_version(1, 64496, vec![64497, 64497]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::ProvidersNotStrictlyIncreasing)); + + // not strictly increasing (descending) + let der = aspa_attestation_explicit_version(1, 64496, vec![64500, 64497]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::ProvidersNotStrictlyIncreasing)); + + // contains customer + let der = aspa_attestation_explicit_version(1, 64496, vec![64496, 64497]); + let err = AspaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, AspaDecodeError::ProvidersContainCustomer(64496))); +} + diff --git a/tests/test_aspa_validate_ee_resources.rs b/tests/test_aspa_validate_ee_resources.rs new file mode 100644 index 0000000..6e8c2a2 --- /dev/null +++ b/tests/test_aspa_validate_ee_resources.rs @@ -0,0 +1,84 @@ +use rpki::data_model::aspa::{AspaEContent, AspaValidateError, EeAspaResources}; + +fn test_aspa() -> AspaEContent { + AspaEContent { + version: 1, + customer_as_id: 64496, + provider_as_ids: vec![64497], + } +} + +#[test] +fn validate_accepts_when_customer_matches_ee_asid() { + let aspa = test_aspa(); + let ee = EeAspaResources { + as_id: Some(64496), + as_resources_inherit: false, + as_resources_range_present: false, + ip_resources_present: false, + }; + aspa.validate_against_ee_resources(&ee) + .expect("customer must match"); +} + +#[test] +fn validate_rejects_missing_as_resources() { + let aspa = test_aspa(); + let ee = EeAspaResources { + as_id: None, + as_resources_inherit: false, + as_resources_range_present: false, + ip_resources_present: false, + }; + let err = aspa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, AspaValidateError::EeAsResourcesMissing)); +} + +#[test] +fn validate_rejects_as_resources_inherit_or_ranges() { + let aspa = test_aspa(); + let ee = EeAspaResources { + as_id: Some(64496), + as_resources_inherit: true, + as_resources_range_present: false, + ip_resources_present: false, + }; + let err = aspa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, AspaValidateError::EeAsResourcesInherit)); + + let ee = EeAspaResources { + as_id: Some(64496), + as_resources_inherit: false, + as_resources_range_present: true, + ip_resources_present: false, + }; + let err = aspa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, AspaValidateError::EeAsResourcesRangePresent)); +} + +#[test] +fn validate_rejects_customer_mismatch() { + let aspa = test_aspa(); + let ee = EeAspaResources { + as_id: Some(64511), + as_resources_inherit: false, + as_resources_range_present: false, + ip_resources_present: false, + }; + let err = aspa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, AspaValidateError::CustomerAsIdMismatch { .. })); +} + +#[test] +fn validate_rejects_ip_resources_present() { + let aspa = test_aspa(); + let ee = EeAspaResources { + as_id: Some(64496), + as_resources_inherit: false, + as_resources_range_present: false, + ip_resources_present: true, + }; + let err = aspa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, AspaValidateError::EeIpResourcesPresent)); +} + diff --git a/tests/test_aspa_verify.rs b/tests/test_aspa_verify.rs new file mode 100644 index 0000000..372d8c6 --- /dev/null +++ b/tests/test_aspa_verify.rs @@ -0,0 +1,14 @@ +use rpki::data_model::aspa::AspaObject; + +#[test] +fn verify_aspa_cms_signature_with_embedded_ee_cert() { + let der = std::fs::read( + "tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa", + ) + .expect("read ASPA fixture"); + let aspa = AspaObject::decode_der(&der).expect("decode aspa"); + aspa.signed_object + .verify_signature() + .expect("ASPA CMS signature should verify with embedded EE cert"); +} + diff --git a/tests/test_roa_canonicalize.rs b/tests/test_roa_canonicalize.rs new file mode 100644 index 0000000..ddb7dae --- /dev/null +++ b/tests/test_roa_canonicalize.rs @@ -0,0 +1,127 @@ +use rpki::data_model::roa::{IpPrefix, RoaAfi, RoaEContent}; + +fn len_bytes(len: usize) -> Vec { + if len < 128 { + vec![len as u8] + } else { + let mut tmp = Vec::new(); + let mut n = len; + while n > 0 { + tmp.push((n & 0xFF) as u8); + n >>= 8; + } + tmp.reverse(); + let mut out = vec![0x80 | (tmp.len() as u8)]; + out.extend(tmp); + out + } +} + +fn tlv(tag: u8, content: &[u8]) -> Vec { + let mut out = vec![tag]; + out.extend(len_bytes(content.len())); + out.extend_from_slice(content); + out +} + +fn der_integer_u64(v: u64) -> Vec { + let mut bytes = Vec::new(); + let mut n = v; + if n == 0 { + bytes.push(0); + } else { + while n > 0 { + bytes.push((n & 0xFF) as u8); + n >>= 8; + } + bytes.reverse(); + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + } + tlv(0x02, &bytes) +} + +fn der_octet_string(bytes: &[u8]) -> Vec { + tlv(0x04, bytes) +} + +fn der_bit_string_from_prefix(prefix_addr: &[u8], prefix_len: u16) -> Vec { + let byte_len = ((prefix_len as usize) + 7) / 8; + let unused = (byte_len * 8) as u16 - prefix_len; + let mut bytes = prefix_addr[..byte_len.min(prefix_addr.len())].to_vec(); + while bytes.len() < byte_len { + bytes.push(0); + } + if !bytes.is_empty() && unused != 0 { + let mask = (1u8 << (unused as u8)) - 1; + let last = bytes.len() - 1; + bytes[last] &= !mask; + } + let mut content = vec![unused as u8]; + content.extend(bytes); + tlv(0x03, &content) +} + +fn der_sequence(children: Vec>) -> Vec { + let mut content = Vec::new(); + for c in children { + content.extend(c); + } + tlv(0x30, &content) +} + +fn roa_ip_address(prefix_bs: Vec) -> Vec { + der_sequence(vec![prefix_bs]) +} + +fn roa_ip_family(afi: [u8; 2], addresses: Vec>) -> Vec { + der_sequence(vec![der_octet_string(&afi), der_sequence(addresses)]) +} + +fn roa_attestation(as_id: u64, families: Vec>) -> Vec { + der_sequence(vec![der_integer_u64(as_id), der_sequence(families)]) +} + +#[test] +fn canonicalize_sorts_families_sorts_and_dedups_addresses() { + // Build: + // - families are in order: IPv6 then IPv4 (should become IPv4 then IPv6) + // - IPv4 addresses contain a duplicate (should dedup) + // - IPv4 /24 uses only 3 bytes, should be padded to 4 with host bits cleared + let v6 = roa_ip_family( + [0, 2], + vec![roa_ip_address(der_bit_string_from_prefix( + &[0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + 32, + ))], + ); + + let v4_prefix = der_bit_string_from_prefix(&[192, 0, 2, 255], 24); + let v4 = roa_ip_family( + [0, 1], + vec![ + roa_ip_address(v4_prefix.clone()), + roa_ip_address(v4_prefix), // duplicate + ], + ); + + let der = roa_attestation(64496, vec![v6, v4]); + let roa = RoaEContent::decode_der(&der).expect("decode roa econtent"); + + assert_eq!(roa.ip_addr_blocks.len(), 2); + assert_eq!(roa.ip_addr_blocks[0].afi, RoaAfi::Ipv4); + assert_eq!(roa.ip_addr_blocks[1].afi, RoaAfi::Ipv6); + + let v4_fam = &roa.ip_addr_blocks[0]; + assert_eq!(v4_fam.addresses.len(), 1); + assert_eq!( + v4_fam.addresses[0].prefix, + IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 24, + addr: vec![192, 0, 2, 0], + } + ); +} + diff --git a/tests/test_roa_decode.rs b/tests/test_roa_decode.rs new file mode 100644 index 0000000..1f589a8 --- /dev/null +++ b/tests/test_roa_decode.rs @@ -0,0 +1,33 @@ +use rpki::data_model::roa::{RoaDecodeError, RoaObject}; + +#[test] +fn decode_roa_fixture_smoke() { + let der = std::fs::read( + "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa", + ) + .expect("read ROA fixture"); + let roa = RoaObject::decode_der(&der).expect("decode roa"); + assert_eq!( + roa.econtent_type, + rpki::data_model::oid::OID_CT_ROUTE_ORIGIN_AUTHZ + ); + assert_eq!(roa.roa.version, 0); + assert_eq!(roa.roa.as_id, 4538); + assert!(!roa.roa.ip_addr_blocks.is_empty()); + assert!(roa + .roa + .ip_addr_blocks + .iter() + .all(|f| !f.addresses.is_empty())); + println!("{roa:#?}"); +} + +#[test] +fn decode_rejects_non_roa_econtent_type() { + let der = std::fs::read( + "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", + ) + .expect("read MFT fixture"); + let err = RoaObject::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidEContentType(_))); +} diff --git a/tests/test_roa_econtent_decode_errors.rs b/tests/test_roa_econtent_decode_errors.rs new file mode 100644 index 0000000..0c1c1bf --- /dev/null +++ b/tests/test_roa_econtent_decode_errors.rs @@ -0,0 +1,205 @@ +use rpki::data_model::roa::{RoaDecodeError, RoaEContent}; + +fn len_bytes(len: usize) -> Vec { + if len < 128 { + vec![len as u8] + } else { + let mut tmp = Vec::new(); + let mut n = len; + while n > 0 { + tmp.push((n & 0xFF) as u8); + n >>= 8; + } + tmp.reverse(); + let mut out = vec![0x80 | (tmp.len() as u8)]; + out.extend(tmp); + out + } +} + +fn tlv(tag: u8, content: &[u8]) -> Vec { + let mut out = vec![tag]; + out.extend(len_bytes(content.len())); + out.extend_from_slice(content); + out +} + +fn der_integer_u64(v: u64) -> Vec { + let mut bytes = Vec::new(); + let mut n = v; + if n == 0 { + bytes.push(0); + } else { + while n > 0 { + bytes.push((n & 0xFF) as u8); + n >>= 8; + } + bytes.reverse(); + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + } + tlv(0x02, &bytes) +} + +fn der_octet_string(bytes: &[u8]) -> Vec { + tlv(0x04, bytes) +} + +fn der_bit_string(unused: u8, bytes: &[u8]) -> Vec { + let mut content = vec![unused]; + content.extend_from_slice(bytes); + tlv(0x03, &content) +} + +fn der_sequence(children: Vec>) -> Vec { + let mut content = Vec::new(); + for c in children { + content.extend(c); + } + tlv(0x30, &content) +} + +fn cs_explicit(tag_no: u8, inner_der: Vec) -> Vec { + tlv(0xA0 | (tag_no & 0x1F), &inner_der) +} + +fn roa_ip_address(prefix_bs: Vec, max_len: Option) -> Vec { + let mut fields = vec![prefix_bs]; + if let Some(m) = max_len { + fields.push(der_integer_u64(m)); + } + der_sequence(fields) +} + +fn roa_ip_family(afi: [u8; 2], addresses: Vec>) -> Vec { + der_sequence(vec![der_octet_string(&afi), der_sequence(addresses)]) +} + +fn roa_attestation(version: Option, as_id: u64, families: Vec>) -> Vec { + let mut fields = Vec::new(); + if let Some(v) = version { + fields.push(cs_explicit(0, der_integer_u64(v))); + } + fields.push(der_integer_u64(as_id)); + fields.push(der_sequence(families)); + der_sequence(fields) +} + +#[test] +fn version_must_be_zero_when_present() { + let der = roa_attestation( + Some(1), + 64496, + vec![roa_ip_family([0, 1], vec![roa_ip_address(der_bit_string(0, &[]), None)])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidVersion(1))); +} + +#[test] +fn as_id_out_of_range_is_rejected() { + let der = roa_attestation( + None, + (u32::MAX as u64) + 1, + vec![roa_ip_family([0, 1], vec![roa_ip_address(der_bit_string(0, &[]), None)])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::AsIdOutOfRange(_))); +} + +#[test] +fn ip_addr_blocks_len_is_validated() { + let der = roa_attestation(None, 64496, vec![]); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidIpAddrBlocksLen(0))); + + let families = vec![ + roa_ip_family([0, 1], vec![roa_ip_address(der_bit_string(0, &[]), None)]), + roa_ip_family([0, 2], vec![roa_ip_address(der_bit_string(0, &[]), None)]), + roa_ip_family([0, 1], vec![roa_ip_address(der_bit_string(0, &[]), None)]), + ]; + let der = roa_attestation(None, 64496, families); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidIpAddrBlocksLen(3))); +} + +#[test] +fn duplicate_afi_is_rejected() { + let families = vec![ + roa_ip_family([0, 1], vec![roa_ip_address(der_bit_string(0, &[]), None)]), + roa_ip_family([0, 1], vec![roa_ip_address(der_bit_string(0, &[]), None)]), + ]; + let der = roa_attestation(None, 64496, families); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::DuplicateAfi(_))); +} + +#[test] +fn unsupported_afi_is_rejected() { + let der = roa_attestation( + None, + 64496, + vec![roa_ip_family([0x12, 0x34], vec![roa_ip_address(der_bit_string(0, &[]), None)])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::UnsupportedAfi(_))); +} + +#[test] +fn empty_address_list_is_rejected() { + let der = roa_attestation(None, 64496, vec![roa_ip_family([0, 1], vec![])]); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::EmptyAddressList)); +} + +#[test] +fn prefix_unused_bits_must_be_zeroed() { + // prefix_len=1 => unused_bits=7, last byte must have lower 7 bits zero. + // Use 0b1000_0001 which has a non-zero unused bit. + let bs = der_bit_string(7, &[0b1000_0001]); + let der = roa_attestation( + None, + 64496, + vec![roa_ip_family([0, 1], vec![roa_ip_address(bs, None)])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidPrefixUnusedBits)); +} + +#[test] +fn prefix_len_out_of_range_is_rejected() { + // IPv4 ub=32, encode 33 bits: 5 bytes with unused_bits=7 => 40-7=33. + let bs = der_bit_string(7, &[0u8; 5]); + let der = roa_attestation( + None, + 64496, + vec![roa_ip_family([0, 1], vec![roa_ip_address(bs, None)])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::PrefixLenOutOfRange { .. })); +} + +#[test] +fn max_length_range_and_relation_are_validated() { + // IPv4, prefix_len=8 + let bs = der_bit_string(0, &[0x0A]); + // maxLength < prefix_len + let der = roa_attestation( + None, + 64496, + vec![roa_ip_family([0, 1], vec![roa_ip_address(bs.clone(), Some(7))])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidMaxLength { .. })); + + // maxLength > ub + let der = roa_attestation( + None, + 64496, + vec![roa_ip_family([0, 1], vec![roa_ip_address(bs, Some(33))])], + ); + let err = RoaEContent::decode_der(&der).unwrap_err(); + assert!(matches!(err, RoaDecodeError::InvalidMaxLength { .. })); +} + diff --git a/tests/test_roa_validate_ee_resources.rs b/tests/test_roa_validate_ee_resources.rs new file mode 100644 index 0000000..ec66153 --- /dev/null +++ b/tests/test_roa_validate_ee_resources.rs @@ -0,0 +1,108 @@ +use rpki::data_model::roa::{ + EeResources, IpPrefix, IpResourceSet, RoaAfi, RoaEContent, RoaIpAddress, RoaIpAddressFamily, + RoaValidateError, +}; + +fn test_roa_single_v4_prefix() -> RoaEContent { + RoaEContent { + version: 0, + as_id: 64496, + ip_addr_blocks: vec![RoaIpAddressFamily { + afi: RoaAfi::Ipv4, + addresses: vec![RoaIpAddress { + prefix: IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 8, + addr: vec![10, 0, 0, 0], + }, + max_length: Some(24), + }], + }], + } +} + +#[test] +fn validate_accepts_when_prefix_is_covered() { + let roa = test_roa_single_v4_prefix(); + let ee = EeResources { + ip_resources: IpResourceSet { + prefixes: vec![IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 0, + addr: vec![0, 0, 0, 0], + }], + }, + ip_resources_inherit: false, + as_resources_present: false, + }; + roa.validate_against_ee_resources(&ee) + .expect("prefix should be covered by 0/0"); +} + +#[test] +fn validate_rejects_when_as_resources_present() { + let roa = test_roa_single_v4_prefix(); + let ee = EeResources { + ip_resources: IpResourceSet { prefixes: vec![] }, + ip_resources_inherit: false, + as_resources_present: true, + }; + let err = roa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, RoaValidateError::EeAsResourcesPresent)); +} + +#[test] +fn validate_rejects_when_ip_resources_inherit() { + let roa = test_roa_single_v4_prefix(); + let ee = EeResources { + ip_resources: IpResourceSet { prefixes: vec![] }, + ip_resources_inherit: true, + as_resources_present: false, + }; + let err = roa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, RoaValidateError::EeIpResourcesInherit)); +} + +#[test] +fn validate_rejects_when_prefix_not_covered() { + let roa = test_roa_single_v4_prefix(); + let ee = EeResources { + ip_resources: IpResourceSet { + prefixes: vec![IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 24, + addr: vec![192, 0, 2, 0], + }], + }, + ip_resources_inherit: false, + as_resources_present: false, + }; + let err = roa.validate_against_ee_resources(&ee).unwrap_err(); + assert!(matches!(err, RoaValidateError::PrefixNotInEeResources { .. })); +} + +#[test] +fn contains_prefix_handles_non_octet_boundary_prefix_len() { + let ee_set = IpResourceSet { + prefixes: vec![IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 9, + addr: vec![0b1010_0000, 0, 0, 0], // 160.0.0.0/9 + }], + }; + + let covered = IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 16, + addr: vec![0b1010_0000, 0x12, 0, 0], // 160.18.0.0/16 + }; + assert!(ee_set.contains_prefix(&covered)); + + let not_covered = IpPrefix { + afi: RoaAfi::Ipv4, + prefix_len: 16, + addr: vec![0b1010_0001, 0x12, 0, 0], // 161.18.0.0/16 + }; + assert!(!ee_set.contains_prefix(¬_covered)); +} + diff --git a/tests/test_roa_verify.rs b/tests/test_roa_verify.rs new file mode 100644 index 0000000..6515d27 --- /dev/null +++ b/tests/test_roa_verify.rs @@ -0,0 +1,14 @@ +use rpki::data_model::roa::RoaObject; + +#[test] +fn verify_roa_cms_signature_with_embedded_ee_cert() { + let der = std::fs::read( + "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa", + ) + .expect("read ROA fixture"); + let roa = RoaObject::decode_der(&der).expect("decode roa"); + roa.signed_object + .verify_signature() + .expect("ROA CMS signature should verify with embedded EE cert"); +} + diff --git a/tests/test_signed_object_decode.rs b/tests/test_signed_object_decode.rs index e5ec462..c52cde1 100644 --- a/tests/test_signed_object_decode.rs +++ b/tests/test_signed_object_decode.rs @@ -26,4 +26,5 @@ fn decode_manifest_signed_object_smoke() { .iter() .any(|u| u.starts_with("rsync://"))); assert_eq!(so.signed_data.signer_infos.len(), 1); + println!("{so:#?}") }