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