Skip to main content

kc/
db.rs

1//! Opening, unlocking, and querying a keychain database.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use crate::crypto::{self, DbBlob, DbKeys, KeyBlob, SSGP_MAGIC, SecretBytes, Ssgp};
7use crate::error::{Error, Result};
8use crate::format::{Keychain, Record, Value, trim_nul};
9use crate::schema::{RecordType, Schema};
10
11/// A keychain database, optionally unlocked.
12pub struct KeychainFile {
13    path: Option<PathBuf>,
14    keychain: Keychain,
15    schema: Schema,
16    /// Present once a password has been accepted.
17    keys: Option<DbKeys>,
18    /// Item keys by their 20-byte `ssgp` label, populated on unlock.
19    item_keys: BTreeMap<[u8; 20], SecretBytes>,
20}
21
22impl KeychainFile {
23    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
24        let path = path.as_ref().to_path_buf();
25        let bytes = std::fs::read(&path).map_err(|source| Error::reading(&path, source))?;
26        let mut file = Self::from_bytes(&bytes)?;
27        file.path = Some(path);
28        Ok(file)
29    }
30
31    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
32        let keychain = Keychain::parse(bytes)?;
33        let schema = keychain.schema()?;
34        Ok(Self {
35            path: None,
36            keychain,
37            schema,
38            keys: None,
39            item_keys: BTreeMap::new(),
40        })
41    }
42
43    pub fn path(&self) -> Option<&Path> {
44        self.path.as_deref()
45    }
46
47    pub fn keychain(&self) -> &Keychain {
48        &self.keychain
49    }
50
51    pub fn schema(&self) -> &Schema {
52        &self.schema
53    }
54
55    pub fn is_unlocked(&self) -> bool {
56        self.keys.is_some()
57    }
58
59    /// The database blob, which holds the salt and the encrypted database keys.
60    pub fn db_blob(&self) -> Result<DbBlob> {
61        let table = self
62            .keychain
63            .table(RecordType::METADATA)
64            .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_METADATA"))?;
65        let record = table
66            .records()
67            .next()
68            .ok_or_else(|| Error::format("metadata table has no record"))?;
69        DbBlob::parse(&record.key_data)
70    }
71
72    /// Derive the database keys from a password and unwrap every item key.
73    pub fn unlock(&mut self, password: &[u8]) -> Result<()> {
74        let keys = self.db_blob()?.unlock(password)?;
75        self.item_keys = self.unwrap_item_keys(&keys)?;
76        self.keys = Some(keys);
77        Ok(())
78    }
79
80    /// Item keys, by label. A key whose blob will not unwrap is skipped rather
81    /// than failing the unlock: one damaged key should not hide every item.
82    fn unwrap_item_keys(&self, keys: &DbKeys) -> Result<BTreeMap<[u8; 20], SecretBytes>> {
83        let mut out = BTreeMap::new();
84        let Some(table) = self.keychain.table(RecordType::SYMMETRIC_KEY) else {
85            return Ok(out);
86        };
87
88        for record in table.records() {
89            let Some(label) = self.key_label(record) else {
90                continue;
91            };
92            let Ok(blob) = KeyBlob::parse(&record.key_data) else {
93                continue;
94            };
95            if let Ok(key) = blob.unwrap_key(keys.encryption_key.as_slice()) {
96                out.insert(label, key);
97            }
98        }
99        Ok(out)
100    }
101
102    /// The `Label` of a key record, when it is a secure-storage group label.
103    fn key_label(&self, record: &Record) -> Option<[u8; 20]> {
104        let value = self
105            .schema
106            .attribute(RecordType::SYMMETRIC_KEY, record, "Label")?;
107        let bytes = value.as_bytes()?;
108        if bytes.len() != 20 || &bytes[..4] != SSGP_MAGIC {
109            return None;
110        }
111        Some(bytes.try_into().expect("checked length"))
112    }
113
114    /// Number of item keys recovered by [`Self::unlock`].
115    pub fn item_key_count(&self) -> usize {
116        self.item_keys.len()
117    }
118
119    /// Every password item in the keychain, in table order.
120    pub fn items(&self) -> Vec<Item<'_>> {
121        let mut items = Vec::new();
122        for record_type in [
123            RecordType::GENERIC_PASSWORD,
124            RecordType::INTERNET_PASSWORD,
125            RecordType::APPLESHARE_PASSWORD,
126        ] {
127            items.extend(self.items_of_type(record_type));
128        }
129        items
130    }
131
132    pub fn items_of_type(&self, record_type: RecordType) -> Vec<Item<'_>> {
133        let Some(table) = self.keychain.table(record_type) else {
134            return Vec::new();
135        };
136        table
137            .records()
138            .map(|record| Item {
139                record_type,
140                record,
141                schema: &self.schema,
142            })
143            .collect()
144    }
145
146    /// Records of any relation, for `kc show --all`.
147    pub fn records_of_type(&self, record_type: RecordType) -> Vec<&Record> {
148        self.keychain
149            .table(record_type)
150            .map(|table| table.records().collect())
151            .unwrap_or_default()
152    }
153
154    /// Decrypt an item's secret. Requires an unlocked keychain.
155    pub fn secret(&self, item: &Item<'_>) -> Result<SecretBytes> {
156        if self.keys.is_none() {
157            return Err(Error::Locked);
158        }
159        let ssgp = Ssgp::parse(&item.record.key_data)?;
160        let key = self
161            .item_keys
162            .get(&ssgp.label)
163            .ok_or_else(|| Error::MissingItemKey {
164                label: ssgp.label_hex(),
165            })?;
166        ssgp.open(key.as_slice())
167    }
168
169    /// Items matching every supplied criterion.
170    pub fn find(&self, query: &Query) -> Vec<Item<'_>> {
171        let candidates = match query.record_type {
172            Some(record_type) => self.items_of_type(record_type),
173            None => self.items(),
174        };
175        candidates
176            .into_iter()
177            .filter(|item| query.matches(item))
178            .collect()
179    }
180
181    /// Exactly one match, or an error naming the ambiguity.
182    pub fn find_one(&self, query: &Query) -> Result<Item<'_>> {
183        let mut matches = self.find(query);
184        match matches.len() {
185            0 => Err(Error::NoSuchItem),
186            1 => Ok(matches.remove(0)),
187            _ => Err(Error::other(format!(
188                "{} items match; narrow the query (accounts: {})",
189                matches.len(),
190                matches
191                    .iter()
192                    .map(|item| item.account().unwrap_or_else(|| "?".into()))
193                    .collect::<Vec<_>>()
194                    .join(", ")
195            ))),
196        }
197    }
198
199    /// Write the database back out.
200    pub fn save(&self, path: impl AsRef<Path>) -> Result<()> {
201        let path = path.as_ref();
202        let bytes = self.keychain.to_bytes()?;
203        write_file(path, &bytes)
204    }
205
206    /// Write back to the path this was opened from.
207    pub fn save_in_place(&self) -> Result<()> {
208        let path = self
209            .path
210            .as_ref()
211            .ok_or_else(|| Error::other("this keychain was not opened from a file"))?;
212        self.save(path)
213    }
214
215    pub(crate) fn keychain_mut(&mut self) -> &mut Keychain {
216        &mut self.keychain
217    }
218
219    /// Re-derive the cached schema from the schema tables.
220    ///
221    /// Needed after adding a relation: every attribute lookup goes through the
222    /// cached [`Schema`], so it has to learn the new relation before records of
223    /// that type can be written or read.
224    pub(crate) fn reload_schema(&mut self) -> Result<()> {
225        self.schema = self.keychain.schema()?;
226        Ok(())
227    }
228
229    pub(crate) fn keys(&self) -> Option<&DbKeys> {
230        self.keys.as_ref()
231    }
232
233    #[allow(dead_code)]
234    pub(crate) fn set_path(&mut self, path: PathBuf) {
235        self.path = Some(path);
236    }
237
238    pub(crate) fn remember_item_key(&mut self, label: [u8; 20], key: SecretBytes) {
239        self.item_keys.insert(label, key);
240    }
241}
242
243/// Write atomically, keeping an existing file's permissions and defaulting new
244/// files to owner-only: a keychain file is worth a brute-force attempt.
245fn write_file(path: &Path, bytes: &[u8]) -> Result<()> {
246    use std::io::Write as _;
247    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
248
249    let mode = std::fs::metadata(path)
250        .map(|meta| meta.permissions().mode() & 0o777)
251        .unwrap_or(0o600);
252    let temp = path.with_extension(format!("kc-tmp.{}", std::process::id()));
253
254    let mut file = std::fs::OpenOptions::new()
255        .write(true)
256        .create(true)
257        .truncate(true)
258        .mode(mode)
259        .open(&temp)
260        .map_err(|source| Error::io(format!("could not create {}", temp.display()), source))?;
261    let result = file
262        .write_all(bytes)
263        .and_then(|()| file.sync_all())
264        .map_err(|source| Error::io(format!("could not write {}", temp.display()), source));
265    drop(file);
266
267    if let Err(error) = result {
268        let _ = std::fs::remove_file(&temp);
269        return Err(error);
270    }
271    std::fs::rename(&temp, path).map_err(|source| {
272        let _ = std::fs::remove_file(&temp);
273        Error::io(format!("could not replace {}", path.display()), source)
274    })
275}
276
277/// Attributes that hold a four-character code in an integer field.
278pub const FOUR_CHAR_CODE_ATTRIBUTES: [&str; 3] = ["ptcl", "crtr", "type"];
279
280/// A password item: a record plus the schema needed to read it.
281#[derive(Clone, Copy)]
282pub struct Item<'kc> {
283    pub record_type: RecordType,
284    pub record: &'kc Record,
285    schema: &'kc Schema,
286}
287
288impl<'kc> Item<'kc> {
289    pub fn number(&self) -> u32 {
290        self.record.number
291    }
292
293    pub fn attribute(&self, name: &str) -> Option<&'kc Value> {
294        self.schema.attribute(self.record_type, self.record, name)
295    }
296
297    /// An attribute's bytes as a string, NUL-trimmed.
298    pub fn text(&self, name: &str) -> Option<String> {
299        let bytes = self.attribute(name)?.as_bytes()?;
300        let trimmed = trim_nul(bytes);
301        if trimmed.is_empty() {
302            return None;
303        }
304        Some(String::from_utf8_lossy(trimmed).into_owned())
305    }
306
307    pub fn number_attribute(&self, name: &str) -> Option<u32> {
308        self.attribute(name)?.as_u32()
309    }
310
311    pub fn account(&self) -> Option<String> {
312        self.text("acct")
313    }
314
315    pub fn service(&self) -> Option<String> {
316        self.text("svce")
317    }
318
319    pub fn server(&self) -> Option<String> {
320        self.text("srvr")
321    }
322
323    pub fn label(&self) -> Option<String> {
324        self.text("PrintName")
325    }
326
327    pub fn path(&self) -> Option<String> {
328        self.text("path")
329    }
330
331    pub fn port(&self) -> Option<u32> {
332        self.number_attribute("port").filter(|port| *port != 0)
333    }
334
335    pub fn volume(&self) -> Option<String> {
336        self.text("vlme")
337    }
338
339    pub fn address(&self) -> Option<String> {
340        self.text("addr")
341    }
342
343    pub fn signature(&self) -> Option<String> {
344        self.text("ssig")
345    }
346
347    pub fn created(&self) -> Option<String> {
348        self.text("cdat")
349    }
350
351    pub fn modified(&self) -> Option<String> {
352        self.text("mdat")
353    }
354
355    /// An attribute rendered the way `kc` displays it.
356    ///
357    /// Four-char codes are stored in integer fields (`ptcl` holds `htps` as
358    /// 0x68747073); they read as text, so they are shown and matched as text.
359    pub fn display_attribute(&self, name: &str) -> Option<String> {
360        let value = self.attribute(name)?;
361        if FOUR_CHAR_CODE_ATTRIBUTES.contains(&name)
362            && let Some(number) = value.as_u32()
363        {
364            let bytes = number.to_be_bytes();
365            if bytes.iter().all(|byte| (0x20..0x7f).contains(byte)) {
366                return Some(String::from_utf8_lossy(&bytes).into_owned());
367            }
368        }
369        Some(value.to_display_string())
370    }
371
372    /// Every attribute that has a value, for `kc show`.
373    pub fn attributes(&self) -> Vec<(&'kc str, &'kc Value)> {
374        self.schema.named_attributes(self.record_type, self.record)
375    }
376
377    /// True when the record carries an encrypted secret.
378    pub fn has_secret(&self) -> bool {
379        Ssgp::parse(&self.record.key_data).is_ok()
380    }
381}
382
383/// Attribute-match criteria for `kc find <type>`.
384#[derive(Debug, Clone, Default)]
385pub struct Query {
386    pub record_type: Option<RecordType>,
387    pub account: Option<String>,
388    pub service: Option<String>,
389    pub server: Option<String>,
390    pub label: Option<String>,
391    pub path: Option<String>,
392    pub port: Option<u32>,
393    pub volume: Option<String>,
394    pub address: Option<String>,
395    pub signature: Option<String>,
396    /// `desc`, the "kind" Keychain Access shows.
397    pub description: Option<String>,
398    /// `icmt`, the comment.
399    pub comment: Option<String>,
400    /// `sdmn`, an internet item's authentication realm.
401    pub security_domain: Option<String>,
402    /// `gena`, a generic item's application-defined bytes.
403    pub generic: Option<String>,
404    /// Any other attribute, by the name the relation gives it, compared against
405    /// the value as [`Item::display_attribute`] renders it. This is the escape
406    /// hatch for attributes without a flag of their own.
407    pub attributes: Vec<(String, String)>,
408}
409
410impl Query {
411    pub fn generic() -> Self {
412        Self {
413            record_type: Some(RecordType::GENERIC_PASSWORD),
414            ..Self::default()
415        }
416    }
417
418    pub fn internet() -> Self {
419        Self {
420            record_type: Some(RecordType::INTERNET_PASSWORD),
421            ..Self::default()
422        }
423    }
424
425    pub fn appleshare() -> Self {
426        Self {
427            record_type: Some(RecordType::APPLESHARE_PASSWORD),
428            ..Self::default()
429        }
430    }
431
432    pub fn is_empty(&self) -> bool {
433        self.account.is_none()
434            && self.service.is_none()
435            && self.server.is_none()
436            && self.label.is_none()
437            && self.path.is_none()
438            && self.port.is_none()
439            && self.volume.is_none()
440            && self.address.is_none()
441            && self.signature.is_none()
442            && self.description.is_none()
443            && self.comment.is_none()
444            && self.security_domain.is_none()
445            && self.generic.is_none()
446            && self.attributes.is_empty()
447    }
448
449    fn matches(&self, item: &Item<'_>) -> bool {
450        let text_matches = |wanted: &Option<String>, actual: Option<String>| match wanted {
451            None => true,
452            Some(wanted) => actual.is_some_and(|actual| actual == *wanted),
453        };
454
455        text_matches(&self.account, item.account())
456            && text_matches(&self.service, item.service())
457            && text_matches(&self.server, item.server())
458            && text_matches(&self.label, item.label())
459            && text_matches(&self.path, item.path())
460            && text_matches(&self.volume, item.volume())
461            && text_matches(&self.address, item.address())
462            && text_matches(&self.signature, item.signature())
463            && text_matches(&self.description, item.text("desc"))
464            && text_matches(&self.comment, item.text("icmt"))
465            && text_matches(&self.security_domain, item.text("sdmn"))
466            && text_matches(&self.generic, item.text("gena"))
467            && match self.port {
468                None => true,
469                Some(port) => item.port() == Some(port),
470            }
471            && self.attributes.iter().all(|(name, wanted)| {
472                item.display_attribute(name)
473                    .is_some_and(|actual| actual == *wanted)
474            })
475    }
476}
477
478/// Summary for `kc info`.
479#[derive(Debug, Clone)]
480pub struct Info {
481    pub version: u32,
482    pub tables: Vec<(RecordType, usize)>,
483    pub blob_version: u32,
484    pub sequence: u32,
485    pub idle_timeout: u32,
486    pub lock_on_sleep: bool,
487    pub salt: String,
488    pub iv: String,
489    pub pbkdf2_iterations: u32,
490}
491
492impl KeychainFile {
493    pub fn info(&self) -> Result<Info> {
494        let blob = self.db_blob()?;
495        Ok(Info {
496            version: self.keychain.version,
497            tables: self
498                .keychain
499                .tables
500                .iter()
501                .map(|table| (table.record_type, table.record_count()))
502                .collect(),
503            blob_version: blob.version,
504            sequence: blob.sequence,
505            idle_timeout: blob.idle_timeout,
506            lock_on_sleep: blob.lock_on_sleep,
507            salt: hex::encode(blob.salt),
508            iv: hex::encode(blob.iv),
509            pbkdf2_iterations: crypto::PBKDF2_ITERATIONS,
510        })
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517
518    #[test]
519    fn queries_match_on_every_supplied_field() {
520        let mut query = Query::generic();
521        assert!(query.is_empty());
522        query.account = Some("alice".into());
523        assert!(!query.is_empty());
524        assert_eq!(query.record_type, Some(RecordType::GENERIC_PASSWORD));
525
526        let internet = Query::internet();
527        assert_eq!(internet.record_type, Some(RecordType::INTERNET_PASSWORD));
528    }
529
530    #[test]
531    fn parsing_rejects_garbage_before_any_crypto_happens() {
532        assert!(KeychainFile::from_bytes(b"not a keychain").is_err());
533    }
534}