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)]
11
use base64::Engine;
12
#[cfg(test)]
13
use base64::engine::general_purpose::STANDARD as BASE64;
14
use scripting::runtime::{
15
    alloc_entity_via_export, alloc_pair_chain, alloc_string_ref, read_string_arg,
16
};
17
#[cfg(test)]
18
use server::command::ssh_key::SshKeyRecord;
19
use server::command::ssh_key::{ListSshKeys, LookupUserBySshKey, RemoveSshKey, UserHasSshKey};
20
use server::command::{CmdError, CmdResult};
21
use uuid::Uuid;
22
use wasmtime::{AnyRef, ArrayRef, Caller, Linker, Rooted, StructRef, Val};
23

            
24
use crate::session::SessionData;
25

            
26
pub 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

            
33
2559
pub fn register(linker: &mut Linker<SessionData>) -> wasmtime::Result<()> {
34
2559
    linker.func_wrap_async(
35
2559
        "nomi",
36
2559
        "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
18
        > {
42
18
            Box::new(async move {
43
18
                let user_id = caller.data().ctx().user_id;
44
18
                let result = ListSshKeys::new().user_id(user_id).run().await;
45
18
                let entries = list_ssh_key_entries("list-ssh-keys", result)?;
46
18
                alloc_ssh_key_chain(&mut caller, entries).await
47
18
            })
48
18
        },
49
    )?;
50
2559
    linker.func_wrap_async(
51
2559
        "nomi",
52
2559
        "ssh_key_user_has_ssh_key",
53
        |caller: Caller<'_, SessionData>,
54
         ()|
55
18
         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
56
18
            Box::new(async move {
57
18
                let user_id = caller.data().ctx().user_id;
58
18
                let result = UserHasSshKey::new().user_id(user_id).run().await;
59
18
                bool_from_command_result("user-has-ssh-key", result)
60
18
            })
61
18
        },
62
    )?;
63
2559
    linker.func_wrap_async(
64
2559
        "nomi",
65
2559
        "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
18
        > {
71
18
            Box::new(async move {
72
18
                let fp = read_string_arg(&mut caller, fp_arg)?;
73
18
                match run_lookup_user_by_ssh_key(fp).await? {
74
                    Some(id) => Ok(Some(alloc_string_ref(&mut caller, id.as_bytes())?)),
75
18
                    None => Ok(None),
76
                }
77
18
            })
78
18
        },
79
    )?;
80
2559
    linker.func_wrap_async(
81
2559
        "nomi",
82
2559
        "ssh_key_remove_ssh_key",
83
        |mut caller: Caller<'_, SessionData>,
84
         (fp_arg,): (Option<Rooted<ArrayRef>>,)|
85
18
         -> Box<dyn std::future::Future<Output = wasmtime::Result<i32>> + Send> {
86
18
            Box::new(async move {
87
18
                let user_id = caller.data().ctx().user_id;
88
18
                let fp = read_string_arg(&mut caller, fp_arg)?;
89
18
                run_remove_ssh_key(user_id, fp).await
90
18
            })
91
18
        },
92
    )?;
93
2559
    Ok(())
94
2559
}
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.
101
20
async fn run_remove_ssh_key(user_id: Uuid, fp_arg: Option<String>) -> wasmtime::Result<i32> {
102
20
    let fingerprint = fp_arg
103
20
        .filter(|s| !s.is_empty())
104
20
        .ok_or_else(|| wasmtime::Error::msg("remove-ssh-key: missing or empty :fingerprint arg"))?;
105
18
    let result = RemoveSshKey::new()
106
18
        .user_id(user_id)
107
18
        .fingerprint(fingerprint)
108
18
        .run()
109
18
        .await;
110
18
    bool_from_command_result("remove-ssh-key", result)
111
20
}
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.
118
20
async fn run_lookup_user_by_ssh_key(fp_arg: Option<String>) -> wasmtime::Result<Option<String>> {
119
20
    let fingerprint = fp_arg.filter(|s| !s.is_empty()).ok_or_else(|| {
120
2
        wasmtime::Error::msg("lookup-user-by-ssh-key: missing or empty :fingerprint arg")
121
2
    })?;
122
18
    match LookupUserBySshKey::new()
123
18
        .fingerprint(fingerprint)
124
18
        .run()
125
18
        .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
18
        Ok(None) => Ok(None),
132
        Err(err) => Err(wasmtime::Error::msg(format!(
133
            "lookup-user-by-ssh-key: {err}"
134
        ))),
135
    }
