kitgit

tirbofish/kitgit · commit

6551e0b6f2b4b94ccb6716fbfd928fd5f9af16b6

feat: ssh key verification, enhanced admin panel and opengraph implementations.

Verified

Thribhu K <4tkbytes@pm.me> · 2026-07-28 14:00

view full diff

diff --git a/Cargo.lock b/Cargo.lock
index bf2c3c7..75f4044 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10,12 +10,33 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
 
 [[package]]
 name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common 0.1.6",
+ "generic-array 0.14.9",
+]
+
+[[package]]
+name = "aead"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
 dependencies = [
  "crypto-common 0.2.2",
- "inout",
+ "inout 0.2.2",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher 0.4.4",
+ "cpufeatures 0.2.17",
 ]
 
 [[package]]
@@ -24,7 +45,7 @@ version = "0.9.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58"
 dependencies = [
- "cipher",
+ "cipher 0.5.2",
  "cpubits",
  "cpufeatures 0.3.0",
  "zeroize",
@@ -32,20 +53,43 @@ dependencies = [
 
 [[package]]
 name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead 0.5.2",
+ "aes 0.8.4",
+ "cipher 0.4.4",
+ "ctr 0.9.2",
+ "ghash 0.5.1",
+ "subtle",
+]
+
+[[package]]
+name = "aes-gcm"
 version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028"
 dependencies = [
- "aead",
- "aes",
- "cipher",
- "ctr",
- "ghash",
+ "aead 0.6.1",
+ "aes 0.9.2",
+ "cipher 0.5.2",
+ "ctr 0.10.1",
+ "ghash 0.6.0",
  "subtle",
  "zeroize",
 ]
 
 [[package]]
+name = "aes-kw"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c"
+dependencies = [
+ "aes 0.8.4",
+]
+
+[[package]]
 name = "aho-corasick"
 version = "1.1.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -127,14 +171,27 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
 
 [[package]]
 name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2 0.10.6",
+ "cpufeatures 0.2.17",
+ "password-hash 0.5.0",
+ "zeroize",
+]
+
+[[package]]
+name = "argon2"
 version = "0.6.0-rc.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7af50940b73bf4e16c15c448a2b121c63f2d68e3e54b6a8731673cb4aa0cdff5"
 dependencies = [
  "base64ct",
- "blake2",
+ "blake2 0.11.0-rc.6",
  "cpufeatures 0.3.0",
- "password-hash",
+ "password-hash 0.6.1",
 ]
 
 [[package]]
@@ -176,7 +233,7 @@ dependencies = [
  "memchr",
  "serde",
  "serde_derive",
- "winnow",
+ "winnow 0.7.15",
 ]
 
 [[package]]
@@ -381,7 +438,7 @@ version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
 dependencies = [
- "blowfish",
+ "blowfish 0.10.0",
  "pbkdf2",
  "sha2 0.11.0",
 ]
@@ -411,6 +468,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
 
 [[package]]
+name = "bitfield"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac"
+
+[[package]]
 name = "bitflags"
 version = "2.13.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -421,6 +484,15 @@ dependencies = [
 
 [[package]]
 name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "blake2"
 version = "0.11.0-rc.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690"
