1use crate::{ArtifactDescriptor, UpdateError, UpdateResult};
2use ed25519_dalek::{Signature, Verifier, VerifyingKey};
3use std::collections::{BTreeMap, BTreeSet};
4#[cfg(feature = "allow-unsigned-local-artifacts")]
5use std::path::{Path, PathBuf};
6
7pub trait ArtifactAuthenticityVerifier: Send + Sync {
9 fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()>;
11}
12
13#[cfg(feature = "allow-unsigned-local-artifacts")]
19#[derive(Debug, Clone)]
20pub struct UnsignedLocalArtifactVerifier {
21 root: PathBuf,
22}
23
24#[cfg(feature = "allow-unsigned-local-artifacts")]
25impl UnsignedLocalArtifactVerifier {
26 pub fn new(root: impl AsRef<Path>) -> UpdateResult<Self> {
28 let root = canonical_private_root(root.as_ref())?;
29 Ok(Self { root })
30 }
31}
32
33#[cfg(feature = "allow-unsigned-local-artifacts")]
34impl ArtifactAuthenticityVerifier for UnsignedLocalArtifactVerifier {
35 fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()> {
36 let raw_path = artifact
37 .artifact_reference()
38 .strip_prefix("file:")
39 .ok_or_else(|| {
40 UpdateError::Authenticity(
41 "unsigned local artifacts require a file: reference".to_string(),
42 )
43 })?;
44 let path = Path::new(raw_path);
45 reject_symlink_components(path)?;
46 let canonical = std::fs::canonicalize(path)
47 .map_err(|error| UpdateError::Authenticity(error.to_string()))?;
48 if !canonical.starts_with(&self.root) {
49 return Err(UpdateError::Authenticity(
50 "unsigned artifact escapes its canonical local root".to_string(),
51 ));
52 }
53 validate_private_artifact(&canonical)
54 }
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct Ed25519ArtifactVerifier {
60 trust_roots: BTreeMap<String, TrustedSigningKey>,
61}
62
63#[derive(Debug, Clone)]
64struct TrustedSigningKey {
65 key: VerifyingKey,
66 status: SigningKeyStatus,
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum SigningKeyStatus {
72 Active,
74 Deprecated,
76 Revoked,
78}
79
80impl Ed25519ArtifactVerifier {
81 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn add_trust_root(
88 &mut self,
89 key_id: impl Into<String>,
90 public_key: [u8; 32],
91 ) -> UpdateResult<()> {
92 let key_id = key_id.into();
93 validate_key_id(&key_id)?;
94 let verifying_key = VerifyingKey::from_bytes(&public_key)
95 .map_err(|error| UpdateError::Authenticity(error.to_string()))?;
96 self.trust_roots.insert(
97 key_id,
98 TrustedSigningKey {
99 key: verifying_key,
100 status: SigningKeyStatus::Active,
101 },
102 );
103 Ok(())
104 }
105
106 pub fn add_trust_root_hex(
108 &mut self,
109 key_id: impl Into<String>,
110 public_key: &str,
111 ) -> UpdateResult<()> {
112 self.add_trust_root(key_id, decode_hex::<32>(public_key)?)
113 }
114
115 pub fn set_trust_root_status(
117 &mut self,
118 key_id: &str,
119 status: SigningKeyStatus,
120 ) -> UpdateResult<()> {
121 let key = self.trust_roots.get_mut(key_id).ok_or_else(|| {
122 UpdateError::Authenticity(format!("signing key `{key_id}` is not configured"))
123 })?;
124 key.status = status;
125 Ok(())
126 }
127
128 pub fn trust_root_count(&self) -> usize {
130 self.trust_roots.len()
131 }
132}
133
134impl ArtifactAuthenticityVerifier for Ed25519ArtifactVerifier {
135 fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()> {
136 let key_id = artifact.signing_key_id().ok_or_else(|| {
137 UpdateError::Authenticity("signed artifact metadata is required".to_string())
138 })?;
139 let signature = artifact.ed25519_signature().ok_or_else(|| {
140 UpdateError::Authenticity("signed artifact metadata is required".to_string())
141 })?;
142 let trusted_key = self.trust_roots.get(key_id).ok_or_else(|| {
143 UpdateError::Authenticity(format!("signing key `{key_id}` is not trusted"))
144 })?;
145 if trusted_key.status == SigningKeyStatus::Revoked {
146 return Err(UpdateError::Authenticity(format!(
147 "signing key `{key_id}` is revoked"
148 )));
149 }
150 let signature_bytes = decode_hex::<64>(signature)?;
151 let signature = Signature::from_bytes(&signature_bytes);
152 trusted_key
153 .key
154 .verify(&artifact_signing_payload(artifact), &signature)
155 .map_err(|_| UpdateError::Authenticity("signature is invalid".to_string()))
156 }
157}
158
159#[derive(Debug, Clone, Default)]
161pub struct ArtifactTrustPolicy {
162 allowed_channels: BTreeSet<String>,
163 allowed_origins: BTreeSet<String>,
164}
165
166impl ArtifactTrustPolicy {
167 pub fn new() -> Self {
169 Self::default()
170 }
171
172 pub fn allow_channel(mut self, channel: impl Into<String>) -> UpdateResult<Self> {
174 let channel = channel.into();
175 validate_policy_value("channel", &channel)?;
176 self.allowed_channels.insert(channel);
177 Ok(self)
178 }
179
180 pub fn allow_origin(mut self, origin: impl Into<String>) -> UpdateResult<Self> {
182 let origin = normalize_origin(&origin.into())?;
183 self.allowed_origins.insert(origin);
184 Ok(self)
185 }
186
187 pub fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()> {
189 if !self.allowed_channels.contains(artifact.channel()) {
190 return Err(UpdateError::Authenticity(format!(
191 "update channel `{}` is not allowed",
192 artifact.channel()
193 )));
194 }
195 let origin = artifact_origin(artifact.artifact_reference())?;
196 if !self.allowed_origins.contains(&origin) {
197 return Err(UpdateError::Authenticity(format!(
198 "artifact origin `{origin}` is not allowed"
199 )));
200 }
201 Ok(())
202 }
203}
204
205#[derive(Debug, Clone)]
207pub struct PolicyArtifactVerifier<V> {
208 policy: ArtifactTrustPolicy,
209 verifier: V,
210}
211
212impl<V> PolicyArtifactVerifier<V> {
213 pub fn new(policy: ArtifactTrustPolicy, verifier: V) -> Self {
215 Self { policy, verifier }
216 }
217}
218
219impl<V> ArtifactAuthenticityVerifier for PolicyArtifactVerifier<V>
220where
221 V: ArtifactAuthenticityVerifier,
222{
223 fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()> {
224 self.policy.verify(artifact)?;
225 self.verifier.verify(artifact)
226 }
227}
228
229pub fn artifact_signing_payload(artifact: &ArtifactDescriptor) -> Vec<u8> {
231 [
232 ("application_id", artifact.application_id().as_str()),
233 ("application_version", artifact.application_version()),
234 ("build_id", artifact.build_id().as_str()),
235 ("channel", artifact.channel()),
236 ("runtime_requirement", artifact.runtime_requirement()),
237 ("protocol_version", artifact.protocol_version()),
238 ("artifact_reference", artifact.artifact_reference()),
239 ("sha256", artifact.sha256()),
240 ("size_bytes", &artifact.size_bytes().to_string()),
241 ]
242 .into_iter()
243 .flat_map(|(name, value)| {
244 let mut field = Vec::with_capacity(name.len() + value.len() + 2);
245 field.extend_from_slice(name.as_bytes());
246 field.push(b'=');
247 field.extend_from_slice(value.as_bytes());
248 field.push(b'\n');
249 field
250 })
251 .collect()
252}
253
254fn validate_key_id(key_id: &str) -> UpdateResult<()> {
255 if key_id.trim().is_empty() || key_id.len() > 128 || key_id.chars().any(char::is_control) {
256 return Err(UpdateError::Authenticity(
257 "trust root key identity is invalid".to_string(),
258 ));
259 }
260 Ok(())
261}
262
263fn validate_policy_value(name: &str, value: &str) -> UpdateResult<()> {
264 if value.trim().is_empty() || value.len() > 2_048 || value.chars().any(char::is_control) {
265 return Err(UpdateError::Authenticity(format!(
266 "artifact policy {name} is invalid"
267 )));
268 }
269 Ok(())
270}
271
272fn artifact_origin(reference: &str) -> UpdateResult<String> {
273 let (scheme, rest) = reference
274 .split_once(':')
275 .ok_or_else(|| UpdateError::Authenticity("artifact reference has no scheme".to_string()))?;
276 match scheme {
277 "file" => Ok("file:".to_string()),
278 "https" => {
279 let authority = rest.strip_prefix("//").ok_or_else(|| {
280 UpdateError::Authenticity("HTTPS artifact reference has no authority".to_string())
281 })?;
282 let authority = authority.split('/').next().unwrap_or_default();
283 if authority.is_empty() || authority.contains('@') {
284 return Err(UpdateError::Authenticity(
285 "HTTPS artifact authority is invalid".to_string(),
286 ));
287 }
288 Ok(format!("https://{}", authority.to_ascii_lowercase()))
289 }
290 _ => Err(UpdateError::Authenticity(format!(
291 "artifact scheme `{scheme}` is not production-supported"
292 ))),
293 }
294}
295
296fn normalize_origin(origin: &str) -> UpdateResult<String> {
297 validate_policy_value("origin", origin)?;
298 if origin == "file:" {
299 return Ok(origin.to_string());
300 }
301 let normalized = artifact_origin(origin)?;
302 if normalized != origin.trim_end_matches('/').to_ascii_lowercase() {
303 return Err(UpdateError::Authenticity(
304 "allowed origin must contain only scheme and authority".to_string(),
305 ));
306 }
307 Ok(normalized)
308}
309
310fn decode_hex<const N: usize>(value: &str) -> UpdateResult<[u8; N]> {
311 if value.len() != N * 2 {
312 return Err(UpdateError::Authenticity(
313 "signature has an invalid length".to_string(),
314 ));
315 }
316 let mut decoded = [0_u8; N];
317 for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
318 let text = std::str::from_utf8(chunk)
319 .map_err(|_| UpdateError::Authenticity("signature is not hexadecimal".to_string()))?;
320 decoded[index] = u8::from_str_radix(text, 16)
321 .map_err(|_| UpdateError::Authenticity("signature is not hexadecimal".to_string()))?;
322 }
323 Ok(decoded)
324}
325
326#[cfg(feature = "allow-unsigned-local-artifacts")]
327fn canonical_private_root(root: &Path) -> UpdateResult<PathBuf> {
328 reject_symlink_components(root)?;
329 let canonical = std::fs::canonicalize(root)
330 .map_err(|error| UpdateError::Authenticity(error.to_string()))?;
331 let metadata = std::fs::symlink_metadata(&canonical)
332 .map_err(|error| UpdateError::Authenticity(error.to_string()))?;
333 if !metadata.is_dir() {
334 return Err(UpdateError::Authenticity(
335 "unsigned artifact root is not a directory".to_string(),
336 ));
337 }
338 validate_owner(&metadata)?;
339 Ok(canonical)
340}
341
342#[cfg(feature = "allow-unsigned-local-artifacts")]
343fn reject_symlink_components(path: &Path) -> UpdateResult<()> {
344 let mut current = PathBuf::new();
345 for component in path.components() {
346 current.push(component);
347 match std::fs::symlink_metadata(¤t) {
348 Ok(metadata) if metadata.file_type().is_symlink() => {
349 return Err(UpdateError::Authenticity(
350 "unsigned artifact path contains a symlink".to_string(),
351 ));
352 }
353 Ok(_) => {}
354 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
355 Err(error) => return Err(UpdateError::Authenticity(error.to_string())),
356 }
357 }
358 Ok(())
359}
360
361#[cfg(feature = "allow-unsigned-local-artifacts")]
362fn validate_private_artifact(path: &Path) -> UpdateResult<()> {
363 let metadata = std::fs::symlink_metadata(path)
364 .map_err(|error| UpdateError::Authenticity(error.to_string()))?;
365 if metadata.file_type().is_symlink() || !metadata.is_file() {
366 return Err(UpdateError::Authenticity(
367 "unsigned artifact is not a regular file".to_string(),
368 ));
369 }
370 validate_owner(&metadata)
371}
372
373#[cfg(all(feature = "allow-unsigned-local-artifacts", unix))]
374fn validate_owner(metadata: &std::fs::Metadata) -> UpdateResult<()> {
375 use std::os::unix::fs::{MetadataExt, PermissionsExt};
376 if metadata.uid() != unsafe { libc::geteuid() } || metadata.permissions().mode() & 0o077 != 0 {
377 return Err(UpdateError::Authenticity(
378 "unsigned artifact is not owner-controlled".to_string(),
379 ));
380 }
381 Ok(())
382}
383
384#[cfg(all(feature = "allow-unsigned-local-artifacts", not(unix)))]
385fn validate_owner(_metadata: &std::fs::Metadata) -> UpdateResult<()> {
386 Err(UpdateError::Authenticity(
387 "unsigned local artifacts are unsupported on this platform".to_string(),
388 ))
389}