use std::path::{Path, PathBuf};
use bincode::{Decode, Encode};
use rcypher::{Cypher, CypherVersion, EncryptionKey};
use zeroize::{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,
};
pub struct GeneratedKeyPair {
pub private_pem: Zeroizing<String>,
pub public_pem: String,
}
pub fn generate_key_pair() -> std::result::Result<GeneratedKeyPair, KeystoreError> {
use ed25519_dalek::SigningKey;
use ed25519_dalek::pkcs8::EncodePrivateKey;
use ed25519_dalek::pkcs8::spki::EncodePublicKey;
use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
let mut seed = Zeroizing::new([0u8; 32]);
getrandom::fill(seed.as_mut_slice())
.map_err(|e| KeystoreError::Crypto(format!("could not read OS randomness: {e}")))?;
let signing = SigningKey::from_bytes(&seed);
let private_pem = signing
.to_pkcs8_pem(LineEnding::LF)
.map_err(|e| KeystoreError::Crypto(format!("could not encode private key: {e}")))?;
let public_pem = signing
.verifying_key()
.to_public_key_pem(LineEnding::LF)
.map_err(|e| KeystoreError::Crypto(format!("could not encode public key: {e}")))?;
Ok(GeneratedKeyPair {
private_pem,
public_pem,
})
}
#[derive(Encode, Decode, Default)]
struct Records(Vec<(String, String)>);
impl Records {
fn get(&self, name: &str) -> Option<&str> {
self.0
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
}
fn set(&mut self, name: &str, value: &str) {
if let Some(entry) = self.0.iter_mut().find(|(key, _)| key == name) {
value.clone_into(&mut entry.1);
} else {
self.0.push((name.to_owned(), value.to_owned()));
}
}
}
impl Drop for Records {
fn drop(&mut self) {
for (name, value) in &mut self.0 {
name.zeroize();
value.zeroize();
}
}
}
#[derive(Clone)]
pub struct KeystoreOptions {
pub argon2: Argon2Params,
pub trace_detection: bool,
}
impl Default for KeystoreOptions {
fn default() -> Self {
Self {
argon2: Argon2Params::default(),
trace_detection: true,
}
}
}
impl std::fmt::Debug for KeystoreOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KeystoreOptions")
.field("trace_detection", &self.trace_detection)
.finish_non_exhaustive()
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum KeystoreError {
#[error("vault file error: {0}")]
Io(#[from] std::io::Error),
#[error("could not unlock the vault (wrong password or corrupted file): {0}")]
Unlock(String),
#[error("vault crypto error: {0}")]
Crypto(String),
#[error("invalid vault contents: {0}")]
Contents(String),
}
pub struct Keystore {
cypher: Cypher,
blob: Vec<u8>,
path: PathBuf,
}
impl Keystore {
pub const API_KEY: &'static str = "api_key";
pub const PRIVATE_KEY_PEM: &'static str = "private_key_pem";
pub fn open(path: &Path, password: &str) -> std::result::Result<Self, KeystoreError> {
Self::open_with(path, password, &KeystoreOptions::default())
}
pub fn open_with(
path: &Path,
password: &str,
options: &KeystoreOptions,
) -> std::result::Result<Self, KeystoreError> {
let (cypher, blob) = if path.exists() {
let blob = std::fs::read(path)?;
let key = EncryptionKey::for_data_with_params(password, &blob, &options.argon2)
.map_err(|e| KeystoreError::Unlock(e.to_string()))?;
let cypher = Cypher::with_trace_detection(key, options.trace_detection);
cypher
.decrypt(&blob)
.map_err(|e| KeystoreError::Unlock(e.to_string()))?;
(cypher, blob)
} else {
let key = EncryptionKey::from_password_with_params(
CypherVersion::default(),
password,
&options.argon2,
)
.map_err(|e| KeystoreError::Crypto(e.to_string()))?;
let cypher = Cypher::with_trace_detection(key, options.trace_detection);
let blob = encrypt_records(&cypher, &Records::default())?;
(cypher, blob)
};
Ok(Self {
cypher,
blob,
path: path.to_owned(),
})
}
pub fn get(&self, name: &str) -> std::result::Result<Option<Zeroizing<String>>, KeystoreError> {
Ok(self
.decrypt()?
.get(name)
.map(|value| Zeroizing::new(value.to_owned())))
}
pub fn set(&mut self, name: &str, value: &str) -> std::result::Result<(), KeystoreError> {
let mut records = self.decrypt()?;
records.set(name, value);
self.blob = encrypt_records(&self.cypher, &records)?;
Ok(())
}
pub fn save(&self) -> std::result::Result<(), KeystoreError> {
atomic_write(&self.path, &self.blob)?;
Ok(())
}
fn decrypt(&self) -> std::result::Result<Records, KeystoreError> {
let plaintext = self
.cypher
.decrypt(&self.blob)
.map_err(|e| KeystoreError::Crypto(e.to_string()))?;
let (records, _) =
bincode::decode_from_slice(plaintext.as_slice(), bincode::config::standard())
.map_err(|e| KeystoreError::Contents(e.to_string()))?;
Ok(records)
}
}
fn encrypt_records(
cypher: &Cypher,
records: &Records,
) -> std::result::Result<Vec<u8>, KeystoreError> {
let plaintext = Zeroizing::new(
bincode::encode_to_vec(records, bincode::config::standard())
.map_err(|e| KeystoreError::Contents(e.to_string()))?,
);
cypher
.encrypt(plaintext.as_slice())
.map_err(|e| KeystoreError::Crypto(e.to_string()))
}
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let file_name = path.file_name().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"vault path has no file name",
)
})?;
let tmp_name = format!(
".{}.{}.tmp",
file_name.to_string_lossy(),
std::process::id()
);
let tmp = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map_or_else(|| PathBuf::from(&tmp_name), |parent| parent.join(&tmp_name));
let mut options = std::fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let write = (|| {
let mut file = options.open(&tmp)?;
file.write_all(data)?;
file.sync_all()
})();
if let Err(e) = write {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
std::fs::rename(&tmp, path)
}
impl Signer for Keystore {
fn authenticate(&self, message: &[u8]) -> Result<RequestAuth> {
let records = self.decrypt().map_err(|e| Error::Signing {
message: format!("vault decrypt failed: {e}"),
})?;
let api_key = records.get(Self::API_KEY).ok_or_else(|| Error::Signing {
message: format!("vault has no '{}' record", Self::API_KEY),
})?;
let private_key_pem = records
.get(Self::PRIVATE_KEY_PEM)
.ok_or_else(|| Error::Signing {
message: format!("vault has no '{}' record", Self::PRIVATE_KEY_PEM),
})?;
let signer = Ed25519Signer::from_pem(api_key, private_key_pem)?;
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;
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 test_options() -> KeystoreOptions {
KeystoreOptions {
argon2: Argon2Params::insecure(),
trace_detection: false,
}
}
fn write_vault(path: &Path, password: &str, api_key: &str, pem: &str) {
let mut vault = Keystore::open_with(path, password, &test_options()).unwrap();
vault.set(Keystore::API_KEY, api_key).unwrap();
vault.set(Keystore::PRIVATE_KEY_PEM, pem).unwrap();
vault.save().unwrap();
}
#[test]
fn vault_round_trips_and_signs_identically() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.rcx");
write_vault(&path, "master-pw", "api-key", TEST_PEM);
let keystore = Keystore::open_with(&path, "master-pw", &test_options()).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 dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.rcx");
write_vault(&path, "pw", "api-key", &generated.private_pem);
let keystore = Keystore::open_with(&path, "pw", &test_options()).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 two_generated_key_pairs_differ() {
let a = generate_key_pair().unwrap();
let b = generate_key_pair().unwrap();
assert_ne!(*a.private_pem, *b.private_pem);
assert_ne!(a.public_pem, b.public_pem);
}
#[test]
fn wrong_password_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.rcx");
write_vault(&path, "right", "k", TEST_PEM);
assert!(Keystore::open_with(&path, "wrong", &test_options()).is_err());
}
#[test]
fn records_round_trip_and_overwrite() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.rcx");
let mut vault = Keystore::open_with(&path, "pw", &test_options()).unwrap();
assert!(vault.get("api_key").unwrap().is_none(), "empty to start");
vault.set("api_key", "first").unwrap();
vault.set("api_key", "second").unwrap(); vault.save().unwrap();
let reopened = Keystore::open_with(&path, "pw", &test_options()).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 dir = tempfile::tempdir().unwrap();
let path = dir.path().join("vault.rcx");
write_vault(&path, "pw", "k", TEST_PEM);
let keystore = Keystore::open_with(&path, "pw", &test_options()).unwrap();
let client = crate::RevolutXClient::builder()
.signer(Arc::new(keystore))
.build()
.unwrap();
assert!(client.is_authenticated());
}
}