Skip to main content

server/
auth_keys.rs

1//! Per-user JWT signing keypairs.
2//!
3//! Each user signs their own tokens with an RSA keypair: the private key lives
4//! only in their per-user database (`user_auth_keys`), the public key in the
5//! global `users.jwt_public_key` directory. Keys are stored as base64-encoded
6//! PEM — the same wire form the web layer's token encode/decode already expects.
7
8use crate::db::{DBError, get_connection};
9use crate::user::User;
10use base64::{Engine as _, engine::general_purpose::STANDARD};
11use rsa::pkcs8::{EncodePrivateKey, EncodePublicKey, LineEnding};
12use rsa::{RsaPrivateKey, RsaPublicKey};
13use sqlx::types::Uuid;
14
15/// RSA modulus size. 2048 is the standard floor for RS256 and far cheaper to
16/// generate than 4096 — important because generation happens during user
17/// provisioning and must not become a CPU-bound DoS lever on the request path.
18const KEY_BITS: usize = 2048;
19
20/// A freshly generated keypair, both halves base64-encoded PEM.
21pub struct KeyPair {
22    pub private_pem_b64: String,
23    pub public_pem_b64: String,
24}
25
26/// Generates an RS256 keypair off the async runtime.
27///
28/// RSA keygen is CPU-bound (tens to hundreds of ms); running it on a
29/// `spawn_blocking` thread keeps it off the request executor so it can't stall
30/// the reactor or starve other tasks.
31///
32/// # Errors
33/// [`DBError::KeyGen`] if generation or PEM encoding fails.
34pub async fn generate() -> Result<KeyPair, DBError> {
35    tokio::task::spawn_blocking(generate_blocking)
36        .await
37        .map_err(|_| DBError::KeyGen)?
38}
39
40fn generate_blocking() -> Result<KeyPair, DBError> {
41    let mut rng = rand::rng();
42    let private = RsaPrivateKey::new(&mut rng, KEY_BITS).map_err(|_| DBError::KeyGen)?;
43    let public = RsaPublicKey::from(&private);
44
45    let private_pem = private
46        .to_pkcs8_pem(LineEnding::LF)
47        .map_err(|_| DBError::KeyGen)?;
48    let public_pem = public
49        .to_public_key_pem(LineEnding::LF)
50        .map_err(|_| DBError::KeyGen)?;
51
52    Ok(KeyPair {
53        private_pem_b64: STANDARD.encode(private_pem.as_bytes()),
54        public_pem_b64: STANDARD.encode(public_pem.as_bytes()),
55    })
56}
57
58/// Fetches a user's base64-PEM private signing key from their per-user
59/// database (`user_auth_keys`, a single-row table). Used on the token MINT path
60/// after the user has been identified.
61///
62/// # Errors
63/// [`DBError::Sqlx`] on a DB error; [`DBError::KeyGen`] if the user has no key
64/// (a provisioning invariant violation — surfaced rather than silently signing
65/// with nothing).
66pub async fn private_key_for(user_id: Uuid) -> Result<String, DBError> {
67    let user = User { id: user_id };
68    let mut conn = user.get_connection().await?;
69    let key: Option<String> = sqlx::query_scalar("SELECT private_key FROM user_auth_keys LIMIT 1")
70        .fetch_optional(&mut *conn)
71        .await?;
72    key.ok_or(DBError::KeyGen)
73}
74
75pub struct TokenIdentity {
76    pub public_key: String,
77    pub verified: bool,
78    pub generation: i32,
79}
80
81/// Fetches a user's base64-PEM public verification key from the global `users`
82/// directory, with the account's current status. Used on the token VERIFY path,
83/// looked up by the (still-unverified) `sub` claim — so the right key is fetched
84/// before the per-user DB is reachable.
85/// Returns `None` if the user is unknown or has no key.
86///
87/// # Errors
88/// [`DBError::Sqlx`] on a DB error.
89pub async fn public_key_for(user_id: Uuid) -> Result<Option<TokenIdentity>, DBError> {
90    let mut conn = get_connection().await?;
91    let row = sqlx::query!(
92        "SELECT jwt_public_key, verified, session_generation FROM users WHERE id = $1",
93        user_id
94    )
95    .fetch_optional(&mut *conn)
96    .await?;
97
98    Ok(row.and_then(|row| {
99        row.jwt_public_key.map(|public_key| TokenIdentity {
100            public_key,
101            verified: row.verified,
102            generation: row.session_generation,
103        })
104    }))
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey};
111
112    fn decode_pem(b64: &str) -> String {
113        String::from_utf8(STANDARD.decode(b64).expect("base64")).expect("utf8")
114    }
115
116    #[tokio::test]
117    async fn generate_produces_decodable_rsa_pem_pair() {
118        let pair = generate().await.expect("keygen");
119
120        let priv_pem = decode_pem(&pair.private_pem_b64);
121        let pub_pem = decode_pem(&pair.public_pem_b64);
122        assert!(priv_pem.contains("BEGIN PRIVATE KEY"));
123        assert!(pub_pem.contains("BEGIN PUBLIC KEY"));
124
125        // The PEM must round-trip back into RSA keys (the web token layer parses
126        // the same base64-PEM form when signing/verifying), and the public half
127        // must match the private half.
128        let parsed_priv = RsaPrivateKey::from_pkcs8_pem(&priv_pem).expect("private parses");
129        let parsed_pub = RsaPublicKey::from_public_key_pem(&pub_pem).expect("public parses");
130        assert_eq!(RsaPublicKey::from(&parsed_priv), parsed_pub);
131    }
132
133    #[tokio::test]
134    async fn generate_yields_distinct_keys() {
135        let a = generate().await.expect("keygen a");
136        let b = generate().await.expect("keygen b");
137        assert_ne!(a.private_pem_b64, b.private_pem_b64);
138        assert_ne!(a.public_pem_b64, b.public_pem_b64);
139    }
140}