Skip to main content

agentmux_common/
install_presence.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! An install's presence record: one AgentMux install (a channel on a
5//! machine) saying it is up, on which platform, and which agents it runs, so
6//! the account's relay can list it to a signed-in phone that is not on the
7//! same LAN. Wire contract: agentmux-mobile's
8//! SPEC_FLEET_HOST_TAGS_AND_CLOUD_HOSTS_2026_10_06 §6.1; version 2 adds each
9//! agent's `state` (agentmux-mobile's
10//! SPEC_AGENT_STATUS_AND_LIVE_PANE_FEED_2026_10_07 §13.1). The desktop
11//! publishes v2; v1 is still built and checked, for the relay's sake.
12//! Version 3 is v2 with an optional top-level `gone`. The desktop sends it
13//! only as the goodbye when it quits, signs out or stops publishing:
14//! `gone: true` and no agent states, which the relay keeps as "offline
15//! since" for a short while instead of a live entry.
16//! Version 4 is v3 with each agent's optional `line`, its Swarm summary line
17//! (agentmux's SPEC_SWARM_OTHER_HOSTS_AND_CHANNELS_2026_10_02 §3.4 Phase 4),
18//! signed as a fourth agent field. The desktop publishes v4 when it has
19//! lines to share; a goodbye stays v3.
20//!
21//! Signed with the install's WAN instance key (the W3-S identity in
22//! [`crate::jekt_sign`]), so the record names the key that signed it and
23//! `instance_id` is checkable against it. `agentmux-srv`'s
24//! `muxbus::wan_presence` builds and publishes it; the relay re-implements
25//! the check in TypeScript, and the fixed vector in the tests below is the
26//! contract between the two.
27
28use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
29use serde::{Deserialize, Serialize};
30
31use crate::jekt_sign::{decode_public_key_b64, ed25519_sign_b64, ed25519_verify_b64, wan_instance_id, FIELD_SEP};
32
33/// Domain separator for the presence signature, the same in v1 and v2 (`v`
34/// is signed right after it).
35const PRESENCE_DOMAIN: &str = "amx-install-presence-v1";
36/// Between an agent's name, kind and (v2) state in the signed list.
37const AGENT_FIELD_SEP: char = '\u{2}';
38/// Between agents in the signed list.
39const AGENT_SEP: char = '\u{3}';
40
41/// The version the desktop publishes.
42pub const PRESENCE_VERSION: u32 = 2;
43/// The first version: no agent `state`.
44pub const PRESENCE_VERSION_V1: u32 = 1;
45/// The goodbye: `gone: true`, no agent `state`.
46pub const PRESENCE_VERSION_GOODBYE: u32 = 3;
47/// v3 plus each agent's optional `line`.
48pub const PRESENCE_VERSION_LINES: u32 = 4;
49/// Longest agent `line`, in characters (`agent_line::MAX_LINE_CHARS` in srv).
50pub const MAX_PRESENCE_LINE_CHARS: usize = 200;
51/// Most bytes of lines one signed record carries, so a record stays small
52/// enough to send on every change; lines past it are dropped.
53pub const MAX_PRESENCE_LINE_BYTES: usize = 16 * 1024;
54/// The values an agent's `state` may take (the LAN feed's `agent_status`).
55pub const PRESENCE_STATES: [&str; 5] = ["working", "waiting", "idle", "stopped", "error"];
56/// Agents in one record; the rest are left out.
57pub const MAX_PRESENCE_AGENTS: usize = 200;
58/// Longest `hostname`, `channel` and agent `name`, in characters.
59pub const MAX_PRESENCE_NAME_CHARS: usize = 128;
60pub const MAX_PRESENCE_VERSION_CHARS: usize = 64;
61pub const MAX_CHANNELS_RUNNING: u32 = 99;
62const MAX_OS_LEN: usize = 16;
63
64/// One agent of the install. `kind` is `host` or `container` (the agent's
65/// block `agentMode`); `state` (v2 and later) is one of [`PRESENCE_STATES`],
66/// or absent when unknown; `line` (v4 only) is its summary line, or absent.
67#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct PresenceAgent {
69    pub name: String,
70    pub kind: String,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub state: Option<String>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub line: Option<String>,
75}
76
77/// The record, in the contract's field order. Public keys and the signature
78/// are standard base64.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct InstallPresence {
81    pub v: u32,
82    pub instance_id: String,
83    pub instance_public_key: String,
84    pub hostname: String,
85    pub channel: String,
86    pub os: String,
87    pub version: String,
88    pub channels_running: u32,
89    pub agents: Vec<PresenceAgent>,
90    pub published_at_ms: u64,
91    /// v3 only: the install has gone offline. The desktop sends v3 only as
92    /// a goodbye, so always with this set; the relay also takes a v3
93    /// record without it.
94    #[serde(default, skip_serializing_if = "is_false")]
95    pub gone: bool,
96    pub sig: String,
97}
98
99fn is_false(b: &bool) -> bool {
100    !*b
101}
102
103fn has_control_char(s: &str) -> bool {
104    s.chars().any(|c| u32::from(c) < 0x20)
105}
106
107fn text_in_bounds(s: &str, max_chars: usize) -> bool {
108    (1..=max_chars).contains(&s.chars().count()) && !has_control_char(s)
109}
110
111/// Empty, or a plain lowercase token (`host_os`'s rule).
112fn os_in_bounds(os: &str) -> bool {
113    os.is_empty()
114        || (os.len() <= MAX_OS_LEN
115            && os.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'))
116}
117
118fn agent_in_bounds(agent: &PresenceAgent) -> bool {
119    text_in_bounds(&agent.name, MAX_PRESENCE_NAME_CHARS)
120        && (agent.kind == "host" || agent.kind == "container")
121        && agent.state.as_deref().is_none_or(|s| PRESENCE_STATES.contains(&s))
122        && agent.line.as_deref().is_none_or(|l| text_in_bounds(l, MAX_PRESENCE_LINE_CHARS))
123}
124
125/// The agents as a record carries them: sorted by lower-cased name, ties by
126/// name; an agent whose name, kind, state or line is out of bounds (a control
127/// character, too long, an unknown kind or state) left out; each name once;
128/// at most [`MAX_PRESENCE_AGENTS`].
129pub fn canonical_agents(agents: impl IntoIterator<Item = PresenceAgent>) -> Vec<PresenceAgent> {
130    let mut keyed: Vec<(String, PresenceAgent)> = agents
131        .into_iter()
132        .filter(agent_in_bounds)
133        .map(|a| (a.name.to_lowercase(), a))
134        .collect();
135    keyed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.name.cmp(&b.1.name)));
136    keyed.dedup_by(|later, earlier| later.1.name == earlier.1.name);
137    keyed.truncate(MAX_PRESENCE_AGENTS);
138    keyed.into_iter().map(|(_, a)| a).collect()
139}
140
141/// Keeps each agent's line, in order, while the lines kept so far fit in
142/// [`MAX_PRESENCE_LINE_BYTES`]; a line that doesn't fit is dropped.
143fn fit_lines(mut agents: Vec<PresenceAgent>) -> Vec<PresenceAgent> {
144    let mut left = MAX_PRESENCE_LINE_BYTES;
145    for a in &mut agents {
146        match a.line.as_ref().map(String::len) {
147            Some(n) if n <= left => left -= n,
148            Some(_) => a.line = None,
149            None => {}
150        }
151    }
152    agents
153}
154
155impl InstallPresence {
156    /// Build and sign a [`PRESENCE_VERSION`] record with the instance key.
157    /// `agents` may come in any order and is passed through
158    /// [`canonical_agents`]; `channels_running` is clamped to
159    /// 1..=[`MAX_CHANNELS_RUNNING`]. `None` for a malformed key or a
160    /// top-level field out of bounds.
161    #[allow(clippy::too_many_arguments)]
162    pub fn sign(
163        instance_private_key: &[u8],
164        hostname: &str,
165        channel: &str,
166        os: &str,
167        version: &str,
168        channels_running: u32,
169        agents: impl IntoIterator<Item = PresenceAgent>,
170        published_at_ms: u64,
171    ) -> Option<Self> {
172        Self::sign_version(
173            PRESENCE_VERSION,
174            instance_private_key,
175            hostname,
176            channel,
177            os,
178            version,
179            channels_running,
180            agents,
181            published_at_ms,
182        )
183    }
184
185    /// [`Self::sign`] for record version `v` (1 to 4). A v1 record carries
186    /// no states: any given are dropped. A v3 record is the goodbye: `gone`
187    /// is set and the states are dropped too. Lines are dropped below v4,
188    /// and in v4 any that don't fit [`MAX_PRESENCE_LINE_BYTES`], in order.
189    #[allow(clippy::too_many_arguments)]
190    pub fn sign_version(
191        v: u32,
192        instance_private_key: &[u8],
193        hostname: &str,
194        channel: &str,
195        os: &str,
196        version: &str,
197        channels_running: u32,
198        agents: impl IntoIterator<Item = PresenceAgent>,
199        published_at_ms: u64,
200    ) -> Option<Self> {
201        let seed: [u8; 32] = instance_private_key.try_into().ok()?;
202        let public_key = ed25519_dalek::SigningKey::from_bytes(&seed).verifying_key().to_bytes();
203        let gone = v == PRESENCE_VERSION_GOODBYE;
204        let agents = agents.into_iter().map(|mut a| {
205            if v == PRESENCE_VERSION_V1 || gone {
206                a.state = None;
207            }
208            if v < PRESENCE_VERSION_LINES {
209                a.line = None;
210            }
211            a
212        });
213        let mut record = Self {
214            v,
215            instance_id: wan_instance_id(&public_key),
216            instance_public_key: BASE64.encode(public_key),
217            hostname: hostname.to_string(),
218            channel: channel.to_string(),
219            os: os.to_string(),
220            version: version.to_string(),
221            channels_running: channels_running.clamp(1, MAX_CHANNELS_RUNNING),
222            agents: fit_lines(canonical_agents(agents)),
223            published_at_ms,
224            gone,
225            sig: String::new(),
226        };
227        if !record.in_bounds() {
228            return None;
229        }
230        record.sig = ed25519_sign_b64(&seed, &record.signed_material())?;
231        Some(record)
232    }
233
234    /// The UTF-8 string the signature covers: the fields joined by U+0001,
235    /// numbers in decimal, the agents joined by U+0003 (empty when there are
236    /// none), in the record's order. In v1 an agent is `name` U+0002 `kind`;
237    /// in v2 and v3 `name` U+0002 `kind` U+0002 `state`, `state` empty when
238    /// absent; v4 adds U+0002 `line`, empty when absent. v3 and v4 add one
239    /// more field at the end: U+0001 then `1` when `gone`, nothing otherwise.
240    pub fn signed_material(&self) -> String {
241        let agents = self
242            .agents
243            .iter()
244            .map(|a| {
245                let state = a.state.as_deref().unwrap_or("");
246                match self.v {
247                    PRESENCE_VERSION_V1 => format!("{}{AGENT_FIELD_SEP}{}", a.name, a.kind),
248                    PRESENCE_VERSION_LINES => {
249                        let line = a.line.as_deref().unwrap_or("");
250                        format!("{}{AGENT_FIELD_SEP}{}{AGENT_FIELD_SEP}{state}{AGENT_FIELD_SEP}{line}", a.name, a.kind)
251                    }
252                    _ => format!("{}{AGENT_FIELD_SEP}{}{AGENT_FIELD_SEP}{state}", a.name, a.kind),
253                }
254            })
255            .collect::<Vec<_>>()
256            .join(&AGENT_SEP.to_string());
257        let gone = match (self.v >= PRESENCE_VERSION_GOODBYE, self.gone) {
258            (true, true) => format!("{FIELD_SEP}1"),
259            (true, false) => FIELD_SEP.to_string(),
260            (false, _) => String::new(),
261        };
262        format!(
263            "{PRESENCE_DOMAIN}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}{}{FIELD_SEP}\
264             {}{FIELD_SEP}{}{FIELD_SEP}{agents}{FIELD_SEP}{}{gone}",
265            self.v,
266            self.instance_id,
267            self.hostname,
268            self.channel,
269            self.os,
270            self.version,
271            self.channels_running,
272            self.published_at_ms
273        )
274    }
275
276    /// The contract's bounds, on every field the signature covers. The
277    /// agents must already be in canonical order: the order is signed. A v1
278    /// record may not carry a state, nor one below v4 a line, which their
279    /// signatures would not cover. Only v3 and v4 may be `gone`, and a `gone`
280    /// record carries no state or line.
281    pub fn in_bounds(&self) -> bool {
282        let stateless = || self.agents.iter().all(|a| a.state.is_none());
283        let lineless = || self.agents.iter().all(|a| a.line.is_none());
284        let version_ok = match self.v {
285            PRESENCE_VERSION_V1 => !self.gone && stateless() && lineless(),
286            PRESENCE_VERSION => !self.gone && lineless(),
287            PRESENCE_VERSION_GOODBYE => lineless() && (!self.gone || stateless()),
288            PRESENCE_VERSION_LINES => !self.gone || (stateless() && lineless()),
289            _ => false,
290        };
291        version_ok
292            && text_in_bounds(&self.hostname, MAX_PRESENCE_NAME_CHARS)
293            && text_in_bounds(&self.channel, MAX_PRESENCE_NAME_CHARS)
294            && text_in_bounds(&self.version, MAX_PRESENCE_VERSION_CHARS)
295            && os_in_bounds(&self.os)
296            && (1..=MAX_CHANNELS_RUNNING).contains(&self.channels_running)
297            && self.agents.len() <= MAX_PRESENCE_AGENTS
298            && self.agents == canonical_agents(self.agents.iter().cloned())
299    }
300
301    /// In bounds, `instance_id` is the hash of `instance_public_key`, and
302    /// that key signed the record. Never panics on malformed input.
303    pub fn verify(&self) -> bool {
304        let Some(public_key) = decode_public_key_b64(&self.instance_public_key) else { return false };
305        self.in_bounds()
306            && wan_instance_id(&public_key) == self.instance_id
307            && ed25519_verify_b64(&public_key, &self.signed_material(), &self.sig)
308    }
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    fn agent(name: &str, kind: &str) -> PresenceAgent {
316        PresenceAgent { name: name.to_string(), kind: kind.to_string(), state: None, line: None }
317    }
318
319    fn agent_in(name: &str, kind: &str, state: &str) -> PresenceAgent {
320        PresenceAgent { state: Some(state.to_string()), ..agent(name, kind) }
321    }
322
323    fn hex(bytes: &[u8]) -> String {
324        bytes.iter().map(|b| format!("{b:02x}")).collect()
325    }
326
327    // The shared vector: the relay's TypeScript suite asserts the same seed,
328    // record, material and signature.
329    const SEED_HEX: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
330    const MATERIAL_HEX: &str = "\
331        616d782d696e7374616c6c2d70726573656e63652d76310131016d7733616d3436773577656578346134667172633361766e\
332        7561016e61726b6f01737461626c650177696e646f777301302e35392e31310133016167656e747802636f6e7461696e6572\
333        034167656e745902686f73740343616d70657202686f73740131373931333532343933333838";
334    const SIG: &str = "7FxOua+TWFMp3fwOP/mGr+YXjgLfKESklzZ3odiOu5WH5QrstJlk4JSV5WrkMo9rnywbYWdFhRZ7KySij/GNAg==";
335
336    fn seed() -> [u8; 32] {
337        let mut out = [0u8; 32];
338        for (i, b) in out.iter_mut().enumerate() {
339            *b = u8::from_str_radix(&SEED_HEX[i * 2..i * 2 + 2], 16).unwrap();
340        }
341        out
342    }
343
344    fn vector_record() -> InstallPresence {
345        InstallPresence::sign_version(
346            PRESENCE_VERSION_V1,
347            &seed(),
348            "narko",
349            "stable",
350            "windows",
351            "0.59.11",
352            3,
353            // Any input order: the record sorts.
354            [agent("Camper", "host"), agent("agentx", "container"), agent("AgentY", "host")],
355            1_791_352_493_388,
356        )
357        .expect("the vector is in bounds")
358    }
359
360    #[test]
361    fn the_shared_vector_signs_byte_for_byte() {
362        let record = vector_record();
363        assert_eq!(hex(record.signed_material().as_bytes()), MATERIAL_HEX);
364        assert_eq!(record.sig, SIG);
365        assert_eq!(
366            serde_json::to_value(&record).unwrap(),
367            serde_json::json!({
368                "v": 1,
369                "instance_id": "mw3am46w5weex4a4fqrc3avnua",
370                "instance_public_key": "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=",
371                "hostname": "narko",
372                "channel": "stable",
373                "os": "windows",
374                "version": "0.59.11",
375                "channels_running": 3,
376                "agents": [
377                    {"name": "agentx", "kind": "container"},
378                    {"name": "AgentY", "kind": "host"},
379                    {"name": "Camper", "kind": "host"},
380                ],
381                "published_at_ms": 1_791_352_493_388u64,
382                "sig": SIG,
383            })
384        );
385        assert!(record.verify());
386        // The JSON parses back to the same record, which still verifies.
387        let back: InstallPresence = serde_json::from_str(&serde_json::to_string(&record).unwrap()).unwrap();
388        assert!(back.verify());
389    }
390
391    // The v2 vector: the same install, two agents with a state and one
392    // without. Shared with the relay's suite like the v1 vector.
393    const MATERIAL_V2_HEX: &str = "\
394        616d782d696e7374616c6c2d70726573656e63652d76310132016d7733616d3436773577656578346134667172633361766e\
395        7561016e61726b6f01737461626c650177696e646f777301302e35392e31310133016167656e747802636f6e7461696e6572\
396        02776f726b696e67034167656e745902686f7374020343616d70657202686f73740269646c65013137393133353234393333\
397        3838";
398    const SIG_V2: &str = "ELDWTLT+BEoxMn/o/AsYYVbGLgIAbJjDPuQ6/AbAZ8fk+jqphiEtEn/c/5ZKjHJZdQF+hxIKzmnq6gLZzmWDDw==";
399
400    fn vector_v2_record() -> InstallPresence {
401        InstallPresence::sign(
402            &seed(),
403            "narko",
404            "stable",
405            "windows",
406            "0.59.11",
407            3,
408            [agent_in("Camper", "host", "idle"), agent_in("agentx", "container", "working"), agent("AgentY", "host")],
409            1_791_352_493_388,
410        )
411        .expect("the vector is in bounds")
412    }
413
414    #[test]
415    fn the_v2_shared_vector_signs_byte_for_byte() {
416        let record = vector_v2_record();
417        assert_eq!(record.v, 2);
418        assert_eq!(hex(record.signed_material().as_bytes()), MATERIAL_V2_HEX);
419        assert_eq!(record.sig, SIG_V2);
420        assert_eq!(
421            serde_json::to_value(&record).unwrap(),
422            serde_json::json!({
423                "v": 2,
424                "instance_id": "mw3am46w5weex4a4fqrc3avnua",
425                "instance_public_key": "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=",
426                "hostname": "narko",
427                "channel": "stable",
428                "os": "windows",
429                "version": "0.59.11",
430                "channels_running": 3,
431                "agents": [
432                    {"name": "agentx", "kind": "container", "state": "working"},
433                    {"name": "AgentY", "kind": "host"},
434                    {"name": "Camper", "kind": "host", "state": "idle"},
435                ],
436                "published_at_ms": 1_791_352_493_388u64,
437                "sig": SIG_V2,
438            })
439        );
440        assert!(record.verify());
441        let back: InstallPresence = serde_json::from_str(&serde_json::to_string(&record).unwrap()).unwrap();
442        assert_eq!(back, record);
443        assert!(back.verify());
444    }
445
446    // The v3 (goodbye) vector: the v2 vector's install and agents, as a
447    // goodbye. Shared with the relay's suite like the others.
448    const MATERIAL_V3_HEX: &str = "\
449        616d782d696e7374616c6c2d70726573656e63652d76310133016d7733616d3436773577656578346134667172633361766e\
450        7561016e61726b6f01737461626c650177696e646f777301302e35392e31310133016167656e747802636f6e7461696e6572\
451        02034167656e745902686f7374020343616d70657202686f73740201313739313335323439333338380131";
452    const SIG_V3: &str = "CihvXkUTZu79ykAvxqgN2WOkOw5t+FsYJ5YRF3I94MiHZCsi301grrVNLErajob9g+AEd2J2e2Kt1ulOR5KpCQ==";
453
454    fn vector_v3_record() -> InstallPresence {
455        InstallPresence::sign_version(
456            PRESENCE_VERSION_GOODBYE,
457            &seed(),
458            "narko",
459            "stable",
460            "windows",
461            "0.59.11",
462            3,
463            [agent_in("Camper", "host", "idle"), agent_in("agentx", "container", "working"), agent("AgentY", "host")],
464            1_791_352_493_388,
465        )
466        .expect("the vector is in bounds")
467    }
468
469    #[test]
470    fn the_v3_goodbye_vector_signs_byte_for_byte() {
471        let record = vector_v3_record();
472        assert_eq!(hex(record.signed_material().as_bytes()), MATERIAL_V3_HEX);
473        assert_eq!(record.sig, SIG_V3);
474        assert_eq!(
475            serde_json::to_value(&record).unwrap(),
476            serde_json::json!({
477                "v": 3,
478                "instance_id": "mw3am46w5weex4a4fqrc3avnua",
479                "instance_public_key": "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=",
480                "hostname": "narko",
481                "channel": "stable",
482                "os": "windows",
483                "version": "0.59.11",
484                "channels_running": 3,
485                "agents": [
486                    {"name": "agentx", "kind": "container"},
487                    {"name": "AgentY", "kind": "host"},
488                    {"name": "Camper", "kind": "host"},
489                ],
490                "published_at_ms": 1_791_352_493_388u64,
491                "gone": true,
492                "sig": SIG_V3,
493            }),
494            "every state absent, gone set"
495        );
496        assert!(record.verify());
497        let back: InstallPresence = serde_json::from_str(&serde_json::to_string(&record).unwrap()).unwrap();
498        assert_eq!(back, record);
499        assert!(back.verify());
500        // v2's layout, states empty, then the flag: 11 fields.
501        let material = record.signed_material();
502        assert!(material.starts_with(&format!("{PRESENCE_DOMAIN}{FIELD_SEP}3{FIELD_SEP}")), "{material:?}");
503        assert!(material.ends_with(&format!("1791352493388{FIELD_SEP}1")), "{material:?}");
504        assert_eq!(material.split(FIELD_SEP).count(), 11);
505    }
506
507    // The same v3 record without `gone`: the last field is empty. Shared
508    // with the relay's suite; the desktop never sends one.
509    const SIG_V3_NOT_GONE: &str =
510        "/CV2c+4Cc7FJvrbhbzh3fhUnSS6388P/mY2fy8m3FDCrgcauiLrssFCkZh6Y5LemxpbP+UDdRi04Pu4iwEUiCw==";
511
512    #[test]
513    fn a_v3_record_without_gone_signs_an_empty_last_field() {
514        let mut record = vector_v3_record();
515        record.gone = false;
516        let material = record.signed_material();
517        assert!(material.ends_with(&format!("1791352493388{FIELD_SEP}")), "{material:?}");
518        assert_eq!(material.split(FIELD_SEP).count(), 11);
519        assert!(!record.verify(), "the goodbye's signature covers gone");
520        record.sig = ed25519_sign_b64(&seed(), &material).unwrap();
521        assert_eq!(record.sig, SIG_V3_NOT_GONE);
522        assert!(record.verify());
523        let wire = serde_json::to_value(&record).unwrap();
524        assert!(wire.get("gone").is_none(), "{wire}");
525        let back: InstallPresence = serde_json::from_value(wire).unwrap();
526        assert!(back.verify());
527        // Without gone, a v3 record may carry states, like v2.
528        let mut stateful = record.clone();
529        stateful.agents[0].state = Some("working".into());
530        assert!(stateful.in_bounds());
531    }
532
533    #[test]
534    fn v1_and_v2_keep_ten_fields() {
535        assert_eq!(vector_record().signed_material().split(FIELD_SEP).count(), 10);
536        assert_eq!(vector_v2_record().signed_material().split(FIELD_SEP).count(), 10);
537    }
538
539    #[test]
540    fn gone_is_signed_and_only_a_v3_record_may_say_it() {
541        let goodbye = vector_v3_record();
542        let tampered: Vec<(&str, Box<dyn Fn(&mut InstallPresence)>)> = vec![
543            ("gone cleared", Box::new(|r| r.gone = false)),
544            ("a state added", Box::new(|r| r.agents[0].state = Some("working".into()))),
545            ("version lowered to 2", Box::new(|r| r.v = 2)),
546        ];
547        for (what, change) in tampered {
548            let mut r = goodbye.clone();
549            change(&mut r);
550            assert!(!r.verify(), "{what} but the goodbye still verifies");
551        }
552        // A live record that claims to be gone is out of bounds.
553        for mut live in [vector_record(), vector_v2_record()] {
554            live.gone = true;
555            assert!(!live.in_bounds() && !live.verify(), "v{} with gone", live.v);
556        }
557        // `gone` is on the wire only in v3; a v1 or v2 record reads it as false.
558        let v2 = serde_json::to_value(vector_v2_record()).unwrap();
559        assert!(v2.get("gone").is_none(), "{v2}");
560        let back: InstallPresence = serde_json::from_value(v2).unwrap();
561        assert!(!back.gone && back.verify());
562    }
563
564    #[test]
565    fn a_changed_added_or_removed_state_fails_verification() {
566        let good = vector_v2_record();
567        let tampered: Vec<(&str, Box<dyn Fn(&mut InstallPresence)>)> = vec![
568            ("state changed", Box::new(|r| r.agents[0].state = Some("idle".into()))),
569            ("state added", Box::new(|r| r.agents[1].state = Some("working".into()))),
570            ("state removed", Box::new(|r| r.agents[2].state = None)),
571            ("version lowered", Box::new(|r| r.v = 1)),
572            ("unknown version", Box::new(|r| r.v = 3)),
573        ];
574        for (what, change) in tampered {
575            let mut r = good.clone();
576            change(&mut r);
577            assert!(!r.verify(), "{what} but the record still verifies");
578        }
579    }
580
581    #[test]
582    fn a_v1_record_carries_no_state() {
583        let r = InstallPresence::sign_version(
584            PRESENCE_VERSION_V1,
585            &seed(),
586            "narko",
587            "stable",
588            "windows",
589            "1",
590            1,
591            [agent_in("AgentY", "host", "working")],
592            1,
593        )
594        .unwrap();
595        assert_eq!(r.agents, vec![agent("AgentY", "host")], "the state is dropped, not signed");
596        assert!(r.verify());
597        let mut smuggled = r.clone();
598        smuggled.agents[0].state = Some("working".into());
599        assert!(!smuggled.verify(), "a v1 signature does not cover a state");
600    }
601
602    #[test]
603    fn an_unknown_state_leaves_the_agent_out() {
604        assert_eq!(
605            canonical_agents([agent_in("a", "host", "busy"), agent_in("b", "host", "idle")]),
606            vec![agent_in("b", "host", "idle")]
607        );
608    }
609
610    #[test]
611    fn a_change_to_any_signed_field_fails_verification() {
612        let good = vector_record();
613        let tampered: Vec<(&str, Box<dyn Fn(&mut InstallPresence)>)> = vec![
614            ("hostname", Box::new(|r| r.hostname = "area54".into())),
615            ("channel", Box::new(|r| r.channel = "dev".into())),
616            ("os", Box::new(|r| r.os = "linux".into())),
617            ("version", Box::new(|r| r.version = "0.59.12".into())),
618            ("channels_running", Box::new(|r| r.channels_running = 4)),
619            ("published_at_ms", Box::new(|r| r.published_at_ms += 1)),
620            ("agent kind", Box::new(|r| r.agents[0].kind = "host".into())),
621            ("agent dropped", Box::new(|r| {
622                r.agents.pop();
623            })),
624            ("instance_id", Box::new(|r| r.instance_id = "a".repeat(26))),
625            ("sig", Box::new(|r| r.sig = BASE64.encode([0u8; 64]))),
626        ];
627        for (what, change) in tampered {
628            let mut r = good.clone();
629            change(&mut r);
630            assert!(!r.verify(), "{what} changed but the record still verifies");
631        }
632    }
633
634    #[test]
635    fn another_key_cannot_claim_the_instance() {
636        let mut r = vector_record();
637        let other = InstallPresence::sign(&[9u8; 32], "narko", "stable", "windows", "0.59.11", 3, [], 1).unwrap();
638        r.instance_public_key = other.instance_public_key;
639        assert!(!r.verify(), "the id no longer names the key");
640    }
641
642    #[test]
643    fn no_agents_signs_an_empty_list_field() {
644        let r = InstallPresence::sign(&seed(), "narko", "stable", "", "0.59.11", 1, [], 5).unwrap();
645        assert!(r.agents.is_empty());
646        assert!(r.signed_material().contains(&format!("{FIELD_SEP}{FIELD_SEP}5")));
647        assert!(r.verify());
648    }
649
650    #[test]
651    fn agents_out_of_bounds_are_left_out_and_the_list_is_capped() {
652        let agents = canonical_agents([
653            agent("ok", "host"),
654            agent("bad\u{1}name", "host"),
655            agent("tab\tname", "host"),
656            agent("", "host"),
657            agent(&"x".repeat(MAX_PRESENCE_NAME_CHARS + 1), "host"),
658            agent("weird", "vm"),
659            agent("ok", "container"),
660        ]);
661        assert_eq!(agents, vec![agent("ok", "host")], "each name once, the first kept");
662
663        let many: Vec<_> = (0..MAX_PRESENCE_AGENTS + 10).map(|i| agent(&format!("a{i:03}"), "host")).collect();
664        let r = InstallPresence::sign(&seed(), "narko", "stable", "windows", "1", 1, many, 1).unwrap();
665        assert_eq!(r.agents.len(), MAX_PRESENCE_AGENTS);
666        assert!(r.verify());
667    }
668
669    #[test]
670    fn a_top_level_field_out_of_bounds_is_not_signed() {
671        let sign = |hostname: &str, channel: &str, os: &str, version: &str| {
672            InstallPresence::sign(&seed(), hostname, channel, os, version, 1, [], 1)
673        };
674        assert!(sign("", "stable", "windows", "1").is_none());
675        assert!(sign("nar\nko", "stable", "windows", "1").is_none());
676        assert!(sign(&"h".repeat(MAX_PRESENCE_NAME_CHARS + 1), "stable", "windows", "1").is_none());
677        assert!(sign("narko", "", "windows", "1").is_none());
678        assert!(sign("narko", "stable", "Windows", "1").is_none());
679        assert!(sign("narko", "stable", "windows", "").is_none());
680        assert!(sign("narko", "stable", "windows", &"9".repeat(MAX_PRESENCE_VERSION_CHARS + 1)).is_none());
681        assert!(InstallPresence::sign(&[1u8; 31], "narko", "stable", "windows", "1", 1, [], 1).is_none());
682    }
683
684    #[test]
685    fn channels_running_is_clamped() {
686        let at = |n| InstallPresence::sign(&seed(), "narko", "stable", "windows", "1", n, [], 1).unwrap().channels_running;
687        assert_eq!(at(0), 1);
688        assert_eq!(at(3), 3);
689        assert_eq!(at(500), MAX_CHANNELS_RUNNING);
690    }
691
692    #[test]
693    fn agents_out_of_canonical_order_do_not_verify() {
694        let mut r = vector_record();
695        r.agents.reverse();
696        assert!(!r.verify());
697    }
698
699    // The v4 vector: the v2 vector's install and agents, two with a line (one
700    // not ASCII), one without. Shared with the relay's suite like the others.
701    const MATERIAL_V4_HEX: &str = "\
702        616d782d696e7374616c6c2d70726573656e63652d76310134016d7733616d3436773577656578346134667172633361766e\
703        7561016e61726b6f01737461626c650177696e646f777301302e35392e31310133016167656e747802636f6e7461696e6572\
704        02776f726b696e6702466978696e6720746865206c6f67696e2072616365034167656e745902686f737402020343616d7065\
705        7202686f73740269646c6502526576696577696e672074686520636166c3a9206d656e750131373931333532343933333838\
706        01";
707    const SIG_V4: &str = "uQEoRZnWxRgCTX/hCtx8zCwqtrsG7DleLCnBjWs7zGWOY2fcTtIgOavDJQSyPe7MH+MlntCG5hGMie17k+FVCA==";
708
709    fn with_line(a: PresenceAgent, line: &str) -> PresenceAgent {
710        PresenceAgent { line: Some(line.to_string()), ..a }
711    }
712
713    fn vector_v4_record() -> InstallPresence {
714        InstallPresence::sign_version(
715            PRESENCE_VERSION_LINES,
716            &seed(),
717            "narko",
718            "stable",
719            "windows",
720            "0.59.11",
721            3,
722            [
723                with_line(agent_in("Camper", "host", "idle"), "Reviewing the café menu"),
724                with_line(agent_in("agentx", "container", "working"), "Fixing the login race"),
725                agent("AgentY", "host"),
726            ],
727            1_791_352_493_388,
728        )
729        .expect("the vector is in bounds")
730    }
731
732    #[test]
733    fn the_v4_vector_signs_byte_for_byte() {
734        let record = vector_v4_record();
735        assert_eq!(hex(record.signed_material().as_bytes()), MATERIAL_V4_HEX);
736        assert_eq!(record.sig, SIG_V4);
737        assert_eq!(
738            serde_json::to_value(&record).unwrap(),
739            serde_json::json!({
740                "v": 4,
741                "instance_id": "mw3am46w5weex4a4fqrc3avnua",
742                "instance_public_key": "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=",
743                "hostname": "narko",
744                "channel": "stable",
745                "os": "windows",
746                "version": "0.59.11",
747                "channels_running": 3,
748                "agents": [
749                    {"name": "agentx", "kind": "container", "state": "working", "line": "Fixing the login race"},
750                    {"name": "AgentY", "kind": "host"},
751                    {"name": "Camper", "kind": "host", "state": "idle", "line": "Reviewing the café menu"},
752                ],
753                "published_at_ms": 1_791_352_493_388u64,
754                "sig": SIG_V4,
755            })
756        );
757        assert!(record.verify());
758        let back: InstallPresence = serde_json::from_str(&serde_json::to_string(&record).unwrap()).unwrap();
759        assert_eq!(back, record);
760        assert!(back.verify());
761    }
762
763    #[test]
764    fn a_line_needs_v4_and_a_live_record() {
765        let lined = || with_line(agent("AgentY", "host"), "Building");
766        for v in [PRESENCE_VERSION_V1, PRESENCE_VERSION, PRESENCE_VERSION_GOODBYE] {
767            let r = InstallPresence::sign_version(v, &seed(), "narko", "stable", "", "1", 1, [lined()], 1).unwrap();
768            assert_eq!(r.agents[0].line, None, "v{v} drops the line");
769            let mut forged = r.clone();
770            forged.agents[0].line = Some("Building".into());
771            assert!(!forged.in_bounds(), "v{v} can't carry a line its signature doesn't cover");
772        }
773        let mut goodbye = vector_v4_record();
774        goodbye.gone = true;
775        assert!(!goodbye.in_bounds(), "a v4 goodbye carries no line or state");
776    }
777
778    #[test]
779    fn an_agent_with_an_out_of_bounds_line_is_left_out() {
780        let ok = with_line(agent("Ok", "host"), "fine");
781        let control = with_line(agent("Ctl", "host"), "two
782lines");
783        let long = with_line(agent("Long", "host"), &"x".repeat(MAX_PRESENCE_LINE_CHARS + 1));
784        let empty = with_line(agent("Empty", "host"), "");
785        let names: Vec<String> = canonical_agents([ok, control, long, empty]).into_iter().map(|a| a.name).collect();
786        assert_eq!(names, ["Ok"]);
787    }
788
789    #[test]
790    fn lines_past_the_byte_budget_are_dropped_in_order() {
791        let line = "x".repeat(MAX_PRESENCE_LINE_CHARS);
792        let n = MAX_PRESENCE_LINE_BYTES / line.len() + 3;
793        let agents = (0..n).map(|i| with_line(agent(&format!("a{i:03}"), "host"), &line));
794        let r = InstallPresence::sign_version(PRESENCE_VERSION_LINES, &seed(), "narko", "stable", "", "1", 1, agents, 1).unwrap();
795        let kept = r.agents.iter().filter(|a| a.line.is_some()).count();
796        assert_eq!(kept, MAX_PRESENCE_LINE_BYTES / line.len());
797        assert!(r.agents[..kept].iter().all(|a| a.line.is_some()), "the first ones keep theirs");
798        assert_eq!(r.agents.len(), n, "every agent is still listed");
799        assert!(r.verify());
800        assert!(serde_json::to_string(&r).unwrap().len() < 24 * 1024, "the lines are most of it");
801    }
802}