增加aspa对象解析

This commit is contained in:
yuyr 2026-02-02 15:39:22 +08:00
parent bcd4829486
commit 56ae2ca4fc
44 changed files with 1608 additions and 0 deletions

View File

@ -182,6 +182,8 @@ RFC 引用RFC 5280 §4.2.2.1RFC 5280 §4.2.2.2RFC 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.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.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.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 §6RFC 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.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.2RPKI 约束见 RFC 6487 §4.8.8 | | `1.3.6.1.5.5.7.1.11` | X.509 v3 扩展subjectInfoAccess | RFC 5280 §4.2.2.2RPKI 约束见 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 | | `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 |

View File

@ -102,5 +102,9 @@ FileAndHash ::= SEQUENCE {
Manifest 使用“one-time-use EE certificate”进行签名验证规范对该 EE 证书的使用方式给出约束: Manifest 使用“one-time-use EE certificate”进行签名验证规范对该 EE 证书的使用方式给出约束:
- Manifest 相关 EE 证书应为 one-time-use每次新 manifest 生成新密钥对/新 EE。RFC 9286 §4Section 4 前导段落)。 - Manifest 相关 EE 证书应为 one-time-use每次新 manifest 生成新密钥对/新 EE。RFC 9286 §4Section 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.1RFC 3779。
- 若证书包含 **AS Identifier Delegation Extension**ASN delegation内容必须为 `inherit`(不得显式列 ASID/range。RFC 9286 §5.1RFC 3779。
- 另外按资源证书 profile资源证书 **MUST** 至少包含上述两类扩展之一(也可两者都有),且这些扩展 **MUST** 标记为 critical。RFC 6487 §2。
- 用于验证 manifest 的 EE 证书 **MUST** 具有与 `thisUpdate..nextUpdate` 区间一致的有效期,以避免 CRL 无谓增长。RFC 9286 §4.2.1manifestNumber 段落前的说明)。 - 用于验证 manifest 的 EE 证书 **MUST** 具有与 `thisUpdate..nextUpdate` 区间一致的有效期,以避免 CRL 无谓增长。RFC 9286 §4.2.1manifestNumber 段落前的说明)。
- 替换 manifest 时CA 必须撤销旧 manifest 对应 EE 证书;且若新 manifest 早于旧 manifest 的 nextUpdate 发行,则 CA **MUST** 同时发行新 CRL 撤销旧 manifest EE。RFC 9286 §4.2.1nextUpdate 段落末RFC 9286 §5.1(生成步骤)。 - 替换 manifest 时CA 必须撤销旧 manifest 对应 EE 证书;且若新 manifest 早于旧 manifest 的 nextUpdate 发行,则 CA **MUST** 同时发行新 CRL 撤销旧 manifest EE。RFC 9286 §4.2.1nextUpdate 段落末RFC 9286 §5.1(生成步骤)。

100
specs/08_aspa.md Normal file
View File

@ -0,0 +1,100 @@
# 08. ASPAAutonomous System Provider Authorization
## 8.1 对象定位
ASPAAutonomous 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-§3RFC 9589 §4。
- eContentType`id-ct-ASPA`OID `1.2.840.113549.1.9.16.1.49``draft-ietf-sidrops-aspa-profile-21` §2。
- eContentDER 编码 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 objectCMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType``eContent`payload的 ASN.1 定义见 `draft-ietf-sidrops-aspa-profile-21` §2-§3。
**eContentTypeOID**`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) }
```
**eContentASPA 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 §3RFC 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 §3RFC 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.2ASID 定义) |
| `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。

View File

@ -0,0 +1,87 @@
# 09. Ghostbusters RecordGBR
## 9.1 对象定位
Ghostbusters RecordGBR是一个可选的 RPKI Signed Object用于承载“联系人信息”人类可读的联系渠道以便在证书过期、CRL 失效、密钥轮换等事件中能够联系到维护者。RFC 6493 §1。
GBR 由 CMS 外壳 + vCard 载荷组成:
- 外壳RFC 6488更新RFC 9589
- 载荷payloadRFC 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 §6RFC 6493 §9.1。
- eContent一个 OCTET STRING其 octets 是 vCard 文本vCard 4.0,且受 RFC 6493 的 profile 约束。RFC 6493 §5RFC 6493 §6。
> 说明:与 ROA/MFT 这类“eContent 内部再 DER 解码为 ASN.1 结构”的对象不同GBR 的 eContent 语义上就是“vCard 文本内容本身”(由 Signed Object Template 的 `eContent OCTET STRING` 承载。RFC 6493 §6。
## 9.3 vCard profileRFC 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 §3RFC 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 §5RFC 6493 §7。
4) 通过校验后,将允许属性映射为 `GhostbustersVCard` 语义对象(见下)。
## 9.5 抽象数据模型(接口)
### 9.5.1 `GhostbustersObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 6493 §6RFC 6488 §3RFC 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 §5RFC 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 §5RFC 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
View 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)
}

View File

@ -8,3 +8,5 @@ mod oids;
pub mod oid; pub mod oid;
pub mod signed_object; pub mod signed_object;
pub mod manifest; pub mod manifest;
pub mod roa;
pub mod aspa;

View File

@ -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_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_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) // 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"; pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11";

466
src/data_model/roa.rs Normal file
View 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)
}

25
tests/test_aspa_decode.rs Normal file
View 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(_)));
}

View 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)));
}

View 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
View 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");
}

View 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
View 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(_)));
}

View 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 { .. }));
}

View 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(&not_covered));
}

14
tests/test_roa_verify.rs Normal file
View 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");
}

View File

@ -26,4 +26,5 @@ fn decode_manifest_signed_object_smoke() {
.iter() .iter()
.any(|u| u.starts_with("rsync://"))); .any(|u| u.starts_with("rsync://")));
assert_eq!(so.signed_data.signer_infos.len(), 1); assert_eq!(so.signed_data.signer_infos.len(), 1);
println!("{so:#?}")
} }