initial commit

Signed-off-by: sisungo <[email protected]>
This commit is contained in:
2026-07-18 20:30:39 +08:00
commit bfcc98b50e
39 changed files with 2904 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
/.zed
/.idea
/.vscode
/Cargo.lock
/target
/build_config
+12
View File
@@ -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"] }
+11
View File
@@ -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"
+45
View File
@@ -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<String>,
#[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(())
}
+83
View File
@@ -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<String>,
/// Full name
#[arg(short = 'N', long)]
fullname: Option<String>,
/// Host UID
#[arg(short = 'U', long)]
host_uid: Option<HostUid>,
/// 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<String>,
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(())
}
+52
View File
@@ -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::<Uuid>() {
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();
}
+52
View File
@@ -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<Client> {
// 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()
}
}
+48
View File
@@ -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<String>,
#[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(())
}
+70
View File
@@ -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<String>, v: &'a impl Display) {
self.0.push((k.into(), v));
}
pub fn add_optional(&mut self, k: impl Into<String>, v: &'a Option<impl Display>) {
if let Some(v) = v {
self.0.push((k.into(), v));
}
}
pub fn add_conditional<V: Display>(
&mut self,
k: impl Into<String>,
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<Uuid> {
if let Ok(uuid) = filter.parse::<Uuid>() {
return Some(uuid);
}
let args = match filter.parse::<HostUid>() {
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<Uuid> {
if let Ok(uuid) = filter.parse::<Uuid>() {
return Some(uuid);
}
let args = match filter.parse::<HostUid>() {
Ok(uid) => GetGroupInfoArgs::HostGid(uid),
Err(_) => GetGroupInfoArgs::Groupname(filter.into()),
};
Some(client.get_group_info(args).ok()?.uuid)
}
+26
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
// Sandbox rules for running an authentication method.
+387
View File
@@ -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<AppState>) -> 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<Box<dyn Future<Output = Result<serde_json::Value, Error>> + Send>>;
type FnMethod = Box<dyn Fn(Arc<AppState>, Caller, serde_json::Value) -> FutureMethod + Send + Sync>;
/// A context for API users.
pub struct ApiContext {
methods: FxHashMap<&'static str, FnMethod>,
app_state: Arc<AppState>,
}
impl ApiContext {
pub fn new(app_state: Arc<AppState>) -> 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<ApiContext>,
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<ApiContext>,
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<AppState>,
caller: Caller,
args: ListUserArgs,
) -> Result<Vec<Uuid>, Error> {
Ok(state.local_db.list_user(args.start, args.len))
}
async fn list_group(
state: Arc<AppState>,
caller: Caller,
args: ListGroupArgs,
) -> Result<Vec<Uuid>, Error> {
Ok(state.local_db.list_group(args.start, args.len))
}
async fn get_user_info(
state: Arc<AppState>,
caller: Caller,
args: GetUserInfoArgs,
) -> Result<UserInfo, Error> {
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<AppState>,
caller: Caller,
args: GetGroupInfoArgs,
) -> Result<GroupInfo, Error> {
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<AppState>,
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<AppState>,
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<AppState>,
caller: Caller,
args: RemoveUserArgs,
) -> Result<(), Error> {
require_secure_tags(&state, caller, &[SecureTag::RemoveUser]).await?;
todo!();
}
async fn get_secret(
state: Arc<AppState>,
caller: Caller,
args: GetSecretArgs,
) -> Result<Secret, Error> {
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<AppState>,
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<AppState>,
caller: Caller,
args: GetUserAuthMethodsArgs,
) -> Result<Vec<UserAuthMethodRecord>, 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<AppState>,
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<AppState>,
caller: Caller,
args: GetAuthMethodPathArgs,
) -> Result<PathBuf, Error> {
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(())
}
+57
View File
@@ -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<String> {
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<AuthMethodInfo> {
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
}
}
+67
View File
@@ -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<AppState>) -> anyhow::Result<()> {
let init_file: Vec<InitCommand> = 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(())
}
+391
View File
@@ -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<rusqlite::Connection>);
impl LocalDb {
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
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<Secret> {
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<Uuid> {
self._list_user_group("user", start, len)
}
pub fn list_group(&self, start: u32, len: u32) -> Vec<Uuid> {
self._list_user_group("group", start, len)
}
pub fn get_user_info(&self, uuid: &Uuid) -> Option<UserInfo> {
self._get_user_group_info("user", uuid)
}
pub fn get_group_info(&self, uuid: &Uuid) -> Option<GroupInfo> {
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<Vec<UserAuthMethodRecord>, 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<Uuid> {
self._find_uuid_by("user", "user_name", &username)
}
pub fn find_group_by_name(&self, groupname: &str) -> Option<Uuid> {
self._find_uuid_by("group", "group_name", &groupname)
}
pub fn find_user_by_host_uid(&self, host_uid: HostUid) -> Option<Uuid> {
self._find_uuid_by("user", "user_host_uid", &host_uid.to_string())
}
pub fn find_group_by_host_gid(&self, host_gid: HostGid) -> Option<Uuid> {
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<Uuid> {
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::<Uuid>()
.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<T: DeserializeOwned>(
&self,
table: &'static str,
uuid: &Uuid,
) -> Option<T> {
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<T: rusqlite::types::ToSql>(
&self,
table: &'static str,
column: &'static str,
val: &T,
) -> Option<Uuid> {
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::<Uuid>()
.ok()
}
fn _kv_get(&self, key: &str) -> Option<String> {
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(())
}
+107
View File
@@ -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<dyn Iterator<Item = auth::AuthExec>> {
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<auth::AuthExec> {
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<PathBuf>,
/// Specify data directory
#[arg(long)]
data_dir: Option<PathBuf>,
}
#[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() {}
}
+31
View File
@@ -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<FxHashMap<Uuid, MasterKeyProvision>>);
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<MasterKeyProvision> {
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);
}
}
+39
View File
@@ -0,0 +1,39 @@
use std::{
fs::{DirEntry, ReadDir},
path::Path,
};
#[derive(Debug)]
pub struct TreeDir(Vec<ReadDir>);
impl TreeDir {
pub fn open<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
Ok(Self(vec![std::fs::read_dir(path)?]))
}
}
impl Iterator for TreeDir {
type Item = DirEntry;
fn next(&mut self) -> Option<Self::Item> {
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
}
}
+74
View File
@@ -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<Self> {
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<Connection> {
Ok(Connection(self.0.accept().await?.0))
}
}
#[derive(Debug)]
pub struct Connection(UnixStream);
impl Connection {
pub async fn recv(&mut self, data: &mut Vec<u8>) -> 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<HostUid> {
Some(self.0.peer_cred().ok()?.uid())
}
}
+13
View File
@@ -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");
}
}
+15
View File
@@ -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)
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "auth_method_fx"
version = "0.1.0"
edition = "2024"
[dependencies]
semios_account = { path = "../semios_account" }
serde_json = "1"
+81
View File
@@ -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<Box<dyn Mode>>,
}
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<dyn Mode>) -> Self {
self.modes.push(mode);
self
}
pub fn run(self) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>>;
fn update(&self, args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>>;
}
pub fn run(app: App) -> Result<(), Box<dyn std::error::Error>> {
let cli = AuthCli::parse(&std::env::args().skip(1).collect::<Vec<_>>())?;
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<dyn std::error::Error>> {
println!("{}", serde_json::to_string(&app.info())?);
Ok(())
}
fn authenticate(app: &App, args: AuthenticateArgs) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let Some(mode) = app.modes.iter().find(|x| x.name() == &args.mode) else {
return Err(Box::from("mode not found"));
};
mode.update(args)
}
+16
View File
@@ -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"] }
+161
View File
@@ -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, Box<dyn std::error::Error>> {
Self::_from_session_args(SessionArgs::parse(args)?)
}
pub fn compose(&self) -> Vec<String> {
self._to_session_args().compose()
}
fn _from_session_args(sa: SessionArgs) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
$(
$n: sa.0.get(stringify!($n))
.ok_or_else(|| Box::<dyn std::error::Error>::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<ModeInfo>,
}
#[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<Self, Box<dyn std::error::Error>> {
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<String> {
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<String, String>);
impl SessionArgs {
fn parse(args: &[String]) -> Result<Self, Box<dyn std::error::Error>> {
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<String> {
self.0.iter().map(|(k, v)| format!("{k}={v}")).collect()
}
}
+95
View File
@@ -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<u8>,
}
impl Client {
pub fn connect_default() -> std::io::Result<Self> {
Self::connect(&crate::protocol::uri())
}
pub fn connect(uri: &str) -> std::io::Result<Self> {
Ok(Self {
conn: Connection::connect(uri)?,
buf: Vec::with_capacity(512),
})
}
pub fn invoke<P: Serialize, R: DeserializeOwned>(
&mut self,
method: String,
params: P,
) -> Result<R, Error> {
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<UserInfo, Error> {
self.invoke(GET_USER_INFO.into(), args)
}
pub fn get_group_info(&mut self, args: GetGroupInfoArgs) -> Result<GroupInfo, Error> {
self.invoke(GET_GROUP_INFO.into(), args)
}
pub fn get_secret(&mut self, args: GetSecretArgs) -> Result<Secret, Error> {
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<Vec<UserAuthMethodRecord>, Error> {
self.invoke(GET_USER_AUTH_METHODS.into(), args)
}
pub fn get_auth_method_path(&mut self, args: GetAuthMethodPathArgs) -> Result<PathBuf, Error> {
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)
}
}
+59
View File
@@ -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())
}
}
+19
View File
@@ -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;
+266
View File
@@ -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<Self, Self::Err> {
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<T: DeserializeOwned>(self) -> Result<T, Error> {
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<R: Serialize>(result: Result<R, Error>) -> 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<R: DeserializeOwned>(self) -> Result<R, Error> {
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<String>,
/// Specify a host UID.
pub host_uid: Option<HostUid>,
/// User description.
#[serde(default)]
pub description: String,
/// Secure tags.
#[serde(default)]
pub secure_tags: HashSet<SecureTag>,
/// Extra records.
#[serde(default)]
pub extra_records: HashMap<String, String>,
/// Defaults of the user.
#[serde(default)]
pub defaults: HashMap<String, String>,
/// Home directory of the user.
pub home_directory: Option<String>,
/// Initial groups of the newly created user.
#[serde(default)]
pub groups: Vec<Uuid>,
}
#[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<String>,
/// Group ID on current host.
pub host_gid: Option<HostGid>,
/// 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,
}
+49
View File
@@ -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<Self> {
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<u8>) -> std::io::Result<()> {
let mut len = [0u8; size_of::<u32>()];
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(())
}
}
+107
View File
@@ -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<HostUid>,
/// Full name.
pub fullname: String,
/// User description.
pub description: String,
/// Secure tags.
pub secure_tags: HashSet<SecureTag>,
/// Extra records.
pub extra_records: HashMap<String, String>,
/// Defaults of the user.
pub defaults: HashMap<String, String>,
/// Home directory of the user.
pub home_directory: Option<String>,
/// 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<HostGid>,
/// 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;
}
}
+53
View File
@@ -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<u8>,
/// 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<u8>,
pub expiration_time: i64,
}
impl MasterKeyProvision {
pub const ALGORITHM_AES_256_GCM: &str = "AES-256-GCM";
}
+19
View File
@@ -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";
+15
View File
@@ -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"
+25
View File
@@ -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<dyn std::error::Error>> {
debug_assert_eq!(mode, self.name());
todo!();
}
fn update(&self, args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
todo!();
}
}
+281
View File
@@ -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<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<MasterKeyProvision, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<Secret, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<u8> {
[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<Vec<u8>, Box<dyn std::error::Error>> {
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<Vec<u8>, Box<dyn std::error::Error>> {
algorithm.decrypt(&key_by_password(algorithm, password), encrypted_key)
}
fn key_by_password(algorithm: &dyn CryptoAlgo, password: &str) -> Vec<u8> {
let mut key = password.as_bytes().to_vec();
key.resize(algorithm.key_len(), 0);
key
}
fn find_crypto(name: &str) -> Result<&dyn CryptoAlgo, Box<dyn std::error::Error>> {
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<u8>;
/// Decrypt data.
fn decrypt(&self, key: &[u8], data: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>>;
/// Generates a key.
fn generate_key(&self) -> Vec<u8> {
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<u8> {
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<Vec<u8>, Box<dyn std::error::Error>> {
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)
}
}
+42
View File
@@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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(())
}
}
+11
View File
@@ -0,0 +1,11 @@
mod api;
mod backend;
mod cli;
fn main() -> Result<(), Box<dyn std::error::Error>> {
auth_method_fx::App::new("password")
.provides_master_key()
.mode(Box::new(api::ApiMode))
.mode(Box::new(cli::CliMode))
.run()
}
+10
View File
@@ -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