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