Skip to main content

treetop_bundle/
signing.rs

1use crate::{BundleError, FORMAT_VERSION, Result};
2use base64::{Engine as _, engine::general_purpose::STANDARD};
3use ed25519_dalek::pkcs8::{DecodePrivateKey, DecodePublicKey};
4use ed25519_dalek::{Signature, Signer, Verifier, VerifyingKey};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::Path;
10use std::str::FromStr;
11
12const SIGNATURE_DOMAIN: &[u8] = b"treetop-bundle-signature-v1\0";
13
14/// Signature requirements applied while opening an archive.
15#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "kebab-case")]
17pub enum SignaturePolicy {
18    #[default]
19    AllowUnsigned,
20    Required,
21}
22
23impl FromStr for SignaturePolicy {
24    type Err = BundleError;
25
26    fn from_str(value: &str) -> Result<Self> {
27        match value {
28            "allow-unsigned" => Ok(Self::AllowUnsigned),
29            "required" => Ok(Self::Required),
30            _ => Err(BundleError::Key(format!(
31                "unknown signature policy {value:?}; expected allow-unsigned or required"
32            ))),
33        }
34    }
35}
36
37/// Detached signature metadata stored in `signature.json`.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct BundleSignature {
41    format_version: u32,
42    algorithm: String,
43    key_id: String,
44    signature: String,
45}
46
47impl BundleSignature {
48    pub fn format_version(&self) -> u32 {
49        self.format_version
50    }
51
52    pub fn algorithm(&self) -> &str {
53        &self.algorithm
54    }
55
56    pub fn key_id(&self) -> &str {
57        &self.key_id
58    }
59
60    pub fn signature(&self) -> &str {
61        &self.signature
62    }
63
64    pub(crate) fn validate_format(&self) -> Result<()> {
65        if self.format_version != FORMAT_VERSION {
66            return Err(BundleError::Archive(format!(
67                "unsupported signature format version {}",
68                self.format_version
69            )));
70        }
71        if self.algorithm != "ed25519" {
72            return Err(BundleError::Archive(format!(
73                "unsupported signature algorithm {:?}",
74                self.algorithm
75            )));
76        }
77        let decoded = STANDARD.decode(&self.signature).map_err(|error| {
78            BundleError::Archive(format!("signature is not standard base64: {error}"))
79        })?;
80        Signature::from_slice(&decoded).map_err(|error| {
81            BundleError::Archive(format!("malformed Ed25519 signature: {error}"))
82        })?;
83        Ok(())
84    }
85}
86
87/// An Ed25519 private signing key loaded from unencrypted PKCS#8 PEM.
88pub struct SigningKey(ed25519_dalek::SigningKey);
89
90impl SigningKey {
91    /// Load a private key and, on Unix, reject group/other-accessible files.
92    pub fn from_pkcs8_pem_file(path: impl AsRef<Path>) -> Result<Self> {
93        let path = path.as_ref();
94        let metadata = fs::metadata(path).map_err(|error| BundleError::io(path, error))?;
95        #[cfg(unix)]
96        {
97            use std::os::unix::fs::PermissionsExt;
98            if metadata.permissions().mode() & 0o077 != 0 {
99                return Err(BundleError::Key(format!(
100                    "private key file {} is accessible by group or others",
101                    path.display()
102                )));
103            }
104        }
105        let pem = fs::read_to_string(path).map_err(|error| BundleError::io(path, error))?;
106        Self::from_pkcs8_pem(&pem)
107    }
108
109    /// Decode an unencrypted PKCS#8 PEM private key.
110    pub fn from_pkcs8_pem(pem: &str) -> Result<Self> {
111        ed25519_dalek::SigningKey::from_pkcs8_pem(pem)
112            .map(Self)
113            .map_err(|error| {
114                BundleError::Key(format!("invalid PKCS#8 Ed25519 private key: {error}"))
115            })
116    }
117
118    pub fn key_id(&self) -> String {
119        key_id(&self.0.verifying_key())
120    }
121
122    pub(crate) fn sign_manifest(&self, manifest: &[u8]) -> BundleSignature {
123        let mut message = Vec::with_capacity(SIGNATURE_DOMAIN.len() + manifest.len());
124        message.extend_from_slice(SIGNATURE_DOMAIN);
125        message.extend_from_slice(manifest);
126        let signature = self.0.sign(&message);
127        BundleSignature {
128            format_version: FORMAT_VERSION,
129            algorithm: "ed25519".to_string(),
130            key_id: self.key_id(),
131            signature: STANDARD.encode(signature.to_bytes()),
132        }
133    }
134}
135
136/// A trusted Ed25519 public key loaded from SPKI PEM.
137#[derive(Debug, Clone)]
138pub struct TrustedKey {
139    key_id: String,
140    key: VerifyingKey,
141}
142
143impl TrustedKey {
144    pub fn from_spki_pem_file(path: impl AsRef<Path>) -> Result<Self> {
145        let path = path.as_ref();
146        let pem = fs::read_to_string(path).map_err(|error| BundleError::io(path, error))?;
147        Self::from_spki_pem(&pem)
148    }
149
150    pub fn from_spki_pem(pem: &str) -> Result<Self> {
151        let key = VerifyingKey::from_public_key_pem(pem).map_err(|error| {
152            BundleError::Key(format!("invalid SPKI Ed25519 public key: {error}"))
153        })?;
154        Ok(Self {
155            key_id: key_id(&key),
156            key,
157        })
158    }
159
160    pub fn key_id(&self) -> &str {
161        &self.key_id
162    }
163
164    fn raw_key(&self) -> [u8; 32] {
165        self.key.to_bytes()
166    }
167
168    fn verify(&self, manifest: &[u8], signature: &Signature) -> Result<()> {
169        let mut message = Vec::with_capacity(SIGNATURE_DOMAIN.len() + manifest.len());
170        message.extend_from_slice(SIGNATURE_DOMAIN);
171        message.extend_from_slice(manifest);
172        self.key
173            .verify(&message, signature)
174            .map_err(|_| BundleError::Archive("invalid_signature".to_string()))
175    }
176}
177
178/// Trusted public keys indexed by their content-derived key IDs.
179#[derive(Debug, Clone, Default)]
180pub struct TrustStore(BTreeMap<String, TrustedKey>);
181
182impl TrustStore {
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    pub fn from_keys(keys: impl IntoIterator<Item = TrustedKey>) -> Result<Self> {
188        let mut store = Self::new();
189        for key in keys {
190            store.insert(key)?;
191        }
192        Ok(store)
193    }
194
195    pub fn insert(&mut self, key: TrustedKey) -> Result<()> {
196        if let Some(existing) = self.0.get(key.key_id()) {
197            if existing.raw_key() != key.raw_key() {
198                return Err(BundleError::Key(format!(
199                    "duplicate key ID {} has different public key bytes",
200                    key.key_id()
201                )));
202            }
203            return Ok(());
204        }
205        self.0.insert(key.key_id.clone(), key);
206        Ok(())
207    }
208
209    pub fn is_empty(&self) -> bool {
210        self.0.is_empty()
211    }
212
213    pub fn len(&self) -> usize {
214        self.0.len()
215    }
216
217    pub(crate) fn verify(&self, manifest: &[u8], signature: &BundleSignature) -> Result<String> {
218        signature.validate_format()?;
219        let key = self
220            .0
221            .get(signature.key_id())
222            .ok_or_else(|| BundleError::Archive("untrusted_key".to_string()))?;
223        let bytes = STANDARD.decode(signature.signature()).map_err(|error| {
224            BundleError::Archive(format!("signature is not standard base64: {error}"))
225        })?;
226        let signature = Signature::from_slice(&bytes).map_err(|error| {
227            BundleError::Archive(format!("malformed Ed25519 signature: {error}"))
228        })?;
229        key.verify(manifest, &signature)?;
230        Ok(key.key_id.clone())
231    }
232}
233
234fn key_id(key: &VerifyingKey) -> String {
235    let digest = Sha256::digest(key.to_bytes());
236    digest.iter().map(|byte| format!("{byte:02x}")).collect()
237}