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

            
166
25
fn list_ssh_key_entries(
167
25
    name: &str,
168
25
    result: Result<Option<CmdResult>, CmdError>,
169
25
) -> wasmtime::Result<Vec<SshKeyWire>> {
170
25
    match result {
171
25
        Ok(Some(CmdResult::SshKeys(keys))) => Ok(keys
172
25
            .into_iter()
173
25
            .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
25
            .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
25
}
189

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

            
211
25
async fn alloc_ssh_key_chain(
212
25
    caller: &mut Caller<'_, SessionData>,
213
25
    entries: Vec<SshKeyWire>,
214
25
) -> wasmtime::Result<Option<Rooted<StructRef>>> {
215
25
    let mut anyrefs: Vec<Rooted<AnyRef>> = Vec::with_capacity(entries.len());
216
25
    for entry in &entries {
217
        let entity_ref = alloc_ssh_key_entity(caller, entry).await?;
218
        anyrefs.push(entity_ref.to_anyref());
219
    }
220
25
    alloc_pair_chain(caller, anyrefs).await
221
25
}
222

            
223
#[cfg(test)]
224
4
fn 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
4
    let mut out = String::from("(:ssh-keys (");
230
4
    for (idx, key) in keys.iter().enumerate() {
231
3
        if idx > 0 {
232
            out.push(' ');
233
3
        }
234
3
        out.push_str(&format!(
235
            "(:id \"{}\" :key-type {} :fingerprint {} :annotation {} :created-at \"{}\" :last-used-at {} :blob #\"{}\")",
236
            key.id,
237
3
            quote_string(&key.key_type),
238
3
            quote_string(&key.fingerprint),
239
3
            quote_string(&key.annotation),
240
3
            key.created_at.to_rfc3339(),
241
3
            match key.last_used_at {
242
1
                Some(ts) => format!("\"{}\"", ts.to_rfc3339()),
243
2
                None => "nil".to_string(),
244
            },
245
3
            BASE64.encode(&key.key_blob),
246
        ));
247
    }
248
4
    out.push_str("))");
249
4
    out
250
4
}
251

            
252
#[cfg(test)]
253
9
fn quote_string(s: &str) -> String {
254
9
    let mut q = String::with_capacity(s.len() + 2);
255
9
    q.push('"');
256
80
    for ch in s.chars() {
257
80
        match ch {
258
1
            '"' => q.push_str("\\\""),
259
1
            '\\' => q.push_str("\\\\"),
260
78
            other => q.push(other),
261
        }
262
    }
263
9
    q.push('"');
264
9
    q
265
9
}
266

            
267
#[cfg(test)]
268
mod tests {
269
    use super::*;
270
    use chrono::TimeZone;
271
    use uuid::Uuid;
272

            
273
3
    fn record(annotation: &str, last_used: bool) -> SshKeyRecord {
274
3
        let created = chrono::Utc.with_ymd_and_hms(2026, 5, 1, 12, 0, 0).unwrap();
275
        SshKeyRecord {
276
3
            id: Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
277
3
            user_id: Uuid::nil(),
278
3
            key_type: "ssh-ed25519".into(),
279
3
            key_blob: vec![0xab, 0xcd, 0xef],
280
3
            fingerprint: "SHA256:abc".into(),
281
3
            annotation: annotation.into(),
282
3
            created_at: created,
283
3
            last_used_at: last_used
284
3
                .then(|| chrono::Utc.with_ymd_and_hms(2026, 5, 5, 9, 30, 0).unwrap()),
285
        }
286
3
    }
287

            
288
    #[test]
289
1
    fn format_empty_list() {
290
1
        assert_eq!(format_ssh_keys(&[]), "(:ssh-keys ())");
291
1
    }
292

            
293
    #[test]
294
1
    fn format_single_key_with_last_used() {
295
1
        let out = format_ssh_keys(&[record("laptop", true)]);
296
1
        assert!(out.contains(":id \"550e8400-e29b-41d4-a716-446655440000\""));
297
1
        assert!(out.contains(":key-type \"ssh-ed25519\""));
298
1
        assert!(out.contains(":fingerprint \"SHA256:abc\""));
299
1
        assert!(out.contains(":annotation \"laptop\""));
300
1
        assert!(out.contains(":created-at \"2026-05-01T12:00:00+00:00\""));
301
1
        assert!(out.contains(":last-used-at \"2026-05-05T09:30:00+00:00\""));
302
1
        assert!(out.contains(":blob #\"q83v\""));
303
1
    }
304

            
305
    #[test]
306
1
    fn format_unused_key_emits_nil_for_last_used() {
307
1
        let out = format_ssh_keys(&[record("", false)]);
308
1
        assert!(out.contains(":last-used-at nil"));
309
1
        assert!(out.contains(":annotation \"\""));
310
1
    }
311

            
312
    #[test]
313
1
    fn bool_from_command_result_maps_variants() {
314
1
        assert_eq!(
315
1
            bool_from_command_result("x", Ok(Some(CmdResult::Bool(true)))).unwrap(),
316
            1
317
        );
318
1
        assert_eq!(
319
1
            bool_from_command_result("x", Ok(Some(CmdResult::Bool(false)))).unwrap(),
320
            0
321
        );
322
1
        assert_eq!(bool_from_command_result("x", Ok(None)).unwrap(), 0);
323
1
        assert!(bool_from_command_result("x", Err(CmdError::Args("oops".into()))).is_err());
324
1
    }
325

            
326
    #[tokio::test]
327
1
    async fn run_lookup_user_by_ssh_key_no_arg_emits_error() {
328
1
        let err = run_lookup_user_by_ssh_key(None).await.unwrap_err();
329
1
        assert!(err.to_string().contains("missing or empty"));
330
1
    }
331

            
332
    #[tokio::test]
333
1
    async fn run_lookup_user_by_ssh_key_empty_arg_emits_error() {
334
1
        let err = run_lookup_user_by_ssh_key(Some(String::new()))
335
1
            .await
336
1
            .unwrap_err();
337
1
        assert!(err.to_string().contains("missing or empty"));
338
1
    }
339

            
340
    #[tokio::test]
341
1
    async fn run_remove_ssh_key_no_arg_emits_error() {
342
1
        let err = run_remove_ssh_key(Uuid::nil(), None).await.unwrap_err();
343
1
        assert!(err.to_string().contains("missing or empty"));
344
1
    }
345

            
346
    #[tokio::test]
347
1
    async fn run_remove_ssh_key_empty_arg_emits_error() {
348
1
        let err = run_remove_ssh_key(Uuid::nil(), Some(String::new()))
349
1
            .await
350
1
            .unwrap_err();
351
1
        assert!(err.to_string().contains("missing or empty"));
352
1
    }
353

            
354
    #[test]
355
1
    fn format_quotes_embedded_specials_in_annotation() {
356
1
        let key = SshKeyRecord {
357
1
            annotation: r#"weird\"name"#.into(),
358
1
            ..record("", false)
359
1
        };
360
1
        let out = format_ssh_keys(&[key]);
361
1
        assert!(out.contains(r#":annotation "weird\\\"name""#), "got: {out}");
362
1
    }
363
}