chronogrid/crates/chronogrid_ffi/src/api/crypto.rs

156 lines
5.0 KiB
Rust

//! Cryptographic operations — Argon2id key derivation and OS keychain access.
//!
//! These functions wrap [`chronogrid_crypto`] for FFI consumption.
//! Key derivation is stateless and does **not** require an active engine.
//! Keychain operations are platform-gated: on Android they return an error
//! because credential storage is delegated to the Flutter KeyStore bridge.
use super::types::FfiDerivedKey;
/// Derives a 256-bit master key from a passphrase using Argon2id.
///
/// If `salt` is `None`, a fresh 16-byte random salt is generated.
/// If `salt` is provided, it **must** be exactly 16 bytes.
///
/// Returns the derived key and the salt that was used (needed to re-derive
/// the same key later).
pub fn crypto_derive_master_key(
passphrase: String,
salt: Option<Vec<u8>>,
) -> anyhow::Result<FfiDerivedKey> {
let salt_ref = salt.as_deref();
let derived = chronogrid_crypto::kdf::derive_master_key(&passphrase, salt_ref)
.map_err(|e| anyhow::anyhow!("key derivation failed: {e}"))?;
Ok(FfiDerivedKey {
key: derived.key().to_vec(),
salt: derived.salt().to_vec(),
})
}
/// Generates a cryptographically random 16-byte salt.
///
/// This is a convenience wrapper; [`crypto_derive_master_key`] already
/// generates a salt internally when `salt` is `None`.
pub fn crypto_generate_salt() -> Vec<u8> {
chronogrid_crypto::kdf::generate_salt().to_vec()
}
/// Stores raw key material in the OS credential store.
///
/// The bytes are Base64-encoded internally for credential-manager
/// compatibility.
///
/// # Platform Support
///
/// * **Windows**: Windows Credential Manager (DPAPI)
/// * **Linux**: SecretService D-Bus API (libsecret)
/// * **macOS**: macOS Keychain
/// * **Android**: Returns an error — use Flutter KeyStore bridge instead.
pub fn crypto_store_credential(
service: String,
account: String,
key_material: Vec<u8>,
) -> anyhow::Result<()> {
#[cfg(not(target_os = "android"))]
{
chronogrid_crypto::keychain::store_credential(&service, &account, &key_material)
.map_err(|e| anyhow::anyhow!("keychain store failed: {e}"))
}
#[cfg(target_os = "android")]
{
let _ = (service, account, key_material);
anyhow::bail!(
"OS keychain is not available on Android; \
use the Flutter KeyStore bridge instead"
)
}
}
/// Retrieves raw key material from the OS credential store.
///
/// # Errors
///
/// Returns an error if the credential is not found or the keychain
/// backend fails. On Android, always returns an error.
pub fn crypto_retrieve_credential(
service: String,
account: String,
) -> anyhow::Result<Vec<u8>> {
#[cfg(not(target_os = "android"))]
{
chronogrid_crypto::keychain::retrieve_credential(&service, &account)
.map_err(|e| anyhow::anyhow!("keychain retrieve failed: {e}"))
}
#[cfg(target_os = "android")]
{
let _ = (service, account);
anyhow::bail!(
"OS keychain is not available on Android; \
use the Flutter KeyStore bridge instead"
)
}
}
/// Deletes a credential from the OS credential store.
///
/// Idempotent — succeeds even if the credential does not exist.
/// On Android, always returns an error.
pub fn crypto_delete_credential(
service: String,
account: String,
) -> anyhow::Result<()> {
#[cfg(not(target_os = "android"))]
{
chronogrid_crypto::keychain::delete_credential(&service, &account)
.map_err(|e| anyhow::anyhow!("keychain delete failed: {e}"))
}
#[cfg(target_os = "android")]
{
let _ = (service, account);
anyhow::bail!(
"OS keychain is not available on Android; \
use the Flutter KeyStore bridge instead"
)
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derive_key_with_random_salt() {
let dk = crypto_derive_master_key("correct horse battery staple".into(), None)
.expect("derivation should succeed");
assert_eq!(dk.key.len(), 32);
assert_eq!(dk.salt.len(), 16);
}
#[test]
fn derive_key_deterministic_with_same_salt() {
let salt = vec![0xABu8; 16];
let dk1 =
crypto_derive_master_key("hunter2".into(), Some(salt.clone())).unwrap();
let dk2 =
crypto_derive_master_key("hunter2".into(), Some(salt)).unwrap();
assert_eq!(dk1.key, dk2.key, "same passphrase + salt must yield same key");
}
#[test]
fn reject_invalid_salt_length() {
let bad_salt = vec![0u8; 10];
let result = crypto_derive_master_key("pw".into(), Some(bad_salt));
assert!(result.is_err());
}
#[test]
fn generate_salt_returns_16_bytes() {
let salt = crypto_generate_salt();
assert_eq!(salt.len(), 16);
}
}