feat(auth): login, roles, user mgmt, setup wizard, and session guard
- New skald-setup crate: interactive first-run wizard that creates the admin user, prompts for encryption choice and password - Auth system: session-based login/logout with cookie, guard middleware - Roles API: CRUD for data-driven roles, seeded on first boot - Users management API: create, list, edit, delete users - Setup state API: check if first admin has been created - Frontend: login-page, setup-page, users-page, roles-page, profile-page components with corresponding CSS - Topbar: avatar dropdown with profile link and logout - Sidebar: nav entries for Users and Roles (admin only) - Page shell CSS: layout support for the new pages - build.sh: builds both skald and skald-setup binaries - run.sh: runs skald-setup before the server loop - CLAUDE.md: updated workspace layout and build/run docs
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
//! Session management — the layer above `UserManager`.
|
||||
//!
|
||||
//! `SessionStore` maps an opaque session token to a user id, in RAM only.
|
||||
//! It knows nothing about HTTP cookies: the handler layer parses the cookie
|
||||
//! header and calls [`SessionStore::user_of`] / [`SessionStore::logout`].
|
||||
//!
|
||||
//! The session lifetime mirrors the process lifetime: on restart every pool is
|
||||
//! dropped (§9), so a surviving session would be useless — the user must
|
||||
//! re-authenticate to unlock the database anyway.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tracing::info;
|
||||
|
||||
use crate::users::{AuthError, UserManager};
|
||||
|
||||
/// Cookie name used across all auth endpoints.
|
||||
pub const COOKIE_NAME: &str = "skald_session";
|
||||
|
||||
pub struct SessionStore {
|
||||
users: Arc<UserManager>,
|
||||
sessions: RwLock<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new(users: Arc<UserManager>) -> Self {
|
||||
Self {
|
||||
users,
|
||||
sessions: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticates the user and unlocks their database, then mints a session
|
||||
/// token. Returns the token so the caller can set it as a cookie.
|
||||
pub async fn login(
|
||||
&self,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> Result<String, AuthError> {
|
||||
let user = self
|
||||
.users
|
||||
.by_username(username)
|
||||
.await
|
||||
.map_err(AuthError::Internal)?
|
||||
.ok_or(AuthError::UnknownUser)?;
|
||||
|
||||
// Always verify the password, even if the pool is already unlocked —
|
||||
// open_db's idempotency short-circuit would skip the check otherwise.
|
||||
self.users.verify_credentials(&user.id, password).await?;
|
||||
|
||||
// open_db handles pool lifecycle: unlocks if needed, no-op if open.
|
||||
self.users.open_db(&user.id, Some(password)).await?;
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
self.sessions
|
||||
.write()
|
||||
.expect("sessions map poisoned")
|
||||
.insert(token.clone(), user.id.clone());
|
||||
|
||||
info!(user = %user.id, %username, "session created");
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Returns the user id for a session token, or `None` if unknown / expired.
|
||||
pub fn user_of(&self, token: &str) -> Option<String> {
|
||||
self.sessions
|
||||
.read()
|
||||
.ok()?
|
||||
.get(token)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Removes a single session. The database pool stays open (§9).
|
||||
pub fn logout(&self, token: &str) {
|
||||
if let Some(user_id) = self
|
||||
.sessions
|
||||
.write()
|
||||
.expect("sessions map poisoned")
|
||||
.remove(token)
|
||||
{
|
||||
info!(user = %user_id, "session removed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod llm_requests;
|
||||
pub mod mcp_events;
|
||||
pub mod mcp_servers;
|
||||
pub mod plugins;
|
||||
pub mod roles;
|
||||
pub mod scheduled_jobs;
|
||||
pub mod scratchpad;
|
||||
pub mod session_mcp_grants;
|
||||
@@ -349,16 +350,26 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
// the registry — which means it must never hold anything that derives a
|
||||
// user's key: `database_password` is the DEK sealed under a key derived
|
||||
// from the password, useless without it.
|
||||
//
|
||||
// `role_id` has no `REFERENCES roles(id)` yet: sqlx turns on
|
||||
// `PRAGMA foreign_keys`, so pointing at a table that does not exist would
|
||||
// make every INSERT fail. The constraint lands with the `roles` table.
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS roles (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
permission_group TEXT NOT NULL,
|
||||
attrs TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
crate::db::roles::seed_admin(pool).await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
display_name TEXT,
|
||||
role_id TEXT NOT NULL,
|
||||
role_id TEXT NOT NULL REFERENCES roles(id),
|
||||
encrypted INTEGER NOT NULL,
|
||||
kdf_params TEXT,
|
||||
kdf_salt BLOB,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use anyhow::{Result, bail};
|
||||
use serde::Serialize;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
/// The built-in admin role — immutable from the API.
|
||||
pub const ADMIN_ROLE_ID: &str = "admin";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Role {
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
pub permission_group: String,
|
||||
pub attrs: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
type RawRow = (String, String, String, Option<String>, String);
|
||||
|
||||
fn from_raw((id, label, permission_group, attrs, created_at): RawRow) -> Role {
|
||||
Role { id, label, permission_group, attrs, created_at }
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<Role>> {
|
||||
let rows = sqlx::query_as::<_, RawRow>(
|
||||
"SELECT id, label, permission_group, attrs, created_at
|
||||
FROM roles
|
||||
ORDER BY label",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(from_raw).collect())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &SqlitePool, id: &str) -> Result<Option<Role>> {
|
||||
let row = sqlx::query_as::<_, RawRow>(
|
||||
"SELECT id, label, permission_group, attrs, created_at
|
||||
FROM roles WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(from_raw))
|
||||
}
|
||||
|
||||
// ── Writes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn insert(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
label: &str,
|
||||
permission_group: &str,
|
||||
attrs: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO roles (id, label, permission_group, attrs)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(label)
|
||||
.bind(permission_group)
|
||||
.bind(attrs)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
label: &str,
|
||||
permission_group: &str,
|
||||
attrs: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let rows = sqlx::query(
|
||||
"UPDATE roles SET label = ?, permission_group = ?, attrs = ? WHERE id = ?",
|
||||
)
|
||||
.bind(label)
|
||||
.bind(permission_group)
|
||||
.bind(attrs)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
pub async fn delete(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||
if id == ADMIN_ROLE_ID {
|
||||
bail!("cannot delete the built-in admin role");
|
||||
}
|
||||
let rows = sqlx::query("DELETE FROM roles WHERE id = ?")
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if rows == 0 {
|
||||
bail!("no such role: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many users are assigned to a role — prevents deletion when non-zero.
|
||||
pub async fn user_count(pool: &SqlitePool, role_id: &str) -> Result<i64> {
|
||||
let (n,) = sqlx::query_as::<_, (i64,)>("SELECT COUNT(*) FROM users WHERE role_id = ?")
|
||||
.bind(role_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
// ── Seed ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Inserts the built-in `admin` role. Idempotent.
|
||||
pub async fn seed_admin(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT OR IGNORE INTO roles (id, label, permission_group)
|
||||
VALUES ('admin', 'Administrator', 'default')",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -327,6 +327,32 @@ pub async fn set_active(pool: &SqlitePool, id: &str, active: bool) -> Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Changes the role assignment and optionally the display name in one statement.
|
||||
pub async fn update_profile(
|
||||
pool: &SqlitePool,
|
||||
id: &str,
|
||||
username: &str,
|
||||
display_name: Option<&str>,
|
||||
role_id: &str,
|
||||
) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users
|
||||
SET username = ?2, display_name = ?3, role_id = ?4, updated_at = datetime('now')
|
||||
WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(username)
|
||||
.bind(display_name)
|
||||
.bind(role_id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
if n == 0 {
|
||||
bail!("no such user: {id}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(pool: &SqlitePool, id: &str, username: &str, display_name: Option<&str>) -> Result<()> {
|
||||
let n = sqlx::query(
|
||||
"UPDATE users SET username = ?2, display_name = ?3, updated_at = datetime('now')
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod boot;
|
||||
pub mod config;
|
||||
pub mod auth;
|
||||
pub mod config_store;
|
||||
pub mod skald;
|
||||
pub mod agents;
|
||||
|
||||
@@ -52,6 +52,7 @@ impl Skald {
|
||||
// Runtime / cross-cutting
|
||||
pub fn db(&self) -> &Arc<SqlitePool> { &self.rt.db }
|
||||
pub fn users(&self) -> &Arc<UserManager> { &self.rt.users }
|
||||
pub fn sessions(&self) -> &Arc<crate::auth::SessionStore> { &self.rt.sessions }
|
||||
pub fn config(&self) -> &Arc<GlobalConfigManager> { &self.rt.config }
|
||||
pub fn config_properties(&self) -> &[core_api::ConfigSet] { &self.rt.config_properties }
|
||||
pub fn system_bus(&self) -> &Arc<SystemEventBus> { &self.rt.system_bus }
|
||||
|
||||
@@ -17,6 +17,7 @@ use tracing::info;
|
||||
use core_api::events::GlobalEvent;
|
||||
use core_api::system_bus::SystemEventBus;
|
||||
|
||||
use crate::auth::SessionStore;
|
||||
use crate::chat_event_bus::ChatEventBus;
|
||||
use crate::config_store::GlobalConfigManager;
|
||||
use crate::users::UserManager;
|
||||
@@ -28,6 +29,7 @@ pub(super) struct Runtime {
|
||||
/// writes: nothing has moved to per-user pools yet. `users` owns those.
|
||||
pub(super) db: Arc<SqlitePool>,
|
||||
pub(super) users: Arc<UserManager>,
|
||||
pub(super) sessions: Arc<SessionStore>,
|
||||
pub(super) config: Arc<GlobalConfigManager>,
|
||||
pub(super) config_properties: Vec<core_api::ConfigSet>,
|
||||
pub(super) system_bus: Arc<SystemEventBus>,
|
||||
@@ -46,6 +48,7 @@ impl Runtime {
|
||||
let config = Arc::new(GlobalConfigManager::new(Arc::clone(&pool)));
|
||||
|
||||
let users = Arc::new(UserManager::new(Arc::clone(&pool)));
|
||||
let sessions = Arc::new(SessionStore::new(Arc::clone(&users)));
|
||||
|
||||
let system_bus = Arc::new(SystemEventBus::new());
|
||||
info!("system event bus ready");
|
||||
@@ -58,6 +61,7 @@ impl Runtime {
|
||||
Runtime {
|
||||
db: pool,
|
||||
users,
|
||||
sessions,
|
||||
config,
|
||||
config_properties: vec![crate::tic::config_set()],
|
||||
system_bus,
|
||||
|
||||
@@ -135,6 +135,21 @@ impl UserManager {
|
||||
db::users::count(&self.system).await
|
||||
}
|
||||
|
||||
/// Verifies the password **always**, regardless of whether the pool is
|
||||
/// already unlocked. Use this for login authentication; use [`open_db`]
|
||||
/// only for pool lifecycle.
|
||||
pub async fn verify_credentials(&self, id: &str, password: &str) -> Result<(), AuthError> {
|
||||
let user = db::users::get(&self.system, id)
|
||||
.await
|
||||
.map_err(AuthError::Internal)?
|
||||
.ok_or(AuthError::UnknownUser)?;
|
||||
if !user.active {
|
||||
return Err(AuthError::Inactive);
|
||||
}
|
||||
self.authenticate(&user, Some(password)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Unlock registry ───────────────────────────────────────────────────────
|
||||
|
||||
/// The user's pool, or `None` when the database is still locked (§9).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "skald-setup"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
# Guided first-run setup: a thin terminal shell over `skald-core`. Deliberately
|
||||
# separate from the server binary so a richer installer (a GUI later) can be a
|
||||
# third shell over the same `UserManager`, and so the server never links the
|
||||
# TTY-prompt dependencies. It is a *frontend* on provisioning — all the real work
|
||||
# (envelope, file+row ordering, rollback) lives in `UserManager::register_user`.
|
||||
|
||||
[[bin]]
|
||||
name = "skald-setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
skald-core = { path = "../skald-core" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||
anyhow = "1"
|
||||
# Reads a password without echoing it to the terminal.
|
||||
rpassword = "7"
|
||||
@@ -0,0 +1,242 @@
|
||||
//! `skald-setup` — guided first-run setup.
|
||||
//!
|
||||
//! A terminal shell over `skald-core`: it opens the system database, and if no
|
||||
//! user exists yet, walks whoever is at the keyboard through creating the first
|
||||
//! admin. The real provisioning — envelope encryption, writing the file before
|
||||
//! the row, rolling back on failure — lives in [`UserManager::register_user`];
|
||||
//! this file is only the conversation around it.
|
||||
//!
|
||||
//! Run automatically by `run.sh` before the server loop. It is safe to run by
|
||||
//! hand at any time: every step checks whether it is already satisfied and skips
|
||||
//! itself, so re-running never duplicates anything.
|
||||
//!
|
||||
//! ## When it prompts
|
||||
//!
|
||||
//! Only when there is a real person to answer: no users yet **and** stdin is a
|
||||
//! terminal. Headless (systemd, a container, CI) it prints one line and exits 0
|
||||
//! so the server still starts — necessary while nothing consumes `users` yet and
|
||||
//! the server runs fine with none. `SKALD_SKIP_SETUP=1` forces that path even on
|
||||
//! a terminal.
|
||||
//!
|
||||
//! `--check` reports readiness without prompting: exit 0 if an admin exists,
|
||||
//! exit 1 if setup is still needed. Anything else is a real error (exit 2).
|
||||
|
||||
use std::io::{self, IsTerminal, Write};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use skald_core::db::{self, SYSTEM_DB_PATH};
|
||||
use skald_core::users::UserManager;
|
||||
|
||||
/// The role id given to the first user. There is no `roles` table yet, and
|
||||
/// `users.role_id` has no foreign key, so this is a plain string for now — the
|
||||
/// seeded `admin` preset (blueprint §12) will adopt it later without a migration.
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
|
||||
/// SQLCipher's per-user privacy is only as strong as the password's entropy
|
||||
/// times the KDF cost (§5.1). The KDF is fixed; this is the floor we put under
|
||||
/// the entropy. Not a substitute for a real strength meter — a deliberate,
|
||||
/// visible minimum.
|
||||
const MIN_PASSWORD_LEN: usize = 10;
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
let mode = match parse_args() {
|
||||
Ok(m) => m,
|
||||
Err(msg) => {
|
||||
eprintln!("{msg}");
|
||||
return std::process::ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("skald-setup: failed to start async runtime: {e}");
|
||||
return std::process::ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
match rt.block_on(run(mode)) {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("skald-setup: {e:#}");
|
||||
std::process::ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
/// Prompt if needed, otherwise a no-op.
|
||||
Run,
|
||||
/// Report readiness via exit code, never prompt.
|
||||
Check,
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Mode, String> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
None => Ok(Mode::Run),
|
||||
Some("--check") => Ok(Mode::Check),
|
||||
Some("--help" | "-h") => Err(usage()),
|
||||
Some(other) => Err(format!("skald-setup: unknown argument {other:?}\n\n{}", usage())),
|
||||
}
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"skald-setup — guided first-run setup\n\n\
|
||||
usage:\n \
|
||||
skald-setup create the first admin if none exists (interactive)\n \
|
||||
skald-setup --check exit 0 if setup is done, 1 if still needed\n\n\
|
||||
environment:\n \
|
||||
SKALD_SKIP_SETUP=1 never prompt, even on a terminal"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
async fn run(mode: Mode) -> Result<std::process::ExitCode> {
|
||||
// Opening the pool creates `database/system.db` and its schema if absent —
|
||||
// the same call the server makes, so setup and server agree on the layout.
|
||||
let pool = db::init_system_pool(SYSTEM_DB_PATH)
|
||||
.await
|
||||
.context("opening the system database")?;
|
||||
let users = UserManager::new(std::sync::Arc::new(pool));
|
||||
|
||||
let has_admin = users.count().await.context("counting users")? > 0;
|
||||
|
||||
if let Mode::Check = mode {
|
||||
return Ok(if has_admin {
|
||||
std::process::ExitCode::SUCCESS
|
||||
} else {
|
||||
std::process::ExitCode::FAILURE
|
||||
});
|
||||
}
|
||||
|
||||
// ── Steps ──────────────────────────────────────────────────────────────
|
||||
// Each is idempotent: it decides for itself whether there is work to do.
|
||||
// Today there is one. Provider and model setup will be added here as further
|
||||
// steps, in order, each skipping itself when already configured.
|
||||
step_first_user(&users, has_admin).await?;
|
||||
|
||||
Ok(std::process::ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
/// Create the first admin, or do nothing if one already exists.
|
||||
async fn step_first_user(users: &UserManager, has_admin: bool) -> Result<()> {
|
||||
if has_admin {
|
||||
// Idempotent re-run, or a second binary got there first.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// No one to answer ⇒ don't block the server. It runs fine with no users
|
||||
// today, so a missing admin is a note, not a failure.
|
||||
if std::env::var_os("SKALD_SKIP_SETUP").is_some() || !io::stdin().is_terminal() {
|
||||
eprintln!(
|
||||
"skald-setup: no users configured. Run `./bin/skald-setup` from a terminal \
|
||||
to create the first admin."
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("\nWelcome to Skald. Let's create the first user (the admin).\n");
|
||||
|
||||
let username = prompt_username()?;
|
||||
let display_name = prompt_line("Display name (optional): ")?;
|
||||
let display_name = display_name.trim();
|
||||
let display_name = (!display_name.is_empty()).then_some(display_name);
|
||||
|
||||
let encrypt = prompt_encrypt()?;
|
||||
let password = prompt_new_password()?;
|
||||
|
||||
let id = users
|
||||
.register_user(&username, display_name, ADMIN_ROLE, Some(&password), encrypt)
|
||||
.await
|
||||
.context("creating the admin user")?;
|
||||
|
||||
println!("\n✓ Admin user '{username}' created (id {id}).");
|
||||
if encrypt {
|
||||
println!(" Their private database is encrypted. There is no recovery if the password is lost.");
|
||||
}
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Prompts ────────────────────────────────────────────────────────────────
|
||||
|
||||
fn prompt_username() -> Result<String> {
|
||||
loop {
|
||||
let name = prompt_line("Username: ")?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
println!(" A username is required.");
|
||||
continue;
|
||||
}
|
||||
// The username is a login handle; keep it to something a person types.
|
||||
if name.contains(char::is_whitespace) {
|
||||
println!(" No spaces in a username.");
|
||||
continue;
|
||||
}
|
||||
return Ok(name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Default yes, with the honest caveat shown before the choice. For the admin —
|
||||
/// who owns the box — encryption guards against a stolen machine, not against
|
||||
/// the other users (§2/§4); and it has no recovery. The prompt says so.
|
||||
fn prompt_encrypt() -> Result<bool> {
|
||||
println!("Encrypt this user's private conversations at rest?");
|
||||
println!(" • Only this user, unlocked with their password, can read them.");
|
||||
println!(" • There is NO recovery: if the password is lost, the data is gone.");
|
||||
loop {
|
||||
let ans = prompt_line("Encrypt? [Y/n]: ")?;
|
||||
match ans.trim().to_lowercase().as_str() {
|
||||
"" | "y" | "yes" => return Ok(true),
|
||||
"n" | "no" => return Ok(false),
|
||||
_ => println!(" Please answer y or n."),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a password twice, without echo, and holds it to a visible floor.
|
||||
fn prompt_new_password() -> Result<String> {
|
||||
loop {
|
||||
let pw = rpassword::prompt_password("Password: ").context("reading password")?;
|
||||
if let Some(reason) = weak_password(&pw) {
|
||||
println!(" {reason}");
|
||||
continue;
|
||||
}
|
||||
let again = rpassword::prompt_password("Confirm password: ").context("reading password")?;
|
||||
if pw != again {
|
||||
println!(" The passwords did not match — try again.");
|
||||
continue;
|
||||
}
|
||||
return Ok(pw);
|
||||
}
|
||||
}
|
||||
|
||||
/// A blunt weak-password check: a length floor plus the handful of passwords an
|
||||
/// attacker guesses first. Not a strength meter — enough to stop the obviously
|
||||
/// hopeless choice on an account whose password *is* the encryption key.
|
||||
fn weak_password(pw: &str) -> Option<String> {
|
||||
if pw.chars().count() < MIN_PASSWORD_LEN {
|
||||
return Some(format!("Too short — use at least {MIN_PASSWORD_LEN} characters."));
|
||||
}
|
||||
const COMMON: &[&str] = &[
|
||||
"password", "password1", "1234567890", "12345678", "qwertyuiop",
|
||||
"letmein", "iloveyou", "admin", "welcome", "changeme", "skald",
|
||||
];
|
||||
if COMMON.contains(&pw.to_lowercase().as_str()) {
|
||||
return Some("That is one of the most common passwords — choose another.".to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn prompt_line(label: &str) -> Result<String> {
|
||||
print!("{label}");
|
||||
io::stdout().flush().ok();
|
||||
let mut line = String::new();
|
||||
let n = io::stdin().read_line(&mut line).context("reading input")?;
|
||||
if n == 0 {
|
||||
// EOF on a terminal (Ctrl-D): the person is declining. Stop the whole
|
||||
// supervisor rather than looping on empty input.
|
||||
anyhow::bail!("setup cancelled");
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
Reference in New Issue
Block a user