Skip to main content

crypto/
lib.rs

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