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}