use crate::acl::{AclBlob, TrustedApplication};
use crate::apple_schema::{self, RECORD_VERSION};
use crate::crypto::{
self, BLOB_VERSION, BLOCK_SIZE, DbBlob, DbKeys, KEY_LEN, SALT_LEN, SecretBytes, Ssgp,
};
use crate::cssm::{KeyHeader, WrappedKeyFields};
use crate::db::KeychainFile;
use crate::der;
use crate::error::{Error, Result};
use crate::format::{
HEADER_SIZE_FIELD, Keychain, Record, Slot, Table, TableIndexes, VERSION, Value,
};
use crate::index::{Index, IndexBlob};
use crate::records::{CertificateRecord, ItemKeyRecord, PasswordRecord, PrivateKeyRecord};
use crate::schema::{AttributeFormat, RecordType, Relation, Schema};
const KEY_RECORD_UNKNOWN3: u32 = 4;
const PRIVATE_KEY_RECORD_UNKNOWN3: u32 = 5;
#[derive(Debug, Clone)]
pub struct NewIdentity {
pub certificate: Vec<u8>,
pub private_key: Vec<u8>,
pub label: Option<String>,
pub trusted_applications: Vec<TrustedApplication>,
}
#[derive(Debug, Clone)]
pub struct CreateOptions {
pub idle_timeout: u32,
pub lock_on_sleep: bool,
}
impl Default for CreateOptions {
fn default() -> Self {
Self {
idle_timeout: 300,
lock_on_sleep: true,
}
}
}
pub fn create(password: &[u8], options: &CreateOptions) -> Result<KeychainFile> {
let mut tables = Vec::with_capacity(apple_schema::TABLES.len());
for template in &apple_schema::TABLES {
let record_type = RecordType(template.relation_id);
let template_offset = crate::format::TABLE_HEADER_LEN + 4;
let indexes = IndexBlob::parse(template.index_data, template_offset, None)
.map(TableIndexes::Parsed)
.unwrap_or_else(|_| TableIndexes::Raw(template.index_data.to_vec()));
let mut table = Table {
record_type,
unknown_free_list: template.free_list,
slots: Vec::new(),
indexes,
};
match record_type {
RecordType::SCHEMA_INFO => {
for (number, row) in apple_schema::RELATIONS.iter().enumerate() {
table.slots.push(Slot::Record(schema_record(
number as u32,
vec![
Some(Value::Uint32(row.relation_id)),
row.name.map(|name| Value::String(name.to_vec())),
],
)));
}
}
RecordType::SCHEMA_INDEXES => {
for (number, row) in apple_schema::INDEXES.iter().enumerate() {
table.slots.push(Slot::Record(schema_record(
number as u32,
vec![
Some(Value::Uint32(row.relation_id)),
Some(Value::Uint32(row.index_id)),
Some(Value::Uint32(row.attribute_id)),
Some(Value::Uint32(row.index_type)),
Some(Value::Uint32(row.indexed_data_location)),
],
)));
}
}
RecordType::SCHEMA_ATTRIBUTES => {
for (number, row) in apple_schema::ATTRIBUTES.iter().enumerate() {
table.slots.push(Slot::Record(schema_record(
number as u32,
vec![
Some(Value::Uint32(row.relation_id)),
Some(Value::Uint32(row.attribute_id)),
Some(Value::Uint32(row.name_format)),
row.name.map(|name| Value::String(name.to_vec())),
row.name_id.map(|id| Value::Blob(id.to_vec())),
Some(Value::Uint32(row.format)),
],
)));
}
}
RecordType::METADATA => {
}
_ => {
table.slots.push(Slot::Empty);
}
}
tables.push(table);
}
let mut salt = [0u8; SALT_LEN];
salt.copy_from_slice(&crate::secret::random_bytes(SALT_LEN));
let mut iv = [0u8; BLOCK_SIZE];
iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
let mut random_signature = [0u8; 16];
random_signature.copy_from_slice(&crate::secret::random_bytes(16));
let keys = DbKeys {
encryption_key: SecretBytes::new(crate::secret::random_bytes(KEY_LEN)),
signing_key: SecretBytes::new(crate::secret::random_bytes(20)),
private_acl: Vec::new(),
};
let mut blob = DbBlob {
version: BLOB_VERSION,
start_crypto_blob: 0,
total_length: 0,
random_signature,
sequence: 0,
idle_timeout: options.idle_timeout,
lock_on_sleep: options.lock_on_sleep,
salt,
iv,
blob_signature: [0u8; 20],
public_acl: crate::acl::database_public_acl(),
crypto_blob: Vec::new(),
};
blob.seal(password, &keys)?;
let metadata = tables
.iter_mut()
.find(|table| table.record_type == RecordType::METADATA)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_METADATA"))?;
metadata.slots.push(Slot::Record(Record {
number: 0,
version: RECORD_VERSION,
unknown3: 0,
unknown5: 0,
key_data: blob.to_bytes(),
attributes: Vec::new(),
}));
let keychain = Keychain {
version: VERSION,
header_size: HEADER_SIZE_FIELD,
auth_offset: 0,
tables,
commit_version: Some(1),
};
let mut file = KeychainFile::from_bytes(&keychain.to_bytes()?)?;
file.unlock(password)?;
Ok(file)
}
fn fill_unique_key(relation: &Relation, unique: &[u32], attributes: &mut [Option<Value>]) {
for id in unique {
let Some(position) = relation
.attributes
.iter()
.position(|attribute| attribute.id == *id)
else {
continue;
};
let Some(slot) = attributes.get_mut(position) else {
continue;
};
if slot.is_some() {
continue;
}
*slot = match relation.attributes[position].format {
AttributeFormat::Sint32 => Some(Value::Sint32(0)),
AttributeFormat::Uint32 => Some(Value::Uint32(0)),
AttributeFormat::Blob => Some(Value::Blob(Vec::new())),
AttributeFormat::String => Some(Value::String(Vec::new())),
_ => None,
};
}
}
fn index_declarations(rows: &[apple_schema::IndexRow]) -> IndexBlob {
let mut indexes: Vec<Index> = Vec::new();
for row in rows {
match indexes.iter_mut().find(|index| index.id == row.index_id) {
Some(index) => index.attribute_ids.push(row.attribute_id),
None => indexes.push(Index {
id: row.index_id,
kind: u32::from(row.index_type == 0),
attribute_ids: vec![row.attribute_id],
entries: Vec::new(),
}),
}
}
IndexBlob { indexes }
}
fn schema_record(number: u32, attributes: Vec<Option<Value>>) -> Record {
Record {
number,
version: RECORD_VERSION,
unknown3: 0,
unknown5: 0,
key_data: Vec::new(),
attributes,
}
}
#[derive(Debug, Clone, Default)]
pub struct NewItem {
pub label: Option<String>,
pub account: Option<String>,
pub service: Option<String>,
pub generic: Option<Vec<u8>>,
pub server: Option<String>,
pub security_domain: Option<String>,
pub path: Option<String>,
pub port: Option<u32>,
pub protocol: Option<[u8; 4]>,
pub auth_type: Option<[u8; 4]>,
pub volume: Option<String>,
pub address: Option<String>,
pub signature: Option<String>,
pub description: Option<String>,
pub comment: Option<String>,
pub trusted_applications: Vec<TrustedApplication>,
}
impl NewItem {
fn to_record(&self, print_name: &str, timestamp: &str) -> PasswordRecord {
PasswordRecord {
created: timestamp.to_string(),
modified: timestamp.to_string(),
print_name: print_name.to_string(),
description: self.description.clone(),
comment: self.comment.clone(),
account: self.account.clone(),
service: self.service.clone(),
generic: self.generic.clone(),
server: self.server.clone(),
security_domain: self.security_domain.clone(),
path: self.path.clone(),
port: self.port,
protocol: self.protocol,
auth_type: self.auth_type,
volume: self.volume.clone(),
address: self.address.clone(),
signature: self.signature.clone(),
}
}
fn print_name(&self) -> String {
self.label
.clone()
.or_else(|| self.service.clone())
.or_else(|| self.server.clone())
.or_else(|| self.volume.clone())
.or_else(|| self.account.clone())
.unwrap_or_default()
}
}
impl KeychainFile {
pub fn add_password(
&mut self,
record_type: RecordType,
item: &NewItem,
secret: &[u8],
timestamp: &str,
) -> Result<()> {
let keys = self.keys().ok_or(Error::Locked)?;
let encryption_key = SecretBytes::new(keys.encryption_key.as_slice());
let signing_key = SecretBytes::new(keys.signing_key.as_slice());
let item_key = SecretBytes::new(crate::secret::random_bytes(KEY_LEN));
let mut label = [0u8; 20];
label[..4].copy_from_slice(crypto::SSGP_MAGIC);
label[4..].copy_from_slice(&crate::secret::random_bytes(16));
let print_name = item.print_name();
let key_attributes = {
let relation = self
.schema()
.relation(RecordType::SYMMETRIC_KEY)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_SYMMETRIC_KEY"))?;
ItemKeyRecord::for_item_key(label).to_attributes(relation)
};
let key_blob = self.wrap_item_key(
&item_key,
encryption_key.as_slice(),
signing_key.as_slice(),
&print_name,
&item.trusted_applications,
)?;
let mut ssgp_iv = [0u8; BLOCK_SIZE];
ssgp_iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
let ssgp = Ssgp::seal(label, ssgp_iv, item_key.as_slice(), secret)?;
let item_attributes = {
let relation = self.schema().relation(record_type).ok_or_else(|| {
Error::format(format!("keychain has no 0x{:08x} relation", record_type.0))
})?;
let mut attributes = item
.to_record(&print_name, timestamp)
.to_attributes(relation);
if let Some(table) = self.keychain().table(record_type)
&& let Some(unique) = table.unique_index_attribute_ids()
{
fill_unique_key(relation, unique, &mut attributes);
}
attributes
};
if let Some(relation) = self.schema().relation(record_type)
&& let Some(table) = self.keychain().table(record_type)
&& table.has_record_with_unique_key(relation, &item_attributes)
{
return Err(Error::DuplicateItem);
}
let keychain = self.keychain_mut();
keychain.bump_commit_version();
let version = keychain.commit_version.unwrap_or(1);
let key_table = keychain
.table_mut(RecordType::SYMMETRIC_KEY)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_SYMMETRIC_KEY"))?;
let number = key_table.next_record_number();
key_table.insert(Record {
number,
version,
unknown3: KEY_RECORD_UNKNOWN3,
unknown5: 0,
key_data: key_blob,
attributes: key_attributes,
});
let table = keychain
.table_mut(record_type)
.ok_or(Error::MissingTable("password table"))?;
let number = table.next_record_number();
table.insert(Record {
number,
version,
unknown3: 0,
unknown5: 0,
key_data: ssgp.to_bytes(),
attributes: item_attributes,
});
let schema = self.schema().clone();
for touched in [RecordType::SYMMETRIC_KEY, record_type] {
let Some(relation) = schema.relation(touched) else {
continue;
};
if let Some(table) = self.keychain_mut().table_mut(touched) {
table.rebuild_indexes(relation)?;
}
}
self.remember_item_key(label, item_key);
Ok(())
}
pub fn add_identity(&mut self, identity: &NewIdentity) -> Result<[u8; 20]> {
self.ensure_relation(RecordType::X509_CERTIFICATE)?;
let keys = self.keys().ok_or(Error::Locked)?;
let encryption_key = SecretBytes::new(keys.encryption_key.as_slice());
let signing_key = SecretBytes::new(keys.signing_key.as_slice());
let certificate = der::Certificate::parse(&identity.certificate)?;
let public_key_hash = certificate.public_key_hash();
let label = identity
.label
.clone()
.or_else(|| certificate.common_name.clone())
.unwrap_or_else(|| hex::encode(public_key_hash));
let key_info = der::PrivateKeyInfo::parse(&identity.private_key)?;
if !key_info.is_rsa() {
return Err(Error::other(
"only RSA private keys are supported; an EC key would need its own \
KeyType and key-size handling, which is not implemented",
));
}
let key_size = key_info.rsa_key_size_in_bits()?;
let certificate_attributes = {
let relation = self
.schema()
.relation(RecordType::X509_CERTIFICATE)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_X509_CERTIFICATE"))?;
CertificateRecord::for_certificate(&label, &certificate).to_attributes(relation)
};
let key_attributes = {
let relation = self
.schema()
.relation(RecordType::PRIVATE_KEY)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_PRIVATE_KEY"))?;
PrivateKeyRecord::for_private_key(&label, public_key_hash, key_size)
.to_attributes(relation)
};
let key_blob = self.wrap_private_key(
&identity.private_key,
encryption_key.as_slice(),
signing_key.as_slice(),
&label,
key_size,
&identity.trusted_applications,
)?;
let keychain = self.keychain_mut();
keychain.bump_commit_version();
let version = keychain.commit_version.unwrap_or(1);
let key_table = keychain
.table_mut(RecordType::PRIVATE_KEY)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_PRIVATE_KEY"))?;
let number = key_table.next_record_number();
key_table.insert(Record {
number,
version,
unknown3: PRIVATE_KEY_RECORD_UNKNOWN3,
unknown5: 0,
key_data: key_blob,
attributes: key_attributes,
});
let certificate_table = keychain
.table_mut(RecordType::X509_CERTIFICATE)
.ok_or(Error::MissingTable("CSSM_DL_DB_RECORD_X509_CERTIFICATE"))?;
let number = certificate_table.next_record_number();
certificate_table.insert(Record {
number,
version,
unknown3: 0,
unknown5: 0,
key_data: identity.certificate.clone(),
attributes: certificate_attributes,
});
let schema = self.schema().clone();
for touched in [RecordType::PRIVATE_KEY, RecordType::X509_CERTIFICATE] {
let Some(relation) = schema.relation(touched) else {
continue;
};
if let Some(table) = self.keychain_mut().table_mut(touched) {
table.rebuild_indexes(relation)?;
}
}
Ok(public_key_hash)
}
fn ensure_relation(&mut self, record_type: RecordType) -> Result<()> {
if self.keychain().table(record_type).is_some() {
return Ok(());
}
let definition = apple_schema::ON_DEMAND_RELATIONS
.iter()
.find(|relation| relation.relation.relation_id == record_type.0)
.ok_or_else(|| {
Error::other(format!(
"this keychain has no {} table, and adding that relation is not supported",
record_type.name()
))
})?;
let keychain = self.keychain_mut();
let info = keychain
.table_mut(RecordType::SCHEMA_INFO)
.ok_or(Error::MissingTable("CSSM_DL_DB_SCHEMA_INFO"))?;
let number = info.next_record_number();
info.insert(schema_record(
number,
vec![
Some(Value::Uint32(definition.relation.relation_id)),
definition
.relation
.name
.map(|name| Value::String(name.to_vec())),
],
));
let attributes = keychain
.table_mut(RecordType::SCHEMA_ATTRIBUTES)
.ok_or(Error::MissingTable("CSSM_DL_DB_SCHEMA_ATTRIBUTES"))?;
for row in definition.attributes {
let number = attributes.next_record_number();
attributes.insert(schema_record(
number,
vec![
Some(Value::Uint32(row.relation_id)),
Some(Value::Uint32(row.attribute_id)),
Some(Value::Uint32(row.name_format)),
row.name.map(|name| Value::String(name.to_vec())),
row.name_id.map(|id| Value::Blob(id.to_vec())),
Some(Value::Uint32(row.format)),
],
));
}
let indexes = keychain
.table_mut(RecordType::SCHEMA_INDEXES)
.ok_or(Error::MissingTable("CSSM_DL_DB_SCHEMA_INDEXES"))?;
for row in definition.indexes {
let number = indexes.next_record_number();
indexes.insert(schema_record(
number,
vec![
Some(Value::Uint32(row.relation_id)),
Some(Value::Uint32(row.index_id)),
Some(Value::Uint32(row.attribute_id)),
Some(Value::Uint32(row.index_type)),
Some(Value::Uint32(row.indexed_data_location)),
],
));
}
let table = Table {
record_type,
unknown_free_list: definition.free_list,
slots: vec![Slot::Empty],
indexes: TableIndexes::Parsed(index_declarations(definition.indexes)),
};
let at = keychain
.tables
.iter()
.position(|existing| existing.record_type.0 > record_type.0)
.unwrap_or(keychain.tables.len());
keychain.tables.insert(at, table);
self.reload_schema()
}
fn wrap_private_key(
&self,
private_key: &[u8],
encryption_key: &[u8],
signing_key: &[u8],
label: &str,
key_size: u32,
trusted: &[TrustedApplication],
) -> Result<Vec<u8>> {
let mut iv = [0u8; BLOCK_SIZE];
iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
let mut blob = crypto::KeyBlob {
version: BLOB_VERSION,
start_crypto_blob: 0,
total_length: 0,
iv,
header: KeyHeader::private_key(key_size),
wrapped: WrappedKeyFields::item_key(),
blob_signature: [0u8; 20],
public_acl: crypto::PublicAcl::Parsed(if trusted.is_empty() {
AclBlob::for_item(label)
} else {
AclBlob::for_item_trusting(label, trusted.to_vec())
}),
crypto_blob: crypto::wrap_blob(encryption_key, &iv, private_key)?,
};
blob.sign(signing_key);
Ok(blob.to_bytes())
}
#[allow(clippy::too_many_arguments)]
fn wrap_item_key(
&self,
item_key: &SecretBytes,
encryption_key: &[u8],
signing_key: &[u8],
print_name: &str,
trusted: &[TrustedApplication],
) -> Result<Vec<u8>> {
let mut iv = [0u8; BLOCK_SIZE];
iv.copy_from_slice(&crate::secret::random_bytes(BLOCK_SIZE));
let mut blob = crypto::KeyBlob {
version: BLOB_VERSION,
start_crypto_blob: 0,
total_length: 0,
iv,
header: KeyHeader::item_key(),
wrapped: WrappedKeyFields::item_key(),
blob_signature: [0u8; 20],
public_acl: crypto::PublicAcl::Parsed(if trusted.is_empty() {
AclBlob::for_item(print_name)
} else {
AclBlob::for_item_trusting(print_name, trusted.to_vec())
}),
crypto_blob: crypto::wrap_key(encryption_key, &iv, item_key.as_slice())?,
};
blob.sign(signing_key);
Ok(blob.to_bytes())
}
}
pub fn format_timestamp(unix_seconds: i64) -> String {
let (days, seconds) = (
unix_seconds.div_euclid(86_400),
unix_seconds.rem_euclid(86_400),
);
let (year, month, day) = civil_from_days(days);
let (hour, minute, second) = (seconds / 3600, (seconds % 3600) / 60, seconds % 60);
format!("{year:04}{month:02}{day:02}{hour:02}{minute:02}{second:02}Z")
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let day_of_era = z.rem_euclid(146_097);
let year_of_era =
(day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = (day_of_year - (153 * month_prime + 2) / 5 + 1) as u32;
let month = if month_prime < 10 {
month_prime + 3
} else {
month_prime - 9
} as u32;
(if month <= 2 { year + 1 } else { year }, month, day)
}
pub fn now_timestamp() -> String {
let seconds = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
format_timestamp(seconds)
}
pub fn schema_of_created(file: &KeychainFile) -> Result<Schema> {
let schema = file.keychain().schema()?;
for record_type in [
RecordType::GENERIC_PASSWORD,
RecordType::INTERNET_PASSWORD,
RecordType::APPLESHARE_PASSWORD,
] {
let relation = schema
.relation(record_type)
.ok_or_else(|| Error::format("created keychain is missing a password relation"))?;
if relation.index_of("acct").is_none() {
return Err(Error::format(
"created keychain's password relation has no acct",
));
}
}
Ok(schema)
}
pub fn expected_format(name: &str) -> AttributeFormat {
match name {
"cdat" | "mdat" => AttributeFormat::TimeDate,
"port" | "crtr" | "type" | "invi" | "nega" | "cusi" | "ptcl" => AttributeFormat::Uint32,
"scrp" => AttributeFormat::Sint32,
_ => AttributeFormat::Blob,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamps_match_the_keychain_format() {
assert_eq!(format_timestamp(0), "19700101000000Z");
assert_eq!(format_timestamp(1_784_982_896), "20260725123456Z");
assert_eq!(format_timestamp(1_709_164_800), "20240229000000Z");
assert_eq!(format_timestamp(951_782_400), "20000229000000Z");
assert_eq!(now_timestamp().len(), 15);
assert!(now_timestamp().ends_with('Z'));
}
#[test]
fn a_created_keychain_unlocks_and_describes_itself() {
let file = create(b"correct horse", &CreateOptions::default()).unwrap();
assert!(file.is_unlocked());
schema_of_created(&file).unwrap();
let info = file.info().unwrap();
assert_eq!(info.version, VERSION);
assert_eq!(info.tables.len(), apple_schema::TABLES.len());
assert_eq!(info.idle_timeout, 300);
assert!(info.lock_on_sleep);
assert!(file.items().is_empty());
}
#[test]
fn a_created_keychain_rejects_the_wrong_password() {
let file = create(b"right", &CreateOptions::default()).unwrap();
let bytes = file.keychain().to_bytes().unwrap();
let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
assert!(matches!(
reopened.unlock(b"wrong"),
Err(Error::WrongPassword)
));
assert!(!reopened.is_unlocked());
reopened.unlock(b"right").unwrap();
}
#[test]
fn created_keychains_differ_in_salt_and_keys() {
let first = create(b"same password", &CreateOptions::default()).unwrap();
let second = create(b"same password", &CreateOptions::default()).unwrap();
assert_ne!(first.info().unwrap().salt, second.info().unwrap().salt);
assert_ne!(first.info().unwrap().iv, second.info().unwrap().iv);
}
#[test]
fn adding_an_item_stores_a_recoverable_secret() {
let mut file = create(b"master", &CreateOptions::default()).unwrap();
let item = NewItem {
account: Some("alice".into()),
service: Some("myservice".into()),
description: Some("note kind".into()),
..NewItem::default()
};
file.add_password(
RecordType::GENERIC_PASSWORD,
&item,
b"s3cr3t",
"20260725123456Z",
)
.unwrap();
let bytes = file.keychain().to_bytes().unwrap();
let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
reopened.unlock(b"master").unwrap();
let items = reopened.items();
assert_eq!(items.len(), 1);
let stored = &items[0];
assert_eq!(stored.account().as_deref(), Some("alice"));
assert_eq!(stored.service().as_deref(), Some("myservice"));
assert_eq!(stored.label().as_deref(), Some("myservice"));
assert_eq!(stored.created().as_deref(), Some("20260725123456Z"));
assert!(stored.has_secret());
assert_eq!(reopened.secret(stored).unwrap().as_slice(), b"s3cr3t");
assert_eq!(reopened.item_key_count(), 1);
}
#[test]
fn each_item_gets_its_own_key() {
let mut file = create(b"master", &CreateOptions::default()).unwrap();
for (account, secret) in [("a", "one"), ("b", "two"), ("c", "three")] {
let item = NewItem {
account: Some(account.into()),
service: Some("svc".into()),
..NewItem::default()
};
file.add_password(
RecordType::GENERIC_PASSWORD,
&item,
secret.as_bytes(),
"20260725123456Z",
)
.unwrap();
}
let bytes = file.keychain().to_bytes().unwrap();
let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
reopened.unlock(b"master").unwrap();
assert_eq!(reopened.item_key_count(), 3, "one wrapped key per item");
for (account, expected) in [("a", "one"), ("b", "two"), ("c", "three")] {
let item = reopened
.items()
.into_iter()
.find(|item| item.account().as_deref() == Some(account))
.expect("item is present");
assert_eq!(
reopened.secret(&item).unwrap().as_slice(),
expected.as_bytes()
);
}
}
#[test]
fn internet_items_keep_their_network_attributes() {
let mut file = create(b"master", &CreateOptions::default()).unwrap();
let item = NewItem {
account: Some("bob".into()),
server: Some("example.com".into()),
path: Some("/login".into()),
port: Some(8080),
protocol: Some(*b"http"),
auth_type: Some(*b"dflt"),
..NewItem::default()
};
file.add_password(
RecordType::INTERNET_PASSWORD,
&item,
b"pw",
"20260725123456Z",
)
.unwrap();
let bytes = file.keychain().to_bytes().unwrap();
let mut reopened = KeychainFile::from_bytes(&bytes).unwrap();
reopened.unlock(b"master").unwrap();
let items = reopened.items_of_type(RecordType::INTERNET_PASSWORD);
assert_eq!(items.len(), 1);
assert_eq!(items[0].server().as_deref(), Some("example.com"));
assert_eq!(items[0].path().as_deref(), Some("/login"));
assert_eq!(items[0].port(), Some(8080));
assert_eq!(items[0].label().as_deref(), Some("example.com"));
assert_eq!(reopened.secret(&items[0]).unwrap().as_slice(), b"pw");
}
#[test]
fn adding_to_a_locked_keychain_is_refused() {
let file = create(b"master", &CreateOptions::default()).unwrap();
let bytes = file.keychain().to_bytes().unwrap();
let mut locked = KeychainFile::from_bytes(&bytes).unwrap();
let result = locked.add_password(
RecordType::GENERIC_PASSWORD,
&NewItem::default(),
b"secret",
"20260725123456Z",
);
assert!(matches!(result, Err(Error::Locked)));
}
#[test]
fn commit_version_advances_with_each_write() {
let mut file = create(b"master", &CreateOptions::default()).unwrap();
assert_eq!(file.keychain().commit_version, Some(1));
file.add_password(
RecordType::GENERIC_PASSWORD,
&NewItem {
account: Some("a".into()),
..NewItem::default()
},
b"x",
"20260725123456Z",
)
.unwrap();
assert_eq!(file.keychain().commit_version, Some(2));
}
}