Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 44 additions & 0 deletions ak-platform/src/generated/sys_auth_apple/sys_auth_apple.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,50 @@ pub struct RegisterDeviceResponse {
pub nonce_endpoint: ::prost::alloc::string::String,
#[prost(string, tag="7")]
pub device_token: ::prost::alloc::string::String,
#[prost(enumeration="register_device_response::BiometricPolicy", repeated, tag="8")]
pub biometric_policies: ::prost::alloc::vec::Vec<i32>,
}
/// Nested message and enum types in `RegisterDeviceResponse`.
pub mod register_device_response {
/// Biometric requirements for the user Secure Enclave key. Mirrors the members
/// of Apple's userSecureEnclaveKeyBiometricPolicy OptionSet: one requirement
/// (CURRENT_SET or ANY) plus any number of modifiers. Empty means no biometric
/// policy is applied.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)]
#[repr(i32)]
pub enum BiometricPolicy {
Unspecified = 0,
TouchIdOrWatchCurrentSet = 1,
TouchIdOrWatchAny = 2,
ReuseDuringUnlock = 3,
PasswordFallback = 4,
}
impl BiometricPolicy {
/// String value of the enum field names used in the ProtoBuf definition.
///
/// The values are not transformed in any way and thus are considered stable
/// (if the ProtoBuf definition does not change) and safe for programmatic use.
pub fn as_str_name(&self) -> &'static str {
match self {
Self::Unspecified => "UNSPECIFIED",
Self::TouchIdOrWatchCurrentSet => "TOUCH_ID_OR_WATCH_CURRENT_SET",
Self::TouchIdOrWatchAny => "TOUCH_ID_OR_WATCH_ANY",
Self::ReuseDuringUnlock => "REUSE_DURING_UNLOCK",
Self::PasswordFallback => "PASSWORD_FALLBACK",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
pub fn from_str_name(value: &str) -> ::core::option::Option<Self> {
match value {
"UNSPECIFIED" => Some(Self::Unspecified),
"TOUCH_ID_OR_WATCH_CURRENT_SET" => Some(Self::TouchIdOrWatchCurrentSet),
"TOUCH_ID_OR_WATCH_ANY" => Some(Self::TouchIdOrWatchAny),
"REUSE_DURING_UNLOCK" => Some(Self::ReuseDuringUnlock),
"PASSWORD_FALLBACK" => Some(Self::PasswordFallback),
_ => None,
}
}
}
}
include!("sys_auth_apple.tonic.rs");
include!("sys_auth_apple.serde.rs");
Expand Down
110 changes: 106 additions & 4 deletions ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
type Value = GeneratedField;

fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", FIELDS)
write!(formatter, "expected one of: {:?}", &FIELDS)

Check warning on line 73 in ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs

View workflow job for this annotation

GitHub Actions / Lint (rust)

redundant reference in `write!` argument
}

#[allow(unused_variables)]
Expand Down Expand Up @@ -174,6 +174,9 @@
if !self.device_token.is_empty() {
len += 1;
}
if !self.biometric_policies.is_empty() {
len += 1;
}
let mut struct_ser = serializer.serialize_struct("sys_auth_apple.RegisterDeviceResponse", len)?;
if !self.client_id.is_empty() {
struct_ser.serialize_field("clientId", &self.client_id)?;
Expand All @@ -196,6 +199,13 @@
if !self.device_token.is_empty() {
struct_ser.serialize_field("deviceToken", &self.device_token)?;
}
if !self.biometric_policies.is_empty() {
let v = self.biometric_policies.iter().cloned().map(|v| {
register_device_response::BiometricPolicy::try_from(v)
.map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", v)))
}).collect::<std::result::Result<Vec<_>, _>>()?;
struct_ser.serialize_field("biometricPolicies", &v)?;
}
struct_ser.end()
}
}
Expand All @@ -218,6 +228,8 @@
"nonceEndpoint",
"device_token",
"deviceToken",
"biometric_policies",
"biometricPolicies",
];

#[allow(clippy::enum_variant_names)]
Expand All @@ -229,6 +241,7 @@
Audience,
NonceEndpoint,
DeviceToken,
BiometricPolicies,
}
impl<'de> serde::Deserialize<'de> for GeneratedField {
fn deserialize<D>(deserializer: D) -> std::result::Result<GeneratedField, D::Error>
Expand All @@ -241,7 +254,7 @@
type Value = GeneratedField;

fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", FIELDS)
write!(formatter, "expected one of: {:?}", &FIELDS)

Check warning on line 257 in ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs

View workflow job for this annotation

GitHub Actions / Lint (rust)

redundant reference in `write!` argument
}

