1use 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
11pub struct KeychainFile {
13 path: Option<PathBuf>,
14 keychain: Keychain,
15 schema: Schema,
16 keys: Option<DbKeys>,
18 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 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 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 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 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 pub fn item_key_count(&self) -> usize {
116 self.item_keys.len()
117 }
118
119 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 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 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 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 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 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 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 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
243fn 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
277pub const FOUR_CHAR_CODE_ATTRIBUTES: [&str; 3] = ["ptcl", "crtr", "type"];
279
280#[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 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 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 pub fn attributes(&self) -> Vec<(&'kc str, &'kc Value)> {
374 self.schema.named_attributes(self.record_type, self.record)
375 }
376
377 pub fn has_secret(&self) -> bool {
379 Ssgp::parse(&self.record.key_data).is_ok()
380 }
381}
382
383#[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 pub description: Option<String>,
398 pub comment: Option<String>,
400 pub security_domain: Option<String>,
402 pub generic: Option<String>,
404 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#[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}