gunnar-grpc 3.0.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
//! **A person's passkeys live at gunnar, on their principal** (owner,
//! 2026-10-04: "passkeys + invites + passkey↔principal pin → gunnar-server";
//! the console keeps only cookie sessions).
//!
//! Until this file a person's WebAuthn credentials, and which principal each
//! one logged in as, lived only in the console's own state file: a front
//! reinstall lost every login, the CLI could see none of them, and removing a
//! principal at gunnar left its passkeys working at the console. Here they are
//! rows in gunnar's identity table beside the person's SSH keys:
//!
//! * a server admin, or the principal itself (the self-scope), adds, lists and
//!   revokes them;
//! * only a USER may hold one — no human credential on a machine, a group or
//!   a deploy key;
//! * the public key must be a P-256 point (SEC1 uncompressed), and a
//!   credential id is registered once;
//! * a refusal to revoke somebody else's credential is word for word the one
//!   for a credential that does not exist;
//! * removing the principal removes its credentials.

use std::sync::Arc;

use gunnar_grpc::auth::AuthGate;
use gunnar_grpc::{
    sign_credential_for, AccessService, AuthMode, Caller, ControlPlane, EventBus,
    MemoryControlPlane, ServerConfig, ServiceContext, CREDENTIAL_METADATA_KEY,
};
use gunnar_proto::access_server::Access as _;
use gunnar_proto::*;
use tonic::{Code, Request};

fn mint() -> ssh_key::PrivateKey {
    ssh_key::PrivateKey::random(
        &mut rand::rand_core::UnwrapErr(rand::rngs::SysRng),
        ssh_key::Algorithm::Ed25519,
    )
    .expect("mint an ed25519 key")
}

fn now_ms() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("after 1970")
        .as_millis() as i64
}

/// A passkey's public half: a real P-256 point, SEC1 uncompressed.
fn passkey(seed: u8) -> Vec<u8> {
    let key = p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar");
    key.verifying_key().to_sec1_point(false).as_bytes().to_vec()
}

struct Harness {
    plane: Arc<MemoryControlPlane>,
    ctx: ServiceContext,
}

impl Harness {
    fn new() -> Harness {
        let plane = Arc::new(MemoryControlPlane::new());
        let control: Arc<dyn ControlPlane> = plane.clone();
        let ctx = ServiceContext::new(
            control,
            Arc::new(EventBus::new(16)),
            ServerConfig::default(),
        );
        assert_eq!(ctx.config.auth, AuthMode::Required, "the shipped default");
        Harness { plane, ctx }
    }

    fn principal(&self, id: &str, kind: PrincipalKind) -> Caller {
        let key = mint();
        self.plane.add_principal(id, id, kind).expect("principal");
        self.plane
            .add_key(
                id,
                &key.public_key().to_openssh().expect("openssh"),
                id,
                None,
                "",
                None,
            )
            .expect("key");
        let credential = sign_credential_for(
            &key,
            now_ms(),
            &format!("nonce-{id}-web-credentials"),
            "Admin.Health",
        )
        .expect("sign");
        let mut request = Request::new(());
        request.metadata_mut().insert(
            CREDENTIAL_METADATA_KEY,
            credential.to_header_value().parse().expect("ascii"),
        );
        AuthGate::new(Arc::clone(&self.ctx.control), self.ctx.config.auth)
            .authenticate_bound(request.metadata(), now_ms(), Some("Admin.Health"))
            .expect("a registered key verifies")
    }

    fn person(&self, id: &str) -> Caller {
        self.principal(id, PrincipalKind::User)
    }

    fn admin(&self) -> Caller {
        let boss = self.person("boss");
        self.plane.grant_server("boss", GrantLevel::Admin);
        boss
    }

    fn authed<T>(caller: &Caller, body: T) -> Request<T> {
        let mut request = Request::new(body);
        request.extensions_mut().insert(caller.clone());
        request
    }

    fn access(&self) -> AccessService {
        AccessService::new(self.ctx.clone())
    }

    async fn add(
        &self,
        caller: &Caller,
        principal: &str,
        id: &[u8],
        public_key: Vec<u8>,
    ) -> Result<WebCredential, tonic::Status> {
        self.access()
            .add_credential(Harness::authed(
                caller,
                AddCredentialRequest {
                    principal_id: principal.into(),
                    credential_id: id.to_vec(),
                    public_key,
                    label: format!("{principal}'s key"),
                    sign_count: 0,
                },
            ))
            .await
            .map(|r| r.into_inner().credential.expect("a credential"))
    }

    async fn list(&self, caller: &Caller, principal: &str) -> Result<Vec<Vec<u8>>, tonic::Status> {
        self.access()
            .list_credentials(Harness::authed(
                caller,
                ListCredentialsRequest {
                    principal_id: principal.into(),
                },
            ))
            .await
            .map(|r| {
                let mut ids: Vec<Vec<u8>> = r
                    .into_inner()
                    .credentials
                    .into_iter()
                    .map(|c| c.credential_id)
                    .collect();
                ids.sort();
                ids
            })
    }

