Skip to main content

security/keychain/
mod.rs

1//! Generic-password keychain wrappers built on top of `SecItem*`.
2
3use crate::error::{Result, SecurityError};
4use crate::ffi;
5use crate::private::{
6    cf_data, cf_data_to_vec, cf_dictionary_get_value, cf_dictionary_set_value,
7    cf_mutable_dictionary, cf_string, cf_string_to_string, sec_error_message, OwnedCf,
8};
9
10fn generic_password_query(account: Option<&str>, service: &str) -> Result<OwnedCf> {
11    let dictionary = cf_mutable_dictionary(3)?;
12    let service = cf_string(service)?;
13    unsafe {
14        cf_dictionary_set_value(
15            dictionary.as_mut_dictionary(),
16            ffi::kSecClass,
17            ffi::kSecClassGenericPassword.cast(),
18        );
19        cf_dictionary_set_value(
20            dictionary.as_mut_dictionary(),
21            ffi::kSecAttrService,
22            service.as_ptr(),
23        );
24    }
25
26    if let Some(account) = account {
27        let account = cf_string(account)?;
28        unsafe {
29            cf_dictionary_set_value(
30                dictionary.as_mut_dictionary(),
31                ffi::kSecAttrAccount,
32                account.as_ptr(),
33            );
34        }
35    }
36
37    Ok(dictionary)
38}
39
40/// Typed generic-password keychain entry identified by `(account, service)`.
41#[derive(Debug, Clone, PartialEq, Eq, Hash)]
42pub struct KeychainEntry {
43    account: String,
44    service: String,
45}
46
47impl KeychainEntry {
48    /// Create a typed generic-password keychain entry.
49    #[must_use]
50    pub fn new(account: impl Into<String>, service: impl Into<String>) -> Self {
51        Self {
52            account: account.into(),
53            service: service.into(),
54        }
55    }
56
57    /// Account name stored in the keychain item.
58    #[must_use]
59    pub fn account(&self) -> &str {
60        &self.account
61    }
62
63    /// Service name stored in the keychain item.
64    #[must_use]
65    pub fn service(&self) -> &str {
66        &self.service
67    }
68
69    /// Upsert the password for this keychain entry.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if Security.framework rejects the item or the strings contain NUL bytes.
74    pub fn set(&self, password: &str) -> Result<()> {
75        Keychain::set(&self.account, &self.service, password)
76    }
77
78    /// Fetch the password for this keychain entry.
79    ///
80    /// # Errors
81    ///
82    /// Returns an error if the item does not exist or Security.framework rejects the query.
83    pub fn get(&self) -> Result<String> {
84        Keychain::get(&self.account, &self.service)
85    }
86
87    /// Delete this keychain entry.
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if Security.framework rejects the delete request.
92    pub fn delete(&self) -> Result<()> {
93        Keychain::delete(&self.account, &self.service)
94    }
95}
96
97/// Stateless entry point for generic-password keychain operations.
98pub struct Keychain;
99
100impl Keychain {
101    /// Build a typed keychain entry for `(account, service)`.
102    #[must_use]
103    pub fn entry(account: impl Into<String>, service: impl Into<String>) -> KeychainEntry {
104        KeychainEntry::new(account, service)
105    }
106
107    /// Upsert a generic-password keychain item.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if Security.framework rejects the item or the strings contain NUL bytes.
112    pub fn set(account: &str, service: &str, password: &str) -> Result<()> {
113        let search_query = generic_password_query(Some(account), service)?;
114        let add_query = generic_password_query(Some(account), service)?;
115        let password_data = cf_data(password.as_bytes())?;
116        unsafe {
117            cf_dictionary_set_value(
118                add_query.as_mut_dictionary(),
119                ffi::kSecValueData,
120                password_data.as_ptr(),
121            );
122        }
123
124        let status = unsafe { ffi::SecItemAdd(add_query.as_dictionary(), std::ptr::null_mut()) };
125        match status {
126            ffi::status::SUCCESS => Ok(()),
127            ffi::status::DUPLICATE_ITEM => {
128                let attributes = cf_mutable_dictionary(1)?;
129                unsafe {
130                    cf_dictionary_set_value(
131                        attributes.as_mut_dictionary(),
132                        ffi::kSecValueData,
133                        password_data.as_ptr(),
134                    );
135                }
136                let status = unsafe {
137                    ffi::SecItemUpdate(search_query.as_dictionary(), attributes.as_dictionary())
138                };
139                if status == ffi::status::SUCCESS {
140                    Ok(())
141                } else {
142                    Err(SecurityError::from_status(
143                        "SecItemUpdate",
144                        status,
145                        sec_error_message(status),
146                    ))
147                }
148            }
149            _ => Err(SecurityError::from_status(
150                "SecItemAdd",
151                status,
152                sec_error_message(status),
153            )),
154        }
155    }
156
157    /// Fetch a generic-password keychain item as UTF-8 text.
158    ///
159    /// # Errors
160    ///
161    /// Returns an error if the item does not exist, the stored bytes are not UTF-8, or Security.framework rejects the query.
162    pub fn get(account: &str, service: &str) -> Result<String> {
163        let query = generic_password_query(Some(account), service)?;
164        unsafe {
165            cf_dictionary_set_value(
166                query.as_mut_dictionary(),
167                ffi::kSecReturnData,
168                ffi::kCFBooleanTrue.cast(),
169            );
170            cf_dictionary_set_value(
171                query.as_mut_dictionary(),
172                ffi::kSecMatchLimit,
173                ffi::kSecMatchLimitOne.cast(),
174            );
175        }
176
177        let mut result = std::ptr::null();
178        let status = unsafe { ffi::SecItemCopyMatching(query.as_dictionary(), &mut result) };
179        if status != ffi::status::SUCCESS {
180            let context = format!(
181                "generic password {account:?} @ {service:?}: {}",
182                sec_error_message(status)
183            );
184            return Err(SecurityError::from_status(
185                "SecItemCopyMatching",
186                status,
187                context,
188            ));
189        }
190
191        let data = OwnedCf::new(result);
192        if crate::private::cf_type_id(data.as_ptr()) != unsafe { ffi::CFDataGetTypeID() } {
193            return Err(SecurityError::UnexpectedType {
194                operation: "SecItemCopyMatching",
195                expected: "CFData",
196            });
197        }
198
199        String::from_utf8(cf_data_to_vec(data.as_data())).map_err(|error| {
200            SecurityError::InvalidArgument(format!("keychain password is not valid UTF-8: {error}"))
201        })
202    }
203
204    /// Delete a generic-password keychain item.
205    ///
206    /// Missing items are treated as success to make cleanup ergonomic.
207    ///
208    /// # Errors
209    ///
210    /// Returns an error if Security.framework rejects the delete request for another reason.
211    pub fn delete(account: &str, service: &str) -> Result<()> {
212        let query = generic_password_query(Some(account), service)?;
213        let status = unsafe { ffi::SecItemDelete(query.as_dictionary()) };
214        match status {
215            ffi::status::SUCCESS | ffi::status::ITEM_NOT_FOUND => Ok(()),
216            _ => Err(SecurityError::from_status(
217                "SecItemDelete",
218                status,
219                sec_error_message(status),
220            )),
221        }
222    }
223
224    /// List all account names for the given generic-password service.
225    ///
226    /// # Errors
227    ///
228    /// Returns an error if Security.framework rejects the query.
229    pub fn list_accounts(service: &str) -> Result<Vec<String>> {
230        let query = generic_password_query(None, service)?;
231        unsafe {
232            cf_dictionary_set_value(
233                query.as_mut_dictionary(),
234                ffi::kSecReturnAttributes,
235                ffi::kCFBooleanTrue.cast(),
236            );
237            cf_dictionary_set_value(
238                query.as_mut_dictionary(),
239                ffi::kSecMatchLimit,
240                ffi::kSecMatchLimitAll.cast(),
241            );
242        }
243
244        let mut result = std::ptr::null();
245        let status = unsafe { ffi::SecItemCopyMatching(query.as_dictionary(), &mut result) };
246        if status == ffi::status::ITEM_NOT_FOUND {
247            return Ok(Vec::new());
248        }
249        if status != ffi::status::SUCCESS {
250            return Err(SecurityError::from_status(
251                "SecItemCopyMatching",
252                status,
253                sec_error_message(status),
254            ));
255        }
256
257        let result = OwnedCf::new(result);
258        let dictionary_type = unsafe { ffi::CFDictionaryGetTypeID() };
259        let array_type = unsafe { ffi::CFArrayGetTypeID() };
260        let result_type = crate::private::cf_type_id(result.as_ptr());
261        let mut accounts = Vec::new();
262
263        if result_type == dictionary_type {
264            if let Some(account) = account_from_attributes(result.as_dictionary()) {
265                accounts.push(account);
266            }
267        } else if result_type == array_type {
268            let count = unsafe { ffi::CFArrayGetCount(result.as_array()) };
269            let count = usize::try_from(count).unwrap_or_default();
270            for index in 0..count {
271                let Ok(index) = isize::try_from(index) else {
272                    continue;
273                };
274                let value = unsafe { ffi::CFArrayGetValueAtIndex(result.as_array(), index) };
275                if value.is_null() {
276                    continue;
277                }
278                if let Some(account) = account_from_attributes(value.cast()) {
279                    accounts.push(account);
280                }
281            }
282        } else {
283            return Err(SecurityError::UnexpectedType {
284                operation: "SecItemCopyMatching",
285                expected: "CFDictionary or CFArray",
286            });
287        }
288
289        accounts.sort();
290        accounts.dedup();
291        Ok(accounts)
292    }
293}
294
295fn account_from_attributes(dictionary: ffi::CFDictionaryRef) -> Option<String> {
296    let value = cf_dictionary_get_value(dictionary, unsafe { ffi::kSecAttrAccount });
297    if value.is_null() || crate::private::cf_type_id(value) != unsafe { ffi::CFStringGetTypeID() } {
298        return None;
299    }
300    cf_string_to_string(value.cast())
301}