Skip to main content

void_crypto/
pin.rs

1//! PIN-based encryption for identity keys.
2//!
3//! Encrypts signing, recipient, and (optionally) Nostr secret keys using:
4//! - Argon2id for PIN → key derivation (memory-hard, resistant to brute force)
5//! - AES-256-GCM for authenticated encryption
6//!
7//! ## Format versions
8//!
9//! **v1** (legacy): `[1B version=1][16B salt][12B nonce][64B encrypted][16B tag]`
10//! - Payload: 32B signing + 32B recipient = 64 bytes
11//!
12//! **v2** (current): `[1B version=2][16B salt][12B nonce][96B encrypted][16B tag]`
13//! - Payload: 32B signing + 32B recipient + 32B nostr = 96 bytes
14//!
15//! Decryption is backward-compatible: v1 blobs return `nostr = None`.
16
17use aes_gcm::{
18    aead::{Aead, KeyInit},
19    Aes256Gcm,
20};
21use argon2::Argon2;
22use rand::RngCore;
23use zeroize::Zeroize;
24
25use crate::kdf::{AeadNonce, NostrSecretKey, RecipientSecretKey, SigningSecretKey};
26
27/// Current format version (v2 includes Nostr key).
28const VERSION_V2: u8 = 2;
29
30/// Legacy format version (signing + recipient only).
31const VERSION_V1: u8 = 1;
32
33/// AAD for identity key encryption (shared across versions).
34const AAD: &[u8] = b"void:identity-keys:v1";
35
36/// Header sizes.
37const VERSION_LEN: usize = 1;
38const SALT_LEN: usize = 16;
39const TAG_LEN: usize = 16;
40
41/// Key payload sizes.
42const KEYS_LEN_V1: usize = 64; // 32 signing + 32 recipient
43const KEYS_LEN_V2: usize = 96; // 32 signing + 32 recipient + 32 nostr
44
45/// Minimum valid encrypted blob size (v1).
46const MIN_ENCRYPTED_LEN: usize =
47    VERSION_LEN + SALT_LEN + AeadNonce::SIZE + KEYS_LEN_V1 + TAG_LEN;
48
49/// Errors during PIN encryption/decryption.
50#[derive(Debug, thiserror::Error)]
51pub enum PinError {
52    #[error("PIN must not be empty")]
53    EmptyPin,
54
55    #[error("key derivation failed: {0}")]
56    DerivationFailed(String),
57
58    #[error("encryption failed")]
59    EncryptionFailed,
60
61    #[error("decryption failed (wrong PIN or corrupted data)")]
62    DecryptionFailed,
63
64    #[error("invalid encrypted data: too short")]
65    DataTooShort,
66
67    #[error("unsupported format version: {0}")]
68    UnsupportedVersion(u8),
69}
70
71/// Encrypt signing, recipient, and Nostr keys using a PIN (v2 format).
72///
73/// Returns a binary blob containing all data needed for decryption:
74/// version, salt, nonce, and the encrypted+authenticated keys.
75pub fn encrypt_identity_keys(
76    signing: &SigningSecretKey,
77    recipient: &RecipientSecretKey,
78    nostr: &NostrSecretKey,
79    pin: &str,
80) -> Result<Vec<u8>, PinError> {
81    if pin.is_empty() {
82        return Err(PinError::EmptyPin);
83    }
84
85    // Generate random salt and nonce
86    let mut salt = [0u8; SALT_LEN];
87    rand::thread_rng().fill_bytes(&mut salt);
88    let nonce = AeadNonce::generate();
89
90    // Derive encryption key from PIN using Argon2id
91    let mut derived_key = derive_key_from_pin(pin, &salt)?;
92
93    // Concatenate keys for encryption (v2: 96 bytes)
94    let mut plaintext = [0u8; KEYS_LEN_V2];
95    plaintext[..32].copy_from_slice(signing.as_bytes());
96    plaintext[32..64].copy_from_slice(recipient.as_bytes());
97    plaintext[64..96].copy_from_slice(nostr.as_bytes());
98
99    // Encrypt with AES-256-GCM
100    let cipher =
101        Aes256Gcm::new_from_slice(&derived_key).map_err(|_| PinError::EncryptionFailed)?;
102    let gcm_nonce = aes_gcm::Nonce::from_slice(nonce.as_bytes());
103
104    let ciphertext = match cipher.encrypt(
105        gcm_nonce,
106        aes_gcm::aead::Payload {
107            msg: &plaintext,
108            aad: AAD,
109        },
110    ) {
111        Ok(ciphertext) => ciphertext,
112        Err(_) => {
113            derived_key.zeroize();
114            plaintext.zeroize();
115            return Err(PinError::EncryptionFailed);
116        }
117    };
118
119    // Zero sensitive intermediates
120    derived_key.zeroize();
121    plaintext.zeroize();
122
123    // Build output: version || salt || nonce || ciphertext (includes tag)
124    let mut output =
125        Vec::with_capacity(VERSION_LEN + SALT_LEN + AeadNonce::SIZE + ciphertext.len());
126    output.push(VERSION_V2);
127    output.extend_from_slice(&salt);
128    output.extend_from_slice(nonce.as_bytes());
129    output.extend_from_slice(&ciphertext);
130
131    Ok(output)
132}
133
134/// Decrypt identity keys using a PIN.
135///
136/// Supports both v1 (signing + recipient) and v2 (signing + recipient + nostr) formats.
137/// Returns `(signing, recipient, Option<nostr>)` — nostr is `None` for v1 blobs.
138pub fn decrypt_identity_keys(
139    encrypted: &[u8],
140    pin: &str,
141) -> Result<(SigningSecretKey, RecipientSecretKey, Option<NostrSecretKey>), PinError> {
142    if pin.is_empty() {
143        return Err(PinError::EmptyPin);
144    }
145
146    if encrypted.len() < MIN_ENCRYPTED_LEN {
147        return Err(PinError::DataTooShort);
148    }
149
150    // Parse header
151    let version = encrypted[0];
152    let expected_payload_len = match version {
153        VERSION_V1 => KEYS_LEN_V1,
154        VERSION_V2 => KEYS_LEN_V2,
155        _ => return Err(PinError::UnsupportedVersion(version)),
156    };
157
158    let salt = &encrypted[VERSION_LEN..VERSION_LEN + SALT_LEN];
159    let nonce_start = VERSION_LEN + SALT_LEN;
160    let nonce_end = nonce_start + AeadNonce::SIZE;
161    let nonce = AeadNonce::from_bytes(&encrypted[nonce_start..nonce_end])
162        .ok_or(PinError::DataTooShort)?;
163    let ciphertext = &encrypted[nonce_end..];
164
165    // Derive decryption key from PIN
166    let mut derived_key = derive_key_from_pin(pin, salt)?;
167
168    // Decrypt with AES-256-GCM
169    let cipher =
170        Aes256Gcm::new_from_slice(&derived_key).map_err(|_| PinError::DecryptionFailed)?;
171    let gcm_nonce = aes_gcm::Nonce::from_slice(nonce.as_bytes());
172
173    let mut plaintext = match cipher.decrypt(
174        gcm_nonce,
175        aes_gcm::aead::Payload {
176            msg: ciphertext,
177            aad: AAD,
178        },
179    ) {
180        Ok(plaintext) => plaintext,
181        Err(_) => {
182            derived_key.zeroize();
183            return Err(PinError::DecryptionFailed);
184        }
185    };
186
187    // Zero derived key
188    derived_key.zeroize();
189
190    if plaintext.len() != expected_payload_len {
191        plaintext.zeroize();
192        return Err(PinError::DecryptionFailed);
193    }
194
195    // Extract keys
196    let mut signing_bytes = [0u8; 32];
197    let mut recipient_bytes = [0u8; 32];
198    signing_bytes.copy_from_slice(&plaintext[..32]);
199    recipient_bytes.copy_from_slice(&plaintext[32..64]);
200
201    let nostr = if version == VERSION_V2 {
202        let mut nostr_bytes = [0u8; 32];
203        nostr_bytes.copy_from_slice(&plaintext[64..96]);
204        Some(NostrSecretKey::from_bytes(nostr_bytes))
205    } else {
206        None
207    };
208
209    // Zero plaintext
210    plaintext.zeroize();
211
212    Ok((
213        SigningSecretKey::from_bytes(signing_bytes),
214        RecipientSecretKey::from_bytes(recipient_bytes),
215        nostr,
216    ))
217}
218
219/// Derive a 32-byte key from a PIN and salt using Argon2id.
220///
221/// Parameters: 64MB memory, 3 iterations, 4 lanes (~500ms on typical hardware).
222fn derive_key_from_pin(pin: &str, salt: &[u8]) -> Result<[u8; 32], PinError> {
223    let params = argon2::Params::new(
224        64 * 1024, // 64 MB memory cost
225        3,         // 3 iterations
226        4,         // 4 lanes of parallelism
227        Some(32),  // 32-byte output
228    )
229    .map_err(|e| PinError::DerivationFailed(e.to_string()))?;
230
231    let argon2 = Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
232
233    let mut output = [0u8; 32];
234    argon2
235        .hash_password_into(pin.as_bytes(), salt, &mut output)
236        .map_err(|e| PinError::DerivationFailed(e.to_string()))?;
237
238    Ok(output)
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    fn test_keys() -> (SigningSecretKey, RecipientSecretKey, NostrSecretKey) {
246        (
247            SigningSecretKey::from_bytes([0x42u8; 32]),
248            RecipientSecretKey::from_bytes([0x99u8; 32]),
249            NostrSecretKey::from_bytes([0xaa; 32]),
250        )
251    }
252
253    #[test]
254    fn roundtrip_encrypt_decrypt_v2() {
255        let (signing, recipient, nostr) = test_keys();
256        let pin = "1234";
257
258        let encrypted = encrypt_identity_keys(&signing, &recipient, &nostr, pin).unwrap();
259        assert_eq!(encrypted[0], VERSION_V2);
260
261        let (dec_signing, dec_recipient, dec_nostr) =
262            decrypt_identity_keys(&encrypted, pin).unwrap();
263
264        assert_eq!(signing.as_bytes(), dec_signing.as_bytes());
265        assert_eq!(recipient.as_bytes(), dec_recipient.as_bytes());
266        assert!(dec_nostr.is_some());
267        assert_eq!(nostr.as_bytes(), dec_nostr.unwrap().as_bytes());
268    }
269
270    #[test]
271    fn backward_compat_v1_decrypt() {
272        let signing = SigningSecretKey::from_bytes([0x42u8; 32]);
273        let recipient = RecipientSecretKey::from_bytes([0x99u8; 32]);
274        let pin = "test-pin";
275
276        // Build v1 blob manually
277        let mut salt = [0u8; SALT_LEN];
278        rand::thread_rng().fill_bytes(&mut salt);
279        let nonce = AeadNonce::generate();
280
281        let derived_key = derive_key_from_pin(pin, &salt).unwrap();
282        let cipher = Aes256Gcm::new_from_slice(&derived_key).unwrap();
283        let gcm_nonce = aes_gcm::Nonce::from_slice(nonce.as_bytes());
284
285        let mut plaintext = [0u8; KEYS_LEN_V1];
286        plaintext[..32].copy_from_slice(signing.as_bytes());
287        plaintext[32..].copy_from_slice(recipient.as_bytes());
288
289        let ciphertext = cipher
290            .encrypt(
291                gcm_nonce,
292                aes_gcm::aead::Payload {
293                    msg: &plaintext,
294                    aad: AAD,
295                },
296            )
297            .unwrap();
298
299        let mut v1_blob = Vec::new();
300        v1_blob.push(VERSION_V1);
301        v1_blob.extend_from_slice(&salt);
302        v1_blob.extend_from_slice(nonce.as_bytes());
303        v1_blob.extend_from_slice(&ciphertext);
304
305        let (dec_signing, dec_recipient, dec_nostr) =
306            decrypt_identity_keys(&v1_blob, pin).unwrap();
307
308        assert_eq!(signing.as_bytes(), dec_signing.as_bytes());
309        assert_eq!(recipient.as_bytes(), dec_recipient.as_bytes());
310        assert!(dec_nostr.is_none());
311    }
312
313    #[test]
314    fn wrong_pin_fails() {
315        let (signing, recipient, nostr) = test_keys();
316
317        let encrypted = encrypt_identity_keys(&signing, &recipient, &nostr, "correct").unwrap();
318        let result = decrypt_identity_keys(&encrypted, "wrong");
319
320        assert!(result.is_err());
321        assert!(matches!(result, Err(PinError::DecryptionFailed)));
322    }
323
324    #[test]
325    fn tampered_data_fails() {
326        let (signing, recipient, nostr) = test_keys();
327
328        let mut encrypted = encrypt_identity_keys(&signing, &recipient, &nostr, "pin").unwrap();
329        let last = encrypted.len() - 1;
330        encrypted[last] ^= 0xFF;
331
332        let result = decrypt_identity_keys(&encrypted, "pin");
333        assert!(result.is_err());
334    }
335
336    #[test]
337    fn empty_pin_rejected() {
338        let (signing, recipient, nostr) = test_keys();
339
340        let result = encrypt_identity_keys(&signing, &recipient, &nostr, "");
341        assert!(matches!(result, Err(PinError::EmptyPin)));
342
343        let result = decrypt_identity_keys(&[0u8; MIN_ENCRYPTED_LEN], "");
344        assert!(matches!(result, Err(PinError::EmptyPin)));
345    }
346
347    #[test]
348    fn too_short_data_rejected() {
349        let result = decrypt_identity_keys(&[1u8; 5], "pin");
350        assert!(matches!(result, Err(PinError::DataTooShort)));
351    }
352
353    #[test]
354    fn unsupported_version_rejected() {
355        let (signing, recipient, nostr) = test_keys();
356
357        let mut encrypted = encrypt_identity_keys(&signing, &recipient, &nostr, "pin").unwrap();
358        encrypted[0] = 99;
359
360        let result = decrypt_identity_keys(&encrypted, "pin");
361        assert!(matches!(result, Err(PinError::UnsupportedVersion(99))));
362    }
363
364    #[test]
365    fn different_encryptions_produce_different_output() {
366        let (signing, recipient, nostr) = test_keys();
367        let pin = "same-pin";
368
369        let enc1 = encrypt_identity_keys(&signing, &recipient, &nostr, pin).unwrap();
370        let enc2 = encrypt_identity_keys(&signing, &recipient, &nostr, pin).unwrap();
371
372        assert_ne!(enc1, enc2);
373
374        let (s1, r1, n1) = decrypt_identity_keys(&enc1, pin).unwrap();
375        let (s2, r2, n2) = decrypt_identity_keys(&enc2, pin).unwrap();
376        assert_eq!(s1.as_bytes(), s2.as_bytes());
377        assert_eq!(r1.as_bytes(), r2.as_bytes());
378        assert_eq!(n1.unwrap().as_bytes(), n2.unwrap().as_bytes());
379    }
380}