1use std::collections::{BTreeMap, BTreeSet};
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20use tesserax_keytree as kt;
21use zeroize::Zeroize;
22
23pub mod keyring;
24
25pub mod legacy_v1 {
28 pub const PRF_SALT: &[u8] = b"hatchery/m4a-account-root/v1";
30 pub const PRF_HKDF_SALT: &[u8] = b"hatchery-passkey-prf/v1";
31 pub const PRF_HKDF_INFO: &[u8] = b"account-root";
32 pub const STORE_SALT: &[u8] = b"hatchery-m4a-signer/v1";
33 pub const LABEL_KEK: &[u8] = b"store-kek";
34 pub const LABEL_APPROVAL: &[u8] = b"approval";
35 pub const LABEL_TREE: &[u8] = b"tree-root";
36 pub const SALT_NODE: &[u8] = b"node";
37 pub const SALT_SESSION: &[u8] = b"session";
38 pub const SIGN_DOMAIN: &[u8] = b"hatchery-m4a/v1";
40}
41
42pub use legacy_v1::PRF_SALT;
43
44pub mod grants {
46 pub const M4A_SEND: &str = m4a_keys::grants::SEND;
48 pub const M4A_RECV: &str = m4a_keys::grants::RECV;
49 pub const SESSION_RUN_CLOSE: &str = gatekeys::grants::SESSION_RUN_CLOSE;
50 pub const SESSION_SPAWN_CHILD: &str = gatekeys::grants::SESSION_SPAWN_CHILD;
51 pub const NODE_UPLINK: &str = gatekeys::grants::NODE_UPLINK;
52 pub const NODE_ENV_CTL: &str = gatekeys::grants::NODE_ENV_CTL;
53 pub const NODE_PTY_ATTACH: &str = gatekeys::grants::NODE_PTY_ATTACH;
54
55 pub const LEGACY: &[(&str, &str)] = &[
57 ("send_as", M4A_SEND),
58 ("recv_inbox", M4A_RECV),
59 ("run_close", SESSION_RUN_CLOSE),
60 ("spawn_child", SESSION_SPAWN_CHILD),
61 ];
62
63 pub fn canonical(name: &str) -> &str {
65 LEGACY.iter().find(|(l, _)| *l == name).map(|(_, g)| *g).unwrap_or(name)
66 }
67
68 pub fn legacy(grant: &str) -> Option<&'static str> {
70 LEGACY.iter().find(|(_, g)| *g == grant).map(|(l, _)| *l)
71 }
72
73 pub fn transport(grant: &str) -> &str {
75 canonical(grant).split('.').next().unwrap_or("")
76 }
77
78 pub fn needs_human(grant: &str) -> bool {
80 matches!(canonical(grant), SESSION_SPAWN_CHILD | NODE_ENV_CTL)
81 }
82}
83
84pub const RIGHT_SEND: &str = "send_as";
86pub const RIGHT_RECV: &str = "recv_inbox";
87pub const RIGHT_RUN_CLOSE: &str = "run_close";
88pub const RIGHT_SPAWN_CHILD: &str = "spawn_child";
89
90pub fn default_rights() -> BTreeSet<String> {
92 [grants::M4A_SEND, grants::M4A_RECV, grants::SESSION_RUN_CLOSE].into_iter().map(str::to_owned).collect()
93}
94
95fn canon_set(r: &BTreeSet<String>) -> BTreeSet<String> {
96 r.iter().map(|g| grants::canonical(g).to_owned()).collect()
97}
98
99fn token_rights(r: &BTreeSet<String>) -> BTreeSet<String> {
101 let mut out = BTreeSet::new();
102 for g in canon_set(r) {
103 if let Some(l) = grants::legacy(&g) {
104 out.insert(l.to_owned());
105 }
106 out.insert(g);
107 }
108 out
109}
110
111fn wire_op(op: &str) -> &str {
113 let g = grants::canonical(op);
114 grants::legacy(g).unwrap_or(g)
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum SignerError {
119 Locked,
120 WrongHumanKey,
121 Store(String),
122 UnknownSession,
123 Retired,
124 Denied(String),
125 HumanApprovalRequired,
126 BadToken,
127}
128pub type KeystoreError = SignerError;
129
130impl std::fmt::Display for SignerError {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 write!(f, "{self:?}")
133 }
134}
135impl std::error::Error for SignerError {}
136
137fn from_kt(e: kt::Error) -> SignerError {
138 match e {
139 kt::Error::WrongKey => SignerError::WrongHumanKey,
140 kt::Error::Truncated => SignerError::Store("truncated".into()),
141 kt::Error::Io(k) => SignerError::Store(k),
142 kt::Error::Seal => SignerError::Store("seal".into()),
143 kt::Error::BadId => SignerError::Denied("bad id".into()),
144 kt::Error::BadToken => SignerError::BadToken,
145 kt::Error::Denied => SignerError::Denied("not granted".into()),
146 }
147}
148
149pub struct HumanKey(kt::Secret);
151
152impl HumanKey {
153 pub fn from_bytes(b: [u8; 32]) -> Self {
154 Self(kt::Secret::from_bytes(b))
155 }
156 pub fn from_passkey_prf(prf_output: &[u8]) -> Self {
158 Self(kt::Secret::from_prf(prf_output, legacy_v1::PRF_HKDF_SALT, legacy_v1::PRF_HKDF_INFO))
159 }
160 fn derive(&self, label: &[u8]) -> kt::Secret {
161 self.0.derive(legacy_v1::STORE_SALT, label)
162 }
163 fn kek(&self) -> kt::Secret {
164 self.derive(legacy_v1::LABEL_KEK)
165 }
166 fn approval_tag(&self) -> String {
167 self.0.tag(legacy_v1::STORE_SALT, legacy_v1::LABEL_APPROVAL)
168 }
169}
170
171#[derive(Serialize, Deserialize)]
172struct Identity {
173 nick: String,
174 node: String,
175 provider: String,
176 secret: [u8; 32],
177 rights: BTreeSet<String>,
178 parent: Option<String>,
179 retired: bool,
180 current: Option<String>,
182}
183
184impl Drop for Identity {
185 fn drop(&mut self) {
186 self.secret.zeroize();
187 }
188}
189
190#[derive(Serialize, Deserialize, Default)]
191struct Store {
192 #[serde(default)]
193 account: String,
194 #[serde(default)]
195 nodes: BTreeSet<String>,
196 root: Option<Vec<u8>>,
197 approval_tag: String,
198 sessions: BTreeMap<String, Identity>,
199 seq: u64,
200}
201
202impl Drop for Store {
203 fn drop(&mut self) {
204 if let Some(r) = self.root.as_mut() {
205 r.zeroize();
206 }
207 }
208}
209
210#[derive(Debug, Clone)]
212pub struct Provisioned {
213 pub nick: String,
214 pub session: String,
215 pub token: String,
216 pub public_key: [u8; 32],
217}
218
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct Signature {
221 pub nick: String,
222 pub public_key: [u8; 32],
223 pub signature: [u8; 64],
224}
225
226pub struct Signer {
228 path: PathBuf,
229 unlocked: Option<(Store, kt::Secret)>,
230 tree: Option<kt::Secret>,
231}
232pub type Keystore = Signer;
233
234impl Signer {
235 pub fn init(path: impl Into<PathBuf>, human: &HumanKey) -> Result<Self, SignerError> {
236 let path = path.into();
237 if path.exists() {
238 return Err(SignerError::Store("store already exists".into()));
239 }
240 let root = kt::Authority::generate();
241 let store = Store {
242 account: String::new(),
243 nodes: BTreeSet::new(),
244 root: Some(root.private_bytes().to_vec()),
245 approval_tag: human.approval_tag(),
246 sessions: BTreeMap::new(),
247 seq: 0,
248 };
249 let mut s = Self { path, unlocked: Some((store, human.kek())), tree: Some(human.derive(legacy_v1::LABEL_TREE)) };
250 s.persist()?;
251 Ok(s)
252 }
253
254 pub fn open(path: impl Into<PathBuf>) -> Self {
255 Self { path: path.into(), unlocked: None, tree: None }
256 }
257
258 pub fn is_unlocked(&self) -> bool {
259 self.unlocked.is_some()
260 }
261
262 pub fn unlock(&mut self, human: &HumanKey) -> Result<(), SignerError> {
263 let kek = human.kek();
264 let pt = kt::open_file(&self.path, &kek).map_err(from_kt)?;
265 let store: Store = serde_json::from_slice(&pt).map_err(|_| SignerError::Store("corrupt".into()))?;
266 self.unlocked = Some((store, kek));
267 self.tree = Some(human.derive(legacy_v1::LABEL_TREE));
268 Ok(())
269 }
270
271 pub fn lock(&mut self) {
272 self.unlocked = None;
273 self.tree = None;
274 }
275
276 pub fn set_account(&mut self, account: &str) -> Result<(), SignerError> {
277 let (store, _) = self.unlocked.as_mut().ok_or(SignerError::Locked)?;
278 if !store.account.is_empty() && store.account != account {
279 return Err(SignerError::Denied("store belongs to another account".into()));
280 }
281 store.account = account.to_owned();
282 self.persist()
283 }
284
285 pub fn account(&self) -> Result<String, SignerError> {
286 Ok(self.unlocked.as_ref().ok_or(SignerError::Locked)?.0.account.clone())
287 }
288
289 pub fn register_node(&mut self, node: &str, human: &HumanKey) -> Result<(), SignerError> {
290 self.check_approval(Some(human))?;
291 let (store, _) = self.unlocked.as_mut().ok_or(SignerError::Locked)?;
292 store.nodes.insert(node.to_owned());
293 self.persist()
294 }
295
296 fn require_node(&self, node: &str) -> Result<(), SignerError> {
297 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
298 if store.nodes.contains(node) {
299 Ok(())
300 } else {
301 Err(SignerError::Denied(format!("node {node} is not in this account's tree")))
302 }
303 }
304
305 fn session_seed(&self, node: &str, session: &str) -> Result<[u8; 32], SignerError> {
307 let tree = self.tree.as_ref().ok_or(SignerError::Locked)?;
308 let s = tree.derive_path(&[(legacy_v1::SALT_NODE, node.as_bytes()), (legacy_v1::SALT_SESSION, session.as_bytes())]);
309 Ok(*s.expose_secret())
310 }
311
312 fn persist(&mut self) -> Result<(), SignerError> {
313 let (store, kek) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
314 let pt = zeroize::Zeroizing::new(serde_json::to_vec(store).map_err(|e| SignerError::Store(e.to_string()))?);
315 kt::seal_file(&self.path, kek, &pt).map_err(from_kt)
316 }
317
318 fn root(&self) -> Result<kt::Authority, SignerError> {
319 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
320 let bytes = store.root.as_ref().ok_or(SignerError::Locked)?;
321 kt::Authority::from_private_bytes(bytes).map_err(|_| SignerError::Store("root key".into()))
322 }
323
324 fn check_approval(&self, human: Option<&HumanKey>) -> Result<(), SignerError> {
325 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
326 match human {
327 Some(h) if h.approval_tag() == store.approval_tag => Ok(()),
328 Some(_) => Err(SignerError::WrongHumanKey),
329 None => Err(SignerError::HumanApprovalRequired),
330 }
331 }
332
333 pub fn provision(
335 &mut self,
336 node: &str,
337 provider: &str,
338 session: &str,
339 rights: BTreeSet<String>,
340 human: Option<&HumanKey>,
341 ) -> Result<Provisioned, SignerError> {
342 let rights = canon_set(&rights);
343 if rights.iter().any(|g| grants::needs_human(g)) {
344 self.check_approval(human)?;
345 }
346 self.issue(node, provider, session, rights, None)
347 }
348
349 pub fn provision_child(
351 &mut self,
352 parent_token: &str,
353 node: &str,
354 provider: &str,
355 child_session: &str,
356 rights: BTreeSet<String>,
357 ) -> Result<Provisioned, SignerError> {
358 let rights = canon_set(&rights);
359 let (parent_session, _) = self.verify(parent_token, grants::SESSION_SPAWN_CHILD)?;
360 let parent_rights = {
361 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
362 canon_set(&store.sessions.get(&parent_session).ok_or(SignerError::UnknownSession)?.rights)
363 };
364 if !rights.is_subset(&parent_rights) {
365 return Err(SignerError::Denied("child rights exceed parent".into()));
366 }
367 self.issue(node, provider, child_session, rights, Some(parent_session))
368 }
369
370 fn issue(
371 &mut self,
372 node: &str,
373 provider: &str,
374 session: &str,
375 rights: BTreeSet<String>,
376 parent: Option<String>,
377 ) -> Result<Provisioned, SignerError> {
378 self.require_node(node)?;
379 let seed = self.session_seed(node, session)?;
380 let root = self.root()?;
381 let (store, _) = self.unlocked.as_mut().ok_or(SignerError::Locked)?;
382 let account = store.account.clone();
383 let ident = match store.sessions.get_mut(session) {
384 Some(id) if id.retired => return Err(SignerError::Retired),
385 Some(id) => id,
386 None => {
387 store.seq += 1;
388 let nick = format!("{}-{}", short(provider), store.seq);
389 store.sessions.insert(
390 session.to_owned(),
391 Identity {
392 nick,
393 node: node.to_owned(),
394 provider: provider.to_owned(),
395 secret: seed,
396 rights: BTreeSet::new(),
397 parent: parent.clone(),
398 retired: false,
399 current: None,
400 },
401 );
402 store.sessions.get_mut(session).expect("inserted")
403 }
404 };
405 ident.rights = rights.clone();
406 let nick = ident.nick.clone();
407 let issued = root
408 .issue(&[("account", &account), ("session", session), ("nick", &nick), ("node", node)], &nick, &token_rights(&rights))
409 .map_err(from_kt)?;
410 let public_key = kt::KeyHandle::from_seed(&ident.secret).public_key();
411 ident.current = Some(issued.revocation_id);
412 self.persist()?;
413 Ok(Provisioned { nick, session: session.to_owned(), token: issued.token, public_key })
414 }
415
416 fn verify(&self, token: &str, op: &str) -> Result<(String, String), SignerError> {
418 let root = self.root()?;
419 let g = grants::canonical(op);
420 let mut res = root.verify(token, g, "nick", &["session"]);
421 if matches!(res, Err(kt::Error::Denied)) {
422 if let Some(l) = grants::legacy(g) {
423 res = root.verify(token, l, "nick", &["session"]);
424 }
425 }
426 let v = res.map_err(|e| match e {
427 kt::Error::Denied => SignerError::Denied(format!("{op} not granted")),
428 _ => SignerError::BadToken,
429 })?;
430 let session = v.facts.get("session").cloned().ok_or(SignerError::BadToken)?;
431 let nick = v.facts.get("nick").cloned().ok_or(SignerError::BadToken)?;
432 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
433 let ident = store.sessions.get(&session).ok_or(SignerError::UnknownSession)?;
434 if ident.retired {
435 return Err(SignerError::Retired);
436 }
437 if ident.nick != nick || ident.current.as_deref() != Some(v.revocation_id.as_str()) {
438 return Err(SignerError::Denied("token is not the session's current capability".into()));
439 }
440 Ok((session, nick))
441 }
442
443 pub fn sign(&self, token: &str, op: &str, payload: &[u8]) -> Result<Signature, SignerError> {
445 let (session, nick) = self.verify(token, op)?;
446 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
447 let ident = store.sessions.get(&session).ok_or(SignerError::UnknownSession)?;
448 let key = kt::KeyHandle::from_seed(&ident.secret);
449 let msg = domain_message(&nick, op, payload);
450 Ok(Signature { nick, public_key: key.public_key(), signature: key.sign(&msg) })
451 }
452
453 pub fn delegate(&self, token: &str, transport: &str) -> Result<String, SignerError> {
457 let (session, _) = self.verify_any(token)?;
458 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
459 let held = canon_set(&store.sessions.get(&session).ok_or(SignerError::UnknownSession)?.rights);
460 let ops: BTreeSet<String> = token_rights(&held.into_iter().filter(|g| grants::transport(g) == transport).collect());
461 if ops.is_empty() {
462 return Err(SignerError::Denied(format!("no {transport} grants")));
463 }
464 kt::attenuate(token, &self.root()?.public_key(), &ops).map_err(from_kt)
465 }
466
467 fn verify_any(&self, token: &str) -> Result<(String, String), SignerError> {
468 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
469 let session = kt::unverified_fact(token, "session").map_err(from_kt)?;
470 let ident = store.sessions.get(&session).ok_or(SignerError::UnknownSession)?;
471 let any = ident.rights.iter().next().cloned().ok_or(SignerError::Denied("no grants".into()))?;
472 self.verify(token, &any)
473 }
474
475 pub fn sign_m4a_keyproof(&self, node: &str, token: &str, message: &[u8]) -> Result<Signature, SignerError> {
480 if !(message.starts_with(b"m4a-key-login-v1\n") || message.starts_with(b"m4a-key-enroll-v1\n")) {
481 return Err(SignerError::Denied("not an m4a key proof".into()));
482 }
483 let (session, nick) = self.verify(token, grants::M4A_SEND)?;
484 self.session_on_node(&session, node)?;
485 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
486 let ident = store.sessions.get(&session).ok_or(SignerError::UnknownSession)?;
487 let key = kt::KeyHandle::from_seed(&ident.secret);
488 Ok(Signature { nick, public_key: key.public_key(), signature: key.sign(message) })
489 }
490
491 pub fn m4a_vault_key(&self, node: &str, token: &str) -> Result<zeroize::Zeroizing<[u8; 32]>, SignerError> {
495 let (session, nick) = self.verify(token, grants::M4A_SEND)?;
496 self.session_on_node(&session, node)?;
497 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
498 let ident = store.sessions.get(&session).ok_or(SignerError::UnknownSession)?;
499 let k = kt::Secret::from_bytes(ident.secret).derive(b"hatchery/m4a-vault/v1", nick.as_bytes());
500 Ok(zeroize::Zeroizing::new(*k.expose_secret()))
501 }
502
503 pub fn has_node(&self, node: &str) -> bool {
504 self.unlocked.as_ref().is_some_and(|(s, _)| s.nodes.contains(node))
505 }
506
507 pub fn sign_from_node(&self, node: &str, token: &str, op: &str, payload: &[u8]) -> Result<Signature, SignerError> {
508 let (session, _) = self.verify(token, op)?;
509 self.session_on_node(&session, node)?;
510 self.sign(token, op, payload)
511 }
512
513 pub fn session_on_node(&self, session: &str, node: &str) -> Result<(), SignerError> {
514 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
515 let i = store.sessions.get(session).ok_or(SignerError::UnknownSession)?;
516 if i.node == node { Ok(()) } else { Err(SignerError::Denied("session is on another node".into())) }
517 }
518
519 pub fn revoke_session(&mut self, session: &str) -> Result<(), SignerError> {
520 let (store, _) = self.unlocked.as_mut().ok_or(SignerError::Locked)?;
521 let ident = store.sessions.get_mut(session).ok_or(SignerError::UnknownSession)?;
522 ident.current = None;
523 self.persist()
524 }
525
526 pub fn retire(&mut self, session: &str, human: &HumanKey) -> Result<(), SignerError> {
527 self.check_approval(Some(human))?;
528 let (store, _) = self.unlocked.as_mut().ok_or(SignerError::Locked)?;
529 let ident = store.sessions.get_mut(session).ok_or(SignerError::UnknownSession)?;
530 ident.retired = true;
531 ident.current = None;
532 self.persist()
533 }
534
535 pub fn public_key_of(&self, session: &str) -> Result<[u8; 32], SignerError> {
536 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
537 let i = store.sessions.get(session).ok_or(SignerError::UnknownSession)?;
538 Ok(kt::KeyHandle::from_seed(&i.secret).public_key())
539 }
540
541 pub fn parent_of(&self, session: &str) -> Result<Option<String>, SignerError> {
542 let (store, _) = self.unlocked.as_ref().ok_or(SignerError::Locked)?;
543 Ok(store.sessions.get(session).ok_or(SignerError::UnknownSession)?.parent.clone())
544 }
545
546 pub fn path(&self) -> &Path {
547 &self.path
548 }
549}
550
551pub fn domain_message(nick: &str, op: &str, payload: &[u8]) -> Vec<u8> {
553 kt::domain_message(legacy_v1::SIGN_DOMAIN, &[nick.as_bytes(), wire_op(op).as_bytes(), payload])
554}
555
556pub fn verify_signature(sig: &Signature, op: &str, payload: &[u8]) -> bool {
557 kt::verify_signature(&sig.public_key, &domain_message(&sig.nick, op, payload), &sig.signature)
558}
559
560fn short(provider: &str) -> &str {
561 match provider {
562 "claude-code" => "claude",
563 "kimi-code" => "kimi",
564 p => p,
565 }
566}