    async fn revoke(&self, caller: &Caller, id: &[u8]) -> Result<(), tonic::Status> {
        self.access()
            .revoke_credential(Harness::authed(
                caller,
                RevokeCredentialRequest {
                    credential_id: id.to_vec(),
                },
            ))
            .await
            .map(|_| ())
    }
}

/// ★ **A server admin adds a passkey to a person; the person sees their own
/// and nobody else's; the admin sees them all.**
#[tokio::test]
async fn an_admin_adds_a_passkey_and_a_person_lists_their_own() {
    let h = Harness::new();
    let boss = h.admin();
    let kim = h.person("kim");
    let ola = h.person("ola");

    let added = h
        .add(&boss, "kim", b"kim-yubikey", passkey(1))
        .await
        .expect("admin adds kim's");
    assert_eq!(added.principal_id, "kim");
    assert_eq!(added.credential_id, b"kim-yubikey".to_vec());
    assert_eq!(added.label, "kim's key");
    assert!(added.last_used_unix_ms.is_none(), "never used yet");
    h.add(&boss, "ola", b"ola-phone", passkey(2))
        .await
        .expect("admin adds ola's");

    assert_eq!(
        h.list(&kim, "kim").await.expect("kim's own"),
        vec![b"kim-yubikey".to_vec()]
    );
    for (asked, what) in [("ola", "another person's"), ("", "every credential")] {
        let refused = h.list(&kim, asked).await.expect_err(what);
        assert_eq!(
            refused.code(),
            Code::PermissionDenied,
            "{what}: {refused:?}"
        );
    }
    assert_eq!(h.list(&boss, "").await.expect("all").len(), 2);
    let _ = ola;
}

/// ★ **The self-scope: a person adds a passkey to themselves**, and to nobody
/// else.
#[tokio::test]
async fn a_person_adds_a_passkey_to_themselves_and_to_nobody_else() {
    let h = Harness::new();
    let kim = h.person("kim");
    h.person("ola");
    h.add(&kim, "kim", b"kim-laptop", passkey(3))
        .await
        .expect("kim adds her own");
    let refused = h
        .add(&kim, "ola", b"planted", passkey(4))
        .await
        .expect_err("a passkey on another person is a server admin's act");
    assert_eq!(refused.code(), Code::PermissionDenied, "{refused:?}");
    assert!(h.list(&h.admin(), "ola").await.expect("list").is_empty());
}

/// **No human credential on a machine principal** — nor a group, nor a deploy
/// key; a key that is not a P-256 point and a credential id registered twice
/// are refused by name.
#[tokio::test]
async fn a_machine_holds_no_passkey_and_a_bad_key_or_a_second_id_is_refused() {
    let h = Harness::new();
    let boss = h.admin();
    h.principal("ci", PrincipalKind::Machine);
    let refused = h
        .add(&boss, "ci", b"ci-key", passkey(5))
        .await
        .expect_err("no human credential on a machine principal");
    assert_eq!(refused.code(), Code::FailedPrecondition, "{refused:?}");
    assert!(refused.message().contains("machine"), "{refused:?}");

    h.person("kim");
    let mut off_curve = passkey(6);
    off_curve[64] ^= 0x01;
    for (bad, what) in [
        (off_curve, "a point that is not on the curve"),
        (passkey(6)[..33].to_vec(), "a truncated key"),
        (vec![0u8; 65], "zeroes"),
    ] {
        let refused = h.add(&boss, "kim", b"kim-bad", bad).await.expect_err(what);
        assert_eq!(refused.code(), Code::InvalidArgument, "{what}: {refused:?}");
    }
    h.add(&boss, "kim", b"kim-key", passkey(7))
        .await
        .expect("a good one");
    let twice = h
        .add(&boss, "kim", b"kim-key", passkey(8))
        .await
        .expect_err("a credential id is registered once");
    assert_eq!(twice.code(), Code::AlreadyExists, "{twice:?}");
    let nobody = h
        .add(&boss, "ghost", b"ghost-key", passkey(9))
        .await
        .expect_err("no such principal");
    assert_eq!(nobody.code(), Code::NotFound, "{nobody:?}");
}

/// ★ **A person revokes their own passkey; somebody else's is refused in the
/// words a missing one is; an admin revokes anyone's.**
#[tokio::test]
async fn a_person_revokes_their_own_passkey_and_the_refusal_names_nobody() {
    let h = Harness::new();
    let boss = h.admin();
    let kim = h.person("kim");
    h.person("ola");
    h.add(&boss, "kim", b"kim-a", passkey(10))
        .await
        .expect("kim-a");
    h.add(&boss, "ola", b"ola-a", passkey(11))
        .await
        .expect("ola-a");

    h.revoke(&kim, b"kim-a").await.expect("kim revokes her own");
    assert!(h.list(&kim, "kim").await.expect("list").is_empty());

    let theirs = h
        .revoke(&kim, b"ola-a")
        .await
        .expect_err("ola's is not kim's");
    let nobodys = h
        .revoke(&kim, b"no-such")
        .await
        .expect_err("nor is a missing one");
    assert_eq!(theirs.code(), Code::PermissionDenied, "{theirs:?}");
    assert_eq!(
        theirs.message(),
        nobodys.message(),
        "the refusal is no oracle"
    );
    assert!(!theirs.message().contains("ola"), "{theirs:?}");
    assert_eq!(h.list(&boss, "ola").await.expect("list").len(), 1);

    h.revoke(&boss, b"ola-a")
        .await
        .expect("an admin revokes anyone's");
    let missing = h.revoke(&boss, b"ola-a").await.expect_err("gone");
    assert_eq!(missing.code(), Code::NotFound, "{missing:?}");
}

