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