Add persisted account store with NickServ REGISTER/IDENTIFY

Accounts are an append-only JSONL event log (state is the fold of the log) with
argon2id-hashed passwords. NickServ gains REGISTER <password> [email] and
IDENTIFY <password>; the engine tracks UID->nick from the burst so a command
resolves to the sender's current nick. Classic and ircd-agnostic — the cap-based
account-registration forward is the next step.
This commit is contained in:
Jean Chevronnet 2026-07-11 20:28:42 +00:00
parent 7929f5f9f4
commit 9285105afd
No known key found for this signature in database
11 changed files with 348 additions and 41 deletions

101
src/engine/db.rs Normal file
View file

@ -0,0 +1,101 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
pub name: String,
pub password_hash: String,
pub email: Option<String>,
pub ts: u64,
}
// Event-sourced persistence: every change is an Event appended to a JSONL log,
// and account state is the fold of that log. Replicating this log across nodes
// is what turns the store federated later, without changing the services.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "event")]
pub enum Event {
AccountRegistered(Account),
}
pub enum RegError {
Exists,
Internal,
}
pub struct Db {
accounts: HashMap<String, Account>, // keyed by casefolded name
path: PathBuf,
}
fn key(name: &str) -> String {
name.to_ascii_lowercase()
}
fn now() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
impl Db {
pub fn open(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let mut accounts = HashMap::new();
if let Ok(data) = std::fs::read_to_string(&path) {
for line in data.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str::<Event>(line) {
Ok(Event::AccountRegistered(a)) => {
accounts.insert(key(&a.name), a);
}
Err(e) => tracing::warn!(%e, "skipping malformed event log line"),
}
}
}
tracing::info!(accounts = accounts.len(), ?path, "account store loaded");
Self { accounts, path }
}
pub fn exists(&self, name: &str) -> bool {
self.accounts.contains_key(&key(name))
}
pub fn register(&mut self, name: &str, password: &str, email: Option<String>) -> Result<(), RegError> {
if self.exists(name) {
return Err(RegError::Exists);
}
let password_hash = hash_password(password).ok_or(RegError::Internal)?;
let account = Account { name: name.to_string(), password_hash, email, ts: now() };
self.append(&Event::AccountRegistered(account.clone())).map_err(|_| RegError::Internal)?;
self.accounts.insert(key(name), account);
Ok(())
}
pub fn verify(&self, name: &str, password: &str) -> bool {
match self.accounts.get(&key(name)) {
Some(a) => verify_password(password, &a.password_hash),
None => false,
}
}
fn append(&self, event: &Event) -> std::io::Result<()> {
let mut f = std::fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
writeln!(f, "{}", serde_json::to_string(event).unwrap_or_default())
}
}
fn hash_password(password: &str) -> Option<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string())
}
fn verify_password(password: &str, hash: &str) -> bool {
PasswordHash::new(hash)
.map(|parsed| Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok())
.unwrap_or(false)
}

View file