/// Removing the principal removes its passkeys.
#[tokio::test]
async fn removing_a_principal_removes_its_passkeys() {
    let h = Harness::new();
    let boss = h.admin();
    h.person("kim");
    h.add(&boss, "kim", b"kim-a", passkey(12))
        .await
        .expect("kim-a");
    h.add(&boss, "kim", b"kim-b", passkey(13))
        .await
        .expect("kim-b");
    h.access()
        .remove_principal(Harness::authed(
            &boss,
            RemovePrincipalRequest {
                principal_id: "kim".into(),
            },
        ))
        .await
        .expect("removed");
    assert!(
        h.list(&boss, "").await.expect("list").is_empty(),
        "kim's passkeys outlived her"
    );
}

/// ★ **A person renames their own passkey; somebody else's is refused in the
/// words a missing one is; an admin renames anyone's; a label is not empty.**
#[tokio::test]
async fn a_person_renames_their_own_passkey() {
    let h = Harness::new();
    let boss = h.admin();
    let kim = h.person("kim");
    h.person("ola");
    h.add(&boss, "kim", b"kim-a", passkey(20))
        .await
        .expect("kim-a");
    h.add(&boss, "ola", b"ola-a", passkey(21))
        .await
        .expect("ola-a");
    let rename = |caller: &Caller, id: &[u8], label: &str| {
        let access = h.access();
        let request = Harness::authed(
            caller,
            RenameCredentialRequest {
                credential_id: id.to_vec(),
                label: label.into(),
            },
        );
        async move {
            access
                .rename_credential(request)
                .await
                .map(|r| r.into_inner())
        }
    };
    let renamed = rename(&kim, b"kim-a", "work laptop")
        .await
        .expect("kim renames hers");
    assert_eq!(renamed.credential.expect("row").label, "work laptop");
    let theirs = rename(&kim, b"ola-a", "mine now")
        .await
        .expect_err("not kim's");
    let nobodys = rename(&kim, b"no-such", "x").await.expect_err("missing");
    assert_eq!(theirs.code(), Code::PermissionDenied, "{theirs:?}");
    assert_eq!(theirs.message(), nobodys.message());
    let empty = rename(&kim, b"kim-a", "  ")
        .await
        .expect_err("an empty label");
    assert_eq!(empty.code(), Code::InvalidArgument, "{empty:?}");
    rename(&boss, b"ola-a", "ola's phone")
        .await
        .expect("an admin renames anyone's");
    let missing = rename(&boss, b"no-such", "x").await.expect_err("missing");
    assert_eq!(missing.code(), Code::NotFound, "{missing:?}");
}

/// ★ **The box keeps one admin who can log in**: the LAST passkey held by any
/// server-Admin person is refused, by anybody — while another admin's
/// passkey, or a second of the same admin's, lets it go; a person who is not
/// an admin may remove their only one.
#[tokio::test]
async fn the_last_admin_passkey_is_not_revoked() {
    let h = Harness::new();
    let boss = h.admin();
    let kim = h.person("kim");
    h.add(&boss, "boss", b"boss-a", passkey(30))
        .await
        .expect("boss's only passkey");
    for (who, what) in [(&boss, "boss himself"), (&boss, "again")] {
        let refused = h.revoke(who, b"boss-a").await.expect_err(what);
        assert_eq!(
            refused.code(),
            Code::FailedPrecondition,
            "{what}: {refused:?}"
        );
        assert!(refused.message().contains("last"), "{refused:?}");
    }
    h.add(&boss, "boss", b"boss-b", passkey(31))
        .await
        .expect("a second");
    h.revoke(&boss, b"boss-a").await.expect("one of two goes");
    let refused = h
        .revoke(&boss, b"boss-b")
        .await
        .expect_err("the last one again");
    assert_eq!(refused.code(), Code::FailedPrecondition, "{refused:?}");

    let eva = h.person("eva");
    h.plane.grant_server("eva", GrantLevel::Admin);
    h.add(&eva, "eva", b"eva-a", passkey(32))
        .await
        .expect("eva's");
    h.revoke(&boss, b"boss-b")
        .await
        .expect("another admin can still log in");

    h.add(&kim, "kim", b"kim-only", passkey(33))
        .await
        .expect("kim's only");
    h.revoke(&kim, b"kim-only")
        .await
        .expect("kim is not an admin");
}