Skip to main content

ljos_cli/
ballot_sig.rs

1//! Signed seat ballots. A seat signs each ballot it casts with its own
2//! ed25519 key, made on its first vote at
3//! `$XDG_DATA_HOME/ljos/seats/SEAT/ballot.key` (or `LJOS_SEAT_KEY`), so
4//! seats sharing one HOME still sign apart. The signature rides on the
5//! issue as a `ballot-sig:` note. A ballot the tracker held before its
6//! first pin, by commit order ([`recorded_before_signing`]), is legacy
7//! unsigned and still counts. The tracker pins each seat's public key the
8//! first time it signs (`.ljos-seat-keys` at the tracker root, committed),
9//! so a later ballot under that name signed by another key is a mismatch. The push gate counts a seat ballot only
10//! when its latest signature covers exactly that voter, issue and choice
11//! under the pinned key.
12//!
13//! A ballot carries the harness session and a marker (`ljos-ballot/v2`):
14//! `-`, or [`SEAT_NONCE`] when only the seat named the session. The
15//! session is the harness id, else `VISSUE_AGENT`, else `LJOS_SEAT`, else
16//! the MCP connection. It is never a fresh nonce of this process.
17//! Minting another seat inside the same session does not make a second voter.
18
19use anyhow::{bail, Context, Result};
20use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
21use serde_json::Value;
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24
25/// The note prefix a signature rides under.
26pub const SIG_NOTE: &str = "ballot-sig: ";
27/// The note prefix a withdrawn ballot rides under.
28pub const WITHDRAWN_NOTE: &str = "ballot-withdrawn: ";
29/// The pin file at the tracker root.
30pub const PIN_FILE: &str = ".ljos-seat-keys";
31
32/// The bytes a ballot signature covers. v1 is the choice alone. v2 also
33/// covers the harness session and its marker, so a later edit of either
34/// fails the check.
35#[must_use]
36pub fn message(issue: &str, voter: &str, choice: &str) -> Vec<u8> {
37    format!(
38        "ljos-ballot/v1\n{}\n{}\n{}\n",
39        issue.trim(),
40        voter.trim(),
41        choice.trim()
42    )
43    .into_bytes()
44}
45
46/// [`message`] plus the session binding.
47#[must_use]
48pub fn message_v2(issue: &str, voter: &str, choice: &str, harness: &str, nonce: &str) -> Vec<u8> {
49    format!(
50        "ljos-ballot/v2\n{}\n{}\n{}\n{}\n{}\n",
51        issue.trim(),
52        voter.trim(),
53        choice.trim(),
54        harness.trim(),
55        nonce.trim()
56    )
57    .into_bytes()
58}
59
60/// A signature note's fields. `harness` and `nonce` are set when the note
61/// is a v2 binding.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct SigNote {
64    pub voter: String,
65    pub choice: String,
66    pub key: String,
67    pub sig: String,
68    pub harness: Option<String>,
69    pub nonce: Option<String>,
70}
71
72impl SigNote {
73    /// Whether only the seat named the session this ballot is bound to.
74    #[must_use]
75    pub fn seat_only(&self) -> bool {
76        self.nonce.as_deref() == Some(SEAT_NONCE)
77    }
78}
79
80/// `ballot-sig: VOTER CHOICE KEYHEX SIGHEX`, or the same plus
81/// `HARNESS NONCE` when the ballot is bound to a session. The choice may
82/// hold spaces (`beside binary`): the key and signature are found by their
83/// hex lengths, counted from the end, and the choice is the text between
84/// the voter and the key. A note whose key is not 64 hex digits parses only
85/// in the four- or six-field shape.
86#[must_use]
87pub fn parse_note(note: &str) -> Option<SigNote> {
88    let rest = note.trim().strip_prefix(SIG_NOTE)?;
89    let at = spans(rest);
90    let n = at.len();
91    if n < 4 {
92        return None;
93    }
94    let tok = |i: usize| &rest[at[i].0..at[i].1];
95    let key_at = |i: usize| is_hex_len(tok(i), 64) && is_hex_len(tok(i + 1), 128);
96    // Where the key sits, and whether a session follows the signature.
97    let (k, bound) = if n >= 6 && key_at(n - 4) {
98        (n - 4, true)
99    } else if key_at(n - 2) {
100        (n - 2, false)
101    } else if n == 6 {
102        (2, true)
103    } else if n == 4 {
104        (2, false)
105    } else {
106        return None;
107    };
108    let choice = rest[at[1].0..at[k - 1].1].trim();
109    Some(SigNote {
110        voter: tok(0).to_string(),
111        choice: choice.to_string(),
112        key: tok(k).to_string(),
113        sig: tok(k + 1).to_string(),
114        harness: bound.then(|| tok(k + 2).to_string()),
115        nonce: bound.then(|| tok(k + 3).to_string()),
116    })
117}
118
119/// The session a ballot note binds, as [`crate::panel_gate::session_key`]
120/// reads it. A note that does not parse but has six or more fields still
121/// names its session in the last two. The cast gate does not use that
122/// fallback: it requires [`parse_note`] and [`verifies`] for the issue
123/// being cast on. `None` when the note is not a ballot signature.
124#[must_use]
125pub fn note_session(note: &str) -> Option<(String, String)> {
126    if let Some(sig) = parse_note(note) {
127        return Some((
128            sig.harness.unwrap_or_default(),
129            sig.nonce.unwrap_or_default(),
130        ));
131    }
132    let rest = note.trim().strip_prefix(SIG_NOTE)?;
133    let at = spans(rest);
134    let n = at.len();
135    if n < 6 {
136        return None;
137    }
138    let tok = |i: usize| rest[at[i].0..at[i].1].to_string();
139    Some((tok(n - 2), tok(n - 1)))
140}
141
142/// A withdrawn ballot: `ballot-withdrawn: VOTER HARNESS NONCE`.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct WithdrawnNote {
145    pub voter: String,
146    pub harness: String,
147    pub nonce: String,
148    pub issue: Option<String>,
149}
150
151/// One token per field, so the note stays three words.
152#[must_use]
153pub fn withdrawn_line(voter: &str, harness: &str, nonce: &str) -> String {
154    withdrawn_line_on(voter, harness, nonce, None)
155}
156
157/// A withdrawn ballot that names the issue it applies to:
158/// `ballot-withdrawn: VOTER HARNESS NONCE ISSUE`.
159#[must_use]
160pub fn withdrawn_line_on(voter: &str, harness: &str, nonce: &str, issue: Option<&str>) -> String {
161    let issue = issue.map(field).filter(|id| id != "-");
162    if let Some(issue) = issue {
163        return format!(
164            "{WITHDRAWN_NOTE}{} {} {} {}",
165            field(voter),
166            field(harness),
167            field(nonce),
168            issue
169        );
170    }
171    format!(
172        "{WITHDRAWN_NOTE}{} {} {}",
173        field(voter),
174        field(harness),
175        field(nonce)
176    )
177}
178
179/// `ballot-withdrawn: VOTER HARNESS NONCE` (legacy), or
180/// `ballot-withdrawn: VOTER HARNESS NONCE ISSUE`.
181#[must_use]
182pub fn parse_withdrawn(note: &str) -> Option<WithdrawnNote> {
183    let rest = note.trim().strip_prefix(WITHDRAWN_NOTE)?;
184    let parts: Vec<&str> = rest.split_whitespace().collect();
185    let (voter, harness, nonce, issue) = match parts.as_slice() {
186        [voter, harness, nonce] => (*voter, *harness, *nonce, None),
187        [voter, harness, nonce, issue] => (*voter, *harness, *nonce, Some((*issue).to_string())),
188        _ => return None,
189    };
190    Some(WithdrawnNote {
191        voter: voter.to_string(),
192        harness: harness.to_string(),
193        nonce: nonce.to_string(),
194        issue,
195    })
196}
197
198/// Byte ranges of the whitespace-separated fields of `text`.
199fn spans(text: &str) -> Vec<(usize, usize)> {
200    let mut out = Vec::new();
201    let mut start = None;
202    for (i, c) in text.char_indices() {
203        if c.is_whitespace() {
204            if let Some(b) = start.take() {
205                out.push((b, i));
206            }
207        } else if start.is_none() {
208            start = Some(i);
209        }
210    }
211    if let Some(b) = start {
212        out.push((b, text.len()));
213    }
214    out
215}
216
217pub(crate) fn is_hex_len(text: &str, len: usize) -> bool {
218    text.len() == len && text.bytes().all(|b| b.is_ascii_hexdigit())
219}
220
221/// Whether `sig_hex` is an ed25519 signature by `key_hex` over `message`.
222///
223/// The check is strict: a small-order key or `R` never verifies. The
224/// plain equation holds for the identity key, `R` the identity and
225/// `s = 0` over every message.
226#[must_use]
227pub fn signature_ok(key_hex: &str, sig_hex: &str, message: &[u8]) -> bool {
228    let (Ok(key), Ok(sig)) = (hex::decode(key_hex), hex::decode(sig_hex)) else {
229        return false;
230    };
231    let (Ok(key), Ok(sig)) = (<[u8; 32]>::try_from(key), <[u8; 64]>::try_from(sig)) else {
232        return false;
233    };
234    let Ok(verifying) = VerifyingKey::from_bytes(&key) else {
235        return false;
236    };
237    verifying
238        .verify_strict(message, &Signature::from_bytes(&sig))
239        .is_ok()
240}
241
242/// Whether `key_hex` is an ed25519 point of small order.
243#[must_use]
244pub fn weak_key(key_hex: &str) -> bool {
245    let Ok(Ok(key)) = hex::decode(key_hex).map(<[u8; 32]>::try_from) else {
246        return false;
247    };
248    VerifyingKey::from_bytes(&key).is_ok_and(|key| key.is_weak())
249}
250
251/// One name per signing key, for counting keys: the key with its
252/// small-order part cleared, in lower-case hex. Strict verification
253/// refuses a small-order key but not a key with a small-order part, so
254/// one secret signs under eight public keys that differ only there, and
255/// under either case of hex digit. A string that is not a key is its hex
256/// in lower case.
257#[must_use]
258pub fn key_fingerprint(key_hex: &str) -> String {
259    let key = hex::decode(key_hex)
260        .ok()
261        .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
262        .and_then(|bytes| VerifyingKey::from_bytes(&bytes).ok());
263    match key {
264        Some(key) => hex::encode(key.to_edwards().mul_by_cofactor().compress().to_bytes()),
265        None => key_hex.to_ascii_lowercase(),
266    }
267}
268
269/// Public key and signature, both lower-case hex, for `message` under `key`.
270#[must_use]
271pub fn signed_hex(key: &SigningKey, message: &[u8]) -> (String, String) {
272    (
273        hex::encode(key.verifying_key().to_bytes()),
274        hex::encode(key.sign(message).to_bytes()),
275    )
276}
277
278/// Remember `key` as `voter`'s pin, or refuse a different key.
279///
280/// # Errors
281///
282/// `voter` is already pinned to another key.
283pub fn observe_pin(pins: &mut BTreeMap<String, String>, voter: &str, key: &str) -> Result<()> {
284    let fingerprint = key_fingerprint(key);
285    match pins.get(voter) {
286        Some(pinned) if key_fingerprint(pinned) != fingerprint => bail!(
287            "{voter} is pinned to key {}…, got {}…",
288            key_prefix(pinned),
289            key_prefix(key)
290        ),
291        Some(_) => Ok(()),
292        None => {
293            pins.insert(voter.to_string(), key.to_string());
294            Ok(())
295        }
296    }
297}
298
299fn key_prefix(key: &str) -> &str {
300    &key[..12.min(key.len())]
301}
302
303pub(crate) fn key_from_file(path: &std::path::Path) -> Result<SigningKey> {
304    let raw = std::fs::read(path).with_context(|| format!("seat key {}", path.display()))?;
305    let seed: [u8; 32] = raw
306        .get(..32)
307        .and_then(|s| s.try_into().ok())
308        .ok_or_else(|| anyhow::anyhow!("seat key {} holds fewer than 32 bytes", path.display()))?;
309    Ok(SigningKey::from_bytes(&seed))
310}
311
312/// Where `seat`'s ballot key lives: `LJOS_SEAT_KEY` when that path is this
313/// seat's own key and not the machine key, else
314/// `$XDG_DATA_HOME/ljos/seats/SEAT/ballot.key` (`~/.local/share` when unset).
315/// Seats that share one HOME still get one key each. The machine key at
316/// `~/.config/deedar/host.key` is never used.
317#[must_use]
318pub fn seat_key_path(seat: &str) -> Option<PathBuf> {
319    if let Some(p) = std::env::var_os("LJOS_SEAT_KEY").filter(|p| !p.is_empty()) {
320        let path = PathBuf::from(p);
321        if !is_box_host_key(&path) && seat_may_use_override(seat) {
322            return Some(path);
323        }
324    }
325    seat_key_file(seat)
326}
327
328/// The per-seat key file, ignoring `LJOS_SEAT_KEY`.
329///
330/// The directory keeps ASCII letters, digits, `-` and `.`, and writes any
331/// other byte, `_` included, and a leading `.` as `%HH`, so no two seats
332/// share a file. Older builds folded those bytes to `_`; a key at that
333/// folded name moves here on first use. No name written here has a `_`,
334/// so the folded file is no seat's current key.
335#[must_use]
336pub fn seat_key_file(seat: &str) -> Option<PathBuf> {
337    let base = std::env::var_os("XDG_DATA_HOME")
338        .filter(|p| !p.is_empty())
339        .map(PathBuf::from)
340        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
341    let seats = base.join("ljos").join("seats");
342    let seat = seat.trim();
343    let name = seat_dir_name(seat);
344    let path = seats.join(&name).join("ballot.key");
345    let folded = folded_seat_dir_name(seat);
346    if folded != name && !folded.starts_with('.') && !path.exists() {
347        let old = seats.join(folded).join("ballot.key");
348        if old.is_file() {
349            if let Some(dir) = path.parent() {
350                let _ = std::fs::create_dir_all(dir);
351            }
352            let _ = std::fs::rename(&old, &path);
353        }
354    }
355    Some(path)
356}
357
358fn seat_dir_name(seat: &str) -> String {
359    let mut out = String::with_capacity(seat.len());
360    for (at, b) in seat.bytes().enumerate() {
361        if b.is_ascii_alphanumeric() || b == b'-' || (b == b'.' && at > 0) {
362            out.push(char::from(b));
363        } else {
364            out.push_str(&format!("%{b:02x}"));
365        }
366    }
367    out
368}
369
370fn folded_seat_dir_name(seat: &str) -> String {
371    seat.chars()
372        .map(|c| {
373            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
374                c
375            } else {
376                '_'
377            }
378        })
379        .collect()
380}
381
382/// The machine deed key. A seat key must not be this file.
383#[must_use]
384pub fn is_box_host_key(path: &std::path::Path) -> bool {
385    path.ends_with("deedar/host.key")
386}
387
388fn seat_may_use_override(seat: &str) -> bool {
389    let seat = seat.trim();
390    let own = |key: &str| std::env::var(key).ok().is_some_and(|v| v.trim() == seat);
391    match (
392        std::env::var("LJOS_SEAT")
393            .ok()
394            .filter(|s| !s.trim().is_empty()),
395        std::env::var("VISSUE_AGENT")
396            .ok()
397            .filter(|s| !s.trim().is_empty()),
398    ) {
399        (None, None) => true,
400        _ => own("LJOS_SEAT") || own("VISSUE_AGENT"),
401    }
402}
403
404/// The key at `path`, made on first use: 32 random bytes, mode 0600.
405///
406/// # Errors
407///
408/// The key cannot be read or written.
409pub fn load_or_create(path: &std::path::Path) -> Result<SigningKey> {
410    if !path.exists() {
411        use std::io::Read;
412        use std::os::unix::fs::OpenOptionsExt;
413        let mut seed = [0u8; 32];
414        std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
415        if let Some(dir) = path.parent() {
416            std::fs::create_dir_all(dir)?;
417        }
418        let mut f = std::fs::OpenOptions::new()
419            .write(true)
420            .create_new(true)
421            .mode(0o600)
422            .open(path)
423            .with_context(|| format!("seat key {}", path.display()))?;
424        std::io::Write::write_all(&mut f, &seed)?;
425    }
426    key_from_file(path)
427}
428
429/// Whether `voter`'s ballot for `choice` on `issue` was recorded before
430/// signing began in this tracker, by commit order: its `] VOTER: CHOICE`
431/// line is in that issue's own entry in every parent of each commit that
432/// brought the pin file into a tree none of its parents held it in. No
433/// git date is read. `Ok(false)` on a tracker that is not a git repository
434/// or has no pin committed; `LJOS_UNSIGNED_BALLOTS=allow` is for a tracker
435/// that never signed.
436///
437/// # Errors
438///
439/// [`crate::slot::require_tracker_history`] could not make the history
440/// readable. A missing parent is not "the ballot was not there yet".
441pub fn recorded_before_signing(issue: &str, voter: &str, choice: &str) -> Result<bool, String> {
442    let Some(root) = env_root() else {
443        return Ok(false);
444    };
445    recorded_before_signing_in(&root, issue, voter, choice)
446}
447
448/// [`recorded_before_signing`] in the tracker at `root`.
449///
450/// # Errors
451///
452/// [`crate::slot::require_tracker_history`] could not make the history
453/// readable.
454pub fn recorded_before_signing_in(
455    root: &Path,
456    issue: &str,
457    voter: &str,
458    choice: &str,
459) -> Result<bool, String> {
460    crate::slot::require_tracker_history(root)?;
461    let log = git_in(
462        root,
463        &[
464            "--no-pager",
465            "log",
466            "--full-history",
467            "-m",
468            "--diff-filter=A",
469            "--format=%H %P",
470            "--",
471            PIN_FILE,
472        ],
473    )
474    .unwrap_or_default();
475    let held = |commit: &str| crate::slot::file_at_in(root, commit, PIN_FILE).is_some();
476    let starts: Vec<Vec<&str>> = log
477        .lines()
478        .filter_map(|line| {
479            let mut parts = line.split_whitespace();
480            let commit = parts.next()?;
481            let parents: Vec<&str> = parts.collect();
482            (held(commit) && !parents.iter().any(|p| held(p))).then_some(parents)
483        })
484        .collect();
485    let needle = format!("] {}: {}", voter.trim(), choice.trim());
486    Ok(!starts.is_empty()
487        && starts.iter().all(|parents| {
488            !parents.is_empty()
489                && parents
490                    .iter()
491                    .all(|p| crate::slot::note_on_issue_at_in(root, p, issue, &needle))
492        }))
493}
494
495pub(crate) fn env_root() -> Option<PathBuf> {
496    std::env::var_os("VISSUE_ROOT")
497        .filter(|r| !r.is_empty())
498        .map(PathBuf::from)
499}
500
501/// What `git ARGS` prints in the tracker at `VISSUE_ROOT`, trimmed, when
502/// it succeeds. Replacements and grafts are off ([`git_history`]).
503pub(crate) fn tracker_git(args: &[&str]) -> Option<String> {
504    git_in(&env_root()?, args)
505}
506
507/// Whether `VISSUE_ROOT` names a git checkout. `None` when no tracker root is
508/// configured or git could not run.
509#[must_use]
510pub fn tracker_is_git_checkout() -> Option<bool> {
511    let root = env_root()?;
512    let out = std::process::Command::new("git")
513        .arg("-C")
514        .arg(root)
515        .args(["rev-parse", "--is-inside-work-tree"])
516        .output()
517        .ok()?;
518    if !out.status.success() {
519        return Some(false);
520    }
521    Some(String::from_utf8_lossy(&out.stdout).trim() == "true")
522}
523
524/// `git` in `root` with replacements and grafts off. Pin order is the
525/// parents stored in the commits. A `refs/replace` ref or `info/grafts`
526/// rewrites those parents, so a read of tracker history must not follow
527/// either. An exported `GIT_DIR` or object directory would answer for
528/// another repository in place of `root`, so those are dropped too.
529pub(crate) fn git_history(root: &Path) -> std::process::Command {
530    let mut cmd = std::process::Command::new("git");
531    crate::clear_git_repo_env(&mut cmd);
532    cmd.arg("--no-replace-objects")
533        .arg("-C")
534        .arg(root)
535        .env("GIT_NO_REPLACE_OBJECTS", "1")
536        .env("GIT_GRAFT_FILE", "/dev/null");
537    cmd
538}
539
540/// What `git ARGS` prints in `root`, trimmed, when it succeeds.
541/// Replacements and grafts are off ([`git_history`]).
542pub(crate) fn git_in(root: &Path, args: &[&str]) -> Option<String> {
543    let out = git_history(root).args(args).output().ok()?;
544    out.status
545        .success()
546        .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
547}
548
549/// The note text for a ballot signed by `key` (v1, choice only).
550#[must_use]
551pub fn sign_with(key: &SigningKey, issue: &str, voter: &str, choice: &str) -> String {
552    let (public, sig) = signed_hex(key, &message(issue, voter, choice));
553    format!(
554        "{SIG_NOTE}{} {} {} {}",
555        voter.trim(),
556        choice.trim(),
557        public,
558        sig
559    )
560}
561
562/// The note text for a ballot bound to a harness session and its marker.
563#[must_use]
564pub fn sign_bound(
565    key: &SigningKey,
566    issue: &str,
567    voter: &str,
568    choice: &str,
569    harness: &str,
570    nonce: &str,
571) -> String {
572    let harness = field(harness);
573    let nonce = field(nonce);
574    let (public, sig) = signed_hex(key, &message_v2(issue, voter, choice, &harness, &nonce));
575    format!(
576        "{SIG_NOTE}{} {} {} {} {} {}",
577        voter.trim(),
578        choice.trim(),
579        public,
580        sig,
581        harness,
582        nonce
583    )
584}
585
586fn covered(issue: &str, note: &SigNote) -> Vec<u8> {
587    match (&note.harness, &note.nonce) {
588        (Some(h), Some(n)) => message_v2(issue, &note.voter, &note.choice, h, n),
589        _ => message(issue, &note.voter, &note.choice),
590    }
591}
592
593/// Whether `note` is a valid signature over this issue's ballot.
594#[must_use]
595pub fn verifies(issue: &str, note: &SigNote) -> bool {
596    signature_ok(&note.key, &note.sig, &covered(issue, note))
597}
598
599/// The tracker root the pins live at.
600fn pin_path() -> Option<PathBuf> {
601    Some(env_root()?.join(PIN_FILE))
602}
603
604/// `voter keyhex` lines.
605#[must_use]
606pub fn parse_pins(text: &str) -> BTreeMap<String, String> {
607    text.lines()
608        .filter_map(|l| {
609            let mut it = l.split_whitespace();
610            Some((it.next()?.to_string(), it.next()?.to_string()))
611        })
612        .collect()
613}
614
615fn parse_pins_strict(text: &str) -> Result<BTreeMap<String, String>> {
616    let mut out = BTreeMap::new();
617    for (idx, raw) in text.lines().enumerate() {
618        let line = raw.trim();
619        if line.is_empty() {
620            continue;
621        }
622        let mut it = line.split_whitespace();
623        let Some(voter) = it.next() else {
624            continue;
625        };
626        let key = it
627            .next()
628            .ok_or_else(|| anyhow::anyhow!("pin line {} must be `voter key`", idx + 1))?;
629        if it.next().is_some() {
630            bail!("pin line {} must be `voter key`", idx + 1);
631        }
632        if out.insert(voter.to_string(), key.to_string()).is_some() {
633            bail!("duplicate pin voter `{voter}`");
634        }
635    }
636    Ok(out)
637}
638
639/// The pinned keys for record checking, and where they came from.
640///
641/// When there is no pin file, checking starts with no pins and the source is
642/// `none: first-seen in this check`. A link to a missing file is a pin file
643/// that cannot be read, not an absent one.
644pub fn pins_for_check() -> Result<(BTreeMap<String, String>, String)> {
645    match env_root() {
646        Some(root) => pins_for_check_in(&root),
647        None => Ok((BTreeMap::new(), NO_PIN_FILE.into())),
648    }
649}
650
651const NO_PIN_FILE: &str = "none: first-seen in this check";
652
653/// [`pins_for_check`] for the tracker at `root`.
654pub fn pins_for_check_in(root: &Path) -> Result<(BTreeMap<String, String>, String)> {
655    let path = root.join(PIN_FILE);
656    let text = match std::fs::read_to_string(&path) {
657        Ok(text) => text,
658        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
659            if std::fs::symlink_metadata(&path).is_ok() {
660                bail!("pin file {} is a link to a missing file", path.display());
661            }
662            return Ok((BTreeMap::new(), NO_PIN_FILE.into()));
663        }
664        Err(err) => return Err(err).with_context(|| format!("pin file {}", path.display())),
665    };
666    let pins = parse_pins_strict(&text).with_context(|| format!("pin file {}", path.display()))?;
667    Ok((pins, path.display().to_string()))
668}
669
670/// The pinned keys, empty when there is no pin file.
671#[must_use]
672pub fn pins() -> BTreeMap<String, String> {
673    pin_path()
674        .and_then(|p| std::fs::read_to_string(p).ok())
675        .map(|t| parse_pins(&t))
676        .unwrap_or_default()
677}
678
679/// A token safe to put in a signature note: no whitespace.
680#[must_use]
681pub fn field(raw: &str) -> String {
682    let t: String = raw
683        .trim()
684        .chars()
685        .map(|c| {
686            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@') {
687                c
688            } else {
689                '_'
690            }
691        })
692        .collect();
693    if t.is_empty() {
694        "-".into()
695    } else {
696        t
697    }
698}
699
700/// The harness session, and a nonce that is not a process id. The session
701/// comes from the MCP connection, the hook, or the environment, then from
702/// `VISSUE_AGENT` or `LJOS_SEAT`. The nonce is `-`, or [`SEAT_NONCE`] when
703/// the seat stands in for the session: one holder is one session across
704/// processes.
705#[derive(Debug, Clone, PartialEq, Eq)]
706pub struct SessionIdentity {
707    pub harness: String,
708    pub nonce: String,
709}
710
711/// The nonce of a session only the seat names: no harness session and no
712/// run id was found. [`crate::panel_gate::session_key`] reads the harness
713/// alone when one is set, so the key is still the seat.
714pub const SEAT_NONCE: &str = "seat";
715
716impl SessionIdentity {
717    /// Whether only the seat names this session.
718    #[must_use]
719    pub fn seat_only(&self) -> bool {
720        self.nonce == SEAT_NONCE
721    }
722}
723
724/// What a session only the seat names means, where a ballot is cast or
725/// counted.
726#[must_use]
727pub fn unverified_sentence(seat: &str) -> String {
728    format!(
729        "unverified session: nothing named the run but seat {seat}, so every run of that seat is one session"
730    )
731}
732
733tokio::task_local! {
734    static CALL_SESSION: String;
735    static CALL_AGENT: String;
736}
737
738std::thread_local! {
739    static HOOK_SESSION: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
740}
741
742/// The session id the MCP call named, when this task is inside one.
743#[must_use]
744pub fn call_session() -> Option<String> {
745    CALL_SESSION
746        .try_with(|s| s.clone())
747        .ok()
748        .filter(|s| !s.is_empty())
749}
750
751/// Run `f` as an MCP call that named `session`, made by the subagent
752/// `agent`. One server answers a parent and all its subagents, so a call
753/// that names no agent is the parent's, whatever this process holds.
754pub async fn scope_session<F: std::future::Future>(
755    session: Option<String>,
756    agent: Option<String>,
757    f: F,
758) -> F::Output {
759    let agent = agent
760        .map(|a| field(&a))
761        .filter(|a| a != "-")
762        .unwrap_or_default();
763    let f = CALL_AGENT.scope(agent, f);
764    match session.map(|s| field(&s)).filter(|s| s != "-") {
765        Some(s) => CALL_SESSION.scope(s, f).await,
766        None => f.await,
767    }
768}
769
770static PROCESS_AGENT: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
771
772/// Name the subagent a hook payload says this hook process runs for.
773pub fn hook_agent(agent: Option<String>) {
774    let _ = PROCESS_AGENT.set(agent);
775}
776
777/// The subagent this process or MCP call runs for under `parent`, when the
778/// harness named one: the agent a hook saw make this call, else a Codex
779/// thread under its root session. The command line takes no agent, so a
780/// parent cannot cast as its subagent.
781fn subagent(parent: &str) -> Option<String> {
782    if let Ok(agent) = CALL_AGENT.try_with(Clone::clone) {
783        return (!agent.is_empty()).then_some(agent);
784    }
785    PROCESS_AGENT
786        .get_or_init(|| {
787            crate::subagent::claim_command(parent)
788                .filter(|a| !a.is_empty())
789                .map(|a| field(&a))
790                .or_else(|| codex_thread(parent))
791        })
792        .clone()
793}
794
795static HOOKED: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
796
797/// The session a hook saw run this process's `ljos` line, plus its
798/// subagent, for a process whose environment names no session: a seat
799/// whose runner stamps no id in the shell still binds what its hook saw,
800/// rather than the seat. An MCP call names its own session.
801fn hooked_session() -> Option<String> {
802    if CALL_AGENT.try_with(|_| ()).is_ok() {
803        return None;
804    }
805    HOOKED
806        .get_or_init(|| {
807            let (session, agent) = crate::subagent::claim_unsessioned_command()?;
808            let (session, agent) = (field(&session), field(&agent));
809            Some(if agent == "-" {
810                session
811            } else {
812                format!("{session}:{agent}")
813            })
814        })
815        .clone()
816}
817
818/// The thread Codex runs a subagent in, under the root session:
819/// `CODEX_THREAD_ID` when it is not `CODEX_SESSION_ID`, the root's.
820fn codex_thread(parent: &str) -> Option<String> {
821    let root = env_token("CODEX_SESSION_ID")?;
822    let thread = env_token("CODEX_THREAD_ID")?;
823    (root == parent && thread != root).then_some(thread)
824}
825
826/// Remember the hook payload's session for this process, and export it for
827/// a child when the environment has not already named one. An id that
828/// names nobody ([`crate::usable_session_id`]) is no session.
829pub fn export_hook_session(id: Option<&str>) {
830    crate::with_env_mutation(|| {
831        let id = id.and_then(crate::usable_session_id).map(field);
832        HOOK_SESSION.with(|c| *c.borrow_mut() = id.clone());
833        if let Some(id) = id {
834            if std::env::var("LJOS_HARNESS_SESSION")
835                .ok()
836                .filter(|s| !s.trim().is_empty())
837                .is_none()
838            {
839                std::env::set_var("LJOS_HARNESS_SESSION", id);
840            }
841        }
842    });
843}
844
845fn env_token(key: &str) -> Option<String> {
846    let v = std::env::var(key).ok()?;
847    let v = v.trim();
848    if v.is_empty() {
849        return None;
850    }
851    Some(field(v))
852}
853
854static CONNECTION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
855
856/// Pin the MCP connection's session for this process. The first call wins.
857/// A later `_meta` session id and a later `seat` argument do not replace it.
858/// With no hint, the runner's `LJOS_HARNESS_SESSION` is the connection.
859/// Nothing is pinned when that is empty: a per-process nonce is not a session.
860pub fn pin_connection_session(hint: Option<&str>) {
861    let Some(chosen) = hint
862        .map(str::trim)
863        .filter(|s| !s.is_empty())
864        .map(field)
865        .filter(|s| s != "-")
866        .or_else(|| env_token("LJOS_HARNESS_SESSION"))
867    else {
868        return;
869    };
870    let _ = CONNECTION.set(chosen);
871}
872
873/// The session pinned for this MCP connection, when [`pin_connection_session`]
874/// has run.
875#[must_use]
876pub fn connection_session() -> Option<String> {
877    CONNECTION.get().cloned()
878}
879
880/// The harness session id: the MCP connection, then the MCP call, then the
881/// hook, then a runner-stamped `*_SESSION_ID` / thread / conversation id,
882/// then `LJOS_HARNESS_SESSION`, then the run ([`run_session`]).
883/// The connection pin is the process, not the `seat` argument.
884#[must_use]
885pub fn harness_session() -> String {
886    if let Some(s) = connection_session() {
887        return s;
888    }
889    if let Some(s) = call_session() {
890        return s;
891    }
892    if let Some(s) = HOOK_SESSION.with(|c| c.borrow().clone()) {
893        return s;
894    }
895    let stamped = crate::stamped_sessions();
896    const PREFER: &[&str] = &[
897        "CURSOR_SESSION_ID",
898        "CLAUDE_SESSION_ID",
899        // A Codex subagent's thread is not its session: [`codex_thread`]
900        // names it under the root.
901        "CODEX_SESSION_ID",
902        "CODEX_THREAD_ID",
903        "GROK_SESSION_ID",
904        "SEAT_SESSION_ID",
905        "LJOS_SESSION_ID",
906        // Claude Code also stamps a remote-control id that comes and goes,
907        // and opencode may run under another runner's id. Each names its
908        // own session here, so the ballot binds the id its hook reports.
909        "CLAUDE_CODE_SESSION_ID",
910        "OPENCODE_SESSION_ID",
911    ];
912    for key in PREFER {
913        if let Some(s) = stamped
914            .iter()
915            .find(|(k, _)| k == *key)
916            .map(|(_, v)| field(v))
917            .or_else(|| env_token(key))
918        {
919            // A shell runner's env file shares `NAME-shell` across every
920            // conversation; it holds claims and names no session.
921            if s.len() >= 8 && !(*key == "LJOS_SESSION_ID" && crate::is_shared_holder_id(&s)) {
922                return s;
923            }
924        }
925    }
926    if let Some(s) = crate::runner_session_id().map(|s| field(&s)) {
927        return s;
928    }
929    if let Some(s) = env_token("LJOS_HARNESS_SESSION") {
930        return s;
931    }
932    run_session().unwrap_or_default()
933}
934
935#[cfg(test)]
936static RUNNER_SESSIONS: std::sync::Mutex<Option<Vec<String>>> = std::sync::Mutex::new(None);
937
938/// Stand in for the runner a unit test runs without, until the value
939/// returned is dropped.
940#[cfg(test)]
941#[must_use]
942pub(crate) fn fake_runner_sessions(ids: &[&str]) -> impl Drop {
943    struct Reset;
944    impl Drop for Reset {
945        fn drop(&mut self) {
946            *RUNNER_SESSIONS
947                .lock()
948                .unwrap_or_else(std::sync::PoisonError::into_inner) = None;
949        }
950    }
951    *RUNNER_SESSIONS
952        .lock()
953        .unwrap_or_else(std::sync::PoisonError::into_inner) =
954        Some(ids.iter().map(|s| (*s).to_string()).collect());
955    Reset
956}
957
958/// The sessions the runner above this process gave the process it
959/// started, read from that process's environment and never from this
960/// one's: `LJOS_HARNESS_SESSION=forged ljos ...` sets ours, not that.
961/// Every id there is the runner's, so a bind may name any of them. The
962/// session an MCP call's `_meta` names is not: any client of the server
963/// writes it, so it scopes a call ([`call_session`]) and never vouches
964/// for one. Empty when no runner is above this process.
965#[must_use]
966pub fn runner_sessions() -> Vec<String> {
967    #[cfg(test)]
968    if let Some(ids) = RUNNER_SESSIONS
969        .lock()
970        .unwrap_or_else(std::sync::PoisonError::into_inner)
971        .clone()
972    {
973        return ids;
974    }
975    let Some(started) = crate::proc_tree::started_by_runner() else {
976        return Vec::new();
977    };
978    crate::proc_tree::environ(started)
979        .unwrap_or_default()
980        .into_iter()
981        .filter(|(k, v)| k == "LJOS_HARNESS_SESSION" || crate::runner_session_var(k, v))
982        .map(|(_, v)| field(v.trim()))
983        .filter(|v| !v.is_empty() && v != "-")
984        .collect()
985}
986
987/// The run this process belongs to, when no conversation id was stamped:
988/// a GitHub Actions run, else a cloud agent. Every process of one run is
989/// one session, as `ljos cloud up` gives the run one seat.
990fn run_session() -> Option<String> {
991    let actions = std::env::var("GITHUB_ACTIONS").is_ok_and(|v| {
992        let v = v.trim();
993        !v.is_empty() && !matches!(v, "0" | "false")
994    });
995    let ci: &[&str] = if actions { &["GITHUB_RUN_ID"] } else { &[] };
996    ci.iter()
997        .chain(&["CURSOR_AGENT_ID", "CLOUD_AGENT_ID"])
998        .filter_map(|key| env_token(key))
999        .find(|s| s.len() >= 8)
1000}
1001
1002fn random_hex(n: usize) -> String {
1003    use std::io::Read;
1004    let mut buf = vec![0u8; n];
1005    if std::fs::File::open("/dev/urandom")
1006        .and_then(|mut f| f.read_exact(&mut buf))
1007        .is_ok()
1008    {
1009        return hex::encode(buf);
1010    }
1011    format!("{:x}{:x}", std::process::id(), std::process::id())
1012}
1013
1014static PROCESS_NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
1015
1016/// The nonce of this process, handed to a child `ljos`. No ballot carries
1017/// it: [`identity`] binds the session alone.
1018#[must_use]
1019pub fn process_nonce() -> String {
1020    if let Some(s) = env_token("LJOS_PROCESS_NONCE") {
1021        if s != "-" {
1022            return s;
1023        }
1024    }
1025    PROCESS_NONCE.get_or_init(|| random_hex(16)).clone()
1026}
1027
1028/// A person-named agent or seat, when it is not a pronoun.
1029fn named_session_actor(key: &str) -> Option<String> {
1030    let name = env_token(key)?;
1031    let lower = name.to_ascii_lowercase();
1032    if matches!(lower.as_str(), "seat" | "you" | "agent" | "runner" | "-") {
1033        return None;
1034    }
1035    Some(name)
1036}
1037
1038/// This process's session identity. Stable across processes of one holder.
1039/// A subagent the harness named is `SESSION:AGENT`, a session of its own.
1040/// With no session in the environment, the one the hook saw run this
1041/// line comes before the seat.
1042#[must_use]
1043pub fn identity() -> SessionIdentity {
1044    let harness = harness_session();
1045    if !harness.is_empty() {
1046        let harness = match subagent(&harness) {
1047            Some(agent) => format!("{harness}:{agent}"),
1048            None => harness,
1049        };
1050        return SessionIdentity {
1051            harness,
1052            nonce: "-".into(),
1053        };
1054    }
1055    if let Some(harness) = hooked_session() {
1056        return SessionIdentity {
1057            harness,
1058            nonce: "-".into(),
1059        };
1060    }
1061    match named_session_actor("VISSUE_AGENT").or_else(|| named_session_actor("LJOS_SEAT")) {
1062        Some(seat) => SessionIdentity {
1063            harness: seat,
1064            nonce: SEAT_NONCE.into(),
1065        },
1066        None => SessionIdentity {
1067            harness: "-".into(),
1068            nonce: "-".into(),
1069        },
1070    }
1071}
1072
1073/// The key `voter` signs with, pinned on first use.
1074fn signing_key(voter: &str) -> Result<(SigningKey, String)> {
1075    let path = seat_key_path(voter)
1076        .ok_or_else(|| anyhow::anyhow!("no place for a seat key: HOME and XDG_DATA_HOME are unset; the ballot is unsigned and no gate counts it"))?;
1077    let key = load_or_create(&path)?;
1078    let mine = hex::encode(key.verifying_key().to_bytes());
1079    let pinned = match pin_path() {
1080        Some(p) if !pins().contains_key(voter.trim()) => pin_first(&p, voter.trim(), &mine)?,
1081        _ => pins(),
1082    };
1083    let mine_fp = key_fingerprint(&mine);
1084    if let Some(k) = pinned
1085        .get(voter.trim())
1086        .filter(|k| key_fingerprint(k) != mine_fp)
1087    {
1088        bail!(
1089            "seat {voter} is pinned to key {}…, and this key is {}…; the ballot is unsigned and no gate counts it",
1090            &k[..12.min(k.len())],
1091            &mine[..12]
1092        );
1093    }
1094    Ok((key, mine))
1095}
1096
1097/// Pin `mine` for `voter` unless another pin for it landed first, and
1098/// return the pins. Seats pin on their first ballot, often at once: the
1099/// read and the write hold the tracker's commit lock, so no seat's pin is
1100/// lost, and the file is replaced whole, so a `git add` never hashes half
1101/// of it. The seat's tracker commit carries the pin.
1102fn pin_first(path: &Path, voter: &str, mine: &str) -> Result<BTreeMap<String, String>> {
1103    let _held = crate::tracker_commit_lock(path.parent().unwrap_or_else(|| Path::new(".")));
1104    let mut pinned = std::fs::read_to_string(path)
1105        .map(|t| parse_pins(&t))
1106        .unwrap_or_default();
1107    if !pinned.contains_key(voter) {
1108        pinned.insert(voter.to_string(), mine.to_string());
1109        let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
1110        vissue_core::store::replace_file_atomically(path, &text)?;
1111    }
1112    Ok(pinned)
1113}
1114
1115/// Sign this seat's ballot, bound to the harness session, and leave the
1116/// note on the issue. A seat whose key differs from
1117/// its pin is refused, and the ballot stays unsigned (so the gate will not
1118/// count it).
1119///
1120/// # Errors
1121///
1122/// No seat key, a key that does not match the pin, or a tracker write that
1123/// failed.
1124pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
1125    if let Some((existing, withdrawn)) = newest_sig(issue, voter) {
1126        if verifies(issue, &existing) {
1127            match resign_of(&existing, choice, voter, withdrawn) {
1128                Resign::Keep => {
1129                    return Ok(format!("ballot already signed by {voter}\n"));
1130                }
1131                Resign::Refuse => {
1132                    bail!("will not re-sign {voter}'s ballot");
1133                }
1134                Resign::Sign => {}
1135            }
1136        }
1137    }
1138    let (key, mine) = signing_key(voter)?;
1139    let id = identity();
1140    let note = sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce);
1141    if write_sig_note(issue, &note).is_err() {
1142        crate::run_captured("vissue", &["note", issue, &note])?;
1143    }
1144    let session = if id.harness == "-" {
1145        format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
1146    } else {
1147        id.harness.clone()
1148    };
1149    let mut said = format!(
1150        "ballot signed by {voter} with key {}… session {session}\n",
1151        &mine[..12.min(mine.len())]
1152    );
1153    if id.seat_only() {
1154        said.push_str(&format!("{}\n", unverified_sentence(&id.harness)));
1155    }
1156    Ok(said)
1157}
1158
1159enum Resign {
1160    Keep,
1161    Refuse,
1162    Sign,
1163}
1164
1165/// A signature that already verifies stays. The same key may sign a new
1166/// choice. A withdrawn ballot is re-signed, even for the same choice, so
1167/// the new ballot stands above the withdrawal cut. Any other key,
1168/// including the machine key, does not sign again.
1169fn resign_of(existing: &SigNote, choice: &str, voter: &str, withdrawn: bool) -> Resign {
1170    let mine = current_public_hex(voter);
1171    if mine.as_deref() == Some(existing.key.as_str()) {
1172        if existing.choice == choice && !withdrawn {
1173            Resign::Keep
1174        } else {
1175            Resign::Sign
1176        }
1177    } else {
1178        Resign::Refuse
1179    }
1180}
1181
1182fn current_public_hex(voter: &str) -> Option<String> {
1183    let path = seat_key_path(voter)?;
1184    let key = key_from_file(&path).ok()?;
1185    Some(hex::encode(key.verifying_key().to_bytes()))
1186}
1187
1188fn same_voter(signed: &str, voter: &str) -> bool {
1189    signed == voter.trim() || signed == field(voter)
1190}
1191
1192fn newest_sig(issue: &str, voter: &str) -> Option<(SigNote, bool)> {
1193    let layout = vissue_core::Layout::resolve(None, None).ok()?;
1194    let (heading, _, _) = vissue_core::store::find_by_id(&layout, issue).ok()??;
1195    let mut cut = None;
1196    let mut newest = None;
1197    for (at, entry) in heading.logbook.iter().enumerate() {
1198        let Some(text) = entry.note.as_deref() else {
1199            continue;
1200        };
1201        if cut.is_none() {
1202            if let Some(withdrawn) = parse_withdrawn(text) {
1203                if same_voter(&withdrawn.voter, voter) {
1204                    cut = Some(at);
1205                }
1206            }
1207        }
1208        if newest.is_none() {
1209            if let Some(sig) = parse_note(text) {
1210                if same_voter(&sig.voter, voter) {
1211                    newest = Some((at, sig));
1212                }
1213            }
1214        }
1215        if cut.is_some() && newest.is_some() {
1216            break;
1217        }
1218    }
1219    let (at, sig) = newest?;
1220    let withdrawn = cut.is_some_and(|cut_at| cut_at < at);
1221    Some((sig, withdrawn))
1222}
1223
1224pub(crate) fn write_sig_note(issue: &str, note: &str) -> Result<()> {
1225    let layout = vissue_core::Layout::resolve(None, None)?;
1226    vissue_core::ops::note(&layout, issue, note)?;
1227    Ok(())
1228}
1229
1230/// Sign the ballot this process's seat cast on `issue`, when it has one.
1231///
1232/// # Errors
1233///
1234/// The tracker cannot be read, or the ballot is already signed by another key.
1235pub fn sign_own_ballot(issue: &str) -> Result<String> {
1236    let Some(voter) = own_voter() else {
1237        return Ok(String::new());
1238    };
1239    let layout = vissue_core::Layout::resolve(None, None)?;
1240    let ballots = vissue_core::ops::ballots(&layout, issue)?;
1241    let Some(choice) = ballots
1242        .iter()
1243        .find(|b| b.agent == voter)
1244        .map(|b| b.choice.clone())
1245    else {
1246        return Ok(String::new());
1247    };
1248    sign_ballot(issue, &voter, &choice)
1249}
1250
1251/// The name this process's seat casts and signs under: the seat its write
1252/// check named, else `VISSUE_AGENT`, else `LJOS_SEAT`.
1253#[must_use]
1254pub fn own_voter() -> Option<String> {
1255    crate::checked_seat().or_else(|| {
1256        ["VISSUE_AGENT", "LJOS_SEAT"].iter().find_map(|key| {
1257            std::env::var(key)
1258                .ok()
1259                .map(|s| s.trim().to_string())
1260                .filter(|s| !s.is_empty())
1261        })
1262    })
1263}
1264
1265/// Leave `ballot-withdrawn: VOTER HARNESS NONCE` on `issue` for this session.
1266///
1267/// # Errors
1268///
1269/// The tracker did not take the note.
1270pub fn note_withdrawn(issue: &str, voter: &str) -> Result<String> {
1271    note_withdrawn_on(issue, voter, issue)
1272}
1273
1274/// Leave `ballot-withdrawn: VOTER HARNESS NONCE ISSUE` on `logbook_issue`
1275/// for this session.
1276///
1277/// # Errors
1278///
1279/// The tracker did not take the note.
1280pub fn note_withdrawn_on(
1281    logbook_issue: &str,
1282    voter: &str,
1283    withdrawn_issue: &str,
1284) -> Result<String> {
1285    let id = identity();
1286    let note = withdrawn_line_on(voter, &id.harness, &id.nonce, Some(withdrawn_issue));
1287    crate::run_captured("vissue", &["note", logbook_issue, &note]).with_context(|| {
1288        format!("ballot withdrawn; the note on {logbook_issue} was not written")
1289    })?;
1290    Ok(note)
1291}
1292
1293/// The note prefix a design candidate's signature rides under.
1294pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
1295
1296/// The bytes a candidate signature covers, including which other candidates
1297/// this session had already read.
1298#[must_use]
1299pub fn candidate_message(
1300    issue: &str,
1301    seat: &str,
1302    harness: &str,
1303    nonce: &str,
1304    saw: &str,
1305) -> Vec<u8> {
1306    format!(
1307        "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
1308        issue.trim(),
1309        seat.trim(),
1310        harness.trim(),
1311        nonce.trim(),
1312        saw.trim()
1313    )
1314    .into_bytes()
1315}
1316
1317/// A signed candidate binding.
1318#[derive(Debug, Clone, PartialEq, Eq)]
1319pub struct CandidateNote {
1320    pub issue: String,
1321    pub seat: String,
1322    pub harness: String,
1323    pub nonce: String,
1324    pub saw: Vec<String>,
1325    pub key: String,
1326    pub sig: String,
1327}
1328
1329fn saw_token(saw: &[String]) -> String {
1330    if saw.is_empty() {
1331        "-".into()
1332    } else {
1333        saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
1334    }
1335}
1336
1337fn saw_list(token: &str) -> Vec<String> {
1338    if token.is_empty() || token == "-" {
1339        Vec::new()
1340    } else {
1341        token
1342            .split(',')
1343            .map(str::trim)
1344            .filter(|s| !s.is_empty() && *s != "-")
1345            .map(str::to_string)
1346            .collect()
1347    }
1348}
1349
1350/// `candidate-sig: ISSUE SEAT HARNESS NONCE SAW KEYHEX SIGHEX`.
1351#[must_use]
1352pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
1353    let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
1354    let parts: Vec<&str> = rest.split_whitespace().collect();
1355    let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
1356        return None;
1357    };
1358    Some(CandidateNote {
1359        issue: (*issue).to_string(),
1360        seat: (*seat).to_string(),
1361        harness: (*harness).to_string(),
1362        nonce: (*nonce).to_string(),
1363        saw: saw_list(saw),
1364        key: (*key).to_string(),
1365        sig: (*sig).to_string(),
1366    })
1367}
1368
1369/// Whether `note` is a valid signature over that candidate binding.
1370#[must_use]
1371pub fn candidate_verifies(note: &CandidateNote) -> bool {
1372    signature_ok(
1373        &note.key,
1374        &note.sig,
1375        &candidate_message(
1376            &note.issue,
1377            &note.seat,
1378            &note.harness,
1379            &note.nonce,
1380            &saw_token(&note.saw),
1381        ),
1382    )
1383}
1384
1385/// Sign `issue` as a design candidate of `seat` and return the note.
1386///
1387/// # Errors
1388///
1389/// No seat key, or a key that does not match the pin.
1390pub fn sign_candidate(
1391    issue: &str,
1392    seat: &str,
1393    id: &SessionIdentity,
1394    saw: &[String],
1395) -> Result<String> {
1396    let (key, _) = signing_key(seat)?;
1397    let harness = field(&id.harness);
1398    let nonce = field(&id.nonce);
1399    let saw_tok = saw_token(saw);
1400    let (public, sig) = signed_hex(
1401        &key,
1402        &candidate_message(issue, seat, &harness, &nonce, &saw_tok),
1403    );
1404    Ok(format!(
1405        "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
1406        field(issue),
1407        field(seat),
1408        harness,
1409        nonce,
1410        saw_tok,
1411        public,
1412        sig
1413    ))
1414}
1415
1416/// Problems with the seat ballots on `issue`: each `(voter, choice)` needs
1417/// its newest signature note to name that choice, verify, and use the
1418/// pinned key. `notes` are the issue's logbook notes, newest first.
1419#[must_use]
1420pub fn seat_problems(
1421    issue: &str,
1422    seats: &[(&str, &str)],
1423    notes: &[String],
1424    pins: &BTreeMap<String, String>,
1425) -> Vec<String> {
1426    seat_check(issue, seats, notes, pins, &|_, _| false).0
1427}
1428
1429/// [`seat_problems`] with grandfathering: an unsigned ballot `legacy`
1430/// says the tracker recorded before signing began counts, and is named in
1431/// the second list as legacy unsigned.
1432#[must_use]
1433pub fn seat_check(
1434    issue: &str,
1435    seats: &[(&str, &str)],
1436    notes: &[String],
1437    pins: &BTreeMap<String, String>,
1438    legacy: &dyn Fn(&str, &str) -> bool,
1439) -> (Vec<String>, Vec<String>) {
1440    let mut out = Vec::new();
1441    let mut old = Vec::new();
1442    for (voter, choice) in seats {
1443        let newest = notes
1444            .iter()
1445            .filter_map(|n| parse_note(n))
1446            .find(|n| n.voter == *voter);
1447        match newest {
1448            None if legacy(voter, choice) => old.push((*voter).to_string()),
1449            None => out.push(format!("{voter}'s ballot is unsigned")),
1450            Some(n) if n.choice != *choice => out.push(format!(
1451                "{voter}'s signature is for {} and the ballot says {choice}",
1452                n.choice
1453            )),
1454            Some(n) if !verifies(issue, &n) => {
1455                out.push(format!("{voter}'s signature does not verify"));
1456            }
1457            Some(n) => match pins.get(*voter) {
1458                Some(k) if key_fingerprint(k) == key_fingerprint(&n.key) => {}
1459                Some(_) => out.push(format!(
1460                    "{voter}'s ballot is signed by a key other than its pinned one"
1461                )),
1462                None => out.push(format!("{voter} has no pinned key on this tracker")),
1463            },
1464        }
1465    }
1466    (out, old)
1467}
1468
1469/// The logbook notes of an issue JSON, newest first.
1470#[must_use]
1471pub fn notes_of(v: &Value) -> Vec<String> {
1472    v["logbook"]
1473        .as_array()
1474        .into_iter()
1475        .flatten()
1476        .filter_map(|e| e["note"].as_str().map(str::to_string))
1477        .collect()
1478}
1479
1480/// Whether unsigned seat ballots are let through on purpose
1481/// (`LJOS_UNSIGNED_BALLOTS=allow`), for a tracker from before signing.
1482#[must_use]
1483pub fn unsigned_allowed() -> bool {
1484    std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489    use super::*;
1490    use std::path::Path;
1491    use std::process::Command;
1492
1493    fn key(b: u8) -> SigningKey {
1494        SigningKey::from_bytes(&[b; 32])
1495    }
1496
1497    fn git(dir: &Path, args: &[&str], env: &[(&str, String)]) {
1498        let mut cmd = Command::new("git");
1499        cmd.arg("-C").arg(dir).args(args);
1500        for (k, v) in env {
1501            cmd.env(k, v);
1502        }
1503        let out = cmd.output().unwrap();
1504        assert!(
1505            out.status.success(),
1506            "git {:?} failed: {}",
1507            args,
1508            String::from_utf8_lossy(&out.stderr)
1509        );
1510    }
1511
1512    #[test]
1513    fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
1514        let alice = key(1);
1515        let mallory = key(2);
1516        let pins: BTreeMap<String, String> = [(
1517            "alice".to_string(),
1518            hex::encode(alice.verifying_key().to_bytes()),
1519        )]
1520        .into();
1521        let good = sign_with(&alice, "d-1", "alice", "ship");
1522        assert!(seat_problems(
1523            "d-1",
1524            &[("alice", "ship")],
1525            std::slice::from_ref(&good),
1526            &pins
1527        )
1528        .is_empty());
1529        // Unsigned.
1530        let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
1531        assert!(p[0].contains("unsigned"), "{p:?}");
1532        // Signed by another key under alice's name.
1533        let forged = sign_with(&mallory, "d-1", "alice", "ship");
1534        let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
1535        assert!(p[0].contains("other than its pinned"), "{p:?}");
1536        // The ballot changed after signing.
1537        let p = seat_problems(
1538            "d-1",
1539            &[("alice", "hold")],
1540            std::slice::from_ref(&good),
1541            &pins,
1542        );
1543        assert!(p[0].contains("is for ship"), "{p:?}");
1544        // A signature lifted from another issue.
1545        let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
1546        assert!(p[0].contains("does not verify"), "{p:?}");
1547        // No pin on this tracker.
1548        let lone = sign_with(&mallory, "d-1", "bob", "ship");
1549        let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
1550        assert!(p[0].contains("no pinned key"), "{p:?}");
1551    }
1552
1553    #[test]
1554    fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
1555        let pins = BTreeMap::new();
1556        let legacy = |v: &str, _: &str| v == "inky";
1557        let (bad, old) = seat_check(
1558            "d-1",
1559            &[("inky", "ship"), ("bob", "ship")],
1560            &[],
1561            &pins,
1562            &legacy,
1563        );
1564        assert_eq!(old, vec!["inky".to_string()]);
1565        assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
1566    }
1567
1568    #[test]
1569    fn seats_sharing_a_home_get_their_own_keys() {
1570        let dir = tempfile::tempdir().unwrap();
1571        let a = dir.path().join("ljos/seats/inky/ballot.key");
1572        let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
1573        let ka = load_or_create(&a).unwrap();
1574        let kb = load_or_create(&b).unwrap();
1575        assert_ne!(ka.verifying_key(), kb.verifying_key());
1576        assert_eq!(
1577            load_or_create(&a).unwrap().verifying_key(),
1578            ka.verifying_key()
1579        );
1580        use std::os::unix::fs::PermissionsExt;
1581        assert_eq!(
1582            std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
1583            0o600
1584        );
1585    }
1586
1587    #[test]
1588    fn a_key_at_its_folded_name_moves_to_the_seat_file() {
1589        let _lock = crate::test_env_lock();
1590        let data = tempfile::tempdir().unwrap();
1591        crate::with_env_mutation(|| {
1592            // SAFETY: this test holds the process environment lock.
1593            unsafe { std::env::set_var("XDG_DATA_HOME", data.path()) };
1594        });
1595        let folded = data.path().join("ljos/seats/judge_model-1.5/ballot.key");
1596        std::fs::create_dir_all(folded.parent().unwrap()).unwrap();
1597        std::fs::write(&folded, [7u8; 32]).unwrap();
1598        let path = seat_key_file("judge:model-1.5").unwrap();
1599        assert_ne!(path, folded);
1600        assert_eq!(std::fs::read(&path).unwrap(), vec![7u8; 32]);
1601        assert!(!folded.exists());
1602        assert_eq!(seat_key_file("judge:model-1.5").unwrap(), path);
1603        let slug = seat_key_file("disk-saver").unwrap();
1604        assert_eq!(slug, data.path().join("ljos/seats/disk-saver/ballot.key"));
1605    }
1606
1607    #[test]
1608    fn the_newest_signature_wins() {
1609        let a = key(3);
1610        let pins: BTreeMap<String, String> =
1611            [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
1612        let new = sign_with(&a, "d-1", "a", "hold");
1613        let old = sign_with(&a, "d-1", "a", "ship");
1614        assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
1615    }
1616
1617    #[test]
1618    fn a_choice_with_a_space_parses_whole_and_verifies() {
1619        // The panel eval's unscored case: session spaced-1 cast
1620        // `beside binary` as seat-a, then `xdg home`.
1621        let k = key(5);
1622        let bound = sign_bound(&k, "panel-spcd", "seat-a", "beside binary", "spaced-1", "-");
1623        let parsed = parse_note(&bound).expect("a bound note with a spaced choice");
1624        assert_eq!(parsed.voter, "seat-a");
1625        assert_eq!(parsed.choice, "beside binary");
1626        assert_eq!(parsed.harness.as_deref(), Some("spaced-1"));
1627        assert_eq!(parsed.nonce.as_deref(), Some("-"));
1628        assert!(verifies("panel-spcd", &parsed));
1629        assert_eq!(
1630            note_session(&bound),
1631            Some(("spaced-1".to_string(), "-".to_string()))
1632        );
1633        // v1, three words: six fields, and still not read as a session.
1634        let plain = sign_with(&k, "panel-spcd", "seat-a", "xdg home dir");
1635        let parsed = parse_note(&plain).expect("a v1 note with a spaced choice");
1636        assert_eq!(parsed.choice, "xdg home dir");
1637        assert_eq!(parsed.harness, None);
1638        assert!(verifies("panel-spcd", &parsed));
1639        // Two spaces inside the choice stay two, so the signature holds.
1640        let wide = sign_bound(&k, "panel-spcd", "seat-a", "xdg  home", "spaced-1", "-");
1641        let parsed = parse_note(&wide).unwrap();
1642        assert_eq!(parsed.choice, "xdg  home");
1643        assert!(verifies("panel-spcd", &parsed));
1644        let pins: BTreeMap<String, String> = [(
1645            "seat-a".to_string(),
1646            hex::encode(k.verifying_key().to_bytes()),
1647        )]
1648        .into();
1649        assert!(seat_problems(
1650            "panel-spcd",
1651            &[("seat-a", "beside binary")],
1652            &[bound],
1653            &pins
1654        )
1655        .is_empty());
1656    }
1657
1658    #[test]
1659    fn a_note_that_does_not_parse_still_names_its_session() {
1660        let odd = "ballot-sig: seat-a beside binary ab cd spaced-1 -";
1661        assert_eq!(parse_note(odd), None);
1662        assert_eq!(
1663            note_session(odd),
1664            Some(("spaced-1".to_string(), "-".to_string()))
1665        );
1666        assert_eq!(note_session("ballot-sig: seat-a ship ab"), None);
1667        assert_eq!(note_session("finding: seat-a: nit: name the unit"), None);
1668    }
1669
1670    #[test]
1671    fn a_withdrawn_note_is_three_fields() {
1672        let line = withdrawn_line("seat a", "sess-1", "-");
1673        assert_eq!(line, "ballot-withdrawn: seat_a sess-1 -");
1674        let parsed = parse_withdrawn(&line).unwrap();
1675        assert_eq!(parsed.voter, "seat_a");
1676        assert_eq!(parsed.harness, "sess-1");
1677        assert_eq!(parsed.nonce, "-");
1678        assert_eq!(parsed.issue, None);
1679        let on = withdrawn_line_on("seat a", "sess-1", "-", Some("child-a"));
1680        assert_eq!(on, "ballot-withdrawn: seat_a sess-1 - child-a");
1681        let parsed = parse_withdrawn(&on).unwrap();
1682        assert_eq!(parsed.issue.as_deref(), Some("child-a"));
1683        assert!(parse_withdrawn("ballot-withdrawn: only-one").is_none());
1684        assert!(parse_withdrawn("ballot-sig: seat-a ship").is_none());
1685    }
1686
1687    #[test]
1688    fn a_session_binding_is_signed_and_a_changed_session_is_not() {
1689        let alice = key(4);
1690        let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
1691        let parsed = parse_note(&note).unwrap();
1692        assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
1693        assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
1694        assert!(verifies("d-1", &parsed));
1695        let mut moved = parsed.clone();
1696        moved.harness = Some("sess-other-0001".into());
1697        assert!(
1698            !verifies("d-1", &moved),
1699            "a rewritten session still verified"
1700        );
1701        let pins: BTreeMap<String, String> = [(
1702            "alice".to_string(),
1703            hex::encode(alice.verifying_key().to_bytes()),
1704        )]
1705        .into();
1706        assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
1707    }
1708
1709    #[test]
1710    fn a_legacy_ballot_is_read_on_its_own_issue() {
1711        let _lock = crate::test_env_lock();
1712        let dir = tempfile::tempdir().unwrap();
1713        let file = dir.path().join("issues.org");
1714        git(dir.path(), &["init"], &[]);
1715        git(dir.path(), &["config", "user.name", "Rohit Goswami"], &[]);
1716        git(
1717            dir.path(),
1718            &["config", "user.email", "rgoswami@ieee.org"],
1719            &[],
1720        );
1721        std::fs::write(
1722            &file,
1723            "* TODO [#C] old\n:PROPERTIES:\n:ID:         probe-old\n:END:\n- [X] elder: rail\n",
1724        )
1725        .unwrap();
1726        git(dir.path(), &["add", "issues.org"], &[]);
1727        git(dir.path(), &["commit", "-m", "old ballot"], &[]);
1728        std::fs::write(dir.path().join(PIN_FILE), "inky 00\n").unwrap();
1729        git(dir.path(), &["add", PIN_FILE], &[]);
1730        git(dir.path(), &["commit", "-m", "first pin"], &[]);
1731        std::fs::write(
1732            &file,
1733            "* TODO [#C] old\n:PROPERTIES:\n:ID:         probe-old\n:END:\n- [X] elder: rail\n\
1734             * TODO [#C] new\n:PROPERTIES:\n:ID:         probe-new\n:END:\n- [X] elder: rail\n",
1735        )
1736        .unwrap();
1737        git(dir.path(), &["add", "issues.org"], &[]);
1738        git(dir.path(), &["commit", "-m", "new ballot"], &[]);
1739        assert!(recorded_before_signing_in(dir.path(), "probe-old", "elder", "rail").unwrap());
1740        assert!(!recorded_before_signing_in(dir.path(), "probe-new", "elder", "rail").unwrap());
1741    }
1742
1743    #[test]
1744    fn seat_check_accepts_the_same_key_in_uppercase_hex() {
1745        let alice = key(8);
1746        let note = sign_with(&alice, "d-1", "alice", "ship");
1747        let pins: BTreeMap<String, String> = [(
1748            "alice".to_string(),
1749            hex::encode(alice.verifying_key().to_bytes()).to_ascii_uppercase(),
1750        )]
1751        .into();
1752        assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
1753    }
1754
1755    /// One server answers a parent and its subagents: each call is the
1756    /// session plus the agent it names, and a call that names none is the
1757    /// parent's.
1758    #[tokio::test]
1759    async fn an_mcp_call_from_a_subagent_is_a_session_of_its_own() {
1760        let call = |agent: Option<&str>| {
1761            scope_session(
1762                Some("sess-mcp-0001".into()),
1763                agent.map(str::to_string),
1764                async { identity().harness },
1765            )
1766        };
1767        assert_eq!(call(Some("thread-a")).await, "sess-mcp-0001:thread-a");
1768        assert_eq!(call(Some("thread-b")).await, "sess-mcp-0001:thread-b");
1769        assert_eq!(call(None).await, "sess-mcp-0001");
1770    }
1771}