Skip to main content

webylib/
passkey.rs

1//! Passkey encryption for Webcash wallets
2//!
3//! This module provides state-of-the-art passkey encryption functionality for Webcash wallets,
4//! supporting both iOS (Face ID/Touch ID) and Android (Passkey API) platforms.
5//!
6//! # Security Architecture
7//!
8//! The passkey encryption system follows these principles:
9//! 1. **Key Isolation**: Encryption keys are protected by platform hardware security modules
10//! 2. **Zero Secrets**: Passkey data never leaves the device's secure enclave
11//! 3. **Forward Secrecy**: Keys are regenerated when passkey enrollment changes
12//! 4. **Defense in Depth**: Multiple layers of encryption and authentication
13//!
14//! # Implementation Strategy
15//!
16//! ## iOS Integration
17//! - Uses iOS Keychain Services with `kSecAccessControl` and `.biometryAny` flags
18//! - Leverages Secure Enclave for key storage and passkey verification
19//! - Supports both Face ID and Touch ID seamlessly
20//! - Falls back to device passcode when passkeys unavailable
21//!
22//! ## Android Integration
23//! - Uses Android Keystore with passkey authentication requirements
24//! - Supports fingerprint, face unlock, and iris scanning
25//! - Integrates with Android Credential Manager API for unified experience
26//! - Hardware security module protection when available
27//!
28//! # Usage Patterns
29//!
30//! ```rust,no_run
31//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
32//! use webylib::passkey::{PasskeyEncryption, EncryptionConfig};
33//!
34//! // Initialize with platform-specific configuration
35//! let mut passkey = PasskeyEncryption::new(EncryptionConfig::default())?;
36//!
37//! // Encrypt wallet with passkey protection
38//! let wallet_data = b"wallet data";
39//! let encrypted_data = passkey.encrypt_with_passkey(wallet_data).await?;
40//!
41//! // Decrypt wallet (triggers passkey prompt)
42//! let decrypted_data = passkey.decrypt_with_passkey(&encrypted_data).await?;
43//! # Ok(())
44//! # }
45//! ```
46
47use crate::crypto::CryptoSecret;
48use crate::error::{Error, Result};
49use aes_gcm::aead::{generic_array::GenericArray, Aead};
50use hkdf::Hkdf;
51use serde::{Deserialize, Serialize};
52use sha2::Sha256;
53use zeroize::Zeroize;
54
55/// Configuration for passkey encryption
56#[derive(Debug, Clone)]
57pub struct EncryptionConfig {
58    /// Application identifier for keychain/keystore
59    pub app_identifier: String,
60    /// Service name for key storage
61    pub service_name: String,
62    /// Require passkey authentication for every use
63    pub require_auth_every_use: bool,
64    /// Authentication timeout in seconds (0 = always require auth)
65    pub auth_timeout_seconds: u32,
66    /// Fallback to device passcode when passkey fails
67    pub allow_device_passcode_fallback: bool,
68}
69
70impl Default for EncryptionConfig {
71    fn default() -> Self {
72        Self {
73            app_identifier: "com.webycash.webylib".to_string(),
74            service_name: "WalletEncryption".to_string(),
75            require_auth_every_use: true,
76            auth_timeout_seconds: 0,
77            allow_device_passcode_fallback: true,
78        }
79    }
80}
81
82/// Encrypted data container with metadata
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EncryptedData {
85    /// Encrypted payload
86    pub ciphertext: Vec<u8>,
87    /// AES-GCM nonce/IV
88    pub nonce: [u8; 12],
89    /// Key derivation salt
90    pub salt: [u8; 32],
91    /// Encryption algorithm identifier
92    pub algorithm: String,
93    /// Key derivation parameters
94    pub kdf_params: KdfParams,
95    /// Metadata (non-sensitive)
96    pub metadata: EncryptionMetadata,
97}
98
99/// Key derivation parameters
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct KdfParams {
102    /// HKDF info string
103    pub info: String,
104    /// Iteration count (for PBKDF2 if used)
105    pub iterations: u32,
106    /// Memory cost (for Argon2 if used)
107    pub memory_cost: u32,
108    /// Parallelism (for Argon2 if used)
109    pub parallelism: u32,
110}
111
112impl Default for KdfParams {
113    fn default() -> Self {
114        Self {
115            info: "webycash-passkey-v1".to_string(),
116            iterations: 100_000,
117            memory_cost: 65536, // 64MB
118            parallelism: 4,
119        }
120    }
121}
122
123/// Encryption metadata (non-sensitive information)
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct EncryptionMetadata {
126    /// Timestamp when encrypted
127    pub encrypted_at: String,
128    /// Platform (ios/android/other)
129    pub platform: String,
130    /// Wallet version
131    pub version: String,
132    /// Passkey type used (if known)
133    pub passkey_type: Option<String>,
134}
135
136/// Main passkey encryption interface
137pub struct PasskeyEncryption {
138    #[allow(dead_code)] // Reserved for future platform-specific keychain/keystore implementations
139    config: EncryptionConfig,
140    cached_key: Option<CryptoSecret>,
141}
142
143impl PasskeyEncryption {
144    /// Create new passkey encryption instance
145    pub fn new(config: EncryptionConfig) -> Result<Self> {
146        Ok(Self {
147            config,
148            cached_key: None,
149        })
150    }
151
152    /// Encrypt data with passkey protection
153    ///
154    /// This method:
155    /// 1. Generates or retrieves a passkey-protected key
156    /// 2. Derives encryption key using HKDF
157    /// 3. Encrypts data using AES-256-GCM
158    /// 4. Returns encrypted container with all metadata
159    pub async fn encrypt_with_passkey(&mut self, plaintext: &[u8]) -> Result<EncryptedData> {
160        // Generate salt for key derivation
161        let mut salt = [0u8; 32];
162        getrandom::getrandom(&mut salt)
163            .map_err(|e| Error::crypto(format!("Failed to generate salt: {}", e)))?;
164
165        // Get or generate master key protected by passkey
166        let master_key = self.get_or_create_passkey_key().await?;
167
168        // Derive encryption key using HKDF
169        let encryption_key = self.derive_encryption_key(&master_key, &salt)?;
170
171        // Generate nonce for AES-GCM
172        let cipher = encryption_key.create_cipher();
173        let mut nonce_bytes = [0u8; 12];
174        getrandom::getrandom(&mut nonce_bytes)
175            .map_err(|e| Error::crypto(format!("Failed to generate nonce: {}", e)))?;
176        let nonce = GenericArray::from_slice(&nonce_bytes);
177
178        // Encrypt the data
179        let ciphertext = cipher
180            .encrypt(nonce, plaintext)
181            .map_err(|e| Error::crypto(format!("Encryption failed: {}", e)))?;
182
183        // Create metadata
184        let metadata = EncryptionMetadata {
185            encrypted_at: format!(
186                "{}",
187                std::time::SystemTime::now()
188                    .duration_since(std::time::UNIX_EPOCH)
189                    .unwrap()
190                    .as_secs()
191            ),
192            platform: self.get_platform_name(),
193            version: "1.0".to_string(),
194            passkey_type: self.get_available_passkey_type().await,
195        };
196
197        Ok(EncryptedData {
198            ciphertext,
199            nonce: nonce_bytes,
200            salt,
201            algorithm: "AES-256-GCM".to_string(),
202            kdf_params: KdfParams::default(),
203            metadata,
204        })
205    }
206
207    /// Decrypt data using passkey authentication
208    ///
209    /// This method:
210    /// 1. Triggers passkey authentication
211    /// 2. Retrieves the passkey-protected key
212    /// 3. Derives decryption key using stored parameters
213    /// 4. Decrypts and returns the original data
214    pub async fn decrypt_with_passkey(
215        &mut self,
216        encrypted_data: &EncryptedData,
217    ) -> Result<Vec<u8>> {
218        // Validate encryption format
219        if encrypted_data.algorithm != "AES-256-GCM" {
220            return Err(Error::crypto("Unsupported encryption algorithm"));
221        }
222
223        // Authenticate and get master key
224        let master_key = self.authenticate_and_get_key().await?;
225
226        // Derive decryption key
227        let decryption_key = self.derive_encryption_key(&master_key, &encrypted_data.salt)?;
228
229        // Decrypt the data
230        let cipher = decryption_key.create_cipher();
231        let nonce = GenericArray::from_slice(&encrypted_data.nonce);
232
233        let plaintext = cipher
234            .decrypt(nonce, encrypted_data.ciphertext.as_slice())
235            .map_err(|e| Error::crypto(format!("Decryption failed: {}", e)))?;
236
237        Ok(plaintext)
238    }
239
240    /// Clear any cached keys from memory
241    pub fn clear_cached_keys(&mut self) {
242        if let Some(mut key) = self.cached_key.take() {
243            key.zeroize();
244        }
245    }
246
247    /// Check if passkey authentication is available on this device
248    /// Check if passkey is available. The keyring crate supports all major platforms.
249    pub async fn is_passkey_available(&self) -> Result<bool> {
250        // Try creating a keyring entry — if it succeeds, the platform supports it
251        keyring::Entry::new(&self.config.service_name, &self.config.app_identifier)
252            .map(|_| true)
253            .or(Ok(false))
254    }
255
256    /// Get available passkey type description for this platform.
257    pub async fn get_available_passkey_type(&self) -> Option<String> {
258        #[cfg(target_os = "macos")]
259        {
260            Some("macOS Keychain (Touch ID / Apple Watch / Passcode)".to_string())
261        }
262        #[cfg(target_os = "ios")]
263        {
264            Some("iOS Keychain (Face ID / Touch ID)".to_string())
265        }
266        #[cfg(target_os = "linux")]
267        {
268            Some("Linux Secret Service (GNOME Keyring / KDE Wallet)".to_string())
269        }
270        #[cfg(target_os = "windows")]
271        {
272            Some("Windows Credential Manager".to_string())
273        }
274        #[cfg(target_os = "freebsd")]
275        {
276            Some("FreeBSD file-based keyring".to_string())
277        }
278        #[cfg(not(any(
279            target_os = "macos",
280            target_os = "ios",
281            target_os = "linux",
282            target_os = "windows",
283            target_os = "freebsd"
284        )))]
285        {
286            None
287        }
288    }
289
290    // Private implementation methods
291
292    /// Get or create a master key protected by passkey
293    async fn get_or_create_passkey_key(&mut self) -> Result<CryptoSecret> {
294        // Check if we have a cached key (for performance)
295        if let Some(ref key) = self.cached_key {
296            return Ok(key.clone());
297        }
298
299        // Try to retrieve existing key first
300        match self.retrieve_passkey_key().await {
301            Ok(key) => {
302                self.cached_key = Some(key.clone());
303                Ok(key)
304            }
305            Err(_) => {
306                // No existing key, create new one
307                let key = CryptoSecret::generate()
308                    .map_err(|e| Error::crypto(format!("Failed to generate master key: {}", e)))?;
309
310                self.store_passkey_key(&key).await?;
311                self.cached_key = Some(key.clone());
312                Ok(key)
313            }
314        }
315    }
316
317    /// Authenticate with passkey and get the master key
318    async fn authenticate_and_get_key(&mut self) -> Result<CryptoSecret> {
319        // Check cached key first
320        if let Some(ref key) = self.cached_key {
321            // Verify the cached key is still valid
322            if self.verify_passkey_access().await? {
323                return Ok(key.clone());
324            } else {
325                // Clear invalid cached key
326                self.clear_cached_keys();
327            }
328        }
329
330        // Perform passkey authentication and retrieve key
331        let key = self.retrieve_passkey_key().await?;
332        self.cached_key = Some(key.clone());
333        Ok(key)
334    }
335
336    /// Derive encryption key from master key using HKDF
337    fn derive_encryption_key(
338        &self,
339        master_key: &CryptoSecret,
340        salt: &[u8; 32],
341    ) -> Result<CryptoSecret> {
342        let hk = Hkdf::<Sha256>::new(Some(salt), master_key.as_bytes());
343        let mut okm = [0u8; 32];
344        hk.expand(b"webycash-passkey-v1", &mut okm)
345            .map_err(|e| Error::crypto(format!("Key derivation failed: {}", e)))?;
346
347        Ok(CryptoSecret::from_bytes(okm))
348    }
349
350    /// Get platform name
351    fn get_platform_name(&self) -> String {
352        #[cfg(target_os = "ios")]
353        return "ios".to_string();
354        #[cfg(target_os = "android")]
355        return "android".to_string();
356        #[cfg(target_os = "macos")]
357        return "macos".to_string();
358        #[cfg(target_os = "linux")]
359        return "linux".to_string();
360        #[cfg(target_os = "windows")]
361        return "windows".to_string();
362        #[cfg(not(any(
363            target_os = "ios",
364            target_os = "android",
365            target_os = "macos",
366            target_os = "linux",
367            target_os = "windows"
368        )))]
369        return "other".to_string();
370    }
371
372    // All platform-specific passkey storage is handled by the keyring crate.
373    // macOS: Security Framework Keychain (Touch ID / Apple Watch / Passcode)
374    // iOS: Security Framework Keychain (Face ID / Touch ID)
375    // Linux: libsecret (GNOME Keyring / KDE Wallet)
376    // Windows: Windows Credential Manager
377    // FreeBSD: file-based keyring
378    //
379    // ── Cross-platform passkey storage via keyring crate ──────────────
380    // macOS: Keychain (Touch ID / Apple Watch / device passcode)
381    // iOS: Keychain
382    // Linux: libsecret (GNOME Keyring / KDE Wallet)
383    // Windows: Windows Credential Manager
384    // FreeBSD: file-based with encryption
385
386    async fn store_passkey_key(&self, key: &CryptoSecret) -> Result<()> {
387        let entry = keyring::Entry::new(&self.config.service_name, &self.config.app_identifier)
388            .map_err(|e| Error::crypto(format!("Passkey keyring init failed: {}", e)))?;
389
390        // Store key as base64 (keyring stores strings, not raw bytes)
391        let key_b64 =
392            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key.as_bytes());
393        entry
394            .set_password(&key_b64)
395            .map_err(|e| Error::crypto(format!("Passkey store failed: {}", e)))?;
396
397        Ok(())
398    }
399
400    async fn retrieve_passkey_key(&self) -> Result<CryptoSecret> {
401        let entry = keyring::Entry::new(&self.config.service_name, &self.config.app_identifier)
402            .map_err(|e| Error::crypto(format!("Passkey keyring init failed: {}", e)))?;
403
404        let key_b64 = entry
405            .get_password()
406            .map_err(|e| Error::crypto(format!("Passkey retrieve failed: {}", e)))?;
407
408        let key_bytes =
409            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &key_b64)
410                .map_err(|e| Error::crypto(format!("Passkey decode failed: {}", e)))?;
411
412        if key_bytes.len() != 32 {
413            return Err(Error::crypto(format!(
414                "Invalid key length: expected 32, got {}",
415                key_bytes.len()
416            )));
417        }
418
419        let mut arr = [0u8; 32];
420        arr.copy_from_slice(&key_bytes);
421        Ok(CryptoSecret::from_bytes(arr))
422    }
423
424    async fn verify_passkey_access(&self) -> Result<bool> {
425        self.retrieve_passkey_key()
426            .await
427            .map(|_| true)
428            .or(Ok(false))
429    }
430}
431
432impl Drop for PasskeyEncryption {
433    fn drop(&mut self) {
434        self.clear_cached_keys();
435    }
436}
437
438/// Encrypt data with a password-based key (fallback when passkeys unavailable).
439pub fn encrypt_with_password(plaintext: &[u8], password: &str) -> Result<EncryptedData> {
440    // Generate salt
441    let mut salt = [0u8; 32];
442    getrandom::getrandom(&mut salt)
443        .map_err(|e| Error::crypto(format!("Failed to generate salt: {}", e)))?;
444
445    // Derive key using Argon2 (more secure than PBKDF2)
446    let mut key_bytes = [0u8; 32];
447    argon2::Argon2::default()
448        .hash_password_into(password.as_bytes(), &salt, &mut key_bytes)
449        .map_err(|e| Error::crypto(format!("Password key derivation failed: {}", e)))?;
450
451    let encryption_key = CryptoSecret::from_bytes(key_bytes);
452
453    // Encrypt using AES-256-GCM
454    let cipher = encryption_key.create_cipher();
455    let mut nonce_bytes = [0u8; 12];
456    getrandom::getrandom(&mut nonce_bytes)
457        .map_err(|e| Error::crypto(format!("Failed to generate nonce: {}", e)))?;
458    let nonce = GenericArray::from_slice(&nonce_bytes);
459
460    let ciphertext = cipher
461        .encrypt(nonce, plaintext)
462        .map_err(|e| Error::crypto(format!("Password encryption failed: {}", e)))?;
463
464    let metadata = EncryptionMetadata {
465        encrypted_at: format!(
466            "{}",
467            std::time::SystemTime::now()
468                .duration_since(std::time::UNIX_EPOCH)
469                .unwrap()
470                .as_secs()
471        ),
472        platform: "password".to_string(),
473        version: "1.0".to_string(),
474        passkey_type: None,
475    };
476
477    Ok(EncryptedData {
478        ciphertext,
479        nonce: nonce_bytes,
480        salt,
481        algorithm: "AES-256-GCM-PASSWORD".to_string(),
482        kdf_params: KdfParams {
483            info: "webycash-password-v1".to_string(),
484            iterations: 0, // Not used for Argon2
485            memory_cost: 65536,
486            parallelism: 4,
487        },
488        metadata,
489    })
490}
491
492/// Decrypt data with a password-based key
493pub fn decrypt_with_password(encrypted_data: &EncryptedData, password: &str) -> Result<Vec<u8>> {
494    if encrypted_data.algorithm != "AES-256-GCM-PASSWORD" {
495        return Err(Error::crypto("Wrong decryption method for this data"));
496    }
497
498    // Derive key using same parameters
499    let mut key_bytes = [0u8; 32];
500    argon2::Argon2::default()
501        .hash_password_into(password.as_bytes(), &encrypted_data.salt, &mut key_bytes)
502        .map_err(|e| Error::crypto(format!("Password key derivation failed: {}", e)))?;
503
504    let decryption_key = CryptoSecret::from_bytes(key_bytes);
505
506    // Decrypt
507    let cipher = decryption_key.create_cipher();
508    let nonce = GenericArray::from_slice(&encrypted_data.nonce);
509
510    let plaintext = cipher
511        .decrypt(nonce, encrypted_data.ciphertext.as_slice())
512        .map_err(|e| Error::crypto(format!("Password decryption failed: {}", e)))?;
513
514    Ok(plaintext)
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[tokio::test]
522    async fn test_password_encryption_roundtrip() {
523        let plaintext = b"Hello, secure world!";
524        let password = "test_password_123";
525
526        // Encrypt
527        let encrypted = encrypt_with_password(plaintext, password).unwrap();
528
529        // Verify structure
530        assert_eq!(encrypted.algorithm, "AES-256-GCM-PASSWORD");
531        assert_eq!(encrypted.nonce.len(), 12);
532        assert_eq!(encrypted.salt.len(), 32);
533
534        // Decrypt
535        let decrypted = decrypt_with_password(&encrypted, password).unwrap();
536        assert_eq!(decrypted, plaintext);
537
538        // Wrong password should fail
539        let wrong_result = decrypt_with_password(&encrypted, "wrong_password");
540        assert!(wrong_result.is_err());
541    }
542
543    #[tokio::test]
544    async fn test_passkey_encryption_config() {
545        let config = EncryptionConfig::default();
546        let passkey = PasskeyEncryption::new(config);
547        assert!(passkey.is_ok());
548    }
549
550    #[test]
551    fn test_crypto_secret_security() {
552        let secret = CryptoSecret::generate().unwrap();
553
554        // Debug should not reveal secret
555        let debug_str = format!("{:?}", secret);
556        assert_eq!(debug_str, "CryptoSecret([REDACTED])");
557
558        // Display should not reveal secret
559        let display_str = format!("{}", secret);
560        assert_eq!(display_str, "[REDACTED 32-byte secret]");
561    }
562}