1use crate::crypto::{decrypt, encrypt, generate_nonce, SigningKey, VerifyingKey};
36use crate::types::AuthorId;
37use crate::{AionError, Result};
38use rand::RngCore;
39use std::path::PathBuf;
40
41const KEYRING_SERVICE: &str = "aion-v2";
43
44const EXPORT_MAGIC: &[u8; 4] = b"AKEY";
46
47const EXPORT_VERSION: u8 = 2;
49
50const SALT_SIZE: usize = 16;
52
53const KEYS_DIR: &str = "keys";
55
56const KEY_FILE_EXT: &str = ".key";
58
59const FILE_KEY_MAGIC: &[u8; 4] = b"AFKY";
61
62const FILE_KEY_VERSION: u8 = 1;
64
65#[derive(Debug)]
70pub struct KeyStore {
71 use_file_storage: bool,
73 storage_dir: PathBuf,
75}
76
77impl Default for KeyStore {
78 fn default() -> Self {
79 Self::new()
80 }
81}
82
83impl KeyStore {
84 #[must_use]
89 pub fn new() -> Self {
90 let storage_dir = get_aion_keys_dir();
91 let use_file_storage = !is_keyring_available();
92
93 Self {
94 use_file_storage,
95 storage_dir,
96 }
97 }
98
99 #[must_use]
103 pub fn file_based() -> Self {
104 Self {
105 use_file_storage: true,
106 storage_dir: get_aion_keys_dir(),
107 }
108 }
109
110 #[must_use]
114 pub const fn with_storage_dir(storage_dir: PathBuf) -> Self {
115 Self {
116 use_file_storage: true,
117 storage_dir,
118 }
119 }
120
121 pub fn generate_keypair(&self, author_id: AuthorId) -> Result<(SigningKey, VerifyingKey)> {
127 let signing_key = SigningKey::generate();
128 let verifying_key = signing_key.verifying_key();
129
130 self.store_signing_key(author_id, &signing_key)?;
131
132 tracing::info!(
133 event = "keystore_key_created",
134 author = %crate::obs::author_short(author_id),
135 backend = if self.use_file_storage { "file" } else { "os_keyring" },
136 );
137 Ok((signing_key, verifying_key))
138 }
139
140 pub fn store_signing_key(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
148 if self.use_file_storage {
149 self.store_key_to_file(author_id, key)
150 } else {
151 self.store_key_to_keyring(author_id, key)
152 }
153 }
154
155 fn store_key_to_keyring(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
157 let entry = self.get_entry(author_id)?;
158 let key_hex = hex::encode(key.to_bytes());
159
160 entry
161 .set_password(&key_hex)
162 .map_err(|e| AionError::KeyringError {
163 operation: "store".to_string(),
164 reason: e.to_string(),
165 })?;
166
167 Ok(())
168 }
169
170 fn store_key_to_file(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
172 std::fs::create_dir_all(&self.storage_dir).map_err(|e| AionError::KeyringError {
174 operation: "create_dir".to_string(),
175 reason: e.to_string(),
176 })?;
177
178 let file_path = self.get_key_file_path(author_id);
179
180 let encrypted = encrypt_key_for_storage(author_id, key)?;
182
183 let temp_path = file_path.with_extension("tmp");
185 std::fs::write(&temp_path, &encrypted).map_err(|e| AionError::KeyringError {
186 operation: "write".to_string(),
187 reason: e.to_string(),
188 })?;
189
190 std::fs::rename(&temp_path, &file_path).map_err(|e| AionError::KeyringError {
191 operation: "rename".to_string(),
192 reason: e.to_string(),
193 })?;
194
195 #[cfg(unix)]
197 {
198 use std::os::unix::fs::PermissionsExt;
199 let perms = std::fs::Permissions::from_mode(0o600);
200 std::fs::set_permissions(&file_path, perms).map_err(|e| AionError::KeyringError {
201 operation: "chmod".to_string(),
202 reason: e.to_string(),
203 })?;
204 }
205
206 Ok(())
207 }
208
209 pub fn load_signing_key(&self, author_id: AuthorId) -> Result<SigningKey> {
217 let result = if self.use_file_storage {
218 self.load_key_from_file(author_id)
219 } else {
220 self.load_key_from_keyring(author_id)
221 };
222 if let Err(ref e) = result {
223 tracing::warn!(
224 event = "keystore_load_rejected",
225 author = %crate::obs::author_short(author_id),
226 reason = match e {
227 AionError::KeyNotFound { .. } => "key_not_found",
228 AionError::InvalidPrivateKey { .. } => "invalid_key_bytes",
229 AionError::KeyringError { .. } => "keyring_error",
230 _ => "load_error",
231 },
232 );
233 }
234 result
235 }
236
237 fn load_key_from_keyring(&self, author_id: AuthorId) -> Result<SigningKey> {
239 let entry = self.get_entry(author_id)?;
240
241 let key_hex = entry.get_password().map_err(|e| AionError::KeyNotFound {
242 author_id,
243 reason: e.to_string(),
244 })?;
245
246 let key_bytes = hex::decode(&key_hex).map_err(|e| AionError::InvalidPrivateKey {
247 reason: format!("invalid hex in keyring: {e}"),
248 })?;
249
250 SigningKey::from_bytes(&key_bytes)
251 }
252
253 fn load_key_from_file(&self, author_id: AuthorId) -> Result<SigningKey> {
255 let file_path = self.get_key_file_path(author_id);
256
257 if !file_path.exists() {
258 return Err(AionError::KeyNotFound {
259 author_id,
260 reason: format!("key file not found: {}", file_path.display()),
261 });
262 }
263
264 let encrypted = std::fs::read(&file_path).map_err(|e| AionError::KeyNotFound {
265 author_id,
266 reason: e.to_string(),
267 })?;
268
269 decrypt_key_from_storage(author_id, &encrypted)
270 }
271
272 pub fn delete_signing_key(&self, author_id: AuthorId) -> Result<()> {
278 if self.use_file_storage {
279 self.delete_key_from_file(author_id)
280 } else {
281 self.delete_key_from_keyring(author_id)
282 }
283 }
284
285 fn delete_key_from_keyring(&self, author_id: AuthorId) -> Result<()> {
287 let entry = self.get_entry(author_id)?;
288
289 entry
290 .delete_credential()
291 .map_err(|e| AionError::KeyringError {
292 operation: "delete".to_string(),
293 reason: e.to_string(),
294 })?;
295
296 Ok(())
297 }
298
299 fn delete_key_from_file(&self, author_id: AuthorId) -> Result<()> {
301 let file_path = self.get_key_file_path(author_id);
302
303 if !file_path.exists() {
304 return Err(AionError::KeyNotFound {
305 author_id,
306 reason: "key file not found".to_string(),
307 });
308 }
309
310 std::fs::remove_file(&file_path).map_err(|e| AionError::KeyringError {
311 operation: "delete".to_string(),
312 reason: e.to_string(),
313 })?;
314
315 Ok(())
316 }
317
318 #[must_use]
320 pub fn has_signing_key(&self, author_id: AuthorId) -> bool {
321 if self.use_file_storage {
322 self.get_key_file_path(author_id).exists()
323 } else {
324 self.get_entry(author_id)
325 .and_then(|e| {
326 e.get_password().map_err(|e| AionError::KeyringError {
327 operation: "check".to_string(),
328 reason: e.to_string(),
329 })
330 })
331 .is_ok()
332 }
333 }
334
335 pub fn list_keys(&self) -> Result<Vec<AuthorId>> {
340 if !self.use_file_storage {
341 return Ok(Vec::new());
343 }
344
345 if !self.storage_dir.exists() {
346 return Ok(Vec::new());
347 }
348
349 let mut keys = Vec::new();
350
351 let entries =
352 std::fs::read_dir(&self.storage_dir).map_err(|e| AionError::KeyringError {
353 operation: "list".to_string(),
354 reason: e.to_string(),
355 })?;
356
357 for entry in entries {
358 let entry = entry.map_err(|e| AionError::KeyringError {
359 operation: "list".to_string(),
360 reason: e.to_string(),
361 })?;
362
363 let path = entry.path();
364 if let Some(ext) = path.extension() {
365 if ext == "key" {
366 if let Some(stem) = path.file_stem() {
367 if let Some(stem_str) = stem.to_str() {
368 if let Some(id_str) = stem_str.strip_prefix("author-") {
369 if let Ok(id) = id_str.parse::<u64>() {
370 keys.push(AuthorId::new(id));
371 }
372 }
373 }
374 }
375 }
376 }
377 }
378
379 keys.sort_by_key(|k| k.as_u64());
380 Ok(keys)
381 }
382
383 fn get_key_file_path(&self, author_id: AuthorId) -> PathBuf {
385 self.storage_dir
386 .join(format!("author-{}{}", author_id.as_u64(), KEY_FILE_EXT))
387 }
388
389 #[allow(clippy::arithmetic_side_effects)] pub fn export_encrypted(&self, author_id: AuthorId, password: &str) -> Result<Vec<u8>> {
402 let signing_key = self.load_signing_key(author_id)?;
403
404 let salt = generate_salt();
406
407 let encryption_key = derive_key_from_password(password, &salt)?;
409
410 let nonce = generate_nonce();
412 let aad = author_id.as_u64().to_le_bytes();
413 let ciphertext = encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad)?;
414
415 let mut output = Vec::with_capacity(4 + 1 + SALT_SIZE + 12 + ciphertext.len());
417 output.extend_from_slice(EXPORT_MAGIC);
418 output.push(EXPORT_VERSION);
419 output.extend_from_slice(&salt);
420 output.extend_from_slice(&nonce);
421 output.extend_from_slice(&ciphertext);
422
423 Ok(output)
424 }
425
426 pub fn import_encrypted(
438 &self,
439 author_id: AuthorId,
440 password: &str,
441 encrypted_data: &[u8],
442 ) -> Result<SigningKey> {
443 let parsed = parse_encrypted_key_blob(encrypted_data)?;
444 let encryption_key = derive_key_from_password(password, &parsed.salt)?;
445 let aad = author_id.as_u64().to_le_bytes();
446 let key_bytes = decrypt(&encryption_key, &parsed.nonce, parsed.ciphertext, &aad)?;
447 let signing_key = SigningKey::from_bytes(&key_bytes)?;
448 self.store_signing_key(author_id, &signing_key)?;
449 Ok(signing_key)
450 }
451
452 #[allow(clippy::unused_self)] fn get_entry(&self, author_id: AuthorId) -> Result<keyring::Entry> {
455 let username = format!("author-{}", author_id.as_u64());
456 keyring::Entry::new(KEYRING_SERVICE, &username).map_err(|e| AionError::KeyringError {
457 operation: "access".to_string(),
458 reason: e.to_string(),
459 })
460 }
461}
462
463struct ParsedEncryptedKey<'a> {
465 salt: [u8; SALT_SIZE],
466 nonce: [u8; 12],
467 ciphertext: &'a [u8],
468}
469
470fn parse_encrypted_key_blob(encrypted_data: &[u8]) -> Result<ParsedEncryptedKey<'_>> {
471 const MIN_SIZE: usize = 4 + 1 + SALT_SIZE + 12 + 32 + 16;
472 if encrypted_data.len() < MIN_SIZE {
473 return Err(AionError::InvalidFormat {
474 reason: format!(
475 "encrypted key file too small: {} bytes (minimum: {MIN_SIZE})",
476 encrypted_data.len()
477 ),
478 });
479 }
480 let magic = encrypted_data
481 .get(0..4)
482 .ok_or_else(|| AionError::InvalidFormat {
483 reason: "missing magic".to_string(),
484 })?;
485 if magic != EXPORT_MAGIC {
486 return Err(AionError::InvalidFormat {
487 reason: "invalid key file magic".to_string(),
488 });
489 }
490 let version = *encrypted_data
491 .get(4)
492 .ok_or_else(|| AionError::InvalidFormat {
493 reason: "missing version byte".to_string(),
494 })?;
495 if version != EXPORT_VERSION {
496 return Err(AionError::InvalidFormat {
497 reason: format!("unsupported key file version: {version} (expected: {EXPORT_VERSION})"),
498 });
499 }
500 let salt_end = 5_usize.saturating_add(SALT_SIZE);
501 let salt: [u8; SALT_SIZE] = encrypted_data
502 .get(5..salt_end)
503 .and_then(|s| s.try_into().ok())
504 .ok_or_else(|| AionError::InvalidFormat {
505 reason: "invalid salt".to_string(),
506 })?;
507 let nonce_end = salt_end.saturating_add(12);
508 let nonce: [u8; 12] = encrypted_data
509 .get(salt_end..nonce_end)
510 .and_then(|s| s.try_into().ok())
511 .ok_or_else(|| AionError::InvalidFormat {
512 reason: "invalid nonce".to_string(),
513 })?;
514 let ciphertext = encrypted_data
515 .get(nonce_end..)
516 .ok_or_else(|| AionError::InvalidFormat {
517 reason: "missing ciphertext".to_string(),
518 })?;
519 Ok(ParsedEncryptedKey {
520 salt,
521 nonce,
522 ciphertext,
523 })
524}
525
526fn generate_salt() -> [u8; SALT_SIZE] {
528 let mut salt = [0u8; SALT_SIZE];
529 rand::rngs::OsRng.fill_bytes(&mut salt);
530 salt
531}
532
533fn derive_key_from_password(password: &str, salt: &[u8; SALT_SIZE]) -> Result<[u8; 32]> {
545 use argon2::{Algorithm, Argon2, Params, Version};
546
547 let params = Params::new(
550 65536, 3, 4, Some(32), )
555 .map_err(|e| AionError::InvalidPrivateKey {
556 reason: format!("Argon2 params error: {e}"),
557 })?;
558
559 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
560
561 let mut output = [0u8; 32];
562 argon2
563 .hash_password_into(password.as_bytes(), salt, &mut output)
564 .map_err(|e| AionError::InvalidPrivateKey {
565 reason: format!("Argon2 key derivation failed: {e}"),
566 })?;
567
568 Ok(output)
569}
570
571fn get_aion_keys_dir() -> PathBuf {
573 dirs::home_dir()
574 .unwrap_or_else(|| PathBuf::from("."))
575 .join(".aion")
576 .join(KEYS_DIR)
577}
578
579fn is_keyring_available() -> bool {
581 let test_username = "__aion_keyring_test__";
583 let test_entry = keyring::Entry::new(KEYRING_SERVICE, test_username);
584
585 let Ok(entry) = test_entry else {
586 return false;
587 };
588
589 let test_value = "aion-keyring-test-12345";
591 if entry.set_password(test_value).is_err() {
592 return false;
593 }
594
595 let Ok(entry2) = keyring::Entry::new(KEYRING_SERVICE, test_username) else {
598 let _ = entry.delete_credential();
599 return false;
600 };
601
602 let result = matches!(entry2.get_password(), Ok(retrieved) if retrieved == test_value);
604
605 let _ = entry.delete_credential();
607
608 result
609}
610
611const FILE_STORAGE_SALT: [u8; SALT_SIZE] = [
615 0x41, 0x49, 0x4f, 0x4e, 0x76, 0x32, 0x00, 0x00, 0x6b, 0x65, 0x79, 0x73, 0x74, 0x6f, 0x72, 0x65, ];
620
621fn encrypt_key_for_storage(author_id: AuthorId, key: &SigningKey) -> Result<Vec<u8>> {
626 let machine_key = derive_machine_key(&FILE_STORAGE_SALT)?;
629
630 let nonce = generate_nonce();
631 let aad = author_id.as_u64().to_le_bytes();
632 let ciphertext = encrypt(&machine_key, &nonce, key.to_bytes(), &aad)?;
633
634 #[allow(clippy::arithmetic_side_effects)] let mut output = Vec::with_capacity(4 + 1 + 12 + ciphertext.len());
637 output.extend_from_slice(FILE_KEY_MAGIC);
638 output.push(FILE_KEY_VERSION);
639 output.extend_from_slice(&nonce);
640 output.extend_from_slice(&ciphertext);
641
642 Ok(output)
643}
644
645#[allow(clippy::indexing_slicing)] fn decrypt_key_from_storage(author_id: AuthorId, encrypted: &[u8]) -> Result<SigningKey> {
648 const MIN_SIZE: usize = 4 + 1 + 12 + 32 + 16;
650
651 if encrypted.len() < MIN_SIZE {
652 return Err(AionError::InvalidFormat {
653 reason: format!(
654 "encrypted key file too small: {} bytes (minimum: {MIN_SIZE})",
655 encrypted.len()
656 ),
657 });
658 }
659
660 if &encrypted[0..4] != FILE_KEY_MAGIC {
661 return Err(AionError::InvalidFormat {
662 reason: "invalid file key magic".to_string(),
663 });
664 }
665
666 let version = encrypted[4];
667 if version != FILE_KEY_VERSION {
668 return Err(AionError::InvalidFormat {
669 reason: format!(
670 "unsupported file key version: {version} (expected: {FILE_KEY_VERSION})"
671 ),
672 });
673 }
674
675 let nonce: [u8; 12] = encrypted[5..17]
676 .try_into()
677 .map_err(|_| AionError::InvalidFormat {
678 reason: "invalid nonce".to_string(),
679 })?;
680
681 let ciphertext = &encrypted[17..];
682
683 let machine_key = derive_machine_key(&FILE_STORAGE_SALT)?;
684 let aad = author_id.as_u64().to_le_bytes();
685 let key_bytes = decrypt(&machine_key, &nonce, ciphertext, &aad)?;
686
687 SigningKey::from_bytes(&key_bytes)
688}
689
690fn derive_machine_key(salt: &[u8; SALT_SIZE]) -> Result<[u8; 32]> {
695 use argon2::{Algorithm, Argon2, Params, Version};
696
697 let machine_id = get_machine_identifier();
700
701 let params = Params::new(
703 16384, 2, 2, Some(32), )
708 .map_err(|e| AionError::InvalidPrivateKey {
709 reason: format!("Argon2 params error: {e}"),
710 })?;
711
712 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
713
714 let mut output = [0u8; 32];
715 argon2
716 .hash_password_into(machine_id.as_bytes(), salt, &mut output)
717 .map_err(|e| AionError::InvalidPrivateKey {
718 reason: format!("machine key derivation failed: {e}"),
719 })?;
720
721 Ok(output)
722}
723
724fn get_machine_identifier() -> String {
728 #[cfg(target_os = "linux")]
730 {
731 if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
732 return id.trim().to_string();
733 }
734 }
735
736 let username = std::env::var("USER")
738 .or_else(|_| std::env::var("USERNAME"))
739 .unwrap_or_else(|_| "aion-user".to_string());
740
741 let hostname = hostname::get().map_or_else(
742 |_| "localhost".to_string(),
743 |h| h.to_string_lossy().to_string(),
744 );
745
746 format!("{username}@{hostname}")
747}
748
749#[cfg(test)]
750#[allow(clippy::unwrap_used)]
751#[allow(clippy::indexing_slicing)]
752mod tests {
753 use super::*;
754
755 mod password_encryption {
756 use super::*;
757
758 #[test]
759 fn should_derive_consistent_key() {
760 let salt = [1u8; SALT_SIZE];
761 let key1 = derive_key_from_password("password123", &salt).unwrap();
762 let key2 = derive_key_from_password("password123", &salt).unwrap();
763 assert_eq!(key1, key2);
764 }
765
766 #[test]
767 fn should_derive_different_keys_for_different_passwords() {
768 let salt = [1u8; SALT_SIZE];
769 let key1 = derive_key_from_password("password1", &salt).unwrap();
770 let key2 = derive_key_from_password("password2", &salt).unwrap();
771 assert_ne!(key1, key2);
772 }
773
774 #[test]
775 fn should_derive_different_keys_for_different_salts() {
776 let salt1 = [1u8; SALT_SIZE];
777 let salt2 = [2u8; SALT_SIZE];
778 let key1 = derive_key_from_password("password", &salt1).unwrap();
779 let key2 = derive_key_from_password("password", &salt2).unwrap();
780 assert_ne!(key1, key2);
781 }
782
783 #[test]
784 fn should_generate_unique_salts() {
785 let salt1 = generate_salt();
786 let salt2 = generate_salt();
787 assert_ne!(salt1, salt2);
788 }
789 }
790
791 mod export_format {
792 use super::*;
793
794 #[test]
795 fn should_have_correct_magic() {
796 assert_eq!(EXPORT_MAGIC, b"AKEY");
797 }
798
799 #[test]
800 fn should_encrypt_and_decrypt_key() {
801 let signing_key = SigningKey::generate();
802 let author_id = AuthorId::new(50001);
803 let password = "test-password-123";
804
805 let salt = generate_salt();
807 let encryption_key = derive_key_from_password(password, &salt).unwrap();
808 let nonce = generate_nonce();
809 let aad = author_id.as_u64().to_le_bytes();
810 let ciphertext =
811 encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad).unwrap();
812
813 let mut encrypted = Vec::new();
815 encrypted.extend_from_slice(EXPORT_MAGIC);
816 encrypted.push(EXPORT_VERSION);
817 encrypted.extend_from_slice(&salt);
818 encrypted.extend_from_slice(&nonce);
819 encrypted.extend_from_slice(&ciphertext);
820
821 let extracted_salt: [u8; SALT_SIZE] = encrypted[5..5 + SALT_SIZE].try_into().unwrap();
823 let nonce_start = 5 + SALT_SIZE;
824 let extracted_nonce: [u8; 12] =
825 encrypted[nonce_start..nonce_start + 12].try_into().unwrap();
826 let decrypted_ciphertext = &encrypted[nonce_start + 12..];
827
828 let decryption_key = derive_key_from_password(password, &extracted_salt).unwrap();
830 let key_bytes = decrypt(
831 &decryption_key,
832 &extracted_nonce,
833 decrypted_ciphertext,
834 &aad,
835 )
836 .unwrap();
837
838 assert_eq!(key_bytes.as_slice(), signing_key.to_bytes());
839 }
840
841 #[test]
842 fn should_reject_wrong_password() {
843 let signing_key = SigningKey::generate();
844 let author_id = AuthorId::new(50001);
845
846 let salt = generate_salt();
848 let encryption_key = derive_key_from_password("correct-password", &salt).unwrap();
849 let nonce = generate_nonce();
850 let aad = author_id.as_u64().to_le_bytes();
851 let ciphertext =
852 encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad).unwrap();
853
854 let mut encrypted = Vec::new();
856 encrypted.extend_from_slice(EXPORT_MAGIC);
857 encrypted.push(EXPORT_VERSION);
858 encrypted.extend_from_slice(&salt);
859 encrypted.extend_from_slice(&nonce);
860 encrypted.extend_from_slice(&ciphertext);
861
862 let wrong_key = derive_key_from_password("wrong-password", &salt).unwrap();
864 let nonce_start = 5 + SALT_SIZE;
865 let decrypted_ciphertext = &encrypted[nonce_start + 12..];
866 let decrypted_nonce: [u8; 12] =
867 encrypted[nonce_start..nonce_start + 12].try_into().unwrap();
868 let result = decrypt(&wrong_key, &decrypted_nonce, decrypted_ciphertext, &aad);
869
870 assert!(result.is_err());
871 }
872
873 #[test]
874 fn should_reject_invalid_magic() {
875 let mut data = vec![0u8; 81]; data[0..4].copy_from_slice(b"XXXX"); let keystore = KeyStore::new();
879 let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
880 assert!(result.is_err());
881 }
882
883 #[test]
884 fn should_reject_too_small_data() {
885 let data = vec![0u8; 10]; let keystore = KeyStore::new();
888 let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
889 assert!(result.is_err());
890 }
891
892 #[test]
893 fn should_reject_wrong_version() {
894 let mut data = vec![0u8; 81];
895 data[0..4].copy_from_slice(EXPORT_MAGIC);
896 data[4] = 99; let keystore = KeyStore::new();
899 let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
900 assert!(result.is_err());
901 }
902
903 #[test]
904 fn export_format_should_have_correct_size() {
905 assert_eq!(4 + 1 + SALT_SIZE + 12 + 32 + 16, 81);
907 }
908 }
909
910 }