@ -1,25 +1,21 @@
pub mod db;
pub mod service;
pub mod state;
use crate::proto::{NetAction, NetEvent};
use service::{Service, ServiceCtx};
use state::{EventLog, Network};
use db::Db;
use service::{Sender, Service, ServiceCtx};
use state::Network;
pub struct Engine {
services: Vec<Box<dyn Service>>,
#[allow(dead_code)]
network: Network,
#[allow(dead_code)]
log: EventLog,
db: Db,
}
impl Engine {
pub fn new(services: Vec<Box<dyn Service>>) -> Self {
Self {
services,
network: Network::default(),
log: EventLog::default(),
}
pub fn new(services: Vec<Box<dyn Service>>, db: Db) -> Self {
Self { services, network: Network::default(), db }
}
// Sent right after the SERVER line: burst, introduce every service, endburst.
@ -41,18 +37,30 @@ impl Engine {
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
match event {
NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }],
NetEvent::UserConnect { uid, nick } => {
self.network.user_connect(uid, nick);
Vec::new()
}
NetEvent::Quit { uid } => {
self.network.user_quit(&uid);
Vec::new()
}
NetEvent::Privmsg { from, to, text } => self.dispatch(&from, &to, &text),
_ => Vec::new(),
}
}
// Route a PRIVMSG addressed to a service (by uid or nick) into that service.
// Route a PRIVMSG addressed to a service (by uid or nick) into that service,
// handing it the sender's resolved nick and the account store.
fn dispatch(&mut self, from: &str, to: &str, text: &str) -> Vec<NetAction> {
let nick = self.network.nick_of(from).unwrap_or(from).to_string();
let mut ctx = ServiceCtx::default();
for svc in self.services.iter_mut() {
let sender = Sender { uid: from, nick: &nick };
let Self { services, db, .. } = self;
for svc in services.iter_mut() {
if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) {
let args: Vec<&str> = text.split_whitespace().collect();
svc.on_command(from, &args, &mut ctx);
svc.on_command(&sender, &args, &mut ctx, db);
break;
}
}

View file

@ -1,7 +1,14 @@
use crate::engine::db::Db;
use crate::proto::NetAction;
// A pseudo-client (NickServ, ChanServ, OperServ, ...). Introduced at burst,
// receives the commands users message it, and pushes actions onto the ctx.
// Who sent the command, resolved by the engine (UID + current nick).
pub struct Sender<'a> {
pub uid: &'a str,
pub nick: &'a str,
}
// A pseudo-client (NickServ, ChanServ, ...). Introduced at burst, receives the
// commands users message it, reads/writes the account store, and pushes actions.
pub trait Service: Send {
fn nick(&self) -> &str;
fn uid(&self) -> &str;
@ -9,10 +16,9 @@ pub trait Service: Send {
"services.local"
}
fn gecos(&self) -> &str;
fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx);
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db);
}
// What a service can do in response to a command, collected for the link to flush.
#[derive(Default)]
pub struct ServiceCtx {
pub actions: Vec<NetAction>,

View file

@ -1,39 +1,34 @@
use std::collections::HashMap;
// Live network view the services reason about.
// Live network view, rebuilt from the uplink's burst each connect (ephemeral —
// unlike the account store, which persists).
#[derive(Default)]
pub struct Network {
pub users: HashMap<String, User>, // keyed by UID
pub users: HashMap<String, User>, // keyed by UID
pub channels: HashMap<String, Channel>,
}
pub struct User {
pub uid: String,
pub nick: String,
pub account: Option<String>,
}
#[allow(dead_code)]
pub struct Channel {
pub name: String,
pub ts: u64,
}
// The Sable-inspired core: every persistent change is an Event, and state is a
// fold over the log. Single-node today; replicating this log across service
// nodes is what turns it federated later, without rewriting the services.
#[derive(Debug, Clone)]
pub enum Event {
AccountRegistered { account: String, uid: String },
ChannelRegistered { channel: String, founder: String },
}
impl Network {
pub fn user_connect(&mut self, uid: String, nick: String) {
self.users.insert(uid.clone(), User { uid, nick });
}
#[derive(Default)]
pub struct EventLog {
pub events: Vec<Event>,
}
pub fn user_quit(&mut self, uid: &str) {
self.users.remove(uid);
}
impl EventLog {
pub fn append(&mut self, event: Event) {
self.events.push(event);
pub fn nick_of(&self, uid: &str) -> Option<&str> {
self.users.get(uid).map(|u| u.nick.as_str())
}
}

View file

@ -35,7 +35,8 @@ async fn main() -> Result<()> {
let services: Vec<Box<dyn engine::service::Service>> = vec![Box::new(NickServ {
uid: format!("{}AAAAAA", cfg.server.sid),
})];
let engine = Engine::new(services);
let db = engine::db::Db::open("fedserv.db.jsonl");
let engine = Engine::new(services, db);
let addr = format!("{}:{}", cfg.uplink.host, cfg.uplink.port);
tracing::info!(server = %cfg.server.name, %addr, "linking to uplink");

View file

@ -69,6 +69,17 @@ impl Protocol for InspIrcd {
text: trailing(rest),
}]
}
// UID <uuid> <nickts> <nick> … — track who's online so services can
// resolve a sender's current nick.
"UID" => {
let a: Vec<&str> = tokens.collect();
match (a.first(), a.get(2)) {
(Some(uid), Some(nick)) => {
vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string() }]
}
_ => vec![],
}
}
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default() }],
_ => vec![NetEvent::Unknown { line: line.to_string() }],
}

View file

@ -10,6 +10,7 @@ pub enum NetEvent {
EndBurst,
Ping { token: String, from: Option<String> },
Privmsg { from: String, to: String, text: String },
UserConnect { uid: String, nick: String },
Quit { uid: String },
Unknown { line: String },
}

View file

@ -1,4 +1,5 @@
use crate::engine::service::{Service, ServiceCtx};
use crate::engine::db::{Db, RegError};
use crate::engine::service::{Sender, Service, ServiceCtx};
pub struct NickServ {
pub uid: String,
@ -15,10 +16,37 @@ impl Service for NickServ {
"Nickname Services"
}
fn on_command(&mut self, from: &str, args: &[&str], ctx: &mut ServiceCtx) {
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
let me = self.uid.as_str();
match args.first().map(|s| s.to_ascii_uppercase()).as_deref() {
Some("HELP") => ctx.notice(self.uid(), from, "Commands: REGISTER, IDENTIFY (coming soon)."),
Some(other) => ctx.notice(self.uid(), from, format!("Unknown command: {}. Try HELP.", other)),
Some("REGISTER") => {
// REGISTER <password> [email] — registers the sender's current nick.
let Some(password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: REGISTER <password> [email]");
return;
};
let email = args.get(2).map(|s| s.to_string());
match db.register(from.nick, password, email) {
Ok(()) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is now registered.", from.nick)),
Err(RegError::Exists) => ctx.notice(me, from.uid, format!("Nickname \x02{}\x02 is already registered.", from.nick)),
Err(RegError::Internal) => ctx.notice(me, from.uid, "Registration failed, please try again later."),
}
}
Some("IDENTIFY") => {
let Some(password) = args.get(1) else {
ctx.notice(me, from.uid, "Syntax: IDENTIFY <password>");
return;
};
if db.verify(from.nick, password) {
ctx.notice(me, from.uid, format!("You are now identified for \x02{}\x02.", from.nick));
} else {
ctx.notice(me, from.uid, "Invalid password.");
}
}
Some("HELP") => {
ctx.notice(me, from.uid, "Commands: REGISTER <password> [email], IDENTIFY <password>.")
}
Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)),
None => {}
}
}