use crate::error::{IdentityError, Result};
use crate::identity::{AgentCertificate, AgentKeypair, MachineKeypair, UserKeypair};
use crate::revocation::RevocationSet;
use serde::{Deserialize, Serialize};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use tokio::fs;
#[derive(Serialize, Deserialize)]
struct SerializedKeypair {
public_key: Vec<u8>,
secret_key: Vec<u8>,
}
#[derive(Serialize, Deserialize)]
struct SerializedKeypairV2 {
public_key: Vec<u8>,
secret_key: Vec<u8>,
not_after: u64,
}
const KEYFILE_V2_MAGIC: &[u8; 4] = b"X0K2";
fn encode_keypair_bytes(
public_key: Vec<u8>,
secret_key: Vec<u8>,
not_after: Option<u64>,
) -> Result<Vec<u8>> {
match not_after {
None => {
let data = SerializedKeypair {
public_key,
secret_key,
};
bincode::serialize(&data).map_err(|e| IdentityError::Serialization(e.to_string()))
}
Some(not_after) => {
let data = SerializedKeypairV2 {
public_key,
secret_key,
not_after,
};
let body = bincode::serialize(&data)
.map_err(|e| IdentityError::Serialization(e.to_string()))?;
let mut out = Vec::with_capacity(KEYFILE_V2_MAGIC.len() + body.len());
out.extend_from_slice(KEYFILE_V2_MAGIC);
out.extend_from_slice(&body);
Ok(out)
}
}
}
fn decode_keypair_bytes(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>, Option<u64>)> {
if bytes.len() >= KEYFILE_V2_MAGIC.len() && &bytes[..KEYFILE_V2_MAGIC.len()] == KEYFILE_V2_MAGIC
{
let data: SerializedKeypairV2 = bincode::deserialize(&bytes[KEYFILE_V2_MAGIC.len()..])
.map_err(|e| IdentityError::Serialization(e.to_string()))?;
Ok((data.public_key, data.secret_key, Some(data.not_after)))
} else {
let data: SerializedKeypair =
bincode::deserialize(bytes).map_err(|e| IdentityError::Serialization(e.to_string()))?;
Ok((data.public_key, data.secret_key, None))
}
}
pub fn serialize_machine_keypair(kp: &MachineKeypair) -> Result<Vec<u8>> {
encode_keypair_bytes(
kp.public_key().as_bytes().to_vec(),
kp.secret_key().as_bytes().to_vec(),
None,
)
}
pub fn deserialize_machine_keypair(bytes: &[u8]) -> Result<MachineKeypair> {
let (public_key, secret_key, _not_after) = decode_keypair_bytes(bytes)?;
MachineKeypair::from_bytes(&public_key, &secret_key)
}
pub fn deserialize_machine_keypair_with_expiry(
bytes: &[u8],
) -> Result<(MachineKeypair, Option<u64>)> {
let (public_key, secret_key, not_after) = decode_keypair_bytes(bytes)?;
Ok((
MachineKeypair::from_bytes(&public_key, &secret_key)?,
not_after,
))
}
pub fn serialize_machine_keypair_with_expiry(
kp: &MachineKeypair,
not_after: Option<u64>,
) -> Result<Vec<u8>> {
encode_keypair_bytes(
kp.public_key().as_bytes().to_vec(),
kp.secret_key().as_bytes().to_vec(),
not_after,
)
}
pub fn serialize_agent_keypair(kp: &AgentKeypair) -> Result<Vec<u8>> {
encode_keypair_bytes(
kp.public_key().as_bytes().to_vec(),
kp.secret_key().as_bytes().to_vec(),
None,
)
}
pub fn serialize_agent_keypair_with_expiry(
kp: &AgentKeypair,
not_after: Option<u64>,
) -> Result<Vec<u8>> {
encode_keypair_bytes(
kp.public_key().as_bytes().to_vec(),
kp.secret_key().as_bytes().to_vec(),
not_after,
)
}
pub fn deserialize_agent_keypair(bytes: &[u8]) -> Result<AgentKeypair> {
let (public_key, secret_key, _not_after) = decode_keypair_bytes(bytes)?;
AgentKeypair::from_bytes(&public_key, &secret_key)
}
pub fn deserialize_agent_keypair_with_expiry(bytes: &[u8]) -> Result<(AgentKeypair, Option<u64>)> {
let (public_key, secret_key, not_after) = decode_keypair_bytes(bytes)?;
Ok((
AgentKeypair::from_bytes(&public_key, &secret_key)?,
not_after,
))
}
const X0X_DIR: &str = ".x0x";
const MACHINE_KEY_FILE: &str = "machine.key";
const AGENT_KEY_FILE: &str = "agent.key";
const USER_KEY_FILE: &str = "user.key";
const AGENT_CERT_FILE: &str = "agent.cert";
const REVOCATION_FILE: &str = "revocations.bin";
static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
async fn write_private_file(path: &Path, bytes: Vec<u8>) -> Result<()> {
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
fs::create_dir_all(parent)
.await
.map_err(IdentityError::from)?;
let file_name = path.file_name().ok_or_else(|| {
IdentityError::from(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid path: missing file name",
))
})?;
let unique = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp_path = parent.join(format!(
".{}.{}.{}.tmp",
file_name.to_string_lossy(),
std::process::id(),
unique
));
fs::write(&tmp_path, bytes)
.await
.map_err(IdentityError::from)?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&tmp_path)
.await
.map_err(IdentityError::from)?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(&tmp_path, perms)
.await
.map_err(IdentityError::from)?;
}
if let Err(err) = fs::rename(&tmp_path, path).await {
let _ = fs::remove_file(&tmp_path).await;
return Err(IdentityError::from(err));
}
Ok(())
}
pub async fn write_private_bytes(path: &Path, bytes: Vec<u8>) -> Result<()> {
write_private_file(path, bytes).await
}
async fn x0x_dir() -> Result<std::path::PathBuf> {
let home = dirs::home_dir().ok_or_else(|| {
IdentityError::from(std::io::Error::new(
std::io::ErrorKind::NotFound,
"home directory not found",
))
})?;
Ok(home.join(X0X_DIR))
}
pub async fn save_machine_keypair(kp: &MachineKeypair) -> Result<()> {
let dir = x0x_dir().await?;
let path = dir.join(MACHINE_KEY_FILE);
let bytes = serialize_machine_keypair(kp)?;
write_private_file(&path, bytes).await
}
pub async fn load_machine_keypair() -> Result<MachineKeypair> {
let path = x0x_dir().await?.join(MACHINE_KEY_FILE);
let bytes = fs::read(&path).await.map_err(IdentityError::from)?;
deserialize_machine_keypair(&bytes)
}
pub async fn machine_keypair_exists() -> bool {
let Ok(path) = x0x_dir().await else {
return false;
};
tokio::fs::try_exists(path.join(MACHINE_KEY_FILE))
.await
.unwrap_or(false)
}
pub async fn save_agent_keypair<P: AsRef<Path>>(kp: &AgentKeypair, path: P) -> Result<()> {
let bytes = serialize_agent_keypair(kp)?;
write_private_file(path.as_ref(), bytes).await
}
pub async fn save_machine_keypair_to<P: AsRef<Path> + Clone>(
kp: &MachineKeypair,
path: P,
) -> Result<()> {
let bytes = serialize_machine_keypair(kp)?;
write_private_file(path.as_ref(), bytes).await
}
pub async fn load_machine_keypair_from<P: AsRef<Path>>(path: P) -> Result<MachineKeypair> {
let bytes = tokio::fs::read(path).await.map_err(IdentityError::from)?;
deserialize_machine_keypair(&bytes)
}
pub async fn load_agent_keypair<P: AsRef<Path>>(path: P) -> Result<AgentKeypair> {
let bytes = tokio::fs::read(path).await.map_err(IdentityError::from)?;
deserialize_agent_keypair(&bytes)
}
pub async fn save_agent_keypair_default(kp: &AgentKeypair) -> Result<()> {
let dir = x0x_dir().await?;
let path = dir.join(AGENT_KEY_FILE);
let bytes = serialize_agent_keypair(kp)?;
write_private_file(&path, bytes).await
}
pub async fn load_agent_keypair_default() -> Result<AgentKeypair> {
let path = x0x_dir().await?.join(AGENT_KEY_FILE);
let bytes = fs::read(&path).await.map_err(IdentityError::from)?;
deserialize_agent_keypair(&bytes)
}
pub async fn agent_keypair_exists() -> bool {
let Ok(path) = x0x_dir().await else {
return false;
};
tokio::fs::try_exists(path.join(AGENT_KEY_FILE))
.await
.unwrap_or(false)
}
pub async fn save_agent_keypair_to<P: AsRef<Path> + Clone>(
kp: &AgentKeypair,
path: P,
) -> Result<()> {
let bytes = serialize_agent_keypair(kp)?;
write_private_file(path.as_ref(), bytes).await
}
pub async fn load_agent_keypair_from<P: AsRef<Path>>(path: P) -> Result<AgentKeypair> {
let bytes = tokio::fs::read(path).await.map_err(IdentityError::from)?;
deserialize_agent_keypair(&bytes)
}
pub fn serialize_user_keypair(kp: &UserKeypair) -> Result<Vec<u8>> {
encode_keypair_bytes(
kp.public_key().as_bytes().to_vec(),
kp.secret_key().as_bytes().to_vec(),
None,
)
}
pub fn deserialize_user_keypair(bytes: &[u8]) -> Result<UserKeypair> {
let (public_key, secret_key, _not_after) = decode_keypair_bytes(bytes)?;
UserKeypair::from_bytes(&public_key, &secret_key)
}
pub async fn save_user_keypair(kp: &UserKeypair) -> Result<()> {
let dir = x0x_dir().await?;
let path = dir.join(USER_KEY_FILE);
let bytes = serialize_user_keypair(kp)?;
write_private_file(&path, bytes).await
}
pub async fn load_user_keypair() -> Result<UserKeypair> {
let path = x0x_dir().await?.join(USER_KEY_FILE);
let bytes = fs::read(&path).await.map_err(IdentityError::from)?;
deserialize_user_keypair(&bytes)
}
pub async fn user_keypair_exists() -> bool {
let Ok(path) = x0x_dir().await else {
return false;
};
tokio::fs::try_exists(path.join(USER_KEY_FILE))
.await
.unwrap_or(false)
}
pub async fn save_user_keypair_to<P: AsRef<Path> + Clone>(kp: &UserKeypair, path: P) -> Result<()> {
let bytes = serialize_user_keypair(kp)?;
write_private_file(path.as_ref(), bytes).await
}
pub async fn load_user_keypair_from<P: AsRef<Path>>(path: P) -> Result<UserKeypair> {
let bytes = tokio::fs::read(path).await.map_err(IdentityError::from)?;
deserialize_user_keypair(&bytes)
}
pub async fn save_agent_certificate(cert: &AgentCertificate) -> Result<()> {
let dir = x0x_dir().await?;
let path = dir.join(AGENT_CERT_FILE);
let bytes = cert.to_storage_bytes()?;
write_private_file(&path, bytes).await
}
pub async fn load_agent_certificate() -> Result<AgentCertificate> {
let path = x0x_dir().await?.join(AGENT_CERT_FILE);
let bytes = fs::read(&path).await.map_err(IdentityError::from)?;
AgentCertificate::from_storage_bytes(&bytes)
}
pub async fn agent_certificate_exists() -> bool {
let Ok(path) = x0x_dir().await else {
return false;
};
tokio::fs::try_exists(path.join(AGENT_CERT_FILE))
.await
.unwrap_or(false)
}
pub async fn save_agent_certificate_to<P: AsRef<Path> + Clone>(
cert: &AgentCertificate,
path: P,
) -> Result<()> {
let bytes = cert.to_storage_bytes()?;
write_private_file(path.as_ref(), bytes).await
}
pub async fn load_agent_certificate_from<P: AsRef<Path>>(path: P) -> Result<AgentCertificate> {
let bytes = tokio::fs::read(path).await.map_err(IdentityError::from)?;
AgentCertificate::from_storage_bytes(&bytes)
}
fn revocation_path(identity_dir: Option<&Path>) -> Option<std::path::PathBuf> {
match identity_dir {
Some(dir) => Some(dir.join(REVOCATION_FILE)),
None => {
let home = dirs::home_dir()?;
Some(home.join(X0X_DIR).join(REVOCATION_FILE))
}
}
}
pub async fn load_revocation_set(identity_dir: Option<&Path>) -> RevocationSet {
let Some(path) = revocation_path(identity_dir) else {
return RevocationSet::new();
};
match tokio::fs::read(&path).await {
Ok(bytes) => match RevocationSet::from_bytes(&bytes) {
Ok(set) => set,
Err(e) => {
tracing::warn!("Failed to load revocation set from {}: {e}", path.display());
RevocationSet::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => RevocationSet::new(),
Err(e) => {
tracing::warn!("Could not read revocation file {}: {e}", path.display());
RevocationSet::new()
}
}
}
pub async fn save_revocation_set(set: &RevocationSet, identity_dir: Option<&Path>) -> Result<()> {
let Some(path) = revocation_path(identity_dir) else {
return Err(IdentityError::Storage(std::io::Error::other(
"no home directory and no identity_dir — cannot persist revocations",
)));
};
let bytes = set.to_bytes()?;
write_private_file(&path, bytes).await
}
pub async fn save_revocation_set_bytes(bytes: Vec<u8>, identity_dir: Option<&Path>) -> Result<()> {
let Some(path) = revocation_path(identity_dir) else {
return Err(IdentityError::Storage(std::io::Error::other(
"no home directory and no identity_dir — cannot persist revocations",
)));
};
write_private_file(&path, bytes).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{AgentKeypair, MachineKeypair};
#[tokio::test]
async fn test_keypair_serialization_roundtrip() {
let original = MachineKeypair::generate().unwrap();
let serialized = serialize_machine_keypair(&original).unwrap();
let deserialized = deserialize_machine_keypair(&serialized).unwrap();
assert_eq!(original.machine_id(), deserialized.machine_id());
assert_eq!(
original.public_key().as_bytes(),
deserialized.public_key().as_bytes()
);
let original_agent = AgentKeypair::generate().unwrap();
let serialized_agent = serialize_agent_keypair(&original_agent).unwrap();
let deserialized_agent = deserialize_agent_keypair(&serialized_agent).unwrap();
assert_eq!(original_agent.agent_id(), deserialized_agent.agent_id());
assert_eq!(
original_agent.public_key().as_bytes(),
deserialized_agent.public_key().as_bytes()
);
}
#[tokio::test]
async fn test_save_and_load_machine_keypair() {
let keypair = MachineKeypair::generate().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test_machine.key");
save_machine_keypair_to_path(&keypair, &path).await.unwrap();
let loaded = load_machine_keypair_from_path(&path).await.unwrap();
assert_eq!(keypair.machine_id(), loaded.machine_id());
}
#[tokio::test]
async fn test_machine_keypair_exists() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join(MACHINE_KEY_FILE);
assert!(!machine_keypair_exists_in_dir(temp_dir.path()).await);
let keypair = MachineKeypair::generate().unwrap();
save_machine_keypair_to_path(&keypair, &path).await.unwrap();
assert!(machine_keypair_exists_in_dir(temp_dir.path()).await);
}
#[tokio::test]
async fn test_invalid_deserialization() {
let result = deserialize_machine_keypair(&[1u8, 2u8, 3u8]).unwrap_err();
assert!(matches!(result, IdentityError::Serialization(_)));
}
#[cfg(unix)]
#[tokio::test]
async fn test_key_file_has_restrictive_permissions() {
let keypair = MachineKeypair::generate().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test_machine.key");
save_machine_keypair_to_path(&keypair, &path).await.unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"key file must have 0600 permissions, got {:o}",
mode
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_agent_key_file_has_restrictive_permissions() {
let keypair = AgentKeypair::generate().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test_agent.key");
save_agent_keypair_to_path(&keypair, &path).await.unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"agent key file must have 0600 permissions, got {:o}",
mode
);
}
#[cfg(unix)]
#[tokio::test]
async fn test_key_file_not_world_readable() {
let keypair = MachineKeypair::generate().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.key");
save_machine_keypair_to_path(&keypair, &path).await.unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode & 0o004, 0, "key file must not be world-readable");
assert_eq!(mode & 0o040, 0, "key file must not be group-readable");
}
#[cfg(unix)]
#[tokio::test]
async fn test_key_file_not_world_writable() {
let keypair = MachineKeypair::generate().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("test.key");
save_machine_keypair_to_path(&keypair, &path).await.unwrap();
let metadata = std::fs::metadata(&path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode & 0o002, 0, "key file must not be world-writable");
}
#[cfg(unix)]
async fn save_agent_keypair_to_path(kp: &AgentKeypair, path: &Path) -> Result<()> {
let bytes = serialize_agent_keypair(kp)?;
let parent = path.parent().unwrap();
fs::create_dir_all(parent)
.await
.map_err(IdentityError::from)?;
fs::write(path, bytes).await.map_err(IdentityError::from)?;
let mut perms = fs::metadata(path)
.await
.map_err(IdentityError::from)?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms)
.await
.map_err(IdentityError::from)?;
Ok(())
}
async fn save_machine_keypair_to_path(kp: &MachineKeypair, path: &Path) -> Result<()> {
let bytes = serialize_machine_keypair(kp)?;
let parent = path.parent().unwrap();
fs::create_dir_all(parent)
.await
.map_err(IdentityError::from)?;
fs::write(path, bytes).await.map_err(IdentityError::from)?;
#[cfg(unix)]
{
let mut perms = fs::metadata(path)
.await
.map_err(IdentityError::from)?
.permissions();
perms.set_mode(0o600);
fs::set_permissions(path, perms)
.await
.map_err(IdentityError::from)?;
}
Ok(())
}
async fn load_machine_keypair_from_path(path: &Path) -> Result<MachineKeypair> {
let bytes = fs::read(path).await.map_err(IdentityError::from)?;
deserialize_machine_keypair(&bytes)
}
async fn machine_keypair_exists_in_dir(dir: &Path) -> bool {
dir.join(MACHINE_KEY_FILE).exists()
}
#[tokio::test]
async fn write_private_bytes_fails_when_parent_is_a_file_not_a_dir() {
let tmp = tempfile::tempdir().expect("tmpdir");
let blocker = tmp.path().join("blocker");
tokio::fs::write(&blocker, b"x")
.await
.expect("create blocker file");
let target = blocker.join("sub").join("secret.key");
let result = write_private_bytes(&target, b"secret".to_vec()).await;
let err = result.expect_err("must fail when parent is a regular file");
assert!(
matches!(err, IdentityError::Storage(_)),
"unwritable destination must surface as IdentityError::Storage, got {err:?}"
);
}
#[tokio::test]
async fn write_private_bytes_leaves_no_final_file_on_failure() {
let tmp = tempfile::tempdir().expect("tmpdir");
let blocker = tmp.path().join("blocker");
tokio::fs::write(&blocker, b"x")
.await
.expect("create blocker file");
let target = blocker.join("sub").join("secret.key");
let _ = write_private_bytes(&target, b"secret".to_vec()).await;
assert!(
!target.exists(),
"no committed file must remain after a failed atomic write"
);
}
#[test]
fn deserialize_rejects_truncated_and_garbage_bytes_for_every_keypair_type() {
let bad_inputs: &[&[u8]] = &[&[], &[1, 2, 3], &[0xA5; 64], &[0xFF; 4096]];
for bytes in bad_inputs {
let err = deserialize_machine_keypair(bytes)
.err()
.unwrap_or_else(|| panic!("machine: garbage must error: {bytes:?}"));
assert!(
matches!(err, IdentityError::Serialization(_)),
"machine {bytes:?} must be Serialization, got {err:?}"
);
let err = deserialize_agent_keypair(bytes)
.err()
.unwrap_or_else(|| panic!("agent: garbage must error: {bytes:?}"));
assert!(
matches!(err, IdentityError::Serialization(_)),
"agent {bytes:?} must be Serialization, got {err:?}"
);
let err = deserialize_user_keypair(bytes)
.err()
.unwrap_or_else(|| panic!("user: garbage must error: {bytes:?}"));
assert!(
matches!(err, IdentityError::Serialization(_)),
"user {bytes:?} must be Serialization, got {err:?}"
);
}
}
#[test]
fn deserialize_rejects_valid_bincode_with_wrong_size_key_material() {
let malformed = SerializedKeypair {
public_key: vec![0u8; 10], secret_key: vec![0u8; 10],
};
let bytes = bincode::serialize(&malformed).expect("serialize malformed pair");
let err = deserialize_machine_keypair(&bytes).expect_err("must reject wrong-size key");
assert!(
matches!(
err,
IdentityError::InvalidPublicKey(_) | IdentityError::InvalidSecretKey(_)
),
"wrong-size machine key must be InvalidPublicKey/InvalidSecretKey, got {err:?}"
);
let err = deserialize_agent_keypair(&bytes).expect_err("must reject wrong-size key");
assert!(
matches!(
err,
IdentityError::InvalidPublicKey(_) | IdentityError::InvalidSecretKey(_)
),
"wrong-size agent key must be InvalidPublicKey/InvalidSecretKey, got {err:?}"
);
let err = deserialize_user_keypair(&bytes).expect_err("must reject wrong-size key");
assert!(
matches!(
err,
IdentityError::InvalidPublicKey(_) | IdentityError::InvalidSecretKey(_)
),
"wrong-size user key must be InvalidPublicKey/InvalidSecretKey, got {err:?}"
);
}
#[tokio::test]
async fn agent_keypair_round_trips_through_file_storage() {
let kp = AgentKeypair::generate().expect("generate agent keypair");
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join("agent.key");
save_agent_keypair(&kp, &path).await.expect("save");
let loaded = load_agent_keypair(&path).await.expect("load");
assert_eq!(
kp.public_key().as_bytes(),
loaded.public_key().as_bytes(),
"agent public key must survive a save/load round-trip"
);
assert_eq!(
kp.agent_id(),
loaded.agent_id(),
"agent_id must survive a save/load round-trip"
);
}
#[tokio::test]
async fn user_keypair_round_trips_through_file_storage() {
let kp = UserKeypair::generate().expect("generate user keypair");
let tmp = tempfile::tempdir().expect("tmpdir");
let path = tmp.path().join("user.key");
save_user_keypair_to(&kp, &path).await.expect("save");
let loaded = load_user_keypair_from(&path).await.expect("load");
assert_eq!(
kp.public_key().as_bytes(),
loaded.public_key().as_bytes(),
"user public key must survive a save/load round-trip"
);
assert_eq!(
kp.user_id(),
loaded.user_id(),
"user_id must survive a save/load round-trip"
);
}
#[test]
fn keyfile_without_expiry_writes_legacy_format() {
let kp = MachineKeypair::generate().unwrap();
let modern = serialize_machine_keypair(&kp).unwrap();
let legacy = bincode::serialize(&SerializedKeypair {
public_key: kp.public_key().as_bytes().to_vec(),
secret_key: kp.secret_key().as_bytes().to_vec(),
})
.unwrap();
assert_eq!(
modern, legacy,
"a no-expiry key file must be byte-for-byte the legacy format"
);
assert_ne!(
&modern[..KEYFILE_V2_MAGIC.len()],
KEYFILE_V2_MAGIC,
"legacy bytes must not begin with the v2 magic marker"
);
}
#[test]
fn legacy_keyfile_bytes_load_via_new_loader() {
let kp = AgentKeypair::generate().unwrap();
let legacy = bincode::serialize(&SerializedKeypair {
public_key: kp.public_key().as_bytes().to_vec(),
secret_key: kp.secret_key().as_bytes().to_vec(),
})
.unwrap();
let (loaded, not_after) = deserialize_agent_keypair_with_expiry(&legacy).unwrap();
assert_eq!(
loaded.public_key().as_bytes(),
kp.public_key().as_bytes(),
"legacy key bytes must decode to the same public key"
);
assert_eq!(
not_after, None,
"absence of an expiry field must decode as None (never expires)"
);
}
#[test]
fn v2_keyfile_roundtrip_preserves_not_after() {
let kp = MachineKeypair::generate().unwrap();
let expiry = 1_900_000_000u64;
let bytes = serialize_machine_keypair_with_expiry(&kp, Some(expiry)).unwrap();
assert_eq!(
&bytes[..KEYFILE_V2_MAGIC.len()],
KEYFILE_V2_MAGIC,
"a v2 key file must begin with the magic marker"
);
let (loaded, not_after) = deserialize_machine_keypair_with_expiry(&bytes).unwrap();
assert_eq!(
not_after,
Some(expiry),
"the recorded expiry must survive a v2 round-trip"
);
assert_eq!(
loaded.public_key().as_bytes(),
kp.public_key().as_bytes(),
"v2 key material must survive a round-trip"
);
let plain = deserialize_machine_keypair(&bytes).unwrap();
assert_eq!(
plain.public_key().as_bytes(),
kp.public_key().as_bytes(),
"the plain loader must recover key material from a v2 file"
);
}
}