use std::path::{Path, PathBuf};
use aes_gcm_siv::{
aead::{Aead, KeyInit},
Aes256GcmSiv, Key, Nonce,
};
use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize};
use std::{fs::OpenOptions, io::Write};
use zeroize::Zeroizing;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use crate::crypto::error::CryptoError;
const KEYSTORE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScryptParams {
pub log_n: u8,
pub r: u32,
pub p: u32,
}
impl Default for ScryptParams {
fn default() -> Self {
Self { log_n: 18, r: 8, p: 1 }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeystoreEncryption {
pub kdf: String,
pub params: ScryptParams,
pub salt: String,
pub nonce: String,
pub ciphertext: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThresholdKeystore {
pub version: u32,
pub ceremony_id: String,
pub created_at: String,
pub encryption: KeystoreEncryption,
}
fn derive_key(password: &[u8], salt: &[u8], params: &ScryptParams) -> Result<Zeroizing<[u8; 32]>, CryptoError> {
let log_n = params.log_n;
let r = params.r;
let p = params.p;
let scrypt_params = scrypt::Params::new(log_n, r, p, 32)
.map_err(|e| CryptoError::KeystoreEncrypt(format!("invalid scrypt params: {e}")))?;
let mut key = Zeroizing::new([0u8; 32]);
scrypt::scrypt(password, salt, &scrypt_params, &mut *key)
.map_err(|e| CryptoError::KeystoreEncrypt(format!("scrypt KDF failed: {e}")))?;
Ok(key)
}
fn aes_encrypt(key: &[u8; 32], nonce_bytes: &[u8; 12], plaintext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let cipher = Aes256GcmSiv::new(Key::<Aes256GcmSiv>::from_slice(key));
let nonce = Nonce::from_slice(nonce_bytes);
cipher
.encrypt(nonce, plaintext)
.map_err(|e| CryptoError::KeystoreEncrypt(format!("AES-GCM-SIV encrypt failed: {e}")))
}
fn aes_decrypt(key: &[u8; 32], nonce_bytes: &[u8; 12], ciphertext: &[u8]) -> Result<Vec<u8>, CryptoError> {
let cipher = Aes256GcmSiv::new(Key::<Aes256GcmSiv>::from_slice(key));
let nonce = Nonce::from_slice(nonce_bytes);
cipher.decrypt(nonce, ciphertext).map_err(|_| {
CryptoError::KeystoreDecrypt("AES-GCM-SIV decryption failed (wrong password or corrupted data)".into())
})
}
pub fn write_keystore(path: &Path, ceremony_id: &str, plaintext: &[u8], password: &[u8]) -> Result<(), CryptoError> {
let mut salt = [0u8; 32];
OsRng.fill_bytes(&mut salt);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let params = ScryptParams::default();
let key = derive_key(password, &salt, ¶ms)?;
let ciphertext = aes_encrypt(&key, &nonce_bytes, plaintext)?;
let salt_hex = hex::encode(salt);
let nonce_hex = hex::encode(nonce_bytes);
let ciphertext_hex = hex::encode(&ciphertext);
let created_at = chrono::Utc::now().to_rfc3339();
let keystore = ThresholdKeystore {
version: KEYSTORE_VERSION,
ceremony_id: ceremony_id.to_string(),
created_at,
encryption: KeystoreEncryption {
kdf: "scrypt".to_string(),
params,
salt: salt_hex,
nonce: nonce_hex,
ciphertext: ciphertext_hex,
},
};
let json = serde_json::to_string_pretty(&keystore)
.map_err(|e| CryptoError::KeystoreEncrypt(format!("JSON serialization failed: {e}")))?;
let tmp_path = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => path.with_file_name(format!("{name}.tmp")),
None => {
return Err(CryptoError::KeystoreEncrypt(format!(
"invalid keystore path (no file name): {}",
path.display()
)))
}
};
{
let mut opts = OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut file = opts.open(&tmp_path).map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to open keystore temp file at {}: {e}",
tmp_path.display()
))
})?;
file.write_all(json.as_bytes()).map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to write keystore temp file at {}: {e}",
tmp_path.display()
))
})?;
file.sync_all().map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to fsync keystore temp file at {}: {e}",
tmp_path.display()
))
})?;
}
std::fs::rename(&tmp_path, path).map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to atomically replace keystore at {}: {e}",
path.display()
))
})?;
Ok(())
}
pub fn read_keystore(path: &Path, password: &[u8]) -> Result<Vec<u8>, CryptoError> {
let json = std::fs::read_to_string(path)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("failed to read keystore from {}: {e}", path.display())))?;
let keystore: ThresholdKeystore =
serde_json::from_str(&json).map_err(|e| CryptoError::KeystoreDecrypt(format!("invalid keystore JSON: {e}")))?;
decrypt_keystore(&keystore, password)
}
pub fn decrypt_keystore(keystore: &ThresholdKeystore, password: &[u8]) -> Result<Vec<u8>, CryptoError> {
if keystore.version != KEYSTORE_VERSION {
return Err(CryptoError::KeystoreDecrypt(format!(
"unsupported keystore version {} (expected {})",
keystore.version, KEYSTORE_VERSION
)));
}
if keystore.encryption.kdf != "scrypt" {
return Err(CryptoError::KeystoreDecrypt(format!(
"unsupported KDF: {} (expected scrypt)",
keystore.encryption.kdf
)));
}
let salt = hex::decode(&keystore.encryption.salt)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("invalid salt hex: {e}")))?;
let salt: [u8; 32] = salt
.try_into()
.map_err(|_| CryptoError::KeystoreDecrypt("salt must be 32 bytes".into()))?;
let nonce_bytes = hex::decode(&keystore.encryption.nonce)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("invalid nonce hex: {e}")))?;
let nonce_bytes: [u8; 12] = nonce_bytes
.try_into()
.map_err(|_| CryptoError::KeystoreDecrypt("nonce must be 12 bytes".into()))?;
let ciphertext = hex::decode(&keystore.encryption.ciphertext)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("invalid ciphertext hex: {e}")))?;
let key = derive_key(password, &salt, &keystore.encryption.params)
.map_err(|e| CryptoError::KeystoreDecrypt(format!("KDF failed: {e}")))?;
aes_decrypt(&key, &nonce_bytes, &ciphertext)
}
const EPOCH_KEYSTORE_PREFIX: &str = "threshold_keystore_chain_";
fn epoch_keystore_filename(chain_id: u64, epoch_id: u64) -> String {
format!("{EPOCH_KEYSTORE_PREFIX}{chain_id}_epoch_{epoch_id}.json")
}
pub fn write_epoch_keystore(
dir: &Path,
chain_id: u64,
epoch_id: u64,
ceremony_id: &str,
plaintext: &[u8],
password: &[u8],
) -> Result<PathBuf, CryptoError> {
let path = dir.join(epoch_keystore_filename(chain_id, epoch_id));
write_keystore(&path, ceremony_id, plaintext, password)?;
Ok(path)
}
pub fn list_epoch_keystores(dir: &Path, chain_id: u64) -> Result<Vec<(u64, PathBuf)>, CryptoError> {
let entries = std::fs::read_dir(dir).map_err(|e| {
CryptoError::KeystoreDecrypt(format!("failed to read keystore directory {}: {e}", dir.display()))
})?;
let mut keystores: Vec<(u64, PathBuf)> = Vec::new();
for entry in entries {
let entry = entry.map_err(|e| {
CryptoError::KeystoreDecrypt(format!("failed to read directory entry in {}: {e}", dir.display()))
})?;
let path = entry.path();
if let Some((file_chain_id, epoch_id)) = parse_epoch_keystore_path(&path) {
if file_chain_id == chain_id {
keystores.push((epoch_id, path));
}
}
}
keystores.sort_by_key(|k| std::cmp::Reverse(k.0));
Ok(keystores)
}
pub fn read_latest_epoch_keystore(
dir: &Path,
chain_id: u64,
password: &[u8],
) -> Result<Option<(u64, Vec<u8>)>, CryptoError> {
let keystores = list_epoch_keystores(dir, chain_id)?;
match keystores.first() {
Some((epoch_id, path)) => {
let plaintext = read_keystore(path, password)?;
Ok(Some((*epoch_id, plaintext)))
}
None => Ok(None),
}
}
pub fn epoch_keystore_exists(dir: &Path, chain_id: u64, epoch_id: u64) -> bool {
dir.join(epoch_keystore_filename(chain_id, epoch_id)).exists()
}
pub fn delete_epoch_keystore(dir: &Path, chain_id: u64, epoch_id: u64) -> Result<(), CryptoError> {
let path = dir.join(epoch_keystore_filename(chain_id, epoch_id));
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(CryptoError::KeystoreDecrypt(format!(
"failed to delete epoch keystore at {}: {e}",
path.display()
))),
}
}
fn parse_epoch_keystore_path(path: &Path) -> Option<(u64, u64)> {
let filename = path.file_name()?.to_str()?;
let stem = filename.strip_prefix(EPOCH_KEYSTORE_PREFIX)?.strip_suffix(".json")?;
let (chain_str, epoch_str) = stem.split_once("_epoch_")?;
let chain_id = chain_str.parse::<u64>().ok()?;
let epoch_id = epoch_str.parse::<u64>().ok()?;
Some((chain_id, epoch_id))
}
const APPLIED_CEREMONIES_FILE: &str = "applied_refresh_ceremonies.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppliedCeremony {
pub chain_id: u64,
pub epoch_id: u64,
pub ceremony_id: String,
pub mode: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AppliedCeremonyLedger {
pub entries: Vec<AppliedCeremony>,
}
pub fn read_applied_ceremonies(dir: &Path) -> Result<AppliedCeremonyLedger, CryptoError> {
let path = dir.join(APPLIED_CEREMONIES_FILE);
match std::fs::read_to_string(&path) {
Ok(json) => serde_json::from_str(&json).map_err(|e| {
CryptoError::KeystoreDecrypt(format!("invalid applied-ceremony ledger at {}: {e}", path.display()))
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(AppliedCeremonyLedger::default()),
Err(e) => Err(CryptoError::KeystoreDecrypt(format!(
"failed to read applied-ceremony ledger at {}: {e}",
path.display()
))),
}
}
pub fn is_ceremony_applied(dir: &Path, entry: &AppliedCeremony) -> Result<bool, CryptoError> {
Ok(read_applied_ceremonies(dir)?.entries.iter().any(|e| e == entry))
}
pub fn record_applied_ceremony(dir: &Path, entry: AppliedCeremony) -> Result<(), CryptoError> {
let mut ledger = read_applied_ceremonies(dir)?;
if ledger.entries.contains(&entry) {
return Ok(());
}
ledger.entries.push(entry);
let json = serde_json::to_string_pretty(&ledger)
.map_err(|e| CryptoError::KeystoreEncrypt(format!("failed to serialize applied-ceremony ledger: {e}")))?;
let path = dir.join(APPLIED_CEREMONIES_FILE);
let tmp_path = dir.join(format!("{APPLIED_CEREMONIES_FILE}.tmp"));
{
let mut opts = OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
opts.mode(0o600);
let mut file = opts.open(&tmp_path).map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to open applied-ceremony ledger temp file at {}: {e}",
tmp_path.display()
))
})?;
file.write_all(json.as_bytes()).map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to write applied-ceremony ledger temp file at {}: {e}",
tmp_path.display()
))
})?;
file.sync_all().map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to fsync applied-ceremony ledger temp file at {}: {e}",
tmp_path.display()
))
})?;
}
std::fs::rename(&tmp_path, &path).map_err(|e| {
CryptoError::KeystoreEncrypt(format!(
"failed to atomically replace applied-ceremony ledger at {}: {e}",
path.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use tempfile::NamedTempFile;
use super::*;
const TEST_CHAIN_ID: u64 = 31337;
#[test]
fn epoch_keystore_write_read_roundtrip() {
let dir = tempfile::tempdir().expect("tempdir");
let plaintext = b"epoch-0-key-share-data";
let password = b"test-password";
let path = write_epoch_keystore(dir.path(), TEST_CHAIN_ID, 0, "ceremony-epoch-0", plaintext, password)
.expect("write_epoch_keystore");
assert!(path.exists());
let result = read_latest_epoch_keystore(dir.path(), TEST_CHAIN_ID, password).expect("read_latest");
let (epoch_id, recovered) = result.expect("expected Some");
assert_eq!(epoch_id, 0);
assert_eq!(recovered, plaintext);
}
#[test]
fn list_epoch_keystores_sorted_descending() {
let dir = tempfile::tempdir().expect("tempdir");
let password = b"test-password";
write_epoch_keystore(dir.path(), TEST_CHAIN_ID, 0, "c0", b"data-0", password).expect("write 0");
write_epoch_keystore(dir.path(), TEST_CHAIN_ID, 2, "c2", b"data-2", password).expect("write 2");
write_epoch_keystore(dir.path(), TEST_CHAIN_ID, 1, "c1", b"data-1", password).expect("write 1");
let keystores = list_epoch_keystores(dir.path(), TEST_CHAIN_ID).expect("list");
let epoch_ids: Vec<u64> = keystores.iter().map(|(id, _)| *id).collect();
assert_eq!(epoch_ids, vec![2, 1, 0]);
}
#[test]
fn delete_epoch_keystore_removes_file() {
let dir = tempfile::tempdir().expect("tempdir");
let password = b"test-password";
write_epoch_keystore(dir.path(), TEST_CHAIN_ID, 5, "c5", b"data-5", password).expect("write 5");
let keystores = list_epoch_keystores(dir.path(), TEST_CHAIN_ID).expect("list before delete");
assert_eq!(keystores.len(), 1);
delete_epoch_keystore(dir.path(), TEST_CHAIN_ID, 5).expect("delete");
let keystores = list_epoch_keystores(dir.path(), TEST_CHAIN_ID).expect("list after delete");
assert!(keystores.is_empty());
}
#[test]
fn read_latest_returns_none_for_empty_dir() {
let dir = tempfile::tempdir().expect("tempdir");
let result = read_latest_epoch_keystore(dir.path(), TEST_CHAIN_ID, b"any-password").expect("read_latest");
assert!(result.is_none());
}
#[test]
fn epoch_keystores_are_namespaced_per_chain() {
let dir = tempfile::tempdir().expect("tempdir");
let password = b"test-password";
let chain_a = 1u64;
let chain_b = 2u64;
write_epoch_keystore(dir.path(), chain_a, 7, "ca-7", b"chain-a-share", password).expect("write chain a");
write_epoch_keystore(dir.path(), chain_b, 7, "cb-7", b"chain-b-share", password).expect("write chain b");
assert_eq!(list_epoch_keystores(dir.path(), chain_a).expect("list a").len(), 1);
assert_eq!(list_epoch_keystores(dir.path(), chain_b).expect("list b").len(), 1);
let (_, a_share) = read_latest_epoch_keystore(dir.path(), chain_a, password)
.expect("read a")
.expect("some a");
let (_, b_share) = read_latest_epoch_keystore(dir.path(), chain_b, password)
.expect("read b")
.expect("some b");
assert_eq!(a_share, b"chain-a-share");
assert_eq!(b_share, b"chain-b-share");
delete_epoch_keystore(dir.path(), chain_a, 7).expect("delete a");
assert!(list_epoch_keystores(dir.path(), chain_a)
.expect("list a after delete")
.is_empty());
assert_eq!(
list_epoch_keystores(dir.path(), chain_b)
.expect("list b after delete")
.len(),
1
);
}
#[test]
fn epoch_keystore_exists_tracks_write() {
let dir = tempfile::tempdir().expect("tempdir");
let password = b"test-password";
assert!(!epoch_keystore_exists(dir.path(), TEST_CHAIN_ID, 9));
write_epoch_keystore(dir.path(), TEST_CHAIN_ID, 9, "c9", b"data-9", password).expect("write 9");
assert!(epoch_keystore_exists(dir.path(), TEST_CHAIN_ID, 9));
assert!(!epoch_keystore_exists(dir.path(), TEST_CHAIN_ID + 1, 9));
}
#[test]
fn applied_ceremony_ledger_records_and_detects() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = AppliedCeremony {
chain_id: 31337,
epoch_id: 7,
ceremony_id: "pss-refresh-epoch-7".to_string(),
mode: "refresh".to_string(),
};
assert!(!is_ceremony_applied(dir.path(), &entry).expect("check"));
record_applied_ceremony(dir.path(), entry.clone()).expect("record");
assert!(is_ceremony_applied(dir.path(), &entry).expect("check after record"));
record_applied_ceremony(dir.path(), entry.clone()).expect("record again");
let ledger = read_applied_ceremonies(dir.path()).expect("read");
assert_eq!(ledger.entries.len(), 1);
let reshare = AppliedCeremony {
mode: "reshare".to_string(),
..entry.clone()
};
assert!(!is_ceremony_applied(dir.path(), &reshare).expect("check reshare"));
record_applied_ceremony(dir.path(), reshare.clone()).expect("record reshare");
let ledger = read_applied_ceremonies(dir.path()).expect("read after two");
assert_eq!(ledger.entries.len(), 2);
assert!(is_ceremony_applied(dir.path(), &entry).expect("first still present"));
assert!(
!dir.path().join(format!("{APPLIED_CEREMONIES_FILE}.tmp")).exists(),
"temp ledger file should be renamed away, not left on disk"
);
}
#[test]
fn record_applied_ceremony_propagates_corrupt_ledger() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join(APPLIED_CEREMONIES_FILE), b"{ not valid json").expect("seed corrupt");
let entry = AppliedCeremony {
chain_id: 1,
epoch_id: 1,
ceremony_id: "c".to_string(),
mode: "refresh".to_string(),
};
assert!(record_applied_ceremony(dir.path(), entry).is_err());
}
#[test]
fn applied_ceremony_ledger_empty_for_missing_file() {
let dir = tempfile::tempdir().expect("tempdir");
let ledger = read_applied_ceremonies(dir.path()).expect("read empty");
assert!(ledger.entries.is_empty());
}
#[test]
fn keystore_write_read_roundtrip() {
let plaintext = b"this is my secret threshold key share data";
let password = b"correct-horse-battery-staple";
let ceremony_id = "test-ceremony-abc123";
let tmp = NamedTempFile::new().expect("tempfile");
write_keystore(tmp.path(), ceremony_id, plaintext, password).expect("write_keystore");
let recovered = read_keystore(tmp.path(), password).expect("read_keystore");
assert_eq!(recovered, plaintext);
}
#[test]
fn keystore_wrong_password_fails() {
let plaintext = b"sensitive key material";
let password = b"correct-password";
let wrong_password = b"wrong-password";
let tmp = NamedTempFile::new().expect("tempfile");
write_keystore(tmp.path(), "ceremony-1", plaintext, password).expect("write_keystore");
let result = read_keystore(tmp.path(), wrong_password);
assert!(result.is_err(), "expected decryption to fail with wrong password");
let err = result.unwrap_err();
assert!(
matches!(err, CryptoError::KeystoreDecrypt(_)),
"expected KeystoreDecrypt, got: {err}"
);
}
#[test]
fn keystore_file_not_found() {
let path = Path::new("/tmp/nonexistent-newton-keystore-abc123.json");
let result = read_keystore(path, b"password");
assert!(result.is_err(), "expected error for nonexistent file");
let err = result.unwrap_err();
assert!(
matches!(err, CryptoError::KeystoreDecrypt(_)),
"expected KeystoreDecrypt, got: {err}"
);
}
#[test]
fn keystore_corrupted_payload() {
let plaintext = b"key share bytes";
let password = b"my-password";
let tmp = NamedTempFile::new().expect("tempfile");
write_keystore(tmp.path(), "ceremony-2", plaintext, password).expect("write_keystore");
let json = std::fs::read_to_string(tmp.path()).expect("read");
let mut keystore: ThresholdKeystore = serde_json::from_str(&json).expect("parse");
let mut ciphertext_bytes = hex::decode(&keystore.encryption.ciphertext).expect("decode");
ciphertext_bytes[0] ^= 0xff;
keystore.encryption.ciphertext = hex::encode(&ciphertext_bytes);
let corrupted_json = serde_json::to_string_pretty(&keystore).expect("serialize");
std::fs::write(tmp.path(), corrupted_json).expect("write");
let result = read_keystore(tmp.path(), password);
assert!(result.is_err(), "expected decryption to fail with corrupted payload");
let err = result.unwrap_err();
assert!(
matches!(err, CryptoError::KeystoreDecrypt(_)),
"expected KeystoreDecrypt, got: {err}"
);
}
}