From e3cc7b4c6a505efecf1324c6a5bdde3929f8f196 Mon Sep 17 00:00:00 2001 From: Ryan Date: Wed, 4 Nov 2026 09:57:26 +1100 Subject: [PATCH 01/21] soc: apple: sep: sketch a password-secret user key bag (design draft) Design prototype for a macOS-style Touch ID security model: a per-user key bag whose secret is the login password, locked on screen lock after a grace period and on suspend, and closed from boot until the first password unlock. This adds: - userbag.rs: the biometric policy macOS documents (password after boot and logout, after 48 h without an unlock, after 156 h without the password and 4 h without a fingerprint, after five failed matches), the screen-lock grace period and the suspend lock, with KUnit tests; - the unlock request, which reuses the identity-bag unlock the driver already sends, addressed to the user's handle (-uid); - draft UAPI for the root-only node: state, unlock, lock, change, create. The lock, the secret change and the lock-state read are stubs that return EOPNOTSUPP: their wire format is not known yet. Nothing is wired to a device node, and no existing behaviour changes. Assisted-by: LLM Co-Authored-By: Claude Opus 5.5 --- drivers/soc/apple/sep.rs | 1 + drivers/soc/apple/userbag.rs | 567 +++++++++++++++++++++++++++++++++ include/uapi/linux/apple-sep.h | 62 ++++ 3 files changed, 630 insertions(+) create mode 100644 drivers/soc/apple/userbag.rs diff --git a/drivers/soc/apple/sep.rs b/drivers/soc/apple/sep.rs index 5fd41c5bebf60..afcb5bf615564 100644 --- a/drivers/soc/apple/sep.rs +++ b/drivers/soc/apple/sep.rs @@ -37,6 +37,7 @@ mod sks; mod store; mod transfer; mod trusted; +mod userbag; mod xarm; mod xart_apfs; mod xart_guard; diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs new file mode 100644 index 0000000000000..aa9bc8f65ff05 --- /dev/null +++ b/drivers/soc/apple/userbag.rs @@ -0,0 +1,567 @@ +// SPDX-License-Identifier: GPL-2.0-only OR MIT + +//! DESIGN PROTOTYPE: a per-user key bag whose secret is the login password. +//! +//! Today the driver unlocks the designated identity bag at boot with a secret +//! it keeps on disk, and never locks it. This module sketches the macOS model +//! instead: +//! +//! * the machine bag (ref-key, trusted keys, system Catacomb) stays as it is; +//! * each Unix user who enrols gets a second bag, created with that user's +//! password as the secret, and designated as the biometric user. The +//! enclave entangles the secret with its own device key, so the bag cannot +//! be opened off the machine, and root on the machine cannot open it +//! without the password; +//! * the bag stays locked from boot until the first password unlock, locks +//! on screen lock after a grace period and at once on suspend; +//! * Touch ID is offered only while [`Policy::biometry_allowed`] says so. +//! +//! What is wired today: the policy (pure, tested) and the unlock request, +//! which reuses the unlock layout the driver already sends for the identity +//! bag. What is NOT: the lock, the passcode change and the lock-state read. +//! Their wire values have not been captured yet; each is a `TODO(capture)` +//! naming the capture that fills it. Nothing here invents a wire value. +//! +//! The ioctls (UAPI `SEP_BIO_KEYBAG_*`) are meant for the root-only re-arm +//! node, so fprintd never sees a password. The dispatcher is not hooked to a +//! file yet. + +#![allow(dead_code)] + +use kernel::prelude::*; + +/// macOS: "five unsuccessful biometric authentication match attempts". +pub(crate) const MAX_FAILED_MATCHES: u32 = 5; +/// macOS: "hasn't unlocked their device for more than 48 hours". +pub(crate) const NO_UNLOCK_LIMIT_S: u64 = 48 * 3600; +/// macOS: no password unlock for 156 hours (six and a half days) ... +pub(crate) const NO_PASSWORD_LIMIT_S: u64 = 156 * 3600; +/// ... and no biometric unlock in four hours. +pub(crate) const NO_BIOMETRY_LIMIT_S: u64 = 4 * 3600; +/// Longest grace period a lock request may ask for. The default comes from +/// the user's desktop setting, not from here. +pub(crate) const MAX_GRACE_S: u32 = 3600; + +/// Longest secret accepted. TODO(capture N1): the enclave's own limit for a +/// bag secret is unknown; macOS passes the password bytes as typed. +pub(crate) const SECRET_MAX: usize = 256; + +/// The lowest designated user the key store accepts (`DesignateUser`). +pub(crate) const MIN_UID: u32 = crate::sks::SKS_DESIGNATE_USER_MIN as u32; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum BagState { + /// Since boot (or logout) no password has opened the bag. + LockedSinceBoot, + Unlocked, + /// Locked by the screen; class keys stay until `deadline_s`. + Locking { + deadline_s: u64, + }, + Locked, +} + +impl BagState { + pub(crate) const fn uapi(self) -> u32 { + match self { + BagState::LockedSinceBoot => 0, + BagState::Unlocked => 1, + BagState::Locking { .. } => 2, + BagState::Locked => 3, + } + } +} + +/// Why Touch ID is not offered; the reason the lock screen shows. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Refusal { + PasswordSinceBoot, + NoUnlockIn48h, + NoPasswordIn156hAndNoBiometryIn4h, + FailedMatches, +} + +impl Refusal { + pub(crate) const fn uapi(self) -> u32 { + match self { + Refusal::PasswordSinceBoot => 1, + Refusal::NoUnlockIn48h => 2, + Refusal::NoPasswordIn156hAndNoBiometryIn4h => 3, + Refusal::FailedMatches => 4, + } + } +} + +/// What the driver must tell the enclave after a policy step. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum EnclaveAction { + Nothing, + /// Send the bag lock (TODO(capture N2)). + LockBag, +} + +/// Host-side copy of the macOS rules for one user's bag. Times are +/// CLOCK_BOOTTIME seconds, so time asleep counts, as it does on macOS. +/// +/// Once the user bag is real the enclave enforces the part that matters (no +/// keys without the password after boot). This copy decides when to offer +/// Touch ID at all and when to send the lock. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Policy { + state: BagState, + last_unlock_s: Option, + last_password_s: Option, + last_biometry_s: Option, + failed_matches: u32, +} + +impl Policy { + pub(crate) const fn at_boot() -> Policy { + Policy { + state: BagState::LockedSinceBoot, + last_unlock_s: None, + last_password_s: None, + last_biometry_s: None, + failed_matches: 0, + } + } + + pub(crate) fn state(&self) -> BagState { + self.state + } + + pub(crate) fn failed_matches(&self) -> u32 { + self.failed_matches + } + + pub(crate) fn since_password_s(&self, now_s: u64) -> Option { + self.last_password_s.map(|t| now_s.saturating_sub(t)) + } + + pub(crate) fn since_unlock_s(&self, now_s: u64) -> Option { + self.last_unlock_s.map(|t| now_s.saturating_sub(t)) + } + + /// The macOS rule set, in the order the lock screen should report it. + pub(crate) fn biometry_allowed(&self, now_s: u64) -> core::result::Result<(), Refusal> { + let Some(password) = self.last_password_s else { + return Err(Refusal::PasswordSinceBoot); + }; + if self.failed_matches >= MAX_FAILED_MATCHES { + return Err(Refusal::FailedMatches); + } + let unlock = self.last_unlock_s.unwrap_or(password); + if now_s.saturating_sub(unlock) > NO_UNLOCK_LIMIT_S { + return Err(Refusal::NoUnlockIn48h); + } + let no_password = now_s.saturating_sub(password) > NO_PASSWORD_LIMIT_S; + let no_biometry = self + .last_biometry_s + .map_or(true, |t| now_s.saturating_sub(t) > NO_BIOMETRY_LIMIT_S); + if no_password && no_biometry { + return Err(Refusal::NoPasswordIn156hAndNoBiometryIn4h); + } + Ok(()) + } + + /// The enclave accepted the password: the only way out of every refusal, + /// the lockout included (macOS sends no separate clear). + pub(crate) fn password_unlocked(&mut self, now_s: u64) { + self.state = BagState::Unlocked; + self.last_unlock_s = Some(now_s); + self.last_password_s = Some(now_s); + self.failed_matches = 0; + } + + /// A match unlocked the bag. Refused (and nothing changes) when the + /// rules do not allow Touch ID now. + pub(crate) fn biometry_unlocked(&mut self, now_s: u64) -> core::result::Result<(), Refusal> { + self.biometry_allowed(now_s)?; + self.state = BagState::Unlocked; + self.last_unlock_s = Some(now_s); + self.last_biometry_s = Some(now_s); + self.failed_matches = 0; + Ok(()) + } + + pub(crate) fn match_failed(&mut self) { + self.failed_matches = self.failed_matches.saturating_add(1); + } + + /// Screen lock. A zero grace period locks at once. + pub(crate) fn screen_locked(&mut self, now_s: u64, grace_s: u32) -> EnclaveAction { + match self.state { + BagState::Unlocked if grace_s == 0 => { + self.state = BagState::Locked; + EnclaveAction::LockBag + } + BagState::Unlocked => { + let grace = u64::from(grace_s.min(MAX_GRACE_S)); + self.state = BagState::Locking { + deadline_s: now_s.saturating_add(grace), + }; + EnclaveAction::Nothing + } + _ => EnclaveAction::Nothing, + } + } + + /// Called from a timer and before every verify. + pub(crate) fn tick(&mut self, now_s: u64) -> EnclaveAction { + match self.state { + BagState::Locking { deadline_s } if now_s >= deadline_s => { + self.state = BagState::Locked; + EnclaveAction::LockBag + } + _ => EnclaveAction::Nothing, + } + } + + /// Suspend locks at once, grace or not ("locks ... every time it goes to + /// sleep" on a Mac laptop with Touch ID). + pub(crate) fn suspending(&mut self) -> EnclaveAction { + match self.state { + BagState::Unlocked | BagState::Locking { .. } => { + self.state = BagState::Locked; + EnclaveAction::LockBag + } + _ => EnclaveAction::Nothing, + } + } + + /// Logout: as after a restart, only the password opens the bag again. + pub(crate) fn logged_out(&mut self) -> EnclaveAction { + let was_open = matches!(self.state, BagState::Unlocked | BagState::Locking { .. }); + *self = Policy::at_boot(); + if was_open { + EnclaveAction::LockBag + } else { + EnclaveAction::Nothing + } + } +} + +/// The UAPI `struct sep_keybag_unlock` (also used to create the bag). +#[repr(C)] +pub(crate) struct UnlockRequest { + pub(crate) uid: u32, + pub(crate) secret_len: u32, + pub(crate) secret: [u8; SECRET_MAX], +} + +/// The UAPI `struct sep_keybag_lock`. +#[repr(C)] +pub(crate) struct LockRequest { + pub(crate) uid: u32, + pub(crate) reason: u32, + pub(crate) grace_s: u32, + pub(crate) reserved: u32, +} + +pub(crate) const LOCK_REASON_SCREEN: u32 = 1; +pub(crate) const LOCK_REASON_SUSPEND: u32 = 2; +pub(crate) const LOCK_REASON_LOGOUT: u32 = 3; + +/// The UAPI `struct sep_keybag_state`. +#[repr(C)] +pub(crate) struct StateReply { + pub(crate) uid: u32, + pub(crate) bag_state: u32, + /// 0 when Touch ID may be offered, otherwise a `Refusal`. + pub(crate) biometry: u32, + pub(crate) failed_matches: u32, + /// `u64::MAX` when there was none since boot. + pub(crate) since_password_s: u64, + pub(crate) since_unlock_s: u64, + /// The enclave's own lock-state word. TODO(capture N4): the read op and + /// its reply layout are unknown, so this stays 0. + pub(crate) enclave_lock_state: u32, + pub(crate) reserved: u32, +} + +static_assert!(core::mem::size_of::() == 8 + SECRET_MAX); +static_assert!(core::mem::size_of::() == 16); +static_assert!(core::mem::size_of::() == 40); + +/// Checks an unlock/create request and returns the user and the secret. +pub(crate) fn parse_unlock(req: &UnlockRequest) -> Result<(crate::sks::DesignateUser, &[u8])> { + let len = req.secret_len as usize; + if len == 0 || len > SECRET_MAX || req.uid < MIN_UID || req.uid > i32::MAX as u32 { + return Err(EINVAL); + } + let user = crate::sks::DesignateUser::new(req.uid as i32).ok_or(EINVAL)?; + Ok((user, &req.secret[..len])) +} + +pub(crate) fn parse_lock(req: &LockRequest) -> Result<(u32, u32, u32)> { + if req.reserved != 0 || req.uid < MIN_UID || req.uid > i32::MAX as u32 { + return Err(EINVAL); + } + match req.reason { + LOCK_REASON_SCREEN | LOCK_REASON_SUSPEND | LOCK_REASON_LOGOUT => {} + _ => return Err(EINVAL), + } + Ok((req.uid, req.reason, req.grace_s.min(MAX_GRACE_S))) +} + +pub(crate) fn state_reply(uid: u32, p: &Policy, now_s: u64) -> StateReply { + StateReply { + uid, + bag_state: p.state().uapi(), + biometry: match p.biometry_allowed(now_s) { + Ok(()) => 0, + Err(r) => r.uapi(), + }, + failed_matches: p.failed_matches(), + since_password_s: p.since_password_s(now_s).unwrap_or(u64::MAX), + since_unlock_s: p.since_unlock_s(now_s).unwrap_or(u64::MAX), + enclave_lock_state: 0, + reserved: 0, + } +} + +impl crate::SepData { + /// Opens a user's bag with the password. KNOWN: this is the unlock the + /// driver already sends for the identity bag (0x04 state 0 on variant-5 + /// key stores, 0x18 state 0 on 13.5), addressed to the user's special + /// handle (-uid, the same convention macOS uses for its user bags). + /// + /// TODO(capture N1): that a bag CREATED with a password answers this + /// unlock exactly as the identity bag does. macOS on J700 unlocks its + /// user bag with 0x18 carrying the typed password; this path sends 0x04 + /// on J700. Confirm on hardware which one a user bag needs. + pub(crate) fn userbag_unlock( + &self, + user: crate::sks::DesignateUser, + secret: &[u8], + ) -> Result<()> { + let special = user.special_handle(); + self.sks_step(crate::sks::SKS_LOCK_STATE_NAME, |healthy| { + self.sks_req_unlock_special(special, secret, healthy) + }) + .map(|_| ()) + .ok_or(EACCES) + } + + /// TODO(capture N2): the bag lock. The macOS trace shows a + /// DEVICE_STATE_TRANSITION (0x18) for the user's handle with state 1 and + /// no secret, sent by the login window at screen lock. The body on the + /// wire (the flags word in particular) was not dumped. Capture: m1n1 HV + /// `J700_SEP_SWEEP=sks:0x18:...` at a macOS screen lock, or a dtrace + /// tracemem of the request image. Until then this refuses. + pub(crate) fn userbag_lock(&self, _user: crate::sks::DesignateUser) -> Result<()> { + Err(EOPNOTSUPP) + } + + /// TODO(capture N3): change the bag secret (password change). Neither the + /// opcode nor the body is known. Capture: a macOS password change in + /// System Settings under the same dtrace method, plus the HV sweep for + /// the payload. Until then a password change goes through the recovery + /// path in DESIGN.md (unlock with the old password, re-create, migrate). + pub(crate) fn userbag_change_secret( + &self, + _user: crate::sks::DesignateUser, + _old: &[u8], + _new: &[u8], + ) -> Result<()> { + Err(EOPNOTSUPP) + } +} + +#[cfg(CONFIG_APPLE_SEP_KUNIT_TEST)] +#[::kernel::macros::kunit_tests(apple_sep_userbag_policy)] +mod tests { + use super::*; + + const H: u64 = 3600; + const T0: u64 = 1000; + + fn unlocked_at(t: u64) -> Policy { + let mut p = Policy::at_boot(); + p.password_unlocked(t); + p + } + + #[test] + fn password_is_required_after_boot() { + let mut p = Policy::at_boot(); + assert_eq!(p.state(), BagState::LockedSinceBoot); + assert_eq!(p.biometry_allowed(T0), Err(Refusal::PasswordSinceBoot)); + assert_eq!(p.biometry_unlocked(T0), Err(Refusal::PasswordSinceBoot)); + assert_eq!(p.state(), BagState::LockedSinceBoot); + p.password_unlocked(T0); + assert_eq!(p.biometry_allowed(T0 + 1), Ok(())); + } + + #[test] + fn five_failed_matches_lock_out_until_the_password() { + let mut p = unlocked_at(T0); + for _ in 0..MAX_FAILED_MATCHES - 1 { + p.match_failed(); + } + assert_eq!(p.biometry_allowed(T0 + 1), Ok(())); + p.match_failed(); + assert_eq!(p.biometry_allowed(T0 + 1), Err(Refusal::FailedMatches)); + // A match cannot clear it; only the password does. + assert_eq!(p.biometry_unlocked(T0 + 2), Err(Refusal::FailedMatches)); + p.password_unlocked(T0 + 3); + assert_eq!(p.failed_matches(), 0); + assert_eq!(p.biometry_allowed(T0 + 4), Ok(())); + } + + #[test] + fn a_successful_match_resets_the_failure_count() { + let mut p = unlocked_at(T0); + p.match_failed(); + p.match_failed(); + assert_eq!(p.biometry_unlocked(T0 + 1), Ok(())); + assert_eq!(p.failed_matches(), 0); + } + + #[test] + fn no_unlock_for_48_hours_needs_the_password() { + let mut p = unlocked_at(T0); + assert_eq!(p.biometry_allowed(T0 + 48 * H), Ok(())); + assert_eq!( + p.biometry_allowed(T0 + 48 * H + 1), + Err(Refusal::NoUnlockIn48h) + ); + // Touch ID unlocks restart the 48 h window. + assert_eq!(p.biometry_unlocked(T0 + 47 * H), Ok(())); + assert_eq!(p.biometry_allowed(T0 + 94 * H), Ok(())); + } + + #[test] + fn the_156_hour_rule_needs_both_conditions() { + let mut p = unlocked_at(T0); + // Keep unlocking by finger every 3 h: the 4 h condition never holds. + let mut t = T0; + while t < T0 + 160 * H { + t += 3 * H; + assert_eq!(p.biometry_unlocked(t), Ok(())); + } + assert!(t - T0 > NO_PASSWORD_LIMIT_S); + assert_eq!(p.biometry_allowed(t + 4 * H), Ok(())); + // Over 156 h since the password and over 4 h since a finger. + assert_eq!( + p.biometry_allowed(t + 4 * H + 1), + Err(Refusal::NoPasswordIn156hAndNoBiometryIn4h) + ); + } + + #[test] + fn screen_lock_waits_for_the_grace_period() { + let mut p = unlocked_at(T0); + assert_eq!(p.screen_locked(T0, 10), EnclaveAction::Nothing); + assert_eq!( + p.state(), + BagState::Locking { + deadline_s: T0 + 10 + } + ); + assert_eq!(p.tick(T0 + 9), EnclaveAction::Nothing); + assert_eq!(p.tick(T0 + 10), EnclaveAction::LockBag); + assert_eq!(p.state(), BagState::Locked); + assert_eq!(p.tick(T0 + 11), EnclaveAction::Nothing); + } + + #[test] + fn a_match_inside_the_grace_period_keeps_the_bag_open() { + let mut p = unlocked_at(T0); + p.screen_locked(T0, 10); + assert_eq!(p.biometry_unlocked(T0 + 4), Ok(())); + assert_eq!(p.state(), BagState::Unlocked); + assert_eq!(p.tick(T0 + 20), EnclaveAction::Nothing); + } + + #[test] + fn zero_grace_and_suspend_lock_at_once() { + let mut p = unlocked_at(T0); + assert_eq!(p.screen_locked(T0, 0), EnclaveAction::LockBag); + let mut q = unlocked_at(T0); + q.screen_locked(T0, 300); + assert_eq!(q.suspending(), EnclaveAction::LockBag); + assert_eq!(q.state(), BagState::Locked); + assert_eq!(q.suspending(), EnclaveAction::Nothing); + // Touch ID may still open a bag locked by sleep. + assert_eq!(q.biometry_unlocked(T0 + 60), Ok(())); + } + + #[test] + fn grace_is_capped() { + let mut p = unlocked_at(T0); + p.screen_locked(T0, u32::MAX); + assert_eq!( + p.state(), + BagState::Locking { + deadline_s: T0 + u64::from(MAX_GRACE_S) + } + ); + } + + #[test] + fn logout_behaves_like_a_restart() { + let mut p = unlocked_at(T0); + assert_eq!(p.logged_out(), EnclaveAction::LockBag); + assert_eq!(p.biometry_allowed(T0 + 1), Err(Refusal::PasswordSinceBoot)); + assert_eq!(p.logged_out(), EnclaveAction::Nothing); + } + + #[test] + fn requests_are_checked() { + let mut req = UnlockRequest { + uid: 1000, + secret_len: 4, + secret: [0x61; SECRET_MAX], + }; + let (user, secret) = parse_unlock(&req).expect("valid request"); + assert_eq!(user.special_handle().value(), -1000); + assert_eq!(secret.len(), 4); + req.secret_len = 0; + assert!(parse_unlock(&req).is_err()); + req.secret_len = SECRET_MAX as u32 + 1; + assert!(parse_unlock(&req).is_err()); + req.secret_len = 4; + req.uid = 0; + assert!(parse_unlock(&req).is_err()); + req.uid = u32::MAX; + assert!(parse_unlock(&req).is_err()); + + let mut lock = LockRequest { + uid: 1000, + reason: LOCK_REASON_SCREEN, + grace_s: 99999, + reserved: 0, + }; + assert_eq!( + parse_lock(&lock).ok(), + Some((1000, LOCK_REASON_SCREEN, MAX_GRACE_S)) + ); + lock.reason = 0; + assert!(parse_lock(&lock).is_err()); + lock.reason = LOCK_REASON_SUSPEND; + lock.reserved = 1; + assert!(parse_lock(&lock).is_err()); + } + + #[test] + fn the_user_bag_handle_is_minus_the_uid() { + let user = crate::sks::DesignateUser::new(501).expect("user"); + assert_eq!(user.special_handle().value(), -501); + } + + #[test] + fn state_reply_reports_the_refusal() { + let p = Policy::at_boot(); + let r = state_reply(1000, &p, T0); + assert_eq!(r.bag_state, 0); + assert_eq!(r.biometry, Refusal::PasswordSinceBoot.uapi()); + assert_eq!(r.since_password_s, u64::MAX); + let q = unlocked_at(T0); + let r = state_reply(1000, &q, T0 + 5); + assert_eq!(r.bag_state, 1); + assert_eq!(r.biometry, 0); + assert_eq!(r.since_password_s, 5); + assert_eq!(r.enclave_lock_state, 0); + } +} diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index fbae982a4c0ef..7466655b94769 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -148,4 +148,66 @@ struct sep_bio_attest { */ #define SEP_BIO_PREPARE_ENROL _IOW(SEP_BIO_IOC_MAGIC, 0x0d, struct sep_bio_enrol_start) +/* + * DESIGN DRAFT, not a stable ABI: the per-user key bag whose secret is the + * login password. Root only, on the re-arm node, never on /dev/sep-bio. The + * password passes through once per call and the kernel keeps no copy. + * 0x0e and 0x0f are taken by the lockout re-arm. + */ +#define SEP_KEYBAG_SECRET_MAX 256 + +struct sep_keybag_unlock { /* UNLOCK and CREATE */ + __u32 uid; + __u32 secret_len; + __u8 secret[SEP_KEYBAG_SECRET_MAX]; +}; + +#define SEP_KEYBAG_LOCK_SCREEN 1 /* after grace_s */ +#define SEP_KEYBAG_LOCK_SUSPEND 2 /* at once */ +#define SEP_KEYBAG_LOCK_LOGOUT 3 /* at once; the password is needed again */ + +struct sep_keybag_lock { + __u32 uid; + __u32 reason; + __u32 grace_s; + __u32 reserved; +}; + +#define SEP_KEYBAG_STATE_LOCKED_SINCE_BOOT 0 +#define SEP_KEYBAG_STATE_UNLOCKED 1 +#define SEP_KEYBAG_STATE_LOCKING 2 +#define SEP_KEYBAG_STATE_LOCKED 3 + +#define SEP_KEYBAG_BIO_ALLOWED 0 +#define SEP_KEYBAG_BIO_PASSWORD_SINCE_BOOT 1 +#define SEP_KEYBAG_BIO_NO_UNLOCK_48H 2 +#define SEP_KEYBAG_BIO_NO_PASSWORD_156H 3 +#define SEP_KEYBAG_BIO_FAILED_MATCHES 4 + +struct sep_keybag_state { + __u32 uid; /* in */ + __u32 bag_state; /* SEP_KEYBAG_STATE_* */ + __u32 biometry; /* SEP_KEYBAG_BIO_* */ + __u32 failed_matches; + __u64 since_password_s; /* ~0 when none since boot */ + __u64 since_unlock_s; + __u32 enclave_lock_state; /* raw enclave word; 0 until it can be read */ + __u32 reserved; +}; + +struct sep_keybag_change { + __u32 uid; + __u32 old_len; + __u32 new_len; + __u32 reserved; + __u8 old_secret[SEP_KEYBAG_SECRET_MAX]; + __u8 new_secret[SEP_KEYBAG_SECRET_MAX]; +}; + +#define SEP_BIO_KEYBAG_STATE _IOWR(SEP_BIO_IOC_MAGIC, 0x10, struct sep_keybag_state) +#define SEP_BIO_KEYBAG_UNLOCK _IOW(SEP_BIO_IOC_MAGIC, 0x11, struct sep_keybag_unlock) +#define SEP_BIO_KEYBAG_LOCK _IOW(SEP_BIO_IOC_MAGIC, 0x12, struct sep_keybag_lock) +#define SEP_BIO_KEYBAG_CHANGE _IOW(SEP_BIO_IOC_MAGIC, 0x13, struct sep_keybag_change) +#define SEP_BIO_KEYBAG_CREATE _IOW(SEP_BIO_IOC_MAGIC, 0x14, struct sep_keybag_unlock) + #endif /* _UAPI_LINUX_APPLE_SEP_H */ From 06592866440bf0da353eac13974e2390038b04d3 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:34:35 +1100 Subject: [PATCH 02/21] soc: apple: sep: encode captured user keybag operations Add strict SKS request encoders and response decoders for the per-user keybag operations observed on the variant-5 key store. Cover every captured layout with synthetic golden vectors and round-trip KUnit tests, expose live state through the draft keybag ioctls, and wipe password-bearing ioctl copies and request images. Assisted-by: LLM --- drivers/soc/apple/der.rs | 26 + drivers/soc/apple/image.rs | 2 +- drivers/soc/apple/sbio.rs | 3 + drivers/soc/apple/sks.rs | 28 + drivers/soc/apple/userbag.rs | 1029 ++++++++++++++++++++++++++++++-- include/uapi/linux/apple-sep.h | 6 +- 6 files changed, 1038 insertions(+), 56 deletions(-) diff --git a/drivers/soc/apple/der.rs b/drivers/soc/apple/der.rs index fcd198561872f..dae39428aef62 100644 --- a/drivers/soc/apple/der.rs +++ b/drivers/soc/apple/der.rs @@ -158,3 +158,29 @@ pub(crate) fn octet_string_body(tlv: &[u8]) -> Option<&[u8]> { _ => None, } } + +fn integer_value(tlv: &[u8]) -> Option { + let body = match take_tlv(tlv) { + Some((TAG_INTEGER, body, [])) if !body.is_empty() && body.len() <= 5 => body, + _ => return None, + }; + // DER integers are minimal, signed, big-endian values. + if body.len() > 1 + && ((body[0] == 0 && body[1] & 0x80 == 0) || (body[0] == 0xff && body[1] & 0x80 != 0)) + { + return None; + } + let mut value = if body[0] & 0x80 != 0 { -1i64 } else { 0 }; + for byte in body { + value = (value << 8) | i64::from(*byte); + } + Some(value) +} + +pub(crate) fn integer_u32(tlv: &[u8]) -> Option { + u32::try_from(integer_value(tlv)?).ok() +} + +pub(crate) fn integer_i32(tlv: &[u8]) -> Option { + i32::try_from(integer_value(tlv)?).ok() +} diff --git a/drivers/soc/apple/image.rs b/drivers/soc/apple/image.rs index c465ffddfbd70..7e764aedcbe10 100644 --- a/drivers/soc/apple/image.rs +++ b/drivers/soc/apple/image.rs @@ -124,7 +124,7 @@ impl Body { Ok(()) } - fn as_slice(&self) -> &[u8] { + pub(crate) fn as_slice(&self) -> &[u8] { &self.bytes } } diff --git a/drivers/soc/apple/sbio.rs b/drivers/soc/apple/sbio.rs index 98a048443da18..511570712376f 100644 --- a/drivers/soc/apple/sbio.rs +++ b/drivers/soc/apple/sbio.rs @@ -3679,6 +3679,9 @@ impl SepData { if self.shutting_down.load(Relaxed) { return Err(ENODEV); } + if crate::userbag::is_ioctl(cmd) { + return self.userbag_ioctl(cmd, arg); + } if cmd == bio::IOC_ATTEST { return self.bio_attest(arg); } diff --git a/drivers/soc/apple/sks.rs b/drivers/soc/apple/sks.rs index 812244de792e9..6c233d909ccc1 100644 --- a/drivers/soc/apple/sks.rs +++ b/drivers/soc/apple/sks.rs @@ -1315,6 +1315,13 @@ const OP_SKS_LOAD_KEYBAG: u8 = 0x03; pub(crate) const SKS_LOAD_NAME: &CStr = c"LOAD_KEYBAG"; +const OP_SKS_CHANGE_SECRET: u8 = 0x07; +pub(crate) const SKS_CHANGE_SECRET_NAME: &CStr = c"CHANGE_SECRET"; + +pub(crate) fn encode_sks_change_secret(seq: Sequence, len: ImageLen) -> Message { + encode_sks_raw(OP_SKS_CHANGE_SECRET, seq.value(), len.value()) +} + const OP_SKS_CHANGE_LOCK_STATE: u8 = 0x04; pub(crate) const SKS_LOCK_STATE_NAME: &CStr = c"CHANGE_LOCK_STATE"; @@ -1326,6 +1333,27 @@ pub(crate) fn encode_sks_device_state_transition(seq: Sequence, len: ImageLen) - encode_sks_raw(OP_SKS_DEVICE_STATE_TRANSITION, seq.value(), len.value()) } +const OP_SKS_GET_DEVICE_STATE: u8 = 0x19; +pub(crate) const SKS_GET_DEVICE_STATE_NAME: &CStr = c"GET_DEVICE_STATE"; + +pub(crate) fn encode_sks_get_device_state(seq: Sequence, len: ImageLen) -> Message { + encode_sks_raw(OP_SKS_GET_DEVICE_STATE, seq.value(), len.value()) +} + +const OP_SKS_GET_CONFIGURATION: u8 = 0x23; +pub(crate) const SKS_GET_CONFIGURATION_NAME: &CStr = c"GET_CONFIGURATION"; + +pub(crate) fn encode_sks_get_configuration(seq: Sequence, len: ImageLen) -> Message { + encode_sks_raw(OP_SKS_GET_CONFIGURATION, seq.value(), len.value()) +} + +const OP_SKS_HANDLE_TIMER_FIRE: u8 = 0x2f; +pub(crate) const SKS_HANDLE_TIMER_FIRE_NAME: &CStr = c"HANDLE_TIMER_FIRE"; + +pub(crate) fn encode_sks_handle_timer_fire(seq: Sequence, len: ImageLen) -> Message { + encode_sks_raw(OP_SKS_HANDLE_TIMER_FIRE, seq.value(), len.value()) +} + const OP_SKS_VERIFY_SECRET: u8 = 0x21; pub(crate) const SKS_VERIFY_SECRET_NAME: &CStr = c"VERIFY_SECRET"; pub(crate) const SKS_VERIFY_SECRET_VARIANT: u32 = 1; diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index aa9bc8f65ff05..a65d986e3338a 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-2.0-only OR MIT -//! DESIGN PROTOTYPE: a per-user key bag whose secret is the login password. +//! Per-user key-bag protocol and host policy. //! //! Today the driver unlocks the designated identity bag at boot with a secret //! it keeps on disk, and never locks it. This module sketches the macOS model @@ -16,19 +16,439 @@ //! on screen lock after a grace period and at once on suspend; //! * Touch ID is offered only while [`Policy::biometry_allowed`] says so. //! -//! What is wired today: the policy (pure, tested) and the unlock request, -//! which reuses the unlock layout the driver already sends for the identity -//! bag. What is NOT: the lock, the passcode change and the lock-state read. -//! Their wire values have not been captured yet; each is a `TODO(capture)` -//! naming the capture that fills it. Nothing here invents a wire value. -//! //! The ioctls (UAPI `SEP_BIO_KEYBAG_*`) are meant for the root-only re-arm -//! node, so fprintd never sees a password. The dispatcher is not hooked to a -//! file yet. +//! node, so fprintd never sees a password. Request images are wiped on drop, +//! and the ioctl request copies holding passwords are wiped before return. #![allow(dead_code)] use kernel::prelude::*; +use kernel::uaccess::{UserPtr, UserSlice}; + +const CLIENT: u64 = crate::sks::SKS_CLIENT_ID; +const CREATE_VARIANT: u32 = 4; +const CREATE_BAG_TYPE: u32 = 0; +const CREATE_ARG: i32 = -1; +const DESIGNATE_VARIANT: u32 = 1; +const CHANGE_VARIANT: u32 = 3; +const LOCK_STATE_VARIANT: u32 = 1; +const DEVICE_STATE_VARIANT: u32 = 0; +const READ_VARIANT: u32 = 0; +const CHANGE_TRAILER: u32 = 0xff; + +#[derive(Debug, PartialEq, Eq)] +struct Create<'a> { + secret: &'a [u8], +} + +#[derive(Debug, PartialEq, Eq)] +struct Designate { + source_handle: i32, + user_handle: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TransitionState { + Unlocked, + Locked, +} + +impl TransitionState { + const fn wire(self) -> u32 { + match self { + TransitionState::Unlocked => 0, + TransitionState::Locked => 1, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +struct Transition<'a> { + user_handle: i32, + state: TransitionState, + flags: u64, + secret: &'a [u8], +} + +#[derive(Debug, PartialEq, Eq)] +struct ChangeSecret<'a> { + user_handle: i32, + old: &'a [u8], + new: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GetState { + state_word: u32, + lock_phase: u32, + failed_attempts: u32, + attempt_limit: u32, + handle: i32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Configuration { + grace_period_s: u32, + expiry_time_s: u32, + extended_period_s: u32, + attempt_limit: u32, + biometric_disable_limit: u32, +} + +struct Cursor<'a> { + bytes: &'a [u8], + at: usize, +} + +impl<'a> Cursor<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes, at: 0 } + } + + fn u32(&mut self) -> Option { + let end = self.at.checked_add(4)?; + let value = u32::from_le_bytes(self.bytes.get(self.at..end)?.try_into().ok()?); + self.at = end; + Some(value) + } + + fn i32(&mut self) -> Option { + Some(self.u32()? as i32) + } + + fn u64(&mut self) -> Option { + let end = self.at.checked_add(8)?; + let value = u64::from_le_bytes(self.bytes.get(self.at..end)?.try_into().ok()?); + self.at = end; + Some(value) + } + + fn blob(&mut self) -> Option<&'a [u8]> { + let (blob, next) = crate::image::read_blob(self.bytes, self.at)?; + self.at = next; + Some(blob) + } + + fn done(self) -> bool { + self.at == self.bytes.len() + } +} + +fn encode_create(secret: &[u8]) -> Result { + let mut body = crate::image::Body::new(); + body.put_u32(CREATE_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_u32(CREATE_BAG_TYPE)?; + body.put_i32(CREATE_ARG)?; + body.put_blob(secret)?; + // Five absent trailing fields in the captured variant-4 structure. + for _ in 0..5 { + body.put_u32(0)?; + } + Ok(body) +} + +fn decode_create(body: &[u8]) -> Option> { + let mut c = Cursor::new(body); + if c.u32()? != CREATE_VARIANT + || c.u64()? != CLIENT + || c.u32()? != CREATE_BAG_TYPE + || c.i32()? != CREATE_ARG + { + return None; + } + let secret = c.blob()?; + for _ in 0..5 { + if c.u32()? != 0 { + return None; + } + } + c.done().then_some(Create { secret }) +} + +fn decode_create_reply(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + if c.u32()? != CREATE_VARIANT { + return None; + } + let handle = c.i32()?; + if handle < 0 || c.u32()? != 0 || !c.done() { + return None; + } + Some(crate::sks::KeyBagHandle::from_create_reply(handle)) +} + +fn encode_designate(source_handle: i32, user_handle: i32) -> Result { + if source_handle < 0 || user_handle >= 0 { + return Err(EINVAL); + } + let mut body = crate::image::Body::new(); + body.put_u32(DESIGNATE_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_i32(source_handle)?; + body.put_i32(user_handle)?; + body.put_blob(&[])?; + body.put_u64(0)?; + Ok(body) +} + +fn decode_designate(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + if c.u32()? != DESIGNATE_VARIANT || c.u64()? != CLIENT { + return None; + } + let source_handle = c.i32()?; + let user_handle = c.i32()?; + if source_handle < 0 || user_handle >= 0 || !c.blob()?.is_empty() || c.u64()? != 0 || !c.done() + { + return None; + } + Some(Designate { + source_handle, + user_handle, + }) +} + +fn decode_designate_reply(body: &[u8]) -> bool { + body == DESIGNATE_VARIANT.to_le_bytes() +} + +fn encode_transition( + user_handle: i32, + state: TransitionState, + flags: u64, + secret: &[u8], +) -> Result { + if user_handle >= 0 + || (state == TransitionState::Unlocked && flags != 0) + || (state == TransitionState::Locked && (!secret.is_empty() || flags > 1)) + { + return Err(EINVAL); + } + let mut body = crate::image::Body::new(); + body.put_u32(DEVICE_STATE_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_i32(user_handle)?; + body.put_u32(state.wire())?; + body.put_u64(flags)?; + body.put_blob(secret)?; + Ok(body) +} + +fn decode_transition(body: &[u8]) -> Option> { + let mut c = Cursor::new(body); + if c.u32()? != DEVICE_STATE_VARIANT || c.u64()? != CLIENT { + return None; + } + let user_handle = c.i32()?; + let state = match c.u32()? { + 0 => TransitionState::Unlocked, + 1 => TransitionState::Locked, + _ => return None, + }; + let flags = c.u64()?; + let secret = c.blob()?; + if user_handle >= 0 + || !c.done() + || (state == TransitionState::Unlocked && flags != 0) + || (state == TransitionState::Locked && (!secret.is_empty() || flags > 1)) + { + return None; + } + Some(Transition { + user_handle, + state, + flags, + secret, + }) +} + +fn encode_timer_fire() -> Result { + let mut body = crate::image::Body::new(); + body.put_u32(0)?; + body.put_u32(1)?; + body.put_u32(0)?; + Ok(body) +} + +fn decode_timer_fire(body: &[u8]) -> bool { + body == [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] +} + +fn decode_timer_fire_reply(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + let handle = c.i32()?; + // The remaining captured words describe the fired timer. They are kept + // opaque until their meanings are known, but their fixed layout is checked. + let kind = c.u32()?; + let reserved = c.u32()?; + let _token = c.u32()?; + let _generation = c.u32()?; + let trailer = c.u32()?; + (handle < 0 && kind == 1 && reserved == 0 && trailer == 0 && c.done()).then_some(handle) +} + +fn encode_change_secret(user_handle: i32, old: &[u8], new: &[u8]) -> Result { + if user_handle >= 0 || old.is_empty() || new.is_empty() { + return Err(EINVAL); + } + let mut body = crate::image::Body::new(); + body.put_u32(CHANGE_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_i32(user_handle)?; + body.put_blob(old)?; + body.put_blob(new)?; + body.put_u64(0)?; + body.put_u32(CHANGE_TRAILER)?; + body.put_u64(0)?; + Ok(body) +} + +fn decode_change_secret(body: &[u8]) -> Option> { + let mut c = Cursor::new(body); + if c.u32()? != CHANGE_VARIANT || c.u64()? != CLIENT { + return None; + } + let user_handle = c.i32()?; + let old = c.blob()?; + let new = c.blob()?; + if user_handle >= 0 + || old.is_empty() + || new.is_empty() + || c.u64()? != 0 + || c.u32()? != CHANGE_TRAILER + || c.u64()? != 0 + || !c.done() + { + return None; + } + Some(ChangeSecret { + user_handle, + old, + new, + }) +} + +fn decode_change_secret_reply(body: &[u8]) -> bool { + body == [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] +} + +fn encode_lock_query(user_handle: i32) -> Result { + if user_handle >= 0 { + return Err(EINVAL); + } + let mut body = crate::image::Body::new(); + body.put_u32(LOCK_STATE_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_i32(user_handle)?; + body.put_i32(-1)?; + body.put_blob(&[])?; + body.put_u64(0)?; + Ok(body) +} + +fn decode_lock_query(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + if c.u32()? != LOCK_STATE_VARIANT || c.u64()? != CLIENT { + return None; + } + let handle = c.i32()?; + if handle >= 0 || c.i32()? != -1 || !c.blob()?.is_empty() || c.u64()? != 0 || !c.done() { + return None; + } + Some(handle) +} + +fn decode_lock_query_reply(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + if c.u32()? != LOCK_STATE_VARIANT { + return None; + } + let state = c.u32()?; + if c.u32()? != 0 || c.u32()? != 0 || !c.done() { + return None; + } + Some(state) +} + +fn encode_get_state(user_handle: i32) -> Result { + if user_handle >= 0 { + return Err(EINVAL); + } + let mut body = crate::image::Body::new(); + body.put_u32(READ_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_i32(user_handle)?; + body.put_u32(0)?; + Ok(body) +} + +fn decode_get_state_request(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + if c.u32()? != READ_VARIANT || c.u64()? != CLIENT { + return None; + } + let handle = c.i32()?; + if handle >= 0 || c.u32()? != 0 || !c.done() { + return None; + } + Some(handle) +} + +fn response_der(body: &[u8]) -> Option<&[u8]> { + let (der, at) = crate::image::read_blob(body, 0)?; + let mut c = Cursor { bytes: body, at }; + if c.u32()? != 0 || !c.done() { + return None; + } + Some(der) +} + +fn der_u32(der: &[u8], key: &[u8]) -> Option { + crate::der::refkey_find(der, key).and_then(crate::der::integer_u32) +} + +fn decode_get_state_reply(body: &[u8]) -> Option { + let der = response_der(body)?; + Some(GetState { + state_word: der_u32(der, b"ss")?, + lock_phase: der_u32(der, b"sls")?, + failed_attempts: der_u32(der, b"sfa")?, + attempt_limit: der_u32(der, b"mua")?, + handle: crate::der::refkey_find(der, b"bh").and_then(crate::der::integer_i32)?, + }) +} + +fn encode_get_configuration(user_handle: i32) -> Result { + if user_handle >= 0 { + return Err(EINVAL); + } + let mut body = crate::image::Body::new(); + body.put_u32(READ_VARIANT)?; + body.put_u64(CLIENT)?; + body.put_i32(user_handle)?; + Ok(body) +} + +fn decode_get_configuration_request(body: &[u8]) -> Option { + let mut c = Cursor::new(body); + if c.u32()? != READ_VARIANT || c.u64()? != CLIENT { + return None; + } + let handle = c.i32()?; + (handle < 0 && c.done()).then_some(handle) +} + +fn decode_get_configuration_reply(body: &[u8]) -> Option { + let der = response_der(body)?; + Some(Configuration { + grace_period_s: der_u32(der, b"gp")?, + expiry_time_s: der_u32(der, b"etp")?, + extended_period_s: der_u32(der, b"epp")?, + attempt_limit: der_u32(der, b"mua")?, + biometric_disable_limit: der_u32(der, b"bd")?, + }) +} /// macOS: "five unsuccessful biometric authentication match attempts". pub(crate) const MAX_FAILED_MATCHES: u32 = 5; @@ -42,8 +462,8 @@ pub(crate) const NO_BIOMETRY_LIMIT_S: u64 = 4 * 3600; /// the user's desktop setting, not from here. pub(crate) const MAX_GRACE_S: u32 = 3600; -/// Longest secret accepted. TODO(capture N1): the enclave's own limit for a -/// bag secret is unknown; macOS passes the password bytes as typed. +/// Longest secret accepted by the draft UAPI. The enclave's own limit remains +/// unknown; macOS passes the password bytes as typed. pub(crate) const SECRET_MAX: usize = 256; /// The lowest designated user the key store accepts (`DesignateUser`). @@ -96,7 +516,7 @@ impl Refusal { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum EnclaveAction { Nothing, - /// Send the bag lock (TODO(capture N2)). + /// Send the bag lock. LockBag, } @@ -249,6 +669,12 @@ pub(crate) struct UnlockRequest { pub(crate) secret: [u8; SECRET_MAX], } +impl Drop for UnlockRequest { + fn drop(&mut self) { + crate::image::wipe(&mut self.secret); + } +} + /// The UAPI `struct sep_keybag_lock`. #[repr(C)] pub(crate) struct LockRequest { @@ -273,20 +699,68 @@ pub(crate) struct StateReply { /// `u64::MAX` when there was none since boot. pub(crate) since_password_s: u64, pub(crate) since_unlock_s: u64, - /// The enclave's own lock-state word. TODO(capture N4): the read op and - /// its reply layout are unknown, so this stays 0. + /// The enclave's own state word from GET_DEVICE_STATE. pub(crate) enclave_lock_state: u32, + /// The enclave's lock phase from GET_DEVICE_STATE. + pub(crate) lock_phase: u32, +} + +/// The UAPI `struct sep_keybag_change`. +#[repr(C)] +pub(crate) struct ChangeRequest { + pub(crate) uid: u32, + pub(crate) old_len: u32, + pub(crate) new_len: u32, pub(crate) reserved: u32, + pub(crate) old_secret: [u8; SECRET_MAX], + pub(crate) new_secret: [u8; SECRET_MAX], +} + +impl Drop for ChangeRequest { + fn drop(&mut self) { + crate::image::wipe(&mut self.old_secret); + crate::image::wipe(&mut self.new_secret); + } } static_assert!(core::mem::size_of::() == 8 + SECRET_MAX); static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 40); +static_assert!(core::mem::size_of::() == 16 + 2 * SECRET_MAX); + +// SAFETY: these C-layout structures contain no padding with uninitialised +// bytes, and every bit pattern of each integer/byte member is valid. +unsafe impl kernel::transmute::FromBytes for UnlockRequest {} +unsafe impl kernel::transmute::FromBytes for LockRequest {} +unsafe impl kernel::transmute::FromBytes for StateReply {} +unsafe impl kernel::transmute::FromBytes for ChangeRequest {} +unsafe impl kernel::transmute::AsBytes for StateReply {} + +const IOC_MAGIC: u32 = 0xB1; +pub(crate) const IOC_STATE: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x10); +pub(crate) const IOC_UNLOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x11); +pub(crate) const IOC_LOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x12); +pub(crate) const IOC_CHANGE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x13); +pub(crate) const IOC_CREATE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x14); + +pub(crate) fn is_ioctl(cmd: u32) -> bool { + matches!( + cmd, + IOC_STATE | IOC_UNLOCK | IOC_LOCK | IOC_CHANGE | IOC_CREATE + ) +} /// Checks an unlock/create request and returns the user and the secret. -pub(crate) fn parse_unlock(req: &UnlockRequest) -> Result<(crate::sks::DesignateUser, &[u8])> { +pub(crate) fn parse_unlock( + req: &UnlockRequest, + allow_empty: bool, +) -> Result<(crate::sks::DesignateUser, &[u8])> { let len = req.secret_len as usize; - if len == 0 || len > SECRET_MAX || req.uid < MIN_UID || req.uid > i32::MAX as u32 { + if (!allow_empty && len == 0) + || len > SECRET_MAX + || req.uid < MIN_UID + || req.uid > i32::MAX as u32 + { return Err(EINVAL); } let user = crate::sks::DesignateUser::new(req.uid as i32).ok_or(EINVAL)?; @@ -304,6 +778,25 @@ pub(crate) fn parse_lock(req: &LockRequest) -> Result<(u32, u32, u32)> { Ok((req.uid, req.reason, req.grace_s.min(MAX_GRACE_S))) } +pub(crate) fn parse_change( + req: &ChangeRequest, +) -> Result<(crate::sks::DesignateUser, &[u8], &[u8])> { + let old_len = req.old_len as usize; + let new_len = req.new_len as usize; + if req.reserved != 0 + || old_len == 0 + || old_len > SECRET_MAX + || new_len == 0 + || new_len > SECRET_MAX + || req.uid < MIN_UID + || req.uid > i32::MAX as u32 + { + return Err(EINVAL); + } + let user = crate::sks::DesignateUser::new(req.uid as i32).ok_or(EINVAL)?; + Ok((user, &req.old_secret[..old_len], &req.new_secret[..new_len])) +} + pub(crate) fn state_reply(uid: u32, p: &Policy, now_s: u64) -> StateReply { StateReply { uid, @@ -316,55 +809,274 @@ pub(crate) fn state_reply(uid: u32, p: &Policy, now_s: u64) -> StateReply { since_password_s: p.since_password_s(now_s).unwrap_or(u64::MAX), since_unlock_s: p.since_unlock_s(now_s).unwrap_or(u64::MAX), enclave_lock_state: 0, - reserved: 0, + lock_phase: 0, } } impl crate::SepData { - /// Opens a user's bag with the password. KNOWN: this is the unlock the - /// driver already sends for the identity bag (0x04 state 0 on variant-5 - /// key stores, 0x18 state 0 on 13.5), addressed to the user's special - /// handle (-uid, the same convention macOS uses for its user bags). - /// - /// TODO(capture N1): that a bag CREATED with a password answers this - /// unlock exactly as the identity bag does. macOS on J700 unlocks its - /// user bag with 0x18 carrying the typed password; this path sends 0x04 - /// on J700. Confirm on hardware which one a user bag needs. - pub(crate) fn userbag_unlock( + fn userbag_request( + &self, + name: &'static CStr, + body: crate::image::Body, + encode: impl FnOnce( + crate::sks::Sequence, + crate::sks::ImageLen, + ) -> Result, + ) -> Result { + let img = + crate::image::build_request(self.sks_ipc_version(), self.sks_timestamp_us(), &body)?; + let len = self.sks_image_len(&img)?; + Ok(crate::SksRequest { + name, + msg: encode(self.sks_next_seq(), len)?, + img, + }) + } + + fn userbag_body<'a>(&self, name: &CStr, out: &'a crate::SksOutcome) -> Result<&'a [u8]> { + self.sks_report_response(name, out).ok_or(EPROTO) + } + + fn userbag_transition( &self, user: crate::sks::DesignateUser, + state: TransitionState, + flags: u64, secret: &[u8], ) -> Result<()> { - let special = user.special_handle(); - self.sks_step(crate::sks::SKS_LOCK_STATE_NAME, |healthy| { - self.sks_req_unlock_special(special, secret, healthy) + let handle = user.special_handle().value(); + self.sks_step(crate::sks::SKS_DEVICE_STATE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_DEVICE_STATE_NAME, + encode_transition(handle, state, flags, secret)?, + |seq, len| Ok(crate::sks::encode_sks_device_state_transition(seq, len)), + ) }) .map(|_| ()) .ok_or(EACCES) } - /// TODO(capture N2): the bag lock. The macOS trace shows a - /// DEVICE_STATE_TRANSITION (0x18) for the user's handle with state 1 and - /// no secret, sent by the login window at screen lock. The body on the - /// wire (the flags word in particular) was not dumped. Capture: m1n1 HV - /// `J700_SEP_SWEEP=sks:0x18:...` at a macOS screen lock, or a dtrace - /// tracemem of the request image. Until then this refuses. - pub(crate) fn userbag_lock(&self, _user: crate::sks::DesignateUser) -> Result<()> { - Err(EOPNOTSUPP) + /// Opens a user's bag through the captured device-state transition. + pub(crate) fn userbag_unlock( + &self, + user: crate::sks::DesignateUser, + secret: &[u8], + ) -> Result<()> { + self.userbag_transition(user, TransitionState::Unlocked, 0, secret) + } + + pub(crate) fn userbag_lock( + &self, + user: crate::sks::DesignateUser, + screen_lock: bool, + expire_now: bool, + ) -> Result<()> { + self.userbag_transition(user, TransitionState::Locked, u64::from(screen_lock), &[])?; + if expire_now { + let out = self + .sks_step(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, + encode_timer_fire()?, + |seq, len| Ok(crate::sks::encode_sks_handle_timer_fire(seq, len)), + ) + }) + .ok_or(EACCES)?; + let body = self.userbag_body(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, &out)?; + if decode_timer_fire_reply(body) != Some(user.special_handle().value()) { + return Err(EPROTO); + } + } + Ok(()) } - /// TODO(capture N3): change the bag secret (password change). Neither the - /// opcode nor the body is known. Capture: a macOS password change in - /// System Settings under the same dtrace method, plus the HV sweep for - /// the payload. Until then a password change goes through the recovery - /// path in DESIGN.md (unlock with the old password, re-create, migrate). pub(crate) fn userbag_change_secret( &self, - _user: crate::sks::DesignateUser, - _old: &[u8], - _new: &[u8], + user: crate::sks::DesignateUser, + old: &[u8], + new: &[u8], ) -> Result<()> { - Err(EOPNOTSUPP) + let handle = user.special_handle().value(); + let out = self + .sks_step(crate::sks::SKS_CHANGE_SECRET_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_CHANGE_SECRET_NAME, + encode_change_secret(handle, old, new)?, + |seq, len| Ok(crate::sks::encode_sks_change_secret(seq, len)), + ) + }) + .ok_or(EACCES)?; + let body = self.userbag_body(crate::sks::SKS_CHANGE_SECRET_NAME, &out)?; + if !decode_change_secret_reply(body) { + return Err(EPROTO); + } + Ok(()) + } + + pub(crate) fn userbag_create( + &self, + user: crate::sks::DesignateUser, + secret: &[u8], + ) -> Result<()> { + let _healthy = self + .sks_health_check(crate::sks::SKS_CREATE_NAME) + .ok_or(EACCES)?; + let request = self.userbag_request( + crate::sks::SKS_CREATE_NAME, + encode_create(secret)?, + |seq, len| crate::sks::encode_sks_create(seq, len).ok_or(EINVAL), + )?; + let created = crate::xart_guard::keybag_create_window(|| self.sks_send(Ok(request))) + .ok_or(EACCES)?; + if created.reply.status != 0 { + return Err(EACCES); + } + let body = self.userbag_body(crate::sks::SKS_CREATE_NAME, &created)?; + let handle = decode_create_reply(body).ok_or(EPROTO)?; + + let designated = self.sks_step(crate::sks::SKS_DESIGNATE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_DESIGNATE_NAME, + encode_designate(handle.value(), user.special_handle().value())?, + |seq, len| crate::sks::encode_sks_designate(seq, len).ok_or(EINVAL), + ) + }); + let valid = designated.as_ref().is_some_and(|out| { + self.sks_report_response(crate::sks::SKS_DESIGNATE_NAME, out) + .is_some_and(decode_designate_reply) + }); + if !valid { + let _ = self.sks_send(self.sks_req_unload_keybag(handle)); + return Err(EACCES); + } + // Copying the wrapped bag into a per-user durable store is phase 2. + // Until then this ioctl establishes only the live designated bag. + Ok(()) + } + + fn userbag_state(&self, user: crate::sks::DesignateUser) -> Result { + let handle = user.special_handle().value(); + let lock = self + .sks_step(crate::sks::SKS_LOCK_STATE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_LOCK_STATE_NAME, + encode_lock_query(handle)?, + |seq, len| crate::sks::encode_sks_change_lock_state(seq, len).ok_or(EINVAL), + ) + }) + .ok_or(EACCES)?; + let lock_body = self.userbag_body(crate::sks::SKS_LOCK_STATE_NAME, &lock)?; + let _lock_state = decode_lock_query_reply(lock_body).ok_or(EPROTO)?; + + let state = self + .sks_step(crate::sks::SKS_GET_DEVICE_STATE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_GET_DEVICE_STATE_NAME, + encode_get_state(handle)?, + |seq, len| Ok(crate::sks::encode_sks_get_device_state(seq, len)), + ) + }) + .ok_or(EACCES)?; + let body = self.userbag_body(crate::sks::SKS_GET_DEVICE_STATE_NAME, &state)?; + let state = decode_get_state_reply(body).ok_or(EPROTO)?; + if state.handle != handle { + return Err(EPROTO); + } + Ok(state) + } + + fn userbag_configuration(&self, user: crate::sks::DesignateUser) -> Result { + let handle = user.special_handle().value(); + let out = self + .sks_step(crate::sks::SKS_GET_CONFIGURATION_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_GET_CONFIGURATION_NAME, + encode_get_configuration(handle)?, + |seq, len| Ok(crate::sks::encode_sks_get_configuration(seq, len)), + ) + }) + .ok_or(EACCES)?; + let body = self.userbag_body(crate::sks::SKS_GET_CONFIGURATION_NAME, &out)?; + decode_get_configuration_reply(body).ok_or(EPROTO) + } + + pub(crate) fn userbag_ioctl(&self, cmd: u32, arg: usize) -> Result { + crate::bio::require_admin()?; + if self.profile.key_store != crate::profile::KeyStore::Variant5 { + return Err(EOPNOTSUPP); + } + let user_ptr = UserPtr::from_addr(arg); + if cmd != IOC_STATE && !crate::bio::idle_for_mutation(&self.bio_session.lock()) { + return Err(EBUSY); + } + match cmd { + IOC_CREATE | IOC_UNLOCK => { + let request: UnlockRequest = + UserSlice::new(user_ptr, core::mem::size_of::()) + .reader() + .read()?; + let (user, secret) = parse_unlock(&request, cmd == IOC_UNLOCK)?; + if cmd == IOC_CREATE { + if !self.xart_writable() { + return Err(EROFS); + } + self.userbag_create(user, secret)?; + } else { + self.userbag_unlock(user, secret)?; + } + crate::bio::ok() + } + IOC_LOCK => { + let request: LockRequest = + UserSlice::new(user_ptr, core::mem::size_of::()) + .reader() + .read()?; + let (uid, reason, grace_s) = parse_lock(&request)?; + let user = crate::sks::DesignateUser::new(uid as i32).ok_or(EINVAL)?; + let screen = reason == LOCK_REASON_SCREEN; + self.userbag_lock(user, screen, screen && grace_s == 0)?; + crate::bio::ok() + } + IOC_CHANGE => { + let request: ChangeRequest = + UserSlice::new(user_ptr, core::mem::size_of::()) + .reader() + .read()?; + let (user, old, new) = parse_change(&request)?; + self.userbag_change_secret(user, old, new)?; + crate::bio::ok() + } + IOC_STATE => { + let mut reply: StateReply = + UserSlice::new(user_ptr, core::mem::size_of::()) + .reader() + .read()?; + let user = + crate::sks::DesignateUser::new(i32::try_from(reply.uid).map_err(|_| EINVAL)?) + .ok_or(EINVAL)?; + let state = self.userbag_state(user)?; + let configuration = self.userbag_configuration(user)?; + if state.attempt_limit != configuration.attempt_limit { + return Err(EPROTO); + } + reply.bag_state = match state.lock_phase { + 0 => BagState::Unlocked.uapi(), + 2 | 5 => BagState::Locking { deadline_s: 0 }.uapi(), + _ => BagState::Locked.uapi(), + }; + reply.biometry = 0; + reply.failed_matches = state.failed_attempts; + reply.since_password_s = u64::MAX; + reply.since_unlock_s = u64::MAX; + reply.enclave_lock_state = state.state_word; + reply.lock_phase = state.lock_phase; + UserSlice::new(user_ptr, core::mem::size_of::()) + .writer() + .write(&reply)?; + crate::bio::ok() + } + _ => Err(ENOTTY), + } } } @@ -382,6 +1094,218 @@ mod tests { p } + const USER_HANDLE: i32 = -501; + const CLIENT_LE: [u8; 8] = [0x53, 0x4b, 0x53, 0x58, 0x55, 0x4e, 0x49, 0x4c]; + + #[test] + fn create_layout_golden_and_round_trip() { + let body = encode_create(b"pw").expect("create"); + let mut golden = [0u8; 48]; + golden[0..4].copy_from_slice(&4u32.to_le_bytes()); + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[16..20].copy_from_slice(&(-1i32).to_le_bytes()); + golden[20..24].copy_from_slice(&2u32.to_le_bytes()); + golden[24..26].copy_from_slice(b"pw"); + assert_eq!(body.as_slice(), &golden); + assert_eq!( + decode_create(body.as_slice()), + Some(Create { secret: b"pw" }) + ); + assert_eq!( + decode_create_reply(&[4, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0]) + .unwrap() + .value(), + 7 + ); + } + + #[test] + fn designate_layout_golden_and_round_trip() { + let body = encode_designate(1, USER_HANDLE).expect("designate"); + let mut golden = [0u8; 32]; + golden[0..4].copy_from_slice(&1u32.to_le_bytes()); + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&1i32.to_le_bytes()); + golden[16..20].copy_from_slice(&USER_HANDLE.to_le_bytes()); + assert_eq!(body.as_slice(), &golden); + assert_eq!( + decode_designate(body.as_slice()), + Some(Designate { + source_handle: 1, + user_handle: USER_HANDLE + }) + ); + assert!(decode_designate_reply(&1u32.to_le_bytes())); + } + + #[test] + fn device_state_layouts_golden_and_round_trip() { + let lock = + encode_transition(USER_HANDLE, TransitionState::Locked, 1, &[]).expect("screen lock"); + let mut golden = [0u8; 32]; + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + golden[16..20].copy_from_slice(&1u32.to_le_bytes()); + golden[20..28].copy_from_slice(&1u64.to_le_bytes()); + assert_eq!(lock.as_slice(), &golden); + assert_eq!( + decode_transition(lock.as_slice()), + Some(Transition { + user_handle: USER_HANDLE, + state: TransitionState::Locked, + flags: 1, + secret: &[], + }) + ); + let sleep = + encode_transition(USER_HANDLE, TransitionState::Locked, 0, &[]).expect("sleep lock"); + assert_eq!(decode_transition(sleep.as_slice()).unwrap().flags, 0); + + let unlock = + encode_transition(USER_HANDLE, TransitionState::Unlocked, 0, b"pw").expect("unlock"); + let mut golden = [0u8; 36]; + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + golden[28..32].copy_from_slice(&2u32.to_le_bytes()); + golden[32..34].copy_from_slice(b"pw"); + assert_eq!(unlock.as_slice(), &golden); + assert_eq!(decode_transition(unlock.as_slice()).unwrap().secret, b"pw"); + let cancel = encode_transition(USER_HANDLE, TransitionState::Unlocked, 0, &[]) + .expect("grace cancel"); + assert!(decode_transition(cancel.as_slice()).unwrap().secret.is_empty()); + } + + #[test] + fn grace_expiry_layout_golden_and_round_trip() { + let body = encode_timer_fire().expect("timer"); + let golden = [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0]; + assert_eq!(body.as_slice(), &golden); + assert!(decode_timer_fire(body.as_slice())); + let mut reply = [0u8; 24]; + reply[0..4].copy_from_slice(&USER_HANDLE.to_le_bytes()); + reply[4..8].copy_from_slice(&1u32.to_le_bytes()); + reply[16..20].copy_from_slice(&3u32.to_le_bytes()); + assert_eq!(decode_timer_fire_reply(&reply), Some(USER_HANDLE)); + } + + #[test] + fn change_secret_layout_golden_and_round_trip() { + let body = encode_change_secret(USER_HANDLE, b"old", b"new").expect("change"); + let mut golden = [0u8; 52]; + golden[0..4].copy_from_slice(&3u32.to_le_bytes()); + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + golden[16..20].copy_from_slice(&3u32.to_le_bytes()); + golden[20..23].copy_from_slice(b"old"); + golden[24..28].copy_from_slice(&3u32.to_le_bytes()); + golden[28..31].copy_from_slice(b"new"); + golden[40..44].copy_from_slice(&0xffu32.to_le_bytes()); + assert_eq!(body.as_slice(), &golden); + assert_eq!( + decode_change_secret(body.as_slice()), + Some(ChangeSecret { + user_handle: USER_HANDLE, + old: b"old", + new: b"new" + }) + ); + assert!(decode_change_secret_reply(&[ + 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ])); + } + + #[test] + fn lock_query_layout_golden_and_round_trip() { + let body = encode_lock_query(USER_HANDLE).expect("lock query"); + let mut golden = [0u8; 32]; + golden[0..4].copy_from_slice(&1u32.to_le_bytes()); + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + golden[16..20].copy_from_slice(&(-1i32).to_le_bytes()); + assert_eq!(body.as_slice(), &golden); + assert_eq!(decode_lock_query(body.as_slice()), Some(USER_HANDLE)); + assert_eq!( + decode_lock_query_reply(&[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + Some(1) + ); + } + + #[test] + fn get_state_layout_and_der_reply() { + let body = encode_get_state(USER_HANDLE).expect("get state"); + let mut golden = [0u8; 20]; + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + assert_eq!(body.as_slice(), &golden); + assert_eq!(decode_get_state_request(body.as_slice()), Some(USER_HANDLE)); + + // Canonical SET members, sorted by key: bh, mua, sfa, sls, ss. + let der = [ + 0x31, 0x34, 0x30, 0x08, 0x0c, 0x02, b'b', b'h', 0x02, 0x02, 0xfe, 0x0b, 0x30, 0x08, + 0x0c, 0x03, b'm', b'u', b'a', 0x02, 0x01, 10, 0x30, 0x08, 0x0c, 0x03, b's', b'f', b'a', + 0x02, 0x01, 3, 0x30, 0x08, 0x0c, 0x03, b's', b'l', b's', 0x02, 0x01, 2, 0x30, 0x0a, + 0x0c, 0x02, b's', b's', 0x02, 0x04, 0x06, 0, 0, 0x15, + ]; + let mut response = crate::image::Body::new(); + response.put_blob(&der).unwrap(); + response.put_u32(0).unwrap(); + assert_eq!( + decode_get_state_reply(response.as_slice()), + Some(GetState { + state_word: 0x0600_0015, + lock_phase: 2, + failed_attempts: 3, + attempt_limit: 10, + handle: USER_HANDLE, + }) + ); + } + + #[test] + fn configuration_layout_and_der_reply() { + let body = encode_get_configuration(USER_HANDLE).expect("get config"); + let mut golden = [0u8; 16]; + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + assert_eq!(body.as_slice(), &golden); + assert_eq!( + decode_get_configuration_request(body.as_slice()), + Some(USER_HANDLE) + ); + + // Canonical SET members, sorted by key: bd, epp, etp, gp, mua. + let der = [ + 0x31, 0x35, 0x30, 0x07, 0x0c, 0x02, b'b', b'd', 0x02, 0x01, 5, 0x30, 0x0a, 0x0c, 0x03, + b'e', b'p', b'p', 0x02, 0x03, 0x09, 0x3a, 0x80, 0x30, 0x0a, 0x0c, 0x03, b'e', b't', + b'p', 0x02, 0x03, 0x02, 0xa3, 0x00, 0x30, 0x08, 0x0c, 0x02, b'g', b'p', 0x02, 0x02, + 0x01, 0x2c, 0x30, 0x08, 0x0c, 0x03, b'm', b'u', b'a', 0x02, 0x01, 10, + ]; + let mut response = crate::image::Body::new(); + response.put_blob(&der).unwrap(); + response.put_u32(0).unwrap(); + assert_eq!( + decode_get_configuration_reply(response.as_slice()), + Some(Configuration { + grace_period_s: 300, + expiry_time_s: 172800, + extended_period_s: 604800, + attempt_limit: 10, + biometric_disable_limit: 5, + }) + ); + } + + #[test] + fn protocol_decoders_reject_trailing_or_nonzero_reserved_data() { + let create = encode_create(b"pw").expect("create"); + let mut bad = [0u8; 48]; + bad.copy_from_slice(create.as_slice()); + bad[47] = 1; + assert!(decode_create(&bad).is_none()); + assert!(decode_lock_query_reply(&[1, 0, 0, 0, 1, 0, 0, 0]).is_none()); + assert!(encode_transition(USER_HANDLE, TransitionState::Locked, 2, &[]).is_err()); + } + #[test] fn password_is_required_after_boot() { let mut p = Policy::at_boot(); @@ -514,18 +1438,19 @@ mod tests { secret_len: 4, secret: [0x61; SECRET_MAX], }; - let (user, secret) = parse_unlock(&req).expect("valid request"); + let (user, secret) = parse_unlock(&req, false).expect("valid request"); assert_eq!(user.special_handle().value(), -1000); assert_eq!(secret.len(), 4); req.secret_len = 0; - assert!(parse_unlock(&req).is_err()); + assert!(parse_unlock(&req, false).is_err()); + assert!(parse_unlock(&req, true).is_ok()); req.secret_len = SECRET_MAX as u32 + 1; - assert!(parse_unlock(&req).is_err()); + assert!(parse_unlock(&req, false).is_err()); req.secret_len = 4; req.uid = 0; - assert!(parse_unlock(&req).is_err()); + assert!(parse_unlock(&req, false).is_err()); req.uid = u32::MAX; - assert!(parse_unlock(&req).is_err()); + assert!(parse_unlock(&req, false).is_err()); let mut lock = LockRequest { uid: 1000, diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 7466655b94769..23b414f729e5a 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -158,7 +158,7 @@ struct sep_bio_attest { struct sep_keybag_unlock { /* UNLOCK and CREATE */ __u32 uid; - __u32 secret_len; + __u32 secret_len; /* UNLOCK permits zero to cancel pending lock */ __u8 secret[SEP_KEYBAG_SECRET_MAX]; }; @@ -191,8 +191,8 @@ struct sep_keybag_state { __u32 failed_matches; __u64 since_password_s; /* ~0 when none since boot */ __u64 since_unlock_s; - __u32 enclave_lock_state; /* raw enclave word; 0 until it can be read */ - __u32 reserved; + __u32 enclave_lock_state; /* raw enclave state word */ + __u32 lock_phase; /* enclave lock phase */ }; struct sep_keybag_change { From 8c6f00312eaf1dd6e5295ba352f37596f7adf892 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 15:29:11 +1100 Subject: [PATCH 03/21] soc: apple: sep: add /dev/sep-key for P-256 keys that stay in the SEP Add a character device that creates P-256 keys inside the Secure Enclave's key store and signs digests with them. Each key is an enclave ref-key bound to this SEP and to the Linux identity key bag; the private key never leaves the enclave. Userspace gets an opaque handle (a short versioned header plus the enclave's wrapped record) that only this Mac can use, and the public point. ioctls (magic 0xB1, nr 0x20-0x23): SEP_KEY_GET_INFO, SEP_KEY_CREATE, SEP_KEY_SIGN (ECDSA-P256 over a caller-supplied 32-byte digest, DER signature) and SEP_KEY_PUBLIC (point from a handle, no enclave round trip). Each enclave operation loads and unlocks the identity key bag the way a trusted-key unseal does and unloads it afterwards. Version 1 has no access control: every flags field must be zero and is reserved for an enclave-enforced policy. A handle the enclave does not accept (another Mac, another key bag) fails with EKEYREJECTED, so the ref-key sign helper now reports the enclave's refusal separately from a missing or malformed reply. KUnit covers the handle format and the reply bounds. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 338 +++++++++++++++++++++++++++++++++ drivers/soc/apple/refkey.rs | 96 ++++++++-- drivers/soc/apple/sbio.rs | 8 + drivers/soc/apple/sep.rs | 8 + include/uapi/linux/apple-sep.h | 63 ++++++ 5 files changed, 500 insertions(+), 13 deletions(-) create mode 100644 drivers/soc/apple/key.rs diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs new file mode 100644 index 0000000000000..d92d50716cc03 --- /dev/null +++ b/drivers/soc/apple/key.rs @@ -0,0 +1,338 @@ +// SPDX-License-Identifier: GPL-2.0-only OR MIT +// Copyright 2026 Ryan Murray +//! `/dev/sep-key`: P-256 keys created inside the SEP. +//! +//! Each key is an enclave ref-key bound to this SEP and to the Linux identity +//! key bag. Its private half never leaves the enclave; userspace holds only a +//! key handle, the wrapped ref-key record behind a small versioned header. +//! A handle works on the Mac that created it and nowhere else. Signing takes a +//! 32-byte digest and returns a DER `ECDSA-Sig-Value`. +//! +//! There is no access control in this version: anyone who can open the node +//! and holds a handle can sign with it. The `flags` words are reserved for an +//! enclave-enforced policy and must be zero. + +use crate::SepData; +use kernel::ioctl::{_IOR, _IOWR}; +use kernel::prelude::*; +use kernel::sync::atomic::Relaxed; +use kernel::uaccess::{UserPtr, UserSlice}; + +pub(crate) const DEVICE_NAME: &CStr = c"sep-key"; +/// Root only by default; distributions grant a group with a udev rule. +pub(crate) const DEVICE_MODE: u16 = 0o600; + +pub(crate) const IFACE_VERSION: u32 = 1; + +pub(crate) const HANDLE_MAX: usize = 2048; +pub(crate) const PUB_LEN: usize = 65; +pub(crate) const DIGEST_LEN: usize = 32; +pub(crate) const SIG_MAX: usize = 72; + +const MAGIC: u32 = 0xB1; + +/// Handle header: `"SEPK"`, version, flags, reserved u16, blob length (LE u32). +const HANDLE_MAGIC: &[u8; 4] = b"SEPK"; +const HANDLE_VERSION: u8 = 1; +pub(crate) const HANDLE_HEADER: usize = 12; +/// The largest enclave record a handle can carry. +pub(crate) const BLOB_MAX: usize = HANDLE_MAX - HANDLE_HEADER; + +#[repr(C)] +pub(crate) struct Info { + pub(crate) version: u32, + /// `flags` bits `CREATE` and `SIGN` accept; zero in this version. + pub(crate) flags_supported: u32, + pub(crate) handle_max: u32, + pub(crate) reserved: u32, +} + +#[repr(C)] +pub(crate) struct Create { + pub(crate) flags: u32, + pub(crate) handle_len: u32, + pub(crate) public_key: [u8; PUB_LEN], + pub(crate) reserved: [u8; 3], + pub(crate) handle: [u8; HANDLE_MAX], +} + +#[repr(C)] +pub(crate) struct Sign { + pub(crate) flags: u32, + pub(crate) handle_len: u32, + pub(crate) digest: [u8; DIGEST_LEN], + pub(crate) sig_len: u32, + pub(crate) signature: [u8; SIG_MAX], + pub(crate) handle: [u8; HANDLE_MAX], +} + +#[repr(C)] +pub(crate) struct Public { + pub(crate) flags: u32, + pub(crate) handle_len: u32, + pub(crate) public_key: [u8; PUB_LEN], + pub(crate) reserved: [u8; 3], + pub(crate) handle: [u8; HANDLE_MAX], +} + +static_assert!(core::mem::size_of::() == 16); +static_assert!(core::mem::size_of::() == 2124); +static_assert!(core::mem::size_of::() == 2164); +static_assert!(core::mem::size_of::() == 2124); + +// SAFETY: `repr(C)` with no padding (the asserts above), so each value is a +// faithful byte image and every bit pattern is a valid value. +unsafe impl kernel::transmute::AsBytes for Info {} +// SAFETY: as above. +unsafe impl kernel::transmute::AsBytes for Create {} +// SAFETY: as above. +unsafe impl kernel::transmute::FromBytes for Create {} +// SAFETY: as above. +unsafe impl kernel::transmute::AsBytes for Sign {} +// SAFETY: as above. +unsafe impl kernel::transmute::FromBytes for Sign {} +// SAFETY: as above. +unsafe impl kernel::transmute::AsBytes for Public {} +// SAFETY: as above. +unsafe impl kernel::transmute::FromBytes for Public {} + +pub(crate) const IOC_INFO: u32 = _IOR::(MAGIC, 0x20); +pub(crate) const IOC_CREATE: u32 = _IOWR::(MAGIC, 0x21); +pub(crate) const IOC_SIGN: u32 = _IOWR::(MAGIC, 0x22); +pub(crate) const IOC_PUBLIC: u32 = _IOWR::(MAGIC, 0x23); + +pub(crate) fn info() -> Info { + Info { + version: IFACE_VERSION, + flags_supported: 0, + handle_max: HANDLE_MAX as u32, + reserved: 0, + } +} + +/// Writes the handle for `blob` into `out`; returns its length. +pub(crate) fn encode_handle(blob: &[u8], out: &mut [u8; HANDLE_MAX]) -> Result { + if blob.is_empty() || blob.len() > BLOB_MAX { + return Err(EINVAL); + } + let len = u32::try_from(blob.len()).map_err(|_| EINVAL)?; + out.fill(0); + out[..4].copy_from_slice(HANDLE_MAGIC); + out[4] = HANDLE_VERSION; + out[5] = 0; + out[8..12].copy_from_slice(&len.to_le_bytes()); + out[HANDLE_HEADER..HANDLE_HEADER + blob.len()].copy_from_slice(blob); + Ok(HANDLE_HEADER + blob.len()) +} + +/// The enclave record inside the first `len` bytes of `handle`. Refuses an +/// unknown version, any flag, a non-zero reserved field or a length that does +/// not match exactly. +pub(crate) fn decode_handle(handle: &[u8; HANDLE_MAX], len: u32) -> Result<&[u8]> { + let len = len as usize; + if !(HANDLE_HEADER + 1..=HANDLE_MAX).contains(&len) { + return Err(EINVAL); + } + if &handle[..4] != HANDLE_MAGIC || handle[4] != HANDLE_VERSION || handle[5] != 0 { + return Err(EINVAL); + } + if handle[6] != 0 || handle[7] != 0 { + return Err(EINVAL); + } + let blob_len = u32::from_le_bytes([handle[8], handle[9], handle[10], handle[11]]) as usize; + if blob_len != len - HANDLE_HEADER { + return Err(EINVAL); + } + Ok(&handle[HANDLE_HEADER..len]) +} + +/// Copies a DER signature into the reply; refuses one that does not fit. +pub(crate) fn put_signature(sig: &[u8], out: &mut Sign) -> Result<()> { + if sig.is_empty() || sig.len() > SIG_MAX { + return Err(EIO); + } + out.signature.fill(0); + out.signature[..sig.len()].copy_from_slice(sig); + out.sig_len = sig.len() as u32; + Ok(()) +} + +#[::kernel::macros::kunit_tests(apple_sep_key_handle)] +mod tests { + use super::*; + + fn blob() -> [u8; 100] { + let mut b = [0u8; 100]; + for (i, v) in b.iter_mut().enumerate() { + *v = i as u8 ^ 0x5a; + } + b + } + + #[test] + fn round_trip() { + let mut h = [0u8; HANDLE_MAX]; + let n = encode_handle(&blob(), &mut h).unwrap(); + assert_eq!(n, HANDLE_HEADER + 100); + assert_eq!(&h[..4], b"SEPK"); + assert_eq!(decode_handle(&h, n as u32).unwrap(), &blob()[..]); + } + + #[test] + fn length_must_match_exactly() { + let mut h = [0u8; HANDLE_MAX]; + let n = encode_handle(&blob(), &mut h).unwrap() as u32; + assert!(decode_handle(&h, n - 1).is_err()); + assert!(decode_handle(&h, n + 1).is_err()); + assert!(decode_handle(&h, 0).is_err()); + assert!(decode_handle(&h, HANDLE_MAX as u32 + 1).is_err()); + } + + #[test] + fn header_fields_are_checked() { + let mut h = [0u8; HANDLE_MAX]; + let n = encode_handle(&blob(), &mut h).unwrap() as u32; + for at in [0usize, 4, 5, 6, 7] { + let mut bad = h; + bad[at] ^= 0x01; + assert!(decode_handle(&bad, n).is_err()); + } + } + + #[test] + fn empty_and_oversize_blobs_are_refused() { + let mut h = [0u8; HANDLE_MAX]; + assert!(encode_handle(&[], &mut h).is_err()); + let big = [1u8; BLOB_MAX + 1]; + assert!(encode_handle(&big, &mut h).is_err()); + let max = [1u8; BLOB_MAX]; + assert_eq!(encode_handle(&max, &mut h).unwrap(), HANDLE_MAX); + } + + #[test] + fn signature_bounds() { + let mut s = Sign { + flags: 0, + handle_len: 0, + digest: [0; DIGEST_LEN], + sig_len: 0, + signature: [0; SIG_MAX], + handle: [0; HANDLE_MAX], + }; + assert!(put_signature(&[], &mut s).is_err()); + assert!(put_signature(&[0x30; SIG_MAX + 1], &mut s).is_err()); + put_signature(&[0x30, 0x02, 0x01, 0x01], &mut s).unwrap(); + assert_eq!(s.sig_len, 4); + } +} + +impl SepData { + pub(crate) fn register_key(&self) -> Result<()> { + let mut guard = self.key_dev.lock(); + if guard.is_some() { + return Ok(()); + } + let ctx = core::ptr::from_ref(self).cast_mut().cast::(); + // SAFETY: as in `register_bio`: `remove()` drops the registration + // before the `Arc` that keeps `ctx` alive, and the shim's unregister + // clears the stored context under the lock every callback takes. + let dev = unsafe { + crate::shim::BioChardev::register( + DEVICE_NAME, + DEVICE_MODE, + ctx, + key_open_trampoline, + key_release_trampoline, + key_ioctl_trampoline, + key_ready_trampoline, + ) + }?; + *guard = Some(dev); + Ok(()) + } + + fn key_ioctl(&self, cmd: u32, arg: usize) -> Result<()> { + if self.shutting_down.load(Relaxed) { + return Err(ENODEV); + } + let user = UserPtr::from_addr(arg); + match cmd { + IOC_INFO => UserSlice::new(user, core::mem::size_of::()) + .writer() + .write(&info()), + IOC_CREATE => { + let slice = UserSlice::new(user, core::mem::size_of::()); + let (mut reader, mut writer) = slice.reader_writer(); + let mut req: Create = reader.read()?; + if req.flags != 0 || req.reserved != [0; 3] { + return Err(EINVAL); + } + let (blob, point) = self.user_key_create()?; + req.handle_len = encode_handle(&blob, &mut req.handle)? as u32; + req.public_key = point; + writer.write(&req) + } + IOC_SIGN => { + let slice = UserSlice::new(user, core::mem::size_of::()); + let (mut reader, mut writer) = slice.reader_writer(); + let mut req: Sign = reader.read()?; + if req.flags != 0 { + return Err(EINVAL); + } + let sig = { + let blob = decode_handle(&req.handle, req.handle_len)?; + self.user_key_sign(blob, &req.digest)? + }; + put_signature(&sig, &mut req)?; + writer.write(&req) + } + IOC_PUBLIC => { + let slice = UserSlice::new(user, core::mem::size_of::()); + let (mut reader, mut writer) = slice.reader_writer(); + let mut req: Public = reader.read()?; + if req.flags != 0 || req.reserved != [0; 3] { + return Err(EINVAL); + } + req.public_key = + SepData::user_key_public(decode_handle(&req.handle, req.handle_len)?)?; + writer.write(&req) + } + _ => Err(ENOTTY), + } + } +} + +// /dev/sep-key has no session: any number of openers, no poll. + +/// # Safety +/// `ctx` is the `SepData` pointer given to `register_key`, valid while the +/// registration lives (see `register_key`). +unsafe extern "C" fn key_open_trampoline(ctx: *mut c_void) -> c_int { + // SAFETY: per the contract above. + let this = unsafe { &*ctx.cast::() }; + if this.shutting_down.load(Relaxed) { + return ENODEV.to_errno(); + } + 0 +} + +/// # Safety +/// As for `key_open_trampoline`. +unsafe extern "C" fn key_release_trampoline(_ctx: *mut c_void) {} + +/// # Safety +/// As for `key_open_trampoline`. +unsafe extern "C" fn key_ioctl_trampoline(ctx: *mut c_void, cmd: c_uint, arg: c_ulong) -> c_long { + // SAFETY: per the contract above. + let this = unsafe { &*ctx.cast::() }; + match this.key_ioctl(cmd, arg) { + Ok(()) => 0, + Err(e) => e.to_errno() as c_long, + } +} + +/// # Safety +/// As for `key_open_trampoline`. +unsafe extern "C" fn key_ready_trampoline(_ctx: *mut c_void) -> c_int { + 0 +} diff --git a/drivers/soc/apple/refkey.rs b/drivers/soc/apple/refkey.rs index e505fb8d03bea..f1c60a815f60b 100644 --- a/drivers/soc/apple/refkey.rs +++ b/drivers/soc/apple/refkey.rs @@ -309,37 +309,44 @@ impl SepData { } /// Op `osgn`: ECDSA-P256 over `challenge` as the pre-computed digest. + /// `EKEYREJECTED` when the enclave refuses the operation (for example a + /// ref-key record another SEP or another key bag made), `EIO` for no or + /// a malformed reply. fn sks_refkey_sign( &self, handle: crate::sks::KeyBagHandle, refkey_blob: &[u8], challenge: &[u8], - ) -> Option> { + ) -> Result> { use crate::der::RefKeyValue as RV; let mut items: KVec<(&[u8], RV<'_>)> = KVec::new(); - items.push((b"o", RV::Utf8(b"osgn")), GFP_KERNEL).ok()?; - items.push((b"d", RV::Octets(challenge)), GFP_KERNEL).ok()?; - items.push((b"rk", RV::Der(refkey_blob)), GFP_KERNEL).ok()?; - let der = crate::der::encode_refkey_set(&items).ok()?; - let out = self.sks_send(self.sks_req_refkey(handle.value(), &der))?; + items.push((b"o", RV::Utf8(b"osgn")), GFP_KERNEL)?; + items.push((b"d", RV::Octets(challenge)), GFP_KERNEL)?; + items.push((b"rk", RV::Der(refkey_blob)), GFP_KERNEL)?; + let der = crate::der::encode_refkey_set(&items)?; + let out = self + .sks_send(self.sks_req_refkey(handle.value(), &der)) + .ok_or(EIO)?; if out.reply.status != 0 { dev_warn!( self.dev, - "sks: ref-key attest sign failed (status {})\n", + "sks: ref-key sign refused by the enclave (status {})\n", out.reply.status ); - return None; + return Err(EKEYREJECTED); } - let body = self.sks_report_response(crate::sks::SKS_PERFORM_OP_NAME, &out)?; + let body = self + .sks_report_response(crate::sks::SKS_PERFORM_OP_NAME, &out) + .ok_or(EIO)?; let mut f = proto::FieldCursor::new(body); let (Some(0), Some(sig)) = (f.i32(), f.blob()) else { - return None; + return Err(EIO); }; // Enclave wraps the sig in OCTET STRING { SEQUENCE { r, s } }; strip to inner DER. let der_sig = crate::der::octet_string_body(sig).unwrap_or(sig); let mut owned: KVec = KVec::new(); - owned.extend_from_slice(der_sig, GFP_KERNEL).ok()?; - Some(owned) + owned.extend_from_slice(der_sig, GFP_KERNEL)?; + Ok(owned) } /// Attestation of key possession by signing proof; op `oa` (attestation-chained) @@ -371,7 +378,70 @@ impl SepData { }); let sig = self.sks_refkey_sign(handle, &blob, challenge); let _ = self.sks_send(self.sks_req_unload_keybag(handle)); - Ok((sig.ok_or(EIO)?, pubk)) + Ok((sig?, pubk)) + } + + /// Runs `op` with the identity key bag loaded and unlocked, as a trusted + /// key unseal does, and unloads the bag afterwards whatever `op` returns. + fn with_identity_bag( + &self, + op: impl FnOnce(crate::sks::KeyBagHandle, &[u8]) -> Result, + ) -> Result { + self.wait_for_trusted_ready()?; + let keybag::State::Present(stored) = keybag::read(keybag::Slot::Identity)? else { + return Err(ENODEV); + }; + let (handle, _uuid) = self.sks_recover(&stored).ok_or(EIO)?; + let unlocked = crate::xart_guard::policy_window(|| { + if self.profile.key_store == profile::KeyStore::Variant5 { + if let Some(healthy) = self.sks_health_check(c"sep-key unlock") { + let _ = self.sks_send(self.sks_req_change_lock_state( + handle, + LockState::Unlocked, + stored.secret(), + healthy, + )); + } + true + } else { + self.sks_prepare_identity_refkey(handle, stored.secret()) + } + }); + let result = if unlocked { + op(handle, stored.secret()) + } else { + Err(EIO) + }; + let _ = self.sks_send(self.sks_req_unload_keybag(handle)); + result + } + + /// `/dev/sep-key` create: a new enclave P-256 ref-key under the identity + /// key bag. Returns its record and public point; the private half stays + /// in the enclave. + pub(crate) fn user_key_create(&self) -> Result<(KVec, [u8; refkey_seal::POINT_LEN])> { + let blob = self.with_identity_bag(|handle, secret| { + self.sks_create_refkey(handle, secret).ok_or(EIO) + })?; + let mut point = [0u8; refkey_seal::POINT_LEN]; + point.copy_from_slice(Self::refkey_pub(&blob).ok_or(EIO)?); + Ok((blob, point)) + } + + /// The public point recorded in a ref-key record; no enclave round trip. + pub(crate) fn user_key_public(blob: &[u8]) -> Result<[u8; refkey_seal::POINT_LEN]> { + let mut point = [0u8; refkey_seal::POINT_LEN]; + point.copy_from_slice(Self::refkey_pub(blob).ok_or(EINVAL)?); + Ok(point) + } + + /// `/dev/sep-key` sign: the enclave's ECDSA-P256 over `digest` with the + /// key in `blob`. A record this SEP and key bag did not make is refused + /// by the enclave (`EKEYREJECTED`). + pub(crate) fn user_key_sign(&self, blob: &[u8], digest: &[u8; 32]) -> Result> { + // A record without a public point is not a ref-key record at all. + Self::refkey_pub(blob).ok_or(EINVAL)?; + self.with_identity_bag(|handle, _secret| self.sks_refkey_sign(handle, blob, digest)) } /// Enclave ECIES-decrypt (o = "oecd"); takes the ephemeral from the front of `d`. diff --git a/drivers/soc/apple/sbio.rs b/drivers/soc/apple/sbio.rs index 58582dc6e1b8b..7a8e2ae11d609 100644 --- a/drivers/soc/apple/sbio.rs +++ b/drivers/soc/apple/sbio.rs @@ -1898,6 +1898,14 @@ impl SepData { e ); } + if let Err(e) = self.register_key() { + dev_err!( + self.dev, + "bringup: could not publish /dev/{}: {:?}\n", + crate::key::DEVICE_NAME, + e + ); + } } fn activate_touchid(&self) -> Result<()> { diff --git a/drivers/soc/apple/sep.rs b/drivers/soc/apple/sep.rs index fd75957ddeca2..ef7c47605ae8b 100644 --- a/drivers/soc/apple/sep.rs +++ b/drivers/soc/apple/sep.rs @@ -18,6 +18,7 @@ mod dt; mod fv; mod hwrng; mod image; +mod key; mod keybag; mod keybag_identity; mod os_identity; @@ -763,6 +764,10 @@ struct SepData { #[pin] rearm_dev: Mutex>, + // /dev/sep-key: P-256 keys created inside the SEP (see `key`). + #[pin] + key_dev: Mutex>, + // The enclave refused to match (MATCH_RESULT answered 0x1) after failed // attempts, or the lockout survived a restart. Every verify is refused // without a capture until a password authentication re-arms Touch ID. @@ -1041,6 +1046,7 @@ impl SepData { bio_index <- new_mutex!(bio_index), bio_dev <- new_mutex!(None), rearm_dev <- new_mutex!(None), + key_dev <- new_mutex!(None), bio_lockout: Atomic::new(false), bio_lockout_logged: Atomic::new(false), bio_nomatch_streak: Atomic::new(0), @@ -2619,6 +2625,8 @@ impl SepData { drop(dev); let dev = self.rearm_dev.lock().take(); drop(dev); + let dev = self.key_dev.lock().take(); + drop(dev); self.rng_shutdown.store(true, Relaxed); self.control_wq.notify_all(); diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index a347d8d8d0c06..5337e379208b1 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -179,4 +179,67 @@ struct sep_bio_rearm { #define SEP_BIO_REARM _IOW(SEP_BIO_IOC_MAGIC, 0x0f, struct sep_bio_rearm) +/* + * /dev/sep-key: P-256 keys created inside the SEP. + * + * Each key is made by the enclave's key store and bound to this SEP and to + * the Linux identity key bag. The private key never leaves the enclave. + * Userspace holds only an opaque handle (at most SEP_KEY_HANDLE_MAX bytes) + * that works on the Mac that made it and nowhere else; keep it like a key + * file. Signing takes a 32-byte digest (for ES256/ecdsa-sha2-nistp256, the + * SHA-256 of the message) and returns a DER ECDSA-Sig-Value. + * + * Interface version 1 has no access control: whoever can open the node and + * holds a handle can sign with it. The node is created 0600; distributions + * may grant a group. Every 'flags' and 'reserved' field must be zero; later + * versions report what they accept in SEP_KEY_GET_INFO. + * + * Errors: EINVAL for a malformed request or handle, EKEYREJECTED when the + * enclave refuses the key (for example a handle from another Mac or an + * earlier Linux install), ENODEV/ETIMEDOUT while the key store is not up, + * EIO for any other enclave failure. + */ +#define SEP_KEY_IFACE_VERSION 1 +#define SEP_KEY_HANDLE_MAX 2048 +#define SEP_KEY_PUB_LEN 65 /* uncompressed point, 04 || X || Y */ +#define SEP_KEY_DIGEST_LEN 32 +#define SEP_KEY_SIG_MAX 72 /* DER SEQUENCE { INTEGER r, INTEGER s } */ + +struct sep_key_info { + __u32 version; /* SEP_KEY_IFACE_VERSION */ + __u32 flags_supported; /* 0 in version 1 */ + __u32 handle_max; /* SEP_KEY_HANDLE_MAX */ + __u32 reserved; +}; + +struct sep_key_create { + __u32 flags; /* in: 0 */ + __u32 handle_len; /* out */ + __u8 public_key[SEP_KEY_PUB_LEN]; /* out */ + __u8 reserved[3]; /* in: 0 */ + __u8 handle[SEP_KEY_HANDLE_MAX]; /* out */ +}; + +struct sep_key_sign { + __u32 flags; /* in: 0 */ + __u32 handle_len; /* in */ + __u8 digest[SEP_KEY_DIGEST_LEN]; /* in */ + __u32 sig_len; /* out */ + __u8 signature[SEP_KEY_SIG_MAX]; /* out, zero-padded */ + __u8 handle[SEP_KEY_HANDLE_MAX]; /* in */ +}; + +struct sep_key_public { + __u32 flags; /* in: 0 */ + __u32 handle_len; /* in */ + __u8 public_key[SEP_KEY_PUB_LEN]; /* out */ + __u8 reserved[3]; /* in: 0 */ + __u8 handle[SEP_KEY_HANDLE_MAX]; /* in */ +}; + +#define SEP_KEY_GET_INFO _IOR(SEP_BIO_IOC_MAGIC, 0x20, struct sep_key_info) +#define SEP_KEY_CREATE _IOWR(SEP_BIO_IOC_MAGIC, 0x21, struct sep_key_create) +#define SEP_KEY_SIGN _IOWR(SEP_BIO_IOC_MAGIC, 0x22, struct sep_key_sign) +#define SEP_KEY_PUBLIC _IOWR(SEP_BIO_IOC_MAGIC, 0x23, struct sep_key_public) + #endif /* _UAPI_LINUX_APPLE_SEP_H */ From ed3d647659522c42f2cc9b182b957464b984a357 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 17:08:37 +1100 Subject: [PATCH 04/21] soc: apple: sep: sep-key: Touch ID keys the enclave guards Add SEP_KEY_CREATE_BIOMETRY_CURRENT_SET (interface version 2). Such a key's record carries the access-control list macOS gives a Secure Enclave key made with .biometryCurrentSet: signing, key agreement and derivation need a biometric credential for this catacomb and enrolled set; delete and attest are free. The kernel reads the catacomb id and enrolled-set hash with SBIO 0x33 and 0x36 (read only) and puts the list in the create request (`ed`); the enclave keeps it in the record and enforces it on every later operation. Adding or removing a finger changes the hash and makes the key unusable. SEP_KEY_SIGN on such a key waits up to 30 s for a touch. Inside that one call the kernel opens a fresh credential context, evaluates the signing constraint on it (ACM command 0x20, best effort), runs one Touch ID match in it through the normal verify path (ACM match mode, MATCH_CANCEL, the same lockout counting and template persistence as any verify), signs with the context (`acmh`) and deletes the context before returning. No handle to the context ever reaches userspace, so a satisfied context cannot outlive the signature it was opened for: every signature needs its own touch. While it runs, the verify belongs to the ioctl, not to /dev/sep-bio's session; it serialises with that session's work on the catacomb writer lock and never reuses its open prompt. SEP_KEY_SELFTEST (test only: apple_sep.key_selftest=1 and root) signs a biometric key under deliberately unsuitable contexts: none, one with no match, one holding only the key-bag secret (the Linux passcode), a deleted one, and, with one touch, the matched context after it was deleted. It reports the enclave's answer, so the refusal is seen to come from the enclave. The request layouts follow our own trace of macOS 26.6.2 creating and using such keys through public APIs. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/der.rs | 7 + drivers/soc/apple/key.rs | 504 +++++++++++++++++++++++++++++++-- drivers/soc/apple/refkey.rs | 61 ++-- drivers/soc/apple/sbio.rs | 111 +++++++- drivers/soc/apple/scrd.rs | 29 ++ drivers/soc/apple/sep.rs | 16 ++ include/uapi/linux/apple-sep.h | 49 +++- 7 files changed, 709 insertions(+), 68 deletions(-) diff --git a/drivers/soc/apple/der.rs b/drivers/soc/apple/der.rs index fcd198561872f..19acef903feb7 100644 --- a/drivers/soc/apple/der.rs +++ b/drivers/soc/apple/der.rs @@ -8,6 +8,7 @@ use kernel::prelude::*; const TAG_SET: u8 = 0x31; const TAG_SEQUENCE: u8 = 0x30; const TAG_UTF8: u8 = 0x0c; +const TAG_BOOLEAN: u8 = 0x01; pub(crate) const TAG_INTEGER: u8 = 0x02; pub(crate) const TAG_OCTET_STRING: u8 = 0x04; @@ -51,6 +52,8 @@ pub(crate) enum RefKeyValue<'a> { Integer(u32), Octets(&'a [u8]), Der(&'a [u8]), + /// BOOLEAN, encoded as the key store writes it (true = 0x01). + Bool(bool), } /// Canonical DER `SET OF SEQUENCE`, members sorted by key — the shape the @@ -85,6 +88,10 @@ pub(crate) fn encode_refkey_set(items: &[(&[u8], RefKeyValue<'_>)]) -> Result inner.extend_from_slice(d, GFP_KERNEL)?, + RefKeyValue::Bool(b) => { + put_header(&mut inner, TAG_BOOLEAN, 1)?; + inner.push(u8::from(*b), GFP_KERNEL)?; + } } let mut seq = KVec::new(); put_header(&mut seq, TAG_SEQUENCE, inner.len())?; diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index d92d50716cc03..aa991b4639fd0 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -8,10 +8,17 @@ //! A handle works on the Mac that created it and nowhere else. Signing takes a //! 32-byte digest and returns a DER `ECDSA-Sig-Value`. //! -//! There is no access control in this version: anyone who can open the node -//! and holds a handle can sign with it. The `flags` words are reserved for an -//! enclave-enforced policy and must be zero. +//! A key made with `CREATE_BIOMETRY_CURRENT_SET` carries an access-control +//! list in its record, the one macOS gives a Secure Enclave key made with +//! `.biometryCurrentSet`: signing (and key agreement) needs a credential +//! context in which the enclave itself saw a match of one of the fingers +//! enrolled now. The kernel opens that context inside the `SIGN` call, runs a +//! Touch ID match in it, signs, and deletes it before the call returns. No +//! handle to it ever reaches userspace, and every signature needs its own +//! touch. The enclave refuses the signature for any other context; the +//! header's flag only tells the kernel to ask for the touch. +use crate::der::RefKeyValue as RV; use crate::SepData; use kernel::ioctl::{_IOR, _IOWR}; use kernel::prelude::*; @@ -22,18 +29,28 @@ pub(crate) const DEVICE_NAME: &CStr = c"sep-key"; /// Root only by default; distributions grant a group with a udev rule. pub(crate) const DEVICE_MODE: u16 = 0o600; -pub(crate) const IFACE_VERSION: u32 = 1; +pub(crate) const IFACE_VERSION: u32 = 2; pub(crate) const HANDLE_MAX: usize = 2048; pub(crate) const PUB_LEN: usize = 65; pub(crate) const DIGEST_LEN: usize = 32; pub(crate) const SIG_MAX: usize = 72; +/// `CREATE` flag: signing needs a Touch ID match of a currently enrolled +/// finger, enforced by the enclave. +pub(crate) const CREATE_BIOMETRY_CURRENT_SET: u32 = 1; +const CREATE_FLAGS: u32 = CREATE_BIOMETRY_CURRENT_SET; + +/// How long a touch-gated `SIGN` waits for a finger in all. +const TOUCH_WAIT_MS: u64 = 30_000; + const MAGIC: u32 = 0xB1; /// Handle header: `"SEPK"`, version, flags, reserved u16, blob length (LE u32). const HANDLE_MAGIC: &[u8; 4] = b"SEPK"; const HANDLE_VERSION: u8 = 1; +/// Handle flag: the record carries a biometric access-control list. +pub(crate) const HANDLE_BIOMETRY: u8 = 0x01; pub(crate) const HANDLE_HEADER: usize = 12; /// The largest enclave record a handle can carry. pub(crate) const BLOB_MAX: usize = HANDLE_MAX - HANDLE_HEADER; @@ -41,7 +58,7 @@ pub(crate) const BLOB_MAX: usize = HANDLE_MAX - HANDLE_HEADER; #[repr(C)] pub(crate) struct Info { pub(crate) version: u32, - /// `flags` bits `CREATE` and `SIGN` accept; zero in this version. + /// `flags` bits `CREATE` accepts. pub(crate) flags_supported: u32, pub(crate) handle_max: u32, pub(crate) reserved: u32, @@ -75,10 +92,40 @@ pub(crate) struct Public { pub(crate) handle: [u8; HANDLE_MAX], } +/// Test only (module parameter `key_selftest`, root): sign with a +/// biometric key under a deliberately unsuitable context. +#[repr(C)] +pub(crate) struct Selftest { + pub(crate) mode: u32, + pub(crate) handle_len: u32, + pub(crate) digest: [u8; DIGEST_LEN], + /// 0 when the enclave signed, else the negative errno. + pub(crate) result: i32, + /// The enclave's status for that signature. + pub(crate) enclave_status: i32, + /// `SELFTEST_TOUCH_THEN_DELETED`: the second signature. + pub(crate) result2: i32, + pub(crate) enclave_status2: i32, + pub(crate) handle: [u8; HANDLE_MAX], +} + +/// No credential context at all. +pub(crate) const SELFTEST_NO_CONTEXT: u32 = 1; +/// A fresh context in which nothing has matched. +pub(crate) const SELFTEST_UNSATISFIED: u32 = 2; +/// A context holding only the key bag's secret (Linux's "passcode"). +pub(crate) const SELFTEST_PASSCODE: u32 = 3; +/// A fresh context, deleted before the signature. +pub(crate) const SELFTEST_DELETED: u32 = 4; +/// One touch: sign in the matched context, delete it, sign again with it +/// (needs a finger). +pub(crate) const SELFTEST_TOUCH_THEN_DELETED: u32 = 5; + static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 2124); static_assert!(core::mem::size_of::() == 2164); static_assert!(core::mem::size_of::() == 2124); +static_assert!(core::mem::size_of::() == 2104); // SAFETY: `repr(C)` with no padding (the asserts above), so each value is a // faithful byte image and every bit pattern is a valid value. @@ -95,45 +142,51 @@ unsafe impl kernel::transmute::FromBytes for Sign {} unsafe impl kernel::transmute::AsBytes for Public {} // SAFETY: as above. unsafe impl kernel::transmute::FromBytes for Public {} +// SAFETY: as above. +unsafe impl kernel::transmute::AsBytes for Selftest {} +// SAFETY: as above. +unsafe impl kernel::transmute::FromBytes for Selftest {} pub(crate) const IOC_INFO: u32 = _IOR::(MAGIC, 0x20); pub(crate) const IOC_CREATE: u32 = _IOWR::(MAGIC, 0x21); pub(crate) const IOC_SIGN: u32 = _IOWR::(MAGIC, 0x22); pub(crate) const IOC_PUBLIC: u32 = _IOWR::(MAGIC, 0x23); +pub(crate) const IOC_SELFTEST: u32 = _IOWR::(MAGIC, 0x24); pub(crate) fn info() -> Info { Info { version: IFACE_VERSION, - flags_supported: 0, + flags_supported: CREATE_FLAGS, handle_max: HANDLE_MAX as u32, reserved: 0, } } -/// Writes the handle for `blob` into `out`; returns its length. -pub(crate) fn encode_handle(blob: &[u8], out: &mut [u8; HANDLE_MAX]) -> Result { - if blob.is_empty() || blob.len() > BLOB_MAX { +/// Writes the handle for `blob` with `flags` into `out`; returns its length. +pub(crate) fn encode_handle(blob: &[u8], flags: u8, out: &mut [u8; HANDLE_MAX]) -> Result { + if blob.is_empty() || blob.len() > BLOB_MAX || flags & !HANDLE_BIOMETRY != 0 { return Err(EINVAL); } let len = u32::try_from(blob.len()).map_err(|_| EINVAL)?; out.fill(0); out[..4].copy_from_slice(HANDLE_MAGIC); out[4] = HANDLE_VERSION; - out[5] = 0; + out[5] = flags; out[8..12].copy_from_slice(&len.to_le_bytes()); out[HANDLE_HEADER..HANDLE_HEADER + blob.len()].copy_from_slice(blob); Ok(HANDLE_HEADER + blob.len()) } -/// The enclave record inside the first `len` bytes of `handle`. Refuses an -/// unknown version, any flag, a non-zero reserved field or a length that does -/// not match exactly. -pub(crate) fn decode_handle(handle: &[u8; HANDLE_MAX], len: u32) -> Result<&[u8]> { +/// The enclave record inside the first `len` bytes of `handle`, and the +/// handle's flags. Refuses an unknown version or flag, a non-zero reserved +/// field or a length that does not match exactly. The flags are not +/// authenticated; the enclave enforces the record's own list either way. +pub(crate) fn decode_handle(handle: &[u8; HANDLE_MAX], len: u32) -> Result<(&[u8], u8)> { let len = len as usize; if !(HANDLE_HEADER + 1..=HANDLE_MAX).contains(&len) { return Err(EINVAL); } - if &handle[..4] != HANDLE_MAGIC || handle[4] != HANDLE_VERSION || handle[5] != 0 { + if &handle[..4] != HANDLE_MAGIC || handle[4] != HANDLE_VERSION || handle[5] & !HANDLE_BIOMETRY != 0 { return Err(EINVAL); } if handle[6] != 0 || handle[7] != 0 { @@ -143,7 +196,7 @@ pub(crate) fn decode_handle(handle: &[u8; HANDLE_MAX], len: u32) -> Result<&[u8] if blob_len != len - HANDLE_HEADER { return Err(EINVAL); } - Ok(&handle[HANDLE_HEADER..len]) + Ok((&handle[HANDLE_HEADER..len], handle[5])) } /// Copies a DER signature into the reply; refuses one that does not fit. @@ -157,6 +210,75 @@ pub(crate) fn put_signature(sig: &[u8], out: &mut Sign) -> Result<()> { Ok(()) } +/// The access-control list of a biometry-current-set key, in the key +/// store's DER, and the constraint its signing operation carries. +/// +/// As macOS writes it for `.privateKeyUsage | .biometryCurrentSet`: sign, +/// key agreement (`ock`) and derivation (`okd`) need a biometric credential +/// (`cbio`) for this catacomb (`pbioc`) and enrolled set (`pbioh`); delete +/// (`odel`) and attest (`oa`) are free. +pub(crate) struct BioAcl { + pub(crate) ed: KVec, + pub(crate) constraint: KVec, +} + +pub(crate) fn bio_acl(catacomb: &[u8; 16], enrolled_hash: &[u8; 32]) -> Result { + let cbio = crate::der::encode_refkey_set(&[ + (b"pbioc".as_slice(), RV::Octets(catacomb)), + (b"pbioh".as_slice(), RV::Octets(enrolled_hash)), + ])?; + let constraint = crate::der::encode_refkey_set(&[(b"cbio".as_slice(), RV::Der(&cbio))])?; + let acl = crate::der::encode_refkey_set(&[ + (b"oa".as_slice(), RV::Bool(true)), + (b"ock".as_slice(), RV::Der(&constraint)), + (b"odel".as_slice(), RV::Bool(true)), + (b"okd".as_slice(), RV::Der(&constraint)), + (b"osgn".as_slice(), RV::Der(&constraint)), + ])?; + let ed = crate::der::encode_refkey_set(&[(b"acl".as_slice(), RV::Der(&acl))])?; + Ok(BioAcl { ed, constraint }) +} + +/// What one verify run of a touch-gated signature ended with. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum TouchOutcome { + Matched, + NoMatch, + Failed(u32), +} + +/// The touch-gated signature a `SIGN` call is waiting for. Lives only for +/// that call; the verify it runs fills it in. +pub(crate) struct KeyTouch { + blob: KVec, + digest: [u8; DIGEST_LEN], + constraint: KVec, + sign_again_after_delete: bool, + outcome: Option, + sig: Option>>, + status: i32, + again: Option>>, + again_status: i32, +} + +impl KeyTouch { + pub(crate) fn set_outcome(&mut self, outcome: &crate::bio::VerifyOutcome) { + self.outcome = Some(match outcome { + crate::bio::VerifyOutcome::Matched(_) => TouchOutcome::Matched, + crate::bio::VerifyOutcome::NoMatch => TouchOutcome::NoMatch, + crate::bio::VerifyOutcome::Failed(s) => TouchOutcome::Failed(*s), + }); + } +} + +fn errno_of(r: &Option>) -> i32 { + match r { + Some(Ok(_)) => 0, + Some(Err(e)) => e.to_errno(), + None => EIO.to_errno(), + } +} + #[::kernel::macros::kunit_tests(apple_sep_key_handle)] mod tests { use super::*; @@ -172,16 +294,16 @@ mod tests { #[test] fn round_trip() { let mut h = [0u8; HANDLE_MAX]; - let n = encode_handle(&blob(), &mut h).unwrap(); + let n = encode_handle(&blob(), 0, &mut h).unwrap(); assert_eq!(n, HANDLE_HEADER + 100); assert_eq!(&h[..4], b"SEPK"); - assert_eq!(decode_handle(&h, n as u32).unwrap(), &blob()[..]); + assert_eq!(decode_handle(&h, n as u32).unwrap(), (&blob()[..], 0)); } #[test] fn length_must_match_exactly() { let mut h = [0u8; HANDLE_MAX]; - let n = encode_handle(&blob(), &mut h).unwrap() as u32; + let n = encode_handle(&blob(), 0, &mut h).unwrap() as u32; assert!(decode_handle(&h, n - 1).is_err()); assert!(decode_handle(&h, n + 1).is_err()); assert!(decode_handle(&h, 0).is_err()); @@ -191,8 +313,8 @@ mod tests { #[test] fn header_fields_are_checked() { let mut h = [0u8; HANDLE_MAX]; - let n = encode_handle(&blob(), &mut h).unwrap() as u32; - for at in [0usize, 4, 5, 6, 7] { + let n = encode_handle(&blob(), 0, &mut h).unwrap() as u32; + for at in [0usize, 4, 6, 7] { let mut bad = h; bad[at] ^= 0x01; assert!(decode_handle(&bad, n).is_err()); @@ -202,11 +324,11 @@ mod tests { #[test] fn empty_and_oversize_blobs_are_refused() { let mut h = [0u8; HANDLE_MAX]; - assert!(encode_handle(&[], &mut h).is_err()); + assert!(encode_handle(&[], 0, &mut h).is_err()); let big = [1u8; BLOB_MAX + 1]; - assert!(encode_handle(&big, &mut h).is_err()); + assert!(encode_handle(&big, 0, &mut h).is_err()); let max = [1u8; BLOB_MAX]; - assert_eq!(encode_handle(&max, &mut h).unwrap(), HANDLE_MAX); + assert_eq!(encode_handle(&max, 0, &mut h).unwrap(), HANDLE_MAX); } #[test] @@ -224,6 +346,43 @@ mod tests { put_signature(&[0x30, 0x02, 0x01, 0x01], &mut s).unwrap(); assert_eq!(s.sig_len, 4); } + + #[test] + fn biometry_flag_round_trips_and_unknown_flags_are_refused() { + let mut h = [0u8; HANDLE_MAX]; + let n = encode_handle(&blob(), HANDLE_BIOMETRY, &mut h).unwrap() as u32; + assert_eq!(decode_handle(&h, n).unwrap().1, HANDLE_BIOMETRY); + assert!(encode_handle(&blob(), 0x02, &mut h).is_err()); + let mut bad = h; + bad[5] = 0x02; + assert!(decode_handle(&bad, n).is_err()); + } + + #[test] + fn biometric_acl_has_the_key_store_shape() { + let acl = bio_acl(&[0x11; 16], &[0x22; 32]).unwrap(); + // SET { SEQ { "cbio", SET { SEQ { "pbioc", OCTETS[16] }, SEQ { "pbioh", OCTETS[32] } } } } + assert_eq!(acl.constraint.len(), 2 + 2 + 6 + 2 + (2 + 7 + 18) + (2 + 7 + 34)); + assert_eq!(&acl.constraint[..2], &[0x31, 0x50]); + assert_eq!(&acl.constraint[4..10], b"\x0c\x04cbio"); + let ed = &acl.ed; + assert_eq!(ed[0], 0x31); + // The list names all five operations; delete and attest are free. + for op in [b"oa".as_slice(), b"ock", b"odel", b"okd", b"osgn"] { + assert!(ed.windows(op.len() + 2).any(|w| w[0] == 0x0c && w[1] as usize == op.len() && &w[2..] == op)); + } + assert!(ed.windows(3).any(|w| w == [0x01u8, 0x01, 0x01].as_slice())); + } + + #[test] + fn acl_constraint_command_has_the_captured_layout() { + let acl = bio_acl(&[0x11; 16], &[0x22; 32]).unwrap(); + let cmd = crate::scrd::scrd_verify_acl_constraint(&[0x33; 16], &acl.constraint, b"ock").unwrap(); + // macOS: 138 bytes for a 94-byte constraint and "ock"; this one is 80 bytes. + assert_eq!(cmd.len(), 8 + 16 + 4 + acl.constraint.len() + 4 + 3 + 1 + 8); + assert_eq!(&cmd[..8], b"DRCS\x20\x00\x00\x02"); + assert_eq!(&cmd[24..28], &(acl.constraint.len() as u32).to_le_bytes()); + } } impl SepData { @@ -251,6 +410,275 @@ impl SepData { Ok(()) } + /// Whether the capture a verify is waiting on is still wanted: the + /// touch-gated signature that owns it, or /dev/sep-bio's live session. + pub(crate) fn capture_live(&self) -> bool { + (self.key_touch_active.load(Relaxed) && !self.shutting_down.load(Relaxed)) + || crate::bio::capture_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) + } + + fn key_bio_acl(&self) -> Result { + if !self.touchid_started.load(Relaxed) { + return Err(ENODEV); + } + let (catacomb, enrolled) = self.catacomb_binding()?; + bio_acl(&catacomb, &enrolled) + } + + /// Evaluates the signing constraint on the fresh context before the + /// match, as LocalAuthentication does. Best effort: logged only. + pub(crate) fn key_touch_preflight(&self, evaluation: &[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]) { + let constraint = { + let guard = self.key_touch.lock(); + let Some(job) = guard.as_ref() else { + return; + }; + let mut c = KVec::new(); + if c.extend_from_slice(&job.constraint, GFP_KERNEL).is_err() { + return; + } + c + }; + let Ok(payload) = crate::scrd::scrd_verify_acl_constraint(evaluation, &constraint, b"osgn") else { + return; + }; + match self.scrd_transact( + c"SCRD_VERIFY_ACL_CONSTRAINT", + crate::scrd::SCRD_REQUEST_COMMAND, + &payload, + crate::SCRD_TIMEOUT_MS, + ) { + Some((reply, out)) => { + let word = |at: usize| { + out.get(at..at + 4) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + }; + dev_info!( + self.dev, + "sep-key: signing constraint on the new context: status {}, {} reply bytes, satisfied {:?}, first requirement {:?}\n", + reply.status, + out.len(), + word(0), + word(4) + ); + } + None => dev_info!(self.dev, "sep-key: signing constraint check got no answer\n"), + } + } + + /// Signs the waiting job in `evaluation`, the context the match just + /// satisfied. Called from the verify, at most once with success. + pub(crate) fn key_touch_sign(&self, evaluation: &[u8; crate::scrd::SCRD_ACM_HANDLE_LEN], when: &CStr) { + let (blob, digest) = { + let guard = self.key_touch.lock(); + let Some(job) = guard.as_ref() else { + return; + }; + if matches!(job.sig, Some(Ok(_))) { + return; + } + let mut b = KVec::new(); + if b.extend_from_slice(&job.blob, GFP_KERNEL).is_err() { + return; + } + (b, job.digest) + }; + let r = self.user_key_sign(&blob, &digest, Some(evaluation)); + let status = self.last_key_status.load(Relaxed) as i32; + dev_info!( + self.dev, + "sep-key: touch-gated signature {}: {} (enclave status {})\n", + when, + (if r.is_ok() { "signed" } else { "refused" }), + status + ); + if let Some(job) = &mut *self.key_touch.lock() { + job.sig = Some(r); + job.status = status; + } + } + + /// Self-test only: after the matched context is deleted, sign once more + /// with its old handle. + pub(crate) fn key_touch_after_close(&self, evaluation: &[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]) { + let (blob, digest) = { + let guard = self.key_touch.lock(); + let Some(job) = guard.as_ref() else { + return; + }; + if !job.sign_again_after_delete || !matches!(job.sig, Some(Ok(_))) { + return; + } + let mut b = KVec::new(); + if b.extend_from_slice(&job.blob, GFP_KERNEL).is_err() { + return; + } + (b, job.digest) + }; + let r = self.user_key_sign(&blob, &digest, Some(evaluation)); + let status = self.last_key_status.load(Relaxed) as i32; + dev_info!( + self.dev, + "sep-key: self-test: signature with the deleted matched context: {} (enclave status {})\n", + (if r.is_ok() { "SIGNED" } else { "refused" }), + status + ); + if let Some(job) = &mut *self.key_touch.lock() { + job.again = Some(r); + job.again_status = status; + } + } + + /// A touch-gated signature: one fresh credential context, one Touch ID + /// match in it, the signature, and the context deleted, all inside this + /// call. Waits up to `TOUCH_WAIT_MS` for a finger. + fn key_sign_with_touch(&self, blob: &[u8], digest: &[u8; DIGEST_LEN], again: bool) -> Result { + let acl = self.key_bio_acl()?; + let mut owned = KVec::new(); + owned.extend_from_slice(blob, GFP_KERNEL)?; + // Serialises with /dev/sep-bio's enrol and verify work, as they do + // with each other. + let _writer = self.catacomb_writer.lock(); + *self.key_touch.lock() = Some(KeyTouch { + blob: owned, + digest: *digest, + constraint: acl.constraint, + sign_again_after_delete: again, + outcome: None, + sig: None, + status: 0, + again: None, + again_status: 0, + }); + self.key_touch_active.store(true, Relaxed); + dev_info!(self.dev, "sep-key: waiting for a Touch ID match to sign (up to {} s)\n", TOUCH_WAIT_MS / 1000); + let deadline = crate::shim::boottime_ns().saturating_add(TOUCH_WAIT_MS * 1_000_000); + let ended = loop { + if self.shutting_down.load(Relaxed) { + break Err(ENODEV); + } + if !self.capture_begin() { + break Err(EAGAIN); + } + self.run_verify(); + self.capture_end(); + let outcome = match &mut *self.key_touch.lock() { + Some(job) => job.outcome.take(), + None => None, + }; + match outcome { + Some(TouchOutcome::Matched) => break Ok(()), + Some(TouchOutcome::NoMatch) => break Err(EACCES), + Some(TouchOutcome::Failed(s)) if s == crate::ENROL_STATUS_RETRY => { + if crate::shim::boottime_ns() >= deadline { + break Err(ETIMEDOUT); + } + } + Some(TouchOutcome::Failed(s)) if s == crate::ENROL_STATUS_LOCKOUT => break Err(EACCES), + Some(TouchOutcome::Failed(_)) | None => break Err(EIO), + } + }; + self.key_touch_active.store(false, Relaxed); + let job = self.key_touch.lock().take().ok_or(EIO)?; + dev_info!( + self.dev, + "sep-key: touch-gated signature ended: {:?}, signature {}\n", + ended, + errno_of(&job.sig) + ); + ended?; + Ok(job) + } + + /// A credential context holding only the identity key bag's secret, the + /// Linux stand-in for a passcode (self-test). + fn passcode_context(&self) -> Result<[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]> { + let du = crate::sks::DesignateUser::new(crate::SBIO_PROBE_USER_ID).ok_or(EINVAL)?; + let crate::keybag::State::Present(stored) = crate::keybag::read(crate::keybag::Slot::Identity)? else { + return Err(ENODEV); + }; + let (status, _) = self.scrd_command(&crate::scrd::scrd_initialize()).ok_or(EIO)?; + if status != 0 { + return Err(EIO); + } + let (status, out) = self + .scrd_command(&crate::scrd::scrd_context_create_tracked(crate::SBIO_PROBE_USER_ID)) + .ok_or(EIO)?; + let handle = crate::scrd::tracked_create_reference(&out) + .filter(|_| status == 0) + .ok_or(EIO)?; + let ok = (|| -> Option<()> { + let (status, _) = self.scrd_command(&crate::scrd::scrd_context_externalize(&handle))?; + if status != 0 { + return None; + } + let out = self.sks_send(self.sks_req_verify_secret(du.special_handle(), stored.secret(), &handle))?; + dev_info!(self.dev, "sep-key: self-test: passcode credential added, status {}\n", out.reply.status); + (out.reply.status == 0).then_some(()) + })(); + if ok.is_none() { + self.close_unlock_evaluation(&handle); + return Err(EIO); + } + Ok(handle) + } + + fn key_selftest(&self, req: &mut Selftest) -> Result<()> { + if crate::module_parameters::key_selftest.value() == 0 { + return Err(EPERM); + } + crate::bio::require_admin()?; + let (blob, flags) = decode_handle(&req.handle, req.handle_len)?; + if flags & HANDLE_BIOMETRY == 0 { + return Err(EINVAL); + } + let mut owned = KVec::new(); + owned.extend_from_slice(blob, GFP_KERNEL)?; + let digest = req.digest; + let user = crate::sbio::UserId::new(crate::SBIO_PROBE_USER_ID).ok_or(EINVAL)?; + let record = |r: Result>, s: &SepData| -> (i32, i32) { + (r.err().map(|e| e.to_errno()).unwrap_or(0), s.last_key_status.load(Relaxed) as i32) + }; + self.last_key_status.store(0, Relaxed); + let (result, status) = match req.mode { + SELFTEST_NO_CONTEXT => record(self.user_key_sign(&owned, &digest, None), self), + SELFTEST_UNSATISFIED => { + let ctx = self.open_unlock_evaluation(user).ok_or(EIO)?; + let r = record(self.user_key_sign(&owned, &digest, Some(&ctx)), self); + self.close_unlock_evaluation(&ctx); + r + } + SELFTEST_PASSCODE => { + let ctx = self.passcode_context()?; + let r = record(self.user_key_sign(&owned, &digest, Some(&ctx)), self); + self.close_unlock_evaluation(&ctx); + r + } + SELFTEST_DELETED => { + let ctx = self.open_unlock_evaluation(user).ok_or(EIO)?; + self.close_unlock_evaluation(&ctx); + record(self.user_key_sign(&owned, &digest, Some(&ctx)), self) + } + SELFTEST_TOUCH_THEN_DELETED => { + let job = self.key_sign_with_touch(&owned, &digest, true)?; + req.result2 = errno_of(&job.again); + req.enclave_status2 = job.again_status; + (errno_of(&job.sig), job.status) + } + _ => return Err(EINVAL), + }; + req.result = result; + req.enclave_status = status; + dev_info!( + self.dev, + "sep-key: self-test mode {}: {} (enclave status {})\n", + req.mode, + (if result == 0 { "SIGNED" } else { "refused" }), + status + ); + Ok(()) + } + fn key_ioctl(&self, cmd: u32, arg: usize) -> Result<()> { if self.shutting_down.load(Relaxed) { return Err(ENODEV); @@ -264,11 +692,14 @@ impl SepData { let slice = UserSlice::new(user, core::mem::size_of::()); let (mut reader, mut writer) = slice.reader_writer(); let mut req: Create = reader.read()?; - if req.flags != 0 || req.reserved != [0; 3] { + if req.flags & !CREATE_FLAGS != 0 || req.reserved != [0; 3] { return Err(EINVAL); } - let (blob, point) = self.user_key_create()?; - req.handle_len = encode_handle(&blob, &mut req.handle)? as u32; + let biometry = req.flags & CREATE_BIOMETRY_CURRENT_SET != 0; + let acl = if biometry { Some(self.key_bio_acl()?) } else { None }; + let (blob, point) = self.user_key_create(acl.as_ref().map(|a| &a.ed[..]))?; + let flags = if biometry { HANDLE_BIOMETRY } else { 0 }; + req.handle_len = encode_handle(&blob, flags, &mut req.handle)? as u32; req.public_key = point; writer.write(&req) } @@ -280,8 +711,14 @@ impl SepData { return Err(EINVAL); } let sig = { - let blob = decode_handle(&req.handle, req.handle_len)?; - self.user_key_sign(blob, &req.digest)? + let (blob, flags) = decode_handle(&req.handle, req.handle_len)?; + if flags & HANDLE_BIOMETRY != 0 { + let digest = req.digest; + let job = self.key_sign_with_touch(blob, &digest, false)?; + job.sig.ok_or(EIO)?? + } else { + self.user_key_sign(blob, &req.digest, None)? + } }; put_signature(&sig, &mut req)?; writer.write(&req) @@ -294,7 +731,14 @@ impl SepData { return Err(EINVAL); } req.public_key = - SepData::user_key_public(decode_handle(&req.handle, req.handle_len)?)?; + SepData::user_key_public(decode_handle(&req.handle, req.handle_len)?.0)?; + writer.write(&req) + } + IOC_SELFTEST => { + let slice = UserSlice::new(user, core::mem::size_of::()); + let (mut reader, mut writer) = slice.reader_writer(); + let mut req: Selftest = reader.read()?; + self.key_selftest(&mut req)?; writer.write(&req) } _ => Err(ENOTTY), diff --git a/drivers/soc/apple/refkey.rs b/drivers/soc/apple/refkey.rs index f1c60a815f60b..a8b01c4346e97 100644 --- a/drivers/soc/apple/refkey.rs +++ b/drivers/soc/apple/refkey.rs @@ -87,6 +87,7 @@ impl SepData { &self, handle: crate::sks::KeyBagHandle, secret: &[u8], + acl: Option<&[u8]>, ) -> Option> { use crate::der::RefKeyValue as RV; @@ -126,6 +127,11 @@ impl SepData { { return None; } + // `ed` carries the key's access-control list; the enclave keeps it + // in the record and enforces it on every later operation. + if let Some(acl) = acl { + items.push((b"ed", RV::Der(acl)), GFP_KERNEL).ok()?; + } crate::der::encode_refkey_set(&items).ok()? }; let cout = match self.sks_send(self.sks_req_refkey(handle.value(), &create_der)) { @@ -197,7 +203,7 @@ impl SepData { return; } - let Some(blob) = self.sks_create_refkey(handle, secret) else { + let Some(blob) = self.sks_create_refkey(handle, secret, None) else { dev_warn!( self.dev, "sks: could not create the machine ref-key; trusted-key sealing is unavailable this boot.\n" @@ -309,29 +315,39 @@ impl SepData { } /// Op `osgn`: ECDSA-P256 over `challenge` as the pre-computed digest. - /// `EKEYREJECTED` when the enclave refuses the operation (for example a - /// ref-key record another SEP or another key bag made), `EIO` for no or - /// a malformed reply. + /// `acm` is the credential context an access-controlled key's operation + /// is checked against (`acmh`). `EKEYREJECTED` when the enclave refuses + /// the operation (a record another SEP or key bag made, or an access- + /// control list the context does not satisfy), `EIO` for no or a + /// malformed reply. The enclave's status is logged and kept in + /// `last_key_status` for the self-test. fn sks_refkey_sign( &self, handle: crate::sks::KeyBagHandle, refkey_blob: &[u8], challenge: &[u8], + acm: Option<&[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]>, ) -> Result> { use crate::der::RefKeyValue as RV; let mut items: KVec<(&[u8], RV<'_>)> = KVec::new(); items.push((b"o", RV::Utf8(b"osgn")), GFP_KERNEL)?; items.push((b"d", RV::Octets(challenge)), GFP_KERNEL)?; items.push((b"rk", RV::Der(refkey_blob)), GFP_KERNEL)?; + if let Some(acm) = acm { + items.push((b"acmh", RV::Octets(acm)), GFP_KERNEL)?; + } let der = crate::der::encode_refkey_set(&items)?; - let out = self - .sks_send(self.sks_req_refkey(handle.value(), &der)) - .ok_or(EIO)?; + let Some(out) = self.sks_send(self.sks_req_refkey(handle.value(), &der)) else { + self.last_key_status.store(i32::MIN as u32, Relaxed); + return Err(EIO); + }; + self.last_key_status.store(i32::from(out.reply.status) as u32, Relaxed); if out.reply.status != 0 { dev_warn!( self.dev, - "sks: ref-key sign refused by the enclave (status {})\n", - out.reply.status + "sks: ref-key sign refused by the enclave (status {}{})\n", + out.reply.status, + (if acm.is_some() { ", with a credential context" } else { "" }) ); return Err(EKEYREJECTED); } @@ -376,7 +392,7 @@ impl SepData { )); } }); - let sig = self.sks_refkey_sign(handle, &blob, challenge); + let sig = self.sks_refkey_sign(handle, &blob, challenge, None); let _ = self.sks_send(self.sks_req_unload_keybag(handle)); Ok((sig?, pubk)) } @@ -418,10 +434,14 @@ impl SepData { /// `/dev/sep-key` create: a new enclave P-256 ref-key under the identity /// key bag. Returns its record and public point; the private half stays - /// in the enclave. - pub(crate) fn user_key_create(&self) -> Result<(KVec, [u8; refkey_seal::POINT_LEN])> { + /// in the enclave. With `acl` the record carries that access-control + /// list (see `bio_acl`). + pub(crate) fn user_key_create( + &self, + acl: Option<&[u8]>, + ) -> Result<(KVec, [u8; refkey_seal::POINT_LEN])> { let blob = self.with_identity_bag(|handle, secret| { - self.sks_create_refkey(handle, secret).ok_or(EIO) + self.sks_create_refkey(handle, secret, acl).ok_or(EIO) })?; let mut point = [0u8; refkey_seal::POINT_LEN]; point.copy_from_slice(Self::refkey_pub(&blob).ok_or(EIO)?); @@ -436,12 +456,19 @@ impl SepData { } /// `/dev/sep-key` sign: the enclave's ECDSA-P256 over `digest` with the - /// key in `blob`. A record this SEP and key bag did not make is refused - /// by the enclave (`EKEYREJECTED`). - pub(crate) fn user_key_sign(&self, blob: &[u8], digest: &[u8; 32]) -> Result> { + /// key in `blob`, checked against the credential context `acm` when one + /// is given. A record this SEP and key bag did not make, or an access- + /// control list `acm` does not satisfy, is refused by the enclave + /// (`EKEYREJECTED`). + pub(crate) fn user_key_sign( + &self, + blob: &[u8], + digest: &[u8; 32], + acm: Option<&[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]>, + ) -> Result> { // A record without a public point is not a ref-key record at all. Self::refkey_pub(blob).ok_or(EINVAL)?; - self.with_identity_bag(|handle, _secret| self.sks_refkey_sign(handle, blob, digest)) + self.with_identity_bag(|handle, _secret| self.sks_refkey_sign(handle, blob, digest, acm)) } /// Enclave ECIES-decrypt (o = "oecd"); takes the ephemeral from the front of `d`. diff --git a/drivers/soc/apple/sbio.rs b/drivers/soc/apple/sbio.rs index 1071475e6b461..35476d6e4cc38 100644 --- a/drivers/soc/apple/sbio.rs +++ b/drivers/soc/apple/sbio.rs @@ -535,6 +535,30 @@ impl SepData { } } + /// The Linux enclave user's catacomb id and enrolled-set hash, which a + /// key's biometric access-control list names (`pbioc`, `pbioh`). Read + /// only. + pub(crate) fn catacomb_binding( + &self, + ) -> Result<([u8; crate::sbio::SBIO_CATACOMB_ID_LEN], [u8; crate::sbio::SBIO_CATACOMB_HASH_LEN])> { + let user = crate::sbio::UserId::new(SBIO_PROBE_USER_ID).ok_or(EINVAL)?; + let id_reply = self.sbio_expect_ok(&crate::sbio::sbio_catacomb_id(user)).ok_or(EIO)?; + let hash_reply = self.sbio_expect_ok(&crate::sbio::sbio_catacomb_hash(user)).ok_or(EIO)?; + dev_info!( + self.dev, + "sep-key: catacomb id {} bytes, hash {} bytes\n", + id_reply.len(), + hash_reply.len() + ); + if id_reply.len() != crate::sbio::SBIO_CATACOMB_ID_LEN { + return Err(EIO); + } + let mut id = [0u8; crate::sbio::SBIO_CATACOMB_ID_LEN]; + id.copy_from_slice(&id_reply); + let hash = crate::sbio::catacomb_hash_from_reply(&hash_reply).ok_or(EIO)?; + Ok((id, hash)) + } + fn sbio_expect_ok(&self, op: &crate::sbio::SbioOp) -> Option> { match self.sbio_call(op) { SbioOutcome::Ok(payload) => Some(payload), @@ -842,7 +866,7 @@ impl SepData { let mut told = false; let mut first: Option<[u8; sensor::STATUS_LEN]> = None; loop { - if !bio::capture_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) { + if !self.capture_live() { return false; } let Ok(st) = sensor::status() else { @@ -2401,7 +2425,8 @@ impl SepData { /// not ended by a suspend and not closed. fn verify_wanted(&self) -> bool { !self.shutting_down.load(Relaxed) - && bio::verify_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) + && (self.key_touch_active.load(Relaxed) + || bio::verify_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed))) } // Every verify registers the sensor again. On a profile with a nonce @@ -2525,7 +2550,10 @@ impl SepData { // MATCH_CANCEL only after the third). Here a prompt is one open of the // Touch ID device: fprintd claims it for one PAM authentication. let open_gen = self.bio_open_gen.load(Relaxed); - let current = if self.match_scope_gen.load(Relaxed) == open_gen { + let key_mode = self.key_touch_active.load(Relaxed); + // A /dev/sep-key signature is its own prompt: a fresh evaluation that + // never outlives this run, whatever prompt /dev/sep-bio has open. + let current = if !key_mode && self.match_scope_gen.load(Relaxed) == open_gen { *self.match_context.lock() } else { None @@ -2566,6 +2594,9 @@ impl SepData { return; } *self.match_context.lock() = Some(evaluation); + if key_mode { + self.key_touch_preflight(&evaluation); + } self.match_scope_gen.store(open_gen, Relaxed); self.match_scope_touches.store(1, Relaxed); self.match_scope_misses.store(0, Relaxed); @@ -2602,7 +2633,7 @@ impl SepData { let total_misses = self.bio_nomatch_streak.load(Relaxed).saturating_add(miss); self.match_scope_misses.store(prompt_misses, Relaxed); let step = prompt_step(no_match, capture_retry, prompt_misses, total_misses); - let keep_open = !self.enclave_reports_lockout() && step == PromptStep::KeepOpen; + let keep_open = !key_mode && !self.enclave_reports_lockout() && step == PromptStep::KeepOpen; if no_match && !keep_open && prompt_misses >= PROMPT_RETRY_LIMIT @@ -2641,15 +2672,26 @@ impl SepData { } } } - if matches!(outcome, bio::VerifyOutcome::Matched(_)) - && !self.complete_unlock_evaluation(&evaluation, user) - { + let matched = matches!(outcome, bio::VerifyOutcome::Matched(_)); + // The signature runs in the evaluation the match just satisfied, + // before it is completed (as macOS signs after MATCH_CANCEL) and, + // if the enclave wants the completed evaluation, once more after. + if key_mode && matched { + self.key_touch_sign(&evaluation, c"after the match"); + } + if matched && !self.complete_unlock_evaluation(&evaluation, user) { dev_err!(self.dev, "verify: the enclave matched but the unlock policy was not satisfied\n"); outcome = bio::VerifyOutcome::Failed(ENROL_STATUS_ENCLAVE); } + if key_mode && matched { + self.key_touch_sign(&evaluation, c"after the completed evaluation"); + } *self.match_context.lock() = None; self.match_scope_gen.store(0, Relaxed); self.close_unlock_evaluation(&evaluation); + if key_mode && matched { + self.key_touch_after_close(&evaluation); + } } // macOS saves the enclave's lockout record once after every answered // result (SAVE_LOCKOUT, kept on disk for the boot's LOAD_LOCKOUT), @@ -2821,9 +2863,9 @@ impl SepData { } CaptureWait::Abandon => { // A cancelled verify also abandons; only a live one is a fault. - if bio::verify_is_live( - &self.bio_session.lock(), self.capture_epoch.load(Relaxed), - ) { + if self.key_touch_active.load(Relaxed) + || bio::verify_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) + { dev_err!( self.dev, "verify: the sensor status became unreadable during the capture\n" @@ -4084,7 +4126,7 @@ impl SepData { let deadline_ns = crate::shim::monotonic_ns().saturating_add(wait_ms.saturating_mul(1_000_000)); let mut attempt: u32 = 0; loop { - if !bio::capture_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) { + if !self.capture_live() { return CaptureWait::Abandon; } @@ -4827,6 +4869,14 @@ impl SepData { } fn finish_verify(&self, outcome: bio::VerifyOutcome, token_bytes: [u8; bio::TOKEN_LEN]) { + // A touch-gated /dev/sep-key signature owns this verify: its result + // goes to that ioctl, never to /dev/sep-bio's session. + if self.key_touch_active.load(Relaxed) { + if let Some(job) = &mut *self.key_touch.lock() { + job.set_outcome(&outcome); + } + return; + } let filed = bio::verify_finish( &mut self.bio_session.lock(), self.capture_epoch.load(Relaxed), outcome, token_bytes, ); @@ -6411,6 +6461,45 @@ pub(crate) fn sbio_protected_config(id: UserId) -> SbioOp { } } +/// The 16-byte id of `id`'s Touch ID catacomb (the enrolled-template set), +/// as a key access-control list names it. Read only. +const OP_SBIO_CATACOMB_ID: u16 = 0x33; +pub(crate) const SBIO_CATACOMB_ID_LEN: usize = 16; +pub(crate) fn sbio_catacomb_id(id: UserId) -> SbioOp { + SbioOp { + opcode: OP_SBIO_CATACOMB_ID, + payload: pad_payload(&id.value().to_le_bytes()), + payload_len: 4, + name: c"CATACOMB_ID", + } +} + +/// The 32-byte hash of `id`'s enrolled set, which changes when a finger is +/// added or removed. Read only. The reply may lead with one version byte. +const OP_SBIO_CATACOMB_HASH: u16 = 0x36; +pub(crate) const SBIO_CATACOMB_HASH_LEN: usize = 32; +pub(crate) fn sbio_catacomb_hash(id: UserId) -> SbioOp { + SbioOp { + opcode: OP_SBIO_CATACOMB_HASH, + payload: pad_payload(&id.value().to_le_bytes()), + payload_len: 4, + name: c"CATACOMB_HASH", + } +} + +/// The 32-byte hash in a CATACOMB_HASH reply: the bare hash, or one leading +/// version byte and the hash. +pub(crate) fn catacomb_hash_from_reply(reply: &[u8]) -> Option<[u8; SBIO_CATACOMB_HASH_LEN]> { + let raw = match reply.len() { + SBIO_CATACOMB_HASH_LEN => reply, + n if n == SBIO_CATACOMB_HASH_LEN + 1 => &reply[1..], + _ => return None, + }; + let mut out = [0u8; SBIO_CATACOMB_HASH_LEN]; + out.copy_from_slice(raw); + Some(out) +} + const OP_SBIO_BEGIN_ENROL: u16 = 0x03; pub(crate) const BE_AUTH_TYPE_ACM_CONTEXT: u32 = 0; diff --git a/drivers/soc/apple/scrd.rs b/drivers/soc/apple/scrd.rs index fea9b390b6466..bb03acb4a10c7 100644 --- a/drivers/soc/apple/scrd.rs +++ b/drivers/soc/apple/scrd.rs @@ -130,6 +130,35 @@ pub(crate) fn scrd_verify_touchid_enrollment(handle: &[u8; SCRD_ACM_HANDLE_LEN]) } } +const SCRD_CMD_VERIFY_ACL_CONSTRAINT: u8 = 0x20; +const SCRD_ACL_VERSION: u8 = 2; + +/// Command 0x20: evaluate one operation's access-control constraint (a DER +/// set, as in a key's ACL) on the context, as LocalAuthentication does before +/// and after a Touch ID match for a key operation. Laid out as macOS sends it: +/// header, context, u32 constraint length, constraint, u32 operation length, +/// operation, a 1 byte, two zero u32 words. +pub(crate) fn scrd_verify_acl_constraint( + handle: &[u8; SCRD_ACM_HANDLE_LEN], + constraint: &[u8], + operation: &[u8], +) -> Result> { + let mut out = KVec::new(); + let mut header = [0u8; SCRD_MAX_LOGICAL]; + scrd_header(&mut header, SCRD_CMD_VERIFY_ACL_CONSTRAINT, 0, SCRD_ACL_VERSION); + out.extend_from_slice(&header[..8], GFP_KERNEL)?; + out.extend_from_slice(handle, GFP_KERNEL)?; + let clen = u32::try_from(constraint.len()).map_err(|_| EINVAL)?; + out.extend_from_slice(&clen.to_le_bytes(), GFP_KERNEL)?; + out.extend_from_slice(constraint, GFP_KERNEL)?; + let olen = u32::try_from(operation.len()).map_err(|_| EINVAL)?; + out.extend_from_slice(&olen.to_le_bytes(), GFP_KERNEL)?; + out.extend_from_slice(operation, GFP_KERNEL)?; + out.push(1, GFP_KERNEL)?; + out.extend_from_slice(&[0u8; 8], GFP_KERNEL)?; + Ok(out) +} + /// The reference of a tracked context-create reply, or `None` for any /// other shape. pub(crate) fn tracked_create_reference(reply: &[u8]) -> Option<[u8; SCRD_ACM_HANDLE_LEN]> { diff --git a/drivers/soc/apple/sep.rs b/drivers/soc/apple/sep.rs index 06b876e928a86..fdc937da60ff2 100644 --- a/drivers/soc/apple/sep.rs +++ b/drivers/soc/apple/sep.rs @@ -775,6 +775,15 @@ struct SepData { #[pin] key_dev: Mutex>, + // /dev/sep-key: the signature a touch-gated SEP_KEY_SIGN is waiting for. + // Set only for the length of that ioctl; while set, a verify run belongs + // to it and not to /dev/sep-bio's session. + #[pin] + key_touch: Mutex>, + key_touch_active: Atomic, + // The enclave status of the last /dev/sep-key signature (self-test). + last_key_status: Atomic, + // The enclave refused to match (MATCH_RESULT answered 0x1) after failed // attempts, or the lockout survived a restart. Every verify is refused // without a capture until a password authentication re-arms Touch ID. @@ -1061,6 +1070,9 @@ impl SepData { bio_dev <- new_mutex!(None), rearm_dev <- new_mutex!(None), key_dev <- new_mutex!(None), + key_touch <- new_mutex!(None), + key_touch_active: Atomic::new(false), + last_key_status: Atomic::new(0), bio_lockout: Atomic::new(false), bio_lockout_logged: Atomic::new(false), bio_nomatch_streak: Atomic::new(0), @@ -3077,6 +3089,10 @@ module! { default: 0, description: "Opt-in J414s diagnostic: with zero live identities, select the system context and try saving the missing owner Catacomb", }, + key_selftest: u8 { + default: 0, + description: "Test only: let root run SEP_KEY_SELFTEST, which signs with an access-controlled /dev/sep-key key under deliberately unsuitable credential contexts to show the enclave refuses", + }, refkey_v2: u8 { default: 0, description: "Explicit recovery slot for a new identity-bag context; preserves the original machine ref-key", diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 9c1e940a1027e..80b731de98247 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -213,17 +213,25 @@ struct sep_bio_rearm { * file. Signing takes a 32-byte digest (for ES256/ecdsa-sha2-nistp256, the * SHA-256 of the message) and returns a DER ECDSA-Sig-Value. * - * Interface version 1 has no access control: whoever can open the node and - * holds a handle can sign with it. The node is created 0600; distributions - * may grant a group. Every 'flags' and 'reserved' field must be zero; later - * versions report what they accept in SEP_KEY_GET_INFO. + * A key made without flags has no access control: whoever can open the node + * and holds its handle can sign with it. A key made with + * SEP_KEY_CREATE_BIOMETRY_CURRENT_SET (version 2) carries an access-control + * list the enclave enforces: every signature needs a Touch ID match of a + * finger enrolled when the key was made. SEP_KEY_SIGN then waits (up to 30 s) + * for a touch; the kernel opens a credential context, runs the match in it, + * signs and deletes the context inside that one call. Enrolling or removing a + * finger makes the key unusable. The node is created 0600; distributions may + * grant a group. Unknown 'flags' bits and 'reserved' fields must be zero. * * Errors: EINVAL for a malformed request or handle, EKEYREJECTED when the - * enclave refuses the key (for example a handle from another Mac or an - * earlier Linux install), ENODEV/ETIMEDOUT while the key store is not up, - * EIO for any other enclave failure. + * enclave refuses the key (a handle from another Mac or an earlier Linux + * install, or an access-control list not satisfied), EACCES when the touch + * did not match (or Touch ID is locked out), ETIMEDOUT when no finger came, + * ENODEV while the key store or Touch ID is not up, EIO for any other + * enclave failure. */ -#define SEP_KEY_IFACE_VERSION 1 +#define SEP_KEY_IFACE_VERSION 2 +#define SEP_KEY_CREATE_BIOMETRY_CURRENT_SET 0x1 #define SEP_KEY_HANDLE_MAX 2048 #define SEP_KEY_PUB_LEN 65 /* uncompressed point, 04 || X || Y */ #define SEP_KEY_DIGEST_LEN 32 @@ -231,13 +239,13 @@ struct sep_bio_rearm { struct sep_key_info { __u32 version; /* SEP_KEY_IFACE_VERSION */ - __u32 flags_supported; /* 0 in version 1 */ + __u32 flags_supported; /* SEP_KEY_CREATE_* bits this kernel accepts */ __u32 handle_max; /* SEP_KEY_HANDLE_MAX */ __u32 reserved; }; struct sep_key_create { - __u32 flags; /* in: 0 */ + __u32 flags; /* in: SEP_KEY_CREATE_* */ __u32 handle_len; /* out */ __u8 public_key[SEP_KEY_PUB_LEN]; /* out */ __u8 reserved[3]; /* in: 0 */ @@ -266,4 +274,25 @@ struct sep_key_public { #define SEP_KEY_SIGN _IOWR(SEP_BIO_IOC_MAGIC, 0x22, struct sep_key_sign) #define SEP_KEY_PUBLIC _IOWR(SEP_BIO_IOC_MAGIC, 0x23, struct sep_key_public) +/* + * Test only, refused unless apple_sep.key_selftest=1, root: sign with a + * biometric key under a deliberately unsuitable credential context, to show + * the enclave refuses. Modes: 1 no context, 2 a context with no match, + * 3 a context holding only the key bag secret, 4 a deleted context, 5 one + * touch: sign in the matched context, delete it, sign again with it. + * 'result' is 0 when the enclave signed, else a negative errno. + */ +struct sep_key_selftest { + __u32 mode; + __u32 handle_len; + __u8 digest[SEP_KEY_DIGEST_LEN]; + __s32 result; + __s32 enclave_status; + __s32 result2; /* mode 5: the signature after the delete */ + __s32 enclave_status2; + __u8 handle[SEP_KEY_HANDLE_MAX]; +}; + +#define SEP_KEY_SELFTEST _IOWR(SEP_BIO_IOC_MAGIC, 0x24, struct sep_key_selftest) + #endif /* _UAPI_LINUX_APPLE_SEP_H */ From ac6c191af91080da081a2d842551fea440259c7d Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:15:40 +1100 Subject: [PATCH 05/21] soc: apple: sep: sep-key: say how long a Touch ID signature waits One verify capture already waits up to a minute for a finger, so the 30 s overall budget only ever allowed one capture. Make the budget a minute and say "about a minute" in the UAPI comment and the log. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 9 +++++---- include/uapi/linux/apple-sep.h | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index aa991b4639fd0..261ab907703eb 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -41,8 +41,9 @@ pub(crate) const SIG_MAX: usize = 72; pub(crate) const CREATE_BIOMETRY_CURRENT_SET: u32 = 1; const CREATE_FLAGS: u32 = CREATE_BIOMETRY_CURRENT_SET; -/// How long a touch-gated `SIGN` waits for a finger in all. -const TOUCH_WAIT_MS: u64 = 30_000; +/// A touch-gated `SIGN` asks for a finger again after a capture that saw +/// none until this long has passed; one capture itself waits up to a minute. +const TOUCH_WAIT_MS: u64 = 60_000; const MAGIC: u32 = 0xB1; @@ -531,7 +532,7 @@ impl SepData { /// A touch-gated signature: one fresh credential context, one Touch ID /// match in it, the signature, and the context deleted, all inside this - /// call. Waits up to `TOUCH_WAIT_MS` for a finger. + /// call. Waits about a minute for a finger. fn key_sign_with_touch(&self, blob: &[u8], digest: &[u8; DIGEST_LEN], again: bool) -> Result { let acl = self.key_bio_acl()?; let mut owned = KVec::new(); @@ -551,7 +552,7 @@ impl SepData { again_status: 0, }); self.key_touch_active.store(true, Relaxed); - dev_info!(self.dev, "sep-key: waiting for a Touch ID match to sign (up to {} s)\n", TOUCH_WAIT_MS / 1000); + dev_info!(self.dev, "sep-key: waiting for a Touch ID match to sign\n"); let deadline = crate::shim::boottime_ns().saturating_add(TOUCH_WAIT_MS * 1_000_000); let ended = loop { if self.shutting_down.load(Relaxed) { diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 80b731de98247..9415c7bc2adaf 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -217,7 +217,7 @@ struct sep_bio_rearm { * and holds its handle can sign with it. A key made with * SEP_KEY_CREATE_BIOMETRY_CURRENT_SET (version 2) carries an access-control * list the enclave enforces: every signature needs a Touch ID match of a - * finger enrolled when the key was made. SEP_KEY_SIGN then waits (up to 30 s) + * finger enrolled when the key was made. SEP_KEY_SIGN then waits (about a minute) * for a touch; the kernel opens a credential context, runs the match in it, * signs and deletes the context inside that one call. Enrolling or removing a * finger makes the key unusable. The node is created 0600; distributions may From 50ba8b00f5b1dfc3d676b28a05bdc07397d3b7b2 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 21:46:14 +1100 Subject: [PATCH 06/21] soc: apple: sep: sep-key: biometry-any keys; reserve the password policies Add SEP_KEY_CREATE_BIOMETRY_ANY, macOS's .biometryAny: the key's access-control list names the Touch ID catacomb (pbioc) but no enrolled-set hash, so any finger enrolled on this install matches, including fingers enrolled after the key was made. SIGN runs the same touch-gated path as a biometry-current-set key; handle flag 0x02 (with 0x01) picks the matching preflight constraint. The enclave enforces the list in the record either way. Reserve SEP_KEY_CREATE_USER_PRESENCE (.userPresence, Touch ID or the password) and SEP_KEY_CREATE_DEVICE_PASSCODE (.devicePasscode). Both need the enclave to check the user's own password. On Linux the only passcode the enclave knows today is the identity key bag's secret, which root holds, so such a key would be root-satisfiable. CREATE refuses them with EOPNOTSUPP; they are not emulated in software. At most one policy bit may be set. Add self-test mode 6 (key_selftest=1, root, no finger): a throwaway key with the .userPresence list as macOS writes it (cpo "DeviceOwnerAuthentication"), signed with a key-bag-secret context and with an unsatisfied context, to measure what the enclave enforces for that policy on Linux. KUnit: policy parsing, handle flags, the biometry-any and presence lists. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 182 +++++++++++++++++++++++++++++---- include/uapi/linux/apple-sep.h | 28 ++++- 2 files changed, 185 insertions(+), 25 deletions(-) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index 261ab907703eb..e846a02e555f4 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -17,6 +17,18 @@ //! handle to it ever reaches userspace, and every signature needs its own //! touch. The enclave refuses the signature for any other context; the //! header's flag only tells the kernel to ask for the touch. +//! +//! `CREATE_BIOMETRY_ANY` is macOS's `.biometryAny`: the same list without +//! the enrolled-set hash, so any finger enrolled in this catacomb matches, +//! including fingers enrolled after the key was made. +//! +//! macOS's `.userPresence` (Touch ID or the password) and `.devicePasscode` +//! need a passcode credential the enclave checks against the user's own +//! secret. On Linux the only passcode the enclave knows today is the identity +//! key bag's secret, which root holds, so those policies would be +//! root-satisfiable. Their `CREATE` bits are reserved and refused with +//! `EOPNOTSUPP` until a per-user, password-keyed bag exists; they are never +//! emulated in software. use crate::der::RefKeyValue as RV; use crate::SepData; @@ -39,7 +51,36 @@ pub(crate) const SIG_MAX: usize = 72; /// `CREATE` flag: signing needs a Touch ID match of a currently enrolled /// finger, enforced by the enclave. pub(crate) const CREATE_BIOMETRY_CURRENT_SET: u32 = 1; -const CREATE_FLAGS: u32 = CREATE_BIOMETRY_CURRENT_SET; +/// `CREATE` flag: signing needs a Touch ID match of any finger enrolled in +/// this catacomb, now or later, enforced by the enclave. +pub(crate) const CREATE_BIOMETRY_ANY: u32 = 2; +/// Reserved `CREATE` flags for macOS's `.userPresence` and `.devicePasscode`. +/// Refused with `EOPNOTSUPP` until the enclave can check the user's own +/// password (see the module comment). +pub(crate) const CREATE_USER_PRESENCE: u32 = 4; +pub(crate) const CREATE_DEVICE_PASSCODE: u32 = 8; +const CREATE_FLAGS: u32 = CREATE_BIOMETRY_CURRENT_SET | CREATE_BIOMETRY_ANY; +const CREATE_RESERVED: u32 = CREATE_USER_PRESENCE | CREATE_DEVICE_PASSCODE; + +/// The access policy a `CREATE` request asks for: at most one policy bit. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Policy { + None, + BiometryCurrentSet, + BiometryAny, +} + +pub(crate) fn create_policy(flags: u32) -> Result { + if flags & !(CREATE_FLAGS | CREATE_RESERVED) != 0 || flags.count_ones() > 1 { + return Err(EINVAL); + } + match flags { + 0 => Ok(Policy::None), + CREATE_BIOMETRY_CURRENT_SET => Ok(Policy::BiometryCurrentSet), + CREATE_BIOMETRY_ANY => Ok(Policy::BiometryAny), + _ => Err(EOPNOTSUPP), + } +} /// A touch-gated `SIGN` asks for a finger again after a capture that saw /// none until this long has passed; one capture itself waits up to a minute. @@ -52,6 +93,9 @@ const HANDLE_MAGIC: &[u8; 4] = b"SEPK"; const HANDLE_VERSION: u8 = 1; /// Handle flag: the record carries a biometric access-control list. pub(crate) const HANDLE_BIOMETRY: u8 = 0x01; +/// Handle flag, with `HANDLE_BIOMETRY` only: the list names no enrolled-set +/// hash (biometry-any). +pub(crate) const HANDLE_BIOMETRY_ANY: u8 = 0x02; pub(crate) const HANDLE_HEADER: usize = 12; /// The largest enclave record a handle can carry. pub(crate) const BLOB_MAX: usize = HANDLE_MAX - HANDLE_HEADER; @@ -121,6 +165,13 @@ pub(crate) const SELFTEST_DELETED: u32 = 4; /// One touch: sign in the matched context, delete it, sign again with it /// (needs a finger). pub(crate) const SELFTEST_TOUCH_THEN_DELETED: u32 = 5; +/// Research probe, no finger, ignores the handle: make a throwaway key with +/// macOS's `.userPresence` list (`cpo` "DeviceOwnerAuthentication") and sign +/// with a context holding only the key bag's secret (`result`) and with an +/// unsatisfied context (`result2`). Shows whether the enclave enforces that +/// policy and whether a root-held secret satisfies it; `CREATE` refuses the +/// policy either way. +pub(crate) const SELFTEST_PRESENCE_PROBE: u32 = 6; static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 2124); @@ -163,9 +214,14 @@ pub(crate) fn info() -> Info { } } +/// No flags, biometry (current set), or biometry plus biometry-any. +fn handle_flags_valid(flags: u8) -> bool { + matches!(flags, 0 | HANDLE_BIOMETRY | 0x03) +} + /// Writes the handle for `blob` with `flags` into `out`; returns its length. pub(crate) fn encode_handle(blob: &[u8], flags: u8, out: &mut [u8; HANDLE_MAX]) -> Result { - if blob.is_empty() || blob.len() > BLOB_MAX || flags & !HANDLE_BIOMETRY != 0 { + if blob.is_empty() || blob.len() > BLOB_MAX || !handle_flags_valid(flags) { return Err(EINVAL); } let len = u32::try_from(blob.len()).map_err(|_| EINVAL)?; @@ -187,7 +243,7 @@ pub(crate) fn decode_handle(handle: &[u8; HANDLE_MAX], len: u32) -> Result<(&[u8 if !(HANDLE_HEADER + 1..=HANDLE_MAX).contains(&len) { return Err(EINVAL); } - if &handle[..4] != HANDLE_MAGIC || handle[4] != HANDLE_VERSION || handle[5] & !HANDLE_BIOMETRY != 0 { + if &handle[..4] != HANDLE_MAGIC || handle[4] != HANDLE_VERSION || !handle_flags_valid(handle[5]) { return Err(EINVAL); } if handle[6] != 0 || handle[7] != 0 { @@ -211,23 +267,43 @@ pub(crate) fn put_signature(sig: &[u8], out: &mut Sign) -> Result<()> { Ok(()) } +/// macOS's `.userPresence` list: every constrained operation names the +/// `DeviceOwnerAuthentication` policy (Touch ID or the password). Used only +/// by `SELFTEST_PRESENCE_PROBE`. +pub(crate) fn presence_acl() -> Result> { + let constraint = + crate::der::encode_refkey_set(&[(b"cpo".as_slice(), RV::Utf8(b"DeviceOwnerAuthentication"))])?; + let acl = crate::der::encode_refkey_set(&[ + (b"oa".as_slice(), RV::Bool(true)), + (b"ock".as_slice(), RV::Der(&constraint)), + (b"odel".as_slice(), RV::Bool(true)), + (b"okd".as_slice(), RV::Der(&constraint)), + (b"osgn".as_slice(), RV::Der(&constraint)), + ])?; + crate::der::encode_refkey_set(&[(b"acl".as_slice(), RV::Der(&acl))]) +} + /// The access-control list of a biometry-current-set key, in the key /// store's DER, and the constraint its signing operation carries. /// /// As macOS writes it for `.privateKeyUsage | .biometryCurrentSet`: sign, /// key agreement (`ock`) and derivation (`okd`) need a biometric credential /// (`cbio`) for this catacomb (`pbioc`) and enrolled set (`pbioh`); delete -/// (`odel`) and attest (`oa`) are free. +/// (`odel`) and attest (`oa`) are free. With no `enrolled_hash` it is the +/// `.biometryAny` list: `cbio` names the catacomb only. pub(crate) struct BioAcl { pub(crate) ed: KVec, pub(crate) constraint: KVec, } -pub(crate) fn bio_acl(catacomb: &[u8; 16], enrolled_hash: &[u8; 32]) -> Result { - let cbio = crate::der::encode_refkey_set(&[ - (b"pbioc".as_slice(), RV::Octets(catacomb)), - (b"pbioh".as_slice(), RV::Octets(enrolled_hash)), - ])?; +pub(crate) fn bio_acl(catacomb: &[u8; 16], enrolled_hash: Option<&[u8; 32]>) -> Result { + let cbio = match enrolled_hash { + Some(hash) => crate::der::encode_refkey_set(&[ + (b"pbioc".as_slice(), RV::Octets(catacomb)), + (b"pbioh".as_slice(), RV::Octets(hash)), + ])?, + None => crate::der::encode_refkey_set(&[(b"pbioc".as_slice(), RV::Octets(catacomb))])?, + }; let constraint = crate::der::encode_refkey_set(&[(b"cbio".as_slice(), RV::Der(&cbio))])?; let acl = crate::der::encode_refkey_set(&[ (b"oa".as_slice(), RV::Bool(true)), @@ -354,14 +430,46 @@ mod tests { let n = encode_handle(&blob(), HANDLE_BIOMETRY, &mut h).unwrap() as u32; assert_eq!(decode_handle(&h, n).unwrap().1, HANDLE_BIOMETRY); assert!(encode_handle(&blob(), 0x02, &mut h).is_err()); + assert!(encode_handle(&blob(), 0x04, &mut h).is_err()); let mut bad = h; bad[5] = 0x02; assert!(decode_handle(&bad, n).is_err()); + let n = encode_handle(&blob(), HANDLE_BIOMETRY | HANDLE_BIOMETRY_ANY, &mut h).unwrap() as u32; + assert_eq!(decode_handle(&h, n).unwrap().1, HANDLE_BIOMETRY | HANDLE_BIOMETRY_ANY); + } + + #[test] + fn create_takes_one_policy_and_refuses_the_password_ones() { + assert_eq!(create_policy(0).unwrap(), Policy::None); + assert_eq!(create_policy(CREATE_BIOMETRY_CURRENT_SET).unwrap(), Policy::BiometryCurrentSet); + assert_eq!(create_policy(CREATE_BIOMETRY_ANY).unwrap(), Policy::BiometryAny); + assert_eq!(create_policy(CREATE_USER_PRESENCE).unwrap_err(), EOPNOTSUPP); + assert_eq!(create_policy(CREATE_DEVICE_PASSCODE).unwrap_err(), EOPNOTSUPP); + assert_eq!(create_policy(CREATE_BIOMETRY_CURRENT_SET | CREATE_BIOMETRY_ANY).unwrap_err(), EINVAL); + assert_eq!(create_policy(0x10).unwrap_err(), EINVAL); + assert_eq!(info().flags_supported, CREATE_BIOMETRY_CURRENT_SET | CREATE_BIOMETRY_ANY); + } + + #[test] + fn biometry_any_list_names_the_catacomb_only() { + let acl = bio_acl(&[0x11; 16], None).unwrap(); + // SET { SEQ { "cbio", SET { SEQ { "pbioc", OCTETS[16] } } } } + assert_eq!(acl.constraint.len(), 2 + 2 + 6 + 2 + (2 + 7 + 18)); + assert!(!acl.ed.windows(5).any(|w| w == b"pbioh")); + assert!(acl.ed.windows(5).any(|w| w == b"pbioc")); + } + + #[test] + fn presence_list_has_the_captured_shape() { + let ed = presence_acl().unwrap(); + // macOS: each constrained op is SET(34) { SEQ { "cpo", "DeviceOwnerAuthentication" } }. + assert_eq!(ed.windows(2).filter(|w| *w == [0x31u8, 34].as_slice()).count(), 3); + assert_eq!(ed.windows(25).filter(|w| *w == b"DeviceOwnerAuthentication").count(), 3); } #[test] fn biometric_acl_has_the_key_store_shape() { - let acl = bio_acl(&[0x11; 16], &[0x22; 32]).unwrap(); + let acl = bio_acl(&[0x11; 16], Some(&[0x22; 32])).unwrap(); // SET { SEQ { "cbio", SET { SEQ { "pbioc", OCTETS[16] }, SEQ { "pbioh", OCTETS[32] } } } } assert_eq!(acl.constraint.len(), 2 + 2 + 6 + 2 + (2 + 7 + 18) + (2 + 7 + 34)); assert_eq!(&acl.constraint[..2], &[0x31, 0x50]); @@ -377,7 +485,7 @@ mod tests { #[test] fn acl_constraint_command_has_the_captured_layout() { - let acl = bio_acl(&[0x11; 16], &[0x22; 32]).unwrap(); + let acl = bio_acl(&[0x11; 16], Some(&[0x22; 32])).unwrap(); let cmd = crate::scrd::scrd_verify_acl_constraint(&[0x33; 16], &acl.constraint, b"ock").unwrap(); // macOS: 138 bytes for a 94-byte constraint and "ock"; this one is 80 bytes. assert_eq!(cmd.len(), 8 + 16 + 4 + acl.constraint.len() + 4 + 3 + 1 + 8); @@ -418,12 +526,12 @@ impl SepData { || crate::bio::capture_is_live(&self.bio_session.lock(), self.capture_epoch.load(Relaxed)) } - fn key_bio_acl(&self) -> Result { + fn key_bio_acl(&self, any: bool) -> Result { if !self.touchid_started.load(Relaxed) { return Err(ENODEV); } let (catacomb, enrolled) = self.catacomb_binding()?; - bio_acl(&catacomb, &enrolled) + bio_acl(&catacomb, (!any).then_some(&enrolled)) } /// Evaluates the signing constraint on the fresh context before the @@ -533,8 +641,8 @@ impl SepData { /// A touch-gated signature: one fresh credential context, one Touch ID /// match in it, the signature, and the context deleted, all inside this /// call. Waits about a minute for a finger. - fn key_sign_with_touch(&self, blob: &[u8], digest: &[u8; DIGEST_LEN], again: bool) -> Result { - let acl = self.key_bio_acl()?; + fn key_sign_with_touch(&self, blob: &[u8], flags: u8, digest: &[u8; DIGEST_LEN], again: bool) -> Result { + let acl = self.key_bio_acl(flags & HANDLE_BIOMETRY_ANY != 0)?; let mut owned = KVec::new(); owned.extend_from_slice(blob, GFP_KERNEL)?; // Serialises with /dev/sep-bio's enrol and verify work, as they do @@ -624,11 +732,41 @@ impl SepData { Ok(handle) } + fn key_presence_probe(&self, req: &mut Selftest) -> Result<()> { + let ed = presence_acl()?; + let (blob, _) = self.user_key_create(Some(&ed))?; + let digest = req.digest; + let user = crate::sbio::UserId::new(crate::SBIO_PROBE_USER_ID).ok_or(EINVAL)?; + let record = |r: Result>, s: &SepData| -> (i32, i32) { + (r.err().map(|e| e.to_errno()).unwrap_or(0), s.last_key_status.load(Relaxed) as i32) + }; + self.last_key_status.store(0, Relaxed); + let ctx = self.passcode_context()?; + (req.result, req.enclave_status) = record(self.user_key_sign(&blob, &digest, Some(&ctx)), self); + self.close_unlock_evaluation(&ctx); + self.last_key_status.store(0, Relaxed); + let ctx = self.open_unlock_evaluation(user).ok_or(EIO)?; + (req.result2, req.enclave_status2) = record(self.user_key_sign(&blob, &digest, Some(&ctx)), self); + self.close_unlock_evaluation(&ctx); + dev_info!( + self.dev, + "sep-key: self-test presence probe: bag-secret context {} (enclave status {}), unsatisfied context {} (enclave status {})\n", + (if req.result == 0 { "SIGNED" } else { "refused" }), + req.enclave_status, + (if req.result2 == 0 { "SIGNED" } else { "refused" }), + req.enclave_status2 + ); + Ok(()) + } + fn key_selftest(&self, req: &mut Selftest) -> Result<()> { if crate::module_parameters::key_selftest.value() == 0 { return Err(EPERM); } crate::bio::require_admin()?; + if req.mode == SELFTEST_PRESENCE_PROBE { + return self.key_presence_probe(req); + } let (blob, flags) = decode_handle(&req.handle, req.handle_len)?; if flags & HANDLE_BIOMETRY == 0 { return Err(EINVAL); @@ -661,7 +799,7 @@ impl SepData { record(self.user_key_sign(&owned, &digest, Some(&ctx)), self) } SELFTEST_TOUCH_THEN_DELETED => { - let job = self.key_sign_with_touch(&owned, &digest, true)?; + let job = self.key_sign_with_touch(&owned, flags, &digest, true)?; req.result2 = errno_of(&job.again); req.enclave_status2 = job.again_status; (errno_of(&job.sig), job.status) @@ -693,13 +831,15 @@ impl SepData { let slice = UserSlice::new(user, core::mem::size_of::()); let (mut reader, mut writer) = slice.reader_writer(); let mut req: Create = reader.read()?; - if req.flags & !CREATE_FLAGS != 0 || req.reserved != [0; 3] { + if req.reserved != [0; 3] { return Err(EINVAL); } - let biometry = req.flags & CREATE_BIOMETRY_CURRENT_SET != 0; - let acl = if biometry { Some(self.key_bio_acl()?) } else { None }; + let (acl, flags) = match create_policy(req.flags)? { + Policy::None => (None, 0), + Policy::BiometryCurrentSet => (Some(self.key_bio_acl(false)?), HANDLE_BIOMETRY), + Policy::BiometryAny => (Some(self.key_bio_acl(true)?), HANDLE_BIOMETRY | HANDLE_BIOMETRY_ANY), + }; let (blob, point) = self.user_key_create(acl.as_ref().map(|a| &a.ed[..]))?; - let flags = if biometry { HANDLE_BIOMETRY } else { 0 }; req.handle_len = encode_handle(&blob, flags, &mut req.handle)? as u32; req.public_key = point; writer.write(&req) @@ -715,7 +855,7 @@ impl SepData { let (blob, flags) = decode_handle(&req.handle, req.handle_len)?; if flags & HANDLE_BIOMETRY != 0 { let digest = req.digest; - let job = self.key_sign_with_touch(blob, &digest, false)?; + let job = self.key_sign_with_touch(blob, flags, &digest, false)?; job.sig.ok_or(EIO)?? } else { self.user_key_sign(blob, &req.digest, None)? diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 9415c7bc2adaf..762dcaf4b085c 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -220,18 +220,35 @@ struct sep_bio_rearm { * finger enrolled when the key was made. SEP_KEY_SIGN then waits (about a minute) * for a touch; the kernel opens a credential context, runs the match in it, * signs and deletes the context inside that one call. Enrolling or removing a - * finger makes the key unusable. The node is created 0600; distributions may - * grant a group. Unknown 'flags' bits and 'reserved' fields must be zero. + * finger makes the key unusable (macOS .biometryCurrentSet). + * SEP_KEY_CREATE_BIOMETRY_ANY is macOS .biometryAny: the same, but any finger + * enrolled on this install matches, including ones enrolled later. At most one + * policy bit may be set; flags_supported lists the ones this kernel accepts. + * + * SEP_KEY_CREATE_USER_PRESENCE (.userPresence: Touch ID or the password) and + * SEP_KEY_CREATE_DEVICE_PASSCODE (.devicePasscode) are reserved. They need + * the enclave to check the user's own password; on Linux today the enclave's + * only passcode is a key bag secret root holds, so CREATE refuses them with + * EOPNOTSUPP rather than check a password in software. + * + * Keep a second key (a backup) wherever a biometric key is trusted, as with + * hardware security keys: no key can be exported or recovered. + * + * The node is created 0600; distributions may grant a group. Unknown 'flags' + * bits and 'reserved' fields must be zero. * * Errors: EINVAL for a malformed request or handle, EKEYREJECTED when the * enclave refuses the key (a handle from another Mac or an earlier Linux * install, or an access-control list not satisfied), EACCES when the touch * did not match (or Touch ID is locked out), ETIMEDOUT when no finger came, - * ENODEV while the key store or Touch ID is not up, EIO for any other - * enclave failure. + * ENODEV while the key store or Touch ID is not up, EOPNOTSUPP for a + * reserved policy, EIO for any other enclave failure. */ #define SEP_KEY_IFACE_VERSION 2 #define SEP_KEY_CREATE_BIOMETRY_CURRENT_SET 0x1 +#define SEP_KEY_CREATE_BIOMETRY_ANY 0x2 +#define SEP_KEY_CREATE_USER_PRESENCE 0x4 /* reserved: EOPNOTSUPP */ +#define SEP_KEY_CREATE_DEVICE_PASSCODE 0x8 /* reserved: EOPNOTSUPP */ #define SEP_KEY_HANDLE_MAX 2048 #define SEP_KEY_PUB_LEN 65 /* uncompressed point, 04 || X || Y */ #define SEP_KEY_DIGEST_LEN 32 @@ -280,6 +297,9 @@ struct sep_key_public { * the enclave refuses. Modes: 1 no context, 2 a context with no match, * 3 a context holding only the key bag secret, 4 a deleted context, 5 one * touch: sign in the matched context, delete it, sign again with it. + * 6 (handle ignored): make a throwaway key with the .userPresence list and + * sign with a key-bag-secret context ('result') and with a context with no + * match ('result2'). * 'result' is 0 when the enclave signed, else a negative errno. */ struct sep_key_selftest { From c15306a9d1917070d58338d528ee1f62edf2f02c Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 22:44:49 +1100 Subject: [PATCH 07/21] soc: apple: sep: userbag: serve key-bag ioctls on the re-arm node only The draft UAPI puts SEP_BIO_KEYBAG_* on the root-only re-arm node so that fprintd, which holds /dev/sep-bio, never handles a login password, but the dispatch sat in bio_ioctl. Move it to rearm_ioctl and drop it from /dev/sep-bio. The user-bag layouts were captured on the variant-5 key store (macOS 26, Neo generation). On the 13.x key store of M1/M2-generation Macs nothing is sent; say so in the log before returning EOPNOTSUPP. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/sbio.rs | 8 +++++--- drivers/soc/apple/userbag.rs | 7 +++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/drivers/soc/apple/sbio.rs b/drivers/soc/apple/sbio.rs index 07375129b0d24..4c772af601b39 100644 --- a/drivers/soc/apple/sbio.rs +++ b/drivers/soc/apple/sbio.rs @@ -5079,6 +5079,11 @@ impl SepData { if self.shutting_down.load(Relaxed) { return Err(ENODEV); } + // The per-user key bag carries login passwords: root only, on this + // node, never on /dev/sep-bio, which fprintd holds. + if crate::userbag::is_ioctl(cmd) { + return self.userbag_ioctl(cmd, arg).map(|_| ()); + } match cmd { bio::IOC_LOCKOUT_STATE => { let locked = if self.touchid_started.load(Relaxed) { @@ -5520,9 +5525,6 @@ impl SepData { if self.shutting_down.load(Relaxed) { return Err(ENODEV); } - if crate::userbag::is_ioctl(cmd) { - return self.userbag_ioctl(cmd, arg); - } if cmd == bio::IOC_ATTEST { return self.bio_attest(arg); } diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index a65d986e3338a..703c9e98cc907 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -1003,6 +1003,13 @@ impl crate::SepData { pub(crate) fn userbag_ioctl(&self, cmd: u32, arg: usize) -> Result { crate::bio::require_admin()?; if self.profile.key_store != crate::profile::KeyStore::Variant5 { + // The user-bag layouts were captured on the variant-5 key store + // (macOS 26 on the Neo). The 13.x key store of M1/M2-generation + // Macs has not been captured, so nothing is sent there. + dev_info!( + self.dev, + "userbag: per-user key bags are not supported on this key store yet (only variant 5 has been captured)\n" + ); return Err(EOPNOTSUPP); } let user_ptr = UserPtr::from_addr(arg); From 55c510116e6960fe8afe57e8e33a18282b846f3f Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:06:19 +1100 Subject: [PATCH 08/21] soc: apple: sep: userbag: log the replies the decoders refuse The first Linux user-bag create on the Neo worked, but the state query after it returned EPROTO with nothing in the log. Log which step refused which reply (length and first bytes; these replies carry no secret), a bag-handle mismatch, and an attempt-limit mismatch. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index 703c9e98cc907..dc53b8916daf9 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -834,7 +834,19 @@ impl crate::SepData { } fn userbag_body<'a>(&self, name: &CStr, out: &'a crate::SksOutcome) -> Result<&'a [u8]> { - self.sks_report_response(name, out).ok_or(EPROTO) + let body = self.sks_report_response(name, out); + if body.is_none() { + dev_info!(self.dev, "userbag: {}: no usable response (status {})\n", name, out.reply.status); + } + body.ok_or(EPROTO) + } + + /// Logs a reply the decoders refused: which step, its length and its + /// first bytes. Replies to these queries carry no secret. + fn userbag_refused(&self, step: &str, body: &[u8]) -> Error { + let n = body.len().min(96); + dev_info!(self.dev, "userbag: {}: reply refused, {} bytes: {:02x?}\n", step, body.len(), &body[..n]); + EPROTO } fn userbag_transition( @@ -966,7 +978,7 @@ impl crate::SepData { }) .ok_or(EACCES)?; let lock_body = self.userbag_body(crate::sks::SKS_LOCK_STATE_NAME, &lock)?; - let _lock_state = decode_lock_query_reply(lock_body).ok_or(EPROTO)?; + let _lock_state = decode_lock_query_reply(lock_body).ok_or_else(|| self.userbag_refused("lock-state query", lock_body))?; let state = self .sks_step(crate::sks::SKS_GET_DEVICE_STATE_NAME, |_healthy| { @@ -978,9 +990,10 @@ impl crate::SepData { }) .ok_or(EACCES)?; let body = self.userbag_body(crate::sks::SKS_GET_DEVICE_STATE_NAME, &state)?; - let state = decode_get_state_reply(body).ok_or(EPROTO)?; + let state = decode_get_state_reply(body).ok_or_else(|| self.userbag_refused("device state", body))?; if state.handle != handle { - return Err(EPROTO); + dev_info!(self.dev, "userbag: device state names bag handle {}, asked about {}\n", state.handle, handle); + return Err(self.userbag_refused("device state (handle)", body)); } Ok(state) } @@ -997,7 +1010,7 @@ impl crate::SepData { }) .ok_or(EACCES)?; let body = self.userbag_body(crate::sks::SKS_GET_CONFIGURATION_NAME, &out)?; - decode_get_configuration_reply(body).ok_or(EPROTO) + decode_get_configuration_reply(body).ok_or_else(|| self.userbag_refused("configuration", body)) } pub(crate) fn userbag_ioctl(&self, cmd: u32, arg: usize) -> Result { @@ -1064,6 +1077,7 @@ impl crate::SepData { let state = self.userbag_state(user)?; let configuration = self.userbag_configuration(user)?; if state.attempt_limit != configuration.attempt_limit { + dev_info!(self.dev, "userbag: attempt limit {} in the state, {} in the configuration\n", state.attempt_limit, configuration.attempt_limit); return Err(EPROTO); } reply.bag_state = match state.lock_phase { From 09752b7e7093996e455cb137b2eae5c5ba3e0f89 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Sun, 8 Nov 2026 23:29:27 +1100 Subject: [PATCH 09/21] soc: apple: sep: userbag: read DER replies as u32 0, then the dictionary On the Neo the key store answers a device-state request (0x19) with u32 0 followed by the DER dictionary as a padded blob. The draft expected the blob first and refused the reply. Read the observed order, and keep the golden vectors in that order. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index dc53b8916daf9..c8d66fc43c9c9 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -395,10 +395,15 @@ fn decode_get_state_request(body: &[u8]) -> Option { Some(handle) } +/// A DER-dictionary reply: `u32 0`, then the dictionary as a blob (padded to +/// 4), as the Neo's key store answered on Linux on 11-08. fn response_der(body: &[u8]) -> Option<&[u8]> { - let (der, at) = crate::image::read_blob(body, 0)?; - let mut c = Cursor { bytes: body, at }; - if c.u32()? != 0 || !c.done() { + let mut c = Cursor::new(body); + if c.u32()? != 0 { + return None; + } + let der = c.blob()?; + if !c.done() { return None; } Some(der) @@ -1268,8 +1273,8 @@ mod tests { 0x0c, 0x02, b's', b's', 0x02, 0x04, 0x06, 0, 0, 0x15, ]; let mut response = crate::image::Body::new(); - response.put_blob(&der).unwrap(); response.put_u32(0).unwrap(); + response.put_blob(&der).unwrap(); assert_eq!( decode_get_state_reply(response.as_slice()), Some(GetState { @@ -1302,8 +1307,8 @@ mod tests { 0x01, 0x2c, 0x30, 0x08, 0x0c, 0x03, b'm', b'u', b'a', 0x02, 0x01, 10, ]; let mut response = crate::image::Body::new(); - response.put_blob(&der).unwrap(); response.put_u32(0).unwrap(); + response.put_blob(&der).unwrap(); assert_eq!( decode_get_configuration_reply(response.as_slice()), Some(Configuration { From ccdbdc43cfc9506cbdc7bbd5ebf283b316a989d4 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:02:23 +1100 Subject: [PATCH 10/21] soc: apple: sep: sep-key: self-test 7, a credential from another user's bag Research probe (key_selftest=1, root, no finger): sign the throwaway .userPresence key, made under the identity bag, with a context whose passcode credential came from verify-secret on another user's key bag. It shows whether the enclave binds a passcode credential to the bag the key lives under, which decides how /dev/sep-key keys must be bound to per-user bags before .userPresence can go live. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 52 ++++++++++++++++++++++++++++++++++ include/uapi/linux/apple-sep.h | 4 +++ 2 files changed, 56 insertions(+) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index e846a02e555f4..df4c246b3e7a7 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -172,6 +172,13 @@ pub(crate) const SELFTEST_TOUCH_THEN_DELETED: u32 = 5; /// policy and whether a root-held secret satisfies it; `CREATE` refuses the /// policy either way. pub(crate) const SELFTEST_PRESENCE_PROBE: u32 = 6; +/// Research probe, no finger: the `.userPresence` throwaway key (identity +/// bag) signed with a context whose passcode credential came from another +/// user's bag. In: `enclave_status` = that user's uid, the handle buffer = +/// that user's bag password (`handle_len` bytes). Out: `result2` = the +/// verify-secret status, `result`/`enclave_status` = the signature. Shows +/// whether a credential from one bag satisfies a key under another. +pub(crate) const SELFTEST_PRESENCE_OTHER_BAG: u32 = 7; static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 2124); @@ -732,6 +739,48 @@ impl SepData { Ok(handle) } + fn key_presence_other_bag(&self, req: &mut Selftest) -> Result<()> { + let uid = req.enclave_status; + let len = req.handle_len as usize; + if len == 0 || len > 256 || uid < crate::sks::SKS_DESIGNATE_USER_MIN || uid == crate::SBIO_PROBE_USER_ID { + return Err(EINVAL); + } + let du = crate::sks::DesignateUser::new(uid).ok_or(EINVAL)?; + let ed = presence_acl()?; + let (blob, _) = self.user_key_create(Some(&ed))?; + let digest = req.digest; + let (status, _) = self.scrd_command(&crate::scrd::scrd_initialize()).ok_or(EIO)?; + if status != 0 { + return Err(EIO); + } + let (status, out) = self.scrd_command(&crate::scrd::scrd_context_create_tracked(uid)).ok_or(EIO)?; + let ctx = crate::scrd::tracked_create_reference(&out).filter(|_| status == 0).ok_or(EIO)?; + let verify = (|| -> Option { + let (status, _) = self.scrd_command(&crate::scrd::scrd_context_externalize(&ctx))?; + if status != 0 { + return None; + } + let out = self.sks_send(self.sks_req_verify_secret(du.special_handle(), &req.handle[..len], &ctx))?; + Some(out.reply.status as i32) + })(); + req.handle.fill(0); + req.result2 = verify.unwrap_or(-(EIO.to_errno())); + self.last_key_status.store(0, Relaxed); + let r = self.user_key_sign(&blob, &digest, Some(&ctx)); + req.result = r.err().map(|e| e.to_errno()).unwrap_or(0); + req.enclave_status = self.last_key_status.load(Relaxed) as i32; + self.close_unlock_evaluation(&ctx); + dev_info!( + self.dev, + "sep-key: self-test presence key with a credential from uid {}'s bag: verify status {}, signature {} (enclave status {})\n", + uid, + req.result2, + (if req.result == 0 { "SIGNED" } else { "refused" }), + req.enclave_status + ); + Ok(()) + } + fn key_presence_probe(&self, req: &mut Selftest) -> Result<()> { let ed = presence_acl()?; let (blob, _) = self.user_key_create(Some(&ed))?; @@ -767,6 +816,9 @@ impl SepData { if req.mode == SELFTEST_PRESENCE_PROBE { return self.key_presence_probe(req); } + if req.mode == SELFTEST_PRESENCE_OTHER_BAG { + return self.key_presence_other_bag(req); + } let (blob, flags) = decode_handle(&req.handle, req.handle_len)?; if flags & HANDLE_BIOMETRY == 0 { return Err(EINVAL); diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 5ef3eb4c17765..047e0cf0adb28 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -300,6 +300,10 @@ struct sep_key_public { * 6 (handle ignored): make a throwaway key with the .userPresence list and * sign with a key-bag-secret context ('result') and with a context with no * match ('result2'). + * 7 (research): the same throwaway key signed with a context whose passcode + * credential comes from another user's key bag. In: 'enclave_status' = that + * uid, 'handle'/'handle_len' = that bag's password. Out: 'result2' = the + * verify-secret status, 'result'/'enclave_status' = the signature. * 'result' is 0 when the enclave signed, else a negative errno. */ struct sep_key_selftest { From ff19f179730d35853083bc6015e99701fb8aff08 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:37:55 +1100 Subject: [PATCH 11/21] soc: apple: sep: sep-key: self-tests 8 and 9, keys under a user's bag Research probes (key_selftest=1, root, no finger) for binding /dev/sep-key keys to per-user key bags, per the macOS recipe: the key is created with the designated user's special handle (-uid) as the outer key-store handle, and every later operation repeats it. 8: a throwaway .userPresence key under the unlocked bag of a given uid, signed with a passcode credential from that same bag, and with no credential. 9: the same key signed with the Linux identity bag's secret (what root holds) and with a given (wrong) password. ref-key create is split so the create request can name either a loaded bag or a designated user's handle. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 114 +++++++++++++++++++++++++++++++++ drivers/soc/apple/refkey.rs | 46 ++++++++++++- include/uapi/linux/apple-sep.h | 5 ++ 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index df4c246b3e7a7..a4ac859ffb966 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -179,6 +179,16 @@ pub(crate) const SELFTEST_PRESENCE_PROBE: u32 = 6; /// verify-secret status, `result`/`enclave_status` = the signature. Shows /// whether a credential from one bag satisfies a key under another. pub(crate) const SELFTEST_PRESENCE_OTHER_BAG: u32 = 7; +/// Research probe, no finger: a throwaway `.userPresence` key made under +/// the (unlocked) bag of the user in `enclave_status` (outer handle -uid). +/// `result`: signed with a context holding a passcode credential from that +/// same bag (the password in the handle buffer). `result2`: signed with a +/// fresh, unsatisfied context. Statuses in `enclave_status`/`2`. +pub(crate) const SELFTEST_USERBAG_KEY_SAME_BAG: u32 = 8; +/// As 8, but `result` signs with the Linux identity bag's secret as the +/// credential (what root holds) and `result2` with the handle buffer's +/// password used as given (pass a wrong one). +pub(crate) const SELFTEST_USERBAG_KEY_ROOT: u32 = 9; static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 2124); @@ -739,6 +749,107 @@ impl SepData { Ok(handle) } + /// A fresh tracked context for `uid`; with `secret`, a passcode + /// credential from `bag`'s verify-secret is added. Returns the context + /// and the verify status (0 when none was asked). + fn key_context_for( + &self, + uid: i32, + cred: Option<(crate::sks::SpecialHandle, &[u8])>, + ) -> Result<([u8; crate::scrd::SCRD_ACM_HANDLE_LEN], i32)> { + let (status, _) = self.scrd_command(&crate::scrd::scrd_initialize()).ok_or(EIO)?; + if status != 0 { + return Err(EIO); + } + let (status, out) = self.scrd_command(&crate::scrd::scrd_context_create_tracked(uid)).ok_or(EIO)?; + let ctx = crate::scrd::tracked_create_reference(&out).filter(|_| status == 0).ok_or(EIO)?; + let (status, _) = self.scrd_command(&crate::scrd::scrd_context_externalize(&ctx)).ok_or(EIO)?; + if status != 0 { + self.close_unlock_evaluation(&ctx); + return Err(EIO); + } + let mut verify = 0; + if let Some((bag, secret)) = cred { + verify = match self.sks_send(self.sks_req_verify_secret(bag, secret, &ctx)) { + Some(out) => out.reply.status as i32, + None => -(EIO.to_errno()), + }; + } + Ok((ctx, verify)) + } + + fn key_userbag_probe(&self, req: &mut Selftest) -> Result<()> { + let uid = req.enclave_status; + let len = req.handle_len as usize; + if len == 0 || len > 256 || uid < crate::sks::SKS_DESIGNATE_USER_MIN || uid == crate::SBIO_PROBE_USER_ID { + return Err(EINVAL); + } + let mut pw = [0u8; 256]; + pw[..len].copy_from_slice(&req.handle[..len]); + req.handle.fill(0); + let user = crate::sks::DesignateUser::new(uid).ok_or(EINVAL)?; + let ed = presence_acl()?; + let made = self.user_key_create_in_bag(user, Some(&ed)); + let blob = match made { + Ok(b) => b, + Err(e) => { + pw.fill(0); + dev_info!(self.dev, "sep-key: self-test: key under uid {}'s bag not created ({:?})\n", uid, e); + return Err(e); + } + }; + let digest = req.digest; + let sign = |ctx: &[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]| -> (i32, i32) { + self.last_key_status.store(0, Relaxed); + let r = self.user_key_sign_in_bag(user, &blob, &digest, Some(ctx)); + (r.err().map(|e| e.to_errno()).unwrap_or(0), self.last_key_status.load(Relaxed) as i32) + }; + let first = if req.mode == SELFTEST_USERBAG_KEY_SAME_BAG { + self.key_context_for(uid, Some((user.special_handle(), &pw[..len]))) + } else { + let du = crate::sks::DesignateUser::new(crate::SBIO_PROBE_USER_ID).ok_or(EINVAL)?; + match crate::keybag::read(crate::keybag::Slot::Identity) { + Ok(crate::keybag::State::Present(stored)) => { + self.key_context_for(crate::SBIO_PROBE_USER_ID, Some((du.special_handle(), stored.secret()))) + } + _ => Err(ENODEV), + } + }; + let (ctx, v1) = match first { + Ok(c) => c, + Err(e) => { + pw.fill(0); + return Err(e); + } + }; + (req.result, req.enclave_status) = sign(&ctx); + self.close_unlock_evaluation(&ctx); + let second = if req.mode == SELFTEST_USERBAG_KEY_SAME_BAG { + self.key_context_for(uid, None) + } else { + self.key_context_for(uid, Some((user.special_handle(), &pw[..len]))) + }; + pw.fill(0); + let (ctx2, v2) = second?; + (req.result2, req.enclave_status2) = sign(&ctx2); + self.close_unlock_evaluation(&ctx2); + dev_info!( + self.dev, + "sep-key: self-test {}: key under uid {}'s bag: first ({}) verify {} -> {} ({}); second ({}) verify {} -> {} ({})\n", + req.mode, + uid, + (if req.mode == SELFTEST_USERBAG_KEY_SAME_BAG { "same-bag password" } else { "identity-bag secret" }), + v1, + (if req.result == 0 { "SIGNED" } else { "refused" }), + req.enclave_status, + (if req.mode == SELFTEST_USERBAG_KEY_SAME_BAG { "unsatisfied" } else { "given password" }), + v2, + (if req.result2 == 0 { "SIGNED" } else { "refused" }), + req.enclave_status2 + ); + Ok(()) + } + fn key_presence_other_bag(&self, req: &mut Selftest) -> Result<()> { let uid = req.enclave_status; let len = req.handle_len as usize; @@ -819,6 +930,9 @@ impl SepData { if req.mode == SELFTEST_PRESENCE_OTHER_BAG { return self.key_presence_other_bag(req); } + if req.mode == SELFTEST_USERBAG_KEY_SAME_BAG || req.mode == SELFTEST_USERBAG_KEY_ROOT { + return self.key_userbag_probe(req); + } let (blob, flags) = decode_handle(&req.handle, req.handle_len)?; if flags & HANDLE_BIOMETRY == 0 { return Err(EINVAL); diff --git a/drivers/soc/apple/refkey.rs b/drivers/soc/apple/refkey.rs index a8b01c4346e97..3f360a20c0fe6 100644 --- a/drivers/soc/apple/refkey.rs +++ b/drivers/soc/apple/refkey.rs @@ -89,8 +89,6 @@ impl SepData { secret: &[u8], acl: Option<&[u8]>, ) -> Option> { - use crate::der::RefKeyValue as RV; - let (protection_class, key_type) = match self.profile.key_store { // J313 accepted this non-class-F class and 13.5 EC key type for // creation, same-boot unseal, and post-reboot unseal. The exact @@ -115,6 +113,21 @@ impl SepData { return None; } + self.sks_create_refkey_at(handle.value(), protection_class, key_type, acl) + } + + /// The ref-key create itself, with `handle` as the outer key-bag handle: + /// a loaded bag, or a designated user's special handle `-uid` (the bag a + /// macOS Secure Enclave key lives under). The bag must already be + /// unlocked. + fn sks_create_refkey_at( + &self, + handle: i32, + protection_class: u32, + key_type: u32, + acl: Option<&[u8]>, + ) -> Option> { + use crate::der::RefKeyValue as RV; let create_der = { let mut items: KVec<(&[u8], RV<'_>)> = KVec::new(); if items.push((b"o", RV::Utf8(b"oc")), GFP_KERNEL).is_err() @@ -134,7 +147,7 @@ impl SepData { } crate::der::encode_refkey_set(&items).ok()? }; - let cout = match self.sks_send(self.sks_req_refkey(handle.value(), &create_der)) { + let cout = match self.sks_send(self.sks_req_refkey(handle, &create_der)) { Some(out) => out, None => { dev_warn!(self.dev, "sks: ref-key create got no usable reply\n"); @@ -448,6 +461,33 @@ impl SepData { Ok((blob, point)) } + /// Test only (`key_selftest`): a ref-key under the designated user bag + /// `user` (outer handle `-uid`), which the caller has unlocked. Variant-5 + /// key store only. + pub(crate) fn user_key_create_in_bag( + &self, + user: crate::sks::DesignateUser, + acl: Option<&[u8]>, + ) -> Result> { + if self.profile.key_store != profile::KeyStore::Variant5 { + return Err(EOPNOTSUPP); + } + crate::xart_guard::close_policy_window(); + self.sks_create_refkey_at(user.special_handle().value(), 9, 5, acl).ok_or(EIO) + } + + /// Test only: sign with a ref-key under the designated user bag `user`. + pub(crate) fn user_key_sign_in_bag( + &self, + user: crate::sks::DesignateUser, + blob: &[u8], + digest: &[u8; 32], + acm: Option<&[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]>, + ) -> Result> { + let handle = crate::sks::KeyBagHandle::from_load_reply(user.special_handle().value()); + self.sks_refkey_sign(handle, blob, digest, acm) + } + /// The public point recorded in a ref-key record; no enclave round trip. pub(crate) fn user_key_public(blob: &[u8]) -> Result<[u8; refkey_seal::POINT_LEN]> { let mut point = [0u8; refkey_seal::POINT_LEN]; diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 047e0cf0adb28..26ac14872dfd2 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -304,6 +304,11 @@ struct sep_key_public { * credential comes from another user's key bag. In: 'enclave_status' = that * uid, 'handle'/'handle_len' = that bag's password. Out: 'result2' = the * verify-secret status, 'result'/'enclave_status' = the signature. + * 8 (research): a throwaway .userPresence key under the unlocked bag of the + * uid in 'enclave_status'; 'result' = signed with that bag's password + * credential (password in 'handle'), 'result2' = with no credential. + * 9 (research): as 8, 'result' = with the identity bag's secret as the + * credential, 'result2' = with the password given in 'handle'. * 'result' is 0 when the enclave signed, else a negative errno. */ struct sep_key_selftest { From 1b83c8d1a39cbe44ac49358ba24c0158d01c6bd3 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:50:02 +1100 Subject: [PATCH 12/21] soc: apple: sep: sep-key: self-test 10, a locked user bag and its keys Research probe (key_selftest=1, root, no finger) for the property that matters most before per-user bags carry real keys: a key under a user's bag must not be usable while the bag is locked unless the user's password is presented. Sign with a password credential while unlocked, lock the bag for good, sign with that now stale credential, with none and with a fresh password credential, unlock and sign again; return each enclave status and the lock phases. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 71 ++++++++++++++++++++++++++++++++++ drivers/soc/apple/userbag.rs | 6 +++ include/uapi/linux/apple-sep.h | 3 ++ 3 files changed, 80 insertions(+) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index a4ac859ffb966..2e53b019d4504 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -189,6 +189,17 @@ pub(crate) const SELFTEST_USERBAG_KEY_SAME_BAG: u32 = 8; /// credential (what root holds) and `result2` with the handle buffer's /// password used as given (pass a wrong one). pub(crate) const SELFTEST_USERBAG_KEY_ROOT: u32 = 9; +/// Research probe, no finger (T2: a locked bag refuses without its +/// password): a throwaway `.userPresence` key under the unlocked bag of the +/// uid in `enclave_status`, password in the handle buffer. The kernel signs +/// (r0) with a context holding that bag's password credential, locks the bag +/// for good (screen lock, grace expired now), then signs with the same, now +/// stale, context (r1), with no credential (r2) and with a fresh password +/// credential (r3), unlocks the bag with the password and signs with a fresh +/// credential again (r4). The digest buffer returns, as LE i32: r0..r4 (0 = +/// signed, else the enclave status), the lock phase before the lock, after +/// it and after the unlock. +pub(crate) const SELFTEST_USERBAG_LOCKED: u32 = 10; static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 2124); @@ -778,6 +789,63 @@ impl SepData { Ok((ctx, verify)) } + fn key_userbag_locked(&self, req: &mut Selftest) -> Result<()> { + let uid = req.enclave_status; + let len = req.handle_len as usize; + if len == 0 || len > 256 || uid < crate::sks::SKS_DESIGNATE_USER_MIN || uid == crate::SBIO_PROBE_USER_ID { + return Err(EINVAL); + } + let mut pw = [0u8; 256]; + pw[..len].copy_from_slice(&req.handle[..len]); + req.handle.fill(0); + let user = crate::sks::DesignateUser::new(uid).ok_or(EINVAL)?; + let out = (|| -> Result<[i32; 8]> { + let ed = presence_acl()?; + let blob = self.user_key_create_in_bag(user, Some(&ed))?; + let digest = [0x5au8; DIGEST_LEN]; + let sign = |ctx: Option<&[u8; crate::scrd::SCRD_ACM_HANDLE_LEN]>| -> i32 { + self.last_key_status.store(0, Relaxed); + match self.user_key_sign_in_bag(user, &blob, &digest, ctx) { + Ok(_) => 0, + Err(_) => match self.last_key_status.load(Relaxed) as i32 { + 0 => -(EIO.to_errno()), + s => s, + }, + } + }; + let mut r = [0i32; 8]; + r[5] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); + let (stale, _) = self.key_context_for(uid, Some((user.special_handle(), &pw[..len])))?; + r[0] = sign(Some(&stale)); + self.userbag_lock(user, true, true)?; + r[6] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); + r[1] = sign(Some(&stale)); + self.close_unlock_evaluation(&stale); + r[2] = sign(None); + let (fresh, v) = self.key_context_for(uid, Some((user.special_handle(), &pw[..len])))?; + r[3] = if v == 0 { sign(Some(&fresh)) } else { v }; + self.close_unlock_evaluation(&fresh); + self.userbag_unlock(user, &pw[..len])?; + r[7] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); + let (fresh2, v2) = self.key_context_for(uid, Some((user.special_handle(), &pw[..len])))?; + r[4] = if v2 == 0 { sign(Some(&fresh2)) } else { v2 }; + self.close_unlock_evaluation(&fresh2); + Ok(r) + })(); + pw.fill(0); + let r = out?; + for (i, v) in r.iter().enumerate() { + req.digest[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + req.result = 0; + dev_info!( + self.dev, + "sep-key: self-test 10, uid {}'s bag: phase {} -> sign {}; locked (phase {}): stale credential {}, none {}, fresh password credential {}; unlocked (phase {}): {}\n", + uid, r[5], r[0], r[6], r[1], r[2], r[3], r[7], r[4] + ); + Ok(()) + } + fn key_userbag_probe(&self, req: &mut Selftest) -> Result<()> { let uid = req.enclave_status; let len = req.handle_len as usize; @@ -930,6 +998,9 @@ impl SepData { if req.mode == SELFTEST_PRESENCE_OTHER_BAG { return self.key_presence_other_bag(req); } + if req.mode == SELFTEST_USERBAG_LOCKED { + return self.key_userbag_locked(req); + } if req.mode == SELFTEST_USERBAG_KEY_SAME_BAG || req.mode == SELFTEST_USERBAG_KEY_ROOT { return self.key_userbag_probe(req); } diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index c8d66fc43c9c9..e01a8e8024849 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -971,6 +971,12 @@ impl crate::SepData { Ok(()) } + /// The enclave's lock phase for `user`'s bag (0 unlocked, 1 locked, + /// 2 locking, 5 asleep in grace), for the key self-tests. + pub(crate) fn userbag_lock_phase(&self, user: crate::sks::DesignateUser) -> Result { + Ok(self.userbag_state(user)?.lock_phase) + } + fn userbag_state(&self, user: crate::sks::DesignateUser) -> Result { let handle = user.special_handle().value(); let lock = self diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 26ac14872dfd2..748a53e97bd8c 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -309,6 +309,9 @@ struct sep_key_public { * credential (password in 'handle'), 'result2' = with no credential. * 9 (research): as 8, 'result' = with the identity bag's secret as the * credential, 'result2' = with the password given in 'handle'. + * 10 (research): as 8, but the kernel also locks the bag for good and signs + * with a stale, an empty and a fresh credential, then unlocks and signs + * again; the results come back in 'digest' (see key.rs). * 'result' is 0 when the enclave signed, else a negative errno. */ struct sep_key_selftest { From 05df83b928dac76640ce1c60aca2961f6a5eaa62 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:02:44 +1100 Subject: [PATCH 13/21] soc: apple: sep: userbag: log the timer-fire answer the lock path refuses The locked-bag self-test stopped with EPROTO after the grace-expiry timer fire (0x2f): its reply did not match the layout the draft checks, and nothing was logged. Log it, and let self-test 10 measure the bag's lock phase and the signatures even when that answer is not understood. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 7 ++++++- drivers/soc/apple/userbag.rs | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index 2e53b019d4504..170e179486ce5 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -817,7 +817,12 @@ impl SepData { r[5] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); let (stale, _) = self.key_context_for(uid, Some((user.special_handle(), &pw[..len])))?; r[0] = sign(Some(&stale)); - self.userbag_lock(user, true, true)?; + // Lock for good: the screen lock with an expired grace period. + // If the timer-fire answer is not understood, record it and still + // measure what the enclave now does with the bag. + if let Err(e) = self.userbag_lock(user, true, true) { + dev_info!(self.dev, "sep-key: self-test 10: lock returned {:?}\n", e); + } r[6] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); r[1] = sign(Some(&stale)); self.close_unlock_evaluation(&stale); diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index e01a8e8024849..80f1aa4d4c8a2 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -900,8 +900,10 @@ impl crate::SepData { }) .ok_or(EACCES)?; let body = self.userbag_body(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, &out)?; - if decode_timer_fire_reply(body) != Some(user.special_handle().value()) { - return Err(EPROTO); + let fired = decode_timer_fire_reply(body); + if fired != Some(user.special_handle().value()) { + dev_info!(self.dev, "userbag: timer fire answered {:?}, expected {}\n", fired, user.special_handle().value()); + return Err(self.userbag_refused("timer fire", body)); } } Ok(()) From cc1836dade4f599e4adac0372bcbe535a57d1741 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:14:30 +1100 Subject: [PATCH 14/21] soc: apple: sep: userbag: read the grace-timer answer as the Neo gives it The key store answers the grace-timer fire (0x2f) with u32 0, the handle whose grace period ended (0 while none has), the timer kind, u32 0, an opaque token and u32 0. The draft read the handle first and refused it. Split the fire into userbag_timer_fire(), which tells a locked bag from a grace period still running; a lock that wants the expiry now returns EAGAIN in the latter case. Self-test 10 now fires the timer every 5 s until the store locks the bag (at most 330 s) before it measures. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 31 ++++++++++++++++--- drivers/soc/apple/userbag.rs | 60 +++++++++++++++++++++++------------- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index 170e179486ce5..a7c70ba6a30b0 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -817,12 +817,33 @@ impl SepData { r[5] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); let (stale, _) = self.key_context_for(uid, Some((user.special_handle(), &pw[..len])))?; r[0] = sign(Some(&stale)); - // Lock for good: the screen lock with an expired grace period. - // If the timer-fire answer is not understood, record it and still - // measure what the enclave now does with the bag. - if let Err(e) = self.userbag_lock(user, true, true) { - dev_info!(self.dev, "sep-key: self-test 10: lock returned {:?}\n", e); + // Lock for good: the screen lock, then grace-timer fires (as the + // Mac's kernel sends them) until the store locks the bag, for at + // most LOCK_WAIT_S. The store sets the grace period itself. + const LOCK_WAIT_S: u64 = 330; + self.userbag_lock(user, true, false)?; + let start = crate::shim::boottime_ns(); + let mut locked = false; + while crate::shim::boottime_ns() - start < LOCK_WAIT_S * 1_000_000_000 { + kernel::time::delay::fsleep(kernel::time::Delta::from_millis(5000)); + match self.userbag_timer_fire(user) { + Ok(true) => { + locked = true; + break; + } + Ok(false) => {} + Err(e) => { + dev_info!(self.dev, "sep-key: self-test 10: timer fire returned {:?}\n", e); + break; + } + } } + dev_info!( + self.dev, + "sep-key: self-test 10: bag locked by the store: {} after {} s\n", + locked, + (crate::shim::boottime_ns() - start) / 1_000_000_000 + ); r[6] = self.userbag_lock_phase(user).map(|p| p as i32).unwrap_or(-1); r[1] = sign(Some(&stale)); self.close_unlock_evaluation(&stale); diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index 80f1aa4d4c8a2..50e3ef81413b3 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -275,17 +275,18 @@ fn decode_timer_fire(body: &[u8]) -> bool { body == [0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0] } +/// The grace-expiry answer, as the Neo's key store gave it on Linux on +/// 11-09: `u32 0`, the handle whose grace period ended (0 when none has +/// yet), the timer kind (1), `u32 0`, an opaque token, `u32 0`. fn decode_timer_fire_reply(body: &[u8]) -> Option { let mut c = Cursor::new(body); + let lead = c.u32()?; let handle = c.i32()?; - // The remaining captured words describe the fired timer. They are kept - // opaque until their meanings are known, but their fixed layout is checked. let kind = c.u32()?; let reserved = c.u32()?; let _token = c.u32()?; - let _generation = c.u32()?; let trailer = c.u32()?; - (handle < 0 && kind == 1 && reserved == 0 && trailer == 0 && c.done()).then_some(handle) + (lead == 0 && handle <= 0 && kind == 1 && reserved == 0 && trailer == 0 && c.done()).then_some(handle) } fn encode_change_secret(user_handle: i32, old: &[u8], new: &[u8]) -> Result { @@ -890,20 +891,8 @@ impl crate::SepData { ) -> Result<()> { self.userbag_transition(user, TransitionState::Locked, u64::from(screen_lock), &[])?; if expire_now { - let out = self - .sks_step(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, |_healthy| { - self.userbag_request( - crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, - encode_timer_fire()?, - |seq, len| Ok(crate::sks::encode_sks_handle_timer_fire(seq, len)), - ) - }) - .ok_or(EACCES)?; - let body = self.userbag_body(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, &out)?; - let fired = decode_timer_fire_reply(body); - if fired != Some(user.special_handle().value()) { - dev_info!(self.dev, "userbag: timer fire answered {:?}, expected {}\n", fired, user.special_handle().value()); - return Err(self.userbag_refused("timer fire", body)); + if !self.userbag_timer_fire(user)? { + return Err(EAGAIN); } } Ok(()) @@ -973,6 +962,30 @@ impl crate::SepData { Ok(()) } + /// Tells the key store a grace timer is due (`0x2f`), as the Mac's own + /// kernel does. True when the store locked `user`'s bag; false while its + /// grace period has not ended (the store names no handle). + pub(crate) fn userbag_timer_fire(&self, user: crate::sks::DesignateUser) -> Result { + let out = self + .sks_step(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, + encode_timer_fire()?, + |seq, len| Ok(crate::sks::encode_sks_handle_timer_fire(seq, len)), + ) + }) + .ok_or(EACCES)?; + let body = self.userbag_body(crate::sks::SKS_HANDLE_TIMER_FIRE_NAME, &out)?; + match decode_timer_fire_reply(body) { + Some(0) => Ok(false), + Some(h) if h == user.special_handle().value() => Ok(true), + fired => { + dev_info!(self.dev, "userbag: timer fire answered {:?}, expected {}\n", fired, user.special_handle().value()); + Err(self.userbag_refused("timer fire", body)) + } + } + } + /// The enclave's lock phase for `user`'s bag (0 unlocked, 1 locked, /// 2 locking, 5 asleep in grace), for the key self-tests. pub(crate) fn userbag_lock_phase(&self, user: crate::sks::DesignateUser) -> Result { @@ -1216,10 +1229,15 @@ mod tests { assert_eq!(body.as_slice(), &golden); assert!(decode_timer_fire(body.as_slice())); let mut reply = [0u8; 24]; - reply[0..4].copy_from_slice(&USER_HANDLE.to_le_bytes()); - reply[4..8].copy_from_slice(&1u32.to_le_bytes()); - reply[16..20].copy_from_slice(&3u32.to_le_bytes()); + reply[4..8].copy_from_slice(&USER_HANDLE.to_le_bytes()); + reply[8..12].copy_from_slice(&1u32.to_le_bytes()); + reply[16..20].copy_from_slice(&0x027e_4cb2u32.to_le_bytes()); assert_eq!(decode_timer_fire_reply(&reply), Some(USER_HANDLE)); + // Before the grace period ends the store names no handle. + reply[4..8].copy_from_slice(&0i32.to_le_bytes()); + assert_eq!(decode_timer_fire_reply(&reply), Some(0)); + reply[0] = 1; + assert_eq!(decode_timer_fire_reply(&reply), None); } #[test] From 9f5684cbfb68c5378b6a93a85d4dbd0a1a310112 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:24:39 +1100 Subject: [PATCH 15/21] soc: apple: sep: userbag: the grace-timer answer's last word is a counter When the store locks the bag at grace expiry it names the bag and puts 3 in the last word (0 while grace ran). Treat that word as opaque instead of requiring 0. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index 50e3ef81413b3..d5b406894493b 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -277,7 +277,8 @@ fn decode_timer_fire(body: &[u8]) -> bool { /// The grace-expiry answer, as the Neo's key store gave it on Linux on /// 11-09: `u32 0`, the handle whose grace period ended (0 when none has -/// yet), the timer kind (1), `u32 0`, an opaque token, `u32 0`. +/// yet), the timer kind (1), `u32 0`, then two opaque words (a token, and a +/// small counter that was 0 while grace ran and 3 when the bag locked). fn decode_timer_fire_reply(body: &[u8]) -> Option { let mut c = Cursor::new(body); let lead = c.u32()?; @@ -285,8 +286,8 @@ fn decode_timer_fire_reply(body: &[u8]) -> Option { let kind = c.u32()?; let reserved = c.u32()?; let _token = c.u32()?; - let trailer = c.u32()?; - (lead == 0 && handle <= 0 && kind == 1 && reserved == 0 && trailer == 0 && c.done()).then_some(handle) + let _count = c.u32()?; + (lead == 0 && handle <= 0 && kind == 1 && reserved == 0 && c.done()).then_some(handle) } fn encode_change_secret(user_handle: i32, old: &[u8], new: &[u8]) -> Result { @@ -1236,6 +1237,10 @@ mod tests { // Before the grace period ends the store names no handle. reply[4..8].copy_from_slice(&0i32.to_le_bytes()); assert_eq!(decode_timer_fire_reply(&reply), Some(0)); + // When the bag locks, the store names it and the last word is not 0. + reply[4..8].copy_from_slice(&USER_HANDLE.to_le_bytes()); + reply[20..24].copy_from_slice(&3u32.to_le_bytes()); + assert_eq!(decode_timer_fire_reply(&reply), Some(USER_HANDLE)); reply[0] = 1; assert_eq!(decode_timer_fire_reply(&reply), None); } From 0ed09e8d8511e1ad0a00cbedbfc7f744c6e5a684 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:26:58 +1100 Subject: [PATCH 16/21] soc: apple: sep: userbag: export and import a user's wrapped bag A per-user bag lives only until reboot unless its wrapped container is kept. macOS saves it and loads it again at login; the container holds no secret and opens only with the user's password on this Mac. Add SEP_BIO_KEYBAG_EXPORT (0x15: copy the designated -uid bag) and SEP_BIO_KEYBAG_IMPORT (0x16: load a saved bag, designate it as -uid again, drop the temporary handle), both root-only on the re-arm node, so userspace keeps the file. Self-tests 11/12 make a key under a user's bag and use its saved record later, to prove keys survive with the bag. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/key.rs | 64 ++++++++++++++++++++++++++ drivers/soc/apple/sks.rs | 20 +++++++- drivers/soc/apple/userbag.rs | 84 +++++++++++++++++++++++++++++++++- include/uapi/linux/apple-sep.h | 20 ++++++++ 4 files changed, 186 insertions(+), 2 deletions(-) diff --git a/drivers/soc/apple/key.rs b/drivers/soc/apple/key.rs index a7c70ba6a30b0..38f423ad71e60 100644 --- a/drivers/soc/apple/key.rs +++ b/drivers/soc/apple/key.rs @@ -200,6 +200,16 @@ pub(crate) const SELFTEST_USERBAG_KEY_ROOT: u32 = 9; /// signed, else the enclave status), the lock phase before the lock, after /// it and after the unlock. pub(crate) const SELFTEST_USERBAG_LOCKED: u32 = 10; +/// Research: create a throwaway `.userPresence` key under the unlocked bag +/// of the uid in `enclave_status` and return its record in the handle +/// buffer (`handle_len`), so a later boot can use it after the bag is +/// imported again. +pub(crate) const SELFTEST_USERBAG_KEY_MAKE: u32 = 11; +/// Research: sign the record in the handle buffer under the bag of the uid +/// in `enclave_status`, with a password credential from that bag; the +/// password is the NUL-padded `digest` (at most 32 bytes). `result` = the +/// signature, `result2` = the verify-secret status. +pub(crate) const SELFTEST_USERBAG_KEY_USE: u32 = 12; static_assert!(core::mem::size_of::() == 16); static_assert!(core::mem::size_of::() == 2124); @@ -789,6 +799,57 @@ impl SepData { Ok((ctx, verify)) } + fn key_userbag_record(&self, req: &mut Selftest) -> Result<()> { + let uid = req.enclave_status; + if uid < crate::sks::SKS_DESIGNATE_USER_MIN || uid == crate::SBIO_PROBE_USER_ID { + return Err(EINVAL); + } + let user = crate::sks::DesignateUser::new(uid).ok_or(EINVAL)?; + if req.mode == SELFTEST_USERBAG_KEY_MAKE { + let ed = presence_acl()?; + let blob = self.user_key_create_in_bag(user, Some(&ed))?; + if blob.len() > HANDLE_MAX { + return Err(E2BIG); + } + req.handle.fill(0); + req.handle[..blob.len()].copy_from_slice(&blob); + req.handle_len = blob.len() as u32; + req.result = 0; + dev_info!(self.dev, "sep-key: self-test 11: key under uid {}'s bag, record {} bytes\n", uid, blob.len()); + return Ok(()); + } + let len = req.handle_len as usize; + if len == 0 || len > HANDLE_MAX { + return Err(EINVAL); + } + let mut pw = req.digest; + req.digest.fill(0); + let plen = pw.iter().position(|&b| b == 0).unwrap_or(pw.len()); + if plen == 0 { + return Err(EINVAL); + } + let mut record = KVec::new(); + record.extend_from_slice(&req.handle[..len], GFP_KERNEL)?; + let made = self.key_context_for(uid, Some((user.special_handle(), &pw[..plen]))); + pw.fill(0); + let (ctx, v) = made?; + req.result2 = v; + self.last_key_status.store(0, Relaxed); + let r = self.user_key_sign_in_bag(user, &record, &[0x5a; DIGEST_LEN], Some(&ctx)); + self.close_unlock_evaluation(&ctx); + req.result = r.err().map(|e| e.to_errno()).unwrap_or(0); + req.enclave_status = self.last_key_status.load(Relaxed) as i32; + dev_info!( + self.dev, + "sep-key: self-test 12: saved key under uid {}'s bag: verify {} -> {} ({})\n", + uid, + v, + (if req.result == 0 { "SIGNED" } else { "refused" }), + req.enclave_status + ); + Ok(()) + } + fn key_userbag_locked(&self, req: &mut Selftest) -> Result<()> { let uid = req.enclave_status; let len = req.handle_len as usize; @@ -1024,6 +1085,9 @@ impl SepData { if req.mode == SELFTEST_PRESENCE_OTHER_BAG { return self.key_presence_other_bag(req); } + if req.mode == SELFTEST_USERBAG_KEY_MAKE || req.mode == SELFTEST_USERBAG_KEY_USE { + return self.key_userbag_record(req); + } if req.mode == SELFTEST_USERBAG_LOCKED { return self.key_userbag_locked(req); } diff --git a/drivers/soc/apple/sks.rs b/drivers/soc/apple/sks.rs index 13a78806a5559..a4cac9071a108 100644 --- a/drivers/soc/apple/sks.rs +++ b/drivers/soc/apple/sks.rs @@ -548,7 +548,7 @@ impl SepData { } /// `0x02` copy the designated biometric identity bag. - fn sks_req_copy_keybag_special(&self, handle: crate::sks::SpecialHandle) -> Result { + pub(crate) fn sks_req_copy_keybag_special(&self, handle: crate::sks::SpecialHandle) -> Result { let op = crate::sks::sks_copy_keybag(); let mut body = image::Body::new(); body.put_u32(0)?; @@ -557,6 +557,24 @@ impl SepData { self.sks_seal(&op, &body) } + /// Loads a wrapped bag (`0x03`) and returns its temporary handle. Used for + /// per-user bags, whose wrapped container userspace keeps. + pub(crate) fn sks_load_wrapped(&self, wrapped: &[u8]) -> Option { + let out = self.sks_send(self.sks_req_load_keybag(wrapped))?; + let body = self.sks_report_response(crate::sks::SKS_LOAD_NAME, &out)?; + if out.reply.status != 0 || body.len() != SKS_LOAD_REPLY_LEN { + dev_info!(self.dev, "sks: LOAD_KEYBAG (user bag): status {}, {} bytes\n", out.reply.status, body.len()); + return None; + } + let status = i32::from_le_bytes([body[0], body[1], body[2], body[3]]); + let handle = i32::from_le_bytes([body[4], body[5], body[6], body[7]]); + if status != 0 || handle < 0 { + dev_info!(self.dev, "sks: LOAD_KEYBAG (user bag): operation status {}, handle {}\n", status, handle); + return None; + } + Some(crate::sks::KeyBagHandle::from_load_reply(handle)) + } + /// `0x03` load key bag. fn sks_req_load_keybag(&self, wrapped: &[u8]) -> Result { let mut body = image::Body::new(); diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index d5b406894493b..5d370a1f4fb1a 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -749,11 +749,31 @@ pub(crate) const IOC_UNLOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGI pub(crate) const IOC_LOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x12); pub(crate) const IOC_CHANGE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x13); pub(crate) const IOC_CREATE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x14); +pub(crate) const IOC_EXPORT: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x15); +pub(crate) const IOC_IMPORT: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x16); + +/// The largest wrapped user bag `EXPORT`/`IMPORT` carry. +pub(crate) const BAG_BLOB_MAX: usize = 1024; + +/// The UAPI `struct sep_keybag_blob`: a user's wrapped bag. It holds no +/// secret: the enclave wrapped it under a key entangled with its own UID +/// key and the bag opens only with the user's password. +#[repr(C)] +pub(crate) struct BagBlob { + pub(crate) uid: u32, + pub(crate) len: u32, + pub(crate) data: [u8; BAG_BLOB_MAX], +} +static_assert!(core::mem::size_of::() == 8 + BAG_BLOB_MAX); +// SAFETY: C layout, no padding, every bit pattern valid. +unsafe impl kernel::transmute::FromBytes for BagBlob {} +// SAFETY: as above. +unsafe impl kernel::transmute::AsBytes for BagBlob {} pub(crate) fn is_ioctl(cmd: u32) -> bool { matches!( cmd, - IOC_STATE | IOC_UNLOCK | IOC_LOCK | IOC_CHANGE | IOC_CREATE + IOC_STATE | IOC_UNLOCK | IOC_LOCK | IOC_CHANGE | IOC_CREATE | IOC_EXPORT | IOC_IMPORT ) } @@ -922,6 +942,42 @@ impl crate::SepData { Ok(()) } + /// The designated user bag's wrapped container (`0x02` against `-uid`), + /// for userspace to keep across reboots, as macOS saves it. + pub(crate) fn userbag_export(&self, user: crate::sks::DesignateUser) -> Result> { + let out = self.sks_send(self.sks_req_copy_keybag_special(user.special_handle())).ok_or(EACCES)?; + let wrapped = self.wrapped_from_copy_reply(&out, c"a user bag").ok_or(EACCES)?; + if wrapped.len() > BAG_BLOB_MAX { + dev_info!(self.dev, "userbag: wrapped bag is {} bytes, more than {}\n", wrapped.len(), BAG_BLOB_MAX); + return Err(E2BIG); + } + Ok(wrapped) + } + + /// Loads a saved wrapped user bag and designates it as `-uid` again (it + /// stays locked until the password unlocks it), then drops the temporary + /// handle, in macOS's order. + pub(crate) fn userbag_import(&self, user: crate::sks::DesignateUser, wrapped: &[u8]) -> Result<()> { + let handle = self.sks_load_wrapped(wrapped).ok_or(EACCES)?; + let designated = self.sks_step(crate::sks::SKS_DESIGNATE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_DESIGNATE_NAME, + encode_designate(handle.value(), user.special_handle().value())?, + |seq, len| crate::sks::encode_sks_designate(seq, len).ok_or(EINVAL), + ) + }); + let valid = designated.as_ref().is_some_and(|out| { + self.sks_report_response(crate::sks::SKS_DESIGNATE_NAME, out) + .is_some_and(decode_designate_reply) + }); + let _ = self.sks_send(self.sks_req_unload_keybag(handle)); + if !valid { + dev_info!(self.dev, "userbag: designating the imported bag as {} failed\n", user.special_handle().value()); + return Err(EACCES); + } + Ok(()) + } + pub(crate) fn userbag_create( &self, user: crate::sks::DesignateUser, @@ -1057,6 +1113,32 @@ impl crate::SepData { return Err(EBUSY); } match cmd { + IOC_EXPORT => { + let mut blob: BagBlob = UserSlice::new(user_ptr, core::mem::size_of::()).reader().read()?; + let user = crate::sks::DesignateUser::new(i32::try_from(blob.uid).map_err(|_| EINVAL)?).ok_or(EINVAL)?; + if blob.uid < MIN_UID { + return Err(EINVAL); + } + let wrapped = self.userbag_export(user)?; + blob.data.fill(0); + blob.data[..wrapped.len()].copy_from_slice(&wrapped); + blob.len = wrapped.len() as u32; + UserSlice::new(user_ptr, core::mem::size_of::()).writer().write(&blob)?; + crate::bio::ok() + } + IOC_IMPORT => { + let blob: BagBlob = UserSlice::new(user_ptr, core::mem::size_of::()).reader().read()?; + let len = blob.len as usize; + if blob.uid < MIN_UID || len == 0 || len > BAG_BLOB_MAX { + return Err(EINVAL); + } + let user = crate::sks::DesignateUser::new(i32::try_from(blob.uid).map_err(|_| EINVAL)?).ok_or(EINVAL)?; + if !self.xart_writable() { + return Err(EROFS); + } + self.userbag_import(user, &blob.data[..len])?; + crate::bio::ok() + } IOC_CREATE | IOC_UNLOCK => { let request: UnlockRequest = UserSlice::new(user_ptr, core::mem::size_of::()) diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 748a53e97bd8c..332a345f64d0a 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -312,6 +312,9 @@ struct sep_key_public { * 10 (research): as 8, but the kernel also locks the bag for good and signs * with a stale, an empty and a fresh credential, then unlocks and signs * again; the results come back in 'digest' (see key.rs). + * 11 (research): make such a key under that uid's unlocked bag and return its + * record in 'handle'. 12: sign that record with a password credential from + * the bag; the password is the NUL-padded 'digest'. * 'result' is 0 when the enclave signed, else a negative errno. */ struct sep_key_selftest { @@ -389,4 +392,21 @@ struct sep_keybag_change { #define SEP_BIO_KEYBAG_CHANGE _IOW(SEP_BIO_IOC_MAGIC, 0x13, struct sep_keybag_change) #define SEP_BIO_KEYBAG_CREATE _IOW(SEP_BIO_IOC_MAGIC, 0x14, struct sep_keybag_unlock) +/* + * A user's wrapped bag, for userspace to keep across reboots (as macOS + * saves it). It holds no secret: it opens only with the user's password, + * on this Mac. EXPORT copies the designated bag; IMPORT loads a saved one + * and designates it again, locked until SEP_BIO_KEYBAG_UNLOCK. + */ +#define SEP_KEYBAG_BLOB_MAX 1024 + +struct sep_keybag_blob { + __u32 uid; + __u32 len; + __u8 data[SEP_KEYBAG_BLOB_MAX]; +}; + +#define SEP_BIO_KEYBAG_EXPORT _IOWR(SEP_BIO_IOC_MAGIC, 0x15, struct sep_keybag_blob) +#define SEP_BIO_KEYBAG_IMPORT _IOW(SEP_BIO_IOC_MAGIC, 0x16, struct sep_keybag_blob) + #endif /* _UAPI_LINUX_APPLE_SEP_H */ From deedb0c4c21f04dea6078ea7936ec0859728b3d5 Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Mon, 9 Nov 2026 01:32:27 +1100 Subject: [PATCH 17/21] soc: apple: sep: userbag: move the key-bag ioctls to 0x30-0x36 integration/aurora now uses nr 0x10 for SEP_BIO_REQUIRE_PASSWORD. Move the draft key-bag ioctls out of its way. Signed-off-by: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 14 +++++++------- include/uapi/linux/apple-sep.h | 17 +++++++++-------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index 5d370a1f4fb1a..7e994ba3d0768 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -744,13 +744,13 @@ unsafe impl kernel::transmute::FromBytes for ChangeRequest {} unsafe impl kernel::transmute::AsBytes for StateReply {} const IOC_MAGIC: u32 = 0xB1; -pub(crate) const IOC_STATE: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x10); -pub(crate) const IOC_UNLOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x11); -pub(crate) const IOC_LOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x12); -pub(crate) const IOC_CHANGE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x13); -pub(crate) const IOC_CREATE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x14); -pub(crate) const IOC_EXPORT: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x15); -pub(crate) const IOC_IMPORT: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x16); +pub(crate) const IOC_STATE: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x30); +pub(crate) const IOC_UNLOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x31); +pub(crate) const IOC_LOCK: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x32); +pub(crate) const IOC_CHANGE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x33); +pub(crate) const IOC_CREATE: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x34); +pub(crate) const IOC_EXPORT: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x35); +pub(crate) const IOC_IMPORT: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x36); /// The largest wrapped user bag `EXPORT`/`IMPORT` carry. pub(crate) const BAG_BLOB_MAX: usize = 1024; diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index 332a345f64d0a..13eae4a7c262a 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -334,7 +334,8 @@ struct sep_key_selftest { * DESIGN DRAFT, not a stable ABI: the per-user key bag whose secret is the * login password. Root only, on the re-arm node, never on /dev/sep-bio. The * password passes through once per call and the kernel keeps no copy. - * 0x0e and 0x0f are taken by the lockout re-arm. + * 0x0e/0x0f are the lockout re-arm and 0x10 SEP_BIO_REQUIRE_PASSWORD; these + * use 0x30-0x36. */ #define SEP_KEYBAG_SECRET_MAX 256 @@ -386,11 +387,11 @@ struct sep_keybag_change { __u8 new_secret[SEP_KEYBAG_SECRET_MAX]; }; -#define SEP_BIO_KEYBAG_STATE _IOWR(SEP_BIO_IOC_MAGIC, 0x10, struct sep_keybag_state) -#define SEP_BIO_KEYBAG_UNLOCK _IOW(SEP_BIO_IOC_MAGIC, 0x11, struct sep_keybag_unlock) -#define SEP_BIO_KEYBAG_LOCK _IOW(SEP_BIO_IOC_MAGIC, 0x12, struct sep_keybag_lock) -#define SEP_BIO_KEYBAG_CHANGE _IOW(SEP_BIO_IOC_MAGIC, 0x13, struct sep_keybag_change) -#define SEP_BIO_KEYBAG_CREATE _IOW(SEP_BIO_IOC_MAGIC, 0x14, struct sep_keybag_unlock) +#define SEP_BIO_KEYBAG_STATE _IOWR(SEP_BIO_IOC_MAGIC, 0x30, struct sep_keybag_state) +#define SEP_BIO_KEYBAG_UNLOCK _IOW(SEP_BIO_IOC_MAGIC, 0x31, struct sep_keybag_unlock) +#define SEP_BIO_KEYBAG_LOCK _IOW(SEP_BIO_IOC_MAGIC, 0x32, struct sep_keybag_lock) +#define SEP_BIO_KEYBAG_CHANGE _IOW(SEP_BIO_IOC_MAGIC, 0x33, struct sep_keybag_change) +#define SEP_BIO_KEYBAG_CREATE _IOW(SEP_BIO_IOC_MAGIC, 0x34, struct sep_keybag_unlock) /* * A user's wrapped bag, for userspace to keep across reboots (as macOS @@ -406,7 +407,7 @@ struct sep_keybag_blob { __u8 data[SEP_KEYBAG_BLOB_MAX]; }; -#define SEP_BIO_KEYBAG_EXPORT _IOWR(SEP_BIO_IOC_MAGIC, 0x15, struct sep_keybag_blob) -#define SEP_BIO_KEYBAG_IMPORT _IOW(SEP_BIO_IOC_MAGIC, 0x16, struct sep_keybag_blob) +#define SEP_BIO_KEYBAG_EXPORT _IOWR(SEP_BIO_IOC_MAGIC, 0x35, struct sep_keybag_blob) +#define SEP_BIO_KEYBAG_IMPORT _IOW(SEP_BIO_IOC_MAGIC, 0x36, struct sep_keybag_blob) #endif /* _UAPI_LINUX_APPLE_SEP_H */ From 6177e64e7a07d7db162685081317a937677b6cbe Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Tue, 10 Nov 2026 01:07:03 +1100 Subject: [PATCH 18/21] soc: apple: sep: carry wrapped user bags up to 4 KiB A fresh variant-5 user bag wraps to 1504 bytes, so EXPORT failed with E2BIG against the 1024-byte SEP_KEYBAG_BLOB_MAX. Raise the bound to 4096 and keep the blob on the heap in the EXPORT/IMPORT handlers instead of reading the whole struct onto the kernel stack. Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 48 ++++++++++++++++++++++++---------- include/uapi/linux/apple-sep.h | 2 +- 2 files changed, 35 insertions(+), 15 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index 7e994ba3d0768..fd532372577fb 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -752,13 +752,17 @@ pub(crate) const IOC_CREATE: u32 = kernel::ioctl::_IOW::(IOC_MAGI pub(crate) const IOC_EXPORT: u32 = kernel::ioctl::_IOWR::(IOC_MAGIC, 0x35); pub(crate) const IOC_IMPORT: u32 = kernel::ioctl::_IOW::(IOC_MAGIC, 0x36); -/// The largest wrapped user bag `EXPORT`/`IMPORT` carry. -pub(crate) const BAG_BLOB_MAX: usize = 1024; +/// The largest wrapped user bag `EXPORT`/`IMPORT` carry. A fresh variant-5 +/// user bag wraps to 1504 bytes; the bound leaves room for the class keys a +/// bag gains as it is used. +pub(crate) const BAG_BLOB_MAX: usize = 4096; /// The UAPI `struct sep_keybag_blob`: a user's wrapped bag. It holds no /// secret: the enclave wrapped it under a key entangled with its own UID /// key and the bag opens only with the user's password. +/// Only its layout is used: the ioctl handlers copy it to the heap. #[repr(C)] +#[allow(dead_code)] pub(crate) struct BagBlob { pub(crate) uid: u32, pub(crate) len: u32, @@ -770,6 +774,15 @@ unsafe impl kernel::transmute::FromBytes for BagBlob {} // SAFETY: as above. unsafe impl kernel::transmute::AsBytes for BagBlob {} +/// `struct sep_keybag_blob`'s uid and len, read from a copy of the whole +/// struct (the ioctl handlers keep the 4 KiB blob on the heap). +fn blob_uid(blob: &[u8]) -> u32 { + u32::from_ne_bytes([blob[0], blob[1], blob[2], blob[3]]) +} +fn blob_len(blob: &[u8]) -> usize { + u32::from_ne_bytes([blob[4], blob[5], blob[6], blob[7]]) as usize +} + pub(crate) fn is_ioctl(cmd: u32) -> bool { matches!( cmd, @@ -1114,29 +1127,36 @@ impl crate::SepData { } match cmd { IOC_EXPORT => { - let mut blob: BagBlob = UserSlice::new(user_ptr, core::mem::size_of::()).reader().read()?; - let user = crate::sks::DesignateUser::new(i32::try_from(blob.uid).map_err(|_| EINVAL)?).ok_or(EINVAL)?; - if blob.uid < MIN_UID { + // The blob is 4 KiB: keep it off the kernel stack. + let (reader, mut writer) = UserSlice::new(user_ptr, core::mem::size_of::()).reader_writer(); + let mut blob = KVec::::with_capacity(core::mem::size_of::(), GFP_KERNEL)?; + reader.read_all(&mut blob, GFP_KERNEL)?; + let uid = blob_uid(&blob); + let user = crate::sks::DesignateUser::new(i32::try_from(uid).map_err(|_| EINVAL)?).ok_or(EINVAL)?; + if uid < MIN_UID { return Err(EINVAL); } let wrapped = self.userbag_export(user)?; - blob.data.fill(0); - blob.data[..wrapped.len()].copy_from_slice(&wrapped); - blob.len = wrapped.len() as u32; - UserSlice::new(user_ptr, core::mem::size_of::()).writer().write(&blob)?; + blob.fill(0); + blob[..4].copy_from_slice(&uid.to_ne_bytes()); + blob[4..8].copy_from_slice(&(wrapped.len() as u32).to_ne_bytes()); + blob[8..8 + wrapped.len()].copy_from_slice(&wrapped); + writer.write_slice(&blob)?; crate::bio::ok() } IOC_IMPORT => { - let blob: BagBlob = UserSlice::new(user_ptr, core::mem::size_of::()).reader().read()?; - let len = blob.len as usize; - if blob.uid < MIN_UID || len == 0 || len > BAG_BLOB_MAX { + let mut blob = KVec::::with_capacity(core::mem::size_of::(), GFP_KERNEL)?; + UserSlice::new(user_ptr, core::mem::size_of::()).reader().read_all(&mut blob, GFP_KERNEL)?; + let uid = blob_uid(&blob); + let len = blob_len(&blob); + if uid < MIN_UID || len == 0 || len > BAG_BLOB_MAX { return Err(EINVAL); } - let user = crate::sks::DesignateUser::new(i32::try_from(blob.uid).map_err(|_| EINVAL)?).ok_or(EINVAL)?; + let user = crate::sks::DesignateUser::new(i32::try_from(uid).map_err(|_| EINVAL)?).ok_or(EINVAL)?; if !self.xart_writable() { return Err(EROFS); } - self.userbag_import(user, &blob.data[..len])?; + self.userbag_import(user, &blob[8..8 + len])?; crate::bio::ok() } IOC_CREATE | IOC_UNLOCK => { diff --git a/include/uapi/linux/apple-sep.h b/include/uapi/linux/apple-sep.h index bcd017ca3e943..97aab2a94dbfc 100644 --- a/include/uapi/linux/apple-sep.h +++ b/include/uapi/linux/apple-sep.h @@ -426,7 +426,7 @@ struct sep_keybag_change { * on this Mac. EXPORT copies the designated bag; IMPORT loads a saved one * and designates it again, locked until SEP_BIO_KEYBAG_UNLOCK. */ -#define SEP_KEYBAG_BLOB_MAX 1024 +#define SEP_KEYBAG_BLOB_MAX 4096 struct sep_keybag_blob { __u32 uid; From cb12edf363ff889746c5fca04ff312ba6e6c54ce Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:16:06 +1100 Subject: [PATCH 19/21] soc: apple: sep: userbag: lock at once on logout and suspend After the lock transition the key store holds the bag in its locking phase, class keys still usable, until the grace timer fires. The LOCK ioctl fired it only for a screen lock with no grace, so a logout or suspend lock left the bag's keys open: on the Neo a key sealed under a user bag still unsealed after SEP_BIO_KEYBAG_LOCK with the logout reason, and was refused once the timer was fired. Fire it for every lock but a screen lock with a grace period. Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index fd532372577fb..e7600125d868b 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -818,6 +818,14 @@ pub(crate) fn parse_lock(req: &LockRequest) -> Result<(u32, u32, u32)> { Ok((req.uid, req.reason, req.grace_s.min(MAX_GRACE_S))) } +/// Whether a lock request ends the bag's grace at once. After the +/// transition the key store keeps the bag "locking" (its class keys still +/// usable) until the grace timer fires, so only a screen lock with a grace +/// period may leave it there; suspend and logout lock at once. +pub(crate) fn lock_expires_now(reason: u32, grace_s: u32) -> bool { + reason != LOCK_REASON_SCREEN || grace_s == 0 +} + pub(crate) fn parse_change( req: &ChangeRequest, ) -> Result<(crate::sks::DesignateUser, &[u8], &[u8])> { @@ -1183,7 +1191,7 @@ impl crate::SepData { let (uid, reason, grace_s) = parse_lock(&request)?; let user = crate::sks::DesignateUser::new(uid as i32).ok_or(EINVAL)?; let screen = reason == LOCK_REASON_SCREEN; - self.userbag_lock(user, screen, screen && grace_s == 0)?; + self.userbag_lock(user, screen, lock_expires_now(reason, grace_s))?; crate::bio::ok() } IOC_CHANGE => { @@ -1628,6 +1636,15 @@ mod tests { assert!(parse_lock(&lock).is_err()); } + #[test] + fn only_a_screen_lock_with_grace_leaves_the_bag_locking() { + assert!(!lock_expires_now(LOCK_REASON_SCREEN, 300)); + assert!(lock_expires_now(LOCK_REASON_SCREEN, 0)); + assert!(lock_expires_now(LOCK_REASON_SUSPEND, 300)); + assert!(lock_expires_now(LOCK_REASON_LOGOUT, 0)); + assert!(lock_expires_now(LOCK_REASON_LOGOUT, 300)); + } + #[test] fn the_user_bag_handle_is_minus_the_uid() { let user = crate::sks::DesignateUser::new(501).expect("user"); From 5816d7dca9dae1a097900bfdfd588f7913f6c43e Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Tue, 10 Nov 2026 12:34:43 +1100 Subject: [PATCH 20/21] Revert "soc: apple: sep: userbag: lock at once on logout and suspend" Firing the grace timer does not end the key store's locking phase early: on the Neo the store answers the timer with no handle until its own deadline (about 40 s after the lock transition, for every lock reason), so the change only turned logout and suspend locks into EAGAIN. The bag reaches the locked phase by itself at that deadline, and a key sealed under it is refused from then on. Assisted-by: LLM --- drivers/soc/apple/userbag.rs | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index e7600125d868b..fd532372577fb 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -818,14 +818,6 @@ pub(crate) fn parse_lock(req: &LockRequest) -> Result<(u32, u32, u32)> { Ok((req.uid, req.reason, req.grace_s.min(MAX_GRACE_S))) } -/// Whether a lock request ends the bag's grace at once. After the -/// transition the key store keeps the bag "locking" (its class keys still -/// usable) until the grace timer fires, so only a screen lock with a grace -/// period may leave it there; suspend and logout lock at once. -pub(crate) fn lock_expires_now(reason: u32, grace_s: u32) -> bool { - reason != LOCK_REASON_SCREEN || grace_s == 0 -} - pub(crate) fn parse_change( req: &ChangeRequest, ) -> Result<(crate::sks::DesignateUser, &[u8], &[u8])> { @@ -1191,7 +1183,7 @@ impl crate::SepData { let (uid, reason, grace_s) = parse_lock(&request)?; let user = crate::sks::DesignateUser::new(uid as i32).ok_or(EINVAL)?; let screen = reason == LOCK_REASON_SCREEN; - self.userbag_lock(user, screen, lock_expires_now(reason, grace_s))?; + self.userbag_lock(user, screen, screen && grace_s == 0)?; crate::bio::ok() } IOC_CHANGE => { @@ -1636,15 +1628,6 @@ mod tests { assert!(parse_lock(&lock).is_err()); } - #[test] - fn only_a_screen_lock_with_grace_leaves_the_bag_locking() { - assert!(!lock_expires_now(LOCK_REASON_SCREEN, 300)); - assert!(lock_expires_now(LOCK_REASON_SCREEN, 0)); - assert!(lock_expires_now(LOCK_REASON_SUSPEND, 300)); - assert!(lock_expires_now(LOCK_REASON_LOGOUT, 0)); - assert!(lock_expires_now(LOCK_REASON_LOGOUT, 300)); - } - #[test] fn the_user_bag_handle_is_minus_the_uid() { let user = crate::sks::DesignateUser::new(501).expect("user"); From 34ac189ea86db39cc1f97fc705c924fb37248f3a Mon Sep 17 00:00:00 2001 From: Ryan Murray <81541646+RyanTheTide@users.noreply.github.com> Date: Wed, 11 Nov 2026 02:26:45 +1100 Subject: [PATCH 21/21] soc: apple: sep: make explicit userbag locks immediate --- drivers/soc/apple/userbag.rs | 87 ++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 9 deletions(-) diff --git a/drivers/soc/apple/userbag.rs b/drivers/soc/apple/userbag.rs index fd532372577fb..d06f48cf7260f 100644 --- a/drivers/soc/apple/userbag.rs +++ b/drivers/soc/apple/userbag.rs @@ -62,6 +62,27 @@ impl TransitionState { } } +/// State passed to the key store's explicit change-lock-state operation. +/// +/// Device-state transitions model screen/sleep transitions and may enter the +/// bag's configured grace period. This operation is the direct key-bag lock +/// used when the caller asked for no grace (logout, suspend, or a zero-grace +/// screen lock). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LockStateChange { + Query, + Locked, +} + +impl LockStateChange { + const fn wire(self) -> i32 { + match self { + LockStateChange::Query => -1, + LockStateChange::Locked => 1, + } + } +} + #[derive(Debug, PartialEq, Eq)] struct Transition<'a> { user_handle: i32, @@ -335,7 +356,10 @@ fn decode_change_secret_reply(body: &[u8]) -> bool { body == [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] } -fn encode_lock_query(user_handle: i32) -> Result { +fn encode_lock_state_change( + user_handle: i32, + state: LockStateChange, +) -> Result { if user_handle >= 0 { return Err(EINVAL); } @@ -343,12 +367,20 @@ fn encode_lock_query(user_handle: i32) -> Result { body.put_u32(LOCK_STATE_VARIANT)?; body.put_u64(CLIENT)?; body.put_i32(user_handle)?; - body.put_i32(-1)?; + body.put_i32(state.wire())?; body.put_blob(&[])?; body.put_u64(0)?; Ok(body) } +fn encode_lock_query(user_handle: i32) -> Result { + encode_lock_state_change(user_handle, LockStateChange::Query) +} + +fn encode_explicit_lock(user_handle: i32) -> Result { + encode_lock_state_change(user_handle, LockStateChange::Locked) +} + fn decode_lock_query(body: &[u8]) -> Option { let mut c = Cursor::new(body); if c.u32()? != LOCK_STATE_VARIANT || c.u64()? != CLIENT { @@ -818,6 +850,15 @@ pub(crate) fn parse_lock(req: &LockRequest) -> Result<(u32, u32, u32)> { Ok((req.uid, req.reason, req.grace_s.min(MAX_GRACE_S))) } +/// Whether this UAPI request is an explicit, no-grace key-bag lock. +/// +/// Only a screen lock may retain keys for a requested grace period. Apple +/// documents sleep and logout as immediate/password-required boundaries, and +/// the UAPI promises the same for suspend and logout. +pub(crate) fn lock_is_immediate(reason: u32, grace_s: u32) -> bool { + reason != LOCK_REASON_SCREEN || grace_s == 0 +} + pub(crate) fn parse_change( req: &ChangeRequest, ) -> Result<(crate::sks::DesignateUser, &[u8], &[u8])> { @@ -921,14 +962,25 @@ impl crate::SepData { &self, user: crate::sks::DesignateUser, screen_lock: bool, - expire_now: bool, + immediate: bool, ) -> Result<()> { - self.userbag_transition(user, TransitionState::Locked, u64::from(screen_lock), &[])?; - if expire_now { - if !self.userbag_timer_fire(user)? { - return Err(EAGAIN); - } + if immediate { + let handle = user.special_handle().value(); + return self + .sks_step(crate::sks::SKS_LOCK_STATE_NAME, |_healthy| { + self.userbag_request( + crate::sks::SKS_LOCK_STATE_NAME, + encode_explicit_lock(handle)?, + |seq, len| { + crate::sks::encode_sks_change_lock_state(seq, len).ok_or(EINVAL) + }, + ) + }) + .map(|_| ()) + .ok_or(EACCES); } + + self.userbag_transition(user, TransitionState::Locked, u64::from(screen_lock), &[])?; Ok(()) } @@ -1183,7 +1235,7 @@ impl crate::SepData { let (uid, reason, grace_s) = parse_lock(&request)?; let user = crate::sks::DesignateUser::new(uid as i32).ok_or(EINVAL)?; let screen = reason == LOCK_REASON_SCREEN; - self.userbag_lock(user, screen, screen && grace_s == 0)?; + self.userbag_lock(user, screen, lock_is_immediate(reason, grace_s))?; crate::bio::ok() } IOC_CHANGE => { @@ -1387,6 +1439,14 @@ mod tests { decode_lock_query_reply(&[1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), Some(1) ); + + let lock = encode_explicit_lock(USER_HANDLE).expect("explicit lock"); + let mut golden = [0u8; 32]; + golden[0..4].copy_from_slice(&LOCK_STATE_VARIANT.to_le_bytes()); + golden[4..12].copy_from_slice(&CLIENT_LE); + golden[12..16].copy_from_slice(&USER_HANDLE.to_le_bytes()); + golden[16..20].copy_from_slice(&LockStateChange::Locked.wire().to_le_bytes()); + assert_eq!(lock.as_slice(), &golden); } #[test] @@ -1628,6 +1688,15 @@ mod tests { assert!(parse_lock(&lock).is_err()); } + #[test] + fn only_a_screen_lock_with_grace_uses_the_grace_transition() { + assert!(!lock_is_immediate(LOCK_REASON_SCREEN, 300)); + assert!(lock_is_immediate(LOCK_REASON_SCREEN, 0)); + assert!(lock_is_immediate(LOCK_REASON_SUSPEND, 300)); + assert!(lock_is_immediate(LOCK_REASON_LOGOUT, 0)); + assert!(lock_is_immediate(LOCK_REASON_LOGOUT, 300)); + } + #[test] fn the_user_bag_handle_is_minus_the_uid() { let user = crate::sks::DesignateUser::new(501).expect("user");