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-server/src/forge.rs

//! The bytes a forge event is signed over, mirrored from gunnar.
//!
//! An approval is not a row the console can ask gunnar to write. gunnar's
//! `Forge` service has no `Sign` RPC on purpose — "a server that could sign an
//! approval is a server that could manufacture one" — so every event arrives
//! already signed by the caller's own key, and `PullRequests.Merge` refuses
//! unless a **verified** approval names the exact commit it is about to
//! fast-forward to.
//!
//! So this is the console's half of that: the canonical signing bytes and the
//! `sshsig` namespace, so [`crate::signer::Signer`] can produce an event
//! gunnar's `verify_event` accepts.
//!
//! # Why it is a copy
//!
//! Same reason the credential constants in [`crate::signer`] are: the crate
//! that owns them (`gunnar-core`, via `gunnar-grpc`) pulls in `gunnar-proto`,
//! whose build script requires `protoc` and pins tonic 0.13. `tests/contract.rs`
//! asserts this copy has not drifted from gunnar's `crates/gunnar-core/src/forge.rs`
//! whenever gunnar is checked out beside this repo.
//!
//! # What the console can and cannot claim
//!
//! gunnar sees exactly one principal here — the key this process signs with —
//! and `Forge.Append` refuses an event whose `actor` is not the caller's own
//! principal. **An approval made from the console is therefore the console's
//! approval**, not the browser user's: the console has no access to a human's
//! ssh key and must not invent one. What the console *can* do is say, inside
//! the same log, which of its own users asked for it — see
//! [`attribution`] — and that is a claim by the console, checkable only as far
//! as the console is trusted. The signature is the console's; the name is its
//! testimony.

/// The `sshsig` namespace a forge event is signed under.
///
/// Its own namespace: a review approval must never be presentable as a commit
/// signature, and `sshsig` binds the namespace into what is signed.
pub const FORGE_NAMESPACE: &str = "gunnar-forge";

/// The first line of the signed bytes — domain separation.
pub const SIGNING_PREAMBLE: &str = "gunnar-forge-v1";

/// What happened, in the closed set gunnar folds.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event {
    /// A comment on a pull request, or — when `path` is set — on ONE line of
    /// ONE file version.
    ///
    /// An empty `path` is the per-pull comment, and its signed bytes are
    /// byte-for-byte what they were before the anchor existed. A non-empty
    /// `path` anchors the comment to `(path, line, blob_oid)` — the blob it was
    /// made on, so it follows the code across a rebase that leaves the file
    /// alone. Mirrors `gunnar_core::forge::Event::Commented`.
    Commented {
        body: String,
        /// The file the comment is on. Empty for a per-pull comment.
        path: String,
        /// The one-based line within `blob_oid`.
        line: u32,
        /// The blob the commented line lived in.
        blob_oid: String,
    },
    /// An approval of a **specific** commit.
    Approved { commit: String },
    /// **An issue opened.** The console produces these, unlike `Rejected`,
    /// because the issues page can file one; the number is the fold's to assign.
    IssueOpened { title: String, body: String },
    /// A comment on an issue.
    IssueCommented { body: String },
    /// **An issue closed**, with the reason it was.
    IssueClosed { reason: String },
}

impl Event {
    fn tag(&self) -> &'static str {
        match self {
            Event::Commented { .. } => "commented",
            Event::Approved { .. } => "approved",
            Event::IssueOpened { .. } => "issue_opened",
            Event::IssueCommented { .. } => "issue_commented",
            Event::IssueClosed { .. } => "issue_closed",
        }
    }
}

/// One event on one pull request, before it is signed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ForgeEvent {
    /// `<namespace>/<repo>`.
    pub repo: String,
    /// `PullRequest.number`, assigned by `PullRequests.Create`.
    pub pull: u64,
    /// The identity-table principal this is attributed to. `Forge.Append` refuses anything
    /// but the caller's own.
    pub actor: String,
    pub at_unix_ms: i64,
    pub event: Event,
}

