From 3f8649414f8bda1905dbe85763d1d34ed8e390fc Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 03:33:17 +0000 Subject: [PATCH] gRPC Accounts: Provision from pre-derived SCRAM verifiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Accounts.Provision(name, scram256, scram512, email) so an external authority that stores verifiers rather than passwords (a website) can bulk- backfill accounts it never had the plaintext for. It creates the account with the given SCRAM verifiers — SCRAM and keycard login work immediately; typed-password login stays disabled (empty password_hash) until a real password lands via Register/SetPassword. Refuses an existing account, so a re-run can't clobber a fully-credentialed one. scram512 may be empty (SCRAM-SHA-512 then falls back to 256). --- proto/fedserv.proto | 5 +++++ src/engine/db.rs | 46 ++++++++++++++++++++++++++++++++++++++++--- src/engine/mod.rs | 10 ++++++++++ src/grpc.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/proto/fedserv.proto b/proto/fedserv.proto index c51b9b7..c7325ed 100644 --- a/proto/fedserv.proto +++ b/proto/fedserv.proto @@ -96,6 +96,10 @@ message ChannelDescSet { string name = 1; string description = 2; } // an IRC-originated one does — gossip doesn't know or care where it came from. service Accounts { rpc Register(RegisterRequest) returns (AccountReply); + // Create-or-update an account from pre-derived SCRAM verifiers instead of a + // plaintext password. For bulk backfill from an authority (e.g. a website) + // that stores verifiers, not passwords. scram512 may be empty. + rpc Provision(ProvisionRequest) returns (AccountReply); rpc Authenticate(AuthenticateRequest) returns (AuthenticateReply); rpc SetPassword(SetPasswordRequest) returns (AccountReply); rpc SetEmail(SetEmailRequest) returns (AccountReply); @@ -121,6 +125,7 @@ message AccountReply { } message RegisterRequest { string name = 1; string password = 2; string email = 3; } +message ProvisionRequest { string name = 1; string scram256 = 2; string scram512 = 3; string email = 4; } message AuthenticateRequest { string name = 1; string password = 2; } message AuthenticateReply { diff --git a/src/engine/db.rs b/src/engine/db.rs index d37927a..8855816 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -945,9 +945,9 @@ fn valid_fp(fp: &str) -> bool { // registration. Split out from `register` so the derivation (argon2 + two SCRAM // verifiers, ~1s at the default cost) can run off the reactor via spawn_blocking. pub struct Credentials { - password_hash: String, - scram256: String, - scram512: String, + pub(crate) password_hash: String, + pub(crate) scram256: String, + pub(crate) scram512: String, } pub struct Db { @@ -1281,6 +1281,46 @@ impl Db { Ok(()) } + /// Provision a new account directly from pre-derived SCRAM verifiers (no + /// plaintext), for bulk backfill from an external authority that stores + /// verifiers rather than passwords. `password_hash` is left empty — typed- + /// password login stays disabled until a real password is set — but SCRAM and + /// keycard login work at once. Refuses if the account already exists (so a + /// backfill can't clobber a fully-credentialed account). `scram512` empty = + /// SCRAM-SHA-512 unavailable for this account (it falls back to 256). + pub fn provision_account(&mut self, name: &str, scram256: &str, scram512: &str, email: Option) -> Result<(), RegError> { + if self.exists(name) { + return Err(RegError::Exists); + } + if name.is_empty() || scram256.is_empty() { + return Err(RegError::Internal); + } + let account = Account { + name: name.to_string(), + password_hash: String::new(), + email, + ts: now(), + home: self.log.origin.clone(), + scram256: Some(scram256.to_string()), + scram512: (!scram512.is_empty()).then(|| scram512.to_string()), + certfps: Vec::new(), + verified: true, // the external authority vouches for it + ajoin: Vec::new(), + suspension: None, + memos: Vec::new(), + greet: String::new(), + vhost: None, + vhost_request: None, + last_seen: now(), + noexpire: false, + expiry_warned: false, + oper_note: None, + }; + self.log.append(Event::AccountRegistered(Box::new(account.clone()))).map_err(|_| RegError::Internal)?; + self.accounts.insert(key(name), account); + Ok(()) + } + /// Register synchronously, deriving and committing in one call. A test helper; /// the live paths derive off-thread via `derive_credentials` + `register_prepared`. #[cfg(test)] diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 34c407b..6f28f0d 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -406,6 +406,16 @@ impl Engine { Ok(()) } + // Provision an account from pre-derived SCRAM verifiers (bulk backfill from + // the authority). No email confirmation — the authority already vouches for it. + pub fn authority_provision(&mut self, name: &str, scram256: &str, scram512: &str, email: Option) -> AuthorityStatus { + match self.db.provision_account(name, scram256, scram512, email) { + Ok(()) => AuthorityStatus::Ok, + Err(RegError::Exists) => AuthorityStatus::AlreadyExists, + Err(_) => AuthorityStatus::Internal, + } + } + pub fn authority_register(&mut self, name: &str, creds: Option, email: Option) -> AuthorityStatus { let Some(creds) = creds else { return AuthorityStatus::Internal }; let addr = email.clone(); diff --git a/src/grpc.rs b/src/grpc.rs index 657df5a..1523d84 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -28,7 +28,7 @@ use pb::{ AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountReply, AccountVerified, AuthenticateReply, AuthenticateRequest, ChannelDescSet, ChannelDropped, ChannelFounderSet, ChannelRecord, ChannelRegistered, ConfirmRequest, DropRequest, ForceLogoutReply, ForceLogoutRequest, GroupNickRequest, - NickGrouped, NickUngrouped, RegisterRequest, ReplicationEvent, SetEmailRequest, SetPasswordRequest, + NickGrouped, NickUngrouped, ProvisionRequest, RegisterRequest, ReplicationEvent, SetEmailRequest, SetPasswordRequest, SnapshotRequest, SnapshotResponse, Status as PbStatus, StatsRequest, StatsResponse, SubscribeRequest, UngroupNickRequest, }; @@ -251,6 +251,17 @@ impl Accounts for AccountsService { Ok(Response::new(reply(status, describe(status)))) } + async fn provision(&self, req: Request) -> Result, Status> { + authorize(&req, &self.token)?; + let msg = req.into_inner(); + if msg.name.is_empty() || msg.scram256.is_empty() { + return Ok(Response::new(reply(AuthorityStatus::Invalid, "name and scram256 are required"))); + } + let email = if msg.email.is_empty() { None } else { Some(msg.email) }; + let status = self.engine.lock().await.authority_provision(&msg.name, &msg.scram256, &msg.scram512, email); + Ok(Response::new(reply(status, describe(status)))) + } + async fn authenticate(&self, req: Request) -> Result, Status> { authorize(&req, &self.token)?; let msg = req.into_inner(); @@ -561,6 +572,41 @@ mod tests { assert_eq!(bad.status, PbStatus::Invalid as i32); } + #[tokio::test] + async fn provision_creates_account_from_verifiers() { + let (engine, _tx) = engine_with("acct-provision"); + let svc = accounts_svc(engine); + // The authority (e.g. the website) already holds SCRAM verifiers. + let creds = Db::derive_credentials("password1", 4096).unwrap(); + + let prov = svc + .provision(authed(ProvisionRequest { name: "heidi".into(), scram256: creds.scram256.clone(), scram512: creds.scram512.clone(), email: String::new() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(prov.status, PbStatus::Ok as i32); + + // Backfill is safe to re-run: an existing account isn't clobbered. + let dup = svc + .provision(authed(ProvisionRequest { name: "heidi".into(), scram256: creds.scram256.clone(), scram512: String::new(), email: String::new() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(dup.status, PbStatus::AlreadyExists as i32); + + // A missing scram256 is rejected, and a bad bearer never gets in. + let invalid = svc + .provision(authed(ProvisionRequest { name: "ivan".into(), scram256: String::new(), scram512: String::new(), email: String::new() }, "t")) + .await + .unwrap() + .into_inner(); + assert_eq!(invalid.status, PbStatus::Invalid as i32); + assert!(svc + .provision(authed(ProvisionRequest { name: "ivan".into(), scram256: "v".into(), scram512: String::new(), email: String::new() }, "wrong")) + .await + .is_err()); + } + #[tokio::test] async fn register_rejects_a_bad_bearer_token() { let (engine, _tx) = engine_with("acct-auth");