#[allow(unused_variables)]
Expand All @@ -257,6 +270,7 @@
"audience" => Ok(GeneratedField::Audience),
"nonceEndpoint" | "nonce_endpoint" => Ok(GeneratedField::NonceEndpoint),
"deviceToken" | "device_token" => Ok(GeneratedField::DeviceToken),
"biometricPolicies" | "biometric_policies" => Ok(GeneratedField::BiometricPolicies),
_ => Err(serde::de::Error::unknown_field(value, FIELDS)),
}
}
Expand All @@ -283,6 +297,7 @@
let mut audience__ = None;
let mut nonce_endpoint__ = None;
let mut device_token__ = None;
let mut biometric_policies__ = None;
while let Some(k) = map_.next_key()? {
match k {
GeneratedField::ClientId => {
Expand Down Expand Up @@ -327,6 +342,12 @@
}
device_token__ = Some(map_.next_value()?);
}
GeneratedField::BiometricPolicies => {
if biometric_policies__.is_some() {
return Err(serde::de::Error::duplicate_field("biometricPolicies"));
}
biometric_policies__ = Some(map_.next_value::<Vec<register_device_response::BiometricPolicy>>()?.into_iter().map(|x| x as i32).collect());
}
}
}
Ok(RegisterDeviceResponse {
Expand All @@ -337,12 +358,93 @@
audience: audience__.unwrap_or_default(),
nonce_endpoint: nonce_endpoint__.unwrap_or_default(),
device_token: device_token__.unwrap_or_default(),
biometric_policies: biometric_policies__.unwrap_or_default(),
})
}
}
deserializer.deserialize_struct("sys_auth_apple.RegisterDeviceResponse", FIELDS, GeneratedVisitor)
}
}
impl serde::Serialize for register_device_response::BiometricPolicy {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let variant = match self {
Self::Unspecified => "UNSPECIFIED",
Self::TouchIdOrWatchCurrentSet => "TOUCH_ID_OR_WATCH_CURRENT_SET",
Self::TouchIdOrWatchAny => "TOUCH_ID_OR_WATCH_ANY",
Self::ReuseDuringUnlock => "REUSE_DURING_UNLOCK",
Self::PasswordFallback => "PASSWORD_FALLBACK",
};
serializer.serialize_str(variant)
}
}
impl<'de> serde::Deserialize<'de> for register_device_response::BiometricPolicy {
#[allow(deprecated)]
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
const FIELDS: &[&str] = &[
"UNSPECIFIED",
"TOUCH_ID_OR_WATCH_CURRENT_SET",
"TOUCH_ID_OR_WATCH_ANY",
"REUSE_DURING_UNLOCK",
"PASSWORD_FALLBACK",
];

struct GeneratedVisitor;

impl<'de> serde::de::Visitor<'de> for GeneratedVisitor {
type Value = register_device_response::BiometricPolicy;

fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", &FIELDS)

Check warning on line 404 in ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs

View workflow job for this annotation

GitHub Actions / Lint (rust)

redundant reference in `write!` argument
}

fn visit_i64<E>(self, v: i64) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
i32::try_from(v)
.ok()
.and_then(|x| x.try_into().ok())
.ok_or_else(|| {
serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self)
})
}

fn visit_u64<E>(self, v: u64) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
i32::try_from(v)
.ok()
.and_then(|x| x.try_into().ok())
.ok_or_else(|| {
serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self)
})
}

fn visit_str<E>(self, value: &str) -> std::result::Result<Self::Value, E>
where
E: serde::de::Error,
{
match value {
"UNSPECIFIED" => Ok(register_device_response::BiometricPolicy::Unspecified),
"TOUCH_ID_OR_WATCH_CURRENT_SET" => Ok(register_device_response::BiometricPolicy::TouchIdOrWatchCurrentSet),
"TOUCH_ID_OR_WATCH_ANY" => Ok(register_device_response::BiometricPolicy::TouchIdOrWatchAny),
"REUSE_DURING_UNLOCK" => Ok(register_device_response::BiometricPolicy::ReuseDuringUnlock),
"PASSWORD_FALLBACK" => Ok(register_device_response::BiometricPolicy::PasswordFallback),
_ => Err(serde::de::Error::unknown_variant(value, FIELDS)),
}
}
}
deserializer.deserialize_any(GeneratedVisitor)
}
}
impl serde::Serialize for RegisterUserRequest {
#[allow(deprecated)]
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
Expand Down Expand Up @@ -405,7 +507,7 @@
type Value = GeneratedField;

fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", FIELDS)
write!(formatter, "expected one of: {:?}", &FIELDS)

Check warning on line 510 in ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs

View workflow job for this annotation

GitHub Actions / Lint (rust)

redundant reference in `write!` argument
}

#[allow(unused_variables)]
Expand Down Expand Up @@ -514,7 +616,7 @@
type Value = GeneratedField;

fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "expected one of: {:?}", FIELDS)
write!(formatter, "expected one of: {:?}", &FIELDS)

Check warning on line 619 in ak-platform/src/generated/sys_auth_apple/sys_auth_apple.serde.rs

View workflow job for this annotation

GitHub Actions / Lint (rust)

redundant reference in `write!` argument
}

