Skip to main content

pim_crypto/
e2e.rs

1//! End-to-end encryption between a mesh client and its gateway.
2//!
3//! Uses ECIES: Ephemeral X25519 ECDH + HKDF-SHA256 → AES-256-GCM.
4//!
5//! # Wire format
6//!
7//! ```text
8//! ephemeral_pub (32)  || nonce (12) || ciphertext (variable) || tag (16)
9//! ```
10//!
11//! The gateway's static X25519 key is derived from its Ed25519 seed via HKDF
12//! so that no separate key file is needed.
13
14use aes_gcm::aead::KeyInit;
15use aes_gcm::{Aes256Gcm, Nonce};
16use hkdf::Hkdf;
17use rand::rngs::OsRng;
18use sha2::Sha256;
19use x25519_dalek::{PublicKey as X25519PublicKey, SharedSecret, StaticSecret};
20
21// ── Error ─────────────────────────────────────────────────────────────────────
22
23#[derive(Debug, thiserror::Error)]
24/// Errors returned by end-to-end client-to-gateway encryption helpers.
25pub enum E2eError {
26    /// The ciphertext was too short to contain the required header and tag.
27    #[error("ciphertext too short to contain E2E header")]
28    TooShort,
29    /// Authentication failed or the wrong key was used.
30    #[error("decryption failed (invalid ciphertext or wrong key)")]
31    DecryptionFailed,
32    /// Encryption failed unexpectedly.
33    #[error("encryption failed")]
34    EncryptionFailed,
35}
36
37// ── Key material sizes ────────────────────────────────────────────────────────
38
39const EPHEMERAL_PUB_SIZE: usize = 32;
40const NONCE_SIZE: usize = 12;
41const TAG_SIZE: usize = 16;
42const HEADER_SIZE: usize = EPHEMERAL_PUB_SIZE + NONCE_SIZE; // 44
43
44fn derive_e2e_cipher(shared_secret: &SharedSecret) -> Aes256Gcm {
45    let hk = Hkdf::<Sha256>::new(None, shared_secret.as_bytes());
46    let mut okm = [0u8; 32];
47    hk.expand(b"pim-e2e-v1", &mut okm)
48        .expect("32 bytes is valid");
49    Aes256Gcm::new_from_slice(&okm).expect("32 bytes is valid")
50}
51
52// ── Public API ────────────────────────────────────────────────────────────────
53
54/// Derive an X25519 static secret from an Ed25519 seed.
55///
56/// Both the client (for ECDH) and the gateway (for decryption) use this same
57/// derivation so that the gateway's public X25519 key can be included in its
58/// advertisement or config without requiring a separate key file.
59pub fn x25519_from_seed(ed25519_seed: &[u8; 32]) -> StaticSecret {
60    let hk = Hkdf::<Sha256>::new(None, ed25519_seed);
61    let mut key_bytes = [0u8; 32];
62    hk.expand(b"pim-x25519-identity-v1", &mut key_bytes)
63        .expect("32 bytes is valid");
64    StaticSecret::from(key_bytes)
65}
66
67/// Return the X25519 public key corresponding to `ed25519_seed`.
68pub fn x25519_public_from_seed(ed25519_seed: &[u8; 32]) -> [u8; 32] {
69    X25519PublicKey::from(&x25519_from_seed(ed25519_seed)).to_bytes()
70}
71
72/// Encrypt `plaintext` to `gateway_x25519_pub` using ECIES.
73///
74/// Returns: `ephemeral_pub (32) || nonce (12) || ciphertext || tag (16)`.
75pub fn e2e_encrypt(plaintext: &[u8], gateway_x25519_pub: &[u8; 32]) -> Result<Vec<u8>, E2eError> {
76    // Generate ephemeral X25519 key pair
77    let ephemeral_secret = StaticSecret::random_from_rng(OsRng);
78    let ephemeral_pub = X25519PublicKey::from(&ephemeral_secret);
79
80    // ECDH with gateway's static public key
81    let gateway_pub = X25519PublicKey::from(*gateway_x25519_pub);
82    let shared_secret = ephemeral_secret.diffie_hellman(&gateway_pub);
83
84    // Random nonce
85    let mut nonce_bytes = [0u8; NONCE_SIZE];
86    rand::RngCore::fill_bytes(&mut OsRng, &mut nonce_bytes);
87    let nonce = Nonce::from_slice(&nonce_bytes);
88
89    // AES-256-GCM encrypt in-place
90    let cipher = derive_e2e_cipher(&shared_secret);
91
92    // Assemble directly into the output buffer to avoid intermediate allocations
93    let mut out = Vec::with_capacity(HEADER_SIZE + plaintext.len() + TAG_SIZE);
94    out.extend_from_slice(&ephemeral_pub.to_bytes());
95    out.extend_from_slice(&nonce_bytes);
96    out.extend_from_slice(plaintext);
97
98    use aes_gcm::aead::AeadInPlace;
99    let tag = cipher
100        .encrypt_in_place_detached(nonce, b"", &mut out[HEADER_SIZE..])
101        .map_err(|_| E2eError::EncryptionFailed)?;
102
103    out.extend_from_slice(&tag);
104
105    Ok(out)
106}
107
108/// Decrypt an E2E-encrypted frame using the gateway's Ed25519 seed.
109///
110/// `ciphertext` is the output of [`e2e_encrypt`].
111pub fn e2e_decrypt<'a>(
112    ciphertext: &'a mut [u8],
113    gateway_ed25519_seed: &[u8; 32],
114) -> Result<&'a [u8], E2eError> {
115    if ciphertext.len() < HEADER_SIZE + TAG_SIZE {
116        return Err(E2eError::TooShort);
117    }
118
119    // Parse header
120    let mut ephemeral_pub_bytes = [0u8; 32];
121    ephemeral_pub_bytes.copy_from_slice(&ciphertext[..32]);
122    let ephemeral_pub = X25519PublicKey::from(ephemeral_pub_bytes);
123
124    let nonce_bytes = &ciphertext[32..44];
125
126    // Derive gateway's static X25519 secret from Ed25519 seed
127    let gw_secret = x25519_from_seed(gateway_ed25519_seed);
128
129    // ECDH
130    let shared_secret = gw_secret.diffie_hellman(&ephemeral_pub);
131
132    // Decrypt
133    use aes_gcm::aead::AeadInPlace;
134    let cipher = derive_e2e_cipher(&shared_secret);
135
136    let mut nonce_array = [0u8; 12];
137    nonce_array.copy_from_slice(nonce_bytes);
138    let nonce = Nonce::from_slice(&nonce_array);
139
140    let tag_start = ciphertext.len() - TAG_SIZE;
141    let mut tag_array = [0u8; 16];
142    tag_array.copy_from_slice(&ciphertext[tag_start..]);
143    let tag = aes_gcm::aead::Tag::<Aes256Gcm>::from_slice(&tag_array);
144
145    let (body, _) = ciphertext.split_at_mut(tag_start);
146    let (_, payload) = body.split_at_mut(HEADER_SIZE);
147
148    cipher
149        .decrypt_in_place_detached(nonce, b"", payload, tag)
150        .map_err(|_| E2eError::DecryptionFailed)?;
151
152    Ok(&ciphertext[HEADER_SIZE..tag_start])
153}
154
155/// Decrypt an E2E-encrypted frame in-place, modifying the provided buffer.
156///
157/// Upon success, the plaintext will be shifted to the start of `buffer`
158/// and the buffer will be truncated to the plaintext length.
159pub fn e2e_decrypt_in_place(
160    buffer: &mut Vec<u8>,
161    gateway_ed25519_seed: &[u8; 32],
162) -> Result<(), E2eError> {
163    if buffer.len() < HEADER_SIZE + TAG_SIZE {
164        return Err(E2eError::TooShort);
165    }
166
167    // Parse header
168    let mut ephemeral_pub_bytes = [0u8; 32];
169    ephemeral_pub_bytes.copy_from_slice(&buffer[..32]);
170    let ephemeral_pub = X25519PublicKey::from(ephemeral_pub_bytes);
171
172    let nonce = *Nonce::from_slice(&buffer[32..44]);
173
174    // Derive gateway's static X25519 secret from Ed25519 seed
175    let gw_secret = x25519_from_seed(gateway_ed25519_seed);
176
177    // ECDH
178    let shared_secret = gw_secret.diffie_hellman(&ephemeral_pub);
179
180    let cipher = derive_e2e_cipher(&shared_secret);
181
182    // Decrypt in place. AeadInPlace requires the tag to be provided and removed from the ciphertext.
183    let ct_len = buffer.len() - HEADER_SIZE - TAG_SIZE;
184    let mut tag_bytes = [0u8; TAG_SIZE];
185    tag_bytes.copy_from_slice(&buffer[buffer.len() - TAG_SIZE..]);
186    let tag = aes_gcm::aead::Tag::<Aes256Gcm>::from_slice(&tag_bytes);
187
188    // Shift ciphertext to the start of the buffer so AeadInPlace can work on it
189    buffer.copy_within(HEADER_SIZE..HEADER_SIZE + ct_len, 0);
190    buffer.truncate(ct_len);
191
192    aes_gcm::aead::AeadInPlace::decrypt_in_place_detached(&cipher, &nonce, b"", buffer, tag)
193        .map_err(|_| E2eError::DecryptionFailed)?;
194
195    Ok(())
196}
197
198// ── Tests ─────────────────────────────────────────────────────────────────────
199
200#[cfg(test)]
201mod tests;