Skip to main content

crablock_core/
signing.rs

1use std::process::Command;
2
3use base64::Engine;
4use chrono::Utc;
5use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
6
7use crate::crypto::compute_sha256;
8use crate::error::{CrablockError, Result};
9use crate::format::Package;
10
11pub const SIGNATURE_ALGORITHM_ED25519: &str = "ed25519";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SignatureVerificationStatus {
15    NotSigned,
16    Verified,
17}
18
19pub fn is_signature_required(package: &Package, require_signature_flag: bool) -> bool {
20    // The CLI flag and the manifest policy both mean the same thing: unsigned packages should fail.
21    require_signature_flag || package.manifest.require_signature.unwrap_or(false)
22}
23
24pub fn read_signing_key_from_source(source: &str) -> Result<SigningKey> {
25    let raw = read_key_source(source)?;
26
27    match raw.len() {
28        32 => {
29            let mut key = [0u8; 32];
30            key.copy_from_slice(&raw);
31            Ok(SigningKey::from_bytes(&key))
32        }
33        64 => {
34            let mut key = [0u8; 64];
35            key.copy_from_slice(&raw);
36            SigningKey::from_keypair_bytes(&key).map_err(|e| {
37                CrablockError::InvalidKey(format!("Invalid Ed25519 keypair bytes: {e}"))
38            })
39        }
40        len => Err(CrablockError::InvalidKey(format!(
41            "Signing key must decode to 32-byte seed or 64-byte keypair, got {len} bytes"
42        ))),
43    }
44}
45
46pub fn read_verifying_key_from_source(source: &str) -> Result<VerifyingKey> {
47    let raw = read_key_source(source)?;
48
49    match raw.len() {
50        32 => {
51            let mut key = [0u8; 32];
52            key.copy_from_slice(&raw);
53            VerifyingKey::from_bytes(&key)
54                .map_err(|e| CrablockError::InvalidKey(format!("Invalid Ed25519 pubkey: {e}")))
55        }
56        64 => {
57            let mut key = [0u8; 64];
58            key.copy_from_slice(&raw);
59            let signing_key = SigningKey::from_keypair_bytes(&key).map_err(|e| {
60                CrablockError::InvalidKey(format!("Invalid Ed25519 keypair bytes: {e}"))
61            })?;
62            Ok(signing_key.verifying_key())
63        }
64        len => Err(CrablockError::InvalidKey(format!(
65            "Public key must decode to 32-byte Ed25519 pubkey or 64-byte keypair, got {len} bytes"
66        ))),
67    }
68}
69
70pub fn public_key_fingerprint(verifying_key: &VerifyingKey) -> String {
71    compute_sha256(verifying_key.as_bytes())
72}
73
74pub fn sign_package(
75    package: &mut Package,
76    signing_key: &SigningKey,
77    pubkey_id_override: Option<&str>,
78) -> Result<()> {
79    // Signature metadata is copied to both the package trailer and the manifest.
80    // That keeps old and new readers aligned during migration.
81    let verifying_key = signing_key.verifying_key();
82    let fingerprint = pubkey_id_override
83        .map(str::to_string)
84        .unwrap_or_else(|| public_key_fingerprint(&verifying_key));
85    let signed_at = Utc::now().to_rfc3339();
86
87    package.signature_algorithm = Some(SIGNATURE_ALGORITHM_ED25519.to_string());
88    package.signing_pubkey_fingerprint = Some(fingerprint.clone());
89    package.manifest.signature_algorithm = Some(SIGNATURE_ALGORITHM_ED25519.to_string());
90    package.manifest.signing_pubkey_fingerprint = Some(fingerprint);
91    package.manifest.signature_created_at = Some(signed_at);
92
93    let canonical_bytes = package.canonical_signing_bytes()?;
94    let signature = signing_key.sign(&canonical_bytes);
95    package.signature = Some(signature.to_bytes().to_vec());
96
97    Ok(())
98}
99
100pub fn verify_package_signature(
101    package: &Package,
102    verifying_key: &VerifyingKey,
103    require_signature: bool,
104) -> Result<SignatureVerificationStatus> {
105    let signature_bytes = match package.signature.as_ref() {
106        Some(signature) => signature,
107        None => {
108            if require_signature {
109                return Err(CrablockError::SignatureMissing);
110            }
111            return Ok(SignatureVerificationStatus::NotSigned);
112        }
113    };
114
115    let algorithm = package
116        .signature_algorithm
117        .as_deref()
118        .or(package.manifest.signature_algorithm.as_deref())
119        .unwrap_or(SIGNATURE_ALGORITHM_ED25519);
120    if algorithm != SIGNATURE_ALGORITHM_ED25519 {
121        return Err(CrablockError::UnsupportedSignatureAlgorithm(
122            algorithm.to_string(),
123        ));
124    }
125
126    if signature_bytes.len() != 64 {
127        return Err(CrablockError::SignatureInvalid);
128    }
129    let signature =
130        Signature::from_slice(signature_bytes).map_err(|_| CrablockError::SignatureInvalid)?;
131
132    // We verify the exact bytes that were signed at package creation time.
133    let canonical_bytes = package.canonical_signing_bytes()?;
134    verifying_key
135        .verify(&canonical_bytes, &signature)
136        .map_err(|_| CrablockError::SignatureInvalid)?;
137
138    let expected_fingerprint = package
139        .signing_pubkey_fingerprint
140        .as_deref()
141        .or(package.manifest.signing_pubkey_fingerprint.as_deref());
142    if let Some(expected) = expected_fingerprint {
143        let actual = public_key_fingerprint(verifying_key);
144        if expected != actual {
145            return Err(CrablockError::SignatureInvalid);
146        }
147    }
148
149    Ok(SignatureVerificationStatus::Verified)
150}
151
152fn read_key_source(source: &str) -> Result<Vec<u8>> {
153    // Signing keys and public keys support the same source prefixes as encryption keys.
154    if let Some(var) = source.strip_prefix("env:") {
155        let value = std::env::var(var)
156            .map_err(|_| CrablockError::KeySource(format!("Environment variable {var} not set")))?;
157        return decode_key_text(&value);
158    }
159
160    if let Some(path) = source.strip_prefix("file:") {
161        let bytes = std::fs::read(path).map_err(|e| {
162            CrablockError::KeySource(format!("Failed to read key file {path}: {e}"))
163        })?;
164        return decode_key_bytes(bytes);
165    }
166
167    if let Some(cmd) = source.strip_prefix("cmd:") {
168        let output = Command::new("sh")
169            .arg("-c")
170            .arg(cmd)
171            .output()
172            .map_err(|e| CrablockError::KeySource(format!("Failed to execute key command: {e}")))?;
173
174        if !output.status.success() {
175            return Err(CrablockError::KeySource(format!(
176                "Key command failed with exit code: {:?}",
177                output.status.code()
178            )));
179        }
180        return decode_key_bytes(output.stdout);
181    }
182
183    decode_key_text(source)
184}
185
186fn decode_key_bytes(bytes: Vec<u8>) -> Result<Vec<u8>> {
187    match String::from_utf8(bytes.clone()) {
188        Ok(value) => decode_key_text(&value),
189        Err(_) => Ok(bytes),
190    }
191}
192
193fn decode_key_text(value: &str) -> Result<Vec<u8>> {
194    // Accept explicit prefixes first, then try common raw forms.
195    let trimmed = value.trim();
196    if trimmed.is_empty() {
197        return Err(CrablockError::InvalidKey(
198            "Key material cannot be empty".to_string(),
199        ));
200    }
201
202    if let Some(raw) = trimmed.strip_prefix("hex:") {
203        return hex::decode(raw)
204            .map_err(|e| CrablockError::InvalidKey(format!("Invalid hex: {e}")));
205    }
206
207    if let Some(raw) = trimmed.strip_prefix("base64:") {
208        return base64::engine::general_purpose::STANDARD
209            .decode(raw)
210            .map_err(|e| CrablockError::InvalidKey(format!("Invalid base64: {e}")));
211    }
212
213    if trimmed.len().is_multiple_of(2) && trimmed.chars().all(|ch| ch.is_ascii_hexdigit()) {
214        if let Ok(decoded) = hex::decode(trimmed) {
215            return Ok(decoded);
216        }
217    }
218
219    if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(trimmed) {
220        return Ok(decoded);
221    }
222
223    Ok(trimmed.as_bytes().to_vec())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::crypto::EncryptionAlgorithm;
230    use crate::manifest::Manifest;
231
232    #[test]
233    fn test_sign_and_verify_roundtrip() {
234        let signing_key = SigningKey::from_bytes(&[42u8; 32]);
235        let mut package = Package::new(
236            Manifest::new(
237                "test".to_string(),
238                4,
239                EncryptionAlgorithm::Aes256Gcm,
240                &[0u8; 12],
241                "artifact_hash",
242                "payload_hash",
243            ),
244            vec![1, 2, 3, 4],
245            None,
246        );
247
248        sign_package(&mut package, &signing_key, None).unwrap();
249        let status =
250            verify_package_signature(&package, &signing_key.verifying_key(), true).unwrap();
251
252        assert_eq!(status, SignatureVerificationStatus::Verified);
253    }
254
255    #[test]
256    fn test_tampered_payload_invalidates_signature() {
257        let signing_key = SigningKey::from_bytes(&[11u8; 32]);
258        let mut package = Package::new(
259            Manifest::new(
260                "test".to_string(),
261                4,
262                EncryptionAlgorithm::Aes256Gcm,
263                &[0u8; 12],
264                "artifact_hash",
265                "payload_hash",
266            ),
267            vec![1, 2, 3, 4],
268            None,
269        );
270
271        sign_package(&mut package, &signing_key, None).unwrap();
272        package.payload[0] ^= 0xFF;
273
274        let result = verify_package_signature(&package, &signing_key.verifying_key(), true);
275        assert!(matches!(result, Err(CrablockError::SignatureInvalid)));
276    }
277
278    #[test]
279    fn test_unsigned_package_require_signature_behavior() {
280        let signing_key = SigningKey::from_bytes(&[7u8; 32]);
281        let package = Package::new(
282            Manifest::new(
283                "test".to_string(),
284                4,
285                EncryptionAlgorithm::Aes256Gcm,
286                &[0u8; 12],
287                "artifact_hash",
288                "payload_hash",
289            ),
290            vec![1, 2, 3, 4],
291            None,
292        );
293
294        let optional =
295            verify_package_signature(&package, &signing_key.verifying_key(), false).unwrap();
296        assert_eq!(optional, SignatureVerificationStatus::NotSigned);
297
298        let required = verify_package_signature(&package, &signing_key.verifying_key(), true);
299        assert!(matches!(required, Err(CrablockError::SignatureMissing)));
300    }
301
302    #[test]
303    fn test_read_signing_key_from_base64_prefix() {
304        let source = "base64:KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=";
305        let key = read_signing_key_from_source(source).unwrap();
306        assert_eq!(key.to_bytes(), [42u8; 32]);
307    }
308}