Skip to main content

rpc/natives/
ssh_key.rs

1//! SSH-key-domain natives. Wraps `server::command::{ListSshKeys, RemoveSshKey,
2//! UserHasSshKey, LookupUserBySshKey}`. `AddSshKey` is deliberately NOT
3//! exposed: pubkey upload stays on the dedicated ssh-copy-id `exec` flow so the
4//! eval channel can never be used to register impersonation keys.
5//!
6//! v1 binds `list-ssh-keys` for the authenticated session user. The other
7//! three (remove / user-has / lookup) take string arguments and ride the
8//! follow-up slice that lands the host-side StringRef-arg plumbing.
9
10#[cfg(test)]
11use base64::Engine;
12#[cfg(test)]
13use base64::engine::general_purpose::STANDARD as BASE64;
14use scripting::runtime::{
15    alloc_entity_via_export, alloc_pair_chain, alloc_string_ref, read_string_arg,
16};
17#[cfg(test)]
18use server::command::ssh_key::SshKeyRecord;
19use server::command::ssh_key::{ListSshKeys, LookupUserBySshKey, RemoveSshKey, UserHasSshKey};
20use server::command::{CmdError, CmdResult};
21use uuid::Uuid;
22use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
23
24use crate::session::SessionData;
25
26pub const REGISTERED_COMMANDS: &[&str] = &[
27    "list-ssh-keys",
28    "remove-ssh-key",
29    "user-has-ssh-key",
30    "lookup-user-by-ssh-key",
31];
32
33pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
34    linker.func_wrap_async(
35        "nomi",
36        "ssh_key_list_ssh_keys",
37        |mut caller: Caller<'_, SessionData>,
38         ()|
39         -> Box<
40            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<StructRef>>>> + Send,
41        > {
42            Box::new(async move {
43                let user_id = caller.data().ctx().user_id;
44                let result = ListSshKeys::new().user_id(user_id).run().await;
45                let entries = list_ssh_key_entries("list-ssh-keys", result)?;
46                alloc_ssh_key_chain(&mut caller, entries).await
47            })
48        },
49    )?;
50    linker.func_wrap_async(
51        "nomi",
52        "ssh_key_user_has_ssh_key",
53        |caller: Caller<'_, SessionData>,
54         ()|
55         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
56            Box::new(async move {
57                let user_id = caller.data().ctx().user_id;
58                let result = UserHasSshKey::new().user_id(user_id).run().await;
59                bool_from_command_result("user-has-ssh-key", result)
60            })
61        },
62    )?;
63    linker.func_wrap_async(
64        "nomi",
65        "ssh_key_lookup_user_by_ssh_key",
66        |mut caller: Caller<'_, SessionData>,
67         (fp_arg,): (Option<Rooted<ArrayRef>>,)|
68         -> Box<
69            dyn std::future::Future<Output = wasmtime::Result<Option<Rooted<ArrayRef>>>> + Send,
70        > {
71            Box::new(async move {
72                let fp = read_string_arg(&mut caller, fp_arg)?;
73                match run_lookup_user_by_ssh_key(fp).await? {
74                    Some(id) => Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?)),
75                    None => Ok(None),
76                }
77            })
78        },
79    )?;
80    linker.func_wrap_async(
81        "nomi",
82        "ssh_key_remove_ssh_key",
83        |mut caller: Caller<'_, SessionData>,
84         (fp_arg,): (Option<Rooted<ArrayRef>>,)|
85         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
86            Box::new(async move {
87                let user_id = caller.data().ctx().user_id;
88                let fp = read_string_arg(&mut caller, fp_arg)?;
89                run_remove_ssh_key(user_id, fp).await
90            })
91        },
92    )?;
93    Ok(())
94}
95
96/// Deletes the (user_id, fingerprint) key row. Idempotent on the wire:
97/// server's RemoveSshKey returns `Bool(true)` even when no matching row
98/// existed. Side effect — if this drains the user's last key,
99/// `users.ssh_enabled` flips to false, which the next sshd-auth attempt
100/// observes. No user-visible toggle here; just the bool.
101async fn run_remove_ssh_key(user_id: Uuid, fp_arg: Option<String>) -> wasmtime::Result<i32> {
102    let fingerprint = fp_arg
103        .filter(|s| !s.is_empty())
104        .ok_or_else(|| wasmtime::Error::msg("remove-ssh-key: missing or empty :fingerprint arg"))?;
105    let result = RemoveSshKey::new()
106        .user_id(user_id)
107        .fingerprint(fingerprint)
108        .run()
109        .await;
110    bool_from_command_result("remove-ssh-key", result)
111}
112
113/// Resolves a public-key fingerprint to a user UUID. Server short-circuits
114/// to `Ok(None)` when the key is unknown OR the matching user has
115/// `ssh_enabled=false` — both surface here as `None` (which the caller maps
116/// to a null `ref null $i8_array`) so clients can't probe the row table to
117/// distinguish absent-key from disabled-user.
118async fn run_lookup_user_by_ssh_key(fp_arg: Option<String>) -> wasmtime::Result<Option<String>> {
119    let fingerprint = fp_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
120        wasmtime::Error::msg("lookup-user-by-ssh-key: missing or empty :fingerprint arg")
121    })?;
122    match LookupUserBySshKey::new()
123        .fingerprint(fingerprint)
124        .run()
125        .await
126    {
127        Ok(Some(CmdResult::Uuid(id))) => Ok(Some(id.to_string())),
128        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
129            "lookup-user-by-ssh-key: expected Uuid, got {other:?}"
130        ))),
131        Ok(None) => Ok(None),
132        Err(err) => Err(wasmtime::Error::msg(format!(
133            "lookup-user-by-ssh-key: {err}"
134        ))),
135    }
136}
137
138/// Maps the server's typestate `Bool` result (or absent row) into the i32
139/// wire form the wasm signature expects: 1 = true, 0 = false. Any other
140/// `CmdResult` variant is a contract violation and surfaces as a trap.
141fn bool_from_command_result(
142    name: &str,
143    result: Result<Option<CmdResult>, CmdError>,
144) -> wasmtime::Result<i32> {
145    match result {
146        Ok(Some(CmdResult::Bool(b))) => Ok(i32::from(b)),
147        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
148            "{name}: expected Bool, got {other:?}"
149        ))),
150        Ok(None) => Ok(0),
151        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
152    }
153}
154
155/// Pulls the (id, fingerprint, annotation) triple per SSH key out of the
156/// `CmdResult::SshKeys` variant. Surrounding fields (key-type, blob,
157/// created-at, last-used-at) are dropped for v1 — `$ssh_key` carries only
158/// the human-facing strings the emacs client renders. Reinstate richer
159/// shapes by extending the struct + macro declaration.
160fn list_ssh_key_entries(
161    name: &str,
162    result: Result<Option<CmdResult>, CmdError>,
163) -> wasmtime::Result<Vec<(String, String, String)>> {
164    match result {
165        Ok(Some(CmdResult::SshKeys(keys))) => Ok(keys
166            .into_iter()
167            .map(|k| (k.id.to_string(), k.fingerprint, k.annotation))
168            .collect()),
169        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
170            "{name}: expected SshKeys, got {other:?}"
171        ))),
172        Ok(None) => Ok(Vec::new()),
173        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
174    }
175}
176
177async fn alloc_ssh_key_entity(
178    caller: &mut Caller<'_, SessionData>,
179    id: &str,
180    fingerprint: &str,
181    annotation: &str,
182) -> wasmtime::Result<Rooted<StructRef>> {
183    let id_ref = alloc_string_ref(caller, id.as_bytes())?;
184    let fp_ref = alloc_string_ref(caller, fingerprint.as_bytes())?;
185    let name_ref = alloc_string_ref(caller, annotation.as_bytes())?;
186    let args = [
187        Val::AnyRef(Some(id_ref.to_anyref())),
188        Val::AnyRef(Some(fp_ref.to_anyref())),
189        Val::AnyRef(Some(name_ref.to_anyref())),
190    ];
191    alloc_entity_via_export(caller, "alloc_ssh_key", &args).await
192}
193
194async fn alloc_ssh_key_chain(
195    caller: &mut Caller<'_, SessionData>,
196    entries: Vec<(String, String, String)>,
197) -> wasmtime::Result<Option<Rooted<StructRef>>> {
198    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
199    for (id, fingerprint, annotation) in entries {
200        let entity_ref = alloc_ssh_key_entity(caller, &id, &fingerprint, &annotation).await?;
201        anyrefs.push(entity_ref.to_anyref());
202    }
203    alloc_pair_chain(caller, anyrefs).await
204}
205
206#[cfg(test)]
207fn format_ssh_keys(keys: &[SshKeyRecord]) -> String {
208    // Kept under #[cfg(test)] solely so the legacy test assertions
209    // (round-trip / quote-escape) continue to exercise the renderer until
210    // A6 collapses the streaming-string envelope; production now ships
211    // typed `pair<ssh-key>` returns built via `alloc_ssh_key_chain`.
212    let mut out = String::from("(:ssh-keys (");
213    for (idx, key) in keys.iter().enumerate() {
214        if idx > 0 {
215            out.push(' ');
216        }
217        out.push_str(&format!(
218            "(:id \"{}\" :key-type {} :fingerprint {} :annotation {} :created-at \"{}\" :last-used-at {} :blob #\"{}\")",
219            key.id,
220            quote_string(&key.key_type),
221            quote_string(&key.fingerprint),
222            quote_string(&key.annotation),
223            key.created_at.to_rfc3339(),
224            match key.last_used_at {
225                Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
226                None => "nil".to_string(),
227            },
228            BASE64.encode(&key.key_blob),
229        ));
230    }
231    out.push_str("))");
232    out
233}
234
235#[cfg(test)]
236fn quote_string(s: &str) -> String {
237    let mut q = String::with_capacity(s.len() + 2);
238    q.push('"');
239    for ch in s.chars() {
240        match ch {
241            '"' => q.push_str("\\\""),
242            '\\' => q.push_str("\\\\"),
243            other => q.push(other),
244        }
245    }
246    q.push('"');
247    q
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use chrono::TimeZone;
254    use uuid::Uuid;
255
256    fn record(annotation: &str, last_used: bool) -> SshKeyRecord {
257        let created = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
258        SshKeyRecord {
259            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
260            user_id: Uuid::nil(),
261            key_type: "ssh-ed25519".into(),
262            key_blob: vec![0xab, 0xcd, 0xef],
263            fingerprint: "SHA256:abc".into(),
264            annotation: annotation.into(),
265            created_at: created,
266            last_used_at: last_used
267                .then(|| chrono::Utc.with_ymd_and_hms(2026, 5, 5, 9, 30, 0).unwrap()),
268        }
269    }
270
271    #[test]
272    fn format_empty_list() {
273        assert_eq!(format_ssh_keys(&[]), "(:ssh-keys ())");
274    }
275
276    #[test]
277    fn format_single_key_with_last_used() {
278        let out = format_ssh_keys(&[record("laptop", true)]);
279        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
280        assert!(out.contains(":key-type \"ssh-ed25519\""));
281        assert!(out.contains(":fingerprint \"SHA256:abc\""));
282        assert!(out.contains(":annotation \"laptop\""));
283        assert!(out.contains(":created-at \"2026-05-01T12:00:00+00:00\""));
284        assert!(out.contains(":last-used-at \"2026-05-05T09:30:00+00:00\""));
285        assert!(out.contains(":blob #\"q83v\""));
286    }
287
288    #[test]
289    fn format_unused_key_emits_nil_for_last_used() {
290        let out = format_ssh_keys(&[record("", false)]);
291        assert!(out.contains(":last-used-at nil"));
292        assert!(out.contains(":annotation \"\""));
293    }
294
295    #[test]
296    fn bool_from_command_result_maps_variants() {
297        assert_eq!(
298            bool_from_command_result("x", Ok(Some(CmdResult::Bool(true)))).unwrap(),
299            1
300        );
301        assert_eq!(
302            bool_from_command_result("x", Ok(Some(CmdResult::Bool(false)))).unwrap(),
303            0
304        );
305        assert_eq!(bool_from_command_result("x", Ok(None)).unwrap(), 0);
306        assert!(bool_from_command_result("x", Err(CmdError::Args("oops".into()))).is_err());
307    }
308
309    #[tokio::test]
310    async fn run_lookup_user_by_ssh_key_no_arg_emits_error() {
311        let err = run_lookup_user_by_ssh_key(None).await.unwrap_err();
312        assert!(err.to_string().contains("missing or empty"));
313    }
314
315    #[tokio::test]
316    async fn run_lookup_user_by_ssh_key_empty_arg_emits_error() {
317        let err = run_lookup_user_by_ssh_key(Some(String::new()))
318            .await
319            .unwrap_err();
320        assert!(err.to_string().contains("missing or empty"));
321    }
322
323    #[tokio::test]
324    async fn run_remove_ssh_key_no_arg_emits_error() {
325        let err = run_remove_ssh_key(Uuid::nil(), None).await.unwrap_err();
326        assert!(err.to_string().contains("missing or empty"));
327    }
328
329    #[tokio::test]
330    async fn run_remove_ssh_key_empty_arg_emits_error() {
331        let err = run_remove_ssh_key(Uuid::nil(), Some(String::new()))
332            .await
333            .unwrap_err();
334        assert!(err.to_string().contains("missing or empty"));
335    }
336
337    #[test]
338    fn format_quotes_embedded_specials_in_annotation() {
339        let key = SshKeyRecord {
340            annotation: r#"weird\"name"#.into(),
341            ..record("", false)
342        };
343        let out = format_ssh_keys(&[key]);
344        assert!(out.contains(r#":annotation "weird\\\"name""#), "got: {out}");
345    }
346}