impl ForgeEvent {
    /// The exact bytes gunnar verifies against.
    ///
    /// Line-oriented and fixed-order, because JSON has no canonical form. The
    /// body is length-prefixed rather than escaped, so a comment containing a
    /// newline cannot be made to look like two fields.
    pub fn signing_bytes(&self) -> Vec<u8> {
        let mut out = String::new();
        out.push_str(SIGNING_PREAMBLE);
        out.push('\n');
        out.push_str(&format!("repo {}\n", self.repo));
        out.push_str(&format!("pull {}\n", self.pull));
        out.push_str(&format!("actor {}\n", self.actor));
        out.push_str(&format!("at {}\n", self.at_unix_ms));
        out.push_str(&format!("kind {}\n", self.event.tag()));
        let mut field = |name: &str, value: &str| {
            out.push_str(&format!("{name} {} {value}\n", value.len()));
        };
        match &self.event {
            Event::Commented {
                body,
                path,
                line,
                blob_oid,
            } => {
                field("body", body);
                if !path.is_empty() {
                    field("path", path);
                    field("line", &line.to_string());
                    field("blob", blob_oid);
                }
            }
            Event::Approved { commit } => field("commit", commit),
            Event::IssueOpened { title, body } => {
                field("title", title);
                field("body", body);
            }
            Event::IssueCommented { body } => field("body", body),
            Event::IssueClosed { reason } => field("reason", reason),
        }
        out.into_bytes()
    }
}

