Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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}; | |||
| 46 | pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; | 46 | pub use crate::attributes::{AttrValue, Attributes, AttributesCache, AttributesSet}; |
| 47 | pub use crate::pubkey::{ParsedKey, parse_public_key}; | 47 | pub use crate::pubkey::{ParsedKey, parse_public_key}; |
| 48 | pub use crate::repo::Repo; | 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 | pub use crate::types::{ | 50 | pub 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 | } |
| 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 | /// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so | 254 | /// Compare two SSH fingerprints. The `SHA256:<base64>` body is case-sensitive, so |
| 228 | /// only surrounding whitespace is trimmed. | 255 | /// only surrounding whitespace is trimmed. |
| 229 | fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool { | 256 | fn ssh_fingerprint_matches(stored: &str, computed: &str) -> bool { |
| @@ -391,6 +418,34 @@ MiNTlcoHWPclgdKH8OdrpfUmIIjFXAE=\n\ | |||
| 391 | } | 418 | } |
| 392 | 419 | ||
| 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 | } |
| 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 | /// An API token record. The JWT itself is never stored; this row exists so a token | 247 | /// 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}; | |||
| 11 | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | 11 | use crate::{Store, StoreError, map_conflict, new_id, now_ms}; |
| 12 | 12 | ||
| 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`. |
| 14 | const KEY_COLUMNS: &str = | 14 | const 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"; |
| 16 | 16 | ||
| 17 | /// Fields needed to register a key. The fingerprint is computed by the caller | 17 | /// 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 and | 18 | /// (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 | }; |
| 65 | 66 | ||
| 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 | .await | 83 | .await |
| @@ -170,6 +173,20 @@ impl Store { | |||
| 170 | Ok(()) | 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 | /// 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 | /// # Errors | 192 | /// # 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 | } |
| 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 | /// 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 tab | 1658 | /// 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 | } |
| 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 | /// The API tokens settings section. | 1945 | /// The API tokens settings section. |
| 1847 | fn tokens_section(csrf: &str, tokens: &[model::ApiToken], new_token: Option<&str>) -> Markup { | 1946 | fn 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 | ); | ||
| 1252 | 1258 | ||
| 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. | ||
| 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; | ||