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