增加aspa对象解析
This commit is contained in:
parent
bcd4829486
commit
56ae2ca4fc
@ -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 |
|
||||
|
||||
@ -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(生成步骤)。
|
||||
|
||||
100
specs/08_aspa.md
Normal file
100
specs/08_aspa.md
Normal file
@ -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。
|
||||
|
||||
87
specs/09_ghostbusters_gbr.md
Normal file
87
specs/09_ghostbusters_gbr.md
Normal file
@ -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)。
|
||||
|
||||
223
src/data_model/aspa.rs
Normal file
223
src/data_model/aspa.rs
Normal file
@ -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<u32>,
|
||||
}
|
||||
|
||||
#[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<u32>,
|
||||
/// 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<Self, AspaDecodeError> {
|
||||
let signed_object = RpkiSignedObject::decode_der(der)?;
|
||||
Self::from_signed_object(signed_object)
|
||||
}
|
||||
|
||||
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, AspaDecodeError> {
|
||||
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<Self, AspaDecodeError> {
|
||||
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<Vec<u32>, 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<u32> = Vec::with_capacity(seq.len());
|
||||
let mut prev: Option<u32> = 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)
|
||||
}
|
||||
@ -8,3 +8,5 @@ mod oids;
|
||||
pub mod oid;
|
||||
pub mod signed_object;
|
||||
pub mod manifest;
|
||||
pub mod roa;
|
||||
pub mod aspa;
|
||||
|
||||
@ -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";
|
||||
|
||||
466
src/data_model/roa.rs
Normal file
466
src/data_model/roa.rs
Normal file
@ -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<RoaIpAddressFamily>,
|
||||
}
|
||||
|
||||
#[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<u8>),
|
||||
|
||||
#[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<u8>,
|
||||
prefix_len: u16,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct IpResourceSet {
|
||||
pub prefixes: Vec<IpPrefix>,
|
||||
}
|
||||
|
||||
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<Self, RoaDecodeError> {
|
||||
let signed_object = RpkiSignedObject::decode_der(der)?;
|
||||
Self::from_signed_object(signed_object)
|
||||
}
|
||||
|
||||
pub fn from_signed_object(signed_object: RpkiSignedObject) -> Result<Self, RoaDecodeError> {
|
||||
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<RoaIpAddress>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RoaIpAddress {
|
||||
pub prefix: IpPrefix,
|
||||
pub max_length: Option<u16>,
|
||||
}
|
||||
|
||||
#[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<u8>,
|
||||
}
|
||||
|
||||
impl RoaEContent {
|
||||
/// Decode the DER-encoded RouteOriginAttestation defined in RFC 9582 §4.
|
||||
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
|
||||
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<Vec<RoaIpAddressFamily>, 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<RoaIpAddressFamily> = 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<RoaIpAddressFamily, RoaDecodeError> {
|
||||
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<RoaAfi, RoaDecodeError> {
|
||||
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<Vec<RoaIpAddress>, 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<RoaIpAddress, RoaDecodeError> {
|
||||
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<IpPrefix, RoaDecodeError> {
|
||||
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<u8> {
|
||||
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)
|
||||
}
|
||||
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/E0AAy4jgV_BtY7GQELCxALV6Q5U.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/IdgNYO8v_dEbdoPuHFTpsjA3l0U.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/UDn6-ZD0WTj18D8nHih3D--ZO_s.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/Vvx1bC8uAflbQPltsuM_idhPJzQ.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ZW5EIqvxKWSSAOsBmoFfKxIjbpI.cer
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.mft
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fDvN8JDIeg8h7b5wWCFK_F7J_SY.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/fykdvBDNLvaFDLOeKcvquSaaNlM.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/lAN537Pqyt3IsX0kCwc4qs0Cejk.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/laI1_vEYtlNzj-K0SXR_yGpd1TA.gbr
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.crl
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/ovsCA/IOUcOeBGM_Tb4dwfvswY4bnNZYY.mft
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/xF8fZFbI8NRA4qk_N5CLpw6Nmj8.roa
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa
vendored
Normal file
BIN
tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/y9dGCqfvXd6vKRjYx3GJikg6pSw.roa
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl
vendored
Normal file
BIN
tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.crl
vendored
Normal file
Binary file not shown.
BIN
tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft
vendored
Normal file
BIN
tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nlrssf/cdFOuyVdwFjUv6WlHJP3P4MKuI8.mft
vendored
Normal file
Binary file not shown.
25
tests/test_aspa_decode.rs
Normal file
25
tests/test_aspa_decode.rs
Normal file
@ -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(_)));
|
||||
}
|
||||
111
tests/test_aspa_econtent_decode_errors.rs
Normal file
111
tests/test_aspa_econtent_decode_errors.rs
Normal file
@ -0,0 +1,111 @@
|
||||
use rpki::data_model::aspa::{AspaDecodeError, AspaEContent};
|
||||
|
||||
fn len_bytes(len: usize) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let mut out = vec![tag];
|
||||
out.extend(len_bytes(content.len()));
|
||||
out.extend_from_slice(content);
|
||||
out
|
||||
}
|
||||
|
||||
fn der_integer_u64(v: u64) -> Vec<u8> {
|
||||
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<u8>>) -> Vec<u8> {
|
||||
let mut content = Vec::new();
|
||||
for c in children {
|
||||
content.extend(c);
|
||||
}
|
||||
tlv(0x30, &content)
|
||||
}
|
||||
|
||||
fn cs_explicit(tag_no: u8, inner_der: Vec<u8>) -> Vec<u8> {
|
||||
tlv(0xA0 | (tag_no & 0x1F), &inner_der)
|
||||
}
|
||||
|
||||
fn aspa_attestation_explicit_version(version: u64, customer: u64, providers: Vec<u64>) -> Vec<u8> {
|
||||
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<u64>) -> Vec<u8> {
|
||||
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)));
|
||||
}
|
||||
|
||||
84
tests/test_aspa_validate_ee_resources.rs
Normal file
84
tests/test_aspa_validate_ee_resources.rs
Normal file
@ -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));
|
||||
}
|
||||
|
||||
14
tests/test_aspa_verify.rs
Normal file
14
tests/test_aspa_verify.rs
Normal file
@ -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");
|
||||
}
|
||||
|
||||
127
tests/test_roa_canonicalize.rs
Normal file
127
tests/test_roa_canonicalize.rs
Normal file
@ -0,0 +1,127 @@
|
||||
use rpki::data_model::roa::{IpPrefix, RoaAfi, RoaEContent};
|
||||
|
||||
fn len_bytes(len: usize) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let mut out = vec![tag];
|
||||
out.extend(len_bytes(content.len()));
|
||||
out.extend_from_slice(content);
|
||||
out
|
||||
}
|
||||
|
||||
fn der_integer_u64(v: u64) -> Vec<u8> {
|
||||
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<u8> {
|
||||
tlv(0x04, bytes)
|
||||
}
|
||||
|
||||
fn der_bit_string_from_prefix(prefix_addr: &[u8], prefix_len: u16) -> Vec<u8> {
|
||||
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<u8>>) -> Vec<u8> {
|
||||
let mut content = Vec::new();
|
||||
for c in children {
|
||||
content.extend(c);
|
||||
}
|
||||
tlv(0x30, &content)
|
||||
}
|
||||
|
||||
fn roa_ip_address(prefix_bs: Vec<u8>) -> Vec<u8> {
|
||||
der_sequence(vec![prefix_bs])
|
||||
}
|
||||
|
||||
fn roa_ip_family(afi: [u8; 2], addresses: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
der_sequence(vec![der_octet_string(&afi), der_sequence(addresses)])
|
||||
}
|
||||
|
||||
fn roa_attestation(as_id: u64, families: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
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],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
33
tests/test_roa_decode.rs
Normal file
33
tests/test_roa_decode.rs
Normal file
@ -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(_)));
|
||||
}
|
||||
205
tests/test_roa_econtent_decode_errors.rs
Normal file
205
tests/test_roa_econtent_decode_errors.rs
Normal file
@ -0,0 +1,205 @@
|
||||
use rpki::data_model::roa::{RoaDecodeError, RoaEContent};
|
||||
|
||||
fn len_bytes(len: usize) -> Vec<u8> {
|
||||
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<u8> {
|
||||
let mut out = vec![tag];
|
||||
out.extend(len_bytes(content.len()));
|
||||
out.extend_from_slice(content);
|
||||
out
|
||||
}
|
||||
|
||||
fn der_integer_u64(v: u64) -> Vec<u8> {
|
||||
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<u8> {
|
||||
tlv(0x04, bytes)
|
||||
}
|
||||
|
||||
fn der_bit_string(unused: u8, bytes: &[u8]) -> Vec<u8> {
|
||||
let mut content = vec![unused];
|
||||
content.extend_from_slice(bytes);
|
||||
tlv(0x03, &content)
|
||||
}
|
||||
|
||||
fn der_sequence(children: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
let mut content = Vec::new();
|
||||
for c in children {
|
||||
content.extend(c);
|
||||
}
|
||||
tlv(0x30, &content)
|
||||
}
|
||||
|
||||
fn cs_explicit(tag_no: u8, inner_der: Vec<u8>) -> Vec<u8> {
|
||||
tlv(0xA0 | (tag_no & 0x1F), &inner_der)
|
||||
}
|
||||
|
||||
fn roa_ip_address(prefix_bs: Vec<u8>, max_len: Option<u64>) -> Vec<u8> {
|
||||
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<u8>>) -> Vec<u8> {
|
||||
der_sequence(vec![der_octet_string(&afi), der_sequence(addresses)])
|
||||
}
|
||||
|
||||
fn roa_attestation(version: Option<u64>, as_id: u64, families: Vec<Vec<u8>>) -> Vec<u8> {
|
||||
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 { .. }));
|
||||
}
|
||||
|
||||
108
tests/test_roa_validate_ee_resources.rs
Normal file
108
tests/test_roa_validate_ee_resources.rs
Normal file
@ -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));
|
||||
}
|
||||
|
||||
14
tests/test_roa_verify.rs
Normal file
14
tests/test_roa_verify.rs
Normal file
@ -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");
|
||||
}
|
||||
|
||||
@ -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:#?}")
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user