use anyhow::{Context, Result};
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use rand::RngExt;
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
const KEY_DOMAIN: &str = "brainwires-network-identity-v1:";
#[derive(Clone)]
pub struct SigningKey {
cipher: ChaCha20Poly1305,
}
impl SigningKey {
pub fn from_secret(secret: &str) -> Self {
let key = derive_key(secret);
let cipher = ChaCha20Poly1305::new(key.as_ref().into());
Self { cipher }
}
pub fn sign(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let mut nonce_bytes = [0u8; 12];
rand::rng().fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = self
.cipher
.encrypt(nonce, plaintext)
.map_err(|e| anyhow::anyhow!("signing failed: {e}"))?;
let mut output = Vec::with_capacity(12 + ciphertext.len());
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(&ciphertext);
Ok(output)
}
}
impl std::fmt::Debug for SigningKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SigningKey")
.field("cipher", &"<redacted>")
.finish()
}
}
#[derive(Clone)]
pub struct VerifyingKey {
cipher: ChaCha20Poly1305,
}
impl VerifyingKey {
pub fn from_secret(secret: &str) -> Self {
let key = derive_key(secret);
let cipher = ChaCha20Poly1305::new(key.as_ref().into());
Self { cipher }
}
pub fn verify(&self, signed: &[u8]) -> Result<Vec<u8>> {
if signed.len() < 12 {
anyhow::bail!("signed message too short");
}
let (nonce_bytes, ciphertext) = signed.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
self.cipher
.decrypt(nonce, ciphertext)
.map_err(|e| anyhow::anyhow!("verification failed: {e}"))
.context("message authentication failed")
}
}
impl std::fmt::Debug for VerifyingKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("VerifyingKey")
.field("cipher", &"<redacted>")
.finish()
}
}
fn derive_key(secret: &str) -> Zeroizing<[u8; 32]> {
let mut hasher = Sha256::new();
hasher.update(KEY_DOMAIN.as_bytes());
hasher.update(secret.as_bytes());
Zeroizing::new(hasher.finalize().into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_and_verify_roundtrip() {
let secret = "test-secret-key";
let signer = SigningKey::from_secret(secret);
let verifier = VerifyingKey::from_secret(secret);
let message = b"hello, agent network";
let signed = signer.sign(message).unwrap();
let recovered = verifier.verify(&signed).unwrap();
assert_eq!(recovered, message);
}
#[test]
fn wrong_secret_fails_verification() {
let signer = SigningKey::from_secret("secret-a");
let verifier = VerifyingKey::from_secret("secret-b");
let signed = signer.sign(b"test").unwrap();
assert!(verifier.verify(&signed).is_err());
}
#[test]
fn tampered_message_fails() {
let secret = "test-secret";
let signer = SigningKey::from_secret(secret);
let verifier = VerifyingKey::from_secret(secret);
let mut signed = signer.sign(b"test").unwrap();
if let Some(byte) = signed.last_mut() {
*byte ^= 0xFF;
}
assert!(verifier.verify(&signed).is_err());
}
#[test]
fn too_short_message_fails() {
let verifier = VerifyingKey::from_secret("test");
assert!(verifier.verify(&[0u8; 5]).is_err());
}
#[test]
fn debug_redacts_key_material() {
let signer = SigningKey::from_secret("secret");
let debug = format!("{signer:?}");
assert!(debug.contains("<redacted>"));
assert!(!debug.contains("secret"));
}
}