| 1 | use chrono::Utc; |
| 2 | use rand::RngCore; |
| 3 | use serde::{Deserialize, Serialize}; |
| 4 | use sha2::{Digest, Sha256}; |
| 5 | use ssh_key::{PublicKey, SshSig}; |
| 6 | use sqlx::SqlitePool; |
| 7 | |
| 8 | #[derive(Serialize)] |
| 9 | pub struct PublicUser { |
| 10 | pub id: i64, |
| 11 | pub username: String, |
| 12 | } |
| 13 | |
| 14 | #[derive(Deserialize)] |
| 15 | pub struct SignupReq { |
| 16 | pub username: String, |
| 17 | pub public_key: String, |
| 18 | pub invite_code: String, |
| 19 | } |
| 20 | |
| 21 | #[derive(Deserialize)] |
| 22 | pub struct ChallengeReq { |
| 23 | pub username: String, |
| 24 | } |
| 25 | |
| 26 | #[derive(Deserialize)] |
| 27 | pub struct VerifyReq { |
| 28 | pub username: String, |
| 29 | pub nonce: String, |
| 30 | pub signature: String, |
| 31 | } |
| 32 | |
| 33 | pub fn new_token() -> String { |
| 34 | let mut b = [0u8; 32]; |
| 35 | rand::thread_rng().fill_bytes(&mut b); |
| 36 | format!("OPENHUB_{}", hex::encode(b)) |
| 37 | } |
| 38 | |
| 39 | pub fn token_hash(token: &str) -> String { |
| 40 | let mut h = Sha256::new(); |
| 41 | h.update(token.as_bytes()); |
| 42 | hex::encode(h.finalize()) |
| 43 | } |
| 44 | |
| 45 | pub fn verify_ssh_signature(public_key_openssh: &str, nonce: &str, sig_pem: &str) -> bool { |
| 46 | let pk = match PublicKey::from_openssh(public_key_openssh) { |
| 47 | Ok(k) => k, |
| 48 | Err(_) => return false, |
| 49 | }; |
| 50 | let sig = match SshSig::from_pem(sig_pem) { |
| 51 | Ok(s) => s, |
| 52 | Err(_) => return false, |
| 53 | }; |
| 54 | pk.verify("openhub", nonce.as_bytes(), &sig).is_ok() |
| 55 | } |
| 56 | |
| 57 | pub async fn auth_user_by_token(pool: &SqlitePool, token: &str) -> Option<(i64, String)> { |
| 58 | let th = token_hash(token); |
| 59 | let row = sqlx::query!( |
| 60 | r#"SELECT users.id as "id!: i64", users.username as "username!: String" |
| 61 | FROM tokens |
| 62 | JOIN users ON users.id = tokens.user_id |
| 63 | WHERE tokens.token_hash = ?"#, |
| 64 | th |
| 65 | ).fetch_optional(pool).await.ok().flatten()?; |
| 66 | Some((row.id, row.username)) |
| 67 | } |
| 68 | |
| 69 | pub async fn create_user(pool: &SqlitePool, username: &str, public_key: &str, invite_code: &str) -> Result<PublicUser, String> { |
| 70 | // Validate invite |
| 71 | let invite = sqlx::query!( |
| 72 | r#"SELECT code as "code!: String", used_by as "used_by: i64" FROM invites WHERE code = ?"#, |
| 73 | invite_code |
| 74 | ).fetch_optional(pool).await.map_err(|_| "db_error".to_string())?; |
| 75 | |
| 76 | let invite = invite.ok_or_else(|| "invalid_invite".to_string())?; |
| 77 | if invite.used_by.is_some() { |
| 78 | return Err("invite_used".to_string()); |
| 79 | } |
| 80 | |
| 81 | let now = Utc::now().to_rfc3339(); |
| 82 | |
| 83 | let res = sqlx::query!( |
| 84 | "INSERT INTO users(username, public_key, created_at) VALUES(?, ?, ?)", |
| 85 | username, public_key, now |
| 86 | ).execute(pool).await; |
| 87 | |
| 88 | let user = match res { |
| 89 | Ok(r) => PublicUser { id: r.last_insert_rowid(), username: username.to_string() }, |
| 90 | Err(_) => return Err("username_taken".to_string()), |
| 91 | }; |
| 92 | |
| 93 | // Mark invite as used |
| 94 | sqlx::query!( |
| 95 | "UPDATE invites SET used_by = ?, used_at = ? WHERE code = ?", |
| 96 | user.id, now, invite_code |
| 97 | ).execute(pool).await.map_err(|_| "db_error".to_string())?; |
| 98 | |
| 99 | Ok(user) |
| 100 | } |
| 101 | |
| 102 | pub async fn create_challenge(pool: &SqlitePool, username: &str) -> Result<String, String> { |
| 103 | // Verify user exists |
| 104 | let exists = sqlx::query!( |
| 105 | r#"SELECT id as "id!: i64" FROM users WHERE username = ?"#, |
| 106 | username |
| 107 | ).fetch_optional(pool).await.map_err(|_| "db_error".to_string())?; |
| 108 | |
| 109 | if exists.is_none() { |
| 110 | return Err("user_not_found".to_string()); |
| 111 | } |
| 112 | |
| 113 | // Clean up expired challenges for this user (older than 5 minutes) |
| 114 | sqlx::query!( |
| 115 | "DELETE FROM challenges WHERE username = ? AND created_at < datetime('now', '-5 minutes')", |
| 116 | username |
| 117 | ).execute(pool).await.ok(); |
| 118 | |
| 119 | let mut nonce_bytes = [0u8; 32]; |
| 120 | rand::thread_rng().fill_bytes(&mut nonce_bytes); |
| 121 | let nonce = hex::encode(nonce_bytes); |
| 122 | let now = Utc::now().to_rfc3339(); |
| 123 | |
| 124 | sqlx::query!( |
| 125 | "INSERT INTO challenges(username, nonce, created_at) VALUES(?, ?, ?)", |
| 126 | username, nonce, now |
| 127 | ).execute(pool).await.map_err(|_| "db_error".to_string())?; |
| 128 | |
| 129 | Ok(nonce) |
| 130 | } |
| 131 | |
| 132 | pub async fn verify_challenge_and_login( |
| 133 | pool: &SqlitePool, |
| 134 | username: &str, |
| 135 | nonce: &str, |
| 136 | signature: &str, |
| 137 | ) -> Result<(String, PublicUser), String> { |
| 138 | // Look up challenge |
| 139 | let challenge = sqlx::query!( |
| 140 | r#"SELECT id as "id!: i64", created_at as "created_at!: String" |
| 141 | FROM challenges WHERE username = ? AND nonce = ?"#, |
| 142 | username, nonce |
| 143 | ).fetch_optional(pool).await.map_err(|_| "db_error".to_string())?; |
| 144 | |
| 145 | let challenge = challenge.ok_or_else(|| "invalid_challenge".to_string())?; |
| 146 | |
| 147 | // Check expiry (5 minutes) |
| 148 | let created = chrono::DateTime::parse_from_rfc3339(&challenge.created_at) |
| 149 | .map_err(|_| "db_error".to_string())?; |
| 150 | if Utc::now().signed_duration_since(created).num_seconds() > 300 { |
| 151 | // Clean up expired challenge |
| 152 | sqlx::query!("DELETE FROM challenges WHERE id = ?", challenge.id) |
| 153 | .execute(pool).await.ok(); |
| 154 | return Err("challenge_expired".to_string()); |
| 155 | } |
| 156 | |
| 157 | // Look up user's public key |
| 158 | let user = sqlx::query!( |
| 159 | r#"SELECT id as "id!: i64", username as "username!: String", public_key as "public_key!: String" |
| 160 | FROM users WHERE username = ?"#, |
| 161 | username |
| 162 | ).fetch_optional(pool).await.map_err(|_| "db_error".to_string())?; |
| 163 | |
| 164 | let user = user.ok_or_else(|| "user_not_found".to_string())?; |
| 165 | |
| 166 | // Verify signature |
| 167 | if !verify_ssh_signature(&user.public_key, nonce, signature) { |
| 168 | return Err("invalid_signature".to_string()); |
| 169 | } |
| 170 | |
| 171 | // Delete used challenge |
| 172 | sqlx::query!("DELETE FROM challenges WHERE id = ?", challenge.id) |
| 173 | .execute(pool).await.ok(); |
| 174 | |
| 175 | // Issue token |
| 176 | let token = new_token(); |
| 177 | let th = token_hash(&token); |
| 178 | let now = Utc::now().to_rfc3339(); |
| 179 | |
| 180 | sqlx::query!( |
| 181 | "INSERT INTO tokens(token_hash, user_id, created_at) VALUES(?, ?, ?)", |
| 182 | th, user.id, now |
| 183 | ).execute(pool).await.map_err(|_| "db_error".to_string())?; |
| 184 | |
| 185 | Ok((token, PublicUser { id: user.id, username: user.username })) |
| 186 | } |
| 187 | |
| 188 | pub async fn lookup_user_by_pubkey( |
| 189 | pool: &SqlitePool, |
| 190 | key: &russh::keys::key::PublicKey, |
| 191 | ) -> Option<(i64, String)> { |
| 192 | let rows = sqlx::query!( |
| 193 | r#"SELECT id as "id!: i64", username as "username!: String", public_key as "public_key!: String" FROM users"# |
| 194 | ).fetch_all(pool).await.ok()?; |
| 195 | |
| 196 | for row in rows { |
| 197 | // Stored keys are in OpenSSH format: "ssh-ed25519 AAAA... comment" |
| 198 | // Extract the base64 portion and parse it |
| 199 | let base64_part = row.public_key.split_whitespace().nth(1).unwrap_or(&row.public_key); |
| 200 | if let Ok(stored_key) = russh::keys::parse_public_key_base64(base64_part) { |
| 201 | if stored_key == *key { |
| 202 | return Some((row.id, row.username)); |
| 203 | } |
| 204 | } |
| 205 | } |
| 206 | None |
| 207 | } |
| 208 | |
| 209 | pub async fn create_invite(pool: &SqlitePool) -> Result<String, String> { |
| 210 | let mut code_bytes = [0u8; 16]; |
| 211 | rand::thread_rng().fill_bytes(&mut code_bytes); |
| 212 | let code = format!("inv_{}", hex::encode(code_bytes)); |
| 213 | let now = Utc::now().to_rfc3339(); |
| 214 | |
| 215 | sqlx::query!( |
| 216 | "INSERT INTO invites(code, created_by, created_at) VALUES(?, NULL, ?)", |
| 217 | code, now |
| 218 | ).execute(pool).await.map_err(|_| "db_error".to_string())?; |
| 219 | |
| 220 | Ok(code) |
| 221 | } |