use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
use rand_core::OsRng;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use thiserror::Error;
use zeroize::Zeroize;
#[derive(Debug, Error)]
pub enum IdentityError {
#[error("Invalid peer ID format: {0}")]
InvalidPeerId(String),
#[error("Invalid base64 encoding: {0}")]
InvalidBase64(#[from] base64::DecodeError),
#[error("Invalid key length: expected {expected}, got {actual}")]
InvalidKeyLength { expected: usize, actual: usize },
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid signature")]
InvalidSignature,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct PubKey(pub [u8; 32]);
impl Serialize for PubKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serde_bytes::serialize(&self.0[..], serializer)
}
}
impl<'de> Deserialize<'de> for PubKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let bytes: Vec<u8> = serde_bytes::deserialize(deserializer)?;
if bytes.len() != 32 {
return Err(serde::de::Error::invalid_length(bytes.len(), &"32 bytes"));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(PubKey(arr))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Signature(pub [u8; 64]);
impl Serialize for Signature {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serde_bytes::serialize(&self.0[..], serializer)
}
}
impl<'de> Deserialize<'de> for Signature {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let bytes: Vec<u8> = serde_bytes::deserialize(deserializer)?;
if bytes.len() != 64 {
return Err(serde::de::Error::invalid_length(bytes.len(), &"64 bytes"));
}
let mut arr = [0u8; 64];
arr.copy_from_slice(&bytes);
Ok(Signature(arr))
}
}
impl PubKey {
pub fn new(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn is_zero(&self) -> bool {
self.0 == [0u8; 32]
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_peer_id(&self) -> meerkat_core::comms::PeerId {
meerkat_core::comms::PeerId::from_ed25519_pubkey(&self.0)
}
pub fn to_pubkey_string(&self) -> String {
format!("ed25519:{}", BASE64.encode(self.0))
}
pub fn from_pubkey_string(s: &str) -> Result<Self, IdentityError> {
let prefix = "ed25519:";
if !s.starts_with(prefix) {
return Err(IdentityError::InvalidPeerId(format!(
"must start with '{prefix}'"
)));
}
let encoded = &s[prefix.len()..];
let bytes = BASE64.decode(encoded)?;
if bytes.len() != 32 {
return Err(IdentityError::InvalidKeyLength {
expected: 32,
actual: bytes.len(),
});
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(Self(arr))
}
pub fn verify(&self, data: &[u8], sig: &Signature) -> bool {
let Ok(verifying_key) = VerifyingKey::from_bytes(&self.0) else {
return false;
};
let signature = ed25519_dalek::Signature::from_bytes(&sig.0);
verifying_key.verify(data, &signature).is_ok()
}
}
impl Signature {
pub fn new(bytes: [u8; 64]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 64] {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct Keypair {
signing_key: SigningKey,
}
impl Keypair {
pub fn generate() -> Self {
let signing_key = SigningKey::generate(&mut OsRng);
Self { signing_key }
}
pub fn from_secret(mut secret: [u8; 32]) -> Self {
let signing_key = SigningKey::from_bytes(&secret);
secret.zeroize();
Self { signing_key }
}
pub fn public_key(&self) -> PubKey {
PubKey(self.signing_key.verifying_key().to_bytes())
}
pub fn sign(&self, data: &[u8]) -> Signature {
let sig = self.signing_key.sign(data);
Signature(sig.to_bytes())
}
pub fn secret_bytes(&self) -> [u8; 32] {
self.signing_key.to_bytes()
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn save(&self, dir: &Path) -> Result<(), IdentityError> {
tokio::fs::create_dir_all(dir).await?;
let key_path = dir.join("identity.key");
let mut secret_bytes = self.signing_key.to_bytes();
tokio::fs::write(&key_path, &secret_bytes).await?;
secret_bytes.zeroize();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
tokio::fs::set_permissions(&key_path, perms).await?;
}
tokio::fs::write(
dir.join("identity.pub"),
self.signing_key.verifying_key().to_bytes(),
)
.await?;
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn load(dir: &Path) -> Result<Self, IdentityError> {
let mut secret_bytes = tokio::fs::read(dir.join("identity.key")).await?;
if secret_bytes.len() != 32 {
return Err(IdentityError::InvalidKeyLength {
expected: 32,
actual: secret_bytes.len(),
});
}
let mut secret = [0u8; 32];
secret.copy_from_slice(&secret_bytes);
secret_bytes.zeroize();
Ok(Self::from_secret(secret))
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn load_or_generate(dir: &Path) -> Result<Self, IdentityError> {
let key_path = dir.join("identity.key");
if tokio::fs::try_exists(&key_path).await? {
Self::load(dir).await
} else {
let keypair = Self::generate();
keypair.save(dir).await?;
Ok(keypair)
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use meerkat_core::comms::PeerId;
use std::mem::size_of;
use tempfile::TempDir;
#[test]
fn test_pubkey_size() {
assert_eq!(size_of::<PubKey>(), 32);
}
#[test]
fn test_signature_size() {
assert_eq!(size_of::<Signature>(), 64);
}
#[test]
fn test_pubkey_cbor_roundtrip() {
let pubkey = PubKey::new([42u8; 32]);
let mut buf = Vec::new();
ciborium::into_writer(&pubkey, &mut buf).unwrap();
let decoded: PubKey = ciborium::from_reader(&buf[..]).unwrap();
assert_eq!(pubkey, decoded);
}
#[test]
fn test_signature_cbor_roundtrip() {
let sig = Signature::new([99u8; 64]);
let mut buf = Vec::new();
ciborium::into_writer(&sig, &mut buf).unwrap();
let decoded: Signature = ciborium::from_reader(&buf[..]).unwrap();
assert_eq!(sig, decoded);
}
#[test]
fn test_pubkey_to_peer_id_is_deterministic_uuidv5() {
let pubkey = PubKey::new([42u8; 32]);
let a = pubkey.to_peer_id();
let b = pubkey.to_peer_id();
assert_eq!(a, b, "derivation must be deterministic over pubkey bytes");
let rendered = a.as_str();
assert_eq!(rendered.len(), 36, "UUID string length");
assert_eq!(rendered.matches('-').count(), 4, "UUID has 4 hyphens");
}
#[test]
fn test_pubkey_to_peer_id_differs_across_pubkeys() {
let id_a = PubKey::new([1u8; 32]).to_peer_id();
let id_b = PubKey::new([2u8; 32]).to_peer_id();
assert_ne!(id_a, id_b);
}
#[test]
fn test_peer_id_parses_back_as_uuid() {
let pubkey = PubKey::new([7u8; 32]);
let peer_id = pubkey.to_peer_id();
let reparsed = PeerId::parse(&peer_id.as_str()).unwrap();
assert_eq!(peer_id, reparsed);
}
#[test]
fn test_pubkey_to_pubkey_string_format() {
let pubkey = PubKey::new([1u8; 32]);
let encoded = pubkey.to_pubkey_string();
assert!(encoded.starts_with("ed25519:"));
let base64_part = &encoded["ed25519:".len()..];
assert!(
base64_part
.chars()
.all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')
);
assert_eq!(encoded.len(), "ed25519:".len() + 44);
}
#[test]
fn test_pubkey_string_roundtrip() {
let original = PubKey::new([99u8; 32]);
let encoded = original.to_pubkey_string();
let recovered = PubKey::from_pubkey_string(&encoded).unwrap();
assert_eq!(original, recovered);
}
#[test]
fn test_keypair_struct() {
let keypair = Keypair::generate();
let _ = keypair.public_key();
}
#[test]
fn test_keypair_generate() {
let keypair = Keypair::generate();
let pubkey = keypair.public_key();
assert_eq!(pubkey.as_bytes().len(), 32);
}
#[test]
fn test_keypair_public_key() {
let keypair = Keypair::generate();
let pk1 = keypair.public_key();
let pk2 = keypair.public_key();
assert_eq!(pk1, pk2); }
#[test]
fn test_keypair_sign() {
let keypair = Keypair::generate();
let data = b"test message";
let sig = keypair.sign(data);
assert_eq!(sig.as_bytes().len(), 64);
}
#[test]
fn test_pubkey_verify() {
let keypair = Keypair::generate();
let data = b"test message";
let sig = keypair.sign(data);
let pubkey = keypair.public_key();
assert!(pubkey.verify(data, &sig));
}
#[tokio::test]
async fn test_keypair_save() {
let tmp = TempDir::new().unwrap();
let keypair = Keypair::generate();
keypair.save(tmp.path()).await.unwrap();
assert!(tmp.path().join("identity.key").exists());
assert!(tmp.path().join("identity.pub").exists());
}
#[tokio::test]
async fn test_keypair_load() {
let tmp = TempDir::new().unwrap();
let original = Keypair::generate();
original.save(tmp.path()).await.unwrap();
let loaded = Keypair::load(tmp.path()).await.unwrap();
assert_eq!(original.public_key(), loaded.public_key());
}
#[tokio::test]
async fn test_keypair_load_or_generate_existing() {
let tmp = TempDir::new().unwrap();
let original = Keypair::generate();
original.save(tmp.path()).await.unwrap();
let loaded = Keypair::load_or_generate(tmp.path()).await.unwrap();
assert_eq!(original.public_key(), loaded.public_key());
}
#[tokio::test]
async fn test_keypair_load_or_generate_new() {
let tmp = TempDir::new().unwrap();
assert!(!tmp.path().join("identity.key").exists());
let keypair = Keypair::load_or_generate(tmp.path()).await.unwrap();
assert!(tmp.path().join("identity.key").exists());
assert_eq!(keypair.public_key().as_bytes().len(), 32);
}
#[test]
fn test_sign_verify_roundtrip() {
let keypair = Keypair::generate();
let data = b"important message";
let sig = keypair.sign(data);
assert!(keypair.public_key().verify(data, &sig));
}
#[test]
fn test_tamper_detection() {
let keypair = Keypair::generate();
let data = b"original message";
let sig = keypair.sign(data);
let tampered = b"tampered message";
assert!(!keypair.public_key().verify(tampered, &sig));
}
#[test]
fn test_wrong_key_rejection() {
let keypair1 = Keypair::generate();
let keypair2 = Keypair::generate();
let data = b"test data";
let sig = keypair1.sign(data);
assert!(!keypair2.public_key().verify(data, &sig));
}
#[tokio::test]
async fn test_keypair_persistence_roundtrip() {
let tmp = TempDir::new().unwrap();
let original = Keypair::generate();
original.save(tmp.path()).await.unwrap();
let loaded = Keypair::load(tmp.path()).await.unwrap();
let data = b"persistence test";
let sig = loaded.sign(data);
assert!(original.public_key().verify(data, &sig));
}
}