Skip to main content

crypto/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Cryptographic signing for Heddle states.
3
4mod ed25519;
5mod error;
6mod p256;
7mod pem_loader;
8pub mod pop;
9mod state_signature;
10
11#[cfg(test)]
12mod behavior_tests;
13
14use std::path::Path;
15
16pub use ed25519::Ed25519Signer;
17pub use error::SignerError;
18use objects::object::ContentHash;
19pub use objects::object::SignatureStatus;
20pub use p256::P256Signer;
21pub use pem_loader::{PemKind, classify_pem};
22pub use state_signature::{
23    StateSignatureError, public_key_bytes, signature_bytes, state_signature_from_signer,
24    verify_state_signature_bytes,
25};
26
27/// Trait for cryptographic signers.
28pub trait Signer: Send + Sync {
29    fn algorithm(&self) -> &'static str;
30    fn public_key(&self) -> &[u8];
31    fn sign(&self, data: &[u8]) -> Result<Vec<u8>, SignerError>;
32    fn verify(&self, data: &[u8], signature: &[u8]) -> Result<(), SignerError>;
33}
34
35/// Load a signer from a key file. When `algorithm` is `None`, the PEM
36/// header (or raw-seed shape) selects the backend via
37/// [`pem_loader::load_signer_from_pem`].
38pub fn load_signer(path: &Path, algorithm: Option<&str>) -> Result<Box<dyn Signer>, SignerError> {
39    reject_group_or_world_readable_key(path)?;
40    let key_data = std::fs::read(path)?;
41    let pem_content = String::from_utf8_lossy(&key_data);
42
43    if let Some(algo) = algorithm {
44        return match algo.to_lowercase().as_str() {
45            "ed25519" => {
46                Ed25519Signer::from_pem(&pem_content).map(|s| Box::new(s) as Box<dyn Signer>)
47            }
48            "p256" | "ecdsa-p256" => {
49                P256Signer::from_pem(&pem_content).map(|s| Box::new(s) as Box<dyn Signer>)
50            }
51            _ => Err(SignerError::UnsupportedAlgorithm(algo.to_string())),
52        };
53    }
54
55    pem_loader::load_signer_from_pem(&pem_content)
56}
57
58/// Reject a private-key file whose permissions expose it to group/world
59/// readers. The single source of the `0600`-or-stricter rule: the key-file
60/// signer loader ([`load_signer`]) and the auto-signing identity loader
61/// (`repo::identity`) both call this so the threshold lives in one place. On
62/// unix, errors with [`SignerError::InsecureKeyPermissions`] when any of the
63/// group/world bits (`0o077`) are set; a no-op on platforms without a unix
64/// permission model. Propagates I/O errors (e.g. `NotFound`) from the stat.
65#[cfg(unix)]
66pub fn reject_group_or_world_readable_key(path: &Path) -> Result<(), SignerError> {
67    use std::os::unix::fs::PermissionsExt;
68
69    let mode = std::fs::metadata(path)?.permissions().mode() & 0o777;
70    if mode & 0o077 != 0 {
71        return Err(SignerError::InsecureKeyPermissions {
72            path: path.to_path_buf(),
73            mode,
74        });
75    }
76    Ok(())
77}
78
79/// Non-unix stub: no permission model to enforce. See the unix variant.
80#[cfg(not(unix))]
81pub fn reject_group_or_world_readable_key(_path: &Path) -> Result<(), SignerError> {
82    Ok(())
83}
84
85/// Verify a state's signature.
86pub fn verify_state_signature(
87    content_hash: &ContentHash,
88    algorithm: &str,
89    public_key: &[u8],
90    signature: &[u8],
91) -> Result<(), SignerError> {
92    verify_payload_signature(content_hash.as_bytes(), algorithm, public_key, signature)
93}
94
95/// Verify a detached signature over an arbitrary payload. Used by
96/// non-state-signature flows (e.g. `ReviewSignature`) that already have a
97/// canonical byte payload built upstream.
98pub fn verify_payload_signature(
99    payload: &[u8],
100    algorithm: &str,
101    public_key: &[u8],
102    signature: &[u8],
103) -> Result<(), SignerError> {
104    match algorithm.to_lowercase().as_str() {
105        "ed25519" => Ed25519Signer::verify_with_public_key(payload, public_key, signature),
106        "p256" | "ecdsa-p256" => P256Signer::verify_with_public_key(payload, public_key, signature),
107        _ => Err(SignerError::UnsupportedAlgorithm(algorithm.to_string())),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    #[cfg(unix)]
114    use std::os::unix::fs::PermissionsExt;
115
116    use objects::fs_atomic::write_file_atomic_secret;
117    use tempfile::TempDir;
118
119    use super::*;
120
121    #[test]
122    fn test_ed25519_sign_verify_roundtrip() {
123        let signer = Ed25519Signer::generate().expect("generate key");
124        let data = b"test data for signing";
125
126        let signature = signer.sign(data).expect("sign data");
127        signer.verify(data, &signature).expect("verify signature");
128    }
129
130    #[test]
131    fn test_ed25519_sign_verify_invalid_signature_fails_explicitly() {
132        let signer = Ed25519Signer::generate().expect("generate key");
133        let data = b"test data for signing";
134
135        let signature = signer.sign(data).expect("sign data");
136        let error = signer
137            .verify(b"wrong data", &signature)
138            .expect_err("verify should fail");
139
140        assert!(matches!(error, SignerError::VerificationFailed));
141    }
142
143    #[test]
144    fn test_load_signer_ed25519() {
145        let temp = TempDir::new().expect("create temp dir");
146        let key_path = temp.path().join("test_ed25519.pem");
147
148        let signer = Ed25519Signer::generate().expect("generate key");
149        let pem = signer.to_pem().expect("export to PEM");
150        write_file_atomic_secret(&key_path, pem.as_bytes()).expect("write key file");
151
152        let loaded = load_signer(&key_path, Some("ed25519")).expect("load signer");
153        assert_eq!(loaded.algorithm(), "ed25519");
154        assert_eq!(loaded.public_key(), signer.public_key());
155    }
156
157    #[cfg(unix)]
158    #[test]
159    fn load_signer_refuses_group_or_world_readable_private_key() {
160        let temp = TempDir::new().expect("create temp dir");
161        let key_path = temp.path().join("test_ed25519.pem");
162
163        let signer = Ed25519Signer::generate().expect("generate key");
164        let pem = signer.to_pem().expect("export to PEM");
165        write_file_atomic_secret(&key_path, pem.as_bytes()).expect("write key file");
166        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644))
167            .expect("make key insecure");
168
169        let err = match load_signer(&key_path, Some("ed25519")) {
170            Ok(_) => panic!("insecure key must fail"),
171            Err(err) => err,
172        };
173        assert!(matches!(
174            err,
175            SignerError::InsecureKeyPermissions { mode: 0o644, .. }
176        ));
177        // The refusal must be actionable: name the offending path, the
178        // observed + required modes, and the exact chmod to run.
179        let msg = err.to_string();
180        assert!(msg.contains(&key_path.display().to_string()), "{msg}");
181        assert!(msg.contains("0644"), "{msg}");
182        assert!(msg.contains("0600"), "{msg}");
183        assert!(msg.contains("chmod 600"), "{msg}");
184    }
185
186    #[cfg(unix)]
187    #[test]
188    fn load_signer_accepts_owner_only_private_key() {
189        let temp = TempDir::new().expect("create temp dir");
190        let key_path = temp.path().join("test_ed25519.pem");
191
192        let signer = Ed25519Signer::generate().expect("generate key");
193        let pem = signer.to_pem().expect("export to PEM");
194        write_file_atomic_secret(&key_path, pem.as_bytes()).expect("write key file");
195        std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
196            .expect("set owner-only mode");
197
198        let loaded = load_signer(&key_path, Some("ed25519")).expect("0600 key must load");
199        assert_eq!(loaded.public_key(), signer.public_key());
200    }
201}