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/// The six display strings that make up one `$ssh_key` wire entity, in struct
156/// slot order (id, fingerprint, name, key-type, created-at, last-used-at).
157struct SshKeyWire {
158    id: String,
159    fingerprint: String,
160    annotation: String,
161    key_type: String,
162    created_at: String,
163    last_used_at: String,
164}
165
166fn list_ssh_key_entries(
167    name: &str,
168    result: Result<Option<CmdResult>, CmdError>,
169) -> wasmtime::Result<Vec<SshKeyWire>> {
170    match result {
171        Ok(Some(CmdResult::SshKeys(keys))) => Ok(keys
172            .into_iter()
173            .map(|k| SshKeyWire {
174                id: k.id.to_string(),
175                fingerprint: k.fingerprint,
176                annotation: k.annotation,
177                key_type: k.key_type,
178                created_at: k.created_at.to_rfc3339(),
179                last_used_at: k.last_used_at.map_or_else(String::new, |t| t.to_rfc3339()),
180            })
181            .collect()),
182        Ok(Some(other)) => Err(wasmtime::Error::msg(format!(
183            "{name}: expected SshKeys, got {other:?}"
184        ))),
185        Ok(None) => Ok(Vec::new()),
186        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
187    }
188}
189
190async fn alloc_ssh_key_entity(
191    caller: &mut Caller<'_, SessionData>,
192    key: &SshKeyWire,
193) -> wasmtime::Result<Rooted<StructRef>> {
194    let id_ref = alloc_string_ref(caller, key.id.as_bytes())?;
195    let fp_ref = alloc_string_ref(caller, key.fingerprint.as_bytes())?;
196    let name_ref = alloc_string_ref(caller, key.annotation.as_bytes())?;
197    let type_ref = alloc_string_ref(caller, key.key_type.as_bytes())?;
198    let created_ref = alloc_string_ref(caller, key.created_at.as_bytes())?;
199    let last_used_ref = alloc_string_ref(caller, key.last_used_at.as_bytes())?;
200    let args = [
201        Val::AnyRef(Some(id_ref.to_anyref())),
202        Val::AnyRef(Some(fp_ref.to_anyref())),
203        Val::AnyRef(Some(name_ref.to_anyref())),
204        Val::AnyRef(Some(type_ref.to_anyref())),
205        Val::AnyRef(Some(created_ref.to_anyref())),
206        Val::AnyRef(Some(last_used_ref.to_anyref())),
207    ];
208    alloc_entity_via_export(caller, "alloc_ssh_key", &args).await
209}
210
211async fn alloc_ssh_key_chain(
212    caller: &mut Caller<'_, SessionData>,
213    entries: Vec<SshKeyWire>,
214) -> wasmtime::Result<Option<Rooted<StructRef>>> {
215    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
216    for entry in &entries {
217        let entity_ref = alloc_ssh_key_entity(caller, entry).await?;
218        anyrefs.push(entity_ref.to_anyref());
219    }
220    alloc_pair_chain(caller, anyrefs).await
221}
222
223#[cfg(test)]
224fn format_ssh_keys(keys: &[SshKeyRecord]) -> String {
225    // Kept under #[cfg(test)] solely so the legacy test assertions
226    // (round-trip / quote-escape) continue to exercise the renderer until
227    // A6 collapses the streaming-string envelope; production now ships
228    // typed `pair<ssh-key>` returns built via `alloc_ssh_key_chain`.
229    let mut out = String::from("(:ssh-keys (");
230    for (idx, key) in keys.iter().enumerate() {
231        if idx > 0 {
232            out.push(' ');
233        }
234        out.push_str(&format!(
235            "(:id \"{}\" :key-type {} :fingerprint {} :annotation {} :created-at \"{}\" :last-used-at {} :blob #\"{}\")",
236            key.id,
237            quote_string(&key.key_type),
238            quote_string(&key.fingerprint),
239            quote_string(&key.annotation),
240            key.created_at.to_rfc3339(),
241            match key.last_used_at {
242                Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
243                None => "nil".to_string(),
244            },
245            BASE64.encode(&key.key_blob),
246        ));
247    }
248    out.push_str("))");
249    out
250}
251
252#[cfg(test)]
253fn quote_string(s: &str) -> String {
254    let mut q = String::with_capacity(s.len() + 2);
255    q.push('"');
256    for ch in s.chars() {
257        match ch {
258            '"' => q.push_str("\\\""),
259            '\\' => q.push_str("\\\\"),
260            other => q.push(other),
261        }
262    }
263    q.push('"');
264    q
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use chrono::TimeZone;
271    use uuid::Uuid;
272
273    fn record(annotation: &str, last_used: bool) -> SshKeyRecord {
274        let created = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
275        SshKeyRecord {
276            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
277            user_id: Uuid::nil(),
278            key_type: "ssh-ed25519".into(),
279            key_blob: vec![0xab, 0xcd, 0xef],
280            fingerprint: "SHA256:abc".into(),
281            annotation: annotation.into(),
282            created_at: created,
283            last_used_at: last_used
284                .then(|| chrono::Utc.with_ymd_and_hms(2026, 5, 5, 9, 30, 0).unwrap()),
285        }
286    }
287
288    #[test]
289    fn format_empty_list() {
290        assert_eq!(format_ssh_keys(&[]), "(:ssh-keys ())");
291    }
292
293    #[test]
294    fn format_single_key_with_last_used() {
295        let out = format_ssh_keys(&[record("laptop", true)]);
296        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
297        assert!(out.contains(":key-type \"ssh-ed25519\""));
298        assert!(out.contains(":fingerprint \"SHA256:abc\""));
299        assert!(out.contains(":annotation \"laptop\""));
300        assert!(out.contains(":created-at \"2026-05-01T12:00:00+00:00\""));
301        assert!(out.contains(":last-used-at \"2026-05-05T09:30:00+00:00\""));
302        assert!(out.contains(":blob #\"q83v\""));
303    }
304
305    #[test]
306    fn format_unused_key_emits_nil_for_last_used() {
307        let out = format_ssh_keys(&[record("", false)]);
308        assert!(out.contains(":last-used-at nil"));
309        assert!(out.contains(":annotation \"\""));
310    }
311
312    #[test]
313    fn bool_from_command_result_maps_variants() {
314        assert_eq!(
315            bool_from_command_result("x", Ok(Some(CmdResult::Bool(true)))).unwrap(),
316            1
317        );
318        assert_eq!(
319            bool_from_command_result("x", Ok(Some(CmdResult::Bool(false)))).unwrap(),
320            0
321        );
322        assert_eq!(bool_from_command_result("x", Ok(None)).unwrap(), 0);
323        assert!(bool_from_command_result("x", Err(CmdError::Args("oops".into()))).is_err());
324    }
325
326    #[tokio::test]
327    async fn run_lookup_user_by_ssh_key_no_arg_emits_error() {
328        let err = run_lookup_user_by_ssh_key(None).await.unwrap_err();
329        assert!(err.to_string().contains("missing or empty"));
330    }
331
332    #[tokio::test]
333    async fn run_lookup_user_by_ssh_key_empty_arg_emits_error() {
334        let err = run_lookup_user_by_ssh_key(Some(String::new()))
335            .await
336            .unwrap_err();
337        assert!(err.to_string().contains("missing or empty"));
338    }
339
340    #[tokio::test]
341    async fn run_remove_ssh_key_no_arg_emits_error() {
342        let err = run_remove_ssh_key(Uuid::nil(), None).await.unwrap_err();
343        assert!(err.to_string().contains("missing or empty"));
344    }
345
346    #[tokio::test]
347    async fn run_remove_ssh_key_empty_arg_emits_error() {
348        let err = run_remove_ssh_key(Uuid::nil(), Some(String::new()))
349            .await
350            .unwrap_err();
351        assert!(err.to_string().contains("missing or empty"));
352    }
353
354    #[test]
355    fn format_quotes_embedded_specials_in_annotation() {
356        let key = SshKeyRecord {
357            annotation: r#"weird\"name"#.into(),
358            ..record("", false)
359        };
360        let out = format_ssh_keys(&[key]);
361        assert!(out.contains(r#":annotation "weird\\\"name""#), "got: {out}");
362    }
363}