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