Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pam/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ crate-type = ["cdylib"]
libc = "0.2"
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "net", "io-util", "sync", "time"] }
serde = { workspace = true }
serde_repr = "0.1"
zvariant = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
Expand Down
17 changes: 14 additions & 3 deletions pam/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,25 @@ auth required pam_unix.so
auth optional pam_oo7.so
account required pam_unix.so
password required pam_unix.so
password optional pam_oo7.so
session required pam_unix.so
session optional pam_oo7.so auto_start
session optional pam_systemd.so
```

**Important**: The module must be added to both `auth` and `session` stacks:
- `auth` stack: Captures and stashes the password
- `session` stack: Retrieves the password and sends it to the daemon
**Important**: The module should be added to three PAM stacks:
- `auth` stack: Captures and stashes the password during authentication
- `session` stack: Retrieves the stashed password and sends it to the daemon for keyring unlocking
- `password` stack: Intercepts password changes and updates the keyring password to match

#### Password Change Support

When added to the `password` stack, the module will automatically update your keyring passwords when you change your user password (e.g., using `passwd` command). This ensures your keyrings remain accessible after password changes.

The module intercepts the password change operation:
1. Captures both the old and new passwords
2. Sends them to the daemon
3. The daemon validates the old password and re-encrypts all matching keyrings with the new password

## Configuration

Expand Down
6 changes: 6 additions & 0 deletions pam/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,15 @@ use std::os::raw::{c_char, c_int, c_void};
pub const PAM_SUCCESS: c_int = 0;
pub const PAM_SYSTEM_ERR: c_int = 4;
pub const PAM_AUTHTOK_RECOVER_ERR: c_int = 20;
pub const PAM_IGNORE: c_int = 25;

// PAM item types
pub const PAM_AUTHTOK: c_int = 6;
pub const PAM_OLDAUTHTOK: c_int = 7;

// PAM chauthtok flags
pub const PAM_PRELIM_CHECK: c_int = 0x1;
pub const PAM_UPDATE_AUTHTOK: c_int = 0x2;

// Opaque PAM handle type
#[repr(C)]
Expand Down
130 changes: 116 additions & 14 deletions pam/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ use std::{
use zeroize::Zeroizing;

use crate::{
ffi::{PAM_AUTHTOK, PAM_AUTHTOK_RECOVER_ERR, PAM_SUCCESS, PAM_SYSTEM_ERR, pam_handle_t},
ffi::{
PAM_AUTHTOK, PAM_AUTHTOK_RECOVER_ERR, PAM_IGNORE, PAM_OLDAUTHTOK, PAM_PRELIM_CHECK,
PAM_SUCCESS, PAM_SYSTEM_ERR, PAM_UPDATE_AUTHTOK, pam_handle_t,
},
protocol::PamMessage,
socket::send_secret_to_daemon,
};
Expand All @@ -42,18 +45,30 @@ unsafe fn get_user(pamh: *mut pam_handle_t) -> Result<String, c_int> {

/// Get the authentication token from PAM
unsafe fn get_auth_token(pamh: *mut pam_handle_t) -> Result<Zeroizing<Vec<u8>>, c_int> {
unsafe { get_auth_token_internal(pamh, PAM_AUTHTOK, "PAM_AUTHTOK") }
}

/// Get the old authentication token from PAM (for password changes)
unsafe fn get_old_auth_token(pamh: *mut pam_handle_t) -> Result<Zeroizing<Vec<u8>>, c_int> {
unsafe { get_auth_token_internal(pamh, PAM_OLDAUTHTOK, "PAM_OLDAUTHTOK") }
}

unsafe fn get_auth_token_internal(
pamh: *mut pam_handle_t,
item_type: c_int,
item_name: &str,
) -> Result<Zeroizing<Vec<u8>>, c_int> {
let mut authtok_ptr: *const std::os::raw::c_void = std::ptr::null();

// Use pam_get_item to get PAM_AUTHTOK (just like pam_gnome_keyring does)
let ret = unsafe { ffi::pam_get_item(pamh, PAM_AUTHTOK, &mut authtok_ptr) };
let ret = unsafe { ffi::pam_get_item(pamh, item_type, &mut authtok_ptr) };

if ret != PAM_SUCCESS {
tracing::debug!("pam_get_item returned error: {}", ret);
tracing::debug!("pam_get_item({}) returned error: {}", item_name, ret);
return Err(ret);
}

if authtok_ptr.is_null() {
tracing::debug!("PAM_AUTHTOK is null (password not available)");
tracing::debug!("{} is null (password not available)", item_name);
return Err(PAM_SYSTEM_ERR);
}

Expand All @@ -62,7 +77,8 @@ unsafe fn get_auth_token(pamh: *mut pam_handle_t) -> Result<Zeroizing<Vec<u8>>,
let password_bytes = password_cstr.to_bytes().to_vec();

tracing::debug!(
"Captured auth token of length {} bytes",
"Captured {} of length {} bytes",
item_name,
password_bytes.len()
);

Expand Down Expand Up @@ -258,10 +274,7 @@ pub unsafe extern "C" fn pam_sm_open_session(
}
};

let message = PamMessage {
username: username.clone(),
secret: password.to_vec(),
};
let message = PamMessage::unlock(username.clone(), password.to_vec());

// Send the secret to the oo7 daemon
std::thread::spawn(
Expand Down Expand Up @@ -294,11 +307,100 @@ pub extern "C" fn pam_sm_close_session(

/// PAM password change entry point
#[unsafe(no_mangle)]
pub extern "C" fn pam_sm_chauthtok(
_pamh: *mut pam_handle_t,
_flags: c_int,
#[allow(clippy::missing_safety_doc)]
pub unsafe extern "C" fn pam_sm_chauthtok(
pamh: *mut pam_handle_t,
flags: c_int,
_argc: c_int,
_argv: *mut *const c_char,
) -> c_int {
PAM_SUCCESS
if let Ok(layer) = tracing_journald::layer() {
use tracing_subscriber::layer::SubscriberExt;
let subscriber = tracing_subscriber::registry().with(layer);
let _ = tracing::subscriber::set_global_default(subscriber);
}

if flags & PAM_PRELIM_CHECK != 0 {
tracing::debug!("PAM_PRELIM_CHECK phase for password change");
return PAM_IGNORE;
}

if flags & PAM_UPDATE_AUTHTOK != 0 {
tracing::debug!("PAM_UPDATE_AUTHTOK phase for password change");

let username = match unsafe { get_user(pamh) } {
Ok(user) => user,
Err(ret) => {
tracing::error!("Failed to get username during password change");
return ret;
}
};

let user_uid = match get_user_uid(&username) {
Some(uid) => uid,
None => {
tracing::error!("Failed to get UID for user: {}", username);
return PAM_SYSTEM_ERR;
}
};

let old_password = match unsafe { get_old_auth_token(pamh) } {
Ok(pass) => pass,
Err(_) => {
tracing::warn!(
"No old password available for user {}, cannot update keyring password",
username
);
return PAM_SUCCESS;
}
};

let new_password = match unsafe { get_auth_token(pamh) } {
Ok(pass) => pass,
Err(_) => {
tracing::warn!(
"No new password available for user {}, cannot update keyring password",
username
);
return PAM_SUCCESS;
}
};

if old_password.is_empty() || new_password.is_empty() {
tracing::debug!("Old or new password is empty, skipping keyring password change");
return PAM_SUCCESS;
}

tracing::info!(
"Password change for user {}: old={} bytes, new={} bytes",
username,
old_password.len(),
new_password.len()
);

let message = PamMessage::change_password(
username.clone(),
old_password.to_vec(),
new_password.to_vec(),
);

std::thread::spawn(
move || match send_secret_to_daemon(message, user_uid, false) {
Ok(_) => {
tracing::info!(
"Successfully sent password change request to oo7 daemon for user: {}",
username
);
}
Err(e) => {
tracing::error!("Failed to send password change to oo7 daemon: {}", e);
}
},
);

return PAM_SUCCESS;
}

tracing::warn!("pam_sm_chauthtok called with unknown flags: {}", flags);
PAM_IGNORE
}
33 changes: 32 additions & 1 deletion pam/src/protocol.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,45 @@
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use zeroize::{Zeroize, ZeroizeOnDrop};
use zvariant::{Type, serialized::Context, to_bytes};

#[derive(Debug, Clone, Copy, Serialize_repr, Deserialize_repr, Type, PartialEq, Eq)]
#[repr(u8)]
pub enum PamOperation {
Unlock = 0,
ChangePassword = 1,
}

#[derive(Debug, Serialize, Deserialize, Type, Zeroize, ZeroizeOnDrop)]
pub struct PamMessage {
#[zeroize(skip)]
operation: PamOperation,
pub username: String,
pub secret: Vec<u8>,
pub old_secret: Vec<u8>,
pub new_secret: Vec<u8>,
}

impl PamMessage {
/// Create an unlock message
pub fn unlock(username: String, secret: Vec<u8>) -> Self {
Self {
operation: PamOperation::Unlock,
username,
old_secret: Vec::new(),
new_secret: secret,
}
}

/// Create a password change message
pub fn change_password(username: String, old_secret: Vec<u8>, new_secret: Vec<u8>) -> Self {
Self {
operation: PamOperation::ChangePassword,
username,
old_secret,
new_secret,
}
}

pub fn to_bytes(&self) -> Result<Vec<u8>, zvariant::Error> {
let ctxt = Context::new_dbus(zvariant::LE, 0);
to_bytes(ctxt, self).map(|data| data.to_vec())
Expand Down
13 changes: 3 additions & 10 deletions pam/src/socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,11 +156,7 @@ async fn send_secret_to_daemon_async(

tracing::debug!("Connected to daemon socket");

tracing::debug!(
"Sending secret of length {} bytes for user {}",
message.secret.len(),
message.username
);
tracing::debug!("Sending message for user {}", message.username);
let message_bytes = Zeroizing::new(message.to_bytes().map_err(SocketError::Serialize)?);

let length = message_bytes.len() as u32;
Expand Down Expand Up @@ -212,15 +208,12 @@ mod tests {

let message = PamMessage::from_bytes(&message_bytes).unwrap();
assert_eq!(message.username, "testuser");
assert_eq!(message.secret, b"testpassword");
assert_eq!(message.new_secret, b"testpassword");
});

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

let message = PamMessage {
username: "testuser".to_string(),
secret: b"testpassword".to_vec(),
};
let message = PamMessage::unlock("testuser".to_string(), b"testpassword".to_vec());

let result = send_secret_to_daemon_async(message, 1000, false).await;
assert!(result.is_ok());
Expand Down
1 change: 1 addition & 0 deletions server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ openssl = { version = "0.10", optional = true }
oo7 = { workspace = true, features = ["tokio"] }
rpassword = "7.4"
serde.workspace = true
serde_repr = "0.1"
sha2 = { version = "0.10", optional = true }
tokio = { workspace = true, features = ["full"] }
tokio-stream = "0.1"
Expand Down
36 changes: 31 additions & 5 deletions server/src/collection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -404,14 +404,27 @@ impl Collection {
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap();

let sanitized_label = label
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();

Self {
items: Default::default(),
label: Arc::new(Mutex::new(label.to_owned())),
modified: Arc::new(Mutex::new(created)),
alias: Arc::new(Mutex::new(alias.to_owned())),
item_index: Arc::new(RwLock::new(0)),
path: OwnedObjectPath::try_from(format!("/org/freedesktop/secrets/collection/{label}"))
.unwrap(),
path: OwnedObjectPath::try_from(format!(
"/org/freedesktop/secrets/collection/{sanitized_label}"
))
.expect("Sanitized label should always produce valid object path"),
created,
service,
keyring: Arc::new(RwLock::new(Some(keyring))),
Expand Down Expand Up @@ -492,9 +505,22 @@ impl Collection {
custom_service_error("Cannot unlock collection without a secret")
})?;

let unlocked = locked_kr.unlock(secret).await.map_err(|err| {
custom_service_error(&format!("Failed to unlock keyring: {err}"))
})?;
let keyring_path = locked_kr.path().map(|p| p.to_path_buf());

let unlocked = match locked_kr.unlock(secret).await {
Ok(unlocked) => unlocked,
Err(err) => {
// Reload the locked keyring from disk before returning error
if let Some(path) = keyring_path {
if let Ok(reloaded) = oo7::file::LockedKeyring::load(&path).await {
*keyring_guard = Some(Keyring::Locked(reloaded));
}
}
return Err(custom_service_error(&format!(
"Failed to unlock keyring: {err}"
)));
}
};

let items = self.items.lock().await;
for item in items.iter() {
Expand Down
Loading
Loading