1use crate::crypto::{KeyPair, PublicKey, SharedSecret};
73use crate::error::{Result, Web4Error};
74use chacha20poly1305::{
75 aead::{Aead, KeyInit},
76 ChaCha20Poly1305, Key, Nonce,
77};
78use hkdf::Hkdf;
79use rand::RngCore;
80use sha2::Sha256;
81use uuid::Uuid;
82
83const HKDF_INFO: &[u8] = b"web4-paired-channel-v1";
87
88const NONCE_LEN: usize = 12;
91
92#[derive(Clone)]
95pub struct SessionKey([u8; 32]);
96
97impl SessionKey {
98 pub fn as_bytes(&self) -> &[u8; 32] { &self.0 }
99}
100
101impl std::fmt::Debug for SessionKey {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 f.write_str("SessionKey(<redacted 32 bytes>)")
104 }
105}
106
107#[derive(Clone, Debug)]
111pub struct Sealed(Vec<u8>);
112
113impl Sealed {
114 pub fn as_bytes(&self) -> &[u8] { &self.0 }
116
117 pub fn into_bytes(self) -> Vec<u8> { self.0 }
119
120 pub fn to_base64(&self) -> String {
122 use base64::Engine;
123 base64::engine::general_purpose::STANDARD.encode(&self.0)
124 }
125
126 pub fn from_base64(s: &str) -> Result<Self> {
128 use base64::Engine;
129 let bytes = base64::engine::general_purpose::STANDARD
130 .decode(s)
131 .map_err(|e| Web4Error::Crypto(format!("base64 decode: {}", e)))?;
132 if bytes.len() < NONCE_LEN + 16 {
133 return Err(Web4Error::Crypto(
134 format!("sealed blob too short: {} bytes < nonce+tag = {}",
135 bytes.len(), NONCE_LEN + 16)
136 ));
137 }
138 Ok(Self(bytes))
139 }
140
141 pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
143 if bytes.len() < NONCE_LEN + 16 {
144 return Err(Web4Error::Crypto(
145 format!("sealed blob too short: {} bytes < nonce+tag = {}",
146 bytes.len(), NONCE_LEN + 16)
147 ));
148 }
149 Ok(Self(bytes))
150 }
151}
152
153pub fn derive_session_key(shared: &SharedSecret, pair_id: Uuid) -> SessionKey {
158 let hk = Hkdf::<Sha256>::new(Some(pair_id.as_bytes()), shared.as_bytes());
159 let mut okm = [0u8; 32];
160 hk.expand(HKDF_INFO, &mut okm)
162 .expect("HKDF expand for 32 bytes never fails");
163 SessionKey(okm)
164}
165
166pub fn encrypt(session_key: &SessionKey, plaintext: &[u8]) -> Result<Sealed> {
169 let cipher = ChaCha20Poly1305::new(Key::from_slice(session_key.as_bytes()));
170 let mut nonce_bytes = [0u8; NONCE_LEN];
171 rand::thread_rng().fill_bytes(&mut nonce_bytes);
172 let nonce = Nonce::from_slice(&nonce_bytes);
173 let ciphertext = cipher.encrypt(nonce, plaintext)
174 .map_err(|e| Web4Error::Crypto(format!("ChaCha20-Poly1305 encrypt: {}", e)))?;
175 let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
176 out.extend_from_slice(&nonce_bytes);
177 out.extend_from_slice(&ciphertext);
178 Ok(Sealed(out))
179}
180
181pub fn decrypt(session_key: &SessionKey, sealed: &Sealed) -> Result<Vec<u8>> {
185 let bytes = sealed.as_bytes();
186 if bytes.len() < NONCE_LEN + 16 {
187 return Err(Web4Error::Crypto("sealed blob too short".into()));
188 }
189 let (nonce_bytes, ciphertext) = bytes.split_at(NONCE_LEN);
190 let cipher = ChaCha20Poly1305::new(Key::from_slice(session_key.as_bytes()));
191 let nonce = Nonce::from_slice(nonce_bytes);
192 cipher.decrypt(nonce, ciphertext)
193 .map_err(|e| Web4Error::Crypto(format!("ChaCha20-Poly1305 decrypt: {}", e)))
194}
195
196pub fn seal(my: &KeyPair, peer: &PublicKey, pair_id: Uuid, plaintext: &[u8]) -> Result<Sealed> {
200 let shared = my.ecdh_with_peer(peer)?;
201 let key = derive_session_key(&shared, pair_id);
202 encrypt(&key, plaintext)
203}
204
205pub fn open(my: &KeyPair, peer: &PublicKey, pair_id: Uuid, sealed: &Sealed) -> Result<Vec<u8>> {
208 let shared = my.ecdh_with_peer(peer)?;
209 let key = derive_session_key(&shared, pair_id);
210 decrypt(&key, sealed)
211}
212
213const HKDF_INFO_V2: &[u8] = b"web4-paired-channel-v2";
259
260pub struct EphemeralKeyPair {
267 secret: x25519_dalek::StaticSecret,
268 public: x25519_dalek::PublicKey,
269}
270
271impl EphemeralKeyPair {
272 pub fn generate() -> Self {
276 let secret = x25519_dalek::StaticSecret::random_from_rng(&mut rand::thread_rng());
277 let public = x25519_dalek::PublicKey::from(&secret);
278 Self { secret, public }
279 }
280
281 pub fn public_bytes(&self) -> [u8; 32] {
282 *self.public.as_bytes()
283 }
284
285 pub fn secret_hex(&self) -> String {
289 hex_encode(&self.secret.to_bytes())
290 }
291
292 pub fn public_hex(&self) -> String {
293 hex_encode(self.public.as_bytes())
294 }
295
296 pub fn from_secret_hex(s: &str) -> Result<Self> {
299 let bytes = hex_decode(s)?;
300 let arr: [u8; 32] = bytes.as_slice().try_into()
301 .map_err(|_| Web4Error::Crypto(
302 format!("ephemeral secret must be 32 bytes, got {}", bytes.len())
303 ))?;
304 let secret = x25519_dalek::StaticSecret::from(arr);
305 let public = x25519_dalek::PublicKey::from(&secret);
306 Ok(Self { secret, public })
307 }
308}
309
310impl std::fmt::Debug for EphemeralKeyPair {
311 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312 f.write_str("EphemeralKeyPair(<secret redacted>)")
313 }
314}
315
316pub fn ephemeral_public_from_hex(s: &str) -> Result<x25519_dalek::PublicKey> {
320 let bytes = hex_decode(s)?;
321 let arr: [u8; 32] = bytes.as_slice().try_into()
322 .map_err(|_| Web4Error::Crypto(
323 format!("ephemeral pubkey must be 32 bytes, got {}", bytes.len())
324 ))?;
325 Ok(x25519_dalek::PublicKey::from(arr))
326}
327
328pub fn derive_session_key_v2(
332 static_shared: &SharedSecret,
333 ephemeral_shared: &SharedSecret,
334 pair_id: Uuid,
335) -> SessionKey {
336 let mut ikm = Vec::with_capacity(64);
337 ikm.extend_from_slice(static_shared.as_bytes());
338 ikm.extend_from_slice(ephemeral_shared.as_bytes());
339 let hk = Hkdf::<Sha256>::new(Some(pair_id.as_bytes()), &ikm);
340 let mut okm = [0u8; 32];
341 hk.expand(HKDF_INFO_V2, &mut okm)
342 .expect("HKDF expand for 32 bytes never fails");
343 SessionKey(okm)
344}
345
346pub fn seal_fs(
350 my_lct: &KeyPair,
351 my_eph: &EphemeralKeyPair,
352 peer_lct: &PublicKey,
353 peer_eph_pub: &x25519_dalek::PublicKey,
354 pair_id: Uuid,
355 plaintext: &[u8],
356) -> Result<Sealed> {
357 let static_shared = my_lct.ecdh_with_peer(peer_lct)?;
358 let eph_shared = SharedSecret::from_bytes(*my_eph.secret.diffie_hellman(peer_eph_pub).as_bytes());
359 let key = derive_session_key_v2(&static_shared, &eph_shared, pair_id);
360 encrypt(&key, plaintext)
361}
362
363pub fn open_fs(
365 my_lct: &KeyPair,
366 my_eph: &EphemeralKeyPair,
367 peer_lct: &PublicKey,
368 peer_eph_pub: &x25519_dalek::PublicKey,
369 pair_id: Uuid,
370 sealed: &Sealed,
371) -> Result<Vec<u8>> {
372 let static_shared = my_lct.ecdh_with_peer(peer_lct)?;
373 let eph_shared = SharedSecret::from_bytes(*my_eph.secret.diffie_hellman(peer_eph_pub).as_bytes());
374 let key = derive_session_key_v2(&static_shared, &eph_shared, pair_id);
375 decrypt(&key, sealed)
376}
377
378fn hex_encode(bytes: &[u8]) -> String {
381 bytes.iter().map(|b| format!("{:02x}", b)).collect()
382}
383
384fn hex_decode(s: &str) -> Result<Vec<u8>> {
385 if s.len() % 2 != 0 {
386 return Err(Web4Error::Crypto("hex string must have even length".into()));
387 }
388 (0..s.len()).step_by(2).map(|i| {
389 u8::from_str_radix(&s[i..i + 2], 16)
390 .map_err(|e| Web4Error::Crypto(format!("invalid hex: {}", e)))
391 }).collect()
392}
393
394#[cfg(test)]
405mod tests {
406 use super::*;
407
408 #[test]
411 fn seal_open_round_trip() {
412 let alice = KeyPair::generate();
413 let bob = KeyPair::generate();
414 let pair_id = Uuid::new_v4();
415 let plaintext = b"hello bob - this should be e2e";
416
417 let sealed = seal(&alice, &bob.verifying_key(), pair_id, plaintext).unwrap();
418 let recovered = open(&bob, &alice.verifying_key(), pair_id, &sealed).unwrap();
419 assert_eq!(recovered, plaintext);
420 }
421
422 #[test]
424 fn seal_open_is_direction_symmetric() {
425 let alice = KeyPair::generate();
426 let bob = KeyPair::generate();
427 let pair_id = Uuid::new_v4();
428
429 let from_alice = seal(&alice, &bob.verifying_key(), pair_id, b"to bob").unwrap();
430 let from_bob = seal(&bob, &alice.verifying_key(), pair_id, b"to alice").unwrap();
431
432 assert_eq!(open(&bob, &alice.verifying_key(), pair_id, &from_alice).unwrap(), b"to bob");
433 assert_eq!(open(&alice, &bob.verifying_key(), pair_id, &from_bob).unwrap(), b"to alice");
434 }
435
436 #[test]
440 fn tampered_ciphertext_fails_open() {
441 let alice = KeyPair::generate();
442 let bob = KeyPair::generate();
443 let pair_id = Uuid::new_v4();
444
445 let sealed = seal(&alice, &bob.verifying_key(), pair_id, b"important").unwrap();
446 let mut bytes = sealed.into_bytes();
447 let target = bytes.len() / 2;
449 bytes[target] ^= 0xff;
450 let tampered = Sealed::from_bytes(bytes).unwrap();
451 assert!(open(&bob, &alice.verifying_key(), pair_id, &tampered).is_err(),
452 "tampered ciphertext must fail AEAD authentication");
453 }
454
455 #[test]
459 fn different_pair_ids_use_distinct_session_keys() {
460 let alice = KeyPair::generate();
461 let bob = KeyPair::generate();
462 let p1 = Uuid::new_v4();
463 let p2 = Uuid::new_v4();
464
465 let sealed_p1 = seal(&alice, &bob.verifying_key(), p1, b"x").unwrap();
466 assert!(open(&bob, &alice.verifying_key(), p2, &sealed_p1).is_err(),
469 "seal under p1 must not open under p2");
470 }
471
472 #[test]
475 fn wrong_peer_pubkey_fails_open() {
476 let alice = KeyPair::generate();
477 let bob = KeyPair::generate();
478 let carol = KeyPair::generate();
479 let pair_id = Uuid::new_v4();
480
481 let sealed = seal(&alice, &bob.verifying_key(), pair_id, b"for bob").unwrap();
482 assert!(open(&bob, &carol.verifying_key(), pair_id, &sealed).is_err(),
483 "bob using carol's pubkey instead of alice's must fail");
484 }
485
486 #[test]
488 fn sealed_base64_round_trip() {
489 let alice = KeyPair::generate();
490 let bob = KeyPair::generate();
491 let pair_id = Uuid::new_v4();
492
493 let sealed = seal(&alice, &bob.verifying_key(), pair_id, b"hello").unwrap();
494 let b64 = sealed.to_base64();
495 let back = Sealed::from_base64(&b64).unwrap();
496 assert_eq!(sealed.as_bytes(), back.as_bytes());
497 let plain = open(&bob, &alice.verifying_key(), pair_id, &back).unwrap();
499 assert_eq!(plain, b"hello");
500 }
501
502 #[test]
504 fn session_key_debug_is_redacted() {
505 let alice = KeyPair::generate();
506 let bob = KeyPair::generate();
507 let pair_id = Uuid::new_v4();
508 let shared = alice.ecdh_with_peer(&bob.verifying_key()).unwrap();
509 let key = derive_session_key(&shared, pair_id);
510 let s = format!("{:?}", key);
511 assert!(s.contains("redacted"));
512 let hex: String = key.as_bytes().iter()
514 .map(|b| format!("{:02x}", b)).collect();
515 assert!(!s.contains(&hex[..16]));
516 }
517
518 #[test]
521 fn nonces_are_unique_per_encryption() {
522 let alice = KeyPair::generate();
523 let bob = KeyPair::generate();
524 let pair_id = Uuid::new_v4();
525
526 let s1 = seal(&alice, &bob.verifying_key(), pair_id, b"same plaintext").unwrap();
527 let s2 = seal(&alice, &bob.verifying_key(), pair_id, b"same plaintext").unwrap();
528 assert_ne!(s1.as_bytes(), s2.as_bytes(),
529 "identical plaintexts must produce distinct ciphertexts (random nonces)");
530 }
531
532 #[test]
535 fn short_sealed_blob_errors() {
536 let result = Sealed::from_bytes(vec![0u8; 20]);
538 assert!(result.is_err());
539 }
540
541 #[test]
547 fn fs_seal_open_round_trip() {
548 let alice = KeyPair::generate();
549 let bob = KeyPair::generate();
550 let alice_eph = EphemeralKeyPair::generate();
551 let bob_eph = EphemeralKeyPair::generate();
552 let pair_id = Uuid::new_v4();
553 let plaintext = b"forward secrecy demo";
554
555 let sealed = seal_fs(
556 &alice, &alice_eph,
557 &bob.verifying_key(), &bob_eph.public,
558 pair_id, plaintext,
559 ).unwrap();
560 let recovered = open_fs(
561 &bob, &bob_eph,
562 &alice.verifying_key(), &alice_eph.public,
563 pair_id, &sealed,
564 ).unwrap();
565 assert_eq!(recovered, plaintext);
566 }
567
568 #[test]
575 fn fs_v2_ciphertext_cannot_be_opened_with_static_keys_alone() {
576 let alice = KeyPair::generate();
577 let bob = KeyPair::generate();
578 let alice_eph = EphemeralKeyPair::generate();
579 let bob_eph = EphemeralKeyPair::generate();
580 let pair_id = Uuid::new_v4();
581 let plaintext = b"top secret with forward secrecy";
582
583 let sealed = seal_fs(
585 &alice, &alice_eph,
586 &bob.verifying_key(), &bob_eph.public,
587 pair_id, plaintext,
588 ).unwrap();
589
590 let result = open(&alice, &bob.verifying_key(), pair_id, &sealed);
595 assert!(result.is_err(),
596 "FS-sealed ciphertext must not open via the static-only v1 path");
597
598 let fake_eph = EphemeralKeyPair::generate();
602 let result_with_fake = open_fs(
603 &alice, &fake_eph,
604 &bob.verifying_key(), &bob_eph.public,
605 pair_id, &sealed,
606 );
607 assert!(result_with_fake.is_err(),
608 "FS-sealed ciphertext must not open with a fabricated ephemeral");
609 }
610
611 #[test]
615 fn fs_direction_symmetric() {
616 let alice = KeyPair::generate();
617 let bob = KeyPair::generate();
618 let alice_eph = EphemeralKeyPair::generate();
619 let bob_eph = EphemeralKeyPair::generate();
620 let pair_id = Uuid::new_v4();
621
622 let from_alice = seal_fs(
623 &alice, &alice_eph,
624 &bob.verifying_key(), &bob_eph.public,
625 pair_id, b"to bob",
626 ).unwrap();
627 let from_bob = seal_fs(
628 &bob, &bob_eph,
629 &alice.verifying_key(), &alice_eph.public,
630 pair_id, b"to alice",
631 ).unwrap();
632
633 assert_eq!(
634 open_fs(&bob, &bob_eph, &alice.verifying_key(), &alice_eph.public,
635 pair_id, &from_alice).unwrap(),
636 b"to bob"
637 );
638 assert_eq!(
639 open_fs(&alice, &alice_eph, &bob.verifying_key(), &bob_eph.public,
640 pair_id, &from_bob).unwrap(),
641 b"to alice"
642 );
643 }
644
645 #[test]
648 fn fs_ephemeral_secret_hex_round_trip() {
649 let eph = EphemeralKeyPair::generate();
650 let secret_hex = eph.secret_hex();
651 let recovered = EphemeralKeyPair::from_secret_hex(&secret_hex).unwrap();
652 assert_eq!(eph.public_bytes(), recovered.public_bytes(),
653 "recovered keypair must produce the same public key");
654 }
655
656 #[test]
658 fn fs_ephemeral_debug_is_redacted() {
659 let eph = EphemeralKeyPair::generate();
660 let s = format!("{:?}", eph);
661 assert!(s.contains("redacted"));
662 let secret = eph.secret_hex();
664 assert!(!s.contains(&secret[..16]));
665 }
666
667 #[test]
670 fn fs_pair_id_still_binds_session_key() {
671 let alice = KeyPair::generate();
672 let bob = KeyPair::generate();
673 let alice_eph = EphemeralKeyPair::generate();
674 let bob_eph = EphemeralKeyPair::generate();
675 let p1 = Uuid::new_v4();
676 let p2 = Uuid::new_v4();
677
678 let sealed = seal_fs(
679 &alice, &alice_eph, &bob.verifying_key(), &bob_eph.public,
680 p1, b"hello",
681 ).unwrap();
682 let wrong_pair = open_fs(
683 &bob, &bob_eph, &alice.verifying_key(), &alice_eph.public,
684 p2, &sealed,
685 );
686 assert!(wrong_pair.is_err(),
687 "v2 ciphertext under p1 must not open under p2");
688 }
689
690 #[test]
693 fn fs_ephemeral_public_hex_parse_round_trip() {
694 let eph = EphemeralKeyPair::generate();
695 let pub_hex = eph.public_hex();
696 let parsed = ephemeral_public_from_hex(&pub_hex).unwrap();
697 assert_eq!(parsed.as_bytes(), &eph.public_bytes());
698 }
699}