From 56ae2ca4fc355997fa8de2bdf4fd5ec074f178b0 Mon Sep 17 00:00:00 2001 From: yuyr Date: Mon, 2 Feb 2026 15:39:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0aspa=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- specs/00_common_types.md | 2 + specs/06_manifest_mft.md | 4 + specs/08_aspa.md | 100 ++++ specs/09_ghostbusters_gbr.md | 87 ++++ src/data_model/aspa.rs | 223 +++++++++ src/data_model/mod.rs | 2 + src/data_model/oid.rs | 2 + src/data_model/roa.rs | 466 ++++++++++++++++++ .../RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa | Bin 0 -> 1731 bytes .../RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa | Bin 0 -> 1732 bytes .../RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer | Bin 0 -> 1412 bytes .../RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa | Bin 0 -> 1731 bytes .../RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa | Bin 0 -> 1731 bytes .../RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer | Bin 0 -> 1330 bytes .../RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl | Bin 0 -> 704 bytes .../RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft | Bin 0 -> 2706 bytes .../RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa | Bin 0 -> 1732 bytes .../RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa | Bin 0 -> 1731 bytes .../RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa | Bin 0 -> 1731 bytes .../RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr | Bin 0 -> 1922 bytes .../ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl | Bin 0 -> 434 bytes .../ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft | Bin 0 -> 1860 bytes .../RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa | Bin 0 -> 1731 bytes .../RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa | Bin 0 -> 1734 bytes .../111wEGLfsrCC1YXvRd90Mcgv3so.roa | Bin 0 -> 1749 bytes .../5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa | Bin 0 -> 1705 bytes .../9X0AhXWTJDl8lJhfOwvnac-42CA.spl | Bin 0 -> 1853 bytes .../D53F-thyu-FB_Q-Qnu54dMG6kUc.roa | Bin 0 -> 1727 bytes .../euv64En05073B5-r95s2Uu_UwJg.roa | Bin 0 -> 1724 bytes .../yqgF26w2R0m5sRVZCrbvD5cM29g.crl | Bin 0 -> 459 bytes .../yqgF26w2R0m5sRVZCrbvD5cM29g.mft | Bin 0 -> 2278 bytes .../zIlYN-tDLuEj0BDwZSi5PS1eIDI.roa | Bin 0 -> 1729 bytes .../cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl | Bin 0 -> 50503 bytes .../cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft | Bin 0 -> 1833 bytes tests/test_aspa_decode.rs | 25 + tests/test_aspa_econtent_decode_errors.rs | 111 +++++ tests/test_aspa_validate_ee_resources.rs | 84 ++++ tests/test_aspa_verify.rs | 14 + tests/test_roa_canonicalize.rs | 127 +++++ tests/test_roa_decode.rs | 33 ++ tests/test_roa_econtent_decode_errors.rs | 205 ++++++++ tests/test_roa_validate_ee_resources.rs | 108 ++++ tests/test_roa_verify.rs | 14 + tests/test_signed_object_decode.rs | 1 + 44 files changed, 1608 insertions(+) create mode 100644 specs/08_aspa.md create mode 100644 specs/09_ghostbusters_gbr.md create mode 100644 src/data_model/aspa.rs create mode 100644 src/data_model/roa.rs create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa create mode 100644 tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/111wEGLfsrCC1YXvRd90Mcgv3so.roa create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/9X0AhXWTJDl8lJhfOwvnac-42CA.spl create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/D53F-thyu-FB_Q-Qnu54dMG6kUc.roa create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/euv64En05073B5-r95s2Uu_UwJg.roa create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.crl create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/yqgF26w2R0m5sRVZCrbvD5cM29g.mft create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/zIlYN-tDLuEj0BDwZSi5PS1eIDI.roa create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl create mode 100644 tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft create mode 100644 tests/test_aspa_decode.rs create mode 100644 tests/test_aspa_econtent_decode_errors.rs create mode 100644 tests/test_aspa_validate_ee_resources.rs create mode 100644 tests/test_aspa_verify.rs create mode 100644 tests/test_roa_canonicalize.rs create mode 100644 tests/test_roa_decode.rs create mode 100644 tests/test_roa_econtent_decode_errors.rs create mode 100644 tests/test_roa_validate_ee_resources.rs create mode 100644 tests/test_roa_verify.rs 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 0000000000000000000000000000000000000000..f6112d8b94572d1087ddb5f843932ccbf4dc5971 GIT binary patch literal 1731 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 L+!-dxp1TA9!ti%S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8f2d9a7b44fc3070d4ff127fe2dc18142fb544f8 GIT binary patch literal 1412 zcmXqLVr?*JV#!#*%*4pV#LRe7$$*!QQ>)FR?K>|cBR4C9fw7^Xfj%2^C=0W&hFOYP zT2fk~rManbN|Hs2g^8Jov2jY0sj;bHie;i@YO;ZOvZYy4ih-OsuaTL7p@EU1p^1r! zQIt5Zk-32(3`U_CYGUqcX=-X}Y-;Lk=44=C?&#?3V(MaI>f~l(=xS_iVeD#YX=r9* zXwbxn;y6ZD2IeM4eg=akMlPl%Mn;Ak+?l4qOIc?gQ_ry4@%O{g|1(zotvY>sYD3Jd z{qxcuDW1Oh`Qwt_>uknLLpD1KepdaQW-}-1hJ)p_PL`@@v4^+J43@|KHs5HX5wmeh z(oY7jhi(fucImjcxMwfzJZvS)?QH40$xUWugyQ|=gxUw~qV?jki5eH5-eO!3sL{N{ zTkgM#*FVdPQ3{Ht2Moi$7^fVM3Z8x{bG2EoQK_JVL$BtQwr-8eH>jLKmj&1fu#g%!< zR{HwMiF!rpdU>fO`b7oVnfgKQd8sA3{-OFw;kxmK5uui)ei`n8RYrz>K}Lyr#ZDGE zMFx7wML7m3Fw+?sSt1O=3_@Uh1Ew|)nBgEJ^@=hJQXv+k7UUOamgE;z>btnQIfnX# z;I}2U$bcW@Zefs*SPhsN8UGvbfdu$L0xZl-jBEx?jP@{#8M#@S7(q!+*B~AyX29AO z40jC3$9e^ciN$(2-IbDRo?>d6X0Dr>YG$BoVqjpZo0OPhuA5|HWMOELoMvi}YN2nU zZ_wBcw}Gpzo(47$o5S>S(@G3%;Ev&KGsq|@DJZtm*DoqcDZu92y!?{Pw9MqhlFa-( zy^7o%19_NkMs`rR3K;Mi@USp3FdDEivoIXnG99gSVFu;f((hU>b^#La-Og34agKj~ z*t~9&gj6W!uAVo|$7YBMP5vIJ7Vj}X>RZl1mbpofB7|(5!%G?rlFk_#T=CX9KW~4C zq*~5qR^!vkr+TH8)~t5yw(@*>d9UQ@zQ@M**`nB=WzOq)9^W8Z(|>u-fr5_<7J1qq z`YdM{oV&%PC|UCS(2e*3P!sJrjx2ZMWi?)%0?S+@8T zoaOLf5v;rP^sv<46U=xfI$?Bp{54LmTr!2mX-!? zCZ?9g7H+0)2F7ki=7z3D7Ut##Zmw>Ymc~wQP6kbkO3035WMyD(V&rEqXkz4IYGPz$ zxcY71o?n;4BpqVAZps=XwVT<6VxNBBi(r+R{DW)e7gJ~J){H;7&lEN_-3S%Fym;xv_&M8> zRHW4#_Vm2ks%q2z-9LD`a{AWI6I+g~%KK&5VCud)`Q_ZD8_m-Hh4F5CXn!a_tG)Xl ztMRRdxf{B!+ElL*u}_#Ob=T6hLtIepYVTpo?P(z#XV*JjUptXYm1*@{(^ZAFN!k0*kuOIBy)y*-~Cj`GOsYM2SAb0bFe8j@c#NJ@g*afqO zk(;H?pvJ%(#y8+5?59wdJTu)W7lZJSEJF(yi#(6a3}Y8v-6;R~V!fjLL<3#8VZ3dM z86_nJ#i#*Plv1FdmtT^ZR;gE!n`0mkQ_jc^a-o0$p8*dzyFxQ7a?0lhr+h|+b>~*; zuANf%-~Q2Tw$G2G6EC(jhF)HHK;+>6*aG*I4V*unC!c$`GhA+>&Dyt46CZCoH1AML zip9GNN8eS}ww-aT^6qB*x0i7%fATgH&liTFn%5_+l|0?;$h@`7=%xLQNnWlu)~2p4 zEU=vB*}Cf6+7!;O+Sx&tlbbfIRm#(?m>zAtEmUS!n#H2+QyusVc1f*`oOSuk0>R*z z)f?jPi}hW-a@>+{<)_jNW0BXB-sG5Vb6a!I{O1+X(tFdkrZkr~hzW5WT5#AQ*zv;8 zpqH!uO9Z4_dR28M*2`3^>bGbA_ugshl```m)?Y-xO6#jnyAA2*B zr#GqX)e?+A;)USk5+ye=p$rV z43$_EV%Pu9O8AiWmN$BzPha%aBPrK5M*KYHYWGWTg^Br9T~*}hVq|4tX<}q3`xet@ zymrQm@1Nqr_dlvS{a3d++EVwZJgcj5YT^b?r9I~-l~iyRvwRW$=(c11>9xn6h6t*= z#+zRCDU;!@|J$={!JB}hM`ClEOO;D!$Db{H=G7t{zH{M}Q|6$i5HjL{6Eh=3bJo!!6S; z+kOj7&RuLXErH$bz{a1equ0)AZvMEfy6wnu)pr_q-zv-!-G9?tWK-ww&3F9@CvIpf Kyr0Uj;Sm7xNO@=g literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..773266f779ea4d9f88574ac2b51a62461e33c871 GIT binary patch literal 1731 zcmXqLV%yKgsnzDu_MMlJooPW6+XjOswlz$Q%!a%M+-#f)Z61uN%q&cd2AXW#P-UC~ zj1mi^SR@R@n3yE>4FnAMSeO_X4LA(gm{}MO1V3BQ#Io0*iDjoj6Z66a%uI|-Ow5ei z&KdB+?P26*WiT){G&Im>V-96u7S=FJF-uEIOSCjMHBL#gNU<<6Gch(!NisDyHB7Ng zv`kGlFi*BLOG+`26X!KDHLx@?GcqzZHZ_hC=QT1jFgGwX0D~wLLru-xoXt(moQ>R! zTn!A}EKQwE4BRZuT%3$tjg8$bEL|*2+>FePO$?eCm5?3B$jZRn#K_NJ(8S2a)Wpch zaQ@1D`JH}yysHEcwM8F~@Jub^brn^ZeCnG2MMnb%*}~(}*JkS-@>}JwMz=5h*e=gx zACI>p97Z>8OgO<-G4E;1ES8MmXXdZ8e13n-j$Y~9wznW^uHI)Uw+HqA(${|Dd> z9p}bx-&$V8erBJQ8(T1cQ^efQ(@yegZLR%tbBd2*}gZnbcc%g zUf)wAU43kwSHw>yW=00a#Z8P?44N1(7|60QhsyG?h_Q%-{VC1S*JJz{?eH^q(_{P2 z8~*Ah2J#?jWl-X15XsHozp~VJ#|<}U+pVUuv*sPBnYPzJ0;E8Jk?}tZp8<~n7aNB* z8zU<#J0Ful07yoj#nZsuz;%K10>?J{qT`eV2_q@~+ zUH?%1q;TE%!iZ4IQojuMz$zm{zaXQ;ykaMdoFW6gEz5!F4 z2h4Dgk$Od$1*s4VQVa5nGfVP|D)n7l-5f)GLh##?T4cZnayLK7M=Z=t>Snrr+jX3%4cLy{dec= zv%n`)52X0b(D(HBh_$Iu{UBs_A>c<$cuaZrf1dL({xy5zb+#IMbF9?fy*RLCJ|oBe z));4H4LA15PmlHNkE(ngzIfuds)(QOPK!1ETCjB0;}>UM%)VN8oN?CY4yFh3g+lM2 z^RLl#OEp=S+1o(rQ~?Zk<`?eIVuFlMRah zlYgnXyBWRrc(zAWquaOtV^?K}pZmrAih|vNgv6z*XE{0Tc+vFo&a{hn%afV*-*>vH zwP)5>)1z_!|7nTZ%SY?R@cg*BfqQ+|l)P!*F2CMwC2RV_g-btWkFK@Z(gibJc;e5? z&SLx7SLhj;@XPIB%MFocZNFP8V(p9^lb&T{)Wn-!TemxvecMansZ*zhvTgNc-+q(r IT`TJY00roGU;qFB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2795311f66ce06dee97a8e204acc29b726bff7f1 GIT binary patch literal 1330 zcmXqLV%0NfVi8-w%*4pV#LRf&nE@{wr&gOs+jm|@Ms8LH17kx&1AR8;P!?uk4YL%p zw4}5|OLJ4>lq8E33llRFW8;)0Q)5%Z6w5@*)MNwmWJ|N86azVNUL!LDLjywtGa~~- zizsnkBXckpBxD$cYN)B1tBHw`k)xBFp^>GTiKUT&fvb~&p{1jRp{bjZlcAB3v6-u- zkwFuq60+kMSs9p{82K3tni#p5niv@wo-FylhxPQCm@t+?p}^7y?y)J}noRQYVLR)a zT?=lTPrZ2{E9?97&nF*j-X{BEZNiR=d=Lr8v z%w)N(7f}>)bGP8U*uYwiP-FRafmM@YHgB7A;LMWLwqF-S6**2_$*}SM*tTI!QPa{? zrs@=9hDMpZ!VIU(!0p*4zwDIi&rR63M0BB8W^={4ka8wwMh3>kO-!B!O-!x^vTV$u zvV1IJEF!6SE=sFEYNkwLc+EJgFM#ldvdcd|?0ifH0U#N97Ec3r1J?!43mn_*i;64r zlCAXhlN0rd()IFEOZ1BhvNQFA-1AaPbp1p1lfrf53nM};OZ_t31FMV-{ep}V^NO7; za*7P}l8bT-QedVtGO|P%gc*du_y$aE9x%f}M(P!17NkNfNG-@O&Me6LJb#n~$ z3BhkmYLNjy$lbyqAF&!RGcx`+-~$Qpg9KQZnHbp&8ehQdW#ndQylv2U!@vS2V!+y_ z19M4sQD#mK+^4Ap`C#wrCZ#4O=jRpcCMW6}=p+1Oz||H(icT{k^MhV)T8V)f+JqnWEn2x?8XhwZj226H>G!9Yu$27sHu)|lhCtXhk2c=BoBt?w=Ot%X3mRG zW>$W(zn-m`GKF*Y+1DY*MBe1+-dK1`;$~3d+HzO>UF-5{(jxc-jXv3^U)7xXa>;z7 z?X#!)XnN@P{<26cuUmbDX{9lLeac#gzFQ7)UB@Rl@+|rMzm?_pCC*0I<}20{Jl|QK zKD7LJe5H^@VDf9}Icp3y^%Vvf{XG!&OkSJS%>VRR?gOEY&X%ER4|AQT`_|n(B=zgX zj|iKpDMnj1HJm-UQ+(&$Z@c|gNQg+9KW6**SZK+yDUMc}Ir9#&+-LohZ*;Ucx%Zb; i-GfZNXRH2c-gI4VHbv)kjL3l&qpZ#P(>m)azW@MF1IvE^ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..72755f74c5d157f3e300af733381def2f99023c5 GIT binary patch literal 704 zcmXqLV%lTS#JGfsk@5qwPB{BO^B}gMqQ3p@BXdb0`b5u!dQRSz1zBqNTa1 zaY~X!iiL@piLr4?lBuz&VTxs*) zP+4Ub2?MbPk=*?KD@$#6+;DTY-D)a3Yur}q9c=v<+ivh-&^MynI$VdZ+3Bj$-al)f)20iHZ`1J7F)CX@(abO z&xGB@FHM@yKJi)2?EGTy{w9%U%<&6e2}Lo?u<3ZGdwuP`H_wAzmdSql5FxQ^;oS+7 MBsZx1JD2nU0MpOfwEzGB literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..215819d36dd5f856e73805f2ca0ed5e3381a6aab GIT binary patch literal 2706 zcmXqL;_74L)N1o+`_9YA&a|M3tKOiAtD1?C*^t+On~gJ}&4V$OnT3hbpo#e|8#h!T zrvRhWf+prOEKSTO44RmaFflVGno95+85o%v7#bNG7#SHD7(^inSbzmkjq5XT5tDZ{ zaCEFR$x084cPfcAcMo*+ajtOm2{Q{c4b>~kPh?hPxY(#8e)3s`_u_gb4vUDk=mpL3 z3OBw?n7sbcb%j;?s!~Cwc&4QLMfzKm#izI?rQ`>cdbouY6lXaa=NQ0DS+`Gaw>VFI z|ID~eJq+sGXQb3UH*?jnc;|mx&g$d;haW(u1nK64g&Ib<7dxgFR)rb5l^J+s`Ma1! zxn;ZRC8riKD>7`l`{vo<$l0y?`-4tQWA&eW>=M^4o_{Ipw)4&oedcp^0mzh4mpn7w zC>Mk9kSs$B7mGZP%nV}}UEL`E_+qG!g#E8==<%zv`Sw@jw$5?q|5I&a>-j<~Lp2Cdz8K-u(v1l&El1SI@$-3h(gXU`PLAr`&wEH17(}tfT@@sE>}k zpT1TlCHu-MO|FalqRDk8Mb(RD+;46!H`Dudock^p$dsgT-T1IC!7mvZbJXSlz7ey+ac*d355 zX)a}c7G5r%sp%FO=1HdI;m&T}@owf`@xhT$AKfjv`1>Do=1FFq`};Wd-Phf}iLmMyL2l ze);CsZATtEY;)B0;`M29z3sa?&dr|{T1LxGN zY^W(yOMZ$5TmRlHyi{0b_c^VWE1pMe+e}0XxZ4gHWbA@X_ zrc}6Dq(!+Ud0O}dIhqt^$NQN&`xKO$`Q>I=K;5!4O6SGWV_bGWq!^FAJKTS0@|UiA zTcWk6aGc*e*{tA449Jv9%M^F#!nCr86tgn#psdIWV|TC2>~yn&;Bu%bt=na`Ej^rN z`_C}=S6|YbwPMRPzdnfWiLN+vZ{jq$_`n5CEY}R0SS}efG4EQy%*4pV#LReRp#d+v zbYSFWWiT){G&Im>V-96u7S=FJF-uEIOSCjMHBL#gNU<<6Gch(!NisDyHB7Ngv`kGl zFi*BLOG+`26X!)K7{qyv%ni&93=O~_3dK-oOH%_UHzPwcb7Ko*XA?I!3rkZA3s)CM zS5rev6K7XfGZRZgHzy;5CPpP>$1$=pFgG#sGZ-{6axpbAGBV7&<;ugbZ+pZe!^F~? zc`PeZ(+gX={BHYhU6j`D+tSq+R`@f|+xShN!$aFw4>HZB|9Nsrv!mNpz+HtYcH?W? zggnC=7t1nPZvATF3fSfQ?$0-yu8zy0iw&F){WV_r@1#>pd)H5o&RuuwYP;ixo3rg!Q`uSb4%AHBYajtqpuouZpM}qW$AF8CLz|6}m6e^3$shnEBhTV# z;BMf$zc+ZHNP^0a>C}Y6)%)S z{QkAgd^asVHFKx)_I`z}JW>iT_O9exaPsO)zBLUsAsz~0i+tZP-922mMMq+~I%m;= zl?e~7KHoWC_=RL!{h4}0m4(YMT?(3(p3j={XgP1a%x~`(@1&hi$mNya3vfyNV?Xs+ zjC^&$g^UCS^*bkQ?Kj`P_g{r2^5usT=fjHMvg4gsCP`SiZxlVkKXb~&xNAD~{}a|k z39;Mfi8;Dd@e~w=6%Y}HZqBtZs&bg0E9O#rukDm@ zOHH+r&2^EQCEv^+R7b6JIaR;6_mioyx%Qveo}X%;YffLYTFq%H$QLrt0L2qtB)?9I#Isaz0W9DByjs>ftA^kF4HG2nKXY%qKs{by#M;oK{b-R z?|c@U+KEc?czk>C=cvKFlOey|MRKk!)c2n0x9;JxjEJgbx1|-CUi42CNPnf|{B^;D zUGgVfmQ-0>4l^}7V7gRkUQN!_l{aK;Y8}p=a!!8iqN1Rew`S=<7u_aS(FRlQ$`$i6 zHpUoDnegdPkvpqEwSBL@JM&A>I?JTFmA((%8EQ}Tw1`ScyipAL{BfT5 z%jG$uwRhvcd|YgO*JGY?!b`sVWlaZWpP8>OmE!1EvNCJY?h75_s~S9W8>TbZi(dx- DxEWX) zI9r%I8n_u5y1F>Inj4r}7#W&4x;dM=I$OHA7?~L~F)ATDj**ptxrvdV!Jvtei>Zl` zkzskT`^N=|{S&eu79W~bnxYbQ#MaWY=|JsJFXsoMdIQBf9h4^$W3PPN!CH&|i|mlX0rq350tQbe&WAf*cYEJz z)m3_2^rJ>Fx^OEKGb01z;wHu`22G3?3}o4uLuL6`#8^aXtj~U!aH5J|@$J5X2t}_y zaVP((8OVdAm02VV#2Q3$^Y^bTwcT;U&DnOVsqCzI2WqD6HIM))P+(;I&%$TGW5C76 zq0Pp~%F52iWDo$7k!SHVa5r#W;Jm=G&AzC(GB4RmUq3lfuP9wFFSSI!s31F2Kgc~V zwM5rHR6i+PH@+|;)Uwns!#%Ld$j~pyC^4_t$s(u7Krgu{#~=k}IwK=Xgh7}=2#jyQ z)aC&*9AugCq5d@)Heo;fC?H zDQ1+E6cnQdP*F;OeqMe_W?H3QMQ)CPJWM$wJII9s27CrQ;Oq*`tjH;!8=UeP88$bq zPgV53RBXBAM((=_QP1D~x14j-+?(yF_4m~)+PRdD$A>XUMysi~$S-&K_?6$`l96VB zqkH|P0-+g_XID=zi=D&AnXt1eUr9`PUGC}C*jjFRUhnyVD?iQs zeB2f^=U$Ix3wxz-$8VqKmGwLa9<7KpsH=$mtC$fX8?j_f!Q1J%&x52mAJlluTwCIk z^DfA1Dt1OoLAB#N{D^--VW4jK95ym*M9Y$xp@?nyc=FNKR$mse>F{jI0bSO^ghEGa}-; zJR~?$UkWuo<2W`Y_%he29wz(kGvCGTIQe`MYuDyCx1{8PPEP9RbQ3deq~{b>tO=j8B3*cEazNk%4ZoBWUBRy(_WX7JTKc|@M^o5P?bX2o9_s|r6=~@+ zPEYjc@K?Ip(dln-`=p4)0xru2!{t^?iw}6BvF-?ejd7dWd zJ$jx0+l>?on3VPFSmn#vRFY4b>~=NwDZR|&y4YBEN$QFNEvhr~?%PUf7A#R)8TB-a H$65veZ53lv literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..14fb352796149de575c0b5d01c3d2827a7b96542 GIT binary patch literal 1731 zcmXqLV%yKgsnzDu_MMlJooPW6+XjOswlz$Q%!a%M+-#f)Z61uN%q&cd2AXW#P-UC~ zj1mi^SR@R@n3#C13^!_Aqj@G8h;e8XD-cF^94+3u~CAn58A9C0d%B8mA;#q*$1knHU?VB$*nU8m3q# zTBar&m?vABC8Ze1iSrtn8kiUv7@8ZK8(Bt)^BS2Mm>U=xfI$?Bq2_KzmWD1)&V~lg zE=I0yW)`Lf&Q8Xbu11#5POgqlmPTflj>cxD#s*D{O3035WMyD(V&rEqXkz4IYGPz$ zn0a`{rcza_IRU~NuP0>*Eohq=cIMIK^)sh_IQ~%B*TCLwg7e471?ksvGmk9SnH#ic z3H!E39eu%EZ}e>+g()`Ec?O&cyOf;@aaK&Yin_iR$S~6Px^1Ew9g+c*yh9rZ*k0p4gu}_Gq26dD)!8*$s!c zzv%e6XI@8Nh+*&1!ZlT!q{aST3wjg8_;vs88Cf%YV}7)s*~E8TXydM>;!X^~-n#{1XJTe#U|ih9c*UTJ@q&RY8*`{EABz}^NWG@)9)YuZ-&%P#&(l2p zYNy&PvtR>xkhC%=aWshJ=I>uwYP;ixo3rg!Q`uSb4%AHBYajtqpuouZpM}qW$AF8C zLz|6}m6e^3$shnEBhTV#;BMf$zsdH#p@pGEBY| z$hUQEoW=VR?WIn3Q?k50KZc3UsZE_y`1v_D~u@r@_@Wd560D$Y+Y*{ot;pmHfYFIB3>px;->MDfaA?y}y>Ro%Lo z<@1d*fA{gM{u|q-5VEVZbfu1s*Ydq_%lPC>W-k2}BtL75O^X|E*L<@D3#VW(8SOJ2yn@y$jnZARQRiFFB@U7x<2&1YZOsV@8c zz_a%B6*qXp=I%{TXqw=6NcHlI10SwM_2v68J-u^c^}E-fHtzjiI_cEZh^=d%7TgW7 zx^O1hCi}i&6XPm_CdOq^yZ^6*Rh*_N8$&&;}(onL$3(Jllzx)@m*Seh6aOmEoQ zh48#y>YMk_cAliaZOx7uv;H>;#Oa9ddpxTl-A(tX!|Ec5g^yhKHoSLPUE}*XX`^%L ziOq{D_t(mJ@FadTGT=^8%PnkOs3?^7aXHt4Yk~ELAODe;WPbK&Q^{k4li#f_uI6X? zdNI1>IQzlt6F;cle7b&?|B-_?m-M;65ZSY%P+GY%O6~aB%&E%_Q*1As2=~A6L(j0^ zu`KrYjISU0A01%fdj24*X@zpf!SX5zeonz-K3XCXMd!R+zGY6GSuWhXA&jX*k&U6@ zSkwF41~){O&vR&>_ucWvFGXFAR-?|B(~V>HCGXL-Iy$=;7@0d7TN*SmDj_?Lk(GhDiIJbdpox)-sfm%1 zVZrwj4UyHEz7M^=?Elei^ZBB1lEq{Z%ln`1L@ZtRIZk2OdttACX7xw-By3HyYqYHW_oco1eZ~I5!Jw-ZcHzMW5|*}Y zsfTx^pUE=vt}|ljv1qzIe|5^A9cnrAMIJDUq zSy|cnm<$3yGV&~*2JQx~3!E1?w%HdISLP*K>FXya>J_Ey<)xPB7Zqe@>Ib>!rIzUW zhw3MV>&6#Ggj$yRWw-}c85#No871ZwJ6Yru8R#V!+$xN%%tH{kUkcTN}WCyuWz<|$y2b^7@nH4$ZbAwYpBg2|4ziqdB zJAA(4R4iQU^X=j<<4iAu3(-;E+@~%&shlUZ|HEtjdcHNrYk!4C%wM=k)FgPqRsAEc z+HKKF!zRX622G61m>8KGu%~k5l5s({ffT%G<777EMJXr^WDs(!hWu#dmw`S)mc>ws zMM1*1lkZB*i^~3jNS#dP+G$*?S0CKZ@-CCZ<HO1n{(Z$Hhz|zFXki*FsHz#Y) zoz9+F3)Jd0lr?Vs**Nc7UEs!7WQhk-c;SS5lvfNwuN;2bn>0tzwL|Dq{5WF+O_G5$vdCdGW-y}X2)C5 zHp64vu^8{CcP{-n_I#bkK-x{k`?eUq|mi}GD@)@V`_XdOJztKEPx}F)a z_}JAmao+iCX0)R6v-0N2&YiC^%1wV?efn?noBz=PEc5@EeAu=8f8nkzPhKefQ2y2s Ialhja0Nh}74FCWD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9a938f6c03da45fad54c907a4698aaefb38d4af0 GIT binary patch literal 1922 zcmXqLVy|Q4)N1o+`_9YA&a|M3J>Q^-J)4P<*^t+On~gJ}&4V$OnT3hbpz$0VH&h*` z0HgAP#(gY}yPaI!J^if0oE?K)c)7w{gMvN%{j7}j40yR5U4pEvtxXM0%oUuADvL`J za}Yi*pk5Qmn%hi!(Cw(o6F5tSv1J4GpYA zgL%1JeH}f0tcnuzQYsya()IFEOL)25{H%gN0t!y0#TmR@epc4j*4DgS{z2|mLGF2} zB?|tbyj&r!K33X>3PuKI3dW{px)#PJhP+&^el9TAE@)!8X3)fP$)JgO*8*lHMkXd^ z#u;}Ec;TVJ$j!=NU~Fh;pwGq}%EBzHVU}W+mXwxgX>Mwql4Ox$VPa-tY@Cv0YHVtl zVwq@}nrvX6Y-yI1Vjw5ZYh-F*Vq{=wZftIB93{?cWM*J)U}yjaQ7DF5nmQU8o4UEW z85&wRnVA}!8(X-!8abJmSel!dyE$8!Ihwk-m>L@xG%+e6JC2c+fw_s1pTVGsk&CH` zk&)qn#rmTLE{E23M~nTov^sv`^7^&M5|$-1$6Y;ky}&p0iA8hr>*@BI<~L=-=V@%) zr@X|*!N{Nc-j2x%cAw7K9xaQ%b}S>{f$hzSJQok!9(ZfbI_+VH-er-^*0-;#rC9X- zvpI6vDEWNY*YlfzgbcOKM9?1&{+z6XRQhCdQWrvTV$uvV1IJEFx1Eng07Iu`Rf`|IrrD zlKK-_u~!WY|~KsWT2N^lw*(rGo6udQtMJWaPdHE%oX_a~vxj6=+Fy)LKAQ!S3uqG)M_*eq{#bgyq#O=U3l|^sG<+k^YCRwo^!?sM%3WcTQpYoyXsG*L{3?;q#vt zu~XkVO#1uVVUuB=faY>dO(DLeN6dG4F-%~({^qky%!M1=^Wr~mYMF5O`=-U=&QI9B zFUrr*u>ZcUai#XbNeK!E_!ph}d-Jbmk#e4Fdv({}JtcMTZ`5>O&Ru>%z^_U!St;w> z%jEU{L#KWdco27`@Ss(d%h^fOa;G>&)t}Km^lyco$+5FmwO+IBCtTOLU3bPsFznl+ z%wAuUjRzYPY8SN!)kj;ry(~3vVUuh8E!WVwQdZS@2mM8KGuqS`yf_*`@ffT&V<777E zMJeVDWDs(!hWuy+n}I$;mc>wsMd2IovHzd;iB@-XZ0!B_RwONT+q;uLvy5`ztD8M} zzgHj=tpsCfVq}=gxp39#`nA4yK0fYyA;zF-cF1Usqet<%EAtf^t~fY8+Icb6_SCV( ztJN5O%+d<86NynW4WIYr*L-iOMV&0EF>iBH57aDX-L7I&m-&6)I=4m*25}>DALN^(uOnBj8DeAd0@NwittA`Hmi7#$nefVjgsx;@T+Z+Cb Y|B&W2{C|=ChOb%sOU-=aXjZ-N06J*4>Hq)$ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1a87a927f849f98ecb34ee5c7ac2482d1cc6ca2c GIT binary patch literal 434 zcmXqLVq9m?IGu@+(SVnYQ>)FR?K>|cBR4C9fw7^Xfj%2^C=0W&hLM4*siCv6rK^F7 znX$2(iHnn~p^LMjo0F@vv!R8np_8SvivcE9D)ZdtIp2|WEW|q~?pCrpuZOm8zk6hcf`jh~=LQAF_B!>ejRaYqwxxs*vPtt^JVwF)D==>Ii7qo6v#2G!MXfDfa@{A^vE1#u zbooMX$>hLeJT+4OTX(L>-CVU!aYLwK$mZ_o4;eRp=-uF91cw`@Gz-n}5`+=@>Es&Cw~cyBLSm?Esh!Fg=rbit+#D}t(L TCJ0^b)yi0Bp2fYT^Ik3hIs>C( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a08bcd92b5ca2e830b6eb9f633a4c51604397b60 GIT binary patch literal 1860 zcmXqLVs~KU)N1o+`_9YA&a|M3-O!+kU6+ZG*^t+On~gJ}&4V$OnT3hbps|OI8>)^| zfKh5eElagQB@>fet^~i4fswI+rLlpLv8kz%WfVlf(7?dZz!WTiY>bN_kpYae1UkQl4K_WTaklQ4X^r18+%Ba!Rw`tgCBOZcg>6bYk0bbeoD* z<_VuIx^s9G>US?_V!36|#B$A`iFw}wW+p}^kV_1B;a*|nW@RuiHZ(NQXJZa!VHVaf zGH^9DbT+nhH83$VHg+>{adI_uaW-^wa&>k#v~V?avUGMaGqo@?kQ3)c@ePOz_KTr` zp@C5pilMG9=9XsWrj|x-t_F^7CeB7KW@aufE-q$4y%P9|nfuI3g_CKd)wj7rFk zV`ODuZerwTFlb`rVrpV!WLW=AL2bjj(khSCq{91^mWC!wC$%&>qr&)gul%kLb#Z=O zJf&Fll<2$f_hNloHl?h;T3-7lX=eHH37Rr; zUwm~w_EL6vw+PS5ci%fsKI(rs<@LK?%Z*R()O~VzamuubJxAQ$3++ppq~YH1?KXD? zH~U2K()F>6u0(w47C7U{esRyiz%~)?Xy1%k*AKi;niTe6%dJGIR=%q1yx$p>IkL8_ zbNM2FO1SUph0~3SA}k-dk9NPh{#7_$m~qwAqSk*8{8{fL{CpOF?_jj`-G9v|Px9;# zmSewU>2zna_BE$1v-+ElzUDk68YIPb?bh5xCT2zk#>GvHpA4E9-xp;6-Nf5-w`4!>_ArnKNh`BR7>G59C_I(1eBfsM<@Uon^83z6JluIU zwaq{Rq(Fg@@jnZn0gnL}8;3Rx^aD}DXs zM7^SPy}Z;C{i1^GO#LADywnn1|4{w>vSMdPeF6#AAR1-|BO}Cc7~g=Y&7P1Icnwc2 zGT;L_njhpH7G@^)27|^)Fw+^iSsGgn8k-CPVIl_H)bLVnT8V)!+(_Ow#f*}Yf@0KA zDoQEP&&w~#OsmwZ$jvbjg(+v`0J)USfMo#_D}#X|T$mjsEMy>Hz{bMFz{tt~ra?+j zGc7YHJJ>0FaQ629m!u@C@Kc7T!LR-2*3R`4&Q<(lN(+<={uXETXWt>I$-TKNch(s_ ztj;<2hhviD`vU7FXV9Y+`ykPhf@C z67yW^>E_90&$g{~B#yl>c1BruV+8rayCn^wOv^ zYCZEiAIxprcksUO_T$|9O>Eqrt-17&b53G;T9qib<@eeuuZte*xwda9GZ8(VUTwEwQiQ(oDbB!( zZD;4ajSJ_S7Z$mQ-TTQ(!zRX622G61m>8KGu;&5fqIp5KffT&p<777EMJcEaWDs(! zhWw!745_R(&_~F!7%H(SINZOjagalkF?RQs@TYS&n5LZNyHH(qMdkCqJf-u;d?%w7 zV=PUK3^o_fywcV@%4ivDlye)59!twye(&3$014Wt?EnA( literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..65a21e38fe0eeb65152b05570eefc826551859f3 GIT binary patch literal 1731 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f3540c97e0f97a4119d571016e3144be3a21a12a GIT binary patch literal 1853 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 D19ynE literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..44dbd93608d9ebe9108716cc021609aa8a0f3778 GIT binary patch literal 459 zcmXqLVmxlpxPgh0(SVnYQ>)FR?K>|cBR4C9fw7^Xfj%2^C=0W&Msi}Jg@I{GQev{P znTffHWs+r*p`od%r9onnS!$X=nx(lxa!OK)g*dN~nSr5^p@E^PiJ@5(oNEr|8i*N) zFflWJ=@sWSGBq$WG&C|aG&3@cT3~3PZy?Xc94f2KB4HrbAaZI2>+LmW?w&h0ibis6 zd(S_e=k^T)9*_bN7FH%^rpR10$1*oDGBQlaUg{RI#{Toim*&~8pPw{T(>?g6=dwYk z3FCk7e@CUdn;e@|ILyu3er2;3teDcg#f<0IHi`19DN8Lmq@N%ED3YM@$VKPG!r4F1 zxSro|xO~s%^|w#PnReEcVsiHLWd%wT=S*p6PP}2Lf8=FY7DM|1Ic5Gg zL1$!s^%S0Y^;X<6*tK(F^lz(=39kJzEe1*@M-IEI={{W(+)`vPTP*Fv!>>PGN^`1j z%~bG+yAz((rQMlcT-lQz*)D&r;_mCj`rE1ZZ!B&7=>CM+W~-W_N&H^Dgz57#w^!br o=HLAAUGd$$*B{wW-?g$z(8%BHr$XQ}iSK4=2SeVTaKF|D0IVIdW&i*H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..dde9b498eb3d0c2f268529c4ed190fe7c6461b87 GIT binary patch literal 2278 zcmXqL;&{ZysnzDu_MMlJooPW6$7O>ij`K{6%!a%M+-#f)Z61uN%q&cd22D(sY}`A@jEpD?O0hKXO-?T}F3#60%1>lgWXM>5@a^9;eJeA2_oVwF-VFPn ze~OuX#zJ_~ie-zhMV>bSnc`wfvcAld!e=K`y*`2fp%n&A*)4 zxS)yUoIw-IDT5~FO$(Tr7@3%unPQI`@WN9bBR4C9fw7^Xfj%2^C=0W&Msi}Jg@I{G zQev{PnTffHWs+r*p`od%r9onnS!$X=nx(lxa!OK)g@K$nFG`{l=QT1nFgGwX0D~wL zL!I5sO`Xk6EM1)}%}h*9oy}Y=+}te84Gf)JoebS9%}m_PE!_;AObnVBm5?3B$jZRn z#K_NJ(8S2a)WpchaP-;puqW-sqFzG7mEPQ-^)}I%Kub$NP z&N+}D6?0mm>-gg>x3)f>^YzI)hi_ucET?&*&uCg+S?smED@Zu6i1lOT*9IY5IZyW8 z>pVVr*6gpDp3HUM;j9VQbfI19tBX;!y?`lPA7aXSwN zNF88Z(B5lqBkUxhn{-XhA!X?z{cI*?Mh3>kO^i znrlp6|BnLc*YeZ+D?e^AkOxUCvq%_-HHe&A!Fqd*nY-uCjiQlU+urj}=ed2uKmw#d zfsye)3!edx0T&yGHX9==D?1;PK?+Dlo+Z{G+8}a4_=3>3;G*KnykskV{p5_C{8YW- z{G|M%y!_%4y}Z;C{i1^GO#L9w09W0-oUHt$;=IhPl+>bPeFC|_AO&V4BO^t~;>?o#qDp-iS2xE{pAh`Eq!ttnHl%VEQW>5}DUAM7`zg7F|3H9DxK0kIIym)X%;dzs}mAAc;9bOtAHqi;pSiJ0< zn1F;9!>O!)`30#b4*IbNGZ%l{EjH&&5Wnpo&ad<5Y}|IB)Be4AQ9Zw5@hssI*YCQn zPfXg6zHdpeeO|YE;*RND`^rWC921*($m?GTvr~4-rLBSWcbJ8ibp+|(ar(FLc)ZRz zjx(nG7LM(%Z~q?=Nmu_{#?jl?wR3$`!3y^a)ixVh8x}p5mc1h%RBC@q9)sP>pKr_%s$g&tJu_(N)%4sx+%RF2r{_Jt3`_Z{YJC3mJwi8uZzg_&G$P&+! zXeAd*6C(r9_xEdNrZtvVn>5&TrEPaq?TXs=vRM1uD?9C+{|gG*Lk`!n@p7N3Zrgag zcK3+{&7_dE56`QVHGZ|NU!b~YXZ*LjMd!Gj-|Omsey06;&%b?b>(-}8>T8_(zWlmI zjH%06uE~4>o!`p;$LSP5+p|{k%Eo&0vwy3X*2s8|hgq$iv#<)y`>0 VP0XqdQZx=X?z^SwCMj!r8~{1qJsJQ2 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..aa556c36ef0027c79f06f0dc3a80ef5d6e2eb27c GIT binary patch literal 1729 zcmXqLV%y8csnzDu_MMlJooPW6+d6|LwpC1w%!a%M+-#f)Z61uN%q&cd20CoqP-UC~ zj1miES)>glnV4)&83-E)vM@0)8E_kLGP5&i@ojQolvvQjvcsT>Ws5-*^PC0DOpHuS z%uEtz4S3;JF>HTI7#JEEnMaB98krfG8yFgZK@^Ii&dwH=rWVHL zu1+S#My{@gM#e4%h6Zj1W~N3KPL{?lMlPnVMh3=422G4g$c|%VWngY%9 zCHFOM?MuF0%5qaFqG_7ha%r_+eDmf^+-;PdcU_V1(4&QWO!r%ab{frQx#xA{`kANh z%bNrK7!li(XGp-=6pT9Is@1@~f%8|1dDxm);Ru zyZ+qFKMs{&zIS=@r86-zGB7S~Vmxoq#CY03mW?@7mXAe@MdVCpg!yY{y@$#d1U{r{ z?6lR5Q!p}+2T3clNEnDUh@4u%dV7tTyXVf0qLEzN-t$lAxqZVx0;E8Jk?}tZp8<~n z7aNB*8zU<#J0Ful3P?ttCDtI?AaX(Yg3z|$qT^4 zBg=HXSj4TlbVFn>Ez5!F42h2i{;d(`x1*s6LQVa5nGfVP|D)n7l-5f)G zLh##?T4cZn@(Vx6mn_Uo>>4%>8D%b;#2CHW#Hse z9u=Hv8W3!l>gnRCSCpS&e&V)i1b&d^>8Dw9fSNUE8~BHr(KE`&h|&Q7hl4r6)G0CsqF34z?2pOzN{1 z&%Y6-z{mUL%|X7Bx|=^dYi>&UeKfUk`cvY4C#P`bQYMSJ>I;@lI>~oZ|f>f+eN7sPmK6Z7~K?)+^=}DyzlFa$r}$p z*6#b>d@MI$pV8^*wt;FHabgx3`{HG7lnyWL$a-&|yy@}MSvQpzZ`&H?(syju8Zhr}xgB)FqtPCtoj0~;WOHF<@rrW)G zzHoEN<;VLQn2!Av6aATY{OX3O;afjy#U1J3l!)OljmvA?{VJ(slcnQ^Ia8NkxsYM> z^ju-+q4hJL8~WGy&MSZO+4<&?Nq=H|yZVa@y4;t=*_a)+IKQPuNABI!&AKMj^N#a1 zzg}eJr%<{>dXbRr&Sh&&K2JW7%X3S{bTN{aM_*H= z{ANAJ=gus%P5t z+m{rboH^yEOaAg&t%a!-p1Ep0Cg+5TS4~ozaai4d+TIlD#`nyLovBez@5qwPB{BO^B}gMqQ3p@BXdb0`b5u!gx|ilIqrQj(FW zX-cwzsYQxOQd**EqN!oBrE!|Mahip(L1L1HMVdITk(q&^k)eU1si}cc6r5`g<{C5| zo?sx%#Khes&TC|9U}#`y2vG+XY=sM2LR5oPc0dJ<42%(i-7rC9?R{`TB<&NSg2u?& zr@#b}wNHl&B59um6*NKCJ{Km4tbGAo5J~%DsGupb_GK_Zuy*4pu-{k01(CF`feM-- zYhMo&1Zy{fXx{`E1ZjtwxfLpCj;wtLOb}W7Znz*wJJig5P(cf1?FV6k$l8y<1(CEL zhYDIEYd-}OMAm*5E(p>Nb^Ha0prHY>_RBCquy&}*Yj8o3cBtcTLIn+xwcmjWg0(|c z-iHfd1 z{0Ig2<_kM+hbe)(&wek0@LaNxKA85INQHNW%n?wadW;LE52aDnbR3Qyq^AOb}VS zI$RJ*yB0*y2szd9=)we%wHv?%LE52yH--vAQk@AnZ}OPI1i{*&DlOrHAnj1c+du^& zsSc{r9wrFZ4pr#{7evzT3Kc|7bvzz0L1gXTa6yoEs6~EILF81&69^MT)*b>E1Zjsl zDI6+@lIlPefT@9@ktwqFSeQyfkano;2~a^us)L%D3=>4w4yt27W}1PuLse!%RYFo7 z)XW^1AhPy+xFC}4MNmPMR96ZUMAlvb7X)dC+FlJ6grqvC?R79gWbKV`K_u-h5J6*< zRM!p@MAqH~7evzD3l)TAbVTBwhX!32@DpM(n{X+HxMgd|$1kIusck+olf z3nFR13Kc|2v^QXa$l7ni1(CGhg9<_tE!6ghFhOMPPvC+`+Mh!NQ7UMjS1>_j?Qh|N zNZLO@1tF;pYWruHAhPyva6u&PKcRvssqPO<5Ud><9sl8i2<^N~P(kEW$IA*61Z#&l ziI)Q|h@_nxDhNq+P?dZzL1gWMa6u&PB2Yo(RL3h06GYZ71s6opE(;Zeq&leW3NS%r z?aFXLB<*TYLF81&s|gcC)~*8=MAEJg6+}*TyhboVWbLMKK_u-KP(kEW$7>A}1Z#&x z9IqW*5J|fuL=aj*Lsh!K1d+A7!v&GFdqD+}Qys4_Ob}Un09+7BdoWZGlIkG!J8u|F z5Ud><9g%QBB<(R!K}f2DIzApIh^##cE{LQ(6)K35>M~%0$l9~vf=Jr)pn@o=t`H`O zti1#-h@`z7Du|Nms$hc1+H2v0NZK2qf+(r3877FVy$vpiq`ea=h?44hV1mfn`{9B} z+9!bpjiGfMG>)di1i{*&(J=!qh@^csR1gyHhTwJ`?>v|wSUc3rg>XS6?MtA7ka&lx zTn-aN*1ifZh@^cjR1hWJH^2nJ+M$l$3>QSwz6~mf67M@L6K$PZ%bM ztX&K)h@@Q-Du|rw_+(&$$lB%Mf=Jqxpn}M$j!zXPh^$=$E{LRE8!CvL>iG0vg2>tp z;etroO`w9vsgBPaCWx%v3NDDG-4-GUt>Yl^&gTFV1Z#)ncRpvhAd+@Bs33BxQZ2W z$lBB4f=Jr4pn{N82lZwyOb}Un0bCGCdoffHCDoO|1d+8@!Ud7E*FXhPQe8bv5Lr89 z{L|RT9H|e>2O0k~HiD!&XwX5%KaGtnkhMd`KS6>>wnN50jg3%J9c28|*vJxDJ7oM5 zB#5LPGX80d(h22*jDH#%8z5_kjDLazk+eg`KaGu1QXORc)7Th!n3oSS{s|I9(heE_ zG=Wyo(Busn|1?1vCFX;Se}V*&v_r-}O$<;HEoA)D#J~vI@sROPkRXzF$oQv;0VL5v zlMQ72(*!gO4j*~ugN%QI1d+5u#y?FAP!cU<{L{q11le}T_$NpZNjqfx)5HKJ(L%;Q zO$?AnPWT|>pCCab?U3%V zAnj0#Amg7VC>1mxWc!8UHjfL{4@5knv9wL*!J)4;lXi34*jkIu87h@lO*&*GWhLBVT zH4`%a2@(WphYCW*KTQmgQyo8K{L{n`Io0t)#y>%VAnj1cL&iT%43SeEKV*GWhRCUoA2R-FVu+mT_#xw;AVDPUknv9wL*!J)4;lY7F+@&v{E+ca zkRXzF$oQuTN(Idi8UHjfLM|%#A>*GQK_u;v@lO*YlvD>9|1>c|Np+C%PmmyzcF6dr zi4jVwgN%Qg7@?#($oMBn5J@{^{L{n;CDlR3KTV7vsSX-PknvBDAd+^-_@{{xB-KGx zLdHK$j8IY?Wc(8(h@>4d{%K-_lIkGipC%}y#Qc!)PmmyzcF6dri4i2#L6ah6{L{n; zCDlR3KS6>>+9BhgCPpZ!4l@2}VgyNbP%|OppCCab?U31POw)LoI@gf103F(EO0`PZN|1njbR$2@*uo z4jKOhO}c_6QlUBpf*|csLCE;0DM}5?4;lY7h1Rf8m5}jIkRV7q)ON`D zrzuJe%MTg*GQL6CN+-y!3lrYJS60A&2r z6s3k0fQ)~F1QFT=Amg8=C^f7AWc*E6C{YF9WwrDic-T0K*m2!p*1YjcF6cANDxUo zWcVrh82K}f10AyumX_rPmmyzcF6drDM}41 z02%)@h1Rf;#2^3}{{#slX@`t|nxfRO0+8`fQ*GQ zK_u;v@lR8f8dd-@{%H!WVWF`J8UF+cB58+=f10AyumX_rPg9f{Rsb^o2@*uo4jKP6 zMX6y0Amg8=&>9x%c*yuCNDxUoWc4d{%ML*!wNvgKTT0;SOLiRCrA)UJ7oOR6r~F+02%)@Md<{{#slX@`t|nxRzC0+8`fGn5Ki05bjw5=7Dt8UHjxsh|ZQ|1^VE&`|A=@lTK-l6J`Wrx{8GEdUw+G()MN1t8;} zAVDPUknvA5lnPn^GX7}>t)L;c3qr;}L4pYFf{^h~Gn5Ki5HkL0W&mjxLbXH2KS6>> z+9BhgX3z>6suD8(X@*ik3qr;}L4ruyA>*HBC>69IWc7jf)<2~e}V*&v_r-}%}^?6LCE;0 z8A=5$2pRtb2_k8SjDMP;RM3Ku@lP|93R(~{{s|I9(heE_G()MN1tH^~W+)Z3AY}X# zB#5LPGX80XQb7ws#y`!V6*M$9A>*GQK_u;v@lP|93R(~{{%HoSprP6!*HBC>69IWc7jf)<2~e}V)-+95TpAY}a045fk=gp7Zhp;XX7D2{8L4ruyA>*HBC>69IWc;^s z6C{YF9WwrD4y|FK;RzZ4G)Jjn1tH^~AVDPUknvA*lp0nLGX80fQo{;D#y>%VNZKLe zpXMkvtRQ6k(;QmELL&|`{s|I9(heE_G>6u(P?eDJPji$SRuD4&2@*tT7lMp`nxoXP zLXh!KbCeoZ2r~W&5=7Dt8UHj#sbPg6%V zNZKLepXMkvtPo`U(;QmELei2DWc(8(h@>4d{%MX4d{%MX*IsC^f7Q zWcDLQEFHr$oMBn5J@{^ z{L>s-!$Ok{Wc*IsC^f9m0q``gIkbj_rccQDCrA)UJ7oOR9HoX8f{cHf8zJ{5gdpRe zAVDPUknvA*lp0nDGX80fQo{;C#y>%VNZKLepXMkvtPo`U(;TIS6@rX^f&`JYL&iVN zQEFHr$oQu@N)0On8UF+cB58+=f0{$Pz|bg$jDMP=bb*B+UerpBB&x8Y&1G|Fl45f6*N>OWc>+9Bhg7SIYBY7u1o(*jySLoI@g ze}V*&v_r-}El?_GVaWKW1xf`i3>p6f34*jkErN`HT0kpks32tg(*mV}7KV&}f&@X@ zp-zH~e_Eha(87@MPYaX^S{O3^2@*uo4jKQnfL72@+acqh7SIYBDhL_>1PLN(hm3z( zKr3jdO33)91+;>Ox)U<~2@*uo4jKQnfL72@?U34k zDrjNI_$NpZNjqfx(*mV}7KV&}TA)*HxC>68_Wc(8(2+|ITcM-_=rzJ`S zEdm+;w1igBP?eDJPmmx;JJce`_@^aG1uX&@|FlG@phY0#pCCab?U3BV4a~t?xS`<*8UF+cBB_Lo ze}V*&oCF#F1PLNJ2{Qf(5=3$mWcBVQJO{~knvBDAd*Fp@lTK-l0}g5PmmzUBB(nd*Hx1{PqyLjwdd{s|I9vIsK%X=z}I?03lcCrA)UC1m^)BnWa6)FR0ECrA*BVQ5vNpknvBDAd*Fp@lTK-l0}g5PmmyzMUe4NkRXypknvATLjz>LL&iTrf*^~a zArBevx5`~O^f&>vx5`~O^T0*-65WkB;#y>%V zNESiHKS6>>7D2{8L4rsYLB>Bpf=CuY#y>3$jgb8g8UF+cB3T3(|FkqTM)o^o{1YUI zq!Kdz2@*td5@h@nB#7iB$oMBn5Xni9@lQ*XzKkej{1YUIWD#Wi6C{Xa5oG)mB#2}Y zWc(8(h-49D{L|9V7}@WT@lTK-l0}g5PfJ4+WWPhkKS6>>Dk0;aAVDN2LB>Bpf*>bB z(x)h7{1YUI1POvvLM?)fe}V*&oCF#F1POwi1a&-Q z{1YUIjDLazkt~9Ye_9%v zBKsXO{s|HUSp?M%8UM62Gz0q`DhL_>1PLOkgp7ZJ1d*Hs8UF+cA~^{%{s|I9auQ_x z(-PVRhI$h+{s|I9vIsK%2@*uI2r~W&5(HTU)eaf|1PLNp1R4LdG(>LKi9*IdL4rsY zLB>BV4I%A1sNW&upCCbyN@!w$jDLazK~91SLdHKqf*>bBwL``~L4rt5f{cG!qV(TI zA>*GQL6AjI?U3{1YUIWD#Wi z)6x*RT_*||{{#slsf3Jwf&@WMg1Qqj{s|I9auQ_x6C?<75>zE*{L>O;@Ie$Z{s|I9 zvIsK%2@*uI2r~W&5=62HGX4n?M6w7n{%L6lY1ct*hm3!M1VI)-LkTkeX=w;)*Fj=a z3^M)+5(KG)XcvQwe}V)NP7;HRe}V)NP7;HRe}V)-PJ&n@1{wdfgpQm*1tH^~AVDOH zAmg7PK_rVHPC6c^~2DKW_S zCrA)UC1m^)B#7iB$oMBn5acAN?U3k> z{{#slISDfU2@(W32^#N^@lQ*X0XH$o_$NpZ$s)-3CrA*)$oMBn5M&WF zv@}9)*NH*KKS6>Zl~6MwZ zCqcal8UF+cf}8{`Lm=aymMEizVvzAqkRZq+sCLNsCrA*PmmzU zBB&r_{L|71xm_m)8UF+cf-Hingp7Y$8X>pq#319JAVDORknvBDAd-_HZCqaD#8UM6I8R`^+jDLazkt~9Ye}V*&EP{-Gf&`H)f{cHH1VI)-eFPc*v^0XW z>!7wn#y>%VNESiHKP^GW>cIzy#319JAVDORknvBDAd-_H>PJ)bo zTB3~8ib2LdL4rsYLB>Bpf=CuY#y>%VNESiHKS6>>7D2{8Esc=dbz+e5PmmyzMUe4N zOVH7S2){$dKS6>Zm5`tlhm3!M1QAXWhm3!M1VK)Ms)USxf&>vx5{Hg|q6{*NL&raj z%nU&mK_X5ZI{s;7W{6}Fbo|rE%n)P|R3&u$(+K%ID{<)frx9|yP8>S^X=G-EWD#`y z(+Ig;Ck`F|gbr~-eFPo)5zQa$w|=hPa|^!kl!KsT^u_8i84Yi4junA zGDr41bo|rE9NF*C@lPXjWWPhlKT-BUi$ljhjgZ@Q;?VI=BXeZGL&rajklS_Q(D6@{ z@p*CR_@|KtvfrWOpGFqQeus{K8d-q-4viz|_$SKnzBqLJ)5rqZ@6hp2BMY$KO~K1x z#G&J#Mi$6^hmL<5Ss?8J7l)328X>pq#G&J#Mi$6^hmL<5LE3dtcS6TMQ6?9}q2r%M zmdJjGj(-|iBKsXW{%K@s0E%~LvVo3&qRdT*L&rajERp>V9se}41p6K8B%^hspC~gU;?VI=V*_NrL&raj4Zwbf zCU5BYr?COp?@-4>$3IafR>Yy>pT-7Yze7U_I{pc+bwMYef|^_6(D6@W17yEL$3Kk? zko^uF|1?H!*NH>NKaCBL{SF@g1dUXJ`>;^k!Q-D`L8OuaJpKt5M9S~r@lUWIQho=I ze}V;(;vGEx2@(YFM21=f9{&UjB3T3;{{#ynSp**c1PdZr1Rnnc3xX_y1~Pd36C{Y# ze-{Uje}V-;7D3|(JpO5DV1V4N69EJ2i0pUp_$OEpWD(Ty;PFqeAd*Gk@lUWI$Rendz~i4_K_rX70UrMZ3xb>kai;`${1YSy z_B&K1c>EJA2(k#O5EJAh@=uc{s|TYISHyAJpKt5L~;^%{1Yq)auQS}c>EJ2i0pUp z_$OEp$s+LhCs+{4BJlVpSP;n~@c1WK5XmC&_$NpZ?00B*g2z9>f=CvD$3G1X43Nj` zB*5dJU_m66;PFqeAjnBjGr{AZU_m4&fyY0=f=EsRkAH#$k^K%H{{#ynSp**c1Pg*J zf(9LU{1Yq)vIuHBc>EJAh-49X{1YSy_B&KNc>EJAh-49X{L|3D0C~Jl0zCc+7DQ4B z9{&UjA~^{>{s|TYISFbJc>EJAh~y;j_$NpZ+3(=-Pp}}8Md0yIupr1HXqpF)e}V;( zECP>zf(1bqK?4Ll{s|I9_B(j|6D){i5qSL5&;U9Q3w1nr{1YsQq!K*-2^Iu7391r2 z{s|UDauRs_6D$aF5>zF4{1YSy_B+&_;PFqeAd*Gk@lUWIl11S0Pp}}8Md0yIupp8} z;PFq8AhO@Vzf(4N*0*`-!1wj@;9Sk`{{#ynISD-e2^Iu73F;&8_$NpZ z?02X;!Q-D`K_rX7NEU&|KMf5) z)jqr*CjlP+1Pdam1do4$1(BQt9{&UjA~^{>{s|UDI7t#b{s|HU`yCP>lHl=Aupq)B zN$~h5SP)^6BzXK2EQqj35EJA2(kzg@{-{3Pte3LQvEIo z9{&Ujf>c7?2_F9h3xb>k)eav21PdZL2|WG@76ds7l4T^pEJAh-49X{1YSy_B&KNc>EJAh-49X{L|0?Wgb=%JpKt51gV5-2akV(1wl@N1~Pd3 z6D)}2B=GnrSP;oc;PFq8AhO@Vzf&{^ShiV6p ze}V;(ECP>zf)-sM=Xdbf*^}5 zAcElWPp}}8Md0yIupp8};PFqeAjl%9kHF)fAVFlmgU3I?f*^~aJ_3(_8X7?7VWHZ= zEJAh-49X{1YUI?04|^Cs+_<5mY;P{L|0?Iu8qVJb3&QEQq8M zJpKt5L~;^%{1Yq)auQTKc>EJA2yznCBJlVpND$fY;PFqeAjl%9cJTNoSP;n~@c1WK z5XmC&_$OEpWD!(5c>EJ2i0pUp_$OEpWD(R!;PFpG1C)7KDe(9wSP-NVY9@I66D)}2 zB=GnrSP;oc;PFqeAd-{7NEU&|Kf!`X7Jk`{{#yn zISD-e2^Iu73921D{s|HU`yDC>9{&UjB3T3;{{#ynSp**c1PdZr1Rnnc3nEzr9{&Uh zg8dHl5qSI)EQn+gc>L4Q0A(Il3OxP^7DQ4B9{&Ujf}8{`g23aSU_m4&LB~IhjKJfx zP}`y7pU`<&s33Iw)5r)sP74hX==dji9u+oDD+L|@G%`XOrR6BJ16J^hc6m(D6@{ zd01)a_@|L6QoKt;$3Klsk^K%G|1>g1_B(X^)5sL;cc_z~$zbo>)K4-3@}9sfj`hn0qof1=F8N<+s#QRZQ#q2r&>d042A zpyQt?^RUv;@lTX_SZV0^Cv+YbsvSE1i82o>4ITePnTM5zj(Pj(?)e!%9QP zKcVxmkOfH6(D6@{d01)a_$PE87E;GaL&ra%^RQ5hpyQt?^RUv;@lTX_SZV0^Cv+Yb zsvSE1i82o>4ITf4&ci}gLdQQ*=3%9w)#9#$GU{t2Cjg{p*(e?sSBp@PuyPv|@>R1iA;37v<9IvzUy37v<9 zs)UYzqRhieL&rZ+=3%9wa9sh*R!$Qr3 zj(?)e!%9QPKcVxmP?gZ}Pn3CBY3TST$~>$zbo>)#9#$GU{t20fHH3_ANkhj!QRZQ# zq2r$@^RUv;@lTX_SZV0^Cv+Yb>Q3nRC(1mmG<5va7}U3gH6)~=s4F6W`+pWs7$;ZA~%e;ONt%Xx@) z8R+<@u_019F9RL_G={9NgQ|p%e;OMimGd%?@lVhQCSo_43}pNhBnWEPLHsTQ8UF+c znwpxLK-zUOknvBDAfjC-0~!AW34)rMQ0)$oMBn5XmCQ_$NpZ$s)-3 zCrA*I+4q65>{s|HUsf22WjDLazk(>k>{{#slISDfU2@*td z5@h_-5M@WI3}pNhBnYwy8px3GPmmyzMUe4NkRXypknvBDAd*Fp@lQho$l5|^ctXZM zL4qKQpn{O`PebS&G}N1r@lTK-l1j+%VNESiHKS6>>7D2{8L4rsYLB>Bpf=CuY#y<@Wkk=NBpf=Et+jDH%U>;;yAjDLazkt~9Ye}V*&EP{-G zf&`H)f{cHH1d%L)jDH%U>;;yAjDLazkt~9Ye;T69LCZkKKS6>>Dk0;aAVDN2LB>Bp zf=Et+jDLazk(>k>|1?C|3oHW}{{#slSp*sX1PLNp1R4JX2_jhp8UF+cA}o@HjDH%U z>;;yEjDLaz5f;fp#y<_AbI_2CAPX7)1PLOkgp7ZJ1QAY>g^YiK1QAY>g^YiK1d*Hs z8UHkd?gfTqE?LO)$oQur${e&TWc(8(h@=uS{s|HUISFbeWc(8(h~y;5_$NpZ$w`p$PebTlV5oM; z_$NpZWD%sSlZA|bf&`H)f{cHH1d%L)jDLazK^8$n2{Qg^h_V-07Bc<`5=62HGX80Z zG6yXS8UF+cf>c7?2^s$c2_iWOGX4n?L~;^j{1YSyauQTKWc(Af?-o(*%0k9JL4rsY zLB>Bpf=CuY#y>%VNESiHKS6>>7D2{84MDX&d{$i+GX4n?1X%>PJ)bog0=#K{0_ApGX4n?M6w7n{s|I9 zvIsK%2@(WZ1l0~1{{#slSp*sXG(_18EDIU`1PLNp1R4J{M45w@g^YiK1d&ui#y>%V zNKS%`e}V)-PJ)IKWc(8(h~y;5_$SCkAiqOZLdHKqf=CuY#y>%VNESiHKS6>Zi=crF z8UF+cB3T3(|1?C|3oHv6{{#slSp*sXG(?$$mW7Oef&`INLdHKqf=Et+jDLazK~923 z2W0#cB#7iB$oQur%3feu$oMBn5XmCQ_$NpZ$s)-3CrA)v5!COH@lTK-l0}g5PeYWw zz_O6>PmmyzMUe4NLzFpaS;+V&NDxUSWc(8(h~y;5_$NpZbB-3b~0 zG(_18EDIU`1POvHf~tg!e}V*&EP{-Gf&`H)f{cHH1VI)-ZHJ718lvn4mV=Ccf&@Vp zK~%~?#y<_Adx4>XknvBDAd*VR_$NpZ;Uqc8_$NpZ;Uqc8_$NpZ#y<^F_5#a6#y>%VNESiHKMhgl zpyeRrpCCbyN~q%@>PJ)bof&`JA1R4J{gzg1~x)U<~2@(WZ1Qmpg ze}V*&EP{-Gf&`H)f{cHH1d%L)jDH%U>;;yCjDLazkt~9Ye;PvPprN)y#y>%VNGc)Y zpCCabCqc$PL4rt5f{cHH1d*Hs8UHkd?gfTg1R4JX2_jhp8UF+cB3T3({{#slSp*sX z1POvHg2oYK{L>I+FR&bB{1YUIWD#Wi)6fuk&xjmk{1YUIq!Kdz2@(W32{Ixf2O0ka z2_iWOGX4n?1UU)nP00ACA#^V=R6At+6C?<-2vStaLB>Bpf*^~a+9BhgAVDOHAmg7P zK_rVH*HhD09$qknvBDAd*VR_$NpZ*GQK_n+Z z#y>%VNKS%`e;Pvf0z++wjDLazkt~9Ye}V)-7C}`)#y>%VNESiHKS6>>7D2{84N>+2 z%R$CJL4qKQpeY10{%MFZ2Q3E~{{#twR6=csjDLazK~92dhm3!M1d*Hs8UF+cf}8}6 zV#xR>s2zc*-{m0VpCCabiy-5lAVH8tP}?EnpCCabiy-5lAVH8tP?eDJPeYWwz;clB zPmmyzMUe4NLzKP1a***)kRXyu$oMBn5acANk09fpAVH9mpaB9I{{#slISDfU2|DJ) z(A3h{9K5tf4l@1;5(HTU)eaf|1POvHg8B$D{s|HUSp*e?jDLazkt~9Ye;T6f1(t)1 ze}V)-7D2T`#y<^F=Ah*v)K z2Mx(G^3d^5lsRa5==dkf9JD-i{1askS{^$737vz61f4u|{1askS{^$7i82Q*4;}wR znS+*xj(?)eLCZtOKT+nO<)P!B&^c(RkD%k9D09&A(D6@{IcRz4_$SI7v^;eD6FLVC z^$~RZ6FLVC4NvI!Cv*-PDhM6_M45w@hmLMjo*|1>f|8lRVkj(?)eLCZtOKT+nO<)P!B z&^c(RkD%k9D09&A(D6@{IcRz4_$PD@8tQoH_$SI7v^;eD6FLVCRS6ycgw8=j1)<}g zD09&A(D6@{IcRz4_$PD@8tQoH_$SI7v^;eD6FLVCRS6ycgw8=jZHJD3qRc_dL&rZ+ z=Ah-F zIcTULbo>)#4q6^M{)sXNEe{?4gw8=jwL`~0p>xpCECU_?M45w@hmL=u%t6aT$3Ic# zpyi?CpD1(C^3d^5lsRa5==dkf9JD-i{1askS{^$7i82Q*4;}x6&Ot+c1ReiGnS+*x zj(%I{pcrgNAB{ zj(?)eLCZtOKcREbP}`y7pD1(C^3d^5lsRa5==dkf9JD-i{1askS{^$7i82Q*4;}x6 z&Ot+MhmL=u%t6aT$3LNS&=8dh(D6^`95hrAI{pcrgN6!1$3Ic#pcSCwpU^pIs7mPg zC(0bO0(ATnWe!>aI{t|=2dw}d|3sOCR)CIwqRc@nK*v8(=Aadz4@lTXFXa&glr;!2j9JB&t{1YUIXxk`2#y>%V zh_;OaWc(8(h-lj=K*m2of{5mp0%ZIXbi56sO{)MI{{#slSp*sX1PLNp1R4JX2_jhp z8UF+cB3T3(|1>gy%t1rr2r~W&5=62HGX7}<-3ts2CCK;uPmmyzlOW@t zAVDN2LB>Bpf=Et+jDLb!*+_nejDLazK^8&X2^s$c2_jhp8UF+cB3T3({{#slSp*sX zG%^6U3t?#qGX4n?M6w7n{%M3V532wf{{#slsf3Jwf&`JA1R4JX34)vi4LZp9CrA*< zNs#eRBb0eq1<3d(ND#>)$oMBn5M&Y5Ovv~rNDyQZG?XCYpCCabiy-5lM$ma!s7lEA zCrA*)$oMBn5M&WFeL}`RjZo%c6(HlEAVH8tP?eDJPb26& zEYwGk@lTK-NF`JdGX4n?L~;^j{1YSyauU=`$oMBn5acANcF6dr5z0KQ0%ZIXB#2}Y zWc(8(h-49D{1YUIWD#Wi6C{Xa5oG++2xT5t0W$sx5(HTUbth!}(+FiARsk~p2@(XU zggPEF{s|I9I7tyQ{s|HUISHat5i5=1yj5i%VNESiHKaEi4VHF|cpCCabiy-5lMkw>JijeV7kRXyu$oMBn z5acANHzDJnAVH9mpn{O`PmmyzlOW@tM$ma!s7lEACrA*CgQ08G3A>*GQK_r!s@lTK-l9M3gpCCabCqc$PL4qJBLH!OH{{)SVA?kNU z$oMBn5XmCQ_$NpZ$s)-3CrA)v5!6h`_$NpZ$s)-3rxD6LtRiIm6C{Xa5oG++2xT5t z5i5(KG)S_B#Y1PLNJ2{Qf(5=3$mWc(8(2yzlMIw0eppriYc{0*GQL6AjIC!Ggfv;-Q%L)tH+2pRu0 zg5I$LRS6mY1PLNJ2{Qf(5(GI3suD8(2@(W32^w^e@lVhQBFOKMrHP7=@lTK-$VpI@ zknvBDAjl%9AY}X#B#2}YWc(8(2(k!jJ7oOR2s#f7)eaf|1POvHf~tg!e;T38!zx0? zKS6>>Dk0;aAVDN2LB>Bpf=Et+jDLaz5l&KqjDH%T%)=@{#y>%VAd4XGRDz6uf&@Vp zLENbX8UF+cA}msZjDLazkt~9Ye;PsOVWDP1#y>%VNESiHKaEi4VU-}`pCCbyN~rCS z@lTK-l9M3gpCCbylb{ws#y>%VASXcuA>*G$(0N#>AY}X#B#2}YWc(8(2(k#O5;Fb? z5(HTUbrNL!6C{Xa5oG++2s#f7)eaf|1PLNp1R4J{LYaqEf{cHH1d&ui#y>%VASXc* ztrBGX6C?<764WBd_$NpZ$w`p$Pa~9hSS85#CrA*%VNKS%`e}V*& zoCF#F1PLNJ2{Qg^1f7S4+72221POvHf;t{D{s|I9vIsK%2@*uI2r~W&5=62HGX80V zG7qZ+8UF+cB3T3(|1?6GhgE`%e}V)-DxvO#jDLazk(>k>{{#slISDfU2@(W339213 z{%M3V532+j{{#twEQ0zSGX4n?M6w7n{s|I9vIsK%2@(WZ1l0~1|1?6GhgE`%e}V*& zEP{-G8llX?DnZ6SL4rssA>*GQK_n+Z#y>%VNKS%`e}V*&oCF#FG=k2ZCqY$0#y>%VNKS%`e}V*&oCF#FG(wq&Rf3Fvf&`H)f{cHH1d%L)jDLaz zkt~9Ye}V)-7D1f^8UHjwnTJ(^jDLazkt~9Ye;PsOVWHX~`{{#slEK-Jye}V)- z7D2T`#y>%V2#b^<Uer zpCCbylc4T|jDLazk(>k>{{#twoCH+~8UF-LMI!kfGX4n?M6w7n{s|HUSp>BmGX4n? zM6w7n{s|HUSp-!H8UHi_wRjMIhm3!M1d%L)jDH$|!Vckg$oMBn5J@Fu{1YUI)K4+{-X==dje9v13% z==dje9u}$+I{t|=5339v|3sOGRfdj#qRhi8L&rZ+=3$kgR6BJ16J;J&89M%nG7qZ^9sfj`hgF7-e?sSBp=LtI zKT+mkm7(LGDD$w&(D6@{d01uW_$PE87HTGR{1as!Rv9|}i82qX3?2W3&ci~qt3by; zQRZP)pyQt?^ROz=@lWVHEL1ym{1ZA43mM*5fsTKo%)_cc$3LO-u#mK*0v-QEnTJ(@ zj(?)e!>T~XKcVxmQ0>t1Pn3CB73laU$~>$Jbo>)#9##c9{)sXVs{$SWgwDf4y$K!v zM45+GfsTKo%)_cc$3Ic#VO5~xpD6RND$wyylzCVc==dkfJgf?I{1ZA43w087{1ZA4 z3l)Tpf1=F8szAp-QRZP)pyQt?^ROz=@lWVHEHsdz$J zbo>)#9##c9{)sXVs{$SWM45+GfsTJd=V75vf{uTp%)_cc$3Ic#VO5~xpU`<&XedF) zKcVxmP(kSUC(1mm3UvGvWgb=qI{pcrhlM%`I{t|=532$l|3ujftO6bXgzg1~YKM-0 zqU;4$fsTKo>;+bVj(?);1y+HMe?s>HL(PPaf1>OKR)LOxqU;4$fsTJd_X0z;L&ra% zdx4>X(D6^`USOyobo>)#FR%(|{L>6H0}URpgQ^6Le?kNi?K&0E_$NdV(XLYgjekM} z5$!q^(D)}r5Yg0B0gZox1;OXrKw}d${s|F8vIsQ(2@yoH2sHi)5k#^GH2w(@M6w7p z{s|TYpKk;85or7qB8X%WX#5i-h#y=r~Ad4V^s-W>th#<%!s32(k6D)}2cU92%Cqxiokt%5X6C{Yd zwonx`{s|F8QVAOWgb0G11c^9R(D)}r5acANAZYv(A_#I4R1h@&2^K{1yDDh>6Cwz* z2V)JdT6PmmzwJZh+! zpz%+LAd*VZ_$NdV$w{E`PlzDMNl-IEgBqxE!Kf!{?eg}k? z|AYu4ISDlW2@ync5@`GrEQsuP(D)}r5M&WF<gkVViq0*!xy1d;CgkdvU^1dV@!1(E#@8vld{f-HiD z5@`GrB8X%WX#5i*h-49H{1YOGWD#im6D$b!JJd|j_$NdVWD(R!pz%*L1JJ%*#4wsF zX#5i*h@=uU{s|F8auR6#6C#M@B+&RLL=fa8sO_NfPp}}e-$CP_5J4o1K;xefK_rVn zgkVQ~4LF1ngL6AidAE|-HKf!`vze5E<gghgtg@lTK-^4dZ*(D)}r5J@Fy z{1YOGaFQBm{1YMwauU=c(D)}r5XniP@lUWIlHb)pgB#S`fpAbPLi$LR_5J8Yd z5WlN|#y=r~NEU&{Kf!{?eg}bB-3c211Pg-w4iyBAe?kO77C{9;gB#S`fpAbPLi$LR_5J8Yd zP}@P{pI|{`zk|jVR3&Kq z6D)}AchLAJL=a>VR3&Kq6C{Y-u2Tbze?kP2RD#AoA%aLw0*!w{1d*Hs8vld{A~^{( z{s|UD_B&|&6C#LY5or7qB8X%WX#5i*2(kzoN}%yih#-<gB#S`f zpCCa%V$n82c z(D)}r5J@Fy{1YOGgBqxE!Kf!{?eg}%Vkaiu^cF_1IL=dDB zY7uDs6C#M@B+&RLL=eeIpz%+LAd-_ngkdq)OL>)B#2@ync z5@`GrEQsuP(D)}r5XmCY_$NdVWD(R%(D)}r5XmCY_$NdV$s*ABCs+{K@1XHdh#-k?|AYu4ISDlW2^K{5J81k9 zB8X%WX#5i*h-49H{1YOGWD#im6C#LY5or7qEQsuP(D)}r5XmCY_$NpZ)~;J%V4!Cp z&&C`otIQ%{Al4vKc+qdSYV3iCEBlu&m6`nAzL{%BzX3N$fd~r=6O)vm0WTY;R+~rL zcV0$DZdL~7CPqdEmD{X${7;>z37z7>Z*pv^I`_4wi6>TToYR1e0#Y3^%J2R}= zvVZo!`_evlA}hy?)mr;ggjZ}^Bed6&%kb(GJA=tVo1R^l48Ex95;eokYIZFgp7pt6n aDtz9CXTynD=Z9ad?dC4|8!qUxuMPk(rH*L; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f629855d0c38af4a4af0954448527ddb1e38b438 GIT binary patch literal 1833 zcmXqLVpnD3)N1o+`_9YA&a|M3UCf|~U5JU1*^t+On~gJ}&4V$OnT3hbps|OI8>)^| zfKh5eElagQB@>gBp9H^=fsvVkp^>41p{c2XQ52GZIamPM7&ikKG5O>aH~-Sgu#|GQ ztk5#E@Ei}X0OJ4?U++>+3%%r`9A-rZ!zB?OOHS;G_qaH>e%<-z=`G(|zU+9#`Q`Su z0*##hrpg6PEc*?bSausUF)v=g%*4pV#H3zmzzg>ZBR4C9fw7^Xfj%2^C=0W&hPh#i zp-E~|l98!tO0t2eMT$vMTB2#9sbR9EahkbtnuW1JVv>bLnt_}+FN$x#Tyq0M12Bj} zG1S1t&C5iS9BC&0v)X!Nl(>YDN4W1;{r$2n~N>#1lziQi#eHooz zA2!BYPYkm>Y!i4yQTwua>XsH)*X*^fr7un|tnj$Tv-Ztaj*EZn!@Aep@+*F{?tI(J z=@J`O{f@M9u)naWnq90>iMRe~?jgQC@3;I?eE8}7#|b7)4W7?+Wv(Az*K(*wS3>8p7=d6D$Ptn^s~V3Nx9J17tm$0m}j=Rt5t_xG+0NSja%YfQ^NTfsvH~OoNo5 z=1gW#nxDm8qGvCw<8GFzc4qEiauY(x6K3 zk=c{rhheb?>VE&!c6GhILt0~z;bY4 zRYpZhAF^5cmv6ax^p>4FXCFAaAjJQ8riktBjc$xN)`Fe?E1Y9*mh?Fvy}ee_yE4A# zX7k%1+l-wdTbSObS7c`Vk5v25w=Q9oL+$=a5q3(PPCl$!Sw4Y#qw{X4Z(<2AuA1ZL z>~Pik-l6F|Z!5gC+|qL<*5>`|J+$eXs)>EmtcNMdS&@6JZpcnm_c`q^J2p$##IJQjLVo9nH#Vtbma1PLAHSuymaGaHsobPE{zRj5OS=B z{NOSRQ6L-WBV<_&l~@#< zmL^7qM7Q71rZfC&VOytp^N5et`&#Zb85#Q|7w=4;D1RX7&hmr4%Ia@yY7NFL-qH9*4@Q^Y2bwux`H2>){ z312op_?t;=dfSTf;_nWt?OzA45?yN=_y79Thf~k9nC&ueNKOs?!S^dfyLV+T?<;PM&(B*~pD literal 0 HcmV?d00001 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:#?}") }