Skip to main content

choreo_client_core/
credentials.rs

1use base64::Engine as _;
2use choreo_keystore::ServiceCredential;
3use choreo_proto::ClientMessage;
4use tracing::{debug, info, warn};
5use x25519_dalek::{PublicKey, StaticSecret};
6use zeroize::{Zeroize, Zeroizing};
7
8use crate::error::ClientError;
9use crate::known_servers::KnownServers;
10use crate::shell::UnlockMethod;
11
12/// Resolve a private key for an unlock attempt against the daemon at
13/// `addr`.
14///
15/// For `UnlockMethod::Raw`, unlock with the key ALREADY associated with this
16/// daemon: the stored `known_servers` `unlock_key`, falling back to the legacy
17/// raw `identity.pk` file — which is then COPIED into `known_servers.toml` so
18/// the store becomes the single source of truth (the legacy file is never
19/// deleted, merely superseded). Errors with [`ClientError::NoUnlockKey`] when
20/// neither source has a key.
21///
22/// For `UnlockMethod::Key(key)`, the argument IS the unlock key (base64 of
23/// the 32 raw bytes): it is decoded, validated, and returned — WRITE-FREE.
24/// The caller records it via [`record_unlock_key`] ONLY when the daemon
25/// confirms (`Unlocked` reply); nothing is written to `known_servers` on send.
26///
27/// # Errors
28///
29/// Returns [`ClientError`] when the unlock key cannot be loaded
30/// (`PrivateKeyInvalid` for a bad stored length, `Io`/`CredentialParse`
31/// for store access), or when a `Key`-method key is not valid base64 of
32/// exactly 32 bytes.
33pub fn resolve_private_key(method: &UnlockMethod, addr: &str) -> Result<Vec<u8>, ClientError> {
34    match method {
35        UnlockMethod::Raw => {
36            info!(addr, "resolving stored unlock key for addr");
37            stored_or_adopted_unlock_key(addr)?
38                .map(|k| k.to_vec())
39                .ok_or_else(|| ClientError::NoUnlockKey(addr.to_string()))
40        }
41        UnlockMethod::Key(key) => {
42            info!(addr, "unlocking with caller-supplied base64 unlock key");
43            // WRITE-FREE: decode + validate only. Recording happens on the
44            // daemon's targeted confirmation (record_unlock_key), so a key
45            // the daemon rejects never pollutes the store.
46            let key = decode_base64_unlock_key(key)?;
47            Ok(key.to_vec())
48        }
49    }
50}
51
52/// Decode a caller-supplied base64 unlock key into exactly 32 raw bytes
53/// (the same encoding `known_servers.toml` stores). The decoded bytes are
54/// zeroized if validation fails, so a bad key never lingers in a freed
55/// allocation.
56fn decode_base64_unlock_key(key: &str) -> Result<[u8; 32], ClientError> {
57    let raw = Zeroizing::new(
58        base64::engine::general_purpose::STANDARD
59            .decode(key.trim())
60            .map_err(|_| ClientError::PrivateKeyInvalid)?,
61    );
62    raw.as_slice()
63        .try_into()
64        .map_err(|_| ClientError::PrivateKeyInvalid)
65}
66
67/// Read and validate the raw private key file (`identity.pk`).
68/// Returns 32-byte key data.
69fn read_raw_private_key() -> Result<Vec<u8>, ClientError> {
70    let path = choreo_keystore::paths::private_key_path()
71        .map_err(|e| ClientError::PrivateKeyRead(e.to_string()))?;
72    let data = std::fs::read(&path).map_err(|e| ClientError::PrivateKeyRead(e.to_string()))?;
73    if data.len() != 32 {
74        return Err(ClientError::PrivateKeyInvalid);
75    }
76    Ok(data)
77}
78
79/// Read the stored per-daemon unlock key for `addr` from `known_servers`.
80/// Internal helper: failures to LOAD the store or DECODE a stored key are
81/// non-fatal for the resolution chain (we just fall through to the legacy
82/// path), so they are swallowed with a warning here rather than propagated.
83fn stored_unlock_key(addr: &str) -> Option<[u8; 32]> {
84    match KnownServers::load() {
85        Ok(store) => match store.unlock_key(addr) {
86            Ok(Some(key)) => {
87                info!(addr, "using stored per-daemon unlock key");
88                Some(key)
89            }
90            Ok(None) => None,
91            Err(e) => {
92                warn!(addr, error = %e, "stored unlock_key failed to decode; ignoring");
93                None
94            }
95        },
96        Err(e) => {
97            warn!(addr, error = %e, "could not load known_servers store; ignoring stored unlock key");
98            None
99        }
100    }
101}
102
103/// Resolve the unlock key ALREADY associated with `addr`: the stored
104/// `known_servers` `unlock_key`, falling back to the legacy raw `identity.pk`
105/// file. A legacy hit is COPIED into the store (best-effort) so that
106/// `known_servers.toml` becomes the single source of truth — the legacy file
107/// is NEVER deleted, merely superseded. `Ok(None)` when neither source has
108/// a usable key (daemon stays locked; all session operations still work).
109fn stored_or_adopted_unlock_key(addr: &str) -> Result<Option<[u8; 32]>, ClientError> {
110    if let Some(key) = stored_unlock_key(addr) {
111        return Ok(Some(key));
112    }
113    match read_raw_private_key() {
114        Ok(key) => {
115            let key: [u8; 32] = key
116                .as_slice()
117                .try_into()
118                .map_err(|_| ClientError::PrivateKeyInvalid)?;
119            info!(
120                addr,
121                "using legacy raw private key; copying into known_servers.toml"
122            );
123            // Best-effort copy: if the store cannot be written the unlock
124            // still proceeds with the legacy key, and the daemon-confirmed
125            // `record_unlock_key` persists it later.
126            if let Err(e) = KnownServers::load().and_then(|mut s| s.set_unlock_key(addr, &key)) {
127                warn!(
128                    addr,
129                    error = %e,
130                    "could not copy legacy unlock key into known_servers; it will be recorded on daemon confirmation"
131                );
132            }
133            Ok(Some(key))
134        }
135        Err(ClientError::PrivateKeyInvalid) => {
136            warn!(
137                addr,
138                "legacy private key file exists but is not 32 bytes; ignoring"
139            );
140            Ok(None)
141        }
142        Err(_) => Ok(None), // file absent — nothing to fall back to
143    }
144}
145
146/// Try to resolve the unlock key for automatic unlock on connect to the
147/// daemon at `addr`.
148///
149/// Resolution order (per-daemon keystore TOFU design):
150/// 1. The stored `unlock_key` from the `known_servers` entry for `addr`.
151/// 2. LEGACY fallback: the raw `identity.pk` file, COPIED into
152///    `known_servers.toml` on first use (the legacy file is never deleted).
153///
154/// Returns `None` if no key can be resolved, which is fine — the daemon
155/// starts locked but all session operations (create, browse, delete) work
156/// without unlocking.  Only inference (`RunInput`) requires credentials.
157pub fn try_auto_unlock_key(addr: &str) -> Option<Vec<u8>> {
158    match stored_or_adopted_unlock_key(addr) {
159        Ok(Some(key)) => Some(key.to_vec()),
160        Ok(None) => {
161            debug!(
162                addr,
163                "auto-unlock: no key available (daemon will start locked)"
164            );
165            None
166        }
167        Err(e) => {
168            warn!(addr, error = %e, "auto-unlock: key resolution failed");
169            None
170        }
171    }
172}
173
174/// Persist the per-daemon unlock key for `addr` into the `known_servers`
175/// store. Legacy files are NEVER touched: no comparison, no deletion —
176/// `known_servers.toml` simply supersedes them once it holds the key.
177///
178/// Callers MUST only invoke this after the daemon CONFIRMED the key (an
179/// `Unlocked` or `CredentialAdded` reply) — never on send.
180///
181/// # Errors
182///
183/// Returns [`ClientError::PrivateKeyInvalid`] if `key` is not exactly 32
184/// bytes, and [`ClientError::Io`] if the store cannot be read or written.
185pub fn record_unlock_key(addr: &str, key: &[u8]) -> Result<(), ClientError> {
186    let key: [u8; 32] = key.try_into().map_err(|_| ClientError::PrivateKeyInvalid)?;
187    KnownServers::load()?.set_unlock_key(addr, &key)?;
188    Ok(())
189}
190
191fn parse_credential(
192    credential_type: &str,
193    fields: &[String],
194) -> Result<ServiceCredential, ClientError> {
195    match credential_type {
196        "api_key" => {
197            if fields.is_empty() {
198                return Err(ClientError::CredentialParse(
199                    "missing api_key field".to_string(),
200                ));
201            }
202            // The len check above guarantees field 0 exists; first() keeps
203            // the access total instead of panicking on a logic bug.
204            Ok(ServiceCredential::ApiKey {
205                key: fields.first().cloned().ok_or_else(|| {
206                    ClientError::CredentialParse("missing api_key field".to_string())
207                })?,
208            })
209        }
210        "x" => {
211            if fields.len() < 5 {
212                return Err(ClientError::CredentialParse(
213                    "missing X credential fields".to_string(),
214                ));
215            }
216            let bearer = match fields.get(4) {
217                Some(v) if v == "-" => None,
218                Some(v) => Some(v.clone()),
219                None => None,
220            };
221            // The len() >= 5 guard above bounds-guarantees every field;
222            // the closure keeps each access total instead of indexing.
223            let field = |i: usize| -> Result<String, ClientError> {
224                fields.get(i).cloned().ok_or_else(|| {
225                    ClientError::CredentialParse("missing X credential fields".to_string())
226                })
227            };
228            Ok(ServiceCredential::X {
229                api_key: field(0)?,
230                api_key_secret: field(1)?,
231                access_token: field(2)?,
232                access_token_secret: field(3)?,
233                bearer_token: bearer,
234            })
235        }
236        other => Err(ClientError::CredentialParse(format!(
237            "unknown credential type: {other}"
238        ))),
239    }
240}
241
242/// Resolve the per-daemon keystore unlock key for `addr` — VERIFY-ONLY
243/// resolution: the stored `known_servers` key for `addr`, falling back to the
244/// legacy raw `identity.pk` file (which `stored_or_adopted_unlock_key` copies
245/// into the store).
246///
247/// There is NO fresh-mint fallback anymore: a new design reserves key
248/// creation for the explicit bind flow ([`bind_fresh_daemon`]) — stored and
249/// legacy keys are unlock-VERIFICATION keys only and must never be used to
250/// establish a binding. When neither source has a key the caller gets
251/// [`ClientError::NoUnlockKey`] and the frontend surfaces it.
252fn resolve_keystore_key(addr: &str) -> Result<[u8; 32], ClientError> {
253    stored_or_adopted_unlock_key(addr)?.ok_or_else(|| ClientError::NoUnlockKey(addr.to_string()))
254}
255
256/// Prepare an AUTO-BIND of a freshly connected daemon whose keystore is
257/// unbound (learned from the subscribe-time lock state, or from a
258/// `KeystoreUnbound` reply to an auto-unlock attempt).
259///
260/// A brand-new 32-byte CSPRNG key is minted with `rand` and recorded into
261/// `known_servers` for `addr` BEFORE the `BindKeystore` message is returned —
262/// pre-send recording is CORRECT for bind (unlike unlock/add): an unbound
263/// daemon adopts whatever key arrives first, so if the confirmation is lost
264/// the recorded key still matches the binding, and there is nothing to
265/// overwrite. The returned `(key, message)` pair is sent as-is; the caller
266/// MUST confirm on the targeted `DaemonMessage::Bound` reply via
267/// [`record_unlock_key`] (a no-op-safe re-record of the already-persisted
268/// key) to keep the pending-flow contract uniform.
269///
270/// Pre-held keys (stored or legacy) are NEVER used to bind: the binding must
271/// always be fresh so a recorded key is provably the one the daemon adopted.
272/// If the key cannot be persisted pre-send the bind is REFUSED — sending an
273/// unrecorded bind key risks an unrecoverable orphaned binding.
274///
275/// # Errors
276///
277/// Returns [`ClientError::Io`] if the fresh bind key cannot be recorded
278/// into the store pre-send (the bind is then REFUSED, per the doc above).
279pub fn bind_fresh_daemon(addr: &str) -> Result<([u8; 32], ClientMessage), ClientError> {
280    // CSPRNG via rand's thread-local generator: the binding key is the root
281    // of the daemon's credential confidentiality, so it must never be
282    // predictable or reused.
283    let fresh: [u8; 32] = rand::random();
284    info!(
285        addr,
286        "minted fresh random keystore binding key for unbound daemon"
287    );
288    // Pre-send record is MANDATORY here (not best-effort): see doc above.
289    KnownServers::load()?.set_unlock_key(addr, &fresh)?;
290    debug!(addr, "recorded fresh bind key into known_servers pre-send");
291    let msg = ClientMessage::BindKeystore {
292        key: fresh.to_vec(),
293    };
294    Ok((fresh, msg))
295}
296
297/// Build an `AddCredential` message from typed field strings: parse the
298/// credential, then delegate to the shared builder (see
299/// [`build_add_credential_from_credential`] for the full key-resolution and
300/// encryption semantics). The caller-supplied field strings (which may hold a
301/// secret, e.g. the API key) are zeroized once parsing has consumed them.
302///
303/// # Errors
304///
305/// Returns [`ClientError::CredentialParse`] if the credential fields do
306/// not parse for `credential_type`, and store/keystore errors bubbled up
307/// by the shared builder.
308// needless_pass_by_value waived: pub API — TUI/GUI/IM callers pass owned
309// field strings (which get zeroized) and rely on this signature.
310#[allow(clippy::needless_pass_by_value)]
311pub fn build_add_credential_message(
312    addr: &str,
313    service: String,
314    credential_type: String,
315    fields: Vec<String>,
316) -> Result<(ClientMessage, Vec<u8>), ClientError> {
317    debug!(
318        addr,
319        service, credential_type, "building add credential message"
320    );
321    let credential = parse_credential(&credential_type, &fields)?;
322    // Parse first, then hand off to the shared builder. The caller's field
323    // strings (which may hold a typed secret, e.g. an API key) are zeroized
324    // here once parsing is done — the parsed credential zeroizes itself on
325    // drop via `#[zeroize(drop)]` in choreo-keystore.
326    let mut fields = fields;
327    let result = build_add_credential_from_credential(addr, service, credential);
328    for field in &mut fields {
329        field.zeroize();
330    }
331    result
332}
333
334/// One-per-connection keystore AUTO-BIND state machine, shared by the TUI and
335/// GUI frontends so they cannot drift apart on the bind-loop policy.
336///
337/// WHY a latch at all: a `KeystoreUnbound` report can arrive twice on one
338/// connection (subscribe-time lock-state push, then the reply to a failed
339/// auto-unlock). Minting a fresh key per report would churn bindings against
340/// a daemon we no longer understand, so only the FIRST report triggers a
341/// bind; later ones are surfaced by the caller as an error — reconnecting is
342/// the retry path, not re-minting.
343///
344/// The latch lives HERE, not in the frontend state, because the policy
345/// ("at most one bind attempt per connection") is shared and easy to get
346/// subtly wrong — exactly the kind of duplication the hoist exists to kill.
347/// `Clone`/`Copy` so frontends whose state types derive them can embed it;
348/// the latch is a plain bool, so clones behave exactly as expected.
349#[derive(Clone, Copy, Debug)]
350pub struct KeystoreAutoBind {
351    /// Whether a `BindKeystore` has already been minted on this connection.
352    /// Single-bit, never reset: the connection must be re-established to
353    /// clear it (the frontends hold this struct in per-connection state).
354    attempted: bool,
355}
356
357impl Default for KeystoreAutoBind {
358    fn default() -> Self {
359        Self::new()
360    }
361}
362
363impl KeystoreAutoBind {
364    /// Fresh state for a new connection: no bind attempted yet.
365    #[must_use]
366    pub fn new() -> Self {
367        Self { attempted: false }
368    }
369
370    /// Test/diagnostic accessor: has a bind already been attempted?
371    #[must_use]
372    pub fn attempted(&self) -> bool {
373        self.attempted
374    }
375
376    /// Handle a `KeystoreUnbound` report from the daemon.
377    ///
378    /// - `Ok(Some((key, message)))` — FIRST report on this connection: a
379    ///   fresh key was minted and recorded into `known_servers` PRE-SEND (see
380    ///   [`bind_fresh_daemon`] for why pre-send recording is mandatory) and
381    ///   the caller MUST send `message` and hold `key` pending for the
382    ///   targeted `Bound` confirmation.
383    /// - `Ok(None)` — a bind was already attempted; do NOT re-bind (the
384    ///   bind-loop guard). The caller surfaces its own error UI.
385    /// - `Err(ClientError)` — the pre-send store write was refused; no
386    ///   `BindKeystore` exists, so sending nothing is the only safe outcome.
387    ///   The latch stays set: a store that refused once is not going to
388    ///   accept on the next report, and the caller's reconnect path retries
389    ///   with fresh state.
390    ///
391    /// # Errors
392    ///
393    /// Returns [`ClientError::Io`] if the fresh bind key cannot be recorded
394    /// pre-send (the latch stays set; see the doc above).
395    pub fn on_unbound(
396        &mut self,
397        addr: &str,
398    ) -> Result<Option<([u8; 32], ClientMessage)>, ClientError> {
399        if self.attempted {
400            // Bind-loop guard: never re-mint against a daemon that is still
401            // unbound after one bind. Re-minting could overwrite a binding
402            // whose confirmation was merely lost in flight.
403            warn!(
404                addr,
405                "keystore still unbound after a bind attempt; not re-binding"
406            );
407            return Ok(None);
408        }
409        // Set the latch BEFORE the mint: a bind is "attempted" from the
410        // moment we commit to one, so a failure path still counts as the
411        // connection's one attempt.
412        self.attempted = true;
413        bind_fresh_daemon(addr).map(Some)
414    }
415}
416
417/// The outcome of one shared auto-bind attempt — see
418/// [`attempt_keystore_auto_bind`] for the policy and the delivery-contract
419/// comments on each variant.
420///
421/// Not `Clone`: [`AutoBindAttempt::Failed`] carries the structured
422/// [`ClientError`] (so a frontend CAN distinguish a refused pre-send persist
423/// from a store load failure) and the minted key material in
424/// [`AutoBindAttempt::Bind`] must not be silently duplicated.
425#[derive(Debug)]
426pub enum AutoBindAttempt {
427    /// A fresh bind key was minted and recorded into `known_servers` PRE-SEND.
428    /// The caller MUST send `msg` and hold `key` in its pending-key lifecycle
429    /// for the targeted `DaemonMessage::Bound` confirmation (`record_unlock_key`
430    /// re-records the already-persisted key, so the confirm is a no-op-safe
431    /// uniform path).
432    Bind { key: [u8; 32], msg: ClientMessage },
433    /// Bind-loop guard: a `BindKeystore` was already minted on this
434    /// connection. The caller must NOT re-mint; it surfaces its own
435    /// "reconnect to retry" UI.
436    Suppressed,
437    /// The pre-send persist (or store load) was REFUSED, so no
438    /// `BindKeystore` was built — sending nothing is the only safe outcome
439    /// (an unrecorded bind key risks an unrecoverable orphaned binding).
440    /// The latch stays set; the caller surfaces the error (structured, not a
441    /// pre-flattened string — keep the cause type for future UI branching).
442    Failed { error: ClientError },
443}
444
445/// Trigger the once-per-connection auto-bind of an unbound daemon — the
446/// SHARED implementation behind the `KeystoreUnbound` operation reply and the
447/// `Keystore { Unbound }` status push in every frontend, so the mint/pre-send
448/// record/latch policy lives in exactly one place. Frontends keep only the
449/// UI mapping: `Bind` → send + hold the key pending, `Suppressed` → surface
450/// "reconnect to retry", `Failed` → surface the error.
451///
452/// See [`KeystoreAutoBind::on_unbound`] for the underlying latch semantics;
453/// all `tracing` observability (bind made / suppressed / refused, with the
454/// address) is emitted HERE so the policy is fully described by the shared
455/// function, never by a copy of it.
456#[must_use]
457pub fn attempt_keystore_auto_bind(bind: &mut KeystoreAutoBind, addr: &str) -> AutoBindAttempt {
458    match bind.on_unbound(addr) {
459        Ok(Some((key, msg))) => {
460            info!(addr, "auto-binding unbound daemon with a fresh key");
461            AutoBindAttempt::Bind { key, msg }
462        }
463        // The warn log for the suppression already happened in `on_unbound`.
464        Ok(None) => AutoBindAttempt::Suppressed,
465        Err(e) => {
466            warn!(addr, error = %e, "auto-bind failed");
467            AutoBindAttempt::Failed { error: e }
468        }
469    }
470}
471
472/// Build an `AddCredential` message from an already-parsed credential, by
473/// resolving the daemon's unlock key and encrypting the serialized blob to the
474/// public key derived from that key.
475///
476/// VERIFY-ONLY key resolution: the stored per-daemon key or the legacy file —
477/// NEVER a fresh key (fresh keys are minted exclusively by [`bind_fresh_daemon`]).
478/// When neither source has a key this errors with [`ClientError::NoUnlockKey`]
479/// and the frontend surfaces it (the daemon's keystore is either unbound —
480/// in which case the client auto-binds first — or bound to a key this client
481/// does not hold).
482///
483/// Returns the message AND the unlock key used, so the caller can call
484/// [`record_unlock_key`] once the daemon CONFIRMS success (`CredentialAdded` /
485/// `Unlocked` reply) — never on send.
486///
487/// # Errors
488///
489/// Returns [`ClientError::Io`] if the keystore unlock key cannot be
490/// resolved or the encrypted credential cannot be built.
491// needless_pass_by_value waived: pub API — callers move the parsed
492// credential in; taking a reference would complicate every call site.
493#[allow(clippy::needless_pass_by_value)]
494pub fn build_add_credential_from_credential(
495    addr: &str,
496    service: String,
497    credential: ServiceCredential,
498) -> Result<(ClientMessage, Vec<u8>), ClientError> {
499    debug!(
500        addr,
501        service, "building add credential message from parsed credential"
502    );
503    let mut unlock_key = resolve_keystore_key(addr)?;
504    let derived_pub = PublicKey::from(&StaticSecret::from(unlock_key));
505
506    let mut plaintext =
507        postcard::to_allocvec(&credential).map_err(|e| ClientError::Postcard(e.to_string()))?;
508
509    let encrypted_payload =
510        choreo_keystore::crypto::encrypt_with_public_key(derived_pub.as_bytes(), &plaintext)
511            .map_err(|e| ClientError::Encryption(e.to_string()))?;
512
513    // Wipe the plaintext bytes (the credential value zeroizes itself on drop
514    // via `#[zeroize(drop)]` in choreo-keystore, and the daemon zeroizes its
515    // own stored copy, so this closes the remaining gap on the send path).
516    plaintext.zeroize();
517
518    let msg = ClientMessage::AddCredential {
519        service,
520        encrypted_payload,
521        unlock_key: unlock_key.to_vec(),
522    };
523    // Wipe the stack key after building: the two `Vec` copies (in `msg` and
524    // the returned key) are the ones that travel on, and the local array is
525    // then redundant.
526    let result = (msg, unlock_key.to_vec());
527    unlock_key.zeroize();
528    Ok(result)
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534
535    #[test]
536    fn parse_credential_api_key() {
537        let cred = parse_credential("api_key", &["sk-test".into()]).unwrap();
538        assert!(matches!(cred, ServiceCredential::ApiKey { ref key } if key == "sk-test"));
539    }
540
541    #[test]
542    fn parse_credential_api_key_missing_field() {
543        let result = parse_credential("api_key", &[]);
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn parse_credential_x() {
549        let fields = vec![
550            "ak".into(),
551            "aks".into(),
552            "at".into(),
553            "ats".into(),
554            "-".into(),
555        ];
556        let cred = parse_credential("x", &fields).unwrap();
557        let view = cred.as_x().unwrap();
558        assert_eq!(view.api_key, "ak");
559        assert_eq!(view.api_key_secret, "aks");
560        assert_eq!(view.access_token, "at");
561        assert_eq!(view.access_token_secret, "ats");
562        assert!(view.bearer_token.is_none());
563    }
564
565    #[test]
566    fn parse_credential_x_with_bearer() {
567        let fields = vec![
568            "ak".into(),
569            "aks".into(),
570            "at".into(),
571            "ats".into(),
572            "bt".into(),
573        ];
574        let cred = parse_credential("x", &fields).unwrap();
575        let view = cred.as_x().unwrap();
576        assert_eq!(view.bearer_token, Some("bt"));
577    }
578
579    #[test]
580    fn parse_credential_x_missing_fields() {
581        let fields = vec!["ak".into(), "aks".into(), "at".into()];
582        let result = parse_credential("x", &fields);
583        assert!(result.is_err());
584    }
585
586    #[test]
587    fn parse_credential_unknown_type() {
588        let result = parse_credential("unknown", &[]);
589        assert!(result.is_err());
590    }
591
592    // ── try_auto_unlock_key tests ──────────────────────────────────
593
594    #[test]
595    fn try_auto_unlock_key_with_raw_key() {
596        let dir = tempfile::tempdir().unwrap();
597        let _guard =
598            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
599        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
600
601        let (_, sk) = choreo_keystore::crypto::generate_keypair();
602        std::fs::write(dir.path().join("choreographr/identity.pk"), sk).unwrap();
603
604        assert_eq!(try_auto_unlock_key("local.sock"), Some(sk.to_vec()));
605
606        // The legacy raw key is COPIED into known_servers.toml on first use
607        // (the store becomes the single source of truth; the file stays).
608        let store = KnownServers::load().unwrap();
609        assert_eq!(store.unlock_key("local.sock").unwrap(), Some(sk));
610        assert!(dir.path().join("choreographr/identity.pk").exists());
611    }
612
613    #[test]
614    fn try_auto_unlock_key_with_invalid_raw_key_length() {
615        let dir = tempfile::tempdir().unwrap();
616        let _guard =
617            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
618        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
619
620        // Write a file that isn't 32 bytes
621        std::fs::write(dir.path().join("choreographr/identity.pk"), b"not 32 bytes").unwrap();
622
623        assert!(try_auto_unlock_key("local.sock").is_none());
624    }
625
626    /// The stored per-daemon unlock key WINS over the legacy files: this is
627    /// the target-state resolution order.
628    #[test]
629    fn try_auto_unlock_key_stored_key_beats_legacy() {
630        let dir = tempfile::tempdir().unwrap();
631        let _guard =
632            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
633        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
634
635        let (_, legacy_sk) = choreo_keystore::crypto::generate_keypair();
636        std::fs::write(dir.path().join("choreographr/identity.pk"), legacy_sk).unwrap();
637
638        let mut store = KnownServers::load().unwrap();
639        let stored_key: [u8; 32] = [9u8; 32];
640        store.set_unlock_key("daemon-a:9443", &stored_key).unwrap();
641
642        assert_eq!(
643            try_auto_unlock_key("daemon-a:9443"),
644            Some(stored_key.to_vec())
645        );
646        // A different addr with no stored key still falls back to legacy.
647        assert_eq!(
648            try_auto_unlock_key("daemon-b:9443"),
649            Some(legacy_sk.to_vec())
650        );
651    }
652
653    // ── resolve_private_key tests ──────────────────────────────────
654
655    #[test]
656    fn resolve_raw_prefers_stored_then_legacy() {
657        let dir = tempfile::tempdir().unwrap();
658        let _guard =
659            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
660        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
661
662        let (_, legacy_sk) = choreo_keystore::crypto::generate_keypair();
663        std::fs::write(dir.path().join("choreographr/identity.pk"), legacy_sk).unwrap();
664
665        // No stored key: legacy raw file resolves.
666        assert_eq!(
667            resolve_private_key(&UnlockMethod::Raw, "d:1").unwrap(),
668            legacy_sk.to_vec()
669        );
670
671        // Stored key beats the raw file.
672        let stored: [u8; 32] = [7u8; 32];
673        let mut store = KnownServers::load().unwrap();
674        store.set_unlock_key("d:1", &stored).unwrap();
675        assert_eq!(
676            resolve_private_key(&UnlockMethod::Raw, "d:1").unwrap(),
677            stored.to_vec()
678        );
679    }
680
681    /// `/unlock <key>`: the argument IS the unlock key (base64 of the 32 raw
682    /// bytes) — WRITE-FREE: it is decoded, validated, and returned, but NOT
683    /// recorded pre-send. Recording happens only on the daemon's targeted
684    /// confirmation (`record_unlock_key`).
685    #[test]
686    fn resolve_key_does_not_record_supplied_key_into_store() {
687        let dir = tempfile::tempdir().unwrap();
688        let _guard =
689            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
690        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
691
692        let key: [u8; 32] = [21u8; 32];
693        let b64 = base64::engine::general_purpose::STANDARD.encode(key);
694        assert_eq!(
695            resolve_private_key(&UnlockMethod::Key(b64), "d:1").unwrap(),
696            key.to_vec()
697        );
698        // Write-free: even a fresh load sees nothing recorded for the addr —
699        // a rejected key must never pollute the store.
700        let store = KnownServers::load().unwrap();
701        assert_eq!(store.unlock_key("d:1").unwrap(), None);
702    }
703
704    /// A supplied key that is not base64 — or not exactly 32 bytes once
705    /// decoded — is rejected.
706    #[test]
707    fn resolve_key_rejects_bad_input() {
708        let dir = tempfile::tempdir().unwrap();
709        let _guard =
710            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
711        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
712
713        assert!(resolve_private_key(&UnlockMethod::Key("not base64!!!".into()), "d:1").is_err());
714        let short = base64::engine::general_purpose::STANDARD.encode([1u8; 16]);
715        assert!(resolve_private_key(&UnlockMethod::Key(short), "d:1").is_err());
716        // Nothing was recorded for the rejected inputs.
717        assert_eq!(
718            KnownServers::load().unwrap().unlock_key("d:1").unwrap(),
719            None
720        );
721    }
722
723    /// `/unlock` (Raw) with neither a stored key nor a legacy file is a
724    /// clear `NoUnlockKey` error, not a silent failure.
725    #[test]
726    fn resolve_raw_without_any_key_is_a_clear_error() {
727        let dir = tempfile::tempdir().unwrap();
728        let _guard =
729            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
730        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
731
732        assert!(matches!(
733            resolve_private_key(&UnlockMethod::Raw, "d:1"),
734            Err(ClientError::NoUnlockKey(_))
735        ));
736    }
737
738    // ── build_add_credential_message tests ─────────────────────────
739
740    /// A helper to pull the key back out of the built message (tests may
741    /// unwrap; production code may not).
742    fn msg_unlock_key(msg: &ClientMessage) -> Vec<u8> {
743        match msg {
744            ClientMessage::AddCredential { unlock_key, .. } => unlock_key.clone(),
745            other => panic!("expected AddCredential, got {other:?}"),
746        }
747    }
748
749    #[test]
750    fn build_add_credential_uses_stored_key_first() {
751        let dir = tempfile::tempdir().unwrap();
752        let _guard =
753            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
754        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
755
756        let stored: [u8; 32] = [4u8; 32];
757        let mut store = KnownServers::load().unwrap();
758        store.set_unlock_key("d:1", &stored).unwrap();
759
760        let (msg, key) =
761            build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()])
762                .unwrap();
763        assert_eq!(key, stored.to_vec());
764        assert_eq!(msg_unlock_key(&msg), stored.to_vec());
765    }
766
767    #[test]
768    fn build_add_credential_falls_back_to_legacy_key() {
769        let dir = tempfile::tempdir().unwrap();
770        let _guard =
771            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
772        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
773
774        let (_, sk) = choreo_keystore::crypto::generate_keypair();
775        std::fs::write(dir.path().join("choreographr/identity.pk"), sk).unwrap();
776
777        let (_msg, key) =
778            build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()])
779                .unwrap();
780        assert_eq!(key, sk.to_vec());
781    }
782
783    /// Verify-only resolution: with nothing stored and no legacy files, the
784    /// add FAILS with `NoUnlockKey` — no fresh key is minted on the add path
785    /// (fresh keys are minted exclusively by `bind_fresh_daemon`).
786    #[test]
787    fn build_add_credential_without_any_key_is_a_clear_error() {
788        let dir = tempfile::tempdir().unwrap();
789        let _guard =
790            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
791        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
792
793        assert!(matches!(
794            build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()]),
795            Err(ClientError::NoUnlockKey(_))
796        ));
797        // And nothing was optimistically recorded by the failed attempt.
798        assert_eq!(
799            KnownServers::load().unwrap().unlock_key("d:1").unwrap(),
800            None
801        );
802    }
803
804    /// The blob encrypts to the pubkey derived from the RESOLVED (stored)
805    /// key — the daemon-side test-decrypt contract — and the returned key
806    /// equals the stored one.
807    #[test]
808    fn build_add_credential_blob_decrypts_with_stored_key() {
809        let dir = tempfile::tempdir().unwrap();
810        let _guard =
811            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
812        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
813
814        let stored: [u8; 32] = [4u8; 32];
815        let mut store = KnownServers::load().unwrap();
816        store.set_unlock_key("d:1", &stored).unwrap();
817
818        let (msg, key) =
819            build_add_credential_message("d:1", "svc".into(), "api_key".into(), vec!["k".into()])
820                .unwrap();
821        assert_eq!(key, stored.to_vec());
822        let ClientMessage::AddCredential {
823            service,
824            encrypted_payload,
825            ..
826        } = &msg
827        else {
828            panic!("expected AddCredential");
829        };
830        assert_eq!(service, "svc");
831        let plaintext =
832            choreo_keystore::crypto::decrypt_with_private_key(&stored, encrypted_payload)
833                .expect("blob must decrypt with the stored unlock key");
834        let cred: ServiceCredential = postcard::from_bytes(&plaintext).unwrap();
835        assert!(matches!(cred, ServiceCredential::ApiKey { ref key, .. } if key == "k"));
836    }
837
838    /// `bind_fresh_daemon`: mints a FRESH CSPRNG key (never the stored or
839    /// legacy key), records it into `known_servers` PRE-SEND (so a lost
840    /// confirmation cannot orphan the binding), and returns the
841    /// `BindKeystore` message carrying exactly that key.
842    #[test]
843    fn bind_fresh_daemon_mints_fresh_key_records_pre_send_and_returns_message() {
844        let dir = tempfile::tempdir().unwrap();
845        let _guard =
846            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
847        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
848
849        // Pre-existing stored + legacy keys: bind must NOT use either.
850        let stored: [u8; 32] = [30u8; 32];
851        let mut store = KnownServers::load().unwrap();
852        store.set_unlock_key("d:1", &stored).unwrap();
853        let (_, legacy_sk) = choreo_keystore::crypto::generate_keypair();
854        std::fs::write(dir.path().join("choreographr/identity.pk"), legacy_sk).unwrap();
855
856        let (key, msg) = bind_fresh_daemon("d:1").unwrap();
857        assert_ne!(key, stored, "bind must NEVER reuse a stored key");
858        assert_ne!(key, legacy_sk, "bind must NEVER reuse the legacy key");
859        match &msg {
860            ClientMessage::BindKeystore { key: wire_key } => {
861                assert_eq!(wire_key, &key.to_vec(), "message carries the minted key");
862            }
863            other => panic!("expected BindKeystore, got {other:?}"),
864        }
865        // Pre-send record: on disk BEFORE any daemon confirmation.
866        let recorded = KnownServers::load().unwrap().unlock_key("d:1").unwrap();
867        assert_eq!(recorded, Some(key));
868
869        // The pending-flow contract: confirming on the targeted `Bound` reply
870        // via record_unlock_key re-records the SAME key (idempotent).
871        record_unlock_key("d:1", &key).unwrap();
872        assert_eq!(
873            KnownServers::load().unwrap().unlock_key("d:1").unwrap(),
874            Some(key)
875        );
876    }
877
878    /// Two consecutive binds of the same addr mint DIFFERENT keys (fresh
879    /// CSPRNG every time — never a cached/reused value).
880    #[test]
881    fn bind_fresh_daemon_always_mints_a_new_key() {
882        let dir = tempfile::tempdir().unwrap();
883        let _guard =
884            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
885        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
886
887        let (k1, _) = bind_fresh_daemon("d:1").unwrap();
888        let (k2, _) = bind_fresh_daemon("d:1").unwrap();
889        assert_ne!(k1, k2, "each bind must mint a fresh key");
890    }
891
892    // ── record_unlock_key tests ────────────────────────────────────
893
894    #[test]
895    fn record_unlock_key_persists_to_known_servers() {
896        let dir = tempfile::tempdir().unwrap();
897        let _guard =
898            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
899        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
900
901        let key: [u8; 32] = [11u8; 32];
902        record_unlock_key("unix:///run/choreo.sock", &key).unwrap();
903
904        // Persisted: a fresh load sees the key (this is the unix-socket
905        // carrier case — the entry was created with no pubkey).
906        let store = KnownServers::load().unwrap();
907        assert_eq!(
908            store.unlock_key("unix:///run/choreo.sock").unwrap(),
909            Some(key)
910        );
911        let entry = store
912            .entries()
913            .iter()
914            .find(|e| e.addr == "unix:///run/choreo.sock")
915            .unwrap();
916        assert!(entry.pubkey.is_none(), "carrier entry must have no pin");
917    }
918
919    /// Legacy files are NEVER deleted by `record_unlock_key` (or anything
920    /// else): `known_servers.toml` supersedes them, but they stay on disk.
921    #[test]
922    fn record_unlock_key_never_touches_legacy_files() {
923        let dir = tempfile::tempdir().unwrap();
924        let _guard =
925            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
926        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
927
928        let (_, sk) = choreo_keystore::crypto::generate_keypair();
929        std::fs::write(dir.path().join("choreographr/identity.pk"), sk).unwrap();
930
931        let other: [u8; 32] = [12u8; 32];
932        record_unlock_key("d:1", &other).unwrap();
933
934        // The record is in place and the legacy file is untouched.
935        assert!(dir.path().join("choreographr/identity.pk").exists());
936        let store = KnownServers::load().unwrap();
937        assert_eq!(store.unlock_key("d:1").unwrap(), Some(other));
938    }
939
940    #[test]
941    fn record_unlock_key_rejects_non_32_byte_key() {
942        let dir = tempfile::tempdir().unwrap();
943        let _guard =
944            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
945        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
946
947        assert!(record_unlock_key("d:1", b"short").is_err());
948    }
949
950    // ── KeystoreAutoBind tests ─────────────────────────────────────
951
952    #[test]
953    fn auto_bind_first_call_binds_and_records() {
954        let dir = tempfile::tempdir().unwrap();
955        let _guard =
956            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
957        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
958
959        let mut bind = KeystoreAutoBind::new();
960        assert!(!bind.attempted());
961        let (key, msg) = bind
962            .on_unbound("bind-test:1")
963            .unwrap()
964            .expect("first call binds");
965        assert!(bind.attempted(), "the latch is set after the first report");
966
967        // The minted key is recorded into known_servers PRE-SEND, and the
968        // returned message carries exactly that key.
969        let ClientMessage::BindKeystore { key: sent } = msg else {
970            panic!("auto-bind must produce BindKeystore");
971        };
972        assert_eq!(sent, key.to_vec());
973        let store = KnownServers::load().unwrap();
974        assert_eq!(store.unlock_key("bind-test:1").unwrap(), Some(key));
975    }
976
977    #[test]
978    fn auto_bind_second_call_never_rebinds() {
979        let dir = tempfile::tempdir().unwrap();
980        let _guard =
981            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
982        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
983
984        let mut bind = KeystoreAutoBind::new();
985        let (first_key, _) = bind
986            .on_unbound("bind-test:2")
987            .unwrap()
988            .expect("first call binds");
989
990        // A second report must return Ok(None) — no re-bind, and the
991        // known_servers record is untouched (same key as the first bind).
992        assert!(bind.on_unbound("bind-test:2").unwrap().is_none());
993        let store = KnownServers::load().unwrap();
994        assert_eq!(
995            store.unlock_key("bind-test:2").unwrap(),
996            Some(first_key),
997            "the recorded key is never replaced by a second report"
998        );
999    }
1000
1001    #[test]
1002    fn auto_bind_store_failure_propagates_and_latches() {
1003        // Point the config root at an existing FILE: config_dir() resolves to
1004        // `<file>/choreographr`, which cannot be created, so the mandatory
1005        // pre-send store write fails and the bind is refused.
1006        let dir = tempfile::tempdir().unwrap();
1007        let blocker = dir.path().join("not-a-dir");
1008        std::fs::write(&blocker, b"blocker").unwrap();
1009        let _guard = choreo_keystore::paths::TestConfigGuard::set_root(Some(blocker.clone()));
1010
1011        let mut bind = KeystoreAutoBind::new();
1012        // The error must propagate, not be swallowed into a bind.
1013        let _err = bind.on_unbound("bind-fail:1").unwrap_err();
1014        // The failed attempt still consumed the connection's one bind.
1015        assert!(bind.attempted());
1016        assert!(bind.on_unbound("bind-fail:1").unwrap().is_none());
1017    }
1018
1019    /// `attempt_keystore_auto_bind` (the SHARED trigger behind both
1020    /// frontends): the first call returns `Bind` with the minted key, the
1021    /// second returns the `Suppressed` bind-loop guard, and a refused
1022    /// pre-send persist surfaces as `Failed` — the policy every frontend
1023    /// inherits from this one site.
1024    #[test]
1025    fn attempt_keystore_auto_bind_maps_the_three_outcomes() {
1026        let dir = tempfile::tempdir().unwrap();
1027        let _guard =
1028            choreo_keystore::paths::TestConfigGuard::set_root(Some(dir.path().to_path_buf()));
1029        std::fs::create_dir_all(dir.path().join("choreographr")).unwrap();
1030
1031        let mut bind = KeystoreAutoBind::new();
1032        let first = attempt_keystore_auto_bind(&mut bind, "attempt-test:1");
1033        let AutoBindAttempt::Bind {
1034            key,
1035            msg: ClientMessage::BindKeystore { key: wire_key },
1036        } = &first
1037        else {
1038            panic!("first attempt must bind, got {first:?}");
1039        };
1040        assert_eq!(
1041            wire_key,
1042            &key.to_vec(),
1043            "the message carries the minted key"
1044        );
1045        // Pre-send record: the shared trigger persisted the key into
1046        // known_servers BEFORE returning, so a lost confirmation still leaves
1047        // a matching record.
1048        let recorded = KnownServers::load()
1049            .unwrap()
1050            .unlock_key("attempt-test:1")
1051            .unwrap();
1052        assert_eq!(recorded, Some(*key));
1053
1054        // Second attempt: the bind-loop guard exposes as `Suppressed`, so the
1055        // callers surface their own reconnect-to-retry UI and never re-mint.
1056        let second = attempt_keystore_auto_bind(&mut bind, "attempt-test:1");
1057        assert!(
1058            matches!(second, AutoBindAttempt::Suppressed),
1059            "the bind-loop guard must suppress, got {second:?}"
1060        );
1061
1062        // Refused store: a blocker file in place of the config directory makes
1063        // the pre-send store write fail → `Failed` with the error text, and
1064        // the latch still counts the attempt as the connection's one bind.
1065        let blocker = dir.path().join("not-a-dir");
1066        std::fs::write(&blocker, b"blocker").unwrap();
1067        let _guard = choreo_keystore::paths::TestConfigGuard::set_root(Some(blocker.clone()));
1068        let mut bind = KeystoreAutoBind::new();
1069        let failed = attempt_keystore_auto_bind(&mut bind, "attempt-fail:1");
1070        let AutoBindAttempt::Failed { error } = &failed else {
1071            panic!("refused store must surface as Failed, got {failed:?}");
1072        };
1073        assert!(
1074            !error.to_string().is_empty(),
1075            "the failure is surfaced with context"
1076        );
1077        assert!(bind.attempted(), "the failed attempt consumed the latch");
1078    }
1079}