Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-web/src/console_key.rs

//! **What the console's own credential on a person's account is worth**, on the
//! host side of the wasm boundary. Same argument as [`crate::room`].
//!
//! The console reads gunnar as the person sitting in front of it: it enrols a
//! key of its own on their principal and signs every read with it (the server
//! half is `gunnar_ui_server::tenant`). gunnar accepts a SECOND key on a
//! principal only when the principal vouches for it with an sshsig made by a
//! key they already hold — so anybody who registered an SSH key BEFORE their
//! first console read is refused, their reads fall back to the grantless guest,
//! and the console shows them the public subset of a box they own. Until
//! 2026-09-18 there was no sentence on any page saying so.
//!
//! The rule that matters is the one below, and it is about silence: an enrolled
//! credential says NOTHING. A permanent "all good" panel is a panel a reader
//! stops seeing by the second visit, and this one has to be noticed the one
//! time it appears. So the ordinary case renders nothing at all, and every
//! other answer is judged here rather than inside a `view!` where it could not
//! be tested.

use serde::Deserialize;

/// `GET /api/account/console-key`, as the server sends it.
///
/// `#[serde(default)]` on the whole struct and not on one field: `refusal` is
/// already `skip_serializing_if` on the server, and a console that refuses to
/// parse an older or newer appliance's answer would hide exactly the panel
/// whose job is to explain a mismatch. Missing fields become empty, and empty
/// is a state [`shown`] already has an answer for.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ConsoleKey {
    /// `enrolled`, `awaiting_attestation` or `absent`.
    pub state: String,
    /// The principal this session acts for, when there is one.
    pub principal_id: String,
    /// `SHA256:…` of the console's key — the enrolled one, or the one waiting.
    pub fingerprint: String,
    /// The OpenSSH line to attest. Empty unless something is waiting.
    pub openssh_public_key: String,
    /// The command to run on the appliance, composed by the server from the two
    /// values above. Empty unless something is waiting.
    pub command: String,
    /// **gunnar's own sentence**, verbatim, when it refused. Never reworded on
    /// the way to the screen: the appliance knows why and this console does
    /// not, and two explanations of one refusal leave a reader with no way to
    /// tell which is true.
    pub refusal: String,
}

/// What the account page draws, and mostly it draws nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shown {
    /// Nothing at all. The credential works, or there is nothing to say about
    /// it that a person could act on.
    Nothing,
    /// gunnar's refusal, with no way out on offer — there is no key waiting to
    /// be vouched for, so a form would ask for something nobody can produce.
    Refused { refusal: String },
    /// A key is waiting for the account holder's word: gunnar's reason, the
    /// command that signs it, and the fingerprint that command is about.
    Waiting {
        refusal: String,
        command: String,
        fingerprint: String,
    },
}