#[allow(unused_variables)]
Expand Down
4 changes: 4 additions & 0 deletions ak-sysd/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ signal-hook = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.186"

[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-foundation-sys = "0.8"

[target.'cfg(windows)'.dependencies]
windows-service = "0.8"
winreg = { version = "0.56", features = ["serialization-serde"] }
Expand Down
87 changes: 77 additions & 10 deletions ak-sysd/src/cfg/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,17 +212,41 @@ impl DomainManager {
refresh_interval
}

/// First enabled domain — mirrors Go's `dom[0]` shortcut for
/// single-tenant components (ping, auth, directory, device). Do not
/// First enabled domain that has a token — mirrors Go's `dom[0]` shortcut
/// for single-tenant components (ping, auth, directory, device). Do not
/// invent smarter "current domain" selection here.
///
/// An MDM-managed domain wins when there is one: it carries the connector
/// and device group the organisation assigned, whereas a hand-run
/// `domains join` may point somewhere else entirely. Policy beats ad hoc.
///
/// The token check matters because `load_managed` adds an MDM-managed
/// domain alongside any user-enrolled ones. If its enrollment produced no
/// token, it is still `enabled`, and returning it means every request goes
/// out as `Bearer+agent ` and comes back 403 "Authentication credentials
/// were not provided" — while a perfectly good domain sits further down the
/// list.
///
/// Note this still only ever returns one domain, while the storage layer
/// happily holds several. Every auth path (`ping`, `auth/*`, `session`,
/// `directory`) calls this and silently ignores the rest; only `device`
/// check-in and `healthcheck_all` fan out. Making multi-domain genuinely
/// work means binding a domain to each of those call sites.
pub async fn active(&self) -> Result<Arc<LoadedDomain>> {
self.domains
.read()
.await
let domains = self.domains.read().await;
let usable = |d: &&Arc<LoadedDomain>| d.cfg.enabled && !d.cfg.token.is_empty();
let selected = domains
.iter()
.find(|d| d.cfg.enabled)
.find(|d| usable(d) && d.cfg.managed)
.or_else(|| domains.iter().find(usable))
.cloned()
.ok_or_else(|| eyre!("no enabled domain configured"))
.ok_or_else(|| eyre!("no enabled domain with a token configured"))?;
tracing::debug!(
domain = %selected.cfg.domain,
managed = selected.cfg.managed,
"selected active domain"
);
Ok(selected)
}

pub async fn save_domain(&self, cfg: DomainConfig) -> Result<()> {
Expand All @@ -238,7 +262,36 @@ impl DomainManager {
)
.await
{
Ok(_) => on_disk.fallback_token = String::new(),
// A write that cannot be read back is no use to us, so confirm the
// value is retrievable before dropping the on-disk copy. On macOS
// the write succeeds from a launchd daemon but the read fails with
// errSecInteractionNotAllowed, because retrieving the item wants an
// ACL prompt and there is no UI session to show one. Clearing the
// fallback on the strength of the write alone leaves the token
// somewhere sysd can never reach, and every subsequent request goes
// out as `Bearer+agent ` and comes back 403.
Ok(_) => match ak_platform_keyring::store()
.get(
&keyring_service(),
&cfg.domain,
ak_platform_keyring::Accessibility::Always,
)
.await
{
Ok(stored) if stored == cfg.token => on_disk.fallback_token = String::new(),
Ok(_) => {
on_disk.fallback_token = cfg.token.clone();
tracing::warn!(
"keyring returned a different token than was written, keeping file fallback"
);
}
Err(e) => {
on_disk.fallback_token = cfg.token.clone();
tracing::warn!(
"saved domain token to keyring but could not read it back ({e:?}), keeping file fallback"
);
}
},
Err(e) => {
on_disk.fallback_token = cfg.token.clone();
tracing::warn!(
Expand Down Expand Up @@ -370,11 +423,25 @@ impl DomainManager {
/// Loads (or re-enrolls, or removes) the MDM-managed domain. See
/// `cfg::managed` for the platform-specific config source.
pub async fn load_managed(&self) -> Result<()> {
const MANAGED_DOMAIN_NAME: &str = "ak-mdm-managed";

let Some(managed) = crate::cfg::managed::load_managed_config()? else {
// No managed config means the profile is gone, so a managed domain
// left behind is stale policy. `load_all` reloads every `*.json`
// regardless of whether the config that created it still exists, so
// without this the domain lingers forever — never refreshed, never
// revoked, and still competing to be the active one.
if self
.domains()
.await
.iter()
.any(|d| d.cfg.domain == MANAGED_DOMAIN_NAME)
{
tracing::info!("managed config absent, removing managed domain");
self.delete_domain(MANAGED_DOMAIN_NAME).await?;
}
return Ok(());
};

const MANAGED_DOMAIN_NAME: &str = "ak-mdm-managed";
let existing = self
.domains()
.await
Expand Down
Loading
Loading