From be6a47ae134a78802c8e3e331c79c9832eee657c Mon Sep 17 00:00:00 2001 From: liquidev Date: Fri, 23 Aug 2024 19:45:07 +0200 Subject: [PATCH] user authentication through a secret token (NOT AUDITED FOR SECURITY. DO NOT RELY ON THIS CODE.) it's probably okay, but it's incredibly easy to read localStorage from the frontend and get a hold of the secret would be nice (would it?) to have more proper session tokens I guess but we're not doing that right now I'm not entirely sure if generating the password on the server is legit like this, but it leads to an incredibly frictionless experience and I'd like to keep it. if possible. I don't really see a difference compared to password managers generating passwords for you and showing them in plaintext obviously actual passwords are stored within the manager which requires a master password, but like. do we really need that. the secret isn't shown to the user and it's very long. too bad the browser secure storage API or whatever isn't ready yet --- Cargo.lock | 46 ++++++++++++ crates/haku-wasm/Cargo.toml | 4 + crates/haku-wasm/src/lib.rs | 3 +- crates/rkgk/Cargo.toml | 1 + crates/rkgk/src/api.rs | 7 +- crates/rkgk/src/api/wall.rs | 13 +++- crates/rkgk/src/api/wall/schema.rs | 1 + crates/rkgk/src/login/database.rs | 116 ++++++++++++++++++++++++----- static/index.js | 3 +- static/session.js | 18 ++++- 10 files changed, 180 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d102e2..c8c3ac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,6 +53,18 @@ dependencies = [ "libc", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.8" @@ -180,12 +192,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" + [[package]] name = "bitflags" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -389,6 +416,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -958,6 +986,17 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -1142,6 +1181,7 @@ checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" name = "rkgk" version = "0.1.0" dependencies = [ + "argon2", "axum", "base64 0.22.1", "chrono", @@ -1347,6 +1387,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.72" diff --git a/crates/haku-wasm/Cargo.toml b/crates/haku-wasm/Cargo.toml index fd41490..9b87472 100644 --- a/crates/haku-wasm/Cargo.toml +++ b/crates/haku-wasm/Cargo.toml @@ -13,3 +13,7 @@ haku.workspace = true log.workspace = true paste = "1.0.15" +[features] +default = [] +std = [] + diff --git a/crates/haku-wasm/src/lib.rs b/crates/haku-wasm/src/lib.rs index 5e273dc..a8336ea 100644 --- a/crates/haku-wasm/src/lib.rs +++ b/crates/haku-wasm/src/lib.rs @@ -1,4 +1,4 @@ -#![no_std] +#![cfg_attr(not(feature = "std"), no_std)] extern crate alloc; @@ -20,6 +20,7 @@ use haku::{ use log::{debug, info}; pub mod logging; +#[cfg(not(feature = "std"))] mod panicking; #[global_allocator] diff --git a/crates/rkgk/Cargo.toml b/crates/rkgk/Cargo.toml index cda0208..8cd542b 100644 --- a/crates/rkgk/Cargo.toml +++ b/crates/rkgk/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +argon2 = "0.5.3" axum = { version = "0.7.5", features = ["macros", "ws"] } base64 = "0.22.1" chrono = "0.4.38" diff --git a/crates/rkgk/src/api.rs b/crates/rkgk/src/api.rs index c5a41ed..42eff7e 100644 --- a/crates/rkgk/src/api.rs +++ b/crates/rkgk/src/api.rs @@ -34,7 +34,7 @@ struct NewUserParams { #[serde(tag = "status", rename_all = "camelCase")] enum NewUserResponse { #[serde(rename_all = "camelCase")] - Ok { user_id: String }, + Ok { user_id: String, secret: String }, #[serde(rename_all = "camelCase")] Error { message: String }, @@ -51,10 +51,11 @@ async fn login_new(api: State>, params: Json) -> impl In } match api.dbs.login.new_user(params.0.nickname).await { - Ok(user_id) => ( + Ok(new_user) => ( StatusCode::OK, Json(NewUserResponse::Ok { - user_id: user_id.to_string(), + user_id: new_user.user_id.to_string(), + secret: new_user.secret, }), ), Err(error) => ( diff --git a/crates/rkgk/src/api/wall.rs b/crates/rkgk/src/api/wall.rs index 1fd8218..0a2f5da 100644 --- a/crates/rkgk/src/api/wall.rs +++ b/crates/rkgk/src/api/wall.rs @@ -10,6 +10,7 @@ use axum::{ }, response::Response, }; +use base64::Engine; use eyre::{bail, Context, OptionExt}; use haku::value::Value; use schema::{ @@ -25,7 +26,7 @@ use tracing::{error, info, instrument}; use crate::{ haku::{Haku, Limits}, - login::database::LoginStatus, + login::{self, database::LoginStatus}, schema::Vec2, wall::{ self, auto_save::AutoSave, chunk_images::ChunkImages, chunk_iterator::ChunkIterator, @@ -90,16 +91,22 @@ async fn fallible_websocket(api: Arc, ws: &mut WebSocket) -> eyre::Result<( let login_request: LoginRequest = from_message(&recv_expect(ws).await?)?; let user_id = login_request.user; + let secret = base64::engine::general_purpose::URL_SAFE + .decode(&login_request.secret) + .expect("invalid secret string"); + if secret.len() > login::Database::MAX_SECRET_LEN { + bail!("secret is too long"); + } match api .dbs .login - .log_in(user_id) + .log_in(user_id, secret) .await .context("error while logging in")? { LoginStatus::ValidUser => (), - LoginStatus::UserDoesNotExist => { + LoginStatus::InvalidUser => { ws.send(to_message(&LoginResponse::UserDoesNotExist)) .await?; return Ok(()); diff --git a/crates/rkgk/src/api/wall/schema.rs b/crates/rkgk/src/api/wall/schema.rs index c2f070c..7467b38 100644 --- a/crates/rkgk/src/api/wall/schema.rs +++ b/crates/rkgk/src/api/wall/schema.rs @@ -22,6 +22,7 @@ pub struct Error { #[serde(rename_all = "camelCase")] pub struct LoginRequest { pub user: UserId, + pub secret: String, /// If null, a new wall is created. pub wall: Option, pub init: UserInit, diff --git a/crates/rkgk/src/login/database.rs b/crates/rkgk/src/login/database.rs index 39734ac..e588777 100644 --- a/crates/rkgk/src/login/database.rs +++ b/crates/rkgk/src/login/database.rs @@ -1,8 +1,13 @@ use std::path::PathBuf; +use argon2::{ + password_hash::{PasswordHasher, Salt, SaltString}, + Argon2, PasswordHash, PasswordVerifier, +}; +use base64::Engine; use chrono::Utc; use eyre::{eyre, Context}; -use rand::SeedableRng; +use rand::{RngCore, SeedableRng}; use rusqlite::{Connection, OptionalExtension}; use tokio::sync::{mpsc, oneshot}; use tracing::instrument; @@ -21,7 +26,7 @@ pub struct Database { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LoginStatus { ValidUser, - UserDoesNotExist, + InvalidUser, } #[derive(Debug, Clone)] @@ -29,14 +34,23 @@ pub struct UserInfo { pub nickname: String, } +#[derive(Debug, Clone)] +pub struct NewUser { + pub user_id: UserId, + // NOTE: This is kind of unusual, but rkgk generates a secret server-side and sends it to the + // user, rather than having the user come up with a password. + pub secret: String, +} + enum Command { NewUser { nickname: String, - reply: oneshot::Sender>, + reply: oneshot::Sender>, }, LogIn { user_id: UserId, + secret: Vec, reply: oneshot::Sender, }, @@ -47,7 +61,11 @@ enum Command { } impl Database { - pub async fn new_user(&self, nickname: String) -> eyre::Result { + pub const MIN_SECRET_LEN: usize = 256; + pub const MAX_SECRET_LEN: usize = 256; + pub const CURRENT_SECRET_LEN: usize = 256; + + pub async fn new_user(&self, nickname: String) -> eyre::Result { let (tx, rx) = oneshot::channel(); self.command_tx .send(Command::NewUser { @@ -59,10 +77,14 @@ impl Database { rx.await.map_err(|_| eyre!("database is not available"))? } - pub async fn log_in(&self, user_id: UserId) -> eyre::Result { + pub async fn log_in(&self, user_id: UserId, secret: Vec) -> eyre::Result { let (tx, rx) = oneshot::channel(); self.command_tx - .send(Command::LogIn { user_id, reply: tx }) + .send(Command::LogIn { + user_id, + secret, + reply: tx, + }) .await .map_err(|_| eyre!("database is too contended"))?; rx.await.map_err(|_| eyre!("database is not available")) @@ -90,6 +112,7 @@ pub fn start(settings: &Settings) -> eyre::Result { t_users ( user_index INTEGER PRIMARY KEY, long_user_id BLOB NOT NULL, + secret_hash BLOB NOT NULL, nickname TEXT NOT NULL, last_login_timestamp INTEGER NOT NULL ); @@ -100,6 +123,9 @@ pub fn start(settings: &Settings) -> eyre::Result { let mut user_id_rng = rand_chacha::ChaCha20Rng::from_entropy(); + let mut secret_rng = rand_chacha::ChaCha20Rng::from_entropy(); + let argon2 = Argon2::default(); + std::thread::Builder::new() .name("login database thread".into()) .spawn(move || { @@ -107,8 +133,18 @@ pub fn start(settings: &Settings) -> eyre::Result { .prepare( r#" INSERT INTO t_users - (long_user_id, nickname, last_login_timestamp) - VALUES (?, ?, ?); + (long_user_id, nickname, secret_hash, last_login_timestamp) + VALUES (?, ?, ?, ?); + "#, + ) + .unwrap(); + + let mut s_get_secret = db + .prepare( + r#" + SELECT secret_hash + FROM t_users + WHERE long_user_id = ?; "#, ) .unwrap(); @@ -137,21 +173,61 @@ pub fn start(settings: &Settings) -> eyre::Result { while let Some(command) = command_rx.blocking_recv() { match command { Command::NewUser { nickname, reply } => { - let user_id = UserId::new(&mut user_id_rng); - let result = s_insert_user - .execute((user_id.0, nickname, Utc::now().timestamp())) - .context("could not execute query"); - _ = reply.send(result.map(|_| user_id)); + let result = || -> eyre::Result<_> { + let user_id = UserId::new(&mut user_id_rng); + + let mut secret = [0; Database::CURRENT_SECRET_LEN]; + secret_rng.fill_bytes(&mut secret); + let salt = SaltString::generate(&mut secret_rng); + let password_hash = argon2 + .hash_password(&secret, &salt) + .expect("bad argon2 parameters"); + + s_insert_user + .execute(( + user_id.0, + nickname, + password_hash.to_string(), + Utc::now().timestamp(), + )) + .context("could not execute query")?; + + Ok(NewUser { + user_id, + secret: base64::engine::general_purpose::URL_SAFE.encode(secret), + }) + }(); + _ = reply.send(result); } - Command::LogIn { user_id, reply } => { + Command::LogIn { + user_id, + secret, + reply, + } => { // TODO: User expiration. - let login_status = - match s_log_in.execute((Utc::now().timestamp(), user_id.0)) { - Ok(_) => LoginStatus::ValidUser, - Err(_) => LoginStatus::UserDoesNotExist, - }; - _ = reply.send(login_status); + let result = || -> eyre::Result<_> { + let secret_hash: String = s_get_secret + .query_row((user_id.0,), |row| row.get(0)) + .context("no such user")?; + + let hash = PasswordHash::new(&secret_hash) + .map_err(|_| eyre!("invalid secret hash"))?; + argon2 + .verify_password(&secret, &hash) + .map_err(|_| eyre!("invalid secret"))?; + + s_log_in + .execute((Utc::now().timestamp(), user_id.0)) + .context("no such user")?; + + Ok(()) + }(); + + _ = reply.send(match result { + Ok(_) => LoginStatus::ValidUser, + Err(_) => LoginStatus::InvalidUser, + }); } Command::UserInfo { user_id, reply } => { diff --git a/static/index.js b/static/index.js index f819742..084fac2 100644 --- a/static/index.js +++ b/static/index.js @@ -1,5 +1,5 @@ import { Wall } from "./wall.js"; -import { getUserId, newSession, waitForLogin } from "./session.js"; +import { getLoginSecret, getUserId, newSession, waitForLogin } from "./session.js"; import { debounce } from "./framework.js"; import { ReticleCursor } from "./reticle-renderer.js"; @@ -59,6 +59,7 @@ function readUrl() { let session = await newSession( getUserId(), + getLoginSecret(), urlData.wallId ?? localStorage.getItem("rkgk.mostRecentWallId"), { brush: brushEditor.code, diff --git a/static/session.js b/static/session.js index 7e6896b..0a7e2ff 100644 --- a/static/session.js +++ b/static/session.js @@ -17,6 +17,10 @@ export function getUserId() { return loginStorage.userId; } +export function getLoginSecret() { + return loginStorage.secret; +} + export function waitForLogin() { return loggedInPromise; } @@ -54,8 +58,8 @@ export async function registerUser(nickname) { }; } - console.log(responseJson); loginStorage.userId = responseJson.userId; + loginStorage.secret = responseJson.secret; console.info("user registered", loginStorage.userId); saveLoginStorage(); resolveLoggedInPromise(); @@ -71,9 +75,10 @@ export async function registerUser(nickname) { } class Session extends EventTarget { - constructor(userId) { + constructor(userId, secret) { super(); this.userId = userId; + this.secret = secret; } async #recvJson() { @@ -138,6 +143,9 @@ class Session extends EventTarget { } async joinInner(wallId, userInit) { + let secret = this.secret; + this.secret = null; + let version = await this.#recvJson(); console.info("protocol version", version.version); // TODO: This should probably verify that the version is compatible. @@ -149,11 +157,13 @@ class Session extends EventTarget { if (this.wallId == null) { this.#sendJson({ user: this.userId, + secret, init, }); } else { this.#sendJson({ user: this.userId, + secret, wall: wallId, init, }); @@ -259,8 +269,8 @@ class Session extends EventTarget { } } -export async function newSession(userId, wallId, userInit) { - let session = new Session(userId); +export async function newSession(userId, secret, wallId, userInit) { + let session = new Session(userId, secret); await session.join(wallId, userInit); return session; }