1use anyhow::{Context, Result, anyhow, bail};
13use base64::Engine;
14use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
15use ed25519_dalek::{
16 PUBLIC_KEY_LENGTH, SECRET_KEY_LENGTH, SIGNATURE_LENGTH, Signature, Signer, SigningKey,
17 VerifyingKey,
18};
19use serde::{Deserialize, Serialize};
20
21const DOMAIN: &[u8] = b"interlink-v2\0";
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum MessageKind {
31 #[default]
32 Message,
33 PairRequest,
34 PairAccept,
35}
36
37impl MessageKind {
38 fn as_str(self) -> &'static str {
39 match self {
40 MessageKind::Message => "message",
41 MessageKind::PairRequest => "pair_request",
42 MessageKind::PairAccept => "pair_accept",
43 }
44 }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum TaskStatus {
52 Update,
54 NeedsInput,
56 Result,
58 Failed,
60 Canceled,
62}
63
64impl TaskStatus {
65 pub fn as_str(self) -> &'static str {
66 match self {
67 TaskStatus::Update => "update",
68 TaskStatus::NeedsInput => "needs_input",
69 TaskStatus::Result => "result",
70 TaskStatus::Failed => "failed",
71 TaskStatus::Canceled => "canceled",
72 }
73 }
74
75 pub fn from_tag(s: &str) -> Option<Self> {
77 match s {
78 "update" => Some(TaskStatus::Update),
79 "needs_input" => Some(TaskStatus::NeedsInput),
80 "result" => Some(TaskStatus::Result),
81 "failed" => Some(TaskStatus::Failed),
82 "canceled" => Some(TaskStatus::Canceled),
83 _ => None,
84 }
85 }
86
87 pub fn is_terminal(self) -> bool {
89 matches!(
90 self,
91 TaskStatus::Result | TaskStatus::Failed | TaskStatus::Canceled
92 )
93 }
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct AgentId(VerifyingKey);
99
100impl AgentId {
101 pub fn from_b64(s: &str) -> Result<Self> {
102 let raw = B64.decode(s).context("agent id is not valid base64")?;
103 let bytes: [u8; PUBLIC_KEY_LENGTH] = raw
104 .try_into()
105 .map_err(|_| anyhow!("agent id must be {PUBLIC_KEY_LENGTH} bytes"))?;
106 Ok(Self(
107 VerifyingKey::from_bytes(&bytes).context("not a valid ed25519 public key")?,
108 ))
109 }
110
111 pub fn to_b64(self) -> String {
112 B64.encode(self.0.as_bytes())
113 }
114
115 pub fn fingerprint(self) -> String {
117 self.to_b64().chars().take(8).collect()
118 }
119
120 pub fn as_verifying_key(&self) -> &VerifyingKey {
121 &self.0
122 }
123}
124
125pub struct AgentKey(SigningKey);
127
128impl AgentKey {
129 pub fn generate() -> Result<Self> {
131 let mut secret = [0u8; SECRET_KEY_LENGTH];
132 getrandom::fill(&mut secret).map_err(|e| anyhow!("OS entropy unavailable: {e}"))?;
133 Ok(Self(SigningKey::from_bytes(&secret)))
134 }
135
136 pub fn from_b64(s: &str) -> Result<Self> {
137 let raw = B64
138 .decode(s.trim())
139 .context("secret key is not valid base64")?;
140 let bytes: [u8; SECRET_KEY_LENGTH] = raw
141 .try_into()
142 .map_err(|_| anyhow!("secret key must be {SECRET_KEY_LENGTH} bytes"))?;
143 Ok(Self(SigningKey::from_bytes(&bytes)))
144 }
145
146 pub fn to_b64(&self) -> String {
147 B64.encode(self.0.to_bytes())
148 }
149
150 pub fn id(&self) -> AgentId {
151 AgentId(self.0.verifying_key())
152 }
153
154 pub fn sign(&self, to: AgentId, text: &str, ts: u64, msg_id: &str) -> SignedMessage {
157 self.sign_full(to, text, ts, msg_id, MessageKind::Message, None, None, None)
158 }
159
160 pub fn sign_as(
162 &self,
163 to: AgentId,
164 text: &str,
165 ts: u64,
166 msg_id: &str,
167 kind: MessageKind,
168 ) -> SignedMessage {
169 self.sign_full(to, text, ts, msg_id, kind, None, None, None)
170 }
171
172 #[allow(clippy::too_many_arguments)]
176 pub fn sign_full(
177 &self,
178 to: AgentId,
179 text: &str,
180 ts: u64,
181 msg_id: &str,
182 kind: MessageKind,
183 task_id: Option<&str>,
184 status: Option<TaskStatus>,
185 in_reply_to: Option<&str>,
186 ) -> SignedMessage {
187 let bytes = canonical(
188 self.id(),
189 to,
190 ts,
191 msg_id,
192 text,
193 kind,
194 task_id,
195 status,
196 in_reply_to,
197 );
198 let sig: Signature = self.0.sign(&bytes);
199 SignedMessage {
200 from: self.id().to_b64(),
201 to: to.to_b64(),
202 text: text.to_string(),
203 ts,
204 msg_id: msg_id.to_string(),
205 kind,
206 task_id: task_id.map(str::to_string),
207 status,
208 in_reply_to: in_reply_to.map(str::to_string),
209 sig: B64.encode(sig.to_bytes()),
210 }
211 }
212
213 pub fn announce(&self, name: &str, ts: u64) -> Announcement {
215 let sig: Signature = self.0.sign(&announce_canonical(self.id(), name, ts));
216 Announcement {
217 pubkey: self.id().to_b64(),
218 name: name.to_string(),
219 ts,
220 sig: B64.encode(sig.to_bytes()),
221 }
222 }
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227pub struct SignedMessage {
228 pub from: String,
229 pub to: String,
230 pub text: String,
231 pub ts: u64,
232 pub msg_id: String,
233 #[serde(default)]
234 pub kind: MessageKind,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub task_id: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub status: Option<TaskStatus>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub in_reply_to: Option<String>,
243 pub sig: String,
244}
245
246impl SignedMessage {
247 pub fn verify(&self) -> Result<AgentId> {
252 let from = AgentId::from_b64(&self.from)?;
253 let to = AgentId::from_b64(&self.to)?;
254
255 let raw = B64
256 .decode(&self.sig)
257 .context("signature is not valid base64")?;
258 let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
259 .try_into()
260 .map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
261 let sig = Signature::from_bytes(&sig_bytes);
262
263 let bytes = canonical(
264 from,
265 to,
266 self.ts,
267 &self.msg_id,
268 &self.text,
269 self.kind,
270 self.task_id.as_deref(),
271 self.status,
272 self.in_reply_to.as_deref(),
273 );
274 from.as_verifying_key()
275 .verify_strict(&bytes, &sig)
276 .map_err(|_| {
277 anyhow!(
278 "signature does not verify for sender {}",
279 from.fingerprint()
280 )
281 })?;
282 Ok(from)
283 }
284}
285
286#[allow(clippy::too_many_arguments)]
290fn canonical(
291 from: AgentId,
292 to: AgentId,
293 ts: u64,
294 msg_id: &str,
295 text: &str,
296 kind: MessageKind,
297 task_id: Option<&str>,
298 status: Option<TaskStatus>,
299 in_reply_to: Option<&str>,
300) -> Vec<u8> {
301 let k = kind.as_str();
304 let st = status.map(TaskStatus::as_str).unwrap_or("");
305 let tid = task_id.unwrap_or("");
306 let irt = in_reply_to.unwrap_or("");
307 let mut b = Vec::with_capacity(DOMAIN.len() + 88 + k.len() + msg_id.len() + text.len());
308 b.extend_from_slice(DOMAIN);
309 b.extend_from_slice(from.as_verifying_key().as_bytes());
310 b.extend_from_slice(to.as_verifying_key().as_bytes());
311 b.extend_from_slice(&ts.to_le_bytes());
312 for field in [k, msg_id, text, st, tid, irt] {
315 b.extend_from_slice(&(field.len() as u32).to_le_bytes());
316 b.extend_from_slice(field.as_bytes());
317 }
318 b
319}
320
321const ANNOUNCE_DOMAIN: &[u8] = b"interlink-announce-v1\0";
325
326#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330pub struct Announcement {
331 pub pubkey: String,
332 pub name: String,
333 pub ts: u64,
334 pub sig: String,
335}
336
337impl Announcement {
338 pub fn verify(&self) -> Result<AgentId> {
340 let id = AgentId::from_b64(&self.pubkey)?;
341 let raw = B64
342 .decode(&self.sig)
343 .context("announcement signature is not valid base64")?;
344 let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
345 .try_into()
346 .map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
347 let sig = Signature::from_bytes(&sig_bytes);
348 id.as_verifying_key()
349 .verify_strict(&announce_canonical(id, &self.name, self.ts), &sig)
350 .map_err(|_| anyhow!("announcement does not verify for {}", id.fingerprint()))?;
351 Ok(id)
352 }
353}
354
355fn announce_canonical(pubkey: AgentId, name: &str, ts: u64) -> Vec<u8> {
356 let mut b = Vec::with_capacity(ANNOUNCE_DOMAIN.len() + 40 + name.len());
357 b.extend_from_slice(ANNOUNCE_DOMAIN);
358 b.extend_from_slice(pubkey.as_verifying_key().as_bytes());
359 b.extend_from_slice(&(name.len() as u32).to_le_bytes());
360 b.extend_from_slice(name.as_bytes());
361 b.extend_from_slice(&ts.to_le_bytes());
362 b
363}
364
365pub fn check_freshness(ts: u64, now: u64, max_skew_ms: u64) -> Result<()> {
367 let delta = now.abs_diff(ts);
368 if delta > max_skew_ms {
369 bail!("message timestamp is {delta}ms from now (max {max_skew_ms}ms)");
370 }
371 Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 fn key() -> AgentKey {
379 AgentKey::generate().unwrap()
380 }
381
382 #[test]
383 fn sign_then_verify_returns_the_sender() {
384 let (alice, bob) = (key(), key());
385 let msg = alice.sign(bob.id(), "hello", 1234, "m1");
386 assert_eq!(msg.verify().unwrap(), alice.id());
387 }
388
389 #[test]
390 fn tampered_text_fails() {
391 let (alice, bob) = (key(), key());
392 let mut msg = alice.sign(bob.id(), "transfer 1", 1234, "m1");
393 msg.text = "transfer 1000".into();
394 assert!(msg.verify().is_err());
395 }
396
397 #[test]
398 fn tampered_recipient_fails() {
399 let (alice, bob, eve) = (key(), key(), key());
400 let mut msg = alice.sign(bob.id(), "hi", 1, "m1");
401 msg.to = eve.id().to_b64();
402 assert!(msg.verify().is_err());
403 }
404
405 #[test]
406 fn forged_sender_fails() {
407 let (alice, bob, eve) = (key(), key(), key());
409 let mut msg = eve.sign(bob.id(), "hi", 1, "m1");
410 msg.from = alice.id().to_b64();
411 assert!(msg.verify().is_err());
412 }
413
414 #[test]
415 fn ts_and_msg_id_are_covered() {
416 let (alice, bob) = (key(), key());
417 let orig = alice.sign(bob.id(), "hi", 1, "m1");
418 for mut m in [orig.clone(), orig.clone()] {
419 m.ts = 2;
420 assert!(m.verify().is_err(), "ts must be signed");
421 }
422 let mut m = orig;
423 m.msg_id = "m2".into();
424 assert!(m.verify().is_err(), "msg_id must be signed");
425 }
426
427 #[test]
428 fn length_prefixes_stop_boundary_shifting() {
429 let (alice, bob) = (key(), key());
431 let a = canonical(
432 alice.id(),
433 bob.id(),
434 1,
435 "ab",
436 "c",
437 MessageKind::Message,
438 None,
439 None,
440 None,
441 );
442 let b = canonical(
443 alice.id(),
444 bob.id(),
445 1,
446 "a",
447 "bc",
448 MessageKind::Message,
449 None,
450 None,
451 None,
452 );
453 assert_ne!(a, b);
454 }
455
456 #[test]
457 fn domain_separation_is_bound_in() {
458 let (alice, bob) = (key(), key());
459 let bytes = canonical(
460 alice.id(),
461 bob.id(),
462 1,
463 "m1",
464 "hi",
465 MessageKind::Message,
466 None,
467 None,
468 None,
469 );
470 assert!(bytes.starts_with(DOMAIN));
471 }
472
473 #[test]
474 fn id_b64_round_trips() {
475 let alice = key();
476 let id = alice.id();
477 assert_eq!(AgentId::from_b64(&id.to_b64()).unwrap(), id);
478 assert_eq!(id.fingerprint().len(), 8);
479 }
480
481 #[test]
482 fn secret_key_b64_round_trips() {
483 let alice = key();
484 let restored = AgentKey::from_b64(&alice.to_b64()).unwrap();
485 assert_eq!(restored.id(), alice.id());
486 }
487
488 #[test]
489 fn freshness_bounds_replay_window() {
490 assert!(check_freshness(1_000, 1_500, 1_000).is_ok());
491 assert!(check_freshness(1_000, 5_000, 1_000).is_err());
492 assert!(
493 check_freshness(5_000, 1_000, 1_000).is_err(),
494 "future ts too"
495 );
496 }
497
498 #[test]
499 fn announcement_round_trips_and_rejects_tampering() {
500 let alice = key();
501 let a = alice.announce("alice-laptop", 1234);
502 assert_eq!(a.verify().unwrap(), alice.id());
503
504 let mut tampered_name = a.clone();
505 tampered_name.name = "eve-laptop".into();
506 assert!(tampered_name.verify().is_err(), "name is signed");
507
508 let mut forged_key = a;
509 forged_key.pubkey = key().id().to_b64();
510 assert!(
511 forged_key.verify().is_err(),
512 "can't reattribute to another key"
513 );
514 }
515
516 #[test]
517 fn task_fields_are_covered_by_signature() {
518 let (alice, bob) = (key(), key());
519 let mut m = alice.sign_full(
520 bob.id(),
521 "on it",
522 1,
523 "m1",
524 MessageKind::Message,
525 Some("task-1"),
526 Some(TaskStatus::Update),
527 None,
528 );
529 assert!(m.verify().is_ok());
530 m.status = Some(TaskStatus::Canceled);
531 assert!(m.verify().is_err(), "status must be signed");
532
533 let mut m2 = alice.sign_full(
534 bob.id(),
535 "answer",
536 1,
537 "m2",
538 MessageKind::Message,
539 Some("task-1"),
540 None,
541 Some("q1"),
542 );
543 assert!(m2.verify().is_ok());
544 m2.in_reply_to = Some("q2".into());
545 assert!(m2.verify().is_err(), "in_reply_to must be signed");
546 }
547
548 #[test]
549 fn kind_is_covered_by_signature() {
550 let (alice, bob) = (key(), key());
551 let mut m = alice.sign_as(bob.id(), "hi", 1, "m1", MessageKind::Message);
552 assert!(m.verify().is_ok());
553 m.kind = MessageKind::PairRequest;
555 assert!(m.verify().is_err(), "kind must be signed");
556 }
557}