feat: ship Rust login manager and GitLab release pipeline
Replace the legacy Go implementation as the production path, quarantine the reference tree, add deterministic release packaging and version metadata, and run verification through GitLab CI.
This commit is contained in:
11
crates/latchd-auth/Cargo.toml
Normal file
11
crates/latchd-auth/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "latchd-auth"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
latchd-core = { path = "../latchd-core" }
|
||||
libc = "0.2"
|
||||
zeroize = "1.8"
|
||||
548
crates/latchd-auth/src/lib.rs
Normal file
548
crates/latchd-auth/src/lib.rs
Normal file
@@ -0,0 +1,548 @@
|
||||
//! PAM authentication with one dedicated worker thread.
|
||||
//!
|
||||
//! PAM handles are intentionally never moved out of the worker. The public
|
||||
//! ticket contains only an opaque ID, so the UI cannot accidentally use a PAM
|
||||
//! transaction from the wrong thread or forget to close it.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{CStr, CString},
|
||||
mem,
|
||||
os::raw::{c_char, c_int, c_void},
|
||||
ptr,
|
||||
sync::mpsc::{Receiver, SyncSender, sync_channel},
|
||||
thread,
|
||||
};
|
||||
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use latchd_core::{
|
||||
accounts::Identity,
|
||||
error::Error,
|
||||
session::{self, SessionSpec},
|
||||
};
|
||||
|
||||
const PAM_SUCCESS: c_int = 0;
|
||||
const PAM_SYSTEM_ERR: c_int = 4;
|
||||
const PAM_BUF_ERR: c_int = 5;
|
||||
const PAM_CONV_ERR: c_int = 19;
|
||||
const PAM_AUTHINFO_UNAVAIL: c_int = 9;
|
||||
|
||||
const PAM_PROMPT_ECHO_OFF: c_int = 1;
|
||||
const PAM_PROMPT_ECHO_ON: c_int = 2;
|
||||
const PAM_ERROR_MSG: c_int = 3;
|
||||
const PAM_TEXT_INFO: c_int = 4;
|
||||
const PAM_ESTABLISH_CRED: c_int = 0x0002;
|
||||
const PAM_DELETE_CRED: c_int = 0x0004;
|
||||
|
||||
#[repr(C)]
|
||||
struct PamHandle {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamMessage {
|
||||
msg_style: c_int,
|
||||
msg: *const c_char,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamResponse {
|
||||
resp: *mut c_char,
|
||||
resp_retcode: c_int,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct PamConv {
|
||||
conv: Option<
|
||||
unsafe extern "C" fn(
|
||||
c_int,
|
||||
*mut *const PamMessage,
|
||||
*mut *mut PamResponse,
|
||||
*mut c_void,
|
||||
) -> c_int,
|
||||
>,
|
||||
appdata_ptr: *mut c_void,
|
||||
}
|
||||
|
||||
#[link(name = "pam")]
|
||||
unsafe extern "C" {
|
||||
#[link_name = "pam_start"]
|
||||
fn pam_start(
|
||||
service: *const c_char,
|
||||
user: *const c_char,
|
||||
conv: *const PamConv,
|
||||
pamh: *mut *mut PamHandle,
|
||||
) -> c_int;
|
||||
#[link_name = "pam_authenticate"]
|
||||
fn pam_authenticate(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_acct_mgmt"]
|
||||
fn pam_acct_mgmt(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_setcred"]
|
||||
fn pam_setcred(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_open_session"]
|
||||
fn pam_open_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_close_session"]
|
||||
fn pam_close_session(pamh: *mut PamHandle, flags: c_int) -> c_int;
|
||||
#[link_name = "pam_end"]
|
||||
fn pam_end(pamh: *mut PamHandle, pam_status: c_int) -> c_int;
|
||||
#[link_name = "pam_strerror"]
|
||||
fn pam_strerror(pamh: *const PamHandle, pam_status: c_int) -> *const c_char;
|
||||
}
|
||||
|
||||
/// An opaque authenticated PAM transaction.
|
||||
pub struct AuthTicket {
|
||||
id: u64,
|
||||
identity: Identity,
|
||||
owner: Option<AuthService>,
|
||||
}
|
||||
|
||||
impl AuthTicket {
|
||||
/// Returns the account identity associated with this ticket.
|
||||
pub fn identity(&self) -> &Identity {
|
||||
&self.identity
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AuthTicket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(owner) = &self.owner {
|
||||
let _ = owner.commands.try_send(Command::Cancel { id: self.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Authenticate {
|
||||
username: String,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
service: String,
|
||||
response: std::sync::mpsc::SyncSender<Result<AuthTicket, Error>>,
|
||||
},
|
||||
Run {
|
||||
id: u64,
|
||||
spec: SessionSpec,
|
||||
response: std::sync::mpsc::SyncSender<Result<(), Error>>,
|
||||
},
|
||||
Cancel {
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Serializes PAM operations on a dedicated worker thread.
|
||||
#[derive(Clone)]
|
||||
pub struct AuthService {
|
||||
commands: SyncSender<Command>,
|
||||
}
|
||||
|
||||
impl AuthService {
|
||||
/// Starts the PAM worker.
|
||||
pub fn new() -> Result<Self, Error> {
|
||||
let (commands, requests) = sync_channel::<Command>(1);
|
||||
thread::Builder::new()
|
||||
.name("latchd-pam".into())
|
||||
.spawn(move || worker(requests))
|
||||
.map_err(|error| Error::Worker(format!("spawn PAM worker: {error}")))?;
|
||||
Ok(Self { commands })
|
||||
}
|
||||
|
||||
/// Starts one authentication transaction without blocking the UI thread.
|
||||
pub fn authenticate(
|
||||
&self,
|
||||
username: String,
|
||||
password: Vec<u8>,
|
||||
service: String,
|
||||
) -> Result<AuthTicket, Error> {
|
||||
let (response, result) = sync_channel(0);
|
||||
self.commands
|
||||
.send(Command::Authenticate {
|
||||
username,
|
||||
password: Zeroizing::new(password),
|
||||
service,
|
||||
response,
|
||||
})
|
||||
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
|
||||
let ticket = result
|
||||
.recv()
|
||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))??;
|
||||
let mut ticket = ticket;
|
||||
ticket.owner = Some(self.clone());
|
||||
Ok(ticket)
|
||||
}
|
||||
|
||||
/// Runs the authenticated session and closes its PAM transaction.
|
||||
pub fn run_session(&self, mut ticket: AuthTicket, spec: SessionSpec) -> Result<(), Error> {
|
||||
let (response, result) = sync_channel(0);
|
||||
let id = ticket.id;
|
||||
ticket.owner = None;
|
||||
self.commands
|
||||
.send(Command::Run { id, spec, response })
|
||||
.map_err(|_| Error::Worker("PAM worker stopped".into()))?;
|
||||
result
|
||||
.recv()
|
||||
.map_err(|_| Error::Worker("PAM worker disconnected".into()))?
|
||||
}
|
||||
|
||||
/// Cancels a ticket that will not be used for a session.
|
||||
pub fn cancel(&self, mut ticket: AuthTicket) -> Result<(), Error> {
|
||||
let id = ticket.id;
|
||||
ticket.owner = None;
|
||||
self.commands
|
||||
.send(Command::Cancel { id })
|
||||
.map_err(|_| Error::Worker("PAM worker stopped".into()))
|
||||
}
|
||||
}
|
||||
|
||||
struct ConversationData {
|
||||
username: CString,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Drop for ConversationData {
|
||||
fn drop(&mut self) {
|
||||
self.password.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
struct PamSession {
|
||||
handle: *mut PamHandle,
|
||||
conversation: Box<ConversationData>,
|
||||
opened: bool,
|
||||
credentials_established: bool,
|
||||
identity: Option<Identity>,
|
||||
}
|
||||
|
||||
impl PamSession {
|
||||
fn close(&mut self, status: c_int) -> Result<(), Error> {
|
||||
let mut first_error = None;
|
||||
if self.opened {
|
||||
let result = unsafe { pam_close_session(self.handle, 0) };
|
||||
if result != PAM_SUCCESS {
|
||||
first_error = Some(pam_error(self.handle, "pam_close_session", result));
|
||||
}
|
||||
self.opened = false;
|
||||
}
|
||||
if self.credentials_established {
|
||||
let credentials = unsafe { pam_setcred(self.handle, PAM_DELETE_CRED) };
|
||||
if credentials != PAM_SUCCESS && first_error.is_none() {
|
||||
first_error = Some(pam_error(self.handle, "pam_setcred(delete)", credentials));
|
||||
}
|
||||
self.credentials_established = false;
|
||||
}
|
||||
let ended = unsafe { pam_end(self.handle, status) };
|
||||
if ended != PAM_SUCCESS && first_error.is_none() {
|
||||
first_error = Some(pam_error(self.handle, "pam_end", ended));
|
||||
}
|
||||
self.handle = ptr::null_mut();
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PamSession {
|
||||
fn drop(&mut self) {
|
||||
if !self.handle.is_null() {
|
||||
let _ = self.close(PAM_SYSTEM_ERR);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn worker(requests: Receiver<Command>) {
|
||||
let mut sessions = HashMap::<u64, PamSession>::new();
|
||||
let mut next_id = 1u64;
|
||||
while let Ok(command) = requests.recv() {
|
||||
match command {
|
||||
Command::Authenticate {
|
||||
username,
|
||||
password,
|
||||
service,
|
||||
response,
|
||||
} => {
|
||||
let result =
|
||||
authenticate(&username, password, &service, &mut next_id, &mut sessions);
|
||||
let _ = response.send(result);
|
||||
}
|
||||
Command::Run { id, spec, response } => {
|
||||
let result = match sessions.remove(&id) {
|
||||
Some(mut session) => {
|
||||
let opened = unsafe { pam_open_session(session.handle, 0) };
|
||||
if opened != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_open_session", opened);
|
||||
let _ = session.close(opened);
|
||||
Err(error)
|
||||
} else {
|
||||
session.opened = true;
|
||||
let run = match session.identity.as_ref() {
|
||||
Some(identity) => session::run_user_session(identity, &spec),
|
||||
None => Err(Error::Worker("ticket has no account identity".into())),
|
||||
};
|
||||
let close = session.close(if run.is_ok() {
|
||||
PAM_SUCCESS
|
||||
} else {
|
||||
PAM_SYSTEM_ERR
|
||||
});
|
||||
match (run, close) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
None => Err(Error::Worker("unknown authentication ticket".into())),
|
||||
};
|
||||
let _ = response.send(result);
|
||||
}
|
||||
Command::Cancel { id } => {
|
||||
sessions.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn authenticate(
|
||||
username: &str,
|
||||
password: Zeroizing<Vec<u8>>,
|
||||
service: &str,
|
||||
next_id: &mut u64,
|
||||
sessions: &mut HashMap<u64, PamSession>,
|
||||
) -> Result<AuthTicket, Error> {
|
||||
let service =
|
||||
CString::new(service).map_err(|_| Error::Config("PAM service contains NUL".into()))?;
|
||||
let username_c =
|
||||
CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
|
||||
let conversation = Box::new(ConversationData {
|
||||
username: username_c.clone(),
|
||||
password,
|
||||
});
|
||||
let data = Box::into_raw(conversation);
|
||||
let conversation_ptr = PamConv {
|
||||
conv: Some(converse),
|
||||
appdata_ptr: data.cast(),
|
||||
};
|
||||
let mut handle = ptr::null_mut();
|
||||
let started = unsafe {
|
||||
pam_start(
|
||||
service.as_ptr(),
|
||||
username_c.as_ptr(),
|
||||
&conversation_ptr,
|
||||
&mut handle,
|
||||
)
|
||||
};
|
||||
// Reconstitute the allocation on every path after pam_start. PAM copies the
|
||||
// conversation structure, but does not own appdata_ptr.
|
||||
let conversation = unsafe { Box::from_raw(data) };
|
||||
if started != PAM_SUCCESS {
|
||||
let error = pam_error(handle, "pam_start", started);
|
||||
if !handle.is_null() {
|
||||
let _ = unsafe { pam_end(handle, started) };
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
if handle.is_null() {
|
||||
return Err(Error::Pam {
|
||||
operation: "pam_start",
|
||||
status: PAM_SYSTEM_ERR,
|
||||
message: "returned a null handle".into(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut session = PamSession {
|
||||
handle,
|
||||
conversation,
|
||||
opened: false,
|
||||
credentials_established: false,
|
||||
identity: None,
|
||||
};
|
||||
let authenticated = unsafe { pam_authenticate(session.handle, 0) };
|
||||
if authenticated != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_authenticate", authenticated);
|
||||
let _ = session.close(authenticated);
|
||||
return Err(error);
|
||||
}
|
||||
let account = unsafe { pam_acct_mgmt(session.handle, 0) };
|
||||
if account != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_acct_mgmt", account);
|
||||
let _ = session.close(account);
|
||||
return Err(error);
|
||||
}
|
||||
let identity = match lookup_identity(username) {
|
||||
Ok(identity) => identity,
|
||||
Err(error) => {
|
||||
let _ = session.close(PAM_SYSTEM_ERR);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
session.identity = Some(identity);
|
||||
let credentials = unsafe { pam_setcred(session.handle, PAM_ESTABLISH_CRED) };
|
||||
if credentials != PAM_SUCCESS {
|
||||
let error = pam_error(session.handle, "pam_setcred(establish)", credentials);
|
||||
let _ = session.close(credentials);
|
||||
return Err(error);
|
||||
}
|
||||
session.credentials_established = true;
|
||||
|
||||
// Authentication is complete. Do not retain the password for the session.
|
||||
session.conversation.password.zeroize();
|
||||
let identity = session
|
||||
.identity
|
||||
.clone()
|
||||
.ok_or_else(|| Error::Worker("authenticated session has no identity".into()))?;
|
||||
let id = *next_id;
|
||||
*next_id = next_id
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| Error::Worker("authentication ticket counter exhausted".into()))?;
|
||||
sessions.insert(id, session);
|
||||
Ok(AuthTicket {
|
||||
id,
|
||||
identity,
|
||||
owner: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn lookup_identity(username: &str) -> Result<Identity, Error> {
|
||||
let name = CString::new(username).map_err(|_| Error::Config("username contains NUL".into()))?;
|
||||
let passwd = unsafe { libc::getpwnam(name.as_ptr()) };
|
||||
if passwd.is_null() {
|
||||
return Err(Error::Pam {
|
||||
operation: "getpwnam",
|
||||
status: PAM_AUTHINFO_UNAVAIL,
|
||||
message: format!("unknown account {username}"),
|
||||
});
|
||||
}
|
||||
let passwd = unsafe { &*passwd };
|
||||
let home = c_path(passwd.pw_dir, "home")?;
|
||||
let shell = c_path(passwd.pw_shell, "shell")?;
|
||||
if shell == std::path::Path::new("/bin/false")
|
||||
|| shell == std::path::Path::new("/usr/sbin/nologin")
|
||||
|| shell == std::path::Path::new("/sbin/nologin")
|
||||
{
|
||||
return Err(Error::Config(
|
||||
"account does not have an interactive shell".into(),
|
||||
));
|
||||
}
|
||||
Ok(Identity {
|
||||
username: username.into(),
|
||||
uid: passwd.pw_uid,
|
||||
gid: passwd.pw_gid,
|
||||
home,
|
||||
shell,
|
||||
})
|
||||
}
|
||||
|
||||
fn c_path(pointer: *const c_char, field: &str) -> Result<std::path::PathBuf, Error> {
|
||||
if pointer.is_null() {
|
||||
return Err(Error::Config(format!("account has no {field}")));
|
||||
}
|
||||
let value = unsafe { CStr::from_ptr(pointer) }
|
||||
.to_str()
|
||||
.map_err(|_| Error::Config(format!("account {field} is not UTF-8")))?;
|
||||
if value.is_empty() {
|
||||
return Err(Error::Config(format!("account has empty {field}")));
|
||||
}
|
||||
Ok(value.into())
|
||||
}
|
||||
|
||||
#[allow(unsafe_op_in_unsafe_fn)]
|
||||
unsafe extern "C" fn converse(
|
||||
count: c_int,
|
||||
messages: *mut *const PamMessage,
|
||||
responses: *mut *mut PamResponse,
|
||||
data: *mut c_void,
|
||||
) -> c_int {
|
||||
if count <= 0 || messages.is_null() || responses.is_null() || data.is_null() {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let count = count as usize;
|
||||
let output = libc::calloc(count, mem::size_of::<PamResponse>()) as *mut PamResponse;
|
||||
if output.is_null() {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
let context = &*(data.cast::<ConversationData>());
|
||||
for index in 0..count {
|
||||
let message = *messages.add(index);
|
||||
if message.is_null() {
|
||||
free_responses(output, index);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
let value: Option<&[u8]> = match (*message).msg_style {
|
||||
PAM_PROMPT_ECHO_OFF => Some(context.password.as_slice()),
|
||||
PAM_PROMPT_ECHO_ON => Some(context.username.as_bytes()),
|
||||
PAM_ERROR_MSG | PAM_TEXT_INFO => None,
|
||||
_ => {
|
||||
free_responses(output, index);
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
};
|
||||
if let Some(value) = value {
|
||||
let destination = libc::malloc(value.len().saturating_add(1)) as *mut u8;
|
||||
if destination.is_null() {
|
||||
free_responses(output, index);
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
ptr::copy_nonoverlapping(value.as_ptr(), destination, value.len());
|
||||
*destination.add(value.len()) = 0;
|
||||
(*output.add(index)).resp = destination.cast();
|
||||
}
|
||||
}
|
||||
*responses = output;
|
||||
PAM_SUCCESS
|
||||
}
|
||||
|
||||
#[allow(unsafe_op_in_unsafe_fn)]
|
||||
unsafe fn free_responses(responses: *mut PamResponse, count: usize) {
|
||||
for index in 0..count {
|
||||
libc::free((*responses.add(index)).resp.cast());
|
||||
}
|
||||
libc::free(responses.cast());
|
||||
}
|
||||
|
||||
fn pam_error(handle: *mut PamHandle, operation: &'static str, status: c_int) -> Error {
|
||||
let message = if handle.is_null() {
|
||||
format!("status {status}")
|
||||
} else {
|
||||
let pointer = unsafe { pam_strerror(handle, status) };
|
||||
if pointer.is_null() {
|
||||
format!("status {status}")
|
||||
} else {
|
||||
unsafe { CStr::from_ptr(pointer) }
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
};
|
||||
Error::Pam {
|
||||
operation,
|
||||
status,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conversation_never_returns_password_for_echo_on() {
|
||||
let context = Box::new(ConversationData {
|
||||
username: CString::new("alice").unwrap(),
|
||||
password: Zeroizing::new(b"secret".to_vec()),
|
||||
});
|
||||
let pointer = Box::into_raw(context);
|
||||
let message = PamMessage {
|
||||
msg_style: PAM_PROMPT_ECHO_ON,
|
||||
msg: ptr::null(),
|
||||
};
|
||||
let mut message_pointer = &message as *const PamMessage;
|
||||
let mut responses = ptr::null_mut();
|
||||
let status = unsafe { converse(1, &mut message_pointer, &mut responses, pointer.cast()) };
|
||||
assert_eq!(status, PAM_SUCCESS);
|
||||
let response = unsafe { &*responses };
|
||||
assert_eq!(
|
||||
unsafe { CStr::from_ptr(response.resp) }.to_bytes(),
|
||||
b"alice"
|
||||
);
|
||||
unsafe {
|
||||
libc::free(response.resp.cast());
|
||||
libc::free(responses.cast());
|
||||
drop(Box::from_raw(pointer));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user