max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 }} <{{ detail.committer_email }}> · {{ 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"> |