kc-cli 0.1.0

Read and write macOS keychain files without the Security framework
Documentation
#!/usr/bin/env python3
"""Regenerate src/apple_schema.rs from a real macOS keychain.

    security create-keychain -p x /tmp/fresh.keychain
    python3 xtask/extract-schema.py /tmp/fresh.keychain > src/apple_schema.rs

Everything emitted is read out of the file: the relation list, the attribute
definitions, the index definitions, the table order, and the initial index blob
each table starts with. `kc create` replays them so a keychain it writes has the
schema macOS expects.
"""
import struct
import sys

BUF = open(sys.argv[1], "rb").read()
BASE = 20


def tables():
    _size, count = struct.unpack_from(">II", BUF, BASE)
    for offset in struct.unpack_from(f">{count}I", BUF, BASE + 8):
        yield BASE + offset, struct.unpack_from(">7I", BUF, BASE + offset)


def records(tab, hdr):
    for record_offset in struct.unpack_from(f">{hdr[6]}I", BUF, tab + 28):
        if record_offset:
            yield tab + record_offset


def attr_offsets(rec, count):
    return struct.unpack_from(f">{count}I", BUF, rec + 24)


def value(rec, offset, kind):
    if offset == 0:
        return None
    at = rec + (offset & 0xFFFFFFFE)
    if kind == "u32":
        return struct.unpack_from(">I", BUF, at)[0]
    length = struct.unpack_from(">I", BUF, at)[0]
    return BUF[at + 4 : at + 4 + length]


def literal(raw):
    if raw is None:
        return "None"
    body = "".join(
        chr(c) if 32 <= c < 127 and c not in (34, 92) else "\\x%02x" % c for c in raw
    )
    return f'Some(b"{body}")'


by_type = {hdr[1]: (tab, hdr) for tab, hdr in tables()}

relations = []
tab, hdr = by_type[0]
for rec in records(tab, hdr):
    off = attr_offsets(rec, 2)
    relations.append((value(rec, off[0], "u32"), value(rec, off[1], "blob")))

attributes = []
tab, hdr = by_type[2]
for rec in records(tab, hdr):
    off = attr_offsets(rec, 6)
    attributes.append(tuple(
        value(rec, off[i], "u32" if i in (0, 1, 2, 5) else "blob") for i in range(6)
    ))

indexes = []
tab, hdr = by_type[1]
for rec in records(tab, hdr):
    off = attr_offsets(rec, 5)
    indexes.append(tuple(value(rec, off[i], "u32") for i in range(5)))

print(f'''//! Apple's keychain schema, as data.
//!
//! GENERATED by `xtask/extract-schema.py` from a keychain written by
//! `security create-keychain`. Do not edit by hand; re-run the script against a
//! newer macOS if its schema ever changes.
//!
//! `kc create` replays these rows and index blobs so that a keychain it writes
//! carries the schema macOS expects. `tests/keychain_create.rs` checks the
//! result against a keychain `security` created.

/// One row of `CSSM_DL_DB_SCHEMA_INFO`.
pub struct RelationRow {{
    pub relation_id: u32,
    pub name: Option<&'static [u8]>,
}}

/// One row of `CSSM_DL_DB_SCHEMA_ATTRIBUTES`.
pub struct AttributeRow {{
    pub relation_id: u32,
    pub attribute_id: u32,
    pub name_format: u32,
    pub name: Option<&'static [u8]>,
    pub name_id: Option<&'static [u8]>,
    pub format: u32,
}}

/// One row of `CSSM_DL_DB_SCHEMA_INDEXES`.
pub struct IndexRow {{
    pub relation_id: u32,
    pub index_id: u32,
    pub attribute_id: u32,
    pub index_type: u32,
    pub indexed_data_location: u32,
}}

/// A table as macOS first writes it: its relation, the free-list field it sets
/// while the table is empty, and the index blob that declares its indexes.
pub struct TableTemplate {{
    pub relation_id: u32,
    pub free_list: u32,
    pub index_data: &'static [u8],
}}

/// Record header version field that macOS writes for every record.
pub const RECORD_VERSION: u32 = 1;

/// Relations, in the order macOS writes them.
pub const RELATIONS: [RelationRow; {len(relations)}] = [''')
for relation_id, name in relations:
    print(f"    RelationRow {{ relation_id: 0x{relation_id:08x}, name: {literal(name)} }},")
print("];\n")

print(f"pub const ATTRIBUTES: [AttributeRow; {len(attributes)}] = [")
for relation_id, attribute_id, name_format, name, name_id, fmt in attributes:
    print(
        f"    AttributeRow {{ relation_id: 0x{relation_id:08x}, "
        f"attribute_id: 0x{attribute_id:08x}, name_format: {name_format}, "
        f"name: {literal(name)}, name_id: {literal(name_id)}, format: {fmt} }},"
    )
print("];\n")

print(f"pub const INDEXES: [IndexRow; {len(indexes)}] = [")
for relation_id, index_id, attribute_id, index_type, location in indexes:
    print(
        f"    IndexRow {{ relation_id: 0x{relation_id:08x}, index_id: {index_id}, "
        f"attribute_id: 0x{attribute_id:08x}, index_type: {index_type}, "
        f"indexed_data_location: {location} }},"
    )
print("];\n")

print(f"pub const TABLES: [TableTemplate; {len(by_type)}] = [")
for tab, hdr in tables():
    index_blob = BUF[tab + hdr[4] : tab + hdr[0]]
    body = "".join("\\x%02x" % c for c in index_blob)
    print(
        f"    TableTemplate {{ relation_id: 0x{hdr[1]:08x}, free_list: 0x{hdr[5]:08x}, "
        f'index_data: b"{body}" }},'
    )
print("];")