136
20
}
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.
141
40
fn bool_from_command_result(
142
40
    name: &str,
143
40
    result: Result<Option<CmdResult>, CmdError>,
144
40
) -> wasmtime::Result<i32> {
145
38
    match result {
146
38
        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
1
        Ok(None) => Ok(0),
151
1
        Err(err) => Err(wasmtime::Error::msg(format!("{name}: {err}"))),
152
    }
153
40
}
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.
160
18
fn list_ssh_key_entries(
161
18
    name: &str,
162
18
    result: Result<Option<CmdResult>, CmdError>,
163
18
) -> wasmtime::Result<Vec<(String, String, String)>> {
164
18
    match result {
165
18
        Ok(Some(CmdResult::SshKeys(keys))) => Ok(keys
166
18
            .into_iter()
167
18
            .map(|k| (k.id.to_string(), k.fingerprint, k.annotation))
168
18
            .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
18
}
176

            
177
async 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

            
194
18
async fn alloc_ssh_key_chain(
195
18
    caller: &mut Caller<'_, SessionData>,
196
18
    entries: Vec<(String, String, String)>,
197
18
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
198
18
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
199
18
    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
18
    alloc_pair_chain(caller, anyrefs).await
204
18
}
205

            
206
#[cfg(test)]
207
4
fn 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
4
    let mut out = String::from("(:ssh-keys (");
213
4
    for (idx, key) in keys.iter().enumerate() {
214
3
        if idx > 0 {
215
            out.push(' ');
216
3
        }
217
3
        out.push_str(&format!(
218
            "(:id \"{}\" :key-type {} :fingerprint {} :annotation {} :created-at \"{}\" :last-used-at {} :blob #\"{}\")",
219
            key.id,
220
3
            quote_string(&key.key_type),
221
3
            quote_string(&key.fingerprint),
222
3
            quote_string(&key.annotation),
223
3
            key.created_at.to_rfc3339(),
224
3
            match key.last_used_at {
225
1
                Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
226
2
                None => "nil".to_string(),
227
            },
228
3
            BASE64.encode(&key.key_blob),
229
        ));
230
    }
231
4
    out.push_str("))");
232
4
    out
233
4
}
234

            
235
#[cfg(test)]
236
9
fn quote_string(s: &str) -> String {
237
9
    let mut q = String::with_capacity(s.len() + 2);
238
9
    q.push('"');
239
80
    for ch in s.chars() {
240
80
        match ch {
241
1
            '"' => q.push_str("\\\""),
242
1
            '\\' => q.push_str("\\\\"),
243
78
            other => q.push(other),
244
        }
245
    }
246
9
    q.push('"');
247
9
    q
248
9
}
249

            
250
#[cfg(test)]
251
mod tests {
252
    use super::*;
253
    use chrono::TimeZone;
254
    use uuid::Uuid;
255

            
256
3
    fn record(annotation: &str, last_used: bool) -> SshKeyRecord {
257
3
        let created = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
258
        SshKeyRecord {
259
3
            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
260
3
            user_id: Uuid::nil(),
261
3
            key_type: "ssh-ed25519".into(),
262
3
            key_blob: vec![0xab, 0xcd, 0xef],
263
3
            fingerprint: "SHA256:abc".into(),
264
3
            annotation: annotation.into(),
265
3
            created_at: created,
266
3
            last_used_at: last_used
267
3
                .then(|| chrono::Utc.with_ymd_and_hms(2026, 5, 5, 9, 30, 0).unwrap()),
268
        }
269
3
    }
270

            
271
    #[test]
272
1
    fn format_empty_list() {
273
1
        assert_eq!(format_ssh_keys(&[]), "(:ssh-keys ())");
274
1
    }
275

            
276
    #[test]
277
1
    fn format_single_key_with_last_used() {
278
1
        let out = format_ssh_keys(&[record("laptop", true)]);
279
1
        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
280
1
        assert!(out.contains(":key-type \"ssh-ed25519\""));
281
1
        assert!(out.contains(":fingerprint \"SHA256:abc\""));
282
1
        assert!(out.contains(":annotation \"laptop\""));
283
1
        assert!(out.contains(":created-at \"2026-05-01T12:00:00+00:00\""));
284
1
        assert!(out.contains(":last-used-at \"2026-05-05T09:30:00+00:00\""));
285
1
        assert!(out.contains(":blob #\"q83v\""));
286
1
    }
287

            
288
    #[test]
289
1
    fn format_unused_key_emits_nil_for_last_used() {
290
1
        let out = format_ssh_keys(&[record("", false)]);
291
1
        assert!(out.contains(":last-used-at nil"));
292
1
        assert!(out.contains(":annotation \"\""));
293
1
    }
294

            
295
    #[test]
296
1
    fn bool_from_command_result_maps_variants() {
297
1
        assert_eq!(
298
1
            bool_from_command_result("x", Ok(Some(CmdResult::Bool(true)))).unwrap(),
299
            1
300
        );
301
1
        assert_eq!(
302
1
            bool_from_command_result("x", Ok(Some(CmdResult::Bool(false)))).unwrap(),
303
            0
304
        );
305
1
        assert_eq!(bool_from_command_result("x", Ok(None)).unwrap(), 0);
306
1
        assert!(bool_from_command_result("x", Err(CmdError::Args("oops".into()))).is_err());
307
1
    }
308

            
309
    #[tokio::test]
310
1
    async fn run_lookup_user_by_ssh_key_no_arg_emits_error() {
311
1
        let err = run_lookup_user_by_ssh_key(None).await.unwrap_err();
312
1
        assert!(err.to_string().contains("missing or empty"));
313
1
    }
314

            
315
    #[tokio::test]
316
1
    async fn run_lookup_user_by_ssh_key_empty_arg_emits_error() {
317
1
        let err = run_lookup_user_by_ssh_key(Some(String::new()))
318
1
            .await
319
1
            .unwrap_err();
320
1
        assert!(err.to_string().contains("missing or empty"));
321
1
    }
322

            
323
    #[tokio::test]
324
1
    async fn run_remove_ssh_key_no_arg_emits_error() {
325
1
        let err = run_remove_ssh_key(Uuid::nil(), None).await.unwrap_err();
326
1
        assert!(err.to_string().contains("missing or empty"));
327
1
    }
328

            
329
    #[tokio::test]
330
1
    async fn run_remove_ssh_key_empty_arg_emits_error() {
331
1
        let err = run_remove_ssh_key(Uuid::nil(), Some(String::new()))
332
1
            .await
333
1
            .unwrap_err();
334
1
        assert!(err.to_string().contains("missing or empty"));
335
1
    }
336

            
337
    #[test]
338
1
    fn format_quotes_embedded_specials_in_annotation() {
339
1
        let key = SshKeyRecord {
340
1
            annotation: r#"weird\"name"#.into(),
341
1
            ..record("", false)
342
1
        };
343
1
        let out = format_ssh_keys(&[key]);
344
1
        assert!(out.contains(r#":annotation "weird\\\"name""#), "got: {out}");
345
1
    }
346
}