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 too far from now, bounding replay windows.
417pub 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        // Eve signs, then claims to be Alice.
459        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        // ("ab", "c") and ("a", "bc") must not produce the same signed bytes.
481        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        // A message can't be re-typed into a pairing knock under the same signature.
622        m.kind = MessageKind::PairRequest;
623        assert!(m.verify().is_err(), "kind must be signed");
624    }
625}