Skip to main content

kc/
format.rs

1//! The `kych` container: file header, tables, records, attribute values.
2//!
3//! Layout per the
4//! [dtformats specification](https://github.com/libyal/dtformats/blob/main/documentation/MacOS%20keychain%20database%20file%20format.asciidoc),
5//! with the details it leaves open (`[yellow-background]*Unknown*` fields,
6//! padding, value encodings) pinned down against keychains written by macOS.
7//! Everything is big-endian.
8//!
9//! ```text
10//! file header (20 bytes)
11//! tables array: size, count, count x offset
12//!   table: header (28 bytes), slot array, records, index data
13//!     record: header (24 bytes), attribute offsets, key data, attribute values
14//! ```
15//!
16//! The model keeps every field, including the ones whose meaning is unknown and
17//! the opaque per-table index data, so that parsing and re-serializing a
18//! macOS-written keychain reproduces it byte for byte. That round trip is the
19//! test that this understanding of the layout is complete — see
20//! `tests/keychain_roundtrip.rs`.
21
22use std::collections::BTreeMap;
23
24use crate::error::{Error, Result};
25use crate::index::{IndexBlob, IndexValue};
26use crate::schema::{AttributeFormat, RecordType, Relation, Schema};
27
28pub const SIGNATURE: &[u8; 4] = b"kych";
29
30/// Combined major/minor version: major 1, minor 0.
31pub const VERSION: u32 = 0x0001_0000;
32
33/// Value of the header's size field, which is 4 less than the header itself.
34pub const HEADER_SIZE_FIELD: u32 = 16;
35
36pub const HEADER_LEN: usize = 20;
37pub const TABLE_HEADER_LEN: usize = 28;
38pub const RECORD_HEADER_LEN: usize = 24;
39
40/// Attribute offsets carry a set low bit; mask it off to get the real offset.
41const ATTRIBUTE_OFFSET_FLAG: u32 = 1;
42
43/// A record slot whose low bit is set is not a record offset: it is a link in
44/// the table's free list, which macOS uses to allocate record numbers. Reading
45/// one as an offset lands in the middle of a record — the login keychain has 231
46/// of them in its certificate table alone.
47const SLOT_FREE_FLAG: u32 = 1;
48
49/// A parsed keychain database.
50#[derive(Debug, Clone)]
51pub struct Keychain {
52    pub version: u32,
53    /// Header size field (`16` in every observed file).
54    pub header_size: u32,
55    /// Purpose unknown; `0` in every observed file.
56    pub auth_offset: u32,
57    /// Tables in file order. Order is preserved because the file's table offset
58    /// array is written in this order.
59    pub tables: Vec<Table>,
60    /// A `u32` that follows the tables array, outside its declared size. macOS
61    /// writes `1` for a fresh keychain and increments it once per committed
62    /// write, so it reads as a commit counter. Preserved, and bumped by
63    /// [`Self::bump_commit_version`] when this code modifies the database.
64    /// `None` for a file that has no such trailer.
65    pub commit_version: Option<u32>,
66}
67
68#[derive(Debug, Clone)]
69pub struct Table {
70    pub record_type: RecordType,
71    /// Purpose unknown, but it matters: macOS writes `0x1d` for a table that has
72    /// never held a record and `0` once one is stored. Leaving `0x1d` in place
73    /// after inserting a record makes the Security framework treat the first
74    /// slot as free and overwrite it.
75    pub unknown_free_list: u32,
76    /// Record slots in file order. A slot's index is the record number it holds.
77    pub slots: Vec<Slot>,
78    /// The table's index region.
79    pub indexes: TableIndexes,
80}
81
82/// One entry of a table's record-slot array.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub enum Slot {
85    /// A live record.
86    Record(Record),
87    /// A free-list link, preserved verbatim: its low bit is set, and the rest is
88    /// macOS's bookkeeping for reusing this record number.
89    Free(u32),
90    /// Never used, written as zero.
91    Empty,
92}
93
94impl Slot {
95    pub fn record(&self) -> Option<&Record> {
96        match self {
97            Self::Record(record) => Some(record),
98            _ => None,
99        }
100    }
101
102    pub fn record_mut(&mut self) -> Option<&mut Record> {
103        match self {
104            Self::Record(record) => Some(record),
105            _ => None,
106        }
107    }
108
109    pub fn is_empty(&self) -> bool {
110        matches!(self, Self::Empty)
111    }
112}
113
114/// A table's index region: understood, or preserved as-is.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub enum TableIndexes {
117    Parsed(IndexBlob),
118    /// Kept verbatim because it did not parse. Such a table must not be
119    /// modified: its offsets are relative to the table and would go stale.
120    Raw(Vec<u8>),
121}
122
123impl TableIndexes {
124    /// Serialize, with offsets measured from `table_offset` — the position of
125    /// the region within its table.
126    pub fn to_bytes(&self, table_offset: usize) -> Vec<u8> {
127        match self {
128            Self::Parsed(blob) => blob.to_bytes(table_offset),
129            Self::Raw(bytes) => bytes.clone(),
130        }
131    }
132
133    pub fn len(&self) -> usize {
134        match self {
135            Self::Parsed(blob) => blob.encoded_len(),
136            Self::Raw(bytes) => bytes.len(),
137        }
138    }
139
140    pub fn is_empty(&self) -> bool {
141        self.len() == 0
142    }
143
144    pub fn blob(&self) -> Option<&IndexBlob> {
145        match self {
146            Self::Parsed(blob) => Some(blob),
147            Self::Raw(_) => None,
148        }
149    }
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Record {
154    /// Record number, unique within the table.
155    pub number: u32,
156    /// The database's commit version at the moment the record was written.
157    /// macOS stamps both records of an added item with the same value, so the
158    /// highest record version in a file equals its
159    /// [`Keychain::commit_version`].
160    pub version: u32,
161    /// Purpose unknown. `0` in every observed record except symmetric-key
162    /// records, where macOS writes `4`.
163    pub unknown3: u32,
164    /// Purpose unknown; `0` in every observed record.
165    pub unknown5: u32,
166    /// The record's data: an SSGP blob for password items, a key blob for key
167    /// records, a `DbBlob` for the metadata record.
168    pub key_data: Vec<u8>,
169    /// Attribute values, in the relation's schema order. `None` is an absent
170    /// value, written as a zero offset.
171    pub attributes: Vec<Option<Value>>,
172}
173
174/// An attribute value, in the format the schema declares for it.
175#[derive(Debug, Clone, PartialEq, Eq)]
176pub enum Value {
177    /// Length-prefixed, NUL-terminator optional.
178    String(Vec<u8>),
179    Sint32(i32),
180    Uint32(u32),
181    /// Fixed 16 bytes, `YYYYMMDDhhmmssZ` plus a NUL. Not length-prefixed.
182    Date(Vec<u8>),
183    /// Length-prefixed bytes. Also used for `BIG_NUM`, `MULTI_UINT32`,
184    /// `COMPLEX`, and `REAL`, which are not interpreted further.
185    Blob(Vec<u8>),
186}
187
188impl Value {
189    pub fn as_bytes(&self) -> Option<&[u8]> {
190        match self {
191            Self::String(bytes) | Self::Blob(bytes) | Self::Date(bytes) => Some(bytes),
192            _ => None,
193        }
194    }
195
196    pub fn as_u32(&self) -> Option<u32> {
197        match self {
198            Self::Uint32(value) => Some(*value),
199            Self::Sint32(value) => Some(*value as u32),
200            _ => None,
201        }
202    }
203
204    /// Printable rendering: text when the bytes are valid UTF-8, else hex.
205    pub fn to_display_string(&self) -> String {
206        match self {
207            Self::Sint32(value) => value.to_string(),
208            Self::Uint32(value) => value.to_string(),
209            Self::Date(bytes) | Self::String(bytes) | Self::Blob(bytes) => {
210                let trimmed = trim_nul(bytes);
211                match std::str::from_utf8(trimmed) {
212                    Ok(text) if trimmed.iter().all(|b| !b.is_ascii_control()) => text.to_string(),
213                    _ => format!("0x{}", hex::encode(trimmed)),
214                }
215            }
216        }
217    }
218
219    fn encoded_len(&self) -> usize {
220        match self {
221            Self::Sint32(_) | Self::Uint32(_) => 4,
222            Self::Date(_) => 16,
223            Self::String(bytes) | Self::Blob(bytes) => pad4(4 + bytes.len()),
224        }
225    }
226
227    fn write(&self, out: &mut Vec<u8>) {
228        match self {
229            Self::Sint32(value) => out.extend_from_slice(&value.to_be_bytes()),
230            Self::Uint32(value) => out.extend_from_slice(&value.to_be_bytes()),
231            Self::Date(bytes) => {
232                let mut field = [0u8; 16];
233                let take = bytes.len().min(16);
234                field[..take].copy_from_slice(&bytes[..take]);
235                out.extend_from_slice(&field);
236            }
237            Self::String(bytes) | Self::Blob(bytes) => {
238                out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
239                out.extend_from_slice(bytes);
240                out.resize(pad4(out.len()), 0);
241            }
242        }
243    }
244}
245
246pub fn trim_nul(bytes: &[u8]) -> &[u8] {
247    match bytes.iter().position(|b| *b == 0) {
248        Some(end) => &bytes[..end],
249        None => bytes,
250    }
251}
252
253fn pad4(len: usize) -> usize {
254    (len + 3) & !3
255}
256
257// ---------------------------------------------------------------------------
258// Reading
259// ---------------------------------------------------------------------------
260
261/// Bounds-checked big-endian reader over the file image.
262struct Cursor<'a> {
263    data: &'a [u8],
264}
265
266impl<'a> Cursor<'a> {
267    fn u32(&self, at: usize) -> Result<u32> {
268        let end = at.checked_add(4).ok_or_else(|| Error::truncated(at, 4))?;
269        let bytes = self
270            .data
271            .get(at..end)
272            .ok_or_else(|| Error::truncated(at, 4))?;
273        Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
274    }
275
276    fn bytes(&self, at: usize, len: usize) -> Result<&'a [u8]> {
277        let end = at
278            .checked_add(len)
279            .ok_or_else(|| Error::truncated(at, len))?;
280        self.data
281            .get(at..end)
282            .ok_or_else(|| Error::truncated(at, len))
283    }
284}
285
286impl Keychain {
287    /// Parse a keychain image. The schema is read from the file itself.
288    pub fn parse(data: &[u8]) -> Result<Self> {
289        let cursor = Cursor { data };
290
291        if data.len() < HEADER_LEN {
292            return Err(Error::truncated(0, HEADER_LEN));
293        }
294        if &data[..4] != SIGNATURE {
295            return Err(Error::NotAKeychain);
296        }
297
298        let version = cursor.u32(4)?;
299        let header_size = cursor.u32(8)?;
300        let tables_offset = cursor.u32(12)? as usize;
301        let auth_offset = cursor.u32(16)?;
302
303        // The declared tables-array size is not trusted for bounds: table
304        // offsets are read individually and each table is checked on its own.
305        // It does locate the commit-version trailer, which sits just past it.
306        let tables_size = cursor.u32(tables_offset)? as usize;
307        let commit_version = cursor.u32(tables_offset + tables_size).ok();
308
309        let table_count = cursor.u32(tables_offset + 4)? as usize;
310        let mut table_offsets = Vec::with_capacity(table_count);
311        for index in 0..table_count {
312            table_offsets.push(cursor.u32(tables_offset + 8 + index * 4)? as usize);
313        }
314
315        // The schema lives in the file's own tables, so read the schema tables
316        // first — with the built-in definitions for those four relations — and
317        // only then parse everything with the schema the file declares. Parsing
318        // an unknown relation would misplace its key data, so it is not attempted.
319        let bootstrap = Schema::bootstrap();
320        let mut schema_tables = Vec::new();
321        for offset in &table_offsets {
322            let at = tables_offset + offset;
323            let record_type = RecordType(cursor.u32(at + 4)?);
324            if bootstrap.relation(record_type).is_some() {
325                schema_tables.push(Table::parse(&cursor, at, &bootstrap)?);
326            }
327        }
328        let schema = Schema::from_tables(&schema_tables)?;
329
330        let mut tables = Vec::with_capacity(table_count);
331        for offset in &table_offsets {
332            tables.push(Table::parse(&cursor, tables_offset + offset, &schema)?);
333        }
334
335        Ok(Self {
336            version,
337            header_size,
338            auth_offset,
339            tables,
340            commit_version,
341        })
342    }
343
344    /// The schema this file declares.
345    pub fn schema(&self) -> Result<Schema> {
346        Schema::from_tables(&self.tables)
347    }
348
349    /// Record another committed write, the way macOS does.
350    pub fn bump_commit_version(&mut self) {
351        self.commit_version = Some(self.commit_version.unwrap_or(0) + 1);
352    }
353
354    pub fn table(&self, record_type: RecordType) -> Option<&Table> {
355        self.tables
356            .iter()
357            .find(|table| table.record_type == record_type)
358    }
359
360    pub fn table_mut(&mut self, record_type: RecordType) -> Option<&mut Table> {
361        self.tables
362            .iter_mut()
363            .find(|table| table.record_type == record_type)
364    }
365
366    /// Serialize back to a file image.
367    pub fn to_bytes(&self) -> Result<Vec<u8>> {
368        let mut tables = Vec::with_capacity(self.tables.len());
369        for table in &self.tables {
370            tables.push(table.to_bytes()?);
371        }
372
373        // Tables array: size, count, offsets, then the tables themselves.
374        let array_header = 8 + 4 * tables.len();
375        let mut offsets = Vec::with_capacity(tables.len());
376        let mut running = array_header;
377        for table in &tables {
378            offsets.push(running as u32);
379            running += table.len();
380        }
381
382        let mut out = Vec::with_capacity(HEADER_LEN + running);
383        out.extend_from_slice(SIGNATURE);
384        out.extend_from_slice(&self.version.to_be_bytes());
385        out.extend_from_slice(&self.header_size.to_be_bytes());
386        out.extend_from_slice(&(HEADER_LEN as u32).to_be_bytes());
387        out.extend_from_slice(&self.auth_offset.to_be_bytes());
388
389        out.extend_from_slice(&(running as u32).to_be_bytes());
390        out.extend_from_slice(&(tables.len() as u32).to_be_bytes());
391        for offset in offsets {
392            out.extend_from_slice(&offset.to_be_bytes());
393        }
394        for table in tables {
395            out.extend_from_slice(&table);
396        }
397        if let Some(commit_version) = self.commit_version {
398            out.extend_from_slice(&commit_version.to_be_bytes());
399        }
400        Ok(out)
401    }
402}
403
404impl Table {
405    fn parse(cursor: &Cursor<'_>, at: usize, schema: &Schema) -> Result<Self> {
406        let _size = cursor.u32(at)?;
407        let record_type = RecordType(cursor.u32(at + 4)?);
408        let _record_count = cursor.u32(at + 8)?;
409        let _records_offset = cursor.u32(at + 12)?;
410        let indexes_offset = cursor.u32(at + 16)? as usize;
411        let unknown_free_list = cursor.u32(at + 20)?;
412        let slot_count = cursor.u32(at + 24)? as usize;
413        if slot_count > 1 << 20 {
414            return Err(Error::format(format!(
415                "table 0x{:08x} claims {slot_count} record slots",
416                record_type.0
417            )));
418        }
419
420        let mut slots = Vec::with_capacity(slot_count);
421        for index in 0..slot_count {
422            let offset = cursor.u32(at + TABLE_HEADER_LEN + index * 4)?;
423            if offset == 0 {
424                slots.push(Slot::Empty);
425                continue;
426            }
427            if offset & SLOT_FREE_FLAG != 0 {
428                slots.push(Slot::Free(offset));
429                continue;
430            }
431            let offset = offset as usize;
432            if offset < TABLE_HEADER_LEN || offset >= indexes_offset {
433                return Err(Error::format(format!(
434                    "table 0x{:08x} slot {index} points to {offset}, outside its records",
435                    record_type.0
436                )));
437            }
438            slots.push(Slot::Record(Record::parse(
439                cursor,
440                at + offset,
441                record_type,
442                schema,
443            )?));
444        }
445
446        let size = _size as usize;
447        if indexes_offset > size {
448            return Err(Error::format(format!(
449                "table 0x{:08x} index offset {indexes_offset} is past its {size}-byte extent",
450                record_type.0
451            )));
452        }
453        let index_data = cursor.bytes(at + indexes_offset, size - indexes_offset)?;
454
455        // Parsing needs the relation, for the attribute formats the index keys
456        // are encoded in. A region that does not parse is kept verbatim.
457        let relation = schema.relation(record_type);
458        let indexes = match IndexBlob::parse(index_data, indexes_offset, relation) {
459            Ok(blob) if blob.to_bytes(indexes_offset) == index_data => TableIndexes::Parsed(blob),
460            _ => TableIndexes::Raw(index_data.to_vec()),
461        };
462
463        Ok(Self {
464            record_type,
465            unknown_free_list,
466            slots,
467            indexes,
468        })
469    }
470
471    /// Live records, skipping free and empty slots.
472    pub fn records(&self) -> impl Iterator<Item = &Record> {
473        self.slots.iter().filter_map(Slot::record)
474    }
475
476    pub fn records_mut(&mut self) -> impl Iterator<Item = &mut Record> {
477        self.slots.iter_mut().filter_map(Slot::record_mut)
478    }
479
480    pub fn record_count(&self) -> usize {
481        self.records().count()
482    }
483
484    /// Lowest unused record number. macOS numbers records from 0 upward.
485    pub fn next_record_number(&self) -> u32 {
486        self.records()
487            .map(|record| record.number)
488            .max()
489            .map_or(0, |max| max + 1)
490    }
491
492    /// Append a record, reusing a never-used slot when one exists.
493    ///
494    /// Free-list slots are left alone: their values chain macOS's record-number
495    /// allocator, and overwriting one would break the chain.
496    pub fn insert(&mut self, record: Record) {
497        match self.slots.iter_mut().find(|slot| slot.is_empty()) {
498            Some(slot) => *slot = Slot::Record(record),
499            None => self.slots.push(Slot::Record(record)),
500        }
501        // A table that holds a record carries `0` here. Keeping the
502        // never-used marker would tell macOS the first slot is free.
503        self.unknown_free_list = 0;
504    }
505
506    /// The attributes of the table's unique index, by id.
507    ///
508    /// `None` when this build could not parse the index region, or the table
509    /// declares no unique index.
510    pub fn unique_index_attribute_ids(&self) -> Option<&[u32]> {
511        self.indexes
512            .blob()?
513            .indexes
514            .iter()
515            .find(|index| index.kind == 1)
516            .map(|index| index.attribute_ids.as_slice())
517    }
518
519    /// True when a record already has the same key as `attributes` under the
520    /// table's unique index.
521    ///
522    /// The unique index is the one the region marks `kind == 1`; the attributes
523    /// it names are the relation's notion of identity, which is what macOS
524    /// refuses a duplicate of. A table whose index region this build could not
525    /// parse answers `false`: it cannot tell, and refusing a write macOS would
526    /// accept is worse than allowing it.
527    pub fn has_record_with_unique_key(
528        &self,
529        relation: &Relation,
530        attributes: &[Option<Value>],
531    ) -> bool {
532        let Some(blob) = self.indexes.blob() else {
533            return false;
534        };
535        let Some(unique) = blob.indexes.iter().find(|index| index.kind == 1) else {
536            return false;
537        };
538        // Attributes are stored in relation order, so an attribute id becomes a
539        // position in the record.
540        let positions: Vec<usize> = unique
541            .attribute_ids
542            .iter()
543            .filter_map(|id| {
544                relation
545                    .attributes
546                    .iter()
547                    .position(|attribute| attribute.id == *id)
548            })
549            .collect();
550        if positions.len() != unique.attribute_ids.len() {
551            return false;
552        }
553
554        let key_of = |values: &[Option<Value>]| -> Vec<Option<Value>> {
555            positions
556                .iter()
557                .map(|position| values.get(*position).cloned().flatten())
558                .collect()
559        };
560        let wanted = key_of(attributes);
561        self.records()
562            .any(|record| key_of(&record.attributes) == wanted)
563    }
564
565    /// Rebuild every index from the table's records.
566    ///
567    /// Rebuilding rather than patching keeps the indexes consistent with the
568    /// records by construction; `tests/keychain_index.rs` checks that a rebuild
569    /// reproduces the entries and the ordering macOS wrote.
570    pub fn rebuild_indexes(&mut self, relation: &Relation) -> Result<()> {
571        let TableIndexes::Parsed(blob) = &mut self.indexes else {
572            return Err(Error::format(format!(
573                "table 0x{:08x} has an index region this build cannot rewrite",
574                self.record_type.0
575            )));
576        };
577
578        // Attribute id -> (position in the record, format).
579        let positions: Vec<(u32, usize, AttributeFormat)> = relation
580            .attributes
581            .iter()
582            .enumerate()
583            .map(|(position, attribute)| (attribute.id, position, attribute.format))
584            .collect();
585
586        for index in &mut blob.indexes {
587            index.entries.clear();
588        }
589        for record in self.slots.iter().filter_map(Slot::record) {
590            let position_of = |attribute_id: u32| -> Option<usize> {
591                positions
592                    .iter()
593                    .find(|(id, _, _)| *id == attribute_id)
594                    .map(|(_, position, _)| *position)
595            };
596            let key = |attribute_id: u32| -> IndexValue {
597                match positions.iter().find(|(id, _, _)| *id == attribute_id) {
598                    Some((_, position, format)) => {
599                        IndexValue::from_value(record.attribute(*position), *format)
600                    }
601                    None => IndexValue::Bytes(Vec::new()),
602                }
603            };
604            // macOS does not index a record under an attribute it does not
605            // have: an absent value is not an empty one.
606            blob.insert_record_where(record.number, key, |attribute_ids: &[u32]| -> bool {
607                attribute_ids.iter().all(|id| {
608                    position_of(*id).is_some_and(|position| record.attribute(position).is_some())
609                })
610            });
611        }
612        Ok(())
613    }
614
615    fn to_bytes(&self) -> Result<Vec<u8>> {
616        let mut records = Vec::with_capacity(self.slots.len());
617        for slot in &self.slots {
618            records.push(match slot {
619                Slot::Record(record) => Some(record.to_bytes()?),
620                Slot::Free(_) | Slot::Empty => None,
621            });
622        }
623
624        let records_offset = TABLE_HEADER_LEN + 4 * self.slots.len();
625        let mut slot_offsets = Vec::with_capacity(records.len());
626        let mut running = records_offset;
627        for (slot, record) in self.slots.iter().zip(&records) {
628            match (slot, record) {
629                (Slot::Record(_), Some(bytes)) => {
630                    slot_offsets.push(running as u32);
631                    running += bytes.len();
632                }
633                // Free-list links are reproduced as read; empty slots are zero.
634                (Slot::Free(value), _) => slot_offsets.push(*value),
635                _ => slot_offsets.push(0),
636            }
637        }
638        let indexes_offset = running;
639        let index_data = self.indexes.to_bytes(indexes_offset);
640        let size = indexes_offset + index_data.len();
641
642        let mut out = Vec::with_capacity(size);
643        out.extend_from_slice(&(size as u32).to_be_bytes());
644        out.extend_from_slice(&self.record_type.0.to_be_bytes());
645        out.extend_from_slice(&(self.record_count() as u32).to_be_bytes());
646        out.extend_from_slice(&(records_offset as u32).to_be_bytes());
647        out.extend_from_slice(&(indexes_offset as u32).to_be_bytes());
648        out.extend_from_slice(&self.unknown_free_list.to_be_bytes());
649        out.extend_from_slice(&(self.slots.len() as u32).to_be_bytes());
650        for offset in slot_offsets {
651            out.extend_from_slice(&offset.to_be_bytes());
652        }
653        for record in records.into_iter().flatten() {
654            out.extend_from_slice(&record);
655        }
656        out.extend_from_slice(&index_data);
657        Ok(out)
658    }
659}
660
661impl Record {
662    fn parse(
663        cursor: &Cursor<'_>,
664        at: usize,
665        record_type: RecordType,
666        schema: &Schema,
667    ) -> Result<Self> {
668        let size = cursor.u32(at)? as usize;
669        let number = cursor.u32(at + 4)?;
670        let version = cursor.u32(at + 8)?;
671        let unknown3 = cursor.u32(at + 12)?;
672        let key_data_size = cursor.u32(at + 16)? as usize;
673        let unknown5 = cursor.u32(at + 20)?;
674
675        let formats = schema.attribute_formats(record_type);
676        let mut attributes = Vec::with_capacity(formats.len());
677        let mut offsets = Vec::with_capacity(formats.len());
678        for index in 0..formats.len() {
679            offsets.push(cursor.u32(at + RECORD_HEADER_LEN + index * 4)?);
680        }
681
682        let key_data_at = at + RECORD_HEADER_LEN + 4 * formats.len();
683        let key_data = cursor.bytes(key_data_at, key_data_size)?.to_vec();
684
685        for (offset, format) in offsets.iter().zip(&formats) {
686            if *offset == 0 {
687                attributes.push(None);
688                continue;
689            }
690            let value_at = at + (*offset & !ATTRIBUTE_OFFSET_FLAG) as usize;
691            if value_at >= at + size {
692                return Err(Error::format(format!(
693                    "record {number} in table 0x{:08x} points an attribute past its extent",
694                    record_type.0
695                )));
696            }
697            attributes.push(Some(read_value(cursor, value_at, *format)?));
698        }
699
700        Ok(Self {
701            number,
702            version,
703            unknown3,
704            unknown5,
705            key_data,
706            attributes,
707        })
708    }
709
710    pub fn attribute(&self, index: usize) -> Option<&Value> {
711        self.attributes.get(index).and_then(Option::as_ref)
712    }
713
714    fn to_bytes(&self) -> Result<Vec<u8>> {
715        let count = self.attributes.len();
716        let header_and_offsets = RECORD_HEADER_LEN + 4 * count;
717        let key_data_len = pad4(self.key_data.len());
718
719        // Values follow the key data, in attribute order, each padded to 4.
720        let mut offsets = Vec::with_capacity(count);
721        let mut running = header_and_offsets + key_data_len;
722        for attribute in &self.attributes {
723            match attribute {
724                Some(value) => {
725                    offsets.push(running as u32 | ATTRIBUTE_OFFSET_FLAG);
726                    running += value.encoded_len();
727                }
728                None => offsets.push(0),
729            }
730        }
731        let size = running;
732
733        let mut out = Vec::with_capacity(size);
734        out.extend_from_slice(&(size as u32).to_be_bytes());
735        out.extend_from_slice(&self.number.to_be_bytes());
736        out.extend_from_slice(&self.version.to_be_bytes());
737        out.extend_from_slice(&self.unknown3.to_be_bytes());
738        out.extend_from_slice(&(self.key_data.len() as u32).to_be_bytes());
739        out.extend_from_slice(&self.unknown5.to_be_bytes());
740        for offset in offsets {
741            out.extend_from_slice(&offset.to_be_bytes());
742        }
743        out.extend_from_slice(&self.key_data);
744        out.resize(header_and_offsets + key_data_len, 0);
745        for value in self.attributes.iter().flatten() {
746            value.write(&mut out);
747        }
748
749        debug_assert_eq!(
750            out.len(),
751            size,
752            "record serialization disagreed with its own layout"
753        );
754        Ok(out)
755    }
756}
757
758fn read_value(cursor: &Cursor<'_>, at: usize, format: AttributeFormat) -> Result<Value> {
759    Ok(match format {
760        AttributeFormat::Sint32 => Value::Sint32(cursor.u32(at)? as i32),
761        AttributeFormat::Uint32 => Value::Uint32(cursor.u32(at)?),
762        AttributeFormat::TimeDate => Value::Date(cursor.bytes(at, 16)?.to_vec()),
763        AttributeFormat::String => {
764            let len = cursor.u32(at)? as usize;
765            Value::String(cursor.bytes(at + 4, len)?.to_vec())
766        }
767        // Blob, and the formats this does not interpret.
768        _ => {
769            let len = cursor.u32(at)? as usize;
770            Value::Blob(cursor.bytes(at + 4, len)?.to_vec())
771        }
772    })
773}
774
775/// Group the live records of every table by record type.
776pub fn records_by_type(keychain: &Keychain) -> BTreeMap<RecordType, Vec<&Record>> {
777    let mut out = BTreeMap::new();
778    for table in &keychain.tables {
779        out.insert(table.record_type, table.records().collect());
780    }
781    out
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787
788    #[test]
789    fn padding_rounds_up_to_four() {
790        assert_eq!(pad4(0), 0);
791        assert_eq!(pad4(1), 4);
792        assert_eq!(pad4(4), 4);
793        assert_eq!(pad4(13), 16);
794    }
795
796    #[test]
797    fn rejects_files_that_are_not_keychains() {
798        assert!(matches!(
799            Keychain::parse(b"not a keychain at all!!!"),
800            Err(Error::NotAKeychain)
801        ));
802        assert!(matches!(
803            Keychain::parse(b"kych"),
804            Err(Error::Truncated { .. })
805        ));
806    }
807
808    #[test]
809    fn value_encoding_lengths_match_the_layout_rules() {
810        assert_eq!(Value::Uint32(7).encoded_len(), 4);
811        assert_eq!(Value::Date(b"20260725095125Z\0".to_vec()).encoded_len(), 16);
812        // 4-byte length prefix, then the bytes padded to a 4-byte boundary.
813        assert_eq!(Value::Blob(b"alice".to_vec()).encoded_len(), 12);
814        assert_eq!(Value::Blob(b"myservice".to_vec()).encoded_len(), 16);
815        assert_eq!(Value::Blob(Vec::new()).encoded_len(), 4);
816    }
817
818    #[test]
819    fn values_serialize_with_prefix_and_padding() {
820        let mut out = Vec::new();
821        Value::Blob(b"alice".to_vec()).write(&mut out);
822        assert_eq!(out, b"\x00\x00\x00\x05alice\0\0\0");
823
824        let mut out = Vec::new();
825        Value::Date(b"20260725095125Z\0".to_vec()).write(&mut out);
826        assert_eq!(out.len(), 16);
827        assert_eq!(&out, b"20260725095125Z\0");
828
829        // A date shorter than the field is zero-filled, not length-prefixed.
830        let mut out = Vec::new();
831        Value::Date(b"2026".to_vec()).write(&mut out);
832        assert_eq!(out, b"2026\0\0\0\0\0\0\0\0\0\0\0\0");
833    }
834
835    #[test]
836    fn display_prefers_text_and_falls_back_to_hex() {
837        assert_eq!(
838            Value::Blob(b"alice\0".to_vec()).to_display_string(),
839            "alice"
840        );
841        assert_eq!(Value::Uint32(8080).to_display_string(), "8080");
842        assert_eq!(Value::Blob(vec![0xff, 0xfe]).to_display_string(), "0xfffe");
843        assert_eq!(
844            Value::Date(b"20260725095125Z\0".to_vec()).to_display_string(),
845            "20260725095125Z"
846        );
847    }
848
849    #[test]
850    fn trim_nul_stops_at_the_first_terminator() {
851        assert_eq!(trim_nul(b"abc\0def"), b"abc");
852        assert_eq!(trim_nul(b"abc"), b"abc");
853        assert_eq!(trim_nul(b"\0"), b"");
854    }
855
856    #[test]
857    fn empty_slots_are_reused_before_growing_the_table() {
858        let record = |number| Record {
859            number,
860            version: 0,
861            unknown3: 0,
862            unknown5: 0,
863            key_data: Vec::new(),
864            attributes: vec![None],
865        };
866        let mut table = Table {
867            record_type: RecordType::GENERIC_PASSWORD,
868            unknown_free_list: 0,
869            slots: vec![
870                Slot::Record(record(0)),
871                Slot::Empty,
872                Slot::Record(record(2)),
873            ],
874            indexes: TableIndexes::Parsed(IndexBlob {
875                indexes: Vec::new(),
876            }),
877        };
878
879        assert_eq!(table.record_count(), 2);
880        assert_eq!(table.next_record_number(), 3);
881
882        table.insert(record(3));
883        assert_eq!(table.slots.len(), 3, "should have filled the hole");
884        assert!(table.slots[1].record().is_some());
885
886        table.insert(record(4));
887        assert_eq!(table.slots.len(), 4, "no hole left, so the table grows");
888    }
889
890    #[test]
891    fn free_list_slots_are_neither_records_nor_reusable() {
892        let record = |number| Record {
893            number,
894            version: 0,
895            unknown3: 0,
896            unknown5: 0,
897            key_data: Vec::new(),
898            attributes: vec![None],
899        };
900        let mut table = Table {
901            record_type: RecordType::X509_CERTIFICATE,
902            unknown_free_list: 0x431,
903            // What the login keychain's certificate table looks like: a few
904            // records among many free-list links.
905            slots: vec![Slot::Record(record(0)), Slot::Free(65), Slot::Free(73)],
906            indexes: TableIndexes::Parsed(IndexBlob {
907                indexes: Vec::new(),
908            }),
909        };
910
911        assert_eq!(table.record_count(), 1, "free slots are not records");
912        assert_eq!(table.next_record_number(), 1);
913
914        // A free slot must not be reused: its value chains macOS's allocator.
915        table.insert(record(1));
916        assert_eq!(table.slots.len(), 4);
917        assert_eq!(table.slots[1], Slot::Free(65));
918        assert_eq!(table.slots[2], Slot::Free(73));
919
920        // And it survives serialization unchanged.
921        let bytes = table.to_bytes().unwrap();
922        let offsets: Vec<u32> = (0..4)
923            .map(|index| {
924                let at = TABLE_HEADER_LEN + index * 4;
925                u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap())
926            })
927            .collect();
928        assert_eq!(offsets[1], 65);
929        assert_eq!(offsets[2], 73);
930    }
931}