Skip to main content

interlink/
identity.rs

1//! Ed25519 identity. **The public key is the identity**; names are local petnames.
2//!
3//! Claiming the name "alice" gets you nothing without her key, so the sender
4//! gate in the channel server checks a signature rather than a string an agent
5//! typed. This is the property the channel docs demand: *"gate on the sender's
6//! identity."*
7//!
8//! Signing covers a domain-separated, length-prefixed canonical encoding, so a
9//! signature can never be replayed into a different context and no pair of
10//! fields can be shifted across their boundary.
11
12use 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
21/// Bound into every message signature. Bumped v1→v2 when task fields (`status`,
22/// `task_id`, `in_reply_to`) entered the canonical encoding; a bump makes old and
23/// new signatures mutually unverifiable.
24const DOMAIN: &[u8] = b"interlink-v2\0";
25
26/// What a signed message *is*. A plain `Message` is the everyday case; the pairing
27/// kinds are the only thing a non-peer may deliver (a knock), gated specially.
28#[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/// The lifecycle marker on a task message. Absent on a plain chat turn, the
48/// opening request, or an answer; present on the executor's replies about a task.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(rename_all = "snake_case")]
51pub enum TaskStatus {
52    /// Progress on a running task.
53    Update,
54    /// Blocked; needs an answer routed back to the requester's operator.
55    NeedsInput,
56    /// Terminal: finished successfully.
57    Result,
58    /// Terminal: finished with an error.
59    Failed,
60    /// Terminal: aborted by either side.
61    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    /// Parse the tool-facing string form; `None` for an unrecognized value.
76    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    /// Terminal states close a task and cannot restart.
88    pub fn is_terminal(self) -> bool {
89        matches!(
90            self,
91            TaskStatus::Result | TaskStatus::Failed | TaskStatus::Canceled
92        )
93    }
94}
95
96/// An agent's identity: its Ed25519 public key.
97#[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    /// Short form for logs and `<channel>` tags. Not for authentication.
116    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
125/// An agent's secret key. Zeroized on drop by `ed25519-dalek`'s default features.
126pub struct AgentKey(SigningKey);
127
128impl AgentKey {
129    /// Seeded straight from the OS CSPRNG.
130    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    /// Sign a plain message to `to`. `ts` and `msg_id` are covered, giving replay
155    /// protection when paired with the receiver's dedupe set.
156    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    /// Sign a message of a specific `kind` (plain, or a pairing knock/accept).
161    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    /// Sign a message carrying optional task-tracking metadata; all of it —
173    /// `status`, `task_id`, `in_reply_to` — is covered by the signature, so a
174    /// relay can't forge a `Canceled` or flip a status in flight.
175    #[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    /// Sign a presence announcement: "this key is online, calling itself `name`,
215    /// as this live session". The session descriptor is signed too, so a peer can
216    /// trust the cwd/repo/summary it picks from as much as the key.
217    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/// A live session under a node identity: its `session_id` (normally the stable id
233/// Claude Code injects, `CLAUDE_CODE_SESSION_ID`; a random one off-Claude) plus the
234/// descriptor a human recognizes it by in `discover`. One identity hosts several at
235/// once; `key#session_id` is the routing address.
236#[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
247/// A fresh random session id (8 lowercase hex chars) — the fallback when Claude Code's
248/// `CLAUDE_CODE_SESSION_ID` isn't present. Random, not derived from metadata, so two
249/// sessions in the same directory never collide.
250pub 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/// What travels over the bus. The bus treats it as an opaque payload.
257#[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    /// Task-tracking metadata (all optional; absent on a plain chat turn). See
267    /// [`docs/TASKS.md`](../docs/TASKS.md).
268    #[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    /// The sender's own inbox route, `key#session_id` — an **unsigned** routing hint
275    /// (deliberately outside [`canonical`], like the bus-side `to` suffix) so a reply
276    /// returns to the exact session that sent this. A relay could tamper it, but only
277    /// to misroute a reply among the sender's own sessions; the trust gate is
278    /// untouched because the signed `from` is still the bare key.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub reply_to: Option<String>,
281    pub sig: String,
282}
283
284impl SignedMessage {
285    /// Verify the signature and return the *authenticated* sender.
286    ///
287    /// Uses `verify_strict`, which rejects small-order public keys and
288    /// non-canonical signature encodings.
289    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/// Domain-separated, length-prefixed. Length prefixes stop an attacker shifting
325/// bytes across the `msg_id`/`text` boundary to forge a different message under
326/// the same signature.
327#[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    // Optional fields encode as empty when absent — unambiguous, since a real
340    // task_id/status/in_reply_to is never the empty string.
341    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    // Every variable-length field is length-prefixed and in a fixed order, so no
351    // bytes can shift across a field boundary to forge a different message.
352    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
359/// Bound into presence announcements. Separate from the message `DOMAIN` so the
360/// two version independently — a message-format change need not reissue the
361/// announcement format, and vice versa.
362const ANNOUNCE_DOMAIN: &[u8] = b"interlink-announce-v1\0";
363
364/// A signed presence announcement, published to the bus roster. The `name` is a
365/// self-claim; identity is the key, so a peer [`verify`](Announcement::verify)s
366/// before ever trusting the name.
367#[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    /// Age since last refresh, stamped by the bus on `/roster` (never signed, so it's
376    /// outside [`announce_canonical`] and ignored by [`verify`](Announcement::verify)).
377    /// The client classifies a session live vs. away from it. Absent on a freshly-signed
378    /// announcement and on older buses.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub age_ms: Option<u64>,
381}
382
383impl Announcement {
384    /// Verify the self-signature; returns the authenticated key on success.
385    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    // Length-prefix every variable field so no two distinct (name, session)
409    // tuples can share an encoding.
410    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
424/// Reject messages whose timestamp is implausible. The bound is asymmetric: the
425/// **future** side is tight (clock skew — a message dated well ahead of now is a
426/// forgery/replay signal), while the **past** side is generous, because the bus is a
427/// durable keep-until-ack store and a legitimately delayed message (an offline or
428/// slow peer, a server-restart gap) must still be deliverable when it finally lands.
429pub 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        // Eve signs, then claims to be Alice.
479        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        // ("ab", "c") and ("a", "bc") must not produce the same signed bytes.
501        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        // future skew 1s, past delay 10s.
562        assert!(
563            check_freshness(1_000, 1_500, 1_000, 10_000).is_ok(),
564            "recent"
565        );
566        // A tight future bound still rejects a message dated ahead of now.
567        assert!(
568            check_freshness(5_000, 1_000, 1_000, 10_000).is_err(),
569            "too far in the future"
570        );
571        // The generous past bound admits a legitimately delayed message that the old
572        // symmetric 1s window would have dropped.
573        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        // But not one older than the past bound.
578        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        // A message can't be re-typed into a pairing knock under the same signature.
657        m.kind = MessageKind::PairRequest;
658        assert!(m.verify().is_err(), "kind must be signed");
659    }
660}