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