1use 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#[derive(Debug, Clone)]
57pub struct EncryptionConfig {
58 pub app_identifier: String,
60 pub service_name: String,
62 pub require_auth_every_use: bool,
64 pub auth_timeout_seconds: u32,
66 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#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct EncryptedData {
85 pub ciphertext: Vec<u8>,
87 pub nonce: [u8; 12],
89 pub salt: [u8; 32],
91 pub algorithm: String,
93 pub kdf_params: KdfParams,
95 pub metadata: EncryptionMetadata,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct KdfParams {
102 pub info: String,
104 pub iterations: u32,
106 pub memory_cost: u32,
108 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, parallelism: 4,
119 }
120 }
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct EncryptionMetadata {
126 pub encrypted_at: String,
128 pub platform: String,
130 pub version: String,
132 pub passkey_type: Option<String>,
134}
135
136pub struct PasskeyEncryption {
138 #[allow(dead_code)] config: EncryptionConfig,
140 cached_key: Option<CryptoSecret>,
141}
142
143impl PasskeyEncryption {
144 pub fn new(config: EncryptionConfig) -> Result<Self> {
146 Ok(Self {
147 config,
148 cached_key: None,
149 })
150 }
151
152 pub async fn encrypt_with_passkey(&mut self, plaintext: &[u8]) -> Result<EncryptedData> {
160 let mut salt = [0u8; 32];
162 getrandom::getrandom(&mut salt)
163 .map_err(|e| Error::crypto(format!("Failed to generate salt: {}", e)))?;
164
165 let master_key = self.get_or_create_passkey_key().await?;
167
168 let encryption_key = self.derive_encryption_key(&master_key, &salt)?;
170
171 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 let ciphertext = cipher
180 .encrypt(nonce, plaintext)
181 .map_err(|e| Error::crypto(format!("Encryption failed: {}", e)))?;
182
183 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 pub async fn decrypt_with_passkey(
215 &mut self,
216 encrypted_data: &EncryptedData,
217 ) -> Result<Vec<u8>> {
218 if encrypted_data.algorithm != "AES-256-GCM" {
220 return Err(Error::crypto("Unsupported encryption algorithm"));
221 }
222
223 let master_key = self.authenticate_and_get_key().await?;
225
226 let decryption_key = self.derive_encryption_key(&master_key, &encrypted_data.salt)?;
228
229 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 pub fn clear_cached_keys(&mut self) {
242 if let Some(mut key) = self.cached_key.take() {
243 key.zeroize();
244 }
245 }
246
247 pub async fn is_passkey_available(&self) -> Result<bool> {
250 keyring::Entry::new(&self.config.service_name, &self.config.app_identifier)
252 .map(|_| true)
253 .or(Ok(false))
254 }
255
256 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 async fn get_or_create_passkey_key(&mut self) -> Result<CryptoSecret> {
294 if let Some(ref key) = self.cached_key {
296 return Ok(key.clone());
297 }
298
299 match self.retrieve_passkey_key().await {
301 Ok(key) => {
302 self.cached_key = Some(key.clone());
303 Ok(key)
304 }
305 Err(_) => {
306 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 async fn authenticate_and_get_key(&mut self) -> Result<CryptoSecret> {
319 if let Some(ref key) = self.cached_key {
321 if self.verify_passkey_access().await? {
323 return Ok(key.clone());
324 } else {
325 self.clear_cached_keys();
327 }
328 }
329
330 let key = self.retrieve_passkey_key().await?;
332 self.cached_key = Some(key.clone());
333 Ok(key)
334 }
335
336 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 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 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 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
438pub fn encrypt_with_password(plaintext: &[u8], password: &str) -> Result<EncryptedData> {
440 let mut salt = [0u8; 32];
442 getrandom::getrandom(&mut salt)
443 .map_err(|e| Error::crypto(format!("Failed to generate salt: {}", e)))?;
444
445 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 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, memory_cost: 65536,
486 parallelism: 4,
487 },
488 metadata,
489 })
490}
491
492pub 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 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 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 let encrypted = encrypt_with_password(plaintext, password).unwrap();
528
529 assert_eq!(encrypted.algorithm, "AES-256-GCM-PASSWORD");
531 assert_eq!(encrypted.nonce.len(), 12);
532 assert_eq!(encrypted.salt.len(), 32);
533
534 let decrypted = decrypt_with_password(&encrypted, password).unwrap();
536 assert_eq!(decrypted, plaintext);
537
538 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 let debug_str = format!("{:?}", secret);
556 assert_eq!(debug_str, "CryptoSecret([REDACTED])");
557
558 let display_str = format!("{}", secret);
560 assert_eq!(display_str, "[REDACTED 32-byte secret]");
561 }
562}