changeset 70:9f8381a1c09c

Implement low-level conversation primitives. This change does two primary things: 1. Introduces new Conversation traits, to be implemented both by the library and by PAM client applications. 2. Builds the memory-management infrastructure for passing messages through the conversation. ...and it adds tests for both of the above, including ASAN tests.
author Paul Fisher <paul@pfish.zone>
date Tue, 03 Jun 2025 01:21:59 -0400
parents 8f3ae0c7ab92
children 58f9d2a4df38
files Cargo.toml src/constants.rs src/conv.rs src/handle.rs src/lib.rs src/module.rs src/pam_ffi.rs
diffstat 7 files changed, 680 insertions(+), 111 deletions(-) [+]
line wrap: on
line diff
--- a/Cargo.toml	Sun Jun 01 01:15:04 2025 -0400
+++ b/Cargo.toml	Tue Jun 03 01:21:59 2025 -0400
@@ -9,11 +9,9 @@
 license = "MIT"
 edition = "2021"
 
-[features]
-experimental = []
-
 [dependencies]
 bitflags = "2.9.0"
+derive_more = { version = "2.0.1", features = ["from"] }
 libc = "0.2.97"
 num-derive = "0.4.2"
 num-traits = "0.2.19"
--- a/src/constants.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/constants.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -190,6 +190,21 @@
     }
 }
 
