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_skew_ms: u64) -> Result<()> {
418 let delta = now.abs_diff(ts);
419 if delta > max_skew_ms {
420 bail!("message timestamp is {delta}ms from now (max {max_skew_ms}ms)");
421 }
422 Ok(())
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn key() -> AgentKey {
430 AgentKey::generate().unwrap()
431 }
432
433 #[test]
434 fn sign_then_verify_returns_the_sender() {
435 let (alice, bob) = (key(), key());
436 let msg = alice.sign(bob.id(), "hello", 1234, "m1");
437 assert_eq!(msg.verify().unwrap(), alice.id());
438 }
439
440 #[test]
441 fn tampered_text_fails() {
442 let (alice, bob) = (key(), key());
443 let mut msg = alice.sign(bob.id(), "transfer 1", 1234, "m1");
444 msg.text = "transfer 1000".into();
445 assert!(msg.verify().is_err());
446 }
447
448 #[test]
449 fn tampered_recipient_fails() {
450 let (alice, bob, eve) = (key(), key(), key());
451 let mut msg = alice.sign(bob.id(), "hi", 1, "m1");
452 msg.to = eve.id().to_b64();
453 assert!(msg.verify().is_err());
454 }
455
456 #[test]
457 fn forged_sender_fails() {
458 let (alice, bob, eve) = (key(), key(), key());
460 let mut msg = eve.sign(bob.id(), "hi", 1, "m1");
461 msg.from = alice.id().to_b64();
462 assert!(msg.verify().is_err());
463 }
464
465 #[test]
466 fn ts_and_msg_id_are_covered() {
467 let (alice, bob) = (key(), key());
468 let orig = alice.sign(bob.id(), "hi", 1, "m1");
469 for mut m in [orig.clone(), orig.clone()] {
470 m.ts = 2;
471 assert!(m.verify().is_err(), "ts must be signed");
472 }
473 let mut m = orig;
474 m.msg_id = "m2".into();
475 assert!(m.verify().is_err(), "msg_id must be signed");
476 }
477
478 #[test]
479 fn length_prefixes_stop_boundary_shifting() {
480 let (alice, bob) = (key(), key());
482 let a = canonical(
483 alice.id(),
484 bob.id(),
485 1,
486 "ab",
487 "c",
488 MessageKind::Message,
489 None,
490 None,
491 None,
492 );
493 let b = canonical(
494 alice.id(),
495 bob.id(),
496 1,
497 "a",
498 "bc",
499 MessageKind::Message,
500 None,
501 None,
502 None,
503 );
504 assert_ne!(a, b);
505 }
506
507 #[test]
508 fn domain_separation_is_bound_in() {
509 let (alice, bob) = (key(), key());
510 let bytes = canonical(
511 alice.id(),
512 bob.id(),
513 1,
514 "m1",
515 "hi",
516 MessageKind::Message,
517 None,
518 None,
519 None,
520 );
521 assert!(bytes.starts_with(DOMAIN));
522 }
523
524 #[test]
525 fn id_b64_round_trips() {
526 let alice = key();
527 let id = alice.id();
528 assert_eq!(AgentId::from_b64(&id.to_b64()).unwrap(), id);
529 assert_eq!(id.fingerprint().len(), 8);
530 }
531
532 #[test]
533 fn secret_key_b64_round_trips() {
534 let alice = key();
535 let restored = AgentKey::from_b64(&alice.to_b64()).unwrap();
536 assert_eq!(restored.id(), alice.id());
537 }
538
539 #[test]
540 fn freshness_bounds_replay_window() {
541 assert!(check_freshness(1_000, 1_500, 1_000).is_ok());
542 assert!(check_freshness(1_000, 5_000, 1_000).is_err());
543 assert!(
544 check_freshness(5_000, 1_000, 1_000).is_err(),
545 "future ts too"
546 );
547 }
548
549 #[test]
550 fn announcement_round_trips_and_rejects_tampering() {
551 let alice = key();
552 let session = SessionInfo {
553 session_id: "a3f2c1".into(),
554 cwd: "/home/alice/eden".into(),
555 git_root: "eden".into(),
556 summary: "installing deps".into(),
557 };
558 let a = alice.announce("alice-laptop", &session, 1234);
559 assert_eq!(a.verify().unwrap(), alice.id());
560
561 let mut tampered_name = a.clone();
562 tampered_name.name = "eve-laptop".into();
563 assert!(tampered_name.verify().is_err(), "name is signed");
564
565 let mut tampered_session = a.clone();
566 tampered_session.session.summary = "rm -rf /".into();
567 assert!(
568 tampered_session.verify().is_err(),
569 "the session descriptor is signed"
570 );
571
572 let mut swapped_id = a.clone();
573 swapped_id.session.session_id = "deadbeef".into();
574 assert!(swapped_id.verify().is_err(), "session_id is signed");
575
576 let mut forged_key = a;
577 forged_key.pubkey = key().id().to_b64();
578 assert!(
579 forged_key.verify().is_err(),
580 "can't reattribute to another key"
581 );
582 }
583
584 #[test]
585 fn task_fields_are_covered_by_signature() {
586 let (alice, bob) = (key(), key());
587 let mut m = alice.sign_full(
588 bob.id(),
589 "on it",
590 1,
591 "m1",
592 MessageKind::Message,
593 Some("task-1"),
594 Some(TaskStatus::Update),
595 None,
596 );
597 assert!(m.verify().is_ok());
598 m.status = Some(TaskStatus::Canceled);
599 assert!(m.verify().is_err(), "status must be signed");
600
601 let mut m2 = alice.sign_full(
602 bob.id(),
603 "answer",
604 1,
605 "m2",
606 MessageKind::Message,
607 Some("task-1"),
608 None,
609 Some("q1"),
610 );
611 assert!(m2.verify().is_ok());
612 m2.in_reply_to = Some("q2".into());
613 assert!(m2.verify().is_err(), "in_reply_to must be signed");
614 }
615
616 #[test]
617 fn kind_is_covered_by_signature() {
618 let (alice, bob) = (key(), key());
619 let mut m = alice.sign_as(bob.id(), "hi", 1, "m1", MessageKind::Message);
620 assert!(m.verify().is_ok());
621 m.kind = MessageKind::PairRequest;
623 assert!(m.verify().is_err(), "kind must be signed");
624 }
625}