@@ -449,6 +521,15 @@ dependencies = [
 
 [[package]]
 name = "block-padding"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
+dependencies = [
+ "generic-array 0.14.9",
+]
+
+[[package]]
+name = "block-padding"
 version = "0.4.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b"
@@ -458,12 +539,22 @@ dependencies = [
 
 [[package]]
 name = "blowfish"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
+dependencies = [
+ "byteorder",
+ "cipher 0.4.4",
+]
+
+[[package]]
+name = "blowfish"
 version = "0.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
 dependencies = [
  "byteorder",
- "cipher",
+ "cipher 0.5.2",
 ]
 
 [[package]]
@@ -476,6 +567,25 @@ dependencies = [
 ]
 
 [[package]]
+name = "bstr"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530"
+dependencies = [
+ "memchr",
+ "serde_core",
+]
+
+[[package]]
+name = "buffer-redux"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "431a9cc8d7efa49bc326729264537f5e60affce816c66edf434350778c9f4f54"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "bumpalo"
 version = "3.20.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -494,12 +604,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
 
 [[package]]
+name = "camellia"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30"
+dependencies = [
+ "byteorder",
+ "cipher 0.4.4",
+]
+
+[[package]]
+name = "cast5"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26b07d673db1ccf000e90f54b819db9e75a8348d6eb056e9b8ab53231b7a9911"
+dependencies = [
+ "cipher 0.4.4",
+]
+
+[[package]]
 name = "cbc"
 version = "0.2.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896"
 dependencies = [
- "cipher",
+ "cipher 0.5.2",
 ]
 
 [[package]]
@@ -515,6 +644,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "cfb-mode"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330"
+dependencies = [
+ "cipher 0.4.4",
+]
+
+[[package]]
 name = "cfg-if"
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -533,7 +671,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
 dependencies = [
  "cfg-if",
- "cipher",
+ "cipher 0.5.2",
  "cpufeatures 0.3.0",
  "rand_core 0.10.1",
  "zeroize",
@@ -555,13 +693,23 @@ dependencies = [
 
 [[package]]
 name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common 0.1.6",
+ "inout 0.1.4",
+]
+
+[[package]]
+name = "cipher"
 version = "0.5.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
 dependencies = [
  "block-buffer 0.12.1",
  "crypto-common 0.2.2",
- "inout",
+ "inout 0.2.2",
  "zeroize",
 ]
 
@@ -606,6 +754,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
 
 [[package]]
+name = "cmac"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa"
+dependencies = [
+ "cipher 0.4.4",
+ "dbl",
+ "digest 0.10.7",
+]
+
+[[package]]
 name = "cmov"
 version = "0.5.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -721,6 +880,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
 
 [[package]]
+name = "crc24"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd121741cf3eb82c08dd3023eb55bf2665e5f60ec20f89760cf836ae4562e6a0"
+
+[[package]]
 name = "crc32fast"
 version = "1.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -780,6 +945,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
 dependencies = [
  "generic-array 0.14.9",
+ "rand_core 0.6.4",
  "typenum",
 ]
 
@@ -806,11 +972,20 @@ dependencies = [
 
 [[package]]
 name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher 0.4.4",
+]
+
+[[package]]
+name = "ctr"
 version = "0.10.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21"
 dependencies = [
- "cipher",
+ "cipher 0.5.2",
 ]
 
 [[package]]
@@ -869,12 +1044,36 @@ dependencies = [
 
 [[package]]
 name = "darling"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core 0.20.11",
+ "darling_macro 0.20.11",
+]
+
+[[package]]
+name = "darling"
 version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
 dependencies = [
- "darling_core",
- "darling_macro",
+ "darling_core 0.23.0",
+ "darling_macro 0.23.0",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
 ]
 
 [[package]]
@@ -892,11 +1091,22 @@ dependencies = [
 
 [[package]]
 name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core 0.20.11",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
 version = "0.23.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
 dependencies = [
- "darling_core",
+ "darling_core 0.23.0",
  "quote",
  "syn 2.0.119",
 ]
@@ -922,6 +1132,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
 
 [[package]]
+name = "dbl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd2735a791158376708f9347fe8faba9667589d82427ef3aed6794a8981de3d9"
+dependencies = [
+ "generic-array 0.14.9",
+]
+
+[[package]]
 name = "delegate"
 version = "0.13.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -964,12 +1183,73 @@ dependencies = [
 ]
 
 [[package]]
+name = "derive_builder"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
+dependencies = [
+ "derive_builder_macro",
+]
+
+[[package]]
+name = "derive_builder_core"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
+dependencies = [
+ "darling 0.20.11",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "derive_builder_macro"
+version = "0.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
+dependencies = [
+ "derive_builder_core",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "derive_more"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05"
+dependencies = [
+ "derive_more-impl",
+]
+
+[[package]]
+name = "derive_more-impl"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "unicode-xid",
+]
+
+[[package]]
+name = "des"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffdd80ce8ce993de27e9f063a444a4d53ce8e8db4c1f00cc03af5ad5a9867a1e"
+dependencies = [
+ "cipher 0.4.4",
+]
+
+[[package]]
 name = "des"
 version = "0.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a"
 dependencies = [
- "cipher",
+ "cipher 0.5.2",
 ]
 
 [[package]]
@@ -1023,12 +1303,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
 
 [[package]]
+name = "dsa"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48bc224a9084ad760195584ce5abb3c2c34a225fa312a128ad245a6b412b7689"
+dependencies = [
+ "digest 0.10.7",
+ "num-bigint-dig",
+ "num-traits",
+ "pkcs8 0.10.2",
+ "rfc6979 0.4.0",
+ "sha2 0.10.9",
+ "signature 2.2.0",
+ "zeroize",
+]
+
+[[package]]
 name = "dyn-clone"
 version = "1.0.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
 
 [[package]]
+name = "eax"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9954fabd903b82b9d7a68f65f97dc96dd9ad368e40ccc907a7c19d53e6bfac28"
+dependencies = [
+ "aead 0.5.2",
+ "cipher 0.4.4",
+ "cmac",
+ "ctr 0.9.2",
+ "subtle",
+]
+
+[[package]]
 name = "ecdsa"
 version = "0.16.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1108,6 +1417,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "ed448-goldilocks"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87b5fa9e9e3dd5fe1369f380acd3dcdfa766dbd0a1cd5b048fb40e38a6a78e79"
+dependencies = [
+ "fiat-crypto 0.1.20",
+ "hex",
+ "subtle",
+]
+
+[[package]]
 name = "either"
 version = "1.17.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1250,6 +1570,12 @@ dependencies = [
 
 [[package]]
 name = "fiat-crypto"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e825f6987101665dea6ec934c09ec6d721de7bc1bf92248e1d5810c8cd636b77"
+
+[[package]]
+name = "fiat-crypto"
 version = "0.2.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
@@ -1458,11 +1784,21 @@ dependencies = [
 
 [[package]]
 name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval 0.6.2",
+]
+
+[[package]]
+name = "ghash"
 version = "0.6.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5"
 dependencies = [
- "polyval",
+ "polyval 0.7.3",
 ]
 
 [[package]]
@@ -1857,6 +2193,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "idea"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "075557004419d7f2031b8bb7f44bb43e55a83ca7b63076a8fb8fe75753836477"
+dependencies = [
+ "cipher 0.4.4",
+]
+
+[[package]]
 name = "ident_case"
 version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1908,11 +2253,20 @@ dependencies = [
 
 [[package]]
 name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array 0.14.9",
+]
+
+[[package]]
+name = "inout"
 version = "0.2.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
 dependencies = [
- "block-padding",
+ "block-padding 0.4.2",
  "hybrid-array",
 ]
 
@@ -1941,6 +2295,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
 [[package]]
+name = "iter-read"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071ed4cc1afd86650602c7b11aa2e1ce30762a1c27193201cb5cee9c6ebb1294"
+
+[[package]]
 name = "itertools"
 version = "0.10.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1977,6 +2337,29 @@ dependencies = [
 ]
 
 [[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa 0.16.9",
+ "elliptic-curve 0.13.8",
+ "once_cell",
+ "sha2 0.10.9",
+ "signature 2.2.0",
+]
+
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
 name = "keccak"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2012,6 +2395,7 @@ dependencies = [
  "clap",
  "dashmap",
  "data-encoding",
+ "flate2",
  "futures",
  "git2",
  "hex",
@@ -2019,6 +2403,7 @@ dependencies = [
  "http-body-util",
  "mime_guess",
  "openidconnect",
+ "pgp",
  "pulldown-cmark",
  "qrcode",
  "rand 0.10.2",
@@ -2029,6 +2414,7 @@ dependencies = [
  "sha1 0.10.7",
  "sha2 0.10.9",
  "sqlx",
+ "ssh-key",
  "syntect",
  "thiserror 2.0.19",
  "time",
@@ -2214,6 +2600,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
 name = "miniz_oxide"
 version = "0.8.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2289,6 +2681,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
 name = "nu-ansi-term"
 version = "0.50.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2319,6 +2721,7 @@ dependencies = [
  "num-iter",
  "num-traits",
  "rand 0.8.7",
+ "serde",
  "smallvec",
  "zeroize",
 ]
@@ -2359,6 +2762,28 @@ dependencies = [
 ]
 
 [[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
 name = "oauth2"
 version = "5.0.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2379,6 +2804,18 @@ dependencies = [
 ]
 
 [[package]]
+name = "ocb3"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c196e0276c471c843dd5777e7543a36a298a4be942a2a688d8111cd43390dedb"
+dependencies = [
+ "aead 0.5.2",
+ "cipher 0.4.4",
+ "ctr 0.9.2",
+ "subtle",
+]
+
+[[package]]
 name = "once_cell"
 version = "1.21.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2391,6 +2828,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
 
 [[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
 name = "openidconnect"
 version = "4.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2501,6 +2944,20 @@ dependencies = [
 
 [[package]]
 name = "p521"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
+dependencies = [
+ "base16ct 0.2.0",
+ "ecdsa 0.16.9",
+ "elliptic-curve 0.13.8",
+ "primeorder 0.13.6",
+ "rand_core 0.6.4",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p521"
 version = "0.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449"
@@ -2564,6 +3021,17 @@ dependencies = [
 
 [[package]]
 name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "password-hash"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
@@ -2606,6 +3074,73 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
 [[package]]
+name = "pgp"
+version = "0.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1877a97fd422433220ad272eb008ec55691944b1200e9eb204e3cb2cb69d34e9"
+dependencies = [
+ "aes 0.8.4",
+ "aes-gcm 0.10.3",
+ "aes-kw",
+ "argon2 0.5.3",
+ "base64 0.22.1",
+ "bitfield",
+ "block-padding 0.3.3",
+ "blowfish 0.9.1",
+ "bstr",
+ "buffer-redux",
+ "byteorder",
+ "camellia",
+ "cast5",
+ "cfb-mode",
+ "chrono",
+ "cipher 0.4.4",
+ "const-oid 0.9.6",
+ "crc24",
+ "curve25519-dalek 4.1.3",
+ "derive_builder",
+ "derive_more",
+ "des 0.8.1",
+ "digest 0.10.7",
+ "dsa",
+ "eax",
+ "ecdsa 0.16.9",
+ "ed25519-dalek 2.2.0",
+ "elliptic-curve 0.13.8",
+ "flate2",
+ "generic-array 0.14.9",
+ "hex",
+ "hkdf 0.12.4",
+ "idea",
+ "iter-read",
+ "k256",
+ "log",
+ "md-5",
+ "nom",
+ "num-bigint-dig",
+ "num-traits",
+ "num_enum",
+ "ocb3",
+ "p256 0.13.2",
+ "p384 0.13.1",
+ "p521 0.13.3",
+ "rand 0.8.7",
+ "ripemd",
+ "rsa 0.9.10",
+ "sha1 0.10.7",
+ "sha1-checked",
+ "sha2 0.10.9",
+ "sha3 0.10.9",
+ "signature 2.2.0",
+ "smallvec",
+ "thiserror 1.0.69",
+ "twofish",
+ "x25519-dalek",
+ "x448",
+ "zeroize",
+]
+
+[[package]]
 name = "phc"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2648,8 +3183,8 @@ version = "0.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "63d440a804ec8d6fafbb6b84471e013286658d373248927692ab3366686220ca"
 dependencies = [
- "aes",
- "aes-gcm",
+ "aes 0.9.2",
+ "aes-gcm 0.11.0",
  "cbc",
  "der 0.8.1",
  "pbkdf2",
@@ -2700,19 +3235,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
 dependencies = [
  "cpufeatures 0.3.0",
- "universal-hash",
+ "universal-hash 0.6.1",
  "zeroize",
 ]
 
 [[package]]
 name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash 0.5.1",
+]
+
+[[package]]
+name = "polyval"
 version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
 dependencies = [
  "cpubits",
  "cpufeatures 0.3.0",
- "universal-hash",
+ "universal-hash 0.6.1",
 ]
 
 [[package]]
@@ -2776,6 +3323,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.13+spec-1.1.0",
+]
+
+[[package]]
 name = "proc-macro2"
 version = "1.0.107"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2929,6 +3485,12 @@ dependencies = [
 
 [[package]]
 name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+
+[[package]]
+name = "rand_core"
 version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
@@ -3081,6 +3643,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "ripemd"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd124222d17ad93a644ed9d011a40f4fb64aa54275c08cc216524a9ea82fb09f"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
 name = "rsa"
 version = "0.9.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3125,15 +3696,15 @@ version = "0.62.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b8b67b5a0d8068c89dcbe9d95df986af7a851d1f3c604525274c37468e60464f"
 dependencies = [
- "aes",
+ "aes 0.9.2",
  "bitflags",
- "block-padding",
+ "block-padding 0.4.2",
  "byteorder",
  "bytes",
  "cbc",
- "cipher",
+ "cipher 0.5.2",
  "crypto-bigint 0.7.5",
- "ctr",
+ "ctr 0.10.1",
  "curve25519-dalek 5.0.0",
  "data-encoding",
  "delegate",
@@ -3147,12 +3718,12 @@ dependencies = [
  "futures",
  "generic-array 1.4.4",
  "getrandom 0.4.3",
- "ghash",
+ "ghash 0.6.0",
  "hex-literal",
  "hmac 0.13.0",
- "inout",
+ "inout 0.2.2",
  "internal-russh-num-bigint",
- "keccak",
+ "keccak 0.2.0",
  "log",
  "md5",
  "ml-kem",
@@ -3160,13 +3731,13 @@ dependencies = [
  "num-bigint",
  "p256 0.14.0",
  "p384 0.14.0",
- "p521",
+ "p521 0.14.0",
  "pageant",
  "pbkdf2",
  "pkcs1 0.8.0-rc.4",
  "pkcs5",
  "pkcs8 0.11.0",
- "polyval",
+ "polyval 0.7.3",
  "rand 0.10.2",
  "rand_core 0.10.1",
  "ring",
@@ -3187,7 +3758,7 @@ dependencies = [
  "thiserror 2.0.19",
  "tokio",
  "typenum",
- "universal-hash",
+ "universal-hash 0.6.1",
  "zeroize",
 ]
 
@@ -3284,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
 dependencies = [
  "cfg-if",
- "cipher",
+ "cipher 0.5.2",
 ]
 
 [[package]]
@@ -3492,7 +4063,7 @@ version = "3.21.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
 dependencies = [
- "darling",
+ "darling 0.23.0",
  "proc-macro2",
  "quote",
  "syn 2.0.119",
@@ -3531,6 +4102,17 @@ dependencies = [
 ]
 
 [[package]]
+name = "sha1-checked"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423"
+dependencies = [
+ "digest 0.10.7",
+ "sha1 0.10.7",
+ "zeroize",
+]
+
+[[package]]
 name = "sha2"
 version = "0.10.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3554,12 +4136,22 @@ dependencies = [
 
 [[package]]
 name = "sha3"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+dependencies = [
+ "digest 0.10.7",
+ "keccak 0.1.6",
+]
+
+[[package]]
+name = "sha3"
 version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
 dependencies = [
  "digest 0.11.3",
- "keccak",
+ "keccak 0.2.0",
 ]
 
 [[package]]
@@ -3569,7 +4161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
 dependencies = [
  "digest 0.11.3",
- "keccak",
+ "keccak 0.2.0",
  "sponge-cursor",
 ]
 
@@ -3888,13 +4480,13 @@ version = "0.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d801accda99469cde6d73da741422610fdf6508a72d9a69d1b55cb241c720597"
 dependencies = [
- "aead",
- "aes",
- "aes-gcm",
+ "aead 0.6.1",
+ "aes 0.9.2",
+ "aes-gcm 0.11.0",
  "chacha20",
- "cipher",
+ "cipher 0.5.2",
  "ctutils",
- "des",
+ "des 0.9.0",
  "poly1305",
  "ssh-encoding",
  "zeroize",
@@ -3921,7 +4513,7 @@ version = "0.7.0-rc.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f9a32fae177b74a22aa9c5b01bf7e68b33545be32d9e381e248058d2adc15ce3"
 dependencies = [
- "argon2",
+ "argon2 0.6.0-rc.8",
  "bcrypt-pbkdf",
  "ctutils",
  "ed25519-dalek 3.0.0",
@@ -3929,7 +4521,7 @@ dependencies = [
  "hmac 0.13.0",
  "p256 0.14.0",
  "p384 0.14.0",
- "p521",
+ "p521 0.14.0",
  "rand_core 0.10.1",
  "rsa 0.10.0-rc.18",
  "sec1 0.8.1",
@@ -4204,8 +4796,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
 dependencies = [
  "serde",
  "serde_spanned",
- "toml_datetime",
- "toml_edit",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
 ]
 
 [[package]]
@@ -4218,6 +4810,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
 name = "toml_edit"
 version = "0.22.27"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4226,9 +4827,30 @@ dependencies = [
  "indexmap 2.14.0",
  "serde",
  "serde_spanned",
- "toml_datetime",
+ "toml_datetime 0.6.11",
  "toml_write",
- "winnow",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.13+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
+dependencies = [
+ "indexmap 2.14.0",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.4",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow 1.0.4",
 ]
 
 [[package]]
@@ -4363,6 +4985,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
 
 [[package]]
+name = "twofish"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a78e83a30223c757c3947cd144a31014ff04298d8719ae10d03c31c0448c8013"
+dependencies = [
+ "cipher 0.4.4",
+]
+
+[[package]]
 name = "typenum"
 version = "1.20.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4402,6 +5033,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
 
 [[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common 0.1.6",
+ "subtle",
+]
+
+[[package]]
 name = "universal-hash"
 version = "0.6.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4889,6 +5536,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
 name = "wnaf"
 version = "0.14.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4906,6 +5562,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
 
 [[package]]
+name = "x25519-dalek"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+dependencies = [
+ "curve25519-dalek 4.1.3",
+ "rand_core 0.6.4",
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "x448"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4cd07d4fae29e07089dbcacf7077cd52dce7760125ca9a4dd5a35ca603ffebb"
+dependencies = [
+ "ed448-goldilocks",
+ "hex",
+ "rand_core 0.5.1",
+]
+
+[[package]]
 name = "yoke"
 version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4974,6 +5653,20 @@ name = "zeroize"
 version = "1.9.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+dependencies = [
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
 
 [[package]]
 name = "zerotrie"
diff --git a/Cargo.toml b/Cargo.toml
index e80f789..534ce36 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -17,6 +17,7 @@ bytes = "1"
 chrono = { version = "0.4", features = ["serde"] }
 clap = { version = "4", features = ["derive", "env"] }
 data-encoding = "2"
+flate2 = "1"
 futures = "0.3"
 git2 = "0.20"
 hex = "0.4"
@@ -29,8 +30,10 @@ openidconnect = "4"
 pulldown-cmark = { version = "0.13", default-features = false, features = ["html", "simd"] }
 rand = "0.10"
 reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "cookies", "gzip"] }
+pgp = "0.14"
 russh = { version = "0.62", default-features = false, features = ["flate2", "rsa", "ring"] }
 serde = { version = "1", features = ["derive"] }
+ssh-key = { version = "=0.7.0-rc.11", default-features = false, features = ["std", "ed25519", "rsa", "p256", "p384", "p521"] }
 serde_json = "1"
 sha2 = "0.10"
 sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "uuid", "migrate"] }
diff --git a/migrations/007_signup_settings.sql b/migrations/007_signup_settings.sql
new file mode 100644
index 0000000..6ede753
--- /dev/null
+++ b/migrations/007_signup_settings.sql
@@ -0,0 +1,4 @@
+INSERT INTO site_settings (key, value) VALUES
+    ('signups_enabled', 'true'),
+    ('signup_disabled_message', 'Unfortunately, the admin has disabled new signups. Sorry!')
+ON CONFLICT (key) DO NOTHING;
diff --git a/migrations/008_ssh_key_usage.sql b/migrations/008_ssh_key_usage.sql
new file mode 100644
index 0000000..d080925
--- /dev/null
+++ b/migrations/008_ssh_key_usage.sql
@@ -0,0 +1,6 @@
+-- SSH keys may be used for git authentication, commit signing, or both.
+ALTER TABLE ssh_keys
+    ADD COLUMN IF NOT EXISTS key_usage TEXT NOT NULL DEFAULT 'authentication'
+        CHECK (key_usage IN ('authentication', 'signing', 'both'));
+
+CREATE INDEX IF NOT EXISTS ssh_keys_key_usage_idx ON ssh_keys (key_usage);
diff --git a/migrations/009_admin_features.sql b/migrations/009_admin_features.sql
new file mode 100644
index 0000000..541f43d
--- /dev/null
+++ b/migrations/009_admin_features.sql
@@ -0,0 +1,21 @@
+-- Suspended users cannot log in or use Git (HTTP/SSH).
+ALTER TABLE users ADD COLUMN IF NOT EXISTS is_suspended BOOLEAN NOT NULL DEFAULT FALSE;
+CREATE INDEX IF NOT EXISTS users_is_suspended_idx ON users(is_suspended) WHERE is_suspended = TRUE;
+
+-- Site-wide announcement / maintenance banner (separate from MOTD).
+INSERT INTO site_settings (key, value) VALUES ('announcement', '')
+ON CONFLICT (key) DO NOTHING;
+
+-- Single-use invite codes for signup when public signups are disabled.
+CREATE TABLE IF NOT EXISTS invite_codes (
+    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+    code TEXT NOT NULL UNIQUE,
+    created_by UUID REFERENCES users(id) ON DELETE SET NULL,
+    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+    used_by UUID REFERENCES users(id) ON DELETE SET NULL,
+    used_at TIMESTAMPTZ,
+    revoked_at TIMESTAMPTZ
+);
+CREATE INDEX IF NOT EXISTS invite_codes_unused_idx
+    ON invite_codes(created_at DESC)
+    WHERE used_at IS NULL AND revoked_at IS NULL;
diff --git a/src/auth.rs b/src/auth.rs
index d60d2c7..3aa97ed 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -255,6 +255,10 @@ pub async fn finish_login(auth: &AuthState, code: &str, state: &str) -> Result<(
     )
     .await?;
 
+    if user.is_suspended {
+        anyhow::bail!("account suspended");
+    }
+
     // First user to ever log in becomes site admin.
     if queries::site_admin_count(&auth.pool).await? == 0 {
         user = queries::set_site_admin(&auth.pool, user.id, true).await?;
@@ -826,6 +830,9 @@ async fn user_from_authentik_me(auth: &AuthState, me: &AuthentikMe) -> Result<Us
 }
 
 async fn create_kitgit_session(auth: &AuthState, user: &User) -> Result<(User, String)> {
+    if user.is_suspended {
+        anyhow::bail!("account suspended");
+    }
     let token = new_session_token();
     queries::create_session(&auth.pool, user.id, &hash_token(&token), 14).await?;
     Ok((user.clone(), token))
@@ -887,6 +894,9 @@ pub async fn login_with_password(
     password: &str,
 ) -> Result<LoginOutcome> {
     let user = verify_password(auth, username, password).await?;
+    if user.is_suspended {
+        anyhow::bail!("account suspended");
+    }
     if queries::mfa_is_enabled(&auth.pool, user.id).await? {
         let pending = new_session_token();
         queries::delete_mfa_pending_for_user(&auth.pool, user.id).await?;
@@ -940,6 +950,9 @@ pub async fn complete_mfa_login(
     let user = queries::get_user_by_id(&auth.pool, user_id)
         .await?
         .ok_or_else(|| anyhow!("user not found"))?;
+    if user.is_suspended {
+        anyhow::bail!("account suspended");
+    }
     create_kitgit_session(auth, &user).await
 }
 
diff --git a/src/db/models.rs b/src/db/models.rs
index 8f606e6..874e00c 100644
--- a/src/db/models.rs
+++ b/src/db/models.rs
@@ -14,6 +14,7 @@ pub struct User {
     pub avatar_path: Option<String>,
     pub avatar_url: Option<String>,
     pub is_site_admin: bool,
+    pub is_suspended: bool,
     pub show_email: bool,
     pub vigilant_mode: bool,
     pub created_at: DateTime<Utc>,
@@ -115,9 +116,29 @@ pub struct SshKey {
     pub name: String,
     pub public_key: String,
     pub fingerprint: String,
+    /// `authentication`, `signing`, or `both`.
+    pub key_usage: String,
     pub created_at: DateTime<Utc>,
 }
 
+impl SshKey {
+    pub fn allows_authentication(&self) -> bool {
+        self.key_usage == "authentication" || self.key_usage == "both"
+    }
+
+    pub fn allows_signing(&self) -> bool {
+        self.key_usage == "signing" || self.key_usage == "both"
+    }
+
+    pub fn usage_label(&self) -> &'static str {
+        match self.key_usage.as_str() {
+            "signing" => "Signing",
+            "both" => "Authentication & Signing",
+            _ => "Authentication",
+        }
+    }
+}
+
 #[derive(Debug, Clone, FromRow)]
 pub struct Collaborator {
     pub repo_id: Uuid,
diff --git a/src/db/queries.rs b/src/db/queries.rs
index 89777d0..a236a11 100644
--- a/src/db/queries.rs
+++ b/src/db/queries.rs
@@ -127,6 +127,450 @@ pub async fn set_setting(pool: &PgPool, key: &str, value: &str) -> Result<()> {
     Ok(())
 }
 
+pub const DEFAULT_SIGNUP_DISABLED_MESSAGE: &str =
+    "Unfortunately, the admin has disabled new signups. Sorry!";
+
+pub async fn signups_enabled(pool: &PgPool) -> bool {
+    let v = get_setting(pool, "signups_enabled")
+        .await
+        .unwrap_or_default();
+    if v.is_empty() {
+        return true;
+    }
+    matches!(v.as_str(), "true" | "1" | "yes" | "on")
+}
+
+pub async fn signup_disabled_message(pool: &PgPool) -> String {
+    let v = get_setting(pool, "signup_disabled_message")
+        .await
+        .unwrap_or_default();
+    let trimmed = v.trim();
+    if trimmed.is_empty() {
+        DEFAULT_SIGNUP_DISABLED_MESSAGE.to_string()
+    } else {
+        trimmed.to_string()
+    }
+}
+
+pub async fn site_announcement(pool: &PgPool) -> String {
+    get_setting(pool, "announcement")
+        .await
+        .unwrap_or_default()
+        .trim()
+        .to_string()
+}
+
+pub async fn set_user_suspended(pool: &PgPool, id: Uuid, suspended: bool) -> Result<User> {
+    let user = sqlx::query_as::<_, User>(
+        "UPDATE users SET is_suspended = $2, updated_at = now() WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .bind(suspended)
+    .fetch_one(pool)
+    .await?;
+    if suspended {
+        sqlx::query("DELETE FROM sessions WHERE user_id = $1")
+            .bind(id)
+            .execute(pool)
+            .await?;
+        let _ = delete_mfa_pending_for_user(pool, id).await;
+    }
+    Ok(user)
+}
+
+pub async fn search_users(
+    pool: &PgPool,
+    query: Option<&str>,
+    limit: i64,
+    offset: i64,
+) -> Result<(Vec<User>, i64)> {
+    let q = query.map(|s| s.trim()).filter(|s| !s.is_empty());
+    let (users, total): (Vec<User>, i64) = if let Some(term) = q {
+        let like = format!("%{}%", term.replace('%', "\\%").replace('_', "\\_"));
+        let users = sqlx::query_as::<_, User>(
+            r#"
+            SELECT * FROM users
+            WHERE username ILIKE $1 ESCAPE '\'
+               OR email ILIKE $1 ESCAPE '\'
+               OR display_name ILIKE $1 ESCAPE '\'
+            ORDER BY username
+            LIMIT $2 OFFSET $3
+            "#,
+        )
+        .bind(&like)
+        .bind(limit)
+        .bind(offset)
+        .fetch_all(pool)
+        .await?;
+        let (total,): (i64,) = sqlx::query_as(
+            r#"
+            SELECT COUNT(*) FROM users
+            WHERE username ILIKE $1 ESCAPE '\'
+               OR email ILIKE $1 ESCAPE '\'
+               OR display_name ILIKE $1 ESCAPE '\'
+            "#,
+        )
+        .bind(&like)
+        .fetch_one(pool)
+        .await?;
+        (users, total)
+    } else {
+        let users = sqlx::query_as::<_, User>(
+            "SELECT * FROM users ORDER BY username LIMIT $1 OFFSET $2",
+        )
+        .bind(limit)
+        .bind(offset)
+        .fetch_all(pool)
+        .await?;
+        let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
+            .fetch_one(pool)
+            .await?;
+        (users, total)
+    };
+    Ok((users, total))
+}
+
+#[derive(Debug, Clone, sqlx::FromRow)]
+pub struct InviteCode {
+    pub id: Uuid,
+    pub code: String,
+    pub created_by: Option<Uuid>,
+    pub created_at: DateTime<Utc>,
+    pub used_by: Option<Uuid>,
+    pub used_at: Option<DateTime<Utc>>,
+    pub revoked_at: Option<DateTime<Utc>>,
+}
+
+pub fn generate_invite_code() -> String {
+    use rand::RngExt;
+    const ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+    let mut rng = rand::rng();
+    let mut out = String::with_capacity(10);
+    for _ in 0..10 {
+        let mut b = [0u8; 1];
+        rng.fill(&mut b);
+        out.push(ALPHABET[(b[0] as usize) % ALPHABET.len()] as char);
+    }
+    out
+}
+
+pub async fn create_invite(pool: &PgPool, created_by: Uuid) -> Result<InviteCode> {
+    let code = generate_invite_code();
+    Ok(sqlx::query_as::<_, InviteCode>(
+        r#"
+        INSERT INTO invite_codes (code, created_by)
+        VALUES ($1, $2)
+        RETURNING *
+        "#,
+    )
+    .bind(code)
+    .bind(created_by)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn list_active_invites(pool: &PgPool, limit: i64) -> Result<Vec<InviteCode>> {
+    Ok(sqlx::query_as::<_, InviteCode>(
+        r#"
+        SELECT * FROM invite_codes
+        WHERE used_at IS NULL AND revoked_at IS NULL
+        ORDER BY created_at DESC
+        LIMIT $1
+        "#,
+    )
+    .bind(limit)
+    .fetch_all(pool)
+    .await?)
+}
+
+pub async fn revoke_invite(pool: &PgPool, id: Uuid) -> Result<()> {
+    sqlx::query(
+        "UPDATE invite_codes SET revoked_at = now() WHERE id = $1 AND used_at IS NULL AND revoked_at IS NULL",
+    )
+    .bind(id)
+    .execute(pool)
+    .await?;
+    Ok(())
+}
+
+/// Returns the invite if it is unused and not revoked.
+pub async fn get_valid_invite(pool: &PgPool, code: &str) -> Result<Option<InviteCode>> {
+    let code = code.trim().to_uppercase();
+    if code.is_empty() {
+        return Ok(None);
+    }
+    Ok(sqlx::query_as::<_, InviteCode>(
+        r#"
+        SELECT * FROM invite_codes
+        WHERE upper(code) = $1 AND used_at IS NULL AND revoked_at IS NULL
+        "#,
+    )
+    .bind(code)
+    .fetch_optional(pool)
+    .await?)
+}
+
+pub async fn consume_invite(pool: &PgPool, invite_id: Uuid, user_id: Uuid) -> Result<bool> {
+    let res = sqlx::query(
+        r#"
+        UPDATE invite_codes
+        SET used_by = $2, used_at = now()
+        WHERE id = $1 AND used_at IS NULL AND revoked_at IS NULL
+        "#,
+    )
+    .bind(invite_id)
+    .bind(user_id)
+    .execute(pool)
+    .await?;
+    Ok(res.rows_affected() > 0)
+}
+
+#[derive(Debug, Clone)]
+pub struct AdminStats {
+    pub user_count: i64,
+    pub repo_count: i64,
+    pub public_repo_count: i64,
+    pub recent_signups: i64,
+    pub active_invites: i64,
+    pub disk_bytes: u64,
+}
+
+pub async fn admin_stats(pool: &PgPool) -> Result<AdminStats> {
+    let (user_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users")
+        .fetch_one(pool)
+        .await?;
+    let (repo_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM repositories")
+        .fetch_one(pool)
+        .await?;
+    let (public_repo_count,): (i64,) =
+        sqlx::query_as("SELECT COUNT(*) FROM repositories WHERE visibility = 'public'")
+            .fetch_one(pool)
+            .await?;
+    let (recent_signups,): (i64,) = sqlx::query_as(
+        "SELECT COUNT(*) FROM users WHERE created_at > now() - interval '7 days'",
+    )
+    .fetch_one(pool)
+    .await?;
+    let (active_invites,): (i64,) = sqlx::query_as(
+        "SELECT COUNT(*) FROM invite_codes WHERE used_at IS NULL AND revoked_at IS NULL",
+    )
+    .fetch_one(pool)
+    .await?;
+    Ok(AdminStats {
+        user_count,
+        repo_count,
+        public_repo_count,
+        recent_signups,
+        active_invites,
+        disk_bytes: 0,
+    })
+}
+
+pub async fn list_repos_admin(
+    pool: &PgPool,
+    query: Option<&str>,
+    limit: i64,
+    offset: i64,
+) -> Result<(Vec<(Repository, String)>, i64)> {
+    #[derive(sqlx::FromRow)]
+    struct Row {
+        id: Uuid,
+        owner_id: Uuid,
+        name: String,
+        description: String,
+        visibility: String,
+        default_branch: String,
+        archived: bool,
+        issues_enabled: bool,
+        pulls_enabled: bool,
+        releases_enabled: bool,
+        allow_merge: bool,
+        allow_squash: bool,
+        allow_rebase: bool,
+        default_merge_style: String,
+        protect_default_branch: bool,
+        protect_block_force_push: bool,
+        fork_of_id: Option<Uuid>,
+        stars_count: i32,
+        watches_count: i32,
+        forks_count: i32,
+        created_at: DateTime<Utc>,
+        updated_at: DateTime<Utc>,
+        owner_username: String,
+    }
+
+    let q = query.map(|s| s.trim()).filter(|s| !s.is_empty());
+    let (rows, total): (Vec<Row>, i64) = if let Some(term) = q {
+        let like = format!("%{}%", term.replace('%', "\\%").replace('_', "\\_"));
+        let rows = sqlx::query_as(
+            r#"
+            SELECT r.*, u.username AS owner_username
+            FROM repositories r
+            JOIN users u ON u.id = r.owner_id
+            WHERE r.name ILIKE $1 ESCAPE '\'
+               OR r.description ILIKE $1 ESCAPE '\'
+               OR u.username ILIKE $1 ESCAPE '\'
+            ORDER BY r.updated_at DESC
+            LIMIT $2 OFFSET $3
+            "#,
+        )
+        .bind(&like)
+        .bind(limit)
+        .bind(offset)
+        .fetch_all(pool)
+        .await?;
+        let (total,): (i64,) = sqlx::query_as(
+            r#"
+            SELECT COUNT(*)
+            FROM repositories r
+            JOIN users u ON u.id = r.owner_id
+            WHERE r.name ILIKE $1 ESCAPE '\'
+               OR r.description ILIKE $1 ESCAPE '\'
+               OR u.username ILIKE $1 ESCAPE '\'
+            "#,
+        )
+        .bind(&like)
+        .fetch_one(pool)
+        .await?;
+        (rows, total)
+    } else {
+        let rows = sqlx::query_as(
+            r#"
+            SELECT r.*, u.username AS owner_username
+            FROM repositories r
+            JOIN users u ON u.id = r.owner_id
+            ORDER BY r.updated_at DESC
+            LIMIT $1 OFFSET $2
+            "#,
+        )
+        .bind(limit)
+        .bind(offset)
+        .fetch_all(pool)
+        .await?;
+        let (total,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM repositories")
+            .fetch_one(pool)
+            .await?;
+        (rows, total)
+    };
+
+    let repos = rows
+        .into_iter()
+        .map(|r| {
+            (
+                Repository {
+                    id: r.id,
+                    owner_id: r.owner_id,
+                    name: r.name,
+                    description: r.description,
+                    visibility: r.visibility,
+                    default_branch: r.default_branch,
+                    archived: r.archived,
+                    issues_enabled: r.issues_enabled,
+                    pulls_enabled: r.pulls_enabled,
+                    releases_enabled: r.releases_enabled,
+                    allow_merge: r.allow_merge,
+                    allow_squash: r.allow_squash,
+                    allow_rebase: r.allow_rebase,
+                    default_merge_style: r.default_merge_style,
+                    protect_default_branch: r.protect_default_branch,
+                    protect_block_force_push: r.protect_block_force_push,
+                    fork_of_id: r.fork_of_id,
+                    stars_count: r.stars_count,
+                    watches_count: r.watches_count,
+                    forks_count: r.forks_count,
+                    created_at: r.created_at,
+                    updated_at: r.updated_at,
+                },
+                r.owner_username,
+            )
+        })
+        .collect();
+    Ok((repos, total))
+}
+
+pub async fn set_repo_visibility(
+    pool: &PgPool,
+    id: Uuid,
+    visibility: &str,
+) -> Result<Repository> {
+    Ok(sqlx::query_as::<_, Repository>(
+        "UPDATE repositories SET visibility = $2, updated_at = now() WHERE id = $1 RETURNING *",
+    )
+    .bind(id)
+    .bind(visibility)
+    .fetch_one(pool)
+    .await?)
+}
+
+pub async fn get_repo_by_id(pool: &PgPool, id: Uuid) -> Result<Option<(Repository, String)>> {
+    #[derive(sqlx::FromRow)]
+    struct Row {
+        id: Uuid,
+        owner_id: Uuid,
+        name: String,
+        description: String,
+        visibility: String,
+        default_branch: String,
+        archived: bool,
+        issues_enabled: bool,
+        pulls_enabled: bool,
+        releases_enabled: bool,
+        allow_merge: bool,
+        allow_squash: bool,
+        allow_rebase: bool,
+        default_merge_style: String,
+        protect_default_branch: bool,
+        protect_block_force_push: bool,
+        fork_of_id: Option<Uuid>,
+        stars_count: i32,
+        watches_count: i32,
+        forks_count: i32,
+        created_at: DateTime<Utc>,
+        updated_at: DateTime<Utc>,
+        owner_username: String,
+    }
+    let row: Option<Row> = sqlx::query_as(
+        r#"
+        SELECT r.*, u.username AS owner_username
+        FROM repositories r
+        JOIN users u ON u.id = r.owner_id
+        WHERE r.id = $1
+        "#,
+    )
+    .bind(id)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.map(|r| {
+        (
+            Repository {
+                id: r.id,
+                owner_id: r.owner_id,
+                name: r.name,
+                description: r.description,
+                visibility: r.visibility,
+                default_branch: r.default_branch,
+                archived: r.archived,
+                issues_enabled: r.issues_enabled,
+                pulls_enabled: r.pulls_enabled,
+                releases_enabled: r.releases_enabled,
+                allow_merge: r.allow_merge,
+                allow_squash: r.allow_squash,
+                allow_rebase: r.allow_rebase,
+                default_merge_style: r.default_merge_style,
+                protect_default_branch: r.protect_default_branch,
+                protect_block_force_push: r.protect_block_force_push,
+                fork_of_id: r.fork_of_id,
+                stars_count: r.stars_count,
+                watches_count: r.watches_count,
+                forks_count: r.forks_count,
+                created_at: r.created_at,
+                updated_at: r.updated_at,
+            },
+            r.owner_username,
+        )
+    }))
+}
+
 
 pub async fn create_session(pool: &PgPool, user_id: Uuid, token_hash: &str, days: i64) -> Result<()> {
     let expires = Utc::now() + chrono::Duration::days(days);
@@ -146,7 +590,9 @@ pub async fn user_from_session(pool: &PgPool, token_hash: &str) -> Result<Option
         r#"
         SELECT u.* FROM users u
         JOIN sessions s ON s.user_id = u.id
-        WHERE s.token_hash = $1 AND s.expires_at > now()
+        WHERE s.token_hash = $1
+          AND s.expires_at > now()
+          AND u.is_suspended = FALSE
         "#,
     )
     .bind(token_hash)
@@ -238,15 +684,6 @@ pub async fn get_repo(pool: &PgPool, owner: &str, name: &str) -> Result<Option<(
     Ok(repo.map(|r| (r, owner_user)))
 }
 
-pub async fn get_repo_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Repository>> {
-    Ok(
-        sqlx::query_as::<_, Repository>("SELECT * FROM repositories WHERE id = $1")
-            .bind(id)
-            .fetch_optional(pool)
-            .await?,
-    )
-}
-
 pub async fn list_user_repos(pool: &PgPool, owner_id: Uuid, viewer: Option<Uuid>) -> Result<Vec<Repository>> {
     Ok(sqlx::query_as::<_, Repository>(
         r#"
@@ -521,17 +958,27 @@ pub async fn list_collaborators(pool: &PgPool, repo_id: Uuid) -> Result<Vec<(Col
     Ok(out)
 }
 
+pub fn normalize_ssh_key_usage(usage: &str) -> &'static str {
+    match usage.trim().to_ascii_lowercase().as_str() {
+        "signing" => "signing",
+        "both" => "both",
+        _ => "authentication",
+    }
+}
+
 pub async fn add_ssh_key(
     pool: &PgPool,
     user_id: Uuid,
     name: &str,
     public_key: &str,
     fingerprint: &str,
+    key_usage: &str,
 ) -> Result<SshKey> {
+    let key_usage = normalize_ssh_key_usage(key_usage);
     Ok(sqlx::query_as::<_, SshKey>(
         r#"
-        INSERT INTO ssh_keys (user_id, name, public_key, fingerprint)
-        VALUES ($1, $2, $3, $4)
+        INSERT INTO ssh_keys (user_id, name, public_key, fingerprint, key_usage)
+        VALUES ($1, $2, $3, $4, $5)
         RETURNING *
         "#,
     )
@@ -539,6 +986,7 @@ pub async fn add_ssh_key(
     .bind(name)
     .bind(public_key)
     .bind(fingerprint)
+    .bind(key_usage)
     .fetch_one(pool)
     .await?)
 }
@@ -561,6 +1009,23 @@ pub async fn delete_ssh_key(pool: &PgPool, user_id: Uuid, id: Uuid) -> Result<()
     Ok(())
 }
 
+pub async fn update_ssh_key_usage(
+    pool: &PgPool,
+    user_id: Uuid,
+    id: Uuid,
+    key_usage: &str,
+) -> Result<()> {
+    let key_usage = normalize_ssh_key_usage(key_usage);
+    sqlx::query("UPDATE ssh_keys SET key_usage = $3 WHERE id = $1 AND user_id = $2")
+        .bind(id)
+        .bind(user_id)
+        .bind(key_usage)
+        .execute(pool)
+        .await?;
+    Ok(())
+}
+
+/// Git-over-SSH authentication: only authentication / both keys.
 pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result<Option<User>> {
     // OpenSSH prints SHA256 fingerprints without `=` padding; tolerate either form.
     let normalized = fingerprint.trim_end_matches('=');
@@ -569,6 +1034,25 @@ pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result
         SELECT u.* FROM users u
         JOIN ssh_keys k ON k.user_id = u.id
         WHERE rtrim(k.fingerprint, '=') = $1
+          AND k.key_usage IN ('authentication', 'both')
+        "#,
+    )
+    .bind(normalized)
+    .fetch_optional(pool)
+    .await?)
+}
+
+/// Commit signing: only signing / both keys.
+pub async fn signing_ssh_key_by_fingerprint(
+    pool: &PgPool,
+    fingerprint: &str,
+) -> Result<Option<SshKey>> {
+    let normalized = fingerprint.trim_end_matches('=');
+    Ok(sqlx::query_as::<_, SshKey>(
+        r#"
+        SELECT * FROM ssh_keys
+        WHERE rtrim(fingerprint, '=') = $1
+          AND key_usage IN ('signing', 'both')
         "#,
     )
     .bind(normalized)
@@ -576,6 +1060,31 @@ pub async fn user_by_ssh_fingerprint(pool: &PgPool, fingerprint: &str) -> Result
     .await?)
 }
 
+pub async fn user_owns_email(pool: &PgPool, user_id: Uuid, email: &str) -> Result<bool> {
+    let email = email.trim().to_ascii_lowercase();
+    let row: Option<(bool,)> = sqlx::query_as(
+        r#"
+        SELECT true FROM users
+        WHERE id = $1 AND lower(email) = $2
+        UNION
+        SELECT true FROM user_emails
+        WHERE user_id = $1 AND lower(email) = $2
+        LIMIT 1
+        "#,
+    )
+    .bind(user_id)
+    .bind(&email)
+    .fetch_optional(pool)
+    .await?;
+    Ok(row.is_some())
+}
+
+pub async fn list_all_gpg_keys(pool: &PgPool) -> Result<Vec<GpgKey>> {
+    Ok(sqlx::query_as::<_, GpgKey>("SELECT * FROM gpg_keys ORDER BY created_at DESC")
+        .fetch_all(pool)
+        .await?)
+}
+
 pub async fn record_activity(
     pool: &PgPool,
     actor_id: Option<Uuid>,
@@ -635,14 +1144,10 @@ async fn hydrate_activity(
         let (repo, owner_name) = match e.repo_id {
             Some(id) => {
                 let r = get_repo_by_id(pool, id).await?;
-                let oname = if let Some(ref repo) = r {
-                    get_user_by_id(pool, repo.owner_id)
-                        .await?
-                        .map(|u| u.username)
-                } else {
-                    None
-                };
-                (r, oname)
+                match r {
+                    Some((repo, owner_username)) => (Some(repo), Some(owner_username)),
+                    None => (None, None),
+                }
             }
             None => (None, None),
         };
@@ -1782,6 +2287,7 @@ pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json
         "ssh_keys": keys.iter().map(|k| serde_json::json!({
             "name": k.name,
             "fingerprint": k.fingerprint,
+            "key_usage": k.key_usage,
             "created_at": k.created_at,
         })).collect::<Vec<_>>(),
         "repositories": repos.iter().map(|r| serde_json::json!({
diff --git a/src/git/http.rs b/src/git/http.rs
index 0eed64d..05a5e73 100644
--- a/src/git/http.rs
+++ b/src/git/http.rs
@@ -272,7 +272,9 @@ async fn basic_auth_user(pool: &PgPool, headers: &HeaderMap) -> Result<Option<Us
     // For HTTP git with Authentik, prefer session cookie. Basic auth accepts username
     // with any password matching an existing user (dev-friendly). Production should
     // use deploy tokens later; for now OIDC session or SSH is preferred.
-    Ok(queries::get_user_by_username(pool, username).await?)
+    Ok(queries::get_user_by_username(pool, username)
+        .await?
+        .filter(|u| !u.is_suspended))
 }
 
 fn strip_git_suffix(name: &str) -> &str {
diff --git a/src/git/mod.rs b/src/git/mod.rs
index 95a7f9e..b402e6d 100644
--- a/src/git/mod.rs
+++ b/src/git/mod.rs
@@ -3,5 +3,7 @@ pub mod languages;
 pub mod lfs;
 pub mod repo;
 pub mod ssh;
+pub mod verify;
 
 pub use repo::*;
+pub use verify::{extract_commit_signature, verify_commit_signature};
diff --git a/src/git/ssh.rs b/src/git/ssh.rs
index 570b77a..95d2ba2 100644
--- a/src/git/ssh.rs
+++ b/src/git/ssh.rs
@@ -153,6 +153,10 @@ impl Handler for SshHandler {
     ) -> Result<Auth, Self::Error> {
         let fp = fingerprint_public_key(public_key);
         if let Some(user) = queries::user_by_ssh_fingerprint(&self.state.pool, &fp).await? {
+            if user.is_suspended {
+                tracing::info!("ssh rejected suspended user {}", user.username);
+                return Ok(auth_reject());
+            }
             self.user_id = Some(user.id);
             self.username = Some(user.username);
             return Ok(Auth::Accept);
diff --git a/src/git/verify.rs b/src/git/verify.rs
new file mode 100644
index 0000000..3399cbd
--- /dev/null
+++ b/src/git/verify.rs
@@ -0,0 +1,215 @@
+//! Commit signature verification (SSH + GPG).
+
+use crate::db::queries;
+use anyhow::Result;
+use chrono::{DateTime, Datelike, Local, Timelike, Utc};
+use git2::{Oid, Repository as G2Repo};
+use pgp::composed::{Deserializable, SignedPublicKey, StandaloneSignature};
+use pgp::types::PublicKeyTrait;
+use sha2::{Digest, Sha256};
+use sqlx::PgPool;
+use ssh_key::{HashAlg, PublicKey, SshSig};
+
+const GIT_SSH_NAMESPACE: &str = "git";
+
+#[derive(Debug, Clone)]
+pub struct CommitVerification {
+    /// `"ssh"` or `"gpg"`.
+    pub kind: String,
+    /// Fingerprint shown in the badge popover (no `SHA256:` prefix for SSH).
+    pub fingerprint: String,
+    /// Human-readable "Verified on …" timestamp from the commit.
+    pub verified_at: String,
+}
+
+impl CommitVerification {
+    pub fn fingerprint_label(&self) -> &'static str {
+        match self.kind.as_str() {
+            "gpg" => "GPG Key Fingerprint",
+            _ => "SSH Key Fingerprint",
+        }
+    }
+}
+
+/// Sync extract only — keep `git2::Repository` off async boundaries (`!Send`).
+pub fn extract_commit_signature(repo: &G2Repo, commit_id: &str) -> Option<(String, Vec<u8>)> {
+    let oid = Oid::from_str(commit_id).ok()?;
+    let (sig_buf, data_buf) = repo.extract_signature(&oid, None).ok()?;
+    let sig = std::str::from_utf8(&sig_buf).ok()?.trim().to_string();
+    let payload = data_buf.to_vec();
+    if sig.contains("BEGIN SSH SIGNATURE") || sig.contains("BEGIN PGP SIGNATURE") {
+        Some((sig, payload))
+    } else {
+        None
+    }
+}
+
+/// Verify a previously extracted signature against registered signing keys.
+pub async fn verify_commit_signature(
+    pool: &PgPool,
+    sig: &str,
+    payload: &[u8],
+    author_email: &str,
+    commit_time: i64,
+) -> Option<CommitVerification> {
+    let verified_at = format_verified_at(commit_time);
+    if sig.contains("BEGIN SSH SIGNATURE") {
+        return verify_ssh(pool, sig, payload, author_email, verified_at).await;
+    }
+    if sig.contains("BEGIN PGP SIGNATURE") {
+        return verify_gpg(pool, sig, payload, author_email, verified_at).await;
+    }
+    None
+}
+
+/// Extract + verify a commit signature. Does all git2 work before any `.await`.
+pub async fn verify_commit(
+    pool: &PgPool,
+    repo: &G2Repo,
+    commit_id: &str,
+    author_email: &str,
+    commit_time: i64,
+) -> Option<CommitVerification> {
+    let (sig, payload) = extract_commit_signature(repo, commit_id)?;
+    verify_commit_signature(pool, &sig, &payload, author_email, commit_time).await
+}
+
+async fn verify_ssh(
+    pool: &PgPool,
+    sig_pem: &str,
+    payload: &[u8],
+    author_email: &str,
+    verified_at: String,
+) -> Option<CommitVerification> {
+    let sshsig = SshSig::from_pem(sig_pem.as_bytes()).ok()?;
+    let embedded = PublicKey::new(sshsig.public_key().clone(), "");
+    let fp = embedded.fingerprint(HashAlg::Sha256).to_string();
+
+    let key = queries::signing_ssh_key_by_fingerprint(pool, &fp)
+        .await
+        .ok()
+        .flatten()?;
+
+    if !email_matches_user(pool, key.user_id, author_email).await {
+        return None;
+    }
+
+    let stored: PublicKey = key.public_key.trim().parse().ok()?;
+    stored.verify(GIT_SSH_NAMESPACE, payload, &sshsig).ok()?;
+
+    Some(CommitVerification {
+        kind: "ssh".into(),
+        fingerprint: display_ssh_fingerprint(&fp),
+        verified_at,
+    })
+}
+
+async fn verify_gpg(
+    pool: &PgPool,
+    sig_armor: &str,
+    payload: &[u8],
+    author_email: &str,
+    verified_at: String,
+) -> Option<CommitVerification> {
+    let (signature, _) = StandaloneSignature::from_string(sig_armor).ok()?;
+    let keys = queries::list_all_gpg_keys(pool).await.ok()?;
+
+    for key in keys {
+        if !email_matches_user(pool, key.user_id, author_email).await {
+            continue;
+        }
+        let Ok((pubkey, _)) = SignedPublicKey::from_string(&key.public_key) else {
+            continue;
+        };
+        let mut ok = signature.verify(&pubkey, payload).is_ok();
+        if !ok {
+            for sub in &pubkey.public_subkeys {
+                if signature.verify(sub, payload).is_ok() {
+                    ok = true;
+                    break;
+                }
+            }
+        }
+        if ok {
+            return Some(CommitVerification {
+                kind: "gpg".into(),
+                fingerprint: display_gpg_fingerprint(&key.fingerprint),
+                verified_at,
+            });
+        }
+    }
+    None
+}
+
+async fn email_matches_user(pool: &PgPool, user_id: uuid::Uuid, email: &str) -> bool {
+    let email = email.trim();
+    if email.is_empty() {
+        return false;
+    }
+    queries::user_owns_email(pool, user_id, email)
+        .await
+        .unwrap_or(false)
+}
+
+/// Compute a real OpenPGP fingerprint (uppercase hex) from armored public key.
+pub fn gpg_fingerprint_from_armor(armor: &str) -> Result<String> {
+    let (pubkey, _) = SignedPublicKey::from_string(armor)?;
+    Ok(hex::encode_upper(pubkey.fingerprint().as_bytes()))
+}
+
+/// Legacy fallback used when OpenPGP parse fails — keep old keys loadable.
+pub fn gpg_fingerprint_fallback(armor: &str) -> String {
+    let mut hasher = Sha256::new();
+    hasher.update(armor.as_bytes());
+    hex::encode(hasher.finalize())
+}
+
+fn display_ssh_fingerprint(fp: &str) -> String {
+    fp.strip_prefix("SHA256:")
+        .unwrap_or(fp)
+        .trim_end_matches('=')
+        .to_string()
+}
+
+fn display_gpg_fingerprint(fp: &str) -> String {
+    let clean: String = fp.chars().filter(|c| c.is_ascii_hexdigit()).collect();
+    if clean.len() == 40 {
+        clean
+            .as_bytes()
+            .chunks(4)
+            .map(|c| std::str::from_utf8(c).unwrap_or(""))
+            .collect::<Vec<_>>()
+            .join(" ")
+            .to_uppercase()
+    } else {
+        fp.to_string()
+    }
+}
+
+fn format_verified_at(secs: i64) -> String {
+    let dt: DateTime<Utc> = DateTime::from_timestamp(secs, 0).unwrap_or_else(Utc::now);
+    let local = dt.with_timezone(&Local);
+    let (is_pm, hour12) = local.hour12();
+    let ampm = if is_pm { "PM" } else { "AM" };
+    let month = match local.month() {
+        1 => "Jan",
+        2 => "Feb",
+        3 => "Mar",
+        4 => "Apr",
+        5 => "May",
+        6 => "Jun",
+        7 => "Jul",
+        8 => "Aug",
+        9 => "Sep",
+        10 => "Oct",
+        11 => "Nov",
+        _ => "Dec",
+    };
+    format!(
+        "{month} {}, {}, {}:{:02} {ampm}",
+        local.day(),
+        local.year(),
+        hour12,
+        local.minute()
+    )
+}
diff --git a/src/main.rs b/src/main.rs
index d952047..2a913a5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -5,6 +5,7 @@ mod git;
 mod highlight;
 mod markdown;
 mod mfa;
+mod og;
 mod state;
 mod web;
 
diff --git a/src/mfa.rs b/src/mfa.rs
index 8721157..ef12384 100644
--- a/src/mfa.rs
+++ b/src/mfa.rs
@@ -147,9 +147,18 @@ pub fn sanitize_user_error(msg: &str) -> String {
     if lower.contains("email") && lower.contains("required") {
         return msg.to_string();
     }
+    if lower.contains("account suspended") || lower.contains("suspended") {
+        return "this account has been suspended".into();
+    }
+    if lower.contains("invite") {
+        return msg.to_string();
+    }
     if lower.contains("signup unavailable") || lower.contains("api token") {
         return "signup is temporarily unavailable".into();
     }
+    if lower.contains("signups disabled") || lower.contains("disabled new signups") {
+        return msg.to_string();
+    }
     if lower.contains("authentik") || lower.contains("oidc") {
         return "authentication failed".into();
     }
diff --git a/src/og.rs b/src/og.rs
new file mode 100644
index 0000000..9cc37d7
--- /dev/null
+++ b/src/og.rs
@@ -0,0 +1,362 @@
+//! Open Graph / social card helpers derived from repository settings.
+
+use crate::db::models::Repository;
+use flate2::write::ZlibEncoder;
+use flate2::Compression;
+use std::io::Write;
+
+pub const SITE_NAME: &str = "kitgit";
+pub const SITE_TAGLINE: &str = "self-hosted git. templates, not a product page.";
+
+const OG_W: u32 = 1200;
+const OG_H: u32 = 630;
+
+/// View-model fields for meta tags in templates.
+#[derive(Debug, Clone)]
+pub struct SocialMeta {
+    pub title: String,
+    pub description: String,
+    pub image_url: String,
+    pub url: String,
+    pub site_name: String,
+}
+
+fn truncate_chars(s: &str, max: usize) -> String {
+    if s.chars().count() <= max {
+        return s.to_string();
+    }
+    let mut out = String::new();
+    for (i, c) in s.chars().enumerate() {
+        if i + 1 >= max {
+            out.push('\u{2026}');
+            break;
+        }
+        out.push(c);
+    }
+    out
+}
+
+fn fallback_description(repo: &Repository) -> String {
+    let desc = repo.description.trim();
+    if desc.is_empty() {
+        format!("Git repository on {SITE_NAME}.")
+    } else {
+        truncate_chars(desc, 200)
+    }
+}
+
+/// Page title / og:title / twitter:title - aim for ~50-60 SERP chars.
+pub fn repo_social_title(owner: &str, repo: &Repository) -> String {
+    let base = format!("{}/{}", owner, repo.name);
+    let desc = repo.description.trim();
+    if desc.is_empty() {
+        format!("{base} - Git repository on {SITE_NAME}")
+    } else {
+        truncate_chars(&format!("{base} - {desc}"), 60)
+    }
+}
+
+pub fn repo_social_description(repo: &Repository) -> String {
+    fallback_description(repo)
+}
+
+pub fn absolute_url(public_url: &str, path: &str) -> String {
+    let base = public_url.trim_end_matches('/');
+    let path = if path.starts_with('/') {
+        path.to_string()
+    } else {
+        format!("/{path}")
+    };
+    format!("{base}{path}")
+}
+
+pub fn repo_social_meta(public_url: &str, owner: &str, repo: &Repository) -> SocialMeta {
+    let path = format!("/{}/{}", owner, repo.name);
+    SocialMeta {
+        title: repo_social_title(owner, repo),
+        description: repo_social_description(repo),
+        image_url: absolute_url(public_url, &format!("{path}/og.png")),
+        url: absolute_url(public_url, &path),
+        site_name: SITE_NAME.to_string(),
+    }
+}
+
+pub fn site_social_meta(public_url: &str, path: &str, title: &str, description: &str) -> SocialMeta {
+    SocialMeta {
+        title: title.to_string(),
+        description: description.to_string(),
+        image_url: absolute_url(public_url, "/og.png"),
+        url: absolute_url(public_url, path),
+        site_name: SITE_NAME.to_string(),
+    }
+}
+
+// PNG card (1200x630)
+
+fn glyph(c: char) -> [u8; 7] {
+    const FONT: [[u8; 7]; 95] = [
+        [0, 0, 0, 0, 0, 0, 0],
+        [0x04, 0x04, 0x04, 0x04, 0x00, 0x04, 0x00],
+        [0x0A, 0x0A, 0x00, 0x00, 0x00, 0x00, 0x00],
+        [0x0A, 0x1F, 0x0A, 0x1F, 0x0A, 0x00, 0x00],
+        [0x04, 0x0F, 0x14, 0x0E, 0x05, 0x1E, 0x04],
+        [0x18, 0x19, 0x02, 0x04, 0x08, 0x13, 0x03],
+        [0x08, 0x14, 0x08, 0x15, 0x12, 0x0D, 0x00],
+        [0x04, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
+        [0x02, 0x04, 0x04, 0x04, 0x04, 0x02, 0x00],
+        [0x08, 0x04, 0x04, 0x04, 0x04, 0x08, 0x00],
+        [0x00, 0x0A, 0x04, 0x1F, 0x04, 0x0A, 0x00],
+        [0x00, 0x04, 0x04, 0x1F, 0x04, 0x04, 0x00],
+        [0x00, 0x00, 0x00, 0x00, 0x04, 0x04, 0x08],
+        [0x00, 0x00, 0x00, 0x1F, 0x00, 0x00, 0x00],
+        [0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00],
+        [0x01, 0x02, 0x04, 0x08, 0x10, 0x00, 0x00],
+        [0x0E, 0x11, 0x13, 0x15, 0x19, 0x0E, 0x00],
+        [0x04, 0x0C, 0x04, 0x04, 0x04, 0x0E, 0x00],
+        [0x0E, 0x11, 0x01, 0x06, 0x08, 0x1F, 0x00],
+        [0x1F, 0x02, 0x04, 0x02, 0x11, 0x0E, 0x00],
+        [0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x00],
+        [0x1F, 0x10, 0x1E, 0x01, 0x11, 0x0E, 0x00],
+        [0x06, 0x08, 0x1E, 0x11, 0x11, 0x0E, 0x00],
+        [0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x00],
+        [0x0E, 0x11, 0x0E, 0x11, 0x11, 0x0E, 0x00],
+        [0x0E, 0x11, 0x11, 0x0F, 0x01, 0x0C, 0x00],
+        [0x00, 0x04, 0x00, 0x00, 0x04, 0x00, 0x00],
+        [0x00, 0x04, 0x00, 0x00, 0x04, 0x04, 0x08],
+        [0x02, 0x04, 0x08, 0x10, 0x08, 0x04, 0x02],
+        [0x00, 0x00, 0x1F, 0x00, 0x1F, 0x00, 0x00],
+        [0x08, 0x04, 0x02, 0x01, 0x02, 0x04, 0x08],
+        [0x0E, 0x11, 0x01, 0x02, 0x00, 0x02, 0x00],
+        [0x0E, 0x11, 0x17, 0x15, 0x17, 0x10, 0x0E],
+        [0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x00],
+        [0x1E, 0x11, 0x1E, 0x11, 0x11, 0x1E, 0x00],
+        [0x0E, 0x11, 0x10, 0x10, 0x11, 0x0E, 0x00],
+        [0x1E, 0x11, 0x11, 0x11, 0x11, 0x1E, 0x00],
+        [0x1F, 0x10, 0x1E, 0x10, 0x10, 0x1F, 0x00],
+        [0x1F, 0x10, 0x1E, 0x10, 0x10, 0x10, 0x00],
+        [0x0E, 0x11, 0x10, 0x13, 0x11, 0x0F, 0x00],
+        [0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0x00],
+        [0x0E, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00],
+        [0x01, 0x01, 0x01, 0x01, 0x11, 0x0E, 0x00],
+        [0x11, 0x12, 0x1C, 0x12, 0x11, 0x11, 0x00],
+        [0x10, 0x10, 0x10, 0x10, 0x10, 0x1F, 0x00],
+        [0x11, 0x1B, 0x15, 0x11, 0x11, 0x11, 0x00],
+        [0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x00],
+        [0x0E, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00],
+        [0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x00],
+        [0x0E, 0x11, 0x11, 0x15, 0x12, 0x0D, 0x00],
+        [0x1E, 0x11, 0x11, 0x1E, 0x12, 0x11, 0x00],
+        [0x0F, 0x10, 0x0E, 0x01, 0x01, 0x1E, 0x00],
+        [0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00],
+        [0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0x00],
+        [0x11, 0x11, 0x11, 0x11, 0x0A, 0x04, 0x00],
+        [0x11, 0x11, 0x11, 0x15, 0x1B, 0x11, 0x00],
+        [0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x00],
+        [0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x00],
+        [0x1F, 0x01, 0x02, 0x04, 0x08, 0x1F, 0x00],
+        [0x0E, 0x08, 0x08, 0x08, 0x08, 0x0E, 0x00],
+        [0x10, 0x08, 0x04, 0x02, 0x01, 0x00, 0x00],
+        [0x0E, 0x02, 0x02, 0x02, 0x02, 0x0E, 0x00],
+        [0x04, 0x0A, 0x11, 0x00, 0x00, 0x00, 0x00],
+        [0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x00],
+        [0x08, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00],
+        [0x00, 0x00, 0x0E, 0x01, 0x0F, 0x11, 0x0F],
+        [0x10, 0x10, 0x1E, 0x11, 0x11, 0x1E, 0x00],
+        [0x00, 0x00, 0x0E, 0x10, 0x10, 0x11, 0x0E],
+        [0x01, 0x01, 0x0F, 0x11, 0x11, 0x0F, 0x00],
+        [0x00, 0x00, 0x0E, 0x11, 0x1F, 0x10, 0x0E],
+        [0x06, 0x08, 0x1C, 0x08, 0x08, 0x08, 0x00],
+        [0x00, 0x0F, 0x11, 0x0F, 0x01, 0x0E, 0x00],
+        [0x10, 0x10, 0x1E, 0x11, 0x11, 0x11, 0x00],
+        [0x04, 0x00, 0x0C, 0x04, 0x04, 0x0E, 0x00],
+        [0x02, 0x00, 0x06, 0x02, 0x02, 0x12, 0x0C],
+        [0x10, 0x10, 0x12, 0x1C, 0x12, 0x11, 0x00],
+        [0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E, 0x00],
+        [0x00, 0x00, 0x1A, 0x15, 0x15, 0x11, 0x00],
+        [0x00, 0x00, 0x1E, 0x11, 0x11, 0x11, 0x00],
+        [0x00, 0x00, 0x0E, 0x11, 0x11, 0x11, 0x0E],
+        [0x00, 0x00, 0x1E, 0x11, 0x1E, 0x10, 0x10],
+        [0x00, 0x00, 0x0F, 0x11, 0x0F, 0x01, 0x01],
+        [0x00, 0x00, 0x16, 0x19, 0x10, 0x10, 0x00],
+        [0x00, 0x00, 0x0F, 0x10, 0x0E, 0x01, 0x1E],
+        [0x08, 0x08, 0x1C, 0x08, 0x08, 0x06, 0x00],
+        [0x00, 0x00, 0x11, 0x11, 0x11, 0x13, 0x0D],
+        [0x00, 0x00, 0x11, 0x11, 0x11, 0x0A, 0x04],
+        [0x00, 0x00, 0x11, 0x11, 0x15, 0x15, 0x0A],
+        [0x00, 0x00, 0x11, 0x0A, 0x04, 0x0A, 0x11],
+        [0x00, 0x00, 0x11, 0x11, 0x0F, 0x01, 0x0E],
+        [0x00, 0x00, 0x1F, 0x02, 0x04, 0x08, 0x1F],
+        [0x02, 0x04, 0x04, 0x08, 0x04, 0x04, 0x02],
+        [0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x00],
+        [0x08, 0x04, 0x04, 0x02, 0x04, 0x04, 0x08],
+        [0x00, 0x00, 0x08, 0x15, 0x02, 0x00, 0x00],
+    ];
+    let idx = if c.is_ascii() && (0x20..=0x7E).contains(&(c as u8)) {
+        (c as u8 - 0x20) as usize
+    } else {
+        (b'?' - 0x20) as usize
+    };
+    FONT[idx]
+}
+
+struct Canvas {
+    w: u32,
+    h: u32,
+    px: Vec<u8>,
+}
+
+impl Canvas {
+    fn new(w: u32, h: u32, fill: [u8; 3]) -> Self {
+        let mut px = vec![0u8; (w * h * 3) as usize];
+        for chunk in px.chunks_exact_mut(3) {
+            chunk.copy_from_slice(&fill);
+        }
+        Self { w, h, px }
+    }
+
+    fn set(&mut self, x: i32, y: i32, color: [u8; 3]) {
+        if x < 0 || y < 0 || x as u32 >= self.w || y as u32 >= self.h {
+            return;
+        }
+        let i = ((y as u32 * self.w + x as u32) * 3) as usize;
+        self.px[i..i + 3].copy_from_slice(&color);
+    }
+
+    fn fill_rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: [u8; 3]) {
+        for yy in y..y + h {
+            for xx in x..x + w {
+                self.set(xx, yy, color);
+            }
+        }
+    }
+
+    fn text(&mut self, x: i32, y: i32, s: &str, scale: i32, color: [u8; 3]) {
+        let mut cx = x;
+        for ch in s.chars() {
+            let g = glyph(ch);
+            for (row, bits) in g.iter().enumerate() {
+                for col in 0..5 {
+                    if bits & (1 << (4 - col)) != 0 {
+                        self.fill_rect(
+                            cx + col * scale,
+                            y + row as i32 * scale,
+                            scale,
+                            scale,
+                            color,
+                        );
+                    }
+                }
+            }
+            cx += 6 * scale;
+        }
+    }
+
+    fn text_clipped(&mut self, x: i32, y: i32, s: &str, scale: i32, color: [u8; 3], max_w: i32) {
+        let char_w = 6 * scale;
+        let max_chars = (max_w / char_w).max(1) as usize;
+        let clipped = if s.chars().count() > max_chars {
+            truncate_chars(s, max_chars.saturating_sub(1))
+        } else {
+            s.to_string()
+        };
+        self.text(x, y, &clipped, scale, color);
+    }
+
+    fn to_png(&self) -> Vec<u8> {
+        let mut raw = Vec::with_capacity(((self.w * 3 + 1) * self.h) as usize);
+        for y in 0..self.h {
+            raw.push(0);
+            let start = (y * self.w * 3) as usize;
+            let end = start + (self.w * 3) as usize;
+            raw.extend_from_slice(&self.px[start..end]);
+        }
+        let mut enc = ZlibEncoder::new(Vec::new(), Compression::fast());
+        enc.write_all(&raw).expect("zlib write");
+        let compressed = enc.finish().expect("zlib finish");
+
+        let mut out = Vec::with_capacity(compressed.len() + 128);
+        out.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
+        write_chunk(&mut out, b"IHDR", &{
+            let mut d = Vec::with_capacity(13);
+            d.extend_from_slice(&self.w.to_be_bytes());
+            d.extend_from_slice(&self.h.to_be_bytes());
+            d.extend_from_slice(&[8, 2, 0, 0, 0]);
+            d
+        });
+        write_chunk(&mut out, b"IDAT", &compressed);
+        write_chunk(&mut out, b"IEND", &[]);
+        out
+    }
+}
+
+fn write_chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
+    out.extend_from_slice(&(data.len() as u32).to_be_bytes());
+    out.extend_from_slice(ty);
+    out.extend_from_slice(data);
+    let mut crc = 0xFFFF_FFFFu32;
+    crc = crc32_update(crc, ty);
+    crc = crc32_update(crc, data);
+    out.extend_from_slice(&(!crc).to_be_bytes());
+}
+
+fn crc32_update(mut crc: u32, data: &[u8]) -> u32 {
+    for &b in data {
+        crc ^= u32::from(b);
+        for _ in 0..8 {
+            let mask = (!(crc & 1)).wrapping_add(1);
+            crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
+        }
+    }
+    crc
+}
+
+/// Render a social card from repository settings (name + description).
+pub fn render_repo_card(owner: &str, repo: &Repository) -> Vec<u8> {
+    let ink = [0x0a, 0x0a, 0x0a];
+    let muted = [0x6b, 0x6b, 0x6b];
+    let paper = [0xff, 0xff, 0xff];
+    let line = [0xe5, 0xe5, 0xe5];
+
+    let mut c = Canvas::new(OG_W, OG_H, paper);
+    c.fill_rect(0, 0, OG_W as i32, 4, ink);
+    c.fill_rect(0, OG_H as i32 - 56, OG_W as i32, 56, ink);
+
+    c.text(64, 48, SITE_NAME, 4, muted);
+
+    let full = format!("{}/{}", owner, repo.name);
+    c.text_clipped(64, 160, &full, 8, ink, OG_W as i32 - 128);
+
+    let desc = repo.description.trim();
+    if !desc.is_empty() {
+        c.text_clipped(64, 280, desc, 4, muted, OG_W as i32 - 128);
+        let char_w = 6 * 4;
+        let max_chars = ((OG_W as i32 - 128) / char_w).max(1) as usize;
+        if desc.chars().count() > max_chars {
+            let rest: String = desc.chars().skip(max_chars).collect();
+            if !rest.is_empty() {
+                c.text_clipped(64, 320, rest.trim_start(), 4, muted, OG_W as i32 - 128);
+            }
+        }
+    } else {
+        c.text(64, 280, "Git repository", 4, muted);
+    }
+
+    c.fill_rect(64, 400, 120, 2, line);
+    c.text(64, OG_H as i32 - 40, SITE_TAGLINE, 3, [0xa3, 0xa3, 0xa3]);
+
+    c.to_png()
+}
+
+/// Default site-wide OG image (no repo context).
+pub fn render_site_card() -> Vec<u8> {
+    let ink = [0x0a, 0x0a, 0x0a];
+    let muted = [0x6b, 0x6b, 0x6b];
+    let paper = [0xff, 0xff, 0xff];
+
+    let mut c = Canvas::new(OG_W, OG_H, paper);
+    c.fill_rect(0, 0, OG_W as i32, 4, ink);
+    c.fill_rect(0, OG_H as i32 - 56, OG_W as i32, 56, ink);
+    c.text(64, 200, SITE_NAME, 12, ink);
+    c.text(64, 340, SITE_TAGLINE, 4, muted);
+    c.to_png()
+}
diff --git a/src/web/mod.rs b/src/web/mod.rs
index dc5ecbf..c4b4cbb 100644
--- a/src/web/mod.rs
+++ b/src/web/mod.rs
@@ -54,6 +54,7 @@ pub fn app_router(state: AppState) -> Router {
     let static_dir = state.config.static_dir.clone();
     Router::new()
         .route("/", get(routes::home))
+        .route("/og.png", get(routes::site_og_image))
         .route("/explore", get(routes::explore))
         .route(
             "/auth/login",
@@ -72,7 +73,21 @@ pub fn app_router(state: AppState) -> Router {
         .route("/auth/logout", get(routes::auth_logout))
         .route("/admin", get(routes::admin_panel))
         .route("/admin/users", post(routes::admin_set_user))
+        .route("/admin/users/suspend", post(routes::admin_set_suspended))
         .route("/admin/motd", post(routes::admin_save_motd))
+        .route("/admin/announcement", post(routes::admin_save_announcement))
+        .route("/admin/signups", post(routes::admin_save_signups))
+        .route("/admin/invites", post(routes::admin_create_invite))
+        .route(
+            "/admin/invites/{id}/revoke",
+            post(routes::admin_revoke_invite),
+        )
+        .route(
+            "/admin/repos/{id}/visibility",
+            post(routes::admin_repo_visibility),
+        )
+        .route("/admin/repos/{id}/delete", post(routes::admin_repo_delete))
+        .route("/site-banner.json", get(routes::site_banner_json))
         .route("/new", get(routes::new_repo_form).post(routes::new_repo))
         .route(
             "/settings/profile",
@@ -121,6 +136,7 @@ pub fn app_router(state: AppState) -> Router {
             get(routes::keys_settings).post(routes::keys_add),
         )
         .route("/settings/keys/{id}/delete", post(routes::keys_delete))
+        .route("/settings/keys/{id}/usage", post(routes::keys_update_usage))
         .route("/settings/gpg", post(routes_extra::gpg_add))
         .route("/settings/gpg/{id}/delete", post(routes_extra::gpg_delete))
         .route("/avatars/{user_id}", get(routes::avatar))
@@ -142,6 +158,7 @@ pub fn app_router(state: AppState) -> Router {
             get(git::lfs::lfs_download).put(git::lfs::lfs_upload),
         )
         .route("/{owner}/{repo}", get(routes::repo_home))
+        .route("/{owner}/{repo}/og.png", get(routes::repo_og_image))
         .route("/{owner}/{repo}/tree/{*rest}", get(routes::repo_tree))
         .route("/{owner}/{repo}/blob/{*rest}", get(routes::repo_blob))
         .route("/{owner}/{repo}/raw/{*rest}", get(routes_extra::repo_raw))
diff --git a/src/web/routes.rs b/src/web/routes.rs
index 492b99a..c080f6f 100644
--- a/src/web/routes.rs
+++ b/src/web/routes.rs
@@ -8,6 +8,7 @@ use crate::git;
 use crate::git::ssh::fingerprint_ssh_pubkey;
 use crate::highlight::highlight;
 use crate::markdown::{MarkdownRepoBase, parent_dir, render_markdown, render_markdown_in_repo};
+use crate::og;
 use crate::state::AppState;
 use crate::web::templates::*;
 use axum::extract::{Form, Multipart, Path, Query, State};
@@ -599,11 +600,23 @@ fn build_diff_file(idx: usize, path: String, lines: &[String]) -> DiffFileView {
     }
 }
 
-fn latest_commit_view(grepo: &git2::Repository, branch: &str) -> Option<CommitView> {
-    git::list_commits(grepo, branch, 1)
+fn prepare_latest_commit(
+    grepo: &git2::Repository,
+    branch: &str,
+) -> Option<(git::CommitInfo, Option<(String, Vec<u8>)>)> {
+    let c = git::list_commits(grepo, branch, 1)
         .ok()
-        .and_then(|mut v| v.drain(..).next())
-        .map(commit_view)
+        .and_then(|mut v| v.drain(..).next())?;
+    let extracted = git::extract_commit_signature(grepo, &c.id);
+    Some((c, extracted))
+}
+
+async fn latest_commit_view(
+    state: &AppState,
+    prepared: Option<(git::CommitInfo, Option<(String, Vec<u8>)>)>,
+) -> Option<CommitView> {
+    let (c, extracted) = prepared?;
+    Some(commit_view(state, c, extracted).await)
 }
 
 fn clone_urls(state: &AppState, owner: &str, repo: &str) -> (String, String) {
@@ -830,6 +843,12 @@ pub async fn home(
             motd,
             my_repos: Vec::new(),
             activities: Vec::new(),
+            social: og::site_social_meta(
+                &state.config.public_url,
+                "/",
+                "kitgit - self-hosted git forge",
+                og::SITE_TAGLINE,
+            ),
         });
     };
 
@@ -843,6 +862,12 @@ pub async fn home(
         motd,
         my_repos,
         activities,
+        social: og::site_social_meta(
+            &state.config.public_url,
+            "/",
+            "kitgit - self-hosted git forge",
+            og::SITE_TAGLINE,
+        ),
     })
 }
 
@@ -872,10 +897,23 @@ pub async fn explore(
         .into_iter()
         .map(|(repo, owner)| ExploreRepo { owner, repo })
         .collect();
+    let social = if query.trim().is_empty() {
+        og::site_social_meta(
+            &state.config.public_url,
+            "/explore",
+            "explore repositories - kitgit",
+            "Browse public repositories on kitgit.",
+        )
+    } else {
+        let title = format!("search '{query}' - kitgit");
+        let desc = format!("Public repositories matching '{query}' on kitgit.");
+        og::site_social_meta(&state.config.public_url, "/explore", &title, &desc)
+    };
     Ok(ExploreTemplate {
         viewer,
         repos,
         query,
+        social,
     })
 }
 
@@ -984,16 +1022,38 @@ pub async fn auth_oidc_start() -> AppResult<Response> {
     Ok(redirect("/auth/login"))
 }
 
+#[derive(Deserialize)]
+pub struct SignupQuery {
+    pub invite: Option<String>,
+}
+
 pub async fn auth_signup_page(
     State(state): State<AppState>,
     headers: HeaderMap,
+    Query(q): Query<SignupQuery>,
 ) -> AppResult<impl IntoResponse> {
     if current_user(&state.auth, &headers).await?.is_some() {
         return Ok(redirect("/").into_response());
     }
+    let signups_enabled = queries::signups_enabled(&state.pool).await;
+    let signup_disabled_message = queries::signup_disabled_message(&state.pool).await;
+    let mut invite = q.invite.unwrap_or_default().trim().to_string();
+    let mut error = None;
+    if !signups_enabled && !invite.is_empty() {
+        if queries::get_valid_invite(&state.pool, &invite)
+            .await?
+            .is_none()
+        {
+            error = Some("invalid or already used invite code".into());
+            invite.clear();
+        }
+    }
     Ok(SignupTemplate {
         viewer: None,
-        error: None,
+        error,
+        signups_enabled,
+        signup_disabled_message,
+        invite,
     }
     .into_response())
 }
@@ -1004,12 +1064,34 @@ pub struct SignupForm {
     pub email: String,
     pub password: String,
     pub display_name: Option<String>,
+    pub invite: Option<String>,
 }
 
 pub async fn auth_signup_submit(
     State(state): State<AppState>,
     Form(form): Form<SignupForm>,
 ) -> AppResult<Response> {
+    let signups_enabled = queries::signups_enabled(&state.pool).await;
+    let signup_disabled_message = queries::signup_disabled_message(&state.pool).await;
+    let invite_code = form.invite.clone().unwrap_or_default();
+    let invite_row = if !signups_enabled {
+        match queries::get_valid_invite(&state.pool, &invite_code).await? {
+            Some(inv) => Some(inv),
+            None => {
+                return Ok(SignupTemplate {
+                    viewer: None,
+                    error: Some("a valid invite code is required".into()),
+                    signups_enabled: false,
+                    signup_disabled_message,
+                    invite: invite_code,
+                }
+                .into_response());
+            }
+        }
+    } else {
+        None
+    };
+
     match auth::signup_with_password(
         &state.auth,
         &form.username,
@@ -1019,22 +1101,41 @@ pub async fn auth_signup_submit(
     )
     .await
     {
-        Ok(LoginOutcome::Complete { token, .. }) => Ok(redirect_with_cookies(
-            "/",
-            vec![
-                session_cookie_header(&token, 14 * 24 * 3600),
-                clear_mfa_pending_cookie(),
-            ],
-        )),
-        Ok(LoginOutcome::MfaRequired { pending_token }) => Ok(redirect_with_cookie(
-            "/auth/mfa",
-            mfa_pending_cookie_header(&pending_token, 10 * 60),
-        )),
+        Ok(outcome) => {
+            if let Some(inv) = invite_row {
+                if let LoginOutcome::Complete { user, .. } = &outcome {
+                    let _ = queries::consume_invite(&state.pool, inv.id, user.id).await;
+                } else if let Some(u) = queries::get_user_by_username(
+                    &state.pool,
+                    &form.username.trim().to_ascii_lowercase(),
+                )
+                .await?
+                {
+                    let _ = queries::consume_invite(&state.pool, inv.id, u.id).await;
+                }
+            }
+            match outcome {
+                LoginOutcome::Complete { token, .. } => Ok(redirect_with_cookies(
+                    "/",
+                    vec![
+                        session_cookie_header(&token, 14 * 24 * 3600),
+                        clear_mfa_pending_cookie(),
+                    ],
+                )),
+                LoginOutcome::MfaRequired { pending_token } => Ok(redirect_with_cookie(
+                    "/auth/mfa",
+                    mfa_pending_cookie_header(&pending_token, 10 * 60),
+                )),
+            }
+        }
         Err(e) => {
             tracing::warn!("signup failed: {e:#}");
             Ok(SignupTemplate {
                 viewer: None,
                 error: Some(crate::mfa::sanitize_user_error(&e.to_string())),
+                signups_enabled,
+                signup_disabled_message,
+                invite: invite_code,
             }
             .into_response())
         }
@@ -1080,17 +1181,147 @@ async fn require_site_admin(auth: &AuthState, headers: &HeaderMap) -> AppResult<
     Ok(user)
 }
 
+fn format_bytes(n: i64) -> String {
+    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
+    let mut v = n.max(0) as f64;
+    let mut i = 0usize;
+    while v >= 1024.0 && i < UNITS.len() - 1 {
+        v /= 1024.0;
+        i += 1;
+    }
+    if i == 0 {
+        format!("{} {}", n, UNITS[i])
+    } else {
+        format!("{v:.1} {}", UNITS[i])
+    }
+}
+
+fn dir_size(path: &std::path::Path) -> u64 {
+    let mut total = 0u64;
+    let Ok(entries) = std::fs::read_dir(path) else {
+        return 0;
+    };
+    for entry in entries.flatten() {
+        let Ok(meta) = entry.metadata() else {
+            continue;
+        };
+        if meta.is_file() {
+            total += meta.len();
+        } else if meta.is_dir() {
+            total += dir_size(&entry.path());
+        }
+    }
+    total
+}
+
+#[derive(Deserialize)]
+pub struct AdminQuery {
+    pub q: Option<String>,
+    pub page: Option<i64>,
+    pub repo_q: Option<String>,
+    pub repo_page: Option<i64>,
+    pub flash: Option<String>,
+}
+
+const ADMIN_PAGE_SIZE: i64 = 20;
+
 pub async fn admin_panel(
     State(state): State<AppState>,
     headers: HeaderMap,
+    Query(q): Query<AdminQuery>,
 ) -> AppResult<impl IntoResponse> {
     let viewer = require_site_admin(&state.auth, &headers).await?;
-    let users = queries::list_users(&state.pool).await?;
-    let motd = queries::get_setting(&state.pool, "motd").await.unwrap_or_default();
+    let user_query = q.q.unwrap_or_default();
+    let user_page = q.page.unwrap_or(1).max(1);
+    let repo_query = q.repo_q.unwrap_or_default();
+    let repo_page = q.repo_page.unwrap_or(1).max(1);
+
+    let (users, user_total) = queries::search_users(
+        &state.pool,
+        if user_query.is_empty() {
+            None
+        } else {
+            Some(user_query.as_str())
+        },
+        ADMIN_PAGE_SIZE,
+        (user_page - 1) * ADMIN_PAGE_SIZE,
+    )
+    .await?;
+    let user_pages = ((user_total + ADMIN_PAGE_SIZE - 1) / ADMIN_PAGE_SIZE).max(1);
+
+    let (repo_rows, repo_total) = queries::list_repos_admin(
+        &state.pool,
+        if repo_query.is_empty() {
+            None
+        } else {
+            Some(repo_query.as_str())
+        },
+        ADMIN_PAGE_SIZE,
+        (repo_page - 1) * ADMIN_PAGE_SIZE,
+    )
+    .await?;
+    let repo_pages = ((repo_total + ADMIN_PAGE_SIZE - 1) / ADMIN_PAGE_SIZE).max(1);
+    let repos = repo_rows
+        .into_iter()
+        .map(|(r, owner)| AdminRepoView {
+            id: r.id,
+            owner,
+            name: r.name,
+            visibility: r.visibility,
+            archived: r.archived,
+            updated_at: r.updated_at,
+        })
+        .collect();
+
+    let motd = queries::get_setting(&state.pool, "motd")
+        .await
+        .unwrap_or_default();
+    let announcement = queries::site_announcement(&state.pool).await;
+    let signups_enabled = queries::signups_enabled(&state.pool).await;
+    let signup_disabled_message = queries::signup_disabled_message(&state.pool).await;
+    let invites = queries::list_active_invites(&state.pool, 50)
+        .await?
+        .into_iter()
+        .map(|i| AdminInviteView {
+            id: i.id,
+            code: i.code,
+            created_at: i.created_at,
+        })
+        .collect();
+
+    let mut stats = queries::admin_stats(&state.pool).await?;
+    let repos_dir = state.config.repos_dir();
+    stats.disk_bytes = match tokio::task::spawn_blocking(move || dir_size(&repos_dir)).await {
+        Ok(n) => n,
+        Err(_) => 0,
+    };
+
     Ok(AdminTemplate {
         viewer: Some(viewer),
         users,
+        user_query,
+        user_page,
+        user_pages,
+        user_total,
         motd,
+        announcement,
+        signups_enabled,
+        signup_disabled_message,
+        invites,
+        repos,
+        repo_query,
+        repo_page,
+        repo_pages,
+        repo_total,
+        stats: AdminStatsView {
+            user_count: stats.user_count,
+            repo_count: stats.repo_count,
+            public_repo_count: stats.public_repo_count,
+            recent_signups: stats.recent_signups,
+            active_invites: stats.active_invites,
+            disk_label: format_bytes(stats.disk_bytes as i64),
+        },
+        flash: q.flash.filter(|s| !s.is_empty()),
     })
 }
 
@@ -1122,6 +1353,35 @@ pub async fn admin_set_user(
 }
 
 #[derive(Deserialize)]
+pub struct AdminSuspendForm {
+    pub user_id: Uuid,
+    pub is_suspended: Option<String>,
+}
+
+pub async fn admin_set_suspended(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<AdminSuspendForm>,
+) -> AppResult<Response> {
+    let viewer = require_site_admin(&state.auth, &headers).await?;
+    let suspend = checkbox(&form.is_suspended);
+    if form.user_id == viewer.id && suspend {
+        return Err(AppError::bad("cannot suspend yourself"));
+    }
+    let target = queries::get_user_by_id(&state.pool, form.user_id)
+        .await?
+        .ok_or_else(|| AppError::bad("user not found"))?;
+    if target.is_site_admin && suspend {
+        let count = queries::site_admin_count(&state.pool).await?;
+        if count <= 1 {
+            return Err(AppError::bad("cannot suspend the last site admin"));
+        }
+    }
+    queries::set_user_suspended(&state.pool, form.user_id, suspend).await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+#[derive(Deserialize)]
 pub struct AdminMotdForm {
     pub motd: Option<String>,
 }
@@ -1136,6 +1396,135 @@ pub async fn admin_save_motd(
     Ok(redirect_see_other("/admin"))
 }
 
+#[derive(Deserialize)]
+pub struct AdminAnnouncementForm {
+    pub announcement: Option<String>,
+}
+
+pub async fn admin_save_announcement(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<AdminAnnouncementForm>,
+) -> AppResult<Response> {
+    let _viewer = require_site_admin(&state.auth, &headers).await?;
+    queries::set_setting(
+        &state.pool,
+        "announcement",
+        form.announcement.unwrap_or_default().trim(),
+    )
+    .await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+#[derive(Deserialize)]
+pub struct AdminSignupsForm {
+    pub signups_enabled: Option<String>,
+    pub signup_disabled_message: Option<String>,
+}
+
+pub async fn admin_save_signups(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(form): Form<AdminSignupsForm>,
+) -> AppResult<Response> {
+    let _viewer = require_site_admin(&state.auth, &headers).await?;
+    let enabled = checkbox(&form.signups_enabled);
+    queries::set_setting(
+        &state.pool,
+        "signups_enabled",
+        if enabled { "true" } else { "false" },
+    )
+    .await?;
+    let raw = form.signup_disabled_message.unwrap_or_default();
+    let trimmed = raw.trim();
+    let message = if trimmed.is_empty() {
+        queries::DEFAULT_SIGNUP_DISABLED_MESSAGE
+    } else {
+        trimmed
+    };
+    queries::set_setting(&state.pool, "signup_disabled_message", message).await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+pub async fn admin_create_invite(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+) -> AppResult<Response> {
+    let viewer = require_site_admin(&state.auth, &headers).await?;
+    let inv = queries::create_invite(&state.pool, viewer.id).await?;
+    let flash_raw = format!("invite created: {}", inv.code);
+    let flash = urlencoding::encode(&flash_raw);
+    Ok(redirect_see_other(&format!("/admin?flash={flash}")))
+}
+
+pub async fn admin_revoke_invite(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+) -> AppResult<Response> {
+    let _viewer = require_site_admin(&state.auth, &headers).await?;
+    queries::revoke_invite(&state.pool, id).await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+#[derive(Deserialize)]
+pub struct AdminVisibilityForm {
+    pub visibility: String,
+}
+
+pub async fn admin_repo_visibility(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+    Form(form): Form<AdminVisibilityForm>,
+) -> AppResult<Response> {
+    let _viewer = require_site_admin(&state.auth, &headers).await?;
+    let visibility = match form.visibility.as_str() {
+        "public" => "public",
+        _ => "private",
+    };
+    queries::set_repo_visibility(&state.pool, id, visibility).await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+#[derive(Deserialize)]
+pub struct AdminDeleteRepoForm {
+    pub confirm: Option<String>,
+}
+
+pub async fn admin_repo_delete(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+    Form(form): Form<AdminDeleteRepoForm>,
+) -> AppResult<Response> {
+    let viewer = require_site_admin(&state.auth, &headers).await?;
+    let (repo, owner) = queries::get_repo_by_id(&state.pool, id)
+        .await?
+        .ok_or_else(|| AppError::bad("repository not found"))?;
+    let confirm = form.confirm.unwrap_or_default();
+    if confirm != repo.name && confirm != format!("{owner}/{}", repo.name) {
+        return Err(AppError::bad("type the repository name to confirm delete"));
+    }
+    queries::delete_repo(&state.pool, repo.id).await?;
+    let _ = git::remove_bare(&state.config.repos_dir(), &owner, &repo.name);
+    queries::record_activity(
+        &state.pool,
+        Some(viewer.id),
+        None,
+        "repo.delete",
+        "deleted repository",
+        serde_json::json!({ "owner": owner, "name": repo.name, "admin": true }),
+    )
+    .await?;
+    Ok(redirect_see_other("/admin"))
+}
+
+pub async fn site_banner_json(State(state): State<AppState>) -> impl IntoResponse {
+    let message = queries::site_announcement(&state.pool).await;
+    axum::Json(serde_json::json!({ "message": message }))
+}
+
 // ── new repo ─────────────────────────────────────────────────────────────────
 
 pub async fn new_repo_form(
@@ -1363,6 +1752,7 @@ pub async fn keys_settings(
 pub struct AddKeyForm {
     pub name: String,
     pub public_key: String,
+    pub key_usage: Option<String>,
 }
 
 pub async fn keys_add(
@@ -1373,6 +1763,7 @@ pub async fn keys_add(
     let user = require_login(&state.auth, &headers).await?;
     let name = form.name.trim();
     let public_key = form.public_key.trim();
+    let key_usage = form.key_usage.as_deref().unwrap_or("authentication");
     if name.is_empty() || public_key.is_empty() {
         let keys = queries::list_ssh_keys(&state.pool, user.id).await?;
         let gpg_keys = queries::list_gpg_keys(&state.pool, user.id).await?;
@@ -1385,12 +1776,28 @@ pub async fn keys_add(
         .into_response());
     }
     let fp = fingerprint_ssh_pubkey(public_key).map_err(|e| AppError::bad(e.to_string()))?;
-    queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp)
+    queries::add_ssh_key(&state.pool, user.id, name, public_key, &fp, key_usage)
         .await
         .map_err(|e| AppError::bad(format!("could not add key: {e}")))?;
     Ok(redirect_see_other("/settings/keys"))
 }
 
+#[derive(Deserialize)]
+pub struct KeyUsageForm {
+    pub key_usage: String,
+}
+
+pub async fn keys_update_usage(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path(id): Path<Uuid>,
+    Form(form): Form<KeyUsageForm>,
+) -> AppResult<Response> {
+    let user = require_login(&state.auth, &headers).await?;
+    queries::update_ssh_key_usage(&state.pool, user.id, id, &form.key_usage).await?;
+    Ok(redirect_see_other("/settings/keys"))
+}
+
 pub async fn keys_delete(
     State(state): State<AppState>,
     headers: HeaderMap,
@@ -1546,20 +1953,18 @@ pub async fn repo_home(
         }
     }
 
-    let latest_commit = grepo
-        .as_ref()
-        .and_then(|g| latest_commit_view(g, &current_branch));
+    let latest_commit = match grepo.as_ref() {
+        Some(g) => {
+            let prepared = prepare_latest_commit(g, &current_branch);
+            latest_commit_view(&state, prepared).await
+        }
+        None => None,
+    };
     let languages = language_stat_views(languages);
 
     let forked_from = if let Some(fid) = repository.fork_of_id {
         match queries::get_repo_by_id(&state.pool, fid).await? {
-            Some(parent) => {
-                let parent_owner = queries::get_user_by_id(&state.pool, parent.owner_id)
-                    .await?
-                    .map(|u| u.username)
-                    .unwrap_or_else(|| "unknown".into());
-                Some((parent_owner, parent.name))
-            }
+            Some((parent, parent_owner)) => Some((parent_owner, parent.name)),
             None => None,
         }
     } else {
@@ -1574,6 +1979,7 @@ pub async fn repo_home(
         (false, false)
     };
 
+    let social = og::repo_social_meta(&state.config.public_url, &owner, &repository);
     Ok(RepoHomeTemplate {
         viewer,
         owner: owner_user,
@@ -1592,9 +1998,36 @@ pub async fn repo_home(
         forked_from,
         starred,
         watching,
+        social,
     })
 }
 
+pub async fn repo_og_image(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Path((owner, repo)): Path<(String, String)>,
+) -> AppResult<Response> {
+    let (repository, owner_user, _viewer, _access) =
+        load_repo_context(&state, &owner, &repo, &headers).await?;
+    let png = og::render_repo_card(&owner_user.username, &repository);
+    Ok(Response::builder()
+        .status(StatusCode::OK)
+        .header(CONTENT_TYPE, "image/png")
+        .header(axum::http::header::CACHE_CONTROL, "public, max-age=3600")
+        .body(Body::from(png))
+        .unwrap())
+}
+
+pub async fn site_og_image() -> Response {
+    let png = og::render_site_card();
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(CONTENT_TYPE, "image/png")
+        .header(axum::http::header::CACHE_CONTROL, "public, max-age=86400")
+        .body(Body::from(png))
+        .unwrap()
+}
+
 pub async fn repo_tree(
     State(state): State<AppState>,
     headers: HeaderMap,
@@ -1628,7 +2061,8 @@ pub async fn repo_tree(
         _ => None,
     };
     let branches = git::list_branches(&grepo).unwrap_or_default();
-    let latest_commit = latest_commit_view(&grepo, &branch);
+    let prepared = prepare_latest_commit(&grepo, &branch);
+    let latest_commit = latest_commit_view(&state, prepared).await;
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     Ok(RepoTreeTemplate {
         viewer,
@@ -1720,11 +2154,15 @@ pub async fn repo_commits(
     let branch = q
         .branch
         .unwrap_or_else(|| repository.default_branch.clone());
-    let commits = git::list_commits(&grepo, &branch, 100)
-        .unwrap_or_default()
+    let raw_commits = git::list_commits(&grepo, &branch, 100).unwrap_or_default();
+    let prepared: Vec<_> = raw_commits
         .into_iter()
-        .map(commit_view)
+        .map(|c| {
+            let extracted = git::extract_commit_signature(&grepo, &c.id);
+            (c, extracted)
+        })
         .collect();
+    let commits = commit_views(&state, prepared).await;
     let branches = git::list_branches(&grepo).unwrap_or_default();
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
     Ok(RepoCommitsTemplate {
@@ -1750,6 +2188,7 @@ pub async fn repo_commit(
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
         .map_err(|_| AppError::not_found())?;
     let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
+    let extracted = git::extract_commit_signature(&grepo, &commit.id);
     let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
     let diff_html = render_diff_html(&diff);
     let message_html = linkify_commit_message(
@@ -1765,7 +2204,7 @@ pub async fn repo_commit(
         owner: owner_user,
         repo: repository,
         access,
-        commit: commit_view(commit),
+        commit: commit_view(&state, commit, extracted).await,
         message_html,
         diff_html,
         clone_http: clone_urls(&state, &owner, &repo).0,
@@ -1783,6 +2222,7 @@ pub async fn repo_diff(
     let grepo = git::open_bare(&state.config.repos_dir(), &owner, &repo)
         .map_err(|_| AppError::not_found())?;
     let commit = git::get_commit(&grepo, &id).map_err(|_| AppError::not_found())?;
+    let extracted = git::extract_commit_signature(&grepo, &commit.id);
     let diff = git::commit_diff(&grepo, &id).unwrap_or_default();
     let diff_html = render_diff_html(&diff);
     let (clone_http, clone_ssh) = clone_urls(&state, &owner, &repo);
@@ -1791,7 +2231,7 @@ pub async fn repo_diff(
         owner: owner_user,
         repo: repository,
         access,
-        commit: commit_view(commit),
+        commit: commit_view(&state, commit, extracted).await,
         diff_html,
         clone_http,
         clone_ssh,
@@ -2046,7 +2486,38 @@ pub async fn repo_branches(
     })
 }
 
-fn commit_view(c: git::CommitInfo) -> CommitView {
+async fn commit_view(
+    state: &AppState,
+    c: git::CommitInfo,
+    extracted: Option<(String, Vec<u8>)>,
+) -> CommitView {
+    let verification = match extracted {
+        Some((sig, payload)) => {
+            git::verify_commit_signature(
+                &state.pool,
+                &sig,
+                &payload,
+                &c.email,
+                c.time,
+            )
+            .await
+        }
+        None => None,
+    };
+    let (verified, verify_kind, verify_fingerprint, verify_fingerprint_label, verified_at) =
+        match verification {
+            Some(v) => {
+                let label = v.fingerprint_label().to_string();
+                (
+                    true,
+                    v.kind.clone(),
+                    v.fingerprint,
+                    label,
+                    v.verified_at,
+                )
+            }
+            None => (false, String::new(), String::new(), String::new(), String::new()),
+        };
     CommitView {
         id: c.id,
         short_id: c.short_id,
@@ -2054,7 +2525,23 @@ fn commit_view(c: git::CommitInfo) -> CommitView {
         author: c.author,
         email: c.email,
         time: c.time,
+        verified,
+        verify_kind,
+        verify_fingerprint,
+        verify_fingerprint_label,
+        verified_at,
+    }
+}
+
+async fn commit_views(
+    state: &AppState,
+    commits: Vec<(git::CommitInfo, Option<(String, Vec<u8>)>)>,
+) -> Vec<CommitView> {
+    let mut out = Vec::with_capacity(commits.len());
+    for (c, extracted) in commits {
+        out.push(commit_view(state, c, extracted).await);
     }
+    out
 }
 
 // ── issues ───────────────────────────────────────────────────────────────────
@@ -2462,11 +2949,15 @@ pub async fn pull_view(
     let mut commits = Vec::new();
     let mut diff_files = Vec::new();
     if let Ok(g) = git::open_bare(&state.config.repos_dir(), &owner, &repo) {
-        commits = git::commits_between(&g, &pull.source_branch, &pull.target_branch, 100)
+        let prepared: Vec<_> = git::commits_between(&g, &pull.source_branch, &pull.target_branch, 100)
             .unwrap_or_default()
             .into_iter()
-            .map(commit_view)
+            .map(|c| {
+                let extracted = git::extract_commit_signature(&g, &c.id);
+                (c, extracted)
+            })
             .collect();
+        commits = commit_views(&state, prepared).await;
         let diff =
             git::branch_diff(&g, &pull.source_branch, &pull.target_branch).unwrap_or_default();
         diff_files = parse_diff_files(&diff);
@@ -2639,20 +3130,6 @@ pub async fn pull_close(
 
 // ── releases ─────────────────────────────────────────────────────────────────
 
-fn format_bytes(n: i64) -> String {
-    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
-    let mut v = n.max(0) as f64;
-    let mut i = 0usize;
-    while v >= 1024.0 && i < UNITS.len() - 1 {
-        v /= 1024.0;
-        i += 1;
-    }
-    if i == 0 {
-        format!("{} {}", n, UNITS[i])
-    } else {
-        format!("{v:.1} {}", UNITS[i])
-    }
-}
 
 fn asset_views(assets: Vec<crate::db::models::ReleaseAsset>) -> Vec<ReleaseAssetView> {
     assets
diff --git a/src/web/routes_extra.rs b/src/web/routes_extra.rs
index 8a3865f..712ab5f 100644
--- a/src/web/routes_extra.rs
+++ b/src/web/routes_extra.rs
@@ -784,10 +784,8 @@ pub async fn gpg_add(
     if !key.contains("BEGIN PGP PUBLIC KEY") {
         return Err(AppError::bad("expected a PGP public key block"));
     }
-    let mut hasher = sha2::Sha256::new();
-    use sha2::Digest;
-    hasher.update(key.as_bytes());
-    let fp = hex::encode(hasher.finalize());
+    let fp = crate::git::verify::gpg_fingerprint_from_armor(key)
+        .unwrap_or_else(|_| crate::git::verify::gpg_fingerprint_fallback(key));
     queries::add_gpg_key(&state.pool, user.id, form.name.trim(), key, &fp).await?;
     Ok(redirect_see_other("/settings/keys"))
 }
diff --git a/src/web/templates.rs b/src/web/templates.rs
index 1caa7ec..a2932d4 100644
--- a/src/web/templates.rs
+++ b/src/web/templates.rs
@@ -1,3 +1,4 @@
+use crate::og::SocialMeta;
 use crate::db::models::{
     Access, BranchRule, CommitDay, GpgKey, Issue, PullRequest, Release, Repository, SshKey, User,
     UserEmail,
@@ -64,6 +65,11 @@ pub struct CommitView {
     pub author: String,
     pub email: String,
     pub time: i64,
+    pub verified: bool,
+    pub verify_kind: String,
+    pub verify_fingerprint: String,
+    pub verify_fingerprint_label: String,
+    pub verified_at: String,
 }
 
 pub struct CollaboratorView {
@@ -112,6 +118,7 @@ pub struct HomeTemplate {
     pub motd: String,
     pub my_repos: Vec<Repository>,
     pub activities: Vec<ActivityRow>,
+    pub social: SocialMeta,
 }
 
 pub struct ExploreRepo {
@@ -125,6 +132,7 @@ pub struct ExploreTemplate {
     pub viewer: Option<User>,
     pub repos: Vec<ExploreRepo>,
     pub query: String,
+    pub social: SocialMeta,
 }
 
 #[derive(Template, WebTemplate)]
@@ -139,6 +147,9 @@ pub struct LoginTemplate {
 pub struct SignupTemplate {
     pub viewer: Option<User>,
     pub error: Option<String>,
+    pub signups_enabled: bool,
+    pub signup_disabled_message: String,
+    pub invite: String,
 }
 
 #[derive(Template, WebTemplate)]
@@ -251,6 +262,7 @@ pub struct RepoHomeTemplate {
     pub starred: bool,
     pub watching: bool,
     pub forked_from: Option<(String, String)>,
+    pub social: SocialMeta,
 }
 
 #[derive(Template, WebTemplate)]
@@ -520,5 +532,44 @@ pub struct RepoSettingsTemplate {
 pub struct AdminTemplate {
     pub viewer: Option<User>,
     pub users: Vec<User>,
+    pub user_query: String,
+    pub user_page: i64,
+    pub user_pages: i64,
+    pub user_total: i64,
     pub motd: String,
+    pub announcement: String,
+    pub signups_enabled: bool,
+    pub signup_disabled_message: String,
+    pub invites: Vec<AdminInviteView>,
+    pub repos: Vec<AdminRepoView>,
+    pub repo_query: String,
+    pub repo_page: i64,
+    pub repo_pages: i64,
+    pub repo_total: i64,
+    pub stats: AdminStatsView,
+    pub flash: Option<String>,
+}
+
+pub struct AdminInviteView {
+    pub id: Uuid,
+    pub code: String,
+    pub created_at: DateTime<Utc>,
+}
+
+pub struct AdminRepoView {
+    pub id: Uuid,
+    pub owner: String,
+    pub name: String,
+    pub visibility: String,
+    pub archived: bool,
+    pub updated_at: DateTime<Utc>,
+}
+
+pub struct AdminStatsView {
+    pub user_count: i64,
+    pub repo_count: i64,
+    pub public_repo_count: i64,
+    pub recent_signups: i64,
+    pub active_invites: i64,
+    pub disk_label: String,
 }
diff --git a/static/brand.css b/static/brand.css
index 2bce8b2..d50db11 100644
--- a/static/brand.css
+++ b/static/brand.css
@@ -302,6 +302,64 @@ a:hover {
   font-size: 0.875rem;
 }
 
+.kg-flash--banner {
+  margin: 0 0 var(--kg-space-3);
+  white-space: pre-wrap;
+}
+
+.kg-site-banner {
+  position: sticky;
+  top: 0;
+  z-index: 50;
+  padding: 0.65rem 1.25rem;
+  border-bottom: 1px solid var(--kg-line-strong);
+  background: var(--kg-bg);
+  color: var(--kg-fg);
+  font-size: 0.875rem;
+  white-space: pre-wrap;
+  text-align: center;
+}
+
+.kg-admin-stats {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
+  gap: 0.75rem 1rem;
+  margin: 1.5rem 0 0;
+  max-width: 48rem;
+}
+
+.kg-admin-stats div {
+  display: flex;
+  flex-direction: column;
+  gap: 0.2rem;
+  padding: 0.65rem 0;
+  border-bottom: 1px solid var(--kg-line);
+}
+
+.kg-admin-stats strong {
+  font-size: 1.15rem;
+  letter-spacing: var(--kg-tracking-tight);
+}
+
+.kg-admin-stats span {
+  color: var(--kg-muted);
+  font-size: 0.75rem;
+}
+
+.kg-admin-actions {
+  display: inline-flex;
+  flex-wrap: wrap;
+  gap: 0.35rem;
+  justify-content: flex-end;
+}
+
+.kg-admin-pager {
+  display: flex;
+  gap: 1rem;
+  align-items: center;
+  margin-top: 1rem;
+}
+
 .kg-form {
   display: flex;
   flex-direction: column;
@@ -1399,6 +1457,86 @@ html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; }
   color: var(--kg-muted);
 }
 
+.kg-badge--verified {
+  color: #1a7f37;
+  border-color: #1a7f37;
+  background: color-mix(in srgb, #1a7f37 12%, transparent);
+  text-transform: none;
+  letter-spacing: 0.02em;
+  font-weight: 600;
+  cursor: pointer;
+}
+
+.kg-verify {
+  position: relative;
+  display: inline-block;
+  vertical-align: middle;
+}
+
+.kg-verify > summary {
+  list-style: none;
+}
+
+.kg-verify > summary::-webkit-details-marker {
+  display: none;
+}
+
+.kg-verify__panel {
+  position: absolute;
+  left: 0;
+  top: calc(100% + 0.35rem);
+  z-index: 30;
+  min-width: 16rem;
+  max-width: min(28rem, 90vw);
+  padding: var(--kg-space-3);
+  background: var(--kg-bg);
+  border: 1px solid var(--kg-line-strong);
+  box-shadow: 0 8px 24px color-mix(in srgb, var(--kg-fg) 12%, transparent);
+}
+
+.kg-verify__fp {
+  margin: 0 0 0.35rem;
+  font-size: 0.8125rem;
+  line-height: 1.4;
+  word-break: break-all;
+}
+
+.kg-verify__fp code {
+  font-size: 0.75rem;
+}
+
+.kg-key-row {
+  align-items: flex-start;
+}
+
+.kg-key-row__actions {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  flex-wrap: wrap;
+}
+
+.kg-inline-form select {
+  font-family: var(--kg-font);
+  font-size: 0.75rem;
+  border: 1px solid var(--kg-line);
+  background: var(--kg-bg-raised);
+  color: var(--kg-fg);
+  padding: 0.25em 0.4em;
+}
+
+.kg-sr-only {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  padding: 0;
+  margin: -1px;
+  overflow: hidden;
+  clip: rect(0, 0, 0, 0);
+  white-space: nowrap;
+  border: 0;
+}
+
 .kg-clone {
   display: block;
   padding: var(--kg-space-2) var(--kg-space-3);
diff --git a/templates/admin.html b/templates/admin.html
index 936a9f5..f16f5c8 100644
--- a/templates/admin.html
+++ b/templates/admin.html
@@ -5,7 +5,32 @@
   <span class="kg-kicker">admin</span>
   <h1 class="kg-display">site settings</h1>
 
-  <form class="kg-form" method="post" action="/admin/motd" style="margin:1.5rem 0 2.5rem;max-width:36rem">
+  {% if let Some(msg) = flash %}
+    <div class="kg-flash">{{ msg }}</div>
+  {% endif %}
+
+  <div class="kg-admin-stats">
+    <div><strong>{{ stats.user_count }}</strong><span>users</span></div>
+    <div><strong>{{ stats.repo_count }}</strong><span>repos</span></div>
+    <div><strong>{{ stats.public_repo_count }}</strong><span>public</span></div>
+    <div><strong>{{ stats.recent_signups }}</strong><span>signups (7d)</span></div>
+    <div><strong>{{ stats.active_invites }}</strong><span>invites</span></div>
+    <div><strong>{{ stats.disk_label }}</strong><span>disk</span></div>
+  </div>
+
+  <form class="kg-form" method="post" action="/admin/announcement" style="margin:2rem 0;max-width:36rem">
+    <h2 class="kg-title" style="margin:0">announcement</h2>
+    <p class="kg-muted">Sticky site-wide banner (maintenance, incidents). Leave empty to hide.</p>
+    <label>
+      banner message
+      <textarea name="announcement" rows="2" placeholder="Shown at the top of every page">{{ announcement }}</textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">save announcement</button>
+    </div>
+  </form>
+
+  <form class="kg-form" method="post" action="/admin/motd" style="margin:0 0 2.5rem;max-width:36rem">
     <label>
       message of the day
       <textarea name="motd" rows="3" placeholder="Shown on the frontpage">{{ motd }}</textarea>
@@ -15,30 +40,163 @@
     </div>
   </form>
 
-  <h2 class="kg-title">site admins</h2>
-  <p class="kg-muted">Pick who can open this panel. The first successful login becomes admin automatically if none exist.</p>
+  <form class="kg-form" method="post" action="/admin/signups" style="margin:0 0 1.5rem;max-width:36rem">
+    <h2 class="kg-title" style="margin:0">signups</h2>
+    <p class="kg-muted">When disabled, new accounts need a single-use invite code.</p>
+    <label class="kg-check">
+      <input type="checkbox" name="signups_enabled" value="on"{% if signups_enabled %} checked{% endif %} />
+      allow open signups
+    </label>
+    <label>
+      disabled banner message
+      <textarea name="signup_disabled_message" rows="3" placeholder="Shown on the signup page when signups are off">{{ signup_disabled_message }}</textarea>
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">save signup settings</button>
+    </div>
+  </form>
 
-  <ul class="kg-list" style="margin-top:1.5rem">
+  <div style="margin:0 0 2.5rem;max-width:40rem">
+    <h2 class="kg-title">invites</h2>
+    <p class="kg-muted">Create single-use codes for invite-only signups.</p>
+    <form method="post" action="/admin/invites" style="margin:0.75rem 0 1rem">
+      <button class="kg-btn" type="submit">create invite</button>
+    </form>
+    <ul class="kg-list">
+      {% for inv in invites %}
+      <li>
+        <div>
+          <code>{{ inv.code }}</code>
+          <span class="kg-meta"> · {{ inv.created_at }}</span>
+        </div>
+        <form method="post" action="/admin/invites/{{ inv.id }}/revoke" style="display:inline">
+          <button class="kg-btn kg-btn--ghost" type="submit">revoke</button>
+        </form>
+      </li>
+      {% else %}
+      <li><span class="kg-muted">no active invites</span></li>
+      {% endfor %}
+    </ul>
+  </div>
+
+  <h2 class="kg-title">users</h2>
+  <p class="kg-muted">Search, suspend accounts, and manage site admins.</p>
+  <form method="get" action="/admin" class="kg-form" style="margin:1rem 0;max-width:28rem;flex-direction:row;align-items:end;gap:0.75rem">
+    <label style="flex:1">
+      search
+      <input type="search" name="q" value="{{ user_query }}" placeholder="username, email, name" />
+    </label>
+    {% if !repo_query.is_empty() %}<input type="hidden" name="repo_q" value="{{ repo_query }}" />{% endif %}
+    {% if repo_page > 1 %}<input type="hidden" name="repo_page" value="{{ repo_page }}" />{% endif %}
+    <button class="kg-btn" type="submit">search</button>
+  </form>
+  <p class="kg-meta">{{ user_total }} user{% if user_total != 1 %}s{% endif %}</p>
+
+  <ul class="kg-list" style="margin-top:1rem">
     {% for u in users %}
     <li>
       <div>
         <a href="/{{ u.username }}">{{ u.username }}</a>
-        <span class="kg-meta">{{ u.email }}{% if u.is_site_admin %} · admin{% endif %}</span>
+        <span class="kg-meta">
+          {{ u.email }}
+          {% if u.is_site_admin %} · admin{% endif %}
+          {% if u.is_suspended %} · suspended{% endif %}
+        </span>
       </div>
-      <form method="post" action="/admin/users" style="display:inline">
-        <input type="hidden" name="user_id" value="{{ u.id }}" />
-        {% if u.is_site_admin %}
-          <input type="hidden" name="is_site_admin" value="false" />
-          <button class="kg-btn kg-btn--ghost" type="submit">revoke admin</button>
+      <div class="kg-admin-actions">
+        <form method="post" action="/admin/users/suspend" style="display:inline">
+          <input type="hidden" name="user_id" value="{{ u.id }}" />
+          {% if u.is_suspended %}
+            <input type="hidden" name="is_suspended" value="false" />
+            <button class="kg-btn kg-btn--ghost" type="submit">unsuspend</button>
+          {% else %}
+            <input type="hidden" name="is_suspended" value="true" />
+            <button class="kg-btn kg-btn--ghost" type="submit">suspend</button>
+          {% endif %}
+        </form>
+        <form method="post" action="/admin/users" style="display:inline">
+          <input type="hidden" name="user_id" value="{{ u.id }}" />
+          {% if u.is_site_admin %}
+            <input type="hidden" name="is_site_admin" value="false" />
+            <button class="kg-btn kg-btn--ghost" type="submit">revoke admin</button>
+          {% else %}
+            <input type="hidden" name="is_site_admin" value="true" />
+            <button class="kg-btn" type="submit">make admin</button>
+          {% endif %}
+        </form>
+      </div>
+    </li>
+    {% else %}
+    <li><span class="kg-muted">no users match.</span></li>
+    {% endfor %}
+  </ul>
+  {% if user_pages > 1 %}
+  <p class="kg-meta kg-admin-pager">
+    {% if user_page > 1 %}
+      <a href="/admin?q={{ user_query }}&amp;page={{ user_page - 1 }}&amp;repo_q={{ repo_query }}&amp;repo_page={{ repo_page }}">← prev</a>
+    {% endif %}
+    page {{ user_page }} / {{ user_pages }}
+    {% if user_page < user_pages %}
+      <a href="/admin?q={{ user_query }}&amp;page={{ user_page + 1 }}&amp;repo_q={{ repo_query }}&amp;repo_page={{ repo_page }}">next →</a>
+    {% endif %}
+  </p>
+  {% endif %}
+
+  <h2 class="kg-title" style="margin-top:2.5rem">repositories</h2>
+  <p class="kg-muted">Force private or delete repositories across the instance.</p>
+  <form method="get" action="/admin" class="kg-form" style="margin:1rem 0;max-width:28rem;flex-direction:row;align-items:end;gap:0.75rem">
+    <label style="flex:1">
+      search repos
+      <input type="search" name="repo_q" value="{{ repo_query }}" placeholder="owner, name, description" />
+    </label>
+    {% if !user_query.is_empty() %}<input type="hidden" name="q" value="{{ user_query }}" />{% endif %}
+    {% if user_page > 1 %}<input type="hidden" name="page" value="{{ user_page }}" />{% endif %}
+    <button class="kg-btn" type="submit">search</button>
+  </form>
+  <p class="kg-meta">{{ repo_total }} repositor{% if repo_total != 1 %}ies{% else %}y{% endif %}</p>
+
+  <ul class="kg-list" style="margin-top:1rem">
+    {% for r in repos %}
+    <li>
+      <div>
+        <a href="/{{ r.owner }}/{{ r.name }}">{{ r.owner }}/{{ r.name }}</a>
+        <span class="kg-meta">
+          {{ r.visibility }}{% if r.archived %} · archived{% endif %}
+          · {{ r.updated_at }}
+        </span>
+      </div>
+      <div class="kg-admin-actions">
+        {% if r.visibility == "public" %}
+        <form method="post" action="/admin/repos/{{ r.id }}/visibility" style="display:inline">
+          <input type="hidden" name="visibility" value="private" />
+          <button class="kg-btn kg-btn--ghost" type="submit">force private</button>
+        </form>
         {% else %}
-          <input type="hidden" name="is_site_admin" value="true" />
-          <button class="kg-btn" type="submit">make admin</button>
+        <form method="post" action="/admin/repos/{{ r.id }}/visibility" style="display:inline">
+          <input type="hidden" name="visibility" value="public" />
+          <button class="kg-btn kg-btn--ghost" type="submit">make public</button>
+        </form>
         {% endif %}
-      </form>
+        <form method="post" action="/admin/repos/{{ r.id }}/delete" style="display:inline" onsubmit="return confirm('Delete {{ r.owner }}/{{ r.name }} permanently?');">
+          <input type="hidden" name="confirm" value="{{ r.name }}" />
+          <button class="kg-btn kg-btn--ghost" type="submit">delete</button>
+        </form>
+      </div>
     </li>
     {% else %}
-    <li><span class="kg-muted">no users yet — create an account or log in first.</span></li>
+    <li><span class="kg-muted">no repositories match.</span></li>
     {% endfor %}
   </ul>
+  {% if repo_pages > 1 %}
+  <p class="kg-meta kg-admin-pager">
+    {% if repo_page > 1 %}
+      <a href="/admin?q={{ user_query }}&amp;page={{ user_page }}&amp;repo_q={{ repo_query }}&amp;repo_page={{ repo_page - 1 }}">← prev</a>
+    {% endif %}
+    page {{ repo_page }} / {{ repo_pages }}
+    {% if repo_page < repo_pages %}
+      <a href="/admin?q={{ user_query }}&amp;page={{ user_page }}&amp;repo_q={{ repo_query }}&amp;repo_page={{ repo_page + 1 }}">next →</a>
+    {% endif %}
+  </p>
+  {% endif %}
 </section>
 {% endblock %}
diff --git a/templates/explore.html b/templates/explore.html
index 63baa33..55810e9 100644
--- a/templates/explore.html
+++ b/templates/explore.html
@@ -1,6 +1,10 @@
 {% extends "layout.html" %}
 
-{% block title %}explore — kitgit{% endblock %}
+{% block title %}{{ social.title }}{% endblock %}
+
+{% block meta %}
+{% include "partials/social_meta.html" %}
+{% endblock %}
 
 {% block content %}
 <section class="kg-section">
diff --git a/templates/home.html b/templates/home.html
index 27d55ce..89d91cc 100644
--- a/templates/home.html
+++ b/templates/home.html
@@ -1,6 +1,10 @@
 {% extends "layout.html" %}
 
-{% block title %}kitgit{% endblock %}
+{% block title %}{{ social.title }}{% endblock %}
+
+{% block meta %}
+{% include "partials/social_meta.html" %}
+{% endblock %}
 
 {% block content %}
 {% if let Some(u) = viewer %}
diff --git a/templates/keys_settings.html b/templates/keys_settings.html
index 71251aa..ecd9ef5 100644
--- a/templates/keys_settings.html
+++ b/templates/keys_settings.html
@@ -17,17 +17,29 @@
   {% endif %}
 
   <span class="kg-kicker" style="margin-top:1.5rem">SSH keys</span>
+  <p class="kg-muted">Authentication keys let you push/pull over SSH. Signing keys verify signed commits.</p>
   {% if !keys.is_empty() %}
     <ul class="kg-list">
       {% for key in keys %}
-      <li>
+      <li class="kg-key-row">
         <span>
           <strong>{{ key.name }}</strong>
           <span class="kg-meta">{{ key.fingerprint }}</span>
+          <span class="kg-badge">{{ key.usage_label() }}</span>
+        </span>
+        <span class="kg-key-row__actions">
+          <form method="post" action="/settings/keys/{{ key.id }}/usage" class="kg-inline-form">
+            <label class="kg-sr-only" for="usage-{{ key.id }}">usage</label>
+            <select id="usage-{{ key.id }}" name="key_usage" onchange="this.form.submit()">
+              <option value="authentication"{% if key.key_usage == "authentication" %} selected{% endif %}>Authentication</option>
+              <option value="signing"{% if key.key_usage == "signing" %} selected{% endif %}>Signing</option>
+              <option value="both"{% if key.key_usage == "both" %} selected{% endif %}>Authentication &amp; Signing</option>
+            </select>
+          </form>
+          <form method="post" action="/settings/keys/{{ key.id }}/delete">
+            <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
+          </form>
         </span>
-        <form method="post" action="/settings/keys/{{ key.id }}/delete">
-          <button class="kg-btn kg-btn--danger-ghost" type="submit">delete</button>
-        </form>
       </li>
       {% endfor %}
     </ul>
@@ -42,6 +54,14 @@
       <input type="text" name="name" required />
     </label>
     <label>
+      usage
+      <select name="key_usage">
+        <option value="authentication" selected>Authentication</option>
+        <option value="signing">Signing</option>
+        <option value="both">Authentication &amp; Signing</option>
+      </select>
+    </label>
+    <label>
       public key
       <textarea name="public_key" rows="4" required placeholder="ssh-ed25519 AAAA…"></textarea>
     </label>
diff --git a/templates/layout.html b/templates/layout.html
index b1a3563..f589a61 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -4,6 +4,7 @@
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1" />
   <title>{% block title %}kitgit{% endblock %}</title>
+  {% block meta %}{% endblock %}
   <link rel="preconnect" href="https://fonts.googleapis.com" />
   <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
   <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
@@ -11,6 +12,7 @@
   {% block head %}{% endblock %}
 </head>
 <body>
+  <div id="kg-site-banner" class="kg-site-banner" hidden role="status"></div>
   <div class="kg-shell">
     <header class="kg-bar">
       <a class="kg-mark" href="/">
@@ -40,6 +42,18 @@
     <footer class="kg-footer">kitgit</footer>
   </div>
   <script>
+  (async () => {
+    try {
+      const r = await fetch('/site-banner.json', { credentials: 'same-origin' });
+      if (!r.ok) return;
+      const j = await r.json();
+      if (j && j.message) {
+        const el = document.getElementById('kg-site-banner');
+        el.textContent = j.message;
+        el.hidden = false;
+      }
+    } catch (_) {}
+  })();
   document.addEventListener('click', (e) => {
     const btn = e.target.closest('[data-copy]');
     if (!btn) return;
diff --git a/templates/partials/branch_bar.html b/templates/partials/branch_bar.html
index 1468df8..5069541 100644
--- a/templates/partials/branch_bar.html
+++ b/templates/partials/branch_bar.html
@@ -12,6 +12,18 @@
         {{ c.short_id }}
       </a>
       <a class="kg-branchbar__msg" href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}" title="View commit">{{ c.message }}</a>
+      {% if c.verified %}
+      <details class="kg-verify">
+        <summary class="kg-badge kg-badge--verified">Verified</summary>
+        <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+          <p class="kg-verify__fp">
+            <strong>{{ c.verify_fingerprint_label }}:</strong>
+            <code>{{ c.verify_fingerprint }}</code>
+          </p>
+          <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+        </div>
+      </details>
+      {% endif %}
     </div>
     {% endif %}
   </div>
diff --git a/templates/partials/social_meta.html b/templates/partials/social_meta.html
new file mode 100644
index 0000000..ba9cdeb
--- /dev/null
+++ b/templates/partials/social_meta.html
@@ -0,0 +1,13 @@
+  <meta name="description" content="{{ social.description }}" />
+  <meta property="og:type" content="website" />
+  <meta property="og:site_name" content="{{ social.site_name }}" />
+  <meta property="og:title" content="{{ social.title }}" />
+  <meta property="og:description" content="{{ social.description }}" />
+  <meta property="og:url" content="{{ social.url }}" />
+  <meta property="og:image" content="{{ social.image_url }}" />
+  <meta property="og:image:width" content="1200" />
+  <meta property="og:image:height" content="630" />
+  <meta name="twitter:card" content="summary_large_image" />
+  <meta name="twitter:title" content="{{ social.title }}" />
+  <meta name="twitter:description" content="{{ social.description }}" />
+  <meta name="twitter:image" content="{{ social.image_url }}" />
diff --git a/templates/partials/verified_badge.html b/templates/partials/verified_badge.html
new file mode 100644
index 0000000..d448c5c
--- /dev/null
+++ b/templates/partials/verified_badge.html
@@ -0,0 +1,12 @@
+{% if let Some(v) = verification %}
+<details class="kg-verify">
+  <summary class="kg-badge kg-badge--verified">Verified</summary>
+  <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+    <p class="kg-verify__fp">
+      <strong>{{ v.fingerprint_label() }}:</strong>
+      <code>{{ v.fingerprint }}</code>
+    </p>
+    <p class="kg-meta">Verified on {{ v.verified_at }}</p>
+  </div>
+</details>
+{% endif %}
diff --git a/templates/pull_view.html b/templates/pull_view.html
index 34c5028..92b3f55 100644
--- a/templates/pull_view.html
+++ b/templates/pull_view.html
@@ -98,6 +98,18 @@
           <span>
             <a href="/{{ owner.username }}/{{ repo.name }}/commits" title="View commits"><code>{{ c.short_id }}</code></a>
             <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
+            {% if c.verified %}
+            <details class="kg-verify">
+              <summary class="kg-badge kg-badge--verified">Verified</summary>
+              <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+                <p class="kg-verify__fp">
+                  <strong>{{ c.verify_fingerprint_label }}:</strong>
+                  <code>{{ c.verify_fingerprint }}</code>
+                </p>
+                <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+              </div>
+            </details>
+            {% endif %}
           </span>
           <span class="kg-meta">{{ c.author }}</span>
         </li>
diff --git a/templates/repo_commit.html b/templates/repo_commit.html
index 7551888..0b1244e 100644
--- a/templates/repo_commit.html
+++ b/templates/repo_commit.html
@@ -13,7 +13,21 @@
   {% include "partials/repo_tabs.html" %}
 
   <p><code>{{ commit.id }}</code></p>
-  <p class="kg-title">{{ message_html|safe }}</p>
+  <p class="kg-title">
+    {{ message_html|safe }}
+    {% if commit.verified %}
+    <details class="kg-verify">
+      <summary class="kg-badge kg-badge--verified">Verified</summary>
+      <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+        <p class="kg-verify__fp">
+          <strong>{{ commit.verify_fingerprint_label }}:</strong>
+          <code>{{ commit.verify_fingerprint }}</code>
+        </p>
+        <p class="kg-meta">Verified on {{ commit.verified_at }}</p>
+      </div>
+    </details>
+    {% endif %}
+  </p>
   <p class="kg-meta">{{ commit.author }} &lt;{{ commit.email }}&gt; · {{ commit.time }}</p>
 
   <p><a href="/{{ owner.username }}/{{ repo.name }}/diff/{{ commit.id }}">view full diff</a></p>
diff --git a/templates/repo_commits.html b/templates/repo_commits.html
index 9b472cb..a763fd2 100644
--- a/templates/repo_commits.html
+++ b/templates/repo_commits.html
@@ -31,6 +31,18 @@
             <code>{{ c.short_id }}</code>
           </a>
           <a href="/{{ owner.username }}/{{ repo.name }}/commit/{{ c.id }}">{{ c.message }}</a>
+          {% if c.verified %}
+          <details class="kg-verify">
+            <summary class="kg-badge kg-badge--verified">Verified</summary>
+            <div class="kg-verify__panel" role="dialog" aria-label="Commit signature">
+              <p class="kg-verify__fp">
+                <strong>{{ c.verify_fingerprint_label }}:</strong>
+                <code>{{ c.verify_fingerprint }}</code>
+              </p>
+              <p class="kg-meta">Verified on {{ c.verified_at }}</p>
+            </div>
+          </details>
+          {% endif %}
           <span class="kg-muted"> — {{ c.author }}</span>
         </span>
         <span class="kg-meta">{{ c.time }}</span>
diff --git a/templates/repo_home.html b/templates/repo_home.html
index dc12607..7ee008a 100644
--- a/templates/repo_home.html
+++ b/templates/repo_home.html
@@ -1,6 +1,10 @@
 {% extends "layout.html" %}
 
-{% block title %}{{ owner.username }}/{{ repo.name }} — kitgit{% endblock %}
+{% block title %}{{ social.title }}{% endblock %}
+
+{% block meta %}
+{% include "partials/social_meta.html" %}
+{% endblock %}
 
 {% block head %}
 {% if readme_html.is_some() %}
diff --git a/templates/signup.html b/templates/signup.html
index ff9d351..5eceaf6 100644
--- a/templates/signup.html
+++ b/templates/signup.html
@@ -4,6 +4,9 @@
 
 {% block content %}
 <section class="kg-section kg-auth">
+  {% if !signups_enabled %}
+    <div class="kg-flash kg-flash--banner">{{ signup_disabled_message }}</div>
+  {% endif %}
   <p class="kg-landing__brand">
     <span class="kg-mark__glyph" aria-hidden="true">狐</span>
     <span class="kg-mark__name">kitgit</span>
@@ -12,7 +15,14 @@
   {% if let Some(err) = error %}
     <div class="kg-flash">{{ err }}</div>
   {% endif %}
+  {% if signups_enabled || !invite.is_empty() %}
   <form class="kg-form" method="post" action="/auth/signup" autocomplete="on">
+    {% if !signups_enabled %}
+    <label>
+      invite code
+      <input type="text" name="invite" required value="{{ invite }}" autocomplete="off" spellcheck="false" />
+    </label>
+    {% endif %}
     <label>
       username
       <input type="text" name="username" required autofocus autocomplete="username" pattern="[A-Za-z0-9._\-]+" minlength="2" />
@@ -33,6 +43,17 @@
       <button class="kg-btn" type="submit">create account</button>
     </div>
   </form>
+  {% else %}
+  <form class="kg-form" method="get" action="/auth/signup" style="margin-top:1rem">
+    <label>
+      invite code
+      <input type="text" name="invite" required autocomplete="off" spellcheck="false" placeholder="Enter an invite code" />
+    </label>
+    <div class="kg-actions">
+      <button class="kg-btn" type="submit">continue</button>
+    </div>
+  </form>
+  {% endif %}
   <p class="kg-meta" style="margin-top:1.25rem">
     Already have an account? <a href="/auth/login">Log in</a>
   </p>