Skip to main content

kc/
write.rs

1//! Creating keychains and adding items.
2//!
3//! Three operations, all of which have to agree with macOS byte for byte to be
4//! useful:
5//!
6//! * [`create`] writes a new database: the four schema tables replayed from
7//!   [`apple_schema`], an empty table per record relation, and a metadata record
8//!   holding a fresh `DbBlob` sealed with the caller's password.
9//! * [`KeychainFile::add_password`] stores an item: a wrapped item key in the
10//!   symmetric-key table and the encrypted secret in the item record.
11//! * [`KeychainFile::add_identity`] stores a certificate and its private key,
12//!   adding the certificate relation to the schema first if the keychain has
13//!   never held one.
14//!
15//! Every write rebuilds the indexes of the tables it touched, because their
16//! offsets are table-relative: a stale index region sends macOS into the middle
17//! of a record and it reports `errSecNoSuchAttr`.
18
19use crate::acl::{AclBlob, TrustedApplication};
20use crate::apple_schema::{self, RECORD_VERSION};
21use crate::crypto::{
22    self, BLOB_VERSION, BLOCK_SIZE, DbBlob, DbKeys, KEY_LEN, SALT_LEN, SecretBytes, Ssgp,
23};
24use crate::cssm::{KeyHeader, WrappedKeyFields};
25use crate::db::KeychainFile;
26use crate::der;
27use crate::error::{Error, Result};
28use crate::format::{
29    HEADER_SIZE_FIELD, Keychain, Record, Slot, Table, TableIndexes, VERSION, Value,
30};
31use crate::index::{Index, IndexBlob};
32use crate::records::{CertificateRecord, ItemKeyRecord, PasswordRecord, PrivateKeyRecord};
33use crate::schema::{AttributeFormat, RecordType, Relation, Schema};
34
35/// Value macOS writes in a symmetric-key record's fourth header word. Its
36/// meaning is unknown; most record types get `0`.
37const KEY_RECORD_UNKNOWN3: u32 = 4;
38
39/// The same word in a private-key record, where macOS writes `5`.
40const PRIVATE_KEY_RECORD_UNKNOWN3: u32 = 5;
41
42/// An identity to store: a certificate and the private key that matches it.
43#[derive(Debug, Clone)]
44pub struct NewIdentity {
45    /// The certificate, DER-encoded.
46    pub certificate: Vec<u8>,
47    /// The private key as a PKCS#8 `PrivateKeyInfo`, DER-encoded.
48    pub private_key: Vec<u8>,
49    /// Label for both records. Defaults to the certificate's common name.
50    pub label: Option<String>,
51    /// Applications allowed to use the private key. Empty means any.
52    pub trusted_applications: Vec<TrustedApplication>,
53}
54
55/// Settings for a new keychain. The defaults are what macOS writes.
56#[derive(Debug, Clone)]
57pub struct CreateOptions {
58    /// Seconds of inactivity before locking. macOS writes 300.
59    pub idle_timeout: u32,
60    pub lock_on_sleep: bool,
61}
62
63impl Default for CreateOptions {
64    fn default() -> Self {
65        Self {
66            idle_timeout: 300,
67            lock_on_sleep: true,
68        }
69    }
70}
71
72/// Build a new keychain protected by `password`.
73pub fn create(password: &[u8], options: &CreateOptions) -> Result<KeychainFile> {
74    let mut tables = Vec::with_capacity(apple_schema::TABLES.len());
75
76    for template in &apple_schema::TABLES {
77        let record_type = RecordType(template.relation_id);
78        // Index declarations come from the template; entries are built as
79        // records are added. An empty table's declarations sit right after its
80        // slot array, which is where the template's offsets were measured from.
81        let template_offset = crate::format::TABLE_HEADER_LEN + 4;
82        let indexes = IndexBlob::parse(template.index_data, template_offset, None)
83            .map(TableIndexes::Parsed)
84            .unwrap_or_else(|_| TableIndexes::Raw(template.index_data.to_vec()));
85        let mut table = Table {
86            record_type,
87            unknown_free_list: template.free_list,
88            slots: Vec::new(),
89            indexes,
90        };
91
92        match record_type {
93            RecordType::SCHEMA_INFO => {
94                for (number, row) in apple_schema::RELATIONS.iter().enumerate() {
95                    table.slots.push(Slot::Record(schema_record(
96                        number as u32,
97                        vec![
98                            Some(Value::Uint32(row.relation_id)),
99                            row.name.map(|name| Value::String(name.to_vec())),
100                        ],
101                    )));
102                }
103            }
104            RecordType::SCHEMA_INDEXES => {
105                for (number, row) in apple_schema::INDEXES.iter().enumerate() {
106                    table.slots.push(Slot::Record(schema_record(
107                        number as u32,
108                        vec![
109                            Some(Value::Uint32(row.relation_id)),
110                            Some(Value::Uint32(row.index_id)),
111                            Some(Value::Uint32(row.attribute_id)),
112                            Some(Value::Uint32(row.index_type)),
113                            Some(Value::Uint32(row.indexed_data_location)),
114                        ],
115                    )));
116                }
117            }
118            RecordType::SCHEMA_ATTRIBUTES => {
119                for (number, row) in apple_schema::ATTRIBUTES.iter().enumerate() {
120                    table.slots.push(Slot::Record(schema_record(
121                        number as u32,
122                        vec![
123                            Some(Value::Uint32(row.relation_id)),
124                            Some(Value::Uint32(row.attribute_id)),
125                            Some(Value::Uint32(row.name_format)),
126                            row.name.map(|name| Value::String(name.to_vec())),
127                            row.name_id.map(|id| Value::Blob(id.to_vec())),
128                            Some(Value::Uint32(row.format)),
129                        ],
130                    )));
131                }
132            }
133            RecordType::METADATA => {
134                // Filled in below, once the blob is sealed.
135            }
136            _ => {
137                // An empty table still carries one unused slot, the way macOS
138                // writes it.
139                table.slots.push(Slot::Empty);
140            }
141        }
142
143        tables.push(table);
144    }
145
146    // The database blob: fresh salt, IV, and keys, sealed with the password.
147    let mut salt = [0u8; SALT_LEN];
148    salt.copy_from_slice(&crate::secret::random_bytes(SALT_LEN));
149    let mut iv = [0u8; BLOCK_SIZE];
150    iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
151    let mut random_signature = [0u8; 16];
152    random_signature.copy_from_slice(&crate::secret::random_bytes(16));
153
154    let keys = DbKeys {
155        encryption_key: SecretBytes::new(crate::secret::random_bytes(KEY_LEN)),
156        signing_key: SecretBytes::new(crate::secret::random_bytes(20)),
157        private_acl: Vec::new(),
158    };
159
160    let mut blob = DbBlob {
161        version: BLOB_VERSION,
162        start_crypto_blob: 0,
163        total_length: 0,
164        random_signature,
165        sequence: 0,
166        idle_timeout: options.idle_timeout,
167        lock_on_sleep: options.lock_on_sleep,
168        salt,
169        iv,
170        blob_signature: [0u8; 20],
171        public_acl: crate::acl::database_public_acl(),
172        crypto_blob: Vec::new(),
173    };
174    blob.seal(password, &keys)?;
175
176    let metadata = tables
177        .iter_mut()
178        .find(|table| table.record_type == RecordType::METADATA)
179        .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_METADATA"))?;
180    metadata.slots.push(Slot::Record(Record {
181        number: 0,
182        version: RECORD_VERSION,
183        unknown3: 0,
184        unknown5: 0,
185        key_data: blob.to_bytes(),
186        attributes: Vec::new(),
187    }));
188
189    let keychain = Keychain {
190        version: VERSION,
191        header_size: HEADER_SIZE_FIELD,
192        auth_offset: 0,
193        tables,
194        commit_version: Some(1),
195    };
196
197    let mut file = KeychainFile::from_bytes(&keychain.to_bytes()?)?;
198    file.unlock(password)?;
199    Ok(file)
200}
201
202/// Store an empty value for every attribute of the unique index that has none.
203///
204/// An attribute a record does not have is not indexed, and an item missing from
205/// its relation's unique index cannot be found through the Security framework.
206/// macOS avoids that by storing the identity attributes it was given no value
207/// for as zero or empty: an internet item with no port has `port` = 0 and `path`
208/// = "", not no `port` at all.
209fn fill_unique_key(relation: &Relation, unique: &[u32], attributes: &mut [Option<Value>]) {
210    for id in unique {
211        let Some(position) = relation
212            .attributes
213            .iter()
214            .position(|attribute| attribute.id == *id)
215        else {
216            continue;
217        };
218        let Some(slot) = attributes.get_mut(position) else {
219            continue;
220        };
221        if slot.is_some() {
222            continue;
223        }
224        *slot = match relation.attributes[position].format {
225            AttributeFormat::Sint32 => Some(Value::Sint32(0)),
226            AttributeFormat::Uint32 => Some(Value::Uint32(0)),
227            AttributeFormat::Blob => Some(Value::Blob(Vec::new())),
228            AttributeFormat::String => Some(Value::String(Vec::new())),
229            // A date is a fixed sixteen bytes, so it has no empty form; no date
230            // attribute takes part in a unique index. The rest are formats no
231            // password relation uses, and a guessed value would be worse than
232            // none.
233            _ => None,
234        };
235    }
236}
237
238/// The index declarations of a relation, as an index region with no entries.
239///
240/// One index per `IndexID`, over the attributes its rows name, in row order. The
241/// `IndexType` in the schema is the inverse of the `kind` word in the region: a
242/// schema type of `0` marks the relation's unique index, which the region writes
243/// as `1`.
244fn index_declarations(rows: &[apple_schema::IndexRow]) -> IndexBlob {
245    let mut indexes: Vec<Index> = Vec::new();
246    for row in rows {
247        match indexes.iter_mut().find(|index| index.id == row.index_id) {
248            Some(index) => index.attribute_ids.push(row.attribute_id),
249            None => indexes.push(Index {
250                id: row.index_id,
251                kind: u32::from(row.index_type == 0),
252                attribute_ids: vec![row.attribute_id],
253                entries: Vec::new(),
254            }),
255        }
256    }
257    IndexBlob { indexes }
258}
259
260fn schema_record(number: u32, attributes: Vec<Option<Value>>) -> Record {
261    Record {
262        number,
263        version: RECORD_VERSION,
264        unknown3: 0,
265        unknown5: 0,
266        key_data: Vec::new(),
267        attributes,
268    }
269}
270
271/// What to store for a new password item.
272#[derive(Debug, Clone, Default)]
273pub struct NewItem {
274    /// `PrintName`: the name Keychain Access shows. Defaults to the service or
275    /// server when not set.
276    pub label: Option<String>,
277    /// `acct`
278    pub account: Option<String>,
279    /// `svce`, generic items only.
280    pub service: Option<String>,
281    /// `gena`, generic items only: application-defined bytes.
282    pub generic: Option<Vec<u8>>,
283    /// `srvr`, internet and AppleShare items.
284    pub server: Option<String>,
285    /// `sdmn`, internet items only: the authentication realm.
286    pub security_domain: Option<String>,
287    /// `path`, internet items only.
288    pub path: Option<String>,
289    /// `port`, internet items only.
290    pub port: Option<u32>,
291    /// `ptcl`, internet and AppleShare items: a four-char code such as `http`.
292    pub protocol: Option<[u8; 4]>,
293    /// `atyp`, internet items only: a four-char code such as `dflt`.
294    pub auth_type: Option<[u8; 4]>,
295    /// `vlme`, AppleShare items only.
296    pub volume: Option<String>,
297    /// `addr`, AppleShare items only.
298    pub address: Option<String>,
299    /// `ssig`, AppleShare items only.
300    pub signature: Option<String>,
301    /// `desc`: the "kind" shown in Keychain Access.
302    pub description: Option<String>,
303    /// `icmt`: free-text comment.
304    pub comment: Option<String>,
305    /// Applications allowed to decrypt the item. Empty means any application,
306    /// which is what `security add-generic-password -A` stores.
307    pub trusted_applications: Vec<TrustedApplication>,
308}
309
310impl NewItem {
311    /// Lower into the stored attribute set.
312    fn to_record(&self, print_name: &str, timestamp: &str) -> PasswordRecord {
313        PasswordRecord {
314            created: timestamp.to_string(),
315            modified: timestamp.to_string(),
316            print_name: print_name.to_string(),
317            description: self.description.clone(),
318            comment: self.comment.clone(),
319            account: self.account.clone(),
320            service: self.service.clone(),
321            generic: self.generic.clone(),
322            server: self.server.clone(),
323            security_domain: self.security_domain.clone(),
324            path: self.path.clone(),
325            port: self.port,
326            protocol: self.protocol,
327            auth_type: self.auth_type,
328            volume: self.volume.clone(),
329            address: self.address.clone(),
330            signature: self.signature.clone(),
331        }
332    }
333
334    /// The name to store as `PrintName`.
335    fn print_name(&self) -> String {
336        self.label
337            .clone()
338            .or_else(|| self.service.clone())
339            .or_else(|| self.server.clone())
340            .or_else(|| self.volume.clone())
341            .or_else(|| self.account.clone())
342            .unwrap_or_default()
343    }
344}
345
346impl KeychainFile {
347    /// Store a password item, creating the item key that protects it.
348    ///
349    /// Requires an unlocked keychain: the item key is wrapped with the
350    /// database's encryption key and both blobs are signed with its signing key.
351    pub fn add_password(
352        &mut self,
353        record_type: RecordType,
354        item: &NewItem,
355        secret: &[u8],
356        timestamp: &str,
357    ) -> Result<()> {
358        let keys = self.keys().ok_or(Error::Locked)?;
359        let encryption_key = SecretBytes::new(keys.encryption_key.as_slice());
360        let signing_key = SecretBytes::new(keys.signing_key.as_slice());
361
362        // One fresh key per item, wrapped under the database key, exactly as
363        // macOS does. The label ties the item to its key.
364        let item_key = SecretBytes::new(crate::secret::random_bytes(KEY_LEN));
365        let mut label = [0u8; 20];
366        label[..4].copy_from_slice(crypto::SSGP_MAGIC);
367        label[4..].copy_from_slice(&crate::secret::random_bytes(16));
368
369        let print_name = item.print_name();
370        let key_attributes = {
371            let relation = self
372                .schema()
373                .relation(RecordType::SYMMETRIC_KEY)
374                .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_SYMMETRIC_KEY"))?;
375            ItemKeyRecord::for_item_key(label).to_attributes(relation)
376        };
377        let key_blob = self.wrap_item_key(
378            &item_key,
379            encryption_key.as_slice(),
380            signing_key.as_slice(),
381            &print_name,
382            &item.trusted_applications,
383        )?;
384
385        let mut ssgp_iv = [0u8; BLOCK_SIZE];
386        ssgp_iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
387        let ssgp = Ssgp::seal(label, ssgp_iv, item_key.as_slice(), secret)?;
388
389        let item_attributes = {
390            let relation = self.schema().relation(record_type).ok_or_else(|| {
391                Error::format(format!("keychain has no 0x{:08x} relation", record_type.0))
392            })?;
393            let mut attributes = item
394                .to_record(&print_name, timestamp)
395                .to_attributes(relation);
396            if let Some(table) = self.keychain().table(record_type)
397                && let Some(unique) = table.unique_index_attribute_ids()
398            {
399                fill_unique_key(relation, unique, &mut attributes);
400            }
401            attributes
402        };
403
404        // macOS refuses a second item with the same unique key, and two records
405        // sharing one index key would give the index two entries that cannot be
406        // told apart. Refusing here keeps the file valid.
407        if let Some(relation) = self.schema().relation(record_type)
408            && let Some(table) = self.keychain().table(record_type)
409            && table.has_record_with_unique_key(relation, &item_attributes)
410        {
411            return Err(Error::DuplicateItem);
412        }
413
414        // Both records are stamped with the commit version of this write, the
415        // way macOS stamps them, and both go in together: an item without its
416        // key is unreadable, and a key without its item is garbage.
417        let keychain = self.keychain_mut();
418        keychain.bump_commit_version();
419        let version = keychain.commit_version.unwrap_or(1);
420
421        let key_table = keychain
422            .table_mut(RecordType::SYMMETRIC_KEY)
423            .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_SYMMETRIC_KEY"))?;
424        let number = key_table.next_record_number();
425        key_table.insert(Record {
426            number,
427            version,
428            unknown3: KEY_RECORD_UNKNOWN3,
429            unknown5: 0,
430            key_data: key_blob,
431            attributes: key_attributes,
432        });
433
434        let table = keychain
435            .table_mut(record_type)
436            .ok_or(Error::MissingTable("password table"))?;
437        let number = table.next_record_number();
438        table.insert(Record {
439            number,
440            version,
441            unknown3: 0,
442            unknown5: 0,
443            key_data: ssgp.to_bytes(),
444            attributes: item_attributes,
445        });
446
447        // Both tables' indexes must be rebuilt: the records moved, so every
448        // table-relative offset in the region changed, and the new records need
449        // entries or the Security framework will not find them.
450        let schema = self.schema().clone();
451        for touched in [RecordType::SYMMETRIC_KEY, record_type] {
452            let Some(relation) = schema.relation(touched) else {
453                continue;
454            };
455            if let Some(table) = self.keychain_mut().table_mut(touched) {
456                table.rebuild_indexes(relation)?;
457            }
458        }
459
460        self.remember_item_key(label, item_key);
461        Ok(())
462    }
463
464    /// Store an identity: the certificate in the clear, the private key wrapped.
465    ///
466    /// The two records are linked by the certificate's public key hash, which the
467    /// key record carries as its `Label` — that is how `SecIdentity` pairs them,
468    /// and how [`crate::db::KeychainFile`] and `security` both find
469    /// them.
470    ///
471    /// Requires an unlocked keychain: the private key is wrapped with the
472    /// database's encryption key, and both blobs are signed with its signing key.
473    pub fn add_identity(&mut self, identity: &NewIdentity) -> Result<[u8; 20]> {
474        self.ensure_relation(RecordType::X509_CERTIFICATE)?;
475
476        let keys = self.keys().ok_or(Error::Locked)?;
477        let encryption_key = SecretBytes::new(keys.encryption_key.as_slice());
478        let signing_key = SecretBytes::new(keys.signing_key.as_slice());
479
480        let certificate = der::Certificate::parse(&identity.certificate)?;
481        let public_key_hash = certificate.public_key_hash();
482        let label = identity
483            .label
484            .clone()
485            .or_else(|| certificate.common_name.clone())
486            .unwrap_or_else(|| hex::encode(public_key_hash));
487
488        // The key must actually match the certificate, or the identity is a pair
489        // of records that can never be used together.
490        let key_info = der::PrivateKeyInfo::parse(&identity.private_key)?;
491        if !key_info.is_rsa() {
492            return Err(Error::other(
493                "only RSA private keys are supported; an EC key would need its own \
494                 KeyType and key-size handling, which is not implemented",
495            ));
496        }
497        let key_size = key_info.rsa_key_size_in_bits()?;
498
499        let certificate_attributes = {
500            let relation = self
501                .schema()
502                .relation(RecordType::X509_CERTIFICATE)
503                .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_X509_CERTIFICATE"))?;
504            CertificateRecord::for_certificate(&label, &certificate).to_attributes(relation)
505        };
506        let key_attributes = {
507            let relation = self
508                .schema()
509                .relation(RecordType::PRIVATE_KEY)
510                .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_PRIVATE_KEY"))?;
511            PrivateKeyRecord::for_private_key(&label, public_key_hash, key_size)
512                .to_attributes(relation)
513        };
514
515        let key_blob = self.wrap_private_key(
516            &identity.private_key,
517            encryption_key.as_slice(),
518            signing_key.as_slice(),
519            &label,
520            key_size,
521            &identity.trusted_applications,
522        )?;
523
524        let keychain = self.keychain_mut();
525        keychain.bump_commit_version();
526        let version = keychain.commit_version.unwrap_or(1);
527
528        let key_table = keychain
529            .table_mut(RecordType::PRIVATE_KEY)
530            .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_PRIVATE_KEY"))?;
531        let number = key_table.next_record_number();
532        key_table.insert(Record {
533            number,
534            version,
535            unknown3: PRIVATE_KEY_RECORD_UNKNOWN3,
536            unknown5: 0,
537            key_data: key_blob,
538            attributes: key_attributes,
539        });
540
541        let certificate_table = keychain
542            .table_mut(RecordType::X509_CERTIFICATE)
543            .ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_X509_CERTIFICATE"))?;
544        let number = certificate_table.next_record_number();
545        certificate_table.insert(Record {
546            number,
547            version,
548            unknown3: 0,
549            unknown5: 0,
550            // A certificate is public: it is stored unencrypted.
551            key_data: identity.certificate.clone(),
552            attributes: certificate_attributes,
553        });
554
555        let schema = self.schema().clone();
556        for touched in [RecordType::PRIVATE_KEY, RecordType::X509_CERTIFICATE] {
557            let Some(relation) = schema.relation(touched) else {
558                continue;
559            };
560            if let Some(table) = self.keychain_mut().table_mut(touched) {
561                table.rebuild_indexes(relation)?;
562            }
563        }
564        Ok(public_key_hash)
565    }
566
567    /// Add a relation to the schema, if the keychain does not have it yet.
568    ///
569    /// A keychain from `security create-keychain` has no certificate table:
570    /// `securityd` appends the relation's schema rows and an empty table the
571    /// first time something stores a certificate. Writing a certificate record
572    /// into a keychain that has no such relation would produce a file macOS
573    /// cannot read, so the relation is created the same way here.
574    fn ensure_relation(&mut self, record_type: RecordType) -> Result<()> {
575        if self.keychain().table(record_type).is_some() {
576            return Ok(());
577        }
578        let definition = apple_schema::ON_DEMAND_RELATIONS
579            .iter()
580            .find(|relation| relation.relation.relation_id == record_type.0)
581            .ok_or_else(|| {
582                Error::other(format!(
583                    "this keychain has no {} table, and adding that relation is not supported",
584                    record_type.name()
585                ))
586            })?;
587
588        let keychain = self.keychain_mut();
589
590        let info = keychain
591            .table_mut(RecordType::SCHEMA_INFO)
592            .ok_or(Error::MissingTable("CSSM_DL_DB_SCHEMA_INFO"))?;
593        let number = info.next_record_number();
594        info.insert(schema_record(
595            number,
596            vec![
597                Some(Value::Uint32(definition.relation.relation_id)),
598                definition
599                    .relation
600                    .name
601                    .map(|name| Value::String(name.to_vec())),
602            ],
603        ));
604
605        let attributes = keychain
606            .table_mut(RecordType::SCHEMA_ATTRIBUTES)
607            .ok_or(Error::MissingTable("CSSM_DL_DB_SCHEMA_ATTRIBUTES"))?;
608        for row in definition.attributes {
609            let number = attributes.next_record_number();
610            attributes.insert(schema_record(
611                number,
612                vec![
613                    Some(Value::Uint32(row.relation_id)),
614                    Some(Value::Uint32(row.attribute_id)),
615                    Some(Value::Uint32(row.name_format)),
616                    row.name.map(|name| Value::String(name.to_vec())),
617                    row.name_id.map(|id| Value::Blob(id.to_vec())),
618                    Some(Value::Uint32(row.format)),
619                ],
620            ));
621        }
622
623        let indexes = keychain
624            .table_mut(RecordType::SCHEMA_INDEXES)
625            .ok_or(Error::MissingTable("CSSM_DL_DB_SCHEMA_INDEXES"))?;
626        for row in definition.indexes {
627            let number = indexes.next_record_number();
628            indexes.insert(schema_record(
629                number,
630                vec![
631                    Some(Value::Uint32(row.relation_id)),
632                    Some(Value::Uint32(row.index_id)),
633                    Some(Value::Uint32(row.attribute_id)),
634                    Some(Value::Uint32(row.index_type)),
635                    Some(Value::Uint32(row.indexed_data_location)),
636                ],
637            ));
638        }
639
640        let table = Table {
641            record_type,
642            unknown_free_list: definition.free_list,
643            // An empty table still carries one unused slot.
644            slots: vec![Slot::Empty],
645            indexes: TableIndexes::Parsed(index_declarations(definition.indexes)),
646        };
647        // The tables array is ordered by record type.
648        let at = keychain
649            .tables
650            .iter()
651            .position(|existing| existing.record_type.0 > record_type.0)
652            .unwrap_or(keychain.tables.len());
653        keychain.tables.insert(at, table);
654
655        self.reload_schema()
656    }
657
658    /// The key blob for a private key: the same wrapping an item key uses, over a
659    /// PKCS#8 payload instead of 24 bytes.
660    fn wrap_private_key(
661        &self,
662        private_key: &[u8],
663        encryption_key: &[u8],
664        signing_key: &[u8],
665        label: &str,
666        key_size: u32,
667        trusted: &[TrustedApplication],
668    ) -> Result<Vec<u8>> {
669        let mut iv = [0u8; BLOCK_SIZE];
670        iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
671
672        let mut blob = crypto::KeyBlob {
673            version: BLOB_VERSION,
674            start_crypto_blob: 0,
675            total_length: 0,
676            iv,
677            header: KeyHeader::private_key(key_size),
678            wrapped: WrappedKeyFields::item_key(),
679            blob_signature: [0u8; 20],
680            public_acl: crypto::PublicAcl::Parsed(if trusted.is_empty() {
681                AclBlob::for_item(label)
682            } else {
683                AclBlob::for_item_trusting(label, trusted.to_vec())
684            }),
685            crypto_blob: crypto::wrap_blob(encryption_key, &iv, private_key)?,
686        };
687        blob.sign(signing_key);
688        Ok(blob.to_bytes())
689    }
690
691    /// The key blob for an item key: wrapped under the database key, carrying
692    /// the item's ACL, and signed.
693    #[allow(clippy::too_many_arguments)]
694    fn wrap_item_key(
695        &self,
696        item_key: &SecretBytes,
697        encryption_key: &[u8],
698        signing_key: &[u8],
699        print_name: &str,
700        trusted: &[TrustedApplication],
701    ) -> Result<Vec<u8>> {
702        let mut iv = [0u8; BLOCK_SIZE];
703        iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
704
705        let mut blob = crypto::KeyBlob {
706            version: BLOB_VERSION,
707            start_crypto_blob: 0,
708            total_length: 0,
709            iv,
710            header: KeyHeader::item_key(),
711            wrapped: WrappedKeyFields::item_key(),
712            blob_signature: [0u8; 20],
713            public_acl: crypto::PublicAcl::Parsed(if trusted.is_empty() {
714                AclBlob::for_item(print_name)
715            } else {
716                AclBlob::for_item_trusting(print_name, trusted.to_vec())
717            }),
718            crypto_blob: crypto::wrap_key(encryption_key, &iv, item_key.as_slice())?,
719        };
720        blob.sign(signing_key);
721        Ok(blob.to_bytes())
722    }
723}
724
725/// Format a timestamp the way keychain date attributes are written.
726pub fn format_timestamp(unix_seconds: i64) -> String {
727    let (days, seconds) = (
728        unix_seconds.div_euclid(86_400),
729        unix_seconds.rem_euclid(86_400),
730    );
731    let (year, month, day) = civil_from_days(days);
732    let (hour, minute, second) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60);
733    format!("{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}Z")
734}
735
736/// Days since the epoch to a civil date (Howard Hinnant's `civil_from_days`).
737fn civil_from_days(days: i64) -> (i64, u32, u32) {
738    let z = days + 719_468;
739    let era = z.div_euclid(146_097);
740    let day_of_era = z.rem_euclid(146_097);
741    let year_of_era =
742        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
743    let year = year_of_era + era * 400;
744    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
745    let month_prime = (5 * day_of_year + 2) / 153;
746    let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32;
747    let month = if month_prime < 10 {
748        month_prime + 3
749    } else {
750        month_prime - 9
751    } as u32;
752    (if month <= 2 { year + 1 } else { year }, month, day)
753}
754
755/// Timestamp for right now.
756pub fn now_timestamp() -> String {
757    let seconds = std::time::SystemTime::now()
758        .duration_since(std::time::UNIX_EPOCH)
759        .map(|d| d.as_secs() as i64)
760        .unwrap_or(0);
761    format_timestamp(seconds)
762}
763
764/// Verify a schema built by [`create`] against the file's own declarations.
765pub fn schema_of_created(file: &KeychainFile) -> Result<Schema> {
766    let schema = file.keychain().schema()?;
767    // A created keychain must be able to describe its own password relations, or
768    // nothing can be stored in it.
769    for record_type in [
770        RecordType::GENERIC_PASSWORD,
771        RecordType::INTERNET_PASSWORD,
772        RecordType::APPLESHARE_PASSWORD,
773    ] {
774        let relation = schema
775            .relation(record_type)
776            .ok_or_else(|| Error::format("created keychain is missing a password relation"))?;
777        if relation.index_of("acct").is_none() {
778            return Err(Error::format(
779                "created keychain's password relation has no acct",
780            ));
781        }
782    }
783    Ok(schema)
784}
785
786/// Formats used by the password relations, for tests and diagnostics.
787pub fn expected_format(name: &str) -> AttributeFormat {
788    match name {
789        "cdat" | "mdat" => AttributeFormat::TimeDate,
790        "port" | "crtr" | "type" | "invi" | "nega" | "cusi" | "ptcl" => AttributeFormat::Uint32,
791        "scrp" => AttributeFormat::Sint32,
792        _ => AttributeFormat::Blob,
793    }
794}
795
796#[cfg(test)]
797mod tests {
798    use super::*;
799
800    #[test]
801    fn timestamps_match_the_keychain_format() {
802        assert_eq!(format_timestamp(0), "19700101000000Z");
803        assert_eq!(format_timestamp(1_784_982_896), "20260725123456Z");
804        assert_eq!(format_timestamp(1_709_164_800), "20240229000000Z");
805        assert_eq!(format_timestamp(951_782_400), "20000229000000Z");
806        assert_eq!(now_timestamp().len(), 15);
807        assert!(now_timestamp().ends_with('Z'));
808    }
809
810    #[test]
811    fn a_created_keychain_unlocks_and_describes_itself() {
812        let file = create(b"correct horse", &CreateOptions::default()).unwrap();
813        assert!(file.is_unlocked());
814        schema_of_created(&file).unwrap();
815
816        let info = file.info().unwrap();
817        assert_eq!(info.version, VERSION);
818        assert_eq!(info.tables.len(), apple_schema::TABLES.len());
819        assert_eq!(info.idle_timeout, 300);
820        assert!(info.lock_on_sleep);
821        assert!(file.items().is_empty());
822    }
823
824    #[test]
825    fn a_created_keychain_rejects_the_wrong_password() {
826        let file = create(b"right", &CreateOptions::default()).unwrap();
827        let bytes = file.keychain().to_bytes().unwrap();
828
829        let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
830        assert!(matches!(
831            reopened.unlock(b"wrong"),
832            Err(Error::WrongPassword)
833        ));
834        assert!(!reopened.is_unlocked());
835        reopened.unlock(b"right").unwrap();
836    }
837
838    #[test]
839    fn created_keychains_differ_in_salt_and_keys() {
840        let first = create(b"same password", &CreateOptions::default()).unwrap();
841        let second = create(b"same password", &CreateOptions::default()).unwrap();
842        assert_ne!(first.info().unwrap().salt, second.info().unwrap().salt);
843        assert_ne!(first.info().unwrap().iv, second.info().unwrap().iv);
844    }
845
846    #[test]
847    fn adding_an_item_stores_a_recoverable_secret() {
848        let mut file = create(b"master", &CreateOptions::default()).unwrap();
849        let item = NewItem {
850            account: Some("alice".into()),
851            service: Some("myservice".into()),
852            description: Some("note kind".into()),
853            ..NewItem::default()
854        };
855        file.add_password(
856            RecordType::GENERIC_PASSWORD,
857            &item,
858            b"s3cr3t",
859            "20260725123456Z",
860        )
861        .unwrap();
862
863        // Round-trip through bytes, so this exercises the serializer too.
864        let bytes = file.keychain().to_bytes().unwrap();
865        let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
866        reopened.unlock(b"master").unwrap();
867
868        let items = reopened.items();
869        assert_eq!(items.len(), 1);
870        let stored = &items[0];
871        assert_eq!(stored.account().as_deref(), Some("alice"));
872        assert_eq!(stored.service().as_deref(), Some("myservice"));
873        assert_eq!(stored.label().as_deref(), Some("myservice"));
874        assert_eq!(stored.created().as_deref(), Some("20260725123456Z"));
875        assert!(stored.has_secret());
876        assert_eq!(reopened.secret(stored).unwrap().as_slice(), b"s3cr3t");
877        assert_eq!(reopened.item_key_count(), 1);
878    }
879
880    #[test]
881    fn each_item_gets_its_own_key() {
882        let mut file = create(b"master", &CreateOptions::default()).unwrap();
883        for (account, secret) in [("a", "one"), ("b", "two"), ("c", "three")] {
884            let item = NewItem {
885                account: Some(account.into()),
886                service: Some("svc".into()),
887                ..NewItem::default()
888            };
889            file.add_password(
890                RecordType::GENERIC_PASSWORD,
891                &item,
892                secret.as_bytes(),
893                "20260725123456Z",
894            )
895            .unwrap();
896        }
897
898        let bytes = file.keychain().to_bytes().unwrap();
899        let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
900        reopened.unlock(b"master").unwrap();
901        assert_eq!(reopened.item_key_count(), 3, "one wrapped key per item");
902
903        for (account, expected) in [("a", "one"), ("b", "two"), ("c", "three")] {
904            let item = reopened
905                .items()
906                .into_iter()
907                .find(|item| item.account().as_deref() == Some(account))
908                .expect("item is present");
909            assert_eq!(
910                reopened.secret(&item).unwrap().as_slice(),
911                expected.as_bytes()
912            );
913        }
914    }
915
916    #[test]
917    fn internet_items_keep_their_network_attributes() {
918        let mut file = create(b"master", &CreateOptions::default()).unwrap();
919        let item = NewItem {
920            account: Some("bob".into()),
921            server: Some("example.com".into()),
922            path: Some("/login".into()),
923            port: Some(8080),
924            protocol: Some(*b"http"),
925            auth_type: Some(*b"dflt"),
926            ..NewItem::default()
927        };
928        file.add_password(
929            RecordType::INTERNET_PASSWORD,
930            &item,
931            b"pw",
932            "20260725123456Z",
933        )
934        .unwrap();
935
936        let bytes = file.keychain().to_bytes().unwrap();
937        let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
938        reopened.unlock(b"master").unwrap();
939
940        let items = reopened.items_of_type(RecordType::INTERNET_PASSWORD);
941        assert_eq!(items.len(), 1);
942        assert_eq!(items[0].server().as_deref(), Some("example.com"));
943        assert_eq!(items[0].path().as_deref(), Some("/login"));
944        assert_eq!(items[0].port(), Some(8080));
945        assert_eq!(items[0].label().as_deref(), Some("example.com"));
946        assert_eq!(reopened.secret(&items[0]).unwrap().as_slice(), b"pw");
947    }
948
949    #[test]
950    fn adding_to_a_locked_keychain_is_refused() {
951        let file = create(b"master", &CreateOptions::default()).unwrap();
952        let bytes = file.keychain().to_bytes().unwrap();
953        let mut locked = KeychainFile::from_bytes(&bytes).unwrap();
954
955        let result = locked.add_password(
956            RecordType::GENERIC_PASSWORD,
957            &NewItem::default(),
958            b"secret",
959            "20260725123456Z",
960        );
961        assert!(matches!(result, Err(Error::Locked)));
962    }
963
964    #[test]
965    fn commit_version_advances_with_each_write() {
966        let mut file = create(b"master", &CreateOptions::default()).unwrap();
967        assert_eq!(file.keychain().commit_version, Some(1));
968        file.add_password(
969            RecordType::GENERIC_PASSWORD,
970            &NewItem {
971                account: Some("a".into()),
972                ..NewItem::default()
973            },
974            b"x",
975            "20260725123456Z",
976        )
977        .unwrap();
978        assert_eq!(file.keychain().commit_version, Some(2));
979    }
980}