/// ★ **The one decision the panel makes.**
///
/// Read in this order, and the order is the point:
///
/// * `enrolled` wins over everything, including a `refusal` left in the answer
///   by an earlier attempt. The credential works; there is nothing to do.
/// * `awaiting_attestation` is the case this whole surface exists for, and the
///   only one that draws a form.
/// * anything else — `absent`, or a state word from an appliance newer than
///   this bundle — shows gunnar's sentence if there is one and stays quiet if
///   there is not. Quiet rather than a guess: an unknown word is not a reason
///   to invent a ceremony, and a refusal with nothing waiting is not a form.
pub fn shown(key: &ConsoleKey) -> Shown {
    let refusal = key.refusal.trim().to_owned();
    match key.state.as_str() {
        "enrolled" => Shown::Nothing,
        "awaiting_attestation" => Shown::Waiting {
            refusal,
            command: key.command.trim().to_owned(),
            fingerprint: key.fingerprint.trim().to_owned(),
        },
        _ if refusal.is_empty() => Shown::Nothing,
        _ => Shown::Refused { refusal },
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The ordinary case, and it is silent. Asserted first because it is what
    /// every visit to `/account` hits: a panel here would be noise on every
    /// page load for everybody whose console is working.
    #[test]
    fn an_enrolled_credential_says_nothing() {
        assert_eq!(
            shown(&ConsoleKey {
                state: "enrolled".to_owned(),
                principal_id: "p-anna".to_owned(),
                fingerprint: "SHA256:theconsoleskey".to_owned(),
                ..ConsoleKey::default()
            }),
            Shown::Nothing
        );
        // Even with a sentence left over from the attempt that got it there:
        // the credential works now, and yesterday's refusal is not news.
        assert_eq!(
            shown(&ConsoleKey {
                state: "enrolled".to_owned(),
                refusal: "principal p-anna already holds 1 key(s)".to_owned(),
                ..ConsoleKey::default()
            }),
            Shown::Nothing
        );
    }

    /// ★ **The stuck account**: gunnar's words, the command, and the
    /// fingerprint it is about, all three, because a person who has to run
    /// something on the appliance cannot go and read the console's log to find
    /// out which key is meant.
    #[test]
    fn a_waiting_key_carries_gunnars_reason_and_the_command() {
        let waiting = shown(&ConsoleKey {
            state: "awaiting_attestation".to_owned(),
            principal_id: "p-anna".to_owned(),
            fingerprint: "SHA256:thewaitingkey".to_owned(),
            openssh_public_key: "ssh-ed25519 AAAAwaiting x".to_owned(),
            command: "gunnar control user attest-key p-anna --key 'ssh-ed25519 AAAAwaiting x'\n"
                .to_owned(),
            refusal: "  principal p-anna already holds 1 key(s), so adding another needs their \
                      own attestation  "
                .to_owned(),
        });
        let Shown::Waiting {
            refusal,
            command,
            fingerprint,
        } = waiting
        else {
            panic!("a key waiting to be vouched for drew something else: {waiting:?}");
        };
        assert!(
            refusal.starts_with("principal p-anna already holds"),
            "gunnar's sentence was reworded or padded: {refusal:?}"
        );
        assert!(
            command.starts_with("gunnar control user attest-key") && command.ends_with('\''),
            "the command is not one pasteable line: {command:?}"
        );
        assert_eq!(fingerprint, "SHA256:thewaitingkey");
    }

    /// **A refusal with nothing waiting is a sentence and not a form.** The
    /// mint itself failed, or the store did; asking for an attestation of a key
    /// that was never made is asking for something nobody can produce.
    #[test]
    fn a_refusal_with_nothing_waiting_offers_no_ceremony() {
        assert_eq!(
            shown(&ConsoleKey {
                state: "absent".to_owned(),
                refusal: "gunnar has no principal bound to this session".to_owned(),
                ..ConsoleKey::default()
            }),
            Shown::Refused {
                refusal: "gunnar has no principal bound to this session".to_owned()
            }
        );
        // And an absence gunnar gave no reason for stays quiet. There is
        // nothing a reader could do with "absent" on its own.
        assert_eq!(
            shown(&ConsoleKey {
                state: "absent".to_owned(),
                ..ConsoleKey::default()
            }),
            Shown::Nothing
        );
    }

    /// **A state word this bundle has never heard of.** The console and the
    /// appliance are upgraded separately, so a newer server WILL eventually
    /// send one. It must not become a form — the browser would be inventing a
    /// ceremony for a state it cannot know the shape of — and it must not
    /// swallow the server's reason either.
    #[test]
    fn a_newer_appliances_state_word_is_not_guessed_at() {
        assert_eq!(
            shown(&ConsoleKey {
                state: "revoked_upstream".to_owned(),
                refusal: "the key was revoked on the appliance".to_owned(),
                command: "gunnar control user attest-key p-anna --key 'x'".to_owned(),
                ..ConsoleKey::default()
            }),
            Shown::Refused {
                refusal: "the key was revoked on the appliance".to_owned()
            },
            "an unknown state drew the attestation form"
        );
    }

    /// **The answer's shape is the server's**, and this pins it against
    /// `gunnar_ui_server::api::account::ConsoleKey`. The two structs are
    /// mirrored rather than shared — this crate compiles to wasm and does not
    /// depend on the BFF — so a field renamed on one side has to fail on the
    /// other, and a silently-empty panel is not a failure anybody would notice.
    #[test]
    fn the_answer_shape_is_the_servers() {
        let sent = r#"{
            "state": "awaiting_attestation",
            "principal_id": "p-anna",
            "fingerprint": "SHA256:thewaitingkey",
            "openssh_public_key": "ssh-ed25519 AAAAwaiting gunnar-ui console session for anna",
            "command": "gunnar control user attest-key p-anna --key 'ssh-ed25519 AAAAwaiting x'",
            "refusal": "principal p-anna already holds 1 key(s)"
        }"#;
        let parsed: ConsoleKey = serde_json::from_str(sent).expect("the server's own shape");
        assert_eq!(parsed.state, "awaiting_attestation");
        assert_eq!(parsed.principal_id, "p-anna");
        assert!(parsed.command.contains("attest-key"));

        // `refusal` is `skip_serializing_if = "String::is_empty"` on the
        // server, so the enrolled answer arrives WITHOUT it. That must parse.
        let enrolled = r#"{"state":"enrolled","principal_id":"p-anna",
            "fingerprint":"SHA256:x","openssh_public_key":"","command":""}"#;
        let parsed: ConsoleKey = serde_json::from_str(enrolled).expect("no refusal field");
        assert_eq!(shown(&parsed), Shown::Nothing);
    }
}