/// The line the console writes into the forge log naming **which of its own
/// users** asked for an approval.
///
/// The approval itself is signed by the console's key and attributed to the
/// console's principal, because that is the only key this process has. Without
/// this line gunnar's log would record that the console approved something and
/// nothing about who drove it, and "who approved this" is the question the
/// whole forge log exists to answer.
///
/// It is deliberately a `Commented` event and not a field on the approval:
/// inventing a field would mean a second review model, and the console's word
/// about its own session is a comment, not evidence.
pub fn attribution(user: &str, role: &str, commit: &str) -> String {
    format!(
        "Approved from the Gunnar console by {user:?} ({role}) for commit {commit}. This \
         approval is signed by the console's own key: the console has no access to \
         {user:?}'s ssh key, so the signature is the console's and this line is the \
         console's word about who asked for it."
    )
}

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

    /// The exact bytes, spelled out. If gunnar's writer and this one ever
    /// disagree by one character, every approval the console files is rejected
    /// as "the event's signature does not verify" — a message that reads like a
    /// key problem and is not.
    #[test]
    fn the_signing_bytes_are_what_gunnar_writes() {
        let event = ForgeEvent {
            repo: "team/proposed".into(),
            pull: 7,
            actor: "console".into(),
            at_unix_ms: 1_700_000_000_000,
            event: Event::Approved {
                commit: "a".repeat(40),
            },
        };

        assert_eq!(
            String::from_utf8(event.signing_bytes()).expect("utf-8"),
            format!(
                "gunnar-forge-v1\nrepo team/proposed\npull 7\nactor console\nat \
                 1700000000000\nkind approved\ncommit 40 {}\n",
                "a".repeat(40)
            )
        );
    }

    /// The issue-open bytes, spelled out. Same drift hazard as the approval:
    /// one character apart from gunnar's writer and every issue the console
    /// opens is refused as "the event's signature does not verify". `pull` is 0
    /// because an opening has no number yet — the fold assigns it.
    #[test]
    fn the_issue_open_bytes_are_what_gunnar_writes() {
        let event = ForgeEvent {
            repo: "team/proposed".into(),
            pull: 0,
            actor: "console".into(),
            at_unix_ms: 1_700_000_000_000,
            event: Event::IssueOpened {
                title: "the boot is dark".into(),
                body: "PID 1 to a dead ttyS0".into(),
            },
        };
        assert_eq!(
            String::from_utf8(event.signing_bytes()).expect("utf-8"),
            "gunnar-forge-v1\nrepo team/proposed\npull 0\nactor console\nat 1700000000000\n\
             kind issue_opened\ntitle 16 the boot is dark\nbody 21 PID 1 to a dead ttyS0\n"
        );
    }

    /// A comment's body is length-prefixed, so text that looks like a field is
    /// text. The attribution line is operator-supplied through `user`, which is
    /// exactly the value that would otherwise be worth smuggling a newline in.
    #[test]
    fn a_body_cannot_smuggle_a_field_past_the_signature() {
        let event = ForgeEvent {
            repo: "team/proposed".into(),
            pull: 1,
            actor: "console".into(),
            at_unix_ms: 1,
            event: Event::Commented {
                body: "looks fine\nactor mallory".into(),
                path: String::new(),
                line: 0,
                blob_oid: String::new(),
            },
        };
        let text = String::from_utf8(event.signing_bytes()).expect("utf-8");

        assert!(
            text.contains("body 24 looks fine\nactor mallory"),
            "the body is not length-prefixed: {text}"
        );
    }

    /// **The inline anchor is appended, length-prefixed, only when the comment
    /// has a path** — and the exact bytes are spelled out, because this copy and
    /// gunnar's `verify_event` must agree to the character or every inline
    /// comment the console files is refused as unverifiable.
    #[test]
    fn an_inline_comment_signs_the_anchor_and_a_plain_one_does_not() {
        let base = |event| ForgeEvent {
            repo: "team/proposed".into(),
            pull: 7,
            actor: "console".into(),
            at_unix_ms: 1_700_000_000_000,
            event,
        };
        let plain = base(Event::Commented {
            body: "hi".into(),
            path: String::new(),
            line: 0,
            blob_oid: String::new(),
        });
        assert!(
            String::from_utf8(plain.signing_bytes())
                .expect("utf-8")
                .ends_with("kind commented\nbody 2 hi\n"),
            "a per-pull comment must sign exactly what it did before the anchor existed"
        );

        let inline = base(Event::Commented {
            body: "hi".into(),
            path: "a.rs".into(),
            line: 5,
            blob_oid: "a".repeat(40),
        });
        assert_eq!(
            String::from_utf8(inline.signing_bytes()).expect("utf-8"),
            format!(
                "gunnar-forge-v1\nrepo team/proposed\npull 7\nactor console\nat \
                 1700000000000\nkind commented\nbody 2 hi\npath 4 a.rs\nline 1 5\nblob 40 {}\n",
                "a".repeat(40)
            )
        );
    }

    /// Every field is inside the signature, so none of them can be moved.
    #[test]
    fn no_field_can_be_moved_without_changing_the_bytes() {
        let base = ForgeEvent {
            repo: "team/proposed".into(),
            pull: 7,
            actor: "console".into(),
            at_unix_ms: 1_700_000_000_000,
            event: Event::Approved {
                commit: "a".repeat(40),
            },
        };
        for mutate in [
            |e: &mut ForgeEvent| e.repo = "other/repo".into(),
            |e: &mut ForgeEvent| e.pull = 8,
            |e: &mut ForgeEvent| e.actor = "mallory".into(),
            |e: &mut ForgeEvent| e.at_unix_ms += 1,
            |e: &mut ForgeEvent| {
                e.event = Event::Approved {
                    commit: "b".repeat(40),
                }
            },
        ] {
            let mut moved = base.clone();
            mutate(&mut moved);
            assert_ne!(
                base.signing_bytes(),
                moved.signing_bytes(),
                "a field moved without changing what is signed: {moved:?}"
            );
        }
    }

    /// The attribution names the human, the role and the commit — all three,
    /// because a line that says only "approved from the console" answers none
    /// of the questions an auditor asks.
    #[test]
    fn the_attribution_names_the_user_the_role_and_the_commit() {
        let line = attribution("bob", "admin", &"c".repeat(40));

        assert!(line.contains("bob"));
        assert!(line.contains("admin"));
        assert!(line.contains(&"c".repeat(40)));
        // And it must not read as if bob signed it.
        assert!(line.contains("signed by the console's own key"));
    }
}