Skip to main content

max / makenotwork

server: verify and display commit and tag signatures P7c of the git meta-operations program, and the one piece here that depends on owning the key store. MNW already holds the SSH keys that authenticate pushes, so an SSH signature can be checked against them and the signer named. Every forge without its own key store can say "this is signed"; this says "signed by @username". SSHSIG verification through ssh-key 0.6 (RustCrypto, per the crypto provider preference), with the feature set matching the algorithms an account key may use. Verification runs before the account lookup and the lookup never feeds back into it, so a registered key cannot launder a bad signature. A lookup failure reports the same as a key we do not hold rather than downgrading a good signature. Five states, four of them visible. Unsigned renders nothing: an unsigned commit is normal, and a forge that badges it teaches people to ignore the badge. Only a signature that does not verify gets a colour. PGP is reported as unchecked rather than shown as either good or bad. The payload is the part worth being exact about, and it is pinned by a test that writes a real signed commit into an object database and verifies what comes back: git signs the commit with the whole gpgsig header removed, and a reconstruction off by one header name would make every signature on the site read as invalid.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 00:18 UTC
Commit: 0262a7f8bd7254c21cd92bdf6ae71e66162ca6bf
Parent: 8ec07bd
11 files changed, +473 insertions, -8 deletions
@@ -5257,6 +5257,7 @@
5257 5257 "sha1 0.11.0",
5258 5258 "sha2 0.11.0",
5259 5259 "sqlx",
5260 + "ssh-key",
5260 5261 "subtle",
5261 5262 "syntect",
5262 5263 "tagtree",
@@ -8127,6 +8128,49 @@
8127 8128 "uuid",
8128 8129 ]
8129 8130
8131 + [[package]]
8132 + name = "ssh-cipher"
8133 + version = "0.2.0"
8134 + source = "registry+https://github.com/rust-lang/crates.io-index"
8135 + checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
8136 + dependencies = [
8137 + "cipher 0.4.4",
8138 + "ssh-encoding",
8139 + ]
8140 +
8141 + [[package]]
8142 + name = "ssh-encoding"
8143 + version = "0.2.0"
8144 + source = "registry+https://github.com/rust-lang/crates.io-index"
8145 + checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
8146 + dependencies = [
8147 + "base64ct",
8148 + "pem-rfc7468",
8149 + "sha2 0.10.9",
8150 + ]
8151 +
8152 + [[package]]
8153 + name = "ssh-key"
8154 + version = "0.6.7"
8155 + source = "registry+https://github.com/rust-lang/crates.io-index"
8156 + checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
8157 + dependencies = [
8158 + "ed25519-dalek",
8159 + "num-bigint-dig",
8160 + "p256",
8161 + "p384",
8162 + "p521",
8163 + "rand_core 0.6.4",
8164 + "rsa",
8165 + "sec1",
8166 + "sha2 0.10.9",
8167 + "signature",
8168 + "ssh-cipher",
8169 + "ssh-encoding",
8170 + "subtle",
8171 + "zeroize",
8172 + ]
8173 +
8130 8174 [[package]]
8131 8175 name = "stable_deref_trait"
8132 8176 version = "1.2.1"
@@ -10597,6 +10641,14 @@
10597 10641 "pkg-config",
10598 10642 ]
10599 10643
10644 + [[patch.unused]]
10645 + name = "synckit-client"
10646 + version = "0.8.0"
10647 +
10648 + [[patch.unused]]
10649 + name = "synckit-config"
10650 + version = "0.2.0"
10651 +
10600 10652 [[patch.unused]]
10601 10653 name = "kberg"
10602 10654 version = "0.1.0"
@@ -10606,9 +10658,17 @@
10606 10658 version = "0.1.0"
10607 10659
10608 10660 [[patch.unused]]
10609 - name = "synckit-client"
10610 - version = "0.8.0"
10661 + name = "quasi-http"
10662 + version = "0.1.0"
10611 10663
10612 10664 [[patch.unused]]
10613 - name = "synckit-config"
10614 - version = "0.2.0"
10665 + name = "quasi-router"
10666 + version = "0.1.0"
10667 +
10668 + [[patch.unused]]
10669 + name = "quasi-tauri"
10670 + version = "0.1.0"
10671 +
10672 + [[patch.unused]]
10673 + name = "quasi-webview"
10674 + version = "0.1.0"
@@ -95,6 +95,12 @@
95 95 chacha20poly1305 = "0.11.0"
96 96 hex = "0.4.3"
97 97 base64 = "0.22.1"
98 + # SSHSIG verification for signed commits and tags (`git::signing`). Feature set
99 + # matches `validation::users::SSH_KEY_TYPES`, the algorithms MNW accepts as
100 + # account keys: there is no point verifying a signature by a key nobody could
101 + # have registered. 0.6 rather than the 0.7 release candidate, and RustCrypto
102 + # rather than a C library, per the crypto-provider preference.
103 + ssh-key = { version = "0.6", default-features = false, features = ["alloc", "std", "ed25519", "p256", "p384", "p521", "rsa"] }
98 104
99 105 # Temp files (content export)
100 106 tempfile = "3"
@@ -7202,6 +7202,11 @@
7202 7202 }
7203 7203 .git-tag-meta a { color: var(--content); text-decoration: none; }
7204 7204 .git-tag-meta a:hover { text-decoration: underline; }
7205 + /* Signature state. Neutral by default: only a bad signature gets a colour. */
7206 + .git-signature { font-size: var(--text-fine); opacity: 0.7; }
7207 + .git-signature a { color: var(--content); }
7208 + .git-signature.is-verified { opacity: 1; }
7209 + .git-signature.is-bad { color: var(--danger); opacity: 1; }
7205 7210 /* refs/replace/*: object substitutions, listed but not applied while browsing */
7206 7211 .git-replace-intro { padding-bottom: var(--gap-section); font-size: var(--text-note); }
7207 7212 .git-replace-intro p { margin: 0; }
@@ -7,6 +7,7 @@
7 7 pub mod notes;
8 8 mod objects;
9 9 mod refs;
10 + pub mod signing;
10 11
11 12 pub use history::*;
12 13 pub use objects::*;
@@ -95,6 +96,13 @@
95 96 /// The annotation, whitespace-trimmed. `None` for a lightweight tag or an
96 97 /// annotated tag with an empty message.
97 98 pub message: Option<String>,
99 + /// Verified signature state. `Unsigned` as listed; the handler fills this in
100 + /// from `raw_signature`, which needs a database lookup and so cannot happen
101 + /// on the blocking pool where the tags are read.
102 + pub signature: signing::SignatureStatus,
103 + /// The signature as read off the tag object, consumed by the handler on its
104 + /// way to `signature` and never rendered.
105 + pub raw_signature: Option<signing::RawSignature>,
98 106 }
99 107
100 108 /// One `refs/replace/<source>` mapping: the object git would serve in place of
@@ -112,6 +112,11 @@
112 112 .filter(|o| o.kind == gix::object::Kind::Tag)
113 113 .and_then(|o| o.try_into_tag().ok());
114 114
115 + let raw_signature = tag_object.as_ref().and_then(|tag| {
116 + let decoded = tag.decode().ok()?;
117 + super::signing::tag_signature(&tag.data, decoded.pgp_signature?)
118 + });
119 +
115 120 let (tagger_name, tagger_time, tagger_seconds, message) = match tag_object.as_ref() {
116 121 Some(tag) => {
117 122 let decoded = tag.decode().ok();
@@ -148,6 +153,8 @@
148 153 tagger_time,
149 154 sort_key: tagger_seconds,
150 155 message,
156 + signature: super::signing::SignatureStatus::Unsigned,
157 + raw_signature,
151 158 });
152 159 }
153 160
@@ -421,9 +421,12 @@
421 421
422 422 let oid = gix::ObjectId::from_hex(oid_str.as_bytes()).map_err(|_| AppError::NotFound)?;
423 423 let repo_name_c = repo_name.clone();
424 - let (detail, diff_files, refs, info, notes) = resolved
424 + let (detail, diff_files, refs, info, notes, raw_signature) = resolved
425 425 .with_repo(move |gix_repo| {
426 - gix_repo.find_commit(oid).map_err(|_| AppError::NotFound)?;
426 + let commit = gix_repo.find_commit(oid).map_err(|_| AppError::NotFound)?;
427 + // Lifted here rather than in the handler because it is gitoxide
428 + // work; verifying it needs the database and happens below.
429 + let raw_signature = git::signing::commit_signature(&commit);
427 430 let detail = git::commit_detail(gix_repo, oid)?;
428 431 let diff_files = git::commit_diff(
429 432 gix_repo,
@@ -436,10 +439,12 @@
436 439 // Notes live in the repo, so this inherits the visibility check
437 440 // `resolve_repo` already made; nothing extra is needed here.
438 441 let notes = notes_view::notes_on_object(gix_repo, &detail.oid);
439 - Ok((detail, diff_files, refs, info, notes))
442 + Ok((detail, diff_files, refs, info, notes, raw_signature))
440 443 })
441 444 .await?;
442 445
446 + let signature = git::signing::verify_opt(&db, raw_signature.as_ref()).await;
447 +
443 448 let total_files = diff_files.len();
444 449 let total_additions: usize = diff_files.iter().map(|f| f.additions).sum();
445 450 let total_deletions: usize = diff_files.iter().map(|f| f.deletions).sum();
@@ -465,6 +470,7 @@
465 470 current_ref: info.default_branch,
466 471 refs,
467 472 detail,
473 + signature,
468 474 notes,
469 475 can_write_notes,
470 476 notes_merged: query.notes.as_deref() == Some("merged"),
@@ -598,7 +604,7 @@
598 604 .await?;
599 605
600 606 let repo_name_c = repo_name.clone();
601 - let (refs, info, tags) = resolved
607 + let (refs, info, mut tags) = resolved
602 608 .with_repo(move |gix_repo| {
603 609 let refs = git::list_refs(gix_repo);
604 610 let info = git::repo_info(gix_repo, &repo_name_c);
@@ -607,6 +613,14 @@
607 613 })
608 614 .await?;
609 615
616 + // Sequential rather than joined: a signed tag is rare, the lookup is a
617 + // single indexed row, and a repository with hundreds of signed tags would
618 + // rather queue than open hundreds of connections at once.
619 + for tag in &mut tags {
620 + let raw = tag.raw_signature.take();
621 + tag.signature = git::signing::verify_opt(&db, raw.as_ref()).await;
622 + }
623 +
610 624 let csrf_token = get_csrf_token(&session).await;
611 625 let is_owner = maybe_user.as_ref().map(|u| u.id) == Some(resolved.db_user.id);
612 626 let (open_issue_count, _) = db::issues::get_issue_counts(&db, resolved.db_repo.id)
@@ -134,6 +134,9 @@
134 134 pub current_ref: String,
135 135 pub refs: Vec<git::RefInfo>,
136 136 pub detail: git::CommitDetail,
137 + /// Signature state. `Unsigned` for almost every commit, and rendered as
138 + /// nothing rather than as a warning: an unsigned commit is normal.
139 + pub signature: git::signing::SignatureStatus,
137 140 /// Notes on this commit, one entry per `refs/notes/*` namespace that has
138 141 /// one. Empty for the ordinary unannotated commit.
139 142 pub notes: Vec<CommitNote>,
@@ -34,6 +34,9 @@
34 34 {% if detail.committer_name != detail.author_name || detail.committer_email != detail.author_email %}
35 35 <div><strong>Committer:</strong> {{ detail.committer_name }} &lt;{{ detail.committer_email }}&gt; &middot; {{ detail.committer_time }}</div>
36 36 {% endif %}
37 + {% if signature.is_signed() %}
38 + <div>{% let sig = signature %}{% include "partials/git_signature.html" %}</div>
39 + {% endif %}
37 40 <div class="git-commit-oid">Commit: {{ detail.oid }}</div>
38 41 {% if !detail.parents.is_empty() %}
39 42 <div class="git-commit-parents">