1use 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
24pub const SIG_NOTE: &str = "ballot-sig: ";
26pub const PIN_FILE: &str = ".ljos-seat-keys";
28
29#[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#[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#[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#[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
105pub const SIGNING_SINCE: i64 = 1_791_984_778;
109
110#[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
141pub 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#[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#[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#[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 (¬e.harness, ¬e.nonce) {
232 (Some(h), Some(n)) => message_v2(issue, ¬e.voter, ¬e.choice, h, n),
233 _ => message(issue, ¬e.voter, ¬e.choice),
234 }
235}
236
237#[must_use]
239pub fn verifies(issue: &str, note: &SigNote) -> bool {
240 let (Ok(k), Ok(s)) = (hex::decode(¬e.key), hex::decode(¬e.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
253fn 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#[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#[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#[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#[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#[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
327pub 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
335pub 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
364pub 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#[must_use]
384pub fn connection_session() -> Option<String> {
385 CONNECTION.get().cloned()
386}
387
388#[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#[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
451fn 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#[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
478fn 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
510pub 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, ¬e])?;
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
535pub const CANDIDATE_NOTE: &str = "candidate-sig: ";
537
538#[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#[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#[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#[must_use]
613pub fn candidate_verifies(note: &CandidateNote) -> bool {
614 let (Ok(k), Ok(s)) = (hex::decode(¬e.key), hex::decode(¬e.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 ¬e.issue,
626 ¬e.seat,
627 ¬e.harness,
628 ¬e.nonce,
629 &saw_token(¬e.saw),
630 ),
631 &Signature::from_bytes(&s),
632 )
633 .is_ok()
634}
635
636pub 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#[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#[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#[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#[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 let p = seat_problems("d-1", &[("alice", "ship")], &[], &pins);
762 assert!(p[0].contains("unsigned"), "{p:?}");
763 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 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 let p = seat_problems("d-2", &[("alice", "ship")], &[good], &pins);
777 assert!(p[0].contains("does not verify"), "{p:?}");
778 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(¬e).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}