Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +19 −0
| @@ -942,6 +942,25 @@ select:focus { | |||
| 942 | 942 | margin-top: 0.2rem; | |
| 943 | 943 | font-size: 0.85em; | |
| 944 | 944 | } | |
| 945 | + | .key-verify { | |
| 946 | + | margin-top: 0.5rem; | |
| 947 | + | } | |
| 948 | + | .key-verify > summary { | |
| 949 | + | cursor: pointer; | |
| 950 | + | color: var(--fb-accent); | |
| 951 | + | font-size: 0.9em; | |
| 952 | + | } | |
| 953 | + | .key-verify textarea { | |
| 954 | + | font-family: var(--fb-font-mono); | |
| 955 | + | font-size: 0.85em; | |
| 956 | + | } | |
| 957 | + | .key-challenge { | |
| 958 | + | padding: 0.6rem 0.75rem; | |
| 959 | + | background: var(--fb-bg-inset); | |
| 960 | + | border-radius: var(--fb-radius); | |
| 961 | + | overflow-x: auto; | |
| 962 | + | white-space: pre-wrap; | |
| 963 | + | } | |
| 945 | 964 | .key-add { | |
| 946 | 965 | padding-top: 1rem; | |
| 947 | 966 | border-top: 1px solid var(--fb-border); | |
crates/git/src/lib.rs +1 −1
| @@ -46,7 +46,7 @@ use git2::{Repository, RepositoryInitOptions}; | |||
| 46 | 46 | pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; | |
| 47 | 47 | pub use crate::pubkey::{ParsedKey, parse_public_key}; | |
| 48 | 48 | pub use crate::repo::Repo; | |
| 49 | - | pub use crate::signature::{SignatureCache, SignatureState, SigningKey}; | |
| 49 | + | pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_challenge}; | |
| 50 | 50 | pub use crate::types::{ | |
| 51 | 51 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, | |
| 52 | 52 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, | |
crates/git/src/signature.rs +55 −0
| @@ -224,6 +224,33 @@ pub(crate) fn verify_gpg(armor: &str, payload: &[u8], keys: &[SigningKey]) -> Si | |||
| 224 | 224 | } | |
| 225 | 225 | } | |
| 226 | 226 | ||
| 227 | + | /// Verify that `armored_sig` is a valid signature over `payload` produced by the | |
| 228 | + | /// private key matching `public_key` (an `authorized_keys` line for SSH or an | |
| 229 | + | /// ASCII-armored block for GPG). Proves possession of the private key — used for | |
| 230 | + | /// key-ownership challenges. SSH signatures must use the `git` namespace. | |
| 231 | + | #[must_use] | |
| 232 | + | pub fn verify_challenge( | |
| 233 | + | kind: KeyKind, | |
| 234 | + | fingerprint: &str, | |
| 235 | + | public_key: &str, | |
| 236 | + | armored_sig: &str, | |
| 237 | + | payload: &[u8], | |
| 238 | + | ) -> bool { | |
| 239 | + | let key = SigningKey { | |
| 240 | + | user_id: String::new(), | |
| 241 | + | key_id: String::new(), | |
| 242 | + | kind, | |
| 243 | + | fingerprint: fingerprint.to_string(), | |
| 244 | + | public_key: public_key.to_string(), | |
| 245 | + | }; | |
| 246 | + | let keys = std::slice::from_ref(&key); | |
| 247 | + | let state = match kind { | |
| 248 | + | KeyKind::Ssh => verify_ssh(armored_sig, payload, keys), | |
| 249 | + | KeyKind::Gpg => verify_gpg(armored_sig, payload, keys), | |
| 250 | + | }; | |
| 251 | + | matches!(state, SignatureState::Verified { .. }) | |
| 252 | + | } | |
| 253 | + | ||
| 227 | 254 | /// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so | |
| 228 | 255 | /// only surrounding whitespace is trimmed. | |
| 229 | 256 | fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool { | |
| @@ -391,6 +418,34 @@ MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\ | |||
| 391 | 418 | } | |
| 392 | 419 | ||
| 393 | 420 | #[test] | |
| 421 | + | fn verify_challenge_accepts_a_valid_gpg_signature() { | |
| 422 | + | // The pinned signature is over GPG_PAYLOAD, so verify_challenge accepts it. | |
| 423 | + | assert!(verify_challenge( | |
| 424 | + | KeyKind::Gpg, | |
| 425 | + | GPG_FPR, | |
| 426 | + | GPG_PUB, | |
| 427 | + | GPG_SIG, | |
| 428 | + | GPG_PAYLOAD | |
| 429 | + | )); | |
| 430 | + | // A different payload (as a real challenge would use) must be refused. | |
| 431 | + | assert!(!verify_challenge( | |
| 432 | + | KeyKind::Gpg, | |
| 433 | + | GPG_FPR, | |
| 434 | + | GPG_PUB, | |
| 435 | + | GPG_SIG, | |
| 436 | + | b"a different challenge" | |
| 437 | + | )); | |
| 438 | + | // Garbage signature is refused, not a panic. | |
| 439 | + | assert!(!verify_challenge( | |
| 440 | + | KeyKind::Gpg, | |
| 441 | + | GPG_FPR, | |
| 442 | + | GPG_PUB, | |
| 443 | + | "not a signature", | |
| 444 | + | GPG_PAYLOAD | |
| 445 | + | )); | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | #[test] | |
| 394 | 449 | fn gpg_verified_unknown_and_invalid() { | |
| 395 | 450 | // Registered → Verified. | |
| 396 | 451 | match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[gpg_key()]) { | |
crates/model/src/entity.rs +11 −0
| @@ -229,10 +229,21 @@ pub struct Key { | |||
| 229 | 229 | pub ordinal: i64, | |
| 230 | 230 | /// When the key was last used to authenticate, if ever. | |
| 231 | 231 | pub last_used_at: Option<Timestamp>, | |
| 232 | + | /// When ownership of the key was verified, or `None` if unverified. SSH keys | |
| 233 | + | /// verify on first successful SSH auth; GPG keys via a signed challenge. | |
| 234 | + | pub verified_at: Option<Timestamp>, | |
| 232 | 235 | /// Creation time. | |
| 233 | 236 | pub created_at: Timestamp, | |
| 234 | 237 | } | |
| 235 | 238 | ||
| 239 | + | impl Key { | |
| 240 | + | /// Whether ownership of the key has been verified. | |
| 241 | + | #[must_use] | |
| 242 | + | pub fn verified(&self) -> bool { | |
| 243 | + | self.verified_at.is_some() | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 236 | 247 | /// An API token record. The JWT itself is never stored; this row exists so a token | |
| 237 | 248 | /// can be listed and revoked (`revoked_at`), which the API checks on every request. | |
| 238 | 249 | #[derive(Debug, Clone, PartialEq, Eq)] | |
crates/ssh/src/server.rs +3 −0
| @@ -87,6 +87,9 @@ impl Handler for GitHandler { | |||
| 87 | 87 | return Ok(Auth::reject()); | |
| 88 | 88 | } | |
| 89 | 89 | let _ = self.shared.store.touch_key_used(&key.id).await; | |
| 90 | + | // A successful public-key auth proves possession of the private key, so | |
| 91 | + | // the key is now verified. | |
| 92 | + | let _ = self.shared.store.set_key_verified(&key.id).await; | |
| 90 | 93 | self.identity = Some(Identity { | |
| 91 | 94 | user_id: user.id, | |
| 92 | 95 | username: user.username, | |
crates/store/src/keys.rs +22 −4
| @@ -11,8 +11,8 @@ use sqlx::{AssertSqlSafe, Row}; | |||
| 11 | 11 | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |
| 12 | 12 | ||
| 13 | 13 | /// The columns of `keys` in a fixed order, shared by every `SELECT`. | |
| 14 | - | const KEY_COLUMNS: &str = | |
| 15 | - | "id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, created_at"; | |
| 14 | + | const KEY_COLUMNS: &str = "id, user_id, kind, name, fingerprint, public_key, ordinal, \ | |
| 15 | + | last_used_at, verified_at, created_at"; | |
| 16 | 16 | ||
| 17 | 17 | /// Fields needed to register a key. The fingerprint is computed by the caller | |
| 18 | 18 | /// (which parses and validates the key material); the store assigns the id and | |
| @@ -60,12 +60,14 @@ impl Store { | |||
| 60 | 60 | public_key: new.public_key, | |
| 61 | 61 | ordinal: next, | |
| 62 | 62 | last_used_at: None, | |
| 63 | + | verified_at: None, | |
| 63 | 64 | created_at: now_ms(), | |
| 64 | 65 | }; | |
| 65 | 66 | ||
| 66 | 67 | let sql = "INSERT INTO keys \ | |
| 67 | - | (id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, created_at) \ | |
| 68 | - | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"; | |
| 68 | + | (id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, \ | |
| 69 | + | verified_at, created_at) \ | |
| 70 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"; | |
| 69 | 71 | sqlx::query(sql) | |
| 70 | 72 | .bind(key.id.clone()) | |
| 71 | 73 | .bind(key.user_id.clone()) | |
| @@ -75,6 +77,7 @@ impl Store { | |||
| 75 | 77 | .bind(key.public_key.clone()) | |
| 76 | 78 | .bind(key.ordinal) | |
| 77 | 79 | .bind(key.last_used_at) | |
| 80 | + | .bind(key.verified_at) | |
| 78 | 81 | .bind(key.created_at) | |
| 79 | 82 | .execute(&self.pool) | |
| 80 | 83 | .await | |
| @@ -170,6 +173,20 @@ impl Store { | |||
| 170 | 173 | Ok(()) | |
| 171 | 174 | } | |
| 172 | 175 | ||
| 176 | + | /// Mark a key's ownership verified (idempotent; sets `verified_at` once). | |
| 177 | + | /// | |
| 178 | + | /// # Errors | |
| 179 | + | /// | |
| 180 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 181 | + | pub async fn set_key_verified(&self, key_id: &str) -> Result<(), StoreError> { | |
| 182 | + | sqlx::query("UPDATE keys SET verified_at = $1 WHERE id = $2 AND verified_at IS NULL") | |
| 183 | + | .bind(now_ms()) | |
| 184 | + | .bind(key_id) | |
| 185 | + | .execute(&self.pool) | |
| 186 | + | .await?; | |
| 187 | + | Ok(()) | |
| 188 | + | } | |
| 189 | + | ||
| 173 | 190 | /// Delete a key by id. Returns `true` if a row was removed. | |
| 174 | 191 | /// | |
| 175 | 192 | /// # Errors | |
| @@ -199,6 +216,7 @@ fn map_key(row: &AnyRow) -> Result<Key, sqlx::Error> { | |||
| 199 | 216 | public_key: row.try_get("public_key")?, | |
| 200 | 217 | ordinal: row.try_get("ordinal")?, | |
| 201 | 218 | last_used_at: row.try_get("last_used_at")?, | |
| 219 | + | verified_at: row.try_get("verified_at")?, | |
| 202 | 220 | created_at: row.try_get("created_at")?, | |
| 203 | 221 | }) | |
| 204 | 222 | } | |
crates/web/src/lib.rs +1 −0
| @@ -173,6 +173,7 @@ pub fn build_router(state: AppState) -> Router { | |||
| 173 | 173 | get(pages::settings_keys).post(pages::key_add), | |
| 174 | 174 | ) | |
| 175 | 175 | .route("/settings/keys/delete", post(pages::key_delete)) | |
| 176 | + | .route("/settings/keys/verify", post(pages::key_verify)) | |
| 176 | 177 | .route("/settings/avatar", post(avatar::upload)) | |
| 177 | 178 | .route("/settings/theme", get(pages::set_theme)) | |
| 178 | 179 | .route("/avatar/{username}", get(avatar::show)) | |
crates/web/src/pages.rs +100 −1
| @@ -1602,6 +1602,58 @@ pub async fn key_delete( | |||
| 1602 | 1602 | Redirect::to("/settings/keys").into_response() | |
| 1603 | 1603 | } | |
| 1604 | 1604 | ||
| 1605 | + | /// The verify-key form: the key id and a pasted armored signature. | |
| 1606 | + | #[derive(Debug, Deserialize)] | |
| 1607 | + | pub struct KeyVerifyForm { | |
| 1608 | + | /// The key id. | |
| 1609 | + | #[serde(default)] | |
| 1610 | + | id: String, | |
| 1611 | + | /// The armored detached signature over the challenge. | |
| 1612 | + | #[serde(default)] | |
| 1613 | + | signature: String, | |
| 1614 | + | /// CSRF token. | |
| 1615 | + | #[serde(rename = "_csrf")] | |
| 1616 | + | csrf: String, | |
| 1617 | + | } | |
| 1618 | + | ||
| 1619 | + | /// `POST /settings/keys/verify` — verify GPG key ownership from a signed | |
| 1620 | + | /// challenge. (SSH keys verify automatically on authentication.) | |
| 1621 | + | pub async fn key_verify( | |
| 1622 | + | State(state): State<AppState>, | |
| 1623 | + | RequireUser(user): RequireUser, | |
| 1624 | + | jar: CookieJar, | |
| 1625 | + | headers: HeaderMap, | |
| 1626 | + | Form(form): Form<KeyVerifyForm>, | |
| 1627 | + | ) -> Response { | |
| 1628 | + | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | |
| 1629 | + | return AppError::Forbidden.into_response(); | |
| 1630 | + | } | |
| 1631 | + | let Ok(keys) = state.store.keys_by_user(&user.id).await else { | |
| 1632 | + | return AppError::internal("could not load keys").into_response(); | |
| 1633 | + | }; | |
| 1634 | + | let Some(key) = keys.into_iter().find(|k| k.id == form.id) else { | |
| 1635 | + | return AppError::NotFound.into_response(); | |
| 1636 | + | }; | |
| 1637 | + | if key.kind != model::KeyKind::Gpg { | |
| 1638 | + | return AppError::BadRequest("Only GPG keys are verified this way.".to_string()) | |
| 1639 | + | .into_response(); | |
| 1640 | + | } | |
| 1641 | + | let challenge = gpg_challenge(&key); | |
| 1642 | + | let ok = git::verify_challenge( | |
| 1643 | + | model::KeyKind::Gpg, | |
| 1644 | + | &key.fingerprint, | |
| 1645 | + | &key.public_key, | |
| 1646 | + | form.signature.trim(), | |
| 1647 | + | challenge.as_bytes(), | |
| 1648 | + | ); | |
| 1649 | + | if !ok { | |
| 1650 | + | return AppError::BadRequest("That signature did not verify against this key.".to_string()) | |
| 1651 | + | .into_response(); | |
| 1652 | + | } | |
| 1653 | + | let _ = state.store.set_key_verified(&key.id).await; | |
| 1654 | + | Redirect::to("/settings/keys").into_response() | |
| 1655 | + | } | |
| 1656 | + | ||
| 1605 | 1657 | /// Render the settings page: avatar, profile, account credentials, and tokens. | |
| 1606 | 1658 | /// The settings page shell: a left tab nav and the active tab's content. Each tab | |
| 1607 | 1659 | /// is its own route, so navigation works without JavaScript. | |
| @@ -1803,13 +1855,21 @@ fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup { | |||
| 1803 | 1855 | } @else { | |
| 1804 | 1856 | ul class="entry-list key-list" { | |
| 1805 | 1857 | @for k in keys { | |
| 1806 | - | li class="entry-row" { | |
| 1858 | + | li class="entry-row key-entry" { | |
| 1807 | 1859 | div class="entry-row-body" { | |
| 1808 | 1860 | span class="entry-row-title" { | |
| 1809 | 1861 | span class="badge" { (k.kind.as_str()) } | |
| 1810 | 1862 | " " (k.name.as_deref().unwrap_or("(unnamed)")) | |
| 1863 | + | @if k.verified() { | |
| 1864 | + | " " span class="badge badge-verified" { "verified" } | |
| 1865 | + | } @else { | |
| 1866 | + | " " span class="badge badge-unverified" { "unverified" } | |
| 1867 | + | } | |
| 1811 | 1868 | } | |
| 1812 | 1869 | div class="entry-row-meta muted mono" { (k.fingerprint) } | |
| 1870 | + | @if !k.verified() { | |
| 1871 | + | (key_verify_hint(k, csrf)) | |
| 1872 | + | } | |
| 1813 | 1873 | } | |
| 1814 | 1874 | div class="entry-row-actions" { | |
| 1815 | 1875 | form method="post" action="/settings/keys/delete" { | |
| @@ -1843,6 +1903,45 @@ fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup { | |||
| 1843 | 1903 | } | |
| 1844 | 1904 | } | |
| 1845 | 1905 | ||
| 1906 | + | /// The deterministic message a GPG key owner signs to prove possession. | |
| 1907 | + | fn gpg_challenge(key: &model::Key) -> String { | |
| 1908 | + | format!( | |
| 1909 | + | "fabrica GPG key ownership verification\nfingerprint: {}\n", | |
| 1910 | + | key.fingerprint | |
| 1911 | + | ) | |
| 1912 | + | } | |
| 1913 | + | ||
| 1914 | + | /// The verify affordance for an unverified key: a note for SSH keys (which verify | |
| 1915 | + | /// on use) and a sign-a-challenge form for GPG keys. | |
| 1916 | + | fn key_verify_hint(key: &model::Key, csrf: &str) -> Markup { | |
| 1917 | + | match key.kind { | |
| 1918 | + | model::KeyKind::Ssh => html! { | |
| 1919 | + | p class="muted field-hint key-verify-note" { | |
| 1920 | + | "Verifies automatically the next time you authenticate over SSH." | |
| 1921 | + | } | |
| 1922 | + | }, | |
| 1923 | + | model::KeyKind::Gpg => html! { | |
| 1924 | + | details class="key-verify" { | |
| 1925 | + | summary { "Verify ownership" } | |
| 1926 | + | p class="muted field-hint" { | |
| 1927 | + | "Sign this exact text with your GPG key and paste the armored signature:" | |
| 1928 | + | } | |
| 1929 | + | pre class="key-challenge" { code { (gpg_challenge(key)) } } | |
| 1930 | + | p class="muted field-hint mono" { | |
| 1931 | + | "gpg --local-user " (key.fingerprint) " --armor --detach-sign challenge.txt" | |
| 1932 | + | } | |
| 1933 | + | form method="post" action="/settings/keys/verify" { | |
| 1934 | + | input type="hidden" name="_csrf" value=(csrf); | |
| 1935 | + | input type="hidden" name="id" value=(key.id); | |
| 1936 | + | textarea name="signature" rows="6" required | |
| 1937 | + | placeholder="-----BEGIN PGP SIGNATURE-----" {} | |
| 1938 | + | button class="btn" type="submit" { "Verify" } | |
| 1939 | + | } | |
| 1940 | + | } | |
| 1941 | + | }, | |
| 1942 | + | } | |
| 1943 | + | } | |
| 1944 | + | ||
| 1846 | 1945 | /// The API tokens settings section. | |
| 1847 | 1946 | fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { | |
| 1848 | 1947 | html! { | |
crates/web/src/tests.rs +7 −1
| @@ -1248,7 +1248,13 @@ async fn settings_keys_add_list_and_delete() { | |||
| 1248 | 1248 | .body(Body::empty()) | |
| 1249 | 1249 | .unwrap(); | |
| 1250 | 1250 | let res = app.clone().oneshot(req).await.unwrap(); | |
| 1251 | - | assert!(body_string(res).await.contains("laptop"), "key label shown"); | |
| 1251 | + | let listing = body_string(res).await; | |
| 1252 | + | assert!(listing.contains("laptop"), "key label shown"); | |
| 1253 | + | assert!(listing.contains("unverified"), "new keys start unverified"); | |
| 1254 | + | assert!( | |
| 1255 | + | listing.contains("authenticate over SSH"), | |
| 1256 | + | "SSH auto-verify hint shown" | |
| 1257 | + | ); | |
| 1252 | 1258 | ||
| 1253 | 1259 | // Delete it. | |
| 1254 | 1260 | let req = Request::builder() | |
migrations/postgres/0010_key_verification.sql +5 −0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | -- Key ownership verification. SSH keys verify automatically on first successful | |
| 2 | + | -- SSH authentication; GPG keys verify by signing a challenge. Existing keys | |
| 3 | + | -- predate this and are trusted. | |
| 4 | + | ALTER TABLE keys ADD COLUMN verified_at BIGINT; | |
| 5 | + | UPDATE keys SET verified_at = created_at; | |
migrations/sqlite/0010_key_verification.sql +5 −0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | -- Key ownership verification. SSH keys verify automatically on first successful | |
| 2 | + | -- SSH authentication; GPG keys verify by signing a challenge. Existing keys | |
| 3 | + | -- predate this and are trusted. | |
| 4 | + | ALTER TABLE keys ADD COLUMN verified_at BIGINT; | |
| 5 | + | UPDATE keys SET verified_at = created_at; | |