use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use ed25519_dalek::{
PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH, Signature, Signer, SigningKey,
VerifyingKey,
};
use serde::{Deserialize, Serialize};
const DOMAIN: &[u8] = b"interlink-v1\0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageKind {
#[default]
Message,
PairRequest,
PairAccept,
}
impl MessageKind {
fn as_str(self) -> &'static str {
match self {
MessageKind::Message => "message",
MessageKind::PairRequest => "pair_request",
MessageKind::PairAccept => "pair_accept",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AgentId(VerifyingKey);
impl AgentId {
pub fn from_b64(s: &str) -> Result<Self> {
let raw = B64.decode(s).context("agent id is not valid base64")?;
let bytes: [u8; PUBLIC_KEY_LENGTH] = raw
.try_into()
.map_err(|_| anyhow!("agent id must be {PUBLIC_KEY_LENGTH} bytes"))?;
Ok(Self(
VerifyingKey::from_bytes(&bytes).context("not a valid ed25519 public key")?,
))
}
pub fn to_b64(self) -> String {
B64.encode(self.0.as_bytes())
}
pub fn fingerprint(self) -> String {
self.to_b64().chars().take(8).collect()
}
pub fn as_verifying_key(&self) -> &VerifyingKey {
&self.0
}
}
pub struct AgentKey(SigningKey);
impl AgentKey {
pub fn generate() -> Result<Self> {
let mut secret = [0u8; SECRET_KEY_LENGTH];
getrandom::fill(&mut secret).map_err(|e| anyhow!("OS entropy unavailable: {e}"))?;
Ok(Self(SigningKey::from_bytes(&secret)))
}
pub fn from_b64(s: &str) -> Result<Self> {
let raw = B64
.decode(s.trim())
.context("secret key is not valid base64")?;
let bytes: [u8; SECRET_KEY_LENGTH] = raw
.try_into()
.map_err(|_| anyhow!("secret key must be {SECRET_KEY_LENGTH} bytes"))?;
Ok(Self(SigningKey::from_bytes(&bytes)))
}
pub fn to_b64(&self) -> String {
B64.encode(self.0.to_bytes())
}
pub fn id(&self) -> AgentId {
AgentId(self.0.verifying_key())
}
pub fn sign(&self, to: AgentId, text: &str, ts: u64, msg_id: &str) -> SignedMessage {
self.sign_as(to, text, ts, msg_id, MessageKind::Message)
}
pub fn sign_as(
&self,
to: AgentId,
text: &str,
ts: u64,
msg_id: &str,
kind: MessageKind,
) -> SignedMessage {
let bytes = canonical(self.id(), to, ts, msg_id, text, kind);
let sig: Signature = self.0.sign(&bytes);
SignedMessage {
from: self.id().to_b64(),
to: to.to_b64(),
text: text.to_string(),
ts,
msg_id: msg_id.to_string(),
kind,
sig: B64.encode(sig.to_bytes()),
}
}
pub fn announce(&self, name: &str, ts: u64) -> Announcement {
let sig: Signature = self.0.sign(&announce_canonical(self.id(), name, ts));
Announcement {
pubkey: self.id().to_b64(),
name: name.to_string(),
ts,
sig: B64.encode(sig.to_bytes()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignedMessage {
pub from: String,
pub to: String,
pub text: String,
pub ts: u64,
pub msg_id: String,
#[serde(default)]
pub kind: MessageKind,
pub sig: String,
}
impl SignedMessage {
pub fn verify(&self) -> Result<AgentId> {
let from = AgentId::from_b64(&self.from)?;
let to = AgentId::from_b64(&self.to)?;
let raw = B64
.decode(&self.sig)
.context("signature is not valid base64")?;
let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
.try_into()
.map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
let sig = Signature::from_bytes(&sig_bytes);
let bytes = canonical(from, to, self.ts, &self.msg_id, &self.text, self.kind);
from.as_verifying_key()
.verify_strict(&bytes, &sig)
.map_err(|_| {
anyhow!(
"signature does not verify for sender {}",
from.fingerprint()
)
})?;
Ok(from)
}
}
fn canonical(
from: AgentId,
to: AgentId,
ts: u64,
msg_id: &str,
text: &str,
kind: MessageKind,
) -> Vec<u8> {
let k = kind.as_str();
let mut b = Vec::with_capacity(DOMAIN.len() + 88 + k.len() + msg_id.len() + text.len());
b.extend_from_slice(DOMAIN);
b.extend_from_slice(from.as_verifying_key().as_bytes());
b.extend_from_slice(to.as_verifying_key().as_bytes());
b.extend_from_slice(&ts.to_le_bytes());
b.extend_from_slice(&(k.len() as u32).to_le_bytes());
b.extend_from_slice(k.as_bytes());
b.extend_from_slice(&(msg_id.len() as u32).to_le_bytes());
b.extend_from_slice(msg_id.as_bytes());
b.extend_from_slice(&(text.len() as u32).to_le_bytes());
b.extend_from_slice(text.as_bytes());
b
}
const ANNOUNCE_DOMAIN: &[u8] = b"interlink-announce-v1\0";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Announcement {
pub pubkey: String,
pub name: String,
pub ts: u64,
pub sig: String,
}
impl Announcement {
pub fn verify(&self) -> Result<AgentId> {
let id = AgentId::from_b64(&self.pubkey)?;
let raw = B64
.decode(&self.sig)
.context("announcement signature is not valid base64")?;
let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
.try_into()
.map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
let sig = Signature::from_bytes(&sig_bytes);
id.as_verifying_key()
.verify_strict(&announce_canonical(id, &self.name, self.ts), &sig)
.map_err(|_| anyhow!("announcement does not verify for {}", id.fingerprint()))?;
Ok(id)
}
}
fn announce_canonical(pubkey: AgentId, name: &str, ts: u64) -> Vec<u8> {
let mut b = Vec::with_capacity(ANNOUNCE_DOMAIN.len() + 40 + name.len());
b.extend_from_slice(ANNOUNCE_DOMAIN);
b.extend_from_slice(pubkey.as_verifying_key().as_bytes());
b.extend_from_slice(&(name.len() as u32).to_le_bytes());
b.extend_from_slice(name.as_bytes());
b.extend_from_slice(&ts.to_le_bytes());
b
}
pub fn check_freshness(ts: u64, now: u64, max_skew_ms: u64) -> Result<()> {
let delta = now.abs_diff(ts);
if delta > max_skew_ms {
bail!("message timestamp is {delta}ms from now (max {max_skew_ms}ms)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> AgentKey {
AgentKey::generate().unwrap()
}
#[test]
fn sign_then_verify_returns_the_sender() {
let (alice, bob) = (key(), key());
let msg = alice.sign(bob.id(), "hello", 1234, "m1");
assert_eq!(msg.verify().unwrap(), alice.id());
}
#[test]
fn tampered_text_fails() {
let (alice, bob) = (key(), key());
let mut msg = alice.sign(bob.id(), "transfer 1", 1234, "m1");
msg.text = "transfer 1000".into();
assert!(msg.verify().is_err());
}
#[test]
fn tampered_recipient_fails() {
let (alice, bob, eve) = (key(), key(), key());
let mut msg = alice.sign(bob.id(), "hi", 1, "m1");
msg.to = eve.id().to_b64();
assert!(msg.verify().is_err());
}
#[test]
fn forged_sender_fails() {
let (alice, bob, eve) = (key(), key(), key());
let mut msg = eve.sign(bob.id(), "hi", 1, "m1");
msg.from = alice.id().to_b64();
assert!(msg.verify().is_err());
}
#[test]
fn ts_and_msg_id_are_covered() {
let (alice, bob) = (key(), key());
let orig = alice.sign(bob.id(), "hi", 1, "m1");
for mut m in [orig.clone(), orig.clone()] {
m.ts = 2;
assert!(m.verify().is_err(), "ts must be signed");
}
let mut m = orig;
m.msg_id = "m2".into();
assert!(m.verify().is_err(), "msg_id must be signed");
}
#[test]
fn length_prefixes_stop_boundary_shifting() {
let (alice, bob) = (key(), key());
let a = canonical(alice.id(), bob.id(), 1, "ab", "c", MessageKind::Message);
let b = canonical(alice.id(), bob.id(), 1, "a", "bc", MessageKind::Message);
assert_ne!(a, b);
}
#[test]
fn domain_separation_is_bound_in() {
let (alice, bob) = (key(), key());
let bytes = canonical(alice.id(), bob.id(), 1, "m1", "hi", MessageKind::Message);
assert!(bytes.starts_with(DOMAIN));
}
#[test]
fn id_b64_round_trips() {
let alice = key();
let id = alice.id();
assert_eq!(AgentId::from_b64(&id.to_b64()).unwrap(), id);
assert_eq!(id.fingerprint().len(), 8);
}
#[test]
fn secret_key_b64_round_trips() {
let alice = key();
let restored = AgentKey::from_b64(&alice.to_b64()).unwrap();
assert_eq!(restored.id(), alice.id());
}
#[test]
fn freshness_bounds_replay_window() {
assert!(check_freshness(1_000, 1_500, 1_000).is_ok());
assert!(check_freshness(1_000, 5_000, 1_000).is_err());
assert!(
check_freshness(5_000, 1_000, 1_000).is_err(),
"future ts too"
);
}
#[test]
fn announcement_round_trips_and_rejects_tampering() {
let alice = key();
let a = alice.announce("alice-laptop", 1234);
assert_eq!(a.verify().unwrap(), alice.id());
let mut tampered_name = a.clone();
tampered_name.name = "eve-laptop".into();
assert!(tampered_name.verify().is_err(), "name is signed");
let mut forged_key = a;
forged_key.pubkey = key().id().to_b64();
assert!(
forged_key.verify().is_err(),
"can't reattribute to another key"
);
}
#[test]
fn kind_is_covered_by_signature() {
let (alice, bob) = (key(), key());
let mut m = alice.sign_as(bob.id(), "hi", 1, "m1", MessageKind::Message);
assert!(m.verify().is_ok());
m.kind = MessageKind::PairRequest;
assert!(m.verify().is_err(), "kind must be signed");
}
}