From bfcc98b50e05eec56c231627e55971787a991a50 Mon Sep 17 00:00:00 2001 From: sisungo Date: Sat, 18 Jul 2026 20:30:39 +0800 Subject: [PATCH] initial commit Signed-off-by: sisungo --- .gitignore | 6 + Cargo.toml | 12 + bin/account/Cargo.toml | 11 + bin/account/src/authenticate.rs | 45 +++ bin/account/src/create_user.rs | 83 +++++ bin/account/src/info.rs | 52 +++ bin/account/src/main.rs | 52 +++ bin/account/src/update_auth.rs | 48 +++ bin/account/src/util.rs | 70 ++++ daemon/accountd/Cargo.toml | 26 ++ daemon/accountd/share/auth_method.sb | 1 + daemon/accountd/src/api.rs | 387 +++++++++++++++++++++ daemon/accountd/src/auth.rs | 57 ++++ daemon/accountd/src/init_file.rs | 67 ++++ daemon/accountd/src/local.rs | 391 ++++++++++++++++++++++ daemon/accountd/src/main.rs | 107 ++++++ daemon/accountd/src/secret.rs | 31 ++ daemon/accountd/src/util/fs.rs | 39 +++ daemon/accountd/src/util/ipc_unix.rs | 74 ++++ daemon/accountd/src/util/mod.rs | 13 + daemon/accountd/src/util/time.rs | 15 + lib/auth_method_fx/Cargo.toml | 8 + lib/auth_method_fx/src/lib.rs | 81 +++++ lib/semios_account/Cargo.toml | 16 + lib/semios_account/src/auth.rs | 161 +++++++++ lib/semios_account/src/client.rs | 95 ++++++ lib/semios_account/src/error.rs | 59 ++++ lib/semios_account/src/lib.rs | 19 ++ lib/semios_account/src/protocol.rs | 266 +++++++++++++++ lib/semios_account/src/raw_client_unix.rs | 49 +++ lib/semios_account/src/record.rs | 107 ++++++ lib/semios_account/src/secret.rs | 53 +++ lib/semios_account/src/wellknown.rs | 19 ++ libexec/auth_password/Cargo.toml | 15 + libexec/auth_password/src/api.rs | 25 ++ libexec/auth_password/src/backend.rs | 281 ++++++++++++++++ libexec/auth_password/src/cli.rs | 42 +++ libexec/auth_password/src/main.rs | 11 + misc/accountd.airs | 10 + 39 files changed, 2904 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 bin/account/Cargo.toml create mode 100644 bin/account/src/authenticate.rs create mode 100644 bin/account/src/create_user.rs create mode 100644 bin/account/src/info.rs create mode 100644 bin/account/src/main.rs create mode 100644 bin/account/src/update_auth.rs create mode 100644 bin/account/src/util.rs create mode 100644 daemon/accountd/Cargo.toml create mode 100644 daemon/accountd/share/auth_method.sb create mode 100644 daemon/accountd/src/api.rs create mode 100644 daemon/accountd/src/auth.rs create mode 100644 daemon/accountd/src/init_file.rs create mode 100644 daemon/accountd/src/local.rs create mode 100644 daemon/accountd/src/main.rs create mode 100644 daemon/accountd/src/secret.rs create mode 100644 daemon/accountd/src/util/fs.rs create mode 100644 daemon/accountd/src/util/ipc_unix.rs create mode 100644 daemon/accountd/src/util/mod.rs create mode 100644 daemon/accountd/src/util/time.rs create mode 100644 lib/auth_method_fx/Cargo.toml create mode 100644 lib/auth_method_fx/src/lib.rs create mode 100644 lib/semios_account/Cargo.toml create mode 100644 lib/semios_account/src/auth.rs create mode 100644 lib/semios_account/src/client.rs create mode 100644 lib/semios_account/src/error.rs create mode 100644 lib/semios_account/src/lib.rs create mode 100644 lib/semios_account/src/protocol.rs create mode 100644 lib/semios_account/src/raw_client_unix.rs create mode 100644 lib/semios_account/src/record.rs create mode 100644 lib/semios_account/src/secret.rs create mode 100644 lib/semios_account/src/wellknown.rs create mode 100644 libexec/auth_password/Cargo.toml create mode 100644 libexec/auth_password/src/api.rs create mode 100644 libexec/auth_password/src/backend.rs create mode 100644 libexec/auth_password/src/cli.rs create mode 100644 libexec/auth_password/src/main.rs create mode 100644 misc/accountd.airs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..941e0ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.zed +/.idea +/.vscode +/Cargo.lock +/target +/build_config diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1504cdb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +members = [ + "lib/semios_account", + "lib/auth_method_fx", + "daemon/accountd", + "libexec/auth_password", + "bin/account", +] +resolver = "3" + +[workspace.dependencies] +clap = { version = "4", default-features = false, features = ["std", "help", "derive"] } diff --git a/bin/account/Cargo.toml b/bin/account/Cargo.toml new file mode 100644 index 0000000..bcd8a30 --- /dev/null +++ b/bin/account/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "account" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +clap = { workspace = true } +uuid = "1" +semios_account = { path = "../../lib/semios_account" } +libc = "0.2" diff --git a/bin/account/src/authenticate.rs b/bin/account/src/authenticate.rs new file mode 100644 index 0000000..43616f4 --- /dev/null +++ b/bin/account/src/authenticate.rs @@ -0,0 +1,45 @@ +use crate::{client, util::uuid_by_one_user_filter}; +use anyhow::anyhow; +use clap::Parser; +use semios_account::{ + auth::{AuthCli, AuthenticateArgs, UserAuthMethodFlags}, + protocol::{GetAuthMethodPathArgs, GetUserAuthMethodsArgs}, +}; + +#[derive(Debug, Clone, Parser)] +pub struct Cli { + #[arg(long)] + method: Option, + + #[arg(long, default_value = "CLI")] + mode: String, + + user: String, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut client = client()?; + let user = uuid_by_one_user_filter(&mut client, &cli.user) + .ok_or_else(|| anyhow!("user \"{}\" not found", cli.user))?; + let available_methods = client.get_user_auth_methods(GetUserAuthMethodsArgs { user })?; + let main_method = available_methods + .iter() + .find(|x| x.flags.contains(UserAuthMethodFlags::MAIN)); + let Some(method) = cli.method.or(main_method.map(|x| x.name.clone())) else { + return Err(anyhow!("no auth method available")); + }; + if !available_methods.iter().any(|x| x.name == method) { + return Err(anyhow!("unregistered auth method {method}")); + } + let executable = client.get_auth_method_path(GetAuthMethodPathArgs { name: method })?; + let args = AuthCli::Authenticate(AuthenticateArgs { + user_uuid: user, + mode: cli.mode, + }) + .compose(); + std::process::Command::new(executable) + .args(args) + .spawn()? + .wait()?; + Ok(()) +} diff --git a/bin/account/src/create_user.rs b/bin/account/src/create_user.rs new file mode 100644 index 0000000..fa336b2 --- /dev/null +++ b/bin/account/src/create_user.rs @@ -0,0 +1,83 @@ +use std::collections::{HashMap, HashSet}; + +use crate::client; +use anyhow::anyhow; +use clap::Parser; +use semios_account::{ + protocol::CreateUserArgs, + record::{HostUid, SecureTag}, + wellknown, +}; + +#[derive(Debug, Clone, Parser)] +pub struct Cli { + /// Don't create user home directory + #[arg(short = 'H', long)] + no_create_home: bool, + + /// Specify custom home directory + #[arg(short = 'd', long)] + home: Option, + + /// Full name + #[arg(short = 'N', long)] + fullname: Option, + + /// Host UID + #[arg(short = 'U', long)] + host_uid: Option, + + /// User description + #[arg(short = 'D', long, default_value_t)] + description: String, + + /// Indicate that the user cannot be logged in + #[arg(short = 'L', long)] + no_login: bool, + + /// CLI shell. + #[arg(short, long, default_value = "/bin/sh")] + cli_shell: String, + + /// Join groups + #[arg(short, long)] + groups: Vec, + + username: String, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut client = client()?; + let home_directory = cli + .home + .unwrap_or_else(|| format!("/home/{}", cli.username)); + + let mut secure_tags = HashSet::new(); + if !cli.no_login { + secure_tags.insert(SecureTag::Login); + } + + let mut groups = Vec::new(); + for i in cli.groups { + let group_uuid = crate::util::uuid_by_one_group_filter(&mut client, &i) + .ok_or_else(|| anyhow!("no such group \"{i}\""))?; + groups.push(group_uuid); + } + + let mut defaults = HashMap::new(); + defaults.insert(wellknown::CLI_LOGIN_SHELL.into(), cli.cli_shell); + + client.create_user(CreateUserArgs { + username: cli.username, + fullname: cli.fullname, + host_uid: cli.host_uid, + description: cli.description, + secure_tags, + extra_records: Default::default(), + defaults, + home_directory: Some(home_directory), + groups, + })?; + + Ok(()) +} diff --git a/bin/account/src/info.rs b/bin/account/src/info.rs new file mode 100644 index 0000000..f7deb2d --- /dev/null +++ b/bin/account/src/info.rs @@ -0,0 +1,52 @@ +use crate::{client, util::ManifestPrinter}; +use anyhow::anyhow; +use clap::Parser; +use semios_account::{protocol::GetUserInfoArgs, record::UserInfo, wellknown::CLI_LOGIN_SHELL}; +use uuid::Uuid; + +#[derive(Debug, Clone, Parser)] +pub struct Cli { + filter: String, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut client = client()?; + let mut found = false; + + let mut user_tries = Vec::with_capacity(3); + user_tries.push(GetUserInfoArgs::Username(cli.filter.clone())); + if let Ok(uuid) = cli.filter.parse::() { + user_tries.push(GetUserInfoArgs::Uuid(uuid)); + } + + for user_try in user_tries { + if let Ok(user_info) = client.get_user_info(user_try) { + found = true; + print_user_info(user_info); + } + } + + if found { + Ok(()) + } else { + Err(anyhow!("no such user")) + } +} + +fn print_user_info(user_info: UserInfo) { + let mut mp = ManifestPrinter::new(); + + mp.add("UUID", &user_info.uuid); + mp.add("Username", &user_info.username); + mp.add("Full Name", &user_info.fullname); + mp.add_optional("Host UID", &user_info.host_uid); + mp.add_conditional("Description", &user_info.description, |x| !x.is_empty()); + mp.add("Creation Time", &user_info.creation_time); + mp.add_conditional("Last Login Time", &user_info.last_login_time, |x| *x != 0); + if let Some(cli_shell) = user_info.defaults.get(CLI_LOGIN_SHELL) { + mp.add("CLI Login Shell", cli_shell); + } + mp.add_optional("Home Directory", &user_info.home_directory); + + mp.finish(); +} diff --git a/bin/account/src/main.rs b/bin/account/src/main.rs new file mode 100644 index 0000000..d3fa2f8 --- /dev/null +++ b/bin/account/src/main.rs @@ -0,0 +1,52 @@ +mod authenticate; +mod create_user; +mod info; +mod update_auth; +mod util; + +use clap::Parser; +use semios_account::{client::Client, protocol::DEFAULT_URI}; + +#[derive(Debug, Clone, Parser)] +pub enum Cli { + /// Query user or group information + Info(info::Cli), + + /// Create new user + CreateUser(create_user::Cli), + + /// Authenticate user + Authenticate(authenticate::Cli), + + /// Update user authentication + UpdateAuth(update_auth::Cli), +} + +fn main() { + let cli = Cli::parse(); + + let result = match cli { + Cli::Info(cli) => info::main(cli), + Cli::CreateUser(cli) => create_user::main(cli), + Cli::Authenticate(cli) => authenticate::main(cli), + Cli::UpdateAuth(cli) => update_auth::main(cli), + }; + + if let Err(err) = result { + eprintln!("error: {err}"); + std::process::exit(1); + } +} + +fn is_suid_mode() -> bool { + unsafe { libc::getuid() != libc::geteuid() && libc::geteuid() == 0 } +} + +fn client() -> std::io::Result { + // If we are in SUID mode, the default uri is always used, to avoid unexpected setuid. + if is_suid_mode() { + Client::connect(DEFAULT_URI) + } else { + Client::connect_default() + } +} diff --git a/bin/account/src/update_auth.rs b/bin/account/src/update_auth.rs new file mode 100644 index 0000000..493c961 --- /dev/null +++ b/bin/account/src/update_auth.rs @@ -0,0 +1,48 @@ +use crate::{client, util::uuid_by_one_user_filter}; +use anyhow::anyhow; +use clap::Parser; +use semios_account::{ + auth::{AuthCli, UpdateArgs, UserAuthMethodFlags}, + protocol::{GetAuthMethodPathArgs, GetUserAuthMethodsArgs, UserAddAuthMethodArgs}, +}; + +#[derive(Debug, Clone, Parser)] +pub struct Cli { + #[arg(long)] + method: Option, + + #[arg(long, default_value = "CLI")] + mode: String, + + user: String, +} + +pub fn main(cli: Cli) -> anyhow::Result<()> { + let mut client = client()?; + let user = uuid_by_one_user_filter(&mut client, &cli.user) + .ok_or_else(|| anyhow!("user \"{}\" not found", cli.user))?; + let available_methods = client.get_user_auth_methods(GetUserAuthMethodsArgs { user })?; + let main_method = available_methods + .iter() + .find(|x| x.flags.contains(UserAuthMethodFlags::MAIN)); + let Some(method) = cli.method.or(main_method.map(|x| x.name.clone())) else { + return Err(anyhow!("no auth method available")); + }; + if !available_methods.iter().any(|x| x.name == method) { + client.user_add_auth_method(UserAddAuthMethodArgs { + user, + auth_method: method.clone(), + })?; + } + let executable = client.get_auth_method_path(GetAuthMethodPathArgs { name: method })?; + let args = AuthCli::Update(UpdateArgs { + user_uuid: user, + mode: cli.mode, + }) + .compose(); + std::process::Command::new(executable) + .args(args) + .spawn()? + .wait()?; + Ok(()) +} diff --git a/bin/account/src/util.rs b/bin/account/src/util.rs new file mode 100644 index 0000000..6f0d423 --- /dev/null +++ b/bin/account/src/util.rs @@ -0,0 +1,70 @@ +use semios_account::{ + client::Client, + protocol::{GetGroupInfoArgs, GetUserInfoArgs}, + record::HostUid, +}; +use std::fmt::Display; +use uuid::Uuid; + +#[derive(Default)] +pub struct ManifestPrinter<'a>(Vec<(String, &'a dyn Display)>); +impl<'a> ManifestPrinter<'a> { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, k: impl Into, v: &'a impl Display) { + self.0.push((k.into(), v)); + } + + pub fn add_optional(&mut self, k: impl Into, v: &'a Option) { + if let Some(v) = v { + self.0.push((k.into(), v)); + } + } + + pub fn add_conditional( + &mut self, + k: impl Into, + v: &'a V, + c: impl FnOnce(&V) -> bool, + ) { + if c(v) { + self.0.push((k.into(), v)); + } + } + + pub fn finish(self) { + let max_key_len = self.0.iter().map(|x| x.0.len()).max().unwrap_or_default(); + let width = max_key_len + 2; + for (k, v) in self.0 { + println!("{:width$}: {}", k, v); + } + } +} + +pub fn uuid_by_one_user_filter(client: &mut Client, filter: &str) -> Option { + if let Ok(uuid) = filter.parse::() { + return Some(uuid); + } + + let args = match filter.parse::() { + Ok(uid) => GetUserInfoArgs::HostUid(uid), + Err(_) => GetUserInfoArgs::Username(filter.into()), + }; + + Some(client.get_user_info(args).ok()?.uuid) +} + +pub fn uuid_by_one_group_filter(client: &mut Client, filter: &str) -> Option { + if let Ok(uuid) = filter.parse::() { + return Some(uuid); + } + + let args = match filter.parse::() { + Ok(uid) => GetGroupInfoArgs::HostGid(uid), + Err(_) => GetGroupInfoArgs::Groupname(filter.into()), + }; + + Some(client.get_group_info(args).ok()?.uuid) +} diff --git a/daemon/accountd/Cargo.toml b/daemon/accountd/Cargo.toml new file mode 100644 index 0000000..bcb20d9 --- /dev/null +++ b/daemon/accountd/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "accountd" +version = "0.1.0" +edition = "2024" + +[features] +airup = [] +embed_init_file = [] + +[dependencies] +anyhow = "1" +cfg-if = "1" +bitflags = "2" +clap = { workspace = true } +rusqlite = "0.39" +rustc-hash = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +semios_account = { path = "../../lib/semios_account" } +uuid = { version = "1", features = ["v7"] } +tracing = "0.1" +tracing-subscriber = "0.3" + +[dependencies.tokio] +version = "1" +features = ["rt", "net", "macros", "io-util", "process", "signal", "sync", "time"] diff --git a/daemon/accountd/share/auth_method.sb b/daemon/accountd/share/auth_method.sb new file mode 100644 index 0000000..8875ab4 --- /dev/null +++ b/daemon/accountd/share/auth_method.sb @@ -0,0 +1 @@ +// Sandbox rules for running an authentication method. diff --git a/daemon/accountd/src/api.rs b/daemon/accountd/src/api.rs new file mode 100644 index 0000000..fb732de --- /dev/null +++ b/daemon/accountd/src/api.rs @@ -0,0 +1,387 @@ +//! Implementation of the API server. + +use crate::{ + AppState, + util::{ + ipc::{Connection, Listener}, + time::timestamp_s, + }, +}; +use rustc_hash::FxHashMap; +use semios_account::{ + auth::{UserAuthMethodFlags, UserAuthMethodRecord}, + error::Error, + protocol::*, + record::{GroupInfo, SecureTag, UserInfo}, + secret::Secret, +}; +use std::{fmt::Debug, path::PathBuf, pin::Pin, sync::Arc}; +use uuid::Uuid; + +pub async fn launch_server(app_state: Arc) -> anyhow::Result<()> { + let context = Arc::new(ApiContext::new(app_state)); + let listener = Listener::new(&semios_account::protocol::uri()).await?; + let server = Server { context, listener }; + tokio::spawn(server.run()); + Ok(()) +} + +macro_rules! compose { + ($orig:ident) => { + Box::new(|state, caller, args| { + Box::pin(async move { + let args = match serde_json::from_value(args) { + Ok(x) => x, + Err(e) => return Err(Error::InvalidParams(e.to_string())), + }; + $orig(state, caller, args) + .await + .map(|x| serde_json::to_value(x).expect("invalid api return value")) + }) as FutureMethod + }) as FnMethod + }; +} + +type FutureMethod = Pin> + Send>>; +type FnMethod = Box, Caller, serde_json::Value) -> FutureMethod + Send + Sync>; + +/// A context for API users. +pub struct ApiContext { + methods: FxHashMap<&'static str, FnMethod>, + app_state: Arc, +} +impl ApiContext { + pub fn new(app_state: Arc) -> Self { + let mut methods = FxHashMap::default(); + methods.insert(GET_USER_INFO, compose!(get_user_info)); + methods.insert(GET_GROUP_INFO, compose!(get_group_info)); + methods.insert(LIST_USER, compose!(list_user)); + methods.insert(LIST_GROUP, compose!(list_group)); + methods.insert(CREATE_USER, compose!(create_user)); + methods.insert(REMOVE_USER, compose!(remove_user)); + methods.insert(CREATE_GROUP, compose!(create_group)); + methods.insert(GET_SECRET, compose!(get_secret)); + methods.insert(SET_SECRET, compose!(set_secret)); + methods.insert(GET_USER_AUTH_METHODS, compose!(get_user_auth_methods)); + methods.insert(USER_ADD_AUTH_METHOD, compose!(user_add_auth_method)); + methods.insert(GET_AUTH_METHOD_PATH, compose!(get_auth_method_path)); + Self { methods, app_state } + } + + pub async fn invoke(&self, method: &str, caller: Caller, args: serde_json::Value) -> Response { + let Some(method) = self.methods.get(&method) else { + return Response::from_result::<()>(Err(Error::NotImplemented(method.into()))); + }; + Response::from_result(method(self.app_state.clone(), caller, args).await) + } +} +impl Debug for ApiContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiContext") + .field("methods", &self.methods.keys()) + .field("app_state", &self.app_state) + .finish() + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Caller { + /// Unknown user. No privileges are granted. + Unknown, + + /// Explicit user. + User(Uuid), + + /// All privileges are granted, but no user is specified. + Immortal, +} + +#[derive(Debug)] +pub struct Server { + context: Arc, + listener: Listener, +} +impl Server { + pub async fn run(self) -> anyhow::Result<()> { + loop { + let connection = match self.listener.accept().await { + Ok(x) => x, + Err(e) => { + tracing::warn!("ipc_accept() failed: {e}"); + continue; + } + }; + let session = Session { + context: self.context.clone(), + connection, + caller: Caller::Unknown, + }; + tokio::spawn(session.run()); + } + } +} + +#[derive(Debug)] +pub struct Session { + context: Arc, + connection: Connection, + caller: Caller, +} +impl Session { + pub async fn run(mut self) -> anyhow::Result<()> { + let peer_uuid = match self.connection.peer_host_uid() { + Some(uid) => self.context.app_state.local_db.find_user_by_host_uid(uid), + None => None, + }; + self.caller = match peer_uuid { + Some(uuid) => Caller::User(uuid), + None => Caller::Unknown, + }; + let mut buf = Vec::with_capacity(1024); + loop { + self.connection.recv(&mut buf).await?; + let req: Request = serde_json::from_slice(&buf)?; + let resp = self + .context + .invoke(&req.method, self.caller, req.params) + .await; + buf.clear(); + serde_json::to_writer(&mut buf, &resp).expect("invalid response emitted"); + self.connection.send(&buf).await?; + } + } +} + +async fn list_user( + state: Arc, + caller: Caller, + args: ListUserArgs, +) -> Result, Error> { + Ok(state.local_db.list_user(args.start, args.len)) +} + +async fn list_group( + state: Arc, + caller: Caller, + args: ListGroupArgs, +) -> Result, Error> { + Ok(state.local_db.list_group(args.start, args.len)) +} + +async fn get_user_info( + state: Arc, + caller: Caller, + args: GetUserInfoArgs, +) -> Result { + let uuid = match args { + GetUserInfoArgs::Uuid(x) => x, + GetUserInfoArgs::HostUid(uid) => state + .local_db + .find_user_by_host_uid(uid) + .ok_or(Error::NoSuchUser)?, + GetUserInfoArgs::Username(name) => state + .local_db + .find_user_by_name(&name) + .ok_or(Error::NoSuchUser)?, + }; + state.local_db.get_user_info(&uuid).ok_or(Error::NoSuchUser) +} + +async fn get_group_info( + state: Arc, + caller: Caller, + args: GetGroupInfoArgs, +) -> Result { + let uuid = match args { + GetGroupInfoArgs::Uuid(x) => x, + GetGroupInfoArgs::HostGid(gid) => state + .local_db + .find_group_by_host_gid(gid) + .ok_or(Error::NoSuchGroup)?, + GetGroupInfoArgs::Groupname(name) => state + .local_db + .find_group_by_name(&name) + .ok_or(Error::NoSuchGroup)?, + }; + state + .local_db + .get_group_info(&uuid) + .ok_or(Error::NoSuchGroup) +} + +async fn create_user( + state: Arc, + caller: Caller, + args: CreateUserArgs, +) -> Result<(), Error> { + require_secure_tags(&state, caller, &[SecureTag::CreateUser]).await?; + let user_info = UserInfo { + uuid: Uuid::new_v7(uuid::Timestamp::now(uuid::ContextV7::new())), + fullname: args.fullname.unwrap_or_else(|| args.username.clone()), + username: args.username, + host_uid: args.host_uid, + description: args.description, + secure_tags: args.secure_tags, + extra_records: args.extra_records, + defaults: args.defaults, + home_directory: args.home_directory, + creation_time: timestamp_s(), + last_login_time: 0, + }; + state + .local_db + .insert_user_info(user_info) + .map_err(|_| Error::AlreadyExists)?; + Ok(()) +} + +async fn create_group( + state: Arc, + caller: Caller, + args: CreateGroupArgs, +) -> Result<(), Error> { + require_secure_tags(&state, caller, &[SecureTag::CreateUser]).await?; + let group_info = GroupInfo { + uuid: Uuid::new_v7(uuid::Timestamp::now(uuid::ContextV7::new())), + fullname: args.fullname.unwrap_or_else(|| args.groupname.clone()), + groupname: args.groupname, + host_gid: args.host_gid, + description: args.description, + creation_time: timestamp_s(), + }; + state + .local_db + .insert_group_info(group_info) + .map_err(|_| Error::AlreadyExists)?; + Ok(()) +} + +async fn remove_user( + state: Arc, + caller: Caller, + args: RemoveUserArgs, +) -> Result<(), Error> { + require_secure_tags(&state, caller, &[SecureTag::RemoveUser]).await?; + todo!(); +} + +async fn get_secret( + state: Arc, + caller: Caller, + args: GetSecretArgs, +) -> Result { + require_secure_tags(&state, caller, &[SecureTag::ReadSecret]) + .await + .or(require_same_user(&state, caller, args.user).await)?; + + let raw_secret = state + .local_db + .get_secret(&args.user, &args.name) + .ok_or(Error::NoSuchSecret)?; + + Ok(raw_secret) +} + +async fn set_secret( + state: Arc, + caller: Caller, + args: SetSecretArgs, +) -> Result<(), Error> { + require_secure_tags(&state, caller, &[SecureTag::WriteSecret]) + .await + .or(require_same_user(&state, caller, args.user).await)?; + + let raw_secret = args.secret; + + _ = state.local_db.delete_secret(&args.user, &raw_secret.name); + state.local_db.insert_secret(&args.user, raw_secret)?; + + Ok(()) +} + +async fn get_user_auth_methods( + state: Arc, + caller: Caller, + args: GetUserAuthMethodsArgs, +) -> Result, Error> { + require_secure_tags(&state, Caller::User(args.user), &[SecureTag::Login]).await?; + state.local_db.get_user_auth_methods(&args.user) +} + +async fn user_add_auth_method( + state: Arc, + caller: Caller, + args: UserAddAuthMethodArgs, +) -> Result<(), Error> { + require_secure_tags(&state, Caller::User(args.user), &[SecureTag::Login]).await?; + + let current = state.local_db.get_user_auth_methods(&args.user)?; + let exec = state + .auth_method(&args.auth_method) + .ok_or(Error::NoSuchAuthMethod)?; + let info = exec + .info() + .await + .map_err(|e| Error::Internal(e.to_string()))?; + let mut flags = UserAuthMethodFlags::empty(); + if info.provides_master_key && current.is_empty() { + flags |= UserAuthMethodFlags::MAIN; + } + state + .local_db + .insert_user_auth_method(&args.user, &args.auth_method, flags)?; + Ok(()) +} + +async fn get_auth_method_path( + state: Arc, + caller: Caller, + args: GetAuthMethodPathArgs, +) -> Result { + Ok(state + .auth_method(&args.name) + .ok_or(Error::NoSuchAuthMethod)? + .path() + .into()) +} + +// ==- Helpers -== + +async fn require_same_user( + state: &AppState, + caller: Caller, + requested_user: Uuid, +) -> Result<(), Error> { + let peer_user = match caller { + Caller::Unknown => return Err(Error::PermissionDenied), + Caller::User(uuid) => uuid, + Caller::Immortal => return Ok(()), + }; + if requested_user == peer_user { + Ok(()) + } else { + Err(Error::PermissionDenied) + } +} + +async fn require_secure_tags( + state: &AppState, + caller: Caller, + tags: &[SecureTag], +) -> Result<(), Error> { + let peer_user = match caller { + Caller::Unknown => return Err(Error::PermissionDenied), + Caller::User(uuid) => uuid, + Caller::Immortal => return Ok(()), + }; + let user_info = state + .local_db + .get_user_info(&peer_user) + .ok_or(Error::PermissionDenied)?; + for tag in tags { + if !user_info.secure_tags.contains(tag) { + return Err(Error::PermissionDenied); + } + } + Ok(()) +} diff --git a/daemon/accountd/src/auth.rs b/daemon/accountd/src/auth.rs new file mode 100644 index 0000000..c90a7a1 --- /dev/null +++ b/daemon/accountd/src/auth.rs @@ -0,0 +1,57 @@ +use anyhow::anyhow; +use semios_account::auth::{AuthCli, AuthMethodInfo}; +use std::{ + path::{Path, PathBuf}, + process::Stdio, +}; + +#[derive(Debug)] +pub struct AuthExec(PathBuf); +impl AuthExec { + pub fn from_path_buf(path_buf: PathBuf) -> Self { + Self(path_buf) + } + + pub fn name(&self) -> anyhow::Result { + let err = || anyhow!("invalid auth exec \"{}\"", self.0.display()); + self.0 + .file_name() + .ok_or_else(err)? + .to_string_lossy() + .strip_prefix("auth_") + .ok_or_else(err) + .map(Into::into) + } + + pub async fn info(&self) -> anyhow::Result { + let wait = tokio::process::Command::new(&self.0) + .args(AuthCli::QueryInformation.compose()) + .stdout(Stdio::piped()) + .spawn()? + .wait_with_output() + .await?; + + if !wait.status.success() { + return Err(anyhow!( + "process \"{}\" exited with status code {}", + self.0.display(), + wait.status + )); + } + let parsed: AuthMethodInfo = serde_json::from_slice(&wait.stdout)?; + + if parsed.name != self.name()? { + return Err(anyhow!( + "mismatched filename \"{}\" for auth method \"{}\"", + self.name()?, + parsed.name + )); + } + + Ok(parsed) + } + + pub fn path(&self) -> &Path { + &self.0 + } +} diff --git a/daemon/accountd/src/init_file.rs b/daemon/accountd/src/init_file.rs new file mode 100644 index 0000000..2d6654c --- /dev/null +++ b/daemon/accountd/src/init_file.rs @@ -0,0 +1,67 @@ +//! Initialization on first run. + +use crate::{ + AppState, + api::{ApiContext, Caller}, +}; +use anyhow::anyhow; +use semios_account::protocol::Request; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; + +#[cfg(feature = "embed_init_file")] +const EMBEDDED: &str = include_str!("../../../build_config/init.json"); + +#[cfg(not(feature = "embed_init_file"))] +const EMBEDDED: &str = "[]"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct InitCommand { + pub on_failure: OnFailure, + pub request: Request, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum OnFailure { + Terminate, + Warn, + Skip, +} + +pub async fn run(app_state: Arc) -> anyhow::Result<()> { + let init_file: Vec = serde_json::from_str(EMBEDDED)?; + let api_context = ApiContext::new(app_state); + for cmd in init_file { + let resp = api_context + .invoke( + &cmd.request.method, + Caller::Immortal, + cmd.request.params.clone(), + ) + .await; + if resp.success { + tracing::debug!( + "Successfully ran init_command {:?} with response {:?}", + cmd, + resp + ); + } else { + match cmd.on_failure { + OnFailure::Terminate => { + tracing::error!("Failed to run init_command {:?}: {:?}", cmd, resp); + return Err(anyhow!( + "An init command failed to run, see logs for details" + )); + } + OnFailure::Warn => { + tracing::warn!("Failed to run init_command {:?}: {:?}", cmd, resp); + } + OnFailure::Skip => { + tracing::debug!("Failed to run init_command {:?}: {:?}", cmd, resp); + } + } + } + } + Ok(()) +} diff --git a/daemon/accountd/src/local.rs b/daemon/accountd/src/local.rs new file mode 100644 index 0000000..deb3895 --- /dev/null +++ b/daemon/accountd/src/local.rs @@ -0,0 +1,391 @@ +use rusqlite::params; +use semios_account::{ + auth::{UserAuthMethodFlags, UserAuthMethodRecord}, + error::Error, + record::{GroupInfo, HostGid, HostUid, UserInfo}, + secret::{Secret, SecurityFlags}, +}; +use serde::de::DeserializeOwned; +use std::{path::Path, sync::Mutex}; +use uuid::Uuid; + +/// Schema of the database. +/// +/// The "user" table has columns: +/// - "user_uuid": UUID of the user. +/// - "user_name": Name of the user. +/// - "user_host_uid": Host UID of the user. +/// - "user_info": User information JSON (contains host specific information). +/// +/// The "group" table has columns: +/// - "group_uuid": UUID of the group. +/// - "group_name": Name of the group. +/// - "group_host_gid": Host GID of the group. +/// - "group_info": Group information JSON (contains host specific information). +/// +/// The "kv_store" table is used to store automatic configurations that are not edited by user. +/// +/// The "secret" table has columns: +/// - "secret_owner": Owner user of the secret, in UUID text. +/// - "secret_name": Name of the secret. +/// - "secret_data": Data of the secret. +/// - "secret_security_flags": Secret security flags. +/// - "secret_creation_time": Timestamp the secret is created. +/// - "secret_expiration_time": Timestamp the secret will be expired. +/// +/// The "group_membership" table has columns: +/// - "group_membership_user": UUID of the user in the record. +/// - "group_membership_group": UUID of the group in the record. +/// +/// The "user_auth" table has columns: +/// - "user_auth_user": UUID of the user in the record. +/// - "user_auth_method": Name of the auth method specified in the record. +/// - "user_auth_flags": Flags of the record. +const SCHEMA: &str = " +CREATE TABLE IF NOT EXISTS \"user\"( + \"user_uuid\" TEXT PRIMARY KEY, + \"user_name\" TEXT NOT NULL UNIQUE, + \"user_host_uid\" TEXT NOT NULL UNIQUE, + \"user_info\" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS \"group\"( + \"group_uuid\" TEXT PRIMARY KEY, + \"group_name\" TEXT NOT NULL UNIQUE, + \"group_host_gid\" TEXT NOT NULL UNIQUE, + \"group_info\" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS \"kv_store\"( + \"kv_store_key\" TEXT PRIMARY KEY, + \"kv_store_value\" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS \"secret\"( + \"secret_owner\" TEXT, + \"secret_name\" TEXT, + \"secret_data\" BLOB NOT NULL, + \"secret_security_flags\" INTEGER NOT NULL, + \"secret_creation_time\" INTEGER NOT NULL, + \"secret_expiration_time\" INTEGER NOT NULL, + PRIMARY KEY (\"secret_owner\", \"secret_name\") +); + +CREATE TABLE IF NOT EXISTS \"group_membership\"( + \"group_membership_user\" TEXT NOT NULL, + \"group_membership_group\" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS \"user_auth\"( + \"user_auth_user\" TEXT NOT NULL, + \"user_auth_method\" TEXT NOT NULL, + \"user_auth_flags\" INTEGER NOT NULL +); +"; + +#[derive(Debug)] +pub struct LocalDb(Mutex); +impl LocalDb { + pub fn open>(path: P) -> Result { + let connection = rusqlite::Connection::open(path).map_err(Error::make_internal)?; + connection + .execute_batch(SCHEMA) + .map_err(Error::make_internal)?; + Ok(Self(Mutex::new(connection))) + } + + pub fn get_secret(&self, owner: &Uuid, name: &str) -> Option { + let lock = self.0.lock().unwrap(); + let mut stmt = lock + .prepare_cached( + "SELECT * FROM \"secret\" WHERE \"secret_owner\" = ?1 AND \"secret_name\" = ?2", + ) + .ok()?; + stmt.query_one(params![owner.to_string(), name], |row| { + Ok(Secret { + name: name.into(), + data: row.get("secret_data")?, + security_flags: SecurityFlags::from_bits_retain(row.get("secret_security_flags")?), + creation_time: row.get("secret_creation_time")?, + expiration_time: row.get("secret_expiration_time")?, + }) + }) + .ok() + } + + pub fn insert_secret(&self, owner: &Uuid, secret: Secret) -> Result<(), Error> { + let mut lock = self.0.lock().unwrap(); + execute( + &mut *lock, + "INSERT INTO \"secret\"( + \"secret_owner\", + \"secret_name\", + \"secret_data\", + \"secret_security_flags\", + \"secret_creation_time\", + \"secret_expiration_time\" + ) VALUES(?1, ?2, ?3, ?4, ?5, ?6)", + params![ + owner.to_string(), + secret.name, + secret.data, + secret.security_flags.bits(), + secret.creation_time, + secret.expiration_time + ], + ) + } + + pub fn delete_secret(&self, uuid: &Uuid, name: &str) -> Result<(), Error> { + execute( + &mut *self.0.lock().unwrap(), + "DELETE FROM \"secret\" WHERE \"secret_owner\" = ?1 AND \"secret_name\" = ?2", + params![uuid.to_string(), name], + ) + } + + pub fn list_user(&self, start: u32, len: u32) -> Vec { + self._list_user_group("user", start, len) + } + + pub fn list_group(&self, start: u32, len: u32) -> Vec { + self._list_user_group("group", start, len) + } + + pub fn get_user_info(&self, uuid: &Uuid) -> Option { + self._get_user_group_info("user", uuid) + } + + pub fn get_group_info(&self, uuid: &Uuid) -> Option { + self._get_user_group_info("group", uuid) + } + + pub fn insert_user_info(&self, user_info: UserInfo) -> Result<(), Error> { + let mut lock = self.0.lock().unwrap(); + execute( + &mut *lock, + "INSERT INTO \"user\"(\"user_uuid\", \"user_name\", \"user_host_uid\", \"user_info\") + VALUES(?1, ?2, ?3, ?4)", + params![ + user_info.uuid.to_string(), + user_info.username, + user_info + .host_uid + .ok_or_else(|| Error::make_internal("host_uid not specified"))?, + serde_json::to_string(&user_info).map_err(Error::make_internal)?, + ], + ) + } + + pub fn delete_user(&self, uuid: &Uuid) -> Result<(), Error> { + let mut lock = self.0.lock().unwrap(); + execute( + &mut *lock, + "DELETE FROM \"user\" WHERE \"user_uuid\" = ?1", + params![uuid.to_string()], + )?; + execute( + &mut *lock, + "DELETE FROM \"secret\" WHERE \"secret_owner\" = ?1", + params![uuid.to_string()], + )?; + execute( + &mut *lock, + "DELETE FROM \"group_membership\" WHERE \"group_membership_user\" = ?1", + params![uuid.to_string()], + )?; + execute( + &mut *lock, + "DELETE FROM \"user_auth\" WHERE \"user_auth_user\" = ?1", + params![uuid.to_string()], + )?; + Ok(()) + } + + pub fn insert_group_info(&self, group_info: GroupInfo) -> Result<(), Error> { + let mut lock = self.0.lock().unwrap(); + execute( + &mut *lock, + "INSERT INTO \"group\"(\"group_uuid\", \"group_name\", \"group_host_gid\", \"group_info\") + VALUES(?1, ?2, ?3, ?4)", + params![ + group_info.uuid.to_string(), + group_info.groupname, + group_info + .host_gid + .ok_or_else(|| Error::make_internal("host_gid not specified"))?, + serde_json::to_string(&group_info).map_err(Error::make_internal)?, + ], + ) + } + + pub fn delete_group(&self, uuid: &Uuid) -> Result<(), Error> { + let mut lock = self.0.lock().unwrap(); + execute( + &mut *lock, + "DELETE FROM \"group\" WHERE \"group_uuid\" = ?1", + params![uuid.to_string()], + )?; + execute( + &mut *lock, + "DELETE FROM \"group_membership\" WHERE \"group_membership_group\" = ?1", + params![uuid.to_string()], + )?; + Ok(()) + } + + pub fn get_user_auth_methods(&self, uuid: &Uuid) -> Result, Error> { + let lock = self.0.lock().unwrap(); + let mut stmt = lock + .prepare_cached("SELECT * FROM \"user_auth\" WHERE \"user_auth_user\" = ?1") + .unwrap(); + let mut rows = stmt + .query(params![uuid.to_string()]) + .map_err(Error::make_internal)?; + let mut all = Vec::new(); + while let Ok(Some(i)) = rows.next() { + let name = i.get("user_auth_method").map_err(Error::make_internal)?; + let flags = i.get("user_auth_flags").map_err(Error::make_internal)?; + all.push(UserAuthMethodRecord { + name, + flags: UserAuthMethodFlags::from_bits_retain(flags), + }); + } + Ok(all) + } + + pub fn insert_user_auth_method( + &self, + uuid: &Uuid, + method: &str, + flags: UserAuthMethodFlags, + ) -> Result<(), Error> { + let mut lock = self.0.lock().unwrap(); + execute( + &mut *lock, + "INSERT INTO \"user_auth\"(\"user_auth_user\", \"user_auth_method\", \"user_auth_flags\") + VALUES(?1, ?2, ?3)", + params![uuid.to_string(), method, flags.bits()], + ) + } + + pub fn find_user_by_name(&self, username: &str) -> Option { + self._find_uuid_by("user", "user_name", &username) + } + + pub fn find_group_by_name(&self, groupname: &str) -> Option { + self._find_uuid_by("group", "group_name", &groupname) + } + + pub fn find_user_by_host_uid(&self, host_uid: HostUid) -> Option { + self._find_uuid_by("user", "user_host_uid", &host_uid.to_string()) + } + + pub fn find_group_by_host_gid(&self, host_gid: HostGid) -> Option { + self._find_uuid_by("group", "group_host_gid", &host_gid.to_string()) + } + + pub fn is_first_run(&self) -> bool { + !self._kv_get("initialized").is_some() + } + + pub fn unset_first_run(&self) -> Result<(), Error> { + self._kv_set("initialized", "1") + .map_err(Error::make_internal) + } + + fn _list_user_group(&self, table: &'static str, start: u32, len: u32) -> Vec { + let lock = self.0.lock().unwrap(); + let stmt = lock.prepare_cached(&format!( + "SELECT \"{table}_uuid\" FROM \"{table}\" ORDER BY \"{table}_uuid\" LIMIT ?1 OFFSET ?2" + )); + let Ok(mut stmt) = stmt else { + return Vec::new(); + }; + stmt.query_map(params![len, start], |x| { + x.get::<_, String>(&format!("{table}_uuid")[..])? + .parse::() + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + }) + .map(|x| x.filter_map(|y| y.ok()).collect()) + .unwrap_or_default() + } + + fn _get_user_group_info( + &self, + table: &'static str, + uuid: &Uuid, + ) -> Option { + let lock = self.0.lock().unwrap(); + let mut stmt = lock + .prepare_cached(&format!( + "SELECT * FROM \"{table}\" WHERE \"{table}_uuid\" = ?1" + )) + .ok()?; + stmt.query_one(params![uuid.to_string()], |rows| { + serde_json::from_str(&rows.get::<_, String>(&format!("{table}_info")[..])?) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e))) + }) + .ok() + } + + fn _find_uuid_by( + &self, + table: &'static str, + column: &'static str, + val: &T, + ) -> Option { + let lock = self.0.lock().unwrap(); + let mut stmt = lock + .prepare_cached(&format!( + "SELECT \"{table}_uuid\" FROM \"{table}\" WHERE \"{column}\" = ?1" + )) + .ok()?; + stmt.query_one(params![val], |rows| { + rows.get::<_, String>(&format!("{table}_uuid")[..]) + }) + .ok()? + .parse::() + .ok() + } + + fn _kv_get(&self, key: &str) -> Option { + let lock = self.0.lock().unwrap(); + let mut stmt = lock + .prepare_cached( + "SELECT \"kv_store_value\" FROM \"kv_store\" WHERE \"kv_store_key\" = ?1", + ) + .ok()?; + stmt.query_one(params![key], |rows| rows.get("kv_store_value")) + .ok() + } + + fn _kv_set(&self, key: &str, val: &str) -> rusqlite::Result<()> { + let lock = self.0.lock().unwrap(); + let mut stmt_get = lock.prepare_cached( + "SELECT \"kv_store_key\" FROM \"kv_store\" WHERE \"kv_store_key\" = ?1", + )?; + let mut stmt_set = lock.prepare_cached( + "UPDATE \"kv_store\" SET \"kv_store_value\" = ?1 WHERE \"kv_store_key\" = ?2", + )?; + let mut stmt_insert = lock.prepare_cached( + "INSERT INTO \"kv_store\"(\"kv_store_key\", \"kv_store_value\") VALUES(?1, ?2)", + )?; + if stmt_get.query_one(params![key], |_| Ok(())).is_ok() { + stmt_set.execute(params![val, key])?; + } else { + stmt_insert.execute(params![key, val])?; + } + Ok(()) + } +} + +fn execute( + conn: &mut rusqlite::Connection, + sql: &str, + params: &[&dyn rusqlite::ToSql], +) -> Result<(), Error> { + let mut stmt = conn.prepare_cached(sql).map_err(Error::make_internal)?; + stmt.execute(params).map_err(Error::make_internal)?; + Ok(()) +} diff --git a/daemon/accountd/src/main.rs b/daemon/accountd/src/main.rs new file mode 100644 index 0000000..bb23106 --- /dev/null +++ b/daemon/accountd/src/main.rs @@ -0,0 +1,107 @@ +mod api; +mod auth; +mod init_file; +mod local; +mod secret; +mod util; + +use crate::{local::LocalDb, secret::ResidentKeys}; +use clap::Parser; +use std::{path::PathBuf, sync::Arc}; + +const DEFAULT_LIBEXEC_DIR: &str = match std::option_env!("DEFAULT_LIBEXEC_DIR") { + Some(x) => x, + None => "/usr/libexec/accountd", +}; +const DEFAULT_DATA_DIR: &str = match std::option_env!("DEFAULT_DATA_DIR") { + Some(x) => x, + None => "/var/lib/accountd", +}; + +#[derive(Debug)] +struct AppState { + /// The `accountd` dedicated libexec directory, e.g. `/usr/libexec/accountd`. + libexec_dir: PathBuf, + + /// The `accountd` dedicated data directory, e.g. `/var/lib/accountd`. + data_dir: PathBuf, + + /// Local database. + local_db: LocalDb, + + /// Resident keys. + resident_keys: ResidentKeys, +} +impl AppState { + fn auth_methods(&self) -> Box> { + let Ok(tree_dir) = util::fs::TreeDir::open(&self.libexec_dir) else { + return Box::new(std::iter::empty()); + }; + Box::new( + tree_dir + .filter(|x| x.file_name().to_string_lossy().starts_with("auth_")) + .map(|x| auth::AuthExec::from_path_buf(x.path())), + ) + } + + fn auth_method(&self, name: &str) -> Option { + self.auth_methods() + .find(|x| matches!(x.name().as_deref(), Ok(x) if x == name)) + } +} + +#[derive(Debug, Clone, Parser)] +struct Cli { + /// Specify libexec directory + #[arg(long)] + libexec_dir: Option, + + /// Specify data directory + #[arg(long)] + data_dir: Option, +} + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let cli = Cli::parse(); + tracing_subscriber::fmt::init(); + + let libexec_dir = cli + .libexec_dir + .unwrap_or_else(|| DEFAULT_LIBEXEC_DIR.into()); + let data_dir = cli.data_dir.unwrap_or_else(|| DEFAULT_DATA_DIR.into()); + _ = std::fs::create_dir_all(&data_dir); + + let local_db_path = data_dir.join("local.db"); + let local_db = match LocalDb::open(&local_db_path) { + Ok(x) => x, + Err(e) => { + tracing::error!("failed to open local database: {e}",); + std::process::exit(1); + } + }; + + let app_state = Arc::new(AppState { + libexec_dir, + data_dir, + local_db, + resident_keys: ResidentKeys::new(), + }); + + if app_state.local_db.is_first_run() { + if init_file::run(app_state.clone()).await.is_err() { + std::process::exit(1); + } + if let Err(e) = app_state.local_db.unset_first_run() { + tracing::error!("failed to unset first run: {e}"); + std::process::exit(1); + } + } + + if let Err(e) = api::launch_server(app_state).await { + tracing::error!("failed to launch api server: {e}"); + std::process::exit(1); + } + + while tokio::signal::ctrl_c().await.is_err() {} +} diff --git a/daemon/accountd/src/secret.rs b/daemon/accountd/src/secret.rs new file mode 100644 index 0000000..473edff --- /dev/null +++ b/daemon/accountd/src/secret.rs @@ -0,0 +1,31 @@ +use crate::util::time::timestamp_s; +use rustc_hash::FxHashMap; +use semios_account::secret::MasterKeyProvision; +use std::sync::Mutex; +use uuid::Uuid; + +#[derive(Debug, Default)] +pub struct ResidentKeys(Mutex>); +impl ResidentKeys { + pub fn new() -> Self { + Self::default() + } + + pub fn set(&self, uuid: Uuid, provision: MasterKeyProvision) { + self.0.lock().unwrap().insert(uuid, provision); + } + + pub fn get(&self, uuid: &Uuid) -> Option { + let mut lock = self.0.lock().unwrap(); + let provision = lock.get(uuid)?; + if provision.expiration_time < timestamp_s() { + lock.remove(uuid); + return None; + } + Some(provision.clone()) + } + + pub fn remove(&self, uuid: &Uuid) { + self.0.lock().unwrap().remove(uuid); + } +} diff --git a/daemon/accountd/src/util/fs.rs b/daemon/accountd/src/util/fs.rs new file mode 100644 index 0000000..9de6c66 --- /dev/null +++ b/daemon/accountd/src/util/fs.rs @@ -0,0 +1,39 @@ +use std::{ + fs::{DirEntry, ReadDir}, + path::Path, +}; + +#[derive(Debug)] +pub struct TreeDir(Vec); +impl TreeDir { + pub fn open>(path: P) -> std::io::Result { + Ok(Self(vec![std::fs::read_dir(path)?])) + } +} +impl Iterator for TreeDir { + type Item = DirEntry; + + fn next(&mut self) -> Option { + while let Some(current_dir) = self.0.last_mut() { + match current_dir.next() { + Some(Ok(entry)) => { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + if let Ok(sub_dir) = std::fs::read_dir(entry.path()) { + self.0.push(sub_dir); + } + } + } + return Some(entry); + } + Some(Err(_)) => { + continue; + } + None => { + self.0.pop(); + } + } + } + None + } +} diff --git a/daemon/accountd/src/util/ipc_unix.rs b/daemon/accountd/src/util/ipc_unix.rs new file mode 100644 index 0000000..fe8f9e0 --- /dev/null +++ b/daemon/accountd/src/util/ipc_unix.rs @@ -0,0 +1,74 @@ +//! Local IPC implementation -- Unix-like Operating Systems. + +use anyhow::anyhow; +use cfg_if::cfg_if; +use semios_account::{ + protocol::{MAX_MESSAGE_LEN, Uri}, + record::HostUid, +}; +use std::os::unix::net::{SocketAddr, UnixListener as StdUnixListener}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{UnixListener, UnixStream}, +}; + +#[derive(Debug)] +pub struct Listener(UnixListener); +impl Listener { + pub async fn new(uri: &str) -> anyhow::Result { + let uri: Uri = uri.parse()?; + let sockaddr = match uri { + Uri::Unix(path) => { + if path.exists() && tokio::net::UnixStream::connect(&path).await.is_err() { + std::fs::remove_file(&path)?; + } + SocketAddr::from_pathname(path)? + } + Uri::UnixAbstract(_name) => { + cfg_if! { + if #[cfg(target_os = "linux")] { + use std::os::linux::net::SocketAddrExt; + SocketAddr::from_abstract_name(_name.as_bytes())? + } else { + return Err(anyhow!("abstract socket names are only supported on Linux")); + } + } + } + _ => return Err(anyhow!("only \"unix://\" ipc is supported on unix")), + }; + let listener = StdUnixListener::bind_addr(&sockaddr)?; + listener.set_nonblocking(true)?; + Ok(Self(UnixListener::from_std(listener)?)) + } + + pub async fn accept(&self) -> anyhow::Result { + Ok(Connection(self.0.accept().await?.0)) + } +} + +#[derive(Debug)] +pub struct Connection(UnixStream); +impl Connection { + pub async fn recv(&mut self, data: &mut Vec) -> anyhow::Result<()> { + let len = self.0.read_u32_le().await? as usize; + if len > MAX_MESSAGE_LEN { + return Err(anyhow!("message too large: {len} bytes")); + } + data.resize(len, 0); + self.0.read_exact(data).await?; + Ok(()) + } + + pub async fn send(&mut self, data: &[u8]) -> anyhow::Result<()> { + if data.len() > MAX_MESSAGE_LEN { + return Err(anyhow!("message too large: {} bytes", data.len())); + } + self.0.write_u32_le(data.len() as u32).await?; + self.0.write_all(data).await?; + Ok(()) + } + + pub fn peer_host_uid(&self) -> Option { + Some(self.0.peer_cred().ok()?.uid()) + } +} diff --git a/daemon/accountd/src/util/mod.rs b/daemon/accountd/src/util/mod.rs new file mode 100644 index 0000000..f54e5d5 --- /dev/null +++ b/daemon/accountd/src/util/mod.rs @@ -0,0 +1,13 @@ +pub mod fs; +pub mod time; + +use cfg_if::cfg_if; + +cfg_if! { + if #[cfg(target_family = "unix")] { + #[path = "ipc_unix.rs"] + pub mod ipc; + } else { + std::compile_error!("target not supported"); + } +} diff --git a/daemon/accountd/src/util/time.rs b/daemon/accountd/src/util/time.rs new file mode 100644 index 0000000..9158cea --- /dev/null +++ b/daemon/accountd/src/util/time.rs @@ -0,0 +1,15 @@ +use std::time::SystemTime; + +macro_rules! timestamp { + ($f:ident) => {{ + let now = SystemTime::now(); + match now.duration_since(SystemTime::UNIX_EPOCH) { + Ok(x) => x.$f() as i64, + Err(_) => -(SystemTime::UNIX_EPOCH.duration_since(now).unwrap().$f() as i64), + } + }}; +} + +pub fn timestamp_s() -> i64 { + timestamp!(as_secs) +} diff --git a/lib/auth_method_fx/Cargo.toml b/lib/auth_method_fx/Cargo.toml new file mode 100644 index 0000000..57c0e1a --- /dev/null +++ b/lib/auth_method_fx/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "auth_method_fx" +version = "0.1.0" +edition = "2024" + +[dependencies] +semios_account = { path = "../semios_account" } +serde_json = "1" diff --git a/lib/auth_method_fx/src/lib.rs b/lib/auth_method_fx/src/lib.rs new file mode 100644 index 0000000..0d51dc5 --- /dev/null +++ b/lib/auth_method_fx/src/lib.rs @@ -0,0 +1,81 @@ +//! A framework for writing an auth method. + +use semios_account::auth::{AuthCli, AuthMethodInfo, AuthenticateArgs, ModeInfo, UpdateArgs}; + +pub struct App { + name: &'static str, + provides_master_key: bool, + modes: Vec>, +} +impl App { + #[inline] + pub const fn new(name: &'static str) -> Self { + Self { + name, + provides_master_key: false, + modes: Vec::new(), + } + } + + pub fn provides_master_key(mut self) -> Self { + self.provides_master_key = true; + self + } + + pub fn mode(mut self, mode: Box) -> Self { + self.modes.push(mode); + self + } + + pub fn run(self) -> Result<(), Box> { + run(self) + } + + fn info(&self) -> AuthMethodInfo { + AuthMethodInfo { + name: self.name.into(), + provides_master_key: self.provides_master_key, + modes: self + .modes + .iter() + .map(|x| ModeInfo { + name: x.name().into(), + }) + .collect(), + } + } +} + +pub trait Mode { + fn name(&self) -> &'static str; + fn authenticate(&self, args: AuthenticateArgs) -> Result<(), Box>; + fn update(&self, args: UpdateArgs) -> Result<(), Box>; +} + +pub fn run(app: App) -> Result<(), Box> { + let cli = AuthCli::parse(&std::env::args().skip(1).collect::>())?; + match cli { + AuthCli::QueryInformation => query_info(&app), + AuthCli::Authenticate(args) => authenticate(&app, args), + AuthCli::Update(args) => update(&app, args), + } +} + +fn query_info(app: &App) -> Result<(), Box> { + println!("{}", serde_json::to_string(&app.info())?); + Ok(()) +} + +fn authenticate(app: &App, args: AuthenticateArgs) -> Result<(), Box> { + let Some(mode) = app.modes.iter().find(|x| x.name() == &args.mode) else { + return Err(Box::from("mode not found")); + }; + mode.authenticate(args) +} + +fn update(app: &App, args: UpdateArgs) -> Result<(), Box> { + let Some(mode) = app.modes.iter().find(|x| x.name() == &args.mode) else { + return Err(Box::from("mode not found")); + }; + mode.update(args) +} diff --git a/lib/semios_account/Cargo.toml b/lib/semios_account/Cargo.toml new file mode 100644 index 0000000..8d0acf9 --- /dev/null +++ b/lib/semios_account/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "semios_account" +version = "0.1.0" +edition = "2024" + +[features] +default = ["client"] +client = [] + +[dependencies] +cfg-if = "1" +bitflags = { version = "2", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +uuid = { version = "1", features = ["serde"] } diff --git a/lib/semios_account/src/auth.rs b/lib/semios_account/src/auth.rs new file mode 100644 index 0000000..d276436 --- /dev/null +++ b/lib/semios_account/src/auth.rs @@ -0,0 +1,161 @@ +//! User authentication. + +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +macro_rules! decl_session_args { + (pub struct $sn:ident { $(pub $n:ident : $t:ty,)* }) => { + #[derive(Debug, Clone)] + pub struct $sn { + $(pub $n: $t),* + } + impl $sn { + pub fn parse(args: &[String]) -> Result> { + Self::_from_session_args(SessionArgs::parse(args)?) + } + + pub fn compose(&self) -> Vec { + self._to_session_args().compose() + } + + fn _from_session_args(sa: SessionArgs) -> Result> { + Ok(Self { + $( + $n: sa.0.get(stringify!($n)) + .ok_or_else(|| Box::::from(format!( + "argument {} not found", stringify!($n), + )))? + .parse::<$t>()? + ),* + }) + } + + fn _to_session_args(&self) -> SessionArgs { + let mut map = HashMap::new(); + $( + map.insert(stringify!($n).into(), self.$n.to_string()); + )* + SessionArgs(map) + } + } + }; +} + +/// Maximum count of auth methods enabled for one user. +pub const USER_MAX_AUTH_METHODS: usize = 32; + +/// Record of an auth method enabled for a user. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAuthMethodRecord { + /// Name of the auth method. + pub name: String, + + /// Flags of the auth method. + pub flags: UserAuthMethodFlags, +} + +bitflags! { + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] + pub struct UserAuthMethodFlags: u32 { + /// Indicates if the auth method is a "main" auth method. + /// + /// A "main" auth method is unique for a user. It provides a master key for secret storage. + const MAIN = 1; + } +} + +/// Information about an authentication method. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthMethodInfo { + /// Name of the auth method. + pub name: String, + + /// True if the auth method supports providing the master key. + pub provides_master_key: bool, + + /// Supported operation modes. + pub modes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModeInfo { + /// Name of the operation mode. + pub name: String, +} + +/// Command line of an authentication method provider. +#[derive(Debug, Clone)] +pub enum AuthCli { + /// Queries information about the auth method. + /// + /// The implementor should output an [`AuthMethod`] in JSON to stdout stream, and then exit. + QueryInformation, + + /// Begins an authentication session. + Authenticate(AuthenticateArgs), + + /// Begins an update session. + Update(UpdateArgs), +} +impl AuthCli { + pub fn parse(args: &[String]) -> Result> { + match args.first().map(String::as_str) { + Some("QueryInformation") => Ok(Self::QueryInformation), + Some("Authenticate") => Ok(Self::Authenticate(AuthenticateArgs::parse(&args[1..])?)), + Some("Update") => Ok(Self::Update(UpdateArgs::parse(&args[1..])?)), + Some(unexpected) => Err(Box::from(format!("unexpected command `{unexpected}`"))), + None => Err(Box::from("no command specified")), + } + } + + pub fn compose(&self) -> Vec { + match self { + Self::QueryInformation => vec!["QueryInformation".into()], + Self::Authenticate(args) => { + let mut ret = vec!["Authenticate".into()]; + ret.append(&mut args.compose()); + ret + } + Self::Update(args) => { + let mut ret = vec!["Update".into()]; + ret.append(&mut args.compose()); + ret + } + } + } +} + +decl_session_args! { + pub struct AuthenticateArgs { + pub user_uuid: Uuid, + pub mode: String, + } +} + +decl_session_args! { + pub struct UpdateArgs { + pub user_uuid: Uuid, + pub mode: String, + } +} + +#[derive(Debug, Clone)] +struct SessionArgs(HashMap); +impl SessionArgs { + fn parse(args: &[String]) -> Result> { + let mut map = HashMap::with_capacity(args.len()); + for arg in args { + let Some((key, val)) = arg.split_once('=') else { + return Err(Box::from("invalid argument format")); + }; + map.insert(key.into(), val.into()); + } + Ok(Self(map)) + } + + fn compose(&self) -> Vec { + self.0.iter().map(|(k, v)| format!("{k}={v}")).collect() + } +} diff --git a/lib/semios_account/src/client.rs b/lib/semios_account/src/client.rs new file mode 100644 index 0000000..d5b8b67 --- /dev/null +++ b/lib/semios_account/src/client.rs @@ -0,0 +1,95 @@ +use crate::{ + auth::UserAuthMethodRecord, + error::Error, + protocol::*, + raw_client::Connection, + record::{GroupInfo, UserInfo}, + secret::Secret, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::path::PathBuf; + +#[derive(Debug)] +pub struct Client { + conn: Connection, + buf: Vec, +} +impl Client { + pub fn connect_default() -> std::io::Result { + Self::connect(&crate::protocol::uri()) + } + + pub fn connect(uri: &str) -> std::io::Result { + Ok(Self { + conn: Connection::connect(uri)?, + buf: Vec::with_capacity(512), + }) + } + + pub fn invoke( + &mut self, + method: String, + params: P, + ) -> Result { + self.buf.clear(); + let params = + serde_json::to_value(params).map_err(|e| Error::Communication(e.to_string()))?; + let req = Request { method, params }; + serde_json::to_writer(&mut self.buf, &req) + .map_err(|e| Error::Communication(e.to_string()))?; + self.conn + .send(&self.buf) + .map_err(|e| Error::Communication(e.to_string()))?; + self.conn + .recv(&mut self.buf) + .map_err(|e| Error::Communication(e.to_string()))?; + let resp: Response = + serde_json::from_slice(&self.buf).map_err(|e| Error::Communication(e.to_string()))?; + resp.into_result() + .map(|x| serde_json::from_value(x).map_err(|e| Error::Communication(e.to_string()))) + .flatten() + } + + pub fn get_user_info(&mut self, args: GetUserInfoArgs) -> Result { + self.invoke(GET_USER_INFO.into(), args) + } + + pub fn get_group_info(&mut self, args: GetGroupInfoArgs) -> Result { + self.invoke(GET_GROUP_INFO.into(), args) + } + + pub fn get_secret(&mut self, args: GetSecretArgs) -> Result { + self.invoke(GET_SECRET.into(), args) + } + + pub fn set_secret(&mut self, args: SetSecretArgs) -> Result<(), Error> { + self.invoke(SET_SECRET.into(), args) + } + + pub fn provide_master_key(&mut self, args: ProvideMasterKeyArgs) -> Result<(), Error> { + self.invoke(PROVIDE_MASTER_KEY.into(), args) + } + + pub fn clear_master_key(&mut self, args: ClearMasterKeyArgs) -> Result<(), Error> { + self.invoke(CLEAR_MASTER_KEY.into(), args) + } + + pub fn get_user_auth_methods( + &mut self, + args: GetUserAuthMethodsArgs, + ) -> Result, Error> { + self.invoke(GET_USER_AUTH_METHODS.into(), args) + } + + pub fn get_auth_method_path(&mut self, args: GetAuthMethodPathArgs) -> Result { + self.invoke(GET_AUTH_METHOD_PATH.into(), args) + } + + pub fn user_add_auth_method(&mut self, args: UserAddAuthMethodArgs) -> Result<(), Error> { + self.invoke(USER_ADD_AUTH_METHOD.into(), args) + } + + pub fn create_user(&mut self, args: CreateUserArgs) -> Result<(), Error> { + self.invoke(CREATE_USER.into(), args) + } +} diff --git a/lib/semios_account/src/error.rs b/lib/semios_account/src/error.rs new file mode 100644 index 0000000..f0c2859 --- /dev/null +++ b/lib/semios_account/src/error.rs @@ -0,0 +1,59 @@ +//! Errors. + +use serde::{Deserialize, Serialize}; +use std::fmt::Display; + +/// An error. +#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)] +pub enum Error { + /// Communication error. + #[error("communication error: {0}")] + Communication(String), + + /// The specified method is not implemented. + #[error("`{0}`: Not implemented")] + NotImplemented(String), + + /// Parameters are not suitable for the called method. + #[error("invalid parameters: {0}")] + InvalidParams(String), + + /// The user has not enough permission to perform the action. + #[error("permission denied")] + PermissionDenied, + + /// The operation operates on a user, but the specified user does not exist. + #[error("no such user")] + NoSuchUser, + + /// The operation operates on a group, but the specified group does not exist. + #[error("no such group")] + NoSuchGroup, + + /// The operation operates on a secret, but the specified secret does not exist. + #[error("no such secret")] + NoSuchSecret, + + /// Attempted to launch an auth method that is not installed on current system. + #[error("no such auth method")] + NoSuchAuthMethod, + + /// Attempted to authenticate/update authentication via an auth method that is not activated for the user. + /// + /// **NOTE**: This does not imply that the auth method is installed on current system. + #[error("the specified auth method is not activated for the user")] + NotActivatedAuthMethod, + + /// TODO + #[error("already exists")] + AlreadyExists, + + /// An internal error. + #[error("internal error: {0}")] + Internal(String), +} +impl Error { + pub fn make_internal(x: impl Display) -> Self { + Self::Internal(x.to_string()) + } +} diff --git a/lib/semios_account/src/lib.rs b/lib/semios_account/src/lib.rs new file mode 100644 index 0000000..5bb2ea1 --- /dev/null +++ b/lib/semios_account/src/lib.rs @@ -0,0 +1,19 @@ +pub mod auth; +pub mod error; +pub mod protocol; +pub mod record; +pub mod secret; +pub mod wellknown; + +#[cfg(feature = "client")] +cfg_if::cfg_if! { + if #[cfg(target_family = "unix")] { + #[path = "raw_client_unix.rs"] + pub mod raw_client; + } else { + std::compile_error!("target not supported"); + } +} + +#[cfg(feature = "client")] +pub mod client; diff --git a/lib/semios_account/src/protocol.rs b/lib/semios_account/src/protocol.rs new file mode 100644 index 0000000..b116cb7 --- /dev/null +++ b/lib/semios_account/src/protocol.rs @@ -0,0 +1,266 @@ +use crate::{ + error::Error, + record::{GroupInfo, HostGid, HostUid, SecureTag}, + secret::{MasterKeyProvision, Secret}, +}; +use cfg_if::cfg_if; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +/// Max length of a single message. +pub const MAX_MESSAGE_LEN: usize = 128 * 1024; + +// ==- IPC URI -== +cfg_if! { + if #[cfg(target_os = "linux")] { + pub const DEFAULT_URI: &str = "unix://@verified:org.semilabs.os/accountd"; + } else if #[cfg(target_family = "unix")] { + pub const DEFAULT_URI: &str = "unix:///var/run/accountd.sock"; + } else { + pub const DEFAULT_URI: &str = ""; + } +} + +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum Uri { + Unix(std::path::PathBuf), + UnixAbstract(String), +} +impl std::str::FromStr for Uri { + type Err = std::io::Error; + + fn from_str(s: &str) -> Result { + if let Some(unix) = s.strip_prefix("unix://") { + if unix.starts_with('/') { + Ok(Self::Unix(unix.into())) + } else if unix.starts_with('@') { + Ok(Self::UnixAbstract(unix[1..].into())) + } else { + Err(std::io::ErrorKind::AddrNotAvailable.into()) + } + } else { + Err(std::io::ErrorKind::AddrNotAvailable.into()) + } + } +} + +/// Gets IPC URI. +pub fn uri() -> String { + std::env::var("ACCOUNT_IPC_URI").unwrap_or_else(|_| DEFAULT_URI.into()) +} + +// ==- Basic Definitions -== + +/// A request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub method: String, + pub params: serde_json::Value, +} +impl Request { + pub fn params(self) -> Result { + serde_json::from_value(self.params).map_err(|e| Error::InvalidParams(e.to_string())) + } +} + +/// A response. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub success: bool, + pub value: serde_json::Value, +} +impl Response { + pub fn from_result(result: Result) -> Self { + let success = result.is_ok(); + let value = match result { + Ok(x) => serde_json::to_value(x).expect("response not serialized"), + Err(e) => serde_json::to_value(e).expect("response not serialized"), + }; + Self { success, value } + } + + pub fn into_result(self) -> Result { + if self.success { + Ok(serde_json::from_value(self.value) + .map_err(|x| Error::InvalidParams(x.to_string()))?) + } else { + Err(serde_json::from_value(self.value) + .map_err(|x| Error::InvalidParams(x.to_string()))?) + } + } +} + +// ==- Method Names: Querying Server Information -== + +pub const SERVER_VERSION: &str = "ServerVersion"; + +// ==- Methods: Querying Records -== + +pub const LIST_USER: &str = "ListUser"; +pub const LIST_GROUP: &str = "ListGroup"; +pub const GET_USER_INFO: &str = "GetUserInfo"; +pub const GET_GROUP_INFO: &str = "GetGroupInfo"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListUserArgs { + pub start: u32, + pub len: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListGroupArgs { + pub start: u32, + pub len: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GetUserInfoArgs { + Uuid(Uuid), + Username(String), + HostUid(HostUid), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum GetGroupInfoArgs { + Uuid(Uuid), + Groupname(String), + HostGid(HostGid), +} + +// ==- Methods: Managed Secrets -== + +pub const GET_SECRET: &str = "GetSecret"; +pub const SET_SECRET: &str = "SetSecret"; +pub const REMOVE_SECRET: &str = "RemoveSecret"; +pub const PROVIDE_MASTER_KEY: &str = "ProvideMasterKey"; +pub const CLEAR_MASTER_KEY: &str = "ClearMasterKey"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSecretArgs { + pub user: Uuid, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetSecretArgs { + pub user: Uuid, + pub secret: Secret, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveSecretArgs { + pub user: Uuid, + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProvideMasterKeyArgs { + pub user: Uuid, + pub provision: MasterKeyProvision, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClearMasterKeyArgs { + pub user: Uuid, +} + +// ==- Methods: User Management -== + +pub const CREATE_USER: &str = "CreateUser"; +pub const REMOVE_USER: &str = "RemoveUser"; +pub const CREATE_GROUP: &str = "CreateGroup"; +pub const REMOVE_GROUP: &str = "RemoveGroup"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateUserArgs { + /// User name. + pub username: String, + + /// Full name. + pub fullname: Option, + + /// Specify a host UID. + pub host_uid: Option, + + /// User description. + #[serde(default)] + pub description: String, + + /// Secure tags. + #[serde(default)] + pub secure_tags: HashSet, + + /// Extra records. + #[serde(default)] + pub extra_records: HashMap, + + /// Defaults of the user. + #[serde(default)] + pub defaults: HashMap, + + /// Home directory of the user. + pub home_directory: Option, + + /// Initial groups of the newly created user. + #[serde(default)] + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveUserArgs { + /// UUID of the user to be removed. + pub uuid: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateGroupArgs { + /// Group name. + pub groupname: String, + + /// Full group name. + pub fullname: Option, + + /// Group ID on current host. + pub host_gid: Option, + + /// Group description. + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoveGroupArgs { + /// UUID of the group to be removed. + pub uuid: Uuid, +} + +// ==- Methods: Authentication -== + +pub const GET_USER_AUTH_METHODS: &str = "GetUserAuthMethods"; +pub const GET_AUTH_METHOD_PATH: &str = "GetAuthMethodPath"; +pub const USER_ADD_AUTH_METHOD: &str = "UserAddAuthMethod"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetUserAuthMethodsArgs { + /// UUID of the user. + pub user: Uuid, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetAuthMethodPathArgs { + /// Name of the auth method. + pub name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserAddAuthMethodArgs { + /// UUID of the user. + pub user: Uuid, + + /// Name of the auth method. + pub auth_method: String, +} diff --git a/lib/semios_account/src/raw_client_unix.rs b/lib/semios_account/src/raw_client_unix.rs new file mode 100644 index 0000000..0876686 --- /dev/null +++ b/lib/semios_account/src/raw_client_unix.rs @@ -0,0 +1,49 @@ +use crate::protocol::{MAX_MESSAGE_LEN, Uri}; +use cfg_if::cfg_if; +use std::{ + io::{Read, Write}, + os::unix::net::{SocketAddr, UnixStream}, +}; + +#[derive(Debug)] +pub struct Connection(UnixStream); +impl Connection { + pub fn connect(uri: &str) -> std::io::Result { + let uri: Uri = uri.parse()?; + let sockaddr = match uri { + Uri::Unix(path) => SocketAddr::from_pathname(path)?, + Uri::UnixAbstract(_name) => { + cfg_if! { + if #[cfg(target_os = "linux")] { + use std::os::linux::net::SocketAddrExt; + SocketAddr::from_abstract_name(_name.as_bytes())? + } else { + return Err(std::io::ErrorKind::Unsupported.into()); + } + } + } + }; + Ok(Self(UnixStream::connect_addr(&sockaddr)?)) + } + + pub fn send(&mut self, data: &[u8]) -> std::io::Result<()> { + if data.len() > MAX_MESSAGE_LEN { + return Err(std::io::ErrorKind::FileTooLarge.into()); + } + self.0.write_all(&(data.len() as u32).to_le_bytes())?; + self.0.write(data)?; + Ok(()) + } + + pub fn recv(&mut self, buf: &mut Vec) -> std::io::Result<()> { + let mut len = [0u8; size_of::()]; + self.0.read_exact(&mut len)?; + let len = u32::from_le_bytes(len) as usize; + if len > MAX_MESSAGE_LEN { + return Err(std::io::ErrorKind::FileTooLarge.into()); + } + buf.resize(len, 0); + self.0.read_exact(buf)?; + Ok(()) + } +} diff --git a/lib/semios_account/src/record.rs b/lib/semios_account/src/record.rs new file mode 100644 index 0000000..2e86f44 --- /dev/null +++ b/lib/semios_account/src/record.rs @@ -0,0 +1,107 @@ +//! Basic user information records. + +use cfg_if::cfg_if; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use uuid::Uuid; + +cfg_if! { + if #[cfg(target_family = "unix")] { + pub type HostUid = u32; + pub type HostGid = u32; + } else if #[cfg(target_family = "windows")] { + pub type HostUid = String; + pub type HostGid = String; + } else { + pub type HostUid = u64; + pub type HostGid = u64; + } +} + +/// Basical user information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + /// User UUID. + pub uuid: Uuid, + + /// Username. + pub username: String, + + /// User ID on current host. + pub host_uid: Option, + + /// Full name. + pub fullname: String, + + /// User description. + pub description: String, + + /// Secure tags. + pub secure_tags: HashSet, + + /// Extra records. + pub extra_records: HashMap, + + /// Defaults of the user. + pub defaults: HashMap, + + /// Home directory of the user. + pub home_directory: Option, + + /// Time the user is created. + pub creation_time: i64, + + /// Time the user is last logged in. + pub last_login_time: i64, +} +impl UserInfo { + pub fn clear_host_specific(&mut self) { + self.host_uid = None; + } +} + +/// User secure tag. +#[derive(Debug, Clone, Serialize, Deserialize, Hash, PartialEq, Eq)] +pub enum SecureTag { + /// Indicates that the user can be logged in. + Login, + + /// Indicates that the user may create another user. + CreateUser, + + /// Indicates that the user may remove users. + RemoveUser, + + /// Read secrets of other users. + ReadSecret, + + /// Set or delete secrets of other users. + WriteSecret, +} + +/// Basical group information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GroupInfo { + /// Group UUID. + pub uuid: Uuid, + + /// Group name. + pub groupname: String, + + /// Group ID on current host. + pub host_gid: Option, + + /// Full group name. + pub fullname: String, + + /// Group description. + pub description: String, + + /// Time the group is created. + pub creation_time: i64, +} +impl GroupInfo { + pub fn clear_host_specific(&mut self) { + self.host_gid = None; + } +} diff --git a/lib/semios_account/src/secret.rs b/lib/semios_account/src/secret.rs new file mode 100644 index 0000000..0d16ec7 --- /dev/null +++ b/lib/semios_account/src/secret.rs @@ -0,0 +1,53 @@ +//! User managed secrets. +//! +//! Managed Secrets is a feature that allows applications host their secrets (e.g. password database keys) here, +//! and get the secrets later with proper authentication, like Apple Keychain and KDE KWallet. + +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; + +/// Maximum length of a secret name. +pub const SECRET_NAME_LEN: usize = 1024; + +/// Maximum length of a managed secret item. +pub const SECRET_MAX_LEN: usize = 4096; + +/// Maximum count of managed secrets owned by a user. +pub const MAX_SECRET_COUNT: usize = 16384; + +/// A managed secret item. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Secret { + /// Name of the secret. + pub name: String, + + /// Data of the secret. + pub data: Vec, + + /// Security flags of the secret. + pub security_flags: SecurityFlags, + + /// Time the secret is created. + pub creation_time: i64, + + /// Time the secret will be expired. + pub expiration_time: i64, +} + +bitflags! { + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] + pub struct SecurityFlags: u32 { + const ENCRYPTED = 1; + const WRITE_PROTECTED = 2; + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MasterKeyProvision { + pub algorithm: String, + pub key: Vec, + pub expiration_time: i64, +} +impl MasterKeyProvision { + pub const ALGORITHM_AES_256_GCM: &str = "AES-256-GCM"; +} diff --git a/lib/semios_account/src/wellknown.rs b/lib/semios_account/src/wellknown.rs new file mode 100644 index 0000000..4ea7abf --- /dev/null +++ b/lib/semios_account/src/wellknown.rs @@ -0,0 +1,19 @@ +//! "Well-known" strings. + +/// Command-line login shell. Used in "defaults" field of a user. +pub const CLI_LOGIN_SHELL: &str = "CliLoginShell"; + +/// Birth date. Used in "extra_records" field of a user. +pub const BIRTH_DATE: &str = "BirthDate"; + +/// Gender. Used in "extra_records" field of a user. +pub const GENDER: &str = "Gender"; + +/// Name of the GUI interactive operation mode. +pub const OP_MODE_GUI: &str = "GUI"; + +/// Name of the CLI interactive operation mode. +pub const OP_MODE_CLI: &str = "CLI"; + +/// Name of the API non-interactive operation mode. +pub const OP_MODE_API: &str = "API"; diff --git a/libexec/auth_password/Cargo.toml b/libexec/auth_password/Cargo.toml new file mode 100644 index 0000000..7b3a6c1 --- /dev/null +++ b/libexec/auth_password/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "auth_password" +version = "0.1.0" +edition = "2024" + +[dependencies] +semios_account = { path = "../../lib/semios_account", features = ["client"] } +auth_method_fx = { path = "../../lib/auth_method_fx" } +password-hash = { version = "0.6", features = ["phc", "rand_core", "getrandom"] } +argon2 = "0.6.0-rc.8" +uuid = "1" +dialoguer = "0.12.0" +rand = "0.10" +aes-gcm = "0.10.3" +generic-array = "0.14.7" diff --git a/libexec/auth_password/src/api.rs b/libexec/auth_password/src/api.rs new file mode 100644 index 0000000..7fe35ca --- /dev/null +++ b/libexec/auth_password/src/api.rs @@ -0,0 +1,25 @@ +use auth_method_fx::Mode; +use semios_account::{ + auth::{AuthenticateArgs, UpdateArgs}, + wellknown::OP_MODE_API, +}; + +#[derive(Debug)] +pub struct ApiMode; +impl Mode for ApiMode { + fn name(&self) -> &'static str { + OP_MODE_API + } + + fn authenticate( + &self, + AuthenticateArgs { user_uuid, mode }: AuthenticateArgs, + ) -> Result<(), Box> { + debug_assert_eq!(mode, self.name()); + todo!(); + } + + fn update(&self, args: UpdateArgs) -> Result<(), Box> { + todo!(); + } +} diff --git a/libexec/auth_password/src/backend.rs b/libexec/auth_password/src/backend.rs new file mode 100644 index 0000000..3fbeb42 --- /dev/null +++ b/libexec/auth_password/src/backend.rs @@ -0,0 +1,281 @@ +//! The backend implementation. + +use aes_gcm::{ + Aes256Gcm as AesGcm, Nonce, + aead::{Aead, KeyInit}, +}; +use argon2::{ARGON2D_IDENT, ARGON2I_IDENT, ARGON2ID_IDENT, PasswordHash, PasswordVerifier}; +use generic_array::GenericArray; +use password_hash::PasswordHasher; +use rand::Rng; +use semios_account::{ + client::Client, + protocol::{GetSecretArgs, SetSecretArgs}, + secret::{MasterKeyProvision, Secret, SecurityFlags}, +}; +use uuid::Uuid; + +/// Default algorithm to hash new password strings. +type PasswordHashAlgorithm = argon2::Argon2<'static>; + +/// Name of secret used to store the hashed password string. +const SECRET_NAME_PASSWORD: &str = "org.semilabs.os.account.auth.password/Password"; + +/// Name of secret used to store the encrypted (in this crate) master key. +const SECRET_NAME_MASTER_KEY: &str = "org.semilabs.os.account.auth.password/MasterKey"; + +/// List of supported crypto algorithms. +const CRYPTO_ALGO_LIST: &[&dyn CryptoAlgo] = &[&Aes256Gcm]; + +/// Default crypto algorithm. +const DEFAULT_CRYPTO_ALGO: &dyn CryptoAlgo = CRYPTO_ALGO_LIST[0]; + +/// Updates password of a user. +pub fn update( + uuid: &Uuid, + old_password: &str, + new_password: &str, +) -> Result<(), Box> { + let mut client = Client::connect_default()?; + + let crypto_algo; + let clear_text_key; + let master_key_secret = client.get_secret(GetSecretArgs { + user: uuid.clone(), + name: SECRET_NAME_MASTER_KEY.into(), + }); + if let Ok(master_key_secret) = &master_key_secret { + let (algo, encrypted_key) = extract_secret_master_key_data(&master_key_secret.data)?; + crypto_algo = find_crypto(algo)?; + clear_text_key = decrypt_key(crypto_algo, encrypted_key, old_password)?; + } else { + crypto_algo = DEFAULT_CRYPTO_ALGO; + clear_text_key = DEFAULT_CRYPTO_ALGO.generate_key(); + } + let reencrypted_key = encrypt_key(crypto_algo, &clear_text_key, new_password)?; + + client.set_secret(SetSecretArgs { + user: uuid.clone(), + secret: compose_secret_master_key(crypto_algo, &reencrypted_key), + })?; + client.set_secret(SetSecretArgs { + user: uuid.clone(), + secret: compose_secret_password(new_password)?, + })?; + Ok(()) +} + +/// Verifies if the provided password can login the user represented by the provided UUID. +pub fn verify(uuid: &Uuid, password: &str) -> Result> { + let mut client = Client::connect_default()?; + let secret = client.get_secret(GetSecretArgs { + user: uuid.clone(), + name: SECRET_NAME_PASSWORD.into(), + })?; + verify_secret_password(&secret, password) +} + +/// Provides master key of a user. +pub fn provide_master_key( + uuid: &Uuid, + password: &str, +) -> Result> { + let mut client = Client::connect_default()?; + let secret = client.get_secret(GetSecretArgs { + user: uuid.clone(), + name: SECRET_NAME_MASTER_KEY.into(), + })?; + let (algo, encrypted_key) = extract_secret_master_key_data(&secret.data)?; + let algo = find_crypto(algo)?; + let key = decrypt_key(algo, encrypted_key, password)?; + Ok(MasterKeyProvision { + algorithm: algo.name().into(), + key, + expiration_time: i64::MAX, + }) +} + +/// Verifies if the password matches the managed secret item. +/// +/// # Errors +/// This function would return an error if: +/// +/// - the PHC is invalid; +/// - the algorithm used is unsupported; +/// - the secret has invalid permission settings. +fn verify_secret_password( + secret: &Secret, + password: &str, +) -> Result> { + if !secret + .security_flags + .contains(SecurityFlags::WRITE_PROTECTED) + { + return Err(Box::from("insecure password secret")); + } + let phc = str::from_utf8(&secret.data)?; + verify_phc(phc, password) +} + +/// Verifies if the password matches the PHC text. +/// +/// # Errors +/// This function would return an error if the PHC is invalid, or uses an unsupported algorithm. +fn verify_phc(phc: &str, password: &str) -> Result> { + let phc = PasswordHash::new(phc)?; + match phc.algorithm { + ARGON2D_IDENT | ARGON2ID_IDENT | ARGON2I_IDENT => Ok(argon2::Argon2::default() + .verify_password(password.as_bytes(), &phc) + .is_ok()), + _ => Err(Box::from("Unsupported algorithm")), + } +} + +/// Compose a [`Secret`] representing to the given password text. +fn compose_secret_password(password: &str) -> Result> { + Ok(Secret { + name: SECRET_NAME_PASSWORD.into(), + data: compose_phc(password)?.into_bytes(), + security_flags: SecurityFlags::WRITE_PROTECTED, + creation_time: 0, + expiration_time: i64::MAX, + }) +} + +/// Composes a PHC string, hashing the provided password text using the default algorithm. +fn compose_phc(password: &str) -> Result> { + let algorithm = PasswordHashAlgorithm::default(); + Ok(algorithm.hash_password(password.as_bytes())?.to_string()) +} + +/// Extracts algorithm and encrypted key from master key secret data. +fn extract_secret_master_key_data( + data: &[u8], +) -> Result<(&str, &[u8]), Box> { + let zero_position = data.iter().position(|x| *x == 0).unwrap_or_default(); + if zero_position == 0 { + return Err(Box::from("invalid master key format")); + } + let Ok(algo) = str::from_utf8(&data[..zero_position]) else { + return Err(Box::from("invalid master key format")); + }; + Ok((algo, &data[zero_position + 1..])) +} + +fn compose_secret_master_key(algorithm: &dyn CryptoAlgo, encrypted_key: &[u8]) -> Secret { + Secret { + name: SECRET_NAME_MASTER_KEY.into(), + data: compose_secret_master_key_data(algorithm, encrypted_key), + security_flags: SecurityFlags::WRITE_PROTECTED, + creation_time: 0, + expiration_time: i64::MAX, + } +} + +/// Composes data of the [`Secret`] representing to the master key. +fn compose_secret_master_key_data(algorithm: &dyn CryptoAlgo, encrypted_key: &[u8]) -> Vec { + [algorithm.name().as_bytes(), encrypted_key].join(&b'\0') +} + +/// Encrypts a key via a password. +fn encrypt_key( + algorithm: &dyn CryptoAlgo, + key: &[u8], + password: &str, +) -> Result, Box> { + Ok(algorithm.encrypt(&key_by_password(algorithm, password), key)) +} + +/// Decrypts a encrypted key via a password. +fn decrypt_key( + algorithm: &dyn CryptoAlgo, + encrypted_key: &[u8], + password: &str, +) -> Result, Box> { + algorithm.decrypt(&key_by_password(algorithm, password), encrypted_key) +} + +fn key_by_password(algorithm: &dyn CryptoAlgo, password: &str) -> Vec { + let mut key = password.as_bytes().to_vec(); + key.resize(algorithm.key_len(), 0); + key +} + +fn find_crypto(name: &str) -> Result<&dyn CryptoAlgo, Box> { + CRYPTO_ALGO_LIST + .iter() + .find(|x| x.name() == name) + .copied() + .ok_or_else(|| Box::from("unknown crypto algorithm")) +} + +trait CryptoAlgo: Send + Sync { + /// Returns name of the algorithm. + fn name(&self) -> &'static str; + + /// Length of a key, in bytes. + fn key_len(&self) -> usize; + + /// Encrypt data. + fn encrypt(&self, key: &[u8], data: &[u8]) -> Vec; + + /// Decrypt data. + fn decrypt(&self, key: &[u8], data: &[u8]) -> Result, Box>; + + /// Generates a key. + fn generate_key(&self) -> Vec { + let mut v = vec![0; self.key_len()]; + rand::fill(&mut v); + v + } +} + +#[derive(Debug)] +struct Aes256Gcm; +impl CryptoAlgo for Aes256Gcm { + fn name(&self) -> &'static str { + MasterKeyProvision::ALGORITHM_AES_256_GCM + } + + fn key_len(&self) -> usize { + 256 / 8 + } + + fn encrypt(&self, key: &[u8], data: &[u8]) -> Vec { + assert_eq!(key.len(), self.key_len(), "Invalid key length"); + + let mut nonce = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce); + let nonce = Nonce::from_slice(&nonce); + + let cipher = AesGcm::new(GenericArray::from_slice(key)); + + let ciphertext = cipher + .encrypt(nonce, data.as_ref()) + .expect("encryption failure!"); + + let mut result = Vec::with_capacity(nonce.len() + ciphertext.len()); + result.extend_from_slice(nonce); + result.extend_from_slice(&ciphertext); + result + } + + fn decrypt(&self, key: &[u8], data: &[u8]) -> Result, Box> { + assert_eq!(key.len(), self.key_len(), "Invalid key length"); + + if data.len() < 12 { + return Err("Data too short".into()); + } + + let (nonce_bytes, ciphertext) = data.split_at(12); + let nonce = Nonce::from_slice(nonce_bytes); + + let cipher = AesGcm::new(GenericArray::from_slice(key)); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("decryption failure: {:?}", e))?; + + Ok(plaintext) + } +} diff --git a/libexec/auth_password/src/cli.rs b/libexec/auth_password/src/cli.rs new file mode 100644 index 0000000..df074c6 --- /dev/null +++ b/libexec/auth_password/src/cli.rs @@ -0,0 +1,42 @@ +use auth_method_fx::Mode; +use semios_account::{ + auth::{AuthenticateArgs, UpdateArgs}, + wellknown::OP_MODE_CLI, +}; + +#[derive(Debug)] +pub struct CliMode; +impl Mode for CliMode { + fn name(&self) -> &'static str { + OP_MODE_CLI + } + + fn authenticate(&self, args: AuthenticateArgs) -> Result<(), Box> { + let password: String = dialoguer::Password::new() + .allow_empty_password(true) + .with_prompt("Password") + .interact()?; + if !matches!(crate::backend::verify(&args.user_uuid, &password), Ok(true)) { + eprintln!("Sorry."); + std::process::exit(1); + } + Ok(()) + } + + fn update(&self, args: UpdateArgs) -> Result<(), Box> { + let old_password: String = dialoguer::Password::new() + .allow_empty_password(true) + .with_prompt("Old Password") + .interact()?; + let new_password: String = dialoguer::Password::new() + .allow_empty_password(true) + .with_prompt("New Password") + .with_confirmation("Confirm password", "Passwords mismatching") + .interact()?; + if let Err(err) = crate::backend::update(&args.user_uuid, &old_password, &new_password) { + eprintln!("Unable to update user password: {err}"); + std::process::exit(1); + } + Ok(()) + } +} diff --git a/libexec/auth_password/src/main.rs b/libexec/auth_password/src/main.rs new file mode 100644 index 0000000..c357f2b --- /dev/null +++ b/libexec/auth_password/src/main.rs @@ -0,0 +1,11 @@ +mod api; +mod backend; +mod cli; + +fn main() -> Result<(), Box> { + auth_method_fx::App::new("password") + .provides_master_key() + .mode(Box::new(api::ApiMode)) + .mode(Box::new(cli::CliMode)) + .run() +} diff --git a/misc/accountd.airs b/misc/accountd.airs new file mode 100644 index 0000000..aadc378 --- /dev/null +++ b/misc/accountd.airs @@ -0,0 +1,10 @@ +[service] +display-name = "System Account Service" +description = "Service of system account information, authentication and federation." + +[exec] +start = "accountd" + +[env] +working_dir = "/" +clear_vars = true