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 committed before
6//! signing began ([`SIGNING_SINCE`]) is legacy unsigned and still counts. The tracker
7//! pins each seat's public key the first time it signs (`.ljos-seat-keys`
8//! at the tracker root, committed), so a later ballot under that name signed
9//! 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 nonce (`ljos-ballot/v2`).
14//! The session is the harness id, else `VISSUE_AGENT`, else `LJOS_SEAT`,
15//! else the MCP connection. It is never a fresh nonce of this process.
16//! Minting another seat inside the same session does not make a second voter.
17
18use anyhow::{bail, Context, Result};
19use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
20use serde_json::Value;
21use std::collections::BTreeMap;
22use std::path::PathBuf;
23
24/// The note prefix a signature rides under.
25pub const SIG_NOTE: &str = "ballot-sig: ";
26/// The pin file at the tracker root.
27pub const PIN_FILE: &str = ".ljos-seat-keys";
28
29/// The bytes a ballot signature covers. v1 is the choice alone. v2 also
30/// covers the harness session and the process nonce, so a later edit of
31/// either fails the check.
32#[must_use]
33pub fn message(issue: &str, voter: &str, choice: &str) -> Vec<u8> {
34    format!(
35        "ljos-ballot/v1\n{}\n{}\n{}\n",
36        issue.trim(),
37        voter.trim(),
38        choice.trim()
39    )
40    .into_bytes()
41}
42
43/// [`message`] plus the session binding.
44#[must_use]
45pub fn message_v2(issue: &str, voter: &str, choice: &str, harness: &str, nonce: &str) -> Vec<u8> {
46    format!(
47        "ljos-ballot/v2\n{}\n{}\n{}\n{}\n{}\n",
48        issue.trim(),
49        voter.trim(),
50        choice.trim(),
51        harness.trim(),
52        nonce.trim()
53    )
54    .into_bytes()
55}
56
57/// A signature note's fields. `harness` and `nonce` are set when the note
58/// is a v2 binding.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct SigNote {
61    pub voter: String,
62    pub choice: String,
63    pub key: String,
64    pub sig: String,
65    pub harness: Option<String>,
66    pub nonce: Option<String>,
67}
68
69/// `ballot-sig: VOTER CHOICE KEYHEX SIGHEX`, or the same plus
70/// `HARNESS NONCE` when the ballot is bound to a session.
71#[must_use]
72pub fn parse_note(note: &str) -> Option<SigNote> {
73    let rest = note.trim().strip_prefix(SIG_NOTE)?;
74    let parts: Vec<&str> = rest.split_whitespace().collect();
75    match parts.as_slice() {
76        [voter, choice, key, sig] => Some(SigNote {
77            voter: (*voter).to_string(),
78            choice: (*choice).to_string(),
79            key: (*key).to_string(),
80            sig: (*sig).to_string(),
81            harness: None,
82            nonce: None,
83        }),
84        [voter, choice, key, sig, harness, nonce] => Some(SigNote {
85            voter: (*voter).to_string(),
86            choice: (*choice).to_string(),
87            key: (*key).to_string(),
88            sig: (*sig).to_string(),
89            harness: Some((*harness).to_string()),
90            nonce: Some((*nonce).to_string()),
91        }),
92        _ => None,
93    }
94}
95
96fn key_from_file(path: &std::path::Path) -> Result<SigningKey> {
97    let raw = std::fs::read(path).with_context(|| format!("seat key {}", path.display()))?;
98    let seed: [u8; 32] = raw
99        .get(..32)
100        .and_then(|s| s.try_into().ok())
101        .ok_or_else(|| anyhow::anyhow!("seat key {} holds fewer than 32 bytes", path.display()))?;
102    Ok(SigningKey::from_bytes(&seed))
103}
104
105/// The commit time of the signing change: a ballot the tracker recorded
106/// before it is legacy unsigned and still counts; one after needs a
107/// signature.
108pub const SIGNING_SINCE: i64 = 1_791_984_778;
109
110/// Where `seat`'s ballot key lives: `LJOS_SEAT_KEY` when set, else
111/// `$XDG_DATA_HOME/ljos/seats/SEAT/ballot.key` (`~/.local/share` when unset).
112/// Seats that share one HOME still get one key each.
113#[must_use]
114pub fn seat_key_path(seat: &str) -> Option<PathBuf> {
115    if let Some(p) = std::env::var_os("LJOS_SEAT_KEY").filter(|p| !p.is_empty()) {
116        return Some(PathBuf::from(p));
117    }
118    let base = std::env::var_os("XDG_DATA_HOME")
119        .filter(|p| !p.is_empty())
120        .map(PathBuf::from)
121        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))?;
122    let name: String = seat
123        .trim()
124        .chars()
125        .map(|c| {
126            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
127                c
128            } else {
129                '_'
130            }
131        })
132        .collect();
133    Some(
134        base.join("ljos")
135            .join("seats")
136            .join(name)
137            .join("ballot.key"),
138    )
139}
140
141/// The key at `path`, made on first use: 32 random bytes, mode 0600.
142///
143/// # Errors
144///
145/// The key cannot be read or written.
146pub fn load_or_create(path: &std::path::Path) -> Result<SigningKey> {
147    if !path.exists() {
148        use std::io::Read;
149        use std::os::unix::fs::OpenOptionsExt;
150        let mut seed = [0u8; 32];
151        std::fs::File::open("/dev/urandom")?.read_exact(&mut seed)?;
152        if let Some(dir) = path.parent() {
153            std::fs::create_dir_all(dir)?;
154        }
155        let mut f = std::fs::OpenOptions::new()
156            .write(true)
157            .create_new(true)
158            .mode(0o600)
159            .open(path)
160            .with_context(|| format!("seat key {}", path.display()))?;
161        std::io::Write::write_all(&mut f, &seed)?;
162    }
163    key_from_file(path)
164}
165
166/// When the tracker recorded `voter`'s ballot for `choice` on `issue`: the
167/// newest commit that added or removed its `] VOTER: CHOICE` line in the
168/// file holding the issue. `None` when the tracker is not a git repository
169/// or the ballot is not committed yet.
170#[must_use]
171pub fn recorded_at(issue: &str, voter: &str, choice: &str) -> Option<i64> {
172    let root = std::env::var_os("VISSUE_ROOT").filter(|r| !r.is_empty())?;
173    let git = |args: &[&str]| -> Option<String> {
174        let out = std::process::Command::new("git")
175            .arg("-C")
176            .arg(&root)
177            .args(args)
178            .output()
179            .ok()?;
180        out.status
181            .success()
182            .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string())
183    };
184    let pattern = format!(":ID:[[:space:]]+{issue}[[:space:]]*$");
185    let files = git(&["grep", "-l", "-E", &pattern])?;
186    let file = files.lines().next()?.to_string();
187    let needle = format!("] {}: {}", voter.trim(), choice.trim());
188    git(&["log", "-1", "--format=%ct", "-S", &needle, "--", &file])?
189        .parse()
190        .ok()
191}
192
193/// The note text for a ballot signed by `key` (v1, choice only).
194#[must_use]
195pub fn sign_with(key: &SigningKey, issue: &str, voter: &str, choice: &str) -> String {
196    let sig = key.sign(&message(issue, voter, choice));
197    format!(
198        "{SIG_NOTE}{} {} {} {}",
199        voter.trim(),
200        choice.trim(),
201        hex::encode(key.verifying_key().to_bytes()),
202        hex::encode(sig.to_bytes())
203    )
204}
205
206/// The note text for a ballot bound to a harness session and a process nonce.
207#[must_use]
208pub fn sign_bound(
209    key: &SigningKey,
210    issue: &str,
211    voter: &str,
212    choice: &str,
213    harness: &str,
214    nonce: &str,
215) -> String {
216    let harness = field(harness);
217    let nonce = field(nonce);
218    let sig = key.sign(&message_v2(issue, voter, choice, &harness, &nonce));
219    format!(
220        "{SIG_NOTE}{} {} {} {} {} {}",
221        voter.trim(),
222        choice.trim(),
223        hex::encode(key.verifying_key().to_bytes()),
224        hex::encode(sig.to_bytes()),
225        harness,
226        nonce
227    )
228}
229
230fn covered(issue: &str, note: &SigNote) -> Vec<u8> {
231    match (&note.harness, &note.nonce) {
232        (Some(h), Some(n)) => message_v2(issue, &note.voter, &note.choice, h, n),
233        _ => message(issue, &note.voter, &note.choice),
234    }
235}
236
237/// Whether `note` is a valid signature over this issue's ballot.
238#[must_use]
239pub fn verifies(issue: &str, note: &SigNote) -> bool {
240    let (Ok(k), Ok(s)) = (hex::decode(&note.key), hex::decode(&note.sig)) else {
241        return false;
242    };
243    let (Ok(k), Ok(s)) = (<[u8; 32]>::try_from(k), <[u8; 64]>::try_from(s)) else {
244        return false;
245    };
246    let Ok(vk) = VerifyingKey::from_bytes(&k) else {
247        return false;
248    };
249    vk.verify(&covered(issue, note), &Signature::from_bytes(&s))
250        .is_ok()
251}
252
253/// The tracker root the pins live at.
254fn pin_path() -> Option<PathBuf> {
255    let root = std::env::var_os("VISSUE_ROOT").filter(|r| !r.is_empty())?;
256    Some(PathBuf::from(root).join(PIN_FILE))
257}
258
259/// `voter keyhex` lines.
260#[must_use]
261pub fn parse_pins(text: &str) -> BTreeMap<String, String> {
262    text.lines()
263        .filter_map(|l| {
264            let mut it = l.split_whitespace();
265            Some((it.next()?.to_string(), it.next()?.to_string()))
266        })
267        .collect()
268}
269
270/// The pinned keys, empty when there is no pin file.
271#[must_use]
272pub fn pins() -> BTreeMap<String, String> {
273    pin_path()
274        .and_then(|p| std::fs::read_to_string(p).ok())
275        .map(|t| parse_pins(&t))
276        .unwrap_or_default()
277}
278
279/// A token safe to put in a signature note: no whitespace.
280#[must_use]
281pub fn field(raw: &str) -> String {
282    let t: String = raw
283        .trim()
284        .chars()
285        .map(|c| {
286            if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':' | '@') {
287                c
288            } else {
289                '_'
290            }
291        })
292        .collect();
293    if t.is_empty() {
294        "-".into()
295    } else {
296        t
297    }
298}
299
300/// The harness session, and a nonce that is not a process id. The session
301/// comes from the MCP connection, the hook, or the environment, then from
302/// `VISSUE_AGENT` or `LJOS_SEAT`. The nonce stays `-`: one holder is one
303/// session across processes.
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct SessionIdentity {
306    pub harness: String,
307    pub nonce: String,
308}
309
310tokio::task_local! {
311    static CALL_SESSION: String;
312}
313
314std::thread_local! {
315    static HOOK_SESSION: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
316}
317
318/// The session id the MCP call named, when this task is inside one.
319#[must_use]
320pub fn call_session() -> Option<String> {
321    CALL_SESSION
322        .try_with(|s| s.clone())
323        .ok()
324        .filter(|s| !s.is_empty())
325}
326
327/// Run `f` as an MCP call that named `session`.
328pub async fn scope_session<F: std::future::Future>(session: Option<String>, f: F) -> F::Output {
329    match session.map(|s| field(&s)).filter(|s| s != "-") {
330        Some(s) => CALL_SESSION.scope(s, f).await,
331        None => f.await,
332    }
333}
334
335/// Remember the hook payload's session for this process, and export it for
336/// a child when the environment has not already named one.
337pub fn export_hook_session(id: Option<&str>) {
338    crate::with_env_mutation(|| {
339        let id = id.map(str::trim).filter(|s| !s.is_empty()).map(field);
340        HOOK_SESSION.with(|c| *c.borrow_mut() = id.clone());
341        if let Some(id) = id {
342            if std::env::var("LJOS_HARNESS_SESSION")
343                .ok()
344                .filter(|s| !s.trim().is_empty())
345                .is_none()
346            {
347                std::env::set_var("LJOS_HARNESS_SESSION", id);
348            }
349        }
350    });
351}
352
353fn env_token(key: &str) -> Option<String> {
354    let v = std::env::var(key).ok()?;
355    let v = v.trim();
356    if v.is_empty() {
357        return None;
358    }
359    Some(field(v))
360}
361
362static CONNECTION: std::sync::OnceLock<String> = std::sync::OnceLock::new();
363
364/// Pin the MCP connection's session for this process. The first call wins.
365/// A later `_meta` session id and a later `seat` argument do not replace it.
366/// With no hint, the runner's `LJOS_HARNESS_SESSION` is the connection.
367/// Nothing is pinned when that is empty: a per-process nonce is not a session.
368pub fn pin_connection_session(hint: Option<&str>) {
369    let Some(chosen) = hint
370        .map(str::trim)
371        .filter(|s| !s.is_empty())
372        .map(field)
373        .filter(|s| s != "-")
374        .or_else(|| env_token("LJOS_HARNESS_SESSION"))
375    else {
376        return;
377    };
378    let _ = CONNECTION.set(chosen);
379}
380
381/// The session pinned for this MCP connection, when [`pin_connection_session`]
382/// has run.
383#[must_use]
384pub fn connection_session() -> Option<String> {
385    CONNECTION.get().cloned()
386}
387
388/// The harness session id: the MCP connection, then the MCP call, then the
389/// hook, then `LJOS_HARNESS_SESSION`, then a runner `*_SESSION_ID` / thread id.
390/// The connection pin is the process, not the `seat` argument.
391#[must_use]
392pub fn harness_session() -> String {
393    if let Some(s) = connection_session() {
394        return s;
395    }
396    if let Some(s) = call_session() {
397        return s;
398    }
399    if let Some(s) = HOOK_SESSION.with(|c| c.borrow().clone()) {
400        return s;
401    }
402    if let Some(s) = env_token("LJOS_HARNESS_SESSION") {
403        return s;
404    }
405    const PREFER: &[&str] = &[
406        "CURSOR_SESSION_ID",
407        "CLAUDE_SESSION_ID",
408        "CODEX_THREAD_ID",
409        "CODEX_SESSION_ID",
410        "GROK_SESSION_ID",
411        "SEAT_SESSION_ID",
412        "LJOS_SESSION_ID",
413    ];
414    for key in PREFER {
415        if let Some(s) = env_token(key) {
416            if s.len() >= 8 {
417                return s;
418            }
419        }
420    }
421    crate::runner_session_id()
422        .map(|s| field(&s))
423        .unwrap_or_default()
424}
425
426fn random_hex(n: usize) -> String {
427    use std::io::Read;
428    let mut buf = vec![0u8; n];
429    if std::fs::File::open("/dev/urandom")
430        .and_then(|mut f| f.read_exact(&mut buf))
431        .is_ok()
432    {
433        return hex::encode(buf);
434    }
435    format!("{:x}{:x}", std::process::id(), std::process::id())
436}
437
438static PROCESS_NONCE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
439
440/// The nonce of this process. Seats this process mints share it.
441#[must_use]
442pub fn process_nonce() -> String {
443    if let Some(s) = env_token("LJOS_PROCESS_NONCE") {
444        if s != "-" {
445            return s;
446        }
447    }
448    PROCESS_NONCE.get_or_init(|| random_hex(16)).clone()
449}
450
451/// A person-named agent or seat, when it is not a pronoun.
452fn named_session_actor(key: &str) -> Option<String> {
453    let name = env_token(key)?;
454    let lower = name.to_ascii_lowercase();
455    if matches!(lower.as_str(), "seat" | "you" | "agent" | "runner" | "-") {
456        return None;
457    }
458    Some(name)
459}
460
461/// This process's session identity. Stable across processes of one holder.
462#[must_use]
463pub fn identity() -> SessionIdentity {
464    let harness = harness_session();
465    let harness = if !harness.is_empty() {
466        harness
467    } else {
468        named_session_actor("VISSUE_AGENT")
469            .or_else(|| named_session_actor("LJOS_SEAT"))
470            .unwrap_or_else(|| "-".into())
471    };
472    SessionIdentity {
473        harness,
474        nonce: "-".into(),
475    }
476}
477
478/// The key `voter` signs with, pinned on first use.
479fn signing_key(voter: &str) -> Result<(SigningKey, String)> {
480    let path = seat_key_path(voter)
481        .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"))?;
482    let key = load_or_create(&path)?;
483    let mine = hex::encode(key.verifying_key().to_bytes());
484    let mut pinned = pins();
485    match pinned.get(voter.trim()) {
486        Some(k) if *k != mine => bail!(
487            "seat {voter} is pinned to key {}…, and this key is {}…; the ballot is unsigned and no gate counts it",
488            &k[..12.min(k.len())],
489            &mine[..12]
490        ),
491        Some(_) => {}
492        None => {
493            if let Some(p) = pin_path() {
494                pinned.insert(voter.trim().to_string(), mine.clone());
495                let text: String = pinned.iter().map(|(v, k)| format!("{v} {k}\n")).collect();
496                std::fs::write(&p, text)?;
497                if let Some(root) = p.parent() {
498                    let _ = std::process::Command::new("git")
499                        .arg("-C")
500                        .arg(root)
501                        .args(["add", PIN_FILE])
502                        .output();
503                }
504            }
505        }
506    }
507    Ok((key, mine))
508}
509
510/// Sign this seat's ballot, bound to the harness session, and leave the
511/// note on the issue. A seat whose key differs from
512/// its pin is refused, and the ballot stays unsigned (so the gate will not
513/// count it).
514///
515/// # Errors
516///
517/// No seat key, a key that does not match the pin, or a tracker write that
518/// failed.
519pub fn sign_ballot(issue: &str, voter: &str, choice: &str) -> Result<String> {
520    let (key, mine) = signing_key(voter)?;
521    let id = identity();
522    let note = sign_bound(&key, issue, voter, choice, &id.harness, &id.nonce);
523    crate::run_captured("vissue", &["note", issue, &note])?;
524    let session = if id.harness == "-" {
525        format!("nonce {}", &id.nonce[..12.min(id.nonce.len())])
526    } else {
527        id.harness.clone()
528    };
529    Ok(format!(
530        "ballot signed by {voter} with key {}… session {session}\n",
531        &mine[..12.min(mine.len())]
532    ))
533}
534
535/// The note prefix a design candidate's signature rides under.
536pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
537
538/// The bytes a candidate signature covers, including which other candidates
539/// this session had already read.
540#[must_use]
541pub fn candidate_message(
542    issue: &str,
543    seat: &str,
544    harness: &str,
545    nonce: &str,
546    saw: &str,
547) -> Vec<u8> {
548    format!(
549        "ljos-candidate/v1\n{}\n{}\n{}\n{}\n{}\n",
550        issue.trim(),
551        seat.trim(),
552        harness.trim(),
553        nonce.trim(),
554        saw.trim()
555    )
556    .into_bytes()
557}
558
559/// A signed candidate binding.
560#[derive(Debug, Clone, PartialEq, Eq)]
561pub struct CandidateNote {
562    pub issue: String,
563    pub seat: String,
564    pub harness: String,
565    pub nonce: String,
566    pub saw: Vec<String>,
567    pub key: String,
568    pub sig: String,
569}
570
571fn saw_token(saw: &[String]) -> String {
572    if saw.is_empty() {
573        "-".into()
574    } else {
575        saw.iter().map(|s| field(s)).collect::<Vec<_>>().join(",")
576    }
577}
578
579fn saw_list(token: &str) -> Vec<String> {
580    if token.is_empty() || token == "-" {
581        Vec::new()
582    } else {
583        token
584            .split(',')
585            .map(str::trim)
586            .filter(|s| !s.is_empty() && *s != "-")
587            .map(str::to_string)
588            .collect()
589    }
590}
591
592/// `candidate-sig: ISSUE SEAT HARNESS NONCE SAW KEYHEX SIGHEX`.
593#[must_use]
594pub fn parse_candidate(note: &str) -> Option<CandidateNote> {
595    let rest = note.trim().strip_prefix(CANDIDATE_NOTE)?;
596    let parts: Vec<&str> = rest.split_whitespace().collect();
597    let [issue, seat, harness, nonce, saw, key, sig] = parts.as_slice() else {
598        return None;
599    };
600    Some(CandidateNote {
601        issue: (*issue).to_string(),
602        seat: (*seat).to_string(),
603        harness: (*harness).to_string(),
604        nonce: (*nonce).to_string(),
605        saw: saw_list(saw),
606        key: (*key).to_string(),
607        sig: (*sig).to_string(),
608    })
609}
610
611/// Whether `note` is a valid signature over that candidate binding.
612#[must_use]
613pub fn candidate_verifies(note: &CandidateNote) -> bool {
614    let (Ok(k), Ok(s)) = (hex::decode(&note.key), hex::decode(&note.sig)) else {
615        return false;
616    };
617    let (Ok(k), Ok(s)) = (<[u8; 32]>::try_from(k), <[u8; 64]>::try_from(s)) else {
618        return false;
619    };
620    let Ok(vk) = VerifyingKey::from_bytes(&k) else {
621        return false;
622    };
623    vk.verify(
624        &candidate_message(
625            &note.issue,
626            &note.seat,
627            &note.harness,
628            &note.nonce,
629            &saw_token(&note.saw),
630        ),
631        &Signature::from_bytes(&s),
632    )
633    .is_ok()
634}
635
636/// Sign `issue` as a design candidate of `seat` and return the note.
637///
638/// # Errors
639///
640/// No seat key, or a key that does not match the pin.
641pub fn sign_candidate(
642    issue: &str,
643    seat: &str,
644    id: &SessionIdentity,
645    saw: &[String],
646) -> Result<String> {
647    let (key, _) = signing_key(seat)?;
648    let harness = field(&id.harness);
649    let nonce = field(&id.nonce);
650    let saw_tok = saw_token(saw);
651    let sig = key.sign(&candidate_message(issue, seat, &harness, &nonce, &saw_tok));
652    Ok(format!(
653        "{CANDIDATE_NOTE}{} {} {} {} {} {} {}",
654        field(issue),
655        field(seat),
656        harness,
657        nonce,
658        saw_tok,
659        hex::encode(key.verifying_key().to_bytes()),
660        hex::encode(sig.to_bytes())
661    ))
662}
663
664/// Problems with the seat ballots on `issue`: each `(voter, choice)` needs
665/// its newest signature note to name that choice, verify, and use the
666/// pinned key. `notes` are the issue's logbook notes, newest first.
667#[must_use]
668pub fn seat_problems(
669    issue: &str,
670    seats: &[(&str, &str)],
671    notes: &[String],
672    pins: &BTreeMap<String, String>,
673) -> Vec<String> {
674    seat_check(issue, seats, notes, pins, &|_, _| false).0
675}
676
677/// [`seat_problems`] with grandfathering: an unsigned ballot `legacy`
678/// says the tracker recorded before signing began counts, and is named in
679/// the second list as legacy unsigned.
680#[must_use]
681pub fn seat_check(
682    issue: &str,
683    seats: &[(&str, &str)],
684    notes: &[String],
685    pins: &BTreeMap<String, String>,
686    legacy: &dyn Fn(&str, &str) -> bool,
687) -> (Vec<String>, Vec<String>) {
688    let mut out = Vec::new();
689    let mut old = Vec::new();
690    for (voter, choice) in seats {
691        let newest = notes
692            .iter()
693            .filter_map(|n| parse_note(n))
694            .find(|n| n.voter == *voter);
695        match newest {
696            None if legacy(voter, choice) => old.push((*voter).to_string()),
697            None => out.push(format!("{voter}'s ballot is unsigned")),
698            Some(n) if n.choice != *choice => out.push(format!(
699                "{voter}'s signature is for {} and the ballot says {choice}",
700                n.choice
701            )),
702            Some(n) if !verifies(issue, &n) => {
703                out.push(format!("{voter}'s signature does not verify"));
704            }
705            Some(n) => match pins.get(*voter) {
706                Some(k) if *k == n.key => {}
707                Some(_) => out.push(format!(
708                    "{voter}'s ballot is signed by a key other than its pinned one"
709                )),
710                None => out.push(format!("{voter} has no pinned key on this tracker")),
711            },
712        }
713    }
714    (out, old)
715}
716
717/// The logbook notes of an issue JSON, newest first.
718#[must_use]
719pub fn notes_of(v: &Value) -> Vec<String> {
720    v["logbook"]
721        .as_array()
722        .into_iter()
723        .flatten()
724        .filter_map(|e| e["note"].as_str().map(str::to_string))
725        .collect()
726}
727
728/// Whether unsigned seat ballots are let through on purpose
729/// (`LJOS_UNSIGNED_BALLOTS=allow`), for a tracker from before signing.
730#[must_use]
731pub fn unsigned_allowed() -> bool {
732    std::env::var("LJOS_UNSIGNED_BALLOTS").is_ok_and(|v| v == "allow")
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738
739    fn key(b: u8) -> SigningKey {
740        SigningKey::from_bytes(&[b; 32])
741    }
742
743    #[test]
744    fn a_signed_pinned_ballot_counts_and_every_forgery_does_not() {
745        let alice = key(1);
746        let mallory = key(2);
747        let pins: BTreeMap<String, String> = [(
748            "alice".to_string(),
749            hex::encode(alice.verifying_key().to_bytes()),
750        )]
751        .into();
752        let good = sign_with(&alice, "d-1", "alice", "ship");
753        assert!(seat_problems(
754            "d-1",
755            &[("alice", "ship")],
756            std::slice::from_ref(&good),
757            &pins
758        )
759        .is_empty());
760        // Unsigned.
761        let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
762        assert!(p[0].contains("unsigned"), "{p:?}");
763        // Signed by another key under alice's name.
764        let forged = sign_with(&mallory, "d-1", "alice", "ship");
765        let p = seat_problems("d-1", &[("alice", "ship")], &[forged], &pins);
766        assert!(p[0].contains("other than its pinned"), "{p:?}");
767        // The ballot changed after signing.
768        let p = seat_problems(
769            "d-1",
770            &[("alice", "hold")],
771            std::slice::from_ref(&good),
772            &pins,
773        );
774        assert!(p[0].contains("is for ship"), "{p:?}");
775        // A signature lifted from another issue.
776        let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
777        assert!(p[0].contains("does not verify"), "{p:?}");
778        // No pin on this tracker.
779        let lone = sign_with(&mallory, "d-1", "bob", "ship");
780        let p = seat_problems("d-1", &[("bob", "ship")], &[lone], &pins);
781        assert!(p[0].contains("no pinned key"), "{p:?}");
782    }
783
784    #[test]
785    fn an_old_unsigned_ballot_is_legacy_and_a_new_one_is_not() {
786        let pins = BTreeMap::new();
787        let legacy = |v: &str, _: &str| v == "inky";
788        let (bad, old) = seat_check(
789            "d-1",
790            &[("inky", "ship"), ("bob", "ship")],
791            &[],
792            &pins,
793            &legacy,
794        );
795        assert_eq!(old, vec!["inky".to_string()]);
796        assert_eq!(bad, vec!["bob's ballot is unsigned".to_string()]);
797    }
798
799    #[test]
800    fn seats_sharing_a_home_get_their_own_keys() {
801        let dir = tempfile::tempdir().unwrap();
802        let a = dir.path().join("ljos/seats/inky/ballot.key");
803        let b = dir.path().join("ljos/seats/disk-saver/ballot.key");
804        let ka = load_or_create(&a).unwrap();
805        let kb = load_or_create(&b).unwrap();
806        assert_ne!(ka.verifying_key(), kb.verifying_key());
807        assert_eq!(
808            load_or_create(&a).unwrap().verifying_key(),
809            ka.verifying_key()
810        );
811        use std::os::unix::fs::PermissionsExt;
812        assert_eq!(
813            std::fs::metadata(&a).unwrap().permissions().mode() & 0o777,
814            0o600
815        );
816    }
817
818    #[test]
819    fn the_newest_signature_wins() {
820        let a = key(3);
821        let pins: BTreeMap<String, String> =
822            [("a".to_string(), hex::encode(a.verifying_key().to_bytes()))].into();
823        let new = sign_with(&a, "d-1", "a", "hold");
824        let old = sign_with(&a, "d-1", "a", "ship");
825        assert!(seat_problems("d-1", &[("a", "hold")], &[new, old], &pins).is_empty());
826    }
827
828    #[test]
829    fn a_session_binding_is_signed_and_a_changed_session_is_not() {
830        let alice = key(4);
831        let note = sign_bound(&alice, "d-1", "alice", "ship", "sess-alice-0001", "nonce-1");
832        let parsed = parse_note(&note).unwrap();
833        assert_eq!(parsed.harness.as_deref(), Some("sess-alice-0001"));
834        assert_eq!(parsed.nonce.as_deref(), Some("nonce-1"));
835        assert!(verifies("d-1", &parsed));
836        let mut moved = parsed.clone();
837        moved.harness = Some("sess-other-0001".into());
838        assert!(
839            !verifies("d-1", &moved),
840            "a rewritten session still verified"
841        );
842        let pins: BTreeMap<String, String> = [(
843            "alice".to_string(),
844            hex::encode(alice.verifying_key().to_bytes()),
845        )]
846        .into();
847        assert!(seat_problems("d-1", &[("alice", "ship")], &[note], &pins).is_empty());
848    }
849}