An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(auth): OAuthSigningKey type + load_or_create_oauth_signing_key

+133
+133
crates/relay/src/auth/mod.rs
··· 10 10 use sha2::{Digest, Sha256}; 11 11 12 12 use crate::app::AppState; 13 + use p256::elliptic_curve::sec1::ToEncodedPoint; 14 + use p256::pkcs8::EncodePrivateKey; 15 + use sqlx::SqlitePool; 16 + use std::collections::HashMap; 17 + use std::sync::Arc; 18 + use std::time::Instant; 19 + use tokio::sync::Mutex; 20 + use uuid::Uuid; 13 21 14 22 // ── Public types ───────────────────────────────────────────────────────────── 15 23 ··· 49 57 pub scope: AuthScope, 50 58 pub token_type: TokenType, 51 59 } 60 + 61 + /// The server's persistent ES256 signing keypair, held in `AppState`. 62 + /// 63 + /// `encoding_key` is derived from the P-256 private key in PKCS#8 DER format, as required by 64 + /// `jsonwebtoken`. `key_id` is a UUID that appears as the `kid` header in issued access tokens. 65 + #[derive(Clone)] 66 + pub struct OAuthSigningKey { 67 + /// UUID identifier embedded in JWT `kid` header. 68 + pub key_id: String, 69 + /// PKCS#8 DER ES256 encoding key for JWT signing. 70 + pub encoding_key: jsonwebtoken::EncodingKey, 71 + } 72 + 73 + /// In-memory store for server-issued DPoP nonces. 74 + /// 75 + /// Maps nonce string → expiry `Instant`. Protected by a `Mutex` so handlers can issue, 76 + /// validate, and prune concurrently. Held in `AppState`. 77 + pub type DpopNonceStore = Arc<Mutex<HashMap<String, Instant>>>; 52 78 53 79 // ── JWT claims ─────────────────────────────────────────────────────────────── 54 80 ··· 185 211 token_type, 186 212 }) 187 213 } 214 + } 215 + 216 + // ── OAuth signing key management ───────────────────────────────────────────── 217 + 218 + /// Create an empty `DpopNonceStore`. 219 + pub fn new_nonce_store() -> DpopNonceStore { 220 + Arc::new(Mutex::new(HashMap::new())) 221 + } 222 + 223 + /// Load the OAuth signing key from the database, or generate a new one on first boot. 224 + /// 225 + /// If `master_key` is `None`, generates an ephemeral (non-persistent) key and logs a warning. 226 + /// Ephemeral keys are not stored in the DB and invalidate all issued tokens on restart. 227 + pub(crate) async fn load_or_create_oauth_signing_key( 228 + pool: &SqlitePool, 229 + master_key: Option<&[u8; 32]>, 230 + ) -> anyhow::Result<OAuthSigningKey> { 231 + use crate::db::oauth::{get_oauth_signing_key, store_oauth_signing_key}; 232 + 233 + // Attempt to load an existing key. 234 + if let Some(row) = get_oauth_signing_key(pool).await? { 235 + let key = decode_oauth_signing_key(&row.id, &row.private_key_encrypted, master_key)?; 236 + tracing::info!(key_id = %row.id, "OAuth signing key loaded from database"); 237 + return Ok(key); 238 + } 239 + 240 + // No key stored yet. Generate one. 241 + let keypair = crypto::generate_p256_keypair() 242 + .map_err(|e| anyhow::anyhow!("failed to generate P-256 keypair: {e}"))?; 243 + 244 + let key_id = Uuid::new_v4().to_string(); 245 + 246 + // Build JWK for the public key (uncompressed EC point → x, y coordinates). 247 + let signing_key = p256::ecdsa::SigningKey::from_bytes( 248 + p256::FieldBytes::from_slice(keypair.private_key_bytes.as_ref()), 249 + ) 250 + .map_err(|e| anyhow::anyhow!("invalid P-256 private key bytes: {e}"))?; 251 + 252 + let vk = signing_key.verifying_key(); 253 + let point = vk.to_encoded_point(false); 254 + let x = URL_SAFE_NO_PAD.encode(point.x().expect("P-256 x coordinate")); 255 + let y = URL_SAFE_NO_PAD.encode(point.y().expect("P-256 y coordinate")); 256 + let public_key_jwk = serde_json::to_string(&serde_json::json!({ 257 + "kty": "EC", 258 + "crv": "P-256", 259 + "x": x, 260 + "y": y, 261 + "kid": key_id, 262 + })) 263 + .map_err(|e| anyhow::anyhow!("JWK serialization failed: {e}"))?; 264 + 265 + match master_key { 266 + Some(key) => { 267 + let encrypted = 268 + crypto::encrypt_private_key(&*keypair.private_key_bytes, key) 269 + .map_err(|e| anyhow::anyhow!("key encryption failed: {e}"))?; 270 + store_oauth_signing_key(pool, &key_id, &public_key_jwk, &encrypted).await?; 271 + tracing::info!(key_id = %key_id, "OAuth signing key generated and persisted"); 272 + } 273 + None => { 274 + tracing::warn!( 275 + "signing_key_master_key not configured; \ 276 + OAuth signing key is ephemeral — tokens will be invalidated on restart" 277 + ); 278 + } 279 + } 280 + 281 + let encoding_key = build_encoding_key(&signing_key)?; 282 + Ok(OAuthSigningKey { key_id, encoding_key }) 283 + } 284 + 285 + /// Decode a stored OAuth signing key row into an `OAuthSigningKey`. 286 + fn decode_oauth_signing_key( 287 + key_id: &str, 288 + private_key_encrypted: &str, 289 + master_key: Option<&[u8; 32]>, 290 + ) -> anyhow::Result<OAuthSigningKey> { 291 + let master_key = master_key.ok_or_else(|| { 292 + anyhow::anyhow!( 293 + "signing_key_master_key not configured but an OAuth signing key exists in the DB; \ 294 + cannot decrypt it — set signing_key_master_key in config" 295 + ) 296 + })?; 297 + 298 + let raw_bytes = crypto::decrypt_private_key(private_key_encrypted, master_key) 299 + .map_err(|e| anyhow::anyhow!("failed to decrypt OAuth signing key: {e}"))?; 300 + 301 + let signing_key = p256::ecdsa::SigningKey::from_bytes( 302 + p256::FieldBytes::from_slice(raw_bytes.as_ref()), 303 + ) 304 + .map_err(|e| anyhow::anyhow!("invalid stored P-256 private key: {e}"))?; 305 + 306 + let encoding_key = build_encoding_key(&signing_key)?; 307 + Ok(OAuthSigningKey { 308 + key_id: key_id.to_string(), 309 + encoding_key, 310 + }) 311 + } 312 + 313 + /// Convert a `p256::ecdsa::SigningKey` to a `jsonwebtoken::EncodingKey` via PKCS#8 DER. 314 + fn build_encoding_key( 315 + signing_key: &p256::ecdsa::SigningKey, 316 + ) -> anyhow::Result<jsonwebtoken::EncodingKey> { 317 + let pkcs8_der = signing_key 318 + .to_pkcs8_der() 319 + .map_err(|e| anyhow::anyhow!("PKCS#8 DER encoding failed: {e}"))?; 320 + Ok(jsonwebtoken::EncodingKey::from_ec_der(pkcs8_der.as_bytes())) 188 321 } 189 322 190 323 // ── Internal helpers ─────────────────────────────────────────────────────────