Skip to main content

netget/server/ssh_agent/
actions.rs

1//! SSH Agent server protocol actions
2
3use crate::llm::actions::{
4    protocol_trait::{ActionResult, Protocol, Server},
5    ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
9use crate::protocol::EventType;
10use crate::state::app_state::AppState;
11use anyhow::{Context, Result};
12use serde_json::json;
13use std::sync::LazyLock;
14
15// ============================================================================
16// Action constants
17//
18// These are defined here rather than inline in `get_sync_actions` so that each event type can
19// attach the actions that actually answer it. An event that lists none leaves the model with
20// only the common actions (`set_memory`, `show_message`, ...), so every agent action it returns
21// is rejected as unknown and the request fails after two retries - which is what happened to
22// all eight of these events. See `tests/event_action_declarations_test.rs`.
23// ============================================================================
24
25pub static SEND_IDENTITIES_LIST_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
26    ActionDefinition {
27        name: "send_identities_list".to_string(),
28        description: "Answer ssh_agent_request_identities with the keys this agent \
29            holds. This is the ONLY valid answer to that event - send_success does not \
30            satisfy it. An empty 'identities' array is valid and means the agent holds \
31            no keys, which is what `ssh-add -l` reports as \"no identities\"."
32            .to_string(),
33        parameters: vec![Parameter {
34            name: "identities".to_string(),
35            type_hint: "array".to_string(),
36            description: "Array of objects, one per key. Give each key as 'public_key': an \
37                OpenSSH public key exactly as it appears in authorized_keys or a .pub file, \
38                \"<algorithm> <base64 blob> [comment]\". 'comment' is the label \
39                `ssh-add -l` prints and overrides any comment in the line. \
40                'public_key_blob_hex' is the escape hatch for a blob with no OpenSSH text \
41                form - give that or 'public_key', never both. A key whose blob will not \
42                decode, or whose length prefixes do not span it exactly, fails the whole \
43                request rather than sending a broken identity"
44                .to_string(),
45            required: true,
46        }],
47        // An OpenSSH text key, not a hex blob. The hex spelling here used to declare a
48        // 32-byte ed25519 key and then supply three bytes of it - a truncation nobody could
49        // see, because nobody proofreads hex, and one `ssh-add -l` would have rejected. The
50        // text form is the one a model has actually seen, and its framing is now checked.
51        example: json!({"type": "send_identities_list", "identities": [{"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH98ewNgR4yzG9S6UrA3P6sN2mFMedO/XrRqPcibUAfr", "comment": "deploy-key"}]}),
52        log_template: Some(
53            LogTemplate::new()
54                .with_info("-> SSH Agent {identities_len} identities")
55                .with_debug("SSH Agent send_identities_list: count={identities_len}"),
56        ),
57    }
58});
59
60pub static SEND_SIGN_RESPONSE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| {
61    ActionDefinition {
62        name: "send_sign_response".to_string(),
63        description: "Answer ssh_agent_sign_request with a signature over the data the \
64            client sent. NOTE: no private key exists here - you are fabricating the \
65            signature bytes, so a real client will fail to verify them against the \
66            public key it holds. Useful for honeypots and for exercising the protocol; \
67            reply with send_failure to refuse the signature instead."
68            .to_string(),
69        parameters: vec![
70            Parameter {
71                name: "algorithm".to_string(),
72                type_hint: "string".to_string(),
73                description: "Signature algorithm name - \"ssh-ed25519\", \"ssh-rsa\", \
74                    \"rsa-sha2-256\" or \"rsa-sha2-512\" - matching the key in the event's \
75                    'key_type' and its 'flags'. The server builds the framing (algorithm \
76                    name and signature, each length-prefixed) and, unless you give \
77                    'signature_bytes_hex', fabricates a signature of the size that \
78                    algorithm requires. This is the field to use"
79                    .to_string(),
80                required: false,
81            },
82            Parameter {
83                name: "signature_bytes_hex".to_string(),
84                type_hint: "string".to_string(),
85                description: "Optional: the raw signature bytes to put inside the framing \
86                    'algorithm' builds - 64 for ssh-ed25519. NOT the framed blob; that is \
87                    'signature_hex'"
88                    .to_string(),
89                required: false,
90            },
91            Parameter {
92                name: "signature_hex".to_string(),
93                type_hint: "string".to_string(),
94                description: "Escape hatch: the whole framed signature blob as hex - the \
95                    algorithm name and the signature, each length-prefixed, as in the \
96                    event's 'public_key_blob_hex' encoding. Give this or 'algorithm', never \
97                    both. Invalid hex, or hex decoding to zero bytes, fails the request \
98                    instead of sending an empty signature"
99                    .to_string(),
100                required: false,
101            },
102        ],
103        // Name the algorithm; the server frames it. The hex spelling here used to declare a
104        // 64-byte ed25519 signature and supply four bytes of it, which is the drift a hex
105        // literal in an example invites - and the framing is the only part of a fabricated
106        // signature that has a right answer at all.
107        example: json!({"type": "send_sign_response", "algorithm": "ssh-ed25519"}),
108        log_template: Some(
109            LogTemplate::new()
110                .with_info("-> SSH Agent signature")
111                .with_debug("SSH Agent send_sign_response"),
112        ),
113    }
114});
115
116pub static SEND_SUCCESS_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| ActionDefinition {
117    name: "send_success".to_string(),
118    description: "Send SSH_AGENT_SUCCESS: the operation was accepted. This is the \
119        expected answer to ssh_agent_add_identity, ssh_agent_remove_identity, \
120        ssh_agent_remove_all_identities, ssh_agent_lock and ssh_agent_unlock. It is \
121        NOT a valid answer to a request for identities or a signature."
122        .to_string(),
123    parameters: vec![],
124    example: json!({"type": "send_success"}),
125    log_template: Some(
126        LogTemplate::new()
127            .with_info("-> SSH Agent SUCCESS")
128            .with_debug("SSH Agent send_success"),
129    ),
130});
131
132pub static SEND_FAILURE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| ActionDefinition {
133    name: "send_failure".to_string(),
134    description: "Send SSH_AGENT_FAILURE: refuse the operation. Use it to deny a \
135        signature, reject a key, or refuse an unlock whose passphrase does not \
136        match. Every event accepts this. If you return no action at all the server \
137        sends nothing and the client blocks, so refuse explicitly."
138        .to_string(),
139    parameters: vec![],
140    example: json!({"type": "send_failure"}),
141    log_template: Some(
142        LogTemplate::new()
143            .with_info("-> SSH Agent FAILURE")
144            .with_debug("SSH Agent send_failure"),
145    ),
146});
147
148pub static CLOSE_CONNECTION_ACTION: LazyLock<ActionDefinition> =
149    LazyLock::new(|| ActionDefinition {
150        name: "close_connection".to_string(),
151        description: "Close the agent connection that raised this event, without \
152        replying to the request. The client sees the socket drop. Use send_failure \
153        instead when you want to refuse an operation but keep the session."
154            .to_string(),
155        parameters: vec![],
156        example: json!({"type": "close_connection"}),
157        log_template: Some(
158            LogTemplate::new()
159                .with_info("-> SSH Agent close connection")
160                .with_debug("SSH Agent close_connection"),
161        ),
162    });
163
164pub static WAIT_FOR_MORE_ACTION: LazyLock<ActionDefinition> = LazyLock::new(|| ActionDefinition {
165    name: "wait_for_more".to_string(),
166    description: "Send no reply and wait for the next message. Rarely correct: the \
167        agent protocol is strict request/response and a client blocks until it gets \
168        an answer, so prefer send_success or send_failure."
169        .to_string(),
170    parameters: vec![],
171    example: json!({"type": "wait_for_more"}),
172    log_template: Some(
173        LogTemplate::new()
174            .with_info("-> SSH Agent wait")
175            .with_debug("SSH Agent wait_for_more"),
176    ),
177});
178
179// Event type constants
180pub static SSH_AGENT_CONNECTION_OPENED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
181    EventType::new(
182        "ssh_agent_connection_opened",
183        "A client connected to the agent socket and has not sent a request yet. Nothing is \
184         expected of you here - it exists so you can set up state before the first request. \
185         Returning no action is normal; do NOT send send_success, which would put an \
186         unrequested reply on the wire and desynchronise the client. Close the connection if \
187         you want to refuse the client outright.",
188        json!({
189            "type": "close_connection"
190        }),
191    )
192    .with_parameter(Parameter {
193        name: "connection_id".to_string(),
194        type_hint: "string".to_string(),
195        description: "Unique connection identifier".to_string(),
196        required: true,
197    })
198    // Only close_connection: replying to a request the client has not made desynchronises it,
199    // which the description above spells out. Refusing the connection outright is legitimate.
200    .with_actions(vec![CLOSE_CONNECTION_ACTION.clone()])
201    .with_log_template(
202        LogTemplate::new()
203            .with_info("SSH Agent connection opened")
204            .with_debug("SSH Agent conn={connection_id}")
205            .with_trace("SSH Agent connection: {json_pretty(.)}"),
206    )
207});
208
209pub static SSH_AGENT_REQUEST_IDENTITIES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
210    EventType::new(
211        "ssh_agent_request_identities",
212        "The client asked which keys this agent holds (`ssh-add -l`, or ssh choosing a key for a \
213         login). Answer with send_identities_list - an empty list is valid.",
214        json!({
215            "type": "send_identities_list",
216            "identities": []
217        }),
218    )
219    .with_actions(vec![
220        SEND_IDENTITIES_LIST_ACTION.clone(),
221        SEND_FAILURE_ACTION.clone(),
222        CLOSE_CONNECTION_ACTION.clone(),
223    ])
224    .with_log_template(
225        LogTemplate::new()
226            .with_info("SSH Agent REQUEST_IDENTITIES")
227            .with_debug("SSH Agent REQUEST_IDENTITIES")
228            .with_trace("SSH Agent identities request: {json_pretty(.)}"),
229    )
230});
231
232pub static SSH_AGENT_SIGN_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
233    EventType::new(
234        "ssh_agent_sign_request",
235        "The client asked the agent to sign a challenge with one of its keys, which is how an \
236         SSH login using agent authentication proves key possession. Answer with \
237         send_sign_response to sign, or send_failure to refuse. No private key exists here, so \
238         any signature you return is fabricated and will not verify.",
239        json!({
240            "type": "send_sign_response",
241            "algorithm": "ssh-ed25519"
242        }),
243    )
244    .with_parameters(vec![
245        Parameter {
246            name: "key_type".to_string(),
247            type_hint: "string".to_string(),
248            description: "Algorithm name decoded from the key blob, e.g. \"ssh-ed25519\" or \
249                \"ssh-rsa\". Use this to identify which key is being asked for rather than \
250                comparing hex blobs. Empty if the blob could not be decoded"
251                .to_string(),
252            required: true,
253        },
254        Parameter {
255            name: "public_key_blob_hex".to_string(),
256            type_hint: "string".to_string(),
257            description: "The public key the client wants to sign with, as the hex-encoded SSH \
258                wire blob. Compare it against the blobs you returned from send_identities_list \
259                to tell which of your keys is meant"
260                .to_string(),
261            required: true,
262        },
263        Parameter {
264            name: "data_hex".to_string(),
265            type_hint: "string".to_string(),
266            description: "The challenge to be signed, hex-encoded. It is an SSH session \
267                identifier and login request, not human-readable text"
268                .to_string(),
269            required: true,
270        },
271        Parameter {
272            name: "flags".to_string(),
273            type_hint: "integer".to_string(),
274            description: "Signature flags from the client: 0 for the key's default algorithm, \
275                2 requests rsa-sha2-256 and 4 rsa-sha2-512 for RSA keys"
276                .to_string(),
277            required: true,
278        },
279    ])
280    .with_actions(vec![
281        SEND_SIGN_RESPONSE_ACTION.clone(),
282        SEND_FAILURE_ACTION.clone(),
283        CLOSE_CONNECTION_ACTION.clone(),
284    ])
285    .with_log_template(
286        LogTemplate::new()
287            .with_info("SSH Agent SIGN_REQUEST flags={flags}")
288            .with_debug("SSH Agent SIGN_REQUEST flags={flags}")
289            .with_trace("SSH Agent sign request: {json_pretty(.)}"),
290    )
291});
292
293pub static SSH_AGENT_ADD_IDENTITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
294    EventType::new(
295        "ssh_agent_add_identity",
296        "The client is loading a private key into the agent (`ssh-add`). The private key material \
297         is parsed off the wire and discarded - it is never given to you and never stored. \
298         Answer send_success to accept the key or send_failure to refuse it; remember accepted \
299         keys in memory if you want them to appear in later identity listings.",
300        json!({
301            "type": "send_success"
302        })
303    )
304    .with_parameters(vec![
305        Parameter {
306            name: "key_type".to_string(),
307            type_hint: "string".to_string(),
308            description: "Algorithm name the client sent, e.g. \"ssh-ed25519\" or \"ssh-rsa\""
309                .to_string(),
310            required: true,
311        },
312        Parameter {
313            name: "public_key_blob_hex".to_string(),
314            type_hint: "string".to_string(),
315            description: "Hex-encoded public part of the key. NOTE: this is parsed assuming the \
316                Ed25519 layout, so for RSA and other multi-field key types this value and \
317                'comment' may be wrong or the message may fail to parse entirely"
318                .to_string(),
319            required: true,
320        },
321        Parameter {
322            name: "comment".to_string(),
323            type_hint: "string".to_string(),
324            description: "The label the client attached to the key, usually the path or an \
325                email address. Subject to the same Ed25519-layout caveat as \
326                'public_key_blob_hex'"
327                .to_string(),
328            required: true,
329        },
330        Parameter {
331            name: "constrained".to_string(),
332            type_hint: "boolean".to_string(),
333            description: "True if the client sent the key with constraints such as a lifetime \
334                (`ssh-add -t`) or confirmation requirement (`ssh-add -c`). The constraints \
335                themselves are not parsed and nothing enforces them - honour them yourself if \
336                you care"
337                .to_string(),
338            required: true,
339        },
340    ])
341    .with_actions(vec![
342        SEND_SUCCESS_ACTION.clone(),
343        SEND_FAILURE_ACTION.clone(),
344        CLOSE_CONNECTION_ACTION.clone(),
345    ])
346    .with_log_template(
347        LogTemplate::new()
348            .with_info("SSH Agent ADD_IDENTITY {key_type} ({comment})")
349            .with_debug("SSH Agent ADD_IDENTITY key_type={key_type} comment={comment} constrained={constrained}")
350            .with_trace("SSH Agent add identity: {json_pretty(.)}"),
351    )
352});
353
354pub static SSH_AGENT_REMOVE_IDENTITY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
355    EventType::new(
356        "ssh_agent_remove_identity",
357        "The client asked the agent to forget one key (`ssh-add -d`). Answer send_success if you \
358         drop it from memory, or send_failure if you do not hold it.",
359        json!({
360            "type": "send_success"
361        }),
362    )
363    .with_parameters(vec![Parameter {
364        name: "public_key_blob_hex".to_string(),
365        type_hint: "string".to_string(),
366        description: "Hex-encoded SSH wire blob of the key to forget. Match it against the \
367                blobs you return from send_identities_list"
368            .to_string(),
369        required: true,
370    }])
371    .with_actions(vec![
372        SEND_SUCCESS_ACTION.clone(),
373        SEND_FAILURE_ACTION.clone(),
374        CLOSE_CONNECTION_ACTION.clone(),
375    ])
376    .with_log_template(
377        LogTemplate::new()
378            .with_info("SSH Agent REMOVE_IDENTITY")
379            .with_debug("SSH Agent REMOVE_IDENTITY")
380            .with_trace("SSH Agent remove identity: {json_pretty(.)}"),
381    )
382});
383
384pub static SSH_AGENT_REMOVE_ALL_IDENTITIES_EVENT: LazyLock<EventType> = LazyLock::new(|| {
385    EventType::new(
386        "ssh_agent_remove_all_identities",
387        "The client asked the agent to forget every key (`ssh-add -D`). Answer send_success after \
388         clearing them from memory.",
389        json!({
390            "type": "send_success"
391        }),
392    )
393    .with_actions(vec![
394        SEND_SUCCESS_ACTION.clone(),
395        SEND_FAILURE_ACTION.clone(),
396        CLOSE_CONNECTION_ACTION.clone(),
397    ])
398    .with_log_template(
399        LogTemplate::new()
400            .with_info("SSH Agent REMOVE_ALL_IDENTITIES")
401            .with_debug("SSH Agent REMOVE_ALL_IDENTITIES")
402            .with_trace("SSH Agent remove all: {json_pretty(.)}"),
403    )
404});
405
406pub static SSH_AGENT_LOCK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
407    EventType::new(
408        "ssh_agent_lock",
409        "The client asked to lock the agent with a passphrase (`ssh-add -x`). While locked, a \
410         real agent hides its identities and refuses to sign until unlocked. Nothing here \
411         enforces that: record the passphrase and the locked state in memory and honour it \
412         yourself on later events. Answer send_success to accept.",
413        json!({
414            "type": "send_success"
415        }),
416    )
417    .with_parameter(Parameter {
418        name: "passphrase".to_string(),
419        type_hint: "string".to_string(),
420        description: "The passphrase the client sent, in the clear".to_string(),
421        required: true,
422    })
423    .with_actions(vec![
424        SEND_SUCCESS_ACTION.clone(),
425        SEND_FAILURE_ACTION.clone(),
426        CLOSE_CONNECTION_ACTION.clone(),
427    ])
428    .with_log_template(
429        LogTemplate::new()
430            .with_info("SSH Agent LOCK")
431            .with_debug("SSH Agent LOCK")
432            .with_trace("SSH Agent lock: {json_pretty(.)}"),
433    )
434});
435
436pub static SSH_AGENT_UNLOCK_EVENT: LazyLock<EventType> = LazyLock::new(|| {
437    EventType::new(
438        "ssh_agent_unlock",
439        "The client asked to unlock the agent (`ssh-add -X`). Compare 'passphrase' with the one \
440         from the lock request you remembered: answer send_success if it matches and \
441         send_failure if it does not.",
442        json!({
443            "type": "send_success"
444        }),
445    )
446    .with_parameter(Parameter {
447        name: "passphrase".to_string(),
448        type_hint: "string".to_string(),
449        description: "The passphrase the client sent, in the clear".to_string(),
450        required: true,
451    })
452    .with_actions(vec![
453        SEND_SUCCESS_ACTION.clone(),
454        SEND_FAILURE_ACTION.clone(),
455        CLOSE_CONNECTION_ACTION.clone(),
456    ])
457    .with_log_template(
458        LogTemplate::new()
459            .with_info("SSH Agent UNLOCK")
460            .with_debug("SSH Agent UNLOCK")
461            .with_trace("SSH Agent unlock: {json_pretty(.)}"),
462    )
463});
464
465// ============================================================================
466// Turning what the model can write into what the wire needs
467//
468// Both of these exist for the same reason. An SSH key blob and an SSH signature blob are
469// sequences of length-prefixed strings, and the two examples this protocol advertised were
470// hex transcriptions of them that were *wrong*: one declared a 32-byte ed25519 key and
471// supplied three bytes, the other declared a 64-byte signature and supplied four. Nothing
472// caught it, because nothing proofreads hex - which is the whole argument of
473// `tests/example_hex_drift_test.rs`. Naming the algorithm and letting the server frame the
474// blob removes the only part of this a model could get wrong.
475// ============================================================================
476
477/// Do the SSH `string` length prefixes in `blob` span it exactly?
478///
479/// Every standard public key blob is a run of length-prefixed fields - `string(algorithm)`
480/// then the key's own fields, `mpint` for RSA, `string` for ed25519 and ECDSA - so a walk
481/// that consumes the blob exactly is the cheapest check that it is not truncated, which is
482/// how the old example was broken.
483fn ssh_fields_span_exactly(blob: &[u8]) -> bool {
484    let mut at = 0usize;
485    while at < blob.len() {
486        let Some(header) = blob.get(at..at + 4) else {
487            return false;
488        };
489        let len = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize;
490        at += 4;
491        let Some(end) = at.checked_add(len) else {
492            return false;
493        };
494        if end > blob.len() {
495            return false;
496        }
497        at = end;
498    }
499    at == blob.len() && !blob.is_empty()
500}
501
502/// Encode an SSH `string`: a 32-bit big-endian length followed by the bytes.
503fn ssh_string(out: &mut Vec<u8>, bytes: &[u8]) {
504    out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
505    out.extend_from_slice(bytes);
506}
507
508/// Normalise one entry of `send_identities_list` into the `public_key_blob_hex` the server
509/// writes, from whichever spelling the model used.
510fn normalise_identity(identity: &serde_json::Value) -> Result<serde_json::Value> {
511    use base64::Engine as _;
512
513    let text = identity
514        .get("public_key")
515        .and_then(|v| v.as_str())
516        .filter(|s| !s.trim().is_empty());
517    let blob_hex = identity
518        .get("public_key_blob_hex")
519        .and_then(|v| v.as_str())
520        .filter(|s| !s.is_empty());
521    let stated_comment = identity.get("comment").and_then(|v| v.as_str());
522
523    match (text, blob_hex) {
524        (Some(line), None) => {
525            let mut fields = line.split_whitespace();
526            let algorithm = fields
527                .next()
528                .context("'public_key' is empty; it is an authorized_keys line")?;
529            let encoded = fields.next().with_context(|| {
530                format!(
531                    "'public_key' must be \"<algorithm> <base64 blob> [comment]\", e.g. \
532                     \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...\"; got {line:?}"
533                )
534            })?;
535            let line_comment = fields.next();
536
537            let blob = base64::engine::general_purpose::STANDARD
538                .decode(encoded)
539                .with_context(|| {
540                    format!("the base64 blob in 'public_key' does not decode ({encoded:?})")
541                })?;
542            if !ssh_fields_span_exactly(&blob) {
543                anyhow::bail!(
544                    "the key blob in 'public_key' is truncated: its length prefixes do not \
545                     span its {} bytes. Copy a whole line from a .pub file rather than \
546                     shortening it",
547                    blob.len()
548                );
549            }
550            // The blob names its own algorithm; disagreeing with the word in front of it
551            // means one of the two is wrong and there is no way to tell which.
552            let named: &[u8] = {
553                let len = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]) as usize;
554                &blob[4..4 + len]
555            };
556            if named != algorithm.as_bytes() {
557                anyhow::bail!(
558                    "'public_key' says {algorithm:?} but its blob names {:?}",
559                    String::from_utf8_lossy(named)
560                );
561            }
562
563            Ok(json!({
564                "public_key_blob_hex": hex::encode(&blob),
565                "comment": stated_comment.or(line_comment).unwrap_or(""),
566            }))
567        }
568        (None, Some(blob_hex)) => Ok(json!({
569            "public_key_blob_hex": blob_hex,
570            "comment": stated_comment.unwrap_or(""),
571        })),
572        (Some(_), Some(_)) => anyhow::bail!(
573            "an identity has both 'public_key' and 'public_key_blob_hex'; they are two \
574             spellings of the same key and nothing can tell which one you meant"
575        ),
576        (None, None) => anyhow::bail!(
577            "an identity has neither 'public_key' (an authorized_keys line, e.g. \
578             \"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... deploy-key\") nor \
579             'public_key_blob_hex'"
580        ),
581    }
582}
583
584/// How many bytes of signature a named algorithm carries inside its frame.
585///
586/// RSA's is the modulus size, so 2048-bit is the assumption; a caller wanting anything else
587/// supplies `signature_bytes_hex`.
588fn fabricated_signature_len(algorithm: &str) -> Option<usize> {
589    match algorithm {
590        "ssh-ed25519" => Some(64),
591        "ssh-rsa" | "rsa-sha2-256" | "rsa-sha2-512" => Some(256),
592        _ => None,
593    }
594}
595
596/// Build the framed signature blob `send_sign_response` puts on the wire.
597fn sign_response_blob(action: &serde_json::Value) -> Result<Vec<u8>> {
598    let algorithm = action
599        .get("algorithm")
600        .and_then(|v| v.as_str())
601        .filter(|s| !s.is_empty());
602    let framed_hex = action
603        .get("signature_hex")
604        .and_then(|v| v.as_str())
605        .filter(|s| !s.is_empty());
606
607    match (algorithm, framed_hex) {
608        (Some(algorithm), None) => {
609            let signature = match action.get("signature_bytes_hex").and_then(|v| v.as_str()) {
610                Some(raw) if !raw.is_empty() => hex::decode(raw)
611                    .context("Invalid hex in 'signature_bytes_hex'")
612                    .and_then(|bytes| {
613                        if bytes.is_empty() {
614                            anyhow::bail!("'signature_bytes_hex' decoded to zero bytes");
615                        }
616                        Ok(bytes)
617                    })?,
618                _ => {
619                    let len = fabricated_signature_len(algorithm).with_context(|| {
620                        format!(
621                            "no signature size is known for {algorithm:?}, so there is \
622                             nothing to fabricate - give 'signature_bytes_hex' as well, or \
623                             the whole framed blob as 'signature_hex'"
624                        )
625                    })?;
626                    // Recognisable in a capture as what it is. Nothing here holds a private
627                    // key, so these bytes cannot verify and should not pretend to.
628                    const MARKER: &[u8] = b"netget-fabricated-signature-";
629                    MARKER.iter().copied().cycle().take(len).collect()
630                }
631            };
632
633            let mut blob = Vec::with_capacity(8 + algorithm.len() + signature.len());
634            ssh_string(&mut blob, algorithm.as_bytes());
635            ssh_string(&mut blob, &signature);
636            Ok(blob)
637        }
638        (None, Some(framed_hex)) => {
639            let blob = hex::decode(framed_hex).context("Invalid hex in 'signature_hex'")?;
640            if blob.is_empty() {
641                anyhow::bail!("'signature_hex' decoded to zero bytes");
642            }
643            Ok(blob)
644        }
645        (Some(_), Some(_)) => anyhow::bail!(
646            "give 'algorithm' or 'signature_hex', not both - the first builds the framing and \
647             the second already contains it"
648        ),
649        (None, None) => anyhow::bail!(
650            "Missing 'algorithm'. Name the signature algorithm (e.g. \"ssh-ed25519\") and the \
651             server frames the blob for you, or pass a whole framed blob as 'signature_hex'"
652        ),
653    }
654}
655
656/// SSH Agent server protocol implementation
657pub struct SshAgentProtocol;
658
659impl SshAgentProtocol {
660    pub fn new() -> Self {
661        Self
662    }
663}
664
665// Implement Protocol trait (common functionality)
666impl Protocol for SshAgentProtocol {
667    /// A Unix socket has no host and no port.
668    ///
669    /// Without this, `server_startup.rs` treats the protocol as "unmigrated" and *requires* a
670    /// `port`, so starting it from the dashboard or over MCP with only a `socket_path` failed
671    /// with "requires 'port' parameter" — the same defect `socket_file` had. Declaring empty
672    /// binding defaults opts into the path where port is optional; the listen address that
673    /// path computes is ignored, since the server binds `socket_path`.
674    fn default_binding(&self) -> Option<crate::protocol::BindingDefaults> {
675        Some(crate::protocol::BindingDefaults {
676            mac_address: None,
677            interface: None,
678            host: None,
679            port: None,
680        })
681    }
682
683    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
684        vec![
685            ParameterDefinition {
686                name: "socket_path".to_string(),
687                type_hint: "string".to_string(),
688                description: "Path to Unix domain socket (default: ./netget-ssh-agent.sock)"
689                    .to_string(),
690                required: false,
691                example: json!("./netget-ssh-agent.sock"),
692                default: None,
693            },
694            ParameterDefinition {
695                name: "first_byte_timeout_secs".to_string(),
696                type_hint: "number".to_string(),
697                description: "Seconds a connected peer may send no request at all before the \
698                              server closes it. Default 300: ssh-add and ssh ask at once, but \
699                              NetGet's own ssh_agent client connects and waits for a person."
700                    .to_string(),
701                required: false,
702                example: json!(300),
703                default: Some(serde_json::json!(super::FIRST_BYTE_READ_TIMEOUT.as_secs())),
704            },
705            ParameterDefinition {
706                name: "idle_timeout_secs".to_string(),
707                type_hint: "number".to_string(),
708                description: "Seconds an established connection may be silent between requests \
709                              before the server closes it. Default 900. A request still being \
710                              answered never counts as silence."
711                    .to_string(),
712                required: false,
713                example: json!(900),
714                default: Some(serde_json::json!(
715                    super::IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
716                )),
717            },
718        ]
719    }
720
721    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
722        // None.
723        //
724        // `modify_instruction` produced an ActionResult::Custom that execute_action_result
725        // ignored, so it never changed anything; the common `update_instruction` action does
726        // the job properly. `close_connection` declared a `connection_id` parameter that the
727        // executor discarded - it always closed the connection that raised the event, never
728        // the one named - so it has moved to the sync actions below, without that parameter.
729        Vec::new()
730    }
731
732    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
733        vec![
734            SEND_IDENTITIES_LIST_ACTION.clone(),
735            SEND_SIGN_RESPONSE_ACTION.clone(),
736            SEND_SUCCESS_ACTION.clone(),
737            SEND_FAILURE_ACTION.clone(),
738            CLOSE_CONNECTION_ACTION.clone(),
739            WAIT_FOR_MORE_ACTION.clone(),
740        ]
741    }
742
743    fn protocol_name(&self) -> &'static str {
744        "SSH Agent"
745    }
746
747    fn get_event_types(&self) -> Vec<EventType> {
748        vec![
749            (*SSH_AGENT_CONNECTION_OPENED_EVENT).clone(),
750            (*SSH_AGENT_REQUEST_IDENTITIES_EVENT).clone(),
751            (*SSH_AGENT_SIGN_REQUEST_EVENT).clone(),
752            (*SSH_AGENT_ADD_IDENTITY_EVENT).clone(),
753            (*SSH_AGENT_REMOVE_IDENTITY_EVENT).clone(),
754            (*SSH_AGENT_REMOVE_ALL_IDENTITIES_EVENT).clone(),
755            (*SSH_AGENT_LOCK_EVENT).clone(),
756            (*SSH_AGENT_UNLOCK_EVENT).clone(),
757        ]
758    }
759
760    fn stack_name(&self) -> &'static str {
761        "UNIX Socket > SSH Agent"
762    }
763
764    fn keywords(&self) -> Vec<&'static str> {
765        vec!["ssh-agent", "agent", "key-agent", "ssh keys"]
766    }
767
768    fn metadata(&self) -> ProtocolMetadataV2 {
769        ProtocolMetadataV2::builder()
770            .state(DevelopmentState::Experimental)
771            // Unix domain socket, so no privileged port is involved.
772            .implementation("Custom SSH Agent wire parser over a Unix domain socket")
773            .llm_control("Identity listings, signing decisions, key lifecycle, lock/unlock")
774            .e2e_testing(
775                "tests/server/ssh_agent drives the real binary over its Unix socket with a \
776                 hand-written agent client and a mocked model, covering REQUEST_IDENTITIES, \
777                 SIGN_REQUEST, ADD_IDENTITY and a pipelined multi-operation session. That \
778                 client is written from the wire format inside the test, so it is an \
779                 independent reading of the protocol, not an independent implementation - \
780                 the same class of evidence as dhcp's in-test RFC 2131 decoder. No \
781                 third-party agent client drives it, and none usefully could: see notes.",
782            )
783            .notes(
784                "FAILS CLOSED: an LLM error, an answer with no usable action, and an action \
785                 whose fields will not decode all send SSH_AGENT_FAILURE, logged as \
786                 decision=fail_closed_*. Saying nothing is not an option - the client blocks \
787                 on the read - and it is never SSH_AGENT_SUCCESS. Virtual agent: no private \
788                 keys exist, so signatures are fabricated and will not verify, which is why \
789                 a real `ssh` cannot complete a login through this agent and why its rating \
790                 cannot rest on one. ADD_IDENTITY parsing assumes the Ed25519 key layout. \
791                 Lock/unlock and key constraints are reported but never enforced.",
792            )
793            .max_inbound_bytes(crate::server::ssh_agent::MAX_AGENT_MESSAGE_LEN)
794            .build()
795    }
796
797    fn description(&self) -> &'static str {
798        "SSH Agent protocol server for managing SSH keys and signing operations"
799    }
800
801    fn example_prompt(&self) -> &'static str {
802        "Start SSH Agent on ./netget-ssh-agent.sock; provide 2 Ed25519 keys (admin-key, deploy-key); sign any requests automatically"
803    }
804
805    fn group_name(&self) -> &'static str {
806        "Security"
807    }
808
809    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
810        use crate::llm::actions::StartupExamples;
811        use serde_json::json;
812
813        // Deterministic: report an empty key list for every identities request,
814        // no LLM call.
815        let script = r#"import json, sys
816data = json.load(sys.stdin)
817event = data["event"]
818if data["event_type_id"] == "ssh_agent_request_identities":
819    actions = [{"type": "send_identities_list", "identities": []}]
820else:
821    actions = []
822print(json.dumps({"actions": actions}))"#;
823
824        StartupExamples::new(
825            // LLM mode: LLM handles all SSH Agent responses intelligently
826            json!({
827                "type": "open_server",
828                "port": 0,
829                "base_stack": "ssh-agent",
830                "instruction": "SSH Agent managing keys and signing operations"
831            }),
832            // Script mode: Code-based deterministic responses
833            json!({
834                "type": "open_server",
835                "port": 0,
836                "base_stack": "ssh-agent",
837                "event_handlers": [{
838                    "event_pattern": "ssh_agent_request_identities",
839                    "handler": {
840                        "type": "script",
841                        "language": "python",
842                        "code": script
843                    }
844                }]
845            }),
846            // Static mode: Fixed responses
847            json!({
848                "type": "open_server",
849                "port": 0,
850                "base_stack": "ssh-agent",
851                "event_handlers": [{
852                    "event_pattern": "ssh_agent_request_identities",
853                    "handler": {
854                        "type": "static",
855                        "actions": [{
856                            "type": "send_identities_list",
857                            "identities": []
858                        }]
859                    }
860                }]
861            }),
862        )
863    }
864}
865
866// Implement Server trait (server-specific functionality)
867impl Server for SshAgentProtocol {
868    fn spawn(
869        &self,
870        ctx: crate::protocol::SpawnContext,
871    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
872    {
873        Box::pin(async move {
874            // For Unix sockets, we need a path not a SocketAddr
875            // Extract socket_path from startup_params or use default
876            let socket_path = ctx
877                .startup_params
878                .as_ref()
879                .map(|p| p.get_optional_string("socket_path"))
880                .transpose()?
881                .flatten()
882                .unwrap_or_else(|| "./netget-ssh-agent.sock".to_string());
883
884            let socket_path_buf = std::path::PathBuf::from(socket_path);
885
886            // Both read bounds are the operator's to tune; the defaults are argued beside
887            // FIRST_BYTE_READ_TIMEOUT and IDLE_BETWEEN_REQUESTS_TIMEOUT in mod.rs.
888            let secs = |name: &str| -> Result<Option<u64>> {
889                Ok(ctx
890                    .startup_params
891                    .as_ref()
892                    .map(|p| p.get_optional_u64(name))
893                    .transpose()?
894                    .flatten())
895            };
896            let first_byte_timeout_secs = secs("first_byte_timeout_secs")?;
897            let idle_timeout_secs = secs("idle_timeout_secs")?;
898
899            use crate::server::ssh_agent::SshAgentServer;
900            let _actual_path = SshAgentServer::spawn_with_llm_actions(
901                socket_path_buf,
902                ctx.llm_client,
903                ctx.state,
904                ctx.status_tx,
905                ctx.server_id,
906                first_byte_timeout_secs,
907                idle_timeout_secs,
908            )
909            .await?;
910
911            // Return a dummy SocketAddr since Unix sockets don't have IP addresses
912            Ok("127.0.0.1:0".parse().unwrap())
913        })
914    }
915
916    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
917        let action_type = action["type"]
918            .as_str()
919            .ok_or_else(|| anyhow::anyhow!("Missing 'type' field in action"))?;
920
921        match action_type {
922            "send_identities_list" => {
923                let identities = action["identities"]
924                    .as_array()
925                    .ok_or_else(|| anyhow::anyhow!("Missing or invalid 'identities' field"))?;
926
927                // Normalise here rather than in the server loop, so the one form that
928                // reaches the wire is the hex blob and `executable_examples_test` exercises
929                // the conversion the advertised example depends on.
930                let normalised = identities
931                    .iter()
932                    .map(normalise_identity)
933                    .collect::<Result<Vec<_>>>()?;
934
935                Ok(ActionResult::Custom {
936                    name: "send_identities_list".to_string(),
937                    data: json!({ "identities": normalised }),
938                })
939            }
940            "send_sign_response" => Ok(ActionResult::Custom {
941                name: "send_sign_response".to_string(),
942                data: json!({ "signature_hex": hex::encode(sign_response_blob(&action)?) }),
943            }),
944            "send_success" => Ok(ActionResult::Custom {
945                name: "send_success".to_string(),
946                data: json!({}),
947            }),
948            "send_failure" => Ok(ActionResult::Custom {
949                name: "send_failure".to_string(),
950                data: json!({}),
951            }),
952            "wait_for_more" => Ok(ActionResult::WaitForMore),
953            // No `modify_instruction` arm. It is advertised by nothing (see
954            // get_async_actions), and the Custom result it used to build had no branch in
955            // execute_action_result, so it was accepted and did nothing - the shape a
956            // round-trip check passes and a user never gets any use from. Rejecting the name
957            // now routes it through the fail-closed rule as decision=fail_closed_action_error.
958            "close_connection" => {
959                // CloseConnection is a unit variant
960                Ok(ActionResult::CloseConnection)
961            }
962            _ => anyhow::bail!("Unknown action type: {}", action_type),
963        }
964    }
965}