Sprint 1: PAM Authentication
Goal: Implement PAM-based user authentication in the daemon with proper conversation handling.
Objectives
- Integrate with PAM for user authentication
- Handle PAM conversation (prompts, messages)
- Create IPC server for greeter communication
- Implement session state machine
- Test authentication flow end-to-end
Background: How PAM Works
PAM (Pluggable Authentication Modules) uses a "conversation" model:
- Application calls
pam_authenticate() - PAM modules may request information via conversation callback
- Callback returns user input (password, OTP, etc.)
- PAM returns success/failure
For a display manager, we need to bridge this conversation to the greeter UI:
Greeter ←→ IPC ←→ Daemon ←→ PAM ←→ System
Tasks
1.1 Add PAM Dependency
The pam crate provides safe Rust bindings:
# gardmd/Cargo.toml
[dependencies]
pam = "0.8"
1.2 Create PAM Configuration
/etc/pam.d/gardm:
#%PAM-1.0
auth required pam_securetty.so
auth requisite pam_nologin.so
auth include system-local-login
account include system-local-login
session include system-local-login
password include system-local-login
1.3 Implement Authentication State Machine
// gardmd/src/auth.rs
use pam::Authenticator;
use std::ffi::CString;
pub enum AuthState {
/// No active authentication
Idle,
/// Waiting for username
AwaitingUsername,
/// PAM conversation in progress
Authenticating {
username: String,
authenticator: Authenticator<'static, PasswordConv>,
},
/// Authentication succeeded, ready to start session
Authenticated {
username: String,
},
}
pub struct AuthSession {
state: AuthState,
}
impl AuthSession {
pub fn new() -> Self {
Self { state: AuthState::Idle }
}
pub fn create_session(&mut self, username: &str) -> Result<AuthResponse> {
let service = CString::new("gardm")?;
let username_c = CString::new(username)?;
// Create PAM authenticator
let mut auth = Authenticator::with_password(&service)?;
auth.get_handler().set_credentials(username_c, /* password later */);
self.state = AuthState::Authenticating {
username: username.to_string(),
authenticator: auth,
};
// PAM will ask for password
Ok(AuthResponse::Prompt {
prompt: "Password:".to_string(),
echo: false,
})
}
pub fn authenticate(&mut self, response: &str) -> Result<AuthResponse> {
match &mut self.state {
AuthState::Authenticating { username, authenticator } => {
// Provide password to PAM
authenticator.get_handler().set_credentials(
CString::new(username.as_str())?,
CString::new(response)?,
);
// Attempt authentication
match authenticator.authenticate() {
Ok(()) => {
let username = username.clone();
self.state = AuthState::Authenticated { username };
Ok(AuthResponse::Success)
}
Err(e) => {
self.state = AuthState::Idle;
Ok(AuthResponse::Error {
message: format!("Authentication failed: {}", e),
})
}
}
}
_ => Ok(AuthResponse::Error {
message: "No authentication in progress".to_string(),
}),
}
}
pub fn cancel(&mut self) {
self.state = AuthState::Idle;
}
}
1.4 Implement IPC Server
// gardmd/src/ipc.rs
use gardm_ipc::{Request, Response};
use tokio::net::{UnixListener, UnixStream};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
pub struct IpcServer {
listener: UnixListener,
}
impl IpcServer {
pub async fn new(path: &str) -> anyhow::Result<Self> {
// Remove stale socket
let _ = std::fs::remove_file(path);
let listener = UnixListener::bind(path)?;
// Set permissions (only gardm user can connect)
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(Self { listener })
}
pub async fn accept(&self) -> anyhow::Result<IpcClient> {
let (stream, _) = self.listener.accept().await?;
Ok(IpcClient::new(stream))
}
}
pub struct IpcClient {
reader: BufReader<tokio::net::unix::OwnedReadHalf>,
writer: tokio::net::unix::OwnedWriteHalf,
}
impl IpcClient {
pub fn new(stream: UnixStream) -> Self {
let (read, write) = stream.into_split();
Self {
reader: BufReader::new(read),
writer: write,
}
}
pub async fn read_request(&mut self) -> anyhow::Result<Option<Request>> {
let mut line = String::new();
let n = self.reader.read_line(&mut line).await?;
if n == 0 {
return Ok(None);
}
Ok(Some(serde_json::from_str(&line)?))
}
pub async fn send_response(&mut self, response: &Response) -> anyhow::Result<()> {
let json = serde_json::to_string(response)?;
self.writer.write_all(json.as_bytes()).await?;
self.writer.write_all(b"\n").await?;
self.writer.flush().await?;
Ok(())
}
}
1.5 Wire Up Main Loop
// gardmd/src/main.rs
async fn run() -> anyhow::Result<()> {
let config = Config::load()?;
let ipc = IpcServer::new("/run/gardm.sock").await?;
let mut auth = AuthSession::new();
// Notify systemd we're ready
sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?;
tracing::info!("gardmd ready, listening for connections");
loop {
let mut client = ipc.accept().await?;
// Handle single client (greeter)
while let Some(request) = client.read_request().await? {
let response = match request {
Request::CreateSession { username } => {
auth.create_session(&username)?
}
Request::Authenticate { response } => {
auth.authenticate(&response)?
}
Request::CancelSession => {
auth.cancel();
Response::Success
}
// Power actions handled in later sprint
_ => Response::Error {
message: "Not implemented".to_string(),
},
};
client.send_response(&response).await?;
}
}
}
1.6 Create Test Client
For testing without the full greeter:
// gardm-greeter/src/bin/test-auth.rs
use gardm_ipc::{Request, Response};
use std::io::{self, BufRead, Write};
use std::os::unix::net::UnixStream;
fn main() -> anyhow::Result<()> {
let mut stream = UnixStream::connect("/run/gardm.sock")?;
// Get username
print!("Username: ");
io::stdout().flush()?;
let mut username = String::new();
io::stdin().lock().read_line(&mut username)?;
// Send create_session
let req = Request::CreateSession {
username: username.trim().to_string(),
};
writeln!(stream, "{}", serde_json::to_string(&req)?)?;
// Read response
let mut response = String::new();
let mut reader = io::BufReader::new(&stream);
reader.read_line(&mut response)?;
println!("Response: {}", response);
// Get password
print!("Password: ");
io::stdout().flush()?;
let password = rpassword::read_password()?;
// Send authenticate
let req = Request::Authenticate { response: password };
writeln!(stream, "{}", serde_json::to_string(&req)?)?;
// Read response
response.clear();
reader.read_line(&mut response)?;
println!("Response: {}", response);
Ok(())
}
Acceptance Criteria
- Daemon accepts IPC connections from greeter
CreateSessioninitiates PAM conversationAuthenticatewith correct password returnsSuccessAuthenticatewith wrong password returnsAuthErrorCancelSessionresets state cleanly- Multiple auth attempts work without daemon restart
- PAM config file is properly loaded
Pitfalls to Avoid
- PAM is synchronous - don't block the async runtime. Use
spawn_blockingfor PAM calls. - Memory safety with passwords - zero memory after use (pam crate should handle this).
- Don't store passwords - only pass through to PAM immediately.
- Handle PAM errors gracefully - account locked, expired password, etc. have specific error types.
- Test with real PAM - mock tests are useful but test against real system too.
- Remember PAM is stateful - can't call authenticate twice on same session.
Testing
# Build and run daemon as root (required for PAM)
sudo RUST_LOG=debug ./target/release/gardmd &
# Test with CLI client
./target/release/test-auth
# Test wrong password
# Test account lockout (if configured)
# Test non-existent user
Security Considerations
- Daemon must run as root for PAM access
- IPC socket must be protected (0600 permissions)
- Failed auth attempts should be rate-limited (PAM may do this)
- Log auth attempts but NOT passwords
Dependencies for This Sprint
# gardmd/Cargo.toml
[dependencies]
pam = "0.8"
nix = { version = "0.27", features = ["user"] }
# gardm-greeter/Cargo.toml (for test client)
[dependencies]
rpassword = "7.0"
Next Sprint
Sprint 2 will add X11 server management - starting Xorg and the greeter.
View source
| 1 | # Sprint 1: PAM Authentication |
| 2 | |
| 3 | **Goal:** Implement PAM-based user authentication in the daemon with proper conversation handling. |
| 4 | |
| 5 | ## Objectives |
| 6 | |
| 7 | - Integrate with PAM for user authentication |
| 8 | - Handle PAM conversation (prompts, messages) |
| 9 | - Create IPC server for greeter communication |
| 10 | - Implement session state machine |
| 11 | - Test authentication flow end-to-end |
| 12 | |
| 13 | ## Background: How PAM Works |
| 14 | |
| 15 | PAM (Pluggable Authentication Modules) uses a "conversation" model: |
| 16 | |
| 17 | 1. Application calls `pam_authenticate()` |
| 18 | 2. PAM modules may request information via conversation callback |
| 19 | 3. Callback returns user input (password, OTP, etc.) |
| 20 | 4. PAM returns success/failure |
| 21 | |
| 22 | For a display manager, we need to bridge this conversation to the greeter UI: |
| 23 | |
| 24 | ``` |
| 25 | Greeter ←→ IPC ←→ Daemon ←→ PAM ←→ System |
| 26 | ``` |
| 27 | |
| 28 | ## Tasks |
| 29 | |
| 30 | ### 1.1 Add PAM Dependency |
| 31 | |
| 32 | The `pam` crate provides safe Rust bindings: |
| 33 | |
| 34 | ```toml |
| 35 | # gardmd/Cargo.toml |
| 36 | [dependencies] |
| 37 | pam = "0.8" |
| 38 | ``` |
| 39 | |
| 40 | ### 1.2 Create PAM Configuration |
| 41 | |
| 42 | `/etc/pam.d/gardm`: |
| 43 | |
| 44 | ``` |
| 45 | #%PAM-1.0 |
| 46 | auth required pam_securetty.so |
| 47 | auth requisite pam_nologin.so |
| 48 | auth include system-local-login |
| 49 | account include system-local-login |
| 50 | session include system-local-login |
| 51 | password include system-local-login |
| 52 | ``` |
| 53 | |
| 54 | ### 1.3 Implement Authentication State Machine |
| 55 | |
| 56 | ```rust |
| 57 | // gardmd/src/auth.rs |
| 58 | |
| 59 | use pam::Authenticator; |
| 60 | use std::ffi::CString; |
| 61 | |
| 62 | pub enum AuthState { |
| 63 | /// No active authentication |
| 64 | Idle, |
| 65 | /// Waiting for username |
| 66 | AwaitingUsername, |
| 67 | /// PAM conversation in progress |
| 68 | Authenticating { |
| 69 | username: String, |
| 70 | authenticator: Authenticator<'static, PasswordConv>, |
| 71 | }, |
| 72 | /// Authentication succeeded, ready to start session |
| 73 | Authenticated { |
| 74 | username: String, |
| 75 | }, |
| 76 | } |
| 77 | |
| 78 | pub struct AuthSession { |
| 79 | state: AuthState, |
| 80 | } |
| 81 | |
| 82 | impl AuthSession { |
| 83 | pub fn new() -> Self { |
| 84 | Self { state: AuthState::Idle } |
| 85 | } |
| 86 | |
| 87 | pub fn create_session(&mut self, username: &str) -> Result<AuthResponse> { |
| 88 | let service = CString::new("gardm")?; |
| 89 | let username_c = CString::new(username)?; |
| 90 | |
| 91 | // Create PAM authenticator |
| 92 | let mut auth = Authenticator::with_password(&service)?; |
| 93 | auth.get_handler().set_credentials(username_c, /* password later */); |
| 94 | |
| 95 | self.state = AuthState::Authenticating { |
| 96 | username: username.to_string(), |
| 97 | authenticator: auth, |
| 98 | }; |
| 99 | |
| 100 | // PAM will ask for password |
| 101 | Ok(AuthResponse::Prompt { |
| 102 | prompt: "Password:".to_string(), |
| 103 | echo: false, |
| 104 | }) |
| 105 | } |
| 106 | |
| 107 | pub fn authenticate(&mut self, response: &str) -> Result<AuthResponse> { |
| 108 | match &mut self.state { |
| 109 | AuthState::Authenticating { username, authenticator } => { |
| 110 | // Provide password to PAM |
| 111 | authenticator.get_handler().set_credentials( |
| 112 | CString::new(username.as_str())?, |
| 113 | CString::new(response)?, |
| 114 | ); |
| 115 | |
| 116 | // Attempt authentication |
| 117 | match authenticator.authenticate() { |
| 118 | Ok(()) => { |
| 119 | let username = username.clone(); |
| 120 | self.state = AuthState::Authenticated { username }; |
| 121 | Ok(AuthResponse::Success) |
| 122 | } |
| 123 | Err(e) => { |
| 124 | self.state = AuthState::Idle; |
| 125 | Ok(AuthResponse::Error { |
| 126 | message: format!("Authentication failed: {}", e), |
| 127 | }) |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | _ => Ok(AuthResponse::Error { |
| 132 | message: "No authentication in progress".to_string(), |
| 133 | }), |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | pub fn cancel(&mut self) { |
| 138 | self.state = AuthState::Idle; |
| 139 | } |
| 140 | } |
| 141 | ``` |
| 142 | |
| 143 | ### 1.4 Implement IPC Server |
| 144 | |
| 145 | ```rust |
| 146 | // gardmd/src/ipc.rs |
| 147 | |
| 148 | use gardm_ipc::{Request, Response}; |
| 149 | use tokio::net::{UnixListener, UnixStream}; |
| 150 | use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; |
| 151 | |
| 152 | pub struct IpcServer { |
| 153 | listener: UnixListener, |
| 154 | } |
| 155 | |
| 156 | impl IpcServer { |
| 157 | pub async fn new(path: &str) -> anyhow::Result<Self> { |
| 158 | // Remove stale socket |
| 159 | let _ = std::fs::remove_file(path); |
| 160 | |
| 161 | let listener = UnixListener::bind(path)?; |
| 162 | |
| 163 | // Set permissions (only gardm user can connect) |
| 164 | std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; |
| 165 | |
| 166 | Ok(Self { listener }) |
| 167 | } |
| 168 | |
| 169 | pub async fn accept(&self) -> anyhow::Result<IpcClient> { |
| 170 | let (stream, _) = self.listener.accept().await?; |
| 171 | Ok(IpcClient::new(stream)) |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | pub struct IpcClient { |
| 176 | reader: BufReader<tokio::net::unix::OwnedReadHalf>, |
| 177 | writer: tokio::net::unix::OwnedWriteHalf, |
| 178 | } |
| 179 | |
| 180 | impl IpcClient { |
| 181 | pub fn new(stream: UnixStream) -> Self { |
| 182 | let (read, write) = stream.into_split(); |
| 183 | Self { |
| 184 | reader: BufReader::new(read), |
| 185 | writer: write, |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | pub async fn read_request(&mut self) -> anyhow::Result<Option<Request>> { |
| 190 | let mut line = String::new(); |
| 191 | let n = self.reader.read_line(&mut line).await?; |
| 192 | if n == 0 { |
| 193 | return Ok(None); |
| 194 | } |
| 195 | Ok(Some(serde_json::from_str(&line)?)) |
| 196 | } |
| 197 | |
| 198 | pub async fn send_response(&mut self, response: &Response) -> anyhow::Result<()> { |
| 199 | let json = serde_json::to_string(response)?; |
| 200 | self.writer.write_all(json.as_bytes()).await?; |
| 201 | self.writer.write_all(b"\n").await?; |
| 202 | self.writer.flush().await?; |
| 203 | Ok(()) |
| 204 | } |
| 205 | } |
| 206 | ``` |
| 207 | |
| 208 | ### 1.5 Wire Up Main Loop |
| 209 | |
| 210 | ```rust |
| 211 | // gardmd/src/main.rs |
| 212 | |
| 213 | async fn run() -> anyhow::Result<()> { |
| 214 | let config = Config::load()?; |
| 215 | let ipc = IpcServer::new("/run/gardm.sock").await?; |
| 216 | let mut auth = AuthSession::new(); |
| 217 | |
| 218 | // Notify systemd we're ready |
| 219 | sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?; |
| 220 | |
| 221 | tracing::info!("gardmd ready, listening for connections"); |
| 222 | |
| 223 | loop { |
| 224 | let mut client = ipc.accept().await?; |
| 225 | |
| 226 | // Handle single client (greeter) |
| 227 | while let Some(request) = client.read_request().await? { |
| 228 | let response = match request { |
| 229 | Request::CreateSession { username } => { |
| 230 | auth.create_session(&username)? |
| 231 | } |
| 232 | Request::Authenticate { response } => { |
| 233 | auth.authenticate(&response)? |
| 234 | } |
| 235 | Request::CancelSession => { |
| 236 | auth.cancel(); |
| 237 | Response::Success |
| 238 | } |
| 239 | // Power actions handled in later sprint |
| 240 | _ => Response::Error { |
| 241 | message: "Not implemented".to_string(), |
| 242 | }, |
| 243 | }; |
| 244 | |
| 245 | client.send_response(&response).await?; |
| 246 | } |
| 247 | } |
| 248 | } |
| 249 | ``` |
| 250 | |
| 251 | ### 1.6 Create Test Client |
| 252 | |
| 253 | For testing without the full greeter: |
| 254 | |
| 255 | ```rust |
| 256 | // gardm-greeter/src/bin/test-auth.rs |
| 257 | |
| 258 | use gardm_ipc::{Request, Response}; |
| 259 | use std::io::{self, BufRead, Write}; |
| 260 | use std::os::unix::net::UnixStream; |
| 261 | |
| 262 | fn main() -> anyhow::Result<()> { |
| 263 | let mut stream = UnixStream::connect("/run/gardm.sock")?; |
| 264 | |
| 265 | // Get username |
| 266 | print!("Username: "); |
| 267 | io::stdout().flush()?; |
| 268 | let mut username = String::new(); |
| 269 | io::stdin().lock().read_line(&mut username)?; |
| 270 | |
| 271 | // Send create_session |
| 272 | let req = Request::CreateSession { |
| 273 | username: username.trim().to_string(), |
| 274 | }; |
| 275 | writeln!(stream, "{}", serde_json::to_string(&req)?)?; |
| 276 | |
| 277 | // Read response |
| 278 | let mut response = String::new(); |
| 279 | let mut reader = io::BufReader::new(&stream); |
| 280 | reader.read_line(&mut response)?; |
| 281 | println!("Response: {}", response); |
| 282 | |
| 283 | // Get password |
| 284 | print!("Password: "); |
| 285 | io::stdout().flush()?; |
| 286 | let password = rpassword::read_password()?; |
| 287 | |
| 288 | // Send authenticate |
| 289 | let req = Request::Authenticate { response: password }; |
| 290 | writeln!(stream, "{}", serde_json::to_string(&req)?)?; |
| 291 | |
| 292 | // Read response |
| 293 | response.clear(); |
| 294 | reader.read_line(&mut response)?; |
| 295 | println!("Response: {}", response); |
| 296 | |
| 297 | Ok(()) |
| 298 | } |
| 299 | ``` |
| 300 | |
| 301 | ## Acceptance Criteria |
| 302 | |
| 303 | 1. Daemon accepts IPC connections from greeter |
| 304 | 2. `CreateSession` initiates PAM conversation |
| 305 | 3. `Authenticate` with correct password returns `Success` |
| 306 | 4. `Authenticate` with wrong password returns `AuthError` |
| 307 | 5. `CancelSession` resets state cleanly |
| 308 | 6. Multiple auth attempts work without daemon restart |
| 309 | 7. PAM config file is properly loaded |
| 310 | |
| 311 | ## Pitfalls to Avoid |
| 312 | |
| 313 | 1. **PAM is synchronous** - don't block the async runtime. Use `spawn_blocking` for PAM calls. |
| 314 | 2. **Memory safety with passwords** - zero memory after use (pam crate should handle this). |
| 315 | 3. **Don't store passwords** - only pass through to PAM immediately. |
| 316 | 4. **Handle PAM errors gracefully** - account locked, expired password, etc. have specific error types. |
| 317 | 5. **Test with real PAM** - mock tests are useful but test against real system too. |
| 318 | 6. **Remember PAM is stateful** - can't call authenticate twice on same session. |
| 319 | |
| 320 | ## Testing |
| 321 | |
| 322 | ```bash |
| 323 | # Build and run daemon as root (required for PAM) |
| 324 | sudo RUST_LOG=debug ./target/release/gardmd & |
| 325 | |
| 326 | # Test with CLI client |
| 327 | ./target/release/test-auth |
| 328 | |
| 329 | # Test wrong password |
| 330 | # Test account lockout (if configured) |
| 331 | # Test non-existent user |
| 332 | ``` |
| 333 | |
| 334 | ## Security Considerations |
| 335 | |
| 336 | - Daemon must run as root for PAM access |
| 337 | - IPC socket must be protected (0600 permissions) |
| 338 | - Failed auth attempts should be rate-limited (PAM may do this) |
| 339 | - Log auth attempts but NOT passwords |
| 340 | |
| 341 | ## Dependencies for This Sprint |
| 342 | |
| 343 | ```toml |
| 344 | # gardmd/Cargo.toml |
| 345 | [dependencies] |
| 346 | pam = "0.8" |
| 347 | nix = { version = "0.27", features = ["user"] } |
| 348 | |
| 349 | # gardm-greeter/Cargo.toml (for test client) |
| 350 | [dependencies] |
| 351 | rpassword = "7.0" |
| 352 | ``` |
| 353 | |
| 354 | ## Next Sprint |
| 355 | |
| 356 | Sprint 2 will add X11 server management - starting Xorg and the greeter. |