markdown · 9761 bytes Raw Blame History

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:

  1. Application calls pam_authenticate()
  2. PAM modules may request information via conversation callback
  3. Callback returns user input (password, OTP, etc.)
  4. 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

  1. Daemon accepts IPC connections from greeter
  2. CreateSession initiates PAM conversation
  3. Authenticate with correct password returns Success
  4. Authenticate with wrong password returns AuthError
  5. CancelSession resets state cleanly
  6. Multiple auth attempts work without daemon restart
  7. PAM config file is properly loaded

Pitfalls to Avoid

  1. PAM is synchronous - don't block the async runtime. Use spawn_blocking for PAM calls.
  2. Memory safety with passwords - zero memory after use (pam crate should handle this).
  3. Don't store passwords - only pass through to PAM immediately.
  4. Handle PAM errors gracefully - account locked, expired password, etc. have specific error types.
  5. Test with real PAM - mock tests are useful but test against real system too.
  6. 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.