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:
parent
fba91b4d62
commit
3f8649414f
4 changed files with 105 additions and 4 deletions
|
|
@ -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.
|
// an IRC-originated one does — gossip doesn't know or care where it came from.
|
||||||
service Accounts {
|
service Accounts {
|
||||||
rpc Register(RegisterRequest) returns (AccountReply);
|
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 Authenticate(AuthenticateRequest) returns (AuthenticateReply);
|
||||||
rpc SetPassword(SetPasswordRequest) returns (AccountReply);
|
rpc SetPassword(SetPasswordRequest) returns (AccountReply);
|
||||||
rpc SetEmail(SetEmailRequest) 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 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 AuthenticateRequest { string name = 1; string password = 2; }
|
||||||
message AuthenticateReply {
|
message AuthenticateReply {
|
||||||
|
|
|
||||||
|
|
@ -945,9 +945,9 @@ fn valid_fp(fp: &str) -> bool {
|
||||||
// registration. Split out from `register` so the derivation (argon2 + two SCRAM
|
// 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.
|
// verifiers, ~1s at the default cost) can run off the reactor via spawn_blocking.
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
password_hash: String,
|
pub(crate) password_hash: String,
|
||||||
scram256: String,
|
pub(crate) scram256: String,
|
||||||
scram512: String,
|
pub(crate) scram512: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
|
|
@ -1281,6 +1281,46 @@ impl Db {
|
||||||
Ok(())
|
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;
|
/// Register synchronously, deriving and committing in one call. A test helper;
|
||||||
/// the live paths derive off-thread via `derive_credentials` + `register_prepared`.
|
/// the live paths derive off-thread via `derive_credentials` + `register_prepared`.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,16 @@ impl Engine {
|
||||||
Ok(())
|
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 {
|
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 Some(creds) = creds else { return AuthorityStatus::Internal };
|
||||||
let addr = email.clone();
|
let addr = email.clone();
|
||||||
|
|
|
||||||
48
src/grpc.rs
48
src/grpc.rs
|
|
@ -28,7 +28,7 @@ use pb::{
|
||||||
AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountReply, AccountVerified,
|
AccountDropped, AccountEmailSet, AccountRecord, AccountRegistered, AccountReply, AccountVerified,
|
||||||
AuthenticateReply, AuthenticateRequest, ChannelDescSet, ChannelDropped, ChannelFounderSet, ChannelRecord,
|
AuthenticateReply, AuthenticateRequest, ChannelDescSet, ChannelDropped, ChannelFounderSet, ChannelRecord,
|
||||||
ChannelRegistered, ConfirmRequest, DropRequest, ForceLogoutReply, ForceLogoutRequest, GroupNickRequest,
|
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,
|
SnapshotRequest, SnapshotResponse, Status as PbStatus, StatsRequest, StatsResponse, SubscribeRequest,
|
||||||
UngroupNickRequest,
|
UngroupNickRequest,
|
||||||
};
|
};
|
||||||
|
|
@ -251,6 +251,17 @@ impl Accounts for AccountsService {
|
||||||
Ok(Response::new(reply(status, describe(status))))
|
Ok(Response::new(reply(status, describe(status))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn provision(&self, req: Request<ProvisionRequest>) -> Result<Response<AccountReply>, 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<AuthenticateRequest>) -> Result<Response<AuthenticateReply>, Status> {
|
async fn authenticate(&self, req: Request<AuthenticateRequest>) -> Result<Response<AuthenticateReply>, Status> {
|
||||||
authorize(&req, &self.token)?;
|
authorize(&req, &self.token)?;
|
||||||
let msg = req.into_inner();
|
let msg = req.into_inner();
|
||||||
|
|
@ -561,6 +572,41 @@ mod tests {
|
||||||
assert_eq!(bad.status, PbStatus::Invalid as i32);
|
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]
|
#[tokio::test]
|
||||||
async fn register_rejects_a_bad_bearer_token() {
|
async fn register_rejects_a_bad_bearer_token() {
|
||||||
let (engine, _tx) = engine_with("acct-auth");
|
let (engine, _tx) = engine_with("acct-auth");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue