use crate::crypto::{decrypt, encrypt, generate_nonce, SigningKey, VerifyingKey};
use crate::types::AuthorId;
use crate::{AionError, Result};
use rand::RngCore;
use std::path::PathBuf;
const KEYRING_SERVICE: &str = "aion-v2";
const EXPORT_MAGIC: &[u8; 4] = b"AKEY";
const EXPORT_VERSION: u8 = 2;
const SALT_SIZE: usize = 16;
const KEYS_DIR: &str = "keys";
const KEY_FILE_EXT: &str = ".key";
const FILE_KEY_MAGIC: &[u8; 4] = b"AFKY";
const FILE_KEY_VERSION: u8 = 1;
#[derive(Debug)]
pub struct KeyStore {
use_file_storage: bool,
storage_dir: PathBuf,
}
impl Default for KeyStore {
fn default() -> Self {
Self::new()
}
}
impl KeyStore {
#[must_use]
pub fn new() -> Self {
let storage_dir = get_aion_keys_dir();
let use_file_storage = !is_keyring_available();
Self {
use_file_storage,
storage_dir,
}
}
#[must_use]
pub fn file_based() -> Self {
Self {
use_file_storage: true,
storage_dir: get_aion_keys_dir(),
}
}
#[must_use]
pub const fn with_storage_dir(storage_dir: PathBuf) -> Self {
Self {
use_file_storage: true,
storage_dir,
}
}
pub fn generate_keypair(&self, author_id: AuthorId) -> Result<(SigningKey, VerifyingKey)> {
let signing_key = SigningKey::generate();
let verifying_key = signing_key.verifying_key();
self.store_signing_key(author_id, &signing_key)?;
tracing::info!(
event = "keystore_key_created",
author = %crate::obs::author_short(author_id),
backend = if self.use_file_storage { "file" } else { "os_keyring" },
);
Ok((signing_key, verifying_key))
}
pub fn store_signing_key(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
if self.use_file_storage {
self.store_key_to_file(author_id, key)
} else {
self.store_key_to_keyring(author_id, key)
}
}
fn store_key_to_keyring(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
let entry = self.get_entry(author_id)?;
let key_hex = hex::encode(key.to_bytes());
entry
.set_password(&key_hex)
.map_err(|e| AionError::KeyringError {
operation: "store".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
fn store_key_to_file(&self, author_id: AuthorId, key: &SigningKey) -> Result<()> {
std::fs::create_dir_all(&self.storage_dir).map_err(|e| AionError::KeyringError {
operation: "create_dir".to_string(),
reason: e.to_string(),
})?;
let file_path = self.get_key_file_path(author_id);
let encrypted = encrypt_key_for_storage(author_id, key)?;
let temp_path = file_path.with_extension("tmp");
std::fs::write(&temp_path, &encrypted).map_err(|e| AionError::KeyringError {
operation: "write".to_string(),
reason: e.to_string(),
})?;
std::fs::rename(&temp_path, &file_path).map_err(|e| AionError::KeyringError {
operation: "rename".to_string(),
reason: e.to_string(),
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&file_path, perms).map_err(|e| AionError::KeyringError {
operation: "chmod".to_string(),
reason: e.to_string(),
})?;
}
Ok(())
}
pub fn load_signing_key(&self, author_id: AuthorId) -> Result<SigningKey> {
let result = if self.use_file_storage {
self.load_key_from_file(author_id)
} else {
self.load_key_from_keyring(author_id)
};
if let Err(ref e) = result {
tracing::warn!(
event = "keystore_load_rejected",
author = %crate::obs::author_short(author_id),
reason = match e {
AionError::KeyNotFound { .. } => "key_not_found",
AionError::InvalidPrivateKey { .. } => "invalid_key_bytes",
AionError::KeyringError { .. } => "keyring_error",
_ => "load_error",
},
);
}
result
}
fn load_key_from_keyring(&self, author_id: AuthorId) -> Result<SigningKey> {
let entry = self.get_entry(author_id)?;
let key_hex = entry.get_password().map_err(|e| AionError::KeyNotFound {
author_id,
reason: e.to_string(),
})?;
let key_bytes = hex::decode(&key_hex).map_err(|e| AionError::InvalidPrivateKey {
reason: format!("invalid hex in keyring: {e}"),
})?;
SigningKey::from_bytes(&key_bytes)
}
fn load_key_from_file(&self, author_id: AuthorId) -> Result<SigningKey> {
let file_path = self.get_key_file_path(author_id);
if !file_path.exists() {
return Err(AionError::KeyNotFound {
author_id,
reason: format!("key file not found: {}", file_path.display()),
});
}
let encrypted = std::fs::read(&file_path).map_err(|e| AionError::KeyNotFound {
author_id,
reason: e.to_string(),
})?;
decrypt_key_from_storage(author_id, &encrypted)
}
pub fn delete_signing_key(&self, author_id: AuthorId) -> Result<()> {
if self.use_file_storage {
self.delete_key_from_file(author_id)
} else {
self.delete_key_from_keyring(author_id)
}
}
fn delete_key_from_keyring(&self, author_id: AuthorId) -> Result<()> {
let entry = self.get_entry(author_id)?;
entry
.delete_credential()
.map_err(|e| AionError::KeyringError {
operation: "delete".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
fn delete_key_from_file(&self, author_id: AuthorId) -> Result<()> {
let file_path = self.get_key_file_path(author_id);
if !file_path.exists() {
return Err(AionError::KeyNotFound {
author_id,
reason: "key file not found".to_string(),
});
}
std::fs::remove_file(&file_path).map_err(|e| AionError::KeyringError {
operation: "delete".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
#[must_use]
pub fn has_signing_key(&self, author_id: AuthorId) -> bool {
if self.use_file_storage {
self.get_key_file_path(author_id).exists()
} else {
self.get_entry(author_id)
.and_then(|e| {
e.get_password().map_err(|e| AionError::KeyringError {
operation: "check".to_string(),
reason: e.to_string(),
})
})
.is_ok()
}
}
pub fn list_keys(&self) -> Result<Vec<AuthorId>> {
if !self.use_file_storage {
return Ok(Vec::new());
}
if !self.storage_dir.exists() {
return Ok(Vec::new());
}
let mut keys = Vec::new();
let entries =
std::fs::read_dir(&self.storage_dir).map_err(|e| AionError::KeyringError {
operation: "list".to_string(),
reason: e.to_string(),
})?;
for entry in entries {
let entry = entry.map_err(|e| AionError::KeyringError {
operation: "list".to_string(),
reason: e.to_string(),
})?;
let path = entry.path();
if let Some(ext) = path.extension() {
if ext == "key" {
if let Some(stem) = path.file_stem() {
if let Some(stem_str) = stem.to_str() {
if let Some(id_str) = stem_str.strip_prefix("author-") {
if let Ok(id) = id_str.parse::<u64>() {
keys.push(AuthorId::new(id));
}
}
}
}
}
}
}
keys.sort_by_key(|k| k.as_u64());
Ok(keys)
}
fn get_key_file_path(&self, author_id: AuthorId) -> PathBuf {
self.storage_dir
.join(format!("author-{}{}", author_id.as_u64(), KEY_FILE_EXT))
}
#[allow(clippy::arithmetic_side_effects)] pub fn export_encrypted(&self, author_id: AuthorId, password: &str) -> Result<Vec<u8>> {
let signing_key = self.load_signing_key(author_id)?;
let salt = generate_salt();
let encryption_key = derive_key_from_password(password, &salt)?;
let nonce = generate_nonce();
let aad = author_id.as_u64().to_le_bytes();
let ciphertext = encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad)?;
let mut output = Vec::with_capacity(4 + 1 + SALT_SIZE + 12 + ciphertext.len());
output.extend_from_slice(EXPORT_MAGIC);
output.push(EXPORT_VERSION);
output.extend_from_slice(&salt);
output.extend_from_slice(&nonce);
output.extend_from_slice(&ciphertext);
Ok(output)
}
pub fn import_encrypted(
&self,
author_id: AuthorId,
password: &str,
encrypted_data: &[u8],
) -> Result<SigningKey> {
let parsed = parse_encrypted_key_blob(encrypted_data)?;
let encryption_key = derive_key_from_password(password, &parsed.salt)?;
let aad = author_id.as_u64().to_le_bytes();
let key_bytes = decrypt(&encryption_key, &parsed.nonce, parsed.ciphertext, &aad)?;
let signing_key = SigningKey::from_bytes(&key_bytes)?;
self.store_signing_key(author_id, &signing_key)?;
Ok(signing_key)
}
#[allow(clippy::unused_self)] fn get_entry(&self, author_id: AuthorId) -> Result<keyring::Entry> {
let username = format!("author-{}", author_id.as_u64());
keyring::Entry::new(KEYRING_SERVICE, &username).map_err(|e| AionError::KeyringError {
operation: "access".to_string(),
reason: e.to_string(),
})
}
}
struct ParsedEncryptedKey<'a> {
salt: [u8; SALT_SIZE],
nonce: [u8; 12],
ciphertext: &'a [u8],
}
fn parse_encrypted_key_blob(encrypted_data: &[u8]) -> Result<ParsedEncryptedKey<'_>> {
const MIN_SIZE: usize = 4 + 1 + SALT_SIZE + 12 + 32 + 16;
if encrypted_data.len() < MIN_SIZE {
return Err(AionError::InvalidFormat {
reason: format!(
"encrypted key file too small: {} bytes (minimum: {MIN_SIZE})",
encrypted_data.len()
),
});
}
let magic = encrypted_data
.get(0..4)
.ok_or_else(|| AionError::InvalidFormat {
reason: "missing magic".to_string(),
})?;
if magic != EXPORT_MAGIC {
return Err(AionError::InvalidFormat {
reason: "invalid key file magic".to_string(),
});
}
let version = *encrypted_data
.get(4)
.ok_or_else(|| AionError::InvalidFormat {
reason: "missing version byte".to_string(),
})?;
if version != EXPORT_VERSION {
return Err(AionError::InvalidFormat {
reason: format!("unsupported key file version: {version} (expected: {EXPORT_VERSION})"),
});
}
let salt_end = 5_usize.saturating_add(SALT_SIZE);
let salt: [u8; SALT_SIZE] = encrypted_data
.get(5..salt_end)
.and_then(|s| s.try_into().ok())
.ok_or_else(|| AionError::InvalidFormat {
reason: "invalid salt".to_string(),
})?;
let nonce_end = salt_end.saturating_add(12);
let nonce: [u8; 12] = encrypted_data
.get(salt_end..nonce_end)
.and_then(|s| s.try_into().ok())
.ok_or_else(|| AionError::InvalidFormat {
reason: "invalid nonce".to_string(),
})?;
let ciphertext = encrypted_data
.get(nonce_end..)
.ok_or_else(|| AionError::InvalidFormat {
reason: "missing ciphertext".to_string(),
})?;
Ok(ParsedEncryptedKey {
salt,
nonce,
ciphertext,
})
}
fn generate_salt() -> [u8; SALT_SIZE] {
let mut salt = [0u8; SALT_SIZE];
rand::rngs::OsRng.fill_bytes(&mut salt);
salt
}
fn derive_key_from_password(password: &str, salt: &[u8; SALT_SIZE]) -> Result<[u8; 32]> {
use argon2::{Algorithm, Argon2, Params, Version};
let params = Params::new(
65536, 3, 4, Some(32), )
.map_err(|e| AionError::InvalidPrivateKey {
reason: format!("Argon2 params error: {e}"),
})?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output = [0u8; 32];
argon2
.hash_password_into(password.as_bytes(), salt, &mut output)
.map_err(|e| AionError::InvalidPrivateKey {
reason: format!("Argon2 key derivation failed: {e}"),
})?;
Ok(output)
}
fn get_aion_keys_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".aion")
.join(KEYS_DIR)
}
fn is_keyring_available() -> bool {
let test_username = "__aion_keyring_test__";
let test_entry = keyring::Entry::new(KEYRING_SERVICE, test_username);
let Ok(entry) = test_entry else {
return false;
};
let test_value = "aion-keyring-test-12345";
if entry.set_password(test_value).is_err() {
return false;
}
let Ok(entry2) = keyring::Entry::new(KEYRING_SERVICE, test_username) else {
let _ = entry.delete_credential();
return false;
};
let result = matches!(entry2.get_password(), Ok(retrieved) if retrieved == test_value);
let _ = entry.delete_credential();
result
}
const FILE_STORAGE_SALT: [u8; SALT_SIZE] = [
0x41, 0x49, 0x4f, 0x4e, 0x76, 0x32, 0x00, 0x00, 0x6b, 0x65, 0x79, 0x73, 0x74, 0x6f, 0x72, 0x65, ];
fn encrypt_key_for_storage(author_id: AuthorId, key: &SigningKey) -> Result<Vec<u8>> {
let machine_key = derive_machine_key(&FILE_STORAGE_SALT)?;
let nonce = generate_nonce();
let aad = author_id.as_u64().to_le_bytes();
let ciphertext = encrypt(&machine_key, &nonce, key.to_bytes(), &aad)?;
#[allow(clippy::arithmetic_side_effects)] let mut output = Vec::with_capacity(4 + 1 + 12 + ciphertext.len());
output.extend_from_slice(FILE_KEY_MAGIC);
output.push(FILE_KEY_VERSION);
output.extend_from_slice(&nonce);
output.extend_from_slice(&ciphertext);
Ok(output)
}
#[allow(clippy::indexing_slicing)] fn decrypt_key_from_storage(author_id: AuthorId, encrypted: &[u8]) -> Result<SigningKey> {
const MIN_SIZE: usize = 4 + 1 + 12 + 32 + 16;
if encrypted.len() < MIN_SIZE {
return Err(AionError::InvalidFormat {
reason: format!(
"encrypted key file too small: {} bytes (minimum: {MIN_SIZE})",
encrypted.len()
),
});
}
if &encrypted[0..4] != FILE_KEY_MAGIC {
return Err(AionError::InvalidFormat {
reason: "invalid file key magic".to_string(),
});
}
let version = encrypted[4];
if version != FILE_KEY_VERSION {
return Err(AionError::InvalidFormat {
reason: format!(
"unsupported file key version: {version} (expected: {FILE_KEY_VERSION})"
),
});
}
let nonce: [u8; 12] = encrypted[5..17]
.try_into()
.map_err(|_| AionError::InvalidFormat {
reason: "invalid nonce".to_string(),
})?;
let ciphertext = &encrypted[17..];
let machine_key = derive_machine_key(&FILE_STORAGE_SALT)?;
let aad = author_id.as_u64().to_le_bytes();
let key_bytes = decrypt(&machine_key, &nonce, ciphertext, &aad)?;
SigningKey::from_bytes(&key_bytes)
}
fn derive_machine_key(salt: &[u8; SALT_SIZE]) -> Result<[u8; 32]> {
use argon2::{Algorithm, Argon2, Params, Version};
let machine_id = get_machine_identifier();
let params = Params::new(
16384, 2, 2, Some(32), )
.map_err(|e| AionError::InvalidPrivateKey {
reason: format!("Argon2 params error: {e}"),
})?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output = [0u8; 32];
argon2
.hash_password_into(machine_id.as_bytes(), salt, &mut output)
.map_err(|e| AionError::InvalidPrivateKey {
reason: format!("machine key derivation failed: {e}"),
})?;
Ok(output)
}
fn get_machine_identifier() -> String {
#[cfg(target_os = "linux")]
{
if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
return id.trim().to_string();
}
}
let username = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| "aion-user".to_string());
let hostname = hostname::get().map_or_else(
|_| "localhost".to_string(),
|h| h.to_string_lossy().to_string(),
);
format!("{username}@{hostname}")
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
#[allow(clippy::indexing_slicing)]
mod tests {
use super::*;
mod password_encryption {
use super::*;
#[test]
fn should_derive_consistent_key() {
let salt = [1u8; SALT_SIZE];
let key1 = derive_key_from_password("password123", &salt).unwrap();
let key2 = derive_key_from_password("password123", &salt).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn should_derive_different_keys_for_different_passwords() {
let salt = [1u8; SALT_SIZE];
let key1 = derive_key_from_password("password1", &salt).unwrap();
let key2 = derive_key_from_password("password2", &salt).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn should_derive_different_keys_for_different_salts() {
let salt1 = [1u8; SALT_SIZE];
let salt2 = [2u8; SALT_SIZE];
let key1 = derive_key_from_password("password", &salt1).unwrap();
let key2 = derive_key_from_password("password", &salt2).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn should_generate_unique_salts() {
let salt1 = generate_salt();
let salt2 = generate_salt();
assert_ne!(salt1, salt2);
}
}
mod export_format {
use super::*;
#[test]
fn should_have_correct_magic() {
assert_eq!(EXPORT_MAGIC, b"AKEY");
}
#[test]
fn should_encrypt_and_decrypt_key() {
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let password = "test-password-123";
let salt = generate_salt();
let encryption_key = derive_key_from_password(password, &salt).unwrap();
let nonce = generate_nonce();
let aad = author_id.as_u64().to_le_bytes();
let ciphertext =
encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad).unwrap();
let mut encrypted = Vec::new();
encrypted.extend_from_slice(EXPORT_MAGIC);
encrypted.push(EXPORT_VERSION);
encrypted.extend_from_slice(&salt);
encrypted.extend_from_slice(&nonce);
encrypted.extend_from_slice(&ciphertext);
let extracted_salt: [u8; SALT_SIZE] = encrypted[5..5 + SALT_SIZE].try_into().unwrap();
let nonce_start = 5 + SALT_SIZE;
let extracted_nonce: [u8; 12] =
encrypted[nonce_start..nonce_start + 12].try_into().unwrap();
let decrypted_ciphertext = &encrypted[nonce_start + 12..];
let decryption_key = derive_key_from_password(password, &extracted_salt).unwrap();
let key_bytes = decrypt(
&decryption_key,
&extracted_nonce,
decrypted_ciphertext,
&aad,
)
.unwrap();
assert_eq!(key_bytes.as_slice(), signing_key.to_bytes());
}
#[test]
fn should_reject_wrong_password() {
let signing_key = SigningKey::generate();
let author_id = AuthorId::new(50001);
let salt = generate_salt();
let encryption_key = derive_key_from_password("correct-password", &salt).unwrap();
let nonce = generate_nonce();
let aad = author_id.as_u64().to_le_bytes();
let ciphertext =
encrypt(&encryption_key, &nonce, signing_key.to_bytes(), &aad).unwrap();
let mut encrypted = Vec::new();
encrypted.extend_from_slice(EXPORT_MAGIC);
encrypted.push(EXPORT_VERSION);
encrypted.extend_from_slice(&salt);
encrypted.extend_from_slice(&nonce);
encrypted.extend_from_slice(&ciphertext);
let wrong_key = derive_key_from_password("wrong-password", &salt).unwrap();
let nonce_start = 5 + SALT_SIZE;
let decrypted_ciphertext = &encrypted[nonce_start + 12..];
let decrypted_nonce: [u8; 12] =
encrypted[nonce_start..nonce_start + 12].try_into().unwrap();
let result = decrypt(&wrong_key, &decrypted_nonce, decrypted_ciphertext, &aad);
assert!(result.is_err());
}
#[test]
fn should_reject_invalid_magic() {
let mut data = vec![0u8; 81]; data[0..4].copy_from_slice(b"XXXX");
let keystore = KeyStore::new();
let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
assert!(result.is_err());
}
#[test]
fn should_reject_too_small_data() {
let data = vec![0u8; 10];
let keystore = KeyStore::new();
let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
assert!(result.is_err());
}
#[test]
fn should_reject_wrong_version() {
let mut data = vec![0u8; 81];
data[0..4].copy_from_slice(EXPORT_MAGIC);
data[4] = 99;
let keystore = KeyStore::new();
let result = keystore.import_encrypted(AuthorId::new(1), "password", &data);
assert!(result.is_err());
}
#[test]
fn export_format_should_have_correct_size() {
assert_eq!(4 + 1 + SALT_SIZE + 12 + 32 + 16, 81);
}
}
}