Skip to main content

kovra_agent/
session.rs

1//! Session logic: map a parsed ssh-agent [`Request`] to a response, applying
2//! kovra's policy (KOV-13). This is the heart of the governed agent and is
3//! **pure / OS-free** so it is driven entirely by mocks in tests — the real
4//! socket and a real `ssh` client are `[host]`, not unit-tested (CLAUDE.md
5//! rule 4, like the biometric path).
6//!
7//! For each request the session:
8//! - **REQUEST_IDENTITIES** → enumerate the custodied keypairs that are
9//!   *addressable* under the agent's [`AgentScope`] (I13: an out-of-scope key is
10//!   not even listed — unaddressable, not listed-then-refused) and that hold a
11//!   private half, and answer with their public blobs.
12//! - **SIGN_REQUEST** → match the requested public blob to a custodied keypair
13//!   (exact bytes); re-check scope (I13, defense in depth); run the per-signature
14//!   policy funnel exactly like KOV-12's `gate_private_key_op`
15//!   (`Operation::Inject` → `policy::decide`): `high`/`prod` confirm via the
16//!   injected [`Confirmer`] on **every** signature (I3/I15), `low`/`medium` sign
17//!   silently; sign **in memory** (I7) with `keypair::sign_ssh_agent`; and audit
18//!   the act (I12 — coordinate + truncated fingerprint, never key bytes, never
19//!   the challenge). Any denial/timeout/mismatch yields `SSH_AGENT_FAILURE`.
20
21use std::time::Duration;
22
23use kovra_core::{
24    AccessRequest, AgentScope, AuditAction, AuditEvent, AuditSink, Clock, ConfirmOutcome,
25    ConfirmRequest, Confirmer, Coordinate, Decision, Operation, Origin, Sensitivity, Surface,
26    decide, fingerprint, public_key_blob, sign_ssh_agent,
27};
28use zeroize::Zeroizing;
29
30use crate::error::AgentError;
31use crate::protocol::{
32    Identity, Request, encode_failure, encode_identities_answer, encode_sign_response,
33};
34
35/// A custodied keypair the agent can offer/sign with. Built by the face from a
36/// `SecretRecord::Keypair { private: Some(_), .. }`; the private half is held in
37/// a zeroizing buffer that is wiped when the entry drops. Public-only entries
38/// are never turned into a `KeypairEntry` (nothing to sign with).
39pub struct KeypairEntry {
40    /// The canonical coordinate (`<env>/<component>/<key>`), for prompts/audit.
41    pub coordinate: Coordinate,
42    /// The owning project (`None` = global vault), for scope addressing.
43    pub project: Option<String>,
44    /// The environment segment, for the audit/confirm record.
45    pub environment: String,
46    /// The secret's sensitivity (drives per-signature confirmation).
47    pub sensitivity: Sensitivity,
48    /// OpenSSH public key (`ssh-ed25519 …` / `ssh-rsa …`).
49    pub public_openssh: String,
50    /// OpenSSH private key, sealed in memory only — never written to disk (I7).
51    pub private_openssh: Zeroizing<String>,
52}
53
54impl KeypairEntry {
55    /// The canonical coordinate string for prompts and audit.
56    fn canonical(&self) -> String {
57        self.coordinate.canonical_path().unwrap_or_else(|_| {
58            format!(
59                "{}/{}/{}",
60                self.environment, self.coordinate.component, self.coordinate.key
61            )
62        })
63    }
64
65    /// Whether this key is addressable under `scope` (I13). A key not addressable
66    /// is neither listed nor signable — it does not exist for this channel.
67    fn addressable(&self, scope: &AgentScope) -> bool {
68        scope.addresses(&self.coordinate, self.project.as_deref())
69    }
70
71    /// The advertised comment (the canonical coordinate, public metadata).
72    fn comment(&self) -> String {
73        format!("kovra:{}", self.canonical())
74    }
75}
76
77/// Everything the session needs from the face: the custodied keys, the agent's
78/// scope, the confirmation broker, the audit sink, the clock, and the
79/// confirmation timeout. All behind traits so tests inject mocks.
80pub struct Session<'a> {
81    /// The keys this agent may offer/sign with (already filtered to those with a
82    /// private half).
83    pub keys: &'a [KeypairEntry],
84    /// The agent's capability scope (I13).
85    pub scope: &'a AgentScope,
86    /// The per-signature confirmation broker (biometric / file fallback).
87    pub confirmer: &'a dyn Confirmer,
88    /// The append-only audit sink (I12).
89    pub audit: &'a dyn AuditSink,
90    /// The clock for audit timestamps.
91    pub clock: &'a dyn Clock,
92    /// How long a `high`/`prod` confirmation may block before failing safe.
93    pub confirm_timeout: Duration,
94    /// The observed requesting process, for the I16 prompt line (set by the
95    /// face from `kovra_wrapper::observe_parent()`); `None` when unobserved.
96    pub requesting_process: Option<String>,
97}
98
99impl Session<'_> {
100    /// Handle one parsed request, returning the **response body** (ready to be
101    /// framed by the daemon). All policy faults map to `SSH_AGENT_FAILURE`; this
102    /// function never returns an `Err` for a protocol-level refusal (the wire
103    /// answer carries it). It returns `Err` only on an audit/IO fault the daemon
104    /// should log.
105    pub fn handle(&self, request: &Request) -> Result<Vec<u8>, AgentError> {
106        match request {
107            Request::RequestIdentities => Ok(self.identities_answer()),
108            Request::SignRequest {
109                key_blob,
110                data,
111                flags,
112            } => self.sign(key_blob, data, *flags),
113        }
114    }
115
116    /// `SSH_AGENT_IDENTITIES_ANSWER` over the in-scope custodied keys (I13).
117    fn identities_answer(&self) -> Vec<u8> {
118        let mut identities = Vec::new();
119        for k in self.keys {
120            if !k.addressable(self.scope) {
121                continue; // out of scope: unaddressable, not listed (I13)
122            }
123            // The public blob is public material (I12); a key whose public half
124            // cannot be encoded is silently skipped rather than aborting the list.
125            if let Ok(blob) = public_key_blob(&k.public_openssh) {
126                identities.push(Identity {
127                    key_blob: blob,
128                    comment: k.comment(),
129                });
130            }
131        }
132        encode_identities_answer(&identities)
133    }
134
135    /// `SSH_AGENT_SIGN_RESPONSE`, or `SSH_AGENT_FAILURE` on any refusal.
136    fn sign(&self, key_blob: &[u8], data: &[u8], flags: u32) -> Result<Vec<u8>, AgentError> {
137        // (a) Match the requested public blob to a custodied key by exact bytes.
138        //     A key the agent does not hold → FAILURE (no information leak).
139        let key = match self.match_key(key_blob) {
140            Some(k) => k,
141            None => return Ok(encode_failure()),
142        };
143        let canonical = key.canonical();
144
145        // (b) Re-check scope (I13, defense in depth). An out-of-scope key was
146        //     never listed; even a client that crafted its blob cannot sign.
147        if !key.addressable(self.scope) {
148            self.record(
149                AuditAction::OutOfScopeAttempt,
150                "unaddressable",
151                &canonical,
152                &key.environment,
153                None,
154            );
155            return Ok(encode_failure());
156        }
157
158        // (c) Per-signature policy funnel — identical to KOV-12's
159        //     gate_private_key_op: a private-key op routed as Inject.
160        let request = AccessRequest {
161            coordinate: &key.coordinate,
162            project: key.project.as_deref(),
163            sensitivity: key.sensitivity,
164            revealable: false,
165            operation: Operation::Inject,
166            surface: Surface::Cli,
167            origin: Origin::Human,
168        };
169        match decide(&request, self.scope) {
170            Decision::Allow => {
171                // low/medium: sign silently, but still audited (I12).
172                let sig = self.sign_in_memory(key, data, flags)?;
173                self.record(
174                    AuditAction::Inject,
175                    "sign",
176                    &canonical,
177                    &key.environment,
178                    Some(key),
179                );
180                Ok(encode_sign_response(&sig))
181            }
182            Decision::RequireConfirmation => {
183                // high/prod: confirm on EVERY signature (I3/I15).
184                let mut req = ConfirmRequest::new(
185                    &canonical,
186                    key.sensitivity,
187                    &key.environment,
188                    Origin::Human,
189                )
190                .with_command(format!("ssh-agent sign {canonical}"));
191                if let Some(proc) = &self.requesting_process {
192                    req = req.with_requesting_process(proc.clone());
193                }
194                match self.confirmer.confirm(&req, self.confirm_timeout) {
195                    ConfirmOutcome::Approved => {
196                        let sig = self.sign_in_memory(key, data, flags)?;
197                        self.record(
198                            AuditAction::Approve,
199                            "approved",
200                            &canonical,
201                            &key.environment,
202                            Some(key),
203                        );
204                        Ok(encode_sign_response(&sig))
205                    }
206                    ConfirmOutcome::Denied => {
207                        self.record(
208                            AuditAction::Deny,
209                            "denied",
210                            &canonical,
211                            &key.environment,
212                            None,
213                        );
214                        Ok(encode_failure())
215                    }
216                    ConfirmOutcome::TimedOut => {
217                        self.record(
218                            AuditAction::Timeout,
219                            "timeout",
220                            &canonical,
221                            &key.environment,
222                            None,
223                        );
224                        Ok(encode_failure())
225                    }
226                }
227            }
228            // Unaddressable was handled above; any other non-allow decision
229            // (e.g. a future Deny) fails safe to FAILURE, audited.
230            Decision::Deny(_) | Decision::Unaddressable => {
231                self.record(
232                    AuditAction::Deny,
233                    "denied",
234                    &canonical,
235                    &key.environment,
236                    None,
237                );
238                Ok(encode_failure())
239            }
240        }
241    }
242
243    /// Sign the challenge **in memory** with the custodied private key (I7). The
244    /// key bytes live only inside this call; the raw ssh-agent signature blob
245    /// carries no key material.
246    fn sign_in_memory(
247        &self,
248        key: &KeypairEntry,
249        data: &[u8],
250        flags: u32,
251    ) -> Result<Vec<u8>, AgentError> {
252        Ok(sign_ssh_agent(&key.private_openssh, data, flags)?)
253    }
254
255    /// Find the custodied key whose public blob equals `key_blob` (exact bytes).
256    fn match_key(&self, key_blob: &[u8]) -> Option<&KeypairEntry> {
257        self.keys.iter().find(|k| {
258            public_key_blob(&k.public_openssh)
259                .map(|b| b == key_blob)
260                .unwrap_or(false)
261        })
262    }
263
264    /// Record an audit event (I12). The optional `key` adds the **public** key's
265    /// truncated fingerprint — never the private half, never the challenge.
266    fn record(
267        &self,
268        action: AuditAction,
269        result: &str,
270        canonical: &str,
271        environment: &str,
272        key: Option<&KeypairEntry>,
273    ) {
274        let mut ev = AuditEvent::new(self.clock, action, result)
275            .at(canonical, environment)
276            .by(Origin::Human);
277        if let Some(k) = key {
278            ev = ev.with_fingerprint(fingerprint(k.public_openssh.as_bytes()));
279        }
280        // An audit write failure must not crash the daemon mid-connection; the
281        // file sink fsyncs, and a transient error is dropped like the CLI's
282        // `audit()` helper does.
283        let _ = self.audit.record(&ev);
284    }
285}