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