Skip to main content

hatchery_keystore/
lib.rs

1//! Hatchery keystore: the hq-core authority of the key tree (spec
2//! `specs/keystore-placement-2026-10-16.md`).
3//!
4//! - The crypto (derivation, sealing, non-exportable signing, capabilities) is
5//!   [`tesserax_keytree`]. This crate holds hatchery's policy only: accounts, nodes, sessions,
6//!   grants per transport, human approval, and delegation of attenuated capabilities to
7//!   consumers (m4a, g4a) that run inside hatchery.
8//! - Account root = the human's passkey PRF. Another human's passkey opens only their branch.
9//! - Session keys are generated here and never leave: no getter, no export.
10//! - Child sessions: a NEW identity with grants ⊆ the parent's, only with `spawn_child`.
11//!
12//! Compatibility with 0.2.15 (`hatchery-m4a-signer`): the salts, labels, sealed layout and the
13//! signed message domain are byte-identical ([`legacy_v1`]); the old right names are accepted
14//! as aliases of the namespaced grants.
15
16use 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
25/// Byte-identical constants of the 0.2.15 store. Changing any of them makes every sealed
26/// store and every browser PRF evaluation unreadable. A v2 needs an explicit re-seal.
27pub mod legacy_v1 {
28    /// Salt the browser passes to the passkey PRF extension (`prf.eval.first`).
29    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    /// Domain of signed messages (`domain\0nick\0op\0payload`).
39    pub const SIGN_DOMAIN: &[u8] = b"hatchery-m4a/v1";
40}
41
42pub use legacy_v1::PRF_SALT;
43
44/// Grants, namespaced by the transport they unlock.
45pub mod grants {
46    //! Names come from the consumers (`m4a-keys`, `gate4agent-keys`); hatchery is the authority.
47    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    /// 0.2.15 right name -> grant.
56    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    /// Canonical grant for a grant or legacy right name.
64    pub fn canonical(name: &str) -> &str {
65        LEGACY.iter().find(|(l, _)| *l == name).map(|(_, g)| *g).unwrap_or(name)
66    }
67
68    /// Legacy right name of a grant, if it had one.
69    pub fn legacy(grant: &str) -> Option<&'static str> {
70        LEGACY.iter().find(|(_, g)| *g == grant).map(|(l, _)| *l)
71    }
72
73    /// Transport a grant unlocks (its namespace).
74    pub fn transport(grant: &str) -> &str {
75        canonical(grant).split('.').next().unwrap_or("")
76    }
77
78    /// Grants that need the human key per operation.
79    pub fn needs_human(grant: &str) -> bool {
80        matches!(canonical(grant), SESSION_SPAWN_CHILD | NODE_ENV_CTL)
81    }
82}
83
84/// Deprecated 0.2.15 names; use [`grants`].
85pub 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
90/// Grants a fresh session gets without any human approval.
91pub 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
99/// Rights written into a token: each grant plus its legacy alias (old verifiers keep working).
100fn 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
111/// Op string inside signed messages: the legacy name where one exists (wire-compatible).
112fn 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
149/// The human's account root (from the passkey PRF). Zeroized on drop.
150pub 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    /// From the WebAuthn PRF output of the account passkey (salt [`PRF_SALT`] in the browser).
157    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    /// Revocation id of the only valid token (respawn rotates it; stop clears it).
181    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/// What the node receives at provision. `token` is held by the node proxy, never put in PTY.
211#[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
226/// One account's branch of the keystore (kept as `Signer` for 0.2.15 callers).
227pub 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    /// Session seed = tree / node / session.
306    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    /// New identity for a root session. Grants that need a human require the human key.
334    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    /// Child session: NEW identity, grants ⊆ parent's, parent must hold `spawn_child`.
350    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    /// Verify `token` grants `op` (grant or legacy name) on its own nick and is current.
417    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    /// Sign `payload` as the token's own nick.
444    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    /// Attenuated copy of a session token for a consumer running inside hatchery: only the
454    /// grants of `transport` (e.g. "m4a") that the token already holds. The consumer can verify
455    /// and use it but cannot widen it or mint siblings.
456    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    /// Sign an m4a key-proof statement (`m4a-key-login-v1` / `m4a-key-enroll-v1`) with the
476    /// token's own session key, so the session's m4a account is proven by the same key that
477    /// never leaves the keystore. Any other message is refused: this cannot sign letters or
478    /// arbitrary bytes. The session must live on `node`.
479    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    /// The 32-byte master key of this session's m4a client vault (E2E device state, store keys),
492    /// derived from the session's keystore secret. Only the session's own token on its own node
493    /// gets it; another session's vault cannot be opened with it.
494    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
551/// What a signature covers: domain, nick, operation (wire name), payload.
552pub 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}