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; }