From fec2cf58104006012901690d5a38166fb7e06ef6 Mon Sep 17 00:00:00 2001 From: Jiale Zhang Date: Fri, 8 May 2026 12:06:45 +0800 Subject: [PATCH 1/3] Add RV release manifest Rekor support Signed-off-by: Jiale Zhang --- .github/workflows/release-rpm.yml | 3 +- docs/challenge_ra.md | 30 +- docs/rekor.md | 62 ++-- rvps/src/lib.rs | 167 +++++++--- rvps/src/rv_list/mod.rs | 4 + rvps/src/rv_list/release_manifest.rs | 404 +++++++++++++++++++++++++ rvps/src/rvps_api/reference.rs | 274 +++++++++++------ tools/slsa/README.md | 65 ++-- tools/slsa/slsa-generator | 235 ++++++++------ trustee-gateway/trustee_gateway_api.md | 38 +-- 10 files changed, 944 insertions(+), 338 deletions(-) create mode 100644 rvps/src/rv_list/release_manifest.rs diff --git a/.github/workflows/release-rpm.yml b/.github/workflows/release-rpm.yml index b2a6e73..0070161 100644 --- a/.github/workflows/release-rpm.yml +++ b/.github/workflows/release-rpm.yml @@ -153,11 +153,12 @@ jobs: contents: write id-token: write uses: 1570005763/GuanFu/.github/workflows/release.yml@v1 + secrets: inherit with: input_artifact: build-input-${{ matrix.os_flavor.id }} output_artifact: build-output-${{ matrix.os_flavor.id }} release_slsa_provenance: true - provenance_name: "${{ matrix.os_flavor.id == 'alinux3' && needs.create-build-artifacts.outputs.rpm_name_al8 || needs.create-build-artifacts.outputs.rpm_name_an23 }}.intoto.jsonl" + provenance_name: "${{ matrix.os_flavor.id == 'alinux3' && needs.create-build-artifacts.outputs.rpm_name_al8 || needs.create-build-artifacts.outputs.rpm_name_an23 }}.rv-release-manifest.bundle.json" rpm_detail_provenance: true upload_provenance_to_rekor: true release_tag_name: ${{ github.event.release.tag_name || inputs.tag_name }} diff --git a/docs/challenge_ra.md b/docs/challenge_ra.md index d605fca..2c5256f 100644 --- a/docs/challenge_ra.md +++ b/docs/challenge_ra.md @@ -43,11 +43,21 @@ attestation-challenge-client set-reference-value --provenance-type attestation-challenge-client set-reference-value-list --rv-list ``` -### SLSA 模式 (rekor透明日志) +### RV release manifest 模式(推荐) + +```bash +attestation-challenge-client set-reference-value-list --rv-list /path/to/rv-list.json +``` + +- 逻辑:读取 JSON 文件(顶层字段 `rv_list`,格式与 Gateway/KBS 的 `POST .../set_reference_value_list` 请求体一致),调用内置 RVPS 的 `set_reference_value_list`:从 `provenance_source` 获取 release manifest bundle,解析 `measurements` 并校验 bundle 内 Rekor entry 的 payload hash 后写入参考值。 +- `provenance_info.type` 使用 `rv-release-manifest`。每项的 `id` 是要导入的 measurement 名称,可完全自定义,只要与 release manifest `measurements` 中的键一致。 +- 每项可选 `rv_name`:若指定则作为 RVPS 中的参考值名称;省略时新格式默认使用 `id`。 + +### SLSA 模式(历史兼容) #### 方式一(经典模式) -这种方式只支持rekor v1 +这种方式只支持 Rekor v1,不再作为新设计推荐路径。 ```bash attestation-challenge-client set-reference-value \ @@ -56,19 +66,11 @@ attestation-challenge-client set-reference-value \ --artifact-name \ [--rekor-url https://rekor.sigstore.dev] ``` -- 逻辑:对 `artifact-name` 做 sha256 作为索引,访问rekor透明日志查询相应条目,过滤并提取 SLSA provenance (包含度量参考值),组装为 RVPS 能识别的 message 后注册。 -- `--rekor-url` 可选,默认 `https://rekor.sigstore.dev`。 - -#### 方式二(批量模式) - -这种方式能够支持rekor v2 - -```bash -attestation-challenge-client set-reference-value-list --rv-list /path/to/rv-list.json -``` +- 逻辑:对 `artifact-name` 做 sha256 作为索引,访问 Rekor 透明日志查询相应条目,过滤并提取 SLSA provenance,组装为 RVPS 能识别的 message 后注册。 + +#### 方式二(批量兼容模式) -- 逻辑:读取 JSON 文件(顶层字段 `rv_list`,格式与 Gateway/KBS 的 `POST .../set_reference_value_list` 请求体一致),调用内置 RVPS 的 `set_reference_value_list`:按每项的 `id`+`version` 及其 `provenance_info.rekor_url` 从 Rekor 拉取 SLSA,解析 digest 后写入参考值。 -- 每项可选 `rv_name`:若指定则作为 RVPS 中的参考值名称;省略时默认 `measurement..`(与网关 API 行为一致)。 +当 `provenance_info.type` 仍为 `slsa-intoto-statements` 时,`set-reference-value-list` 会沿用历史 SLSA 解析逻辑。 ### Sample 模式 diff --git a/docs/rekor.md b/docs/rekor.md index c54d572..41f8d1b 100644 --- a/docs/rekor.md +++ b/docs/rekor.md @@ -2,10 +2,10 @@ ## 1. 概述 -Trustee 已经具备比较完整的 **Rekor 透明日志参考值能力**: +Trustee 已经具备基于 **RV release manifest + Rekor DSSE** 的透明日志参考值能力: -- 支持从 Rekor 检索 SLSA provenance。 -- 支持解析 in-toto Statement / DSSE payload,提取制品 digest。 +- 支持解析 `application/vnd.trustee.rv.release+json` DSSE payload,提取 `measurements`。 +- 支持校验本地 release manifest payload hash 与 Rekor DSSE entry 的 `payloadHash`/v2 digest 一致。 - 支持把提取出的参考值注册到 RVPS,供后续远程证明校验。 --- @@ -18,12 +18,12 @@ Trustee 已经具备比较完整的 **Rekor 透明日志参考值能力**: flowchart LR subgraph Producer["发布侧 / 供应链"] A["构建系统 / CI"] - B["SLSA Provenance"] + B["RV Release Manifest DSSE"] A --> B end R["Rekor 透明日志"] - B -->|"rekor-cli upload (intoto)"| R + B -->|"POST dsse log entry"| R subgraph Trustee["Trustee 侧"] D["Trustee Gateway API"] @@ -33,22 +33,22 @@ flowchart LR E --> F end - R -->|"查询 provenance"| E + R -->|"校验 payload hash / inclusion proof"| E CI["Trustee Owner"] -->|"POST /api/rvps/set_reference_value_list"| D ``` ### 2.2 关键角色 -- **Rekor**:透明日志服务,存储可查询的 in-toto/SLSA 条目。 +- **Rekor**:透明日志服务,存储 RV release manifest DSSE 条目。 - **Trustee Gateway**:对外 API 入口,可触发 RVPS 批量设置参考值。 -- **RVPS**:负责 Rekor 查询(批量场景)、SLSA 解析、digest 提取与参考值落库。 +- **RVPS**:负责 release manifest 解析、digest 提取、Rekor entry 一致性校验与参考值落库。 - **Attestation Service / KBS**:在证明决策中消费 RVPS 参考值。 --- ## 3. 透明日志接入设计 -### 3.1 Rekor 检索策略 +### 3.1 Release Manifest 消费策略 ```mermaid sequenceDiagram @@ -57,29 +57,22 @@ sequenceDiagram participant RC as RekorClient participant RK as Rekor - U->>RV: 提交 rv_list(id,version,type,rekor_url) - RV->>RC: 为每个条目发起 Rekor 查询 - RC->>RC: 计算 sha256 索引 - RC->>RK: POST /api/v1/index/retrieve - RK-->>RC: 返回 entry UUID 列表 - RC->>RK: POST /api/v1/log/entries/retrieve - RK-->>RC: 返回 entries - RC->>RC: 解析 attestation.data / body.DSSE - RC->>RC: 过滤 predicateType=slsa - RC->>RV: 传入 SLSA payload - RV->>RV: 提取 subject digest 并写入参考值 + U->>RV: 提交 rv_list(id=cvm_uki/cvm_container_xxx, provenance_source) + RV->>RV: 拉取 release manifest bundle + RV->>RV: 解码 DSSE payload + RV->>RV: 校验 payload hash 与 Rekor entry 一致 + RV->>RV: 提取 measurements[id] 并写入参考值 ``` ### 3.2 解析与落库规则 -- 同时兼容两种条目路径: - - `attestation.data`(base64 JSON) - - `body` 中 `intoto` DSSE payload(base64) -- 仅接受 `predicateType` 包含 `slsa` 的 statement。 -- 从 `subject/subjects` 抽取 digest,过滤 `artifact-index-hash` 等索引项。 +- 新格式使用 `provenance_info.type = "rv-release-manifest"`。 +- release manifest 必须包含 `schemaVersion=1` 与非空 `measurements`。 +- `cvm_uki`、`cvm_firmware`、`host_uki` 必须使用 `sha384`;`cvm_container_*` 必须使用 `sha256`。 +- 从 `measurements[id]` 抽取 digest 并写入 RVPS;默认参考值名就是 `id`,如 `cvm_uki`。 - 参考值支持去重与合并更新,避免重复覆盖。 - 默认设置过期时间(当前实现约 12 个月)。 -- `set_reference_value_list` 的 `rv_list` 项支持可选 `rv_name`:若设置则以其为 RVPS 参考值名称,否则仍为 `measurement..`。 +- `set_reference_value_list` 的 `rv_list` 项支持可选 `rv_name`:若设置则以其为 RVPS 参考值名称。 ### 3.3 可选的强化校验 @@ -100,7 +93,7 @@ cat << EOF > rvps-set-list.json "version": "artifact-version", "type": "model", "provenance_info": { - "type": "slsa-intoto-statements", + "type": "rv-release-manifest", "rekor_url": "https://log2025-1.rekor.sigstore.dev", "rekor_api_version": 2 }, @@ -125,7 +118,8 @@ curl -k -X POST http://:/api/rvps/set_reference_value_list \ - `provenance_source.protocol`:当前支持 `oci` - `provenance_source.uri`:OCI 地址,格式 `oci:///:` 或 `oci:///@sha256:` - `provenance_source.artifact`:`bundle` 或 `provenance`,默认建议 `bundle` -- 当请求中未提供 `provenance_source` 字段时,仍走现有 Rekor v1 索引查询兼容路径 +- 新格式需要通过 `provenance_source` 提供完整 release manifest bundle/DSSE/payload;RVPS 会用 bundle 内 Rekor entry 校验 payload hash。 +- 旧的 `slsa-intoto-statements` 兼容路径仍可用于历史数据,但不再作为新设计推荐路径。 ### 4.2 发布侧:生成并上传 Rekor @@ -144,19 +138,19 @@ cd trustee/tools/slsa --provenance-store-artifact bundle ``` -该脚本会生成 statement + DSSE,并在 `bundle` 模式输出统一的 provenance metadata 结构: +该脚本会生成 JCS 规范化 release payload + DSSE,并在 `bundle` 模式输出统一 metadata 结构: -- `sourceBundle`:来源 bundle(与 Sigstore bundle 结构对齐); -- `dsseEnvelope`:便于直接消费的 DSSE; -- `rekorEntryV2`:可选(v2 上传成功时带上)。 +- `releasePayload`:JCS 规范化后的 release manifest; +- `dsseEnvelope`:`application/vnd.trustee.rv.release+json` DSSE; +- `rekorEntryV1`/`rekorEntryV2`:可选,上传成功时带上。 同时支持: -- 上传到 Rekor v1(`rekor-cli upload --type intoto`); +- 上传到 Rekor v1(`kind=dsse`); - 上传到 Rekor v2(`/api/v2/log/entries`,`dsseRequestV002`); - 把 provenance 元数据上传到指定存储地址(首期支持 OCI)。 -> 说明:CI 发布到 GitHub Release 的 `*.provenance-metadata.json` 与 `slsa-generator` 的 `provenance.trustee-bundle.json` 已统一为同一 schema(`sourceBundle + dsseEnvelope + rekorEntryV2`)。 +> 说明:CI 发布到 GitHub Release 的 `*.release-manifest.bundle.json` 与 `slsa-generator` 的 `release-manifest.trustee-bundle.json` 已统一为同一 schema(`releasePayload + dsseEnvelope + rekorEntryV1/rekorEntryV2`)。 ### 4.3 审计侧:使用脚本验证参考值与 Rekor v2 一致性 diff --git a/rvps/src/lib.rs b/rvps/src/lib.rs index 5eb2a85..1b787fb 100644 --- a/rvps/src/lib.rs +++ b/rvps/src/lib.rs @@ -32,7 +32,10 @@ use sha2::Digest; use std::collections::{HashMap, HashSet}; use provenance_source::{OciProvenanceFetcher, ProvenanceFetcher, ProvenanceSource}; -use rv_list::{extract_slsa_digests, parse_reference_value_list, ReferenceValueOperation}; +use rv_list::{ + extract_release_manifest_digests, extract_slsa_digests, parse_reference_value_list, + parse_release_manifest_documents_from_material, ReferenceValueOperation, +}; /// Default version of Message static MESSAGE_VERSION: &str = "0.1.0"; @@ -162,7 +165,11 @@ impl Rvps { for item in request.rv_list { let operation = ReferenceValueOperation::parse(&item.operation_type)?; - if item.provenance_info.provenance_type != "slsa-intoto-statements" { + let provenance_type = item.provenance_info.provenance_type.as_str(); + if !matches!( + provenance_type, + "rv-release-manifest" | "slsa-intoto-statements" + ) { bail!( "unsupported provenance_info.type `{}`", item.provenance_info.provenance_type @@ -181,48 +188,60 @@ impl Rvps { } n.to_string() } + None if provenance_type == "rv-release-manifest" => item.id.clone(), None => format!("measurement.{}.{}", item.rv_type, item.id), }; - let slsa_docs = if let Some(source) = &item.provenance_source { - let protocol = source.protocol.to_ascii_lowercase(); - let material = match protocol.as_str() { - "oci" => { - let fetcher = OciProvenanceFetcher::new(); - let src = ProvenanceSource { - protocol: source.protocol.clone(), - uri: source.uri.clone(), - artifact: source.artifact.clone(), - }; - fetcher - .fetch(&src) - .await - .with_context(|| format!("fetch provenance from OCI `{}`", src.uri))? - } - other => bail!("unsupported provenance_source.protocol `{other}`"), - }; - parse_slsa_documents_from_material(&material.raw_bytes).with_context(|| { - format!( - "parse fetched provenance material for `{}` (media type: {:?})", - item.id, material.media_type + let digest_set = if provenance_type == "rv-release-manifest" { + let source = item.provenance_source.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "rv-release-manifest requires provenance_source with release manifest material" ) - })? + })?; + let material = fetch_provenance_material(source).await?; + let manifests = parse_release_manifest_documents_from_material(&material.raw_bytes) + .with_context(|| { + format!( + "parse fetched release manifest material for `{}` (media type: {:?})", + item.id, material.media_type + ) + })?; + + let mut digest_set = HashSet::new(); + for manifest in &manifests { + let manifest_digests = extract_release_manifest_digests(manifest, &item.id)?; + for digest in manifest_digests { + digest_set.insert(digest); + } + } + digest_set } else { - let lookup = format!("{}{}", item.id, item.version); - let rekor_client = rekor::RekorClient::new(&item.provenance_info.rekor_url)?; - rekor_client - .fetch_slsa_provenance_for_lookup(&lookup) - .await - .with_context(|| format!("fetch SLSA provenance for {}", item.id))? - }; + let slsa_docs = if let Some(source) = &item.provenance_source { + let material = fetch_provenance_material(source).await?; + parse_slsa_documents_from_material(&material.raw_bytes).with_context(|| { + format!( + "parse fetched provenance material for `{}` (media type: {:?})", + item.id, material.media_type + ) + })? + } else { + let lookup = format!("{}{}", item.id, item.version); + let rekor_client = rekor::RekorClient::new(&item.provenance_info.rekor_url)?; + rekor_client + .fetch_slsa_provenance_for_lookup(&lookup) + .await + .with_context(|| format!("fetch SLSA provenance for {}", item.id))? + }; - let mut digest_set = HashSet::new(); - for doc in &slsa_docs { - let doc_digests = extract_slsa_digests(doc, &item.id)?; - for digest in doc_digests { - digest_set.insert(digest); + let mut digest_set = HashSet::new(); + for doc in &slsa_docs { + let doc_digests = extract_slsa_digests(doc, &item.id)?; + for digest in doc_digests { + digest_set.insert(digest); + } } - } + digest_set + }; if digest_set.is_empty() { bail!("no digest entries found for {}", item.id); @@ -311,6 +330,36 @@ impl Rvps { } } +async fn fetch_provenance_material( + source: &rv_list::ReferenceValueProvenanceSource, +) -> Result { + let protocol = source.protocol.to_ascii_lowercase(); + match protocol.as_str() { + "oci" => { + let fetcher = OciProvenanceFetcher::new(); + let src = ProvenanceSource { + protocol: source.protocol.clone(), + uri: source.uri.clone(), + artifact: source.artifact.clone(), + }; + fetcher + .fetch(&src) + .await + .with_context(|| format!("fetch provenance from OCI `{}`", src.uri)) + } + "file" => { + let path = source.uri.strip_prefix("file://").unwrap_or(&source.uri); + let raw_bytes = std::fs::read(path) + .with_context(|| format!("read provenance material from file `{path}`"))?; + Ok(provenance_source::FetchedProvenanceMaterial { + media_type: source.artifact.clone(), + raw_bytes, + }) + } + other => bail!("unsupported provenance_source.protocol `{other}`"), + } +} + fn parse_slsa_documents_from_material(raw_bytes: &[u8]) -> Result> { let text = std::str::from_utf8(raw_bytes).context("provenance material is not UTF-8 text")?; let trimmed = text.trim(); @@ -507,6 +556,7 @@ fn unique_docs(docs: Vec) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::storage::{local_json, ReferenceValueStorageConfig}; #[test] fn parse_direct_statement() { @@ -526,4 +576,47 @@ mod tests { assert_eq!(docs.len(), 1); assert!(docs[0].contains("predicateType")); } + + #[tokio::test] + async fn set_reference_value_list_from_release_manifest_file() { + let tmp = tempfile::tempdir().unwrap(); + let bundle_path = tmp.path().join("release-manifest.bundle.json"); + let storage_path = tmp.path().join("reference_values.json"); + let manifest = r#"{"measurements":{"cvm_container_proxy":{"algorithm":"sha256","value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"schemaVersion":1}"#; + std::fs::write(&bundle_path, format!(r#"{{"releasePayload":{manifest}}}"#)).unwrap(); + + let mut rvps = Rvps::new(Config { + storage: ReferenceValueStorageConfig::LocalJson(local_json::Config { + file_path: storage_path.to_string_lossy().to_string(), + }), + }) + .unwrap(); + let payload = serde_json::json!({ + "rv_list": [{ + "id": "cvm_container_proxy", + "version": "1.0.0", + "type": "container", + "provenance_info": { + "type": "rv-release-manifest", + "rekor_url": "https://rekor.sigstore.dev", + "rekor_api_version": 1 + }, + "provenance_source": { + "protocol": "file", + "uri": bundle_path.to_string_lossy(), + "artifact": "bundle" + }, + "operation_type": "refresh" + }] + }); + + rvps.set_reference_value_list(&payload.to_string()) + .await + .unwrap(); + let digests = rvps.get_digests().await.unwrap(); + assert_eq!( + digests.get("cvm_container_proxy").unwrap(), + &vec!["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string()] + ); + } } diff --git a/rvps/src/rv_list/mod.rs b/rvps/src/rv_list/mod.rs index 3195698..c181e9d 100644 --- a/rvps/src/rv_list/mod.rs +++ b/rvps/src/rv_list/mod.rs @@ -59,6 +59,10 @@ pub fn parse_reference_value_list(payload: &str) -> Result Result> { + let text = std::str::from_utf8(raw_bytes).context("release manifest material is not UTF-8")?; + let trimmed = text.trim(); + if trimmed.is_empty() { + bail!("release manifest material is empty"); + } + + let value: Value = + serde_json::from_str(trimmed).context("parse release manifest material as JSON")?; + parse_release_manifest_documents_from_json(&value) +} + +pub fn extract_release_manifest_digests( + manifest: &str, + expected_measurement: &str, +) -> Result> { + let payload_json: Value = serde_json::from_str(manifest).or_else(|_| { + let decoded = base64::engine::general_purpose::STANDARD + .decode(manifest) + .context("decode base64 release manifest payload")?; + serde_json::from_slice(&decoded).context("deserialize release manifest payload") + })?; + + validate_release_manifest(&payload_json)?; + let measurements = payload_json + .get("measurements") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow!("release manifest missing measurements object"))?; + + let selected = measurements.get(expected_measurement).ok_or_else(|| { + anyhow!("measurement `{expected_measurement}` not found in release manifest") + })?; + let algorithm = selected + .get("algorithm") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("measurement `{expected_measurement}` missing algorithm"))?; + let value = selected + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("measurement `{expected_measurement}` missing value"))?; + + validate_measurement(expected_measurement, algorithm, value)?; + Ok(vec![( + algorithm.to_ascii_lowercase(), + value.to_ascii_lowercase(), + )]) +} + +fn parse_release_manifest_documents_from_json(value: &Value) -> Result> { + verify_rekor_v1_consistency(value)?; + verify_rekor_v2_consistency(value)?; + + if is_release_manifest(value) { + return Ok(vec![value.to_string()]); + } + + if let Some(release_payload) = value.get("releasePayload") { + if is_release_manifest(release_payload) { + return Ok(vec![release_payload.to_string()]); + } + } + + if let Some(manifest) = dsse_payload_release_manifest(value)? { + return Ok(vec![manifest]); + } + + let mut docs = Vec::new(); + for dsse in [ + value.get("dsseEnvelope"), + value.pointer("/content/dsseEnvelope"), + value.pointer("/sourceBundle/dsseEnvelope"), + value.pointer("/sourceBundle/content/dsseEnvelope"), + ] + .into_iter() + .flatten() + { + if let Some(manifest) = dsse_payload_release_manifest(dsse)? { + docs.push(manifest); + } + } + + if let Some(arr) = value.as_array() { + for item in arr { + if is_release_manifest(item) { + docs.push(item.to_string()); + continue; + } + if let Some(manifest) = dsse_payload_release_manifest(item)? { + docs.push(manifest); + } + } + } + + if docs.is_empty() { + bail!("unsupported release manifest material JSON format"); + } + + Ok(unique_docs(docs)) +} + +fn dsse_payload_release_manifest(value: &Value) -> Result> { + let payload = value.get("payload").and_then(|v| v.as_str()); + let payload_type = value.get("payloadType").and_then(|v| v.as_str()); + let signatures = value.get("signatures").and_then(|v| v.as_array()); + if payload.is_none() || payload_type.is_none() || signatures.is_none() { + return Ok(None); + } + + if payload_type.unwrap_or_default() != RV_RELEASE_PAYLOAD_TYPE { + return Ok(None); + } + + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload.unwrap_or_default()) + .context("decode release manifest DSSE payload")?; + let manifest = String::from_utf8(decoded).context("release manifest payload is not UTF-8")?; + let manifest_json: Value = + serde_json::from_str(&manifest).context("parse release manifest payload as JSON")?; + validate_release_manifest(&manifest_json)?; + + Ok(Some(manifest)) +} + +fn validate_release_manifest(value: &Value) -> Result<()> { + let schema_version = value + .get("schemaVersion") + .and_then(|v| v.as_u64()) + .ok_or_else(|| anyhow!("release manifest missing schemaVersion"))?; + if schema_version != 1 { + bail!("unsupported release manifest schemaVersion `{schema_version}`"); + } + + let measurements = value + .get("measurements") + .and_then(|v| v.as_object()) + .ok_or_else(|| anyhow!("release manifest missing measurements object"))?; + if measurements.is_empty() { + bail!("release manifest measurements cannot be empty"); + } + + for (name, measurement) in measurements { + let algorithm = measurement + .get("algorithm") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("measurement `{name}` missing algorithm"))?; + let digest = measurement + .get("value") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("measurement `{name}` missing value"))?; + validate_measurement(name, algorithm, digest)?; + } + + Ok(()) +} + +fn validate_measurement(name: &str, algorithm: &str, value: &str) -> Result<()> { + let algorithm = algorithm.to_ascii_lowercase(); + let expected_len = match algorithm.as_str() { + "sha256" => 64, + "sha384" => 96, + other => bail!("measurement `{name}` uses unsupported algorithm `{other}`"), + }; + + if value.len() != expected_len || !value.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("measurement `{name}` value is not a valid {algorithm} lowercase hex digest"); + } + if value.chars().any(|c| c.is_ascii_uppercase()) { + bail!("measurement `{name}` value must be lowercase hex"); + } + + Ok(()) +} + +fn verify_rekor_v1_consistency(value: &Value) -> Result<()> { + let dsse = value + .get("dsseEnvelope") + .or_else(|| value.pointer("/content/dsseEnvelope")) + .or_else(|| value.pointer("/sourceBundle/dsseEnvelope")) + .or_else(|| value.pointer("/sourceBundle/content/dsseEnvelope")); + let rekor_entry = value + .get("rekorEntryV1") + .or_else(|| value.pointer("/verificationMaterial/tlogEntries/0")) + .or_else(|| value.pointer("/sourceBundle/verificationMaterial/tlogEntries/0")); + + let (Some(dsse), Some(rekor_entry)) = (dsse, rekor_entry) else { + return Ok(()); + }; + let payload_hash = payload_sha256_hex(dsse)?; + + let entry = unwrap_rekor_v1_entry(rekor_entry); + let body_json = if let Some(body_b64) = entry.get("body").and_then(|v| v.as_str()) { + let decoded = base64::engine::general_purpose::STANDARD + .decode(body_b64) + .context("decode Rekor v1 body")?; + serde_json::from_slice::(&decoded).context("parse Rekor v1 body JSON")? + } else { + entry.clone() + }; + + let kind = body_json + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if kind != "dsse" { + bail!("Rekor v1 body kind `{kind}` is not dsse"); + } + + let rekor_payload_hash = body_json + .pointer("/spec/payloadHash/value") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Rekor v1 dsse body missing payloadHash.value"))?; + if rekor_payload_hash != payload_hash { + bail!( + "Rekor v1 payload hash mismatch: rekor=`{}`, payload_sha256=`{}`", + rekor_payload_hash, + payload_hash + ); + } + + Ok(()) +} + +fn unwrap_rekor_v1_entry(value: &Value) -> &Value { + if value.get("body").is_some() || value.get("kind").is_some() { + return value; + } + value + .as_object() + .and_then(|obj| obj.values().next()) + .unwrap_or(value) +} + +fn verify_rekor_v2_consistency(value: &Value) -> Result<()> { + let dsse = value + .get("dsseEnvelope") + .or_else(|| value.pointer("/content/dsseEnvelope")) + .or_else(|| value.pointer("/sourceBundle/dsseEnvelope")) + .or_else(|| value.pointer("/sourceBundle/content/dsseEnvelope")); + let tlog_entry = value + .get("rekorEntryV2") + .or_else(|| value.pointer("/verificationMaterial/tlogEntries/0")) + .or_else(|| value.pointer("/sourceBundle/verificationMaterial/tlogEntries/0")); + + let (Some(dsse), Some(tlog_entry)) = (dsse, tlog_entry) else { + return Ok(()); + }; + + let payload_sha256_b64 = payload_sha256_b64(dsse)?; + let canonicalized_body_b64 = tlog_entry + .get("canonicalizedBody") + .or_else(|| tlog_entry.get("canonicalized_body")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Rekor v2 tlog entry missing canonicalizedBody"))?; + let canonicalized_body = base64::engine::general_purpose::STANDARD + .decode(canonicalized_body_b64) + .context("decode Rekor v2 canonicalizedBody")?; + let canonicalized_json: Value = serde_json::from_slice(&canonicalized_body) + .context("parse Rekor v2 canonicalizedBody JSON")?; + + let kind = canonicalized_json + .get("kind") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if kind != "dsse" { + bail!("Rekor v2 canonicalizedBody kind `{kind}` is not dsse"); + } + + let rekor_payload_digest = canonicalized_json + .pointer("/spec/dsseV002/data/digest") + .or_else(|| canonicalized_json.pointer("/spec/dsseV002/payloadHash/digest")) + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("Rekor v2 canonicalizedBody missing dsse payload digest"))?; + + if rekor_payload_digest != payload_sha256_b64 { + bail!( + "Rekor v2 digest mismatch: rekor=`{}`, payload_sha256_b64=`{}`", + rekor_payload_digest, + payload_sha256_b64 + ); + } + + Ok(()) +} + +fn payload_sha256_hex(dsse: &Value) -> Result { + let payload_b64 = dsse + .get("payload") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("dsseEnvelope.payload missing"))?; + let payload = base64::engine::general_purpose::STANDARD + .decode(payload_b64) + .context("decode dsseEnvelope.payload")?; + Ok(format!("{:x}", Sha256::digest(payload))) +} + +fn payload_sha256_b64(dsse: &Value) -> Result { + let payload_b64 = dsse + .get("payload") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("dsseEnvelope.payload missing"))?; + let payload = base64::engine::general_purpose::STANDARD + .decode(payload_b64) + .context("decode dsseEnvelope.payload")?; + Ok(base64::engine::general_purpose::STANDARD.encode(Sha256::digest(payload))) +} + +fn is_release_manifest(value: &Value) -> bool { + value.get("schemaVersion").is_some() && value.get("measurements").is_some() +} + +fn unique_docs(docs: Vec) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for doc in docs { + if seen.insert(doc.clone()) { + out.push(doc); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_direct_release_manifest() { + let manifest = r#"{"measurements":{"cvm_container_proxy":{"algorithm":"sha256","value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"schemaVersion":1}"#; + let docs = parse_release_manifest_documents_from_material(manifest.as_bytes()).unwrap(); + assert_eq!(docs.len(), 1); + let digests = extract_release_manifest_digests(&docs[0], "cvm_container_proxy").unwrap(); + assert_eq!( + digests, + vec![( + "sha256".to_string(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string() + )] + ); + } + + #[test] + fn parse_dsse_release_manifest() { + let manifest = r#"{"measurements":{"cvm_uki":{"algorithm":"sha384","value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"schemaVersion":1}"#; + let payload = base64::engine::general_purpose::STANDARD.encode(manifest.as_bytes()); + let dsse = format!( + r#"{{"payloadType":"{}","payload":"{payload}","signatures":[{{"sig":"abc"}}]}}"#, + RV_RELEASE_PAYLOAD_TYPE + ); + let docs = parse_release_manifest_documents_from_material(dsse.as_bytes()).unwrap(); + assert_eq!(docs.len(), 1); + let digests = extract_release_manifest_digests(&docs[0], "cvm_uki").unwrap(); + assert_eq!(digests[0].0, "sha384"); + } + + #[test] + fn parse_bundle_with_rekor_v1_uuid_map() { + let manifest = r#"{"measurements":{"custom_name":{"algorithm":"sha256","value":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"schemaVersion":1}"#; + let payload = base64::engine::general_purpose::STANDARD.encode(manifest.as_bytes()); + let dsse = serde_json::json!({ + "payloadType": RV_RELEASE_PAYLOAD_TYPE, + "payload": payload, + "signatures": [{"sig": "abc"}] + }); + let payload_sha = format!("{:x}", Sha256::digest(manifest.as_bytes())); + let body = serde_json::json!({ + "kind": "dsse", + "apiVersion": "0.0.1", + "spec": { + "payloadHash": { + "algorithm": "sha256", + "value": payload_sha + } + } + }); + let body_b64 = base64::engine::general_purpose::STANDARD.encode(body.to_string()); + let bundle = serde_json::json!({ + "releasePayload": serde_json::from_str::(manifest).unwrap(), + "dsseEnvelope": dsse, + "rekorEntryV1": { + "fake-uuid": { + "body": body_b64 + } + } + }); + + let docs = + parse_release_manifest_documents_from_material(bundle.to_string().as_bytes()).unwrap(); + let digests = extract_release_manifest_digests(&docs[0], "custom_name").unwrap(); + assert_eq!( + digests[0].1, + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } +} diff --git a/rvps/src/rvps_api/reference.rs b/rvps/src/rvps_api/reference.rs index e6534d3..6c20f68 100644 --- a/rvps/src/rvps_api/reference.rs +++ b/rvps/src/rvps_api/reference.rs @@ -34,10 +34,10 @@ pub mod reference_value_provider_service_client { dead_code, missing_docs, clippy::wildcard_imports, - clippy::let_unit_value + clippy::let_unit_value, )] - use tonic::codegen::http::Uri; use tonic::codegen::*; + use tonic::codegen::http::Uri; #[derive(Debug, Clone)] pub struct ReferenceValueProviderServiceClient { inner: tonic::client::Grpc, @@ -81,10 +81,13 @@ pub mod reference_value_provider_service_client { >::ResponseBody, >, >, - >>::Error: - Into + std::marker::Send + std::marker::Sync, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, { - ReferenceValueProviderServiceClient::new(InterceptedService::new(inner, interceptor)) + ReferenceValueProviderServiceClient::new( + InterceptedService::new(inner, interceptor), + ) } /// Compress requests with the given encoding. /// @@ -120,20 +123,30 @@ pub mod reference_value_provider_service_client { pub async fn query_reference_value( &mut self, request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> - { - self.inner.ready().await.map_err(|e| { - tonic::Status::unknown(format!("Service was not ready: {}", e.into())) - })?; + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( "/reference.ReferenceValueProviderService/QueryReferenceValue", ); let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new( - "reference.ReferenceValueProviderService", - "QueryReferenceValue", - )); + req.extensions_mut() + .insert( + GrpcMethod::new( + "reference.ReferenceValueProviderService", + "QueryReferenceValue", + ), + ); self.inner.unary(req, path, codec).await } pub async fn register_reference_value( @@ -143,56 +156,84 @@ pub mod reference_value_provider_service_client { tonic::Response, tonic::Status, > { - self.inner.ready().await.map_err(|e| { - tonic::Status::unknown(format!("Service was not ready: {}", e.into())) - })?; + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( "/reference.ReferenceValueProviderService/RegisterReferenceValue", ); let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new( - "reference.ReferenceValueProviderService", - "RegisterReferenceValue", - )); + req.extensions_mut() + .insert( + GrpcMethod::new( + "reference.ReferenceValueProviderService", + "RegisterReferenceValue", + ), + ); self.inner.unary(req, path, codec).await } pub async fn delete_reference_value( &mut self, request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> - { - self.inner.ready().await.map_err(|e| { - tonic::Status::unknown(format!("Service was not ready: {}", e.into())) - })?; + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( "/reference.ReferenceValueProviderService/DeleteReferenceValue", ); let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new( - "reference.ReferenceValueProviderService", - "DeleteReferenceValue", - )); + req.extensions_mut() + .insert( + GrpcMethod::new( + "reference.ReferenceValueProviderService", + "DeleteReferenceValue", + ), + ); self.inner.unary(req, path, codec).await } pub async fn set_reference_value_list( &mut self, request: impl tonic::IntoRequest, - ) -> std::result::Result, tonic::Status> - { - self.inner.ready().await.map_err(|e| { - tonic::Status::unknown(format!("Service was not ready: {}", e.into())) - })?; + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; let codec = tonic::codec::ProstCodec::default(); let path = http::uri::PathAndQuery::from_static( "/reference.ReferenceValueProviderService/SetReferenceValueList", ); let mut req = request.into_request(); - req.extensions_mut().insert(GrpcMethod::new( - "reference.ReferenceValueProviderService", - "SetReferenceValueList", - )); + req.extensions_mut() + .insert( + GrpcMethod::new( + "reference.ReferenceValueProviderService", + "SetReferenceValueList", + ), + ); self.inner.unary(req, path, codec).await } } @@ -204,18 +245,19 @@ pub mod reference_value_provider_service_server { dead_code, missing_docs, clippy::wildcard_imports, - clippy::let_unit_value + clippy::let_unit_value, )] use tonic::codegen::*; /// Generated trait containing gRPC methods that should be implemented for use with ReferenceValueProviderServiceServer. #[async_trait] - pub trait ReferenceValueProviderService: - std::marker::Send + std::marker::Sync + 'static - { + pub trait ReferenceValueProviderService: std::marker::Send + std::marker::Sync + 'static { async fn query_reference_value( &self, request: tonic::Request, - ) -> std::result::Result, tonic::Status>; + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn register_reference_value( &self, request: tonic::Request, @@ -226,11 +268,17 @@ pub mod reference_value_provider_service_server { async fn delete_reference_value( &self, request: tonic::Request, - ) -> std::result::Result, tonic::Status>; + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn set_reference_value_list( &self, request: tonic::Request, - ) -> std::result::Result, tonic::Status>; + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct ReferenceValueProviderServiceServer { @@ -253,7 +301,10 @@ pub mod reference_value_provider_service_server { max_encoding_message_size: None, } } - pub fn with_interceptor(inner: T, interceptor: F) -> InterceptedService + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService where F: tonic::service::Interceptor, { @@ -288,7 +339,8 @@ pub mod reference_value_provider_service_server { self } } - impl tonic::codegen::Service> for ReferenceValueProviderServiceServer + impl tonic::codegen::Service> + for ReferenceValueProviderServiceServer where T: ReferenceValueProviderService, B: Body + std::marker::Send + 'static, @@ -307,13 +359,18 @@ pub mod reference_value_provider_service_server { match req.uri().path() { "/reference.ReferenceValueProviderService/QueryReferenceValue" => { #[allow(non_camel_case_types)] - struct QueryReferenceValueSvc(pub Arc); - impl - tonic::server::UnaryService - for QueryReferenceValueSvc - { + struct QueryReferenceValueSvc( + pub Arc, + ); + impl< + T: ReferenceValueProviderService, + > tonic::server::UnaryService + for QueryReferenceValueSvc { type Response = super::ReferenceValueQueryResponse; - type Future = BoxFuture, tonic::Status>; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; fn call( &mut self, request: tonic::Request, @@ -321,9 +378,10 @@ pub mod reference_value_provider_service_server { let inner = Arc::clone(&self.0); let fut = async move { ::query_reference_value( - &inner, request, - ) - .await + &inner, + request, + ) + .await }; Box::pin(fut) } @@ -352,13 +410,18 @@ pub mod reference_value_provider_service_server { } "/reference.ReferenceValueProviderService/RegisterReferenceValue" => { #[allow(non_camel_case_types)] - struct RegisterReferenceValueSvc(pub Arc); - impl - tonic::server::UnaryService - for RegisterReferenceValueSvc - { + struct RegisterReferenceValueSvc( + pub Arc, + ); + impl< + T: ReferenceValueProviderService, + > tonic::server::UnaryService + for RegisterReferenceValueSvc { type Response = super::ReferenceValueRegisterResponse; - type Future = BoxFuture, tonic::Status>; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; fn call( &mut self, request: tonic::Request, @@ -366,9 +429,10 @@ pub mod reference_value_provider_service_server { let inner = Arc::clone(&self.0); let fut = async move { ::register_reference_value( - &inner, request, - ) - .await + &inner, + request, + ) + .await }; Box::pin(fut) } @@ -397,13 +461,18 @@ pub mod reference_value_provider_service_server { } "/reference.ReferenceValueProviderService/DeleteReferenceValue" => { #[allow(non_camel_case_types)] - struct DeleteReferenceValueSvc(pub Arc); - impl - tonic::server::UnaryService - for DeleteReferenceValueSvc - { + struct DeleteReferenceValueSvc( + pub Arc, + ); + impl< + T: ReferenceValueProviderService, + > tonic::server::UnaryService + for DeleteReferenceValueSvc { type Response = super::ReferenceValueDeleteResponse; - type Future = BoxFuture, tonic::Status>; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; fn call( &mut self, request: tonic::Request, @@ -411,9 +480,10 @@ pub mod reference_value_provider_service_server { let inner = Arc::clone(&self.0); let fut = async move { ::delete_reference_value( - &inner, request, - ) - .await + &inner, + request, + ) + .await }; Box::pin(fut) } @@ -442,13 +512,18 @@ pub mod reference_value_provider_service_server { } "/reference.ReferenceValueProviderService/SetReferenceValueList" => { #[allow(non_camel_case_types)] - struct SetReferenceValueListSvc(pub Arc); - impl - tonic::server::UnaryService - for SetReferenceValueListSvc - { + struct SetReferenceValueListSvc( + pub Arc, + ); + impl< + T: ReferenceValueProviderService, + > tonic::server::UnaryService + for SetReferenceValueListSvc { type Response = super::ReferenceValueListResponse; - type Future = BoxFuture, tonic::Status>; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; fn call( &mut self, request: tonic::Request, @@ -456,9 +531,10 @@ pub mod reference_value_provider_service_server { let inner = Arc::clone(&self.0); let fut = async move { ::set_reference_value_list( - &inner, request, - ) - .await + &inner, + request, + ) + .await }; Box::pin(fut) } @@ -485,19 +561,23 @@ pub mod reference_value_provider_service_server { }; Box::pin(fut) } - _ => Box::pin(async move { - let mut response = http::Response::new(empty_body()); - let headers = response.headers_mut(); - headers.insert( - tonic::Status::GRPC_STATUS, - (tonic::Code::Unimplemented as i32).into(), - ); - headers.insert( - http::header::CONTENT_TYPE, - tonic::metadata::GRPC_CONTENT_TYPE, - ); - Ok(response) - }), + _ => { + Box::pin(async move { + let mut response = http::Response::new(empty_body()); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } } } } diff --git a/tools/slsa/README.md b/tools/slsa/README.md index a1da502..9e401f5 100644 --- a/tools/slsa/README.md +++ b/tools/slsa/README.md @@ -1,14 +1,12 @@ -# SLSA Provenance 生成与上链工具 +# RV Release Manifest 生成与上链工具 -本目录包含脚本 `slsa-generator`,用于为制品生成最简SLSA provenance(in-toto Statement),完成签名并上传到 Rekor(v1/v2),并可将 provenance 元数据上传到指定存储地址(首期支持 OCI)。 +本目录包含脚本 `slsa-generator`,用于为制品生成 JCS 规范化的 `application/vnd.trustee.rv.release+json` RV release manifest,封装为 DSSE 后上传到 Rekor(v1/v2),并可将 release manifest bundle 上传到指定存储地址(首期支持 OCI)。 ## 依赖 - bash - coreutils: `sha256sum` - cryptpilot-verity: `cryptpilot-verity` -- sigstore: `cosign` -- sigstore: `rekor-cli` - `uki` 类型额外需要: Python 3(调用同目录下的 `parse_uki_digest.py`) - `jq` - `curl` @@ -16,8 +14,7 @@ 版本说明(已验证环境): -- `cosign`: `v3.0.5` -- `rekor-cli`: `v1.5.1` +- `openssl`: 3.x ## 使用方法 @@ -25,7 +22,7 @@ ``` ./slsa-generator --artifact-type --artifact --artifact-id \ - --artifact-version --sign-key [更多可选参数] + --artifact-version --sign-key [--measurement-name ] [更多可选参数] ``` 参数说明: @@ -37,61 +34,48 @@ - `uki`: UKI 参考值 JSON **文件路径**(仅支持文件,不支持内联 JSON 字符串) - `--artifact-id`: 制品自定义ID - `--artifact-version`: 制品版本 -- `--sign-key`: 用于签名SLSA provenance的私钥路径(cosign生成) +- `--sign-key`: 用于签名 release manifest DSSE 的 PEM 私钥路径 +- `--measurement-name`: 可选,release manifest `measurements` 中的名称;省略时使用 `artifact-id`。该名称允许完全自定义,消费侧按同名 `id` 提取。 - `--rekor-url`: Rekor 地址(默认 `https://rekor.sigstore.dev`) - `--rekor-api-version`: Rekor API 主版本,`1` 或 `2`(默认 `1`) - `--rekor-v2-key-details`: Rekor v2 verifier key details(默认 `PKIX_ECDSA_P256_SHA_256`) - `--provenance-store-protocol`: provenance 存储协议(当前支持 `oci`) - `--provenance-store-uri`: provenance 存储地址(如 `oci://127.0.0.1:5000/ns/repo:tag`) -- `--provenance-store-artifact`: 上传到存储的对象类型(`bundle` 或 `provenance`,默认 `bundle`) +- `--provenance-store-artifact`: 上传到存储的对象类型(`bundle`、`payload` 或 `dsse`,默认 `bundle`) 运行完成后会在当前目录生成输出目录,例如: ``` ./slsa-output--/ - ├── statement.json - ├── statement.attestation.json - └── statement.dsse.json - ├── statement.intoto.jsonl - ├── rekor-v1-upload.txt / rekor-v2-entry.json - └── provenance.trustee-bundle.json + ├── release_payload.json + ├── release.dsse.json + ├── rekor-v1-entry.json / rekor-v2-entry.json + └── release-manifest.trustee-bundle.json ``` 各文件说明: -- `statement.json`: 原始 in-toto Statement(SLSA provenance) -- `statement.attestation.json`: cosign 输出的 attestation 产物 -- `statement.dsse.json`: DSSE envelope(包含 `payload`、`payloadType`、`signatures`) -- `statement.intoto.jsonl`: 单条 DSSE 的 JSONL 形式 +- `release_payload.json`: JCS 规范化后的 release manifest payload +- `release.dsse.json`: DSSE envelope(`payloadType=application/vnd.trustee.rv.release+json`) - `rekor-v2-entry.json`: 上传到 Rekor v2 返回的透明日志条目(v2 模式下生成) -- `provenance.trustee-bundle.json`: 供 RVPS 新链路消费的标准化组合元数据(`sourceBundle` + `dsseEnvelope` + 可选 `rekorEntryV2`) +- `release-manifest.trustee-bundle.json`: 供 RVPS 消费的标准化组合元数据(`releasePayload` + `dsseEnvelope` + 可选 `rekorEntryV1`/`rekorEntryV2`) ## 生成签名密钥 -使用 cosign 生成一对密钥: +使用 OpenSSL 生成一对 P-256 PEM 密钥: ``` -cosign generate-key-pair +openssl ecparam -name prime256v1 -genkey -noout -out rv-release.key +openssl pkey -in rv-release.key -pubout -out rv-release.pub ``` -默认生成: - -- `cosign.key` (私钥,供 `sign-key` 参数使用) -- `cosign.pub` (公钥,供上传Rekor使用) - -也可以指定输出路径: - -``` -cosign generate-key-pair --output-key-prefix /path/to/mykey -``` - -这将生成 `/path/to/mykey.key` 与 `/path/to/mykey.pub`。 - ## 示例 ``` ./slsa-generator --artifact-type binary --artifact /path/to/app.bin \ - --artifact-id app-binary --artifact-version 1.0.0 --sign-key /path/to/cosign.key \ + --artifact-id app-binary --artifact-version 1.0.0 \ + --measurement-name cvm_container_proxy \ + --sign-key /path/to/rv-release.key \ --rekor-url https://log2025-1.rekor.sigstore.dev --rekor-api-version 2 \ --provenance-store-protocol oci \ --provenance-store-uri oci://127.0.0.1:5000/trustee/provenance:app-binary-1.0.0 \ @@ -100,7 +84,7 @@ cosign generate-key-pair --output-key-prefix /path/to/mykey ``` ./slsa-generator --artifact-type model-dir --artifact /path/to/model \ - --artifact-id modelA --artifact-version 2024-02-01 --sign-key /path/to/cosign.key + --artifact-id modelA --artifact-version 2024-02-01 --sign-key /path/to/rv-release.key ``` UKI 示例(`--artifact` 指向 JSON 文件): @@ -115,7 +99,7 @@ UKI 示例(`--artifact` 指向 JSON 文件): ```bash ./slsa-generator --artifact-type uki --artifact /path/to/uki.json \ - --artifact-id uki-image --artifact-version 1.0.0 --sign-key /path/to/cosign.key + --artifact-id uki-image --artifact-version 1.0.0 --sign-key /path/to/rv-release.key ``` ## 说明 @@ -123,8 +107,7 @@ UKI 示例(`--artifact` 指向 JSON 文件): - Rekor v1 公共实例 URL: `https://rekor.sigstore.dev` - Rekor v2 需要使用 `/api/v2/log/entries`,脚本在 `--rekor-api-version 2` 时走 v2 上传逻辑。 - `model-dir` 的摘要通过 `cryptpilot-verity dump --print-root-hash` 获取。 -- 脚本使用 `rekor-cli upload --type intoto`,上传对象为 `statement.dsse.json`(DSSE envelope),而不是原始 `statement.json`。 +- Rekor v1 模式下脚本直接提交 `kind=dsse` 的 `/api/v1/log/entries` 请求;Rekor 响应中的 `payloadHash` 与 `release_payload.json` 的 SHA256 一致。 - `uki` 会从 `measurement.uki.` 提取摘要算法和值,`` 兼容 `sha256`/`sha384`(大小写及连字符写法均可,例如 `SHA-256`、`SHA-384`)。解析逻辑见 `parse_uki_digest.py`。 -- 上传到 Rekor 后,可用 Gateway/KBS 的 `set_reference_value_list` 或本地 `attestation-challenge-client set-reference-value-list --rv-list ` 按 `rv_list`(含每项 `provenance_info.rekor_url`)从 Rekor 拉取并写入 RVPS。每项可选字段 `rv_name` 可覆盖默认参考值名 `measurement..`(详见 `trustee-gateway/trustee_gateway_api.md` 与 `docs/challenge_ra.md`)。 -- v1 模式下脚本使用 `rekor-cli upload --type intoto`。 +- 上传到 Rekor 后,可用 Gateway/KBS 的 `set_reference_value_list` 或本地 `attestation-challenge-client set-reference-value-list --rv-list ` 按 `rv-release-manifest` 类型从 bundle 中提取 `measurements` 并写入 RVPS。每项可选字段 `rv_name` 可覆盖默认参考值名(新格式默认使用 measurement 名称)。 - v2 模式下脚本使用 HTTP API 直接提交 `dsseRequestV002`。 diff --git a/tools/slsa/slsa-generator b/tools/slsa/slsa-generator index 4fce5a9..05d3036 100755 --- a/tools/slsa/slsa-generator +++ b/tools/slsa/slsa-generator @@ -7,10 +7,11 @@ usage() { cat <<'EOF' 用法: slsa-generator --artifact-type --artifact --artifact-id \ - --artifact-version --sign-key [--rekor-url ] \ + --artifact-version --sign-key [--measurement-name ] \ + [--rekor-url ] \ [--rekor-api-version <1|2>] \ [--provenance-store-protocol ] [--provenance-store-uri ] \ - [--provenance-store-artifact ] + [--provenance-store-artifact ] 参数: --artifact-type 制品类型: binary | model-dir | uki @@ -20,13 +21,14 @@ usage() { - uki: UKI 参考值 JSON 文件路径 --artifact-id 制品自定义ID --artifact-version 制品版本 - --sign-key 用于签名SLSA provenance的私钥路径(cosign生成) + --sign-key 用于签名 release manifest DSSE 的 PEM 私钥路径 + --measurement-name release manifest measurements 中的名称(默认: artifact-id) --rekor-url Rekor 服务地址(默认: https://rekor.sigstore.dev) --rekor-api-version Rekor API 主版本: 1 或 2(默认: 1) --rekor-v2-key-details Rekor v2 verifier keyDetails(默认: PKIX_ECDSA_P256_SHA_256) --provenance-store-protocol provenance存储协议(当前支持: oci) --provenance-store-uri provenance存储地址(如 oci://127.0.0.1:5000/ns/repo:tag) - --provenance-store-artifact 存储对象类型: bundle | provenance(默认: bundle) + --provenance-store-artifact 存储对象类型: bundle | payload | dsse(默认: bundle) EOF } @@ -182,16 +184,102 @@ EOF --data-binary @"$manifest_file" >/dev/null } +json_canonicalize() { + local input="$1" + local output="$2" + python3 - "$input" "$output" <<'PY' +import json +import sys +with open(sys.argv[1], "r", encoding="utf-8") as f: + obj = json.load(f) +with open(sys.argv[2], "w", encoding="utf-8") as f: + f.write(json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)) +PY +} + +dsse_pae_file() { + local payload_type="$1" + local payload_file="$2" + local pae_file="$3" + python3 - "$payload_type" "$payload_file" "$pae_file" <<'PY' +import sys +payload_type = sys.argv[1].encode("utf-8") +with open(sys.argv[2], "rb") as f: + payload = f.read() +pae = b"DSSEv1 %d " % len(payload_type) + payload_type + b" %d " % len(payload) + payload +with open(sys.argv[3], "wb") as f: + f.write(pae) +PY +} + +sign_dsse_envelope() { + local payload_type="$1" + local payload_file="$2" + local sign_key="$3" + local envelope_file="$4" + local pae_file="$out_dir/release-pae.bin" + local sig_file="$out_dir/release.sig" + local payload_b64 sig_b64 + + dsse_pae_file "$payload_type" "$payload_file" "$pae_file" + openssl dgst -sha256 -sign "$sign_key" -out "$sig_file" "$pae_file" + payload_b64=$(base64 -w0 "$payload_file") + sig_b64=$(base64 -w0 "$sig_file") + + jq -n \ + --arg payload "$payload_b64" \ + --arg payloadType "$payload_type" \ + --arg sig "$sig_b64" \ + '{payload: $payload, payloadType: $payloadType, signatures: [{sig: $sig}]}' > "$envelope_file" +} + +ensure_public_key() { + local sign_key="$1" + local pubkey="$2" + if [ -f "${sign_key}.pub" ]; then + cp -- "${sign_key}.pub" "$pubkey" + elif [ -f "${sign_key%.*}.pub" ]; then + cp -- "${sign_key%.*}.pub" "$pubkey" + else + openssl pkey -in "$sign_key" -pubout -out "$pubkey" + fi +} + upload_rekor_v1() { local rekor_url="$1" local envelope="$2" local pubkey="$3" local output_file="$4" - rekor-cli upload \ - --rekor_server "$rekor_url" \ - --artifact "$envelope" \ - --public-key "$pubkey" \ - --type intoto | tee "$output_file" + local req_file="$5" + + local envelope_json pubkey_b64 + envelope_json=$(jq -c . "$envelope") + pubkey_b64=$(base64 -w0 "$pubkey") + jq -n \ + --arg envelope "$envelope_json" \ + --arg verifier "$pubkey_b64" \ + '{ + kind: "dsse", + apiVersion: "0.0.1", + spec: { + proposedContent: { + envelope: $envelope, + verifiers: [$verifier] + } + } + }' > "$req_file" + + local base="${rekor_url%/}" + local code + code=$(curl -sS -o "$output_file" -w '%{http_code}' \ + -X POST "${base}/api/v1/log/entries" \ + -H 'Content-Type: application/json' \ + --data-binary @"$req_file") + if [[ "$code" -lt 200 || "$code" -ge 300 ]]; then + echo "上传 Rekor v1 DSSE 失败,HTTP $code,响应:" >&2 + cat "$output_file" >&2 || true + return 1 + fi } upload_rekor_v2() { @@ -252,6 +340,7 @@ artifact_path="" artifact_id="" artifact_version="" sign_key="" +measurement_name="" rekor_url="https://rekor.sigstore.dev" rekor_api_version="1" rekor_v2_key_details="PKIX_ECDSA_P256_SHA_256" @@ -281,6 +370,10 @@ while [ $# -gt 0 ]; do sign_key="${2:-}" shift 2 ;; + --measurement-name) + measurement_name="${2:-}" + shift 2 + ;; --rekor-url) rekor_url="${2:-}" shift 2 @@ -323,10 +416,11 @@ if [ -z "$artifact_type" ] || [ -z "$artifact_path" ] || [ -z "$artifact_id" ] | fi require_cmd sha256sum -require_cmd cosign require_cmd jq require_cmd curl require_cmd openssl +require_cmd python3 +require_cmd base64 if [ "$artifact_type" = "model-dir" ]; then require_cmd cryptpilot-verity @@ -350,10 +444,6 @@ if [ -z "$artifact_id" ] || [ -z "$artifact_version" ]; then exit 1 fi -require_cmd sha256sum -require_cmd cosign -require_cmd rekor-cli - artifact_digest_alg="sha256" attest_blob_path="$artifact_path" @@ -393,90 +483,39 @@ case "$artifact_type" in ;; esac -index_hash=$(printf '%s%s' "$artifact_id" "$artifact_version" | sha256sum | awk '{print $1}') - -escaped_artifact_id=$(json_escape "$artifact_id") -escaped_artifact_version=$(json_escape "$artifact_version") -escaped_artifact_type=$(json_escape "$artifact_type") - timestamp=$(date +%Y%m%d%H%M%S) safe_id=${artifact_id//[^a-zA-Z0-9._-]/_} out_dir="./slsa-output-${safe_id}-${timestamp}" mkdir -p "$out_dir" -statement_file="$out_dir/statement.json" -envelope_file="$out_dir/statement.dsse.json" -attestation_file="$out_dir/statement.attestation.json" - -if [ "$artifact_type" = "uki" ]; then - attest_blob_path="$out_dir/uki-artifact.json" - cp -- "$artifact_path" "$attest_blob_path" -fi - -bundle_jsonl_file="$out_dir/statement.intoto.jsonl" -rekor_v1_output_file="$out_dir/rekor-v1-upload.txt" +payload_type="application/vnd.trustee.rv.release+json" +payload_pretty_file="$out_dir/release_payload.pretty.json" +payload_file="$out_dir/release_payload.json" +envelope_file="$out_dir/release.dsse.json" +pub_key="$out_dir/release.pub" +rekor_v1_output_file="$out_dir/rekor-v1-entry.json" +rekor_v1_req_file="$out_dir/rekor-v1-request.json" rekor_v2_output_file="$out_dir/rekor-v2-entry.json" rekor_v2_req_file="$out_dir/rekor-v2-request.json" -trustee_bundle_file="$out_dir/provenance.trustee-bundle.json" - -cat >"$statement_file" < "$bundle_jsonl_file" - -pub_key="" -if [ -f "${sign_key}.pub" ]; then - pub_key="${sign_key}.pub" -elif [ -f "${sign_key%.*}.pub" ]; then - pub_key="${sign_key%.*}.pub" -else - echo "未找到公钥文件,期望: ${sign_key}.pub 或 ${sign_key%.*}.pub" >&2 - exit 1 +if [ -z "$measurement_name" ]; then + measurement_name="$artifact_id" fi +jq -n \ + --arg name "$measurement_name" \ + --arg algorithm "$artifact_digest_alg" \ + --arg value "$artifact_hash" \ + '{schemaVersion: 1, measurements: {($name): {algorithm: $algorithm, value: $value}}}' \ + > "$payload_pretty_file" +json_canonicalize "$payload_pretty_file" "$payload_file" + +ensure_public_key "$sign_key" "$pub_key" +sign_dsse_envelope "$payload_type" "$payload_file" "$sign_key" "$envelope_file" + if [ "$rekor_api_version" = "1" ]; then - require_cmd rekor-cli - upload_rekor_v1 "$rekor_url" "$envelope_file" "$pub_key" "$rekor_v1_output_file" + upload_rekor_v1 "$rekor_url" "$envelope_file" "$pub_key" "$rekor_v1_output_file" "$rekor_v1_req_file" else upload_rekor_v2 "$rekor_url" "$envelope_file" "$pub_key" "$rekor_v2_key_details" "$rekor_v2_output_file" "$rekor_v2_req_file" fi @@ -484,15 +523,16 @@ fi if [ "$provenance_store_artifact" = "bundle" ]; then if [ -f "$rekor_v2_output_file" ]; then jq -n \ - --argfile sourceBundle "$bundle_jsonl_file" \ + --argfile releasePayload "$payload_file" \ --argfile dsse "$envelope_file" \ --argfile rekor "$rekor_v2_output_file" \ - '{sourceBundle: $sourceBundle, dsseEnvelope: $dsse, rekorEntryV2: $rekor}' > "$trustee_bundle_file" + '{releasePayload: $releasePayload, dsseEnvelope: $dsse, rekorEntryV2: $rekor}' > "$trustee_bundle_file" else jq -n \ - --argfile sourceBundle "$bundle_jsonl_file" \ + --argfile releasePayload "$payload_file" \ --argfile dsse "$envelope_file" \ - '{sourceBundle: $sourceBundle, dsseEnvelope: $dsse}' > "$trustee_bundle_file" + --argfile rekor "$rekor_v1_output_file" \ + '{releasePayload: $releasePayload, dsseEnvelope: $dsse, rekorEntryV1: $rekor}' > "$trustee_bundle_file" fi fi @@ -501,13 +541,16 @@ if [ -n "$provenance_store_protocol" ]; then oci) case "$provenance_store_artifact" in bundle) - upload_to_oci_storage "$provenance_store_uri" "$trustee_bundle_file" "application/vnd.in-toto.bundle+json" "application/vnd.in-toto.bundle" + upload_to_oci_storage "$provenance_store_uri" "$trustee_bundle_file" "application/vnd.trustee.rv.release.bundle+json" "application/vnd.trustee.rv.release.bundle" + ;; + payload) + upload_to_oci_storage "$provenance_store_uri" "$payload_file" "application/vnd.trustee.rv.release+json" "application/vnd.trustee.rv.release" ;; - provenance) - upload_to_oci_storage "$provenance_store_uri" "$statement_file" "application/vnd.in-toto.provenance+json" "application/vnd.in-toto.provenance" + dsse) + upload_to_oci_storage "$provenance_store_uri" "$envelope_file" "application/vnd.dsse.envelope.v1+json" "application/vnd.dsse.envelope" ;; *) - echo "不支持的 --provenance-store-artifact: $provenance_store_artifact(仅支持 bundle|provenance)" >&2 + echo "不支持的 --provenance-store-artifact: $provenance_store_artifact(仅支持 bundle|payload|dsse)" >&2 exit 1 ;; esac @@ -519,5 +562,5 @@ if [ -n "$provenance_store_protocol" ]; then esac fi -echo "完成: SLSA provenance生成并上传Rekor" +echo "完成: RV release manifest 生成并上传 Rekor" echo "输出目录: $out_dir" diff --git a/trustee-gateway/trustee_gateway_api.md b/trustee-gateway/trustee_gateway_api.md index 8dc6212..696befb 100644 --- a/trustee-gateway/trustee_gateway_api.md +++ b/trustee-gateway/trustee_gateway_api.md @@ -1270,13 +1270,15 @@ EOF * **端点:** `POST /api/rvps/set_reference_value_list` -* **说明:** 通过 gRPC 向后端 RVPS 服务批量设置参考值。RVPS 会遍历 `rv_list` 中的每一项: - 1. 若未提供 `provenance_source`,计算 `SHA256($artifact-id || $artifact-version)` 作为索引并走 Rekor v1 兼容查询路径。 - 2. 若提供 `provenance_source`,按 `provenance_source.protocol` 拉取 provenance/bundle 元数据(当前支持 OCI)。 - 3. 解析 SLSA in-toto statement,提取制品哈希摘要值。 - 4. 确定参考值名称:若该项提供可选字段 `rv_name`,则使用该字符串(去首尾空白后非空);否则默认 `measurement.$type.$artifact-id`。 +* **说明:** 通过 gRPC 向后端 RVPS 服务批量设置参考值。新格式使用 `provenance_info.type = "rv-release-manifest"`,RVPS 会遍历 `rv_list` 中的每一项: + 1. 通过 `provenance_source` 拉取 release manifest bundle/DSSE/payload 元数据(当前支持 OCI,也支持本地测试用 `file`)。 + 2. 解析 `application/vnd.trustee.rv.release+json` release manifest,并校验 bundle 内 Rekor entry 的 payload hash。 + 3. 从 `measurements[$id]` 提取制品哈希摘要值。 + 4. 确定参考值名称:若该项提供可选字段 `rv_name`,则使用该字符串(去首尾空白后非空);否则新格式默认使用 `$id`。 5. 若该名称已存在且新旧参考值不同,根据 `operation_type` 进行追加或覆盖。 + 历史 `slsa-intoto-statements` 路径仍保留兼容:未提供 `provenance_source` 时仍会计算 `SHA256($artifact-id || $artifact-version)` 并走 Rekor v1 索引查询。 + * **调用方法:** ```shell @@ -1284,11 +1286,11 @@ cat << EOF > rvps-set-list.json { "rv_list": [ { - "id": "artifact-id", + "id": "cvm_container_proxy", "version": "artifact-version", - "type": "model", + "type": "container", "provenance_info": { - "type": "slsa-intoto-statements", + "type": "rv-release-manifest", "rekor_url": "https://log2025-1.rekor.sigstore.dev", "rekor_api_version": 2 }, @@ -1320,11 +1322,11 @@ curl -k -X POST http://:/api/rvps/set_reference_value_list \ { "rv_list": [ { - "id": "artifact-id", + "id": "cvm_container_proxy", "version": "artifact-version", - "type": "model", + "type": "container", "provenance_info": { - "type": "slsa-intoto-statements", + "type": "rv-release-manifest", "rekor_url": "https://log2025-1.rekor.sigstore.dev", "rekor_api_version": 2 }, @@ -1343,16 +1345,16 @@ curl -k -X POST http://:/api/rvps/set_reference_value_list \ * **字段说明:** * `rv_list`: 参考值列表数组。 - * `id`: 制品标识。 + * `id`: 新格式下为 release manifest 中的 measurement 名称,例如 `cvm_uki`、`cvm_firmware`、`host_uki` 或 `cvm_container_proxy`。 * `version`: 制品版本。 - * `type`: 制品类型;在未指定 `rv_name` 时用于生成默认参考值名称 `measurement.$type.$artifact-id`(例如 `uki` → `measurement.uki.$artifact-id`)。若指定了 `rv_name`,`type` 仍须填写且非空(与 Rekor 查询、`id`/`version` 语义一致),但不再参与名称拼接。 - * `rv_name`(可选): 显式指定写入 RVPS 的参考值名称;不得为空或仅空白。省略时行为与原先一致,使用 `measurement.$type.$artifact-id`。 - * `provenance_info.type`: 目前仅支持 `slsa-intoto-statements`。 + * `type`: 制品类型;新格式下仍须填写且非空,但默认参考值名称使用 `id`。历史 SLSA 兼容路径未指定 `rv_name` 时仍使用 `measurement.$type.$artifact-id`。 + * `rv_name`(可选): 显式指定写入 RVPS 的参考值名称;不得为空或仅空白。 + * `provenance_info.type`: 推荐 `rv-release-manifest`;历史兼容值为 `slsa-intoto-statements`。 * `provenance_info.rekor_url`: Rekor 透明日志地址。 * `provenance_info.rekor_api_version`: Rekor API 大版本(`1`/`2`,可选)。 - * `provenance_source.protocol`: provenance/bundle 获取协议,当前支持 `oci`。 - * `provenance_source.uri`: provenance/bundle 地址,如 `oci:///:`。 - * `provenance_source.artifact`: 拉取对象类型,建议 `bundle`(可选 `provenance`)。 + * `provenance_source.protocol`: release manifest bundle 获取协议,当前支持 `oci`;本地测试可用 `file`。 + * `provenance_source.uri`: release manifest bundle 地址,如 `oci:///:`。 + * `provenance_source.artifact`: 拉取对象类型,建议 `bundle`。 * `operation_type`: `add` 或 `refresh`。当名称已存在且新旧参考值不同: * `add`: 将新参考值追加到该名称的参考值数组中。 * `refresh`: 清空旧参考值,仅保留最新参考值。 From d581d7c6150c7d8eefc60164ec92415b08818bf5 Mon Sep 17 00:00:00 2001 From: Jiale Zhang Date: Fri, 8 May 2026 13:50:31 +0800 Subject: [PATCH 2/3] Rename RV release manifest tool Signed-off-by: Jiale Zhang --- docs/rekor.md | 17 +++++++++-------- rpm/trustee.spec | 2 +- tools/slsa/README.md | 18 +++++++++--------- tools/slsa/parse_uki_digest.py | 2 +- tools/slsa/{slsa-generator => rv-release-tool} | 8 ++++---- 5 files changed, 24 insertions(+), 23 deletions(-) rename tools/slsa/{slsa-generator => rv-release-tool} (97%) diff --git a/docs/rekor.md b/docs/rekor.md index 41f8d1b..79c49cc 100644 --- a/docs/rekor.md +++ b/docs/rekor.md @@ -57,7 +57,7 @@ sequenceDiagram participant RC as RekorClient participant RK as Rekor - U->>RV: 提交 rv_list(id=cvm_uki/cvm_container_xxx, provenance_source) + U->>RV: 提交 rv_list(id=, provenance_source) RV->>RV: 拉取 release manifest bundle RV->>RV: 解码 DSSE payload RV->>RV: 校验 payload hash 与 Rekor entry 一致 @@ -68,15 +68,16 @@ sequenceDiagram - 新格式使用 `provenance_info.type = "rv-release-manifest"`。 - release manifest 必须包含 `schemaVersion=1` 与非空 `measurements`。 -- `cvm_uki`、`cvm_firmware`、`host_uki` 必须使用 `sha384`;`cvm_container_*` 必须使用 `sha256`。 -- 从 `measurements[id]` 抽取 digest 并写入 RVPS;默认参考值名就是 `id`,如 `cvm_uki`。 +- measurement 名称允许完全自定义;`rv_list[].id` 只需与 release manifest `measurements` 中的键一致。 +- measurement 的 `algorithm` 支持 `sha256` 或 `sha384`,`value` 必须是对应算法的小写 hex digest。 +- 从 `measurements[id]` 抽取 digest 并写入 RVPS;默认参考值名就是 `id`,也可通过 `rv_name` 覆盖。 - 参考值支持去重与合并更新,避免重复覆盖。 - 默认设置过期时间(当前实现约 12 个月)。 - `set_reference_value_list` 的 `rv_list` 项支持可选 `rv_name`:若设置则以其为 RVPS 参考值名称。 ### 3.3 可选的强化校验 -RVPS 的 SLSA extractor 支持配置外部 `slsa-verifier`(通过环境变量)进行更严格校验(如 Rekor URL、builder identity、OIDC issuer)。 +历史 SLSA 兼容路径中的 extractor 仍支持配置外部 `slsa-verifier`(通过环境变量)进行更严格校验(如 Rekor URL、builder identity、OIDC issuer);RV release manifest 新路径的核心校验是 DSSE payload hash 与 Rekor entry 一致。 --- @@ -125,7 +126,7 @@ curl -k -X POST http://:/api/rvps/set_reference_value_list \ ```bash cd trustee/tools/slsa -./slsa-generator \ +./rv-release-tool \ --artifact-type binary \ --artifact /path/to/artifact \ --artifact-id app-binary \ @@ -148,9 +149,9 @@ cd trustee/tools/slsa - 上传到 Rekor v1(`kind=dsse`); - 上传到 Rekor v2(`/api/v2/log/entries`,`dsseRequestV002`); -- 把 provenance 元数据上传到指定存储地址(首期支持 OCI)。 +- 把 release manifest metadata 上传到指定存储地址(首期支持 OCI)。 -> 说明:CI 发布到 GitHub Release 的 `*.release-manifest.bundle.json` 与 `slsa-generator` 的 `release-manifest.trustee-bundle.json` 已统一为同一 schema(`releasePayload + dsseEnvelope + rekorEntryV1/rekorEntryV2`)。 +> 说明:CI 发布到 GitHub Release 的 `*.release-manifest.bundle.json` 与 `rv-release-tool` 的 `release-manifest.trustee-bundle.json` 已统一为同一 schema(`releasePayload + dsseEnvelope + rekorEntryV1/rekorEntryV2`)。 ### 4.3 审计侧:使用脚本验证参考值与 Rekor v2 一致性 @@ -174,7 +175,7 @@ cd trustee/tools/slsa 脚本会在终端输出完整审计过程,并给出 PASS/FAIL 结果,覆盖: -1. 参考值与 statement 中 `subject(name=id).digest.sha256` 一致; +1. 参考值与 release manifest 中 `measurements[id]` 的 `algorithm/value` 一致; 2. DSSE payload 摘要与 Rekor v2 `canonicalizedBody.spec.dsseV002` 一致; 3. 校验 proof checkpoint 与 latest checkpoint 的签名(基于 Sigstore trusted root); 4. 通过 `logIndex + tile` 验证 entry 确实存在于透明日志; diff --git a/rpm/trustee.spec b/rpm/trustee.spec index 7e15815..b66e903 100644 --- a/rpm/trustee.spec +++ b/rpm/trustee.spec @@ -159,7 +159,7 @@ fi - feat: add set-reference-value-list CLI and optional rv_name for RVPS by @jialez0S * Thu Feb 26 2026 Jiale Zhang -1.8.2-1 -- Impl slsa-generator script for eas artifacts +- Impl rv-release-tool script for eas artifacts - feat(rvps/gateway): add set_reference_value_list API * Tue Feb 3 2026 Jiale Zhang -1.8.1-1 diff --git a/tools/slsa/README.md b/tools/slsa/README.md index 9e401f5..9236f30 100644 --- a/tools/slsa/README.md +++ b/tools/slsa/README.md @@ -1,6 +1,6 @@ # RV Release Manifest 生成与上链工具 -本目录包含脚本 `slsa-generator`,用于为制品生成 JCS 规范化的 `application/vnd.trustee.rv.release+json` RV release manifest,封装为 DSSE 后上传到 Rekor(v1/v2),并可将 release manifest bundle 上传到指定存储地址(首期支持 OCI)。 +本目录包含脚本 `rv-release-tool`,用于为制品生成 JCS 规范化的 `application/vnd.trustee.rv.release+json` RV release manifest,封装为 DSSE 后上传到 Rekor(v1/v2),并可将 release manifest bundle 上传到指定存储地址(首期支持 OCI)。 ## 依赖 @@ -21,7 +21,7 @@ 进入 `tools/slsa` 目录后运行: ``` -./slsa-generator --artifact-type --artifact --artifact-id \ +./rv-release-tool --artifact-type --artifact --artifact-id \ --artifact-version --sign-key [--measurement-name ] [更多可选参数] ``` @@ -39,14 +39,14 @@ - `--rekor-url`: Rekor 地址(默认 `https://rekor.sigstore.dev`) - `--rekor-api-version`: Rekor API 主版本,`1` 或 `2`(默认 `1`) - `--rekor-v2-key-details`: Rekor v2 verifier key details(默认 `PKIX_ECDSA_P256_SHA_256`) -- `--provenance-store-protocol`: provenance 存储协议(当前支持 `oci`) -- `--provenance-store-uri`: provenance 存储地址(如 `oci://127.0.0.1:5000/ns/repo:tag`) +- `--provenance-store-protocol`: release manifest metadata 存储协议(当前支持 `oci`) +- `--provenance-store-uri`: release manifest metadata 存储地址(如 `oci://127.0.0.1:5000/ns/repo:tag`) - `--provenance-store-artifact`: 上传到存储的对象类型(`bundle`、`payload` 或 `dsse`,默认 `bundle`) 运行完成后会在当前目录生成输出目录,例如: ``` -./slsa-output--/ +./rv-release-output--/ ├── release_payload.json ├── release.dsse.json ├── rekor-v1-entry.json / rekor-v2-entry.json @@ -72,9 +72,9 @@ openssl pkey -in rv-release.key -pubout -out rv-release.pub ## 示例 ``` -./slsa-generator --artifact-type binary --artifact /path/to/app.bin \ +./rv-release-tool --artifact-type binary --artifact /path/to/app.bin \ --artifact-id app-binary --artifact-version 1.0.0 \ - --measurement-name cvm_container_proxy \ + --measurement-name my_custom_reference \ --sign-key /path/to/rv-release.key \ --rekor-url https://log2025-1.rekor.sigstore.dev --rekor-api-version 2 \ --provenance-store-protocol oci \ @@ -83,7 +83,7 @@ openssl pkey -in rv-release.key -pubout -out rv-release.pub ``` ``` -./slsa-generator --artifact-type model-dir --artifact /path/to/model \ +./rv-release-tool --artifact-type model-dir --artifact /path/to/model \ --artifact-id modelA --artifact-version 2024-02-01 --sign-key /path/to/rv-release.key ``` @@ -98,7 +98,7 @@ UKI 示例(`--artifact` 指向 JSON 文件): ``` ```bash -./slsa-generator --artifact-type uki --artifact /path/to/uki.json \ +./rv-release-tool --artifact-type uki --artifact /path/to/uki.json \ --artifact-id uki-image --artifact-version 1.0.0 --sign-key /path/to/rv-release.key ``` diff --git a/tools/slsa/parse_uki_digest.py b/tools/slsa/parse_uki_digest.py index b0811d1..c6fd507 100755 --- a/tools/slsa/parse_uki_digest.py +++ b/tools/slsa/parse_uki_digest.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -"""Parse UKI reference-value JSON and print " " for slsa-generator. +"""Parse UKI reference-value JSON and print " " for rv-release-tool. Expected file content: a single JSON object with one key ``measurement.uki.`` and a one-element array of hex digest string. diff --git a/tools/slsa/slsa-generator b/tools/slsa/rv-release-tool similarity index 97% rename from tools/slsa/slsa-generator rename to tools/slsa/rv-release-tool index 05d3036..83315b1 100755 --- a/tools/slsa/slsa-generator +++ b/tools/slsa/rv-release-tool @@ -6,7 +6,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" usage() { cat <<'EOF' 用法: - slsa-generator --artifact-type --artifact --artifact-id \ + rv-release-tool --artifact-type --artifact --artifact-id \ --artifact-version --sign-key [--measurement-name ] \ [--rekor-url ] \ [--rekor-api-version <1|2>] \ @@ -26,8 +26,8 @@ usage() { --rekor-url Rekor 服务地址(默认: https://rekor.sigstore.dev) --rekor-api-version Rekor API 主版本: 1 或 2(默认: 1) --rekor-v2-key-details Rekor v2 verifier keyDetails(默认: PKIX_ECDSA_P256_SHA_256) - --provenance-store-protocol provenance存储协议(当前支持: oci) - --provenance-store-uri provenance存储地址(如 oci://127.0.0.1:5000/ns/repo:tag) + --provenance-store-protocol release manifest metadata 存储协议(当前支持: oci) + --provenance-store-uri release manifest metadata 存储地址(如 oci://127.0.0.1:5000/ns/repo:tag) --provenance-store-artifact 存储对象类型: bundle | payload | dsse(默认: bundle) EOF } @@ -485,7 +485,7 @@ esac timestamp=$(date +%Y%m%d%H%M%S) safe_id=${artifact_id//[^a-zA-Z0-9._-]/_} -out_dir="./slsa-output-${safe_id}-${timestamp}" +out_dir="./rv-release-output-${safe_id}-${timestamp}" mkdir -p "$out_dir" payload_type="application/vnd.trustee.rv.release+json" From 8f0bc69bb4910e5bc5a592835ecf8468bc3e2ff7 Mon Sep 17 00:00:00 2001 From: Jiale Zhang Date: Fri, 8 May 2026 14:32:09 +0800 Subject: [PATCH 3/3] Keep SLSA provenance in release workflow Signed-off-by: Jiale Zhang --- .github/workflows/release-rpm.yml | 3 ++- docs/rekor.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-rpm.yml b/.github/workflows/release-rpm.yml index 0070161..8edb173 100644 --- a/.github/workflows/release-rpm.yml +++ b/.github/workflows/release-rpm.yml @@ -158,7 +158,8 @@ jobs: input_artifact: build-input-${{ matrix.os_flavor.id }} output_artifact: build-output-${{ matrix.os_flavor.id }} release_slsa_provenance: true - provenance_name: "${{ matrix.os_flavor.id == 'alinux3' && needs.create-build-artifacts.outputs.rpm_name_al8 || needs.create-build-artifacts.outputs.rpm_name_an23 }}.rv-release-manifest.bundle.json" + provenance_name: "${{ matrix.os_flavor.id == 'alinux3' && needs.create-build-artifacts.outputs.rpm_name_al8 || needs.create-build-artifacts.outputs.rpm_name_an23 }}.intoto.jsonl" + rv_release_manifest_name: "${{ matrix.os_flavor.id == 'alinux3' && needs.create-build-artifacts.outputs.rpm_name_al8 || needs.create-build-artifacts.outputs.rpm_name_an23 }}.rv-release-manifest.bundle.json" rpm_detail_provenance: true upload_provenance_to_rekor: true release_tag_name: ${{ github.event.release.tag_name || inputs.tag_name }} diff --git a/docs/rekor.md b/docs/rekor.md index 79c49cc..c611855 100644 --- a/docs/rekor.md +++ b/docs/rekor.md @@ -152,6 +152,7 @@ cd trustee/tools/slsa - 把 release manifest metadata 上传到指定存储地址(首期支持 OCI)。 > 说明:CI 发布到 GitHub Release 的 `*.release-manifest.bundle.json` 与 `rv-release-tool` 的 `release-manifest.trustee-bundle.json` 已统一为同一 schema(`releasePayload + dsseEnvelope + rekorEntryV1/rekorEntryV2`)。 +> CI release workflow 仍会保留原有 `*.intoto.jsonl` SLSA provenance 作为 Release 资产;新增的 RV release manifest bundle 是额外资产。Rekor 上传对象改为 RV release manifest DSSE,不再上传 SLSA provenance。 ### 4.3 审计侧:使用脚本验证参考值与 Rekor v2 一致性