#![allow(clippy::same_item_push)]
use std::{io::Write, path::Path};
use exo_core::{
crypto::KeyPair,
types::{Did, PublicKey},
};
use zeroize::Zeroize;
pub struct NodeIdentity {
pub did: Did,
pub public_key: PublicKey,
keypair: KeyPair,
}
impl NodeIdentity {
#[must_use]
pub fn public_key_bytes(&self) -> &[u8; 32] {
&self.public_key.0
}
}
impl NodeIdentity {
#[must_use]
pub fn sign(&self, message: &[u8]) -> exo_core::types::Signature {
self.keypair.sign(message)
}
#[must_use]
#[allow(dead_code)] pub fn public_key(&self) -> &PublicKey {
&self.public_key
}
}
pub fn did_from_public_key(public_key: &PublicKey) -> anyhow::Result<Did> {
let hash = blake3::hash(public_key.as_bytes());
let encoded = bs58_encode(hash.as_bytes());
Did::new(&format!("did:exo:{encoded}")).map_err(Into::into)
}
pub fn load_or_create(data_dir: &Path) -> anyhow::Result<NodeIdentity> {
let key_path = data_dir.join("identity.key");
let did_path = data_dir.join("identity.did");
if key_path.exists() {
let mut secret_bytes = std::fs::read(&key_path)?;
if secret_bytes.len() != 32 {
let actual_len = secret_bytes.len();
secret_bytes.zeroize();
anyhow::bail!(
"Corrupt identity key at {} — expected 32 bytes, got {}",
key_path.display(),
actual_len
);
}
let mut buf = [0u8; 32];
buf.copy_from_slice(&secret_bytes);
secret_bytes.zeroize();
let keypair_result = KeyPair::from_secret_bytes(buf);
buf.zeroize();
let keypair = keypair_result?;
let did_str = std::fs::read_to_string(&did_path)?;
let did = Did::new(did_str.trim())?;
let derived_did = did_from_public_key(keypair.public_key())?;
if did != derived_did {
anyhow::bail!(
"identity.did does not match identity.key public key at {}; stored {}, derived {}",
did_path.display(),
did,
derived_did
);
}
tracing::info!(did = %did, "Loaded existing identity");
Ok(NodeIdentity {
did,
public_key: *keypair.public_key(),
keypair,
})
} else {
let keypair = KeyPair::generate();
let public_key = *keypair.public_key();
let did = did_from_public_key(&public_key)?;
write_secret(&key_path, keypair.secret_key().as_bytes())?;
std::fs::write(&did_path, did.as_str().as_bytes())?;
tracing::info!(did = %did, "Generated new node identity");
Ok(NodeIdentity {
did,
public_key,
keypair,
})
}
}
fn bs58_encode(data: &[u8]) -> String {
const ALPHABET: &[u8] = b"123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
if data.is_empty() {
return String::new();
}
let leading_zeros = data.iter().take_while(|&&b| b == 0).count();
let mut num = data.to_vec();
let mut encoded = Vec::new();
while !num.is_empty() {
let mut remainder = 0u32;
let mut next = Vec::new();
for &byte in &num {
let value = (remainder << 8) | u32::from(byte);
let digit = value / 58;
remainder = value % 58;
if !next.is_empty() || digit > 0 {
let Ok(digit_byte) = u8::try_from(digit) else {
return String::new();
};
next.push(digit_byte);
}
}
let Ok(alphabet_index) = usize::try_from(remainder) else {
return String::new();
};
encoded.push(ALPHABET[alphabet_index]);
num = next;
}
for _ in 0..leading_zeros {
encoded.push(b'1');
}
encoded.reverse();
encoded.into_iter().map(char::from).collect()
}
fn write_secret(path: &Path, data: &[u8]) -> anyhow::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| {
anyhow::anyhow!("failed to create secret key file {}: {e}", path.display())
})?;
file.write_all(data)?;
file.sync_all()?;
}
#[cfg(not(unix))]
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| {
anyhow::anyhow!("failed to create secret key file {}: {e}", path.display())
})?;
file.write_all(data)?;
file.sync_all()?;
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn load_or_create_creates_identity_files() {
let dir = tempfile::tempdir().unwrap();
let identity = load_or_create(dir.path()).unwrap();
let key_bytes = std::fs::read(dir.path().join("identity.key")).unwrap();
let did_text = std::fs::read_to_string(dir.path().join("identity.did")).unwrap();
assert_eq!(key_bytes.len(), 32);
assert_eq!(did_text.trim(), identity.did.as_str());
assert_eq!(identity.public_key_bytes().len(), 32);
}
#[test]
fn load_or_create_reloads_existing_identity() {
let dir = tempfile::tempdir().unwrap();
let first = load_or_create(dir.path()).unwrap();
let second = load_or_create(dir.path()).unwrap();
assert_eq!(first.did, second.did);
assert_eq!(first.public_key_bytes(), second.public_key_bytes());
}
#[test]
fn load_or_create_rejects_identity_did_not_bound_to_secret_key() {
let dir = tempfile::tempdir().unwrap();
let first = load_or_create(dir.path()).unwrap();
std::fs::write(dir.path().join("identity.did"), b"did:exo:forged-validator").unwrap();
let err = match load_or_create(dir.path()) {
Ok(identity) => panic!(
"identity.did must not load when it does not derive from identity.key: loaded {} for original {}",
identity.did, first.did
),
Err(err) => err,
};
assert!(
err.to_string()
.contains("identity.did does not match identity.key"),
"unexpected error: {err}"
);
}
#[test]
fn did_from_public_key_matches_node_identity_derivation() {
let dir = tempfile::tempdir().unwrap();
let identity = load_or_create(dir.path()).unwrap();
assert_eq!(
did_from_public_key(identity.public_key()).unwrap(),
identity.did
);
}
#[test]
fn load_or_create_rejects_corrupt_secret_key() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("identity.key"), [7u8; 31]).unwrap();
std::fs::write(dir.path().join("identity.did"), b"did:exo:corrupt").unwrap();
let err = match load_or_create(dir.path()) {
Ok(_) => panic!("corrupt secret key must not load"),
Err(err) => err,
};
assert!(
err.to_string().contains("Corrupt identity key"),
"unexpected error: {err}"
);
}
#[test]
fn write_secret_rejects_existing_file_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let key_path = dir.path().join("identity.key");
std::fs::write(&key_path, [0xA5u8; 32]).unwrap();
let err = match write_secret(&key_path, &[0x5Au8; 32]) {
Ok(()) => panic!("write_secret must not overwrite an existing secret path"),
Err(err) => err,
};
let contents = std::fs::read(&key_path).unwrap();
assert!(
err.to_string().contains("identity.key"),
"error should identify the refused secret path: {err}"
);
assert_eq!(contents, vec![0xA5u8; 32]);
}
#[test]
fn write_secret_source_creates_file_with_restrictive_mode_before_write() {
let source = include_str!("identity.rs");
let write_secret_source = source
.split("fn write_secret")
.nth(1)
.unwrap()
.split("#[cfg(test)]")
.next()
.unwrap();
assert!(
!write_secret_source.contains("std::fs::write(path, data)"),
"secret key must not be written before restrictive permissions are applied"
);
assert!(
write_secret_source.contains(".create_new(true)"),
"secret key creation must fail if an attacker races in an existing path"
);
#[cfg(unix)]
assert!(
write_secret_source.contains(".mode(0o600)"),
"Unix secret key file must be created with mode 0600 before bytes are written"
);
}
#[test]
fn load_existing_identity_source_zeroizes_secret_read_buffer() {
let source = include_str!("identity.rs");
let load_source = source
.split("pub fn load_or_create")
.nth(1)
.unwrap()
.split("/// Minimal base58 encoding")
.next()
.unwrap();
assert!(
load_source.contains("secret_bytes.zeroize()"),
"temporary Vec holding identity.key bytes must be zeroized after copying"
);
}
#[test]
fn bs58_encode_source_uses_checked_integer_conversions() {
let source = include_str!("identity.rs");
let production = source
.split("#[cfg(test)]")
.next()
.expect("production section");
let encode_source = production
.split("fn bs58_encode")
.nth(1)
.expect("base58 encoder source exists")
.split("/// Write secret key bytes with restrictive permissions.")
.next()
.expect("base58 encoder source ends before write_secret");
assert!(
!encode_source.contains("clippy::as_conversions"),
"node identity base58 encoding must not suppress checked conversion lints"
);
assert!(
!encode_source.contains("digit as u8"),
"base58 digit conversion must be checked rather than truncated"
);
assert!(
!encode_source.contains("remainder as usize"),
"base58 alphabet index conversion must be checked rather than truncated"
);
assert!(
!encode_source.contains("String::from_utf8(encoded).unwrap_or_default()"),
"base58 encoding must not silently collapse encoding defects to an empty string"
);
}
#[cfg(unix)]
#[test]
fn load_or_create_writes_secret_key_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let _identity = load_or_create(dir.path()).unwrap();
let mode = std::fs::metadata(dir.path().join("identity.key"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
}