Skip to main content

kc/
acl.rs

1//! Public ACL blobs, parsed and produced as structures.
2//!
3//! Every key blob carries a public ACL between its fixed header and its
4//! encrypted region. Apple's serialization of `AclEntryPrototype` is not
5//! published in a form this could be built from, so the layout here was
6//! recovered from keychains written by `security add-generic-password -A`.
7//!
8//! What is established, by parsing samples whose item names are 1, 4, 5, 8, 9,
9//! 11 and 12 bytes long and re-serializing them byte for byte, is the *shape*:
10//!
11//! ```text
12//! blob  := owner entry, entry count, count x entry
13//! entry := kind word, subject words, name, authorization group
14//! name  := bytes, NUL terminator, padding to a 4-byte boundary
15//! ```
16//!
17//! The owner entry has one fewer subject word than the others and no
18//! authorization group. Field *meanings* are not guessed at: the words whose
19//! purpose is unknown are carried in [`Subject::Unknown`] and reproduced as
20//! they were read, and the constants this code writes are the ones macOS writes.
21//! `Authorization` names only the two tag sets that appear.
22
23use crate::error::{Error, Result};
24
25/// Word that opens every entry. Constant in every sample.
26const ENTRY_LEAD: u32 = 0x0000_0000;
27
28/// Second word of every entry. Constant in every sample; purpose unknown.
29const SUBJECT_TAG: u32 = 0x0000_007b;
30
31/// Word that opens every subject, before its type. Constant in every sample.
32const SUBJECT_LEAD: u32 = 0x0000_0001;
33
34/// Two words that close every subject, before the item name. Constant in every
35/// sample; purpose unknown.
36const SUBJECT_TRAILER: [u32; 2] = [0x0101_0000, 0x0101_0000];
37
38/// Subject type granting any application.
39const SUBJECT_ANY: u32 = 0x0000_0001;
40
41/// Subject type naming one trusted application.
42const SUBJECT_TRUSTED_APPLICATION: u32 = 0x0000_0074;
43
44/// Word that follows the trusted-application type. Constant in every sample.
45const TRUSTED_APPLICATION_LEAD: u32 = 0x0000_0001;
46
47/// Length of the legacy code hash in a trusted-application block.
48const LEGACY_HASH_LEN: usize = 20;
49
50/// Which of the two roles an entry plays in the blob.
51///
52/// This is *not* a stored field. The word at that position counts the elements
53/// in the entry's subject, plus one; the owner entry has no subject and so stores
54/// `1`. Reading it as a two-valued kind happens to work for an owner entry and
55/// for a subject with one element, and silently mis-encodes a subject with two —
56/// which is how a two-application ACL came out malformed.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum EntryKind {
59    /// The first entry in the blob: no subject, no authorization group.
60    Owner,
61    /// A subsequent entry: a subject and an authorization group.
62    Authorization,
63}
64
65/// The authorization tag sets that appear in keychain item ACLs.
66///
67/// These are `CSSM_ACL_AUTHORIZATION_TAG` values, and the CSSM headers that name
68/// them are no longer shipped, so the variants are named for what macOS is
69/// *observed* to do with them rather than decoded tag by tag. The distinction
70/// matters: when an item names trusted applications, macOS restricts
71/// [`Self::ItemAccess`] and leaves [`Self::Tag35`] open, so it is `ItemAccess`
72/// that gates use of the item.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Authorization {
75    /// Tag `35` alone. Left open to any application even on a restricted item.
76    Tag35,
77    /// Tags `24, 28, 37, 38, 59, 115` — the set macOS restricts to the trusted
78    /// applications, and therefore the entry that governs the item.
79    ItemAccess,
80    /// Any other tag set, kept as read.
81    Tags(Vec<u32>),
82}
83
84impl Authorization {
85    const TAG_35: [u32; 1] = [35];
86    const ITEM_ACCESS: [u32; 6] = [24, 28, 37, 38, 59, 115];
87
88    pub fn from_tags(tags: Vec<u32>) -> Self {
89        if tags == Self::TAG_35 {
90            Self::Tag35
91        } else if tags == Self::ITEM_ACCESS {
92            Self::ItemAccess
93        } else {
94            Self::Tags(tags)
95        }
96    }
97
98    pub fn tags(&self) -> &[u32] {
99        match self {
100            Self::Tag35 => &Self::TAG_35,
101            Self::ItemAccess => &Self::ITEM_ACCESS,
102            Self::Tags(tags) => tags,
103        }
104    }
105}
106
107/// One application an ACL entry trusts.
108///
109/// The three fields are what macOS stores per trusted application. Only the
110/// requirement is evaluated on current systems: zeroing `legacy_hash` and
111/// re-signing the key blob leaves `security` reading the item, so an ACL written
112/// here supplies zeros for it rather than inventing a value it cannot compute.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct TrustedApplication {
115    /// The application's path, as macOS records it.
116    pub path: String,
117    /// Legacy CDSA code hash. Not the modern cdhash, and not derivable from it.
118    pub legacy_hash: [u8; LEGACY_HASH_LEN],
119    /// The application's designated requirement, magic and length header
120    /// included — byte-identical to the blob in its own code signature.
121    pub requirement: Vec<u8>,
122}
123
124impl TrustedApplication {
125    /// A trusted application identified by its designated requirement, with the
126    /// legacy hash left zeroed.
127    pub fn new(path: impl Into<String>, requirement: Vec<u8>) -> Self {
128        Self {
129            path: path.into(),
130            legacy_hash: [0u8; LEGACY_HASH_LEN],
131            requirement,
132        }
133    }
134
135    /// Length of the second length-prefixed field: the path and the requirement.
136    fn comment_len(&self) -> usize {
137        name_field_len(&self.path) + self.requirement.len()
138    }
139
140    fn encoded_len(&self) -> usize {
141        // type, lead, hash length, hash, comment length, comment. Blocks abut
142        // directly: there is no separator between them.
143        4 * 3 + LEGACY_HASH_LEN + 4 + self.comment_len()
144    }
145
146    fn write(&self, out: &mut Vec<u8>) {
147        push_words(
148            out,
149            &[SUBJECT_TRUSTED_APPLICATION, TRUSTED_APPLICATION_LEAD],
150        );
151        push_words(out, &[LEGACY_HASH_LEN as u32]);
152        out.extend_from_slice(&self.legacy_hash);
153        push_words(out, &[self.comment_len() as u32]);
154        out.extend_from_slice(&name_field(&self.path));
155        out.extend_from_slice(&self.requirement);
156    }
157}
158
159/// Who an ACL entry grants access to.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum Subject {
162    /// Any application. What `security add-generic-password -A` writes, and what
163    /// the owner entry always carries.
164    Any,
165    /// Only these applications.
166    TrustedApplications(Vec<TrustedApplication>),
167    /// A shape this build does not model, preserved word for word.
168    Unknown(Vec<u32>),
169}
170
171impl Subject {
172    /// Number of elements the subject contributes to the entry's element count.
173    fn element_count(&self) -> usize {
174        match self {
175            Self::Any => 1,
176            Self::TrustedApplications(apps) => apps.len(),
177            // An unmodelled subject is reproduced as read, so its count came
178            // from the file.
179            Self::Unknown(_) => 1,
180        }
181    }
182
183    /// The words between the subject lead and the trailer.
184    fn encoded_len(&self) -> usize {
185        match self {
186            Self::Any => 4,
187            Self::TrustedApplications(apps) => {
188                apps.iter().map(TrustedApplication::encoded_len).sum()
189            }
190            Self::Unknown(words) => 4 * words.len(),
191        }
192    }
193
194    fn write(&self, out: &mut Vec<u8>) {
195        match self {
196            Self::Any => push_words(out, &[SUBJECT_ANY]),
197            Self::TrustedApplications(apps) => {
198                for app in apps {
199                    app.write(out);
200                }
201            }
202            Self::Unknown(words) => push_words(out, words),
203        }
204    }
205
206    /// Paths of the applications this subject trusts.
207    pub fn trusted_paths(&self) -> Vec<&str> {
208        match self {
209            Self::TrustedApplications(apps) => apps.iter().map(|app| app.path.as_str()).collect(),
210            _ => Vec::new(),
211        }
212    }
213}
214
215/// One ACL entry.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct AclEntry {
218    pub kind: EntryKind,
219    /// Who the entry grants access to. The owner entry carries no subject type
220    /// at all, which is represented as `None`.
221    pub subject: Option<Subject>,
222    /// The item name the entry names.
223    pub name: String,
224    /// Present on [`EntryKind::Authorization`] entries.
225    pub authorization: Option<Authorization>,
226    /// Two words that precede the authorization group; `0` in every sample.
227    pub authorization_prefix: [u32; 2],
228}
229
230impl AclEntry {
231    /// The stored count word: one more than the subject's element count.
232    fn element_word(&self) -> u32 {
233        1 + self.subject.as_ref().map_or(0, Subject::element_count) as u32
234    }
235
236    fn encoded_len(&self) -> usize {
237        // lead, tag, kind, subject lead, [subject], trailer, name
238        let mut len = 4 * 4 + 4 * SUBJECT_TRAILER.len() + name_field_len(&self.name);
239        if let Some(subject) = &self.subject {
240            len += subject.encoded_len();
241        }
242        if let Some(authorization) = &self.authorization {
243            len += 4 * (2 + 1 + authorization.tags().len());
244        }
245        len
246    }
247}
248
249/// A public ACL blob.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct AclBlob {
252    /// The first entry, followed by a count of the entries after it.
253    pub owner: AclEntry,
254    pub entries: Vec<AclEntry>,
255}
256
257impl AclBlob {
258    /// The ACL macOS writes for an item created with "allow all applications":
259    /// an owner entry plus a decrypt entry and an item-operations entry.
260    pub fn for_item(name: &str) -> Self {
261        Self::for_item_with_subject(name, Subject::Any)
262    }
263
264    /// The same three entries, restricted to specific applications.
265    ///
266    /// macOS restricts the [`Authorization::ItemAccess`] entry and leaves the
267    /// other open, which is what `security add-generic-password -T` writes.
268    /// Putting the applications on the wrong entry yields an ACL that parses and
269    /// looks restricted while granting the item to everyone.
270    pub fn for_item_trusting(name: &str, applications: Vec<TrustedApplication>) -> Self {
271        Self::for_item_with_subject(name, Subject::TrustedApplications(applications))
272    }
273
274    /// Build the standard three entries with `subject` on the entry that governs
275    /// use of the item.
276    pub fn for_item_with_subject(name: &str, subject: Subject) -> Self {
277        Self {
278            owner: AclEntry {
279                kind: EntryKind::Owner,
280                subject: None,
281                name: name.to_string(),
282                authorization: None,
283                authorization_prefix: [0, 0],
284            },
285            entries: vec![
286                AclEntry {
287                    kind: EntryKind::Authorization,
288                    subject: Some(Subject::Any),
289                    name: name.to_string(),
290                    authorization: Some(Authorization::Tag35),
291                    authorization_prefix: [0, 0],
292                },
293                AclEntry {
294                    kind: EntryKind::Authorization,
295                    subject: Some(subject),
296                    name: name.to_string(),
297                    authorization: Some(Authorization::ItemAccess),
298                    authorization_prefix: [0, 0],
299                },
300            ],
301        }
302    }
303
304    /// Applications this ACL restricts the item to, across all of its entries.
305    pub fn trusted_paths(&self) -> Vec<&str> {
306        self.entries
307            .iter()
308            .flat_map(|entry| entry.subject.iter().flat_map(Subject::trusted_paths))
309            .collect()
310    }
311
312    pub fn parse(data: &[u8]) -> Result<Self> {
313        let mut reader = WordReader { data, at: 0 };
314        let owner = reader.entry(EntryKind::Owner)?;
315        let count = reader.u32()? as usize;
316        if count > 64 {
317            return Err(Error::format(format!("ACL claims {count} entries")));
318        }
319        let mut entries = Vec::with_capacity(count);
320        for _ in 0..count {
321            entries.push(reader.entry(EntryKind::Authorization)?);
322        }
323        if reader.at != data.len() {
324            return Err(Error::format(format!(
325                "ACL has {} trailing bytes",
326                data.len() - reader.at
327            )));
328        }
329        Ok(Self { owner, entries })
330    }
331
332    pub fn to_bytes(&self) -> Vec<u8> {
333        let mut out = Vec::with_capacity(self.encoded_len());
334        write_entry(&mut out, &self.owner);
335        out.extend_from_slice(&(self.entries.len() as u32).to_be_bytes());
336        for entry in &self.entries {
337            write_entry(&mut out, entry);
338        }
339        out
340    }
341
342    pub fn encoded_len(&self) -> usize {
343        self.owner.encoded_len()
344            + 4
345            + self
346                .entries
347                .iter()
348                .map(AclEntry::encoded_len)
349                .sum::<usize>()
350    }
351
352    /// The name every entry refers to, when they agree.
353    pub fn item_name(&self) -> Option<&str> {
354        let name = self.owner.name.as_str();
355        self.entries
356            .iter()
357            .all(|entry| entry.name == name)
358            .then_some(name)
359    }
360}
361
362fn write_entry(out: &mut Vec<u8>, entry: &AclEntry) {
363    push_words(
364        out,
365        &[ENTRY_LEAD, SUBJECT_TAG, entry.element_word(), SUBJECT_LEAD],
366    );
367    if let Some(subject) = &entry.subject {
368        subject.write(out);
369    }
370    push_words(out, &SUBJECT_TRAILER);
371    out.extend_from_slice(&name_field(&entry.name));
372    if let Some(authorization) = &entry.authorization {
373        for word in entry.authorization_prefix {
374            out.extend_from_slice(&word.to_be_bytes());
375        }
376        out.extend_from_slice(&(authorization.tags().len() as u32).to_be_bytes());
377        for tag in authorization.tags() {
378            out.extend_from_slice(&tag.to_be_bytes());
379        }
380    }
381}
382
383struct WordReader<'a> {
384    data: &'a [u8],
385    at: usize,
386}
387
388impl WordReader<'_> {
389    fn u32(&mut self) -> Result<u32> {
390        let bytes = self
391            .data
392            .get(self.at..self.at + 4)
393            .ok_or_else(|| Error::format("ACL ends mid-word"))?;
394        self.at += 4;
395        Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
396    }
397
398    fn entry(&mut self, kind: EntryKind) -> Result<AclEntry> {
399        let lead = self.u32()?;
400        if lead != ENTRY_LEAD {
401            return Err(Error::format(format!("ACL entry starts with 0x{lead:08x}")));
402        }
403        let tag = self.u32()?;
404        if tag != SUBJECT_TAG {
405            return Err(Error::format(format!("ACL subject tag is 0x{tag:08x}")));
406        }
407
408        // One more than the number of subject elements that follow.
409        let element_word = self.u32()? as usize;
410        let elements = element_word
411            .checked_sub(1)
412            .ok_or_else(|| Error::format("ACL entry claims zero elements"))?;
413        if elements > 64 {
414            return Err(Error::format(format!(
415                "ACL entry claims {elements} subject elements"
416            )));
417        }
418        match kind {
419            EntryKind::Owner if elements != 0 => {
420                return Err(Error::format(format!(
421                    "the owner entry carries {elements} subject elements"
422                )));
423            }
424            EntryKind::Authorization if elements == 0 => {
425                return Err(Error::format("an authorization entry carries no subject"));
426            }
427            _ => {}
428        }
429
430        let subject_lead = self.u32()?;
431        if subject_lead != SUBJECT_LEAD {
432            return Err(Error::format(format!(
433                "ACL subject lead is 0x{subject_lead:08x}"
434            )));
435        }
436
437        // The owner entry carries no subject; the others name one, made of
438        // exactly `elements` parts.
439        let subject = match kind {
440            EntryKind::Owner => None,
441            EntryKind::Authorization => Some(self.subject(elements)?),
442        };
443
444        for word in SUBJECT_TRAILER {
445            let found = self.u32()?;
446            if found != word {
447                return Err(Error::format(format!(
448                    "ACL subject trailer is 0x{found:08x}, expected 0x{word:08x}"
449                )));
450            }
451        }
452
453        let name = self.name()?;
454        let (authorization, authorization_prefix) = match kind {
455            EntryKind::Owner => (None, [0, 0]),
456            EntryKind::Authorization => {
457                let prefix = [self.u32()?, self.u32()?];
458                let count = self.u32()? as usize;
459                if count > 64 {
460                    return Err(Error::format(format!("ACL entry claims {count} tags")));
461                }
462                let mut tags = Vec::with_capacity(count);
463                for _ in 0..count {
464                    tags.push(self.u32()?);
465                }
466                (Some(Authorization::from_tags(tags)), prefix)
467            }
468        };
469
470        Ok(AclEntry {
471            kind,
472            subject,
473            name,
474            authorization,
475            authorization_prefix,
476        })
477    }
478
479    /// The subject, made of `elements` parts: either the "any" marker, or one
480    /// block per trusted application.
481    fn subject(&mut self, elements: usize) -> Result<Subject> {
482        match self.peek()? {
483            SUBJECT_ANY if elements == 1 => {
484                self.u32()?;
485                Ok(Subject::Any)
486            }
487            SUBJECT_TRUSTED_APPLICATION => {
488                let mut applications = Vec::with_capacity(elements);
489                for _ in 0..elements {
490                    applications.push(self.trusted_application()?);
491                }
492                Ok(Subject::TrustedApplications(applications))
493            }
494            other => Err(Error::format(format!(
495                "unknown ACL subject type 0x{other:08x} with {elements} elements"
496            ))),
497        }
498    }
499
500    fn trusted_application(&mut self) -> Result<TrustedApplication> {
501        self.u32()?; // the subject type, already peeked
502        let lead = self.u32()?;
503        if lead != TRUSTED_APPLICATION_LEAD {
504            return Err(Error::format(format!(
505                "trusted-application lead is 0x{lead:08x}"
506            )));
507        }
508
509        let hash_len = self.u32()? as usize;
510        if hash_len != LEGACY_HASH_LEN {
511            return Err(Error::format(format!(
512                "trusted-application hash is {hash_len} bytes, expected {LEGACY_HASH_LEN}"
513            )));
514        }
515        let hash = self.bytes(LEGACY_HASH_LEN)?;
516
517        // One length-prefixed field holds the path and then the requirement.
518        let comment_len = self.u32()? as usize;
519        let comment = self.bytes(comment_len)?;
520        let path_end = comment
521            .iter()
522            .position(|byte| *byte == 0)
523            .ok_or_else(|| Error::format("trusted-application path is not terminated"))?;
524        let path = String::from_utf8_lossy(&comment[..path_end]).into_owned();
525        let requirement = comment[name_field_len(&path)..].to_vec();
526
527        Ok(TrustedApplication {
528            path,
529            legacy_hash: hash.try_into().expect("checked length"),
530            requirement,
531        })
532    }
533
534    fn peek(&self) -> Result<u32> {
535        let bytes = self
536            .data
537            .get(self.at..self.at + 4)
538            .ok_or_else(|| Error::format("ACL ends mid-word"))?;
539        Ok(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
540    }
541
542    fn bytes(&mut self, len: usize) -> Result<Vec<u8>> {
543        let bytes = self
544            .data
545            .get(self.at..self.at + len)
546            .ok_or_else(|| Error::format("ACL field runs past the blob"))?
547            .to_vec();
548        self.at += len;
549        Ok(bytes)
550    }
551
552    /// A NUL-terminated name, padded to a 4-byte boundary.
553    fn name(&mut self) -> Result<String> {
554        let rest = self
555            .data
556            .get(self.at..)
557            .ok_or_else(|| Error::format("ACL ends at a name"))?;
558        let end = rest
559            .iter()
560            .position(|byte| *byte == 0)
561            .ok_or_else(|| Error::format("ACL name is not terminated"))?;
562        let name = String::from_utf8_lossy(&rest[..end]).into_owned();
563        self.at += name_field_len(&name);
564        Ok(name)
565    }
566}
567
568/// Encoded length of a name: the bytes, a NUL, then padding to 4 bytes. A name
569/// whose length is already a multiple of 4 still gains a whole word.
570fn name_field_len(name: &str) -> usize {
571    (name.len() + 1 + 3) & !3
572}
573
574fn push_words(out: &mut Vec<u8>, words: &[u32]) {
575    for word in words {
576        out.extend_from_slice(&word.to_be_bytes());
577    }
578}
579
580fn name_field(name: &str) -> Vec<u8> {
581    let mut field = vec![0u8; name_field_len(name)];
582    field[..name.len()].copy_from_slice(name.as_bytes());
583    field
584}
585
586/// The public ACL for an item's key, naming the item.
587pub fn item_public_acl(item_name: &str) -> Vec<u8> {
588    AclBlob::for_item(item_name).to_bytes()
589}
590
591/// The public ACL of the database blob itself, which is the same in every
592/// keychain macOS writes and does not follow the entry layout above.
593pub fn database_public_acl() -> Vec<u8> {
594    DATABASE_PUBLIC_ACL.to_vec()
595}
596
597const DATABASE_PUBLIC_ACL: [u8; 28] = [
598    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00,
599    0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
600];
601
602#[cfg(test)]
603mod tests {
604    use super::*;
605
606    #[test]
607    fn name_field_is_nul_terminated_and_padded() {
608        assert_eq!(name_field("a"), b"a\0\0\0");
609        assert_eq!(name_field("abcd"), b"abcd\0\0\0\0");
610        assert_eq!(name_field("abcdefgh"), b"abcdefgh\0\0\0\0");
611        assert_eq!(name_field("abcdefghi"), b"abcdefghi\0\0\0");
612        assert_eq!(name_field("example.com"), b"example.com\0");
613        assert_eq!(name_field(""), b"\0\0\0\0");
614    }
615
616    #[test]
617    fn built_acl_round_trips_through_its_own_parser() {
618        for name in ["a", "abcd", "myservice", "example.com", "abcdefghijkl", ""] {
619            let blob = AclBlob::for_item(name);
620            let bytes = blob.to_bytes();
621            assert_eq!(
622                bytes.len(),
623                blob.encoded_len(),
624                "length agrees for {name:?}"
625            );
626
627            let parsed = AclBlob::parse(&bytes).unwrap();
628            assert_eq!(parsed, blob, "structure survives a round trip for {name:?}");
629            assert_eq!(parsed.to_bytes(), bytes);
630            assert_eq!(parsed.item_name(), Some(name));
631        }
632    }
633
634    #[test]
635    fn built_acl_has_the_expected_structure() {
636        let blob = AclBlob::for_item("myservice");
637        assert_eq!(blob.owner.kind, EntryKind::Owner);
638        assert!(blob.owner.authorization.is_none());
639        assert_eq!(blob.entries.len(), 2);
640        assert_eq!(blob.entries[0].authorization, Some(Authorization::Tag35));
641        assert_eq!(
642            blob.entries[1].authorization,
643            Some(Authorization::ItemAccess)
644        );
645        // The owner entry carries no subject type at all.
646        assert!(blob.owner.subject.is_none());
647        assert_eq!(blob.entries[0].subject, Some(Subject::Any));
648    }
649
650    /// Lengths observed in keychains written by macOS.
651    #[test]
652    fn acl_lengths_match_the_observed_samples() {
653        for (name, expected) in [
654            ("a", 148),
655            ("abcd", 160),
656            ("abcdefgh", 172),
657            ("abcdefghijkl", 184),
658            ("abcdefghi", 172),
659            ("myservice", 172),
660            ("other", 160),
661            ("example.com", 172),
662        ] {
663            assert_eq!(item_public_acl(name).len(), expected, "name {name:?}");
664        }
665    }
666
667    /// The full byte pattern for the one-character sample, transcribed from a
668    /// keychain written by `security add-generic-password -A`.
669    #[test]
670    fn acl_bytes_match_the_observed_sample() {
671        let expected = concat!(
672            "00000000", "0000007b", "00000001", "00000001", "01010000", "01010000", "61000000",
673            "00000002", //
674            "00000000", "0000007b", "00000002", "00000001", "00000001", "01010000", "01010000",
675            "61000000", "00000000", "00000000", "00000001", "00000023", //
676            "00000000", "0000007b", "00000002", "00000001", "00000001", "01010000", "01010000",
677            "61000000", "00000000", "00000000", "00000006", "00000018", "0000001c", "00000025",
678            "00000026", "0000003b", "00000073",
679        );
680        // macOS writes the two authorization entries in either order, so the
681        // sample is compared as a structure with them sorted.
682        let parsed = AclBlob::parse(&hex::decode(expected).unwrap()).unwrap();
683        let generated = AclBlob::for_item("a");
684        assert_eq!(parsed.owner, generated.owner);
685        assert_eq!(sorted_entries(&parsed), sorted_entries(&generated));
686        assert_eq!(parsed.encoded_len(), generated.encoded_len());
687    }
688
689    /// Entries ordered by their tag sets, for comparing ACLs that macOS may have
690    /// written in either order.
691    fn sorted_entries(blob: &AclBlob) -> Vec<AclEntry> {
692        let mut entries = blob.entries.clone();
693        entries.sort_by_key(|entry| {
694            entry
695                .authorization
696                .as_ref()
697                .map(|authorization| authorization.tags().to_vec())
698        });
699        entries
700    }
701
702    #[test]
703    fn parser_rejects_malformed_blobs() {
704        assert!(AclBlob::parse(&[]).is_err());
705        assert!(AclBlob::parse(&[0u8; 8]).is_err(), "no subject tag");
706
707        // Trailing bytes mean the layout was misread, so they are an error
708        // rather than something to ignore.
709        let mut bytes = item_public_acl("a");
710        bytes.push(0);
711        assert!(AclBlob::parse(&bytes).is_err());
712
713        // An unterminated name.
714        let mut bytes = item_public_acl("a");
715        let len = bytes.len();
716        bytes[24..len.min(28)].fill(0x41);
717        assert!(AclBlob::parse(&bytes).is_err());
718    }
719
720    #[test]
721    fn authorization_tag_sets_are_recognized_and_preserved() {
722        assert_eq!(Authorization::from_tags(vec![35]), Authorization::Tag35);
723        assert_eq!(
724            Authorization::from_tags(vec![24, 28, 37, 38, 59, 115]),
725            Authorization::ItemAccess
726        );
727        let other = Authorization::from_tags(vec![1, 2]);
728        assert_eq!(other, Authorization::Tags(vec![1, 2]));
729        assert_eq!(other.tags(), &[1, 2]);
730    }
731
732    /// The exact bytes macOS wrote for an item created with
733    /// `-T /usr/bin/security`, from the subject type onward. Transcribed from a
734    /// keychain this machine produced.
735    const TRUSTED_SUBJECT_SAMPLE: &str = concat!(
736        "00000074", // subject type: trusted application
737        "00000001", // lead
738        "00000014", // legacy hash length: 20
739        "014b034370a7a0b4b319a58e182cc37a320784e2",
740        "00000044", // comment length: 68 = path field (20) + requirement (48)
741        "2f7573722f62696e2f7365637572697479000000", // "/usr/bin/security" + NUL, padded to 20
742        // the binary's designated requirement, magic and all
743        // the binary's designated requirement, which itself ends in 00000003
744        "fade0c000000003000000001000000060000000200000012",
745        "636f6d2e6170706c652e7365637572697479000000000003",
746    );
747
748    fn sample_application() -> TrustedApplication {
749        TrustedApplication {
750            path: "/usr/bin/security".to_string(),
751            legacy_hash: hex::decode("014b034370a7a0b4b319a58e182cc37a320784e2")
752                .unwrap()
753                .try_into()
754                .unwrap(),
755            requirement: hex::decode(concat!(
756                "fade0c000000003000000001000000060000000200000012",
757                "636f6d2e6170706c652e7365637572697479000000000003",
758            ))
759            .unwrap(),
760        }
761    }
762
763    #[test]
764    fn a_trusted_application_block_matches_the_bytes_macos_wrote() {
765        let mut out = Vec::new();
766        sample_application().write(&mut out);
767        assert_eq!(hex::encode(&out), TRUSTED_SUBJECT_SAMPLE.replace(' ', ""));
768    }
769
770    #[test]
771    fn trusted_application_acls_round_trip() {
772        for applications in [
773            vec![sample_application()],
774            vec![
775                sample_application(),
776                TrustedApplication::new("/bin/ls", vec![0xfa, 0xde, 0x0c, 0x00, 0, 0, 0, 8]),
777            ],
778        ] {
779            let blob = AclBlob::for_item_trusting("item", applications.clone());
780            let bytes = blob.to_bytes();
781            assert_eq!(bytes.len(), blob.encoded_len());
782
783            let parsed = AclBlob::parse(&bytes).unwrap();
784            assert_eq!(parsed, blob);
785            assert_eq!(
786                parsed.trusted_paths(),
787                applications
788                    .iter()
789                    .map(|app| app.path.as_str())
790                    .collect::<Vec<_>>()
791            );
792            // Only the item-access entry is restricted; the rest stay open.
793            assert_eq!(parsed.entries[0].subject, Some(Subject::Any));
794            assert_eq!(
795                parsed.entries[1].authorization,
796                Some(Authorization::ItemAccess),
797                "the restricted entry must be the one macOS restricts"
798            );
799            assert!(parsed.owner.subject.is_none());
800        }
801    }
802
803    #[test]
804    fn a_new_trusted_application_leaves_the_legacy_hash_zeroed() {
805        let app = TrustedApplication::new("/bin/ls", vec![0xfa, 0xde, 0x0c, 0x00, 0, 0, 0, 8]);
806        assert_eq!(app.legacy_hash, [0u8; LEGACY_HASH_LEN]);
807        // It still round-trips, so a zeroed hash is representable.
808        let blob = AclBlob::for_item_trusting("x", vec![app]);
809        assert_eq!(AclBlob::parse(&blob.to_bytes()).unwrap(), blob);
810    }
811
812    #[test]
813    fn an_allow_any_acl_reports_no_trusted_paths() {
814        assert!(AclBlob::for_item("x").trusted_paths().is_empty());
815    }
816
817    #[test]
818    fn database_acl_is_the_observed_length() {
819        assert_eq!(database_public_acl().len(), 28);
820    }
821}