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