7 Commits

Author SHA1 Message Date
6e135b9d7a run tree from tal pass 2026-02-10 12:09:59 +08:00
afc31c02ab 串行验证通过 2026-02-09 19:35:54 +08:00
7be865d7f1 优化时间表示 2026-02-06 15:30:26 +08:00
a58e507f92 重构error code 2026-02-04 17:02:17 +08:00
cc9f3f21de 增加rc,完成所有模型的解析,优化error code 的RFC引用 2026-02-03 16:50:52 +08:00
56ae2ca4fc 增加aspa对象解析 2026-02-02 15:42:30 +08:00
bcd4829486 add signed object, manifest impl. add coverage script 2026-02-02 15:42:01 +08:00
163 changed files with 26592 additions and 1387 deletions

View File

@@ -4,12 +4,23 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
der-parser = "10.0.0" der-parser = { version = "10.0.0", features = ["serialize"] }
hex = "0.4.3" hex = "0.4.3"
base64 = "0.22.1"
sha2 = "0.10.8"
thiserror = "2.0.18" thiserror = "2.0.18"
time = "0.3.45" time = "0.3.45"
ring = "0.17.14"
x509-parser = { version = "0.18.0", features = ["verify"] } x509-parser = { version = "0.18.0", features = ["verify"] }
url = "2.5.8" url = "2.5.8"
asn1-rs = "0.7.1" serde = { version = "1.0.218", features = ["derive"] }
asn1-rs-derive = "0.6.0" serde_json = "1.0.140"
asn1 = "0.23.0" toml = "0.8.20"
rocksdb = { version = "0.22.0", default-features = false, features = ["lz4"] }
serde_cbor = "0.11.2"
roxmltree = "0.20.0"
uuid = { version = "1.7.0", features = ["v4"] }
reqwest = { version = "0.12.12", default-features = false, features = ["blocking", "rustls-tls"] }
[dev-dependencies]
tempfile = "3.16.0"

View File

@@ -9,3 +9,20 @@ cargo test
cargo test -- --nocapture cargo test -- --nocapture
``` ```
# 覆盖率(cargo-llvm-cov)
安装工具:
```
rustup component add llvm-tools-preview
cargo install cargo-llvm-cov --locked
```
统计行覆盖率并要求 >=90%:
```
./scripts/coverage.sh
# 或
cargo llvm-cov --fail-under-lines 90
```

2734
model.txt Normal file

File diff suppressed because it is too large Load Diff

80
scripts/coverage.sh Executable file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
set -euo pipefail
# Requires:
# rustup component add llvm-tools-preview
# cargo install cargo-llvm-cov --locked
run_out="$(mktemp)"
text_out="$(mktemp)"
html_out="$(mktemp)"
cleanup() {
rm -f "$run_out" "$text_out" "$html_out"
}
trap cleanup EXIT
# Preserve colored output even though we post-process output by running under a pseudo-TTY.
# We run tests only once, then generate both CLI text + HTML reports without rerunning tests.
set +e
cargo llvm-cov clean --workspace >/dev/null 2>&1
# 1) Run tests once to collect coverage data (no report).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov --no-report" "$run_out" >/dev/null 2>&1
run_status="$?"
# 2) CLI summary report + fail-under gate (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --fail-under-lines 90" "$text_out" >/dev/null 2>&1
text_status="$?"
# 3) HTML report (no test rerun).
script -q -e -c "CARGO_TERM_COLOR=always cargo llvm-cov report --html" "$html_out" >/dev/null 2>&1
html_status="$?"
set -e
strip_script_noise() {
tr -d '\r' | sed '/^Script \(started\|done\) on /d'
}
strip_ansi_for_parse() {
awk '
{
line = $0
gsub(/\033\[[0-9;]*[A-Za-z]/, "", line) # CSI escapes
gsub(/\033\([A-Za-z]/, "", line) # charset escapes (e.g., ESC(B)
gsub(/\r/, "", line)
print line
}
'
}
cat "$run_out" | strip_script_noise
cat "$text_out" | strip_script_noise
cat "$html_out" | strip_script_noise
cat "$run_out" | strip_ansi_for_parse | awk '
BEGIN {
passed=0; failed=0; ignored=0; measured=0; filtered=0;
}
/^test result: / {
if (match($0, /([0-9]+) passed; ([0-9]+) failed; ([0-9]+) ignored; ([0-9]+) measured; ([0-9]+) filtered out;/, m)) {
passed += m[1]; failed += m[2]; ignored += m[3]; measured += m[4]; filtered += m[5];
}
}
END {
executed = passed + failed;
total = passed + failed + ignored + measured;
printf("\nTEST SUMMARY (all suites): passed=%d failed=%d ignored=%d measured=%d filtered_out=%d executed=%d total=%d\n",
passed, failed, ignored, measured, filtered, executed, total);
}
'
echo
echo "HTML report: target/llvm-cov/html/index.html"
status="$text_status"
if [ "$run_status" -ne 0 ]; then status="$run_status"; fi
if [ "$html_status" -ne 0 ]; then status="$html_status"; fi
exit "$status"

View File

@@ -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.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 §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.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.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 | | `1.3.6.1.5.5.7.48.2` | AIA accessMethod: id-ad-caIssuers | RFC 5280 §4.2.2.1 |

View File

@@ -1,36 +1,94 @@
# 01. Trust Anchor Locator (TAL) # 01. TAL(Trust Anchor Locator)
## 1.1 对象定位 ## 1.1 对象定位
TAL是一个数据格式/配置文件,目的是告诉RP信任锚的公钥是什么,以及相关对象可以从哪里获取。
## 1.2 数据格式 (RFC 8630 §2.2) TAL(Trust Anchor Locator)用于向 RP 提供:
TAL是一个配置文件,格式定义如下:
``` 1) 可检索“当前 TA 证书”的一个或多个 URI;以及
The TAL is an ordered sequence of: 2) 该 TA 证书的 `subjectPublicKeyInfo`(SPKI)期望值(用于绑定/防替换)。
1. an optional comment section consisting of one or more lines each starting with the "#" character, followed by human-readable informational UTF-8 text, conforming to the restrictions defined
in Section 2 of [RFC5198], and ending with a line break, RFC 8630 §2;RFC 8630 §2.3。
2. a URI section that is comprised of one or more ordered lines, each containing a TA URI, and ending with a line break,
3. a line break, and ## 1.2 原始载体与编码
4. a subjectPublicKeyInfo [RFC5280] in DER format [X.509], encoded in base64 (see Section 4 of [RFC4648]). To avoid long lines,
line breaks MAY be inserted into the base64-encoded string. - 载体:文本文件(ASCII/UTF-8 兼容的行文本)。
Note that line breaks in this file can use either "<CRLF>" or "<LF>". - 行结束:允许 `CRLF` 或 `LF`。RFC 8630 §2.2。
- 结构:`[可选注释区] + URI 区 + 空行 + Base64(SPKI DER)`。RFC 8630 §2.2。
### 1.2.1 注释区
- 一行或多行,以 `#` 开头,后随人类可读 UTF-8 文本。RFC 8630 §2.2。
- 注释行文本需符合 RFC 5198 §2 的限制(RFC 8630 §2.2 引用)。
### 1.2.2 URI 区
- 一行或多行,每行一个 TA URI,按序排列。RFC 8630 §2.2。
- TA URI **MUST** 是 `rsync` 或 `https`。RFC 8630 §2.2。
### 1.2.3 空行分隔
- URI 区后必须有一个额外的换行(即空行),用于与 Base64 区分隔。RFC 8630 §2.2(第 3 点)。
### 1.2.4 SPKI(Base64)
- `subjectPublicKeyInfo` 以 DER 编码(ASN.1)后,再 Base64 编码表示。RFC 8630 §2.2(第 4 点)。
- 为避免长行,Base64 字符串中 **MAY** 插入换行。RFC 8630 §2.2。
- SPKI ASN.1 类型来自 X.509 / RFC 5280。RFC 8630 §2.2(第 4 点);RFC 5280 §4.1.2.7。
#### 1.2.4.1 `SubjectPublicKeyInfo` 的 ASN.1 定义(RFC 5280 §4.1)
TAL 中携带的是一个 X.509 `SubjectPublicKeyInfo` 的 DER 字节串(再 Base64)。其 ASN.1 定义如下:RFC 5280 §4.1。
```asn1
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
``` ```
## 1.3 抽象数据模型 其中 `algorithm`/`subjectPublicKey` 的取值受 RPKI 算法 profile 约束(例如 RSA 2048 + SHA-256 等;SKI/AKI 计算仍用 SHA-1)。RFC 5280 §4.1.2.7;RFC 7935 §2-§3.1;RFC 6487 §4.8.2-§4.8.3。
### 1.3.1 TAL ## 1.3 解析规则(语义层)
输入:`TalFileBytes: bytes`。
解析步骤:
1) 按 `LF` / `CRLF` 识别行。RFC 8630 §2.2。
2) 从文件开头读取所有以 `#` 开头的行,作为 `comments`(保留去掉 `#` 后的 UTF-8 文本或保留原始行均可,但需保持 UTF-8)。RFC 8630 §2.2。
3) 继续读取一行或多行非空行,作为 `ta_uris`(保持顺序)。RFC 8630 §2.2(第 2 点)。
4) 读取一个空行(必须存在)。RFC 8630 §2.2(第 3 点)。
5) 将剩余行拼接为 Base64 文本(移除行分隔),Base64 解码得到 `subject_public_key_info_der`。RFC 8630 §2.2(第 4 点)。
6) 可选:将 `subject_public_key_info_der` 解析为 X.509 `SubjectPublicKeyInfo` 结构(用于与 TA 证书比对)。RFC 8630 §2.3;RFC 5280 §4.1.2.7。
URI 解析与约束:
- `ta_uris[*]` 的 scheme **MUST** 为 `rsync` 或 `https`。RFC 8630 §2.2。
- 每个 `ta_uri` **MUST** 指向“单个对象”,且 **MUST NOT** 指向目录或集合。RFC 8630 §2.3。
## 1.4 抽象数据模型(接口)
### 1.4.1 `Tal`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 | | 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|-------------|-------------------------|--------------------------------------------|---------------| |---|---|---|---|---|
| uris | Vec<TalUri> | 指向TA的URI列表 | 允许rsync和https协议。 | RFC 8630 §2.1 | | `raw` | `bytes` | TAL 原始文件字节 | 原样保留(可选但建议) | RFC 8630 §2.2 |
| comment | Vec<String> | 注释(可选) | | RFC 8630 §2.2 | | `comments` | `list[Utf8Text]` | 注释行(按出现顺序) | 每行以 `#` 开头;文本为 UTF-8;内容限制见 RFC 5198 §2 | RFC 8630 §2.2 |
| spki_der | Vec<u8> | 原始的subjectPublicKeyInfo | x.509 SubjectPublicKeyInfo DER编码,再base64编码 | RFC 8630 §2.2 | | `ta_uris` | `list[Uri]` | TA 证书位置列表 | 至少 1 个;按序;每个 scheme 必须是 `rsync` 或 `https` | RFC 8630 §2.2 |
| `subject_public_key_info_der` | `DerBytes` | TA 证书 SPKI 的期望 DER | Base64 解码所得 DER;Base64 中可有换行 | RFC 8630 §2.2 |
### 1.4.2 `TaUri`(可选细化)
### 1.3.2 TalUri > 若你的实现希望对 URI 做更强类型化,可在 `Tal.ta_uris` 上进一步拆分为 `TaUri` 结构。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 | | 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------|--------|---------|---------|---------------| |---|---|---|---|---|
| Rsync | String | rsync地址 | | RFC 8630 §2.1 | | `uri` | `Uri` | 完整 URI 文本 | scheme 为 `rsync` 或 `https` | RFC 8630 §2.2 |
| Https | String | https地址 | | RFC 8630 §2.1 | | `scheme` | `enum` | `rsync` / `https` | 从 `uri` 解析 | RFC 8630 §2.2 |
## 1.5 字段级约束清单(实现对照)
- TAL 由(可选)注释区 + URI 区 + 空行 + Base64(SPKI DER) 组成。RFC 8630 §2.2。
- URI 区至少 1 行,每行一个 TA URI,顺序有意义。RFC 8630 §2.2。
- TA URI 仅允许 `rsync` 或 `https`。RFC 8630 §2.2。
- Base64 区允许插入换行。RFC 8630 §2.2。
- 每个 TA URI 必须引用单个对象,不能指向目录/集合。RFC 8630 §2.3。

View File

@@ -1,121 +0,0 @@
# 02. Trust Anchor (TA)
## 2.1 对象定位
TA是一个自签名的CA证书。
## 2.2 原始载体与编码
- 载体:X.509 certificates.
- 编码:DER(遵循 RFC 5280 的 certificate 结构与字段语义,但受限于RFC 8630 §2.3)
## 2.3 抽象数据类型
### 2.3.1 TA
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|-------------------|------------------|---------------|---------|---------------|
| name | String | 标识该TA,如apnic等 | | |
| cert_der | Vec<u8> | 原始DER内容 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
| publication_point | Uri | 获取该TA的URI | | |
### 2.3.2 ResourceSet
资源集合是来自RFC 3779的IP地址块(§2)和AS号段(§3),受约束于RFC 8630 §2.3
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|------|----------------|--------|-------------|---------------------------|
| ips | IpResourceSet | IP地址集合 | 不能是inherit | RFC 3779 §2和RFC 8630 §2.3 |
| asns | AsnResourceSet | ASN集合 | 不能是inherit | RFC 3779 §3和RFC 8630 §2.3 |
[//]: # ()
[//]: # (### 2.3.3 IpResourceSet)
[//]: # (包括IPv4和IPv6的前缀表示)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----|------------------------|----------|-------------|--------------|)
[//]: # (| v4 | PrefixSet<Ipv4Prefix> | IPv4前缀集合 | | RFC 3779 §2 |)
[//]: # (| v6 | PrefixSet<Ipv6Prefix> | IPv6前缀集合 | | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.4 AsnResourceSet)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-------|--------------------|-------|-------------|-------------|)
[//]: # (| range | RangeSet<AsnBlock> | ASN集合 | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.5 Ipv4Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|-----|-----|---------|-------------|)
[//]: # (| addr | u32 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-32 | RFC 3779 §2 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.6 Ipv6Prefix)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|------|------|-----|---------|-------------|)
[//]: # (| addr | u128 | 地址 | | RFC 3779 §2 |)
[//]: # (| len | u8 | 长度 | 0-128 | RFC 3779 §2 |)
[//]: # ()
[//]: # (### 2.3.7 AsnBlock)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|----------|----------|-------|---------|--------------|)
[//]: # (| asn | Asn | ASN | | RFC 3779 §3 |)
[//]: # (| asnRange | AsnRange | ASN范围 | | RFC 3779 §3 |)
[//]: # ()
[//]: # ()
[//]: # (### 2.3.8 Asn)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-----|---------|-------------|)
[//]: # (| asn | u32 | ASN | | RFC 3779 §3 |)
[//]: # ()
[//]: # (### 2.3.8 AsnRange)
[//]: # ()
[//]: # (| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |)
[//]: # (|-----|-----|-------|---------|--------------|)
[//]: # (| min | Asn | 最小ASN | | RFC 3779 §3 |)
[//]: # (| max | Asn | 最大ASN | | RFC 3779 §3 |)
# 2.4 TA校验流程(RFC 8630 §3)
1. 从TAL的URI列表中获取证书对象。(顺序访问,若前面失效,再访问后面的)
2. 验证证书格式,必须是当前、有效的自签名RPKI证书。
3. 验证公钥匹配。TAL中的SubjectPublicKeyInfo与下载证书的公钥一致。
4. 其他检查。
5. 更新本地存储库缓存。

View File

@@ -0,0 +1,88 @@
# 02. TA(Trust Anchor)自签名证书
## 2.1 对象定位
在 RP 侧,“信任锚(Trust Anchor, TA)”以一个**自签名 CA 资源证书**体现,其可获取位置与期望公钥由 TAL 提供。RFC 8630 §2.3。
本文件描述两个紧密相关的数据对象:
1) `TaCertificate`:TA 自签名资源证书本体(X.509 DER)
2) `TrustAnchor`:语义组合对象(`TAL` + `TaCertificate` 的绑定语义)
## 2.2 原始载体与编码
- 载体:X.509 证书(通常以 `.cer` 存放于仓库,但文件扩展名不作为语义依据)。
- 编码:DER。TA 证书必须符合 RPKI 资源证书 profile。RFC 8630 §2.3;RFC 6487 §4。
### 2.2.1 X.509 Certificate 的 ASN.1 定义(RFC 5280 §4.1;TA 与 RC 共享)
TA 证书与普通资源证书(RC)在编码层面都是 X.509 `Certificate`(DER)。其 ASN.1 定义如下:RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
```
其中 `tbsCertificate.extensions`(v3 扩展)是 RPKI 语义的主要承载处(IP/AS 资源扩展、SIA/AIA/CRLDP 等)。RFC 5280 §4.1;RPKI 对字段/扩展存在性与关键性约束见 RFC 6487 §4。
> 说明:更完整的 RC 编码层结构(包括 Extension 外层“extnValue 二次 DER 解码”的套娃方式)在 `03_resource_certificate_rc.md` 与 `00_common_types.md` 中给出。
## 2.3 TA 证书的 RPKI 语义约束(在 RC profile 基础上额外强调)
### 2.3.1 自签名与 profile
- TA URI 指向的对象 **MUST** 是一个**自签名 CA 证书**,并且 **MUST** 符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
- 自签名证书在 RC profile 下的通用差异(例如 CRLDP/AIA 的省略规则、AKI 的规则)见 RFC 6487。RFC 6487 §4.8.3;RFC 6487 §4.8.6;RFC 6487 §4.8.7。
### 2.3.2 INR(IP/AS 资源扩展)在 TA 上的额外约束
- TA 的 INR 扩展(IP/AS 资源扩展,RFC 3779)**MUST** 是非空资源集合。RFC 8630 §2.3。
- TA 的 INR 扩展 **MUST NOT** 使用 `inherit` 形式。RFC 8630 §2.3。
- 说明:一般 RC profile 允许 `inherit`。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3.5;RFC 3779 §3.2.3.3。
### 2.3.3 TAL ↔ TA 公钥绑定
- 用于验证 TA 的公钥(来自 TAL 中的 SPKI)**MUST** 与 TA 证书中的 `subjectPublicKeyInfo` 相同。RFC 8630 §2.3。
### 2.3.4 TA 稳定性语义(实现需建模为“约束/假设”,但不属于验证结果态)
- TA 公钥与 TAL 中公钥必须保持稳定(用于 RP 侧长期信任锚)。RFC 8630 §2.3。
### 2.3.5 TA 与 CRL/Manifest 的关系(语义)
- RFC 8630 指出:TA 为自签名证书,没有对应 CRL,且不会被 manifest 列出;TA 的获取/轮换由 TAL 控制。RFC 8630 §2.3。
> 注:这条更偏“发布/运维语义”,但对数据对象建模有影响:`TrustAnchor` 组合对象不应依赖 CRL/MFT 的存在。
## 2.4 抽象数据模型(接口)
### 2.4.1 `TaCertificate`
> 该对象在字段层面复用 `RC(CA)` 的语义模型(见 `03_resource_certificate_rc.md`),但增加 TA 特有约束。
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | TA 证书 DER | X.509 DER;证书 profile 约束见 RC 文档 | RFC 8630 §2.3;RFC 6487 §4 |
| `rc_ca` | `ResourceCaCertificate` | 以 RC(CA) 语义解析出的字段集合 | 必须满足“自签名 CA”分支约束;且 INR 必须非空且不允许 inherit | RFC 8630 §2.3;RFC 6487 §4;RFC 3779 §2/§3 |
### 2.4.2 `TrustAnchor`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `tal` | `Tal` | TAL 文件语义对象 | 见 `01_tal.md` | RFC 8630 §2.2 |
| `ta_certificate` | `TaCertificate` | TA 证书语义对象 | TA URI 指向的对象 | RFC 8630 §2.3 |
| `tal_spki_der` | `DerBytes` | 从 TAL 解析出的 SPKI DER | `tal.subject_public_key_info_der` | RFC 8630 §2.2 |
| `ta_spki_der` | `DerBytes` | 从 TA 证书抽取的 SPKI DER | `ta_certificate` 的 `subjectPublicKeyInfo` | RFC 8630 §2.3;RFC 5280 §4.1.2.7 |
**绑定约束(字段级)**
- `tal_spki_der` 必须与 `ta_spki_der` 完全相等(字节层面的 DER 等价)。RFC 8630 §2.3。
## 2.5 字段级约束清单(实现对照)
- TA URI 指向的对象必须是自签名 CA 证书,且符合 RPKI 证书 profile。RFC 8630 §2.3;RFC 6487 §4。
- TA 的 INR 扩展必须非空,且不得使用 inherit。RFC 8630 §2.3。
- TAL 中 SPKI 必须与 TA 证书的 `subjectPublicKeyInfo` 匹配。RFC 8630 §2.3。
- TA 不依赖 CRL/MFT(无对应 CRL,且不被 manifest 列出)。RFC 8630 §2.3。

View File

@@ -1,314 +0,0 @@
# 03. RC (Resource Certifications)
## 3.1 对象定位
RC是资源证书,包括CA和EE
## 3.2 原始载体与编码
- 载体:X.509 certificates.
- 编码:DER(遵循 RFC 5280 的 Certificate 结构与字段语义,但受 RPKI profile 限制)RFC 6487 §4
### 3.2.1 基本语法(RFC 5280 §4,RFC 6487 )
RC是遵循RFC5280定义的X.509Certificate语法(RFC 5280 §4),并且符合RFC 6487 §4的约束。只选取RFC 6487 §4章节列出来的字段。(Unless specifically noted as being OPTIONAL, all the fields listed
here MUST be present, and any other fields MUST NOT appear in a
conforming resource certificate.)
```
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING
}
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version MUST be v3,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
subject Name,
validity Validity,
subjectPublicKeyInfo SubjectPublicKeyInfo,
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
> 其中`Name` "a valid X.501 distinguished name"(RFC 6487 §4.4)
### 3.2.2 证书扩展字段 (RFC 6487 §4.8)
RC的证书扩展字段按照RFC 6487 §4.8的规定,有以下几个扩展:
- Basic Constraints
- Subject Key Identifier
- Authority Key Identifier
- Key Usage
- Extended Key Usage(CA证书,以及验证RPKI对象的EE证书不能出现该字段。非RPKI对象的EE可以出现EKU,但必须为non-critical)
- CRL Distribution Points
- Authority Information Access
- Subject Information Access
- SIA for CA Certificates
- SIA for EE Certificates
- Certificate Policies
- IP Resources
- AS Resources
```
# Basic Constraints
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE }
# Subject Key Identifier
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
KeyIdentifier ::= OCTET STRING
# Authority Key Identifier
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL }
# Key Usage
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
# Extended Key Usage
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
# CRL Distribution Points
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames }
## Authority Information Access
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
# AccessDescription
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
# CA 证书发布位置
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
# OCSP 服务地址
id-ad-ocsp OBJECT IDENTIFIER ::= { id-ad 1 }
# Subject Information Access
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::= SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
## Subject Information Access for CA (RFC 6487 §4.8.8.1)
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 }
必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。
必须存在一个accessMethod=id-ad-repiManifest, accessLocation=rsync URI,指向该CA的mft对象。
## Subject Information Access for EE (RFC 6487 §4.8.8.2)
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 }
必须存在一个accessMethod=id-ad-signedObject, accessLocation=rsyncURI
不允许其他的accessMethod
# Certificate Policies
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
UserNotice ::= SEQUENCE {
noticeRef NoticeReference OPTIONAL,
explicitText DisplayText OPTIONAL }
NoticeReference ::= SEQUENCE {
organization DisplayText,
noticeNumbers SEQUENCE OF INTEGER }
DisplayText ::= CHOICE {
ia5String IA5String (SIZE (1..200)),
visibleString VisibleString (SIZE (1..200)),
bmpString BMPString (SIZE (1..200)),
utf8String UTF8String (SIZE (1..200)) }
# IP Resources
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
# AS Resources
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
# 3.3 抽象数据结构
采用X509 Certificate + Resource + 约束校验的方式组合
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|----------|---------------------|----------|---------|---------------|
| cert_der | Vec<u8> | 证书原始数据 | | |
| cert | X509Certificate | 基础X509证书 | | RFC 5280 §4.1 |
| resource | ResourceSet | 资源集合 | | |
# 3.4 约束规则
## 3.4.1 Cert约束校验规则
RFC 6487中规定的证书的字段参见[3.2.1 ](#321-基本语法rfc-5280-4rfc-6487-)
-
| 字段 | 语义 | 约束/解析规则 | RFC 引用 |
|-----------|-------|----------------------------------------------|--------------|
| version | 证书版本 | 必须是v3(值为2) | RFC6487 §4.1 |
| serial | 证书编号 | 同一个CA签发的证书编号必须唯一 | RFC6487 §4.2 |
| validity | 证书有效期 | notBefore:时间不能早于证书的生成时间。若时间段大于上级证书的有效期,也是有效的 | RFC6487 §4.6 |
## 3.4.2 Cert Extentions中字段的约束校验规则
RFC 6487中规定的扩展字段参见[3.2.2 ](#322-证书扩展字段-rfc-6487-48)
| 字段 | critical | 语义 | 约束/解析规则 | RFC 引用 |
|----------------------------|----------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------|
| basicConstraints | Y | 证书类型 | CA证书:cA=TRUE; EE证书:cA=FALSE | RFC6487 §4.8.1 |
| subjectKeyIdentifier | N | 证书公钥 | SKI = SHA-1(DER-encoded SPKI bit string) | RFC6487 §4.8.2 |
| authorityKeyIdentifier | N | 父证书的公钥 | 字段只包含keyIdentifier,不能包含authorityCertIssuer和authorityCertSerialNumber;除了自签名CA外,其余证书必须出现。自签名CA若出现该字段,则等于SKI | RFC6487 §4.8.3 |
| keyUsage | Y | 证书公钥的用途权限 | CA证书:keyCertSign = TRUE, cRLSign = TRUE 其他都是FALSE。EE证书:digitalSignature = TRUE 其他都是FALSE | RFC6487 §4.8.4 |
| extendedKeyUsage | N | 扩展证书公钥的用途权限 | CA证书:不能出现EKU;验证 RPKI 对象的 EE 证书:不能出现EKU;非 RPKI 对象的 EE:可以出现EKU,但必须为non-critical. | RFC6487 §4.8.5 |
| cRLDistributionPoints | N | CRL的发布点位置 | 字段:distributionPoint,不能包含reasons、cRLIssuer。其中distributionPoint字段包含:fullName,不能包含nameRelativeToCRLIssuer。fullName的格式必须是URI。自签名证书禁止出现该字段。非自签名证书必须出现。一个CA只能有一个CRL。一个CRLDP只能包含一个distributionPoint。但一个distributionPoint字段中可以包含多于1个的URI,但必须包含rsync URI且必须是最新的。 | RFC6487 §4.8.6 |
| authorityInformationAccess | N | 签发者的发布点位置 | 除了自签名的CA,必须出现。自签名CA,禁止出现。推荐的URI访问方式是rsync,并且rsyncURI的话,必须指定accessMethod=id-ad-caIssuers | RFC6487 §4.8.7 |
| subjectInformationAccess | N | 发布点位置 | CA证书:必须存在。必须存在一个accessMethod=id-ad-caRepository,accessLocation=rsyncURI。必须存在一个accessMethod=id-ad-repiManifest,accessLocation=rsync URI,指向该CA的mft对象。 EE证书:必须存在。必须存在一个accessMethod=id-ad-signedObject,accessLocation=rsyncURI。不允许其他的accessMethod | RFC6487 §4.8.8 |
| certificatePolicies | Y | 证书策略 | 必须存在,并且只能存在一种策略:RFC 6484 — RPKI Certificate Policy (CP) | RFC6487 §4.8.9 |
| iPResources | Y | IP地址集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.10 |
| aSResources | Y | ASN集合 | 所有的RPKI证书中必须包含IP Resources或者ASResources,或者两者都包含。 | RFC6487 §4.8.11 |

View File

@@ -0,0 +1,460 @@
# 03. RC(Resource Certificate:资源证书,CA/EE)
## 3.1 对象定位
资源证书(RC)是 X.509 v3 证书,遵循 PKIX profile(RFC 5280),并受 RPKI profile 进一步约束。RFC 6487 §4。
RC 在 RPKI 中至少分为两类语义用途:
- `CA 证书`:签发下级证书/CRL,并在 SIA 中声明发布点与 manifest。RFC 6487 §4.8.8.1。
- `EE 证书`:用于验证某个 RPKI Signed Object(如 ROA/MFT),在 SIA 中指向被验证对象。RFC 6487 §4.8.8.2。
## 3.2 原始载体与编码
- 载体:X.509 证书。
- 编码:DER。RFC 6487 §4(“valid X.509 public key certificate consistent with RFC 5280” + RPKI 限制)。
### 3.2.1 X.509 v3 证书基本语法(ASN.1;RFC 5280 §4.1)
资源证书在编码层面是 RFC 5280 定义的 X.509 v3 `Certificate`(DER),其中 `tbsCertificate` 携带主体字段与扩展集合(`Extensions`)。RFC 5280 §4.1。
```asn1
Certificate ::= SEQUENCE {
tbsCertificate TBSCertificate,
signatureAlgorithm AlgorithmIdentifier,
signatureValue BIT STRING }
TBSCertificate ::= SEQUENCE {
version [0] EXPLICIT Version DEFAULT v1,
serialNumber CertificateSerialNumber,
signature AlgorithmIdentifier,
issuer Name,
validity Validity,
subject Name,
subjectPublicKeyInfo SubjectPublicKeyInfo,
issuerUniqueID [1] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
subjectUniqueID [2] IMPLICIT UniqueIdentifier OPTIONAL,
-- If present, version MUST be v2 or v3
extensions [3] EXPLICIT Extensions OPTIONAL
-- If present, version MUST be v3
}
Version ::= INTEGER { v1(0), v2(1), v3(2) }
CertificateSerialNumber ::= INTEGER
Validity ::= SEQUENCE {
notBefore Time,
notAfter Time }
Time ::= CHOICE {
utcTime UTCTime,
generalTime GeneralizedTime }
UniqueIdentifier ::= BIT STRING
SubjectPublicKeyInfo ::= SEQUENCE {
algorithm AlgorithmIdentifier,
subjectPublicKey BIT STRING }
Extensions ::= SEQUENCE SIZE (1..MAX) OF Extension
Extension ::= SEQUENCE {
extnID OBJECT IDENTIFIER,
critical BOOLEAN DEFAULT FALSE,
extnValue OCTET STRING
-- contains the DER encoding of an ASN.1 value
-- corresponding to the extension type identified
-- by extnID
}
```
### 3.2.2 AlgorithmIdentifier(ASN.1;RFC 5280 §4.1.1.2)
```asn1
AlgorithmIdentifier ::= SEQUENCE {
algorithm OBJECT IDENTIFIER,
parameters ANY DEFINED BY algorithm OPTIONAL }
```
### 3.2.3 Name / DN 结构(ASN.1;RFC 5280 §4.1.2.4)
```asn1
Name ::= CHOICE { -- only one possibility for now --
rdnSequence RDNSequence }
RDNSequence ::= SEQUENCE OF RelativeDistinguishedName
RelativeDistinguishedName ::=
SET SIZE (1..MAX) OF AttributeTypeAndValue
AttributeTypeAndValue ::= SEQUENCE {
type AttributeType,
value AttributeValue }
AttributeType ::= OBJECT IDENTIFIER
AttributeValue ::= ANY -- DEFINED BY AttributeType
DirectoryString ::= CHOICE {
teletexString TeletexString (SIZE (1..MAX)),
printableString PrintableString (SIZE (1..MAX)),
universalString UniversalString (SIZE (1..MAX)),
utf8String UTF8String (SIZE (1..MAX)),
bmpString BMPString (SIZE (1..MAX)) }
```
### 3.2.4 GeneralNames / GeneralName(ASN.1;RFC 5280 §4.2.1.6)
> 说明:RPKI 的 AIA/SIA/CRLDP 等扩展通常把 URI 编码在 `uniformResourceIdentifier [6] IA5String` 分支中。RFC 5280 §4.2.1.6。
```asn1
GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
GeneralName ::= CHOICE {
otherName [0] OtherName,
rfc822Name [1] IA5String,
dNSName [2] IA5String,
x400Address [3] ORAddress,
directoryName [4] Name,
ediPartyName [5] EDIPartyName,
uniformResourceIdentifier [6] IA5String,
iPAddress [7] OCTET STRING,
registeredID [8] OBJECT IDENTIFIER }
OtherName ::= SEQUENCE {
type-id OBJECT IDENTIFIER,
value [0] EXPLICIT ANY DEFINED BY type-id }
EDIPartyName ::= SEQUENCE {
nameAssigner [0] DirectoryString OPTIONAL,
partyName [1] DirectoryString }
```
### 3.2.5 AIA(Authority Information Access;ASN.1;RFC 5280 §4.2.2.1)
```asn1
id-pe-authorityInfoAccess OBJECT IDENTIFIER ::= { id-pe 1 }
AuthorityInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-caIssuers OBJECT IDENTIFIER ::= { id-ad 2 }
```
### 3.2.6 SIA(Subject Information Access;ASN.1;RFC 5280 §4.2.2.2)
```asn1
id-pe-subjectInfoAccess OBJECT IDENTIFIER ::= { id-pe 11 }
SubjectInfoAccessSyntax ::=
SEQUENCE SIZE (1..MAX) OF AccessDescription
AccessDescription ::= SEQUENCE {
accessMethod OBJECT IDENTIFIER,
accessLocation GeneralName }
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-caRepository OBJECT IDENTIFIER ::= { id-ad 5 }
```
### 3.2.7 RPKI 在 SIA 中新增/使用的 accessMethod OID(RFC 6487 §4.8.8.1 / §4.8.8.2;RFC 8182 §3.2)
> 说明:下列 OID 用于 `AccessDescription.accessMethod`,并放在 SIA 的 `extnValue` 内层结构中(其外层 extnID 仍为 SIA:`id-pe-subjectInfoAccess`)。RFC 6487 §4.8.8;RFC 8182 §3.2。
```asn1
id-ad OBJECT IDENTIFIER ::= { id-pkix 48 }
id-ad-rpkiManifest OBJECT IDENTIFIER ::= { id-ad 10 } -- 1.3.6.1.5.5.7.48.10
id-ad-signedObject OBJECT IDENTIFIER ::= { id-ad 11 } -- 1.3.6.1.5.5.7.48.11
id-ad-rpkiNotify OBJECT IDENTIFIER ::= { id-ad 13 } -- 1.3.6.1.5.5.7.48.13
```
### 3.2.8 CRLDistributionPoints(CRLDP;ASN.1;RFC 5280 §4.2.1.13)
```asn1
id-ce-cRLDistributionPoints OBJECT IDENTIFIER ::= { id-ce 31 }
CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
DistributionPoint ::= SEQUENCE {
distributionPoint [0] DistributionPointName OPTIONAL,
reasons [1] ReasonFlags OPTIONAL,
cRLIssuer [2] GeneralNames OPTIONAL }
DistributionPointName ::= CHOICE {
fullName [0] GeneralNames,
nameRelativeToCRLIssuer [1] RelativeDistinguishedName }
ReasonFlags ::= BIT STRING {
unused (0),
keyCompromise (1),
cACompromise (2),
affiliationChanged (3),
superseded (4),
cessationOfOperation (5),
certificateHold (6),
privilegeWithdrawn (7),
aACompromise (8) }
```
### 3.2.9 Certificate Policies(ASN.1;RFC 5280 §4.2.1.4)
```asn1
id-ce-certificatePolicies OBJECT IDENTIFIER ::= { id-ce 32 }
anyPolicy OBJECT IDENTIFIER ::= { id-ce-certificatePolicies 0 }
certificatePolicies ::= SEQUENCE SIZE (1..MAX) OF PolicyInformation
PolicyInformation ::= SEQUENCE {
policyIdentifier CertPolicyId,
policyQualifiers SEQUENCE SIZE (1..MAX) OF
PolicyQualifierInfo OPTIONAL }
CertPolicyId ::= OBJECT IDENTIFIER
PolicyQualifierInfo ::= SEQUENCE {
policyQualifierId PolicyQualifierId,
qualifier ANY DEFINED BY policyQualifierId }
-- policyQualifierIds for Internet policy qualifiers
id-qt OBJECT IDENTIFIER ::= { id-pkix 2 }
id-qt-cps OBJECT IDENTIFIER ::= { id-qt 1 }
id-qt-unotice OBJECT IDENTIFIER ::= { id-qt 2 }
PolicyQualifierId ::= OBJECT IDENTIFIER ( id-qt-cps | id-qt-unotice )
Qualifier ::= CHOICE {
cPSuri CPSuri,
userNotice UserNotice }
CPSuri ::= IA5String
```
### 3.2.10 RFC 3779 IP/AS 资源扩展(ASN.1;RFC 3779 §2.2.1-§2.2.3;RFC 3779 §3.2.1-§3.2.3)
> 说明:RFC 3779 给出两个扩展的 OID 与 ASN.1 语法;它们作为 X.509 v3 扩展出现在 `extensions` 中(外层 extnID 为下列 OID)。RPKI profile 进一步约束 criticality/SAFI/RDI 等,见 RFC 6487 §4.8.10-§4.8.11。
```asn1
-- IP Address Delegation Extension
id-pe-ipAddrBlocks OBJECT IDENTIFIER ::= { id-pe 7 }
IPAddrBlocks ::= SEQUENCE OF IPAddressFamily
IPAddressFamily ::= SEQUENCE { -- AFI & optional SAFI --
addressFamily OCTET STRING (SIZE (2..3)),
ipAddressChoice IPAddressChoice }
IPAddressChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
addressesOrRanges SEQUENCE OF IPAddressOrRange }
IPAddressOrRange ::= CHOICE {
addressPrefix IPAddress,
addressRange IPAddressRange }
IPAddressRange ::= SEQUENCE {
min IPAddress,
max IPAddress }
IPAddress ::= BIT STRING
-- Autonomous System Identifier Delegation Extension
id-pe-autonomousSysIds OBJECT IDENTIFIER ::= { id-pe 8 }
ASIdentifiers ::= SEQUENCE {
asnum [0] EXPLICIT ASIdentifierChoice OPTIONAL,
rdi [1] EXPLICIT ASIdentifierChoice OPTIONAL}
ASIdentifierChoice ::= CHOICE {
inherit NULL, -- inherit from issuer --
asIdsOrRanges SEQUENCE OF ASIdOrRange }
ASIdOrRange ::= CHOICE {
id ASId,
range ASRange }
ASRange ::= SEQUENCE {
min ASId,
max ASId }
ASId ::= INTEGER
```
### 3.2.11 其它 RPKI profile 相关扩展的 ASN.1 定义(RFC 5280 §4.2.1.1-§4.2.1.3;RFC 5280 §4.2.1.9;RFC 5280 §4.2.1.12)
> 说明:这些是 RPKI 资源证书 profile(RFC 6487 §4.8)所引用的通用 PKIX 扩展语法。RPKI 对其“必须/禁止/criticality/字段允许性”有额外限制(见本文件 3.3/3.4),但编码层的 ASN.1 类型来自 RFC 5280。
```asn1
id-ce-authorityKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 35 }
AuthorityKeyIdentifier ::= SEQUENCE {
keyIdentifier [0] KeyIdentifier OPTIONAL,
authorityCertIssuer [1] GeneralNames OPTIONAL,
authorityCertSerialNumber [2] CertificateSerialNumber OPTIONAL }
KeyIdentifier ::= OCTET STRING
id-ce-subjectKeyIdentifier OBJECT IDENTIFIER ::= { id-ce 14 }
SubjectKeyIdentifier ::= KeyIdentifier
id-ce-keyUsage OBJECT IDENTIFIER ::= { id-ce 15 }
KeyUsage ::= BIT STRING {
digitalSignature (0),
nonRepudiation (1), -- recent editions of X.509 have
-- renamed this bit to contentCommitment
keyEncipherment (2),
dataEncipherment (3),
keyAgreement (4),
keyCertSign (5),
cRLSign (6),
encipherOnly (7),
decipherOnly (8) }
id-ce-basicConstraints OBJECT IDENTIFIER ::= { id-ce 19 }
BasicConstraints ::= SEQUENCE {
cA BOOLEAN DEFAULT FALSE,
pathLenConstraint INTEGER (0..MAX) OPTIONAL }
id-ce-extKeyUsage OBJECT IDENTIFIER ::= { id-ce 37 }
ExtKeyUsageSyntax ::= SEQUENCE SIZE (1..MAX) OF KeyPurposeId
KeyPurposeId ::= OBJECT IDENTIFIER
```
## 3.3 抽象数据模型(接口)
> 说明:本模型面向“语义化解析产物”。实现可保留 `raw_der` 作为可追溯入口。
### 3.3.1 顶层联合类型:`ResourceCertificate`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | 证书 DER | 原样保留(建议) | RFC 6487 §4 |
| `tbs` | `RpkixTbsCertificate` | 证书语义字段(见下) | 仅允许 RFC 6487 允许的字段/扩展;其他字段 MUST NOT 出现 | RFC 6487 §4 |
| `kind` | `enum { ca, ee }` | 语义分类 | 来自 BasicConstraints + 用途约束 | RFC 6487 §4.8.1;RFC 6487 §4.8.8 |
### 3.3.1.1 派生类型(用于字段类型标注)
为避免在其它对象文档里反复写“`ResourceCertificate` 且 `kind==...`”,这里定义两个派生/别名类型:
- `ResourceCaCertificate`:`ResourceCertificate` 且 `kind == ca`
- `ResourceEeCertificate`:`ResourceCertificate` 且 `kind == ee`
这些派生类型不引入新字段,只是对 `ResourceCertificate.kind` 的约束化视图。
### 3.3.2 `RpkixTbsCertificate`(语义字段集合)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | X.509 版本 | MUST 为 v3(字段值为 2) | RFC 6487 §4.1 |
| `serial_number` | `int` | 序列号 | 正整数;对每 CA 签发唯一 | RFC 6487 §4.2 |
| `signature_algorithm` | `Oid` | 证书签名算法 | 必须为 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6487 §4.3;RFC 7935 §2(引用 RFC 4055) |
| `issuer_dn` | `RpkixDistinguishedName` | 颁发者 DN | 必含 1 个 CommonName;可含 1 个 serialNumber;CN 必须 PrintableString | RFC 6487 §4.4 |
| `subject_dn` | `RpkixDistinguishedName` | 主体 DN | 同 issuer 约束;且对同一 issuer 下“实体+公钥”唯一 | RFC 6487 §4.5 |
| `validity_not_before` | `UtcTime` | 有效期起 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.1;RFC 5280 §4.1.2.5 |
| `validity_not_after` | `UtcTime` | 有效期止 | X.509 `Time`(UTCTime/GeneralizedTime)解析为 UTC 时间点 | RFC 6487 §4.6.2;RFC 5280 §4.1.2.5 |
| `subject_public_key_info` | `DerBytes` | SPKI DER | 算法 profile 指定 | RFC 6487 §4.7;RFC 7935 §3.1 |
| `extensions` | `RpkixExtensions` | 扩展集合 | 见下表;criticality/存在性/内容受约束 | RFC 6487 §4.8 |
### 3.3.3 `RpkixDistinguishedName`(RPKI profile 下的 DN 语义)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `common_name` | `string` | CommonName (CN) | MUST 存在且仅 1 个;类型为 PrintableString | RFC 6487 §4.4;RFC 6487 §4.5 |
| `serial_number` | `optional[string]` | serialNumber | MAY 存在且仅 1 个 | RFC 6487 §4.4;RFC 6487 §4.5 |
| `rfc4514` | `string` | DN 的 RFC4514 字符串表示 | 便于日志/索引(实现自选) | RFC 6487 §4.5(引用 RFC4514) |
### 3.3.4 `RpkixExtensions`(核心扩展语义)
> 表中 “存在性/criticality” 指 RPKI profile 下对该扩展的要求;实现应能区分 “字段缺失” 与 “字段存在但不符合约束”。
| 字段 | 类型 | 语义 | 存在性/criticality 与内容约束 | RFC 引用 |
|---|---|---|---|---|
| `basic_constraints` | `optional[BasicConstraints]` | CA 标志 | **extnID=`2.5.29.19`**;CA 证书:MUST present & critical;EE:MUST NOT present;pathLen MUST NOT present | RFC 6487 §4.8.1;RFC 5280 §4.2.1.9 |
| `subject_key_identifier` | `bytes` | SKI | **extnID=`2.5.29.14`**;MUST present & non-critical;值为 subjectPublicKey 的 DER bit string 的 SHA-1 哈希 | RFC 6487 §4.8.2(引用 RFC 5280 §4.2.1.2) |
| `authority_key_identifier` | `optional[AuthorityKeyIdentifier]` | AKI | **extnID=`2.5.29.35`**;自签名:MAY present 且可等于 SKI;非自签名:MUST present;authorityCertIssuer/authorityCertSerialNumber MUST NOT present;non-critical | RFC 6487 §4.8.3;RFC 5280 §4.2.1.1 |
| `key_usage` | `KeyUsage` | KeyUsage | **extnID=`2.5.29.15`**;MUST present & critical;CA:仅 `keyCertSign` 与 `cRLSign` 为 TRUE;EE:仅 `digitalSignature` 为 TRUE | RFC 6487 §4.8.4;RFC 5280 §4.2.1.3 |
| `extended_key_usage` | `optional[OidSet]` | EKU | **extnID=`2.5.29.37`**;CA:MUST NOT appear;用于验证 RPKI 对象的 EE:MUST NOT appear;若出现不得标 critical | RFC 6487 §4.8.5;RFC 5280 §4.2.1.12 |
| `crl_distribution_points` | `optional[CrlDistributionPoints]` | CRLDP | **extnID=`2.5.29.31`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;仅 1 个 DistributionPoint;fullName URI;必须包含至少 1 个 `rsync://` | RFC 6487 §4.8.6;RFC 5280 §4.2.1.13 |
| `authority_info_access` | `optional[AuthorityInfoAccess]` | AIA | **extnID=`1.3.6.1.5.5.7.1.1`**;自签名:MUST be omitted;非自签名:MUST present & non-critical;必须含 accessMethod `id-ad-caIssuers`(**`1.3.6.1.5.5.7.48.2`**) 的 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
| `subject_info_access_ca` | `optional[SubjectInfoAccessCa]` | SIA(CA) | **extnID=`1.3.6.1.5.5.7.1.11`**;CA:MUST present & non-critical;必须含 accessMethod `id-ad-caRepository`(**`1.3.6.1.5.5.7.48.5`**)(`rsync://` 目录 URI)与 `id-ad-rpkiManifest`(**`1.3.6.1.5.5.7.48.10`**)(`rsync://` 对象 URI);若 CA 使用 RRDP,还会包含 `id-ad-rpkiNotify`(**`1.3.6.1.5.5.7.48.13`**)(HTTPS Notification URI) | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2;RFC 8182 §3.2 |
| `subject_info_access_ee` | `optional[SubjectInfoAccessEe]` | SIA(EE) | **extnID=`1.3.6.1.5.5.7.1.11`**;EE:MUST present & non-critical;必须含 accessMethod `id-ad-signedObject`(**`1.3.6.1.5.5.7.48.11`**);URI **MUST include** `rsync://`;EE 的 SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
| `certificate_policies` | `CertificatePolicies` | 证书策略 | **extnID=`2.5.29.32`**;MUST present & critical;恰好 1 个 policy;并允许 0 或 1 个 CPS qualifier(若存在其 id 必为 `id-qt-cps`(**`1.3.6.1.5.5.7.2.1`**)) | RFC 6487 §4.8.9;RFC 7318 §2;RFC 5280 §4.2.1.4 |
| `ip_resources` | `optional[IpResourceSet]` | IP 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.7`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;在公用互联网场景 SAFI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.10;RFC 3779 §2.2.1;RFC 3779 §2.2.2 |
| `as_resources` | `optional[AsResourceSet]` | AS 资源扩展 | **extnID=`1.3.6.1.5.5.7.1.8`**;IP/AS 两者至少其一 MUST present;若 present MUST be critical;内容为 RFC 3779 语义;RDI MUST NOT 使用;且必须为非空或 inherit | RFC 6487 §4.8.11;RFC 3779 §3.2.1;RFC 3779 §3.2.2 |
### 3.3.5 结构化子类型(建议)
#### `BasicConstraints`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca` | `bool` | 是否 CA | 由 issuer 决定;在 CA 证书中该扩展必须存在 | RFC 6487 §4.8.1 |
| `path_len_constraint` | `None` | pathLenConstraint | MUST NOT present(RPKI profile 不使用) | RFC 6487 §4.8.1 |
#### `AuthorityKeyIdentifier`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `key_identifier` | `bytes` | AKI.keyIdentifier | 使用 issuer 公钥的 SHA-1 哈希(按 RFC 5280 的定义) | RFC 6487 §4.8.3(引用 RFC 5280 §4.2.1.1) |
| `authority_cert_issuer` | `None` | authorityCertIssuer | MUST NOT present | RFC 6487 §4.8.3 |
| `authority_cert_serial_number` | `None` | authorityCertSerialNumber | MUST NOT present | RFC 6487 §4.8.3 |
#### `CrlDistributionPoints`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `distribution_point_uris` | `list[Uri]` | CRL 位置列表 | 仅 1 个 DistributionPoint;必须包含至少 1 个 `rsync://` URI 指向该 issuer 最新 CRL;可含其它 URI | RFC 6487 §4.8.6 |
#### `AuthorityInfoAccess`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca_issuers_uris` | `list[Uri]` | 上级 CA 证书位置 | accessMethod=`id-ad-caIssuers`(`1.3.6.1.5.5.7.48.2`);必含 `rsync://` URI;可含同对象其它 URI | RFC 6487 §4.8.7;RFC 5280 §4.2.2.1 |
#### `SubjectInfoAccessCa`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `ca_repository_uris` | `list[Uri]` | CA 发布点目录(repository publication point) | accessMethod=`id-ad-caRepository`(`1.3.6.1.5.5.7.48.5`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一目录”的替代访问方式;顺序表示 CA 偏好 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
| `rpki_manifest_uris` | `list[Uri]` | 当前 manifest 对象 URI | accessMethod=`id-ad-rpkiManifest`(`1.3.6.1.5.5.7.48.10`);至少 1 个;必须包含 `rsync://`;也可包含其它机制(例如 `https://`)作为“同一对象”的替代访问方式 | RFC 6487 §4.8.8.1;RFC 5280 §4.2.2.2 |
| `rpki_notify_uris` | `optional[list[Uri]]` | RRDP Notification(Update Notification File)URI | accessMethod=`id-ad-rpkiNotify`(`1.3.6.1.5.5.7.48.13`);若存在则 accessLocation MUST 为 `https://` URI,指向 RRDP Notification 文件 | RFC 8182 §3.2;RFC 5280 §4.2.2.2 |
#### `SubjectInfoAccessEe`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `signed_object_uris` | `list[Uri]` | 被 EE 证书验证的签名对象位置 | accessMethod=`id-ad-signedObject`(`1.3.6.1.5.5.7.48.11`);必须包含 `rsync://`;其它 URI 可作为同对象替代机制;EE SIA 不允许其它 AccessMethods | RFC 6487 §4.8.8.2;RFC 5280 §4.2.2.2 |
#### `CertificatePolicies`
| 字段 | 类型 | 语义 | 约束 | RFC 引用 |
|---|---|---|---|---|
| `policy_oid` | `Oid` | 唯一 policy OID | 恰好 1 个 policy;RPKI CP 分配的 OID 为 `id-cp-ipAddr-asNumber`(`1.3.6.1.5.5.7.14.2`) | RFC 6487 §4.8.9;RFC 6484 §1.2 |
| `cps_uri` | `optional[Uri]` | CPS policy qualifier URI | MAY 存在且最多 1 个;若存在其 `policyQualifierId` 必为 `id-qt-cps`;对该 URI 不施加处理要求 | RFC 7318 §2;RFC 5280 §4.2.1.4 |
## 3.4 字段级约束清单(实现对照)
- 仅允许 RFC 6487 §4 指定的字段/扩展;未列出字段 MUST NOT 出现。RFC 6487 §4。
- 证书版本必须为 v3。RFC 6487 §4.1。
- CA/EE 在 BasicConstraints 与 SIA 的约束不同。RFC 6487 §4.8.1;RFC 6487 §4.8.8.1;RFC 6487 §4.8.8.2。
- KeyUsage:CA 仅 `keyCertSign`/`cRLSign`;EE 仅 `digitalSignature`。RFC 6487 §4.8.4。
- CRLDP/AIA:自签名必须省略;非自签名必须存在并包含 `rsync://`。RFC 6487 §4.8.6;RFC 6487 §4.8.7。
- IP/AS 资源扩展:两者至少其一存在;若存在必须 critical;语义来自 RFC 3779;在公用互联网场景 SAFI 与 RDI 均不得使用。RFC 6487 §4.8.10;RFC 6487 §4.8.11;RFC 3779 §2.2.3;RFC 3779 §3.2.3。

View File

@@ -0,0 +1,158 @@
# 05. RPKI Signed Object(CMS SignedData 外壳)
## 5.1 对象定位
ROA、Manifest 等都属于 “RPKI Signed Object”,其外壳是 CMS SignedData,并受 RFC 6488 的 profile 约束;RFC 9589 进一步更新了 `signedAttrs` 的要求。RFC 6488 §2-§4;RFC 9589 §4。
本文件描述**通用外壳模型**(eContentType/eContent 由具体对象文档给出)。
## 5.2 原始载体与编码
- 载体:CMS `ContentInfo`,其中 `contentType` 为 SignedData。RFC 6488 §2;RFC 6488 §3(1a)。
- 编码:DER。RFC 6488 §2;RFC 6488 §3(1l)。
### 5.2.1 CMS 外壳:ContentInfo(ASN.1;RFC 5652 §3)
```asn1
ContentInfo ::= SEQUENCE {
contentType ContentType,
content [0] EXPLICIT ANY DEFINED BY contentType }
ContentType ::= OBJECT IDENTIFIER
```
### 5.2.2 CMS 外壳:SignedData(ASN.1;RFC 5652 §5.1)
```asn1
id-signedData OBJECT IDENTIFIER ::= { iso(1) member-body(2)
us(840) rsadsi(113549) pkcs(1) pkcs7(7) 2 }
SignedData ::= SEQUENCE {
version CMSVersion,
digestAlgorithms DigestAlgorithmIdentifiers,
encapContentInfo EncapsulatedContentInfo,
certificates [0] IMPLICIT CertificateSet OPTIONAL,
crls [1] IMPLICIT RevocationInfoChoices OPTIONAL,
signerInfos SignerInfos }
DigestAlgorithmIdentifiers ::= SET OF DigestAlgorithmIdentifier
SignerInfos ::= SET OF SignerInfo
```
### 5.2.3 CMS 外壳:EncapsulatedContentInfo(ASN.1;RFC 5652 §5.2)
```asn1
EncapsulatedContentInfo ::= SEQUENCE {
eContentType ContentType,
eContent [0] EXPLICIT OCTET STRING OPTIONAL }
ContentType ::= OBJECT IDENTIFIER
```
> 注:CMS 允许 `eContent` 不一定 DER 编码(RFC 5652 §5.2);但 RPKI signed object profile 要求**整个对象 DER 编码**(RFC 6488 §2;RFC 6488 §3(1l)),且 eContent(payload)由对象规范定义并通常为 DER(如 ROA:RFC 9582 §4;Manifest:RFC 9286 §4.2)。
### 5.2.4 CMS 外壳:SignerInfo 与 Attribute(ASN.1;RFC 5652 §5.3)
```asn1
SignerInfo ::= SEQUENCE {
version CMSVersion,
sid SignerIdentifier,
digestAlgorithm DigestAlgorithmIdentifier,
signedAttrs [0] IMPLICIT SignedAttributes OPTIONAL,
signatureAlgorithm SignatureAlgorithmIdentifier,
signature SignatureValue,
unsignedAttrs [1] IMPLICIT UnsignedAttributes OPTIONAL }
SignerIdentifier ::= CHOICE {
issuerAndSerialNumber IssuerAndSerialNumber,
subjectKeyIdentifier [0] SubjectKeyIdentifier }
SignedAttributes ::= SET SIZE (1..MAX) OF Attribute
UnsignedAttributes ::= SET SIZE (1..MAX) OF Attribute
Attribute ::= SEQUENCE {
attrType OBJECT IDENTIFIER,
attrValues SET OF AttributeValue }
AttributeValue ::= ANY
SignatureValue ::= OCTET STRING
```
### 5.2.5 RPKI 对 CMS 外壳字段的 profile 约束(RFC 6488 §2.1;RFC 6488 §3;更新:RFC 9589 §4)
> 说明:上面是 CMS 的通用 ASN.1;RPKI 进一步约束取值与允许出现的字段(例如 SignedData.version 必须为 3、crls 必须省略、signedAttrs 的内容限制等)。RFC 6488 §2-§3;RFC 9589 §4。
### 5.2.6 signedAttrs 中允许的属性与 attrType OID(RFC 6488 §2.1.6.4.1-§2.1.6.4.2;更新:RFC 9589 §4)
RPKI signed object profile 对 `SignerInfo.signedAttrs` 的 Attribute 集合施加限制(除 ASN.1 结构外,还包含“只允许哪些 attrType”的编码约束):
- `content-type`:attrType OID `1.2.840.113549.1.9.3`。RFC 6488 §2.1.6.4.1。
- `message-digest`:attrType OID `1.2.840.113549.1.9.4`。RFC 6488 §2.1.6.4.2。
- `signing-time`:attrType OID `1.2.840.113549.1.9.5`。RFC 9589 §4(更新 RFC 6488 的相关要求)。
并且:
- 每种属性在集合中只能出现一次;且 `attrValues` 虽然语法是 `SET OF`,但在 RPKI 中必须只含一个值。RFC 6488 §2.1.6.4。
## 5.3 抽象数据模型(接口)
### 5.3.1 `RpkiSignedObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `raw_der` | `DerBytes` | CMS DER | 原样保留(建议) | RFC 6488 §2;RFC 6488 §3(1l) |
| `content_info_content_type` | `Oid` | ContentInfo.contentType | MUST 为 SignedData:`1.2.840.113549.1.7.2` | RFC 6488 §3(1a) |
| `signed_data` | `SignedDataProfiled` | SignedData 语义字段 | 见下 | RFC 6488 §2.1;RFC 6488 §3 |
### 5.3.2 `SignedDataProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | SignedData.version | MUST 为 3 | RFC 6488 §3(1b);RFC 6488 §2.1.1 |
| `digest_algorithms` | `list[Oid]` | SignedData.digestAlgorithms | MUST contain exactly one digest algorithm,且必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §2.1.2;RFC 7935 §2(引用 RFC 5754) |
| `encap_content_info` | `EncapsulatedContentInfo` | EncapsulatedContentInfo | 见下;eContentType 由具体对象定义 | RFC 6488 §2.1.3 |
| `certificates` | `list[ResourceEeCertificate]` | SignedData.certificates | MUST present;且仅包含 1 个 EE 证书;该 EE 的 SKI 必须匹配 SignerInfo.sid | RFC 6488 §3(1c) |
| `crls` | `None` | SignedData.crls | MUST be omitted | RFC 6488 §3(1d) |
| `signer_infos` | `list[SignerInfoProfiled]` | SignedData.signerInfos | MUST contain exactly one SignerInfo | RFC 6488 §2.1;RFC 6488 §2.1(SignerInfos 约束段落) |
### 5.3.3 `EncapsulatedContentInfo`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `econtent_type` | `Oid` | eContentType | MUST 与 signedAttrs.content-type 的 attrValues 一致;具体值由对象定义(如 ROA/MFT) | RFC 6488 §3(1h);RFC 6488 §2.1.3.1 |
| `econtent_der` | `DerBytes` | eContent(对象 payload) | DER 编码的对象特定 ASN.1(ROA/MFT 文档定义);在 CMS 中以 OCTET STRING 承载 | RFC 6488 §2.1.3;RFC 9286 §4.2;RFC 9582 §4 |
### 5.3.4 `SignerInfoProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | SignerInfo.version | MUST 为 3 | RFC 6488 §3(1e) |
| `sid_ski` | `bytes` | sid(SubjectKeyIdentifier) | 必须与 EE 证书的 SKI 匹配 | RFC 6488 §3(1c) |
| `digest_algorithm` | `Oid` | SignerInfo.digestAlgorithm | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 6488 §3(1j);RFC 7935 §2(引用 RFC 5754) |
| `signature_algorithm` | `Oid` | SignerInfo.signatureAlgorithm | 生成时 MUST 为 `rsaEncryption`(`1.2.840.113549.1.1.1`);验证时实现必须接受 `rsaEncryption` 或 `sha256WithRSAEncryption`(`1.2.840.113549.1.1.11`) | RFC 6488 §3(1k);RFC 7935 §2 |
| `signed_attrs` | `SignedAttrsProfiled` | signedAttrs | MUST present;仅允许特定 3 个属性 | RFC 9589 §4(更新 RFC 6488 §3(1f)/(1g)) |
| `unsigned_attrs` | `None` | unsignedAttrs | MUST be omitted | RFC 6488 §3(1i) |
### 5.3.5 `SignedAttrsProfiled`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `content_type` | `Oid` | signedAttrs.content-type | attrType=`1.2.840.113549.1.9.3`;MUST present;attrValues 等于 eContentType | RFC 9589 §4;RFC 6488 §3(1h) |
| `message_digest` | `bytes` | signedAttrs.message-digest | attrType=`1.2.840.113549.1.9.4`;MUST present | RFC 9589 §4(更新 RFC 6488 §3(1f)) |
| `signing_time` | `UtcTime` | signedAttrs.signing-time | attrType=`1.2.840.113549.1.9.5`;MUST present(时间值正确性不用于安全假设) | RFC 9589 §4;RFC 9589 §5 |
| `other_attrs` | `None` | 其它 signed attributes | MUST NOT be included(binary-signing-time 也不允许) | RFC 9589 §4 |
## 5.4 字段级约束清单(实现对照)
- ContentInfo.contentType 必须为 SignedData(OID `1.2.840.113549.1.7.2`)。RFC 6488 §3(1a)。
- SignedData.version 必须为 3,且 SignerInfos 仅允许 1 个 SignerInfo。RFC 6488 §3(1b);RFC 6488 §2.1。
- SignedData.certificates 必须存在且仅含 1 个 EE 证书;该证书 SKI 必须匹配 SignerInfo.sid。RFC 6488 §3(1c)。
- SignedData.crls 必须省略。RFC 6488 §3(1d)。
- signedAttrs 必须存在,且仅允许 content-type/message-digest/signing-time;其它全部禁止。RFC 9589 §4。
- eContentType 必须与 content-type attribute 一致。RFC 6488 §3(1h)。
- unsignedAttrs 必须省略。RFC 6488 §3(1i)。
- digest/signature 算法必须符合算法 profile。RFC 6488 §3(1j)/(1k);RFC 7935 §2。
- 整个对象必须 DER 编码。RFC 6488 §3(1l)。

110
specs/06_manifest_mft.md Normal file
View File

@@ -0,0 +1,110 @@
# 06. Manifest(MFT)
## 6.1 对象定位
Manifest 是 CA 发布点内对象的“清单”(文件名 + hash),用于 RP 侧检测删除/替换/回放等不一致情况。RFC 9286 §1;RFC 9286 §6。
Manifest 是一种 RPKI Signed Object:CMS 外壳遵循 RFC 6488/9589,eContent 遵循 RFC 9286。RFC 9286 §4;RFC 6488 §4;RFC 9589 §4。
## 6.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9286 §4;RFC 6488 §2。
- eContentType:`id-ct-rpkiManifest`,OID `1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1。
- eContent:DER 编码 ASN.1 `Manifest`。RFC 9286 §4.2。
### 6.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9286 §4.1;RFC 9286 §4.2)
Manifest 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。其 `eContentType` 与 `eContent` 的 ASN.1 由 RFC 9286 明确定义。RFC 9286 §4。
**eContentType(OID)**:RFC 9286 §4.1。
```asn1
id-smime OBJECT IDENTIFIER ::= { iso(1) member-body(2) us(840)
rsadsi(113549) pkcs(1) pkcs9(9) 16 }
id-ct OBJECT IDENTIFIER ::= { id-smime 1 }
id-ct-rpkiManifest OBJECT IDENTIFIER ::= { id-ct 26 }
```
**eContent(Manifest 结构)**:RFC 9286 §4.2。
```asn1
Manifest ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
manifestNumber INTEGER (0..MAX),
thisUpdate GeneralizedTime,
nextUpdate GeneralizedTime,
fileHashAlg OBJECT IDENTIFIER,
fileList SEQUENCE SIZE (0..MAX) OF FileAndHash
}
FileAndHash ::= SEQUENCE {
file IA5String,
hash BIT STRING
}
```
解码要点:
- `fileHashAlg` 决定 `FileAndHash.hash` 的算法与输出长度(RPKI profile 要求 SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
- `hash` 在 ASN.1 中是 BIT STRING,但 hash 输出是按字节的比特串,DER 编码时应为 “unused bits = 0” 的 octet-aligned BIT STRING(实现可据此做一致性检查)。RFC 9286 §4.2。
## 6.3 解析规则(eContent 语义层)
输入:`RpkiSignedObject`。
1) 先按通用 Signed Object 外壳解析得到 `encap_content_info.econtent_type` 与 `econtent_der`。RFC 6488 §3;RFC 9589 §4。
2) 要求 `econtent_type == 1.2.840.113549.1.9.16.1.26`。RFC 9286 §4.1;RFC 9286 §4.4(1)。
3) 将 `econtent_der` 以 DER 解析为 `Manifest` ASN.1。RFC 9286 §4.2。
4) 将 `fileList` 映射为语义字段 `files: list[FileAndHash]`,其中 `hash` 为 `fileHashAlg` 对应算法的输出字节序列。RFC 9286 §4.2.1(fileHashAlg/fileList 定义)。
## 6.4 抽象数据模型(接口)
### 6.4.1 `ManifestObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9286 §4;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.26` | RFC 9286 §4.1 |
| `manifest` | `ManifestEContent` | eContent 语义对象 | 见下 | RFC 9286 §4.2 |
### 6.4.2 `ManifestEContent`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | Manifest.version | MUST 为 0 | RFC 9286 §4.2.1(version) |
| `manifest_number` | `int` | manifestNumber | 0..MAX;可达 20 octets;issuer 必须单调递增;RP 必须可处理至 20 octets | RFC 9286 §4.2;RFC 9286 §4.2.1(manifestNumber) |
| `this_update` | `UtcTime` | thisUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须比先前生成的 manifest 更新 | RFC 9286 §4.2;RFC 9286 §4.2.1(thisUpdate) |
| `next_update` | `UtcTime` | nextUpdate | 由 ASN.1 `GeneralizedTime` 解析为 UTC 时间点;且必须晚于 thisUpdate | RFC 9286 §4.2;RFC 9286 §4.2.1(nextUpdate) |
| `file_hash_alg` | `Oid` | fileHashAlg | 必须为 `id-sha256`(`2.16.840.1.101.3.4.2.1`) | RFC 9286 §4.2.1(fileHashAlg);RFC 7935 §2(引用 RFC 5754) |
| `files` | `list[FileAndHash]` | fileList | `SEQUENCE SIZE (0..MAX)`;每项含文件名与 hash | RFC 9286 §4.2;RFC 9286 §4.2.1(fileList) |
### 6.4.3 `FileAndHash`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `file_name` | `string` | 文件名(不含路径) | 字符集限制:`[a-zA-Z0-9-_]+` + `.` + 三字母扩展;扩展必须在 IANA “RPKI Repository Name Schemes” 注册表中 | RFC 9286 §4.2.2 |
| `hash_bytes` | `bytes` | 文件内容 hash | 由 `file_hash_alg` 指定算法计算 | RFC 9286 §4.2.1(fileHashAlg/fileList) |
## 6.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-rpkiManifest`(OID `1.2.840.113549.1.9.16.1.26`)。RFC 9286 §4.1。
- eContent 必须 DER 编码且符合 `Manifest` ASN.1。RFC 9286 §4.2。
- `version` 必须为 0。RFC 9286 §4.2.1。
- `manifestNumber` 由 issuer 单调递增;RP 必须能处理至 20 octets;issuer 不得超过 20 octets。RFC 9286 §4.2.1。
- `nextUpdate` 必须晚于 `thisUpdate`。RFC 9286 §4.2.1。
- `fileHashAlg` 必须符合算法 profile(SHA-256)。RFC 9286 §4.2.1;RFC 7935 §2。
- `fileList` 中 `file` 名称字符集与扩展名受限;实现需按 RFC 限制解析并保留大小写语义。RFC 9286 §4.2.2。
## 6.6 与 EE 证书的语义约束(为后续验证准备)
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(生成步骤)。

159
specs/07_roa.md Normal file
View File

@@ -0,0 +1,159 @@
# 07. ROA(Route Origin Authorization)
## 7.1 对象定位
ROA 是一种 RPKI Signed Object,用于声明“某 AS 被授权起源某些前缀”。RFC 9582 §1;RFC 9582 §4。
ROA 由 CMS 外壳 + ROA eContent 组成:
- 外壳:RFC 6488(更新:RFC 9589)
- eContent:RFC 9582
## 7.2 原始载体与编码
- 外壳:CMS SignedData DER(见 `05_signed_object_cms.md`)。RFC 9582 §1(引用 RFC 6488)。
- eContentType:`id-ct-routeOriginAuthz`,OID `1.2.840.113549.1.9.16.1.24`。RFC 9582 §3。
- eContent:DER 编码 ASN.1 `RouteOriginAttestation`。RFC 9582 §4。
### 7.2.1 eContentType 与 eContent 的 ASN.1 定义(RFC 9582 §3;RFC 9582 §4)
ROA 是一种 RPKI signed object(CMS 外壳见 `05_signed_object_cms.md`)。RFC 9582 定义了其 `eContentType` 以及 `eContent`(payload)的 ASN.1。RFC 9582 §3-§4。
**eContentType(OID)**:RFC 9582 §3。
```asn1
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
```
**eContent(ROA ASN.1 模块)**:RFC 9582 §4。
```asn1
RPKI-ROA-2023
{ iso(1) member-body(2) us(840) rsadsi(113549)
pkcs(1) pkcs9(9) smime(16) mod(0)
id-mod-rpkiROA-2023(75) }
DEFINITIONS EXPLICIT TAGS ::=
BEGIN
IMPORTS
CONTENT-TYPE
FROM CryptographicMessageSyntax-2010 -- in [RFC6268]
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) smime(16) modules(0) id-mod-cms-2009(58) } ;
ct-routeOriginAttestation CONTENT-TYPE ::=
{ TYPE RouteOriginAttestation
IDENTIFIED BY id-ct-routeOriginAuthz }
id-ct-routeOriginAuthz OBJECT IDENTIFIER ::=
{ iso(1) member-body(2) us(840) rsadsi(113549) pkcs(1)
pkcs-9(9) id-smime(16) id-ct(1) routeOriginAuthz(24) }
RouteOriginAttestation ::= SEQUENCE {
version [0] INTEGER DEFAULT 0,
asID ASID,
ipAddrBlocks SEQUENCE (SIZE(1..2)) OF ROAIPAddressFamily }
ASID ::= INTEGER (0..4294967295)
ROAIPAddressFamily ::= SEQUENCE {
addressFamily ADDRESS-FAMILY.&afi ({AddressFamilySet}),
addresses ADDRESS-FAMILY.&Addresses
({AddressFamilySet}{@addressFamily}) }
ADDRESS-FAMILY ::= CLASS {
&afi OCTET STRING (SIZE(2)) UNIQUE,
&Addresses
} WITH SYNTAX { AFI &afi ADDRESSES &Addresses }
AddressFamilySet ADDRESS-FAMILY ::=
{ addressFamilyIPv4 | addressFamilyIPv6 }
addressFamilyIPv4 ADDRESS-FAMILY ::=
{ AFI afi-IPv4 ADDRESSES ROAAddressesIPv4 }
addressFamilyIPv6 ADDRESS-FAMILY ::=
{ AFI afi-IPv6 ADDRESSES ROAAddressesIPv6 }
afi-IPv4 OCTET STRING ::= '0001'H
afi-IPv6 OCTET STRING ::= '0002'H
ROAAddressesIPv4 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv4}
ROAAddressesIPv6 ::= SEQUENCE (SIZE(1..MAX)) OF ROAIPAddress{ub-IPv6}
ub-IPv4 INTEGER ::= 32
ub-IPv6 INTEGER ::= 128
ROAIPAddress {INTEGER: ub} ::= SEQUENCE {
address BIT STRING (SIZE(0..ub)),
maxLength INTEGER (0..ub) OPTIONAL }
END
```
编码/解码要点(与上面 ASN.1 结构直接对应):
- `addressFamily` 仅允许 IPv4/IPv6 两种 AFI,并且每个 AFI 最多出现一次。RFC 9582 §4.3.1。
- `address` 是 BIT STRING 表示的前缀,语义与 RFC 3779 的 `IPAddress` 一致(按前缀长度截断,DER unused bits 置零)。RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8)。
- `maxLength` 为可选字段,出现与否会影响语义与编码规范约束(例如等于前缀长时不建议编码)。RFC 9582 §4.3.2.2。
## 7.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.24`。RFC 9582 §3。
3) 将 `econtent_der` 以 DER 解析为 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
4) 将 `ipAddrBlocks` 解析为“前缀集合”的语义结构,并建议按 RFC 9582 给出的 canonicalization 过程做去重/排序/归一化(以便后续处理一致)。RFC 9582 §4.3.3。
## 7.4 抽象数据模型(接口)
### 7.4.1 `RoaObject`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `signed_object` | `RpkiSignedObject` | CMS 外壳 | 外壳约束见 RFC 6488/9589 | RFC 9582 §1;RFC 6488 §3;RFC 9589 §4 |
| `econtent_type` | `Oid` | eContentType | 必须为 `1.2.840.113549.1.9.16.1.24` | RFC 9582 §3 |
| `roa` | `RoaEContent` | eContent 语义对象 | 见下 | RFC 9582 §4 |
### 7.4.2 `RoaEContent`(RouteOriginAttestation)
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `version` | `int` | version | MUST 为 0 | RFC 9582 §4.1 |
| `as_id` | `int` | asID | 0..4294967295 | RFC 9582 §4(ASID 定义);RFC 9582 §4.2 |
| `ip_addr_blocks` | `list[RoaIpAddressFamily]` | ipAddrBlocks | `SIZE(1..2)`;最多 IPv4/IPv6 各一个;建议 canonicalize | RFC 9582 §4;RFC 9582 §4.3.1;RFC 9582 §4.3.3 |
### 7.4.3 `RoaIpAddressFamily`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `afi` | `enum { ipv4, ipv6 }` | Address Family | MUST 为 IPv4(0001) 或 IPv6(0002) | RFC 9582 §4.3.1 |
| `addresses` | `list[RoaIpAddress]` | 前缀列表 | `SIZE(1..MAX)`;每项为前缀 + 可选 maxLength | RFC 9582 §4(ROAAddressesIPv4/IPv6);RFC 9582 §4.3.2 |
### 7.4.4 `RoaIpAddress`
| 字段 | 类型 | 语义 | 约束/解析规则 | RFC 引用 |
|---|---|---|---|---|
| `prefix` | `IpPrefix` | 前缀 | address 以 BIT STRING 表示前缀(同 RFC 3779 IPAddress 表示) | RFC 9582 §4.3.2.1(引用 RFC 3779 §2.2.3.8) |
| `max_length` | `optional[int]` | 最大允许前缀长 | 若存在:必须 `>= prefix_len` 且 `<= 32/128`;并且 `maxLength == prefix_len` 时 **SHOULD NOT** 编码(未来 RP 可能视为编码错误) | RFC 9582 §4.3.2.2 |
## 7.5 字段级约束清单(实现对照)
- eContentType 必须为 `id-ct-routeOriginAuthz`(OID `1.2.840.113549.1.9.16.1.24`),且该 OID 必须同时出现在 eContentType 与 signedAttrs.content-type。RFC 9582 §3(引用 RFC 6488)。
- eContent 必须 DER 编码并符合 `RouteOriginAttestation` ASN.1。RFC 9582 §4。
- `version` 必须为 0。RFC 9582 §4.1。
- `ipAddrBlocks` 长度为 1..2;每种 AFI 最多出现一次;仅支持 IPv4/IPv6。RFC 9582 §4;RFC 9582 §4.3.1。
- `maxLength` 若存在必须在范围内,且不应出现“等于前缀长”的冗余编码。RFC 9582 §4.3.2.2。
- 建议按 canonical form 归一化/排序以利一致处理。RFC 9582 §4.3.3。
## 7.6 与 EE 证书的语义约束(为后续验证准备)
ROA 的外壳包含一个 EE 证书,用于验证 ROA 签名;RFC 对该 EE 证书与 ROA payload 的匹配关系提出要求:
- ROA 的 EE 证书必须是有效的 RPKI EE 证书(路径从 TA 到 EE 可建立),并用于验证 CMS 签名。RFC 9582 §1(引用 RFC 6488);RFC 6488 §3(2)-(3)。
- ROA EE 证书中的 IP 资源扩展必须存在且不得使用 inherit。RFC 9582 §5。
- ROA EE 证书中 AS 资源扩展不得出现。RFC 9582 §5。
- ROA payload 中每个前缀必须包含在 EE 证书的 IP 资源集合内(资源包含语义来自 RFC 3779)。RFC 9582 §5;RFC 3779 §2.3。

100
specs/08_aspa.md Normal file
View 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。

View 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)。

86
specs/arch.excalidraw Normal file
View File

@@ -0,0 +1,86 @@
{
"type": "excalidraw",
"version": 2,
"source": "https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor",
"elements": [
{
"id": "A3BjC6kJe019Pes-xjr_L",
"type": "rectangle",
"x": 307.66668701171875,
"y": 719.3333740234375,
"width": 321.66668701171875,
"height": 104,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0",
"roundness": {
"type": 3
},
"seed": 1572721102,
"version": 56,
"versionNonce": 1402545874,
"isDeleted": false,
"boundElements": [
{
"type": "text",
"id": "KnkPQWiZzaALgJ-eehAOa"
}
],
"updated": 1770174471076,
"link": null,
"locked": false
},
{
"id": "KnkPQWiZzaALgJ-eehAOa",
"type": "text",
"x": 406.6700668334961,
"y": 746.3333740234375,
"width": 123.65992736816406,
"height": 50,
"angle": 0,
"strokeColor": "#1e1e1e",
"backgroundColor": "transparent",
"fillStyle": "solid",
"strokeWidth": 2,
"strokeStyle": "solid",
"roughness": 1,
"opacity": 100,
"groupIds": [],
"frameId": null,
"index": "a0V",
"roundness": null,
"seed": 1287469326,
"version": 49,
"versionNonce": 630772558,
"isDeleted": false,
"boundElements": null,
"updated": 1770174490368,
"link": null,
"locked": false,
"text": "lib\n(data model)",
"fontSize": 20,
"fontFamily": 5,
"textAlign": "center",
"verticalAlign": "middle",
"containerId": "A3BjC6kJe019Pes-xjr_L",
"originalText": "lib\n(data model)",
"autoResize": true,
"lineHeight": 1.25
}
],
"appState": {
"gridSize": 20,
"gridStep": 5,
"gridModeEnabled": false,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

163
src/audit.rs Normal file
View File

@@ -0,0 +1,163 @@
use serde::Serialize;
use sha2::Digest;
use crate::policy::Policy;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectKind {
Manifest,
Crl,
Certificate,
Roa,
Aspa,
Other,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuditObjectResult {
Ok,
Skipped,
Error,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ObjectAuditEntry {
pub rsync_uri: String,
pub sha256_hex: String,
pub kind: AuditObjectKind,
pub result: AuditObjectResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditWarning {
pub message: String,
pub rfc_refs: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
}
impl From<&crate::report::Warning> for AuditWarning {
fn from(w: &crate::report::Warning) -> Self {
Self {
message: w.message.clone(),
rfc_refs: w.rfc_refs.iter().map(|r| r.0.to_string()).collect(),
context: w.context.clone(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct PublicationPointAudit {
/// Monotonic node ID assigned by the traversal engine.
///
/// Present when running via the Stage2 tree engine; may be absent in ad-hoc runs.
#[serde(skip_serializing_if = "Option::is_none")]
pub node_id: Option<u64>,
/// Parent node ID in the traversal tree.
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_node_id: Option<u64>,
/// Provenance metadata for non-root nodes (how this CA instance was discovered).
#[serde(skip_serializing_if = "Option::is_none")]
pub discovered_from: Option<DiscoveredFrom>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rrdp_notification_uri: Option<String>,
pub source: String,
pub this_update_rfc3339_utc: String,
pub next_update_rfc3339_utc: String,
pub verified_at_rfc3339_utc: String,
pub warnings: Vec<AuditWarning>,
pub objects: Vec<ObjectAuditEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct DiscoveredFrom {
pub parent_manifest_rsync_uri: String,
pub child_ca_certificate_rsync_uri: String,
pub child_ca_certificate_sha256_hex: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct TreeSummary {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<AuditWarning>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditRunMeta {
pub validation_time_rfc3339_utc: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AuditReportV1 {
pub format_version: u32,
pub meta: AuditRunMeta,
pub policy: Policy,
pub tree: TreeSummary,
pub publication_points: Vec<PublicationPointAudit>,
pub vrps: Vec<VrpOutput>,
pub aspas: Vec<AspaOutput>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct VrpOutput {
pub asn: u32,
pub prefix: String,
pub max_length: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct AspaOutput {
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
pub fn sha256_hex_from_32(bytes: &[u8; 32]) -> String {
hex::encode(bytes)
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = sha2::Sha256::digest(bytes);
hex::encode(digest)
}
pub fn format_roa_ip_prefix(p: &crate::data_model::roa::IpPrefix) -> String {
match p.afi {
crate::data_model::roa::RoaAfi::Ipv4 => {
if p.addr.len() != 4 {
return format!("ipv4:{:02X?}/{}", p.addr, p.prefix_len);
}
format!(
"{}.{}.{}.{}{}",
p.addr[0],
p.addr[1],
p.addr[2],
p.addr[3],
format!("/{}", p.prefix_len)
)
}
crate::data_model::roa::RoaAfi::Ipv6 => {
if p.addr.len() != 16 {
return format!("ipv6:{:02X?}/{}", p.addr, p.prefix_len);
}
let mut parts = Vec::with_capacity(8);
for i in 0..8 {
let hi = p.addr[i * 2] as u16;
let lo = p.addr[i * 2 + 1] as u16;
parts.push(format!("{:x}", (hi << 8) | lo));
}
format!("{}{}", parts.join(":"), format!("/{}", p.prefix_len))
}
}
}

12
src/bin/rpki.rs Normal file
View File

@@ -0,0 +1,12 @@
fn main() {
let argv: Vec<String> = std::env::args().collect();
if let Err(e) = rpki::cli::run(&argv) {
// `parse_args` uses `Err(usage())` for `--help`, so treat it as success.
if argv.iter().any(|a| a == "--help" || a == "-h") {
println!("{e}");
return;
}
eprintln!("{e}");
std::process::exit(2);
}
}

674
src/cli.rs Normal file
View File

@@ -0,0 +1,674 @@
use std::path::{Path, PathBuf};
use crate::audit::{
AspaOutput, AuditReportV1, AuditRunMeta, AuditWarning, TreeSummary, VrpOutput,
format_roa_ip_prefix,
};
use crate::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use crate::policy::Policy;
use crate::storage::RocksStore;
use crate::validation::run_tree_from_tal::{
RunTreeFromTalAuditOutput, run_tree_from_tal_and_ta_der_serial_audit,
run_tree_from_tal_url_serial_audit,
};
use crate::validation::tree::TreeRunConfig;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CliArgs {
pub tal_url: Option<String>,
pub tal_path: Option<PathBuf>,
pub ta_path: Option<PathBuf>,
pub db_path: PathBuf,
pub policy_path: Option<PathBuf>,
pub report_json_path: Option<PathBuf>,
pub rsync_local_dir: Option<PathBuf>,
pub max_depth: Option<usize>,
pub max_instances: Option<usize>,
pub validation_time: Option<time::OffsetDateTime>,
}
fn usage() -> String {
let bin = "rpki";
format!(
"\
Usage:
{bin} --db <path> --tal-url <url> [options]
{bin} --db <path> --tal-path <path> --ta-path <path> [options]
Options:
--db <path> RocksDB directory path (required)
--policy <path> Policy TOML path (optional)
--report-json <path> Write full audit report as JSON (optional)
--tal-url <url> TAL URL (downloads TAL + TA over HTTPS)
--tal-path <path> TAL file path (offline-friendly; requires --ta-path)
--ta-path <path> TA certificate DER file path (offline-friendly)
--rsync-local-dir <path> Use LocalDirRsyncFetcher rooted at this directory (offline tests)
--max-depth <n> Max CA instance depth (0 = root only)
--max-instances <n> Max number of CA instances to process
--validation-time <rfc3339> Validation time in RFC3339 (default: now UTC)
--help Show this help
"
)
}
pub fn parse_args(argv: &[String]) -> Result<CliArgs, String> {
let mut tal_url: Option<String> = None;
let mut tal_path: Option<PathBuf> = None;
let mut ta_path: Option<PathBuf> = None;
let mut db_path: Option<PathBuf> = None;
let mut policy_path: Option<PathBuf> = None;
let mut report_json_path: Option<PathBuf> = None;
let mut rsync_local_dir: Option<PathBuf> = None;
let mut max_depth: Option<usize> = None;
let mut max_instances: Option<usize> = None;
let mut validation_time: Option<time::OffsetDateTime> = None;
let mut i = 1usize;
while i < argv.len() {
let arg = argv[i].as_str();
match arg {
"--help" | "-h" => return Err(usage()),
"--tal-url" => {
i += 1;
let v = argv.get(i).ok_or("--tal-url requires a value")?;
tal_url = Some(v.clone());
}
"--tal-path" => {
i += 1;
let v = argv.get(i).ok_or("--tal-path requires a value")?;
tal_path = Some(PathBuf::from(v));
}
"--ta-path" => {
i += 1;
let v = argv.get(i).ok_or("--ta-path requires a value")?;
ta_path = Some(PathBuf::from(v));
}
"--db" => {
i += 1;
let v = argv.get(i).ok_or("--db requires a value")?;
db_path = Some(PathBuf::from(v));
}
"--policy" => {
i += 1;
let v = argv.get(i).ok_or("--policy requires a value")?;
policy_path = Some(PathBuf::from(v));
}
"--report-json" => {
i += 1;
let v = argv.get(i).ok_or("--report-json requires a value")?;
report_json_path = Some(PathBuf::from(v));
}
"--rsync-local-dir" => {
i += 1;
let v = argv.get(i).ok_or("--rsync-local-dir requires a value")?;
rsync_local_dir = Some(PathBuf::from(v));
}
"--max-depth" => {
i += 1;
let v = argv.get(i).ok_or("--max-depth requires a value")?;
max_depth = Some(
v.parse::<usize>()
.map_err(|_| format!("invalid --max-depth: {v}"))?,
);
}
"--max-instances" => {
i += 1;
let v = argv.get(i).ok_or("--max-instances requires a value")?;
max_instances = Some(
v.parse::<usize>()
.map_err(|_| format!("invalid --max-instances: {v}"))?,
);
}
"--validation-time" => {
i += 1;
let v = argv.get(i).ok_or("--validation-time requires a value")?;
use time::format_description::well_known::Rfc3339;
let t = time::OffsetDateTime::parse(v, &Rfc3339)
.map_err(|e| format!("invalid --validation-time (RFC3339 expected): {e}"))?;
validation_time = Some(t);
}
_ => return Err(format!("unknown argument: {arg}\n\n{}", usage())),
}
i += 1;
}
let db_path = db_path.ok_or_else(|| format!("--db is required\n\n{}", usage()))?;
let tal_mode_count = tal_url.is_some() as u8 + tal_path.is_some() as u8;
if tal_mode_count != 1 {
return Err(format!(
"must specify exactly one of --tal-url or --tal-path\n\n{}",
usage()
));
}
if tal_path.is_some() && ta_path.is_none() {
return Err(format!(
"--tal-path requires --ta-path (offline-friendly mode)\n\n{}",
usage()
));
}
Ok(CliArgs {
tal_url,
tal_path,
ta_path,
db_path,
policy_path,
report_json_path,
rsync_local_dir,
max_depth,
max_instances,
validation_time,
})
}
fn read_policy(path: Option<&Path>) -> Result<Policy, String> {
match path {
None => Ok(Policy::default()),
Some(p) => {
let s = std::fs::read_to_string(p)
.map_err(|e| format!("read policy file failed: {}: {e}", p.display()))?;
Policy::from_toml_str(&s).map_err(|e| e.to_string())
}
}
}
fn write_json(path: &Path, report: &AuditReportV1) -> Result<(), String> {
let f = std::fs::File::create(path)
.map_err(|e| format!("create report file failed: {}: {e}", path.display()))?;
serde_json::to_writer_pretty(f, report)
.map_err(|e| format!("write report json failed: {e}"))?;
Ok(())
}
fn unique_rrdp_repos(report: &AuditReportV1) -> usize {
use std::collections::HashSet;
let mut set: HashSet<&str> = HashSet::new();
for pp in &report.publication_points {
if let Some(u) = pp.rrdp_notification_uri.as_deref() {
set.insert(u);
}
}
set.len()
}
fn print_summary(report: &AuditReportV1) {
let rrdp_repos = unique_rrdp_repos(report);
println!("RPKI stage2 serial run summary");
println!(
"validation_time={}",
report.meta.validation_time_rfc3339_utc
);
println!(
"publication_points_processed={} publication_points_failed={}",
report.tree.instances_processed, report.tree.instances_failed
);
println!("rrdp_repos_unique={rrdp_repos}");
println!("vrps={}", report.vrps.len());
println!("aspas={}", report.aspas.len());
println!(
"audit_publication_points={}",
report.publication_points.len()
);
println!(
"warnings_total={}",
report.tree.warnings.len()
+ report
.publication_points
.iter()
.map(|pp| pp.warnings.len())
.sum::<usize>()
);
}
fn build_report(
policy: &Policy,
validation_time: time::OffsetDateTime,
out: RunTreeFromTalAuditOutput,
) -> AuditReportV1 {
use time::format_description::well_known::Rfc3339;
let validation_time_rfc3339_utc = validation_time
.to_offset(time::UtcOffset::UTC)
.format(&Rfc3339)
.expect("format validation_time");
let vrps = out
.tree
.vrps
.iter()
.map(|v| VrpOutput {
asn: v.asn,
prefix: format_roa_ip_prefix(&v.prefix),
max_length: v.max_length,
})
.collect::<Vec<_>>();
let aspas = out
.tree
.aspas
.iter()
.map(|a| AspaOutput {
customer_as_id: a.customer_as_id,
provider_as_ids: a.provider_as_ids.clone(),
})
.collect::<Vec<_>>();
AuditReportV1 {
format_version: 1,
meta: AuditRunMeta {
validation_time_rfc3339_utc,
},
policy: policy.clone(),
tree: TreeSummary {
instances_processed: out.tree.instances_processed,
instances_failed: out.tree.instances_failed,
warnings: out.tree.warnings.iter().map(AuditWarning::from).collect(),
},
publication_points: out.publication_points,
vrps,
aspas,
}
}
pub fn run(argv: &[String]) -> Result<(), String> {
let args = parse_args(argv)?;
let policy = read_policy(args.policy_path.as_deref())?;
let validation_time = args
.validation_time
.unwrap_or_else(time::OffsetDateTime::now_utc);
let store = RocksStore::open(&args.db_path).map_err(|e| e.to_string())?;
let http = BlockingHttpFetcher::new(HttpFetcherConfig::default()).map_err(|e| e.to_string())?;
let config = TreeRunConfig {
max_depth: args.max_depth,
max_instances: args.max_instances,
};
let out = if let Some(dir) = args.rsync_local_dir.as_ref() {
let rsync = LocalDirRsyncFetcher::new(dir);
match (
args.tal_url.as_ref(),
args.tal_path.as_ref(),
args.ta_path.as_ref(),
) {
(Some(url), _, _) => run_tree_from_tal_url_serial_audit(
&store,
&policy,
url,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?,
(None, Some(tal_path), Some(ta_path)) => {
let tal_bytes = std::fs::read(tal_path)
.map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?;
let ta_der = std::fs::read(ta_path)
.map_err(|e| format!("read ta failed: {}: {e}", ta_path.display()))?;
run_tree_from_tal_and_ta_der_serial_audit(
&store,
&policy,
&tal_bytes,
&ta_der,
None,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?
}
_ => unreachable!("validated by parse_args"),
}
} else {
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig::default());
match (
args.tal_url.as_ref(),
args.tal_path.as_ref(),
args.ta_path.as_ref(),
) {
(Some(url), _, _) => run_tree_from_tal_url_serial_audit(
&store,
&policy,
url,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?,
(None, Some(tal_path), Some(ta_path)) => {
let tal_bytes = std::fs::read(tal_path)
.map_err(|e| format!("read tal failed: {}: {e}", tal_path.display()))?;
let ta_der = std::fs::read(ta_path)
.map_err(|e| format!("read ta failed: {}: {e}", ta_path.display()))?;
run_tree_from_tal_and_ta_der_serial_audit(
&store,
&policy,
&tal_bytes,
&ta_der,
None,
&http,
&rsync,
validation_time,
&config,
)
.map_err(|e| e.to_string())?
}
_ => unreachable!("validated by parse_args"),
}
};
let report = build_report(&policy, validation_time, out);
if let Some(p) = args.report_json_path.as_deref() {
write_json(p, &report)?;
}
print_summary(&report);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_help_returns_usage() {
let argv = vec!["rpki".to_string(), "--help".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("Usage:"), "{err}");
assert!(err.contains("--db"), "{err}");
}
#[test]
fn parse_rejects_unknown_argument() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("unknown argument"), "{err}");
}
#[test]
fn parse_rejects_both_tal_url_and_tal_path() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--tal-path".to_string(),
"x.tal".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(
err.contains("exactly one of --tal-url or --tal-path"),
"{err}"
);
}
#[test]
fn parse_rejects_invalid_max_depth() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--max-depth".to_string(),
"nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --max-depth"), "{err}");
}
#[test]
fn parse_rejects_invalid_validation_time() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--validation-time".to_string(),
"not-a-time".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --validation-time"), "{err}");
}
#[test]
fn parse_rejects_invalid_max_instances() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--max-instances".to_string(),
"nope".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("invalid --max-instances"), "{err}");
}
#[test]
fn parse_rejects_missing_value_for_db() {
let argv = vec!["rpki".to_string(), "--db".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--db requires a value"), "{err}");
}
#[test]
fn parse_rejects_missing_value_for_tal_url() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--tal-url requires a value"), "{err}");
}
#[test]
fn parse_rejects_missing_db() {
let argv = vec!["rpki".to_string(), "--tal-url".to_string(), "x".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(err.contains("--db is required"), "{err}");
}
#[test]
fn parse_rejects_missing_tal_mode() {
let argv = vec!["rpki".to_string(), "--db".to_string(), "db".to_string()];
let err = parse_args(&argv).unwrap_err();
assert!(
err.contains("--tal-url") || err.contains("--tal-path"),
"{err}"
);
}
#[test]
fn parse_accepts_tal_url_mode() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.tal_url.as_deref(), Some("https://example.test/x.tal"));
assert!(args.tal_path.is_none());
assert!(args.ta_path.is_none());
}
#[test]
fn parse_accepts_offline_mode_requires_ta() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-path".to_string(),
"a.tal".to_string(),
"--ta-path".to_string(),
"ta.cer".to_string(),
"--max-depth".to_string(),
"0".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert_eq!(args.tal_path.as_deref(), Some(Path::new("a.tal")));
assert_eq!(args.ta_path.as_deref(), Some(Path::new("ta.cer")));
assert_eq!(args.max_depth, Some(0));
}
#[test]
fn parse_accepts_validation_time_rfc3339() {
let argv = vec![
"rpki".to_string(),
"--db".to_string(),
"db".to_string(),
"--tal-url".to_string(),
"https://example.test/x.tal".to_string(),
"--validation-time".to_string(),
"2026-01-01T00:00:00Z".to_string(),
];
let args = parse_args(&argv).expect("parse");
assert!(args.validation_time.is_some());
}
#[test]
fn read_policy_accepts_valid_toml() {
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("policy.toml");
std::fs::write(
&p,
"signed_object_failure_policy = \"drop_publication_point\"\n",
)
.expect("write policy");
let policy = read_policy(Some(&p)).expect("parse policy");
assert_eq!(
policy.signed_object_failure_policy,
crate::policy::SignedObjectFailurePolicy::DropPublicationPoint
);
}
#[test]
fn read_policy_reports_missing_file() {
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("missing.toml");
let err = read_policy(Some(&p)).unwrap_err();
assert!(err.contains("read policy file failed"), "{err}");
}
#[test]
fn build_report_and_helpers_work_on_synthetic_output() {
let tal_bytes = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/tal/apnic-rfc7730-https.tal"),
)
.expect("read tal fixture");
let ta_der = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/ta/apnic-ta.cer"),
)
.expect("read ta fixture");
let discovery = crate::validation::from_tal::discover_root_ca_instance_from_tal_and_ta_der(
&tal_bytes, &ta_der, None,
)
.expect("discover root");
let tree = crate::validation::tree::TreeRunOutput {
instances_processed: 1,
instances_failed: 0,
warnings: vec![
crate::report::Warning::new("synthetic warning")
.with_rfc_refs(&[crate::report::RfcRef("RFC 6487 §4.8.8.1")])
.with_context("rsync://example.test/repo/pp/"),
],
vrps: vec![crate::validation::objects::Vrp {
asn: 64496,
prefix: crate::data_model::roa::IpPrefix {
afi: crate::data_model::roa::RoaAfi::Ipv4,
prefix_len: 24,
addr: vec![192, 0, 2, 0],
},
max_length: 24,
}],
aspas: vec![crate::validation::objects::AspaAttestation {
customer_as_id: 64496,
provider_as_ids: vec![64497, 64498],
}],
};
let mut pp1 = crate::audit::PublicationPointAudit::default();
pp1.rrdp_notification_uri = Some("https://example.test/n1.xml".to_string());
let mut pp2 = crate::audit::PublicationPointAudit::default();
pp2.rrdp_notification_uri = Some("https://example.test/n1.xml".to_string());
let mut pp3 = crate::audit::PublicationPointAudit::default();
pp3.rrdp_notification_uri = Some("https://example.test/n2.xml".to_string());
let out = crate::validation::run_tree_from_tal::RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points: vec![pp1, pp2, pp3],
};
let policy = Policy::default();
let validation_time = time::OffsetDateTime::now_utc();
let report = build_report(&policy, validation_time, out);
assert_eq!(unique_rrdp_repos(&report), 2);
assert_eq!(report.vrps.len(), 1);
assert_eq!(report.aspas.len(), 1);
print_summary(&report);
}
#[test]
fn write_json_writes_report() {
let report = AuditReportV1 {
format_version: 1,
meta: AuditRunMeta {
validation_time_rfc3339_utc: "2026-01-01T00:00:00Z".to_string(),
},
policy: Policy::default(),
tree: TreeSummary {
instances_processed: 0,
instances_failed: 0,
warnings: Vec::new(),
},
publication_points: Vec::new(),
vrps: Vec::new(),
aspas: Vec::new(),
};
let dir = tempfile::tempdir().expect("tmpdir");
let p = dir.path().join("report.json");
write_json(&p, &report).expect("write json");
let s = std::fs::read_to_string(&p).expect("read report");
assert!(s.contains("\"format_version\""));
assert!(s.contains("\"policy\""));
}
}

375
src/data_model/aspa.rs Normal file
View File

@@ -0,0 +1,375 @@
use crate::data_model::oid::OID_CT_ASPA;
use crate::data_model::rc::ResourceCertificate;
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
use der_parser::ber::Class;
use der_parser::der::{DerObject, Tag, parse_der};
#[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 AspaObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub aspa: Option<AspaEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaEContent {
pub version: u32,
pub customer_as_id: u32,
pub provider_as_ids: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AspaEContentParsed {
der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum AspaParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("ASPA parse error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
Parse(String),
#[error("ASPA trailing bytes: {0} bytes (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error(
"ASPA eContentType must be {OID_CT_ASPA}, got {0} (draft-ietf-sidrops-aspa-profile-21 §2)"
)]
InvalidEContentType(String),
#[error("ASPA profile decode error: {0} (draft-ietf-sidrops-aspa-profile-21 §3; DER)")]
ProfileDecode(String),
#[error(
"ASProviderAttestation must be a SEQUENCE of 3 elements (draft-ietf-sidrops-aspa-profile-21 §3)"
)]
InvalidAttestationSequence,
#[error(
"ASPA version must be 1 and MUST be explicitly encoded (draft-ietf-sidrops-aspa-profile-21 §3.1)"
)]
VersionMustBeExplicitOne,
#[error(
"ASPA customerASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.2)"
)]
CustomerAsIdOutOfRange(u64),
#[error(
"ASPA providers must contain at least one ASID (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
EmptyProviders,
#[error(
"ASPA provider ASID out of range (0..=4294967295), got {0} (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProviderAsIdOutOfRange(u64),
#[error(
"ASPA providers must be in strictly increasing order (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProvidersNotStrictlyIncreasing,
#[error(
"ASPA providers contains the customerASID ({0}) which is not allowed (draft-ietf-sidrops-aspa-profile-21 §3.3)"
)]
ProvidersContainCustomer(u32),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaDecodeError {
#[error("{0}")]
Parse(#[from] AspaParseError),
#[error("{0}")]
Validate(#[from] AspaProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum AspaValidateError {
#[error(
"ASPA EE certificate must contain AS resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2)"
)]
EeAsResourcesMissing,
#[error(
"ASPA EE certificate AS resources must not use inherit (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.3)"
)]
EeAsResourcesInherit,
#[error(
"ASPA EE certificate AS resources must not include ranges (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.6-§3.2.3.7)"
)]
EeAsResourcesRangePresent,
#[error(
"ASPA EE certificate AS resources must not include RDI (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §3.2.3.5; RFC 6487 §4.8.11)"
)]
EeAsResourcesRdiPresent,
#[error(
"ASPA EE certificate AS resources must contain exactly one ASID (id element) (draft-ietf-sidrops-aspa-profile-21 §4)"
)]
EeAsResourcesNotSingleId,
#[error(
"ASPA customerASID ({customer_as_id}) does not match EE AS resources ({ee_as_id}) (draft-ietf-sidrops-aspa-profile-21 §4)"
)]
CustomerAsIdMismatch { customer_as_id: u32, ee_as_id: u32 },
#[error(
"ASPA EE certificate must not contain IP resources extension (draft-ietf-sidrops-aspa-profile-21 §4; RFC 3779 §2.2)"
)]
EeIpResourcesPresent,
}
impl AspaObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<AspaObjectParsed, AspaParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let aspa = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(AspaEContent::parse_der)
.transpose()?;
Ok(AspaObjectParsed {
signed_object,
econtent_type,
aspa,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `AspaObject` is already profile-validated when constructed via `decode_der()` /
/// `AspaObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
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(AspaProfileError::InvalidEContentType(econtent_type).into());
}
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(),
})
}
/// Validate this ASPA's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), AspaValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.aspa.validate_against_ee_cert(ee)
}
}
impl AspaEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<AspaEContentParsed, AspaParseError> {
let (rem, _obj) = parse_der(der).map_err(|e| AspaParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(AspaParseError::TrailingBytes(rem.len()));
}
Ok(AspaEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `AspaEContent` is already profile-validated when constructed via `decode_der()` /
/// `AspaEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), AspaProfileError> {
Ok(())
}
/// Decode the DER-encoded ASProviderAttestation defined in
/// draft-ietf-sidrops-aspa-profile-21 §3 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, AspaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Validate ASPA payload against the embedded EE resource certificate.
///
/// 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_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), AspaValidateError> {
if ee.tbs.extensions.ip_resources.is_some() {
return Err(AspaValidateError::EeIpResourcesPresent);
}
let asn = ee
.tbs
.extensions
.as_resources
.as_ref()
.ok_or(AspaValidateError::EeAsResourcesMissing)?;
if asn.rdi.is_some() {
return Err(AspaValidateError::EeAsResourcesRdiPresent);
}
if asn.is_asnum_inherit() {
return Err(AspaValidateError::EeAsResourcesInherit);
}
if asn.has_any_range() {
return Err(AspaValidateError::EeAsResourcesRangePresent);
}
let ee_as_id = asn
.asnum_single_id()
.ok_or(AspaValidateError::EeAsResourcesNotSingleId)?;
if ee_as_id != self.customer_as_id {
return Err(AspaValidateError::CustomerAsIdMismatch {
customer_as_id: self.customer_as_id,
ee_as_id,
});
}
Ok(())
}
}
impl AspaObjectParsed {
pub fn validate_profile(self) -> Result<AspaObject, AspaProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ASPA {
return Err(AspaProfileError::InvalidEContentType(econtent_type));
}
let aspa = self
.aspa
.ok_or_else(|| AspaProfileError::ProfileDecode("ASPA.eContent missing".into()))?
.validate_profile()?;
Ok(AspaObject {
signed_object,
econtent_type: OID_CT_ASPA.to_string(),
aspa,
})
}
}
impl AspaEContentParsed {
pub fn validate_profile(self) -> Result<AspaEContent, AspaProfileError> {
let (_rem, obj) =
parse_der(&self.der).map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
let seq = obj
.as_sequence()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 3 {
return Err(AspaProfileError::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(AspaProfileError::VersionMustBeExplicitOne);
}
let inner_der = v_obj
.as_slice()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
let (rem, inner) =
parse_der(inner_der).map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if !rem.is_empty() {
return Err(AspaProfileError::ProfileDecode(
"trailing bytes inside ASProviderAttestation.version".into(),
));
}
let v = inner
.as_u64()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if v != 1 {
return Err(AspaProfileError::VersionMustBeExplicitOne);
}
let customer_u64 = seq[1]
.as_u64()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if customer_u64 > u32::MAX as u64 {
return Err(AspaProfileError::CustomerAsIdOutOfRange(customer_u64));
}
let customer_as_id = customer_u64 as u32;
let providers = parse_providers(&seq[2], customer_as_id)?;
Ok(AspaEContent {
version: 1,
customer_as_id,
provider_as_ids: providers,
})
}
}
fn parse_providers(obj: &DerObject<'_>, customer_as_id: u32) -> Result<Vec<u32>, AspaProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| AspaProfileError::ProfileDecode(e.to_string()))?;
if seq.is_empty() {
return Err(AspaProfileError::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| AspaProfileError::ProfileDecode(e.to_string()))?;
if v > u32::MAX as u64 {
return Err(AspaProfileError::ProviderAsIdOutOfRange(v));
}
let asn = v as u32;
if asn == customer_as_id {
return Err(AspaProfileError::ProvidersContainCustomer(customer_as_id));
}
if let Some(p) = prev {
if asn <= p {
return Err(AspaProfileError::ProvidersNotStrictlyIncreasing);
}
}
prev = Some(asn);
out.push(asn);
}
Ok(out)
}

111
src/data_model/common.rs Normal file
View File

@@ -0,0 +1,111 @@
use x509_parser::asn1_rs::Tag;
use x509_parser::x509::AlgorithmIdentifier;
pub type UtcTime = time::OffsetDateTime;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Asn1TimeEncoding {
UtcTime,
GeneralizedTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asn1TimeUtc {
pub utc: UtcTime,
pub encoding: Asn1TimeEncoding,
}
impl Asn1TimeUtc {
/// Validate Time encoding rules (RFC 5280): years 1950-2049 use UTCTime,
/// other years use GeneralizedTime.
pub fn validate_encoding_rfc5280(
&self,
field: &'static str,
) -> Result<(), InvalidTimeEncodingError> {
let year = self.utc.year();
let expected = if year <= 2049 {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
if self.encoding != expected {
return Err(InvalidTimeEncodingError {
field,
year,
encoding: self.encoding,
});
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BigUnsigned {
/// Minimal big-endian bytes. For zero, this is `[0]`.
pub bytes_be: Vec<u8>,
}
impl BigUnsigned {
pub fn from_biguint(n: &der_parser::num_bigint::BigUint) -> Self {
let mut bytes = n.to_bytes_be();
if bytes.is_empty() {
bytes.push(0);
}
Self { bytes_be: bytes }
}
pub fn to_hex_upper(&self) -> String {
hex::encode_upper(&self.bytes_be)
}
pub fn to_u64(&self) -> Option<u64> {
if self.bytes_be.len() > 8 {
return None;
}
let mut value: u64 = 0;
for &b in &self.bytes_be {
value = (value << 8) | (b as u64);
}
Some(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[error(
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §4.1.2.5; RFC 5280 §5.1.2.4-§5.1.2.6)"
)]
pub struct InvalidTimeEncodingError {
pub field: &'static str,
pub year: i32,
pub encoding: Asn1TimeEncoding,
}
pub fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc {
let encoding = if t.is_utctime() {
Asn1TimeEncoding::UtcTime
} else {
Asn1TimeEncoding::GeneralizedTime
};
Asn1TimeUtc {
utc: t.to_datetime(),
encoding,
}
}
pub fn algorithm_params_absent_or_null(sig: &AlgorithmIdentifier<'_>) -> bool {
match sig.parameters.as_ref() {
None => true,
Some(p) if p.tag() == Tag::Null => true,
Some(_p) => false,
}
}
/// Filename extensions registered in IANA "RPKI Repository Name Schemes".
///
/// Source: <https://www.iana.org/assignments/rpki/rpki.xhtml>
/// Snapshot date: 2026-01-28.
///
/// Notes:
/// - Includes entries marked TEMPORARY/DEPRECATED by IANA (e.g., `asa`, `gbr`).
pub const IANA_RPKI_REPOSITORY_FILENAME_EXTENSIONS: &[&str] =
&["asa", "cer", "crl", "gbr", "mft", "roa", "sig", "tak"];

View File

@@ -1,51 +1,14 @@
use x509_parser::extensions::{AuthorityKeyIdentifier, ParsedExtension, X509Extension}; pub use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc, BigUnsigned};
use x509_parser::prelude::FromDer; use crate::data_model::oid::{
use x509_parser::prelude::X509Version; OID_AUTHORITY_KEY_IDENTIFIER, OID_CRL_NUMBER, OID_SHA256_WITH_RSA_ENCRYPTION,
use x509_parser::revocation_list::CertificateRevocationList; OID_SUBJECT_KEY_IDENTIFIER,
use x509_parser::asn1_rs::Tag; };
use x509_parser::certificate::X509Certificate; use x509_parser::certificate::X509Certificate;
use x509_parser::x509::SubjectPublicKeyInfo; use x509_parser::extensions::{ParsedExtension, X509Extension};
use x509_parser::x509::AlgorithmIdentifier; use x509_parser::prelude::{FromDer, X509Version};
use x509_parser::revocation_list::CertificateRevocationList;
const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11"; use x509_parser::x509::{AlgorithmIdentifier, SubjectPublicKeyInfo};
const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35"; use x509_parser::{asn1_rs::Class as Asn1Class, asn1_rs::Tag as Asn1Tag};
const OID_CRL_NUMBER: &str = "2.5.29.20";
const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Asn1TimeEncoding {
UtcTime,
GeneralizedTime,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Asn1TimeUtc {
pub utc: time::OffsetDateTime,
pub encoding: Asn1TimeEncoding,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BigUnsigned {
/// Minimal big-endian bytes. For zero, this is `[0]`.
pub bytes_be: Vec<u8>,
}
impl BigUnsigned {
pub fn to_hex_upper(&self) -> String {
hex::encode_upper(&self.bytes_be)
}
pub fn to_u64(&self) -> Option<u64> {
if self.bytes_be.len() > 8 {
return None;
}
let mut value: u64 = 0;
for &b in &self.bytes_be {
value = (value << 8) | (b as u64);
}
Some(value)
}
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevokedCert { pub struct RevokedCert {
@@ -71,54 +34,131 @@ pub struct RpkixCrl {
pub extensions: CrlExtensions, pub extensions: CrlExtensions,
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkixCrlParsed {
pub raw_der: Vec<u8>,
pub version: Option<X509Version>,
pub issuer_dn: String,
pub signature_algorithm: AlgorithmIdentifierValue,
pub tbs_signature_algorithm: AlgorithmIdentifierValue,
pub this_update: Asn1TimeUtc,
pub next_update: Option<Asn1TimeUtc>,
pub revoked_certs: Vec<RevokedCertParsed>,
pub extensions: Vec<CrlExtensionParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RevokedCertParsed {
pub serial_number: BigUnsigned,
pub revocation_date: Asn1TimeUtc,
pub has_extensions: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CrlExtensionParsed {
AuthorityKeyIdentifier {
key_identifier: Option<Vec<u8>>,
has_other_fields: bool,
critical: bool,
},
CrlNumber {
number: der_parser::num_bigint::BigUint,
critical: bool,
},
Other {
oid: String,
critical: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmIdentifierValue {
pub oid: String,
pub parameters: Option<AlgorithmParametersValue>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmParametersValue {
pub class: Asn1Class,
pub tag: Asn1Tag,
pub data: Vec<u8>,
}
impl AlgorithmIdentifierValue {
pub fn params_absent_or_null(&self) -> bool {
match &self.parameters {
None => true,
Some(p) if p.class == Asn1Class::Universal && p.tag == Asn1Tag::Null => true,
Some(_p) => false,
}
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CrlDecodeError { pub enum CrlParseError {
#[error("X.509 CRL parse error: {0}")] #[error("X.509 CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
Parse(String), Parse(String),
#[error("trailing bytes after CRL DER: {0} bytes")] #[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
TrailingBytes(usize), TrailingBytes(usize),
}
#[error("CRL version must be v2, got {0:?}")] #[derive(Debug, thiserror::Error)]
pub enum CrlProfileError {
#[error("CRL version must be v2, got {0:?} (RFC 5280 §5.1; RFC 6487 §5)")]
InvalidVersion(Option<u32>), InvalidVersion(Option<u32>),
#[error("CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0}")] #[error("CRL signatureAlgorithm must match TBSCertList.signature (RFC 5280 §5.1)")]
InvalidSignatureAlgorithm(String),
#[error("CRL signature algorithm parameters must be absent or NULL")]
InvalidSignatureAlgorithmParameters,
#[error("CRL signatureAlgorithm must match TBSCertList.signature")]
SignatureAlgorithmMismatch, SignatureAlgorithmMismatch,
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0}")] #[error(
"CRL signatureAlgorithm must be sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6487 §5; RFC 7935 §2)"
)]
InvalidSignatureAlgorithm(String),
#[error(
"CRL signature algorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
)]
InvalidSignatureAlgorithmParameters,
#[error("CRL extensions must be exactly two (AKI + CRLNumber), got {0} (RFC 9829 §3.1)")]
InvalidExtensionsCount(usize), InvalidExtensionsCount(usize),
#[error("unsupported CRL extension OID {0}")] #[error("unsupported CRL extension OID {0} (RFC 9829 §3.1)")]
UnsupportedExtension(String), UnsupportedExtension(String),
#[error("duplicate CRL extension OID {0}")] #[error("duplicate CRL extension OID {0} (RFC 5280 §4.2; RFC 9829 §3.1)")]
DuplicateExtension(String), DuplicateExtension(String),
#[error("AuthorityKeyIdentifier must contain keyIdentifier")] #[error("AuthorityKeyIdentifier CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.1)")]
AkiMissing,
#[error("AuthorityKeyIdentifier must contain keyIdentifier (RFC 5280 §5.2.1; RFC 9829 §3.1)")]
AkiMissingKeyIdentifier, AkiMissingKeyIdentifier,
#[error("AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber")] #[error(
"AuthorityKeyIdentifier must not contain authorityCertIssuer or authorityCertSerialNumber (RFC 5280 §5.2.1; RFC 9829 §3.1)"
)]
AkiHasOtherFields, AkiHasOtherFields,
#[error("CRLNumber must be non-critical")] #[error("CRLNumber CRL extension missing (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
CrlNumberMissing,
#[error("CRLNumber must be non-critical (RFC 9829 §3.1; RFC 5280 §5.2.3)")]
CrlNumberCritical, CrlNumberCritical,
#[error("CRLNumber out of range (must fit in 0..2^159-1)")] #[error("CRLNumber out of range (must fit in 0..2^159-1) (RFC 9829 §3.1)")]
CrlNumberOutOfRange, CrlNumberOutOfRange,
#[error("CRL entry extensions must not be present")] #[error("CRL entry extensions must not be present (RFC 6487 §5; RFC 5280 §5.1)")]
EntryExtensionsNotAllowed, EntryExtensionsNotAllowed,
#[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5)")] #[error("CRL nextUpdate must be present (RFC 5280 §5.1.2.5; RFC 6487 §5)")]
NextUpdateMissing, NextUpdateMissing,
#[error("{field} time encoding invalid for year {year}: got {encoding:?}")] #[error(
"{field} time encoding invalid for year {year}: got {encoding:?} (RFC 5280 §5.1.2.4-§5.1.2.6)"
)]
InvalidTimeEncoding { InvalidTimeEncoding {
field: &'static str, field: &'static str,
year: i32, year: i32,
@@ -126,64 +166,47 @@ pub enum CrlDecodeError {
}, },
} }
#[derive(Debug, thiserror::Error)]
pub enum CrlDecodeError {
#[error("{0}")]
Parse(#[from] CrlParseError),
#[error("{0}")]
Validate(#[from] CrlProfileError),
}
impl RpkixCrl { impl RpkixCrl {
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from /// Parse step of scheme A (`parse → validate → verify`).
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1). pub fn parse_der(der: &[u8]) -> Result<RpkixCrlParsed, CrlParseError> {
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
let (rem, crl) = CertificateRevocationList::from_der(der) let (rem, crl) = CertificateRevocationList::from_der(der)
.map_err(|e| CrlDecodeError::Parse(e.to_string()))?; .map_err(|e| CrlParseError::Parse(e.to_string()))?;
if !rem.is_empty() { if !rem.is_empty() {
return Err(CrlDecodeError::TrailingBytes(rem.len())); return Err(CrlParseError::TrailingBytes(rem.len()));
} }
let version = match crl.version() {
Some(X509Version::V2) => 2,
Some(v) => return Err(CrlDecodeError::InvalidVersion(Some(v.0))),
None => return Err(CrlDecodeError::InvalidVersion(None)),
};
let sig_oid = crl.signature_algorithm.algorithm.to_id_string();
let tbs_sig_oid = crl.tbs_cert_list.signature.algorithm.to_id_string();
if sig_oid != tbs_sig_oid {
return Err(CrlDecodeError::SignatureAlgorithmMismatch);
}
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
return Err(CrlDecodeError::InvalidSignatureAlgorithm(sig_oid));
}
validate_sig_params(&crl.signature_algorithm)?;
validate_sig_params(&crl.tbs_cert_list.signature)?;
let extensions = parse_and_validate_extensions(crl.extensions())?;
let revoked_certs = crl let revoked_certs = crl
.iter_revoked_certificates() .iter_revoked_certificates()
.map(|rc| { .map(|rc| RevokedCertParsed {
if !rc.extensions().is_empty() { serial_number: BigUnsigned::from_biguint(rc.serial()),
return Err(CrlDecodeError::EntryExtensionsNotAllowed); revocation_date: crate::data_model::common::asn1_time_to_model(rc.revocation_date),
} has_extensions: !rc.extensions().is_empty(),
let revocation_date = asn1_time_to_model(rc.revocation_date);
validate_time_encoding("revocationDate", &revocation_date)?;
Ok(RevokedCert {
serial_number: biguint_to_big_unsigned(rc.serial()),
revocation_date,
}) })
}) .collect::<Vec<_>>();
.collect::<Result<Vec<_>, _>>()?;
let this_update = asn1_time_to_model(crl.last_update());
validate_time_encoding("thisUpdate", &this_update)?;
let this_update = crate::data_model::common::asn1_time_to_model(crl.last_update());
let next_update = crl let next_update = crl
.next_update() .next_update()
.map(asn1_time_to_model) .map(crate::data_model::common::asn1_time_to_model);
.ok_or(CrlDecodeError::NextUpdateMissing)?;
validate_time_encoding("nextUpdate", &next_update)?;
Ok(RpkixCrl { let extensions =
parse_extensions_parse(crl.extensions()).map_err(|e| CrlParseError::Parse(e))?;
Ok(RpkixCrlParsed {
raw_der: der.to_vec(), raw_der: der.to_vec(),
version, version: crl.version(),
issuer_dn: crl.issuer().to_string(), issuer_dn: crl.issuer().to_string(),
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(), signature_algorithm: algorithm_identifier_value(&crl.signature_algorithm),
tbs_signature_algorithm: algorithm_identifier_value(&crl.tbs_cert_list.signature),
this_update, this_update,
next_update, next_update,
revoked_certs, revoked_certs,
@@ -191,6 +214,20 @@ impl RpkixCrl {
}) })
} }
/// Decode a DER-encoded X.509 v2 CRL and enforce the RPKI profile constraints from
/// `specs/prepare/data_models/04_crl.md` (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1).
pub fn decode_der(der: &[u8]) -> Result<Self, CrlDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RpkixCrl` is already profile-validated when constructed via `decode_der()` /
/// `RpkixCrlParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), CrlProfileError> {
Ok(())
}
/// Verify the cryptographic signature on this CRL using the issuer certificate. /// Verify the cryptographic signature on this CRL using the issuer certificate.
/// ///
/// Signature verification needs the issuer public key (RFC 5280 §6.3.3 (f)-(g)). /// Signature verification needs the issuer public key (RFC 5280 §6.3.3 (f)-(g)).
@@ -265,55 +302,107 @@ impl RpkixCrl {
} }
} }
impl RpkixCrlParsed {
/// Profile validate step of scheme A (`parse → validate → verify`).
pub fn validate_profile(self) -> Result<RpkixCrl, CrlProfileError> {
let version = match self.version {
Some(X509Version::V2) => 2,
Some(v) => return Err(CrlProfileError::InvalidVersion(Some(v.0))),
None => return Err(CrlProfileError::InvalidVersion(None)),
};
// signatureAlgorithm must match tbsCertList.signature
if self.signature_algorithm != self.tbs_signature_algorithm {
return Err(CrlProfileError::SignatureAlgorithmMismatch);
}
let sig_oid = self.signature_algorithm.oid.clone();
if sig_oid != OID_SHA256_WITH_RSA_ENCRYPTION {
return Err(CrlProfileError::InvalidSignatureAlgorithm(sig_oid));
}
if !self.signature_algorithm.params_absent_or_null() {
return Err(CrlProfileError::InvalidSignatureAlgorithmParameters);
}
let extensions = validate_extensions_profile(&self.extensions)?;
let mut revoked_out = Vec::with_capacity(self.revoked_certs.len());
for rc in self.revoked_certs {
if rc.has_extensions {
return Err(CrlProfileError::EntryExtensionsNotAllowed);
}
validate_time_encoding_rfc5280("revocationDate", &rc.revocation_date)?;
revoked_out.push(RevokedCert {
serial_number: rc.serial_number,
revocation_date: rc.revocation_date,
});
}
validate_time_encoding_rfc5280("thisUpdate", &self.this_update)?;
let next_update = self.next_update.ok_or(CrlProfileError::NextUpdateMissing)?;
validate_time_encoding_rfc5280("nextUpdate", &next_update)?;
Ok(RpkixCrl {
raw_der: self.raw_der,
version,
issuer_dn: self.issuer_dn,
signature_algorithm_oid: OID_SHA256_WITH_RSA_ENCRYPTION.to_string(),
this_update: self.this_update,
next_update,
revoked_certs: revoked_out,
extensions,
})
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum CrlVerifyError { pub enum CrlVerifyError {
#[error("issuer certificate parse error: {0}")] #[error("issuer certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4)")]
IssuerCertificateParse(String), IssuerCertificateParse(String),
#[error("trailing bytes after issuer certificate DER: {0} bytes")] #[error("trailing bytes after issuer certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
IssuerCertificateTrailingBytes(usize), IssuerCertificateTrailingBytes(usize),
#[error("issuer SubjectPublicKeyInfo parse error: {0}")] #[error("issuer SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
IssuerSpkiParse(String), IssuerSpkiParse(String),
#[error("trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes")] #[error(
"trailing bytes after issuer SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)"
)]
IssuerSpkiTrailingBytes(usize), IssuerSpkiTrailingBytes(usize),
#[error("CRL parse error: {0}")] #[error("CRL parse error: {0} (RFC 5280 §5.1; RFC 6487 §5)")]
CrlParse(String), CrlParse(String),
#[error("trailing bytes after CRL DER: {0} bytes")] #[error("trailing bytes after CRL DER: {0} bytes (DER; RFC 5280 §5.1)")]
CrlTrailingBytes(usize), CrlTrailingBytes(usize),
#[error("CRL issuer DN does not match issuer certificate subject")] #[error(
"CRL issuer DN does not match issuer certificate subject (RFC 5280 §5.1; RFC 5280 §6.3.3(b))"
)]
IssuerSubjectMismatch { IssuerSubjectMismatch {
crl_issuer_dn: String, crl_issuer_dn: String,
issuer_subject_dn: String, issuer_subject_dn: String,
}, },
#[error("issuer certificate keyUsage present but missing cRLSign")] #[error(
"issuer certificate keyUsage present but missing cRLSign (RFC 5280 §4.2.1.3; RFC 5280 §6.3.3(f))"
)]
IssuerKeyUsageMissingCrlSign, IssuerKeyUsageMissingCrlSign,
#[error("CRL AKI.keyIdentifier does not match issuer certificate SKI")] #[error(
"CRL AKI.keyIdentifier does not match issuer certificate SKI (RFC 5280 §4.2.1.1; RFC 5280 §4.2.1.2; RFC 5280 §6.3.3(c)/(f))"
)]
AkiSkiMismatch, AkiSkiMismatch,
#[error("CRL signature verification failed: {0}")] #[error("CRL signature verification failed: {0} (RFC 5280 §6.3.3(g); RFC 7935 §2)")]
InvalidSignature(String), InvalidSignature(String),
} }
fn asn1_time_to_model(t: x509_parser::time::ASN1Time) -> Asn1TimeUtc { fn validate_time_encoding_rfc5280(
let encoding = if t.is_utctime() { field: &'static str,
Asn1TimeEncoding::UtcTime t: &Asn1TimeUtc,
} else { ) -> Result<(), CrlProfileError> {
Asn1TimeEncoding::GeneralizedTime
};
Asn1TimeUtc {
utc: t.to_datetime(),
encoding,
}
}
fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), CrlDecodeError> {
let year = t.utc.year(); let year = t.utc.year();
let expected = if year <= 2049 { let expected = if year <= 2049 {
Asn1TimeEncoding::UtcTime Asn1TimeEncoding::UtcTime
@@ -321,7 +410,7 @@ fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), Cr
Asn1TimeEncoding::GeneralizedTime Asn1TimeEncoding::GeneralizedTime
}; };
if t.encoding != expected { if t.encoding != expected {
return Err(CrlDecodeError::InvalidTimeEncoding { return Err(CrlProfileError::InvalidTimeEncoding {
field, field,
year, year,
encoding: t.encoding, encoding: t.encoding,
@@ -330,94 +419,109 @@ fn validate_time_encoding(field: &'static str, t: &Asn1TimeUtc) -> Result<(), Cr
Ok(()) Ok(())
} }
fn validate_sig_params(sig: &AlgorithmIdentifier<'_>) -> Result<(), CrlDecodeError> { fn algorithm_identifier_value(ai: &AlgorithmIdentifier<'_>) -> AlgorithmIdentifierValue {
match sig.parameters.as_ref() { let parameters = ai.parameters.as_ref().map(|p| AlgorithmParametersValue {
None => Ok(()), class: p.class(),
Some(p) if p.tag() == Tag::Null => Ok(()), tag: p.tag(),
Some(_p) => Err(CrlDecodeError::InvalidSignatureAlgorithmParameters), data: p.as_bytes().to_vec(),
});
AlgorithmIdentifierValue {
oid: ai.algorithm.to_id_string(),
parameters,
} }
} }
fn parse_and_validate_extensions(exts: &[X509Extension<'_>]) -> Result<CrlExtensions, CrlDecodeError> { fn parse_extensions_parse(exts: &[X509Extension<'_>]) -> Result<Vec<CrlExtensionParsed>, String> {
if exts.len() != 2 { let mut out = Vec::with_capacity(exts.len());
return Err(CrlDecodeError::InvalidExtensionsCount(exts.len()));
}
let mut authority_key_identifier: Option<Vec<u8>> = None;
let mut crl_number: Option<BigUnsigned> = None;
for ext in exts { for ext in exts {
let oid = ext.oid.to_id_string(); let oid = ext.oid.to_id_string();
match oid.as_str() { match oid.as_str() {
OID_AUTHORITY_KEY_IDENTIFIER => { OID_AUTHORITY_KEY_IDENTIFIER => {
if authority_key_identifier.is_some() { let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension() else {
return Err(CrlDecodeError::DuplicateExtension(oid)); return Err("AKI extension parse failed".to_string());
};
out.push(CrlExtensionParsed::AuthorityKeyIdentifier {
key_identifier: aki.key_identifier.as_ref().map(|k| k.0.to_vec()),
has_other_fields: aki.authority_cert_issuer.is_some()
|| aki.authority_cert_serial.is_some(),
critical: ext.critical,
});
} }
let aki = parse_aki(ext)?; OID_CRL_NUMBER => match ext.parsed_extension() {
authority_key_identifier = Some(aki); ParsedExtension::CRLNumber(n) => out.push(CrlExtensionParsed::CrlNumber {
number: n.clone(),
critical: ext.critical,
}),
_ => return Err("CRLNumber extension parse failed".to_string()),
},
_ => out.push(CrlExtensionParsed::Other {
oid,
critical: ext.critical,
}),
} }
OID_CRL_NUMBER => {
if crl_number.is_some() {
return Err(CrlDecodeError::DuplicateExtension(oid));
} }
if ext.critical { Ok(out)
return Err(CrlDecodeError::CrlNumberCritical); }
fn validate_extensions_profile(
exts: &[CrlExtensionParsed],
) -> Result<CrlExtensions, CrlProfileError> {
if exts.len() != 2 {
return Err(CrlProfileError::InvalidExtensionsCount(exts.len()));
} }
let n = parse_crl_number(ext)?;
if n.bits() > 159 { let mut seen: Vec<String> = Vec::new();
return Err(CrlDecodeError::CrlNumberOutOfRange); let mut authority_key_identifier: Option<Vec<u8>> = None;
let mut crl_number: Option<BigUnsigned> = None;
for ext in exts {
match ext {
CrlExtensionParsed::AuthorityKeyIdentifier {
key_identifier,
has_other_fields,
critical: _,
} => {
let oid = OID_AUTHORITY_KEY_IDENTIFIER.to_string();
if seen.iter().any(|s| s == &oid) {
return Err(CrlProfileError::DuplicateExtension(oid));
} }
crl_number = Some(biguint_to_big_unsigned(&n)); seen.push(oid.clone());
if *has_other_fields {
return Err(CrlProfileError::AkiHasOtherFields);
}
let ki = key_identifier
.as_ref()
.ok_or(CrlProfileError::AkiMissingKeyIdentifier)?;
authority_key_identifier = Some(ki.clone());
}
CrlExtensionParsed::CrlNumber { number, critical } => {
let oid = OID_CRL_NUMBER.to_string();
if seen.iter().any(|s| s == &oid) {
return Err(CrlProfileError::DuplicateExtension(oid));
}
seen.push(oid.clone());
if *critical {
return Err(CrlProfileError::CrlNumberCritical);
}
if number.bits() > 159 {
return Err(CrlProfileError::CrlNumberOutOfRange);
}
crl_number = Some(BigUnsigned::from_biguint(number));
}
CrlExtensionParsed::Other { oid, .. } => {
return Err(CrlProfileError::UnsupportedExtension(oid.clone()));
} }
_ => return Err(CrlDecodeError::UnsupportedExtension(oid)),
} }
} }
Ok(CrlExtensions { Ok(CrlExtensions {
authority_key_identifier: authority_key_identifier.unwrap(), authority_key_identifier: authority_key_identifier.ok_or(CrlProfileError::AkiMissing)?,
crl_number: crl_number.unwrap(), crl_number: crl_number.ok_or(CrlProfileError::CrlNumberMissing)?,
}) })
} }
fn parse_aki(ext: &X509Extension<'_>) -> Result<Vec<u8>, CrlDecodeError> {
let ParsedExtension::AuthorityKeyIdentifier(aki) = ext.parsed_extension() else {
return Err(CrlDecodeError::Parse("AKI extension parse failed".into()));
};
validate_aki_profile(aki)?;
Ok(aki
.key_identifier
.as_ref()
.ok_or(CrlDecodeError::AkiMissingKeyIdentifier)?
.0
.to_vec())
}
fn validate_aki_profile(aki: &AuthorityKeyIdentifier<'_>) -> Result<(), CrlDecodeError> {
if aki.key_identifier.is_none() {
return Err(CrlDecodeError::AkiMissingKeyIdentifier);
}
if aki.authority_cert_issuer.is_some() || aki.authority_cert_serial.is_some() {
return Err(CrlDecodeError::AkiHasOtherFields);
}
Ok(())
}
fn parse_crl_number(ext: &X509Extension<'_>) -> Result<der_parser::num_bigint::BigUint, CrlDecodeError> {
match ext.parsed_extension() {
ParsedExtension::CRLNumber(n) => Ok(n.clone()),
ParsedExtension::ParseError { error } => Err(CrlDecodeError::Parse(error.to_string())),
_ => Err(CrlDecodeError::Parse("CRLNumber extension parse failed".into())),
}
}
fn biguint_to_big_unsigned(n: &der_parser::num_bigint::BigUint) -> BigUnsigned {
let mut bytes = n.to_bytes_be();
if bytes.is_empty() {
bytes.push(0);
}
BigUnsigned { bytes_be: bytes }
}
fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> { fn get_subject_key_identifier(cert: &X509Certificate<'_>) -> Option<Vec<u8>> {
cert.extensions() cert.extensions()
.iter() .iter()

462
src/data_model/manifest.rs Normal file
View File

@@ -0,0 +1,462 @@
use crate::data_model::common::{BigUnsigned, UtcTime};
use crate::data_model::oid::{OID_CT_RPKI_MANIFEST, OID_SHA256};
use crate::data_model::rc::ResourceCertificate;
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
use der_parser::ber::BerObjectContent;
use der_parser::der::{DerObject, Tag, parse_der};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObject {
pub signed_object: RpkiSignedObject,
pub econtent_type: String,
pub manifest: ManifestEContent,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub manifest: Option<ManifestEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestEContent {
pub version: u32,
pub manifest_number: BigUnsigned,
pub this_update: UtcTime,
pub next_update: UtcTime,
pub file_hash_alg: String,
pub files: Vec<FileAndHash>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ManifestEContentParsed {
der: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileAndHash {
pub file_name: String,
pub hash_bytes: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("DER parse error: {0} (RFC 9286 §4.2; DER)")]
Parse(String),
#[error("trailing bytes after DER object: {0} bytes (RFC 9286 §4.2; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error(
"eContentType must be id-ct-rpkiManifest ({OID_CT_RPKI_MANIFEST}), got {0} (RFC 9286 §4.1; RFC 9286 §4.4(1))"
)]
InvalidEContentType(String),
#[error("manifest profile decode error: {0} (RFC 9286 §4.2; DER)")]
ProfileDecode(String),
#[error("Manifest must be a SEQUENCE of 5 or 6 elements, got {0} (RFC 9286 §4.2)")]
InvalidManifestSequenceLen(usize),
#[error("Manifest.version must be 0, got {0} (RFC 9286 §4.2.1)")]
InvalidManifestVersion(u64),
#[error(
"Manifest.manifestNumber must be non-negative INTEGER (RFC 9286 §4.2; RFC 9286 §4.2.1)"
)]
InvalidManifestNumber,
#[error("Manifest.manifestNumber longer than 20 octets (RFC 9286 §4.2.1)")]
ManifestNumberTooLong,
#[error("Manifest.thisUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
InvalidThisUpdate,
#[error("Manifest.nextUpdate must be GeneralizedTime (RFC 9286 §4.2)")]
InvalidNextUpdate,
#[error("Manifest.nextUpdate must be later than thisUpdate (RFC 9286 §4.2.1)")]
NextUpdateNotLater,
#[error(
"Manifest.fileHashAlg must be id-sha256 ({OID_SHA256}), got {0} (RFC 9286 §4.2.1; RFC 7935 §2)"
)]
InvalidFileHashAlg(String),
#[error("Manifest.fileList must be a SEQUENCE (RFC 9286 §4.2)")]
InvalidFileList,
#[error("FileAndHash must be SEQUENCE of 2 (RFC 9286 §4.2)")]
InvalidFileAndHash,
#[error("fileList file name invalid: {0} (RFC 9286 §4.2.2)")]
InvalidFileName(String),
#[error("fileList hash must be BIT STRING (RFC 9286 §4.2)")]
InvalidHashType,
#[error(
"fileList hash BIT STRING must be octet-aligned (unused bits=0) (RFC 9286 §4.2.1; DER BIT STRING)"
)]
HashNotOctetAligned,
#[error(
"fileList hash length invalid for sha256: got {0} bytes (RFC 9286 §4.2.1; RFC 7935 §2)"
)]
InvalidHashLength(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestDecodeError {
#[error("{0}")]
Parse(#[from] ManifestParseError),
#[error("{0}")]
Validate(#[from] ManifestProfileError),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestValidateError {
#[error(
"Manifest EE certificate MUST include at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 3779; RFC 9286 §5.1)"
)]
EeResourcesMissing,
#[error(
"Manifest EE certificate IP resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §2.2.3.5)"
)]
EeIpResourcesNotInherit,
#[error(
"Manifest EE certificate AS resources MUST use inherit only (RFC 9286 §5.1; RFC 3779 §3.2.3.3)"
)]
EeAsResourcesNotInherit,
#[error(
"Manifest EE certificate AS resources rdi MUST be absent (RFC 6487 §4.8.11; RFC 3779 §3.2.3.5)"
)]
EeAsResourcesRdiPresent,
}
impl ManifestObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestObjectParsed, ManifestParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let manifest = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(ManifestEContent::parse_der)
.transpose()?;
Ok(ManifestObjectParsed {
signed_object,
econtent_type,
manifest,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ManifestObject` is already profile-validated when constructed via `decode_der()` /
/// `ManifestObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
pub fn from_signed_object(
signed_object: RpkiSignedObject,
) -> Result<Self, ManifestDecodeError> {
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_RPKI_MANIFEST {
return Err(ManifestProfileError::InvalidEContentType(econtent_type).into());
}
let manifest =
ManifestEContent::decode_der(&signed_object.signed_data.encap_content_info.econtent)?;
Ok(Self {
signed_object,
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
manifest,
})
}
/// Validate the embedded EE certificate resources against RFC 9286 §5.1.
///
/// This does **not** perform certificate path validation. It assumes `ee` is a parsed and
/// profile-validated RPKI EE resource certificate.
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), ManifestValidateError> {
let ip = ee.tbs.extensions.ip_resources.as_ref();
let asn = ee.tbs.extensions.as_resources.as_ref();
if ip.is_none() && asn.is_none() {
return Err(ManifestValidateError::EeResourcesMissing);
}
if let Some(ip) = ip {
if !ip.is_all_inherit() {
return Err(ManifestValidateError::EeIpResourcesNotInherit);
}
}
if let Some(asn) = asn {
if asn.rdi.is_some() {
return Err(ManifestValidateError::EeAsResourcesRdiPresent);
}
if !asn.is_asnum_inherit() {
return Err(ManifestValidateError::EeAsResourcesNotInherit);
}
}
Ok(())
}
/// Validate this manifest's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), ManifestValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.validate_against_ee_cert(ee)
}
}
impl ManifestEContent {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<ManifestEContentParsed, ManifestParseError> {
let (rem, _obj) = parse_der(der).map_err(|e| ManifestParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(ManifestParseError::TrailingBytes(rem.len()));
}
Ok(ManifestEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `ManifestEContent` is already profile-validated when constructed via `decode_der()` /
/// `ManifestEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), ManifestProfileError> {
Ok(())
}
/// Decode the DER-encoded Manifest eContent defined in RFC 9286 §4.2 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, ManifestDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
}
impl ManifestObjectParsed {
pub fn validate_profile(self) -> Result<ManifestObject, ManifestProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_RPKI_MANIFEST {
return Err(ManifestProfileError::InvalidEContentType(econtent_type));
}
let manifest = self
.manifest
.ok_or_else(|| ManifestProfileError::ProfileDecode("Manifest.eContent missing".into()))?
.validate_profile()?;
Ok(ManifestObject {
signed_object,
econtent_type: OID_CT_RPKI_MANIFEST.to_string(),
manifest,
})
}
}
impl ManifestEContentParsed {
pub fn validate_profile(self) -> Result<ManifestEContent, ManifestProfileError> {
let (_rem, obj) =
parse_der(&self.der).map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
let seq = obj
.as_sequence()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 5 && seq.len() != 6 {
return Err(ManifestProfileError::InvalidManifestSequenceLen(seq.len()));
}
let mut idx = 0;
let mut version: u32 = 0;
if seq.len() == 6 {
let v_obj = &seq[0];
if v_obj.class() != der_parser::ber::Class::ContextSpecific || v_obj.tag() != Tag(0) {
return Err(ManifestProfileError::ProfileDecode(
"Manifest.version must be [0] EXPLICIT INTEGER".into(),
));
}
let inner_der = v_obj
.as_slice()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
let (rem, inner) = parse_der(inner_der)
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if !rem.is_empty() {
return Err(ManifestProfileError::ProfileDecode(
"trailing bytes inside Manifest.version".into(),
));
}
let v = inner
.as_u64()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if v != 0 {
return Err(ManifestProfileError::InvalidManifestVersion(v));
}
version = 0;
idx = 1;
}
let manifest_number = parse_manifest_number(&seq[idx])?;
idx += 1;
let this_update =
parse_generalized_time(&seq[idx], ManifestProfileError::InvalidThisUpdate)?;
idx += 1;
let next_update =
parse_generalized_time(&seq[idx], ManifestProfileError::InvalidNextUpdate)?;
idx += 1;
if next_update <= this_update {
return Err(ManifestProfileError::NextUpdateNotLater);
}
let file_hash_alg = oid_to_string(&seq[idx])?;
idx += 1;
if file_hash_alg != OID_SHA256 {
return Err(ManifestProfileError::InvalidFileHashAlg(file_hash_alg));
}
let files = parse_file_list_sha256(&seq[idx])?;
Ok(ManifestEContent {
version,
manifest_number,
this_update,
next_update,
file_hash_alg: OID_SHA256.to_string(),
files,
})
}
}
fn parse_manifest_number(obj: &DerObject<'_>) -> Result<BigUnsigned, ManifestProfileError> {
let n = obj
.as_biguint()
.map_err(|_e| ManifestProfileError::InvalidManifestNumber)?;
let out = BigUnsigned::from_biguint(&n);
if out.bytes_be.len() > 20 {
return Err(ManifestProfileError::ManifestNumberTooLong);
}
Ok(out)
}
fn parse_generalized_time(
obj: &DerObject<'_>,
err: ManifestProfileError,
) -> Result<UtcTime, ManifestProfileError> {
match &obj.content {
BerObjectContent::GeneralizedTime(dt) => dt
.to_datetime()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string())),
_ => Err(err),
}
}
fn parse_file_list_sha256(obj: &DerObject<'_>) -> Result<Vec<FileAndHash>, ManifestProfileError> {
let seq = obj
.as_sequence()
.map_err(|_e| ManifestProfileError::InvalidFileList)?;
let mut out = Vec::with_capacity(seq.len());
for entry in seq {
let (file_name, hash_bytes) = parse_file_and_hash(entry)?;
validate_file_name(&file_name)?;
if hash_bytes.len() != 32 {
return Err(ManifestProfileError::InvalidHashLength(hash_bytes.len()));
}
out.push(FileAndHash {
file_name,
hash_bytes,
});
}
Ok(out)
}
fn parse_file_and_hash(obj: &DerObject<'_>) -> Result<(String, Vec<u8>), ManifestProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 2 {
return Err(ManifestProfileError::InvalidFileAndHash);
}
let file_name = seq[0]
.as_str()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?
.to_string();
let (unused_bits, bits) = match &seq[1].content {
BerObjectContent::BitString(unused, bso) => (*unused, bso.data.to_vec()),
_ => return Err(ManifestProfileError::InvalidHashType),
};
if unused_bits != 0 {
return Err(ManifestProfileError::HashNotOctetAligned);
}
Ok((file_name, bits))
}
fn validate_file_name(name: &str) -> Result<(), ManifestProfileError> {
// RFC 9286 §4.2.2:
// 1+ chars from a-zA-Z0-9-_ , then '.', then 3-letter extension.
let Some((base, ext)) = name.rsplit_once('.') else {
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
};
if base.is_empty() || ext.len() != 3 {
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
if !base
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
if !ext.bytes().all(|b| b.is_ascii_alphabetic()) {
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
let ext_lower = ext.to_ascii_lowercase();
if !crate::data_model::common::IANA_RPKI_REPOSITORY_FILENAME_EXTENSIONS
.iter()
.any(|&e| e == ext_lower)
{
return Err(ManifestProfileError::InvalidFileName(name.to_string()));
}
Ok(())
}
fn oid_to_string(obj: &DerObject<'_>) -> Result<String, ManifestProfileError> {
let oid = obj
.as_oid()
.map_err(|e| ManifestProfileError::ProfileDecode(e.to_string()))?;
Ok(oid.to_id_string())
}

View File

@@ -1,6 +1,10 @@
pub mod aspa;
pub mod common;
pub mod crl; pub mod crl;
mod rc; pub mod manifest;
mod tal; pub mod oid;
mod ta; pub mod rc;
mod resources; pub mod roa;
mod oids; pub mod signed_object;
pub mod ta;
pub mod tal;

42
src/data_model/oid.rs Normal file
View File

@@ -0,0 +1,42 @@
pub const OID_SHA256: &str = "2.16.840.1.101.3.4.2.1";
pub const OID_SIGNED_DATA: &str = "1.2.840.113549.1.7.2";
pub const OID_CMS_ATTR_CONTENT_TYPE: &str = "1.2.840.113549.1.9.3";
pub const OID_CMS_ATTR_MESSAGE_DIGEST: &str = "1.2.840.113549.1.9.4";
pub const OID_CMS_ATTR_SIGNING_TIME: &str = "1.2.840.113549.1.9.5";
pub const OID_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.1";
pub const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
// X.509 extensions (RFC 5280 / RFC 6487)
pub const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19";
pub const OID_KEY_USAGE: &str = "2.5.29.15";
pub const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37";
pub const OID_CRL_DISTRIBUTION_POINTS: &str = "2.5.29.31";
pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
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";
pub const OID_AD_SIGNED_OBJECT: &str = "1.3.6.1.5.5.7.48.11";
pub const OID_AD_CA_ISSUERS: &str = "1.3.6.1.5.5.7.48.2";
pub const OID_AD_CA_REPOSITORY: &str = "1.3.6.1.5.5.7.48.5";
pub const OID_AD_RPKI_MANIFEST: &str = "1.3.6.1.5.5.7.48.10";
pub const OID_AD_RPKI_NOTIFY: &str = "1.3.6.1.5.5.7.48.13";
// RFC 3779 resource extensions (RFC 6487 profile)
pub const OID_IP_ADDR_BLOCKS: &str = "1.3.6.1.5.5.7.1.7";
pub const OID_AUTONOMOUS_SYS_IDS: &str = "1.3.6.1.5.5.7.1.8";
// RPKI CP (RFC 6484 / RFC 6487)
pub const OID_CP_IPADDR_ASNUMBER: &str = "1.3.6.1.5.5.7.14.2";

View File

@@ -1,16 +0,0 @@
pub const OID_BASIC_CONSTRAINTS: &str = "2.5.29.19";
pub const OID_SUBJECT_KEY_IDENTIFIER: &str = "2.5.29.14";
pub const OID_AUTHORITY_KEY_IDENTIFIER: &str = "2.5.29.35";
pub const OID_KEY_USAGE: &str = "2.5.29.15";
pub const OID_EXTENDED_KEY_USAGE: &str = "2.5.29.37";
pub const OID_CRL_DISTRIBUTION_POINTS: &str = "2.5.29.31";
pub const OID_AUTHORITY_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.1";
pub const OID_ACCESS_DESCRIPTION: &str = "1.3.6.1.5.5.7.48";
pub const OID_AD_CA_ISSUERS: &str = "1.3.6.1.5.5.7.48.2";
pub const OID_AD_OCSP: &str = "1.3.6.1.5.5.7.48.1";
pub const OID_SUBJECT_INFO_ACCESS: &str = "1.3.6.1.5.5.7.1.11";
pub const OID_CERTIFICATE_POLICIES: &str = "2.5.29.32";
pub const OID_SHA256_WITH_RSA_ENCRYPTION: &str = "1.2.840.113549.1.1.11";
pub const OID_IP_ADDRESS_BLOCKS: &str = "1.3.6.1.5.5.7.1.7";
pub const OID_AS_IDENTIFIERS: &str = "1.3.6.1.5.5.7.1.8";
pub const OID_RPKI_CP: &str = "1.3.6.1.5.5.7.14.2";

File diff suppressed because it is too large Load Diff

View File

@@ -1,90 +0,0 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ASIdentifiers {
pub asn: Vec<ASIdentifierChoice>
}
// ASN
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ASIdentifierChoice {
Inherit,
ASIDsOrRanges(Vec<ASIDOrRange>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ASIDOrRange {
Id(Asn),
AsRange(ASRange),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ASRange {
pub min: Asn,
pub max: Asn,
}
impl ASRange {
/// Creates a new AS number range from the smallest and largest number.
pub fn new(min: Asn, max: Asn) -> Self {
ASRange { min, max }
}
/// Returns an AS block covering all ASNs.
pub fn all() -> ASRange {
ASRange::new(Asn::MIN, Asn::MAX)
}
/// Returns the smallest AS number that is part of this range.
pub fn min(self) -> Asn {
self.min
}
/// Returns the largest AS number that is still part of this range.
pub fn max(self) -> Asn {
self.max
}
/// Returns the number of ASNs covered by this value.
pub fn asn_count(self) -> u32 {
u32::from(self.max) - u32::from(self.min) + 1
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct Asn(u32);
impl Asn {
pub const MIN: Asn = Asn(u32::MIN);
pub const MAX: Asn = Asn(u32::MAX);
/// Creates an AS number from a `u32`.
pub fn from_u32(value: u32) -> Self {
Asn(value)
}
/// Converts an AS number into a `u32`.
pub fn into_u32(self) -> u32 {
self.0
}
/// Converts an AS number into a network-order byte array.
pub fn to_raw(self) -> [u8; 4] {
self.0.to_be_bytes()
}
}
impl From<u32> for Asn {
fn from(id: u32) -> Self {
Asn(id)
}
}
impl From<Asn> for u32 {
fn from(id: Asn) -> Self {
id.0
}
}

View File

@@ -1,47 +0,0 @@
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IPAddrBlocks {
pub ips: Vec<IPAddressFamily>
}
// IP Address Family
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IPAddressFamily {
pub address_family: Afi,
pub ip_address_choice: IPAddressChoice,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Afi {
Ipv4,
Ipv6,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IPAddressChoice {
Inherit,
AddressOrRange(Vec<IPAddressOrRange>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum IPAddressOrRange {
AddressPrefix(IPAddressPrefix),
AddressRange(IPAddressRange),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IPAddressPrefix {
pub address: IPAddress,
pub prefix_length: u8,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IPAddressRange {
pub min: IPAddress,
pub max: IPAddress,
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct IPAddress(u128);

View File

@@ -1,3 +0,0 @@
pub(crate) mod ip_resources;
pub(crate) mod as_resources;
pub mod resource;

View File

@@ -1,10 +0,0 @@
use crate::data_model::resources::as_resources::ASIdentifiers;
use crate::data_model::resources::ip_resources::IPAddrBlocks;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceSet {
ip_addr_blocks: IPAddrBlocks,
as_identifiers: ASIdentifiers,
}

565
src/data_model/roa.rs Normal file
View File

@@ -0,0 +1,565 @@
use crate::data_model::oid::OID_CT_ROUTE_ORIGIN_AUTHZ;
use crate::data_model::rc::{Afi as RcAfi, IpPrefix as RcIpPrefix, ResourceCertificate};
use crate::data_model::signed_object::{
RpkiSignedObject, RpkiSignedObjectParsed, SignedObjectParseError, SignedObjectValidateError,
};
use der_parser::ber::{BerObjectContent, Class};
use der_parser::der::{DerObject, Tag, parse_der};
#[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 RoaObjectParsed {
pub signed_object: RpkiSignedObjectParsed,
pub econtent_type: String,
pub roa: Option<RoaEContentParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaEContent {
pub version: u32,
pub as_id: u32,
pub ip_addr_blocks: Vec<RoaIpAddressFamily>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RoaEContentParsed {
der: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum RoaParseError {
#[error("signed object parse error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectParseError),
#[error("ROA parse error: {0} (RFC 9582 §4; DER)")]
Parse(String),
#[error("ROA trailing bytes: {0} bytes (RFC 9582 §4; DER)")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum RoaProfileError {
#[error("signed object profile error: {0} (RFC 6488 §2-§3; RFC 9589 §4)")]
SignedObject(#[from] SignedObjectValidateError),
#[error("ROA eContentType must be {OID_CT_ROUTE_ORIGIN_AUTHZ}, got {0} (RFC 9582 §3)")]
InvalidEContentType(String),
#[error("ROA profile decode error: {0} (RFC 9582 §4; DER)")]
ProfileDecode(String),
#[error("RouteOriginAttestation must be a SEQUENCE of 2 or 3 elements, got {0} (RFC 9582 §4)")]
InvalidAttestationSequenceLen(usize),
#[error("ROA version must be 0, got {0} (RFC 9582 §4.1)")]
InvalidVersion(u64),
#[error("ROA asID out of range (0..=4294967295), got {0} (RFC 9582 §4.2)")]
AsIdOutOfRange(u64),
#[error("ROA ipAddrBlocks must have length 1..2, got {0} (RFC 9582 §4; RFC 9582 §4.3.1)")]
InvalidIpAddrBlocksLen(usize),
#[error("ROAIPAddressFamily must be a SEQUENCE of 2 elements (RFC 9582 §4.3.1)")]
InvalidIpAddressFamily,
#[error("ROA addressFamily must be an OCTET STRING of 2 bytes (RFC 9582 §4.3.1)")]
InvalidAddressFamily,
#[error("ROA addressFamily AFI not supported: {0:02X?} (RFC 9582 §4.3.1)")]
UnsupportedAfi(Vec<u8>),
#[error("ROA contains duplicate AFI {0:?} (RFC 9582 §4.3.1)")]
DuplicateAfi(RoaAfi),
#[error("ROAAddresses must have at least one entry (RFC 9582 §4.3.2)")]
EmptyAddressList,
#[error("ROAIPAddress must be a SEQUENCE of 1..2 elements (RFC 9582 §4.3.2)")]
InvalidRoaIpAddress,
#[error("ROAIPAddress.address must be a BIT STRING (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)")]
InvalidPrefixBitString,
#[error(
"ROAIPAddress.address has invalid unused bits encoding (RFC 9582 §4.3.2.1; RFC 3779 §2.2.3.8)"
)]
InvalidPrefixUnusedBits,
#[error(
"ROAIPAddress.address prefix length {prefix_len} out of range for {afi:?} (RFC 9582 §4.3.2.1)"
)]
PrefixLenOutOfRange { afi: RoaAfi, prefix_len: u16 },
#[error(
"ROAIPAddress.maxLength out of range for {afi:?}: prefix_len={prefix_len}, max_len={max_len} (RFC 9582 §4.3.2.2)"
)]
InvalidMaxLength {
afi: RoaAfi,
prefix_len: u16,
max_len: u16,
},
}
#[derive(Debug, thiserror::Error)]
pub enum RoaDecodeError {
#[error("{0}")]
Parse(#[from] RoaParseError),
#[error("{0}")]
Validate(#[from] RoaProfileError),
}
#[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 must contain IP resources extension (RFC 9582 §5)")]
EeIpResourcesMissing,
#[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} (RFC 9582 §5; RFC 3779 §2.3)"
)]
PrefixNotInEeResources {
afi: RoaAfi,
addr: Vec<u8>,
prefix_len: u16,
},
}
impl RoaObject {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RoaObjectParsed, RoaParseError> {
let signed_object = RpkiSignedObject::parse_der(der)?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
let roa = signed_object
.signed_data
.encap_content_info
.econtent
.as_deref()
.map(RoaEContent::parse_der)
.transpose()?;
Ok(RoaObjectParsed {
signed_object,
econtent_type,
roa,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RoaObject` is already profile-validated when constructed via `decode_der()` /
/// `RoaObjectParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
Ok(())
}
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
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(RoaProfileError::InvalidEContentType(econtent_type).into());
}
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(),
})
}
/// Validate this ROA's embedded EE certificate resources.
pub fn validate_embedded_ee_cert(&self) -> Result<(), RoaValidateError> {
let ee = &self.signed_object.signed_data.certificates[0].resource_cert;
self.roa.validate_against_ee_cert(ee)
}
}
#[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 {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<RoaEContentParsed, RoaParseError> {
let (rem, _obj) = parse_der(der).map_err(|e| RoaParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(RoaParseError::TrailingBytes(rem.len()));
}
Ok(RoaEContentParsed { der: der.to_vec() })
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `RoaEContent` is already profile-validated when constructed via `decode_der()` /
/// `RoaEContentParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), RoaProfileError> {
Ok(())
}
/// Decode the DER-encoded RouteOriginAttestation defined in RFC 9582 §4 (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, RoaDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
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 the embedded EE resource certificate (RFC 9582 §5).
///
/// This performs the EE/payload semantic checks that do not require certificate path
/// validation.
pub fn validate_against_ee_cert(
&self,
ee: &ResourceCertificate,
) -> Result<(), RoaValidateError> {
if ee.tbs.extensions.as_resources.is_some() {
return Err(RoaValidateError::EeAsResourcesPresent);
}
let ip = ee
.tbs
.extensions
.ip_resources
.as_ref()
.ok_or(RoaValidateError::EeIpResourcesMissing)?;
if ip.has_any_inherit() {
return Err(RoaValidateError::EeIpResourcesInherit);
}
for fam in &self.ip_addr_blocks {
for entry in &fam.addresses {
let rc_prefix = roa_prefix_to_rc(&entry.prefix);
if !ip.contains_prefix(&rc_prefix) {
return Err(RoaValidateError::PrefixNotInEeResources {
afi: entry.prefix.afi,
addr: entry.prefix.addr.clone(),
prefix_len: entry.prefix.prefix_len,
});
}
}
}
Ok(())
}
}
impl RoaObjectParsed {
pub fn validate_profile(self) -> Result<RoaObject, RoaProfileError> {
let signed_object = self.signed_object.validate_profile()?;
let econtent_type = signed_object
.signed_data
.encap_content_info
.econtent_type
.clone();
if econtent_type != OID_CT_ROUTE_ORIGIN_AUTHZ {
return Err(RoaProfileError::InvalidEContentType(econtent_type));
}
let roa = self
.roa
.ok_or_else(|| RoaProfileError::ProfileDecode("ROA.eContent missing".into()))?
.validate_profile()?;
Ok(RoaObject {
signed_object,
econtent_type: OID_CT_ROUTE_ORIGIN_AUTHZ.to_string(),
roa,
})
}
}
impl RoaEContentParsed {
pub fn validate_profile(self) -> Result<RoaEContent, RoaProfileError> {
let (_rem, obj) =
parse_der(&self.der).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
let seq = obj
.as_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 2 && seq.len() != 3 {
return Err(RoaProfileError::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(RoaProfileError::ProfileDecode(
"RouteOriginAttestation.version must be [0] EXPLICIT INTEGER".into(),
));
}
let inner_der = v_obj
.as_slice()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
let (rem, inner) =
parse_der(inner_der).map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if !rem.is_empty() {
return Err(RoaProfileError::ProfileDecode(
"trailing bytes inside RouteOriginAttestation.version".into(),
));
}
let v = inner
.as_u64()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if v != 0 {
return Err(RoaProfileError::InvalidVersion(v));
}
version = 0;
idx = 1;
}
let as_id_u64 = seq[idx]
.as_u64()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if as_id_u64 > u32::MAX as u64 {
return Err(RoaProfileError::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 = RoaEContent {
version,
as_id,
ip_addr_blocks,
};
out.canonicalize();
Ok(out)
}
}
fn roa_prefix_to_rc(p: &IpPrefix) -> RcIpPrefix {
let afi = match p.afi {
RoaAfi::Ipv4 => RcAfi::Ipv4,
RoaAfi::Ipv6 => RcAfi::Ipv6,
};
RcIpPrefix {
afi,
prefix_len: p.prefix_len,
addr: p.addr.clone(),
}
}
fn parse_ip_addr_blocks(obj: &DerObject<'_>) -> Result<Vec<RoaIpAddressFamily>, RoaProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if seq.is_empty() || seq.len() > 2 {
return Err(RoaProfileError::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(RoaProfileError::DuplicateAfi(family.afi));
}
out.push(family);
}
Ok(out)
}
fn parse_ip_address_family(obj: &DerObject<'_>) -> Result<RoaIpAddressFamily, RoaProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if seq.len() != 2 {
return Err(RoaProfileError::InvalidIpAddressFamily);
}
let afi = parse_afi(&seq[0])?;
let addresses = parse_roa_addresses(afi, &seq[1])?;
if addresses.is_empty() {
return Err(RoaProfileError::EmptyAddressList);
}
Ok(RoaIpAddressFamily { afi, addresses })
}
fn parse_afi(obj: &DerObject<'_>) -> Result<RoaAfi, RoaProfileError> {
let bytes = obj
.as_slice()
.map_err(|_e| RoaProfileError::InvalidAddressFamily)?;
if bytes.len() != 2 {
return Err(RoaProfileError::InvalidAddressFamily);
}
match bytes {
[0x00, 0x01] => Ok(RoaAfi::Ipv4),
[0x00, 0x02] => Ok(RoaAfi::Ipv6),
_ => Err(RoaProfileError::UnsupportedAfi(bytes.to_vec())),
}
}
fn parse_roa_addresses(
afi: RoaAfi,
obj: &DerObject<'_>,
) -> Result<Vec<RoaIpAddress>, RoaProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(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, RoaProfileError> {
let seq = obj
.as_sequence()
.map_err(|e| RoaProfileError::ProfileDecode(e.to_string()))?;
if seq.is_empty() || seq.len() > 2 {
return Err(RoaProfileError::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| RoaProfileError::ProfileDecode(e.to_string()))?;
let max_len: u16 = v
.try_into()
.map_err(|_e| RoaProfileError::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(RoaProfileError::InvalidMaxLength {
afi,
prefix_len: prefix.prefix_len,
max_len,
});
}
}
Ok(RoaIpAddress { prefix, max_length })
}
fn parse_prefix_bits(afi: RoaAfi, obj: &DerObject<'_>) -> Result<IpPrefix, RoaProfileError> {
let (unused_bits, bytes) = match &obj.content {
BerObjectContent::BitString(unused, bso) => (*unused, bso.data.to_vec()),
_ => return Err(RoaProfileError::InvalidPrefixBitString),
};
if unused_bits > 7 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
if bytes.is_empty() {
if unused_bits != 0 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
} else if unused_bits != 0 {
let mask = (1u8 << unused_bits) - 1;
if (bytes[bytes.len() - 1] & mask) != 0 {
return Err(RoaProfileError::InvalidPrefixUnusedBits);
}
}
let prefix_len = (bytes.len() * 8)
.checked_sub(unused_bits as usize)
.ok_or(RoaProfileError::InvalidPrefixUnusedBits)? as u16;
if prefix_len > afi.ub() {
return Err(RoaProfileError::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
}

View File

@@ -0,0 +1,992 @@
use crate::data_model::common::{Asn1TimeEncoding, Asn1TimeUtc};
use crate::data_model::oid::{
OID_AD_SIGNED_OBJECT, OID_CMS_ATTR_CONTENT_TYPE, OID_CMS_ATTR_MESSAGE_DIGEST,
OID_CMS_ATTR_SIGNING_TIME, OID_RSA_ENCRYPTION, OID_SHA256, OID_SHA256_WITH_RSA_ENCRYPTION,
OID_SIGNED_DATA, OID_SUBJECT_INFO_ACCESS,
};
use crate::data_model::rc::{ResourceCertificate, SubjectInfoAccess};
use der_parser::ber::Class;
use der_parser::der::{DerObject, Tag, parse_der};
use sha2::{Digest, Sha256};
use x509_parser::prelude::FromDer;
use x509_parser::public_key::PublicKey;
use x509_parser::x509::SubjectPublicKeyInfo;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceEeCertificate {
pub raw_der: Vec<u8>,
pub subject_key_identifier: Vec<u8>,
pub spki_der: Vec<u8>,
pub sia_signed_object_uris: Vec<String>,
pub resource_cert: ResourceCertificate,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkiSignedObject {
pub raw_der: Vec<u8>,
pub content_info_content_type: String,
pub signed_data: SignedDataProfiled,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedDataProfiled {
pub version: u32,
pub digest_algorithms: Vec<String>,
pub encap_content_info: EncapsulatedContentInfo,
pub certificates: Vec<ResourceEeCertificate>,
pub crls_present: bool,
pub signer_infos: Vec<SignerInfoProfiled>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncapsulatedContentInfo {
pub econtent_type: String,
pub econtent: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignerInfoProfiled {
pub version: u32,
pub sid_ski: Vec<u8>,
pub digest_algorithm: String,
pub signature_algorithm: String,
pub signed_attrs: SignedAttrsProfiled,
pub unsigned_attrs_present: bool,
pub signature: Vec<u8>,
pub signed_attrs_der_for_signature: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedAttrsProfiled {
pub content_type: String,
pub message_digest: Vec<u8>,
pub signing_time: Asn1TimeUtc,
pub other_attrs_present: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RpkiSignedObjectParsed {
pub raw_der: Vec<u8>,
pub content_info_content_type: String,
pub signed_data: SignedDataParsed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedDataParsed {
pub version: u64,
pub digest_algorithms: Vec<AlgorithmIdentifierParsed>,
pub encap_content_info: EncapsulatedContentInfoParsed,
pub certificates: Option<Vec<Vec<u8>>>,
pub crls_present: bool,
pub signer_infos: Vec<SignerInfoParsed>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AlgorithmIdentifierParsed {
pub oid: String,
pub params_ok: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct EncapsulatedContentInfoParsed {
pub econtent_type: String,
pub econtent: Option<Vec<u8>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignerInfoParsed {
pub version: u64,
pub sid: SignerIdentifierParsed,
pub digest_algorithm: AlgorithmIdentifierParsed,
pub signature_algorithm: AlgorithmIdentifierParsed,
pub signed_attrs_content: Option<Vec<u8>>,
pub signed_attrs_der_for_signature: Option<Vec<u8>>,
pub unsigned_attrs_present: bool,
pub signature: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SignerIdentifierParsed {
SubjectKeyIdentifier(Vec<u8>),
Other,
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectParseError {
#[error("DER parse error: {0} (RFC 6488 §2; RFC 6488 §3(1l); RFC 5652 §3/§5)")]
Parse(String),
#[error("trailing bytes after DER object: {0} bytes (DER; RFC 6488 §3(1l))")]
TrailingBytes(usize),
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectValidateError {
#[error(
"ContentInfo.contentType must be SignedData ({OID_SIGNED_DATA}), got {0} (RFC 6488 §3(1a); RFC 5652 §3)"
)]
InvalidContentInfoContentType(String),
#[error(
"SignedData.version must be 3, got {0} (RFC 6488 §2.1.1; RFC 6488 §3(1b); RFC 5652 §5.1)"
)]
InvalidSignedDataVersion(u64),
#[error(
"SignedData.digestAlgorithms must contain exactly one AlgorithmIdentifier, got {0} (RFC 6488 §2.1.2; RFC 6488 §3(1b); RFC 5652 §5.1)"
)]
InvalidDigestAlgorithmsCount(usize),
#[error(
"digest algorithm must be id-sha256 ({OID_SHA256}), got {0} (RFC 6488 §2.1.2; RFC 6488 §3(1b); RFC 7935 §2)"
)]
InvalidDigestAlgorithm(String),
#[error("SignedData.certificates MUST be present (RFC 6488 §3(1c); RFC 5652 §5.1)")]
CertificatesMissing,
#[error(
"SignedData.certificates must contain exactly one EE certificate, got {0} (RFC 6488 §3(1c))"
)]
InvalidCertificatesCount(usize),
#[error("SignedData.crls MUST be omitted (RFC 6488 §3(1d))")]
CrlsPresent,
#[error(
"SignedData.signerInfos must contain exactly one SignerInfo, got {0} (RFC 6488 §2.1; RFC 6488 §3(1e); RFC 5652 §5.1)"
)]
InvalidSignerInfosCount(usize),
#[error("SignerInfo.version must be 3, got {0} (RFC 6488 §3(1e); RFC 5652 §5.3)")]
InvalidSignerInfoVersion(u64),
#[error("SignerInfo.sid must be subjectKeyIdentifier [0] (RFC 6488 §3(1c); RFC 5652 §5.3)")]
InvalidSignerIdentifier,
#[error(
"SignerInfo.digestAlgorithm must be id-sha256 ({OID_SHA256}), got {0} (RFC 6488 §3(1j); RFC 7935 §2)"
)]
InvalidSignerInfoDigestAlgorithm(String),
#[error("SignerInfo.signedAttrs MUST be present (RFC 9589 §4; RFC 6488 §3(1f))")]
SignedAttrsMissing,
#[error("SignerInfo.unsignedAttrs MUST be omitted (RFC 6488 §3(1i))")]
UnsignedAttrsPresent,
#[error(
"SignerInfo.signatureAlgorithm must be rsaEncryption ({OID_RSA_ENCRYPTION}) or \
sha256WithRSAEncryption ({OID_SHA256_WITH_RSA_ENCRYPTION}), got {0} (RFC 6488 §3(1k); RFC 7935 §2)"
)]
InvalidSignatureAlgorithm(String),
#[error(
"SignerInfo.signatureAlgorithm parameters must be absent or NULL (RFC 5280 §4.1.1.2; RFC 7935 §2)"
)]
InvalidSignatureAlgorithmParameters,
#[error("signedAttrs contains unsupported attribute OID {0} (RFC 9589 §4; RFC 6488 §2.1.6.4)")]
UnsupportedSignedAttribute(String),
#[error("signedAttrs contains duplicate attribute OID {0} (RFC 6488 §2.1.6.4; RFC 9589 §4)")]
DuplicateSignedAttribute(String),
#[error("signedAttrs parse error: {0} (RFC 5652 §5.3; RFC 6488 §3(1f); RFC 9589 §4)")]
SignedAttrsParse(String),
#[error(
"signedAttrs attribute {oid} attrValues must contain exactly one value, got {count} (RFC 6488 §2.1.6.4; RFC 5652 §5.3)"
)]
InvalidSignedAttributeValuesCount { oid: String, count: usize },
#[error(
"signedAttrs missing content-type attribute (RFC 9589 §4; RFC 5652 §11.1; RFC 6488 §2.1.6.4)"
)]
SignedAttrsContentTypeMissing,
#[error(
"signedAttrs missing message-digest attribute (RFC 9589 §4; RFC 5652 §11.2; RFC 6488 §2.1.6.4)"
)]
SignedAttrsMessageDigestMissing,
#[error(
"signedAttrs missing signing-time attribute (RFC 9589 §4; RFC 5652 §11.3; RFC 6488 §2.1.6.4)"
)]
SignedAttrsSigningTimeMissing,
#[error(
"signedAttrs.content-type attrValues must equal eContentType ({econtent_type}), got {attr_content_type} (RFC 6488 §3(1h); RFC 9589 §4)"
)]
ContentTypeAttrMismatch {
econtent_type: String,
attr_content_type: String,
},
#[error("EncapsulatedContentInfo.eContent MUST be present (RFC 6488 §2.1.3; RFC 5652 §5.2)")]
EContentMissing,
#[error(
"signedAttrs.message-digest does not match SHA-256(eContent) (RFC 6488 §3(1f); RFC 5652 §11.2)"
)]
MessageDigestMismatch,
#[error("EE certificate parse error: {0} (RFC 6488 §3(1c); RFC 6487 §4)")]
EeCertificateParse(String),
#[error(
"EE certificate missing SubjectKeyIdentifier extension (RFC 6488 §3(1c); RFC 6487 §4.8.2)"
)]
EeCertificateMissingSki,
#[error(
"EE certificate missing SubjectInfoAccess extension ({OID_SUBJECT_INFO_ACCESS}) (RFC 6487 §4.8.8.2)"
)]
EeCertificateMissingSia,
#[error(
"EE certificate SIA missing id-ad-signedObject access method ({OID_AD_SIGNED_OBJECT}) (RFC 6487 §4.8.8.2)"
)]
EeCertificateMissingSignedObjectSia,
#[error(
"EE certificate SIA id-ad-signedObject accessLocation must be a URI (RFC 6487 §4.8.8.2; RFC 5280 §4.2.2.2)"
)]
EeCertificateSignedObjectSiaNotUri,
#[error(
"EE certificate SIA id-ad-signedObject must include at least one rsync:// URI (RFC 6487 §4.8.8.2)"
)]
EeCertificateSignedObjectSiaNoRsync,
#[error(
"SignerInfo.sid SKI does not match EE certificate SKI (RFC 6488 §3(1c); RFC 5652 §5.3)"
)]
SidSkiMismatch,
#[error(
"invalid signing-time attribute value (expected UTCTime or GeneralizedTime) (RFC 5652 §11.3; RFC 9589 §4)"
)]
InvalidSigningTimeValue,
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectDecodeError {
#[error("SignedObject parse error: {0}")]
Parse(#[from] SignedObjectParseError),
#[error("SignedObject validate error: {0}")]
Validate(#[from] SignedObjectValidateError),
}
#[derive(Debug, thiserror::Error)]
pub enum SignedObjectVerifyError {
#[error("EE SubjectPublicKeyInfo parse error: {0} (RFC 5280 §4.1.2.7)")]
EeSpkiParse(String),
#[error("trailing bytes after EE SubjectPublicKeyInfo DER: {0} bytes (DER; RFC 5280 §4.1.2.7)")]
EeSpkiTrailingBytes(usize),
#[error("unsupported EE public key algorithm (only RSA supported in M3) (RFC 7935 §2)")]
UnsupportedEePublicKeyAlgorithm,
#[error("EE RSA public exponent invalid (RFC 8017 §A.1.1; RFC 7935 §2)")]
InvalidEeRsaExponent,
#[error("signature verification failed (RFC 6488 §3(2)-(3); RFC 5652 §5.3; RFC 7935 §2)")]
InvalidSignature,
}
impl RpkiSignedObject {
/// Parse a DER-encoded RPKI Signed Object (CMS ContentInfo wrapping SignedData).
///
/// This performs encoding/structure parsing only. Profile constraints are enforced by
/// `RpkiSignedObjectParsed::validate_profile`.
pub fn parse_der(der: &[u8]) -> Result<RpkiSignedObjectParsed, SignedObjectParseError> {
let (rem, obj) =
parse_der(der).map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectParseError::TrailingBytes(rem.len()));
}
let content_info_seq = obj
.as_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if content_info_seq.len() != 2 {
return Err(SignedObjectParseError::Parse(
"ContentInfo must be a SEQUENCE of 2 elements".into(),
));
}
let content_type = oid_to_string_parse(&content_info_seq[0])?;
let signed_data = parse_signed_data_from_contentinfo_parse(&content_info_seq[1])?;
Ok(RpkiSignedObjectParsed {
raw_der: der.to_vec(),
content_info_content_type: content_type,
signed_data,
})
}
/// Decode a DER-encoded RPKI Signed Object (CMS ContentInfo wrapping SignedData) and enforce
/// the profile constraints from RFC 6488 §2-§3 and RFC 9589 §4.
pub fn decode_der(der: &[u8]) -> Result<Self, SignedObjectDecodeError> {
let parsed = Self::parse_der(der)?;
Ok(parsed.validate_profile()?)
}
/// Scheme-A naming for signature verification.
pub fn verify(&self) -> Result<(), SignedObjectVerifyError> {
self.verify_signature()
}
/// Verify the CMS signature using the embedded EE certificate public key.
pub fn verify_signature(&self) -> Result<(), SignedObjectVerifyError> {
let ee = &self.signed_data.certificates[0];
self.verify_signature_with_ee_spki_der(&ee.spki_der)
}
/// Verify the CMS signature using a DER-encoded SubjectPublicKeyInfo.
pub fn verify_signature_with_ee_spki_der(
&self,
ee_spki_der: &[u8],
) -> Result<(), SignedObjectVerifyError> {
let (rem, spki) = SubjectPublicKeyInfo::from_der(ee_spki_der)
.map_err(|e| SignedObjectVerifyError::EeSpkiParse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectVerifyError::EeSpkiTrailingBytes(rem.len()));
}
self.verify_signature_with_ee_spki(&spki)
}
/// Verify the CMS signature using a parsed SubjectPublicKeyInfo.
pub fn verify_signature_with_ee_spki(
&self,
ee_spki: &SubjectPublicKeyInfo<'_>,
) -> Result<(), SignedObjectVerifyError> {
let pk = ee_spki
.parsed()
.map_err(|_e| SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm)?;
let (n, e) = match pk {
PublicKey::RSA(rsa) => {
let n = strip_leading_zeros(rsa.modulus).to_vec();
let e = strip_leading_zeros(rsa.exponent).to_vec();
let _exp = rsa
.try_exponent()
.map_err(|_e| SignedObjectVerifyError::InvalidEeRsaExponent)?;
(n, e)
}
_ => return Err(SignedObjectVerifyError::UnsupportedEePublicKeyAlgorithm),
};
let signer = &self.signed_data.signer_infos[0];
// The message to be verified is the DER encoding of SignedAttributes (SET OF Attribute).
let msg = &signer.signed_attrs_der_for_signature;
let pk = ring::signature::RsaPublicKeyComponents { n, e };
pk.verify(
&ring::signature::RSA_PKCS1_2048_8192_SHA256,
msg,
&signer.signature,
)
.map_err(|_e| SignedObjectVerifyError::InvalidSignature)
}
}
impl RpkiSignedObjectParsed {
pub fn validate_profile(self) -> Result<RpkiSignedObject, SignedObjectValidateError> {
if self.content_info_content_type != OID_SIGNED_DATA {
return Err(SignedObjectValidateError::InvalidContentInfoContentType(
self.content_info_content_type,
));
}
let signed_data = validate_signed_data_profile(self.signed_data)?;
Ok(RpkiSignedObject {
raw_der: self.raw_der,
content_info_content_type: OID_SIGNED_DATA.to_string(),
signed_data,
})
}
}
fn parse_signed_data_from_contentinfo_parse(
obj: &DerObject<'_>,
) -> Result<SignedDataParsed, SignedObjectParseError> {
// ContentInfo.content is `[0] EXPLICIT`, but `der-parser` will represent unknown tagged
// objects as `Unknown(Any)`. For EXPLICIT tags, the content octets are the full encoding of
// the inner object, so we parse it from the object's slice.
if obj.class() != Class::ContextSpecific || obj.tag() != Tag(0) {
return Err(SignedObjectParseError::Parse(
"ContentInfo.content must be [0] EXPLICIT".into(),
));
}
let inner_der = obj
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let (rem, inner_obj) =
parse_der(inner_der).map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectParseError::Parse(
"trailing bytes inside ContentInfo.content".into(),
));
}
parse_signed_data_parse(&inner_obj)
}
fn parse_signed_data_parse(
obj: &DerObject<'_>,
) -> Result<SignedDataParsed, SignedObjectParseError> {
let seq = obj
.as_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if seq.len() < 4 || seq.len() > 6 {
return Err(SignedObjectParseError::Parse(
"SignedData must be a SEQUENCE of 4..6 elements".into(),
));
}
let version = seq[0]
.as_u64()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let digest_set = seq[1]
.as_set()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let mut digest_algorithms: Vec<AlgorithmIdentifierParsed> =
Vec::with_capacity(digest_set.len());
for item in digest_set {
let (oid, params_ok) = parse_algorithm_identifier_parse(item)?;
digest_algorithms.push(AlgorithmIdentifierParsed { oid, params_ok });
}
let encap_content_info = parse_encapsulated_content_info_parse(&seq[2])?;
let mut certificates: Option<Vec<Vec<u8>>> = None;
let mut crls_present = false;
let mut signer_infos_obj: Option<&DerObject<'_>> = None;
for item in &seq[3..] {
if item.class() == Class::ContextSpecific && item.tag() == Tag(0) {
if certificates.is_some() {
return Err(SignedObjectParseError::Parse(
"SignedData.certificates appears more than once".into(),
));
}
certificates = Some(parse_certificate_set_implicit_parse(item)?);
} else if item.class() == Class::ContextSpecific && item.tag() == Tag(1) {
crls_present = true;
} else if item.class() == Class::Universal && item.tag() == Tag::Set {
signer_infos_obj = Some(item);
} else {
return Err(SignedObjectParseError::Parse(
"unexpected field in SignedData".into(),
));
}
}
let signer_infos_obj = signer_infos_obj
.ok_or_else(|| SignedObjectParseError::Parse("SignedData.signerInfos missing".into()))?;
let signer_infos_set = signer_infos_obj
.as_set()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let mut signer_infos: Vec<SignerInfoParsed> = Vec::with_capacity(signer_infos_set.len());
for si in signer_infos_set {
signer_infos.push(parse_signer_info_parse(si)?);
}
Ok(SignedDataParsed {
version,
digest_algorithms,
encap_content_info,
certificates,
crls_present,
signer_infos,
})
}
fn parse_encapsulated_content_info_parse(
obj: &DerObject<'_>,
) -> Result<EncapsulatedContentInfoParsed, SignedObjectParseError> {
let seq = obj
.as_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if seq.is_empty() || seq.len() > 2 {
return Err(SignedObjectParseError::Parse(
"EncapsulatedContentInfo must be SEQUENCE of 1..2".into(),
));
}
let econtent_type = oid_to_string_parse(&seq[0])?;
let econtent = match seq.get(1) {
None => None,
Some(econtent_tagged) => {
if econtent_tagged.class() != Class::ContextSpecific || econtent_tagged.tag() != Tag(0)
{
return Err(SignedObjectParseError::Parse(
"EncapsulatedContentInfo.eContent must be [0] EXPLICIT".into(),
));
}
let inner_der = econtent_tagged
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let (rem, inner_obj) =
parse_der(inner_der).map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(SignedObjectParseError::Parse(
"trailing bytes inside EncapsulatedContentInfo.eContent".into(),
));
}
Some(
inner_obj
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?
.to_vec(),
)
}
};
Ok(EncapsulatedContentInfoParsed {
econtent_type,
econtent,
})
}
fn parse_certificate_set_implicit_parse(
obj: &DerObject<'_>,
) -> Result<Vec<Vec<u8>>, SignedObjectParseError> {
let content = obj
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let mut input = content;
let mut certs = Vec::new();
while !input.is_empty() {
let (rem, _any_obj) =
parse_der(input).map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let consumed = input.len() - rem.len();
certs.push(input[..consumed].to_vec());
input = rem;
}
Ok(certs)
}
fn validate_ee_certificate(der: &[u8]) -> Result<ResourceEeCertificate, SignedObjectValidateError> {
let rc = match ResourceCertificate::from_der(der) {
Ok(v) => v,
Err(e) => {
return match e {
crate::data_model::rc::ResourceCertificateDecodeError::Validate(
crate::data_model::rc::ResourceCertificateProfileError::SignedObjectSiaNotUri,
) => Err(SignedObjectValidateError::EeCertificateSignedObjectSiaNotUri),
crate::data_model::rc::ResourceCertificateDecodeError::Validate(
crate::data_model::rc::ResourceCertificateProfileError::SignedObjectSiaNoRsync,
) => Err(SignedObjectValidateError::EeCertificateSignedObjectSiaNoRsync),
_ => Err(SignedObjectValidateError::EeCertificateParse(e.to_string())),
};
}
};
let ski = rc
.tbs
.extensions
.subject_key_identifier
.clone()
.ok_or(SignedObjectValidateError::EeCertificateMissingSki)?;
let spki_der = rc.tbs.subject_public_key_info.clone();
let sia = rc
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or(SignedObjectValidateError::EeCertificateMissingSia)?;
let signed_object_uris: Vec<String> = match sia {
SubjectInfoAccess::Ee(ee) => ee
.signed_object_uris
.iter()
.map(|u| u.as_str().to_string())
.collect(),
SubjectInfoAccess::Ca(_ca) => Vec::new(),
};
if signed_object_uris.is_empty() {
return Err(SignedObjectValidateError::EeCertificateMissingSignedObjectSia);
}
if !signed_object_uris.iter().any(|u| u.starts_with("rsync://")) {
return Err(SignedObjectValidateError::EeCertificateSignedObjectSiaNoRsync);
}
Ok(ResourceEeCertificate {
raw_der: der.to_vec(),
subject_key_identifier: ski,
spki_der,
sia_signed_object_uris: signed_object_uris,
resource_cert: rc,
})
}
fn parse_signer_info_parse(
obj: &DerObject<'_>,
) -> Result<SignerInfoParsed, SignedObjectParseError> {
let seq = obj
.as_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if seq.len() < 5 || seq.len() > 7 {
return Err(SignedObjectParseError::Parse(
"SignerInfo must be a SEQUENCE of 5..7 elements".into(),
));
}
let version = seq[0]
.as_u64()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
let sid = &seq[1];
let sid = if sid.class() == Class::ContextSpecific && sid.tag() == Tag(0) {
let ski = sid
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?
.to_vec();
SignerIdentifierParsed::SubjectKeyIdentifier(ski)
} else {
SignerIdentifierParsed::Other
};
let (digest_oid, digest_params_ok) = parse_algorithm_identifier_parse(&seq[2])?;
let digest_algorithm = AlgorithmIdentifierParsed {
oid: digest_oid,
params_ok: digest_params_ok,
};
let mut idx = 3;
let mut signed_attrs_content: Option<Vec<u8>> = None;
let mut signed_attrs_der_for_signature: Option<Vec<u8>> = None;
if seq[idx].class() == Class::ContextSpecific && seq[idx].tag() == Tag(0) {
let signed_attrs_obj = &seq[idx];
let content = signed_attrs_obj
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?
.to_vec();
signed_attrs_content = Some(content);
signed_attrs_der_for_signature =
Some(make_signed_attrs_der_for_signature_parse(signed_attrs_obj)?);
idx += 1;
}
let (signature_oid, signature_params_ok) = parse_algorithm_identifier_parse(&seq[idx])?;
let signature_algorithm = AlgorithmIdentifierParsed {
oid: signature_oid,
params_ok: signature_params_ok,
};
idx += 1;
let signature = seq[idx]
.as_slice()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?
.to_vec();
idx += 1;
let unsigned_attrs_present = seq.get(idx).is_some();
Ok(SignerInfoParsed {
version,
sid,
digest_algorithm,
signature_algorithm,
signed_attrs_content,
unsigned_attrs_present,
signature,
signed_attrs_der_for_signature,
})
}
fn validate_signed_data_profile(
signed_data: SignedDataParsed,
) -> Result<SignedDataProfiled, SignedObjectValidateError> {
if signed_data.version != 3 {
return Err(SignedObjectValidateError::InvalidSignedDataVersion(
signed_data.version,
));
}
if signed_data.digest_algorithms.len() != 1 {
return Err(SignedObjectValidateError::InvalidDigestAlgorithmsCount(
signed_data.digest_algorithms.len(),
));
}
let digest_alg = &signed_data.digest_algorithms[0];
if digest_alg.oid != OID_SHA256 {
return Err(SignedObjectValidateError::InvalidDigestAlgorithm(
digest_alg.oid.clone(),
));
}
if signed_data.crls_present {
return Err(SignedObjectValidateError::CrlsPresent);
}
let econtent = signed_data
.encap_content_info
.econtent
.clone()
.ok_or(SignedObjectValidateError::EContentMissing)?;
if econtent.is_empty() {
return Err(SignedObjectValidateError::EContentMissing);
}
let encap_content_info = EncapsulatedContentInfo {
econtent_type: signed_data.encap_content_info.econtent_type.clone(),
econtent: econtent.clone(),
};
let certs = signed_data
.certificates
.as_ref()
.ok_or(SignedObjectValidateError::CertificatesMissing)?;
if certs.len() != 1 {
return Err(SignedObjectValidateError::InvalidCertificatesCount(
certs.len(),
));
}
let ee = validate_ee_certificate(&certs[0])?;
if signed_data.signer_infos.len() != 1 {
return Err(SignedObjectValidateError::InvalidSignerInfosCount(
signed_data.signer_infos.len(),
));
}
let signer = &signed_data.signer_infos[0];
if signer.version != 3 {
return Err(SignedObjectValidateError::InvalidSignerInfoVersion(
signer.version,
));
}
let sid_ski = match &signer.sid {
SignerIdentifierParsed::SubjectKeyIdentifier(ski) => ski.clone(),
SignerIdentifierParsed::Other => {
return Err(SignedObjectValidateError::InvalidSignerIdentifier);
}
};
if signer.digest_algorithm.oid != OID_SHA256 {
return Err(SignedObjectValidateError::InvalidSignerInfoDigestAlgorithm(
signer.digest_algorithm.oid.clone(),
));
}
let signed_attrs_content = signer
.signed_attrs_content
.as_deref()
.ok_or(SignedObjectValidateError::SignedAttrsMissing)?;
let signed_attrs_der_for_signature = signer
.signed_attrs_der_for_signature
.clone()
.ok_or(SignedObjectValidateError::SignedAttrsMissing)?;
let signed_attrs = parse_signed_attrs_implicit(signed_attrs_content)?;
if signer.unsigned_attrs_present {
return Err(SignedObjectValidateError::UnsignedAttrsPresent);
}
if !signer.signature_algorithm.params_ok {
return Err(SignedObjectValidateError::InvalidSignatureAlgorithmParameters);
}
let signature_algorithm = signer.signature_algorithm.oid.clone();
if signature_algorithm != OID_RSA_ENCRYPTION
&& signature_algorithm != OID_SHA256_WITH_RSA_ENCRYPTION
{
return Err(SignedObjectValidateError::InvalidSignatureAlgorithm(
signature_algorithm,
));
}
if sid_ski != ee.subject_key_identifier {
return Err(SignedObjectValidateError::SidSkiMismatch);
}
if signed_attrs.content_type != encap_content_info.econtent_type {
return Err(SignedObjectValidateError::ContentTypeAttrMismatch {
econtent_type: encap_content_info.econtent_type.clone(),
attr_content_type: signed_attrs.content_type.clone(),
});
}
let computed = Sha256::digest(&encap_content_info.econtent).to_vec();
if computed != signed_attrs.message_digest {
return Err(SignedObjectValidateError::MessageDigestMismatch);
}
Ok(SignedDataProfiled {
version: 3,
digest_algorithms: vec![OID_SHA256.to_string()],
encap_content_info,
certificates: vec![ee.clone()],
crls_present: false,
signer_infos: vec![SignerInfoProfiled {
version: 3,
sid_ski,
digest_algorithm: OID_SHA256.to_string(),
signature_algorithm: signer.signature_algorithm.oid.clone(),
signed_attrs,
unsigned_attrs_present: false,
signature: signer.signature.clone(),
signed_attrs_der_for_signature,
}],
})
}
fn parse_signed_attrs_implicit(
input: &[u8],
) -> Result<SignedAttrsProfiled, SignedObjectValidateError> {
let mut content_type: Option<String> = None;
let mut message_digest: Option<Vec<u8>> = None;
let mut signing_time: Option<Asn1TimeUtc> = None;
let mut remaining = input;
while !remaining.is_empty() {
let (rem, attr_obj) = parse_der(remaining)
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
remaining = rem;
let attr_seq = attr_obj
.as_sequence()
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
if attr_seq.len() != 2 {
return Err(SignedObjectValidateError::SignedAttrsParse(
"Attribute must be SEQUENCE of 2".into(),
));
}
let oid = oid_to_string_parse(&attr_seq[0])
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
let values_set = attr_seq[1]
.as_set()
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
if values_set.len() != 1 {
return Err(
SignedObjectValidateError::InvalidSignedAttributeValuesCount {
oid,
count: values_set.len(),
},
);
}
match oid.as_str() {
OID_CMS_ATTR_CONTENT_TYPE => {
if content_type.is_some() {
return Err(SignedObjectValidateError::DuplicateSignedAttribute(oid));
}
let v = oid_to_string_parse(&values_set[0])
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?;
content_type = Some(v);
}
OID_CMS_ATTR_MESSAGE_DIGEST => {
if message_digest.is_some() {
return Err(SignedObjectValidateError::DuplicateSignedAttribute(oid));
}
let v = values_set[0]
.as_slice()
.map_err(|e| SignedObjectValidateError::SignedAttrsParse(e.to_string()))?
.to_vec();
message_digest = Some(v);
}
OID_CMS_ATTR_SIGNING_TIME => {
if signing_time.is_some() {
return Err(SignedObjectValidateError::DuplicateSignedAttribute(oid));
}
signing_time = Some(parse_signing_time_value(&values_set[0])?);
}
_ => {
return Err(SignedObjectValidateError::UnsupportedSignedAttribute(oid));
}
}
}
Ok(SignedAttrsProfiled {
content_type: content_type
.ok_or(SignedObjectValidateError::SignedAttrsContentTypeMissing)?,
message_digest: message_digest
.ok_or(SignedObjectValidateError::SignedAttrsMessageDigestMissing)?,
signing_time: signing_time
.ok_or(SignedObjectValidateError::SignedAttrsSigningTimeMissing)?,
other_attrs_present: false,
})
}
fn parse_signing_time_value(obj: &DerObject<'_>) -> Result<Asn1TimeUtc, SignedObjectValidateError> {
match &obj.content {
der_parser::ber::BerObjectContent::UTCTime(dt) => Ok(Asn1TimeUtc {
utc: dt
.to_datetime()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?,
encoding: Asn1TimeEncoding::UtcTime,
}),
der_parser::ber::BerObjectContent::GeneralizedTime(dt) => Ok(Asn1TimeUtc {
utc: dt
.to_datetime()
.map_err(|_| SignedObjectValidateError::InvalidSigningTimeValue)?,
encoding: Asn1TimeEncoding::GeneralizedTime,
}),
_ => Err(SignedObjectValidateError::InvalidSigningTimeValue),
}
}
fn make_signed_attrs_der_for_signature_parse(
obj: &DerObject<'_>,
) -> Result<Vec<u8>, SignedObjectParseError> {
// We need the DER encoding of SignedAttributes (SET OF Attribute) as signature input.
// The SignedAttributes field in SignerInfo is `[0] IMPLICIT`, so the on-wire bytes start with
// a context-specific constructed tag (0xA0 for tag 0). For signature verification, this tag
// is replaced with the universal SET tag (0x31), leaving length+content unchanged.
//
let mut cs_der = obj
.to_vec()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if cs_der.is_empty() {
return Err(SignedObjectParseError::Parse(
"signedAttrs encoding is empty".into(),
));
}
// The first byte should be the context-specific tag (0xA0) for [0] constructed.
// Replace it with universal SET (0x31) for signature input.
cs_der[0] = 0x31;
Ok(cs_der)
}
fn oid_to_string_parse(obj: &DerObject<'_>) -> Result<String, SignedObjectParseError> {
let oid = obj
.as_oid()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
Ok(oid.to_id_string())
}
fn parse_algorithm_identifier_parse(
obj: &DerObject<'_>,
) -> Result<(String, bool), SignedObjectParseError> {
let seq = obj
.as_sequence()
.map_err(|e| SignedObjectParseError::Parse(e.to_string()))?;
if seq.is_empty() || seq.len() > 2 {
return Err(SignedObjectParseError::Parse(
"AlgorithmIdentifier must be SEQUENCE of 1..2".into(),
));
}
let oid = oid_to_string_parse(&seq[0])?;
let params_ok = match seq.get(1) {
None => true,
Some(p) => matches!(p.content, der_parser::ber::BerObjectContent::Null),
};
Ok((oid, params_ok))
}
fn strip_leading_zeros(bytes: &[u8]) -> &[u8] {
let mut idx = 0;
while idx < bytes.len() && bytes[idx] == 0 {
idx += 1;
}
if idx == bytes.len() {
&bytes[bytes.len() - 1..]
} else {
&bytes[idx..]
}
}

View File

@@ -1,27 +1,288 @@
use url::Url; use url::Url;
use x509_parser::prelude::*; use x509_parser::prelude::{FromDer, X509Certificate};
use crate::data_model::resources::resource::ResourceSet; use crate::data_model::oid::OID_CP_IPADDR_ASNUMBER;
use crate::data_model::rc::{
AsIdentifierChoice, IpAddressChoice, ResourceCertKind, ResourceCertificate,
ResourceCertificateParseError, ResourceCertificateParsed, ResourceCertificateProfileError,
};
use crate::data_model::tal::Tal;
// #[derive(Clone, Debug, PartialEq, Eq)]
// #[derive(Debug, Clone)] pub struct TaCertificate {
// pub struct TrustAnchorCert { pub raw_der: Vec<u8>,
// /// 信任锚证书名称 pub rc_ca: ResourceCertificate,
// pub name: String, }
//
// /// 证书原始DER内容 #[derive(Debug, thiserror::Error)]
// pub cert_der: Vec<u8>, pub enum TaCertificateParseError {
// #[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 6487 §4; RFC 8630 §2.3)")]
// /// 证书 ResourceCertificate(#[from] ResourceCertificateParseError),
// pub cert: X509Certificate<'static>, }
//
// /// 资源集合 #[derive(Clone, Debug, PartialEq, Eq)]
// pub resources: ResourceSet, pub struct TaCertificateParsed {
// pub rc_parsed: ResourceCertificateParsed,
// ///发布点 }
// pub publication_point: Url,
// } #[derive(Debug, thiserror::Error)]
// pub enum TaCertificateProfileError {
// impl TrustAnchorCert { #[error("resource certificate profile error: {0} (RFC 5280 §4; RFC 6487 §4)")]
// ResourceCertificate(#[from] ResourceCertificateProfileError),
// }
#[error("TA certificate must be a CA certificate (RFC 8630 §2.3; RFC 6487 §4.8.1)")]
NotCa,
#[error(
"TA certificate must be self-signed (issuer DN must equal subject DN) (RFC 8630 §2.3; RFC 5280 §4.1.2.4)"
)]
NotSelfSignedIssuerSubject,
#[error(
"TA certificate must contain certificatePolicies ipAddr-asNumber ({OID_CP_IPADDR_ASNUMBER}) (RFC 6487 §4.8.9; RFC 8630 §2.3)"
)]
MissingOrInvalidCertificatePolicies,
#[error("TA certificate must contain SubjectKeyIdentifier (RFC 6487 §4.8.2; RFC 8630 §2.3)")]
MissingSubjectKeyIdentifier,
#[error(
"TA certificate must contain at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11; RFC 8630 §2.3)"
)]
ResourcesMissing,
#[error("TA certificate resources must be non-empty (RFC 8630 §2.3)")]
ResourcesEmpty,
#[error(
"TA certificate MUST NOT use inherit in IP resources (RFC 8630 §2.3; RFC 3779 §2.2.3.5)"
)]
IpResourcesInherit,
#[error(
"TA certificate MUST NOT use inherit in AS resources (RFC 8630 §2.3; RFC 3779 §3.2.3.3)"
)]
AsResourcesInherit,
}
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateDecodeError {
#[error("{0}")]
Parse(#[from] TaCertificateParseError),
#[error("{0}")]
Validate(#[from] TaCertificateProfileError),
}
/// Backwards-compatible name: TA certificate errors from parse+validate.
pub type TaCertificateError = TaCertificateDecodeError;
#[derive(Debug, thiserror::Error)]
pub enum TaCertificateVerifyError {
#[error("TA certificate parse error: {0} (RFC 5280 §4.1; RFC 8630 §2.3)")]
Parse(String),
#[error("trailing bytes after TA certificate DER: {0} bytes (DER; RFC 5280 §4.1)")]
TrailingBytes(usize),
#[error(
"TA certificate self-signature verification failed: {0} (RFC 8630 §2.3; RFC 5280 §6.1)"
)]
InvalidSelfSignature(String),
}
impl TaCertificate {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_der(der: &[u8]) -> Result<TaCertificateParsed, TaCertificateParseError> {
Ok(TaCertificateParsed {
rc_parsed: ResourceCertificate::parse_der(der)?,
})
}
/// Profile validate step of scheme A (`parse → validate → verify`).
///
/// `TaCertificate` is already profile-validated when constructed via `decode_der()` /
/// `TaCertificateParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), TaCertificateProfileError> {
Ok(())
}
/// Decode a TA certificate (`parse + validate`).
pub fn decode_der(der: &[u8]) -> Result<Self, TaCertificateDecodeError> {
Ok(Self::parse_der(der)?.validate_profile()?)
}
/// Backwards-compatible helper (historical name).
pub fn from_der(der: &[u8]) -> Result<Self, TaCertificateError> {
Self::decode_der(der)
}
pub fn spki_der(&self) -> &[u8] {
&self.rc_ca.tbs.subject_public_key_info
}
/// Verify step of scheme A (`parse → validate → verify`).
pub fn verify_self_signature(&self) -> Result<(), TaCertificateVerifyError> {
let (rem, cert) = X509Certificate::from_der(&self.raw_der)
.map_err(|e| TaCertificateVerifyError::Parse(e.to_string()))?;
if !rem.is_empty() {
return Err(TaCertificateVerifyError::TrailingBytes(rem.len()));
}
cert.verify_signature(None)
.map_err(|e| TaCertificateVerifyError::InvalidSelfSignature(e.to_string()))?;
Ok(())
}
/// Validate TA-specific semantic constraints on a parsed Resource Certificate.
///
/// Note: this does not verify the X.509 signature; it is intended for higher-level logic and
/// for unit tests that exercise individual constraint branches.
pub fn validate_rc_constraints(
rc_ca: &ResourceCertificate,
) -> Result<(), TaCertificateProfileError> {
if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa);
}
if rc_ca.tbs.extensions.certificate_policies_oid.as_deref() != Some(OID_CP_IPADDR_ASNUMBER)
{
return Err(TaCertificateProfileError::MissingOrInvalidCertificatePolicies);
}
if rc_ca.tbs.extensions.subject_key_identifier.is_none() {
return Err(TaCertificateProfileError::MissingSubjectKeyIdentifier);
}
let ip = rc_ca.tbs.extensions.ip_resources.as_ref();
let asn = rc_ca.tbs.extensions.as_resources.as_ref();
if ip.is_none() && asn.is_none() {
return Err(TaCertificateProfileError::ResourcesMissing);
}
let mut has_any_resource = false;
if let Some(ip) = ip {
if ip.has_any_inherit() {
return Err(TaCertificateProfileError::IpResourcesInherit);
}
for fam in &ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
return Err(TaCertificateProfileError::IpResourcesInherit);
}
IpAddressChoice::AddressesOrRanges(items) => {
if !items.is_empty() {
has_any_resource = true;
}
}
}
}
}
if let Some(asn) = asn {
if matches!(asn.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(asn.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(TaCertificateProfileError::AsResourcesInherit);
}
if let Some(AsIdentifierChoice::AsIdsOrRanges(items)) = asn.asnum.as_ref() {
if !items.is_empty() {
has_any_resource = true;
}
}
if let Some(AsIdentifierChoice::AsIdsOrRanges(items)) = asn.rdi.as_ref() {
if !items.is_empty() {
has_any_resource = true;
}
}
}
if !has_any_resource {
return Err(TaCertificateProfileError::ResourcesEmpty);
}
Ok(())
}
}
impl TaCertificateParsed {
pub fn validate_profile(self) -> Result<TaCertificate, TaCertificateProfileError> {
let rc_ca = self.rc_parsed.validate_profile()?;
if rc_ca.kind != ResourceCertKind::Ca {
return Err(TaCertificateProfileError::NotCa);
}
if rc_ca.tbs.issuer_dn != rc_ca.tbs.subject_dn {
return Err(TaCertificateProfileError::NotSelfSignedIssuerSubject);
}
TaCertificate::validate_rc_constraints(&rc_ca)?;
Ok(TaCertificate {
raw_der: rc_ca.raw_der.clone(),
rc_ca,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustAnchor {
pub tal: Tal,
pub ta_certificate: TaCertificate,
pub resolved_ta_uri: Option<Url>,
}
#[derive(Debug, thiserror::Error)]
pub enum TrustAnchorError {
#[error("TA certificate error: {0} (RFC 8630 §2.3)")]
TaCertificate(#[from] TaCertificateDecodeError),
#[error("{0}")]
Bind(#[from] TrustAnchorBindError),
}
#[derive(Debug, thiserror::Error)]
pub enum TrustAnchorBindError {
#[error("resolved TA URI not listed in TAL: {0} (RFC 8630 §2.2-§2.3)")]
ResolvedUriNotInTal(String),
#[error(
"TAL SPKI does not match TA certificate SubjectPublicKeyInfo (RFC 8630 §2.3; RFC 5280 §4.1.2.7)"
)]
TalSpkiMismatch,
}
impl TrustAnchor {
/// Bind a TAL and a downloaded TA certificate.
///
/// This does not download anything; it only validates the binding rules from RFC 8630 §2.3.
pub fn bind_der(
tal: Tal,
ta_der: &[u8],
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorError> {
let ta_certificate = TaCertificate::decode_der(ta_der)?;
Ok(Self::bind(tal, ta_certificate, resolved_uri)?)
}
pub fn bind(
tal: Tal,
ta_certificate: TaCertificate,
resolved_uri: Option<&Url>,
) -> Result<Self, TrustAnchorBindError> {
if let Some(u) = resolved_uri {
if !tal.ta_uris.iter().any(|x| x == u) {
return Err(TrustAnchorBindError::ResolvedUriNotInTal(u.to_string()));
}
}
if tal.subject_public_key_info_der != ta_certificate.spki_der() {
return Err(TrustAnchorBindError::TalSpkiMismatch);
}
Ok(Self {
tal,
ta_certificate,
resolved_ta_uri: resolved_uri.cloned(),
})
}
}

View File

@@ -1,18 +1,183 @@
/// TAL Model use base64::Engine;
#[derive(Clone, Debug)] use url::Url;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TalParsed {
pub raw: Vec<u8>,
/// Lines split by '\n' and normalized by stripping a trailing '\r' per line.
pub lines: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Tal { pub struct Tal {
/// Optional human-readable comments pub raw: Vec<u8>,
pub comments: Vec<String>, pub comments: Vec<String>,
pub ta_uris: Vec<Url>,
/// Ordered list of URIs pointing to the TA certificate pub subject_public_key_info_der: Vec<u8>,
pub uris: Vec<TalUri>,
/// SubjectPublicKeyInfo DER
pub spki_der: Vec<u8>,
} }
#[derive(Debug, Clone)] #[derive(Debug, thiserror::Error)]
pub enum TalUri { pub enum TalParseError {
Rsync(String), #[error("TAL must be valid UTF-8 (RFC 8630 §2.2)")]
Https(String), InvalidUtf8,
}
#[derive(Debug, thiserror::Error)]
pub enum TalProfileError {
#[error("TAL comments must appear only at the beginning (RFC 8630 §2.2)")]
CommentAfterHeader,
#[error("TAL must contain at least one TA URI line (RFC 8630 §2.2)")]
MissingTaUris,
#[error(
"TAL must contain an empty line separator between URI list and SPKI base64 (RFC 8630 §2.2)"
)]
MissingSeparatorEmptyLine,
#[error("TAL TA URI invalid: {0} (RFC 8630 §2.2)")]
InvalidUri(String),
#[error("TAL TA URI scheme must be rsync or https, got {0} (RFC 8630 §2.2)")]
UnsupportedUriScheme(String),
#[error(
"TAL TA URI must reference a single object (must not end with '/'): {0} (RFC 8630 §2.3)"
)]
UriIsDirectory(String),
#[error(
"TAL must contain base64-encoded SubjectPublicKeyInfo after the separator (RFC 8630 §2.2)"
)]
MissingSpki,
#[error("TAL SPKI base64 decode failed (RFC 8630 §2.2)")]
SpkiBase64Decode,
#[error("TAL SPKI DER is empty (RFC 8630 §2.2)")]
SpkiDerEmpty,
}
#[derive(Debug, thiserror::Error)]
pub enum TalDecodeError {
#[error("{0}")]
Parse(#[from] TalParseError),
#[error("{0}")]
Validate(#[from] TalProfileError),
}
impl Tal {
/// Parse step of scheme A (`parse → validate → verify`).
pub fn parse_bytes(input: &[u8]) -> Result<TalParsed, TalParseError> {
let raw = input.to_vec();
let text = std::str::from_utf8(input).map_err(|_| TalParseError::InvalidUtf8)?;
let lines: Vec<String> = text
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l).to_string())
.collect();
Ok(TalParsed { raw, lines })
}
/// Validate step of scheme A (`parse → validate → verify`).
///
/// `Tal` is already profile-validated when constructed via `decode_bytes()` /
/// `TalParsed::validate_profile()`.
pub fn validate_profile(&self) -> Result<(), TalProfileError> {
Ok(())
}
pub fn decode_bytes(input: &[u8]) -> Result<Self, TalDecodeError> {
Ok(Self::parse_bytes(input)?.validate_profile()?)
}
}
impl TalParsed {
pub fn validate_profile(self) -> Result<Tal, TalProfileError> {
let mut idx = 0usize;
// 1) Leading comments.
let mut comments: Vec<String> = Vec::new();
while idx < self.lines.len() && self.lines[idx].starts_with('#') {
comments.push(self.lines[idx][1..].to_string());
idx += 1;
}
// 2) URI list (one or more non-empty lines).
let mut ta_uris: Vec<Url> = Vec::new();
while idx < self.lines.len() {
let line = self.lines[idx].trim();
if line.is_empty() {
break;
}
if line.starts_with('#') {
return Err(TalProfileError::CommentAfterHeader);
}
let url = match Url::parse(line) {
Ok(u) => u,
Err(_) => {
if !ta_uris.is_empty() {
return Err(TalProfileError::MissingSeparatorEmptyLine);
}
return Err(TalProfileError::InvalidUri(line.to_string()));
}
};
match url.scheme() {
"rsync" | "https" => {}
s => return Err(TalProfileError::UnsupportedUriScheme(s.to_string())),
}
if url.path().ends_with('/') {
return Err(TalProfileError::UriIsDirectory(line.to_string()));
}
if url
.path_segments()
.and_then(|mut s| s.next_back())
.unwrap_or("")
.is_empty()
{
return Err(TalProfileError::UriIsDirectory(line.to_string()));
}
ta_uris.push(url);
idx += 1;
}
if ta_uris.is_empty() {
return Err(TalProfileError::MissingTaUris);
}
// 3) Empty line separator (must exist).
if idx >= self.lines.len() || !self.lines[idx].trim().is_empty() {
return Err(TalProfileError::MissingSeparatorEmptyLine);
}
idx += 1;
// 4) Base64(SPKI DER) remainder; allow line wrapping.
let mut b64 = String::new();
while idx < self.lines.len() {
let line = self.lines[idx].trim();
if !line.is_empty() {
b64.push_str(line);
}
idx += 1;
}
if b64.is_empty() {
return Err(TalProfileError::MissingSpki);
}
let spki_der = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|_| TalProfileError::SpkiBase64Decode)?;
if spki_der.is_empty() {
return Err(TalProfileError::SpkiDerEmpty);
}
Ok(Tal {
raw: self.raw,
comments,
ta_uris,
subject_public_key_info_der: spki_der,
})
}
} }

64
src/fetch/http.rs Normal file
View File

@@ -0,0 +1,64 @@
use std::time::Duration;
use reqwest::blocking::Client;
use crate::sync::rrdp::Fetcher;
#[derive(Clone, Debug)]
pub struct HttpFetcherConfig {
pub timeout: Duration,
pub user_agent: String,
}
impl Default for HttpFetcherConfig {
fn default() -> Self {
Self {
timeout: Duration::from_secs(20),
user_agent: "rpki-dev/0.1 (stage2)".to_string(),
}
}
}
/// Minimal blocking HTTP(S) fetcher for stage2.
///
/// This is used for:
/// - downloading TAL / TA certificates (RFC 8630 §2)
/// - fetching RRDP notification/snapshot files (RFC 8182 §3.4)
#[derive(Clone, Debug)]
pub struct BlockingHttpFetcher {
client: Client,
}
impl BlockingHttpFetcher {
pub fn new(config: HttpFetcherConfig) -> Result<Self, String> {
let client = Client::builder()
.timeout(config.timeout)
.user_agent(config.user_agent)
.build()
.map_err(|e| e.to_string())?;
Ok(Self { client })
}
pub fn fetch_bytes(&self, uri: &str) -> Result<Vec<u8>, String> {
let resp = self
.client
.get(uri)
.send()
.map_err(|e| format!("http request failed: {e}"))?;
let status = resp.status();
if !status.is_success() {
return Err(format!("http status {status}"));
}
resp.bytes()
.map(|b| b.to_vec())
.map_err(|e| format!("http read body failed: {e}"))
}
}
impl Fetcher for BlockingHttpFetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String> {
self.fetch_bytes(uri)
}
}

3
src/fetch/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod http;
pub mod rsync;
pub mod rsync_system;

82
src/fetch/rsync.rs Normal file
View File

@@ -0,0 +1,82 @@
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum RsyncFetchError {
#[error("rsync fetch error: {0}")]
Fetch(String),
}
pub type RsyncFetchResult<T> = Result<T, RsyncFetchError>;
/// Fetch repository objects from a publication point.
///
/// v1: this is intentionally abstract so unit tests can use a mock, and later we can
/// back it by calling the system `rsync` binary (RFC 6481 §5; RFC 8182 §3.4.5).
pub trait RsyncFetcher {
/// Return a list of objects as `(rsync_uri, bytes)` pairs.
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>>;
}
/// A simple "rsync" implementation backed by a local directory.
///
/// This is primarily meant for offline tests and fixtures. The key generation mimics rsync URIs:
/// `rsync_base_uri` + relative path (with `/` separators).
pub struct LocalDirRsyncFetcher {
pub root_dir: PathBuf,
}
impl LocalDirRsyncFetcher {
pub fn new(root_dir: impl Into<PathBuf>) -> Self {
Self {
root_dir: root_dir.into(),
}
}
}
impl RsyncFetcher for LocalDirRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let base = normalize_rsync_base_uri(rsync_base_uri);
let mut out = Vec::new();
walk_dir_collect(&self.root_dir, &self.root_dir, &base, &mut out)
.map_err(|e| RsyncFetchError::Fetch(e))?;
Ok(out)
}
}
fn walk_dir_collect(
root: &Path,
current: &Path,
rsync_base_uri: &str,
out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), String> {
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
walk_dir_collect(root, &path, rsync_base_uri, out)?;
continue;
}
if !meta.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
let uri = format!("{rsync_base_uri}{rel}");
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
out.push((uri, bytes));
}
Ok(())
}
fn normalize_rsync_base_uri(s: &str) -> String {
if s.ends_with('/') {
s.to_string()
} else {
format!("{s}/")
}
}

144
src/fetch/rsync_system.rs Normal file
View File

@@ -0,0 +1,144 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use uuid::Uuid;
use crate::fetch::rsync::{RsyncFetchError, RsyncFetchResult, RsyncFetcher};
#[derive(Clone, Debug)]
pub struct SystemRsyncConfig {
pub rsync_bin: PathBuf,
pub timeout: Duration,
pub extra_args: Vec<String>,
}
impl Default for SystemRsyncConfig {
fn default() -> Self {
Self {
rsync_bin: PathBuf::from("rsync"),
timeout: Duration::from_secs(60),
extra_args: Vec::new(),
}
}
}
/// A `RsyncFetcher` implementation backed by the system `rsync` binary.
///
/// This is intended for live stage2 runs. For unit tests and offline fixtures,
/// prefer `LocalDirRsyncFetcher`.
#[derive(Clone, Debug)]
pub struct SystemRsyncFetcher {
config: SystemRsyncConfig,
}
impl SystemRsyncFetcher {
pub fn new(config: SystemRsyncConfig) -> Self {
Self { config }
}
fn run_rsync(&self, src: &str, dst: &Path) -> Result<(), String> {
// `--timeout` is I/O timeout in seconds (applies to network reads/writes).
let timeout_secs = self.config.timeout.as_secs().max(1).to_string();
let mut cmd = Command::new(&self.config.rsync_bin);
cmd.arg("-rt")
.arg("--delete")
.arg("--timeout")
.arg(timeout_secs)
.args(&self.config.extra_args)
.arg(src)
.arg(dst);
let out = cmd
.output()
.map_err(|e| format!("rsync spawn failed: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
return Err(format!(
"rsync failed: status={} stdout={} stderr={}",
out.status,
stdout.trim(),
stderr.trim()
));
}
Ok(())
}
}
impl RsyncFetcher for SystemRsyncFetcher {
fn fetch_objects(&self, rsync_base_uri: &str) -> RsyncFetchResult<Vec<(String, Vec<u8>)>> {
let base = normalize_rsync_base_uri(rsync_base_uri);
let tmp = TempDir::new().map_err(|e| RsyncFetchError::Fetch(e.to_string()))?;
self.run_rsync(&base, tmp.path())
.map_err(RsyncFetchError::Fetch)?;
let mut out = Vec::new();
walk_dir_collect(tmp.path(), tmp.path(), &base, &mut out)
.map_err(RsyncFetchError::Fetch)?;
Ok(out)
}
}
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new() -> Result<Self, String> {
let mut p = std::env::temp_dir();
p.push(format!("rpki-system-rsync-{}", Uuid::new_v4()));
std::fs::create_dir_all(&p).map_err(|e| e.to_string())?;
Ok(Self { path: p })
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn normalize_rsync_base_uri(s: &str) -> String {
if s.ends_with('/') {
s.to_string()
} else {
format!("{s}/")
}
}
fn walk_dir_collect(
root: &Path,
current: &Path,
rsync_base_uri: &str,
out: &mut Vec<(String, Vec<u8>)>,
) -> Result<(), String> {
let rd = std::fs::read_dir(current).map_err(|e| e.to_string())?;
for entry in rd {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let meta = entry.metadata().map_err(|e| e.to_string())?;
if meta.is_dir() {
walk_dir_collect(root, &path, rsync_base_uri, out)?;
continue;
}
if !meta.is_file() {
continue;
}
let rel = path
.strip_prefix(root)
.map_err(|e| e.to_string())?
.to_string_lossy()
.replace('\\', "/");
let uri = format!("{rsync_base_uri}{rel}");
let bytes = std::fs::read(&path).map_err(|e| e.to_string())?;
out.push((uri, bytes));
}
Ok(())
}

View File

@@ -1 +1,9 @@
pub mod audit;
pub mod cli;
pub mod data_model; pub mod data_model;
pub mod fetch;
pub mod policy;
pub mod report;
pub mod storage;
pub mod sync;
pub mod validation;

70
src/policy.rs Normal file
View File

@@ -0,0 +1,70 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SyncPreference {
RrdpThenRsync,
RsyncOnly,
}
impl Default for SyncPreference {
fn default() -> Self {
Self::RrdpThenRsync
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CaFailedFetchPolicy {
UseVerifiedCache,
StopAllOutput,
}
impl Default for CaFailedFetchPolicy {
fn default() -> Self {
Self::UseVerifiedCache
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SignedObjectFailurePolicy {
DropObject,
DropPublicationPoint,
}
impl Default for SignedObjectFailurePolicy {
fn default() -> Self {
Self::DropObject
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct Policy {
pub sync_preference: SyncPreference,
pub ca_failed_fetch_policy: CaFailedFetchPolicy,
pub signed_object_failure_policy: SignedObjectFailurePolicy,
}
impl Default for Policy {
fn default() -> Self {
Self {
sync_preference: SyncPreference::default(),
ca_failed_fetch_policy: CaFailedFetchPolicy::default(),
signed_object_failure_policy: SignedObjectFailurePolicy::default(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PolicyParseError {
#[error("policy TOML parse error: {0}")]
Toml(String),
}
impl Policy {
pub fn from_toml_str(s: &str) -> Result<Self, PolicyParseError> {
toml::from_str(s).map_err(|e| PolicyParseError::Toml(e.to_string()))
}
}

29
src/report.rs Normal file
View File

@@ -0,0 +1,29 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RfcRef(pub &'static str);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Warning {
pub message: String,
pub rfc_refs: Vec<RfcRef>,
pub context: Option<String>,
}
impl Warning {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
rfc_refs: Vec::new(),
context: None,
}
}
pub fn with_rfc_refs(mut self, refs: &[RfcRef]) -> Self {
self.rfc_refs.extend_from_slice(refs);
self
}
pub fn with_context(mut self, context: impl Into<String>) -> Self {
self.context = Some(context.into());
self
}
}

390
src/storage.rs Normal file
View File

@@ -0,0 +1,390 @@
use std::path::Path;
use rocksdb::{
ColumnFamily, ColumnFamilyDescriptor, DB, DBCompressionType, Direction, IteratorMode, Options,
WriteBatch,
};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::collections::HashSet;
const CF_RAW_OBJECTS: &str = "raw_objects";
const CF_VERIFIED_PUBLICATION_POINTS: &str = "verified_publication_points";
const CF_RRDP_STATE: &str = "rrdp_state";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct VerifiedKey(String);
impl VerifiedKey {
pub fn from_manifest_rsync_uri(manifest_rsync_uri: &str) -> Self {
Self(format!("verified:{manifest_rsync_uri}"))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, thiserror::Error)]
pub enum StorageError {
#[error("rocksdb error: {0}")]
RocksDb(String),
#[error("missing column family: {0}")]
MissingColumnFamily(&'static str),
#[error("verified publication point pack error: {0}")]
Pack(#[from] PackDecodeError),
}
pub type StorageResult<T> = Result<T, StorageError>;
pub struct RocksStore {
db: DB,
}
pub mod pack {
pub use super::{PackDecodeError, PackFile, PackTime, VerifiedPublicationPointPack};
}
impl RocksStore {
pub fn open(path: &Path) -> StorageResult<Self> {
let mut base_opts = Options::default();
base_opts.create_if_missing(true);
base_opts.create_missing_column_families(true);
// Prefer conservative compression; may be overridden later.
base_opts.set_compression_type(DBCompressionType::Lz4);
// Best-effort BlobDB enablement (ignored if bindings don't support it).
enable_blobdb_if_supported(&mut base_opts);
let cfs = vec![
ColumnFamilyDescriptor::new(CF_RAW_OBJECTS, Options::default()),
ColumnFamilyDescriptor::new(CF_VERIFIED_PUBLICATION_POINTS, Options::default()),
ColumnFamilyDescriptor::new(CF_RRDP_STATE, Options::default()),
];
let db = DB::open_cf_descriptors(&base_opts, path, cfs)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(Self { db })
}
fn cf(&self, name: &'static str) -> StorageResult<&ColumnFamily> {
self.db
.cf_handle(name)
.ok_or(StorageError::MissingColumnFamily(name))
}
pub fn put_raw(&self, rsync_uri: &str, bytes: &[u8]) -> StorageResult<()> {
let cf = self.cf(CF_RAW_OBJECTS)?;
self.db
.put_cf(cf, rsync_uri.as_bytes(), bytes)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_raw(&self, rsync_uri: &str) -> StorageResult<Option<Vec<u8>>> {
let cf = self.cf(CF_RAW_OBJECTS)?;
let v = self
.db
.get_cf(cf, rsync_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(v)
}
pub fn delete_raw(&self, rsync_uri: &str) -> StorageResult<()> {
let cf = self.cf(CF_RAW_OBJECTS)?;
self.db
.delete_cf(cf, rsync_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn put_verified(&self, key: &VerifiedKey, bytes: &[u8]) -> StorageResult<()> {
let cf = self.cf(CF_VERIFIED_PUBLICATION_POINTS)?;
self.db
.put_cf(cf, key.as_str().as_bytes(), bytes)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_verified(&self, key: &VerifiedKey) -> StorageResult<Option<Vec<u8>>> {
let cf = self.cf(CF_VERIFIED_PUBLICATION_POINTS)?;
let v = self
.db
.get_cf(cf, key.as_str().as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(v)
}
pub fn put_rrdp_state(&self, notification_uri: &str, bytes: &[u8]) -> StorageResult<()> {
let cf = self.cf(CF_RRDP_STATE)?;
self.db
.put_cf(cf, notification_uri.as_bytes(), bytes)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
pub fn get_rrdp_state(&self, notification_uri: &str) -> StorageResult<Option<Vec<u8>>> {
let cf = self.cf(CF_RRDP_STATE)?;
let v = self
.db
.get_cf(cf, notification_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(v)
}
#[allow(dead_code)]
pub fn delete_rrdp_state(&self, notification_uri: &str) -> StorageResult<()> {
let cf = self.cf(CF_RRDP_STATE)?;
self.db
.delete_cf(cf, notification_uri.as_bytes())
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
#[allow(dead_code)]
pub fn raw_iter_prefix<'a>(
&'a self,
prefix: &'a [u8],
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, Box<[u8]>)> + 'a> {
let cf = self.cf(CF_RAW_OBJECTS)?;
let mode = IteratorMode::From(prefix, Direction::Forward);
Ok(self
.db
.iterator_cf(cf, mode)
.take_while(move |res| match res {
Ok((k, _v)) => k.starts_with(prefix),
Err(_) => false,
})
.filter_map(|res| res.ok()))
}
#[allow(dead_code)]
pub fn raw_iter_all<'a>(
&'a self,
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, Box<[u8]>)> + 'a> {
let cf = self.cf(CF_RAW_OBJECTS)?;
let mode = IteratorMode::Start;
Ok(self.db.iterator_cf(cf, mode).filter_map(|res| res.ok()))
}
#[allow(dead_code)]
pub fn verified_iter_all<'a>(
&'a self,
) -> StorageResult<impl Iterator<Item = (Box<[u8]>, Box<[u8]>)> + 'a> {
let cf = self.cf(CF_VERIFIED_PUBLICATION_POINTS)?;
let mode = IteratorMode::Start;
Ok(self.db.iterator_cf(cf, mode).filter_map(|res| res.ok()))
}
#[allow(dead_code)]
pub fn write_batch(&self, batch: WriteBatch) -> StorageResult<()> {
self.db
.write(batch)
.map_err(|e| StorageError::RocksDb(e.to_string()))?;
Ok(())
}
}
fn enable_blobdb_if_supported(opts: &mut Options) {
// Rust bindings may or may not expose BlobDB options depending on RocksDB build.
// We enable them in a best-effort way using method detection via trait bounds is
// not possible in stable Rust; so we keep this minimal.
//
// If the crate exposes `set_enable_blob_files`, use it; otherwise do nothing.
#[allow(unused_mut)]
let mut _enabled = false;
#[allow(dead_code)]
fn _set(opts: &mut Options) {
// If this method exists, this compiles and enables BlobDB.
opts.set_enable_blob_files(true);
opts.set_min_blob_size(1024);
}
// Call the helper in a way that fails to compile if methods don't exist.
// To keep compilation stable, this file is expected to compile with a rocksdb
// crate version that exposes these setters. If not, remove the call and use
// plain RocksDB (still compatible with later enabling).
_set(opts);
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerifiedPublicationPointPack {
pub format_version: u32,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
pub this_update: PackTime,
pub next_update: PackTime,
pub verified_at: PackTime,
pub manifest_bytes: Vec<u8>,
/// Objects listed in the Manifest fileList (RFC 9286 §4.2.1).
///
/// Note: the manifest object itself is *not* listed in fileList; it is stored separately
/// in `manifest_bytes`.
pub files: Vec<PackFile>,
}
impl VerifiedPublicationPointPack {
pub const FORMAT_VERSION_V1: u32 = 1;
pub fn encode(&self) -> StorageResult<Vec<u8>> {
serde_cbor::to_vec(self).map_err(|e| PackDecodeError::Encode(e.to_string()).into())
}
pub fn decode(bytes: &[u8]) -> StorageResult<Self> {
let pack: Self =
serde_cbor::from_slice(bytes).map_err(|e| PackDecodeError::Decode(e.to_string()))?;
pack.validate_internal()?;
Ok(pack)
}
pub fn validate_internal(&self) -> StorageResult<()> {
if self.format_version != Self::FORMAT_VERSION_V1 {
return Err(PackDecodeError::UnsupportedFormatVersion(self.format_version).into());
}
if self.manifest_rsync_uri.is_empty() {
return Err(PackDecodeError::MissingField("manifest_rsync_uri").into());
}
if self.publication_point_rsync_uri.is_empty() {
return Err(PackDecodeError::MissingField("publication_point_rsync_uri").into());
}
self.this_update
.parse()
.map_err(|e| PackDecodeError::InvalidTimeField {
field: "this_update",
detail: e,
})?;
self.next_update
.parse()
.map_err(|e| PackDecodeError::InvalidTimeField {
field: "next_update",
detail: e,
})?;
self.verified_at
.parse()
.map_err(|e| PackDecodeError::InvalidTimeField {
field: "verified_at",
detail: e,
})?;
if self.manifest_bytes.is_empty() {
return Err(PackDecodeError::MissingManifestBytes.into());
}
let mut seen: HashSet<&str> = HashSet::with_capacity(self.files.len());
for file in &self.files {
if file.rsync_uri.is_empty() {
return Err(PackDecodeError::MissingField("files[].rsync_uri").into());
}
if !seen.insert(file.rsync_uri.as_str()) {
return Err(PackDecodeError::DuplicateFileRsyncUri(file.rsync_uri.clone()).into());
}
if file.bytes.is_empty() {
return Err(PackDecodeError::EmptyFileBytes(file.rsync_uri.clone()).into());
}
let computed = file.compute_sha256();
if computed != file.sha256 {
return Err(PackDecodeError::FileHashMismatch {
rsync_uri: file.rsync_uri.clone(),
}
.into());
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackFile {
pub rsync_uri: String,
pub bytes: Vec<u8>,
pub sha256: [u8; 32],
}
impl PackFile {
pub fn new(rsync_uri: impl Into<String>, bytes: Vec<u8>, sha256: [u8; 32]) -> Self {
Self {
rsync_uri: rsync_uri.into(),
bytes,
sha256,
}
}
pub fn from_bytes_compute_sha256(rsync_uri: impl Into<String>, bytes: Vec<u8>) -> Self {
let sha256 = compute_sha256_32(&bytes);
Self::new(rsync_uri, bytes, sha256)
}
pub fn compute_sha256(&self) -> [u8; 32] {
compute_sha256_32(&self.bytes)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackTime {
/// RFC 3339 timestamp in UTC, e.g. "2026-02-06T00:00:00Z".
pub rfc3339_utc: String,
}
impl PackTime {
pub fn from_utc_offset_datetime(t: time::OffsetDateTime) -> Self {
use time::format_description::well_known::Rfc3339;
let utc = t.to_offset(time::UtcOffset::UTC);
let s = utc.format(&Rfc3339).expect("format RFC 3339 UTC time");
Self { rfc3339_utc: s }
}
pub fn parse(&self) -> Result<time::OffsetDateTime, String> {
use time::format_description::well_known::Rfc3339;
time::OffsetDateTime::parse(&self.rfc3339_utc, &Rfc3339).map_err(|e| e.to_string())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PackDecodeError {
#[error("encode verified publication point pack failed: {0}")]
Encode(String),
#[error("decode verified publication point pack failed: {0}")]
Decode(String),
#[error("unsupported pack format_version: {0}")]
UnsupportedFormatVersion(u32),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("missing manifest_bytes in verified pack")]
MissingManifestBytes,
#[error("duplicate file rsync uri in verified pack: {0}")]
DuplicateFileRsyncUri(String),
#[error("empty file bytes in verified pack: {0}")]
EmptyFileBytes(String),
#[error("file hash mismatch in verified pack: {rsync_uri}")]
FileHashMismatch { rsync_uri: String },
#[error("invalid time field {field}: {detail}")]
InvalidTimeField { field: &'static str, detail: String },
}
fn compute_sha256_32(bytes: &[u8]) -> [u8; 32] {
let digest = sha2::Sha256::digest(bytes);
let mut out = [0u8; 32];
out.copy_from_slice(&digest);
out
}

2
src/sync/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod repo;
pub mod rrdp;

128
src/sync/repo.rs Normal file
View File

@@ -0,0 +1,128 @@
use crate::fetch::rsync::{RsyncFetchError, RsyncFetcher};
use crate::policy::{Policy, SyncPreference};
use crate::report::{RfcRef, Warning};
use crate::storage::RocksStore;
use crate::sync::rrdp::{
Fetcher as HttpFetcher, RrdpState, RrdpSyncError, parse_notification_snapshot,
sync_from_notification_snapshot,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RepoSyncSource {
Rrdp,
Rsync,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RepoSyncResult {
pub source: RepoSyncSource,
pub objects_written: usize,
pub warnings: Vec<Warning>,
}
#[derive(Debug, thiserror::Error)]
pub enum RepoSyncError {
#[error("RRDP sync failed: {0}")]
Rrdp(#[from] RrdpSyncError),
#[error("rsync fallback failed: {0}")]
Rsync(#[from] RsyncFetchError),
#[error("storage error: {0}")]
Storage(String),
}
/// Sync a publication point into `raw_objects`.
///
/// v1 behavior:
/// - If `rrdp_notification_uri` is present and `policy.sync_preference` is `rrdp_then_rsync`,
/// try RRDP snapshot sync first (RFC 8182 §3.4.1-§3.4.3).
/// - On RRDP failure, fall back to rsync (RFC 8182 §3.4.5).
/// - If `sync_preference` is `rsync_only` or there is no RRDP URI, use rsync.
pub fn sync_publication_point(
store: &RocksStore,
policy: &Policy,
rrdp_notification_uri: Option<&str>,
rsync_base_uri: &str,
http_fetcher: &dyn HttpFetcher,
rsync_fetcher: &dyn RsyncFetcher,
) -> Result<RepoSyncResult, RepoSyncError> {
match (policy.sync_preference, rrdp_notification_uri) {
(SyncPreference::RrdpThenRsync, Some(notification_uri)) => {
match try_rrdp_sync(store, notification_uri, http_fetcher) {
Ok(written) => Ok(RepoSyncResult {
source: RepoSyncSource::Rrdp,
objects_written: written,
warnings: Vec::new(),
}),
Err(err) => {
let warnings = vec![
Warning::new(format!("RRDP failed; falling back to rsync: {err}"))
.with_rfc_refs(&[RfcRef("RFC 8182 §3.4.5")])
.with_context(notification_uri),
];
let written =
rsync_sync_into_raw_objects(store, rsync_base_uri, rsync_fetcher)?;
Ok(RepoSyncResult {
source: RepoSyncSource::Rsync,
objects_written: written,
warnings,
})
}
}
}
_ => {
let written = rsync_sync_into_raw_objects(store, rsync_base_uri, rsync_fetcher)?;
Ok(RepoSyncResult {
source: RepoSyncSource::Rsync,
objects_written: written,
warnings: Vec::new(),
})
}
}
}
fn try_rrdp_sync(
store: &RocksStore,
notification_uri: &str,
http_fetcher: &dyn HttpFetcher,
) -> Result<usize, RrdpSyncError> {
let notification_xml = http_fetcher
.fetch(notification_uri)
.map_err(RrdpSyncError::Fetch)?;
// Stage2 snapshot-only optimization: if the stored RRDP state matches the current notification's
// session_id+serial, skip snapshot fetch/apply. This avoids repeatedly downloading/applying the
// same snapshot when traversing many CA instances sharing an RRDP endpoint.
//
// RFC 8182 §3.4.1-§3.4.3: clients use notification to discover snapshot and can avoid fetching
// snapshot when serial hasn't advanced.
if let Ok(notif) = parse_notification_snapshot(&notification_xml) {
if let Ok(Some(state_bytes)) = store.get_rrdp_state(notification_uri) {
if let Ok(state) = RrdpState::decode(&state_bytes) {
if state.session_id == notif.session_id.to_string() && state.serial == notif.serial
{
return Ok(0);
}
}
}
}
sync_from_notification_snapshot(store, notification_uri, &notification_xml, http_fetcher)
}
fn rsync_sync_into_raw_objects(
store: &RocksStore,
rsync_base_uri: &str,
rsync_fetcher: &dyn RsyncFetcher,
) -> Result<usize, RepoSyncError> {
let objects = rsync_fetcher.fetch_objects(rsync_base_uri)?;
let mut written = 0usize;
for (rsync_uri, bytes) in objects {
store
.put_raw(&rsync_uri, &bytes)
.map_err(|e| RepoSyncError::Storage(e.to_string()))?;
written += 1;
}
Ok(written)
}

290
src/sync/rrdp.rs Normal file
View File

@@ -0,0 +1,290 @@
use crate::storage::RocksStore;
use base64::Engine;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use uuid::Uuid;
const RRDP_XMLNS: &str = "http://www.ripe.net/rpki/rrdp";
#[derive(Debug, thiserror::Error)]
pub enum RrdpError {
#[error("RRDP XML must be US-ASCII encoded (RFC 8182 §3.5.1.3, §3.5.2.3), got non-ASCII bytes")]
NotAscii,
#[error("RRDP XML parse error: {0} (RFC 8182 §3.5.1.3, §3.5.2.3)")]
Xml(String),
#[error(
"RRDP root element must be <notification> or <snapshot>, got <{0}> (RFC 8182 §3.5.1.3, §3.5.2.3)"
)]
UnexpectedRoot(String),
#[error("RRDP XML namespace must be {RRDP_XMLNS}, got {0} (RFC 8182 §3.5.1.3, §3.5.2.3)")]
InvalidNamespace(String),
#[error("RRDP version must be 1, got {0} (RFC 8182 §3.5.1.3, §3.5.2.3)")]
InvalidVersion(String),
#[error("RRDP session_id invalid UUID: {0} (RFC 8182 §3.5.1.3)")]
InvalidSessionId(String),
#[error("RRDP serial invalid unsigned integer: {0} (RFC 8182 §3.5.1.3)")]
InvalidSerial(String),
#[error("notification must contain exactly one <snapshot> element (RFC 8182 §3.5.1.3)")]
SnapshotCountInvalid,
#[error("snapshot/@uri missing (RFC 8182 §3.5.1.3)")]
SnapshotUriMissing,
#[error("snapshot/@hash missing (RFC 8182 §3.5.1.3)")]
SnapshotHashMissing,
#[error("snapshot/@hash must be hex encoding of SHA-256, got {0} (RFC 8182 §3.5.1.3)")]
SnapshotHashInvalid(String),
#[error("snapshot file hash mismatch (RFC 8182 §3.5.1.3)")]
SnapshotHashMismatch,
#[error("snapshot session_id mismatch: expected {expected}, got {got} (RFC 8182 §3.5.2.3)")]
SnapshotSessionIdMismatch { expected: String, got: String },
#[error("snapshot serial mismatch: expected {expected}, got {got} (RFC 8182 §3.5.2.3)")]
SnapshotSerialMismatch { expected: u64, got: u64 },
#[error("publish/@uri missing (RFC 8182 §3.5.2.3)")]
PublishUriMissing,
#[error("publish element missing base64 content (RFC 8182 §3.5.2.3)")]
PublishContentMissing,
#[error("publish base64 decode failed (RFC 8182 §3.5.2.3): {0}")]
PublishBase64(String),
}
#[derive(Debug, thiserror::Error)]
pub enum RrdpSyncError {
#[error("{0}")]
Rrdp(#[from] RrdpError),
#[error("fetch failed: {0}")]
Fetch(String),
#[error("storage error: {0}")]
Storage(String),
}
pub type RrdpSyncResult<T> = Result<T, RrdpSyncError>;
pub trait Fetcher {
fn fetch(&self, uri: &str) -> Result<Vec<u8>, String>;
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RrdpState {
pub session_id: String,
pub serial: u64,
}
impl RrdpState {
pub fn encode(&self) -> Result<Vec<u8>, String> {
serde_cbor::to_vec(self).map_err(|e| e.to_string())
}
pub fn decode(bytes: &[u8]) -> Result<Self, String> {
serde_cbor::from_slice(bytes).map_err(|e| e.to_string())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NotificationSnapshot {
pub session_id: Uuid,
pub serial: u64,
pub snapshot_uri: String,
pub snapshot_hash_sha256: [u8; 32],
}
pub fn parse_notification_snapshot(xml: &[u8]) -> Result<NotificationSnapshot, RrdpError> {
let doc = parse_rrdp_xml(xml)?;
let root = doc.root_element();
if root.tag_name().name() != "notification" {
return Err(RrdpError::UnexpectedRoot(
root.tag_name().name().to_string(),
));
}
validate_root_common(&root)?;
let session_id = parse_uuid_attr(&root, "session_id")?;
let serial = parse_u64_attr(&root, "serial")?;
let snapshots: Vec<_> = root
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "snapshot")
.collect();
if snapshots.len() != 1 {
return Err(RrdpError::SnapshotCountInvalid);
}
let snapshot = snapshots[0];
let snapshot_uri = snapshot
.attribute("uri")
.ok_or(RrdpError::SnapshotUriMissing)?
.to_string();
let snapshot_hash_hex = snapshot
.attribute("hash")
.ok_or(RrdpError::SnapshotHashMissing)?;
let snapshot_hash_sha256 = parse_sha256_hex(snapshot_hash_hex)?;
Ok(NotificationSnapshot {
session_id,
serial,
snapshot_uri,
snapshot_hash_sha256,
})
}
pub fn sync_from_notification_snapshot(
store: &RocksStore,
notification_uri: &str,
notification_xml: &[u8],
fetcher: &dyn Fetcher,
) -> RrdpSyncResult<usize> {
let notif = parse_notification_snapshot(notification_xml)?;
let snapshot_xml = fetcher
.fetch(&notif.snapshot_uri)
.map_err(RrdpSyncError::Fetch)?;
let computed = sha2::Sha256::digest(&snapshot_xml);
if computed.as_slice() != notif.snapshot_hash_sha256.as_slice() {
return Err(RrdpError::SnapshotHashMismatch.into());
}
let published = apply_snapshot(store, &snapshot_xml, notif.session_id, notif.serial)?;
let state = RrdpState {
session_id: notif.session_id.to_string(),
serial: notif.serial,
};
let bytes = state.encode().map_err(RrdpSyncError::Storage)?;
store
.put_rrdp_state(notification_uri, &bytes)
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
Ok(published)
}
fn apply_snapshot(
store: &RocksStore,
snapshot_xml: &[u8],
expected_session_id: Uuid,
expected_serial: u64,
) -> Result<usize, RrdpSyncError> {
let doc = parse_rrdp_xml(snapshot_xml)?;
let root = doc.root_element();
if root.tag_name().name() != "snapshot" {
return Err(RrdpError::UnexpectedRoot(root.tag_name().name().to_string()).into());
}
validate_root_common(&root)?;
let got_session_id = parse_uuid_attr(&root, "session_id")?;
if got_session_id != expected_session_id {
return Err(RrdpError::SnapshotSessionIdMismatch {
expected: expected_session_id.to_string(),
got: got_session_id.to_string(),
}
.into());
}
let got_serial = parse_u64_attr(&root, "serial")?;
if got_serial != expected_serial {
return Err(RrdpError::SnapshotSerialMismatch {
expected: expected_serial,
got: got_serial,
}
.into());
}
let mut published = 0usize;
for publish in root
.children()
.filter(|n| n.is_element() && n.tag_name().name() == "publish")
{
let uri = publish
.attribute("uri")
.ok_or(RrdpError::PublishUriMissing)?;
let content_b64 = collect_element_text(&publish).ok_or(RrdpError::PublishContentMissing)?;
let content_b64 = strip_all_ascii_whitespace(&content_b64);
let bytes = base64::engine::general_purpose::STANDARD
.decode(content_b64.as_bytes())
.map_err(|e| RrdpError::PublishBase64(e.to_string()))?;
store
.put_raw(uri, &bytes)
.map_err(|e| RrdpSyncError::Storage(e.to_string()))?;
published += 1;
}
Ok(published)
}
fn parse_rrdp_xml(xml: &[u8]) -> Result<roxmltree::Document<'_>, RrdpError> {
if xml.iter().any(|&b| b > 0x7F) {
return Err(RrdpError::NotAscii);
}
let s = std::str::from_utf8(xml).map_err(|e| RrdpError::Xml(e.to_string()))?;
roxmltree::Document::parse(s).map_err(|e| RrdpError::Xml(e.to_string()))
}
fn validate_root_common(root: &roxmltree::Node<'_, '_>) -> Result<(), RrdpError> {
let ns = root.default_namespace().unwrap_or("").to_string();
if ns != RRDP_XMLNS {
return Err(RrdpError::InvalidNamespace(ns));
}
let version = root.attribute("version").unwrap_or("");
if version != "1" {
return Err(RrdpError::InvalidVersion(version.to_string()));
}
Ok(())
}
fn parse_uuid_attr(root: &roxmltree::Node<'_, '_>, name: &'static str) -> Result<Uuid, RrdpError> {
let s = root.attribute(name).unwrap_or("");
Uuid::parse_str(s).map_err(|_e| RrdpError::InvalidSessionId(s.to_string()))
}
fn parse_u64_attr(root: &roxmltree::Node<'_, '_>, name: &'static str) -> Result<u64, RrdpError> {
let s = root.attribute(name).unwrap_or("");
let v = s
.parse::<u64>()
.map_err(|_e| RrdpError::InvalidSerial(s.to_string()))?;
if v == 0 {
return Err(RrdpError::InvalidSerial(s.to_string()));
}
Ok(v)
}
fn parse_sha256_hex(s: &str) -> Result<[u8; 32], RrdpError> {
let bytes = hex::decode(s).map_err(|_e| RrdpError::SnapshotHashInvalid(s.to_string()))?;
if bytes.len() != 32 {
return Err(RrdpError::SnapshotHashInvalid(s.to_string()));
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(out)
}
fn collect_element_text(node: &roxmltree::Node<'_, '_>) -> Option<String> {
let mut out = String::new();
for child in node.children() {
if child.is_text() {
out.push_str(child.text().unwrap_or(""));
}
}
if out.is_empty() { None } else { Some(out) }
}
fn strip_all_ascii_whitespace(s: &str) -> String {
s.chars().filter(|c| !c.is_ascii_whitespace()).collect()
}

View File

@@ -0,0 +1,123 @@
use crate::data_model::oid::{OID_AD_CA_REPOSITORY, OID_AD_RPKI_MANIFEST, OID_AD_RPKI_NOTIFY};
use crate::data_model::rc::{ResourceCertKind, ResourceCertificate, SubjectInfoAccess};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceUris {
/// CA repository base rsync URI (must end with `/`).
pub rsync_base_uri: String,
/// rsync URI for the manifest object (`.mft`).
pub manifest_rsync_uri: String,
/// Publication point rsync URI (RFC 9286 terminology). In v1 this equals `rsync_base_uri`.
pub publication_point_rsync_uri: String,
/// Optional RRDP notification URI (https).
pub rrdp_notification_uri: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum CaInstanceUrisError {
#[error("certificate must be a CA certificate (RFC 6487 §4.8.1)")]
NotCa,
#[error(
"CA certificate must contain Subject Information Access extension (RFC 6487 §4.8.8; RFC 5280 §4.2.2.2)"
)]
MissingSia,
#[error("CA certificate SIA must contain id-ad-caRepository rsync URI (RFC 6487 §4.8.8.1)")]
MissingCaRepository,
#[error("CA certificate SIA must contain id-ad-rpkiManifest rsync URI (RFC 6487 §4.8.8.2)")]
MissingRpkiManifest,
#[error(
"SIA id-ad-caRepository accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.1)"
)]
CaRepositoryNotRsync(String),
#[error(
"SIA id-ad-rpkiManifest accessLocation must be rsync:// URI, got {0} (RFC 6487 §4.8.8.2)"
)]
RpkiManifestNotRsync(String),
#[error(
"SIA id-ad-rpkiNotify accessLocation must be https:// URI, got {0} (RFC 8182 §3.4.1; RFC 6487 §4.8.8.3)"
)]
RpkiNotifyNotHttps(String),
#[error(
"manifest rsync URI must be under CA publication point: manifest={manifest_rsync_uri} publication_point={publication_point_rsync_uri} (RFC 9286 §6.1)"
)]
ManifestNotUnderPublicationPoint {
manifest_rsync_uri: String,
publication_point_rsync_uri: String,
},
}
pub fn ca_instance_uris_from_ca_certificate(
cert: &ResourceCertificate,
) -> Result<CaInstanceUris, CaInstanceUrisError> {
if cert.kind != ResourceCertKind::Ca {
return Err(CaInstanceUrisError::NotCa);
}
let sia = cert
.tbs
.extensions
.subject_info_access
.as_ref()
.ok_or(CaInstanceUrisError::MissingSia)?;
let access_descriptions = match sia {
SubjectInfoAccess::Ca(ca) => &ca.access_descriptions,
SubjectInfoAccess::Ee(_ee) => return Err(CaInstanceUrisError::MissingSia),
};
let mut ca_repo: Option<String> = None;
let mut manifest: Option<String> = None;
let mut notify: Option<String> = None;
for ad in access_descriptions {
if ad.access_method_oid == OID_AD_CA_REPOSITORY {
let u = ad.access_location.to_string();
if ad.access_location.scheme() != "rsync" {
return Err(CaInstanceUrisError::CaRepositoryNotRsync(u));
}
ca_repo.get_or_insert(u);
} else if ad.access_method_oid == OID_AD_RPKI_MANIFEST {
let u = ad.access_location.to_string();
if ad.access_location.scheme() != "rsync" {
return Err(CaInstanceUrisError::RpkiManifestNotRsync(u));
}
manifest.get_or_insert(u);
} else if ad.access_method_oid == OID_AD_RPKI_NOTIFY {
let u = ad.access_location.to_string();
if ad.access_location.scheme() != "https" {
return Err(CaInstanceUrisError::RpkiNotifyNotHttps(u));
}
notify.get_or_insert(u);
}
}
let mut publication_point_rsync_uri =
ca_repo.ok_or(CaInstanceUrisError::MissingCaRepository)?;
if !publication_point_rsync_uri.ends_with('/') {
publication_point_rsync_uri.push('/');
}
let rsync_base_uri = publication_point_rsync_uri.clone();
let manifest_rsync_uri = manifest.ok_or(CaInstanceUrisError::MissingRpkiManifest)?;
if !manifest_rsync_uri.starts_with(&publication_point_rsync_uri) {
return Err(CaInstanceUrisError::ManifestNotUnderPublicationPoint {
manifest_rsync_uri,
publication_point_rsync_uri,
});
}
Ok(CaInstanceUris {
rsync_base_uri,
manifest_rsync_uri,
publication_point_rsync_uri,
rrdp_notification_uri: notify,
})
}

979
src/validation/ca_path.rs Normal file
View File

@@ -0,0 +1,979 @@
use crate::data_model::common::BigUnsigned;
use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::data_model::oid::OID_KEY_USAGE;
use crate::data_model::rc::{
AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpResourceSet, ResourceCertKind,
ResourceCertificate, ResourceCertificateDecodeError,
};
use x509_parser::prelude::{FromDer, X509Certificate};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedSubordinateCa {
pub child_ca: ResourceCertificate,
pub issuer_ca: ResourceCertificate,
pub issuer_crl: RpkixCrl,
pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>,
}
#[derive(Debug, thiserror::Error)]
pub enum CaPathError {
#[error("child CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
ChildDecode(#[from] ResourceCertificateDecodeError),
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
IssuerDecode(ResourceCertificateDecodeError),
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
CrlDecode(#[from] CrlDecodeError),
#[error(
"child certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
ChildNotCa,
#[error(
"issuer certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
IssuerNotCa,
#[error(
"child issuer DN does not match issuer CA subject DN: child.issuer={child_issuer_dn} issuer.subject={issuer_subject_dn} (RFC 5280 §6.1)"
)]
IssuerSubjectMismatch {
child_issuer_dn: String,
issuer_subject_dn: String,
},
#[error("child CA certificate signature verification failed: {0} (RFC 5280 §6.1)")]
ChildSignatureInvalid(String),
#[error("certificate not valid at validation_time (RFC 5280 §4.1.2.5; RFC 5280 §6.1)")]
CertificateNotValidAtTime,
#[error("child CA KeyUsage extension missing (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageMissing,
#[error("child CA KeyUsage criticality must be critical (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageNotCritical,
#[error("child CA KeyUsage must have only keyCertSign and cRLSign set (RFC 6487 §4.8.4)")]
KeyUsageInvalidBits,
#[error(
"CRL signature/binding verification failed: {0} (RFC 5280 §6.3.3; RFC 6487 §5; RFC 9829 §3.1)"
)]
CrlVerify(#[from] CrlVerifyError),
#[error(
"CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)"
)]
CrlNotValidAtTime,
#[error("child CA certificate is revoked by issuer CRL (RFC 5280 §6.3.3; RFC 6487 §5)")]
ChildRevoked,
#[error(
"child CA certificate must contain at least one RFC 3779 resource extension (IP or AS) (RFC 6487 §4.8.10-§4.8.11)"
)]
ResourcesMissing,
#[error(
"resource extension inheritance cannot be resolved (parent missing resources) (RFC 6487 §7.2)"
)]
InheritWithoutParentResources,
#[error("child CA resources are not a subset of issuer resources (RFC 6487 §7.2)")]
ResourcesNotSubset,
#[error("issuer CA subjectKeyIdentifier missing (RFC 6487 §4.8.2)")]
IssuerSkiMissing,
#[error("child CA authorityKeyIdentifier missing (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)")]
ChildAkiMissing,
#[error(
"child CA authorityKeyIdentifier does not match issuer subjectKeyIdentifier (RFC 6487 §4.8.3)"
)]
ChildAkiMismatch,
#[error("child CA authorityInfoAccess missing (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)")]
ChildAiaMissing,
#[error(
"child CA authorityInfoAccess does not reference issuer certificate rsync URI (RFC 6487 §4.8.7)"
)]
ChildAiaIssuerUriMismatch,
#[error("child CA CRLDistributionPoints missing (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)")]
ChildCrlDpMissing,
#[error(
"child CA CRLDistributionPoints does not reference issuer CRL rsync URI (RFC 6487 §4.8.6)"
)]
ChildCrlDpUriMismatch,
}
pub fn validate_subordinate_ca_cert(
child_ca_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: &str,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
) -> Result<ValidatedSubordinateCa, CaPathError> {
let child_ca = ResourceCertificate::decode_der(child_ca_der)?;
if child_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::ChildNotCa);
}
let issuer_ca =
ResourceCertificate::decode_der(issuer_ca_der).map_err(CaPathError::IssuerDecode)?;
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CaPathError::IssuerNotCa);
}
if child_ca.tbs.issuer_dn != issuer_ca.tbs.subject_dn {
return Err(CaPathError::IssuerSubjectMismatch {
child_issuer_dn: child_ca.tbs.issuer_dn.clone(),
issuer_subject_dn: issuer_ca.tbs.subject_dn.clone(),
});
}
validate_child_aki_matches_issuer_ski(&child_ca, &issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_child_aia_points_to_issuer_uri(&child_ca, expected_issuer_uri)?;
}
validate_child_crldp_contains_issuer_crl_uri(&child_ca, issuer_crl_rsync_uri)?;
if !time_within_validity(
validation_time,
child_ca.tbs.validity_not_before,
child_ca.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CaPathError::CertificateNotValidAtTime);
}
verify_cert_signature_with_issuer(child_ca_der, issuer_ca_der)?;
validate_child_ca_key_usage(child_ca_der)?;
let issuer_crl = RpkixCrl::decode_der(issuer_crl_der)?;
issuer_crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
if !crl_valid_at_time(&issuer_crl, validation_time) {
return Err(CaPathError::CrlNotValidAtTime);
}
if is_serial_revoked_by_crl(&child_ca, &issuer_crl) {
return Err(CaPathError::ChildRevoked);
}
let effective_ip_resources = resolve_child_ip_resources(
child_ca.tbs.extensions.ip_resources.as_ref(),
issuer_effective_ip,
)?;
let effective_as_resources = resolve_child_as_resources(
child_ca.tbs.extensions.as_resources.as_ref(),
issuer_effective_as,
)?;
if effective_ip_resources.is_none() && effective_as_resources.is_none() {
return Err(CaPathError::ResourcesMissing);
}
Ok(ValidatedSubordinateCa {
child_ca,
issuer_ca,
issuer_crl,
effective_ip_resources,
effective_as_resources,
})
}
fn validate_child_aki_matches_issuer_ski(
child: &ResourceCertificate,
issuer: &ResourceCertificate,
) -> Result<(), CaPathError> {
let Some(issuer_ski) = issuer.tbs.extensions.subject_key_identifier.as_deref() else {
return Err(CaPathError::IssuerSkiMissing);
};
let Some(child_aki) = child.tbs.extensions.authority_key_identifier.as_deref() else {
return Err(CaPathError::ChildAkiMissing);
};
if child_aki != issuer_ski {
return Err(CaPathError::ChildAkiMismatch);
}
Ok(())
}
fn validate_child_aia_points_to_issuer_uri(
child: &ResourceCertificate,
issuer_ca_rsync_uri: &str,
) -> Result<(), CaPathError> {
let Some(uris) = child.tbs.extensions.ca_issuers_uris.as_ref() else {
return Err(CaPathError::ChildAiaMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_ca_rsync_uri) {
return Err(CaPathError::ChildAiaIssuerUriMismatch);
}
Ok(())
}
fn validate_child_crldp_contains_issuer_crl_uri(
child: &ResourceCertificate,
issuer_crl_rsync_uri: &str,
) -> Result<(), CaPathError> {
let Some(uris) = child.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(CaPathError::ChildCrlDpMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_crl_rsync_uri) {
return Err(CaPathError::ChildCrlDpUriMismatch);
}
Ok(())
}
fn validate_child_ca_key_usage(child_ca_der: &[u8]) -> Result<(), CaPathError> {
let (rem, cert) = X509Certificate::from_der(child_ca_der)
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CaPathError::ChildSignatureInvalid(
"trailing bytes after child CA certificate".to_string(),
));
}
let mut ku_critical: Option<bool> = None;
for ext in cert.extensions() {
if ext.oid.to_id_string() == OID_KEY_USAGE {
ku_critical = Some(ext.critical);
break;
}
}
let Some(critical) = ku_critical else {
return Err(CaPathError::KeyUsageMissing);
};
if !critical {
return Err(CaPathError::KeyUsageNotCritical);
}
let Some(ku) = cert
.key_usage()
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))?
else {
return Err(CaPathError::KeyUsageMissing);
};
let v = &ku.value;
let ok = v.key_cert_sign()
&& v.crl_sign()
&& !v.digital_signature()
&& !v.non_repudiation()
&& !v.key_encipherment()
&& !v.data_encipherment()
&& !v.key_agreement()
&& !v.encipher_only()
&& !v.decipher_only();
if !ok {
return Err(CaPathError::KeyUsageInvalidBits);
}
Ok(())
}
fn verify_cert_signature_with_issuer(
child_der: &[u8],
issuer_ca_der: &[u8],
) -> Result<(), CaPathError> {
let (rem, child) = X509Certificate::from_der(child_der)
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CaPathError::ChildSignatureInvalid(
"trailing bytes after child certificate".to_string(),
));
}
let (rem, issuer) = X509Certificate::from_der(issuer_ca_der)
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CaPathError::ChildSignatureInvalid(
"trailing bytes after issuer certificate".to_string(),
));
}
child
.verify_signature(Some(&issuer.tbs_certificate.subject_pki))
.map_err(|e| CaPathError::ChildSignatureInvalid(e.to_string()))
}
fn time_within_validity(
t: time::OffsetDateTime,
not_before: time::OffsetDateTime,
not_after: time::OffsetDateTime,
) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let not_before = not_before.to_offset(time::UtcOffset::UTC);
let not_after = not_after.to_offset(time::UtcOffset::UTC);
t >= not_before && t <= not_after
}
fn crl_valid_at_time(crl: &RpkixCrl, t: time::OffsetDateTime) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
t >= this_update && t < next_update
}
fn is_serial_revoked_by_crl(cert: &ResourceCertificate, crl: &RpkixCrl) -> bool {
let serial = BigUnsigned::from_biguint(&cert.tbs.serial_number);
crl.revoked_certs
.iter()
.any(|rc| rc.serial_number == serial)
}
fn resolve_child_ip_resources(
child_ip: Option<&IpResourceSet>,
issuer_effective: Option<&IpResourceSet>,
) -> Result<Option<IpResourceSet>, CaPathError> {
let Some(child_ip) = child_ip else {
return Ok(None);
};
let Some(parent) = issuer_effective else {
if child_ip.has_any_inherit() {
return Err(CaPathError::InheritWithoutParentResources);
}
// With no parent effective resources, we cannot validate subset.
return Err(CaPathError::ResourcesNotSubset);
};
// Resolve per-AFI inherit, producing an effective set with no inherit.
let parent_by_afi = ip_resources_by_afi_items(parent)?;
let mut out_families: Vec<crate::data_model::rc::IpAddressFamily> = Vec::new();
for fam in &child_ip.families {
match &fam.choice {
IpAddressChoice::Inherit => {
let items = parent_by_afi
.get(&fam.afi)
.ok_or(CaPathError::InheritWithoutParentResources)?;
out_families.push(crate::data_model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
}
IpAddressChoice::AddressesOrRanges(items) => {
// Subset check against parent union for that AFI.
let parent_set =
ip_resources_single_afi(parent, fam.afi, parent_by_afi.get(&fam.afi));
if !ip_family_items_subset(items, &parent_set) {
return Err(CaPathError::ResourcesNotSubset);
}
out_families.push(crate::data_model::rc::IpAddressFamily {
afi: fam.afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
}
}
}
Ok(Some(IpResourceSet {
families: out_families,
}))
}
fn resolve_child_as_resources(
child_as: Option<&AsResourceSet>,
issuer_effective: Option<&AsResourceSet>,
) -> Result<Option<AsResourceSet>, CaPathError> {
let Some(child_as) = child_as else {
return Ok(None);
};
let Some(parent) = issuer_effective else {
if matches!(child_as.asnum, Some(AsIdentifierChoice::Inherit))
|| matches!(child_as.rdi, Some(AsIdentifierChoice::Inherit))
{
return Err(CaPathError::InheritWithoutParentResources);
}
return Err(CaPathError::ResourcesNotSubset);
};
let asnum = match child_as.asnum.as_ref() {
None => None,
Some(AsIdentifierChoice::Inherit) => parent
.asnum
.clone()
.ok_or(CaPathError::InheritWithoutParentResources)
.map(Some)?,
Some(_) => {
if !as_choice_subset(child_as.asnum.as_ref(), parent.asnum.as_ref()) {
return Err(CaPathError::ResourcesNotSubset);
}
child_as.asnum.clone()
}
};
let rdi = match child_as.rdi.as_ref() {
None => None,
Some(AsIdentifierChoice::Inherit) => parent
.rdi
.clone()
.ok_or(CaPathError::InheritWithoutParentResources)
.map(Some)?,
Some(_) => {
if !as_choice_subset(child_as.rdi.as_ref(), parent.rdi.as_ref()) {
return Err(CaPathError::ResourcesNotSubset);
}
child_as.rdi.clone()
}
};
Ok(Some(AsResourceSet { asnum, rdi }))
}
fn as_choice_subset(
child: Option<&AsIdentifierChoice>,
parent: Option<&AsIdentifierChoice>,
) -> bool {
let Some(child) = child else {
return true;
};
let Some(parent) = parent else {
return false;
};
// Treat inherit as "all of parent" here; actual resolution is handled elsewhere.
if matches!(child, AsIdentifierChoice::Inherit) {
return true;
}
if matches!(parent, AsIdentifierChoice::Inherit) {
return true;
}
let child_intervals = as_choice_to_merged_intervals(child);
let parent_intervals = as_choice_to_merged_intervals(parent);
for (cmin, cmax) in &child_intervals {
if !as_interval_is_covered(&parent_intervals, *cmin, *cmax) {
return false;
}
}
true
}
fn as_choice_to_merged_intervals(choice: &AsIdentifierChoice) -> Vec<(u32, u32)> {
let mut v = Vec::new();
match choice {
AsIdentifierChoice::Inherit => {}
AsIdentifierChoice::AsIdsOrRanges(items) => {
for item in items {
match item {
crate::data_model::rc::AsIdOrRange::Id(id) => v.push((*id, *id)),
crate::data_model::rc::AsIdOrRange::Range { min, max } => v.push((*min, *max)),
}
}
}
}
v.sort_by_key(|(a, _b)| *a);
merge_as_intervals(&v)
}
fn merge_as_intervals(v: &[(u32, u32)]) -> Vec<(u32, u32)> {
let mut out: Vec<(u32, u32)> = Vec::new();
for (min, max) in v {
let Some(last) = out.last_mut() else {
out.push((*min, *max));
continue;
};
if *min <= last.1.saturating_add(1) {
last.1 = last.1.max(*max);
continue;
}
out.push((*min, *max));
}
out
}
fn as_interval_is_covered(parent: &[(u32, u32)], min: u32, max: u32) -> bool {
for (pmin, pmax) in parent {
if *pmin <= min && max <= *pmax {
return true;
}
if *pmin > min {
break;
}
}
false
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
enum AfiKey {
V4,
V6,
}
fn ip_resources_by_afi_items(
set: &IpResourceSet,
) -> Result<
std::collections::BTreeMap<
crate::data_model::rc::Afi,
Vec<crate::data_model::rc::IpAddressOrRange>,
>,
CaPathError,
> {
let mut m: std::collections::BTreeMap<
crate::data_model::rc::Afi,
Vec<crate::data_model::rc::IpAddressOrRange>,
> = std::collections::BTreeMap::new();
for fam in &set.families {
match &fam.choice {
IpAddressChoice::Inherit => return Err(CaPathError::InheritWithoutParentResources),
IpAddressChoice::AddressesOrRanges(items) => {
m.insert(fam.afi, items.clone());
}
}
}
Ok(m)
}
fn ip_resources_single_afi(
parent: &IpResourceSet,
afi: crate::data_model::rc::Afi,
items: Option<&Vec<crate::data_model::rc::IpAddressOrRange>>,
) -> IpResourceSet {
let mut families = Vec::new();
if let Some(items) = items {
families.push(crate::data_model::rc::IpAddressFamily {
afi,
choice: IpAddressChoice::AddressesOrRanges(items.clone()),
});
} else {
// If parent doesn't mention this AFI explicitly, treat as empty.
// The subset check will fail.
let _ = parent;
}
IpResourceSet { families }
}
fn ip_family_items_subset(
child_items: &[crate::data_model::rc::IpAddressOrRange],
parent_set: &IpResourceSet,
) -> bool {
let parent_by_afi = ip_resources_to_merged_intervals(parent_set);
// parent_set should contain exactly one AFI.
let (afi_key, parent_intervals) = match parent_by_afi.into_iter().next() {
None => return false,
Some(v) => v,
};
let mut child_intervals: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for item in child_items {
match item {
crate::data_model::rc::IpAddressOrRange::Prefix(p) => {
child_intervals.push(prefix_to_range(p))
}
crate::data_model::rc::IpAddressOrRange::Range(r) => {
child_intervals.push((r.min.clone(), r.max.clone()))
}
}
}
child_intervals.sort_by(|(a, _), (b, _)| a.cmp(b));
let child_intervals = merge_ip_intervals(&child_intervals);
let _ = afi_key;
for (cmin, cmax) in &child_intervals {
if !interval_is_covered(&parent_intervals, cmin, cmax) {
return false;
}
}
true
}
fn ip_resources_to_merged_intervals(
set: &IpResourceSet,
) -> std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> {
let mut m: std::collections::HashMap<AfiKey, Vec<(Vec<u8>, Vec<u8>)>> =
std::collections::HashMap::new();
for fam in &set.families {
let afi = match fam.afi {
crate::data_model::rc::Afi::Ipv4 => AfiKey::V4,
crate::data_model::rc::Afi::Ipv6 => AfiKey::V6,
};
match &fam.choice {
IpAddressChoice::Inherit => {
// When used in subset checks, treat inherit as "all" by leaving it absent.
// Resolution should have happened earlier.
}
IpAddressChoice::AddressesOrRanges(items) => {
let ent = m.entry(afi).or_default();
for item in items {
match item {
crate::data_model::rc::IpAddressOrRange::Prefix(p) => {
let (min, max) = prefix_to_range(p);
ent.push((min, max));
}
crate::data_model::rc::IpAddressOrRange::Range(r) => {
ent.push((r.min.clone(), r.max.clone()));
}
}
}
}
}
}
for (_afi, v) in m.iter_mut() {
v.sort_by(|(a, _), (b, _)| a.cmp(b));
*v = merge_ip_intervals(v);
}
m
}
fn merge_ip_intervals(v: &[(Vec<u8>, Vec<u8>)]) -> Vec<(Vec<u8>, Vec<u8>)> {
let mut out: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
for (min, max) in v {
let Some(last) = out.last_mut() else {
out.push((min.clone(), max.clone()));
continue;
};
if bytes_leq(min, &increment_bytes(&last.1)) {
if bytes_leq(&last.1, max) {
last.1 = max.clone();
}
continue;
}
out.push((min.clone(), max.clone()));
}
out
}
fn interval_is_covered(parent: &[(Vec<u8>, Vec<u8>)], min: &[u8], max: &[u8]) -> bool {
for (pmin, pmax) in parent {
if bytes_leq(pmin, min) && bytes_leq(max, pmax) {
return true;
}
if pmin.as_slice() > min {
break;
}
}
false
}
fn prefix_to_range(prefix: &crate::data_model::rc::IpPrefix) -> (Vec<u8>, Vec<u8>) {
let mut min = prefix.addr.clone();
let mut max = prefix.addr.clone();
let bitlen = match prefix.afi {
crate::data_model::rc::Afi::Ipv4 => 32u16,
crate::data_model::rc::Afi::Ipv6 => 128u16,
};
let plen = prefix.prefix_len.min(bitlen);
for bit in plen..bitlen {
let byte = (bit / 8) as usize;
let offset = 7 - (bit % 8);
let mask = 1u8 << offset;
min[byte] &= !mask;
max[byte] |= mask;
}
(min, max)
}
fn bytes_leq(a: &[u8], b: &[u8]) -> bool {
a <= b
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::rc::{
Afi, AsIdentifierChoice, AsResourceSet, IpAddressChoice, IpAddressFamily, IpResourceSet,
};
use crate::data_model::rc::{
RcExtensions, ResourceCertKind, ResourceCertificate, RpkixTbsCertificate,
};
use der_parser::num_bigint::BigUint;
use url::Url;
fn dummy_cert(
kind: ResourceCertKind,
subject_dn: &str,
issuer_dn: &str,
ski: Option<Vec<u8>>,
aki: Option<Vec<u8>>,
aia: Option<Vec<&str>>,
crldp: Option<Vec<&str>>,
) -> ResourceCertificate {
let aia = aia.map(|v| {
v.into_iter()
.map(|s| Url::parse(s).expect("url"))
.collect::<Vec<_>>()
});
let crldp = crldp.map(|v| {
v.into_iter()
.map(|s| Url::parse(s).expect("url"))
.collect::<Vec<_>>()
});
ResourceCertificate {
raw_der: Vec::new(),
kind,
tbs: RpkixTbsCertificate {
version: 2,
serial_number: BigUint::from(1u8),
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
issuer_dn: issuer_dn.to_string(),
subject_dn: subject_dn.to_string(),
validity_not_before: time::OffsetDateTime::UNIX_EPOCH,
validity_not_after: time::OffsetDateTime::UNIX_EPOCH,
subject_public_key_info: Vec::new(),
extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca,
subject_key_identifier: ski,
authority_key_identifier: aki,
crl_distribution_points_uris: crldp,
ca_issuers_uris: aia,
subject_info_access: None,
certificate_policies_oid: None,
ip_resources: None,
as_resources: None,
},
},
}
}
#[test]
fn resolve_child_ip_resources_rejects_inherit_without_parent_effective_resources() {
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::Inherit,
}],
};
let err = resolve_child_ip_resources(Some(&child), None).unwrap_err();
assert!(matches!(err, CaPathError::InheritWithoutParentResources));
}
#[test]
fn resolve_child_ip_resources_rejects_non_inherit_without_parent_effective_resources() {
let child = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv4,
choice: IpAddressChoice::AddressesOrRanges(vec![]),
}],
};
let err = resolve_child_ip_resources(Some(&child), None).unwrap_err();
assert!(matches!(err, CaPathError::ResourcesNotSubset));
}
#[test]
fn ip_resources_by_afi_items_rejects_inherit_families() {
let parent = IpResourceSet {
families: vec![IpAddressFamily {
afi: Afi::Ipv6,
choice: IpAddressChoice::Inherit,
}],
};
let err = ip_resources_by_afi_items(&parent).unwrap_err();
assert!(matches!(err, CaPathError::InheritWithoutParentResources));
}
#[test]
fn resolve_child_as_resources_rejects_inherit_without_parent_effective_resources() {
let child = AsResourceSet {
asnum: Some(AsIdentifierChoice::Inherit),
rdi: None,
};
let err = resolve_child_as_resources(Some(&child), None).unwrap_err();
assert!(matches!(err, CaPathError::InheritWithoutParentResources));
}
#[test]
fn child_aki_mismatch_is_rejected() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![9]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_child_aki_matches_issuer_ski(&child, &issuer).unwrap_err();
assert!(matches!(err, CaPathError::ChildAkiMismatch), "{err}");
}
#[test]
fn child_aia_missing_is_rejected() {
let _issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
None,
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err =
validate_child_aia_points_to_issuer_uri(&child, "rsync://example.test/issuer.cer")
.unwrap_err();
assert!(matches!(err, CaPathError::ChildAiaMissing), "{err}");
// Also cover issuer ski missing.
let issuer_missing_ski = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
None,
None,
None,
None,
);
let err = validate_child_aki_matches_issuer_ski(&child, &issuer_missing_ski).unwrap_err();
assert!(matches!(err, CaPathError::IssuerSkiMissing), "{err}");
}
#[test]
fn child_aia_issuer_uri_mismatch_is_rejected() {
let _issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/other.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err =
validate_child_aia_points_to_issuer_uri(&child, "rsync://example.test/issuer.cer")
.unwrap_err();
assert!(
matches!(err, CaPathError::ChildAiaIssuerUriMismatch),
"{err}"
);
}
#[test]
fn child_crldp_mismatch_is_rejected() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
None,
);
let err =
validate_child_crldp_contains_issuer_crl_uri(&child, "rsync://example.test/issuer.crl")
.unwrap_err();
assert!(matches!(err, CaPathError::ChildCrlDpMissing), "{err}");
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/other.crl"]),
);
let err =
validate_child_crldp_contains_issuer_crl_uri(&child, "rsync://example.test/issuer.crl")
.unwrap_err();
assert!(matches!(err, CaPathError::ChildCrlDpUriMismatch), "{err}");
// Cover child AKI missing.
let child_missing_aki = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
None,
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_child_aki_matches_issuer_ski(&child_missing_aki, &issuer).unwrap_err();
assert!(matches!(err, CaPathError::ChildAkiMissing), "{err}");
}
#[test]
fn child_binding_checks_accept_when_matching() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let child = dummy_cert(
ResourceCertKind::Ca,
"CN=child",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
validate_child_aki_matches_issuer_ski(&child, &issuer).expect("aki ok");
validate_child_aia_points_to_issuer_uri(&child, "rsync://example.test/issuer.cer")
.expect("aia ok");
validate_child_crldp_contains_issuer_crl_uri(&child, "rsync://example.test/issuer.crl")
.expect("crldp ok");
}
}
fn increment_bytes(v: &[u8]) -> Vec<u8> {
let mut out = v.to_vec();
for i in (0..out.len()).rev() {
if out[i] != 0xFF {
out[i] += 1;
for j in i + 1..out.len() {
out[j] = 0;
}
return out;
}
}
vec![0u8; out.len()]
}

494
src/validation/cert_path.rs Normal file
View File

@@ -0,0 +1,494 @@
use crate::data_model::common::BigUnsigned;
use crate::data_model::crl::{CrlDecodeError, CrlVerifyError, RpkixCrl};
use crate::data_model::rc::{
ResourceCertKind, ResourceCertificate, ResourceCertificateDecodeError,
};
use x509_parser::prelude::{FromDer, X509Certificate};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ValidatedEeCertPath {
pub ee: ResourceCertificate,
pub issuer_ca: ResourceCertificate,
pub issuer_crl: RpkixCrl,
}
#[derive(Debug, thiserror::Error)]
pub enum CertPathError {
#[error("EE certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
EeDecode(#[from] ResourceCertificateDecodeError),
#[error("issuer CA certificate decode failed: {0} (RFC 6487 §4; RFC 5280 §4.1)")]
IssuerDecode(ResourceCertificateDecodeError),
#[error("issuer CRL decode failed: {0} (RFC 6487 §5; RFC 9829 §3.1; RFC 5280 §5.1)")]
CrlDecode(#[from] CrlDecodeError),
#[error(
"issuer certificate must be a CA resource certificate (RFC 6487 §4.8.1; RFC 5280 §4.2.1.9)"
)]
IssuerNotCa,
#[error("EE certificate must be an EE resource certificate (RFC 6487 §4)")]
EeNotEe,
#[error(
"EE issuer DN does not match issuer CA subject DN: ee.issuer={ee_issuer_dn} issuer.subject={issuer_subject_dn} (RFC 5280 §6.1)"
)]
IssuerSubjectMismatch {
ee_issuer_dn: String,
issuer_subject_dn: String,
},
#[error("EE certificate signature verification failed: {0} (RFC 5280 §6.1)")]
EeSignatureInvalid(String),
#[error("EE KeyUsage extension missing (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageMissing,
#[error("EE KeyUsage criticality must be critical (RFC 6487 §4.8.4; RFC 5280 §4.2.1.3)")]
KeyUsageNotCritical,
#[error("EE KeyUsage must have only digitalSignature set (RFC 6487 §4.8.4)")]
KeyUsageInvalidBits,
#[error("issuer CA subjectKeyIdentifier missing (RFC 6487 §4.8.2)")]
IssuerSkiMissing,
#[error("EE authorityKeyIdentifier missing (RFC 6487 §4.8.3; RFC 5280 §4.2.1.1)")]
EeAkiMissing,
#[error(
"EE authorityKeyIdentifier does not match issuer subjectKeyIdentifier (RFC 6487 §4.8.3)"
)]
EeAkiMismatch,
#[error("EE authorityInfoAccess missing (RFC 6487 §4.8.7; RFC 5280 §4.2.2.1)")]
EeAiaMissing,
#[error(
"EE authorityInfoAccess does not reference issuer certificate rsync URI (RFC 6487 §4.8.7)"
)]
EeAiaIssuerUriMismatch,
#[error("EE CRLDistributionPoints missing (RFC 6487 §4.8.6; RFC 5280 §4.2.1.13)")]
EeCrlDpMissing,
#[error("EE CRLDistributionPoints does not reference issuer CRL rsync URI (RFC 6487 §4.8.6)")]
EeCrlDpUriMismatch,
#[error("certificate not valid at validation_time (RFC 5280 §4.1.2.5; RFC 5280 §6.1)")]
CertificateNotValidAtTime,
#[error(
"CRL signature/binding verification failed: {0} (RFC 5280 §6.3.3; RFC 6487 §5; RFC 9829 §3.1)"
)]
CrlVerify(#[from] CrlVerifyError),
#[error(
"CRL not valid at validation_time (RFC 5280 §6.3.3(g); RFC 5280 §5.1.2.4-§5.1.2.5; RFC 6487 §5)"
)]
CrlNotValidAtTime,
#[error("EE certificate is revoked by issuer CRL (RFC 5280 §6.3.3; RFC 6487 §5)")]
EeRevoked,
}
pub fn validate_ee_cert_path(
ee_cert_der: &[u8],
issuer_ca_der: &[u8],
issuer_crl_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_crl_rsync_uri: Option<&str>,
validation_time: time::OffsetDateTime,
) -> Result<ValidatedEeCertPath, CertPathError> {
let ee = ResourceCertificate::decode_der(ee_cert_der)?;
if ee.kind != ResourceCertKind::Ee {
return Err(CertPathError::EeNotEe);
}
let issuer_ca =
ResourceCertificate::decode_der(issuer_ca_der).map_err(CertPathError::IssuerDecode)?;
if issuer_ca.kind != ResourceCertKind::Ca {
return Err(CertPathError::IssuerNotCa);
}
if ee.tbs.issuer_dn != issuer_ca.tbs.subject_dn {
return Err(CertPathError::IssuerSubjectMismatch {
ee_issuer_dn: ee.tbs.issuer_dn.clone(),
issuer_subject_dn: issuer_ca.tbs.subject_dn.clone(),
});
}
validate_ee_aki_matches_issuer_ski(&ee, &issuer_ca)?;
if let Some(expected_issuer_uri) = issuer_ca_rsync_uri {
validate_ee_aia_points_to_issuer_uri(&ee, expected_issuer_uri)?;
}
if let Some(expected_crl_uri) = issuer_crl_rsync_uri {
validate_ee_crldp_contains_issuer_crl_uri(&ee, expected_crl_uri)?;
}
if !time_within_validity(
validation_time,
ee.tbs.validity_not_before,
ee.tbs.validity_not_after,
) || !time_within_validity(
validation_time,
issuer_ca.tbs.validity_not_before,
issuer_ca.tbs.validity_not_after,
) {
return Err(CertPathError::CertificateNotValidAtTime);
}
verify_cert_signature_with_issuer(ee_cert_der, issuer_ca_der)?;
validate_ee_key_usage(ee_cert_der)?;
let issuer_crl = RpkixCrl::decode_der(issuer_crl_der)?;
issuer_crl.verify_signature_with_issuer_certificate_der(issuer_ca_der)?;
if !crl_valid_at_time(&issuer_crl, validation_time) {
return Err(CertPathError::CrlNotValidAtTime);
}
if is_serial_revoked_by_crl(&ee, &issuer_crl) {
return Err(CertPathError::EeRevoked);
}
Ok(ValidatedEeCertPath {
ee,
issuer_ca,
issuer_crl,
})
}
fn validate_ee_aki_matches_issuer_ski(
ee: &ResourceCertificate,
issuer_ca: &ResourceCertificate,
) -> Result<(), CertPathError> {
let Some(issuer_ski) = issuer_ca.tbs.extensions.subject_key_identifier.as_deref() else {
return Err(CertPathError::IssuerSkiMissing);
};
let Some(ee_aki) = ee.tbs.extensions.authority_key_identifier.as_deref() else {
return Err(CertPathError::EeAkiMissing);
};
if ee_aki != issuer_ski {
return Err(CertPathError::EeAkiMismatch);
}
Ok(())
}
fn validate_ee_aia_points_to_issuer_uri(
ee: &ResourceCertificate,
issuer_ca_rsync_uri: &str,
) -> Result<(), CertPathError> {
let Some(uris) = ee.tbs.extensions.ca_issuers_uris.as_ref() else {
return Err(CertPathError::EeAiaMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_ca_rsync_uri) {
return Err(CertPathError::EeAiaIssuerUriMismatch);
}
Ok(())
}
fn validate_ee_crldp_contains_issuer_crl_uri(
ee: &ResourceCertificate,
issuer_crl_rsync_uri: &str,
) -> Result<(), CertPathError> {
let Some(uris) = ee.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(CertPathError::EeCrlDpMissing);
};
if !uris.iter().any(|u| u.as_str() == issuer_crl_rsync_uri) {
return Err(CertPathError::EeCrlDpUriMismatch);
}
Ok(())
}
fn validate_ee_key_usage(ee_cert_der: &[u8]) -> Result<(), CertPathError> {
let (rem, cert) = X509Certificate::from_der(ee_cert_der)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::EeSignatureInvalid(
"trailing bytes after EE certificate".to_string(),
));
}
let mut ku_critical: Option<bool> = None;
for ext in cert.extensions() {
if ext.oid.to_id_string() == crate::data_model::oid::OID_KEY_USAGE {
ku_critical = Some(ext.critical);
break;
}
}
let Some(critical) = ku_critical else {
return Err(CertPathError::KeyUsageMissing);
};
if !critical {
return Err(CertPathError::KeyUsageNotCritical);
}
let Some(ku) = cert
.key_usage()
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?
else {
return Err(CertPathError::KeyUsageMissing);
};
let v = &ku.value;
let ok = v.digital_signature()
&& !v.key_cert_sign()
&& !v.crl_sign()
&& !v.non_repudiation()
&& !v.key_encipherment()
&& !v.data_encipherment()
&& !v.key_agreement()
&& !v.encipher_only()
&& !v.decipher_only();
if !ok {
return Err(CertPathError::KeyUsageInvalidBits);
}
Ok(())
}
fn verify_cert_signature_with_issuer(
ee_cert_der: &[u8],
issuer_ca_der: &[u8],
) -> Result<(), CertPathError> {
let (rem, ee) = X509Certificate::from_der(ee_cert_der)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::EeSignatureInvalid(
"trailing bytes after EE certificate".to_string(),
));
}
let (rem, issuer) = X509Certificate::from_der(issuer_ca_der)
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))?;
if !rem.is_empty() {
return Err(CertPathError::EeSignatureInvalid(
"trailing bytes after issuer certificate".to_string(),
));
}
ee.verify_signature(Some(&issuer.tbs_certificate.subject_pki))
.map_err(|e| CertPathError::EeSignatureInvalid(e.to_string()))
}
fn time_within_validity(
t: time::OffsetDateTime,
not_before: time::OffsetDateTime,
not_after: time::OffsetDateTime,
) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let not_before = not_before.to_offset(time::UtcOffset::UTC);
let not_after = not_after.to_offset(time::UtcOffset::UTC);
t >= not_before && t <= not_after
}
fn crl_valid_at_time(crl: &RpkixCrl, t: time::OffsetDateTime) -> bool {
let t = t.to_offset(time::UtcOffset::UTC);
let this_update = crl.this_update.utc.to_offset(time::UtcOffset::UTC);
let next_update = crl.next_update.utc.to_offset(time::UtcOffset::UTC);
t >= this_update && t < next_update
}
fn is_serial_revoked_by_crl(ee: &ResourceCertificate, crl: &RpkixCrl) -> bool {
let serial = BigUnsigned::from_biguint(&ee.tbs.serial_number);
crl.revoked_certs
.iter()
.any(|rc| rc.serial_number == serial)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::rc::{
RcExtensions, ResourceCertKind, ResourceCertificate, RpkixTbsCertificate,
};
use der_parser::num_bigint::BigUint;
use url::Url;
fn dummy_cert(
kind: ResourceCertKind,
subject_dn: &str,
issuer_dn: &str,
ski: Option<Vec<u8>>,
aki: Option<Vec<u8>>,
aia: Option<Vec<&str>>,
crldp: Option<Vec<&str>>,
) -> ResourceCertificate {
let aia = aia.map(|v| {
v.into_iter()
.map(|s| Url::parse(s).expect("url"))
.collect::<Vec<_>>()
});
let crldp = crldp.map(|v| {
v.into_iter()
.map(|s| Url::parse(s).expect("url"))
.collect::<Vec<_>>()
});
ResourceCertificate {
raw_der: Vec::new(),
kind,
tbs: RpkixTbsCertificate {
version: 2,
serial_number: BigUint::from(1u8),
signature_algorithm: "1.2.840.113549.1.1.11".to_string(),
issuer_dn: issuer_dn.to_string(),
subject_dn: subject_dn.to_string(),
validity_not_before: time::OffsetDateTime::UNIX_EPOCH,
validity_not_after: time::OffsetDateTime::UNIX_EPOCH,
subject_public_key_info: Vec::new(),
extensions: RcExtensions {
basic_constraints_ca: kind == ResourceCertKind::Ca,
subject_key_identifier: ski,
authority_key_identifier: aki,
crl_distribution_points_uris: crldp,
ca_issuers_uris: aia,
subject_info_access: None,
certificate_policies_oid: None,
ip_resources: None,
as_resources: None,
},
},
}
}
#[test]
fn ee_aki_and_aia_and_crldp_binding_checks_report_errors() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let ee = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![9]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer).unwrap_err();
assert!(matches!(err, CertPathError::EeAkiMismatch), "{err}");
let issuer_missing_ski = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
None,
None,
None,
None,
);
let err = validate_ee_aki_matches_issuer_ski(&ee, &issuer_missing_ski).unwrap_err();
assert!(matches!(err, CertPathError::IssuerSkiMissing), "{err}");
let ee_missing_aki = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
None,
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aki_matches_issuer_ski(&ee_missing_aki, &issuer).unwrap_err();
assert!(matches!(err, CertPathError::EeAkiMissing), "{err}");
let ee_missing_aia = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
None,
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err = validate_ee_aia_points_to_issuer_uri(
&ee_missing_aia,
"rsync://example.test/issuer.cer",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeAiaMissing), "{err}");
let ee_wrong_aia = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/other.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
let err =
validate_ee_aia_points_to_issuer_uri(&ee_wrong_aia, "rsync://example.test/issuer.cer")
.unwrap_err();
assert!(
matches!(err, CertPathError::EeAiaIssuerUriMismatch),
"{err}"
);
let ee_missing_crldp = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
None,
);
let err = validate_ee_crldp_contains_issuer_crl_uri(
&ee_missing_crldp,
"rsync://example.test/issuer.crl",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeCrlDpMissing), "{err}");
let ee_wrong_crldp = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/other.crl"]),
);
let err = validate_ee_crldp_contains_issuer_crl_uri(
&ee_wrong_crldp,
"rsync://example.test/issuer.crl",
)
.unwrap_err();
assert!(matches!(err, CertPathError::EeCrlDpUriMismatch), "{err}");
}
#[test]
fn ee_binding_checks_accept_when_matching() {
let issuer = dummy_cert(
ResourceCertKind::Ca,
"CN=issuer",
"CN=issuer",
Some(vec![1]),
None,
None,
None,
);
let ee = dummy_cert(
ResourceCertKind::Ee,
"CN=ee",
"CN=issuer",
Some(vec![2]),
Some(vec![1]),
Some(vec!["rsync://example.test/issuer.cer"]),
Some(vec!["rsync://example.test/issuer.crl"]),
);
validate_ee_aki_matches_issuer_ski(&ee, &issuer).expect("aki ok");
validate_ee_aia_points_to_issuer_uri(&ee, "rsync://example.test/issuer.cer")
.expect("aia ok");
validate_ee_crldp_contains_issuer_crl_uri(&ee, "rsync://example.test/issuer.crl")
.expect("crldp ok");
}
}

163
src/validation/from_tal.rs Normal file
View File

@@ -0,0 +1,163 @@
use url::Url;
use crate::data_model::ta::{TrustAnchor, TrustAnchorError};
use crate::data_model::tal::{Tal, TalDecodeError};
use crate::sync::rrdp::Fetcher;
use crate::validation::ca_instance::{
CaInstanceUris, CaInstanceUrisError, ca_instance_uris_from_ca_certificate,
};
use crate::validation::run::{RunError, RunOutput, run_publication_point_once};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredRootCaInstance {
pub tal_url: Option<String>,
pub trust_anchor: TrustAnchor,
pub ca_instance: CaInstanceUris,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunFromTalOutput {
pub discovery: DiscoveredRootCaInstance,
pub run: RunOutput,
}
#[derive(Debug, thiserror::Error)]
pub enum FromTalError {
#[error("TAL fetch failed: {0} (RFC 8630 §2.2)")]
TalFetch(String),
#[error("TAL decode failed: {0} (RFC 8630 §2.2)")]
TalDecode(#[from] TalDecodeError),
#[error("failed to fetch TA certificate from TAL: {0} (RFC 8630 §2.3)")]
TaFetch(String),
#[error("failed to bind TAL and TA certificate: {0} (RFC 8630 §2.3)")]
Bind(#[from] TrustAnchorError),
#[error("failed to discover CA instance URIs from TA certificate: {0}")]
CaInstanceUris(#[from] CaInstanceUrisError),
#[error("run failed: {0}")]
Run(#[from] RunError),
#[error("TAL contains no TA URIs (RFC 8630 §2.2)")]
NoTaUris,
}
pub fn discover_root_ca_instance_from_tal_url(
http_fetcher: &dyn Fetcher,
tal_url: &str,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal_bytes = http_fetcher
.fetch(tal_url)
.map_err(FromTalError::TalFetch)?;
let tal = Tal::decode_bytes(&tal_bytes)?;
discover_root_ca_instance_from_tal(http_fetcher, tal, Some(tal_url.to_string()))
}
pub fn discover_root_ca_instance_from_tal(
http_fetcher: &dyn Fetcher,
tal: Tal,
tal_url: Option<String>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
if tal.ta_uris.is_empty() {
return Err(FromTalError::NoTaUris);
}
let mut last_err: Option<String> = None;
for ta_uri in tal.ta_uris.iter() {
let ta_der = match http_fetcher.fetch(ta_uri.as_str()) {
Ok(b) => b,
Err(e) => {
last_err = Some(format!("fetch {ta_uri} failed: {e}"));
continue;
}
};
let trust_anchor = match TrustAnchor::bind_der(tal.clone(), &ta_der, Some(ta_uri)) {
Ok(ta) => ta,
Err(e) => {
last_err = Some(format!("bind {ta_uri} failed: {e}"));
continue;
}
};
let ca_instance =
match ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca) {
Ok(v) => v,
Err(e) => {
last_err = Some(format!("CA instance discovery failed: {e}"));
continue;
}
};
return Ok(DiscoveredRootCaInstance {
tal_url,
trust_anchor,
ca_instance,
});
}
Err(FromTalError::TaFetch(last_err.unwrap_or_else(|| {
"unknown TA candidate error".to_string()
})))
}
pub fn discover_root_ca_instance_from_tal_and_ta_der(
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
) -> Result<DiscoveredRootCaInstance, FromTalError> {
let tal = Tal::decode_bytes(tal_bytes)?;
let trust_anchor = TrustAnchor::bind_der(tal, ta_der, resolved_ta_uri)?;
let ca_instance = ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca)?;
Ok(DiscoveredRootCaInstance {
tal_url: None,
trust_anchor,
ca_instance,
})
}
pub fn run_root_from_tal_url_once(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
) -> Result<RunFromTalOutput, FromTalError> {
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
let run = run_publication_point_once(
store,
policy,
discovery.ca_instance.rrdp_notification_uri.as_deref(),
&discovery.ca_instance.rsync_base_uri,
&discovery.ca_instance.manifest_rsync_uri,
&discovery.ca_instance.publication_point_rsync_uri,
http_fetcher,
rsync_fetcher,
&discovery.trust_anchor.ta_certificate.raw_der,
None,
discovery
.trust_anchor
.ta_certificate
.rc_ca
.tbs
.extensions
.ip_resources
.as_ref(),
discovery
.trust_anchor
.ta_certificate
.rc_ca
.tbs
.extensions
.as_resources
.as_ref(),
validation_time,
)?;
Ok(RunFromTalOutput { discovery, run })
}

295
src/validation/manifest.rs Normal file
View File

@@ -0,0 +1,295 @@
use crate::data_model::manifest::{ManifestDecodeError, ManifestObject, ManifestValidateError};
use crate::data_model::signed_object::SignedObjectVerifyError;
use crate::policy::{CaFailedFetchPolicy, Policy};
use crate::report::{RfcRef, Warning};
use crate::storage::{RocksStore, StorageError, VerifiedKey, VerifiedPublicationPointPack};
use sha2::Digest;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PublicationPointSource {
Fresh,
VerifiedCache,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationPointResult {
pub source: PublicationPointSource,
pub pack: VerifiedPublicationPointPack,
pub warnings: Vec<Warning>,
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestFreshError {
#[error(
"manifest not found in raw_objects: {manifest_rsync_uri} (RFC 9286 §6.2; RFC 9286 §6.6)"
)]
MissingManifest { manifest_rsync_uri: String },
#[error("manifest decode failed: {0} (RFC 9286 §4; RFC 9286 §6.2; RFC 9286 §6.6)")]
Decode(#[from] ManifestDecodeError),
#[error(
"manifest embedded EE certificate resources invalid: {0} (RFC 9286 §5.1; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
EeResources(#[from] ManifestValidateError),
#[error(
"manifest CMS signature verification failed: {0} (RFC 6488 §3; RFC 9589 §4; RFC 9286 §6.2; RFC 9286 §6.6)"
)]
Signature(#[from] SignedObjectVerifyError),
#[error(
"manifest is not valid at validation_time: this_update={this_update_rfc3339_utc} next_update={next_update_rfc3339_utc} validation_time={validation_time_rfc3339_utc} (RFC 9286 §6.3; RFC 9286 §6.6)"
)]
StaleOrEarly {
this_update_rfc3339_utc: String,
next_update_rfc3339_utc: String,
validation_time_rfc3339_utc: String,
},
#[error(
"manifest referenced file missing in raw_objects: {rsync_uri} (RFC 9286 §6.4; RFC 9286 §6.6)"
)]
MissingFile { rsync_uri: String },
#[error("manifest file hash mismatch: {rsync_uri} (RFC 9286 §6.5; RFC 9286 §6.6)")]
HashMismatch { rsync_uri: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestCachedError {
#[error("verified cache entry missing: {0} (RFC 9286 §6.6)")]
MissingVerifiedCache(String),
#[error("verified cache pack invalid: {0}")]
InvalidPack(#[from] StorageError),
#[error("cached manifest revalidation failed: {0}")]
CachedManifestFresh(#[from] ManifestFreshError),
}
#[derive(Debug, thiserror::Error)]
pub enum ManifestProcessError {
#[error("manifest processing failed and cache use is disabled: {0}")]
StopAllOutput(#[from] ManifestFreshError),
#[error(
"manifest processing failed and no usable verified cache is available: fresh={fresh}; cached={cached}"
)]
NoUsableCache {
fresh: ManifestFreshError,
cached: ManifestCachedError,
},
#[error("storage error during manifest processing: {0}")]
Storage(#[from] StorageError),
}
pub fn process_manifest_publication_point(
store: &RocksStore,
policy: &Policy,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
validation_time: time::OffsetDateTime,
) -> Result<PublicationPointResult, ManifestProcessError> {
let fresh = try_build_fresh_pack(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
validation_time,
);
match fresh {
Ok(pack) => {
let key = VerifiedKey::from_manifest_rsync_uri(manifest_rsync_uri);
let bytes = pack.encode()?;
store.put_verified(&key, &bytes)?;
Ok(PublicationPointResult {
source: PublicationPointSource::Fresh,
pack,
warnings: Vec::new(),
})
}
Err(fresh_err) => match policy.ca_failed_fetch_policy {
CaFailedFetchPolicy::StopAllOutput => {
Err(ManifestProcessError::StopAllOutput(fresh_err))
}
CaFailedFetchPolicy::UseVerifiedCache => {
let mut warnings = vec![
Warning::new(format!("manifest failed fetch: {fresh_err}"))
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(manifest_rsync_uri),
];
match load_and_revalidate_cached_pack(
store,
manifest_rsync_uri,
publication_point_rsync_uri,
validation_time,
) {
Ok(pack) => {
warnings.push(
Warning::new("using verified cache for publication point")
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(manifest_rsync_uri),
);
Ok(PublicationPointResult {
source: PublicationPointSource::VerifiedCache,
pack,
warnings,
})
}
Err(cached_err) => Err(ManifestProcessError::NoUsableCache {
fresh: fresh_err,
cached: cached_err,
}),
}
}
},
}
}
fn load_and_revalidate_cached_pack(
store: &RocksStore,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
validation_time: time::OffsetDateTime,
) -> Result<VerifiedPublicationPointPack, ManifestCachedError> {
let key = VerifiedKey::from_manifest_rsync_uri(manifest_rsync_uri);
let bytes = store
.get_verified(&key)?
.ok_or_else(|| ManifestCachedError::MissingVerifiedCache(key.as_str().to_string()))?;
let pack = VerifiedPublicationPointPack::decode(&bytes)?;
if pack.manifest_rsync_uri != manifest_rsync_uri {
return Err(ManifestCachedError::InvalidPack(StorageError::RocksDb(
"cached pack manifest_rsync_uri does not match key".to_string(),
)));
}
if pack.publication_point_rsync_uri != publication_point_rsync_uri {
return Err(ManifestCachedError::InvalidPack(StorageError::RocksDb(
"cached pack publication_point_rsync_uri does not match expected".to_string(),
)));
}
revalidate_pack_with_current_time(&pack, validation_time).map_err(ManifestCachedError::from)?;
Ok(pack)
}
fn revalidate_pack_with_current_time(
pack: &VerifiedPublicationPointPack,
validation_time: time::OffsetDateTime,
) -> Result<(), ManifestFreshError> {
let manifest = ManifestObject::decode_der(&pack.manifest_bytes)?;
manifest.validate_embedded_ee_cert()?;
manifest.signed_object.verify()?;
let this_update = manifest
.manifest
.this_update
.to_offset(time::UtcOffset::UTC);
let next_update = manifest
.manifest
.next_update
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
if now < this_update || now > next_update {
return Err(ManifestFreshError::StaleOrEarly {
this_update_rfc3339_utc: this_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format thisUpdate"),
next_update_rfc3339_utc: next_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format nextUpdate"),
validation_time_rfc3339_utc: now
.format(&time::format_description::well_known::Rfc3339)
.expect("format validation_time"),
});
}
Ok(())
}
fn try_build_fresh_pack(
store: &RocksStore,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
validation_time: time::OffsetDateTime,
) -> Result<VerifiedPublicationPointPack, ManifestFreshError> {
let manifest_bytes = store
.get_raw(manifest_rsync_uri)
.map_err(|e| ManifestFreshError::MissingManifest {
manifest_rsync_uri: format!("{manifest_rsync_uri} ({e})"),
})?
.ok_or_else(|| ManifestFreshError::MissingManifest {
manifest_rsync_uri: manifest_rsync_uri.to_string(),
})?;
let manifest = ManifestObject::decode_der(&manifest_bytes)?;
manifest.validate_embedded_ee_cert()?;
manifest.signed_object.verify()?;
let this_update = manifest
.manifest
.this_update
.to_offset(time::UtcOffset::UTC);
let next_update = manifest
.manifest
.next_update
.to_offset(time::UtcOffset::UTC);
let now = validation_time.to_offset(time::UtcOffset::UTC);
if now < this_update || now > next_update {
return Err(ManifestFreshError::StaleOrEarly {
this_update_rfc3339_utc: this_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format thisUpdate"),
next_update_rfc3339_utc: next_update
.format(&time::format_description::well_known::Rfc3339)
.expect("format nextUpdate"),
validation_time_rfc3339_utc: now
.format(&time::format_description::well_known::Rfc3339)
.expect("format validation_time"),
});
}
let mut files = Vec::with_capacity(manifest.manifest.files.len());
for entry in &manifest.manifest.files {
let rsync_uri = join_rsync_dir_and_file(publication_point_rsync_uri, &entry.file_name);
let bytes = store
.get_raw(&rsync_uri)
.map_err(|_e| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?
.ok_or_else(|| ManifestFreshError::MissingFile {
rsync_uri: rsync_uri.clone(),
})?;
let computed = sha2::Sha256::digest(&bytes);
if computed.as_slice() != entry.hash_bytes.as_slice() {
return Err(ManifestFreshError::HashMismatch { rsync_uri });
}
files.push(crate::storage::PackFile::from_bytes_compute_sha256(
rsync_uri, bytes,
));
}
Ok(VerifiedPublicationPointPack {
format_version: VerifiedPublicationPointPack::FORMAT_VERSION_V1,
manifest_rsync_uri: manifest_rsync_uri.to_string(),
publication_point_rsync_uri: publication_point_rsync_uri.to_string(),
this_update: crate::storage::PackTime::from_utc_offset_datetime(this_update),
next_update: crate::storage::PackTime::from_utc_offset_datetime(next_update),
verified_at: crate::storage::PackTime::from_utc_offset_datetime(now),
manifest_bytes,
files,
})
}
fn join_rsync_dir_and_file(base: &str, file_name: &str) -> String {
if base.ends_with('/') {
format!("{base}{file_name}")
} else {
format!("{base}/{file_name}")
}
}

10
src/validation/mod.rs Normal file
View File

@@ -0,0 +1,10 @@
pub mod ca_instance;
pub mod ca_path;
pub mod cert_path;
pub mod from_tal;
pub mod manifest;
pub mod objects;
pub mod run;
pub mod run_tree_from_tal;
pub mod tree;
pub mod tree_runner;

1030
src/validation/objects.rs Normal file

File diff suppressed because it is too large Load Diff

89
src/validation/run.rs Normal file
View File

@@ -0,0 +1,89 @@
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
use crate::fetch::rsync::RsyncFetcher;
use crate::policy::Policy;
use crate::storage::{RocksStore, VerifiedKey};
use crate::sync::repo::{RepoSyncResult, sync_publication_point};
use crate::sync::rrdp::Fetcher as HttpFetcher;
use crate::validation::manifest::{PublicationPointResult, process_manifest_publication_point};
use crate::validation::objects::{
ObjectsOutput, process_verified_publication_point_pack_for_issuer,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunOutput {
pub repo_sync: RepoSyncResult,
pub publication_point: PublicationPointResult,
pub objects: ObjectsOutput,
}
#[derive(Debug, thiserror::Error)]
pub enum RunError {
#[error("repo sync failed: {0}")]
RepoSync(#[from] crate::sync::repo::RepoSyncError),
#[error("manifest processing failed: {0}")]
Manifest(#[from] crate::validation::manifest::ManifestProcessError),
}
/// v1 serial offline-friendly end-to-end execution for a single publication point.
///
/// This orchestrates:
/// 1) repo sync (RRDP or rsync fallback) into `raw_objects`
/// 2) manifest RP processing into a verified pack (`verified:<manifest-rsync-uri>`)
/// 3) signed object processing (ROA/ASPA) from the verified pack
pub fn run_publication_point_once(
store: &RocksStore,
policy: &Policy,
rrdp_notification_uri: Option<&str>,
rsync_base_uri: &str,
manifest_rsync_uri: &str,
publication_point_rsync_uri: &str,
http_fetcher: &dyn HttpFetcher,
rsync_fetcher: &dyn RsyncFetcher,
issuer_ca_der: &[u8],
issuer_ca_rsync_uri: Option<&str>,
issuer_effective_ip: Option<&IpResourceSet>,
issuer_effective_as: Option<&AsResourceSet>,
validation_time: time::OffsetDateTime,
) -> Result<RunOutput, RunError> {
let repo_sync = sync_publication_point(
store,
policy,
rrdp_notification_uri,
rsync_base_uri,
http_fetcher,
rsync_fetcher,
)?;
let publication_point = process_manifest_publication_point(
store,
policy,
manifest_rsync_uri,
publication_point_rsync_uri,
validation_time,
)?;
let objects = process_verified_publication_point_pack_for_issuer(
&publication_point.pack,
policy,
issuer_ca_der,
issuer_ca_rsync_uri,
issuer_effective_ip,
issuer_effective_as,
validation_time,
);
Ok(RunOutput {
repo_sync,
publication_point,
objects,
})
}
pub fn verified_pack_exists(store: &RocksStore, manifest_rsync_uri: &str) -> Result<bool, String> {
let key = VerifiedKey::from_manifest_rsync_uri(manifest_rsync_uri);
store
.get_verified(&key)
.map(|v| v.is_some())
.map_err(|e| e.to_string())
}

View File

@@ -0,0 +1,175 @@
use url::Url;
use crate::audit::PublicationPointAudit;
use crate::data_model::ta::TrustAnchor;
use crate::sync::rrdp::Fetcher;
use crate::validation::from_tal::{
DiscoveredRootCaInstance, FromTalError, discover_root_ca_instance_from_tal_and_ta_der,
discover_root_ca_instance_from_tal_url,
};
use crate::validation::tree::{
CaInstanceHandle, TreeRunAuditOutput, TreeRunConfig, TreeRunError, TreeRunOutput,
run_tree_serial, run_tree_serial_audit,
};
use crate::validation::tree_runner::Rpkiv1PublicationPointRunner;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunTreeFromTalOutput {
pub discovery: DiscoveredRootCaInstance,
pub tree: TreeRunOutput,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RunTreeFromTalAuditOutput {
pub discovery: DiscoveredRootCaInstance,
pub tree: TreeRunOutput,
pub publication_points: Vec<PublicationPointAudit>,
}
#[derive(Debug, thiserror::Error)]
pub enum RunTreeFromTalError {
#[error("{0}")]
FromTal(#[from] FromTalError),
#[error("{0}")]
Tree(#[from] TreeRunError),
}
pub fn root_handle_from_trust_anchor(
trust_anchor: &TrustAnchor,
ca_certificate_rsync_uri: Option<String>,
ca_instance: &crate::validation::ca_instance::CaInstanceUris,
) -> CaInstanceHandle {
let ta_rc = trust_anchor.ta_certificate.rc_ca.clone();
CaInstanceHandle {
depth: 0,
ca_certificate_der: trust_anchor.ta_certificate.raw_der.clone(),
ca_certificate_rsync_uri,
effective_ip_resources: ta_rc.tbs.extensions.ip_resources.clone(),
effective_as_resources: ta_rc.tbs.extensions.as_resources.clone(),
rsync_base_uri: ca_instance.rsync_base_uri.clone(),
manifest_rsync_uri: ca_instance.manifest_rsync_uri.clone(),
publication_point_rsync_uri: ca_instance.publication_point_rsync_uri.clone(),
rrdp_notification_uri: ca_instance.rrdp_notification_uri.clone(),
}
}
pub fn run_tree_from_tal_url_serial(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalOutput, RunTreeFromTalError> {
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let tree = run_tree_serial(root, &runner, config)?;
Ok(RunTreeFromTalOutput { discovery, tree })
}
pub fn run_tree_from_tal_url_serial_audit(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_url: &str,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let discovery = discover_root_ca_instance_from_tal_url(http_fetcher, tal_url)?;
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
Ok(RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points,
})
}
pub fn run_tree_from_tal_and_ta_der_serial(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalOutput, RunTreeFromTalError> {
let discovery =
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, resolved_ta_uri)?;
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let tree = run_tree_serial(root, &runner, config)?;
Ok(RunTreeFromTalOutput { discovery, tree })
}
pub fn run_tree_from_tal_and_ta_der_serial_audit(
store: &crate::storage::RocksStore,
policy: &crate::policy::Policy,
tal_bytes: &[u8],
ta_der: &[u8],
resolved_ta_uri: Option<&Url>,
http_fetcher: &dyn Fetcher,
rsync_fetcher: &dyn crate::fetch::rsync::RsyncFetcher,
validation_time: time::OffsetDateTime,
config: &TreeRunConfig,
) -> Result<RunTreeFromTalAuditOutput, RunTreeFromTalError> {
let discovery =
discover_root_ca_instance_from_tal_and_ta_der(tal_bytes, ta_der, resolved_ta_uri)?;
let runner = Rpkiv1PublicationPointRunner {
store,
policy,
http_fetcher,
rsync_fetcher,
validation_time,
};
let root = root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let TreeRunAuditOutput {
tree,
publication_points,
} = run_tree_serial_audit(root, &runner, config)?;
Ok(RunTreeFromTalAuditOutput {
discovery,
tree,
publication_points,
})
}

228
src/validation/tree.rs Normal file
View File

@@ -0,0 +1,228 @@
use crate::audit::DiscoveredFrom;
use crate::audit::PublicationPointAudit;
use crate::data_model::rc::{AsResourceSet, IpResourceSet};
use crate::report::{RfcRef, Warning};
use crate::storage::VerifiedPublicationPointPack;
use crate::validation::manifest::PublicationPointSource;
use crate::validation::objects::{AspaAttestation, ObjectsOutput, Vrp};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunConfig {
/// Max CA instance depth to process (0 = root only).
pub max_depth: Option<usize>,
/// Max number of CA instances to process.
pub max_instances: Option<usize>,
}
impl Default for TreeRunConfig {
fn default() -> Self {
Self {
max_depth: None,
max_instances: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CaInstanceHandle {
pub depth: usize,
/// DER bytes of the CA certificate for this CA instance.
pub ca_certificate_der: Vec<u8>,
/// rsync URI of this CA certificate object (where it is published).
///
/// This is used for strict AIA binding checks (RFC 6487 §4.8.7) when validating
/// subordinate CA and EE certificates. Trust anchor certificates downloaded from
/// TAL URIs may not have an rsync publication URI, so this can be `None`.
pub ca_certificate_rsync_uri: Option<String>,
/// Effective (fully resolved) resources for this CA instance, used for resource-path
/// validation of subordinate CA certificates.
pub effective_ip_resources: Option<IpResourceSet>,
pub effective_as_resources: Option<AsResourceSet>,
pub rsync_base_uri: String,
pub manifest_rsync_uri: String,
pub publication_point_rsync_uri: String,
pub rrdp_notification_uri: Option<String>,
}
impl CaInstanceHandle {
pub fn with_depth(mut self, depth: usize) -> Self {
self.depth = depth;
self
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PublicationPointRunResult {
pub source: PublicationPointSource,
pub pack: VerifiedPublicationPointPack,
pub warnings: Vec<Warning>,
pub objects: ObjectsOutput,
pub audit: PublicationPointAudit,
/// Candidate child CA instances discovered from this publication point.
///
/// RFC 9286 §6.6 restriction is enforced by the tree engine: if this
/// publication point used verified cache due to failed fetch, children MUST NOT
/// be enqueued/processed in this run.
pub discovered_children: Vec<DiscoveredChildCaInstance>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DiscoveredChildCaInstance {
pub handle: CaInstanceHandle,
pub discovered_from: DiscoveredFrom,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunOutput {
pub instances_processed: usize,
pub instances_failed: usize,
pub warnings: Vec<Warning>,
pub vrps: Vec<Vrp>,
pub aspas: Vec<AspaAttestation>,
}
#[derive(Debug, thiserror::Error)]
pub enum TreeRunError {
#[error("publication point runner failed: {0}")]
Runner(String),
}
pub trait PublicationPointRunner {
fn run_publication_point(
&self,
ca: &CaInstanceHandle,
) -> Result<PublicationPointRunResult, String>;
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TreeRunAuditOutput {
pub tree: TreeRunOutput,
pub publication_points: Vec<PublicationPointAudit>,
}
pub fn run_tree_serial(
root: CaInstanceHandle,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunOutput, TreeRunError> {
Ok(run_tree_serial_audit(root, runner, config)?.tree)
}
pub fn run_tree_serial_audit(
root: CaInstanceHandle,
runner: &dyn PublicationPointRunner,
config: &TreeRunConfig,
) -> Result<TreeRunAuditOutput, TreeRunError> {
#[derive(Clone, Debug)]
struct QueuedCaInstance {
id: u64,
handle: CaInstanceHandle,
parent_id: Option<u64>,
discovered_from: Option<DiscoveredFrom>,
}
let mut next_id: u64 = 0;
let mut queue: std::collections::VecDeque<QueuedCaInstance> = std::collections::VecDeque::new();
queue.push_back(QueuedCaInstance {
id: next_id,
handle: root,
parent_id: None,
discovered_from: None,
});
next_id += 1;
let mut visited_manifest_uris: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut instances_processed = 0usize;
let mut instances_failed = 0usize;
let mut warnings: Vec<Warning> = Vec::new();
let mut vrps: Vec<Vrp> = Vec::new();
let mut aspas: Vec<AspaAttestation> = Vec::new();
let mut publication_points: Vec<PublicationPointAudit> = Vec::new();
while let Some(node) = queue.pop_front() {
let ca = &node.handle;
if !visited_manifest_uris.insert(ca.manifest_rsync_uri.clone()) {
continue;
}
if let Some(max_instances) = config.max_instances {
if instances_processed >= max_instances {
break;
}
}
if let Some(max_depth) = config.max_depth {
if ca.depth > max_depth {
continue;
}
}
let res = match runner.run_publication_point(ca) {
Ok(v) => v,
Err(e) => {
instances_failed += 1;
warnings.push(
Warning::new(format!("publication point failed: {e}"))
.with_context(&ca.manifest_rsync_uri),
);
continue;
}
};
instances_processed += 1;
warnings.extend(res.warnings.clone());
warnings.extend(res.objects.warnings.clone());
vrps.extend(res.objects.vrps.clone());
aspas.extend(res.objects.aspas.clone());
let mut audit = res.audit.clone();
audit.node_id = Some(node.id);
audit.parent_node_id = node.parent_id;
audit.discovered_from = node.discovered_from.clone();
publication_points.push(audit);
let enqueue_children = res.source == PublicationPointSource::Fresh;
if !enqueue_children && !res.discovered_children.is_empty() {
warnings.push(
Warning::new("skipping child CA discovery due to failed fetch cache use")
.with_rfc_refs(&[RfcRef("RFC 9286 §6.6")])
.with_context(&ca.manifest_rsync_uri),
);
}
if enqueue_children {
let mut children = res.discovered_children;
children.sort_by(|a, b| {
a.handle
.manifest_rsync_uri
.cmp(&b.handle.manifest_rsync_uri)
.then_with(|| {
a.discovered_from
.child_ca_certificate_rsync_uri
.cmp(&b.discovered_from.child_ca_certificate_rsync_uri)
})
});
for child in children {
queue.push_back(QueuedCaInstance {
id: next_id,
handle: child.handle.with_depth(ca.depth + 1),
parent_id: Some(node.id),
discovered_from: Some(child.discovered_from),
});
next_id += 1;
}
}
}
Ok(TreeRunAuditOutput {
tree: TreeRunOutput {
instances_processed,
instances_failed,
warnings,
vrps,
aspas,
},
publication_points,
})
}

View File

@@ -0,0 +1,906 @@
use crate::audit::{
AuditObjectKind, AuditObjectResult, AuditWarning, ObjectAuditEntry, PublicationPointAudit,
sha256_hex, sha256_hex_from_32,
};
use crate::fetch::rsync::RsyncFetcher;
use crate::policy::Policy;
use crate::report::{RfcRef, Warning};
use crate::storage::RocksStore;
use crate::sync::repo::sync_publication_point;
use crate::sync::rrdp::Fetcher;
use crate::validation::ca_instance::ca_instance_uris_from_ca_certificate;
use crate::validation::ca_path::{CaPathError, validate_subordinate_ca_cert};
use crate::validation::manifest::{PublicationPointSource, process_manifest_publication_point};
use crate::validation::objects::process_verified_publication_point_pack_for_issuer;
use crate::validation::tree::{
CaInstanceHandle, DiscoveredChildCaInstance, PublicationPointRunResult, PublicationPointRunner,
};
pub struct Rpkiv1PublicationPointRunner<'a> {
pub store: &'a RocksStore,
pub policy: &'a Policy,
pub http_fetcher: &'a dyn Fetcher,
pub rsync_fetcher: &'a dyn RsyncFetcher,
pub validation_time: time::OffsetDateTime,
}
impl<'a> PublicationPointRunner for Rpkiv1PublicationPointRunner<'a> {
fn run_publication_point(
&self,
ca: &CaInstanceHandle,
) -> Result<PublicationPointRunResult, String> {
let mut warnings: Vec<Warning> = Vec::new();
if let Err(e) = sync_publication_point(
self.store,
self.policy,
ca.rrdp_notification_uri.as_deref(),
&ca.rsync_base_uri,
self.http_fetcher,
self.rsync_fetcher,
) {
warnings.push(
Warning::new(format!(
"repo sync failed (continuing with cached/raw data): {e}"
))
.with_rfc_refs(&[RfcRef("RFC 8182 §3.4.5"), RfcRef("RFC 9286 §6.6")])
.with_context(&ca.rsync_base_uri),
);
}
let pp = match process_manifest_publication_point(
self.store,
self.policy,
&ca.manifest_rsync_uri,
&ca.publication_point_rsync_uri,
self.validation_time,
) {
Ok(v) => v,
Err(e) => return Err(format!("{e}")),
};
warnings.extend(pp.warnings.clone());
let objects = process_verified_publication_point_pack_for_issuer(
&pp.pack,
self.policy,
&ca.ca_certificate_der,
ca.ca_certificate_rsync_uri.as_deref(),
ca.effective_ip_resources.as_ref(),
ca.effective_as_resources.as_ref(),
self.validation_time,
);
let (discovered_children, child_audits) = if pp.source == PublicationPointSource::Fresh {
match discover_children_from_fresh_pack_with_audit(ca, &pp.pack, self.validation_time) {
Ok(out) => (out.children, out.audits),
Err(e) => {
warnings.push(
Warning::new(format!("child CA discovery failed: {e}"))
.with_rfc_refs(&[RfcRef("RFC 6487 §7.2")])
.with_context(&ca.manifest_rsync_uri),
);
(Vec::new(), Vec::new())
}
}
} else {
(Vec::new(), Vec::new())
};
let audit = build_publication_point_audit(ca, &pp, &warnings, &objects, &child_audits);
Ok(PublicationPointRunResult {
source: pp.source,
pack: pp.pack,
warnings,
objects,
audit,
discovered_children,
})
}
}
struct ChildDiscoveryOutput {
children: Vec<DiscoveredChildCaInstance>,
audits: Vec<ObjectAuditEntry>,
}
fn discover_children_from_fresh_pack_with_audit(
issuer: &CaInstanceHandle,
pack: &crate::storage::VerifiedPublicationPointPack,
validation_time: time::OffsetDateTime,
) -> Result<ChildDiscoveryOutput, String> {
let issuer_ca_der = issuer.ca_certificate_der.as_slice();
let mut out: Vec<DiscoveredChildCaInstance> = Vec::new();
let mut audits: Vec<ObjectAuditEntry> = Vec::new();
for f in &pack.files {
if !f.rsync_uri.ends_with(".cer") {
continue;
}
let child_der = f.bytes.as_slice();
let (issuer_crl_uri, issuer_crl_der) = match select_issuer_crl_from_pack(child_der, pack) {
Ok(v) => v,
Err(e) => {
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!(
"cannot select issuer CRL for child certificate: {e}"
)),
});
continue;
}
};
let validated = match validate_subordinate_ca_cert(
child_der,
issuer_ca_der,
issuer_crl_der,
issuer.ca_certificate_rsync_uri.as_deref(),
issuer_crl_uri,
issuer.effective_ip_resources.as_ref(),
issuer.effective_as_resources.as_ref(),
validation_time,
) {
Ok(v) => v,
Err(CaPathError::ChildNotCa) => {
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Skipped,
detail: Some("skipped: not a CA resource certificate".to_string()),
});
continue;
}
Err(e) => {
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!("child CA validation failed: {e}")),
});
continue;
}
};
let uris = match ca_instance_uris_from_ca_certificate(&validated.child_ca) {
Ok(v) => v,
Err(e) => {
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Error,
detail: Some(format!("CA instance URI discovery failed: {e}")),
});
continue;
}
};
out.push(DiscoveredChildCaInstance {
handle: CaInstanceHandle {
depth: 0,
ca_certificate_der: child_der.to_vec(),
ca_certificate_rsync_uri: Some(f.rsync_uri.clone()),
effective_ip_resources: validated.effective_ip_resources.clone(),
effective_as_resources: validated.effective_as_resources.clone(),
rsync_base_uri: uris.rsync_base_uri,
manifest_rsync_uri: uris.manifest_rsync_uri,
publication_point_rsync_uri: uris.publication_point_rsync_uri,
rrdp_notification_uri: uris.rrdp_notification_uri,
},
discovered_from: crate::audit::DiscoveredFrom {
parent_manifest_rsync_uri: issuer.manifest_rsync_uri.clone(),
child_ca_certificate_rsync_uri: f.rsync_uri.clone(),
child_ca_certificate_sha256_hex: sha256_hex_from_32(&f.sha256),
},
});
audits.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Certificate,
result: AuditObjectResult::Ok,
detail: Some("validated subordinate CA certificate; enqueued CA instance".to_string()),
});
}
Ok(ChildDiscoveryOutput {
children: out,
audits,
})
}
fn select_issuer_crl_from_pack<'a>(
child_cert_der: &[u8],
pack: &'a crate::storage::VerifiedPublicationPointPack,
) -> Result<(&'a str, &'a [u8]), String> {
let child = crate::data_model::rc::ResourceCertificate::decode_der(child_cert_der)
.map_err(|e| format!("child certificate decode failed: {e}"))?;
let Some(crldp_uris) = child.tbs.extensions.crl_distribution_points_uris.as_ref() else {
return Err(
"child certificate CRLDistributionPoints missing (RFC 6487 §4.8.6)".to_string(),
);
};
for u in crldp_uris {
let s = u.as_str();
if let Some(f) = pack.files.iter().find(|f| f.rsync_uri == s) {
return Ok((f.rsync_uri.as_str(), f.bytes.as_slice()));
}
}
Err(format!(
"CRL referenced by child certificate CRLDistributionPoints not found in verified pack: {} (RFC 6487 §4.8.6; RFC 9286 §4.2.1)",
crldp_uris
.iter()
.map(|u| u.as_str())
.collect::<Vec<_>>()
.join(", ")
))
}
fn kind_from_rsync_uri(uri: &str) -> AuditObjectKind {
if uri.ends_with(".crl") {
AuditObjectKind::Crl
} else if uri.ends_with(".cer") {
AuditObjectKind::Certificate
} else if uri.ends_with(".roa") {
AuditObjectKind::Roa
} else if uri.ends_with(".asa") {
AuditObjectKind::Aspa
} else {
AuditObjectKind::Other
}
}
fn build_publication_point_audit(
ca: &CaInstanceHandle,
pp: &crate::validation::manifest::PublicationPointResult,
runner_warnings: &[Warning],
objects: &crate::validation::objects::ObjectsOutput,
child_audits: &[ObjectAuditEntry],
) -> PublicationPointAudit {
use crate::data_model::crl::RpkixCrl;
use crate::validation::manifest::PublicationPointSource;
use std::collections::HashMap;
let mut audit_by_uri: HashMap<String, ObjectAuditEntry> = HashMap::new();
for f in &pp.pack.files {
audit_by_uri.insert(
f.rsync_uri.clone(),
ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: kind_from_rsync_uri(&f.rsync_uri),
result: AuditObjectResult::Skipped,
detail: Some("skipped: not processed in stage2".to_string()),
},
);
}
// CRL decode status (best-effort).
for f in &pp.pack.files {
if !f.rsync_uri.ends_with(".crl") {
continue;
}
let ok = RpkixCrl::decode_der(&f.bytes).is_ok();
audit_by_uri.insert(
f.rsync_uri.clone(),
ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: AuditObjectKind::Crl,
result: if ok {
AuditObjectResult::Ok
} else {
AuditObjectResult::Error
},
detail: if ok {
None
} else {
Some("CRL decode failed".to_string())
},
},
);
}
// Child discovery audits (.cer).
for e in child_audits {
audit_by_uri.insert(e.rsync_uri.clone(), e.clone());
}
// Signed object audits (.roa/.asa).
for e in &objects.audit {
audit_by_uri.insert(e.rsync_uri.clone(), e.clone());
}
// Emit as a stable order: manifest first, then pack files as listed in the pack.
let mut objects_out: Vec<ObjectAuditEntry> = Vec::with_capacity(pp.pack.files.len() + 1);
objects_out.push(ObjectAuditEntry {
rsync_uri: pp.pack.manifest_rsync_uri.clone(),
sha256_hex: sha256_hex(&pp.pack.manifest_bytes),
kind: AuditObjectKind::Manifest,
result: AuditObjectResult::Ok,
detail: None,
});
for f in &pp.pack.files {
if let Some(e) = audit_by_uri.remove(&f.rsync_uri) {
objects_out.push(e);
} else {
objects_out.push(ObjectAuditEntry {
rsync_uri: f.rsync_uri.clone(),
sha256_hex: sha256_hex_from_32(&f.sha256),
kind: kind_from_rsync_uri(&f.rsync_uri),
result: AuditObjectResult::Skipped,
detail: Some("skipped: no audit entry".to_string()),
});
}
}
let mut warnings = Vec::new();
// `runner_warnings` already includes `pp.warnings` (mirrors the runtime propagation behavior).
warnings.extend(runner_warnings.iter().map(AuditWarning::from));
warnings.extend(objects.warnings.iter().map(AuditWarning::from));
PublicationPointAudit {
node_id: None,
parent_node_id: None,
discovered_from: None,
rsync_base_uri: ca.rsync_base_uri.clone(),
manifest_rsync_uri: ca.manifest_rsync_uri.clone(),
publication_point_rsync_uri: ca.publication_point_rsync_uri.clone(),
rrdp_notification_uri: ca.rrdp_notification_uri.clone(),
source: match pp.source {
PublicationPointSource::Fresh => "fresh".to_string(),
PublicationPointSource::VerifiedCache => "verified_cache".to_string(),
},
this_update_rfc3339_utc: pp.pack.this_update.rfc3339_utc.clone(),
next_update_rfc3339_utc: pp.pack.next_update.rfc3339_utc.clone(),
verified_at_rfc3339_utc: pp.pack.verified_at.rfc3339_utc.clone(),
warnings,
objects: objects_out,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data_model::rc::ResourceCertificate;
use crate::fetch::rsync::LocalDirRsyncFetcher;
use crate::storage::{PackFile, PackTime, VerifiedPublicationPointPack};
use crate::sync::rrdp::Fetcher;
use crate::validation::tree::PublicationPointRunner;
use std::process::Command;
struct NeverHttpFetcher;
impl Fetcher for NeverHttpFetcher {
fn fetch(&self, _uri: &str) -> Result<Vec<u8>, String> {
Err("http fetch disabled in test".to_string())
}
}
fn openssl_available() -> bool {
Command::new("openssl")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
struct Generated {
issuer_ca_der: Vec<u8>,
child_ca_der: Vec<u8>,
issuer_crl_der: Vec<u8>,
}
fn run(cmd: &mut Command) {
let out = cmd.output().expect("run command");
if !out.status.success() {
panic!(
"command failed: {:?}\nstdout={}\nstderr={}",
cmd,
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
}
fn generate_chain_and_crl() -> Generated {
assert!(openssl_available(), "openssl is required for this test");
let td = tempfile::tempdir().expect("tempdir");
let dir = td.path();
std::fs::create_dir_all(dir.join("newcerts")).expect("newcerts");
std::fs::write(dir.join("index.txt"), b"").expect("index");
std::fs::write(dir.join("serial"), b"1000\n").expect("serial");
std::fs::write(dir.join("crlnumber"), b"1000\n").expect("crlnumber");
let cnf = format!(
r#"
[ ca ]
default_ca = CA_default
[ CA_default ]
dir = {dir}
database = $dir/index.txt
new_certs_dir = $dir/newcerts
certificate = $dir/issuer.pem
private_key = $dir/issuer.key
serial = $dir/serial
crlnumber = $dir/crlnumber
default_md = sha256
default_days = 365
default_crl_days = 1
policy = policy_any
x509_extensions = v3_issuer_ca
crl_extensions = crl_ext
unique_subject = no
copy_extensions = none
[ policy_any ]
commonName = supplied
[ req ]
prompt = no
distinguished_name = dn
[ dn ]
CN = Test Issuer CA
[ v3_issuer_ca ]
basicConstraints = critical,CA:true
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
certificatePolicies = critical, 1.3.6.1.5.5.7.14.2
subjectInfoAccess = caRepository;URI:rsync://example.test/repo/issuer/, rpkiManifest;URI:rsync://example.test/repo/issuer/issuer.mft, rpkiNotify;URI:https://example.test/notification.xml
sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/8
sbgp-autonomousSysNum = critical, AS:64496-64511
[ v3_child_ca ]
basicConstraints = critical,CA:true
keyUsage = critical, keyCertSign, cRLSign
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
crlDistributionPoints = URI:rsync://example.test/repo/issuer/issuer.crl
authorityInfoAccess = caIssuers;URI:rsync://example.test/repo/issuer/issuer.cer
certificatePolicies = critical, 1.3.6.1.5.5.7.14.2
subjectInfoAccess = caRepository;URI:rsync://example.test/repo/child/, rpkiManifest;URI:rsync://example.test/repo/child/child.mft, rpkiNotify;URI:https://example.test/notification.xml
sbgp-ipAddrBlock = critical, IPv4:10.0.0.0/16
sbgp-autonomousSysNum = critical, AS:64496
[ crl_ext ]
authorityKeyIdentifier = keyid:always
"#,
dir = dir.display()
);
std::fs::write(dir.join("openssl.cnf"), cnf.as_bytes()).expect("write cnf");
run(Command::new("openssl")
.arg("genrsa")
.arg("-out")
.arg(dir.join("issuer.key"))
.arg("2048"));
run(Command::new("openssl")
.arg("req")
.arg("-new")
.arg("-x509")
.arg("-sha256")
.arg("-days")
.arg("365")
.arg("-key")
.arg(dir.join("issuer.key"))
.arg("-config")
.arg(dir.join("openssl.cnf"))
.arg("-extensions")
.arg("v3_issuer_ca")
.arg("-out")
.arg(dir.join("issuer.pem")));
run(Command::new("openssl")
.arg("genrsa")
.arg("-out")
.arg(dir.join("child.key"))
.arg("2048"));
run(Command::new("openssl")
.arg("req")
.arg("-new")
.arg("-key")
.arg(dir.join("child.key"))
.arg("-subj")
.arg("/CN=Test Child CA")
.arg("-out")
.arg(dir.join("child.csr")));
run(Command::new("openssl")
.arg("ca")
.arg("-batch")
.arg("-config")
.arg(dir.join("openssl.cnf"))
.arg("-in")
.arg(dir.join("child.csr"))
.arg("-extensions")
.arg("v3_child_ca")
.arg("-out")
.arg(dir.join("child.pem")));
run(Command::new("openssl")
.arg("x509")
.arg("-in")
.arg(dir.join("issuer.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("issuer.cer")));
run(Command::new("openssl")
.arg("x509")
.arg("-in")
.arg(dir.join("child.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("child.cer")));
run(Command::new("openssl")
.arg("ca")
.arg("-gencrl")
.arg("-config")
.arg(dir.join("openssl.cnf"))
.arg("-out")
.arg(dir.join("issuer.crl.pem")));
run(Command::new("openssl")
.arg("crl")
.arg("-in")
.arg(dir.join("issuer.crl.pem"))
.arg("-outform")
.arg("DER")
.arg("-out")
.arg(dir.join("issuer.crl")));
Generated {
issuer_ca_der: std::fs::read(dir.join("issuer.cer")).expect("read issuer der"),
child_ca_der: std::fs::read(dir.join("child.cer")).expect("read child der"),
issuer_crl_der: std::fs::read(dir.join("issuer.crl")).expect("read crl der"),
}
}
fn dummy_pack_with_files(files: Vec<PackFile>) -> VerifiedPublicationPointPack {
let now = time::OffsetDateTime::now_utc();
VerifiedPublicationPointPack {
format_version: VerifiedPublicationPointPack::FORMAT_VERSION_V1,
manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(),
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
this_update: PackTime::from_utc_offset_datetime(now),
next_update: PackTime::from_utc_offset_datetime(now + time::Duration::hours(1)),
verified_at: PackTime::from_utc_offset_datetime(now),
manifest_bytes: vec![0x01],
files,
}
}
#[test]
fn select_issuer_crl_from_pack_finds_matching_crl() {
// Use real fixtures to ensure child cert has CRLDP rsync URI and CRL exists.
let child_cert_der =
std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer",
))
.expect("read child cert fixture");
let crl_der = std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl",
))
.expect("read crl fixture");
let pack = dummy_pack_with_files(vec![PackFile::from_bytes_compute_sha256(
"rsync://ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl",
crl_der.clone(),
)]);
let (uri, found) =
select_issuer_crl_from_pack(child_cert_der.as_slice(), &pack).expect("find crl");
assert_eq!(
uri,
"rsync://ca.rg.net/rpki/RGnet-OU/bW-_qXU9uNhGQz21NR2ansB8lr0.crl"
);
assert_eq!(found, crl_der.as_slice());
}
#[test]
fn discover_children_from_fresh_pack_discovers_child_ca() {
let g = generate_chain_and_crl();
let pack = dummy_pack_with_files(vec![
PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/issuer/issuer.crl",
g.issuer_crl_der.clone(),
),
PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/issuer/child.cer",
g.child_ca_der.clone(),
),
]);
let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer");
let issuer = CaInstanceHandle {
depth: 0,
ca_certificate_der: g.issuer_ca_der.clone(),
ca_certificate_rsync_uri: None,
effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(),
effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(),
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(),
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: None,
};
let now = time::OffsetDateTime::now_utc();
let children = discover_children_from_fresh_pack_with_audit(&issuer, &pack, now)
.expect("discover children")
.children;
assert_eq!(children.len(), 1);
assert_eq!(
children[0].discovered_from.parent_manifest_rsync_uri,
issuer.manifest_rsync_uri
);
assert_eq!(
children[0].discovered_from.child_ca_certificate_rsync_uri,
"rsync://example.test/repo/issuer/child.cer"
);
assert_eq!(
children[0].handle.rsync_base_uri,
"rsync://example.test/repo/child/".to_string()
);
assert_eq!(
children[0].handle.manifest_rsync_uri,
"rsync://example.test/repo/child/child.mft".to_string()
);
assert_eq!(
children[0].handle.publication_point_rsync_uri,
"rsync://example.test/repo/child/".to_string()
);
assert_eq!(
children[0].handle.rrdp_notification_uri.as_deref(),
Some("https://example.test/notification.xml")
);
}
#[test]
fn discover_children_with_audit_records_missing_crl_for_child_certificate() {
let now = time::OffsetDateTime::now_utc();
let child_ca_der =
std::fs::read(std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"tests/fixtures/repository/ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer",
))
.expect("read child ca fixture");
// Pack contains the child CA cert but does not contain the CRL referenced by the child
// certificate CRLDistributionPoints extension.
let pack = dummy_pack_with_files(vec![PackFile::from_bytes_compute_sha256(
"rsync://ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer",
child_ca_der,
)]);
let issuer = CaInstanceHandle {
depth: 0,
ca_certificate_der: vec![1],
ca_certificate_rsync_uri: None,
effective_ip_resources: None,
effective_as_resources: None,
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: pack.manifest_rsync_uri.clone(),
publication_point_rsync_uri: pack.publication_point_rsync_uri.clone(),
rrdp_notification_uri: None,
};
let out = discover_children_from_fresh_pack_with_audit(&issuer, &pack, now)
.expect("discovery should succeed with audit error");
assert_eq!(out.children.len(), 0);
assert_eq!(out.audits.len(), 1);
assert_eq!(
out.audits[0].rsync_uri,
"rsync://ca.rg.net/rpki/RGnet-OU/R-lVU1XGsAeqzV1Fv0HjOD6ZFkE.cer"
);
assert_eq!(out.audits[0].result, AuditObjectResult::Error);
assert!(
out.audits[0]
.detail
.as_deref()
.unwrap_or("")
.contains("cannot select issuer CRL"),
"expected deterministic CRL selection failure to be recorded"
);
}
#[test]
fn runner_offline_rsync_fixture_produces_pack_and_warnings() {
let fixture_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0");
assert!(fixture_dir.is_dir(), "fixture directory must exist");
let rsync_base_uri = "rsync://rpki.cernet.net/repo/cernet/0/".to_string();
let manifest_file = "05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft";
let manifest_rsync_uri = format!("{rsync_base_uri}{manifest_file}");
// Pick a validation_time inside the fixture manifest's validity window to keep this
// test stable across wall-clock time.
let fixture_manifest_bytes =
std::fs::read(fixture_dir.join(manifest_file)).expect("read manifest fixture");
let fixture_manifest =
crate::data_model::manifest::ManifestObject::decode_der(&fixture_manifest_bytes)
.expect("decode manifest fixture");
let validation_time = {
let this_update = fixture_manifest.manifest.this_update;
let next_update = fixture_manifest.manifest.next_update;
let candidate = this_update + time::Duration::seconds(60);
if candidate < next_update {
candidate
} else {
this_update
}
};
let store_dir = tempfile::tempdir().expect("store dir");
let store = RocksStore::open(store_dir.path()).expect("open rocksdb");
let policy = Policy {
sync_preference: crate::policy::SyncPreference::RsyncOnly,
..Policy::default()
};
let runner = Rpkiv1PublicationPointRunner {
store: &store,
policy: &policy,
http_fetcher: &NeverHttpFetcher,
rsync_fetcher: &LocalDirRsyncFetcher::new(&fixture_dir),
validation_time,
};
// For this fixture-driven smoke, we provide the correct issuer CA certificate (the CA for
// this publication point) so ROA EE certificate paths can validate.
let issuer_ca_der = std::fs::read(
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(
"tests/fixtures/repository/rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer",
),
)
.expect("read issuer ca fixture");
let issuer_ca = ResourceCertificate::decode_der(&issuer_ca_der).expect("decode issuer ca");
let handle = CaInstanceHandle {
depth: 0,
ca_certificate_der: issuer_ca_der,
ca_certificate_rsync_uri: Some("rsync://rpki.apnic.net/repository/B527EF581D6611E2BB468F7C72FD1FF2/BfycW4hQb3wNP4YsiJW-1n6fjro.cer".to_string()),
effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(),
effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(),
rsync_base_uri: rsync_base_uri.clone(),
manifest_rsync_uri: manifest_rsync_uri.clone(),
publication_point_rsync_uri: rsync_base_uri.clone(),
rrdp_notification_uri: None,
};
let out = runner
.run_publication_point(&handle)
.expect("run publication point");
assert_eq!(out.source, PublicationPointSource::Fresh);
assert_eq!(out.pack.manifest_rsync_uri, manifest_rsync_uri);
assert!(out.pack.files.len() > 1);
assert!(
out.objects.vrps.len() > 1,
"expected to extract VRPs from ROAs"
);
}
#[test]
fn build_publication_point_audit_marks_invalid_crl_as_error_and_overlays_roa_audit() {
let now = time::OffsetDateTime::now_utc();
let pack = dummy_pack_with_files(vec![
PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/issuer/bad.crl",
vec![0u8],
),
PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/issuer/x.roa",
vec![1u8],
),
]);
let pp = crate::validation::manifest::PublicationPointResult {
source: crate::validation::manifest::PublicationPointSource::Fresh,
pack: pack.clone(),
warnings: Vec::new(),
};
let issuer = CaInstanceHandle {
depth: 0,
ca_certificate_der: vec![1],
ca_certificate_rsync_uri: None,
effective_ip_resources: None,
effective_as_resources: None,
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: pack.manifest_rsync_uri.clone(),
publication_point_rsync_uri: pack.publication_point_rsync_uri.clone(),
rrdp_notification_uri: None,
};
let objects = crate::validation::objects::ObjectsOutput {
vrps: Vec::new(),
aspas: Vec::new(),
warnings: Vec::new(),
stats: crate::validation::objects::ObjectsStats::default(),
audit: vec![ObjectAuditEntry {
rsync_uri: "rsync://example.test/repo/issuer/x.roa".to_string(),
sha256_hex: sha256_hex_from_32(&pack.files[1].sha256),
kind: AuditObjectKind::Roa,
result: AuditObjectResult::Ok,
detail: None,
}],
};
let audit = build_publication_point_audit(&issuer, &pp, &[], &objects, &[]);
assert_eq!(audit.objects[0].kind, AuditObjectKind::Manifest);
let crl = audit
.objects
.iter()
.find(|e| e.rsync_uri.ends_with("bad.crl"))
.expect("crl entry");
assert!(matches!(crl.result, AuditObjectResult::Error));
let roa = audit
.objects
.iter()
.find(|e| e.rsync_uri.ends_with("x.roa"))
.expect("roa entry");
assert!(matches!(roa.result, AuditObjectResult::Ok));
// Smoke that time fields are populated from pack.
assert!(audit.verified_at_rfc3339_utc.contains('T'));
assert!(audit.this_update_rfc3339_utc.contains('T'));
assert!(audit.next_update_rfc3339_utc.contains('T'));
let _ = now;
}
#[test]
fn discover_children_with_audit_records_decode_error_for_corrupt_cer() {
let g = generate_chain_and_crl();
let pack = dummy_pack_with_files(vec![
PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/issuer/issuer.crl",
g.issuer_crl_der.clone(),
),
PackFile::from_bytes_compute_sha256(
"rsync://example.test/repo/issuer/corrupt.cer",
vec![0u8],
),
]);
let issuer_ca = ResourceCertificate::decode_der(&g.issuer_ca_der).expect("decode issuer");
let issuer = CaInstanceHandle {
depth: 0,
ca_certificate_der: g.issuer_ca_der.clone(),
ca_certificate_rsync_uri: None,
effective_ip_resources: issuer_ca.tbs.extensions.ip_resources.clone(),
effective_as_resources: issuer_ca.tbs.extensions.as_resources.clone(),
rsync_base_uri: "rsync://example.test/repo/issuer/".to_string(),
manifest_rsync_uri: "rsync://example.test/repo/issuer/issuer.mft".to_string(),
publication_point_rsync_uri: "rsync://example.test/repo/issuer/".to_string(),
rrdp_notification_uri: None,
};
let now = time::OffsetDateTime::now_utc();
let out = discover_children_from_fresh_pack_with_audit(&issuer, &pack, now)
.expect("discover children");
assert!(out.children.is_empty());
assert_eq!(out.audits.len(), 1);
assert!(matches!(out.audits[0].result, AuditObjectResult::Error));
}
}

7
tests/fixtures/rrdp/notification.xml vendored Normal file
View File

@@ -0,0 +1,7 @@
<notification xmlns="http://www.ripe.net/rpki/rrdp"
version="1"
session_id="9df4b597-af9e-4dca-bdda-719cce2c4e28"
serial="1">
<snapshot uri="https://example.net/rrdp/snapshot.xml"
hash="dcb1ce91401d568d7ddf7a4c9f70c65d8428c3a5e7135f82db99c4de30413551"/>
</notification>

12
tests/fixtures/rrdp/snapshot.xml vendored Normal file
View File

@@ -0,0 +1,12 @@
<snapshot xmlns="http://www.ripe.net/rpki/rrdp"
version="1"
session_id="9df4b597-af9e-4dca-bdda-719cce2c4e28"
serial="1">
<publish uri="rsync://example.net/repo/obj1.cer">
YWJj
</publish>
<publish uri="rsync://example.net/repo/obj2.crl">
ZGVm
</publish>
</snapshot>

BIN
tests/fixtures/ta/afrinic-ta.cer vendored Normal file

Binary file not shown.

BIN
tests/fixtures/ta/apnic-ta.cer vendored Normal file

Binary file not shown.

BIN
tests/fixtures/ta/arin-ta.cer vendored Normal file

Binary file not shown.

BIN
tests/fixtures/ta/lacnic-ta.cer vendored Normal file

Binary file not shown.

BIN
tests/fixtures/ta/ripe-ncc-ta.cer vendored Normal file

Binary file not shown.

10
tests/fixtures/tal/afrinic.tal vendored Normal file
View File

@@ -0,0 +1,10 @@
rsync://rpki.afrinic.net/repository/AfriNIC.cer
https://rpki.afrinic.net/repository/AfriNIC.cer
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxsAqAhWIO+ON2Ef9oRDM
pKxv+AfmSLIdLWJtjrvUyDxJPBjgR+kVrOHUeTaujygFUp49tuN5H2C1rUuQavTH
vve6xNF5fU3OkTcqEzMOZy+ctkbde2SRMVdvbO22+TH9gNhKDc9l7Vu01qU4LeJH
k3X0f5uu5346YrGAOSv6AaYBXVgXxa0s9ZvgqFpim50pReQe/WI3QwFKNgpPzfQL
6Y7fDPYdYaVOXPXSKtx7P4s4KLA/ZWmRL/bobw/i2fFviAGhDrjqqqum+/9w1hEl
L/vqihVnV18saKTnLvkItA/Bf5i11Yhw2K7qv573YWxyuqCknO/iYLTR1DToBZcZ
UQIDAQAB

View File

@@ -0,0 +1,10 @@
https://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer
rsync://rpki.apnic.net/repository/apnic-rpki-root-iana-origin.cer
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RWSL61YAAYumEiU8z8
qH2ETVIL01ilxZlzIL9JYSORMN5Cmtf8V2JblIealSqgOTGjvSjEsiV73s67zYQI
7C/iSOb96uf3/s86NqbxDiFQGN8qG7RNcdgVuUlAidl8WxvLNI8VhqbAB5uSg/Mr
LeSOvXRja041VptAxIhcGzDMvlAJRwkrYK/Mo8P4E2rSQgwqCgae0ebY1CsJ3Cjf
i67C1nw7oXqJJovvXJ4apGmEv8az23OLC6Ki54Ul/E6xk227BFttqFV3YMtKx42H
cCcDVZZy01n7JjzvO8ccaXmHIgR7utnqhBRNNq5Xc5ZhbkrUsNtiJmrZzVlgU6Ou
0wIDAQAB

19
tests/fixtures/tal/arin.tal vendored Normal file
View File

@@ -0,0 +1,19 @@
# THIS TRUST ANCHOR LOCATOR IS PROVIDED BY THE AMERICAN REGISTRY FOR
# INTERNET NUMBERS (ARIN) "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
# IN NO EVENT SHALL ARIN BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS PUBLIC KEY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
rsync://rpki.arin.net/repository/arin-rpki-ta.cer
https://rrdp.arin.net/arin-rpki-ta.cer
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3lZPjbHvMRV5sDDqfLc/685th5FnreHMJjg8
pEZUbG8Y8TQxSBsDebbsDpl3Ov3Cj1WtdrJ3CIfQODCPrrJdOBSrMATeUbPC+JlNf2SRP3UB+VJFgtTj
0RN8cEYIuhBW5t6AxQbHhdNQH+A1F/OJdw0q9da2U29Lx85nfFxvnC1EpK9CbLJS4m37+RlpNbT1cba+
b+loXpx0Qcb1C4UpJCGDy7uNf5w6/+l7RpATAHqqsX4qCtwwDYlbHzp2xk9owF3mkCxzl0HwncO+sEHH
eaL3OjtwdIGrRGeHi2Mpt+mvWHhtQqVG+51MHTyg+nIjWFKKGx1Q9+KDx4wJStwveQIDAQAB

4
tests/fixtures/tal/lacnic.tal vendored Normal file
View File

@@ -0,0 +1,4 @@
https://rrdp.lacnic.net/ta/rta-lacnic-rpki.cer
rsync://repository.lacnic.net/rpki/lacnic/rta-lacnic-rpki.cer
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqZEzhYK0+PtDOPfub/KRc3MeWx3neXx4/wbnJWGbNAtbYqXg3uU5J4HFzPgk/VIppgSKAhlO0H60DRP48by9gr5/yDHu2KXhOmnMg46sYsUIpfgtBS9+VtrqWziJfb+pkGtuOWeTnj6zBmBNZKK+5AlMCW1WPhrylIcB+XSZx8tk9GS/3SMQ+YfMVwwAyYjsex14Uzto4GjONALE5oh1M3+glRQduD6vzSwOD+WahMbc9vCOTED+2McLHRKgNaQf0YJ9a1jG9oJIvDkKXEqdfqDRktwyoD74cV57bW3tBAexB7GglITbInyQAsmdngtfg2LUMrcROHHP86QPZINjDQIDAQAB

10
tests/fixtures/tal/ripe-ncc.tal vendored Normal file
View File

@@ -0,0 +1,10 @@
https://rpki.ripe.net/ta/ripe-ncc-ta.cer
rsync://rpki.ripe.net/ta/ripe-ncc-ta.cer
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0URYSGqUz2myBsOzeW1j
Q6NsxNvlLMyhWknvnl8NiBCs/T/S2XuNKQNZ+wBZxIgPPV2pFBFeQAvoH/WK83Hw
A26V2siwm/MY2nKZ+Olw+wlpzlZ1p3Ipj2eNcKrmit8BwBC8xImzuCGaV0jkRB0G
Z0hoH6Ml03umLprRsn6v0xOP0+l6Qc1ZHMFVFb385IQ7FQQTcVIxrdeMsoyJq9eM
kE6DoclHhF/NlSllXubASQ9KUWqJ0+Ot3QCXr4LXECMfkpkVR2TZT+v5v658bHVs
6ZxRD1b6Uk1uQKAyHUbn/tXvP8lrjAibGzVsXDT2L0x4Edx+QdixPgOji3gBMyL2
VwIDAQAB

View File

@@ -0,0 +1,37 @@
use rpki::data_model::ta::TrustAnchor;
use rpki::data_model::tal::Tal;
use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use rpki::validation::ca_instance::ca_instance_uris_from_ca_certificate;
use rpki::validation::from_tal::discover_root_ca_instance_from_tal_url;
const APNIC_TAL_URL: &str = "https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal";
#[test]
#[ignore = "live network smoke test (APNIC TAL)"]
fn apnic_tal_downloads_and_binds_to_ta_certificate() {
let fetcher = BlockingHttpFetcher::new(HttpFetcherConfig::default()).expect("build fetcher");
let tal_bytes = fetcher.fetch_bytes(APNIC_TAL_URL).expect("download TAL");
let tal = Tal::decode_bytes(&tal_bytes).expect("decode TAL");
let ta_uri = tal.ta_uris.first().expect("TAL has TA URIs").clone();
let ta_bytes = fetcher
.fetch_bytes(ta_uri.as_str())
.expect("download TA cert");
let trust_anchor = TrustAnchor::bind_der(tal, &ta_bytes, Some(&ta_uri)).expect("bind");
let ca_uris = ca_instance_uris_from_ca_certificate(&trust_anchor.ta_certificate.rc_ca)
.expect("extract CA instance URIs");
assert!(ca_uris.rsync_base_uri.starts_with("rsync://"));
assert!(ca_uris.manifest_rsync_uri.starts_with("rsync://"));
}
#[test]
#[ignore = "live network smoke test (APNIC TAL)"]
fn apnic_tal_discovery_api_smoke() {
let fetcher = BlockingHttpFetcher::new(HttpFetcherConfig::default()).expect("build fetcher");
let d = discover_root_ca_instance_from_tal_url(&fetcher, APNIC_TAL_URL).expect("discover");
assert!(d.ca_instance.rsync_base_uri.starts_with("rsync://"));
assert!(d.ca_instance.manifest_rsync_uri.starts_with("rsync://"));
}

View File

@@ -0,0 +1,283 @@
use std::cell::RefCell;
use std::collections::{BTreeMap, HashSet};
use std::time::Duration;
use rpki::data_model::crl::RpkixCrl;
use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use rpki::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use rpki::policy::Policy;
use rpki::storage::RocksStore;
use rpki::validation::from_tal::discover_root_ca_instance_from_tal_url;
use rpki::validation::run_tree_from_tal::root_handle_from_trust_anchor;
use rpki::validation::tree::{
CaInstanceHandle, PublicationPointRunResult, PublicationPointRunner, TreeRunConfig,
run_tree_serial,
};
use rpki::validation::tree_runner::Rpkiv1PublicationPointRunner;
const APNIC_TAL_URL: &str = "https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal";
#[derive(Default, Debug)]
struct LiveStats {
publication_points_processed: usize,
publication_points_fresh: usize,
publication_points_cached: usize,
rrdp_repos_unique: HashSet<String>,
pack_file_uris_unique: HashSet<String>,
pack_uris_total: usize,
pack_uris_by_ext_total: BTreeMap<String, usize>,
crl_total: usize,
crl_decode_ok: usize,
child_ca_cert_candidates_total: usize,
child_ca_cert_validated_ok: usize,
roa_total: usize,
roa_ok: usize,
aspa_total: usize,
aspa_ok: usize,
objects_dropped_publication_points: usize,
}
fn ext_of_uri(uri: &str) -> String {
let last = uri.rsplit('/').next().unwrap_or(uri);
match last.rsplit_once('.') {
Some((_base, ext)) if !ext.is_empty() => ext.to_ascii_lowercase(),
_ => "(none)".to_string(),
}
}
impl LiveStats {
fn record_ca_inputs(&mut self, ca: &CaInstanceHandle) {
if let Some(u) = ca.rrdp_notification_uri.as_ref() {
self.rrdp_repos_unique.insert(u.clone());
}
}
fn record(&mut self, res: &PublicationPointRunResult) {
self.publication_points_processed += 1;
match res.source {
rpki::validation::manifest::PublicationPointSource::Fresh => {
self.publication_points_fresh += 1
}
rpki::validation::manifest::PublicationPointSource::VerifiedCache => {
self.publication_points_cached += 1
}
}
// Include manifest object URI itself.
self.pack_uris_total += 1;
self.pack_file_uris_unique
.insert(res.pack.manifest_rsync_uri.clone());
*self
.pack_uris_by_ext_total
.entry(ext_of_uri(&res.pack.manifest_rsync_uri))
.or_insert(0) += 1;
for f in &res.pack.files {
self.pack_uris_total += 1;
self.pack_file_uris_unique.insert(f.rsync_uri.clone());
*self
.pack_uris_by_ext_total
.entry(ext_of_uri(&f.rsync_uri))
.or_insert(0) += 1;
if f.rsync_uri.ends_with(".crl") {
self.crl_total += 1;
if RpkixCrl::decode_der(&f.bytes).is_ok() {
self.crl_decode_ok += 1;
}
}
if f.rsync_uri.ends_with(".cer") {
self.child_ca_cert_candidates_total += 1;
}
}
self.child_ca_cert_validated_ok += res.discovered_children.len();
self.roa_total += res.objects.stats.roa_total;
self.roa_ok += res.objects.stats.roa_ok;
self.aspa_total += res.objects.stats.aspa_total;
self.aspa_ok += res.objects.stats.aspa_ok;
if res.objects.stats.publication_point_dropped {
self.objects_dropped_publication_points += 1;
}
if self.publication_points_processed % 25 == 0 {
println!(
"progress: processed={} fresh={} cached={} failed_objects_pps={} vrps={} aspas={}",
self.publication_points_processed,
self.publication_points_fresh,
self.publication_points_cached,
self.objects_dropped_publication_points,
self.roa_ok,
self.aspa_ok,
);
}
}
}
struct CountingRunner<'a> {
inner: &'a dyn PublicationPointRunner,
stats: &'a RefCell<LiveStats>,
}
impl<'a> PublicationPointRunner for CountingRunner<'a> {
fn run_publication_point(
&self,
ca: &CaInstanceHandle,
) -> Result<PublicationPointRunResult, String> {
self.stats.borrow_mut().record_ca_inputs(ca);
let res = self.inner.run_publication_point(ca)?;
self.stats.borrow_mut().record(&res);
Ok(res)
}
}
#[test]
#[ignore = "live network + rsync full-tree stats (APNIC TAL); may take minutes"]
fn apnic_tree_full_stats_serial() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(30 * 60),
..HttpFetcherConfig::default()
})
.expect("http fetcher");
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: Duration::from_secs(30 * 60),
..SystemRsyncConfig::default()
});
let validation_time = time::OffsetDateTime::now_utc();
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(temp.path()).expect("open rocksdb");
let policy = Policy::default();
let discovery =
discover_root_ca_instance_from_tal_url(&http, APNIC_TAL_URL).expect("discover APNIC TAL");
let runner = Rpkiv1PublicationPointRunner {
store: &store,
policy: &policy,
http_fetcher: &http,
rsync_fetcher: &rsync,
validation_time,
};
let stats = RefCell::new(LiveStats::default());
let counting = CountingRunner {
inner: &runner,
stats: &stats,
};
let root: CaInstanceHandle =
root_handle_from_trust_anchor(&discovery.trust_anchor, None, &discovery.ca_instance);
let max_depth = std::env::var("RPKI_APNIC_MAX_DEPTH")
.ok()
.and_then(|s| s.parse::<usize>().ok());
let max_instances = std::env::var("RPKI_APNIC_MAX_INSTANCES")
.ok()
.and_then(|s| s.parse::<usize>().ok());
let out = run_tree_serial(
root,
&counting,
&TreeRunConfig {
max_depth,
max_instances,
},
)
.expect("run tree");
let stats = stats.into_inner();
let mut raw_total = 0usize;
let mut raw_by_ext: BTreeMap<String, usize> = BTreeMap::new();
for (k, _v) in store.raw_iter_all().expect("raw_iter_all") {
raw_total += 1;
if let Ok(uri) = std::str::from_utf8(&k) {
*raw_by_ext.entry(ext_of_uri(uri)).or_insert(0) += 1;
}
}
let verified_total = store
.verified_iter_all()
.expect("verified_iter_all")
.count();
println!("APNIC Stage2 full-tree serial stats");
println!("tal_url={APNIC_TAL_URL}");
println!(
"validation_time={}",
validation_time
.format(&time::format_description::well_known::Rfc3339)
.unwrap()
);
println!();
println!(
"publication_points_processed={} publication_points_failed={} fresh={} cached={}",
stats.publication_points_processed,
out.instances_failed,
stats.publication_points_fresh,
stats.publication_points_cached
);
println!("rrdp_repos_unique={}", stats.rrdp_repos_unique.len());
println!(
"objects_dropped_publication_points={}",
stats.objects_dropped_publication_points
);
println!();
println!(
"pack_uris_total={} pack_uris_unique={}",
stats.pack_uris_total,
stats.pack_file_uris_unique.len()
);
println!("pack_uris_by_ext_total={:?}", stats.pack_uris_by_ext_total);
println!();
println!(
"crl_total={} crl_decode_ok={}",
stats.crl_total, stats.crl_decode_ok
);
println!(
"child_ca_cert_candidates_total={} child_ca_cert_validated_ok={}",
stats.child_ca_cert_candidates_total, stats.child_ca_cert_validated_ok
);
println!(
"roa_total={} roa_ok={} vrps_out={}",
stats.roa_total,
stats.roa_ok,
out.vrps.len()
);
println!(
"aspa_total={} aspa_ok={} aspas_out={}",
stats.aspa_total,
stats.aspa_ok,
out.aspas.len()
);
println!();
println!(
"rocksdb_raw_objects_total={} raw_by_ext={:?}",
raw_total, raw_by_ext
);
println!("rocksdb_verified_packs_total={}", verified_total);
// Loose sanity assertions (avoid flakiness due to repository churn).
//
// This test supports bounded manual runs via env vars:
// - `RPKI_APNIC_MAX_DEPTH=0` or `RPKI_APNIC_MAX_INSTANCES=1` implies root-only.
// Otherwise we expect to reach at least one child.
let min_expected = if max_depth == Some(0) || max_instances.is_some_and(|n| n <= 1) {
1
} else {
2
};
assert!(
out.instances_processed >= min_expected,
"expected to process at least {min_expected} publication point(s)"
);
}

View File

@@ -0,0 +1,90 @@
use std::time::Duration;
use rpki::fetch::http::{BlockingHttpFetcher, HttpFetcherConfig};
use rpki::fetch::rsync_system::{SystemRsyncConfig, SystemRsyncFetcher};
use rpki::policy::Policy;
use rpki::storage::RocksStore;
use rpki::validation::run_tree_from_tal::run_tree_from_tal_url_serial;
use rpki::validation::run_tree_from_tal::run_tree_from_tal_url_serial_audit;
use rpki::validation::tree::TreeRunConfig;
const APNIC_TAL_URL: &str = "https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal";
#[test]
#[ignore = "live network + rsync smoke test (APNIC TAL)"]
fn apnic_tree_depth1_processes_more_than_root() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(30 * 60),
..HttpFetcherConfig::default()
})
.expect("http fetcher");
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: Duration::from_secs(30 * 60),
..SystemRsyncConfig::default()
});
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(temp.path()).expect("open rocksdb");
let policy = Policy::default();
let out = run_tree_from_tal_url_serial(
&store,
&policy,
APNIC_TAL_URL,
&http,
&rsync,
time::OffsetDateTime::now_utc(),
&TreeRunConfig {
max_depth: Some(1),
max_instances: Some(2),
},
)
.expect("run tree from tal");
assert!(
out.tree.instances_processed >= 2,
"expected to process root + at least one child"
);
}
#[test]
#[ignore = "live network + rsync root-only smoke test (APNIC TAL); 30min timeouts"]
fn apnic_tree_root_only_processes_root_with_long_timeouts() {
let http = BlockingHttpFetcher::new(HttpFetcherConfig {
timeout: Duration::from_secs(30 * 60),
..HttpFetcherConfig::default()
})
.expect("http fetcher");
let rsync = SystemRsyncFetcher::new(SystemRsyncConfig {
timeout: Duration::from_secs(30 * 60),
..SystemRsyncConfig::default()
});
let temp = tempfile::tempdir().expect("tempdir");
let store = RocksStore::open(temp.path()).expect("open rocksdb");
let policy = Policy::default();
let validation_time = time::OffsetDateTime::now_utc();
let out = run_tree_from_tal_url_serial_audit(
&store,
&policy,
APNIC_TAL_URL,
&http,
&rsync,
validation_time,
&TreeRunConfig {
max_depth: Some(0),
max_instances: Some(1),
},
)
.expect("run APNIC root-only");
assert_eq!(
out.tree.instances_processed, 1,
"expected to process exactly the root publication point"
);
assert_eq!(
out.publication_points.len(),
out.tree.instances_processed,
"audit should include one publication point"
);
}

39
tests/test_aspa_decode.rs Normal file
View File

@@ -0,0 +1,39 @@
use rpki::data_model::aspa::{AspaDecodeError, AspaObject, AspaProfileError};
use rpki::data_model::signed_object::RpkiSignedObject;
#[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");
aspa.validate_profile().expect("validate ASPA profile");
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 from_signed_object_accepts_aspa_fixture() {
let der = std::fs::read(
"tests/fixtures/repository/chloe.sobornost.net/rpki/RIPE-nljobsnijders/5m80fwYws_3FiFD7JiQjAqZ1RYQ.asa",
)
.expect("read ASPA fixture");
let so = RpkiSignedObject::decode_der(&der).expect("decode signed object");
let aspa = AspaObject::from_signed_object(so).expect("from_signed_object");
aspa.validate_profile().expect("validate ASPA profile");
}
#[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::Validate(AspaProfileError::InvalidEContentType(_))
));
}

View File

@@ -0,0 +1,187 @@
use rpki::data_model::aspa::{AspaDecodeError, AspaEContent, AspaParseError, AspaProfileError};
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 trailing_bytes_are_rejected_in_parse_step() {
let der = aspa_attestation_explicit_version(1, 64496, vec![64497]);
let mut bad = der.clone();
bad.push(0);
let err = AspaEContent::decode_der(&bad).unwrap_err();
assert!(matches!(
err,
AspaDecodeError::Parse(AspaParseError::TrailingBytes(1))
));
}
#[test]
fn version_tag_must_be_context_specific_0() {
// Build a 3-element SEQUENCE but make the first element an INTEGER, not [0] EXPLICIT.
let providers_der = der_sequence(vec![der_integer_u64(64497)]);
let der = der_sequence(vec![
der_integer_u64(1), // wrong tag/class
der_integer_u64(64496), // customerASID
providers_der, // providers
]);
let err = AspaEContent::decode_der(&der).unwrap_err();
assert!(matches!(
err,
AspaDecodeError::Validate(AspaProfileError::VersionMustBeExplicitOne)
));
}
#[test]
fn version_explicit_tag_rejects_trailing_bytes_inside_inner_der() {
let mut version_inner = der_integer_u64(1);
version_inner.extend(tlv(0x05, &[])); // NULL after INTEGER
let providers_der = der_sequence(vec![der_integer_u64(64497)]);
let der = der_sequence(vec![
cs_explicit(0, version_inner),
der_integer_u64(64496),
providers_der,
]);
let err = AspaEContent::decode_der(&der).unwrap_err();
assert!(matches!(
err,
AspaDecodeError::Validate(AspaProfileError::ProfileDecode(_))
));
}
#[test]
fn provider_asid_out_of_range_is_rejected() {
let der = aspa_attestation_explicit_version(1, 64496, vec![(u32::MAX as u64) + 1]);
let err = AspaEContent::decode_der(&der).unwrap_err();
assert!(matches!(
err,
AspaDecodeError::Validate(AspaProfileError::ProviderAsIdOutOfRange(_))
));
}
#[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::Validate(AspaProfileError::InvalidAttestationSequence)
));
let der = aspa_attestation_explicit_version(0, 64496, vec![64497]);
let err = AspaEContent::decode_der(&der).unwrap_err();
assert!(matches!(
err,
AspaDecodeError::Validate(AspaProfileError::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::Validate(AspaProfileError::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::Validate(AspaProfileError::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::Validate(AspaProfileError::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::Validate(AspaProfileError::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::Validate(AspaProfileError::ProvidersContainCustomer(64496))
));
}

Some files were not shown because too many files have changed in this diff Show More