1use 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
35const KEY_RECORD_UNKNOWN3: u32 = 4;
38
39const PRIVATE_KEY_RECORD_UNKNOWN3: u32 = 5;
41
42#[derive(Debug, Clone)]
44pub struct NewIdentity {
45 pub certificate: Vec<u8>,
47 pub private_key: Vec<u8>,
49 pub label: Option<String>,
51 pub trusted_applications: Vec<TrustedApplication>,
53}
54
55#[derive(Debug, Clone)]
57pub struct CreateOptions {
58 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
72pub 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 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 }
136 _ => {
137 table.slots.push(Slot::Empty);
140 }
141 }
142
143 tables.push(table);
144 }
145
146 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
202fn 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 _ => None,
234 };
235 }
236}
237
238fn 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#[derive(Debug, Clone, Default)]
273pub struct NewItem {
274 pub label: Option<String>,
277 pub account: Option<String>,
279 pub service: Option<String>,
281 pub generic: Option<Vec<u8>>,
283 pub server: Option<String>,
285 pub security_domain: Option<String>,
287 pub path: Option<String>,
289 pub port: Option<u32>,
291 pub protocol: Option<[u8; 4]>,
293 pub auth_type: Option<[u8; 4]>,
295 pub volume: Option<String>,
297 pub address: Option<String>,
299 pub signature: Option<String>,
301 pub description: Option<String>,
303 pub comment: Option<String>,
305 pub trusted_applications: Vec<TrustedApplication>,
308}
309
310impl NewItem {
311 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 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 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 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 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 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 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 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 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 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 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 slots: vec![Slot::Empty],
645 indexes: TableIndexes::Parsed(index_declarations(definition.indexes)),
646 };
647 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 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 #[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
725pub 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
736fn 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
755pub 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
764pub fn schema_of_created(file: &KeychainFile) -> Result<Schema> {
766 let schema = file.keychain().schema()?;
767 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
786pub 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 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}