Skip to main content

appcore_update/
authenticity.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: authenticity.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/07/23 13:45:20 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/24 16:07:49 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! Defines bounded authenticity contracts and behavior for this crate.
12
13use 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
19/// Verifies that an artifact descriptor was published by a trusted identity.
20pub trait ArtifactAuthenticityVerifier: Send + Sync {
21    /// Verifies the descriptor signature and configured trust policy.
22    fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()>;
23}
24
25/// Development-only verifier for unsigned artifacts below one owner-only root.
26///
27/// This verifier exists only when the `allow-unsigned-local-artifacts` feature
28/// is enabled. It rejects non-file origins, symlinks, paths outside the
29/// canonical root, non-regular files, and files owned by another Unix user.
30#[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    /// Creates a verifier from an existing canonical owner-controlled root.
39    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/// Ed25519 verifier backed by deployment-owned public trust roots.
70#[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/// Lifecycle status of one deployment-owned artifact signing key.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum SigningKeyStatus {
84    /// Key may sign and verify current releases.
85    Active,
86    /// Key verifies already published artifacts during a rotation window.
87    Deprecated,
88    /// Key is compromised or retired and must reject every artifact.
89    Revoked,
90}
91
92impl Ed25519ArtifactVerifier {
93    /// Creates an empty verifier. At least one trust root must be added before use.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Adds a public key under a stable deployment-owned identifier.
99    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    /// Adds a lowercase hexadecimal Ed25519 public trust root.
119    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    /// Changes a configured signing key lifecycle status.
128    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    /// Returns the number of accepted signing identities.
141    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/// Deployment-owned allowlist applied before signature verification.
172#[derive(Debug, Clone, Default)]
173pub struct ArtifactTrustPolicy {
174    allowed_channels: BTreeSet<String>,
175    allowed_origins: BTreeSet<String>,
176}
177
178impl ArtifactTrustPolicy {
179    /// Creates a deny-by-default artifact policy.
180    pub fn new() -> Self {
181        Self::default()
182    }
183
184    /// Allows one exact update channel.
185    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    /// Allows one exact artifact origin such as `https://updates.example`.
193    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    /// Verifies channel and source origin against explicit allowlists.
200    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/// Composes deployment allowlists with a cryptographic verifier.
218#[derive(Debug, Clone)]
219pub struct PolicyArtifactVerifier<V> {
220    policy: ArtifactTrustPolicy,
221    verifier: V,
222}
223
224impl<V> PolicyArtifactVerifier<V> {
225    /// Creates a verifier that enforces policy before authenticity.
226    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
241/// Builds the stable byte payload covered by an artifact signature.
242pub 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(&current) {
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}