gRPC Accounts: Provision from pre-derived SCRAM verifiers

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).
This commit is contained in:
Jean Chevronnet 2026-07-14 03:33:17 +00:00
parent fba91b4d62
commit 3f8649414f
No known key found for this signature in database
4 changed files with 105 additions and 4 deletions

View file

@ -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<String>) -> 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)]

View file

@ -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<String>) -> 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<db::Credentials>, email: Option<String>) -> AuthorityStatus {
let Some(creds) = creds else { return AuthorityStatus::Internal };
let addr = email.clone();