1
//! PAM integration - Unix socket listener for receiving secrets from PAM module
2

            
3
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
4

            
5
use oo7::Secret;
6
use serde::{Deserialize, Serialize};
7
use serde_repr::{Deserialize_repr, Serialize_repr};
8
use tokio::{
9
    io::AsyncReadExt,
10
    net::{UnixListener, UnixStream},
11
    sync::RwLock,
12
};
13
use zbus::zvariant::{
14
    self, Type,
15
    serialized::{Context, Data},
16
};
17
use zeroize::{Zeroize, ZeroizeOnDrop};
18

            
19
use crate::{Service, error::Error};
20

            
21
#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)]
22
#[repr(u8)]
23
enum PamOperation {
24
    Unlock = 0,
25
    ChangePassword = 1,
26
}
27

            
28
#[derive(Debug, Serialize, Deserialize, Type, Zeroize, ZeroizeOnDrop)]
29
struct PamMessage {
30
    #[zeroize(skip)]
31
    operation: PamOperation,
32
    username: String,
33
    old_secret: Vec<u8>,
34
    new_secret: Vec<u8>,
35
}
36

            
37
impl PamMessage {
38
2
    fn from_bytes(bytes: &[u8]) -> Result<Self, zvariant::Error> {
39
2
        let ctxt = Context::new_dbus(zvariant::LE, 0);
40
2
        let data = Data::new(bytes, ctxt);
41
8
        data.deserialize().map(|(msg, _)| msg)
42
    }
43
}
44

            
45
/// PAM listener that receives authentication secrets from the PAM module
46
#[derive(Clone)]
47
pub struct PamListener {
48
    socket_path: PathBuf,
49
    service: Service,
50
    /// Current user's secret, used to unlock their keyring <username, secret>
51
    user_secrets: Arc<RwLock<std::collections::HashMap<String, Secret>>>,
52
}
53

            
54
impl PamListener {
55
2
    pub fn new(service: Service) -> Self {
56
2
        let uid = unsafe { libc::getuid() };
57
2
        let socket_path = std::env::var("OO7_PAM_SOCKET")
58
2
            .map(PathBuf::from)
59
2
            .unwrap_or_else(|_| PathBuf::from(format!("/run/user/{uid}/oo7-pam.sock")));
60

            
61
        Self {
62
            socket_path,
63
            service,
64
4
            user_secrets: Arc::new(RwLock::new(std::collections::HashMap::new())),
65
        }
66
    }
67

            
68
    /// Start the PAM listener
69
8
    pub async fn start(self) -> Result<(), Error> {
70
        // Remove old socket if it exists
71
4
        if self.socket_path.exists() {
72
            tokio::fs::remove_file(&self.socket_path).await?;
73
        }
74

            
75
4
        let listener = UnixListener::bind(&self.socket_path)?;
76

            
77
4
        tracing::info!("PAM listener started on {}", self.socket_path.display());
78

            
79
        // Set socket permissions to 0600
80
4
        let perms = std::fs::Permissions::from_mode(0o600);
81
2
        std::fs::set_permissions(&self.socket_path, perms)?;
82

            
83
2
        let listener = Arc::new(listener);
84

            
85
        // Accept connections in a loop
86
        loop {
87
12
            match listener.accept().await {
88
2
                Ok((stream, _addr)) => {
89
2
                    let pam_listener = self.clone();
90

            
91
6
                    tokio::spawn(async move {
92
8
                        if let Err(e) = pam_listener.handle_connection(stream).await {
93
                            tracing::error!("Error handling PAM connection: {}", e);
94
                        }
95
                    });
96
                }
97
                Err(e) => {
98
                    tracing::error!("Error accepting PAM connection: {}", e);
99
                }
100
            }
101
        }
102
    }
103

            
104
    /// Handle a single PAM connection
105
8
    async fn handle_connection(&self, mut stream: UnixStream) -> Result<(), Error> {
106
        // Accept connections from:
107
        // 1. Root (UID 0) as PAM modules run as root during authentication
108
        // 2. Same UID as us
109
4
        let peer_cred = stream.peer_cred()?;
110
2
        let our_uid = unsafe { libc::getuid() };
111
2
        let peer_uid = peer_cred.uid();
112

            
113
4
        if peer_uid != 0 && peer_uid != our_uid {
114
            tracing::warn!(
115
                "Rejected PAM connection from UID {} PID {} (expected UID 0 or {})",
116
                peer_uid,
117
                peer_cred.pid().unwrap_or(0),
118
                our_uid
119
            );
120
            return Err(Error::IO(std::io::Error::new(
121
                std::io::ErrorKind::PermissionDenied,
122
                format!(
123
                    "Connection rejected: unauthorized UID (peer={}, daemon={}, root=0)",
124
                    peer_uid, our_uid
125
                ),
126
            )));
127
        }
128

            
129
        tracing::debug!(
130
            "Accepted PAM connection from {} (UID {}, PID {})",
131
            if peer_uid == 0 {
132
                "root/PAM"
133
            } else {
134
                "same user"
135
            },
136
            peer_uid,
137
            peer_cred.pid().unwrap_or(0)
138
        );
139

            
140
        // Read the message length prefix (4 bytes, little-endian)
141
2
        let mut length_bytes = [0u8; 4];
142
8
        stream.read_exact(&mut length_bytes).await?;
143
2
        let message_length = u32::from_le_bytes(length_bytes) as usize;
144

            
145
2
        let mut message_bytes = vec![0u8; message_length];
146
6
        stream.read_exact(&mut message_bytes).await?;
147

            
148
4
        let message = PamMessage::from_bytes(&message_bytes)
149
2
            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
150

            
151
2
        match message.operation {
152
            PamOperation::Unlock => {
153
4
                tracing::info!("Received unlock request for user: {}", message.username);
154
                tracing::debug!(
155
                    "Received secret of length {} bytes",
156
                    message.new_secret.len()
157
                );
158

            
159
4
                let secret = Secret::from(message.new_secret.to_vec());
160
10
                self.user_secrets
161
                    .write()
162
6
                    .await
163
4
                    .insert(message.username.clone(), secret.clone());
164

            
165
4
                match self.try_unlock_collections(&secret).await {
166
                    Ok(_) => {
167
2
                        tracing::info!(
168
                            "Successfully unlocked collections for user: {}",
169
                            message.username
170
                        );
171
                    }
172
                    Err(e) => {
173
                        tracing::warn!(
174
                            "Failed to unlock collections for user {}: {}",
175
                            message.username,
176
                            e
177
                        );
178
                    }
179
                }
180
            }
181
            PamOperation::ChangePassword => {
182
4
                tracing::info!(
183
                    "Received password change request for user: {}",
184
                    message.username
185
                );
186
4
                tracing::debug!(
187
                    "Old secret: {} bytes, new secret: {} bytes",
188
                    message.old_secret.len(),
189
                    message.new_secret.len()
190
                );
191

            
192
4
                let old_secret = Secret::from(message.old_secret.to_vec());
193
4
                let new_secret = Secret::from(message.new_secret.to_vec());
194

            
195
8
                match self
196
2
                    .change_collection_passwords(&old_secret, &new_secret)
197
8
                    .await
198
                {
199
2
                    Ok(changed_count) => {
200
2
                        tracing::info!(
201
                            "Successfully changed password for {} collection(s) for user: {}",
202
                            changed_count,
203
                            message.username
204
                        );
205
                        // Update stored secret
206
8
                        self.user_secrets
207
                            .write()
208
6
                            .await
209
2
                            .insert(message.username.clone(), new_secret);
210
                    }
211
                    Err(e) => {
212
                        tracing::error!(
213
                            "Failed to change password for user {}: {}",
214
                            message.username,
215
                            e
216
                        );
217
                    }
218
                }
219
            }
220
        }
221

            
222
2
        Ok(())
223
    }
224

            
225
8
    async fn try_unlock_collections(&self, secret: &Secret) -> Result<(), Error> {
226
        // First, try to migrate any pending v0 keyrings
227
6
        let migrated = self.service.migrate_pending_keyrings(secret).await;
228
4
        if !migrated.is_empty() {
229
4
            tracing::info!("Migrated {} v0 keyring(s): {:?}", migrated.len(), migrated);
230
        }
231

            
232
4
        let collections = self.service.collections.lock().await;
233

            
234
8
        for (_path, collection) in collections.iter() {
235
6
            if collection.is_locked().await {
236
2
                tracing::debug!("Attempting to unlock collection: {}", collection.path());
237

            
238
                // Try to unlock with the provided secret
239
6
                if let Err(e) = collection.set_locked(false, Some(secret.clone())).await {
240
                    tracing::debug!("Failed to unlock collection {}: {}", collection.path(), e);
241
                } else {
242
4
                    tracing::info!("Unlocked collection: {}", collection.path());
243
                }
244
            }
245
        }
246

            
247
2
        Ok(())
248
    }
249

            
250
    /// Change password for all collections that match the old password
251
2
    async fn change_collection_passwords(
252
        &self,
253
        old_secret: &Secret,
254
        new_secret: &Secret,
255
    ) -> Result<usize, Error> {
256
4
        let collections = self.service.collections.lock().await;
257
2
        let mut changed_count = 0;
258

            
259
10
        for (path, collection) in collections.iter() {
260
            // Skip session collection (it's temporary and doesn't persist)
261
6
            if collection.alias().await == oo7::dbus::Service::SESSION_COLLECTION {
262
2
                tracing::debug!("Skipping session collection: {}", path);
263
                continue;
264
            }
265

            
266
            // Get the keyring from the collection
267
6
            let keyring_guard = collection.keyring.read().await;
268
4
            let Some(keyring) = keyring_guard.as_ref() else {
269
                tracing::debug!("Collection {} has no keyring", path);
270
                continue;
271
            };
272

            
273
            // Track if we unlocked the collection (so we can re-lock it after)
274
4
            let was_locked = keyring.is_locked();
275

            
276
            // Check if the keyring is locked and unlock if needed
277
2
            if was_locked {
278
                // Try to unlock with old password first
279
4
                tracing::debug!(
280
                    "Collection {} is locked, attempting to unlock with old password",
281
                    path
282
                );
283
2
                drop(keyring_guard);
284

            
285
4
                if let Err(e) = collection.set_locked(false, Some(old_secret.clone())).await {
286
                    tracing::warn!(
287
                        "Failed to unlock collection {} with old password: {}",
288
                        path,
289
                        e
290
                    );
291
                    continue;
292
                }
293
            } else {
294
                drop(keyring_guard);
295
            }
296

            
297
            // Re-acquire the lock to get the unlocked keyring
298
6
            let keyring_guard = collection.keyring.read().await;
299
4
            let Some(oo7::file::Keyring::Unlocked(uk)) = keyring_guard.as_ref() else {
300
                tracing::warn!("Collection {} is not unlocked", path);
301
                continue;
302
            };
303

            
304
            // Validate that the old password can decrypt items in the keyring
305
4
            let can_decrypt = match uk.validate_secret(old_secret).await {
306
2
                Ok(valid) => valid,
307
                Err(e) => {
308
                    tracing::warn!(
309
                        "Failed to validate old password for collection {}: {}",
310
                        path,
311
                        e
312
                    );
313
                    continue;
314
                }
315
            };
316

            
317
2
            if !can_decrypt {
318
                tracing::debug!(
319
                    "Old password does not match keyring {} password, skipping",
320
                    path
321
                );
322
                continue;
323
            }
324

            
325
            // Change the keyring password
326
8
            match uk.change_secret(new_secret.clone()).await {
327
                Ok(_) => {
328
2
                    tracing::info!("Successfully changed password for collection: {}", path);
329
4
                    changed_count += 1;
330

            
331
                    // Re-lock the collection if it was locked before we unlocked it
332
2
                    drop(keyring_guard);
333
2
                    if was_locked {
334
6
                        if let Err(e) = collection.set_locked(true, None).await {
335
                            tracing::warn!(
336
                                "Failed to re-lock collection {} after password change: {}",
337
                                path,
338
                                e
339
                            );
340
                        } else {
341
4
                            tracing::debug!("Re-locked collection: {}", path);
342
                        }
343
                    }
344
                }
345
                Err(e) => {
346
                    tracing::error!("Failed to change password for collection {}: {}", path, e);
347
                }
348
            }
349
        }
350

            
351
2
        Ok(changed_count)
352
    }
353
}
354

            
355
impl Drop for PamListener {
356
2
    fn drop(&mut self) {
357
2
        if self.socket_path.exists() {
358
2
            let _ = std::fs::remove_file(&self.socket_path);
359
        }
360
    }
361
}
362

            
363
#[cfg(test)]
364
mod tests {
365
    use oo7::file::UnlockedKeyring;
366
    use zbus::zvariant::serialized::Context;
367

            
368
    use super::*;
369

            
370
    fn create_pam_message(
371
        operation: PamOperation,
372
        username: &str,
373
        old_secret: &[u8],
374
        new_secret: &[u8],
375
    ) -> Vec<u8> {
376
        let message = PamMessage {
377
            operation,
378
            username: username.to_owned(),
379
            old_secret: old_secret.to_vec(),
380
            new_secret: new_secret.to_vec(),
381
        };
382

            
383
        let ctxt = Context::new_dbus(zvariant::LE, 0);
384
        let encoded = zvariant::to_bytes(ctxt, &message).unwrap();
385
        let message_bytes = encoded.to_vec();
386

            
387
        // Prepend length prefix (4 bytes, little-endian)
388
        let mut result = (message_bytes.len() as u32).to_le_bytes().to_vec();
389
        result.extend_from_slice(&message_bytes);
390
        result
391
    }
392

            
393
    async fn send_pam_message(
394
        socket_path: &std::path::Path,
395
        message_bytes: &[u8],
396
    ) -> Result<(), Box<dyn std::error::Error>> {
397
        use tokio::io::AsyncWriteExt;
398

            
399
        let mut stream = tokio::net::UnixStream::connect(socket_path).await?;
400
        stream.write_all(message_bytes).await?;
401
        stream.flush().await?;
402
        Ok(())
403
    }
404

            
405
    #[tokio::test]
406
    #[serial_test::serial(xdg_env)]
407
    async fn pam_migrates_v0_keyrings() -> Result<(), Box<dyn std::error::Error>> {
408
        let temp_dir = tempfile::tempdir()?;
409
        unsafe { std::env::set_var("XDG_DATA_HOME", temp_dir.path()) };
410
        unsafe { std::env::set_var("OO7_PAM_SOCKET", temp_dir.path().join("pam.sock")) };
411

            
412
        let keyrings_dir = temp_dir.path().join("keyrings");
413
        let v1_dir = keyrings_dir.join("v1");
414
        tokio::fs::create_dir_all(&v1_dir).await?;
415

            
416
        let v0_secret = Secret::from("test");
417
        let fixture_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
418
            .parent()
419
            .unwrap()
420
            .join("client/fixtures/legacy.keyring");
421
        let v0_path = keyrings_dir.join("legacy.keyring");
422
        tokio::fs::copy(&fixture_path, &v0_path).await?;
423

            
424
        let setup = crate::tests::TestServiceSetup::with_disk_keyrings(None).await?;
425

            
426
        assert_eq!(
427
            setup.server.pending_migrations.lock().await.len(),
428
            1,
429
            "V0 keyring should be pending migration"
430
        );
431

            
432
        let pam_listener = PamListener::new(setup.server.clone());
433
        let socket_path = pam_listener.socket_path.clone();
434
        tokio::spawn(async move {
435
            let _ = pam_listener.start().await;
436
        });
437

            
438
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
439

            
440
        let message =
441
            create_pam_message(PamOperation::Unlock, "testuser", &[], v0_secret.as_bytes());
442
        send_pam_message(&socket_path, &message).await?;
443

            
444
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
445

            
446
        assert_eq!(
447
            setup.server.pending_migrations.lock().await.len(),
448
            0,
449
            "V0 keyring should be migrated"
450
        );
451

            
452
        let collections = setup.server.collections.lock().await;
453
        let mut legacy_collection = None;
454
        for collection in collections.values() {
455
            if collection.label().await == "Legacy" {
456
                legacy_collection = Some(collection);
457
                break;
458
            }
459
        }
460
        assert!(
461
            legacy_collection.is_some(),
462
            "Migrated Legacy collection should exist"
463
        );
464

            
465
        let v1_migrated = v1_dir.join("legacy.keyring");
466
        assert!(v1_migrated.exists(), "V1 file should exist after migration");
467

            
468
        assert!(
469
            !v0_path.exists(),
470
            "V0 file should be removed after migration"
471
        );
472

            
473
        unsafe { std::env::remove_var("XDG_DATA_HOME") };
474
        unsafe { std::env::remove_var("OO7_PAM_SOCKET") };
475
        Ok(())
476
    }
477

            
478
    #[tokio::test]
479
    #[serial_test::serial(xdg_env)]
480
    async fn pam_unlocks_locked_collections() -> Result<(), Box<dyn std::error::Error>> {
481
        let temp_dir = tempfile::tempdir()?;
482
        unsafe { std::env::set_var("XDG_DATA_HOME", temp_dir.path()) };
483
        unsafe { std::env::set_var("OO7_PAM_SOCKET", temp_dir.path().join("pam.sock")) };
484

            
485
        // Create a v1 keyring with a known password
486
        let secret = Secret::from("my-secure-password");
487
        let keyring = UnlockedKeyring::open("work", secret.clone()).await?;
488
        keyring
489
            .create_item(
490
                "Work Item",
491
                &[("type", "work")],
492
                Secret::text("work-secret"),
493
                false,
494
            )
495
            .await?;
496
        keyring.write().await?;
497

            
498
        let setup = crate::tests::TestServiceSetup::with_disk_keyrings(None).await?;
499

            
500
        let collections = setup.server.collections.lock().await;
501
        let mut work_collection = None;
502
        for collection in collections.values() {
503
            if collection.label().await == "Work" {
504
                work_collection = Some(collection);
505
                break;
506
            }
507
        }
508
        assert!(work_collection.is_some(), "Work collection should exist");
509
        let work_collection = work_collection.unwrap();
510
        assert!(
511
            work_collection.is_locked().await,
512
            "Work collection should be locked"
513
        );
514
        drop(collections);
515

            
516
        let pam_listener = PamListener::new(setup.server.clone());
517
        let socket_path = pam_listener.socket_path.clone();
518
        tokio::spawn(async move {
519
            let _ = pam_listener.start().await;
520
        });
521

            
522
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
523

            
524
        let message = create_pam_message(PamOperation::Unlock, "testuser", &[], secret.as_bytes());
525
        send_pam_message(&socket_path, &message).await?;
526

            
527
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
528

            
529
        let collections = setup.server.collections.lock().await;
530
        let mut work_collection = None;
531
        for collection in collections.values() {
532
            if collection.label().await == "Work" {
533
                work_collection = Some(collection);
534
                break;
535
            }
536
        }
537
        let work_collection = work_collection.unwrap();
538
        assert!(
539
            !work_collection.is_locked().await,
540
            "Work collection should be unlocked"
541
        );
542

            
543
        unsafe { std::env::remove_var("XDG_DATA_HOME") };
544
        unsafe { std::env::remove_var("OO7_PAM_SOCKET") };
545
        Ok(())
546
    }
547

            
548
    #[tokio::test]
549
    #[serial_test::serial(xdg_env)]
550
    async fn pam_change_password() -> Result<(), Box<dyn std::error::Error>> {
551
        let temp_dir = tempfile::tempdir()?;
552
        unsafe { std::env::set_var("XDG_DATA_HOME", temp_dir.path()) };
553
        unsafe { std::env::set_var("OO7_PAM_SOCKET", temp_dir.path().join("pam.sock")) };
554

            
555
        let old_secret = Secret::from("old-password");
556
        let keyring = UnlockedKeyring::open("work", old_secret.clone()).await?;
557
        keyring
558
            .create_item(
559
                "Work Item",
560
                &[("type", "work")],
561
                Secret::text("work-secret"),
562
                false,
563
            )
564
            .await?;
565
        keyring.write().await?;
566

            
567
        let setup = crate::tests::TestServiceSetup::with_disk_keyrings(None).await?;
568

            
569
        let collections = setup.server.collections.lock().await;
570
        let mut work_collection = None;
571
        for collection in collections.values() {
572
            if collection.label().await == "Work" {
573
                work_collection = Some(collection);
574
                break;
575
            }
576
        }
577
        let work_collection = work_collection.unwrap();
578
        assert!(
579
            work_collection.is_locked().await,
580
            "Work collection should be locked"
581
        );
582
        drop(collections);
583

            
584
        let pam_listener = PamListener::new(setup.server.clone());
585
        let socket_path = pam_listener.socket_path.clone();
586
        tokio::spawn(async move {
587
            let _ = pam_listener.start().await;
588
        });
589

            
590
        tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
591

            
592
        let new_secret = Secret::from("new-password");
593
        let message = create_pam_message(
594
            PamOperation::ChangePassword,
595
            "testuser",
596
            old_secret.as_bytes(),
597
            new_secret.as_bytes(),
598
        );
599
        send_pam_message(&socket_path, &message).await?;
600

            
601
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
602

            
603
        let collections = setup.server.collections.lock().await;
604
        let mut work_collection = None;
605
        for collection in collections.values() {
606
            if collection.label().await == "Work" {
607
                work_collection = Some(collection);
608
                break;
609
            }
610
        }
611
        let work_collection = work_collection.unwrap();
612
        assert!(
613
            work_collection.is_locked().await,
614
            "Collection should be locked after password change"
615
        );
616

            
617
        let unlock_result = work_collection
618
            .set_locked(false, Some(old_secret.clone()))
619
            .await;
620
        assert!(
621
            unlock_result.is_err(),
622
            "Old password should not unlock collection"
623
        );
624

            
625
        work_collection
626
            .set_locked(false, Some(new_secret.clone()))
627
            .await?;
628
        assert!(
629
            !work_collection.is_locked().await,
630
            "New password should unlock collection"
631
        );
632

            
633
        unsafe { std::env::remove_var("XDG_DATA_HOME") };
634
        unsafe { std::env::remove_var("OO7_PAM_SOCKET") };
635
        Ok(())
636
    }
637

            
638
    #[tokio::test]
639
    async fn message_serialization() -> Result<(), Box<dyn std::error::Error>> {
640
        // Test that PamMessage can be properly serialized and deserialized
641
        let message = PamMessage {
642
            operation: PamOperation::Unlock,
643
            username: "testuser".to_owned(),
644
            old_secret: vec![],
645
            new_secret: b"my-password".to_vec(),
646
        };
647

            
648
        let ctxt = Context::new_dbus(zvariant::LE, 0);
649
        let encoded = zvariant::to_bytes(ctxt, &message)?;
650
        let decoded = PamMessage::from_bytes(&encoded)?;
651

            
652
        assert_eq!(decoded.operation, PamOperation::Unlock);
653
        assert_eq!(decoded.username, "testuser");
654
        assert_eq!(decoded.new_secret, b"my-password");
655

            
656
        let message = PamMessage {
657
            operation: PamOperation::ChangePassword,
658
            username: "testuser".to_owned(),
659
            old_secret: b"old-pass".to_vec(),
660
            new_secret: b"new-pass".to_vec(),
661
        };
662

            
663
        let ctxt = Context::new_dbus(zvariant::LE, 0);
664
        let encoded = zvariant::to_bytes(ctxt, &message)?;
665
        let decoded = PamMessage::from_bytes(&encoded)?;
666

            
667
        assert_eq!(decoded.operation, PamOperation::ChangePassword);
668
        assert_eq!(decoded.old_secret, b"old-pass");
669
        assert_eq!(decoded.new_secret, b"new-pass");
670

            
671
        Ok(())
672
    }
673
}