Skip to main content

speccoder_web/
fixes.rs

1//! The person's own commands from the page: only what this host issued.
2//!
3//! A fix that is the person's own command reaches the page as a typed
4//! step speccoder built ([`How::Command`]): exact argvs, built by their
5//! source or one of speccoder's constant lines. While the host builds and
6//! serialises a result, every such step is signed for its project —
7//! HMAC-SHA-256 over the project folder, the argvs and whether they read
8//! the keyboard, with a key the page never sees ([`Issuer::signing`]).
9//! JSON from elsewhere is never signed. `POST /api/fix/run` runs argvs
10//! only with their signature: what speccoder printed, and nothing the
11//! page composed, edited or took from a model's words.
12//!
13//! [`How::Command`]: speccoder_core::step::How::Command
14
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use sha2::{Digest, Sha256};
19
20/// Signs and checks the argvs this host issued.
21pub struct Issuer {
22    key: [u8; 32],
23}
24
25/// The key stays out of every log and panic message.
26impl std::fmt::Debug for Issuer {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.write_str("Issuer { key: [redacted] }")
29    }
30}
31
32impl Issuer {
33    /// A fresh key; `None` without an OS random source.
34    #[must_use]
35    pub fn new() -> Option<Self> {
36        let mut key = [0u8; 32];
37        getrandom::fill(&mut key).ok()?;
38        Some(Self { key })
39    }
40
41    fn mac(&self, root: &Path, argvs: &[Vec<String>], interactive: bool) -> String {
42        const BLOCK: usize = 64;
43        let mut k = [0u8; BLOCK];
44        k[..self.key.len()].copy_from_slice(&self.key);
45        let message = serde_json::to_vec(&(root.display().to_string(), argvs, interactive))
46            .unwrap_or_default();
47        let pad = |b: u8| k.iter().map(|x| x ^ b).collect::<Vec<u8>>();
48        let inner = Sha256::new()
49            .chain_update(pad(0x36))
50            .chain_update(&message)
51            .finalize();
52        let outer = Sha256::new()
53            .chain_update(pad(0x5c))
54            .chain_update(inner)
55            .finalize();
56        outer.iter().map(|b| format!("{b:02x}")).collect()
57    }
58
59    /// Whether `token` is this host's signature of `argvs` in `root`, as a
60    /// step that does (`interactive`) or does not read the keyboard.
61    #[must_use]
62    pub fn issued(
63        &self,
64        root: &Path,
65        argvs: &[Vec<String>],
66        interactive: bool,
67        token: &str,
68    ) -> bool {
69        !argvs.is_empty()
70            && argvs.iter().all(|a| !a.is_empty())
71            && crate::security::constant_time_eq(
72                self.mac(root, argvs, interactive).as_bytes(),
73                token.as_bytes(),
74            )
75    }
76
77    /// Runs `f` with every command step speccoder builds meanwhile on this
78    /// thread signed for the project at `root`.
79    pub fn signing<T>(self: &Arc<Self>, root: PathBuf, f: impl FnOnce() -> T) -> T {
80        let issuer = Arc::clone(self);
81        speccoder_core::step::signing(
82            std::rc::Rc::new(move |argvs: &[Vec<String>], interactive: bool| {
83                issuer.mac(&root, argvs, interactive)
84            }),
85            f,
86        )
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use speccoder_core::step::{How, Step, person};
93
94    use super::*;
95
96    fn s(v: &[&str]) -> Vec<String> {
97        v.iter().map(|w| (*w).to_owned()).collect()
98    }
99
100    fn token_of(step: &Step) -> String {
101        match &step.how {
102            How::Command { token: Some(t), .. } => t.clone(),
103            other => panic!("{other:?}"),
104        }
105    }
106
107    #[test]
108    fn only_what_it_signed_runs() {
109        let issuer = Arc::new(Issuer::new().unwrap_or_else(|| panic!("no random source")));
110        let root = Path::new("/p");
111        let step = issuer.signing(root.to_path_buf(), || person(vec![s(&["npm", "install"])]));
112        let token = token_of(&step);
113        assert_eq!(token.len(), 64);
114        assert!(issuer.issued(root, &[s(&["npm", "install"])], false, &token));
115        // Changed argvs, another project (a host bound elsewhere), the
116        // keyboard flag flipped, another host's key, no token.
117        assert!(!issuer.issued(root, &[s(&["npm", "install", "evil"])], false, &token));
118        assert!(!issuer.issued(Path::new("/q"), &[s(&["npm", "install"])], false, &token));
119        assert!(!issuer.issued(root, &[s(&["npm", "install"])], true, &token));
120        let other = Issuer::new().unwrap_or_else(|| panic!("no random source"));
121        assert!(!other.issued(root, &[s(&["npm", "install"])], false, &token));
122        assert!(!issuer.issued(root, &[s(&["npm", "install"])], false, ""));
123        assert!(!issuer.issued(root, &[], false, &issuer.mac(root, &[], false)));
124        // A sign-in is signed as one: it cannot pass as a command to run here.
125        let login = issuer.signing(root.to_path_buf(), || {
126            person(vec![s(&["copilot", "login"])])
127        });
128        let t = token_of(&login);
129        assert!(issuer.issued(root, &[s(&["copilot", "login"])], true, &t));
130        assert!(!issuer.issued(root, &[s(&["copilot", "login"])], false, &t));
131    }
132
133    #[test]
134    fn json_from_elsewhere_is_never_signed() {
135        let issuer = Arc::new(Issuer::new().unwrap_or_else(|| panic!("no random source")));
136        let v = issuer.signing(PathBuf::from("/p"), || {
137            serde_json::from_str::<serde_json::Value>(
138                r#"{"how":"command","argvs":[["rm","-rf","/"]],"interactive":false}"#,
139            )
140            .unwrap_or_default()
141        });
142        assert!(v.get("token").is_none());
143        assert!(!format!("{issuer:?}").contains(&format!("{:?}", issuer.key)));
144    }
145}