Skip to main content

crypto/
lib.rs

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