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