+/// Returned when text that should not have any `\0` bytes in it does.
+/// Analogous to [`std::ffi::NulError`], but the data it was created from
+/// is borrowed.
+#[derive(Debug, thiserror::Error)]
+#[error("null byte within input at byte {0}")]
+pub struct NulError(pub usize);
+
+/// Returned when trying to fit too much data into a binary message.
+#[derive(Debug, thiserror::Error)]
+#[error("cannot create a message of {actual} bytes; maximum is {max}")]
+pub struct TooBigError {
+    pub actual: usize,
+    pub max: usize,
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
--- a/src/conv.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/conv.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -1,9 +1,156 @@
 //! The PAM conversation and associated Stuff.
 
-use crate::pam_ffi::{BinaryResponseInner, NulError, TextResponseInner};
-use std::num::TryFromIntError;
-use std::ops::Deref;
+// Temporarily allowed until we get the actual conversation functions hooked up.
+#![allow(dead_code)]
+
+use crate::constants::{NulError, Result, TooBigError};
+use crate::pam_ffi::{BinaryResponseInner, GenericResponse, TextResponseInner};
+use secure_string::SecureString;
+use std::mem;
 use std::result::Result as StdResult;
+use std::str::Utf8Error;
+
+// TODO: In most cases, we should be passing around references to strings
+// or binary data. Right now we don't because that turns type inference and
+// trait definitions/implementations into a HUGE MESS.
+//
+// Ideally, we would be using some kind of `TD: TextData` and `BD: BinaryData`
+// associated types in the various Conversation traits to avoid copying
+// when unnecessary.
+
+/// The types of message and request that can be sent to a user.
+///
+/// The data within each enum value is the prompt (or other information)
+/// that will be presented to the user.
+#[derive(Debug)]
+pub enum Message<'a> {
+    /// Requests information from the user; will be masked when typing.
+    ///
+    /// Response: [`Response::MaskedText`]
+    MaskedPrompt(&'a str),
+    /// Requests information from the user; will not be masked.
+    ///
+    /// Response: [`Response::Text`]
+    Prompt(&'a str),
+    /// "Yes/No/Maybe conditionals" (a Linux-PAM extension).
+    ///
+    /// Response: [`Response::Text`]
+    /// (Linux-PAM documentation doesn't define its contents.)
+    RadioPrompt(&'a str),
+    /// Raises an error message to the user.
+    ///
+    /// Response: [`Response::NoResponse`]
+    Error(&'a str),
+    /// Sends an informational message to the user.
+    ///
+    /// Response: [`Response::NoResponse`]
+    Info(&'a str),
+    /// Requests binary data from the client (a Linux-PAM extension).
+    ///
+    /// This is used for non-human or non-keyboard prompts (security key?).
+    /// NOT part of the X/Open PAM specification.
+    ///
+    /// Response: [`Response::Binary`]
+    BinaryPrompt {
+        /// Some binary data.
+        data: &'a [u8],
+        /// A "type" that you can use for signalling. Has no strict definition in PAM.
+        data_type: u8,
+    },
+}
+
+/// The responses that PAM will return from a request.
+#[derive(Debug, PartialEq, derive_more::From)]
+pub enum Response {
+    /// Used to fill in list entries where there is no response expected.
+    ///
+    /// Used in response to:
+    ///
+    /// - [`Error`](Message::Error)
+    /// - [`Info`](Message::Info)
+    NoResponse,
+    /// A response with text data from the user.
+    ///
+    /// Used in response to:
+    ///
+    /// - [`Prompt`](Message::Prompt)
+    /// - [`RadioPrompt`](Message::RadioPrompt) (a Linux-PAM extension)
+    Text(String),
+    /// A response to a masked request with text data from the user.
+    ///
+    /// Used in response to:
+    ///
+    /// - [`MaskedPrompt`](Message::MaskedPrompt)
+    MaskedText(SecureString),
+    /// A response to a binary request (a Linux-PAM extension).
+    ///
+    /// Used in response to:
+    ///
+    /// - [`BinaryPrompt`](Message::BinaryPrompt)
+    Binary(BinaryData),
+}
+
+/// A channel for PAM modules to request information from the user.
+///
+/// This trait is used by both applications and PAM modules:
+///
+/// - Applications implement Conversation and provide a user interface
+///   to allow the user to respond to PAM questions.
+/// - Modules call a Conversation implementation to request information
+///   or send information to the user.
+pub trait Conversation {
+    /// Sends messages to the user.
+    ///
+    /// The returned Vec of messages always contains exactly as many entries
+    /// as there were messages in the request; one corresponding to each.
+    ///
+    /// Messages with no response (e.g. [info](Message::Info) and
+    /// [error](Message::Error)) will have a `None` entry instead of a `Response`.
+    fn send(&mut self, messages: &[Message]) -> Result<Vec<Response>>;
+}
+
+/// Trait that an application can implement if they want to handle messages
+/// one at a time.
+pub trait DemuxedConversation {
+    /// Prompts the user for some text.
+    fn prompt(&mut self, request: &str) -> Result<String>;
+    /// Prompts the user for some text, but hides their typing.
+    fn masked_prompt(&mut self, request: &str) -> Result<SecureString>;
+    /// Prompts the user for a radio option (a Linux-PAM extension).
+    ///
+    /// The Linux-PAM documentation doesn't give the format of the response.
+    fn radio_prompt(&mut self, request: &str) -> Result<String>;
+    /// Alerts the user to an error.
+    fn error(&mut self, message: &str);
+    /// Sends an informational message to the user.
+    fn info(&mut self, message: &str);
+    /// Requests binary data from the user (a Linux-PAM extension).
+    fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData>;
+}
+
+impl<D: DemuxedConversation> Conversation for D {
+    fn send(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
+        messages
+            .iter()
+            .map(|msg| match *msg {
+                Message::Prompt(prompt) => self.prompt(prompt).map(Response::from),
+                Message::MaskedPrompt(prompt) => self.masked_prompt(prompt).map(Response::from),
+                Message::RadioPrompt(prompt) => self.radio_prompt(prompt).map(Response::from),
+                Message::Info(message) => {
+                    self.info(message);
+                    Ok(Response::NoResponse)
+                }
+                Message::Error(message) => {
+                    self.error(message);
+                    Ok(Response::NoResponse)
+                }
+                Message::BinaryPrompt { data_type, data } => {
+                    self.binary_prompt(data, data_type).map(Response::from)
+                }
+            })
+            .collect()
+    }
+}
 
 /// An owned text response to a PAM conversation.
 ///
@@ -12,17 +159,24 @@
 struct TextResponse(*mut TextResponseInner);
 
 impl TextResponse {
-    /// Creates a text response.
+    /// Allocates a new response with the given text.
+    ///
+    /// A copy of the provided text will be allocated on the C heap.
     pub fn new(text: impl AsRef<str>) -> StdResult<Self, NulError> {
         TextResponseInner::alloc(text).map(Self)
     }
-}
 
-impl Deref for TextResponse {
-    type Target = TextResponseInner;
-    fn deref(&self) -> &Self::Target {
-        // SAFETY: We allocated this ourselves, or it was provided by PAM.
-        unsafe { &*self.0 }
+    /// Converts this into a GenericResponse.
+    fn generic(self) -> *mut GenericResponse {
+        let ret = self.0 as *mut GenericResponse;
+        mem::forget(self);
+        ret
+    }
+
+    /// Gets the string data, if possible.
+    pub fn as_str(&self) -> StdResult<&str, Utf8Error> {
+        // SAFETY: We allocated this ourselves or got it back from PAM.
+        unsafe { &*self.0 }.contents().to_str()
     }
 }
 
@@ -38,20 +192,33 @@
 ///
 /// It points to a value on the C heap.
 #[repr(C)]
-struct BinaryResponse(*mut BinaryResponseInner);
+pub struct BinaryResponse(pub(super) *mut BinaryResponseInner);
 
 impl BinaryResponse {
     /// Creates a binary response with the given data.
-    pub fn new(data: impl AsRef<[u8]>, data_type: u8) -> StdResult<Self, TryFromIntError> {
+    ///
+    /// A copy of the data will be made and allocated on the C heap.
+    pub fn new(data: &[u8], data_type: u8) -> StdResult<Self, TooBigError> {
         BinaryResponseInner::alloc(data, data_type).map(Self)
     }
-}
+
+    /// Converts this into a GenericResponse.
+    fn generic(self) -> *mut GenericResponse {
+        let ret = self.0 as *mut GenericResponse;
+        mem::forget(self);
+        ret
+    }
 
-impl Deref for BinaryResponse {
-    type Target = BinaryResponseInner;
-    fn deref(&self) -> &Self::Target {
-        // SAFETY: We allocated this ourselves, or it was provided by PAM.
-        unsafe { &*self.0 }
+    /// The data type we point to.
+    pub fn data_type(&self) -> u8 {
+        // SAFETY: We allocated this ourselves or got it back from PAM.
+        unsafe { &*self.0 }.data_type()
+    }
+
+    /// The data we point to.
+    pub fn data(&self) -> &[u8] {
+        // SAFETY: We allocated this ourselves or got it back from PAM.
+        unsafe { &*self.0 }.contents()
     }
 }
 
@@ -63,19 +230,165 @@
     }
 }
 
+/// Owned binary data.
+#[derive(Debug, PartialEq)]
+pub struct BinaryData {
+    data: Vec<u8>,
+    data_type: u8,
+}
+
+impl BinaryData {
+    pub fn new(data: Vec<u8>, data_type: u8) -> Self {
+        Self { data, data_type }
+    }
+    pub fn data(&self) -> &[u8] {
+        &self.data
+    }
+    pub fn data_type(&self) -> u8 {
+        self.data_type
+    }
+}
+
+impl From<BinaryResponse> for BinaryData {
+    /// Copies the data onto the Rust heap.
+    fn from(value: BinaryResponse) -> Self {
+        Self {
+            data: value.data().to_vec(),
+            data_type: value.data_type(),
+        }
+    }
+}
+
+impl From<BinaryData> for Vec<u8> {
+    /// Extracts the inner vector from the BinaryData.
+    fn from(value: BinaryData) -> Self {
+        value.data
+    }
+}
+
 #[cfg(test)]
 mod test {
-    use super::{BinaryResponse, TextResponse};
+    use super::{
+        BinaryResponse, Conversation, DemuxedConversation, Message, Response, SecureString,
+        TextResponse,
+    };
+    use crate::constants::ErrorCode;
+    use crate::pam_ffi::GenericResponse;
+
+    #[test]
+    fn test_demux() {
+        #[derive(Default)]
+        struct DemuxTester {
+            error_ran: bool,
+            info_ran: bool,
+        }
+
+        impl DemuxedConversation for DemuxTester {
+            fn prompt(&mut self, request: &str) -> crate::Result<String> {
+                match request {
+                    "what" => Ok("whatwhat".to_owned()),
+                    "give_err" => Err(ErrorCode::PermissionDenied),
+                    _ => panic!("unexpected prompt!"),
+                }
+            }
+            fn masked_prompt(&mut self, request: &str) -> crate::Result<SecureString> {
+                assert_eq!("reveal", request);
+                Ok(SecureString::from("my secrets"))
+            }
+            fn radio_prompt(&mut self, request: &str) -> crate::Result<String> {
+                assert_eq!("channel?", request);
+                Ok("zero".to_owned())
+            }
+            fn error(&mut self, message: &str) {
+                self.error_ran = true;
+                assert_eq!("whoopsie", message);
+            }
+            fn info(&mut self, message: &str) {
+                self.info_ran = true;
+                assert_eq!("did you know", message);
+            }
+            fn binary_prompt(
+                &mut self,
+                data: &[u8],
+                data_type: u8,
+            ) -> crate::Result<super::BinaryData> {
+                assert_eq!(&[10, 9, 8], data);
+                assert_eq!(66, data_type);
+                Ok(super::BinaryData::new(vec![5, 5, 5], 5))
+            }
+        }
+
+        let mut tester = DemuxTester::default();
+
+        assert_eq!(
+            vec![
+                Response::Text("whatwhat".to_owned()),
+                Response::MaskedText("my secrets".into()),
+                Response::NoResponse,
+                Response::NoResponse,
+            ],
+            tester
+                .send(&[
+                    Message::Prompt("what"),
+                    Message::MaskedPrompt("reveal"),
+                    Message::Error("whoopsie"),
+                    Message::Info("did you know"),
+                ])
+                .unwrap()
+        );
+        assert!(tester.error_ran);
+        assert!(tester.info_ran);
+
+        assert_eq!(
+            ErrorCode::PermissionDenied,
+            tester.send(&[Message::Prompt("give_err")]).unwrap_err(),
+        );
+
+        // Test the Linux-PAM extensions separately.
+
+        assert_eq!(
+            vec![
+                Response::Text("zero".to_owned()),
+                Response::Binary(super::BinaryData::new(vec![5, 5, 5], 5)),
+            ],
+            tester
+                .send(&[
+                    Message::RadioPrompt("channel?"),
+                    Message::BinaryPrompt {
+                        data: &[10, 9, 8],
+                        data_type: 66
+                    },
+                ])
+                .unwrap()
+        );
+    }
+
+    // The below tests are used in conjunction with ASAN to verify
+    // that we correctly clean up all our memory.
 
     #[test]
     fn test_text_response() {
         let resp = TextResponse::new("it's a-me!").unwrap();
-        assert_eq!("it's a-me!", resp.contents().to_str().unwrap());
+        assert_eq!("it's a-me!", resp.as_str().unwrap());
     }
+
     #[test]
     fn test_binary_response() {
         let data = [123, 210, 55];
         let resp = BinaryResponse::new(&data, 99).unwrap();
-        assert_eq!(&data, resp.contents());
+        assert_eq!(data, resp.data());
+        assert_eq!(99, resp.data_type());
+    }
+
+    #[test]
+    fn test_to_generic() {
+        let text = TextResponse::new("oh no").unwrap();
+        let text = text.generic();
+        let binary = BinaryResponse::new(&[], 33).unwrap();
+        let binary = binary.generic();
+        unsafe {
+            GenericResponse::free(text);
+            GenericResponse::free(binary);
+        }
     }
 }
--- a/src/handle.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/handle.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -22,7 +22,7 @@
     ///
     /// ```no_run
     /// # use nonstick::PamHandle;
-    /// # fn _doc(handle: &impl PamHandle) -> Result<(), Box<dyn std::error::Error>> {
+    /// # fn _doc(handle: &mut impl PamHandle) -> Result<(), Box<dyn std::error::Error>> {
     /// // Get the username using the default prompt.
     /// let user = handle.get_user(None)?;
     /// // Get the username using a custom prompt.
@@ -33,7 +33,7 @@
     ///
     /// [man]: https://www.man7.org/linux/man-pages/man3/pam_get_user.3.html
     /// [mwg]: https://www.chiark.greenend.org.uk/doc/libpam-doc/html/mwg-expected-by-module-item.html#mwg-pam_get_user
-    fn get_user(&self, prompt: Option<&str>) -> Result<String>;
+    fn get_user(&mut self, prompt: Option<&str>) -> Result<String>;
 
     /// Retrieves the authentication token from the user.
     ///
@@ -46,7 +46,7 @@
     ///
     /// ```no_run
     /// # use nonstick::PamHandle;
-    /// # fn _doc(handle: &impl PamHandle) -> Result<(), Box<dyn std::error::Error>> {
+    /// # fn _doc(handle: &mut impl PamHandle) -> Result<(), Box<dyn std::error::Error>> {
     /// // Get the user's password using the default prompt.
     /// let pass = handle.get_authtok(None)?;
     /// // Get the user's password using a custom prompt.
@@ -57,7 +57,7 @@
     ///
     /// [man]: https://www.man7.org/linux/man-pages/man3/pam_get_authtok.3.html
     /// [mwg]: https://www.chiark.greenend.org.uk/doc/libpam-doc/html/mwg-expected-by-module-item.html#mwg-pam_get_item
-    fn get_authtok(&self, prompt: Option<&str>) -> Result<SecureString>;
+    fn get_authtok(&mut self, prompt: Option<&str>) -> Result<SecureString>;
 
     /// Retrieves an [Item] that has been set, possibly by the PAM client.
     ///
@@ -74,7 +74,7 @@
     /// # use nonstick::PamHandle;
     /// use nonstick::items::Service;
     ///
-    /// # fn _doc(pam_handle: &impl PamHandle) -> Result<(), Box<dyn std::error::Error>> {
+    /// # fn _doc(pam_handle: &mut impl PamHandle) -> Result<(), Box<dyn std::error::Error>> {
     /// let svc: Option<Service> = pam_handle.get_item()?;
     /// match svc {
     ///     Some(name) => eprintln!("The calling service name is {:?}", name.to_string_lossy()),
@@ -86,7 +86,7 @@
     ///
     /// [man]: https://www.man7.org/linux/man-pages/man3/pam_get_item.3.html
     /// [mwg]: https://www.chiark.greenend.org.uk/doc/libpam-doc/html/mwg-expected-by-module-item.html#mwg-pam_get_item
-    fn get_item<T: Item>(&self) -> Result<Option<T>>;
+    fn get_item<T: Item>(&mut self) -> Result<Option<T>>;
 
     /// Sets an item in the PAM context. It can be retrieved using [`get_item`](Self::get_item).
     ///
@@ -154,7 +154,7 @@
     ///
     /// [man]: https://www.man7.org/linux/man-pages/man3/pam_get_data.3.html
     /// [mwg]: https://www.chiark.greenend.org.uk/doc/libpam-doc/html/mwg-expected-by-module-item.html#mwg-pam_get_data
-    unsafe fn get_data<T>(&self, key: &str) -> Result<Option<&T>>;
+    unsafe fn get_data<T>(&mut self, key: &str) -> Result<Option<&T>>;
 
     /// Stores a pointer that can be retrieved later with [`get_data`](Self::get_data).
     ///
@@ -201,7 +201,7 @@
 }
 
 impl PamHandle for LibPamHandle {
-    fn get_user(&self, prompt: Option<&str>) -> crate::Result<String> {
+    fn get_user(&mut self, prompt: Option<&str>) -> crate::Result<String> {
         let prompt = memory::option_cstr(prompt)?;
         let mut output: *const c_char = std::ptr::null_mut();
         let ret = unsafe {
@@ -211,7 +211,7 @@
         memory::copy_pam_string(output)
     }
 
-    fn get_authtok(&self, prompt: Option<&str>) -> crate::Result<SecureString> {
+    fn get_authtok(&mut self, prompt: Option<&str>) -> crate::Result<SecureString> {
         let prompt = memory::option_cstr(prompt)?;
         let mut output: *const c_char = std::ptr::null_mut();
         let res = unsafe {
@@ -226,7 +226,7 @@
         memory::copy_pam_string(output).map(SecureString::from)
     }
 
-    fn get_item<T: Item>(&self) -> crate::Result<Option<T>> {
+    fn get_item<T: Item>(&mut self) -> crate::Result<Option<T>> {
         let mut ptr: *const libc::c_void = std::ptr::null();
         let out = unsafe {
             let ret = pam_ffi::pam_get_item(&self.0, T::type_id().into(), &mut ptr);
@@ -259,7 +259,7 @@
 }
 
 impl PamModuleHandle for LibPamHandle {
-    unsafe fn get_data<T>(&self, key: &str) -> crate::Result<Option<&T>> {
+    unsafe fn get_data<T>(&mut self, key: &str) -> crate::Result<Option<&T>> {
         let c_key = CString::new(key).map_err(|_| ErrorCode::ConversationError)?;
         let mut ptr: *const libc::c_void = std::ptr::null();
         ErrorCode::result_from(pam_ffi::pam_get_data(&self.0, c_key.as_ptr(), &mut ptr))?;
--- a/src/lib.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/lib.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -23,10 +23,9 @@
 //! [module-guide]: https://www.chiark.greenend.org.uk/doc/libpam-doc/html/Linux-PAM_MWG.html
 
 pub mod constants;
-#[cfg(feature = "experimental")]
 pub mod conv;
 pub mod items;
-mod module;
+pub mod module;
 
 mod handle;
 mod memory;
--- a/src/module.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/module.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -1,7 +1,13 @@
 //! Functions and types useful for implementing a PAM module.
 
+// Temporarily allowed until we get the actual conversation functions hooked up.
+#![allow(dead_code)]
+
 use crate::constants::{ErrorCode, Flags, Result};
+use crate::conv::BinaryData;
+use crate::conv::{Conversation, Message, Response};
 use crate::handle::PamModuleHandle;
+use secure_string::SecureString;
 use std::ffi::CStr;
 
 /// A trait for a PAM module to implement.
@@ -233,6 +239,81 @@
     }
 }
 
+/// Provides methods to make it easier to send exactly one message.
+///
+/// This is primarily used by PAM modules, so that a module that only needs
+/// one piece of information at a time doesn't have a ton of boilerplate.
+/// You may also find it useful for testing PAM application libraries.
+///
+/// ```
+/// # use nonstick::Result;
+/// # use nonstick::conv::Conversation;
+/// # use nonstick::module::ConversationMux;
+/// # fn _do_test(conv: impl Conversation) -> Result<()> {
+/// let mut mux = ConversationMux(conv);
+/// let token = mux.masked_prompt("enter your one-time token")?;
+/// # Ok(())
+/// # }
+pub struct ConversationMux<C: Conversation>(pub C);
+
+impl<C: Conversation> Conversation for ConversationMux<C> {
+    fn send(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
+        self.0.send(messages)
+    }
+}
+
+impl<C: Conversation> ConversationMux<C> {
+    /// Prompts the user for something.
+    pub fn prompt(&mut self, request: &str) -> Result<String> {
+        let resp = self.send(&[Message::Prompt(request)])?.pop();
+        match resp {
+            Some(Response::Text(s)) => Ok(s),
+            _ => Err(ErrorCode::ConversationError),
+        }
+    }
+
+    /// Prompts the user for something, but hides what the user types.
+    pub fn masked_prompt(&mut self, request: &str) -> Result<SecureString> {
+        let resp = self.send(&[Message::MaskedPrompt(request)])?.pop();
+        match resp {
+            Some(Response::MaskedText(s)) => Ok(s),
+            _ => Err(ErrorCode::ConversationError),
+        }
+    }
+
+    /// Prompts the user for a yes/no/maybe conditional (a Linux-PAM extension).
+    ///
+    /// PAM documentation doesn't define the format of the response.
+    pub fn radio_prompt(&mut self, request: &str) -> Result<String> {
+        let resp = self.send(&[Message::RadioPrompt(request)])?.pop();
+        match resp {
+            Some(Response::Text(s)) => Ok(s),
+            _ => Err(ErrorCode::ConversationError),
+        }
+    }
+
+    /// Alerts the user to an error.
+    pub fn error(&mut self, message: &str) {
+        let _ = self.send(&[Message::Error(message)]);
+    }
+
+    /// Sends an informational message to the user.
+    pub fn info(&mut self, message: &str) {
+        let _ = self.send(&[Message::Info(message)]);
+    }
+
+    /// Requests binary data from the user (a Linux-PAM extension).
+    pub fn binary_prompt(&mut self, data: &[u8], data_type: u8) -> Result<BinaryData> {
+        let resp = self
+            .send(&[Message::BinaryPrompt { data, data_type }])?
+            .pop();
+        match resp {
+            Some(Response::Binary(d)) => Ok(d),
+            _ => Err(ErrorCode::ConversationError),
+        }
+    }
+}
+
 /// Generates the dynamic library entry points for a [PamModule] implementation.
 ///
 /// Calling `pam_hooks!(SomeType)` on a type that implements [PamModule] will
@@ -379,11 +460,91 @@
 }
 
 #[cfg(test)]
-pub mod test {
-    use crate::module::{PamModule, PamModuleHandle};
+mod test {
+    use super::{
+        Conversation, ConversationMux, ErrorCode, Message, Response, Result, SecureString,
+    };
+
+    /// Compile-time test that the `pam_hooks` macro compiles.
+    mod hooks {
+        use super::super::{PamModule, PamModuleHandle};
+        struct Foo;
+        impl<T: PamModuleHandle> PamModule<T> for Foo {}
+
+        pam_hooks!(Foo);
+    }
+
+    #[test]
+    fn test_mux() {
+        struct MuxTester;
 
-    struct Foo;
-    impl<T: PamModuleHandle> PamModule<T> for Foo {}
+        impl Conversation for MuxTester {
+            fn send(&mut self, messages: &[Message]) -> Result<Vec<Response>> {
+                if let [msg] = messages {
+                    match msg {
+                        Message::Info(info) => {
+                            assert_eq!("let me tell you", *info);
+                            Ok(vec![Response::NoResponse])
+                        }
+                        Message::Error(error) => {
+                            assert_eq!("oh no", *error);
+                            Ok(vec![Response::NoResponse])
+                        }
+                        Message::Prompt("should_error") => Err(ErrorCode::BufferError),
+                        Message::Prompt(ask) => {
+                            assert_eq!("question", *ask);
+                            Ok(vec![Response::Text("answer".to_owned())])
+                        }
+                        Message::MaskedPrompt("return_wrong_type") => {
+                            Ok(vec![Response::NoResponse])
+                        }
+                        Message::MaskedPrompt(ask) => {
+                            assert_eq!("password!", *ask);
+                            Ok(vec![Response::MaskedText(SecureString::from(
+                                "open sesame",
+                            ))])
+                        }
+                        Message::BinaryPrompt { data, data_type } => {
+                            assert_eq!(&[1, 2, 3], data);
+                            assert_eq!(69, *data_type);
+                            Ok(vec![Response::Binary(super::BinaryData::new(
+                                vec![3, 2, 1],
+                                42,
+                            ))])
+                        }
+                        Message::RadioPrompt(ask) => {
+                            assert_eq!("radio?", *ask);
+                            Ok(vec![Response::Text("yes".to_owned())])
+                        }
+                    }
+                } else {
+                    panic!("messages is the wrong size ({len})", len = messages.len())
+                }
+            }
+        }
 
-    pam_hooks!(Foo);
+        let mut mux = ConversationMux(MuxTester);
+        assert_eq!("answer", mux.prompt("question").unwrap());
+        assert_eq!(
+            SecureString::from("open sesame"),
+            mux.masked_prompt("password!").unwrap()
+        );
+        mux.error("oh no");
+        mux.info("let me tell you");
+        {
+            assert_eq!("yes", mux.radio_prompt("radio?").unwrap());
+            assert_eq!(
+                super::BinaryData::new(vec![3, 2, 1], 42),
+                mux.binary_prompt(&[1, 2, 3], 69).unwrap(),
+            )
+        }
+        assert_eq!(
+            ErrorCode::BufferError,
+            mux.prompt("should_error").unwrap_err(),
+        );
+        assert_eq!(
+            ErrorCode::ConversationError,
+            mux.masked_prompt("return_wrong_type").unwrap_err()
+        )
+    }
 }
--- a/src/pam_ffi.rs	Sun Jun 01 01:15:04 2025 -0400
+++ b/src/pam_ffi.rs	Tue Jun 03 01:21:59 2025 -0400
@@ -9,14 +9,12 @@
 // Temporarily allow dead code.
 #![allow(dead_code)]
 
-use crate::constants::InvalidEnum;
+use crate::constants::{InvalidEnum, NulError, TooBigError};
 use num_derive::FromPrimitive;
 use num_traits::FromPrimitive;
 use std::ffi::{c_char, c_int, c_void, CStr};
 use std::marker::{PhantomData, PhantomPinned};
-use std::num::TryFromIntError;
 use std::slice;
-use thiserror::Error;
 
 /// Makes whatever it's in not [`Send`], [`Sync`], or [`Unpin`].
 type Immovable = PhantomData<(*mut u8, PhantomPinned)>;
@@ -40,10 +38,12 @@
     ErrorMsg = 3,
     /// An informational message.
     TextInfo = 4,
-    /// Yes/No/Maybe conditionals. Linux-PAM specific.
+    /// Yes/No/Maybe conditionals. A Linux-PAM extension.
     RadioType = 5,
     /// For server–client non-human interaction.
+    ///
     /// NOT part of the X/Open PAM specification.
+    /// A Linux-PAM extension.
     BinaryPrompt = 7,
 }
 
@@ -68,20 +68,18 @@
     /// The style of message to request.
     style: c_int,
     /// A description of the data requested.
+    ///
     /// For most requests, this will be an owned [`CStr`], but for requests
-    /// with [`MessageStyle::BinaryPrompt`], this will be [`BinaryData`].
+    /// with [`MessageStyle::BinaryPrompt`], this will be [`BinaryData`]
+    /// (a Linux-PAM extension).
     data: *const c_void,
 }
 
-/// Returned when text that should not have any `\0` bytes in it does.
-/// Analogous to [`std::ffi::NulError`], but the data it was created from
-/// is borrowed.
-#[derive(Debug, Error)]
-#[error("null byte within input at byte {0}")]
-pub struct NulError(usize);
-
-#[repr(transparent)]
-pub struct TextResponseInner(ResponseInner);
+#[repr(C)]
+pub struct TextResponseInner {
+    data: *mut c_char,
+    _unused: c_int,
+}
 
 impl TextResponseInner {
     /// Allocates a new text response on the C heap.
@@ -91,7 +89,7 @@
     /// on the pointer you get back when you're done with it.
     pub fn alloc(text: impl AsRef<str>) -> Result<*mut Self, NulError> {
         let str_data = Self::malloc_str(text)?;
-        let inner = ResponseInner::alloc(str_data);
+        let inner = GenericResponse::alloc(str_data);
         Ok(inner as *mut Self)
     }
 
@@ -100,7 +98,7 @@
         // SAFETY: This data is either passed from PAM (so we are forced to
         // trust it) or was created by us in TextResponseInner::alloc.
         // In either case, it's going to be a valid null-terminated string.
-        unsafe { CStr::from_ptr(self.0.data as *const c_char) }
+        unsafe { CStr::from_ptr(self.data) }
     }
 
     /// Releases memory owned by this response.
@@ -109,7 +107,14 @@
     ///
     /// You are responsible for no longer using this after calling free.
     pub unsafe fn free(me: *mut Self) {
-        ResponseInner::free(me as *mut ResponseInner)
+        if !me.is_null() {
+            let data = (*me).data;
+            if !data.is_null() {
+                libc::memset(data as *mut c_void, 0, libc::strlen(data));
+            }
+            libc::free(data as *mut c_void);
+        }
+        libc::free(me as *mut c_void);
     }
 
     /// Allocates a string with the given contents on the C heap.
@@ -118,7 +123,7 @@
     ///
     /// - it allocates data on the C heap with [`libc::malloc`].
     /// - it doesn't take ownership of the data passed in.
-    fn malloc_str(text: impl AsRef<str>) -> Result<*const c_void, NulError> {
+    fn malloc_str(text: impl AsRef<str>) -> Result<*mut c_void, NulError> {
         let data = text.as_ref().as_bytes();
         if let Some(nul) = data.iter().position(|x| *x == 0) {
             return Err(NulError(nul));
@@ -126,14 +131,17 @@
         unsafe {
             let data_alloc = libc::calloc(data.len() + 1, 1);
             libc::memcpy(data_alloc, data.as_ptr() as *const c_void, data.len());
-            Ok(data_alloc as *const c_void)
+            Ok(data_alloc)
         }
     }
 }
 
-/// A [`ResponseInner`] with [`BinaryData`] in it.
-#[repr(transparent)]
-pub struct BinaryResponseInner(ResponseInner);
+/// A [`GenericResponse`] with [`BinaryData`] in it.
+#[repr(C)]
+pub struct BinaryResponseInner {
+    data: *mut BinaryData,
+    _unused: c_int,
+}
 
 impl BinaryResponseInner {
     /// Allocates a new binary response on the C heap.
@@ -144,17 +152,15 @@
     /// The referenced data is copied to the C heap. We do not take ownership.
     /// You are responsible for calling [`free`](Self::free)
     /// on the pointer you get back when you're done with it.
-    pub fn alloc(data: impl AsRef<[u8]>, data_type: u8) -> Result<*mut Self, TryFromIntError> {
+    pub fn alloc(data: &[u8], data_type: u8) -> Result<*mut Self, TooBigError> {
         let bin_data = BinaryData::alloc(data, data_type)?;
-        let inner = ResponseInner::alloc(bin_data as *const c_void);
+        let inner = GenericResponse::alloc(bin_data as *mut c_void);
         Ok(inner as *mut Self)
     }
 
     /// Gets the binary data in this response.
     pub fn contents(&self) -> &[u8] {
-        let data = self.data();
-        let length = (u32::from_be_bytes(data.total_length) - 5) as usize;
-        unsafe { slice::from_raw_parts(data.data.as_ptr(), length) }
+        self.data().contents()
     }
 
     /// Gets the `data_type` tag that was embedded with the message.
@@ -167,7 +173,7 @@
         // SAFETY: This was either something we got from PAM (in which case
         // we trust it), or something that was created with
         // BinaryResponseInner::alloc. In both cases, it points to valid data.
-        unsafe { &*(self.0.data as *const BinaryData) }
+        unsafe { &*(self.data) }
     }
 
     /// Releases memory owned by this response.
@@ -176,38 +182,9 @@
     ///
     /// You are responsible for not using this after calling free.
     pub unsafe fn free(me: *mut Self) {
-        ResponseInner::free(me as *mut ResponseInner)
-    }
-}
-
-#[repr(C)]
-pub struct ResponseInner {
-    /// Pointer to the data returned in a response.
-    /// For most responses, this will be a [`CStr`], but for responses to
-    /// [`MessageStyle::BinaryPrompt`]s, this will be [`BinaryData`]
-    data: *const c_void,
-    /// Unused.
-    return_code: c_int,
-}
-
-impl ResponseInner {
-    /// Allocates a response on the C heap pointing to the given data.
-    fn alloc(data: *const c_void) -> *mut Self {
-        unsafe {
-            let alloc = libc::calloc(1, size_of::<ResponseInner>()) as *mut ResponseInner;
-            (*alloc).data = data;
-            alloc
+        if !me.is_null() {
+            BinaryData::free((*me).data);
         }
-    }
-
-    /// Frees one of these that was created with [`Self::alloc`]
-    /// (or provided by PAM).
-    ///
-    /// # Safety
-    ///
-    /// It's up to you to stop using `me` after calling this.
-    unsafe fn free(me: *mut Self) {
-        libc::free((*me).data as *mut c_void);
         libc::free(me as *mut c_void)
     }
 }
@@ -216,6 +193,8 @@
 ///
 /// This is an unsized data type whose memory goes beyond its data.
 /// This must be allocated on the C heap.
+///
+/// A Linux-PAM extension.
 #[repr(C)]
 struct BinaryData {
     /// The total length of the structure; a u32 in network byte order (BE).
@@ -228,12 +207,12 @@
 }
 
 impl BinaryData {
-    fn alloc(
-        source: impl AsRef<[u8]>,
-        data_type: u8,
-    ) -> Result<*const BinaryData, TryFromIntError> {
-        let source = source.as_ref();
-        let buffer_size = u32::try_from(source.len() + 5)?;
+    /// Copies the given data to a new BinaryData on the heap.
+    fn alloc(source: &[u8], data_type: u8) -> Result<*mut BinaryData, TooBigError> {
+        let buffer_size = u32::try_from(source.len() + 5).map_err(|_| TooBigError {
+            max: (u32::MAX - 5) as usize,
+            actual: source.len(),
+        })?;
         let data = unsafe {
             let dest_buffer = libc::malloc(buffer_size as usize) as *mut BinaryData;
             let data = &mut *dest_buffer;
@@ -249,6 +228,69 @@
         };
         Ok(data)
     }
+
+    fn length(&self) -> usize {
+        u32::from_be_bytes(self.total_length).saturating_sub(5) as usize
+    }
+
+    fn contents(&self) -> &[u8] {
+        unsafe { slice::from_raw_parts(self.data.as_ptr(), self.length()) }
+    }
+
+    /// Clears this data and frees it.
+    fn free(me: *mut Self) {
+        if me.is_null() {
+            return;
+        }
+        unsafe {
+            let me_too = &mut *me;
+            let contents = slice::from_raw_parts_mut(me_too.data.as_mut_ptr(), me_too.length());
+            for v in contents {
+                *v = 0
+            }
+            me_too.data_type = 0;
+            me_too.total_length = [0; 4];
+            libc::free(me as *mut c_void);
+        }
+    }
+}
+
+/// Generic version of response data.
+///
+/// This has the same structure as [`BinaryResponseInner`]
+/// and [`TextResponseInner`].
+#[repr(C)]
+pub struct GenericResponse {
+    /// Pointer to the data returned in a response.
+    /// For most responses, this will be a [`CStr`], but for responses to
+    /// [`MessageStyle::BinaryPrompt`]s, this will be [`BinaryData`]
+    /// (a Linux-PAM extension).
+    data: *mut c_void,
+    /// Unused.
+    return_code: c_int,
+}
+
+impl GenericResponse {
+    /// Allocates a response on the C heap pointing to the given data.
+    fn alloc(data: *mut c_void) -> *mut Self {
+        unsafe {
+            let alloc = libc::calloc(1, size_of::<Self>()) as *mut Self;
+            (*alloc).data = data;
+            alloc
+        }
+    }
+
+    /// Frees a response on the C heap.
+    ///
+    /// # Safety
+    ///
+    /// It's on you to stop using this GenericResponse after freeing it.
+    pub unsafe fn free(me: *mut GenericResponse) {
+        if !me.is_null() {
+            libc::free((*me).data);
+        }
+        libc::free(me as *mut c_void);
+    }
 }
 
 /// An opaque pointer we provide to PAM for callbacks.
@@ -261,14 +303,47 @@
 /// The callback that PAM uses to get information in a conversation.
 ///
 /// - `num_msg` is the number of messages in the `pam_message` array.
-/// - `messages` is a pointer to an array of pointers to [`Message`]s.
-/// - `responses` is a pointer to an array of [`ResponseInner`]s,
+/// - `messages` is a pointer to some [`Message`]s (see note).
+/// - `responses` is a pointer to an array of [`GenericResponse`]s,
 ///   which PAM sets in response to a module's request.
+///   This is an array of structs, not an array of pointers to a struct.
+///   There should always be exactly as many `responses` as `num_msg`.
 /// - `appdata` is the `appdata` field of the [`Conversation`] we were passed.
+///
+/// NOTE: On Linux-PAM and other compatible implementations, `messages`
+/// is treated as a pointer-to-pointers, like `int argc, char **argv`.
+///
+/// ```text
+/// ┌──────────┐  points to  ┌─────────────┐       ╔═ Message ═╗
+/// │ messages │ ┄┄┄┄┄┄┄┄┄┄> │ messages[0] │ ┄┄┄┄> ║ style     ║
+/// └──────────┘             │ messages[1] │ ┄┄╮   ║ data      ║
+///                          │ ...         │   ┆   ╚═══════════╝
+///                                            ┆
+///                                            ┆    ╔═ Message ═╗
+///                                            ╰┄┄> ║ style     ║
+///                                                 ║ data      ║
+///                                                 ╚═══════════╝
+/// ```
+///
+/// On OpenPAM and other compatible implementations (like Solaris),
+/// `messages` is a pointer-to-pointer-to-array.
+///
+/// ```text
+/// ┌──────────┐  points to  ┌───────────┐       ╔═ Message[] ═╗
+/// │ messages │ ┄┄┄┄┄┄┄┄┄┄> │ *messages │ ┄┄┄┄> ║ style       ║
+/// └──────────┘             └───────────┘       ║ data        ║
+///                                              ╟─────────────╢
+///                                              ║ style       ║
+///                                              ║ data        ║
+///                                              ╟─────────────╢
+///                                              ║ ...         ║
+/// ```
+///
+/// ***THIS LIBRARY CURRENTLY SUPPORTS ONLY LINUX-PAM.***
 pub type ConversationCallback = extern "C" fn(
     num_msg: c_int,
     messages: *const *const Message,
-    responses: &mut *const ResponseInner,
+    responses: &mut *const GenericResponse,
     appdata: *const AppData,
 ) -> c_int;
 
@@ -316,7 +391,7 @@
 
 #[cfg(test)]
 mod test {
-    use super::{BinaryResponseInner, TextResponseInner};
+    use super::{BinaryResponseInner, GenericResponse, TextResponseInner};
 
     #[test]
     fn test_text_response() {
@@ -342,6 +417,14 @@
     }
 
     #[test]
+    fn test_free_safety() {
+        unsafe {
+            TextResponseInner::free(std::ptr::null_mut());
+            BinaryResponseInner::free(std::ptr::null_mut());
+        }
+    }
+
+    #[test]
     #[ignore]
     fn test_binary_response_too_big() {
         let big_data: Vec<u8> = vec![0xFFu8; 10_000_000_000];