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            sig: B64.encode(sig.to_bytes()),
210        }
211    }
212
213    /// Sign a presence announcement: "this key is online, calling itself `name`".
214    pub fn announce(&self, name: &str, ts: u64) -> Announcement {
215        let sig: Signature = self.0.sign(&announce_canonical(self.id(), name, ts));
216        Announcement {
217            pubkey: self.id().to_b64(),
218            name: name.to_string(),
219            ts,
220            sig: B64.encode(sig.to_bytes()),
221        }
222    }
223}
224
225/// What travels over the bus. The bus treats it as an opaque payload.
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
227pub struct SignedMessage {
228    pub from: String,
229    pub to: String,
230    pub text: String,
231    pub ts: u64,
232    pub msg_id: String,
233    #[serde(default)]
234    pub kind: MessageKind,
235    /// Task-tracking metadata (all optional; absent on a plain chat turn). See
236    /// [`docs/TASKS.md`](../docs/TASKS.md).
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub task_id: Option<String>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub status: Option<TaskStatus>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub in_reply_to: Option<String>,
243    pub sig: String,
244}
245
246impl SignedMessage {
247    /// Verify the signature and return the *authenticated* sender.
248    ///
249    /// Uses `verify_strict`, which rejects small-order public keys and
250    /// non-canonical signature encodings.
251    pub fn verify(&self) -> Result<AgentId> {
252        let from = AgentId::from_b64(&self.from)?;
253        let to = AgentId::from_b64(&self.to)?;
254
255        let raw = B64
256            .decode(&self.sig)
257            .context("signature is not valid base64")?;
258        let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
259            .try_into()
260            .map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
261        let sig = Signature::from_bytes(&sig_bytes);
262
263        let bytes = canonical(
264            from,
265            to,
266            self.ts,
267            &self.msg_id,
268            &self.text,
269            self.kind,
270            self.task_id.as_deref(),
271            self.status,
272            self.in_reply_to.as_deref(),
273        );
274        from.as_verifying_key()
275            .verify_strict(&bytes, &sig)
276            .map_err(|_| {
277                anyhow!(
278                    "signature does not verify for sender {}",
279                    from.fingerprint()
280                )
281            })?;
282        Ok(from)
283    }
284}
285
286/// Domain-separated, length-prefixed. Length prefixes stop an attacker shifting
287/// bytes across the `msg_id`/`text` boundary to forge a different message under
288/// the same signature.
289#[allow(clippy::too_many_arguments)]
290fn canonical(
291    from: AgentId,
292    to: AgentId,
293    ts: u64,
294    msg_id: &str,
295    text: &str,
296    kind: MessageKind,
297    task_id: Option<&str>,
298    status: Option<TaskStatus>,
299    in_reply_to: Option<&str>,
300) -> Vec<u8> {
301    // Optional fields encode as empty when absent — unambiguous, since a real
302    // task_id/status/in_reply_to is never the empty string.
303    let k = kind.as_str();
304    let st = status.map(TaskStatus::as_str).unwrap_or("");
305    let tid = task_id.unwrap_or("");
306    let irt = in_reply_to.unwrap_or("");
307    let mut b = Vec::with_capacity(DOMAIN.len() + 88 + k.len() + msg_id.len() + text.len());
308    b.extend_from_slice(DOMAIN);
309    b.extend_from_slice(from.as_verifying_key().as_bytes());
310    b.extend_from_slice(to.as_verifying_key().as_bytes());
311    b.extend_from_slice(&ts.to_le_bytes());
312    // Every variable-length field is length-prefixed and in a fixed order, so no
313    // bytes can shift across a field boundary to forge a different message.
314    for field in [k, msg_id, text, st, tid, irt] {
315        b.extend_from_slice(&(field.len() as u32).to_le_bytes());
316        b.extend_from_slice(field.as_bytes());
317    }
318    b
319}
320
321/// Bound into presence announcements. Separate from the message `DOMAIN` so the
322/// two version independently — a message-format change need not reissue the
323/// announcement format, and vice versa.
324const ANNOUNCE_DOMAIN: &[u8] = b"interlink-announce-v1\0";
325
326/// A signed presence announcement, published to the bus roster. The `name` is a
327/// self-claim; identity is the key, so a peer [`verify`](Announcement::verify)s
328/// before ever trusting the name.
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
330pub struct Announcement {
331    pub pubkey: String,
332    pub name: String,
333    pub ts: u64,
334    pub sig: String,
335}
336
337impl Announcement {
338    /// Verify the self-signature; returns the authenticated key on success.
339    pub fn verify(&self) -> Result<AgentId> {
340        let id = AgentId::from_b64(&self.pubkey)?;
341        let raw = B64
342            .decode(&self.sig)
343            .context("announcement signature is not valid base64")?;
344        let sig_bytes: [u8; SIGNATURE_LENGTH] = raw
345            .try_into()
346            .map_err(|_| anyhow!("signature must be {SIGNATURE_LENGTH} bytes"))?;
347        let sig = Signature::from_bytes(&sig_bytes);
348        id.as_verifying_key()
349            .verify_strict(&announce_canonical(id, &self.name, self.ts), &sig)
350            .map_err(|_| anyhow!("announcement does not verify for {}", id.fingerprint()))?;
351        Ok(id)
352    }
353}
354
355fn announce_canonical(pubkey: AgentId, name: &str, ts: u64) -> Vec<u8> {
356    let mut b = Vec::with_capacity(ANNOUNCE_DOMAIN.len() + 40 + name.len());
357    b.extend_from_slice(ANNOUNCE_DOMAIN);
358    b.extend_from_slice(pubkey.as_verifying_key().as_bytes());
359    b.extend_from_slice(&(name.len() as u32).to_le_bytes());
360    b.extend_from_slice(name.as_bytes());
361    b.extend_from_slice(&ts.to_le_bytes());
362    b
363}
364
365/// Reject messages whose timestamp is too far from now, bounding replay windows.
366pub fn check_freshness(ts: u64, now: u64, max_skew_ms: u64) -> Result<()> {
367    let delta = now.abs_diff(ts);
368    if delta > max_skew_ms {
369        bail!("message timestamp is {delta}ms from now (max {max_skew_ms}ms)");
370    }
371    Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    fn key() -> AgentKey {
379        AgentKey::generate().unwrap()
380    }
381
382    #[test]
383    fn sign_then_verify_returns_the_sender() {
384        let (alice, bob) = (key(), key());
385        let msg = alice.sign(bob.id(), "hello", 1234, "m1");
386        assert_eq!(msg.verify().unwrap(), alice.id());
387    }
388
389    #[test]
390    fn tampered_text_fails() {
391        let (alice, bob) = (key(), key());
392        let mut msg = alice.sign(bob.id(), "transfer 1", 1234, "m1");
393        msg.text = "transfer 1000".into();
394        assert!(msg.verify().is_err());
395    }
396
397    #[test]
398    fn tampered_recipient_fails() {
399        let (alice, bob, eve) = (key(), key(), key());
400        let mut msg = alice.sign(bob.id(), "hi", 1, "m1");
401        msg.to = eve.id().to_b64();
402        assert!(msg.verify().is_err());
403    }
404
405    #[test]
406    fn forged_sender_fails() {
407        // Eve signs, then claims to be Alice.
408        let (alice, bob, eve) = (key(), key(), key());
409        let mut msg = eve.sign(bob.id(), "hi", 1, "m1");
410        msg.from = alice.id().to_b64();
411        assert!(msg.verify().is_err());
412    }
413
414    #[test]
415    fn ts_and_msg_id_are_covered() {
416        let (alice, bob) = (key(), key());
417        let orig = alice.sign(bob.id(), "hi", 1, "m1");
418        for mut m in [orig.clone(), orig.clone()] {
419            m.ts = 2;
420            assert!(m.verify().is_err(), "ts must be signed");
421        }
422        let mut m = orig;
423        m.msg_id = "m2".into();
424        assert!(m.verify().is_err(), "msg_id must be signed");
425    }
426
427    #[test]
428    fn length_prefixes_stop_boundary_shifting() {
429        // ("ab", "c") and ("a", "bc") must not produce the same signed bytes.
430        let (alice, bob) = (key(), key());
431        let a = canonical(
432            alice.id(),
433            bob.id(),
434            1,
435            "ab",
436            "c",
437            MessageKind::Message,
438            None,
439            None,
440            None,
441        );
442        let b = canonical(
443            alice.id(),
444            bob.id(),
445            1,
446            "a",
447            "bc",
448            MessageKind::Message,
449            None,
450            None,
451            None,
452        );
453        assert_ne!(a, b);
454    }
455
456    #[test]
457    fn domain_separation_is_bound_in() {
458        let (alice, bob) = (key(), key());
459        let bytes = canonical(
460            alice.id(),
461            bob.id(),
462            1,
463            "m1",
464            "hi",
465            MessageKind::Message,
466            None,
467            None,
468            None,
469        );
470        assert!(bytes.starts_with(DOMAIN));
471    }
472
473    #[test]
474    fn id_b64_round_trips() {
475        let alice = key();
476        let id = alice.id();
477        assert_eq!(AgentId::from_b64(&id.to_b64()).unwrap(), id);
478        assert_eq!(id.fingerprint().len(), 8);
479    }
480
481    #[test]
482    fn secret_key_b64_round_trips() {
483        let alice = key();
484        let restored = AgentKey::from_b64(&alice.to_b64()).unwrap();
485        assert_eq!(restored.id(), alice.id());
486    }
487
488    #[test]
489    fn freshness_bounds_replay_window() {
490        assert!(check_freshness(1_000, 1_500, 1_000).is_ok());
491        assert!(check_freshness(1_000, 5_000, 1_000).is_err());
492        assert!(
493            check_freshness(5_000, 1_000, 1_000).is_err(),
494            "future ts too"
495        );
496    }
497
498    #[test]
499    fn announcement_round_trips_and_rejects_tampering() {
500        let alice = key();
501        let a = alice.announce("alice-laptop", 1234);
502        assert_eq!(a.verify().unwrap(), alice.id());
503
504        let mut tampered_name = a.clone();
505        tampered_name.name = "eve-laptop".into();
506        assert!(tampered_name.verify().is_err(), "name is signed");
507
508        let mut forged_key = a;
509        forged_key.pubkey = key().id().to_b64();
510        assert!(
511            forged_key.verify().is_err(),
512            "can't reattribute to another key"
513        );
514    }
515
516    #[test]
517    fn task_fields_are_covered_by_signature() {
518        let (alice, bob) = (key(), key());
519        let mut m = alice.sign_full(
520            bob.id(),
521            "on it",
522            1,
523            "m1",
524            MessageKind::Message,
525            Some("task-1"),
526            Some(TaskStatus::Update),
527            None,
528        );
529        assert!(m.verify().is_ok());
530        m.status = Some(TaskStatus::Canceled);
531        assert!(m.verify().is_err(), "status must be signed");
532
533        let mut m2 = alice.sign_full(
534            bob.id(),
535            "answer",
536            1,
537            "m2",
538            MessageKind::Message,
539            Some("task-1"),
540            None,
541            Some("q1"),
542        );
543        assert!(m2.verify().is_ok());
544        m2.in_reply_to = Some("q2".into());
545        assert!(m2.verify().is_err(), "in_reply_to must be signed");
546    }
547
548    #[test]
549    fn kind_is_covered_by_signature() {
550        let (alice, bob) = (key(), key());
551        let mut m = alice.sign_as(bob.id(), "hi", 1, "m1", MessageKind::Message);
552        assert!(m.verify().is_ok());
553        // A message can't be re-typed into a pairing knock under the same signature.
554        m.kind = MessageKind::PairRequest;
555        assert!(m.verify().is_err(), "kind must be signed");
556    }
557}