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 {
942 margin-top: 0.2rem;942 margin-top: 0.2rem;
943 font-size: 0.85em;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.key-add {964.key-add {
946 padding-top: 1rem;965 padding-top: 1rem;
947 border-top: 1px solid var(--fb-border);966 border-top: 1px solid var(--fb-border);
crates/git/src/lib.rs +1 −1
@@ -46,7 +46,7 @@ use git2::{Repository, RepositoryInitOptions};
46pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};46pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet};
47pub use crate::pubkey::{ParsedKey, parse_public_key};47pub use crate::pubkey::{ParsedKey, parse_public_key};
48pub use crate::repo::Repo;48pub use crate::repo::Repo;
49pub use crate::signature::{SignatureCache, SignatureState, SigningKey};49pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_challenge};
50pub use crate::types::{50pub use crate::types::{
51 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,51 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
52 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,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}
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]
232pub 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/// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so254/// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so
228/// only surrounding whitespace is trimmed.255/// only surrounding whitespace is trimmed.
229fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool {256fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool {
@@ -391,6 +418,34 @@ MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\
391 }418 }
392419
393 #[test]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 fn gpg_verified_unknown_and_invalid() {449 fn gpg_verified_unknown_and_invalid() {
395 // Registered → Verified.450 // Registered → Verified.
396 match verify_gpg(GPG_SIG, GPG_PAYLOAD, &[gpg_key()]) {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 pub ordinal: i64,229 pub ordinal: i64,
230 /// When the key was last used to authenticate, if ever.230 /// When the key was last used to authenticate, if ever.
231 pub last_used_at: Option<Timestamp>,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 /// Creation time.235 /// Creation time.
233 pub created_at: Timestamp,236 pub created_at: Timestamp,
234}237}
235238
239impl 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/// An API token record. The JWT itself is never stored; this row exists so a token247/// An API token record. The JWT itself is never stored; this row exists so a token
237/// can be listed and revoked (`revoked_at`), which the API checks on every request.248/// can be listed and revoked (`revoked_at`), which the API checks on every request.
238#[derive(Debug, Clone, PartialEq, Eq)]249#[derive(Debug, Clone, PartialEq, Eq)]
crates/ssh/src/server.rs +3 −0
@@ -87,6 +87,9 @@ impl Handler for GitHandler {
87 return Ok(Auth::reject());87 return Ok(Auth::reject());
88 }88 }
89 let _ = self.shared.store.touch_key_used(&key.id).await;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 self.identity = Some(Identity {93 self.identity = Some(Identity {
91 user_id: user.id,94 user_id: user.id,
92 username: user.username,95 username: user.username,
crates/store/src/keys.rs +22 −4
@@ -11,8 +11,8 @@ use sqlx::{AssertSqlSafe, Row};
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};11use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
13/// The columns of `keys` in a fixed order, shared by every `SELECT`.13/// The columns of `keys` in a fixed order, shared by every `SELECT`.
14const KEY_COLUMNS: &str =14const KEY_COLUMNS: &str = "id, user_id, kind, name, fingerprint, public_key, ordinal, \
15 "id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, created_at";15 last_used_at, verified_at, created_at";
1616
17/// Fields needed to register a key. The fingerprint is computed by the caller17/// Fields needed to register a key. The fingerprint is computed by the caller
18/// (which parses and validates the key material); the store assigns the id and18/// (which parses and validates the key material); the store assigns the id and
@@ -60,12 +60,14 @@ impl Store {
60 public_key: new.public_key,60 public_key: new.public_key,
61 ordinal: next,61 ordinal: next,
62 last_used_at: None,62 last_used_at: None,
63 verified_at: None,
63 created_at: now_ms(),64 created_at: now_ms(),
64 };65 };
6566
66 let sql = "INSERT INTO keys \67 let sql = "INSERT INTO keys \
67 (id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, created_at) \68 (id, user_id, kind, name, fingerprint, public_key, ordinal, last_used_at, \
68 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)";69 verified_at, created_at) \
70 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)";
69 sqlx::query(sql)71 sqlx::query(sql)
70 .bind(key.id.clone())72 .bind(key.id.clone())
71 .bind(key.user_id.clone())73 .bind(key.user_id.clone())
@@ -75,6 +77,7 @@ impl Store {
75 .bind(key.public_key.clone())77 .bind(key.public_key.clone())
76 .bind(key.ordinal)78 .bind(key.ordinal)
77 .bind(key.last_used_at)79 .bind(key.last_used_at)
80 .bind(key.verified_at)
78 .bind(key.created_at)81 .bind(key.created_at)
79 .execute(&self.pool)82 .execute(&self.pool)
80 .await83 .await
@@ -170,6 +173,20 @@ impl Store {
170 Ok(())173 Ok(())
171 }174 }
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
173 /// Delete a key by id. Returns `true` if a row was removed.190 /// Delete a key by id. Returns `true` if a row was removed.
174 ///191 ///
175 /// # Errors192 /// # Errors
@@ -199,6 +216,7 @@ fn map_key(row: &AnyRow) -> Result<Key, sqlx::Error> {
199 public_key: row.try_get("public_key")?,216 public_key: row.try_get("public_key")?,
200 ordinal: row.try_get("ordinal")?,217 ordinal: row.try_get("ordinal")?,
201 last_used_at: row.try_get("last_used_at")?,218 last_used_at: row.try_get("last_used_at")?,
219 verified_at: row.try_get("verified_at")?,
202 created_at: row.try_get("created_at")?,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 get(pages::settings_keys).post(pages::key_add),173 get(pages::settings_keys).post(pages::key_add),
174 )174 )
175 .route("/settings/keys/delete", post(pages::key_delete))175 .route("/settings/keys/delete", post(pages::key_delete))
176 .route("/settings/keys/verify", post(pages::key_verify))
176 .route("/settings/avatar", post(avatar::upload))177 .route("/settings/avatar", post(avatar::upload))
177 .route("/settings/theme", get(pages::set_theme))178 .route("/settings/theme", get(pages::set_theme))
178 .route("/avatar/{username}", get(avatar::show))179 .route("/avatar/{username}", get(avatar::show))
crates/web/src/pages.rs +100 −1
@@ -1602,6 +1602,58 @@ pub async fn key_delete(
1602 Redirect::to("/settings/keys").into_response()1602 Redirect::to("/settings/keys").into_response()
1603}1603}
16041604
1605/// The verify-key form: the key id and a pasted armored signature.
1606#[derive(Debug, Deserialize)]
1607pub 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.)
1621pub 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/// Render the settings page: avatar, profile, account credentials, and tokens.1657/// Render the settings page: avatar, profile, account credentials, and tokens.
1606/// The settings page shell: a left tab nav and the active tab's content. Each tab1658/// The settings page shell: a left tab nav and the active tab's content. Each tab
1607/// is its own route, so navigation works without JavaScript.1659/// is its own route, so navigation works without JavaScript.
@@ -1803,13 +1855,21 @@ fn keys_section(csrf: &str, keys: &[model::Key]) -> Markup {
1803 } @else {1855 } @else {
1804 ul class="entry-list key-list" {1856 ul class="entry-list key-list" {
1805 @for k in keys {1857 @for k in keys {
1806 li class="entry-row" {1858 li class="entry-row key-entry" {
1807 div class="entry-row-body" {1859 div class="entry-row-body" {
1808 span class="entry-row-title" {1860 span class="entry-row-title" {
1809 span class="badge" { (k.kind.as_str()) }1861 span class="badge" { (k.kind.as_str()) }
1810 " " (k.name.as_deref().unwrap_or("(unnamed)"))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 div class="entry-row-meta muted mono" { (k.fingerprint) }1869 div class="entry-row-meta muted mono" { (k.fingerprint) }
1870 @if !k.verified() {
1871 (key_verify_hint(k, csrf))
1872 }
1813 }1873 }
1814 div class="entry-row-actions" {1874 div class="entry-row-actions" {
1815 form method="post" action="/settings/keys/delete" {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}
18451905
1906/// The deterministic message a GPG key owner signs to prove possession.
1907fn 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.
1916fn 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/// The API tokens settings section.1945/// The API tokens settings section.
1847fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {1946fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup {
1848 html! {1947 html! {
crates/web/src/tests.rs +7 −1
@@ -1248,7 +1248,13 @@ async fn settings_keys_add_list_and_delete() {
1248 .body(Body::empty())1248 .body(Body::empty())
1249 .unwrap();1249 .unwrap();
1250 let res = app.clone().oneshot(req).await.unwrap();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 );
12521258
1253 // Delete it.1259 // Delete it.
1254 let req = Request::builder()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.
4ALTER TABLE keys ADD COLUMN verified_at BIGINT;
5UPDATE 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.
4ALTER TABLE keys ADD COLUMN verified_at BIGINT;
5UPDATE keys SET verified_at = created_at;