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 reply_to: None,
210 sig: B64.encode(sig.to_bytes()),
211 }
212 }
213
214 pub fn announce(&self, name: &str, session: &SessionInfo, ts: u64) -> Announcement {
218 let sig: Signature = self
219 .0
220 .sign(&announce_canonical(self.id(), name, session, ts));
221 Announcement {
222 pubkey: self.id().to_b64(),
223 name: name.to_string(),
224 session: session.clone(),
225 ts,
226 sig: B64.encode(sig.to_bytes()),
227 age_ms: None,
228 }
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
237pub struct SessionInfo {
238 pub session_id: String,
239 #[serde(default)]
240 pub cwd: String,
241 #[serde(default)]
242 pub git_root: String,
243 #[serde(default)]
244 pub summary: String,
245}
246
247pub fn mint_session_id() -> Result<String> {
251 let mut b = [0u8; 4];
252 getrandom::fill(&mut b).map_err(|e| anyhow!("OS entropy unavailable: {e}"))?;
253 Ok(b.iter().map(|x| format!("{x:02x}")).collect())
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
258pub struct SignedMessage {
259 pub from: String,
260 pub to: String,
261 pub text: String,
262 pub ts: u64,
263 pub msg_id: String,
264 #[serde(default)]
265 pub kind: MessageKind,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub task_id: Option<String>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub status: Option<TaskStatus>,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub in_reply_to: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub reply_to: Option<String>,
281 pub sig: String,
282}
283
284impl SignedMessage {
285 pub fn verify(&self) -> Result<AgentId> {
290 let from = AgentId::from_b64(&self.from)?;
291 let to = AgentId::from_b64(&self.to)?;
292
293 let raw = B64
294 .decode(&self.sig)
295 .context("signature is not valid base64")?;
296 let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
297 .try_into()
298 .map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
299 let sig = Signature::from_bytes(&sig_bytes);
300
301 let bytes = canonical(
302 from,
303 to,
304 self.ts,
305 &self.msg_id,
306 &self.text,
307 self.kind,
308 self.task_id.as_deref(),
309 self.status,
310 self.in_reply_to.as_deref(),
311 );
312 from.as_verifying_key()
313 .verify_strict(&bytes, &sig)
314 .map_err(|_| {
315 anyhow!(
316 "signature does not verify for sender {}",
317 from.fingerprint()
318 )
319 })?;
320 Ok(from)
321 }
322}
323
324#[allow(clippy::too_many_arguments)]
328fn canonical(
329 from: AgentId,
330 to: AgentId,
331 ts: u64,
332 msg_id: &str,
333 text: &str,
334 kind: MessageKind,
335 task_id: Option<&str>,
336 status: Option<TaskStatus>,
337 in_reply_to: Option<&str>,
338) -> Vec<u8> {
339 let k = kind.as_str();
342 let st = status.map(TaskStatus::as_str).unwrap_or("");
343 let tid = task_id.unwrap_or("");
344 let irt = in_reply_to.unwrap_or("");
345 let mut b = Vec::with_capacity(DOMAIN.len() + 88 + k.len() + msg_id.len() + text.len());
346 b.extend_from_slice(DOMAIN);
347 b.extend_from_slice(from.as_verifying_key().as_bytes());
348 b.extend_from_slice(to.as_verifying_key().as_bytes());
349 b.extend_from_slice(&ts.to_le_bytes());
350 for field in [k, msg_id, text, st, tid, irt] {
353 b.extend_from_slice(&(field.len() as u32).to_le_bytes());
354 b.extend_from_slice(field.as_bytes());
355 }
356 b
357}
358
359const ANNOUNCE_DOMAIN: &[u8] = b"interlink-announce-v1\0";
363
364#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368pub struct Announcement {
369 pub pubkey: String,
370 pub name: String,
371 #[serde(default)]
372 pub session: SessionInfo,
373 pub ts: u64,
374 pub sig: String,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub age_ms: Option<u64>,
381}
382
383impl Announcement {
384 pub fn verify(&self) -> Result<AgentId> {
386 let id = AgentId::from_b64(&self.pubkey)?;
387 let raw = B64
388 .decode(&self.sig)
389 .context("announcement signature is not valid base64")?;
390 let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
391 .try_into()
392 .map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
393 let sig = Signature::from_bytes(&sig_bytes);
394 id.as_verifying_key()
395 .verify_strict(
396 &announce_canonical(id, &self.name, &self.session, self.ts),
397 &sig,
398 )
399 .map_err(|_| anyhow!("announcement does not verify for {}", id.fingerprint()))?;
400 Ok(id)
401 }
402}
403
404fn announce_canonical(pubkey: AgentId, name: &str, session: &SessionInfo, ts: u64) -> Vec<u8> {
405 let mut b = Vec::with_capacity(ANNOUNCE_DOMAIN.len() + 64 + name.len());
406 b.extend_from_slice(ANNOUNCE_DOMAIN);
407 b.extend_from_slice(pubkey.as_verifying_key().as_bytes());
408 for field in [
411 name,
412 &session.session_id,
413 &session.cwd,
414 &session.git_root,
415 &session.summary,
416 ] {
417 b.extend_from_slice(&(field.len() as u32).to_le_bytes());
418 b.extend_from_slice(field.as_bytes());
419 }
420 b.extend_from_slice(&ts.to_le_bytes());
421 b
422}
423
424pub fn check_freshness(ts: u64, now: u64, max_future_ms: u64, max_past_ms: u64) -> Result<()> {
430 if ts > now.saturating_add(max_future_ms) {
431 bail!(
432 "message timestamp is {}ms in the future (max {max_future_ms}ms)",
433 ts - now
434 );
435 }
436 if now > ts.saturating_add(max_past_ms) {
437 bail!(
438 "message timestamp is {}ms in the past (max {max_past_ms}ms)",
439 now - ts
440 );
441 }
442 Ok(())
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn key() -> AgentKey {
450 AgentKey::generate().unwrap()
451 }
452
453 #[test]
454 fn sign_then_verify_returns_the_sender() {
455 let (alice, bob) = (key(), key());
456 let msg = alice.sign(bob.id(), "hello", 1234, "m1");
457 assert_eq!(msg.verify().unwrap(), alice.id());
458 }
459
460 #[test]
461 fn tampered_text_fails() {
462 let (alice, bob) = (key(), key());
463 let mut msg = alice.sign(bob.id(), "transfer 1", 1234, "m1");
464 msg.text = "transfer 1000".into();
465 assert!(msg.verify().is_err());
466 }
467
468 #[test]
469 fn tampered_recipient_fails() {
470 let (alice, bob, eve) = (key(), key(), key());
471 let mut msg = alice.sign(bob.id(), "hi", 1, "m1");
472 msg.to = eve.id().to_b64();
473 assert!(msg.verify().is_err());
474 }
475
476 #[test]
477 fn forged_sender_fails() {
478 let (alice, bob, eve) = (key(), key(), key());
480 let mut msg = eve.sign(bob.id(), "hi", 1, "m1");
481 msg.from = alice.id().to_b64();
482 assert!(msg.verify().is_err());
483 }
484
485 #[test]
486 fn ts_and_msg_id_are_covered() {
487 let (alice, bob) = (key(), key());
488 let orig = alice.sign(bob.id(), "hi", 1, "m1");
489 for mut m in [orig.clone(), orig.clone()] {
490 m.ts = 2;
491 assert!(m.verify().is_err(), "ts must be signed");
492 }
493 let mut m = orig;
494 m.msg_id = "m2".into();
495 assert!(m.verify().is_err(), "msg_id must be signed");
496 }
497
498 #[test]
499 fn length_prefixes_stop_boundary_shifting() {
500 let (alice, bob) = (key(), key());
502 let a = canonical(
503 alice.id(),
504 bob.id(),
505 1,
506 "ab",
507 "c",
508 MessageKind::Message,
509 None,
510 None,
511 None,
512 );
513 let b = canonical(
514 alice.id(),
515 bob.id(),
516 1,
517 "a",
518 "bc",
519 MessageKind::Message,
520 None,
521 None,
522 None,
523 );
524 assert_ne!(a, b);
525 }
526
527 #[test]
528 fn domain_separation_is_bound_in() {
529 let (alice, bob) = (key(), key());
530 let bytes = canonical(
531 alice.id(),
532 bob.id(),
533 1,
534 "m1",
535 "hi",
536 MessageKind::Message,
537 None,
538 None,
539 None,
540 );
541 assert!(bytes.starts_with(DOMAIN));
542 }
543
544 #[test]
545 fn id_b64_round_trips() {
546 let alice = key();
547 let id = alice.id();
548 assert_eq!(AgentId::from_b64(&id.to_b64()).unwrap(), id);
549 assert_eq!(id.fingerprint().len(), 8);
550 }
551
552 #[test]
553 fn secret_key_b64_round_trips() {
554 let alice = key();
555 let restored = AgentKey::from_b64(&alice.to_b64()).unwrap();
556 assert_eq!(restored.id(), alice.id());
557 }
558
559 #[test]
560 fn freshness_is_asymmetric() {
561 assert!(
563 check_freshness(1_000, 1_500, 1_000, 10_000).is_ok(),
564 "recent"
565 );
566 assert!(
568 check_freshness(5_000, 1_000, 1_000, 10_000).is_err(),
569 "too far in the future"
570 );
571 assert!(
574 check_freshness(1_000, 6_000, 1_000, 10_000).is_ok(),
575 "5s late is fine with a 10s past bound"
576 );
577 assert!(
579 check_freshness(1_000, 12_000, 1_000, 10_000).is_err(),
580 "11s late exceeds the past bound"
581 );
582 }
583
584 #[test]
585 fn announcement_round_trips_and_rejects_tampering() {
586 let alice = key();
587 let session = SessionInfo {
588 session_id: "a3f2c1".into(),
589 cwd: "/home/alice/eden".into(),
590 git_root: "eden".into(),
591 summary: "installing deps".into(),
592 };
593 let a = alice.announce("alice-laptop", &session, 1234);
594 assert_eq!(a.verify().unwrap(), alice.id());
595
596 let mut tampered_name = a.clone();
597 tampered_name.name = "eve-laptop".into();
598 assert!(tampered_name.verify().is_err(), "name is signed");
599
600 let mut tampered_session = a.clone();
601 tampered_session.session.summary = "rm -rf /".into();
602 assert!(
603 tampered_session.verify().is_err(),
604 "the session descriptor is signed"
605 );
606
607 let mut swapped_id = a.clone();
608 swapped_id.session.session_id = "deadbeef".into();
609 assert!(swapped_id.verify().is_err(), "session_id is signed");
610
611 let mut forged_key = a;
612 forged_key.pubkey = key().id().to_b64();
613 assert!(
614 forged_key.verify().is_err(),
615 "can't reattribute to another key"
616 );
617 }
618
619 #[test]
620 fn task_fields_are_covered_by_signature() {
621 let (alice, bob) = (key(), key());
622 let mut m = alice.sign_full(
623 bob.id(),
624 "on it",
625 1,
626 "m1",
627 MessageKind::Message,
628 Some("task-1"),
629 Some(TaskStatus::Update),
630 None,
631 );
632 assert!(m.verify().is_ok());
633 m.status = Some(TaskStatus::Canceled);
634 assert!(m.verify().is_err(), "status must be signed");
635
636 let mut m2 = alice.sign_full(
637 bob.id(),
638 "answer",
639 1,
640 "m2",
641 MessageKind::Message,
642 Some("task-1"),
643 None,
644 Some("q1"),
645 );
646 assert!(m2.verify().is_ok());
647 m2.in_reply_to = Some("q2".into());
648 assert!(m2.verify().is_err(), "in_reply_to must be signed");
649 }
650
651 #[test]
652 fn kind_is_covered_by_signature() {
653 let (alice, bob) = (key(), key());
654 let mut m = alice.sign_as(bob.id(), "hi", 1, "m1", MessageKind::Message);
655 assert!(m.verify().is_ok());
656 m.kind = MessageKind::PairRequest;
658 assert!(m.verify().is_err(), "kind must be signed");
659 }
660}