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        }
228    }
229}
230
231/// A live session under a node identity: a random per-launch `session_id` plus the
232/// descriptor a human recognizes it by. One identity hosts several at once; the id
233/// is pure routing (`key#session_id`), the rest is for `discover`'s pick-list.
234#[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
245/// A fresh random session id (8 lowercase hex chars). Random — not derived from
246/// metadata — so two sessions in the same directory never collide; the id is only
247/// a routing key, never shown to humans.
248pub 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/// What travels over the bus. The bus treats it as an opaque payload.
255#[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    /// Task-tracking metadata (all optional; absent on a plain chat turn). See
265    /// [`docs/TASKS.md`](../docs/TASKS.md).
266    #[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    /// The sender's own inbox route, `key#session_id` — an **unsigned** routing hint
273    /// (deliberately outside [`canonical`], like the bus-side `to` suffix) so a reply
274    /// returns to the exact session that sent this. A relay could tamper it, but only
275    /// to misroute a reply among the sender's own sessions; the trust gate is
276    /// untouched because the signed `from` is still the bare key.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub reply_to: Option<String>,
279    pub sig: String,
280}
281
282impl SignedMessage {
283    /// Verify the signature and return the *authenticated* sender.
284    ///
285    /// Uses `verify_strict`, which rejects small-order public keys and
286    /// non-canonical signature encodings.
287    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/// Domain-separated, length-prefixed. Length prefixes stop an attacker shifting
323/// bytes across the `msg_id`/`text` boundary to forge a different message under
324/// the same signature.
325#[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    // Optional fields encode as empty when absent — unambiguous, since a real
338    // task_id/status/in_reply_to is never the empty string.
339    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    // Every variable-length field is length-prefixed and in a fixed order, so no
349    // bytes can shift across a field boundary to forge a different message.
350    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
357/// Bound into presence announcements. Separate from the message `DOMAIN` so the
358/// two version independently — a message-format change need not reissue the
359/// announcement format, and vice versa.
360const ANNOUNCE_DOMAIN: &[u8] = b"interlink-announce-v1\0";
361
362/// A signed presence announcement, published to the bus roster. The `name` is a
363/// self-claim; identity is the key, so a peer [`verify`](Announcement::verify)s
364/// before ever trusting the name.
365#[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    /// Verify the self-signature; returns the authenticated key on success.
377    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    // Length-prefix every variable field so no two distinct (name, session)
401    // tuples can share an encoding.
402    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
416/// Reject messages whose timestamp is implausible. The bound is asymmetric: the
417/// **future** side is tight (clock skew — a message dated well ahead of now is a
418/// forgery/replay signal), while the **past** side is generous, because the bus is a
419/// durable keep-until-ack store and a legitimately delayed message (an offline or
420/// slow peer, a server-restart gap) must still be deliverable when it finally lands.
421pub 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        // Eve signs, then claims to be Alice.
471        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        // ("ab", "c") and ("a", "bc") must not produce the same signed bytes.
493        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        // future skew 1s, past delay 10s.
554        assert!(
555            check_freshness(1_000, 1_500, 1_000, 10_000).is_ok(),
556            "recent"
557        );
558        // A tight future bound still rejects a message dated ahead of now.
559        assert!(
560            check_freshness(5_000, 1_000, 1_000, 10_000).is_err(),
561            "too far in the future"
562        );
563        // The generous past bound admits a legitimately delayed message that the old
564        // symmetric 1s window would have dropped.
565        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        // But not one older than the past bound.
570        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        // A message can't be re-typed into a pairing knock under the same signature.
649        m.kind = MessageKind::PairRequest;
650        assert!(m.verify().is_err(), "kind must be signed");
651    }
652}