1use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _};
2use chrono::{DateTime, Utc};
3use ed25519_dalek::{Signature, Verifier as _, VerifyingKey};
4use serde::{Deserialize, Serialize};
5use sha2::{Digest as _, Sha256};
6
7pub const RELEASE_SUPPLY_CHAIN_SCHEMA_VERSION: u32 = 1;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "kebab-case")]
11pub enum PackageTrustTier {
12 Official,
13 VerifiedWrapper,
14 Community,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case")]
19pub enum SbomFormat {
20 CycloneDxJson,
21 SpdxJson,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "camelCase", deny_unknown_fields)]
26pub struct SbomReference {
27 pub format: SbomFormat,
28 pub digest: String,
29 pub media_type: String,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34pub struct WrapperProvenance {
35 pub upstream_repository: String,
36 pub upstream_version: String,
37 pub upstream_digest: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase", deny_unknown_fields)]
42pub struct BuildProvenance {
43 pub source_repository: String,
44 pub source_commit: String,
45 pub builder_id: String,
46 pub workflow_ref: Option<String>,
47 pub invocation_id: String,
48 pub inventory_digest: String,
49 pub release_manifest_digest: String,
50 pub sbom: Option<SbomReference>,
51 pub license: Option<String>,
52 pub wrapper: Option<WrapperProvenance>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57pub struct RegistryReleaseSupplyChain {
58 pub schema_version: u32,
59 pub publication_id: String,
60 pub publisher_id: String,
61 pub publisher_name: String,
62 pub public_key: String,
63 pub key_fingerprint: String,
64 pub payload_digest: String,
65 pub sequence: u64,
66 pub signed_at: DateTime<Utc>,
67 pub trust_tier: PackageTrustTier,
68 pub provenance: BuildProvenance,
69 pub signature: String,
70}
71
72impl RegistryReleaseSupplyChain {
73 pub fn verify(&self, package_id: &str) -> Result<(), String> {
74 self.validate()?;
75 let key_bytes = STANDARD_NO_PAD
76 .decode(&self.public_key)
77 .map_err(|_| "trusted publisher public key is invalid".to_string())?;
78 let key_bytes: [u8; 32] = key_bytes
79 .try_into()
80 .map_err(|_| "trusted publisher public key is invalid".to_string())?;
81 let key = VerifyingKey::from_bytes(&key_bytes)
82 .map_err(|_| "trusted publisher public key is invalid".to_string())?;
83 let fingerprint = format!("sha256:{:x}", Sha256::digest(key.as_bytes()));
84 if fingerprint != self.key_fingerprint {
85 return Err("trusted publisher key fingerprint differs from its key".to_string());
86 }
87 let signature_bytes = STANDARD_NO_PAD
88 .decode(&self.signature)
89 .map_err(|_| "publication signature is invalid".to_string())?;
90 let signature = Signature::from_slice(&signature_bytes)
91 .map_err(|_| "publication signature is invalid".to_string())?;
92 key.verify(&self.canonical_signing_bytes(package_id)?, &signature)
93 .map_err(|_| "publication signature is invalid".to_string())
94 }
95
96 pub fn canonical_signing_bytes(&self, package_id: &str) -> Result<Vec<u8>, String> {
97 #[derive(Serialize)]
98 #[serde(rename_all = "camelCase")]
99 struct Canonical<'a> {
100 format: &'static str,
101 publication_id: &'a str,
102 package_id: &'a str,
103 publisher_id: &'a str,
104 payload_digest: &'a str,
105 sequence: u64,
106 issued_at_micros: i64,
107 provenance: &'a BuildProvenance,
108 }
109 serde_json::to_vec(&Canonical {
110 format: "runmat-publication-attestation-v1",
111 publication_id: &self.publication_id,
112 package_id,
113 publisher_id: &self.publisher_id,
114 payload_digest: &self.payload_digest,
115 sequence: self.sequence,
116 issued_at_micros: self.signed_at.timestamp_micros(),
117 provenance: &self.provenance,
118 })
119 .map_err(|error| error.to_string())
120 }
121
122 fn validate(&self) -> Result<(), String> {
123 if self.schema_version != RELEASE_SUPPLY_CHAIN_SCHEMA_VERSION
124 || self.publication_id.is_empty()
125 || self.publisher_id.is_empty()
126 || self.publisher_name.trim().is_empty()
127 || self.publisher_name.len() > 128
128 || self.sequence == 0
129 || !valid_sha256(&self.key_fingerprint)
130 || !valid_sha256(&self.payload_digest)
131 || self.public_key.is_empty()
132 || self.public_key.len() > 256
133 || self.signature.is_empty()
134 || self.signature.len() > 256
135 || (matches!(self.trust_tier, PackageTrustTier::VerifiedWrapper)
136 && self.provenance.wrapper.is_none())
137 {
138 return Err("release supply-chain metadata is invalid".to_string());
139 }
140 self.provenance.validate()
141 }
142}
143
144impl BuildProvenance {
145 fn validate(&self) -> Result<(), String> {
146 if !valid_https_url(&self.source_repository)
147 || !matches!(self.source_commit.len(), 40 | 64)
148 || !self
149 .source_commit
150 .bytes()
151 .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
152 || self.builder_id.trim().is_empty()
153 || self.builder_id.len() > 512
154 || self
155 .workflow_ref
156 .as_deref()
157 .is_some_and(|value| value.trim().is_empty() || value.len() > 512)
158 || self.invocation_id.trim().is_empty()
159 || self.invocation_id.len() > 512
160 || !valid_sha256(&self.inventory_digest)
161 || !valid_sha256(&self.release_manifest_digest)
162 || self.sbom.as_ref().is_some_and(|value| {
163 !valid_sha256(&value.digest)
164 || value.media_type.trim().is_empty()
165 || value.media_type.len() > 128
166 })
167 || self
168 .license
169 .as_deref()
170 .is_some_and(|value| value.trim().is_empty() || value.len() > 256)
171 || self.wrapper.as_ref().is_some_and(|value| {
172 !valid_https_url(&value.upstream_repository)
173 || value.upstream_version.trim().is_empty()
174 || value.upstream_version.len() > 256
175 || !valid_sha256(&value.upstream_digest)
176 })
177 {
178 return Err("build provenance is invalid".to_string());
179 }
180 Ok(())
181 }
182}
183
184fn valid_https_url(value: &str) -> bool {
185 url::Url::parse(value).ok().is_some_and(|url| {
186 url.scheme() == "https"
187 && url.username().is_empty()
188 && url.password().is_none()
189 && url.query().is_none()
190 && url.fragment().is_none()
191 && value.len() <= 2048
192 })
193}
194
195fn valid_sha256(value: &str) -> bool {
196 value.strip_prefix("sha256:").is_some_and(|hex| {
197 hex.len() == 64
198 && hex
199 .bytes()
200 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
201 })
202}