Skip to main content

hatchery_node/
keystore_proxy.rs

1//! Node-side keystore proxy (was `m4a_signer_proxy`) (option A, `specs/m4a-session-keys-2026-10-16.md`).
2//!
3//! The node holds NO m4a private keys. For each PTY session it keeps the session's capability
4//! (issued by the hq-core signer) in its own memory and gives the PTY only a **handle**
5//! (`M4A_SESSION_HANDLE`) plus the nick. A handle is useless on its own: the proxy accepts a
6//! request on its UDS only if the kernel-reported peer (`SO_PEERCRED`) has the session's uid
7//! and its pid is the session's PTY root process or a descendant of it. A neighbour shell on
8//! the same node that steals the handle (env, `/proc/<pid>/environ`, a prompt injection) is
9//! refused, and nothing it could steal lets it sign: the capability never enters the PTY and
10//! the key never leaves hq-core.
11
12use std::collections::HashMap;
13use std::sync::{Arc, Mutex};
14
15use serde::{Deserialize, Serialize};
16use zeroize::Zeroizing;
17
18pub const HANDLE_ENV: &str = "M4A_SESSION_HANDLE";
19pub const SOCKET_ENV: &str = "M4A_SIGNER_SOCK";
20
21/// The hq-core signer as seen from the node (in production: a call over the C2 uplink).
22pub trait CoreSigner: Send + Sync {
23    fn sign(&self, token: &str, op: &str, payload: &[u8]) -> Result<SignedReply, String>;
24    /// Identity for a fresh session: (nick, token, public key hex). Default: not supported.
25    fn provision(&self, _provider: &str, _session: &str) -> Result<(String, String, String), String> {
26        Err("provision not supported".into())
27    }
28    /// Signer-signed letter delivered by core from the token's own nick.
29    fn send(&self, _token: &str, _letter: &serde_json::Value) -> Result<serde_json::Value, String> {
30        Err("send not supported".into())
31    }
32    fn revoke(&self, _session: &str) -> Result<(), String> {
33        Ok(())
34    }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct SignedReply {
39    pub nick: String,
40    pub public_key_hex: String,
41    pub signature_hex: String,
42}
43
44/// Parent-pid lookup; `/proc` on Linux, injectable for tests.
45pub trait ProcTree: Send + Sync {
46    fn parent(&self, pid: u32) -> Option<u32>;
47}
48
49pub struct LinuxProc;
50
51impl ProcTree for LinuxProc {
52    fn parent(&self, pid: u32) -> Option<u32> {
53        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
54        // `pid (comm) state ppid ...` — comm may contain spaces/parens; split after the last ')'.
55        let rest = &stat[stat.rfind(')')? + 1..];
56        rest.split_whitespace().nth(1)?.parse().ok()
57    }
58}
59
60struct Binding {
61    session: String,
62    provider: String,
63    nick: String,
64    /// 0 until the PTY child is running (requests before that are refused).
65    root_pid: u32,
66    uid: u32,
67    /// Empty until the first request provisions the identity at core.
68    token: Zeroizing<String>,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Refusal {
73    UnknownHandle,
74    WrongUser,
75    NotInSession,
76    Upstream(String),
77}
78
79pub struct SignerProxy {
80    bindings: Mutex<HashMap<String, Binding>>,
81    proc_tree: Box<dyn ProcTree>,
82    core: Arc<dyn CoreSigner>,
83}
84
85impl SignerProxy {
86    pub fn new(core: Arc<dyn CoreSigner>) -> Self {
87        Self::with_proc(core, Box::new(LinuxProc))
88    }
89
90    pub fn with_proc(core: Arc<dyn CoreSigner>, proc_tree: Box<dyn ProcTree>) -> Self {
91        Self { bindings: Mutex::new(HashMap::new()), proc_tree, core }
92    }
93
94    /// Called by spawn once the PTY child exists. Returns the handle for the PTY env.
95    /// A rebind of the same session drops its previous handle.
96    pub fn bind(&self, session: &str, nick: &str, root_pid: u32, uid: u32, token: String) -> String {
97        let mut raw = [0u8; 24];
98        fill_random(&mut raw);
99        let handle = hex::encode(raw);
100        let mut b = self.bindings.lock().unwrap_or_else(|p| p.into_inner());
101        b.retain(|_, v| v.session != session);
102        b.insert(
103            handle.clone(),
104            Binding { session: session.to_owned(), provider: String::new(), nick: nick.to_owned(), root_pid, uid, token: Zeroizing::new(token) },
105        );
106        handle
107    }
108
109    /// At spawn, before the PTY exists: a handle for its env; identity is provisioned lazily.
110    pub fn reserve(&self, session: &str, provider: &str, uid: u32) -> String {
111        let h = self.bind(session, "", 0, uid, String::new());
112        if let Some(b) = self.bindings.lock().unwrap_or_else(|p| p.into_inner()).get_mut(&h) {
113            b.provider = provider.to_owned();
114        }
115        h
116    }
117
118    /// The PTY child is running: bind the handle to its process tree.
119    pub fn attach_pid(&self, session: &str, pid: u32) {
120        for b in self.bindings.lock().unwrap_or_else(|p| p.into_inner()).values_mut() {
121            if b.session == session && b.root_pid == 0 {
122                b.root_pid = pid;
123            }
124        }
125    }
126
127    pub fn has_session(&self, session: &str) -> bool {
128        self.bindings.lock().unwrap_or_else(|p| p.into_inner()).values().any(|b| b.session == session)
129    }
130
131    /// Stop: drop the handle and revoke at core.
132    pub fn release(&self, session: &str) {
133        if self.has_session(session) {
134            self.unbind(session);
135            if let Err(e) = self.core.revoke(session) {
136                tracing::warn!(error = %e, "m4a signer revoke failed");
137            }
138        }
139    }
140
141    /// Authorize the peer and return (token, nick), provisioning at core on first use.
142    fn authorize(&self, handle: &str, peer_pid: u32, peer_uid: u32) -> Result<(Zeroizing<String>, String), Refusal> {
143        let (session, provider, token, nick) = {
144            let b = self.bindings.lock().unwrap_or_else(|p| p.into_inner());
145            let v = b.iter().find(|(h, _)| ct_eq(h.as_bytes(), handle.as_bytes())).map(|(_, v)| v).ok_or(Refusal::UnknownHandle)?;
146            if v.uid != peer_uid {
147                return Err(Refusal::WrongUser);
148            }
149            if v.root_pid == 0 || !self.in_tree(v.root_pid, peer_pid) {
150                return Err(Refusal::NotInSession);
151            }
152            (v.session.clone(), v.provider.clone(), v.token.clone(), v.nick.clone())
153        };
154        if !token.is_empty() {
155            return Ok((token, nick));
156        }
157        let (nick, token, public_key) = self.core.provision(&provider, &session).map_err(Refusal::Upstream)?;
158        // The task manager binds the session record to this key (its own signed acts).
159        crate::git_watch::session_key(&session, &public_key);
160        let mut b = self.bindings.lock().unwrap_or_else(|p| p.into_inner());
161        let v = b.get_mut(handle).ok_or(Refusal::UnknownHandle)?;
162        v.token = Zeroizing::new(token.clone());
163        v.nick = nick.clone();
164        Ok((Zeroizing::new(token), nick))
165    }
166
167    /// Send a letter as the peer's own session (core signs and delivers).
168    pub fn send_for_peer(&self, handle: &str, peer_pid: u32, peer_uid: u32, letter: &serde_json::Value) -> Result<serde_json::Value, Refusal> {
169        let (token, nick) = self.authorize(handle, peer_pid, peer_uid)?;
170        let out = self.core.send(&token, letter).map_err(Refusal::Upstream)?;
171        if out.get("from").and_then(|v| v.as_str()) != Some(nick.as_str()) {
172            return Err(Refusal::Upstream("core answered for another nick".into()));
173        }
174        Ok(out)
175    }
176
177    /// Session stopped: its handle stops working at once.
178    pub fn unbind(&self, session: &str) {
179        self.bindings.lock().unwrap_or_else(|p| p.into_inner()).retain(|_, v| v.session != session);
180    }
181
182    fn in_tree(&self, root: u32, mut pid: u32) -> bool {
183        for _ in 0..64 {
184            if pid == root {
185                return true;
186            }
187            match self.proc_tree.parent(pid) {
188                Some(p) if p != 0 && p != pid => pid = p,
189                _ => return false,
190            }
191        }
192        false
193    }
194
195    /// Authorize a peer (kernel-reported pid/uid) presenting `handle`, then ask core to sign.
196    pub fn sign_for_peer(&self, handle: &str, peer_pid: u32, peer_uid: u32, op: &str, payload: &[u8]) -> Result<SignedReply, Refusal> {
197        let (token, nick) = self.authorize(handle, peer_pid, peer_uid)?;
198        let reply = self.core.sign(&token, op, payload).map_err(Refusal::Upstream)?;
199        if reply.nick != nick {
200            return Err(Refusal::Upstream("core answered for another nick".into()));
201        }
202        Ok(reply)
203    }
204}
205
206fn ct_eq(a: &[u8], b: &[u8]) -> bool {
207    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
208}
209
210fn fill_random(buf: &mut [u8]) {
211    use std::io::Read;
212    std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(buf)).expect("/dev/urandom");
213}
214
215#[derive(Debug, Serialize, Deserialize)]
216pub struct ProxyRequest {
217    pub handle: String,
218    pub op: String,
219    pub payload_hex: String,
220}
221
222#[derive(Debug, Serialize, Deserialize)]
223#[serde(tag = "status", rename_all = "snake_case")]
224pub enum ProxyResponse {
225    Signed(SignedReply),
226    Sent { result: serde_json::Value },
227    Refused { reason: String },
228}
229
230/// Serve the proxy on a Unix socket (0600 dir/socket owned by the node user). One JSON line
231/// request per connection; peer identity comes from `SO_PEERCRED`, never from the request.
232#[cfg(unix)]
233pub async fn serve(proxy: Arc<SignerProxy>, listener: tokio::net::UnixListener) {
234    use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
235    loop {
236        let Ok((stream, _)) = listener.accept().await else { continue };
237        let proxy = proxy.clone();
238        tokio::spawn(async move {
239            let cred = match stream.peer_cred() {
240                Ok(c) => c,
241                Err(_) => return,
242            };
243            let (r, mut w) = stream.into_split();
244            let mut line = String::new();
245            let mut reader = BufReader::new(r.take(64 * 1024));
246            if reader.read_line(&mut line).await.is_err() {
247                return;
248            }
249            let resp = match serde_json::from_str::<ProxyRequest>(&line) {
250                Err(_) => ProxyResponse::Refused { reason: "bad request".into() },
251                Ok(req) => match (hex::decode(&req.payload_hex), cred.pid()) {
252                    (Ok(payload), Some(pid)) if pid > 0 => {
253                        let proxy = proxy.clone();
254                        let uid = cred.uid();
255                        let res = tokio::task::spawn_blocking(move || {
256                            if req.op == "send" {
257                                let letter: serde_json::Value = serde_json::from_slice(&payload).map_err(|_| Refusal::Upstream("bad letter".into()))?;
258                                proxy.send_for_peer(&req.handle, pid as u32, uid, &letter).map(|result| ProxyResponse::Sent { result })
259                            } else {
260                                proxy.sign_for_peer(&req.handle, pid as u32, uid, &req.op, &payload).map(ProxyResponse::Signed)
261                            }
262                        })
263                        .await;
264                        match res {
265                            Ok(Ok(r)) => r,
266                            Ok(Err(e)) => {
267                                tracing::warn!(peer_pid = pid, reason = ?e, "m4a signer proxy refused a request");
268                                ProxyResponse::Refused { reason: format!("{e:?}") }
269                            }
270                            Err(_) => ProxyResponse::Refused { reason: "internal".into() },
271                        }
272                    }
273                    _ => ProxyResponse::Refused { reason: "bad request".into() },
274                },
275            };
276            let mut out = serde_json::to_vec(&resp).unwrap_or_default();
277            out.push(b'\n');
278            let _ = w.write_all(&out).await;
279        });
280    }
281}
282
283/// Blocking client (what the session-side `m4a` tool does).
284#[cfg(unix)]
285pub fn request(sock: &std::path::Path, handle: &str, op: &str, payload: &[u8]) -> std::io::Result<ProxyResponse> {
286    use std::io::{BufRead, Write};
287    let mut s = std::os::unix::net::UnixStream::connect(sock)?;
288    let mut req = serde_json::to_vec(&ProxyRequest { handle: handle.into(), op: op.into(), payload_hex: hex::encode(payload) })?;
289    req.push(b'\n');
290    s.write_all(&req)?;
291    let mut line = String::new();
292    std::io::BufReader::new(s).read_line(&mut line)?;
293    serde_json::from_str(&line).map_err(std::io::Error::other)
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    struct FakeTree(HashMap<u32, u32>);
301    impl ProcTree for FakeTree {
302        fn parent(&self, pid: u32) -> Option<u32> {
303            self.0.get(&pid).copied()
304        }
305    }
306    struct Echo;
307    impl CoreSigner for Echo {
308        fn sign(&self, token: &str, _op: &str, _p: &[u8]) -> Result<SignedReply, String> {
309            Ok(SignedReply { nick: token.trim_start_matches("tok-").into(), public_key_hex: String::new(), signature_hex: String::new() })
310        }
311    }
312
313    #[test]
314    fn handle_works_only_inside_its_session_tree_and_for_its_uid() {
315        // 100 = PTY root of A, 101 = child of A, 200 = PTY root of B, 201 = neighbour shell in B.
316        let tree = FakeTree([(101, 100), (100, 1), (201, 200), (200, 1)].into_iter().collect());
317        let p = SignerProxy::with_proc(Arc::new(Echo), Box::new(tree));
318        let ha = p.bind("sa", "a", 100, 1000, "tok-a".into());
319        let hb = p.bind("sb", "b", 200, 1000, "tok-b".into());
320        assert_eq!(p.sign_for_peer(&ha, 100, 1000, "send_as", b"x").unwrap().nick, "a");
321        assert_eq!(p.sign_for_peer(&ha, 101, 1000, "send_as", b"x").unwrap().nick, "a");
322        assert_eq!(p.sign_for_peer(&ha, 201, 1000, "send_as", b"x"), Err(Refusal::NotInSession));
323        assert_eq!(p.sign_for_peer(&ha, 100, 1001, "send_as", b"x"), Err(Refusal::WrongUser));
324        assert_eq!(p.sign_for_peer(&hb, 201, 1000, "send_as", b"x").unwrap().nick, "b");
325        assert_eq!(p.sign_for_peer("guess", 100, 1000, "send_as", b"x"), Err(Refusal::UnknownHandle));
326        p.unbind("sa");
327        assert_eq!(p.sign_for_peer(&ha, 100, 1000, "send_as", b"x"), Err(Refusal::UnknownHandle));
328        let ha2 = p.bind("sb", "b", 200, 1000, "tok-b".into());
329        assert_eq!(p.sign_for_peer(&hb, 200, 1000, "send_as", b"x"), Err(Refusal::UnknownHandle), "rebind kills the old handle");
330        assert!(p.sign_for_peer(&ha2, 200, 1000, "send_as", b"x").is_ok());
331    }
332}
333
334/// Core signer over the node-call channel (Noise over the uplink, opaque to C2; core takes
335/// the node id from the stream). Called from blocking threads only.
336pub struct UplinkCoreSigner {
337    calls: crate::uplink_calls::NodeCalls,
338    rt: tokio::runtime::Handle,
339}
340
341impl UplinkCoreSigner {
342    pub fn new(calls: crate::uplink_calls::NodeCalls, rt: tokio::runtime::Handle) -> Self {
343        Self { calls, rt }
344    }
345    fn call(&self, op: &str, payload: serde_json::Value) -> Result<serde_json::Value, String> {
346        self.rt.block_on(self.calls.call(op, &payload)).map_err(|e| format!("{e:?}"))
347    }
348}
349
350impl CoreSigner for UplinkCoreSigner {
351    fn sign(&self, token: &str, op: &str, payload: &[u8]) -> Result<SignedReply, String> {
352        let v = self.call("signer.sign", serde_json::json!({ "token": token, "op": op, "payload_hex": hex::encode(payload) }))?;
353        serde_json::from_value(v).map_err(|e| e.to_string())
354    }
355    fn provision(&self, provider: &str, session: &str) -> Result<(String, String, String), String> {
356        let v = self.call("signer.provision", serde_json::json!({ "provider": provider, "session": session }))?;
357        Ok((v["nick"].as_str().ok_or("no nick")?.to_owned(), v["token"].as_str().ok_or("no token")?.to_owned(), v["public_key"].as_str().unwrap_or_default().to_owned()))
358    }
359    fn send(&self, token: &str, letter: &serde_json::Value) -> Result<serde_json::Value, String> {
360        let mut p = letter.clone();
361        p["token"] = serde_json::Value::String(token.to_owned());
362        self.call("signer.send", p)
363    }
364    fn revoke(&self, session: &str) -> Result<(), String> {
365        self.call("signer.revoke", serde_json::json!({ "session": session })).map(|_| ())
366    }
367}