service_artifact.rs (10139B)
1 use std::collections::BTreeMap; 2 3 use radroots_runtime_paths::ServiceId; 4 use serde::{Deserialize, Deserializer}; 5 6 use crate::service::ServiceTier1Target; 7 8 const OUTPUT_INVENTORY: [&str; 12] = [ 9 "LICENSE", 10 "SHA256SUMS", 11 "THIRD-PARTY-NOTICES.txt", 12 "artifact-manifest.v1.json", 13 "binary.tar.gz", 14 "config.example.toml", 15 "config.schema.json", 16 "provenance-input.v1.json", 17 "radroots.service.source-lock.v2.toml", 18 "sbom.cdx.json", 19 "service-source.tar.gz", 20 "systemd.service", 21 ]; 22 23 #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] 24 #[serde(rename_all = "snake_case")] 25 pub enum ServiceArtifactChannel { 26 Stable, 27 } 28 29 impl ServiceArtifactChannel { 30 #[must_use] 31 pub const fn as_str(self) -> &'static str { 32 match self { 33 Self::Stable => "stable", 34 } 35 } 36 } 37 38 /// A validated lowercase SHA-256 value. 39 #[derive(Clone, Copy, PartialEq, Eq)] 40 pub struct ServiceArtifactSha256([u8; 32]); 41 42 impl ServiceArtifactSha256 { 43 #[must_use] 44 pub const fn from_bytes(bytes: [u8; 32]) -> Self { 45 Self(bytes) 46 } 47 #[must_use] 48 pub const fn as_bytes(&self) -> &[u8; 32] { 49 &self.0 50 } 51 } 52 53 impl core::fmt::Debug for ServiceArtifactSha256 { 54 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 55 formatter.write_str("ServiceArtifactSha256(<redacted>)") 56 } 57 } 58 59 impl<'de> Deserialize<'de> for ServiceArtifactSha256 { 60 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> 61 where 62 D: Deserializer<'de>, 63 { 64 let value = String::deserialize(deserializer)?; 65 if value.len() != 64 66 || !value 67 .bytes() 68 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) 69 { 70 return Err(serde::de::Error::custom("invalid sha256")); 71 } 72 let mut bytes = [0_u8; 32]; 73 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { 74 bytes[index] = (hex_nibble(pair[0]) 75 .ok_or_else(|| serde::de::Error::custom("invalid sha256"))? 76 << 4) 77 | hex_nibble(pair[1]).ok_or_else(|| serde::de::Error::custom("invalid sha256"))?; 78 } 79 Ok(Self(bytes)) 80 } 81 } 82 83 fn hex_nibble(byte: u8) -> Option<u8> { 84 match byte { 85 b'0'..=b'9' => Some(byte - b'0'), 86 b'a'..=b'f' => Some(byte - b'a' + 10), 87 _ => None, 88 } 89 } 90 91 #[derive(Clone, Deserialize, PartialEq, Eq)] 92 #[serde(try_from = "HardenedServiceArtifactWire")] 93 pub struct HardenedServiceArtifact { 94 service_id: ServiceId, 95 service_revision: String, 96 release_contract: String, 97 release_contract_sha256: ServiceArtifactSha256, 98 source_lock_sha256: ServiceArtifactSha256, 99 package_name: String, 100 binary_name: String, 101 version: String, 102 channel: ServiceArtifactChannel, 103 binary_archive_name: String, 104 artifact_manifest_name: String, 105 checksums_name: String, 106 output_inventory: Vec<String>, 107 tier_1_targets: Vec<ServiceTier1Target>, 108 } 109 110 impl core::fmt::Debug for HardenedServiceArtifact { 111 fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 112 formatter.write_str("HardenedServiceArtifact(<redacted>)") 113 } 114 } 115 116 #[derive(Deserialize)] 117 #[serde(deny_unknown_fields)] 118 struct HardenedServiceArtifactWire { 119 service_id: ServiceId, 120 service_revision: String, 121 release_contract: String, 122 release_contract_sha256: ServiceArtifactSha256, 123 source_lock_sha256: ServiceArtifactSha256, 124 package_name: String, 125 binary_name: String, 126 version: String, 127 channel: ServiceArtifactChannel, 128 binary_archive_name: String, 129 artifact_manifest_name: String, 130 checksums_name: String, 131 output_inventory: Vec<String>, 132 tier_1_targets: Vec<ServiceTier1Target>, 133 } 134 135 impl HardenedServiceArtifact { 136 #[must_use] 137 pub fn service_id(&self) -> &ServiceId { 138 &self.service_id 139 } 140 #[must_use] 141 pub fn service_revision(&self) -> &str { 142 &self.service_revision 143 } 144 #[must_use] 145 pub fn release_contract(&self) -> &str { 146 &self.release_contract 147 } 148 #[must_use] 149 pub const fn release_contract_sha256(&self) -> ServiceArtifactSha256 { 150 self.release_contract_sha256 151 } 152 #[must_use] 153 pub const fn source_lock_sha256(&self) -> ServiceArtifactSha256 { 154 self.source_lock_sha256 155 } 156 #[must_use] 157 pub fn package_name(&self) -> &str { 158 &self.package_name 159 } 160 #[must_use] 161 pub fn binary_name(&self) -> &str { 162 &self.binary_name 163 } 164 #[must_use] 165 pub fn version(&self) -> &str { 166 &self.version 167 } 168 #[must_use] 169 pub const fn channel(&self) -> ServiceArtifactChannel { 170 self.channel 171 } 172 #[must_use] 173 pub fn binary_archive_name(&self) -> &str { 174 &self.binary_archive_name 175 } 176 #[must_use] 177 pub fn artifact_manifest_name(&self) -> &str { 178 &self.artifact_manifest_name 179 } 180 #[must_use] 181 pub fn checksums_name(&self) -> &str { 182 &self.checksums_name 183 } 184 #[must_use] 185 pub fn output_inventory(&self) -> &[String] { 186 &self.output_inventory 187 } 188 #[must_use] 189 pub fn tier_1_targets(&self) -> &[ServiceTier1Target] { 190 &self.tier_1_targets 191 } 192 193 fn has_exact_contract(&self) -> bool { 194 let (revision, release_contract, release_hash, source_lock_hash) = 195 match self.service_id.as_str() { 196 "myc" => ( 197 "77b381648ed1e586efb696888beb05b9215c69cf", 198 "contracts/services_hardening/native_release.v2.json", 199 "4b3ba5789fac6aa219e84e1e5c002cf8230b72f95fd6d95a6419d2fdf2915f83", 200 "f5ebb390a480830d51d502facc623bd1b10eda27b12dad9f3dbb6a1f1f949217", 201 ), 202 "rhi" => ( 203 "07aa6ea988da5372654bb3d1ee183ac099a77cae", 204 "contracts/services_hardening/native_release.v1.json", 205 "06a973176b4b8c11dad13000604576527df829dd0bbe2f501158662f75e70b94", 206 "3cc8bfac0d98730937754abae2ccfe20e40d0a9bbdefe02ebd94264c20f0d0ff", 207 ), 208 _ => return false, 209 }; 210 self.service_revision == revision 211 && self.release_contract == release_contract 212 && self.release_contract_sha256 == sha256_literal(release_hash) 213 && self.source_lock_sha256 == sha256_literal(source_lock_hash) 214 && self.package_name == self.service_id.as_str() 215 && self.binary_name == self.service_id.as_str() 216 && self.version == "0.1.0" 217 && self.channel == ServiceArtifactChannel::Stable 218 && self.binary_archive_name == "binary.tar.gz" 219 && self.artifact_manifest_name == "artifact-manifest.v1.json" 220 && self.checksums_name == "SHA256SUMS" 221 && self 222 .output_inventory 223 .iter() 224 .map(String::as_str) 225 .eq(OUTPUT_INVENTORY) 226 && self.tier_1_targets == ServiceTier1Target::ALL 227 } 228 } 229 230 fn sha256_literal(value: &str) -> ServiceArtifactSha256 { 231 let mut bytes = [0_u8; 32]; 232 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { 233 bytes[index] = (hex_nibble(pair[0]).expect("literal sha256") << 4) 234 | hex_nibble(pair[1]).expect("literal sha256"); 235 } 236 ServiceArtifactSha256(bytes) 237 } 238 239 impl TryFrom<HardenedServiceArtifactWire> for HardenedServiceArtifact { 240 type Error = &'static str; 241 242 fn try_from(wire: HardenedServiceArtifactWire) -> Result<Self, Self::Error> { 243 let artifact = Self { 244 service_id: wire.service_id, 245 service_revision: wire.service_revision, 246 release_contract: wire.release_contract, 247 release_contract_sha256: wire.release_contract_sha256, 248 source_lock_sha256: wire.source_lock_sha256, 249 package_name: wire.package_name, 250 binary_name: wire.binary_name, 251 version: wire.version, 252 channel: wire.channel, 253 binary_archive_name: wire.binary_archive_name, 254 artifact_manifest_name: wire.artifact_manifest_name, 255 checksums_name: wire.checksums_name, 256 output_inventory: wire.output_inventory, 257 tier_1_targets: wire.tier_1_targets, 258 }; 259 artifact 260 .has_exact_contract() 261 .then_some(artifact) 262 .ok_or("invalid hardened service artifact") 263 } 264 } 265 266 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] 267 #[serde(try_from = "BTreeMap<String, HardenedServiceArtifact>")] 268 pub struct HardenedServiceArtifacts(BTreeMap<String, HardenedServiceArtifact>); 269 270 impl HardenedServiceArtifacts { 271 #[must_use] 272 pub fn get(&self, service_id: &ServiceId) -> Option<&HardenedServiceArtifact> { 273 self.0.get(service_id.as_str()) 274 } 275 pub fn iter(&self) -> impl ExactSizeIterator<Item = (&str, &HardenedServiceArtifact)> { 276 self.0.iter().map(|(key, value)| (key.as_str(), value)) 277 } 278 #[must_use] 279 pub fn len(&self) -> usize { 280 self.0.len() 281 } 282 #[must_use] 283 pub fn is_empty(&self) -> bool { 284 self.0.is_empty() 285 } 286 } 287 288 impl TryFrom<BTreeMap<String, HardenedServiceArtifact>> for HardenedServiceArtifacts { 289 type Error = &'static str; 290 291 fn try_from(artifacts: BTreeMap<String, HardenedServiceArtifact>) -> Result<Self, Self::Error> { 292 if artifacts.len() != 2 { 293 return Err("service artifact inventory must contain exactly Myc and RHI"); 294 } 295 for service in ["myc", "rhi"] { 296 let artifact = artifacts 297 .get(service) 298 .ok_or("service artifact inventory is incomplete")?; 299 if artifact.service_id.as_str() != service || !artifact.has_exact_contract() { 300 return Err("service artifact inventory is invalid"); 301 } 302 } 303 if artifacts 304 .iter() 305 .any(|(key, artifact)| key != artifact.service_id.as_str()) 306 { 307 return Err("service artifact key mismatch"); 308 } 309 Ok(Self(artifacts)) 310 } 311 }