221 lines · 7098 bytes
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 }