Skip to main content

pwr_core/
crypto.rs

1//! Cryptographic operations for the pwr protocol.
2//!
3//! Provides three layers of security:
4//! 1. **Authentication**: PSK-based HMAC-SHA256 challenge-response handshake
5//! 2. **At-rest encryption**: age (X25519) encryption of project archives
6//! 3. **Integrity**: SHA-256 hashing of transferred data
7//!
8//! The server never sees plaintext project data. Archives are encrypted
9//! on the client side before upload and decrypted after download.
10
11use age::x25519::{Identity, Recipient};
12use ring::rand::SecureRandom;
13use std::fs;
14use std::path::Path;
15use std::str::FromStr;
16
17use crate::config::identity_path;
18use crate::error::{PwrError, Result};
19
20// ---------------------------------------------------------------------------
21// PSK authentication (pre-shared key)
22// ---------------------------------------------------------------------------
23
24/// Generate a cryptographically random 256-bit pre-shared key.
25///
26/// Returns 32 bytes suitable for hex-encoding and storing in both the
27/// client and server config files.
28pub fn generate_psk() -> [u8; 32] {
29    let rng = ring::rand::SystemRandom::new();
30    let mut key = [0u8; 32];
31    rng.fill(&mut key).expect("CSPRNG failure: cannot generate PSK");
32    key
33}
34
35/// Decode a hex string to bytes.
36fn decode_hex(s: &str) -> std::result::Result<Vec<u8>, String> {
37    if s.len() % 2 != 0 {
38        return Err("hex string must have even length".into());
39    }
40    (0..s.len())
41        .step_by(2)
42        .map(|i| {
43            u8::from_str_radix(&s[i..i + 2], 16)
44                .map_err(|e| format!("invalid hex at position {}: {}", i, e))
45        })
46        .collect()
47}
48
49/// Encode bytes as a lowercase hex string.
50fn encode_hex(bytes: &[u8]) -> String {
51    bytes.iter().map(|b| format!("{:02x}", b)).collect()
52}
53
54/// Convert a hex-encoded PSK string back to bytes.
55pub fn psk_from_hex(hex_str: &str) -> Result<[u8; 32]> {
56    let bytes = decode_hex(hex_str)
57        .map_err(|e| PwrError::Crypto(format!("invalid PSK hex: {}", e)))?;
58    if bytes.len() != 32 {
59        return Err(PwrError::Crypto(format!(
60            "PSK must be 32 bytes, got {}",
61            bytes.len()
62        )));
63    }
64    let mut key = [0u8; 32];
65    key.copy_from_slice(&bytes);
66    Ok(key)
67}
68
69/// Convert a 32-byte PSK to a hex string for storage in config files.
70pub fn psk_to_hex(key: &[u8; 32]) -> String {
71    encode_hex(key)
72}
73
74/// Authentication context string prevents cross-protocol attacks.
75const AUTH_CONTEXT: &[u8] = b"pwr-auth-v1";
76
77/// Compute the HMAC-SHA256 proof for the PSK handshake (client side).
78///
79/// Formula: HMAC-SHA256(client_nonce || AUTH_CONTEXT, PSK)
80pub fn compute_client_proof(psk: &[u8; 32], client_nonce: &[u8; 32]) -> [u8; 32] {
81    let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, psk);
82    let mut ctx = ring::hmac::Context::with_key(&key);
83    ctx.update(client_nonce);
84    ctx.update(AUTH_CONTEXT);
85    let tag = ctx.sign();
86    let mut proof = [0u8; 32];
87    proof.copy_from_slice(tag.as_ref());
88    proof
89}
90
91/// Compute the server proof for mutual authentication.
92///
93/// Formula: HMAC-SHA256(client_nonce || server_nonce || AUTH_CONTEXT, PSK)
94/// Including both nonces prevents replay attacks and proves the server
95/// knows the shared secret.
96pub fn compute_server_proof(
97    psk: &[u8; 32],
98    client_nonce: &[u8; 32],
99    server_nonce: &[u8; 32],
100) -> [u8; 32] {
101    let key = ring::hmac::Key::new(ring::hmac::HMAC_SHA256, psk);
102    let mut ctx = ring::hmac::Context::with_key(&key);
103    ctx.update(client_nonce);
104    ctx.update(server_nonce);
105    ctx.update(AUTH_CONTEXT);
106    let tag = ctx.sign();
107    let mut proof = [0u8; 32];
108    proof.copy_from_slice(tag.as_ref());
109    proof
110}
111
112// ---------------------------------------------------------------------------
113// Age key management (at-rest encryption)
114// ---------------------------------------------------------------------------
115
116/// Generate a new age X25519 identity and save it to disk.
117///
118/// The identity is stored at `~/.config/pwr/identity` with restrictive
119/// file permissions (0o600 on Unix). Returns the identity and its
120/// corresponding public key as a Bech32-encoded string.
121pub fn generate_age_identity() -> Result<(Identity, String)> {
122    let identity = Identity::generate();
123    let public_key = identity.to_public().to_string();
124
125    let path = identity_path();
126    if let Some(parent) = path.parent() {
127        fs::create_dir_all(parent)?;
128    }
129
130    // SecretString exposes its inner value via ExposeSecret
131    use age::secrecy::ExposeSecret;
132    let secret_str = identity.to_string();
133    let exposed: &str = secret_str.expose_secret();
134    fs::write(&path, exposed.as_bytes())?;
135
136    // Restrict permissions on Unix
137    #[cfg(unix)]
138    {
139        use std::os::unix::fs::PermissionsExt;
140        let mut perms = fs::metadata(&path)?.permissions();
141        perms.set_mode(0o600);
142        fs::set_permissions(&path, perms)?;
143    }
144
145    log::info!("Age identity saved to {}", path.display());
146    Ok((identity, public_key))
147}
148
149/// Load the age identity from disk.
150///
151/// Returns an error if the identity file does not exist or contains
152/// invalid data.
153pub fn load_age_identity() -> Result<Identity> {
154    let path = identity_path();
155    if !path.exists() {
156        return Err(PwrError::Crypto(
157            "No age identity found. Run 'pwr init' to generate one.".into(),
158        ));
159    }
160
161    let contents = fs::read_to_string(&path)?;
162    let contents = contents.trim();
163
164    Identity::from_str(contents)
165        .map_err(|e| PwrError::Crypto(format!("Failed to parse age identity: {}", e)))
166}
167
168/// Check whether an age identity file exists on disk.
169pub fn age_identity_exists() -> bool {
170    identity_path().exists()
171}
172
173// ---------------------------------------------------------------------------
174// Age encryption / decryption (one-shot API)
175// ---------------------------------------------------------------------------
176
177/// Encrypt data using an age X25519 public key.
178///
179/// Uses the one-shot `age::encrypt` function which handles the
180/// full age file format including header, stanza, and body.
181pub fn age_encrypt(plaintext: &[u8], public_key: &str) -> Result<Vec<u8>> {
182    let recipient = Recipient::from_str(public_key)
183        .map_err(|e| PwrError::Crypto(format!("Invalid age public key: {}", e)))?;
184
185    let encryptor = age::Encryptor::with_recipients([&recipient as &dyn age::Recipient].into_iter())
186        .map_err(|e| PwrError::Crypto(format!("Age encrypt setup failed: {}", e)))?;
187
188    let mut encrypted = Vec::new();
189    let mut writer = encryptor
190        .wrap_output(&mut encrypted)
191        .map_err(|e| PwrError::Crypto(format!("Age encrypt wrap failed: {}", e)))?;
192
193    std::io::copy(&mut &plaintext[..], &mut writer)
194        .map_err(|e| PwrError::Crypto(format!("Age encrypt write failed: {}", e)))?;
195
196    writer
197        .finish()
198        .map_err(|e| PwrError::Crypto(format!("Age encrypt finish failed: {}", e)))?;
199
200    Ok(encrypted)
201}
202
203/// Decrypt data using an age X25519 identity.
204///
205/// Uses the one-shot `age::decrypt` function. If the encrypted data
206/// was produced with a passphrase instead of an X25519 key, an error
207/// is returned since pwr only supports key-based encryption.
208pub fn age_decrypt(encrypted: &[u8], identity: &Identity) -> Result<Vec<u8>> {
209    // age::decrypt is a simple one-shot function
210    let decrypted = age::decrypt(identity, encrypted)
211        .map_err(|e| PwrError::Crypto(format!("Age decryption failed: {}", e)))?;
212
213    Ok(decrypted)
214}
215
216// ---------------------------------------------------------------------------
217// HKDF key derivation
218// ---------------------------------------------------------------------------
219
220/// Derive a per-project encryption key from the master PSK using HKDF-SHA256.
221///
222/// Uses the project UUID bytes as the info parameter to bind the derived
223/// key to a specific project. The same PSK and UUID always produce the
224/// same 256-bit key, enabling deterministic re-derivation without storing
225/// per-project keys on disk.
226///
227/// The key derivation uses HKDF-Expand with SHA-256, taking the PSK as
228/// the initial key material and the project UUID as context info.
229pub fn derive_project_key(psk: &[u8; 32], project_uuid: &uuid::Uuid) -> [u8; 32] {
230    use ring::hkdf::{Salt, HKDF_SHA256};
231
232    // Use the PSK as the salt for HKDF-Extract
233    let salt = Salt::new(HKDF_SHA256, psk);
234    // Use the project UUID bytes as the info for HKDF-Expand
235    let info = project_uuid.as_bytes();
236
237    // Extract + expand to produce 32 bytes of output key material
238    let mut derived = [0u8; 32];
239    salt.extract(&[])
240        .expand(&[info], HKDF_SHA256)
241        .expect("HKDF-Expand failure")
242        .fill(&mut derived)
243        .expect("HKDF fill failure: output length exceeds limit");
244
245    derived
246}
247
248// ---------------------------------------------------------------------------
249// SHA-256 hashing (delegates to integrity module)
250// ---------------------------------------------------------------------------
251
252/// Compute the SHA-256 hash of a byte slice, returned as a hex string.
253/// Delegates to the integrity module for the canonical implementation.
254pub fn sha256_hex(data: &[u8]) -> String {
255    crate::integrity::hash_bytes(data)
256}
257
258/// Compute the SHA-256 hash of a file, returned as a hex string.
259/// Delegates to the integrity module for the canonical implementation.
260pub fn sha256_file(path: &Path) -> Result<String> {
261    crate::integrity::hash_file(path)
262}
263
264// ---------------------------------------------------------------------------
265// Tests
266// ---------------------------------------------------------------------------
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    // PSK tests
273    #[test]
274    fn test_generate_psk_is_random() {
275        let psk1 = generate_psk();
276        let psk2 = generate_psk();
277        assert_ne!(psk1, psk2);
278        assert_eq!(psk1.len(), 32);
279    }
280
281    #[test]
282    fn test_psk_hex_round_trip() {
283        let psk = generate_psk();
284        let hex = psk_to_hex(&psk);
285        let decoded = psk_from_hex(&hex).unwrap();
286        assert_eq!(psk, decoded);
287    }
288
289    #[test]
290    fn test_psk_from_hex_rejects_short() {
291        assert!(psk_from_hex("abcdef").is_err());
292    }
293
294    #[test]
295    fn test_client_proof_deterministic() {
296        let psk = [0x42; 32];
297        let nonce = [0x99; 32];
298        let proof1 = compute_client_proof(&psk, &nonce);
299        let proof2 = compute_client_proof(&psk, &nonce);
300        assert_eq!(proof1, proof2);
301    }
302
303    #[test]
304    fn test_client_and_server_proofs_differ() {
305        let psk = [0x42; 32];
306        let c_nonce = [0x11; 32];
307        let s_nonce = [0x22; 32];
308
309        let client_proof = compute_client_proof(&psk, &c_nonce);
310        let server_proof = compute_server_proof(&psk, &c_nonce, &s_nonce);
311        assert_ne!(client_proof, server_proof);
312    }
313
314    // Age encryption tests
315    #[test]
316    fn test_age_encrypt_decrypt_round_trip() -> Result<()> {
317        let identity = Identity::generate();
318        let public_key = identity.to_public().to_string();
319
320        let plaintext = b"Project archive contents - this must remain confidential at rest.";
321        let encrypted = age_encrypt(plaintext, &public_key)?;
322        let decrypted = age_decrypt(&encrypted, &identity)?;
323
324        assert_eq!(decrypted, plaintext);
325        assert_ne!(encrypted, plaintext);
326        Ok(())
327    }
328
329    #[test]
330    fn test_age_encrypt_empty() -> Result<()> {
331        let identity = Identity::generate();
332        let public_key = identity.to_public().to_string();
333
334        let encrypted = age_encrypt(b"", &public_key)?;
335        let decrypted = age_decrypt(&encrypted, &identity)?;
336        assert!(decrypted.is_empty());
337        Ok(())
338    }
339
340    #[test]
341    fn test_age_decrypt_wrong_identity_fails() -> Result<()> {
342        let id1 = Identity::generate();
343        let id2 = Identity::generate();
344        let public_key = id1.to_public().to_string();
345
346        let encrypted = age_encrypt(b"secret message", &public_key)?;
347        let result = age_decrypt(&encrypted, &id2);
348        assert!(result.is_err());
349        Ok(())
350    }
351
352    // SHA-256 tests
353    #[test]
354    fn test_sha256_hex_known() {
355        assert_eq!(
356            sha256_hex(b"hello world"),
357            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
358        );
359    }
360
361    #[test]
362    fn test_sha256_file() -> Result<()> {
363        let tmp = tempfile::TempDir::new()?;
364        let path = tmp.path().join("test.txt");
365        fs::write(&path, b"hello world")?;
366
367        let hash = sha256_file(&path)?;
368        assert_eq!(
369            hash,
370            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
371        );
372        Ok(())
373    }
374
375    // HKDF tests
376    #[test]
377    fn test_derive_project_key_deterministic() {
378        let psk = [0x42; 32];
379        let uuid = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
380
381        let key1 = derive_project_key(&psk, &uuid);
382        let key2 = derive_project_key(&psk, &uuid);
383        assert_eq!(key1, key2);
384    }
385
386    #[test]
387    fn test_derive_project_key_different_uuids_produce_different_keys() {
388        let psk = [0x42; 32];
389        let uuid1 = uuid::Uuid::new_v4();
390        let uuid2 = uuid::Uuid::new_v4();
391
392        let key1 = derive_project_key(&psk, &uuid1);
393        let key2 = derive_project_key(&psk, &uuid2);
394        assert_ne!(key1, key2);
395    }
396
397    #[test]
398    fn test_derive_project_key_different_psks_produce_different_keys() {
399        let psk1 = [0x11; 32];
400        let psk2 = [0x22; 32];
401        let uuid = uuid::Uuid::new_v4();
402
403        let key1 = derive_project_key(&psk1, &uuid);
404        let key2 = derive_project_key(&psk2, &uuid);
405        assert_ne!(key1, key2);
406    }
407
408    #[test]
409    fn test_derive_project_key_output_length() {
410        let psk = generate_psk();
411        let uuid = uuid::Uuid::new_v4();
412        let key = derive_project_key(&psk, &uuid);
413        assert_eq!(key.len(), 32);
414    }
415}