fabrica

hanna/fabrica

feat: SSH/GPG key ownership verification

4f981bc · hanna committed on 2026-07-25

Add key verification (migration 0010: keys.verified_at, back-filled for existing
keys). SSH keys verify automatically on the first successful SSH authentication
(the server sets verified_at in auth_publickey). GPG keys verify via a signed
challenge: the keys page shows a per-key challenge string to sign and a box to
paste the armored signature, checked with git::verify_challenge (reusing the
existing pgp/ssh-key verification). Keys show a verified/unverified badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
11 files changed · +229 −7UnifiedSplit
assets/base.css +19 −0
@@ -942,6 +942,25 @@ select:focus {
942942 margin-top: 0.2rem;
943943 font-size: 0.85em;
944944 }
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+}
945964 .key-add {
946965 padding-top: 1rem;
947966 border-top: 1px solid var(--fb-border);
crates/git/src/lib.rs +1 −1
@@ -46,7 +46,7 @@ use git2::{Repository, RepositoryInitOptions};
4646 pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
4747 pub use crate::pubkey::{ParsedKey, parse_public_key};
4848 pub use crate::repo::Repo;
49-pub use crate::signature::{SignatureCache, SignatureState, SigningKey};
49+pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_challenge};
5050 pub use crate::types::{
5151 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
5252 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
224224 }
225225 }
226226
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+
227254 /// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so
228255 /// only surrounding whitespace is trimmed.
229256 fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool {
@@ -391,6 +418,34 @@ MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\
391418 }
392419
393420 #[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]
394449 fn gpg_verified_unknown_and_invalid() {
395450 // Registered → Verified.
396451 match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[gpg_key()]) {
crates/model/src/entity.rs +11 −0
@@ -229,10 +229,21 @@ pub struct Key {
229229 pub ordinal: i64,
230230 /// When the key was last used to authenticate, if ever.
231231 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>,
232235 /// Creation time.
233236 pub created_at: Timestamp,
234237 }
235238
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+
236247 /// An API token record. The JWT itself is never stored; this row exists so a token
237248 /// can be listed and revoked (`revoked_at`), which the API checks on every request.
238249 #[derive(Debug, Clone, PartialEq, Eq)]
crates/ssh/src/server.rs +3 −0
@@ -87,6 +87,9 @@ impl Handler for GitHandler {
8787 return Ok(Auth::reject());
8888 }
8989 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;
9093 self.identity = Some(Identity {
9194 user_id: user.id,
9295 username: user.username,
crates/store/src/keys.rs +22 −4
@@ -11,8 +11,8 @@ use sqlx::{AssertSqlSafe, Row};
1111 use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// 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";
1616
1717 /// Fields needed to register a key. The fingerprint is computed by the caller
1818 /// (which parses and validates the key material); the store assigns the id and
@@ -60,12 +60,14 @@ impl Store {
6060 public_key: new.public_key,
6161 ordinal: next,
6262 last_used_at: None,
63+ verified_at: None,
6364 created_at: now_ms(),
6465 };
6566
6667 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)";
6971 sqlx::query(sql)
7072 .bind(key.id.clone())
7173 .bind(key.user_id.clone())
@@ -75,6 +77,7 @@ impl Store {
7577 .bind(key.public_key.clone())
7678 .bind(key.ordinal)
7779 .bind(key.last_used_at)
80+ .bind(key.verified_at)
7881 .bind(key.created_at)
7982 .execute(&self.pool)
8083 .await
@@ -170,6 +173,20 @@ impl Store {
170173 Ok(())
171174 }
172175
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+
173190 /// Delete a key by id. Returns `true` if a row was removed.
174191 ///
175192 /// # Errors
@@ -199,6 +216,7 @@ fn map_key(row: &AnyRow) -> Result<Key, sqlx::Error> {
199216 public_key: row.try_get("public_key")?,
200217 ordinal: row.try_get("ordinal")?,
201218 last_used_at: row.try_get("last_used_at")?,
219+ verified_at: row.try_get("verified_at")?,
202220 created_at: row.try_get("created_at")?,
203221 })
204222 }
crates/web/src/lib.rs +1 −0
@@ -173,6 +173,7 @@ pub fn build_router(state: AppState) -> Router {
173173 get(pages::settings_keys).post(pages::key_add),
174174 )
175175 .route("/settings/keys/delete", post(pages::key_delete))
176+ .route("/settings/keys/verify", post(pages::key_verify))
176177 .route("/settings/avatar", post(avatar::upload))
177178 .route("/settings/theme", get(pages::set_theme))
178179 .route("/avatar/{username}", get(avatar::show))
crates/web/src/pages.rs +100 −1
@@ -1602,6 +1602,58 @@ pub async fn key_delete(
16021602 Redirect::to("/settings/keys").into_response()
16031603 }
16041604
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+
16051657 /// Render the settings page: avatar, profile, account credentials, and tokens.
16061658 /// The settings page shell: a left tab nav and the active tab's content. Each tab
16071659 /// is its own route, so navigation works without JavaScript.
@@ -1803,13 +1855,21 @@ fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup {
18031855 } @else {
18041856 ul class="entry-list key-list" {
18051857 @for k in keys {
1806- li class="entry-row" {
1858+ li class="entry-row key-entry" {
18071859 div class="entry-row-body" {
18081860 span class="entry-row-title" {
18091861 span class="badge" { (k.kind.as_str()) }
18101862 " " (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+ }
18111868 }
18121869 div class="entry-row-meta muted mono" { (k.fingerprint) }
1870+ @if !k.verified() {
1871+ (key_verify_hint(k, csrf))
1872+ }
18131873 }
18141874 div class="entry-row-actions" {
18151875 form method="post" action="/settings/keys/delete" {
@@ -1843,6 +1903,45 @@ fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup {
18431903 }
18441904 }
18451905
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+
18461945 /// The API tokens settings section.
18471946 fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {
18481947 html! {
crates/web/src/tests.rs +7 −1
@@ -1248,7 +1248,13 @@ async fn settings_keys_add_list_and_delete() {
12481248 .body(Body::empty())
12491249 .unwrap();
12501250 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+ );
12521258
12531259 // Delete it.
12541260 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;