use std::path::Path;
use rcypher::{EncryptedValue, SecretStore, UnlockedContainer};
use zeroize::Zeroizing;
use crate::auth::{Ed25519Signer, RequestAuth, Signer};
use crate::error::{Error, Result};
pub use rcypher::{
Argon2Params, disable_core_dumps, enable_ptrace_protection, is_debugger_attached,
};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum KeystoreError {
#[error("vault store error: {0}")]
Store(String),
}
pub struct Keystore {
store: UnlockedContainer<SecretStore>,
}
impl Keystore {
pub const API_KEY: &'static str = "api_key";
pub const PRIVATE_KEY_PEM: &'static str = "private_key_pem";
pub const PUBLIC_KEY_PEM: &'static str = "public_key_pem";
#[must_use]
pub const fn from_unlocked(store: UnlockedContainer<SecretStore>) -> Self {
Self { store }
}
pub fn create(
factor_name: &str,
password: &str,
argon2: &Argon2Params,
) -> std::result::Result<Self, KeystoreError> {
let store = UnlockedContainer::create_with_password(
factor_name,
password,
SecretStore::new(),
argon2,
)
.map_err(|e| KeystoreError::Store(e.to_string()))?;
Ok(Self { store })
}
#[must_use]
pub const fn container(&self) -> &UnlockedContainer<SecretStore> {
&self.store
}
#[must_use]
pub const fn container_mut(&mut self) -> &mut UnlockedContainer<SecretStore> {
&mut self.store
}
pub fn get(&self, name: &str) -> std::result::Result<Option<Zeroizing<String>>, KeystoreError> {
let cypher = self.store.cypher();
self.store
.data()
.latest(name)
.map(|entry| entry.value.decrypt(&cypher))
.transpose()
.map_err(|e| KeystoreError::Store(e.to_string()))
}
pub fn set(&mut self, name: &str, value: &str) -> std::result::Result<(), KeystoreError> {
let encrypted = EncryptedValue::encrypt(&self.store.cypher(), value)
.map_err(|e| KeystoreError::Store(e.to_string()))?;
self.store.data_mut().put(name.to_owned(), encrypted);
Ok(())
}
pub fn save(&mut self, path: &Path) -> std::result::Result<(), KeystoreError> {
self.store
.save(path)
.map_err(|e| KeystoreError::Store(e.to_string()))
}
}
impl Signer for Keystore {
fn authenticate(&self, message: &[u8]) -> Result<RequestAuth> {
let cypher = self.store.cypher();
let data = self.store.data();
let decrypt = |name: &'static str| -> Result<Zeroizing<String>> {
let entry = data.latest(name).ok_or_else(|| Error::Signing {
message: format!("vault has no '{name}' record"),
})?;
entry.value.decrypt(&cypher).map_err(|e| Error::Signing {
message: format!("vault decrypt of '{name}' failed: {e}"),
})
};
let api_key = decrypt(Self::API_KEY)?;
let private_key_pem = decrypt(Self::PRIVATE_KEY_PEM)?;
let signer = Ed25519Signer::from_pem(api_key.as_str(), private_key_pem.as_str())?;
signer.authenticate(message)
}
}
impl std::fmt::Debug for Keystore {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Keystore").finish_non_exhaustive()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
use crate::auth::signing_message;
use crate::generate_key_pair;
use rcypher::LockedContainer;
const TEST_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
MC4CAQAwBQYDK2VwBCIEIFMSbiie3sYstkM3gSCUb+oVO5xucWXdyv9l4k2pRrZ0\n\
-----END PRIVATE KEY-----\n";
const GET_BALANCES_SIG: &str =
"GZOMBk8Dy8QYI/esfxUSuZW6aDsPD/Yt12eX0xmjDsYR9GIqUSBolSNiP0ZUWvSQvD5oKUlq+LGqAoT/H1hBBg==";
fn params() -> Argon2Params {
Argon2Params::insecure()
}
fn store_bytes(password: &str, api_key: &str, pem: &str) -> Vec<u8> {
let mut ks = Keystore::create("primary", password, ¶ms()).unwrap();
ks.set(Keystore::API_KEY, api_key).unwrap();
ks.set(Keystore::PRIVATE_KEY_PEM, pem).unwrap();
ks.container().to_vec().unwrap()
}
fn open(bytes: &[u8], password: &str) -> std::result::Result<Keystore, String> {
let mut locked =
LockedContainer::from_slice_with_params(bytes, ¶ms()).map_err(|e| e.to_string())?;
if !locked.try_password(password).map_err(|e| e.to_string())? || !locked.can_unlock() {
return Err("wrong password".to_string());
}
let unlocked = locked.unlock::<SecretStore>().map_err(|e| e.to_string())?;
Ok(Keystore::from_unlocked(unlocked))
}
#[test]
fn vault_round_trips_and_signs_identically() {
let bytes = store_bytes("master-pw", "api-key", TEST_PEM);
let keystore = open(&bytes, "master-pw").unwrap();
let message = signing_message(1_700_000_000_000, "GET", "/api/1.0/balances", "", b"");
let auth = keystore.authenticate(&message).unwrap();
assert_eq!(auth.api_key.as_str(), "api-key");
assert_eq!(auth.signature, GET_BALANCES_SIG);
}
#[test]
fn generated_key_pair_round_trips_through_a_vault() {
let generated = generate_key_pair().unwrap();
assert!(
generated
.private_pem
.starts_with("-----BEGIN PRIVATE KEY-----")
);
assert!(
generated
.public_pem
.starts_with("-----BEGIN PUBLIC KEY-----")
);
let bytes = store_bytes("pw", "api-key", &generated.private_pem);
let keystore = open(&bytes, "pw").unwrap();
let message = signing_message(1_700_000_000_000, "GET", "/api/1.0/balances", "", b"");
let auth = keystore.authenticate(&message).unwrap();
assert_eq!(auth.api_key.as_str(), "api-key");
assert_eq!(auth.signature.len(), 88);
}
#[test]
fn wrong_password_is_rejected() {
let bytes = store_bytes("right", "k", TEST_PEM);
assert!(open(&bytes, "wrong").is_err());
}
#[test]
fn records_round_trip_through_a_file_and_overwrite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.rcx");
let mut ks = Keystore::create("primary", "pw", ¶ms()).unwrap();
assert!(ks.get("api_key").unwrap().is_none(), "empty to start");
ks.set("api_key", "first").unwrap();
ks.set("api_key", "second").unwrap(); ks.save(&path).unwrap();
let reopened = open(&std::fs::read(&path).unwrap(), "pw").unwrap();
assert_eq!(
reopened
.get("api_key")
.unwrap()
.as_deref()
.map(String::as_str),
Some("second")
);
assert!(reopened.get("missing").unwrap().is_none());
}
#[test]
fn drives_a_client_as_signer() {
use std::sync::Arc;
let bytes = store_bytes("pw", "k", TEST_PEM);
let keystore = open(&bytes, "pw").unwrap();
let client = crate::RevolutXClient::builder()
.signer(Arc::new(keystore))
.build()
.unwrap();
assert!(client.is_authenticated());
}
}