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