Skip to main content

appcore_update/
authenticity.rs

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
7/// Verifies that an artifact descriptor was published by a trusted identity.
8pub trait ArtifactAuthenticityVerifier: Send + Sync {
9    /// Verifies the descriptor signature and configured trust policy.
10    fn verify(&self, artifact: &ArtifactDescriptor) -> UpdateResult<()>;
11}
12
13/// Development-only verifier for unsigned artifacts below one owner-only root.
14///
15/// This verifier exists only when the `allow-unsigned-local-artifacts` feature
16/// is enabled. It rejects non-file origins, symlinks, paths outside the
17/// canonical root, non-regular files, and files owned by another Unix user.
18#[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    /// Creates a verifier from an existing canonical owner-controlled root.
27    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/// Ed25519 verifier backed by deployment-owned public trust roots.
58#[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/// Lifecycle status of one deployment-owned artifact signing key.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum SigningKeyStatus {
72    /// Key may sign and verify current releases.
73    Active,
74    /// Key verifies already published artifacts during a rotation window.
75    Deprecated,
76    /// Key is compromised or retired and must reject every artifact.
77    Revoked,
78}
79
80impl Ed25519ArtifactVerifier {
81    /// Creates an empty verifier. At least one trust root must be added before use.
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// Adds a public key under a stable deployment-owned identifier.
87    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    /// Adds a lowercase hexadecimal Ed25519 public trust root.
107    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    /// Changes a configured signing key lifecycle status.
116    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    /// Returns the number of accepted signing identities.
129    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/// Deployment-owned allowlist applied before signature verification.
160#[derive(Debug, Clone, Default)]
161pub struct ArtifactTrustPolicy {
162    allowed_channels: BTreeSet<String>,
163    allowed_origins: BTreeSet<String>,
164}
165
166impl ArtifactTrustPolicy {
167    /// Creates a deny-by-default artifact policy.
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    /// Allows one exact update channel.
173    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    /// Allows one exact artifact origin such as `https://updates.example`.
181    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    /// Verifies channel and source origin against explicit allowlists.
188    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/// Composes deployment allowlists with a cryptographic verifier.
206#[derive(Debug, Clone)]
207pub struct PolicyArtifactVerifier<V> {
208    policy: ArtifactTrustPolicy,
209    verifier: V,
210}
211
212impl<V> PolicyArtifactVerifier<V> {
213    /// Creates a verifier that enforces policy before authenticity.
214    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
229/// Builds the stable byte payload covered by an artifact signature.
230pub 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(&current) {
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}