From dee4da8021b28664baa9cc35e6dda92e86850099 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 29 Oct 2026 07:58:33 +0100 Subject: [PATCH 01/21] tpm-specific experiments --- src-tauri/Cargo.lock | 4 ++ src-tauri/Cargo.toml | 2 +- src-tauri/enterprise/tpm/Cargo.toml | 13 ++++ src-tauri/enterprise/tpm/src/lib.rs | 92 +++++++++++++++++++++++++ src-tauri/enterprise/tpm/src/linux.rs | 3 + src-tauri/enterprise/tpm/src/macos.rs | 3 + src-tauri/enterprise/tpm/src/windows.rs | 3 + 7 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src-tauri/enterprise/tpm/Cargo.toml create mode 100644 src-tauri/enterprise/tpm/src/lib.rs create mode 100644 src-tauri/enterprise/tpm/src/linux.rs create mode 100644 src-tauri/enterprise/tpm/src/macos.rs create mode 100644 src-tauri/enterprise/tpm/src/windows.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 90ea3517..d38d1957 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7871,6 +7871,10 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +[[package]] +name = "tpm" +version = "2.2.0" + [[package]] name = "tracing" version = "0.1.44" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 19d7b180..0ceccaca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "enterprise/fido2", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations"] +members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "enterprise/fido2", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations", "enterprise/tpm"] default-members = ["cli", "client-cli", "daemon", "."] [workspace.dependencies] diff --git a/src-tauri/enterprise/tpm/Cargo.toml b/src-tauri/enterprise/tpm/Cargo.toml new file mode 100644 index 00000000..d070ea11 --- /dev/null +++ b/src-tauri/enterprise/tpm/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "tpm" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] + +[lints] +workspace = true diff --git a/src-tauri/enterprise/tpm/src/lib.rs b/src-tauri/enterprise/tpm/src/lib.rs new file mode 100644 index 00000000..4a789649 --- /dev/null +++ b/src-tauri/enterprise/tpm/src/lib.rs @@ -0,0 +1,92 @@ +#[cfg(target_os = "linux")] +mod linux; +#[cfg(windows)] +mod windows; +#[cfg(target_os = "macos")] +mod macos; + +pub enum Algorithm { + Test, +} + +pub struct TpmCapabilities { + pub algorithms: Vec, + pub ecc_curves: Vec, + + pub fixed_properties: Vec, + pub variable_properties: Vec, + pub commands: Vec, + pub pcr_banks: Vec, + + pub profiles: Vec, +} + +/// An algorithm reported by TPM2_GetCapability. +pub struct AlgorithmCapability { + /// TPM_ALG_ID, preserved even if unknown to this library. + pub id: AlgorithmId, + /// Raw TPMA_ALGORITHM flags describing the algorithm's capabilities. + pub flags: u32, +} + +pub struct AlgorithmId(pub u16); + +impl AlgorithmId { + pub fn name(&self) -> Option<&'static str> { + match self.0 { + 0x0001 => Some("RSA"), + 0x000B => Some("SHA-256"), + 0x0018 => Some("ECDSA"), + 0x0023 => Some("ECC"), + // TODO + // ... + _ => None, + } + } +} + +pub struct EccCurveId(pub u16); +impl EccCurveId { + pub fn name(&self) -> Option<&'static str> { + match self.0 { + 0x0001 => Some("RSA"), + 0x000B => Some("SHA-256"), + 0x0018 => Some("ECDSA"), + 0x0023 => Some("ECC"), + // TODO + // ... + _ => None, + } + } +} +pub struct TpmConfig { + +} + +pub struct StoredDeviceKeys { + +} + +pub fn probe() { + #[cfg(target_os = "linux")] + linux::probe(); + + #[cfg(windows)] + windows::probe(); + + #[cfg(target_os = "macos")] + macos::probe(); +} + + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_works() { + let caps = TpmCapabilities { + algorithms: vec![Algorithm::Test], + }; + } +} diff --git a/src-tauri/enterprise/tpm/src/linux.rs b/src-tauri/enterprise/tpm/src/linux.rs new file mode 100644 index 00000000..27a3449d --- /dev/null +++ b/src-tauri/enterprise/tpm/src/linux.rs @@ -0,0 +1,3 @@ +pub(crate) fn probe() { + unimplemented!(); +} diff --git a/src-tauri/enterprise/tpm/src/macos.rs b/src-tauri/enterprise/tpm/src/macos.rs new file mode 100644 index 00000000..27a3449d --- /dev/null +++ b/src-tauri/enterprise/tpm/src/macos.rs @@ -0,0 +1,3 @@ +pub(crate) fn probe() { + unimplemented!(); +} diff --git a/src-tauri/enterprise/tpm/src/windows.rs b/src-tauri/enterprise/tpm/src/windows.rs new file mode 100644 index 00000000..27a3449d --- /dev/null +++ b/src-tauri/enterprise/tpm/src/windows.rs @@ -0,0 +1,3 @@ +pub(crate) fn probe() { + unimplemented!(); +} From c6b9b6cc493211bedaac0d68785718023d53f695 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 29 Oct 2026 10:01:25 +0100 Subject: [PATCH 02/21] abstraction over both macos and windows/linux --- .../{tpm => device-identity}/Cargo.toml | 3 +- .../enterprise/device-identity/src/lib.rs | 217 ++++++++++++++++++ .../enterprise/device-identity/src/linux.rs | 5 + .../enterprise/device-identity/src/macos.rs | 5 + .../enterprise/device-identity/src/windows.rs | 5 + src-tauri/enterprise/tpm/src/lib.rs | 92 -------- src-tauri/enterprise/tpm/src/linux.rs | 3 - src-tauri/enterprise/tpm/src/macos.rs | 3 - src-tauri/enterprise/tpm/src/windows.rs | 3 - 9 files changed, 234 insertions(+), 102 deletions(-) rename src-tauri/enterprise/{tpm => device-identity}/Cargo.toml (80%) create mode 100644 src-tauri/enterprise/device-identity/src/lib.rs create mode 100644 src-tauri/enterprise/device-identity/src/linux.rs create mode 100644 src-tauri/enterprise/device-identity/src/macos.rs create mode 100644 src-tauri/enterprise/device-identity/src/windows.rs delete mode 100644 src-tauri/enterprise/tpm/src/lib.rs delete mode 100644 src-tauri/enterprise/tpm/src/linux.rs delete mode 100644 src-tauri/enterprise/tpm/src/macos.rs delete mode 100644 src-tauri/enterprise/tpm/src/windows.rs diff --git a/src-tauri/enterprise/tpm/Cargo.toml b/src-tauri/enterprise/device-identity/Cargo.toml similarity index 80% rename from src-tauri/enterprise/tpm/Cargo.toml rename to src-tauri/enterprise/device-identity/Cargo.toml index d070ea11..022e838c 100644 --- a/src-tauri/enterprise/tpm/Cargo.toml +++ b/src-tauri/enterprise/device-identity/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "tpm" +name = "device-identity" authors.workspace = true edition.workspace = true homepage.workspace = true @@ -8,6 +8,7 @@ rust-version.workspace = true version.workspace = true [dependencies] +thiserror.workspace = true [lints] workspace = true diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs new file mode 100644 index 00000000..e3b02f8f --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -0,0 +1,217 @@ +use thiserror::Error; + +#[cfg(target_os = "linux")] +#[path = "linux.rs"] +mod backend; +#[cfg(target_os = "macos")] +#[path = "macos.rs"] +mod backend; +#[cfg(windows)] +#[path = "windows.rs"] +mod backend; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendKind { + Tpm, + AppleAppAttest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttestationMethod { + TpmKeyAttestation, + AppleAppAttest, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Capabilities { + pub backend: BackendKind, + pub attestation_methods: Vec, + pub details: PlatformCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformCapabilities { + Tpm(TpmCapabilities), + Apple(AppleCapabilities), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppleCapabilities { + pub app_attest_supported: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TpmCapabilities { + pub algorithms: Vec, + pub ecc_curves: Vec, + pub fixed_properties: Vec, + pub variable_properties: Vec, + pub commands: Vec, + pub pcr_banks: Vec, + pub profiles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AlgorithmCapability { + /// raw TPM_ALG_ID preserves identifiers unknown to this library + pub id: AlgorithmId, + /// raw TPMA_ALGORITHM bitmask + pub flags: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AlgorithmId(pub u16); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct EccCurveId(pub u16); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TpmProperty { + pub id: u32, + pub value: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCapability { + /// raw TPMA_CC includes the command index and its attributes + pub attributes: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PcrBankCapability { + pub hash_algorithm: AlgorithmId, + /// TPMS_PCR_SELECTION bitmap, each set bit identifies an available PCR + pub selection: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProfileId { + EcdsaP256Sha256, + RsaSsa2048Sha256, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SupportStatus { + Supported, + Unsupported, + NotChecked, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProfileSupport { + pub profile: ProfileId, + pub support: SupportStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct IdentityRef { + /// local storage identifier, not a transient TPM handle + pub id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicIdentity { + pub public_key_der: Vec, + pub backend: BackendKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestationContext { + pub session_id: String, + pub nonce: Vec, + pub binding: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationEvidence { + Tpm(TpmEvidence), + AppleAppAttest(AppleEvidence), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TpmEvidence { + /// marshalled TPMT_PUBLIC, not SPKI DER + pub ek_public: Vec, + pub ek_certificate_chain_der: Vec>, + /// marshalled TPMT_PUBLIC, not SPKI DER + pub ak_public: Vec, + /// marshalled TPMT_PUBLIC, not SPKI DER + pub identity_public: Vec, + /// marshalled TPMS_ATTEST + pub attest: Vec, + pub signature: Signature, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppleEvidence { + pub attestation_object: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PossessionProof { + Signature(Signature), + AppleAssertion(AppleAssertion), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Signature { + pub algorithm: SignatureAlgorithm, + pub bytes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignatureAlgorithm { + RsaSsaSha256, + /// signatures use ASN.1 DER encoding, not the raw r || s format + EcdsaSha256, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppleAssertion { + pub assertion_object: Vec, +} + +#[derive(Error, Debug, PartialEq)] +pub enum DeviceIdentityError { + #[error("hardware identity backend is unavailable")] + Unavailable, + #[error("permission to access hardware identity was denied")] + PermissionDenied, + #[error("hardware identity operation is not supported")] + Unsupported, + #[error("hardware identity is no longer available")] + IdentityLost, + #[error("hardware identity challenge is invalid")] + InvalidChallenge, + #[error("hardware identity backend failed")] + BackendFailure, +} + +pub fn probe() -> Result { + backend::probe() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capabilities_preserve_unknown_algorithm_ids_and_flags() { + let capabilities = TpmCapabilities { + algorithms: vec![AlgorithmCapability { + id: AlgorithmId(0xffff), + flags: 0x8000_0000, + }], + ..Default::default() + }; + + assert_eq!(capabilities.algorithms[0].id, AlgorithmId(0xffff)); + assert_eq!(capabilities.algorithms[0].flags, 0x8000_0000); + assert!(capabilities.profiles.is_empty()); + } + + #[test] + fn unimplemented_backend_returns_unsupported() { + assert_eq!(probe(), Err(DeviceIdentityError::Unsupported)); + } +} diff --git a/src-tauri/enterprise/device-identity/src/linux.rs b/src-tauri/enterprise/device-identity/src/linux.rs new file mode 100644 index 00000000..b459e17f --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/linux.rs @@ -0,0 +1,5 @@ +use crate::{Capabilities, DeviceIdentityError}; + +pub(crate) fn probe() -> Result { + Err(DeviceIdentityError::Unsupported) +} diff --git a/src-tauri/enterprise/device-identity/src/macos.rs b/src-tauri/enterprise/device-identity/src/macos.rs new file mode 100644 index 00000000..c02386a5 --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/macos.rs @@ -0,0 +1,5 @@ +use crate::{Capabilities, HardwareIdentityError}; + +pub(crate) fn probe() -> Result { + Err(HardwareIdentityError::Unsupported) +} diff --git a/src-tauri/enterprise/device-identity/src/windows.rs b/src-tauri/enterprise/device-identity/src/windows.rs new file mode 100644 index 00000000..c02386a5 --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/windows.rs @@ -0,0 +1,5 @@ +use crate::{Capabilities, HardwareIdentityError}; + +pub(crate) fn probe() -> Result { + Err(HardwareIdentityError::Unsupported) +} diff --git a/src-tauri/enterprise/tpm/src/lib.rs b/src-tauri/enterprise/tpm/src/lib.rs deleted file mode 100644 index 4a789649..00000000 --- a/src-tauri/enterprise/tpm/src/lib.rs +++ /dev/null @@ -1,92 +0,0 @@ -#[cfg(target_os = "linux")] -mod linux; -#[cfg(windows)] -mod windows; -#[cfg(target_os = "macos")] -mod macos; - -pub enum Algorithm { - Test, -} - -pub struct TpmCapabilities { - pub algorithms: Vec, - pub ecc_curves: Vec, - - pub fixed_properties: Vec, - pub variable_properties: Vec, - pub commands: Vec, - pub pcr_banks: Vec, - - pub profiles: Vec, -} - -/// An algorithm reported by TPM2_GetCapability. -pub struct AlgorithmCapability { - /// TPM_ALG_ID, preserved even if unknown to this library. - pub id: AlgorithmId, - /// Raw TPMA_ALGORITHM flags describing the algorithm's capabilities. - pub flags: u32, -} - -pub struct AlgorithmId(pub u16); - -impl AlgorithmId { - pub fn name(&self) -> Option<&'static str> { - match self.0 { - 0x0001 => Some("RSA"), - 0x000B => Some("SHA-256"), - 0x0018 => Some("ECDSA"), - 0x0023 => Some("ECC"), - // TODO - // ... - _ => None, - } - } -} - -pub struct EccCurveId(pub u16); -impl EccCurveId { - pub fn name(&self) -> Option<&'static str> { - match self.0 { - 0x0001 => Some("RSA"), - 0x000B => Some("SHA-256"), - 0x0018 => Some("ECDSA"), - 0x0023 => Some("ECC"), - // TODO - // ... - _ => None, - } - } -} -pub struct TpmConfig { - -} - -pub struct StoredDeviceKeys { - -} - -pub fn probe() { - #[cfg(target_os = "linux")] - linux::probe(); - - #[cfg(windows)] - windows::probe(); - - #[cfg(target_os = "macos")] - macos::probe(); -} - - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let caps = TpmCapabilities { - algorithms: vec![Algorithm::Test], - }; - } -} diff --git a/src-tauri/enterprise/tpm/src/linux.rs b/src-tauri/enterprise/tpm/src/linux.rs deleted file mode 100644 index 27a3449d..00000000 --- a/src-tauri/enterprise/tpm/src/linux.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) fn probe() { - unimplemented!(); -} diff --git a/src-tauri/enterprise/tpm/src/macos.rs b/src-tauri/enterprise/tpm/src/macos.rs deleted file mode 100644 index 27a3449d..00000000 --- a/src-tauri/enterprise/tpm/src/macos.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) fn probe() { - unimplemented!(); -} diff --git a/src-tauri/enterprise/tpm/src/windows.rs b/src-tauri/enterprise/tpm/src/windows.rs deleted file mode 100644 index 27a3449d..00000000 --- a/src-tauri/enterprise/tpm/src/windows.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) fn probe() { - unimplemented!(); -} From 0be75900ae8549761f2f5bcc7deb5872c4180995 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 29 Oct 2026 10:02:09 +0100 Subject: [PATCH 03/21] . --- src-tauri/Cargo.lock | 11 +++++++---- src-tauri/Cargo.toml | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d38d1957..9c03f570 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1915,6 +1915,13 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "device-identity" +version = "2.2.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "digest" version = "0.10.7" @@ -7871,10 +7878,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" -[[package]] -name = "tpm" -version = "2.2.0" - [[package]] name = "tracing" version = "0.1.44" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0ceccaca..f59fd782 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "enterprise/fido2", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations", "enterprise/tpm"] +members = ["cli", "client-cli", "common", "client-proto", "core", "daemon", "enterprise/fido2", "enterprise/posture", "enterprise/provisioning", "enterprise/config-sync", "enterprise/service-locations", "enterprise/device-identity"] default-members = ["cli", "client-cli", "daemon", "."] [workspace.dependencies] From 02896077eaa731321d87bb0c3f4026a900890487 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 29 Oct 2026 11:57:29 +0100 Subject: [PATCH 04/21] attestation --- .../device-identity/src/backend/linux.rs | 43 +++ .../device-identity/src/backend/macos.rs | 43 +++ .../device-identity/src/backend/windows.rs | 43 +++ .../enterprise/device-identity/src/lib.rs | 314 +++++++++++++++++- .../enterprise/device-identity/src/linux.rs | 5 - .../enterprise/device-identity/src/macos.rs | 5 - .../enterprise/device-identity/src/windows.rs | 5 - 7 files changed, 426 insertions(+), 32 deletions(-) create mode 100644 src-tauri/enterprise/device-identity/src/backend/linux.rs create mode 100644 src-tauri/enterprise/device-identity/src/backend/macos.rs create mode 100644 src-tauri/enterprise/device-identity/src/backend/windows.rs delete mode 100644 src-tauri/enterprise/device-identity/src/linux.rs delete mode 100644 src-tauri/enterprise/device-identity/src/macos.rs delete mode 100644 src-tauri/enterprise/device-identity/src/windows.rs diff --git a/src-tauri/enterprise/device-identity/src/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs new file mode 100644 index 00000000..9c1570a1 --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -0,0 +1,43 @@ +use crate::{ + AttestationChallenge, AttestationContext, AttestationMethod, AttestationProgress, + AttestationSession, Capabilities, DeviceIdentityError, IdentityRef, IdentityRequirements, + PossessionChallenge, PossessionProof, PublicIdentity, +}; + +pub(crate) fn capabilities() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn create_identity( + _requirements: &IdentityRequirements, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn public_identity( + _identity: &IdentityRef, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn begin_attestation( + _identity: &IdentityRef, + _method: AttestationMethod, + _context: AttestationContext, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn continue_attestation( + _session: AttestationSession, + _challenge: AttestationChallenge, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn prove_possession( + _identity: &IdentityRef, + _challenge: &PossessionChallenge, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} diff --git a/src-tauri/enterprise/device-identity/src/backend/macos.rs b/src-tauri/enterprise/device-identity/src/backend/macos.rs new file mode 100644 index 00000000..9c1570a1 --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -0,0 +1,43 @@ +use crate::{ + AttestationChallenge, AttestationContext, AttestationMethod, AttestationProgress, + AttestationSession, Capabilities, DeviceIdentityError, IdentityRef, IdentityRequirements, + PossessionChallenge, PossessionProof, PublicIdentity, +}; + +pub(crate) fn capabilities() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn create_identity( + _requirements: &IdentityRequirements, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn public_identity( + _identity: &IdentityRef, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn begin_attestation( + _identity: &IdentityRef, + _method: AttestationMethod, + _context: AttestationContext, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn continue_attestation( + _session: AttestationSession, + _challenge: AttestationChallenge, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn prove_possession( + _identity: &IdentityRef, + _challenge: &PossessionChallenge, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} diff --git a/src-tauri/enterprise/device-identity/src/backend/windows.rs b/src-tauri/enterprise/device-identity/src/backend/windows.rs new file mode 100644 index 00000000..9c1570a1 --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -0,0 +1,43 @@ +use crate::{ + AttestationChallenge, AttestationContext, AttestationMethod, AttestationProgress, + AttestationSession, Capabilities, DeviceIdentityError, IdentityRef, IdentityRequirements, + PossessionChallenge, PossessionProof, PublicIdentity, +}; + +pub(crate) fn capabilities() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn create_identity( + _requirements: &IdentityRequirements, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn public_identity( + _identity: &IdentityRef, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn begin_attestation( + _identity: &IdentityRef, + _method: AttestationMethod, + _context: AttestationContext, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn continue_attestation( + _session: AttestationSession, + _challenge: AttestationChallenge, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn prove_possession( + _identity: &IdentityRef, + _challenge: &PossessionChallenge, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index e3b02f8f..15237aaa 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -1,194 +1,394 @@ use thiserror::Error; #[cfg(target_os = "linux")] -#[path = "linux.rs"] +#[path = "backend/linux.rs"] mod backend; #[cfg(target_os = "macos")] -#[path = "macos.rs"] +#[path = "backend/macos.rs"] mod backend; #[cfg(windows)] -#[path = "windows.rs"] +#[path = "backend/windows.rs"] mod backend; +/// Platform mechanism used to protect the device identity #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BackendKind { + /// TPM 2.0 accessed through the platform transport Tpm, + /// Apple App Attest using a Secure Enclave-bound key + /// MacOS 27+ only AppleAppAttest, } +/// Protocol used by Core to verify hardware-backed identity #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AttestationMethod { + /// Credential activation followed by identity key certification TpmKeyAttestation, + /// Key attestation issued through Apple services AppleAppAttest, } +/// Client-reported capabilities #[derive(Debug, Clone, PartialEq, Eq)] pub struct Capabilities { + /// Backend used on this platform pub backend: BackendKind, + /// Attestation protocols available from this backend pub attestation_methods: Vec, + /// Raw and interpreted platform-specific capabilities pub details: PlatformCapabilities, } +/// Backend-specific capability details without forcing a shared hardware model #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlatformCapabilities { + /// Capabilities queried from TPM 2.0 Tpm(TpmCapabilities), + /// Capabilities reported by Apple platform APIs Apple(AppleCapabilities), } +/// Runtime availability of Apple attestation services #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppleCapabilities { + /// Runtime App Attest support pub app_attest_supported: bool, } +/// TPM capability snapshot including raw identifiers and checked profiles #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TpmCapabilities { + /// Reported TPM algorithms and their raw attribute flags pub algorithms: Vec, + /// Elliptic curves reported as supported by the TPM pub ecc_curves: Vec, + /// Manufacturer, firmware, limits and other fixed TPM properties pub fixed_properties: Vec, + /// TPM state properties captured at query time pub variable_properties: Vec, + /// Supported commands and their raw attributes pub commands: Vec, + /// Hash algorithms and PCR allocation bitmaps pub pcr_banks: Vec, + /// Profile checks, including profiles not yet checked pub profiles: Vec, } +/// Algorithm and attributes reported by TPM2_GetCapability #[derive(Debug, Clone, PartialEq, Eq)] pub struct AlgorithmCapability { - /// raw TPM_ALG_ID preserves identifiers unknown to this library + /// Raw TPM_ALG_ID preserves identifiers unknown to this library pub id: AlgorithmId, - /// raw TPMA_ALGORITHM bitmask + /// Raw TPMA_ALGORITHM bitmask pub flags: u32, } +/// TPM algorithm identifier, including values unknown to this library #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct AlgorithmId(pub u16); +pub struct AlgorithmId( + /// Raw 16-bit identifier defined by the TPM specification + pub u16, +); +/// TPM elliptic curve identifier, including unknown values #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct EccCurveId(pub u16); +pub struct EccCurveId( + /// Raw 16-bit identifier defined by the TPM specification + pub u16, +); +/// Raw property reported by TPM2_GetCapability #[derive(Debug, Clone, PartialEq, Eq)] pub struct TpmProperty { + /// Raw TPM_PT property identifier pub id: u32, + /// Property value as returned by the TPM pub value: u32, } +/// Attributes of a command supported by the TPM #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandCapability { - /// raw TPMA_CC includes the command index and its attributes + /// Raw TPMA_CC includes the command index and its attributes pub attributes: u32, } +/// Available platform configuration registers for one hash algorithm #[derive(Debug, Clone, PartialEq, Eq)] pub struct PcrBankCapability { + /// Hash algorithm identifying this PCR bank pub hash_algorithm: AlgorithmId, /// TPMS_PCR_SELECTION bitmap, each set bit identifies an available PCR pub selection: Vec, } +/// Library-defined combination of key and signature parameters #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProfileId { + /// ECC NIST P-256 with ECDSA and SHA-256 EcdsaP256Sha256, + /// RSA-2048 with RSASSA-PKCS1-v1_5 and SHA-256 RsaSsa2048Sha256, } +/// Result of checking a profile, distinct from individual algorithm availability #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SupportStatus { + /// The profile parameter combination passed the support check Supported, + /// The profile parameter combination was rejected as unsupported Unsupported, + /// No support check has been performed for this profile NotChecked, } +/// Support assessment for one library-defined profile #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProfileSupport { + /// Parameter combination being assessed pub profile: ProfileId, + /// Whether support was confirmed, rejected or not checked pub support: SupportStatus, } +/// Reference to a locally managed identity #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct IdentityRef { - /// local storage identifier, not a transient TPM handle + /// Local storage identifier pub id: String, } +/// Required key profile and attestation protocol for a new identity +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IdentityRequirements { + /// Key and signature parameters required for the identity + pub profile: ProfileId, + /// Protocol the created identity must support + pub attestation_method: AttestationMethod, +} + +/// Public key and backend associated with a local identity #[derive(Debug, Clone, PartialEq, Eq)] pub struct PublicIdentity { + /// Public key encoded as X.509 SubjectPublicKeyInfo DER pub public_key_der: Vec, + /// Mechanism managing the corresponding private key pub backend: BackendKind, } +/// Core session challenge and application binding for an attestation #[derive(Debug, Clone, PartialEq, Eq)] pub struct AttestationContext { + /// Identifier of the attestation session established with Core pub session_id: String, + /// Fresh challenge supplied by Core pub nonce: Vec, + /// Application data to bind, such as the WireGuard public key pub binding: Vec, } +/// Local context carried between steps of an attestation exchange +#[derive(Debug)] +pub struct AttestationSession { + /// Local identity being attested + pub identity: IdentityRef, + /// Protocol selected for this exchange + pub method: AttestationMethod, + /// Original session challenge and application binding + pub context: AttestationContext, +} + +/// Next exchange step or evidence ready for verification by Core +pub enum AttestationProgress { + /// Core interaction is required before the exchange can continue + Continue { + /// Local context to pass to the next continuation + session: AttestationSession, + /// Intermediate message to send to Core + message: AttestationMessage, + }, + /// Evidence is ready, but still requires verification by Core + Complete(AttestationEvidence), +} + +/// Intermediate message sent to Core during TPM attestation +#[derive(Clone, PartialEq, Eq)] +pub enum AttestationMessage { + /// Public EK and AK data needed to prepare credential activation + TpmIdentity { + /// Endorsement key encoded as marshalled TPMT_PUBLIC + ek_public: Vec, + /// Available endorsement key certificates, each encoded as DER + ek_certificate_chain_der: Vec>, + /// Attestation key encoded as marshalled TPMT_PUBLIC + ak_public: Vec, + }, + /// Recovered secret proving access to the claimed EK and AK + TpmActivationResponse { + /// Secret recovered by ActivateCredential, sensitive until Core verifies it + secret: Vec, + }, +} + +/// Instruction from Core for the next TPM attestation step +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationChallenge { + /// Recover the Core secret using the EK and AK + TpmCredentialActivation { + /// Contents of the TPM2B_ID_OBJECT produced by MakeCredential + credential_blob: Vec, + /// Contents of TPM2B_ENCRYPTED_SECRET carrying the encrypted protection seed + encrypted_secret: Vec, + }, + /// Certify the identity key using the original attestation context + TpmCertification, +} + +/// Core challenge binding a possession proof to a session and application data +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PossessionChallenge { + /// Identifier of the proof session established with Core + pub session_id: String, + /// Fresh single-use challenge supplied by Core + pub nonce: Vec, + /// Application data the proof must also cover + pub binding: Vec, +} + +/// Backend-specific evidence that Core must verify before granting trust #[derive(Debug, Clone, PartialEq, Eq)] pub enum AttestationEvidence { + /// TPM certification data and public keys Tpm(TpmEvidence), + /// Apple-issued evidence for an App Attest key AppleAppAttest(AppleEvidence), } +/// TPM key certification evidence, paired with a separate credential activation exchange #[derive(Debug, Clone, PartialEq, Eq)] pub struct TpmEvidence { - /// marshalled TPMT_PUBLIC, not SPKI DER + /// Endorsement key encoded as marshalled TPMT_PUBLIC pub ek_public: Vec, + /// Available endorsement key certificates, each encoded as DER pub ek_certificate_chain_der: Vec>, - /// marshalled TPMT_PUBLIC, not SPKI DER + /// Attestation key encoded as marshalled TPMT_PUBLIC pub ak_public: Vec, - /// marshalled TPMT_PUBLIC, not SPKI DER + /// Certified identity key encoded as marshalled TPMT_PUBLIC pub identity_public: Vec, - /// marshalled TPMS_ATTEST + /// Marshalled TPMS_ATTEST produced by TPM2_Certify pub attest: Vec, + /// Attestation key signature over the marshalled attestation bytes pub signature: Signature, } +/// App Attest evidence to be validated against Apple trust anchors #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppleEvidence { + /// CBOR attestation object returned by App Attest pub attestation_object: Vec, } +/// Proof of key possession #[derive(Debug, Clone, PartialEq, Eq)] pub enum PossessionProof { + /// Challenge signature verified with the registered public key Signature(Signature), + /// App Attest assertion requiring signature and counter validation AppleAssertion(AppleAssertion), } +/// Signature bytes with an explicit algorithm and encoding contract #[derive(Debug, Clone, PartialEq, Eq)] pub struct Signature { + /// Scheme and hash determining how signature bytes are interpreted pub algorithm: SignatureAlgorithm, + /// Signature encoded according to the selected algorithm pub bytes: Vec, } +/// Signature scheme and hash with a defined byte encoding #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignatureAlgorithm { + /// RSASSA-PKCS1-v1_5 with SHA-256, encoded as raw RSA signature bytes RsaSsaSha256, - /// signatures use ASN.1 DER encoding, not the raw r || s format + /// ECDSA with SHA-256, encoded as ASN.1 DER rather than raw r || s EcdsaSha256, } +/// App Attest assertion for a challenge under a previously attested key #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppleAssertion { + /// CBOR assertion containing signature and authenticator data, including the counter pub assertion_object: Vec, } +/// Platform-independent failure of a device identity operation #[derive(Error, Debug, PartialEq)] pub enum DeviceIdentityError { + /// Required hardware or platform service is not available #[error("hardware identity backend is unavailable")] Unavailable, + /// The caller lacks permission to access the backend #[error("permission to access hardware identity was denied")] PermissionDenied, + /// The requested operation or parameters are not supported #[error("hardware identity operation is not supported")] Unsupported, + /// The referenced identity can no longer be used #[error("hardware identity is no longer available")] IdentityLost, + /// The received challenge is malformed or incompatible with the session #[error("hardware identity challenge is invalid")] InvalidChallenge, + /// Backend failure not represented by a more specific variant #[error("hardware identity backend failed")] BackendFailure, } -pub fn probe() -> Result { - backend::probe() +/// Returns client-reported backend capabilities. +pub fn capabilities() -> Result { + backend::capabilities() +} + +/// Creates an identity meeting the requirements without replacing an existing key. +pub fn create_identity( + requirements: &IdentityRequirements, +) -> Result { + backend::create_identity(requirements) +} + +/// Returns the identity's public key and backend without exposing private key material. +pub fn public_identity(identity: &IdentityRef) -> Result { + backend::public_identity(identity) +} + +/// Starts attestation bound to the Core session, nonce and application data. +/// Returns an intermediate exchange step or evidence ready for Core verification. +pub fn begin_attestation( + identity: &IdentityRef, + method: AttestationMethod, + context: AttestationContext, +) -> Result { + backend::begin_attestation(identity, method, context) +} + +/// Consumes a local session to process Core's next challenge. +/// Returns the next exchange step or evidence. +pub fn continue_attestation( + session: AttestationSession, + challenge: AttestationChallenge, +) -> Result { + backend::continue_attestation(session, challenge) +} + +/// Produces a proof bound to Core's challenge using the referenced identity. +/// Core must verify it against the previously attested key. +pub fn prove_possession( + identity: &IdentityRef, + challenge: &PossessionChallenge, +) -> Result { + backend::prove_possession(identity, challenge) } #[cfg(test)] @@ -212,6 +412,86 @@ mod tests { #[test] fn unimplemented_backend_returns_unsupported() { - assert_eq!(probe(), Err(DeviceIdentityError::Unsupported)); + assert_eq!(capabilities(), Err(DeviceIdentityError::Unsupported)); + } + + fn identity() -> IdentityRef { + IdentityRef { + id: "test-identity".into(), + } + } + + fn context() -> AttestationContext { + AttestationContext { + session_id: "test-session".into(), + nonce: vec![1; 32], + binding: vec![2; 32], + } + } + + #[test] + fn identity_operations_return_unsupported() { + let requirements = IdentityRequirements { + profile: ProfileId::EcdsaP256Sha256, + attestation_method: AttestationMethod::TpmKeyAttestation, + }; + + assert_eq!( + create_identity(&requirements), + Err(DeviceIdentityError::Unsupported) + ); + assert_eq!( + public_identity(&identity()), + Err(DeviceIdentityError::Unsupported) + ); + } + + #[test] + fn beginning_attestation_returns_unsupported_for_each_method() { + for method in [ + AttestationMethod::TpmKeyAttestation, + AttestationMethod::AppleAppAttest, + ] { + assert!(matches!( + begin_attestation(&identity(), method, context()), + Err(DeviceIdentityError::Unsupported) + )); + } + } + + #[test] + fn continuing_attestation_returns_unsupported_for_each_challenge() { + for challenge in [ + AttestationChallenge::TpmCredentialActivation { + credential_blob: vec![3; 32], + encrypted_secret: vec![4; 32], + }, + AttestationChallenge::TpmCertification, + ] { + let session = AttestationSession { + identity: identity(), + method: AttestationMethod::TpmKeyAttestation, + context: context(), + }; + + assert!(matches!( + continue_attestation(session, challenge), + Err(DeviceIdentityError::Unsupported) + )); + } + } + + #[test] + fn possession_proof_returns_unsupported() { + let challenge = PossessionChallenge { + session_id: "test-session".into(), + nonce: vec![1; 32], + binding: vec![2; 32], + }; + + assert_eq!( + prove_possession(&identity(), &challenge), + Err(DeviceIdentityError::Unsupported) + ); } } diff --git a/src-tauri/enterprise/device-identity/src/linux.rs b/src-tauri/enterprise/device-identity/src/linux.rs deleted file mode 100644 index b459e17f..00000000 --- a/src-tauri/enterprise/device-identity/src/linux.rs +++ /dev/null @@ -1,5 +0,0 @@ -use crate::{Capabilities, DeviceIdentityError}; - -pub(crate) fn probe() -> Result { - Err(DeviceIdentityError::Unsupported) -} diff --git a/src-tauri/enterprise/device-identity/src/macos.rs b/src-tauri/enterprise/device-identity/src/macos.rs deleted file mode 100644 index c02386a5..00000000 --- a/src-tauri/enterprise/device-identity/src/macos.rs +++ /dev/null @@ -1,5 +0,0 @@ -use crate::{Capabilities, HardwareIdentityError}; - -pub(crate) fn probe() -> Result { - Err(HardwareIdentityError::Unsupported) -} diff --git a/src-tauri/enterprise/device-identity/src/windows.rs b/src-tauri/enterprise/device-identity/src/windows.rs deleted file mode 100644 index c02386a5..00000000 --- a/src-tauri/enterprise/device-identity/src/windows.rs +++ /dev/null @@ -1,5 +0,0 @@ -use crate::{Capabilities, HardwareIdentityError}; - -pub(crate) fn probe() -> Result { - Err(HardwareIdentityError::Unsupported) -} From add437d06e7024fc409ad90ba8cf1d3c8477e331 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 29 Oct 2026 12:18:15 +0100 Subject: [PATCH 05/21] stateless attestation --- .../device-identity/src/backend/linux.rs | 26 +-- .../device-identity/src/backend/macos.rs | 26 +-- .../device-identity/src/backend/windows.rs | 26 +-- .../enterprise/device-identity/src/lib.rs | 159 ++++++++---------- 4 files changed, 118 insertions(+), 119 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs index 9c1570a1..7018ed6d 100644 --- a/src-tauri/enterprise/device-identity/src/backend/linux.rs +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -1,6 +1,6 @@ use crate::{ - AttestationChallenge, AttestationContext, AttestationMethod, AttestationProgress, - AttestationSession, Capabilities, DeviceIdentityError, IdentityRef, IdentityRequirements, + AttestationContext, AttestationEvidence, AttestationIdentity, AttestationMethod, Capabilities, + CredentialChallenge, DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, PublicIdentity, }; @@ -20,18 +20,24 @@ pub(crate) fn public_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn begin_attestation( +pub(crate) fn attestation_identity( _identity: &IdentityRef, - _method: AttestationMethod, - _context: AttestationContext, -) -> Result { +) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn continue_attestation( - _session: AttestationSession, - _challenge: AttestationChallenge, -) -> Result { +pub(crate) fn activate_credential( + _identity: &IdentityRef, + _challenge: &CredentialChallenge, +) -> Result, DeviceIdentityError> { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn attest_identity( + _identity: &IdentityRef, + _method: AttestationMethod, + _context: &AttestationContext, +) -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/backend/macos.rs b/src-tauri/enterprise/device-identity/src/backend/macos.rs index 9c1570a1..7018ed6d 100644 --- a/src-tauri/enterprise/device-identity/src/backend/macos.rs +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -1,6 +1,6 @@ use crate::{ - AttestationChallenge, AttestationContext, AttestationMethod, AttestationProgress, - AttestationSession, Capabilities, DeviceIdentityError, IdentityRef, IdentityRequirements, + AttestationContext, AttestationEvidence, AttestationIdentity, AttestationMethod, Capabilities, + CredentialChallenge, DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, PublicIdentity, }; @@ -20,18 +20,24 @@ pub(crate) fn public_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn begin_attestation( +pub(crate) fn attestation_identity( _identity: &IdentityRef, - _method: AttestationMethod, - _context: AttestationContext, -) -> Result { +) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn continue_attestation( - _session: AttestationSession, - _challenge: AttestationChallenge, -) -> Result { +pub(crate) fn activate_credential( + _identity: &IdentityRef, + _challenge: &CredentialChallenge, +) -> Result, DeviceIdentityError> { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn attest_identity( + _identity: &IdentityRef, + _method: AttestationMethod, + _context: &AttestationContext, +) -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/backend/windows.rs b/src-tauri/enterprise/device-identity/src/backend/windows.rs index 9c1570a1..7018ed6d 100644 --- a/src-tauri/enterprise/device-identity/src/backend/windows.rs +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -1,6 +1,6 @@ use crate::{ - AttestationChallenge, AttestationContext, AttestationMethod, AttestationProgress, - AttestationSession, Capabilities, DeviceIdentityError, IdentityRef, IdentityRequirements, + AttestationContext, AttestationEvidence, AttestationIdentity, AttestationMethod, Capabilities, + CredentialChallenge, DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, PublicIdentity, }; @@ -20,18 +20,24 @@ pub(crate) fn public_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn begin_attestation( +pub(crate) fn attestation_identity( _identity: &IdentityRef, - _method: AttestationMethod, - _context: AttestationContext, -) -> Result { +) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn continue_attestation( - _session: AttestationSession, - _challenge: AttestationChallenge, -) -> Result { +pub(crate) fn activate_credential( + _identity: &IdentityRef, + _challenge: &CredentialChallenge, +) -> Result, DeviceIdentityError> { + Err(DeviceIdentityError::Unsupported) +} + +pub(crate) fn attest_identity( + _identity: &IdentityRef, + _method: AttestationMethod, + _context: &AttestationContext, +) -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index 15237aaa..f1c423ec 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -188,61 +188,36 @@ pub struct AttestationContext { pub binding: Vec, } -/// Local context carried between steps of an attestation exchange -#[derive(Debug)] -pub struct AttestationSession { - /// Local identity being attested - pub identity: IdentityRef, - /// Protocol selected for this exchange - pub method: AttestationMethod, - /// Original session challenge and application binding - pub context: AttestationContext, -} - -/// Next exchange step or evidence ready for verification by Core -pub enum AttestationProgress { - /// Core interaction is required before the exchange can continue - Continue { - /// Local context to pass to the next continuation - session: AttestationSession, - /// Intermediate message to send to Core - message: AttestationMessage, - }, - /// Evidence is ready, but still requires verification by Core - Complete(AttestationEvidence), -} - -/// Intermediate message sent to Core during TPM attestation -#[derive(Clone, PartialEq, Eq)] -pub enum AttestationMessage { - /// Public EK and AK data needed to prepare credential activation - TpmIdentity { - /// Endorsement key encoded as marshalled TPMT_PUBLIC - ek_public: Vec, - /// Available endorsement key certificates, each encoded as DER - ek_certificate_chain_der: Vec>, - /// Attestation key encoded as marshalled TPMT_PUBLIC - ak_public: Vec, - }, - /// Recovered secret proving access to the claimed EK and AK - TpmActivationResponse { - /// Secret recovered by ActivateCredential, sensitive until Core verifies it - secret: Vec, +/// Public backend data supplied to Core before generating attestation challenges +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationIdentity { + /// Endorsement and attestation keys for credential activation + Tpm(TpmAttestationIdentity), + /// Identifier of the key generated by App Attest + AppleAppAttest { + /// App Attest key identifier stored locally by the client + key_id: String, }, } -/// Instruction from Core for the next TPM attestation step +/// Public TPM keys used by Core to prepare MakeCredential #[derive(Debug, Clone, PartialEq, Eq)] -pub enum AttestationChallenge { - /// Recover the Core secret using the EK and AK - TpmCredentialActivation { - /// Contents of the TPM2B_ID_OBJECT produced by MakeCredential - credential_blob: Vec, - /// Contents of TPM2B_ENCRYPTED_SECRET carrying the encrypted protection seed - encrypted_secret: Vec, - }, - /// Certify the identity key using the original attestation context - TpmCertification, +pub struct TpmAttestationIdentity { + /// Endorsement key encoded as marshalled TPMT_PUBLIC + pub ek_public: Vec, + /// Available endorsement key certificates, each encoded as DER + pub ek_certificate_chain_der: Vec>, + /// Attestation key encoded as marshalled TPMT_PUBLIC + pub ak_public: Vec, +} + +/// Credential activation challenge prepared by Core for a specific EK and AK +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CredentialChallenge { + /// Contents of the TPM2B_ID_OBJECT produced by MakeCredential + pub credential_blob: Vec, + /// Contents of TPM2B_ENCRYPTED_SECRET carrying the encrypted protection seed + pub encrypted_secret: Vec, } /// Core challenge binding a possession proof to a session and application data @@ -338,7 +313,7 @@ pub enum DeviceIdentityError { /// The referenced identity can no longer be used #[error("hardware identity is no longer available")] IdentityLost, - /// The received challenge is malformed or incompatible with the session + /// The received challenge is malformed or incompatible with the identity #[error("hardware identity challenge is invalid")] InvalidChallenge, /// Backend failure not represented by a more specific variant @@ -363,23 +338,30 @@ pub fn public_identity(identity: &IdentityRef) -> Result Result { - backend::begin_attestation(identity, method, context) +) -> Result { + backend::attestation_identity(identity) } -/// Consumes a local session to process Core's next challenge. -/// Returns the next exchange step or evidence. -pub fn continue_attestation( - session: AttestationSession, - challenge: AttestationChallenge, -) -> Result { - backend::continue_attestation(session, challenge) +/// Recovers Core's secret through TPM credential activation using the identity's EK and AK. +/// Returns Unsupported on backends without TPM credential activation. +pub fn activate_credential( + identity: &IdentityRef, + challenge: &CredentialChallenge, +) -> Result, DeviceIdentityError> { + backend::activate_credential(identity, challenge) +} + +/// Generates TPM Certify or Apple App Attest evidence bound to the supplied Core context. +/// The application submits the evidence and any activation response to Core for verification. +pub fn attest_identity( + identity: &IdentityRef, + method: AttestationMethod, + context: &AttestationContext, +) -> Result { + backend::attest_identity(identity, method, context) } /// Produces a proof bound to Core's challenge using the referenced identity. @@ -447,38 +429,37 @@ mod tests { } #[test] - fn beginning_attestation_returns_unsupported_for_each_method() { + fn attestation_identity_returns_unsupported() { + assert_eq!( + attestation_identity(&identity()), + Err(DeviceIdentityError::Unsupported) + ); + } + + #[test] + fn attestation_returns_unsupported_for_each_method() { for method in [ AttestationMethod::TpmKeyAttestation, AttestationMethod::AppleAppAttest, ] { - assert!(matches!( - begin_attestation(&identity(), method, context()), + assert_eq!( + attest_identity(&identity(), method, &context()), Err(DeviceIdentityError::Unsupported) - )); + ); } } #[test] - fn continuing_attestation_returns_unsupported_for_each_challenge() { - for challenge in [ - AttestationChallenge::TpmCredentialActivation { - credential_blob: vec![3; 32], - encrypted_secret: vec![4; 32], - }, - AttestationChallenge::TpmCertification, - ] { - let session = AttestationSession { - identity: identity(), - method: AttestationMethod::TpmKeyAttestation, - context: context(), - }; - - assert!(matches!( - continue_attestation(session, challenge), - Err(DeviceIdentityError::Unsupported) - )); - } + fn credential_activation_returns_unsupported() { + let challenge = CredentialChallenge { + credential_blob: vec![3; 32], + encrypted_secret: vec![4; 32], + }; + + assert_eq!( + activate_credential(&identity(), &challenge), + Err(DeviceIdentityError::Unsupported) + ); } #[test] From f860595bf4f0b84d8bccf56880c575340d237e7b Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Thu, 29 Oct 2026 12:34:04 +0100 Subject: [PATCH 06/21] attest_identity tages abstracted arguments, remove tpm-specific fn activate_credential --- .../device-identity/src/backend/linux.rs | 18 ++-- .../device-identity/src/backend/macos.rs | 18 ++-- .../device-identity/src/backend/windows.rs | 18 ++-- .../enterprise/device-identity/src/lib.rs | 88 ++++++++++--------- 4 files changed, 63 insertions(+), 79 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs index 7018ed6d..6756a593 100644 --- a/src-tauri/enterprise/device-identity/src/backend/linux.rs +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -1,7 +1,7 @@ use crate::{ - AttestationContext, AttestationEvidence, AttestationIdentity, AttestationMethod, Capabilities, - CredentialChallenge, DeviceIdentityError, IdentityRef, IdentityRequirements, - PossessionChallenge, PossessionProof, PublicIdentity, + AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, + DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, + PublicIdentity, }; pub(crate) fn capabilities() -> Result { @@ -26,18 +26,10 @@ pub(crate) fn attestation_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn activate_credential( - _identity: &IdentityRef, - _challenge: &CredentialChallenge, -) -> Result, DeviceIdentityError> { - Err(DeviceIdentityError::Unsupported) -} - pub(crate) fn attest_identity( _identity: &IdentityRef, - _method: AttestationMethod, - _context: &AttestationContext, -) -> Result { + _request: &AttestationRequest, +) -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/backend/macos.rs b/src-tauri/enterprise/device-identity/src/backend/macos.rs index 7018ed6d..6756a593 100644 --- a/src-tauri/enterprise/device-identity/src/backend/macos.rs +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -1,7 +1,7 @@ use crate::{ - AttestationContext, AttestationEvidence, AttestationIdentity, AttestationMethod, Capabilities, - CredentialChallenge, DeviceIdentityError, IdentityRef, IdentityRequirements, - PossessionChallenge, PossessionProof, PublicIdentity, + AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, + DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, + PublicIdentity, }; pub(crate) fn capabilities() -> Result { @@ -26,18 +26,10 @@ pub(crate) fn attestation_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn activate_credential( - _identity: &IdentityRef, - _challenge: &CredentialChallenge, -) -> Result, DeviceIdentityError> { - Err(DeviceIdentityError::Unsupported) -} - pub(crate) fn attest_identity( _identity: &IdentityRef, - _method: AttestationMethod, - _context: &AttestationContext, -) -> Result { + _request: &AttestationRequest, +) -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/backend/windows.rs b/src-tauri/enterprise/device-identity/src/backend/windows.rs index 7018ed6d..6756a593 100644 --- a/src-tauri/enterprise/device-identity/src/backend/windows.rs +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -1,7 +1,7 @@ use crate::{ - AttestationContext, AttestationEvidence, AttestationIdentity, AttestationMethod, Capabilities, - CredentialChallenge, DeviceIdentityError, IdentityRef, IdentityRequirements, - PossessionChallenge, PossessionProof, PublicIdentity, + AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, + DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, + PublicIdentity, }; pub(crate) fn capabilities() -> Result { @@ -26,18 +26,10 @@ pub(crate) fn attestation_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn activate_credential( - _identity: &IdentityRef, - _challenge: &CredentialChallenge, -) -> Result, DeviceIdentityError> { - Err(DeviceIdentityError::Unsupported) -} - pub(crate) fn attest_identity( _identity: &IdentityRef, - _method: AttestationMethod, - _context: &AttestationContext, -) -> Result { + _request: &AttestationRequest, +) -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index f1c423ec..7af07e63 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -231,16 +231,39 @@ pub struct PossessionChallenge { pub binding: Vec, } -/// Backend-specific evidence that Core must verify before granting trust +/// Backend-specific challenge supplied by Core for identity attestation #[derive(Debug, Clone, PartialEq, Eq)] -pub enum AttestationEvidence { - /// TPM certification data and public keys - Tpm(TpmEvidence), +pub enum AttestationChallenge { + /// Credential activation challenge bound to the TPM endorsement and attestation keys + Tpm(CredentialChallenge), + /// App Attest uses the nonce supplied in the attestation context + AppleAppAttest, +} + +/// Complete input for a stateless identity attestation operation +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AttestationRequest { + /// Core session, fresh nonce and application binding + pub context: AttestationContext, + /// Backend-specific input determining the attestation protocol + pub challenge: AttestationChallenge, +} + +/// Attestation result submitted to Core for verification +#[derive(Clone, PartialEq, Eq)] +pub enum AttestationResponse { + /// TPM credential activation result and identity key certification + Tpm { + /// Recovered Core secret, sensitive until the challenge is verified + activation_secret: Vec, + /// Identity key certification signed by the attestation key + evidence: TpmEvidence, + }, /// Apple-issued evidence for an App Attest key AppleAppAttest(AppleEvidence), } -/// TPM key certification evidence, paired with a separate credential activation exchange +/// TPM key certification evidence accompanying the credential activation result #[derive(Debug, Clone, PartialEq, Eq)] pub struct TpmEvidence { /// Endorsement key encoded as marshalled TPMT_PUBLIC @@ -345,23 +368,13 @@ pub fn attestation_identity( backend::attestation_identity(identity) } -/// Recovers Core's secret through TPM credential activation using the identity's EK and AK. -/// Returns Unsupported on backends without TPM credential activation. -pub fn activate_credential( - identity: &IdentityRef, - challenge: &CredentialChallenge, -) -> Result, DeviceIdentityError> { - backend::activate_credential(identity, challenge) -} - -/// Generates TPM Certify or Apple App Attest evidence bound to the supplied Core context. -/// The application submits the evidence and any activation response to Core for verification. +/// Processes the backend challenge and generates evidence bound to the supplied Core context. +/// TPM returns the activation secret and certification, while Apple returns App Attest evidence. pub fn attest_identity( identity: &IdentityRef, - method: AttestationMethod, - context: &AttestationContext, -) -> Result { - backend::attest_identity(identity, method, context) + request: &AttestationRequest, +) -> Result { + backend::attest_identity(identity, request) } /// Produces a proof bound to Core's challenge using the referenced identity. @@ -437,31 +450,26 @@ mod tests { } #[test] - fn attestation_returns_unsupported_for_each_method() { - for method in [ - AttestationMethod::TpmKeyAttestation, - AttestationMethod::AppleAppAttest, + fn attestation_returns_unsupported_for_each_challenge() { + for challenge in [ + AttestationChallenge::Tpm(CredentialChallenge { + credential_blob: vec![3; 32], + encrypted_secret: vec![4; 32], + }), + AttestationChallenge::AppleAppAttest, ] { - assert_eq!( - attest_identity(&identity(), method, &context()), + let request = AttestationRequest { + context: context(), + challenge, + }; + + assert!(matches!( + attest_identity(&identity(), &request), Err(DeviceIdentityError::Unsupported) - ); + )); } } - #[test] - fn credential_activation_returns_unsupported() { - let challenge = CredentialChallenge { - credential_blob: vec![3; 32], - encrypted_secret: vec![4; 32], - }; - - assert_eq!( - activate_credential(&identity(), &challenge), - Err(DeviceIdentityError::Unsupported) - ); - } - #[test] fn possession_proof_returns_unsupported() { let challenge = PossessionChallenge { From 75a2c44ecf83c905ff27f9abb95676e58977678e Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 08:55:29 +0100 Subject: [PATCH 07/21] remove unnecessary public_identity function --- src-tauri/enterprise/device-identity/src/lib.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index 7af07e63..a094f502 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -168,15 +168,6 @@ pub struct IdentityRequirements { pub attestation_method: AttestationMethod, } -/// Public key and backend associated with a local identity -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PublicIdentity { - /// Public key encoded as X.509 SubjectPublicKeyInfo DER - pub public_key_der: Vec, - /// Mechanism managing the corresponding private key - pub backend: BackendKind, -} - /// Core session challenge and application binding for an attestation #[derive(Debug, Clone, PartialEq, Eq)] pub struct AttestationContext { @@ -356,11 +347,6 @@ pub fn create_identity( backend::create_identity(requirements) } -/// Returns the identity's public key and backend without exposing private key material. -pub fn public_identity(identity: &IdentityRef) -> Result { - backend::public_identity(identity) -} - /// Returns the identity's public attestation data for enrollment with Core. pub fn attestation_identity( identity: &IdentityRef, From 45a5859b342ce2b8aa83d490dd73bf76bd39722f Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 09:06:42 +0100 Subject: [PATCH 08/21] create_identity takes no arguments, generates predefined profile --- .../enterprise/device-identity/src/backend/linux.rs | 13 ++----------- .../enterprise/device-identity/src/backend/macos.rs | 13 ++----------- .../device-identity/src/backend/windows.rs | 13 ++----------- src-tauri/enterprise/device-identity/src/lib.rs | 9 ++++----- 4 files changed, 10 insertions(+), 38 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs index 6756a593..388b8f98 100644 --- a/src-tauri/enterprise/device-identity/src/backend/linux.rs +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -1,22 +1,13 @@ use crate::{ AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, - DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, - PublicIdentity, + DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, }; pub(crate) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn create_identity( - _requirements: &IdentityRequirements, -) -> Result { - Err(DeviceIdentityError::Unsupported) -} - -pub(crate) fn public_identity( - _identity: &IdentityRef, -) -> Result { +pub(crate) fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/backend/macos.rs b/src-tauri/enterprise/device-identity/src/backend/macos.rs index 6756a593..388b8f98 100644 --- a/src-tauri/enterprise/device-identity/src/backend/macos.rs +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -1,22 +1,13 @@ use crate::{ AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, - DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, - PublicIdentity, + DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, }; pub(crate) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn create_identity( - _requirements: &IdentityRequirements, -) -> Result { - Err(DeviceIdentityError::Unsupported) -} - -pub(crate) fn public_identity( - _identity: &IdentityRef, -) -> Result { +pub(crate) fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/backend/windows.rs b/src-tauri/enterprise/device-identity/src/backend/windows.rs index 6756a593..388b8f98 100644 --- a/src-tauri/enterprise/device-identity/src/backend/windows.rs +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -1,22 +1,13 @@ use crate::{ AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, - DeviceIdentityError, IdentityRef, IdentityRequirements, PossessionChallenge, PossessionProof, - PublicIdentity, + DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, }; pub(crate) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn create_identity( - _requirements: &IdentityRequirements, -) -> Result { - Err(DeviceIdentityError::Unsupported) -} - -pub(crate) fn public_identity( - _identity: &IdentityRef, -) -> Result { +pub(crate) fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index a094f502..c33a16b2 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -124,9 +124,10 @@ pub struct PcrBankCapability { } /// Library-defined combination of key and signature parameters -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum ProfileId { /// ECC NIST P-256 with ECDSA and SHA-256 + #[default] EcdsaP256Sha256, /// RSA-2048 with RSASSA-PKCS1-v1_5 and SHA-256 RsaSsa2048Sha256, @@ -341,10 +342,8 @@ pub fn capabilities() -> Result { } /// Creates an identity meeting the requirements without replacing an existing key. -pub fn create_identity( - requirements: &IdentityRequirements, -) -> Result { - backend::create_identity(requirements) +pub fn create_identity() -> Result { + backend::create_identity() } /// Returns the identity's public attestation data for enrollment with Core. From ef6eaa9db3810c53c4ee80f7126d22f99522a230 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 09:13:04 +0100 Subject: [PATCH 09/21] remove unused IdentityRequirements struct --- .../enterprise/device-identity/src/lib.rs | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index c33a16b2..3f3af6d5 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -160,15 +160,6 @@ pub struct IdentityRef { pub id: String, } -/// Required key profile and attestation protocol for a new identity -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IdentityRequirements { - /// Key and signature parameters required for the identity - pub profile: ProfileId, - /// Protocol the created identity must support - pub attestation_method: AttestationMethod, -} - /// Core session challenge and application binding for an attestation #[derive(Debug, Clone, PartialEq, Eq)] pub struct AttestationContext { @@ -341,7 +332,7 @@ pub fn capabilities() -> Result { backend::capabilities() } -/// Creates an identity meeting the requirements without replacing an existing key. +/// Creates an identity using the backend's predefined profile. pub fn create_identity() -> Result { backend::create_identity() } @@ -410,20 +401,8 @@ mod tests { } #[test] - fn identity_operations_return_unsupported() { - let requirements = IdentityRequirements { - profile: ProfileId::EcdsaP256Sha256, - attestation_method: AttestationMethod::TpmKeyAttestation, - }; - - assert_eq!( - create_identity(&requirements), - Err(DeviceIdentityError::Unsupported) - ); - assert_eq!( - public_identity(&identity()), - Err(DeviceIdentityError::Unsupported) - ); + fn creating_identity_returns_unsupported() { + assert_eq!(create_identity(), Err(DeviceIdentityError::Unsupported)); } #[test] From 833fa75c48bd6eb41bc78ab8adb4d84ff50888da Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 09:39:03 +0100 Subject: [PATCH 10/21] document create_identity contract --- src-tauri/enterprise/device-identity/src/lib.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index 3f3af6d5..83e9ac12 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -332,7 +332,15 @@ pub fn capabilities() -> Result { backend::capabilities() } -/// Creates an identity using the backend's predefined profile. +/// Creates a hardware-backed identity for attestation and possession proofs. +/// TPM uses ECDSA NIST P-256 with SHA-256. Apple uses an App Attest key. +/// +/// TPM backends must generate a non-restricted signing key with fixedTPM, +/// fixedParent and sensitiveDataOrigin set, plus an AK for key certification. +/// Apple backends must generate an App Attest key bound to the Secure Enclave. +/// +/// Returns a local reference for later attestation and possession proofs. +/// Returns Unsupported when the required profile or mechanism is unavailable. pub fn create_identity() -> Result { backend::create_identity() } From 98597ea1291fd56aeafaea7a3b7256e4b07c1e85 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 09:59:03 +0100 Subject: [PATCH 11/21] attestation and possesion proof work on opaque blobs --- .../enterprise/device-identity/src/lib.rs | 50 +++++-------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index 83e9ac12..6c4cb52f 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -160,17 +160,6 @@ pub struct IdentityRef { pub id: String, } -/// Core session challenge and application binding for an attestation -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AttestationContext { - /// Identifier of the attestation session established with Core - pub session_id: String, - /// Fresh challenge supplied by Core - pub nonce: Vec, - /// Application data to bind, such as the WireGuard public key - pub binding: Vec, -} - /// Public backend data supplied to Core before generating attestation challenges #[derive(Debug, Clone, PartialEq, Eq)] pub enum AttestationIdentity { @@ -203,15 +192,12 @@ pub struct CredentialChallenge { pub encrypted_secret: Vec, } -/// Core challenge binding a possession proof to a session and application data +/// Core-encoded challenge for a proof of identity key possession #[derive(Debug, Clone, PartialEq, Eq)] pub struct PossessionChallenge { - /// Identifier of the proof session established with Core - pub session_id: String, - /// Fresh single-use challenge supplied by Core - pub nonce: Vec, - /// Application data the proof must also cover - pub binding: Vec, + /// Opaque Core-encoded bytes containing nonce, session and operation binding. + /// The backend binds the proof to these exact bytes without reserializing them. + pub data: Vec, } /// Backend-specific challenge supplied by Core for identity attestation @@ -219,16 +205,18 @@ pub struct PossessionChallenge { pub enum AttestationChallenge { /// Credential activation challenge bound to the TPM endorsement and attestation keys Tpm(CredentialChallenge), - /// App Attest uses the nonce supplied in the attestation context + /// App Attest uses the request data without an additional credential challenge AppleAppAttest, } /// Complete input for a stateless identity attestation operation #[derive(Debug, Clone, PartialEq, Eq)] pub struct AttestationRequest { - /// Core session, fresh nonce and application binding - pub context: AttestationContext, - /// Backend-specific input determining the attestation protocol + /// Opaque Core-encoded bytes containing nonce, session and operation binding. + /// Their SHA-256 digest becomes TPM qualifyingData or App Attest clientDataHash. + pub data: Vec, + /// Backend-specific input for credential activation on TPM. + /// Apple App Attest requires only the data field. pub challenge: AttestationChallenge, } @@ -352,7 +340,7 @@ pub fn attestation_identity( backend::attestation_identity(identity) } -/// Processes the backend challenge and generates evidence bound to the supplied Core context. +/// Processes the backend challenge and generates evidence bound to the exact Core-encoded data. /// TPM returns the activation secret and certification, while Apple returns App Attest evidence. pub fn attest_identity( identity: &IdentityRef, @@ -400,14 +388,6 @@ mod tests { } } - fn context() -> AttestationContext { - AttestationContext { - session_id: "test-session".into(), - nonce: vec![1; 32], - binding: vec![2; 32], - } - } - #[test] fn creating_identity_returns_unsupported() { assert_eq!(create_identity(), Err(DeviceIdentityError::Unsupported)); @@ -431,7 +411,7 @@ mod tests { AttestationChallenge::AppleAppAttest, ] { let request = AttestationRequest { - context: context(), + data: vec![1; 32], challenge, }; @@ -444,11 +424,7 @@ mod tests { #[test] fn possession_proof_returns_unsupported() { - let challenge = PossessionChallenge { - session_id: "test-session".into(), - nonce: vec![1; 32], - binding: vec![2; 32], - }; + let challenge = PossessionChallenge { data: vec![1; 32] }; assert_eq!( prove_possession(&identity(), &challenge), From f07ec03b2361bc3aeebac9caba54060ba3d28254 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 10:08:41 +0100 Subject: [PATCH 12/21] async functions for macos --- src-tauri/Cargo.lock | 1 + .../enterprise/device-identity/Cargo.toml | 1 + .../device-identity/src/backend/linux.rs | 6 ++-- .../device-identity/src/backend/macos.rs | 6 ++-- .../device-identity/src/backend/windows.rs | 6 ++-- .../enterprise/device-identity/src/lib.rs | 33 ++++++++++--------- 6 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9c03f570..d728b881 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1920,6 +1920,7 @@ name = "device-identity" version = "2.2.0" dependencies = [ "thiserror 2.0.21", + "tokio", ] [[package]] diff --git a/src-tauri/enterprise/device-identity/Cargo.toml b/src-tauri/enterprise/device-identity/Cargo.toml index 022e838c..5aa22768 100644 --- a/src-tauri/enterprise/device-identity/Cargo.toml +++ b/src-tauri/enterprise/device-identity/Cargo.toml @@ -9,6 +9,7 @@ version.workspace = true [dependencies] thiserror.workspace = true +tokio = { workspace = true, features = ["macros"] } [lints] workspace = true diff --git a/src-tauri/enterprise/device-identity/src/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs index 388b8f98..b27dd11d 100644 --- a/src-tauri/enterprise/device-identity/src/backend/linux.rs +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -7,7 +7,7 @@ pub(crate) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn create_identity() -> Result { +pub(crate) async fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } @@ -17,14 +17,14 @@ pub(crate) fn attestation_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn attest_identity( +pub(crate) async fn attest_identity( _identity: &IdentityRef, _request: &AttestationRequest, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn prove_possession( +pub(crate) async fn prove_possession( _identity: &IdentityRef, _challenge: &PossessionChallenge, ) -> Result { diff --git a/src-tauri/enterprise/device-identity/src/backend/macos.rs b/src-tauri/enterprise/device-identity/src/backend/macos.rs index 388b8f98..b27dd11d 100644 --- a/src-tauri/enterprise/device-identity/src/backend/macos.rs +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -7,7 +7,7 @@ pub(crate) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn create_identity() -> Result { +pub(crate) async fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } @@ -17,14 +17,14 @@ pub(crate) fn attestation_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn attest_identity( +pub(crate) async fn attest_identity( _identity: &IdentityRef, _request: &AttestationRequest, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn prove_possession( +pub(crate) async fn prove_possession( _identity: &IdentityRef, _challenge: &PossessionChallenge, ) -> Result { diff --git a/src-tauri/enterprise/device-identity/src/backend/windows.rs b/src-tauri/enterprise/device-identity/src/backend/windows.rs index 388b8f98..b27dd11d 100644 --- a/src-tauri/enterprise/device-identity/src/backend/windows.rs +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -7,7 +7,7 @@ pub(crate) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn create_identity() -> Result { +pub(crate) async fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } @@ -17,14 +17,14 @@ pub(crate) fn attestation_identity( Err(DeviceIdentityError::Unsupported) } -pub(crate) fn attest_identity( +pub(crate) async fn attest_identity( _identity: &IdentityRef, _request: &AttestationRequest, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn prove_possession( +pub(crate) async fn prove_possession( _identity: &IdentityRef, _challenge: &PossessionChallenge, ) -> Result { diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index 6c4cb52f..a21b5b08 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -329,8 +329,8 @@ pub fn capabilities() -> Result { /// /// Returns a local reference for later attestation and possession proofs. /// Returns Unsupported when the required profile or mechanism is unavailable. -pub fn create_identity() -> Result { - backend::create_identity() +pub async fn create_identity() -> Result { + backend::create_identity().await } /// Returns the identity's public attestation data for enrollment with Core. @@ -342,20 +342,20 @@ pub fn attestation_identity( /// Processes the backend challenge and generates evidence bound to the exact Core-encoded data. /// TPM returns the activation secret and certification, while Apple returns App Attest evidence. -pub fn attest_identity( +pub async fn attest_identity( identity: &IdentityRef, request: &AttestationRequest, ) -> Result { - backend::attest_identity(identity, request) + backend::attest_identity(identity, request).await } /// Produces a proof bound to Core's challenge using the referenced identity. /// Core must verify it against the previously attested key. -pub fn prove_possession( +pub async fn prove_possession( identity: &IdentityRef, challenge: &PossessionChallenge, ) -> Result { - backend::prove_possession(identity, challenge) + backend::prove_possession(identity, challenge).await } #[cfg(test)] @@ -388,9 +388,12 @@ mod tests { } } - #[test] - fn creating_identity_returns_unsupported() { - assert_eq!(create_identity(), Err(DeviceIdentityError::Unsupported)); + #[tokio::test] + async fn creating_identity_returns_unsupported() { + assert_eq!( + create_identity().await, + Err(DeviceIdentityError::Unsupported) + ); } #[test] @@ -401,8 +404,8 @@ mod tests { ); } - #[test] - fn attestation_returns_unsupported_for_each_challenge() { + #[tokio::test] + async fn attestation_returns_unsupported_for_each_challenge() { for challenge in [ AttestationChallenge::Tpm(CredentialChallenge { credential_blob: vec![3; 32], @@ -416,18 +419,18 @@ mod tests { }; assert!(matches!( - attest_identity(&identity(), &request), + attest_identity(&identity(), &request).await, Err(DeviceIdentityError::Unsupported) )); } } - #[test] - fn possession_proof_returns_unsupported() { + #[tokio::test] + async fn possession_proof_returns_unsupported() { let challenge = PossessionChallenge { data: vec![1; 32] }; assert_eq!( - prove_possession(&identity(), &challenge), + prove_possession(&identity(), &challenge).await, Err(DeviceIdentityError::Unsupported) ); } From 85eba972eb7d0195815bf33769cd228c7906ace3 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 10:12:49 +0100 Subject: [PATCH 13/21] add error variants for apple attestation --- src-tauri/enterprise/device-identity/src/lib.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index a21b5b08..12d04f3f 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -298,6 +298,15 @@ pub enum DeviceIdentityError { /// Required hardware or platform service is not available #[error("hardware identity backend is unavailable")] Unavailable, + /// The service is temporarily unavailable and the operation may be retried later + #[error("hardware identity service is temporarily unavailable")] + TemporarilyUnavailable, + /// The operation exceeded its time limit + #[error("hardware identity operation timed out")] + Timeout, + /// The service requires a delay before another attempt + #[error("hardware identity service rate limit exceeded")] + RateLimited, /// The caller lacks permission to access the backend #[error("permission to access hardware identity was denied")] PermissionDenied, From 8f89a8082b8b17abcacdb0f5bb1f3f6271c6dbc7 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 10:49:21 +0100 Subject: [PATCH 14/21] contract tests --- src-tauri/Cargo.lock | 360 +++++++++++++++++- .../enterprise/device-identity/Cargo.toml | 13 + .../enterprise/device-identity/src/lib.rs | 60 +-- .../device-identity/tests/README.md | 98 +++++ .../device-identity/tests/contract.rs | 218 +++++++++++ .../device-identity/tests/support/apple.rs | 262 +++++++++++++ .../device-identity/tests/support/mod.rs | 133 +++++++ .../device-identity/tests/support/tpm.rs | 252 ++++++++++++ .../tests/support/verifier_tests.rs | 129 +++++++ 9 files changed, 1452 insertions(+), 73 deletions(-) create mode 100644 src-tauri/enterprise/device-identity/tests/README.md create mode 100644 src-tauri/enterprise/device-identity/tests/contract.rs create mode 100644 src-tauri/enterprise/device-identity/tests/support/apple.rs create mode 100644 src-tauri/enterprise/device-identity/tests/support/mod.rs create mode 100644 src-tauri/enterprise/device-identity/tests/support/tpm.rs create mode 100644 src-tauri/enterprise/device-identity/tests/support/verifier_tests.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d728b881..244faaa0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -15,7 +15,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.5", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -24,7 +35,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.1", ] @@ -581,6 +592,12 @@ dependencies = [ "tower-service", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -599,6 +616,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -868,7 +891,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -900,6 +923,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "cfb-mode" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "738b8d467867f80a71351933f70461f5b56f24d5c93e0cf216e59229c968d330" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -929,7 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", + "cipher 0.5.2", "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -942,7 +974,7 @@ checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", "chacha20", - "cipher", + "cipher 0.5.2", "poly1305", ] @@ -987,6 +1019,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout 0.1.4", +] + [[package]] name = "cipher" version = "0.5.2" @@ -995,7 +1037,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.5", ] [[package]] @@ -1093,6 +1135,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1301,6 +1349,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -1351,7 +1411,7 @@ version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73e2cee48715b2a8fae3739aeb8433817051a6772562ba67f9b631fc833eb793" dependencies = [ - "aes", + "aes 0.9.3", "anyhow", "base64 0.23.1", "byteorder", @@ -1802,7 +1862,7 @@ dependencies = [ "blake2", "chacha20poly1305", "hex", - "hmac", + "hmac 0.13.0", "ip_network", "ip_network_table", "libc", @@ -1871,6 +1931,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -1919,8 +1990,19 @@ dependencies = [ name = "device-identity" version = "2.2.0" dependencies = [ + "aes 0.8.4", + "base64 0.23.1", + "cfb-mode", + "ciborium", + "hmac 0.12.1", + "p256", + "rand_core 0.6.4", + "rsa", + "sha2 0.10.9", + "tempfile", "thiserror 2.0.21", "tokio", + "x509-parser", ] [[package]] @@ -1930,7 +2012,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.6", + "subtle", ] [[package]] @@ -1940,7 +2024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -2123,6 +2207,20 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + [[package]] name = "either" version = "1.19.0" @@ -2132,6 +2230,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.12" @@ -2333,6 +2452,16 @@ dependencies = [ "log", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -2677,6 +2806,7 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2846,6 +2976,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gtk" version = "0.18.2" @@ -3012,13 +3153,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -3403,6 +3562,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inout" version = "0.2.5" @@ -3727,6 +3895,9 @@ name = "lazy_static" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] [[package]] name = "lebe" @@ -4294,6 +4465,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -4358,6 +4545,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4825,6 +5013,18 @@ dependencies = [ "supports-color 3.0.2", ] +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + [[package]] name = "pad" version = "0.1.6" @@ -4922,6 +5122,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5029,6 +5238,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -5150,6 +5380,15 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -5469,13 +5708,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -5490,6 +5739,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -5500,6 +5759,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -5552,7 +5820,7 @@ dependencies = [ "paste", "profiling", "rand 0.9.5", - "rand_chacha", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.21", "v_frame", @@ -5797,6 +6065,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfd" version = "0.17.2" @@ -5843,6 +6121,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-ini" version = "0.21.3" @@ -6054,6 +6352,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "secrecy" version = "0.10.3" @@ -6344,6 +6656,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6475,6 +6797,16 @@ dependencies = [ "lock_api 0.4.14", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -6609,8 +6941,8 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", diff --git a/src-tauri/enterprise/device-identity/Cargo.toml b/src-tauri/enterprise/device-identity/Cargo.toml index 5aa22768..0352e565 100644 --- a/src-tauri/enterprise/device-identity/Cargo.toml +++ b/src-tauri/enterprise/device-identity/Cargo.toml @@ -13,3 +13,16 @@ tokio = { workspace = true, features = ["macros"] } [lints] workspace = true + +[dev-dependencies] +aes = "0.8" +base64.workspace = true +cfb-mode = "0.8" +ciborium = "0.2" +hmac = "0.12" +p256 = { version = "0.13", features = ["ecdh", "ecdsa", "pem"] } +rand_core = { version = "0.6", features = ["getrandom"] } +rsa = "0.9" +sha2 = { version = "0.10", features = ["oid"] } +tempfile.workspace = true +x509-parser = "0.18" diff --git a/src-tauri/enterprise/device-identity/src/lib.rs b/src-tauri/enterprise/device-identity/src/lib.rs index 12d04f3f..500c3c1f 100644 --- a/src-tauri/enterprise/device-identity/src/lib.rs +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -372,7 +372,7 @@ mod tests { use super::*; #[test] - fn capabilities_preserve_unknown_algorithm_ids_and_flags() { + fn test_capabilities_preserve_unknown_algorithm_ids_and_flags() { let capabilities = TpmCapabilities { algorithms: vec![AlgorithmCapability { id: AlgorithmId(0xffff), @@ -385,62 +385,4 @@ mod tests { assert_eq!(capabilities.algorithms[0].flags, 0x8000_0000); assert!(capabilities.profiles.is_empty()); } - - #[test] - fn unimplemented_backend_returns_unsupported() { - assert_eq!(capabilities(), Err(DeviceIdentityError::Unsupported)); - } - - fn identity() -> IdentityRef { - IdentityRef { - id: "test-identity".into(), - } - } - - #[tokio::test] - async fn creating_identity_returns_unsupported() { - assert_eq!( - create_identity().await, - Err(DeviceIdentityError::Unsupported) - ); - } - - #[test] - fn attestation_identity_returns_unsupported() { - assert_eq!( - attestation_identity(&identity()), - Err(DeviceIdentityError::Unsupported) - ); - } - - #[tokio::test] - async fn attestation_returns_unsupported_for_each_challenge() { - for challenge in [ - AttestationChallenge::Tpm(CredentialChallenge { - credential_blob: vec![3; 32], - encrypted_secret: vec![4; 32], - }), - AttestationChallenge::AppleAppAttest, - ] { - let request = AttestationRequest { - data: vec![1; 32], - challenge, - }; - - assert!(matches!( - attest_identity(&identity(), &request).await, - Err(DeviceIdentityError::Unsupported) - )); - } - } - - #[tokio::test] - async fn possession_proof_returns_unsupported() { - let challenge = PossessionChallenge { data: vec![1; 32] }; - - assert_eq!( - prove_possession(&identity(), &challenge).await, - Err(DeviceIdentityError::Unsupported) - ); - } } diff --git a/src-tauri/enterprise/device-identity/tests/README.md b/src-tauri/enterprise/device-identity/tests/README.md new file mode 100644 index 00000000..89a34699 --- /dev/null +++ b/src-tauri/enterprise/device-identity/tests/README.md @@ -0,0 +1,98 @@ +# Device identity contract tests + +`contract.rs` exercises the public facade. Hardware-dependent tests use `#[ignore]` +until the backends are implemented and a suitable runner is configured. They require +successful operations and cryptographically valid results. They do not accept +`Unsupported` as a successful outcome and do not mock the backend. + +From `src-tauri`: + +```sh +cargo test -p device-identity +cargo test -p device-identity --test contract -- --ignored +``` + +The second command is expected to fail on the current stubs. Run a single scenario +by adding its name before `-- --ignored`. + +## Contracts + +- Capabilities identify the platform backend and its supported identity profile. +- Created identities have nonempty local references and stable public enrollment data. +- TPM activation recovers a fresh secret chosen by the test verifier. +- Attestation certifies the required identity key and binds the exact opaque Core bytes. +- Changed attestation data, evidence or activation secrets fail independent verification. +- Possession proofs verify only with the attested key and unchanged Core bytes. +- Apple assertion counters increase and replayed assertions are rejected by the verifier. +- Creating another identity preserves the first identity's key and usability. +- TPM rejects corrupted credential blobs and encrypted protection seeds. + +The library receives only opaque challenge data. Session expiry and single-use +challenge bookkeeping belong to Core and need separate Core tests. + +## Linux and Windows + +Use a dedicated test TPM or an isolated software TPM. Tests create persistent local +identity records and may leave them behind because the facade has no deletion +operation yet. Do not clear a TPM used for other applications to clean up these tests. + +For Linux, `swtpm` is the preferred fixture. The backend needs transport configuration +before these tests can target it. If the implementation adopts the usual TSS transport +environment variable, a typical setting is: + +```sh +export TSS2_TCTI='swtpm:host=127.0.0.1,port=2321' +``` + +This test suite does not start an emulator, provision a persistent EK handle, or +configure OS permissions. These are responsibilities of the runner and backend. +Windows needs a suitable TPM accessible through its transport and a writable test +identity store. + +The independent software MakeCredential fixture supports standard RSA-2048 and +NIST P-256 EKs with SHA-256 and AES-128-CFB. AK and identity keys must use ECDSA +P-256/SHA-256. The verifier checks key attributes, TPM Names, Certify structure and +signature, challenge binding, and the recovered activation secret. EK manufacturer +CA policy belongs to Core and is outside these facade tests. An absent EK certificate +is allowed for software TPM fixtures. + +## macOS + +Use a macOS 27+ host where App Attest reports support. The test process needs the +signing, provisioning and application identity required by App Attest. Plain +`cargo test` may need an app-hosted runner or packaging to obtain that execution +context. The Rust facade alone does not provide it. + +Initial attestation needs access to Apple services. Assertions are verified locally. +Set: + +```sh +export DEFGUARD_APP_ATTEST_APP_ID='TEAM_ID.bundle_id' +export DEFGUARD_APP_ATTEST_ENVIRONMENT='development' # or production +export DEFGUARD_APP_ATTEST_ROOT_CA='/absolute/path/to/apple-app-attestation-root.pem' +``` + +Obtain the App Attestation Root CA from Apple's official publication, review it and +pin it in the runner configuration. This is the App Attest trust anchor, distinct +from the Apple Enterprise Attestation Root used by Managed Device Attestation. +Never take the trusted root from the attestation response being tested. + +OpenSSL 3 must be available as `openssl` for certificate chain and validity checks. +System trust directories and stores are disabled so only the configured root is trusted. +The Rust verifier also checks the certificate nonce, relying party, environment, +credential ID, P-256 public key and assertion signature/counter. + +The hardware scenarios are serialized within the test binary. They create several +keys and may consume Apple attestation quotas. Prefer individual scenarios during +backend development, and apply service-specific retry/backoff in the runner rather +than repeatedly rerunning the full suite. + +## Independent verifier + +`support/` contains test-only verifier logic and software MakeCredential. It does not +call backend internals or the backend's own verification routines. It is a bounded +fixture for these contracts, not a production Core verifier. + +Two ordinary tests use software-generated signed structures to check that the test +verifiers accept valid inputs and reject altered data, keys, signatures and counters. +They run without hardware or Apple service access. diff --git a/src-tauri/enterprise/device-identity/tests/contract.rs b/src-tauri/enterprise/device-identity/tests/contract.rs new file mode 100644 index 00000000..0aa3b510 --- /dev/null +++ b/src-tauri/enterprise/device-identity/tests/contract.rs @@ -0,0 +1,218 @@ +mod support; + +use device_identity::{ + attest_identity, attestation_identity, capabilities, create_identity, prove_possession, + AttestationChallenge, AttestationIdentity, AttestationMethod, AttestationResponse, BackendKind, + PlatformCapabilities, PossessionChallenge, PossessionProof, ProfileId, SupportStatus, +}; +use support::{challenge_data, enroll, prepare_attestation, verify_attestation}; + +// Hardware operations share limited TPM slots and App Attest service quotas. +static HARDWARE: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +fn expected_backend() -> BackendKind { + if cfg!(target_os = "macos") { + BackendKind::AppleAppAttest + } else { + BackendKind::Tpm + } +} + +#[tokio::test] +#[ignore = "Requires a supported TPM or configured macOS App Attest host"] +async fn test_capabilities_report_the_platform_backend_and_required_profile() { + let _hardware = HARDWARE.lock().await; + let caps = capabilities().expect("query an available hardware backend"); + assert_eq!(caps.backend, expected_backend()); + match (&caps.backend, &caps.details) { + (BackendKind::Tpm, PlatformCapabilities::Tpm(tpm)) => { + assert_eq!(expected_backend(), BackendKind::Tpm); + assert!(caps + .attestation_methods + .contains(&AttestationMethod::TpmKeyAttestation)); + assert!(tpm + .algorithms + .iter() + .any(|algorithm| algorithm.id.0 == 0x0023)); + assert!(tpm + .algorithms + .iter() + .any(|algorithm| algorithm.id.0 == 0x0018)); + assert!(tpm + .algorithms + .iter() + .any(|algorithm| algorithm.id.0 == 0x000b)); + assert!(tpm.ecc_curves.iter().any(|curve| curve.0 == 0x0003)); + assert!(tpm.profiles.iter().any(|profile| { + profile.profile == ProfileId::EcdsaP256Sha256 + && profile.support == SupportStatus::Supported + })); + } + (BackendKind::AppleAppAttest, PlatformCapabilities::Apple(apple)) => { + assert_eq!(expected_backend(), BackendKind::AppleAppAttest); + assert!(apple.app_attest_supported); + assert!(caps + .attestation_methods + .contains(&AttestationMethod::AppleAppAttest)); + } + _ => panic!("backend kind and capability details must agree"), + } +} + +#[tokio::test] +#[ignore = "Requires persistent TPM key storage or a configured App Attest host"] +async fn test_created_identity_has_stable_public_enrollment_data() { + let _hardware = HARDWARE.lock().await; + let identity = create_identity().await.expect("create an identity"); + assert!(!identity.id.is_empty()); + let first = attestation_identity(&identity).expect("read public identity data"); + let second = attestation_identity(&identity).expect("read public identity data again"); + assert_eq!(first, second); + match first { + AttestationIdentity::Tpm(metadata) => { + assert_eq!(expected_backend(), BackendKind::Tpm); + assert!(!metadata.ek_public.is_empty()); + support::tpm::signing_key(&metadata.ak_public, true) + .expect("AK must be a restricted TPM-generated ECDSA P-256/SHA-256 key"); + } + AttestationIdentity::AppleAppAttest { key_id } => { + assert_eq!(expected_backend(), BackendKind::AppleAppAttest); + use base64::Engine; + let key_id = base64::engine::general_purpose::STANDARD + .decode(key_id) + .expect("App Attest key ID must be Base64"); + assert_eq!(key_id.len(), 32); + } + } +} + +#[tokio::test] +#[ignore = "Requires TPM credential activation or live Apple attestation and trusted fixtures"] +async fn test_attestation_verifies_and_rejects_changed_data_or_evidence() { + let _hardware = HARDWARE.lock().await; + let identity = create_identity().await.expect("create an identity"); + let metadata = attestation_identity(&identity).expect("read public identity data"); + let (request, secret) = prepare_attestation(&metadata); + let response = attest_identity(&identity, &request) + .await + .expect("generate attestation"); + verify_attestation(&metadata, &request, secret.as_ref(), &response) + .unwrap_or_else(|error| panic!("valid attestation must verify: {error}")); + + let mut changed_request = request.clone(); + changed_request.data[0] ^= 1; + assert!(verify_attestation(&metadata, &changed_request, secret.as_ref(), &response).is_err()); + + let mut changed_response = response.clone(); + match &mut changed_response { + AttestationResponse::Tpm { evidence, .. } => evidence.signature.bytes[0] ^= 1, + AttestationResponse::AppleAppAttest(evidence) => evidence.attestation_object[0] ^= 1, + } + assert!(verify_attestation(&metadata, &request, secret.as_ref(), &changed_response).is_err()); + + let mut changed_response = response.clone(); + if let AttestationResponse::Tpm { + activation_secret, .. + } = &mut changed_response + { + activation_secret[0] ^= 1; + assert!( + verify_attestation(&metadata, &request, secret.as_ref(), &changed_response).is_err() + ); + } +} + +#[tokio::test] +#[ignore = "Requires two hardware identities and independently verified attestations"] +async fn test_possession_proof_binds_the_exact_data_and_registered_key() { + let _hardware = HARDWARE.lock().await; + let (identity, verifier) = enroll().await; + let (_, mut other_verifier) = enroll().await; + let challenge = PossessionChallenge { + data: challenge_data("possession"), + }; + let proof = prove_possession(&identity, &challenge) + .await + .expect("generate possession proof"); + + let mut valid_verifier = verifier.clone(); + valid_verifier + .verify_proof(&challenge.data, &proof) + .expect("valid proof must verify"); + + let mut changed_data = challenge.data.clone(); + changed_data[0] ^= 1; + assert!(verifier + .clone() + .verify_proof(&changed_data, &proof) + .is_err()); + assert!(other_verifier + .verify_proof(&challenge.data, &proof) + .is_err()); + + let mut corrupted = proof.clone(); + match &mut corrupted { + PossessionProof::Signature(signature) => signature.bytes[0] ^= 1, + PossessionProof::AppleAssertion(assertion) => assertion.assertion_object[0] ^= 1, + } + assert!(verifier + .clone() + .verify_proof(&challenge.data, &corrupted) + .is_err()); + + let next = prove_possession(&identity, &challenge) + .await + .expect("generate another proof"); + valid_verifier + .verify_proof(&challenge.data, &next) + .expect("subsequent proof must verify"); + if matches!(next, PossessionProof::AppleAssertion(_)) { + assert!(valid_verifier.verify_proof(&challenge.data, &next).is_err()); + } +} + +#[tokio::test] +#[ignore = "Requires persistent hardware key storage and a verified identity"] +async fn test_creating_another_identity_preserves_the_existing_identity() { + let _hardware = HARDWARE.lock().await; + let (first, mut verifier) = enroll().await; + let metadata = attestation_identity(&first).expect("read first identity metadata"); + let second = create_identity().await.expect("create a second identity"); + assert_ne!(first, second); + assert_eq!( + metadata, + attestation_identity(&first).expect("reload first identity metadata") + ); + let challenge = PossessionChallenge { + data: challenge_data("identity-preservation"), + }; + let proof = prove_possession(&first, &challenge) + .await + .expect("use first identity after creating another"); + verifier + .verify_proof(&challenge.data, &proof) + .expect("first identity must retain its original key"); +} + +#[cfg(any(target_os = "linux", windows))] +#[tokio::test] +#[ignore = "Requires a TPM with RSA-2048 or P-256 EK and credential activation"] +async fn test_tpm_rejects_a_corrupted_credential_challenge() { + let _hardware = HARDWARE.lock().await; + let identity = create_identity().await.expect("create an identity"); + let metadata = attestation_identity(&identity).expect("read EK and AK"); + let (request, _) = prepare_attestation(&metadata); + let mut bad_blob = request.clone(); + let AttestationChallenge::Tpm(credential) = &mut bad_blob.challenge else { + panic!("expected TPM challenge"); + }; + credential.credential_blob[0] ^= 1; + assert!(attest_identity(&identity, &bad_blob).await.is_err()); + + let mut bad_seed = request; + let AttestationChallenge::Tpm(credential) = &mut bad_seed.challenge else { + panic!("expected TPM challenge"); + }; + credential.encrypted_secret[0] ^= 1; + assert!(attest_identity(&identity, &bad_seed).await.is_err()); +} diff --git a/src-tauri/enterprise/device-identity/tests/support/apple.rs b/src-tauri/enterprise/device-identity/tests/support/apple.rs new file mode 100644 index 00000000..a96d0f66 --- /dev/null +++ b/src-tauri/enterprise/device-identity/tests/support/apple.rs @@ -0,0 +1,262 @@ +use std::{fs, io::Cursor, process::Command}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use ciborium::Value; +use p256::{ecdsa::VerifyingKey, pkcs8::DecodePublicKey}; +use sha2::{Digest, Sha256}; +use x509_parser::parse_x509_certificate; + +use super::{check, tpm::Reader, verify_signature, TestResult}; + +#[derive(Clone)] +pub struct AppleVerifier { + pub key: VerifyingKey, + pub app_id_hash: Vec, + pub counter: u32, +} + +pub fn decode(bytes: &[u8]) -> TestResult { + let mut cursor = Cursor::new(bytes); + let value = ciborium::from_reader(&mut cursor).map_err(|error| error.to_string())?; + check( + cursor.position() == bytes.len() as u64, + "trailing CBOR data", + )?; + Ok(value) +} + +pub fn field<'a>(value: &'a Value, name: &str) -> TestResult<&'a Value> { + value + .as_map() + .and_then(|map| { + map.iter() + .find_map(|(key, value)| (key.as_text() == Some(name)).then_some(value)) + }) + .ok_or_else(|| format!("missing CBOR field {name}")) +} + +fn bytes(value: &Value) -> TestResult<&[u8]> { + value + .as_bytes() + .map(Vec::as_slice) + .ok_or_else(|| "expected CBOR bytes".into()) +} + +fn integer_field(value: &Value, label: i128) -> TestResult<&Value> { + value + .as_map() + .and_then(|map| { + map.iter().find_map(|(key, value)| { + (key.as_integer().map(i128::from) == Some(label)).then_some(value) + }) + }) + .ok_or_else(|| format!("missing COSE key label {label}")) +} + +pub fn verify_attestation(key_id: &str, data: &[u8], object: &[u8]) -> TestResult { + let object = decode(object)?; + check( + field(&object, "fmt")?.as_text() == Some("apple-appattest"), + "unexpected Apple attestation format", + )?; + let statement = field(&object, "attStmt")?; + check( + !bytes(field(statement, "receipt")?)?.is_empty(), + "missing Apple receipt", + )?; + let chain = field(statement, "x5c")? + .as_array() + .ok_or("expected certificate array")?; + check( + chain.len() >= 2, + "expected Apple leaf and intermediate certificates", + )?; + let chain: Vec<&[u8]> = chain.iter().map(bytes).collect::>()?; + verify_certificate_chain(&chain)?; + let (rest, certificate) = + parse_x509_certificate(chain[0]).map_err(|error| error.to_string())?; + check(rest.is_empty(), "trailing certificate data")?; + let key = VerifyingKey::from_public_key_der(certificate.public_key().raw) + .map_err(|error| error.to_string())?; + + let auth_data = bytes(field(&object, "authData")?)?; + let mut reader = Reader::new(auth_data); + let app_id = std::env::var("DEFGUARD_APP_ATTEST_APP_ID") + .map_err(|_| "set DEFGUARD_APP_ATTEST_APP_ID to TEAM_ID.bundle_id")?; + let app_id_hash = Sha256::digest(app_id.as_bytes()).to_vec(); + check( + reader.take(32)? == app_id_hash, + "App Attest relying party mismatch", + )?; + check( + reader.take(1)?[0] & 0x40 != 0, + "missing attested credential data", + )?; + check( + reader.u32()? == 0, + "initial App Attest counter must be zero", + )?; + let aaguid = reader.take(16)?; + let expected_aaguid = match std::env::var("DEFGUARD_APP_ATTEST_ENVIRONMENT").as_deref() { + Ok("production") => b"appattest\0\0\0\0\0\0\0".as_slice(), + Ok("development") => b"appattestdevelop".as_slice(), + _ => return Err("set DEFGUARD_APP_ATTEST_ENVIRONMENT to development or production".into()), + }; + check(aaguid == expected_aaguid, "App Attest environment mismatch")?; + let credential_id = reader.sized()?; + let decoded_key_id = STANDARD.decode(key_id).map_err(|error| error.to_string())?; + check( + credential_id == decoded_key_id, + "App Attest credential ID mismatch", + )?; + check( + decoded_key_id == Sha256::digest(key.to_encoded_point(false).as_bytes())[..], + "App Attest key ID does not match public key", + )?; + + let credential_offset = auth_data.len() - (32 + 1 + 4 + 16 + 2 + credential_id.len()); + let cose = decode(reader.take(credential_offset)?)?; + reader.finish()?; + check( + integer_field(&cose, 1)?.as_integer().map(i128::from) == Some(2), + "expected COSE EC2 key", + )?; + check( + integer_field(&cose, 3)?.as_integer().map(i128::from) == Some(-7), + "expected COSE ES256", + )?; + check( + integer_field(&cose, -1)?.as_integer().map(i128::from) == Some(1), + "expected COSE P-256", + )?; + let point = key.to_encoded_point(false); + check( + bytes(integer_field(&cose, -2)?)? == &point.x().unwrap()[..], + "COSE x coordinate mismatch", + )?; + check( + bytes(integer_field(&cose, -3)?)? == &point.y().unwrap()[..], + "COSE y coordinate mismatch", + )?; + + let nonce = Sha256::digest([auth_data, &Sha256::digest(data)[..]].concat()); + let extension = certificate + .extensions() + .iter() + .find(|extension| extension.oid.to_id_string() == "1.2.840.113635.100.8.2") + .ok_or("missing App Attest nonce extension")?; + let mut sequence = DerReader::new(extension.value); + let value = sequence.element(0x30)?; + sequence.finish()?; + let mut explicit = DerReader::new(value); + let value = explicit.element(0xa1)?; + explicit.finish()?; + let mut octets = DerReader::new(value); + check( + octets.element(0x04)? == &nonce[..], + "App Attest nonce does not bind the exact Core data", + )?; + octets.finish()?; + Ok(AppleVerifier { + key, + app_id_hash, + counter: 0, + }) +} + +pub fn verify_assertion( + verifier: &mut AppleVerifier, + data: &[u8], + object: &[u8], +) -> TestResult<()> { + let object = decode(object)?; + let signature = bytes(field(&object, "signature")?)?; + let auth_data = bytes(field(&object, "authenticatorData")?)?; + let mut reader = Reader::new(auth_data); + check( + reader.take(32)? == verifier.app_id_hash, + "assertion relying party mismatch", + )?; + reader.take(1)?; + let counter = reader.u32()?; + check( + counter > verifier.counter, + "assertion counter did not increase", + )?; + let message = [auth_data, &Sha256::digest(data)[..]].concat(); + verify_signature(&verifier.key, &message, signature)?; + verifier.counter = counter; + Ok(()) +} + +fn verify_certificate_chain(chain: &[&[u8]]) -> TestResult<()> { + let root = std::env::var_os("DEFGUARD_APP_ATTEST_ROOT_CA").ok_or( + "set DEFGUARD_APP_ATTEST_ROOT_CA to the trusted Apple App Attestation Root CA PEM", + )?; + let directory = tempfile::tempdir().map_err(|error| error.to_string())?; + let leaf = directory.path().join("leaf.pem"); + let intermediates = directory.path().join("intermediates.pem"); + fs::write(&leaf, pem(chain[0])).map_err(|error| error.to_string())?; + fs::write( + &intermediates, + chain[1..].iter().map(|cert| pem(cert)).collect::(), + ) + .map_err(|error| error.to_string())?; + let output = Command::new("openssl") + .arg("verify") + .arg("-no-CApath") + .arg("-no-CAstore") + .arg("-CAfile") + .arg(root) + .arg("-untrusted") + .arg(intermediates) + .arg(leaf) + .output() + .map_err(|error| format!("run OpenSSL to validate Apple certificates: {error}"))?; + check( + output.status.success(), + &format!( + "Apple certificate validation failed: {}", + String::from_utf8_lossy(&output.stderr) + ), + ) +} + +fn pem(der: &[u8]) -> String { + let encoded = STANDARD.encode(der); + let body = encoded + .as_bytes() + .chunks(64) + .map(|line| format!("{}\n", std::str::from_utf8(line).unwrap())) + .collect::(); + format!("-----BEGIN CERTIFICATE-----\n{body}-----END CERTIFICATE-----\n") +} + +struct DerReader<'a> { + bytes: &'a [u8], +} + +impl<'a> DerReader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Self { bytes } + } + + fn element(&mut self, tag: u8) -> TestResult<&'a [u8]> { + check( + self.bytes.len() >= 2 && self.bytes[0] == tag, + "unexpected DER tag", + )?; + let length = self.bytes[1] as usize; + check( + length < 128 && self.bytes.len() >= 2 + length, + "invalid DER extension length", + )?; + let value = &self.bytes[2..2 + length]; + self.bytes = &self.bytes[2 + length..]; + Ok(value) + } + + fn finish(self) -> TestResult<()> { + check(self.bytes.is_empty(), "trailing DER extension data") + } +} diff --git a/src-tauri/enterprise/device-identity/tests/support/mod.rs b/src-tauri/enterprise/device-identity/tests/support/mod.rs new file mode 100644 index 00000000..a97f8e90 --- /dev/null +++ b/src-tauri/enterprise/device-identity/tests/support/mod.rs @@ -0,0 +1,133 @@ +pub mod apple; +pub mod tpm; + +use device_identity::{ + attest_identity, attestation_identity, create_identity, AttestationChallenge, + AttestationIdentity, AttestationRequest, AttestationResponse, IdentityRef, PossessionProof, + SignatureAlgorithm, +}; +use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; +use rand_core::{OsRng, RngCore}; + +pub type TestResult = Result; + +pub fn check(condition: bool, message: &str) -> TestResult<()> { + if condition { + Ok(()) + } else { + Err(message.into()) + } +} + +pub fn challenge_data(purpose: &str) -> Vec { + let mut nonce = [0u8; 32]; + OsRng.fill_bytes(&mut nonce); + [ + b"defguard-contract-v1\0".as_slice(), + purpose.as_bytes(), + &[0, 0xff], + &nonce, + ] + .concat() +} + +pub fn prepare_attestation( + metadata: &AttestationIdentity, +) -> (AttestationRequest, Option<[u8; 32]>) { + let (challenge, secret) = match metadata { + AttestationIdentity::Tpm(metadata) => { + let mut secret = [0u8; 32]; + OsRng.fill_bytes(&mut secret); + let challenge = tpm::make_credential(metadata, &secret) + .expect("prepare an independent software MakeCredential challenge"); + (AttestationChallenge::Tpm(challenge), Some(secret)) + } + AttestationIdentity::AppleAppAttest { .. } => (AttestationChallenge::AppleAppAttest, None), + }; + ( + AttestationRequest { + data: challenge_data("attestation"), + challenge, + }, + secret, + ) +} + +#[derive(Clone)] +pub enum IdentityVerifier { + Tpm(VerifyingKey), + Apple(apple::AppleVerifier), +} + +impl IdentityVerifier { + pub fn verify_proof(&mut self, data: &[u8], proof: &PossessionProof) -> TestResult<()> { + match (self, proof) { + (Self::Tpm(key), PossessionProof::Signature(signature)) => { + check( + signature.algorithm == SignatureAlgorithm::EcdsaSha256, + "expected ECDSA/SHA-256 possession proof", + )?; + verify_signature(key, data, &signature.bytes) + } + (Self::Apple(verifier), PossessionProof::AppleAssertion(assertion)) => { + apple::verify_assertion(verifier, data, &assertion.assertion_object) + } + _ => Err("possession proof backend differs from the attested backend".into()), + } + } +} + +pub fn verify_attestation( + metadata: &AttestationIdentity, + request: &AttestationRequest, + expected_secret: Option<&[u8; 32]>, + response: &AttestationResponse, +) -> TestResult { + match (metadata, &request.challenge, response) { + ( + AttestationIdentity::Tpm(metadata), + AttestationChallenge::Tpm(_), + AttestationResponse::Tpm { + activation_secret, + evidence, + }, + ) => { + let secret = expected_secret.ok_or("missing verifier activation secret")?; + check( + activation_secret.as_slice() == secret, + "credential activation did not recover Core's secret", + )?; + tpm::verify_attestation(metadata, &request.data, evidence).map(IdentityVerifier::Tpm) + } + ( + AttestationIdentity::AppleAppAttest { key_id }, + AttestationChallenge::AppleAppAttest, + AttestationResponse::AppleAppAttest(evidence), + ) => apple::verify_attestation(key_id, &request.data, &evidence.attestation_object) + .map(IdentityVerifier::Apple), + _ => Err("attestation backend differs from the requested backend".into()), + } +} + +pub async fn enroll() -> (IdentityRef, IdentityVerifier) { + let identity = create_identity() + .await + .expect("create a hardware-backed identity"); + let metadata = attestation_identity(&identity).expect("read public attestation identity"); + let (request, secret) = prepare_attestation(&metadata); + let response = attest_identity(&identity, &request) + .await + .expect("attest the identity"); + let verifier = verify_attestation(&metadata, &request, secret.as_ref(), &response) + .unwrap_or_else(|error| panic!("independently verify initial attestation: {error}")); + (identity, verifier) +} + +pub fn verify_signature(key: &VerifyingKey, message: &[u8], signature: &[u8]) -> TestResult<()> { + let signature = Signature::from_der(signature).map_err(|error| error.to_string())?; + key.verify(message, &signature) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod verifier_tests; diff --git a/src-tauri/enterprise/device-identity/tests/support/tpm.rs b/src-tauri/enterprise/device-identity/tests/support/tpm.rs new file mode 100644 index 00000000..00b59b4f --- /dev/null +++ b/src-tauri/enterprise/device-identity/tests/support/tpm.rs @@ -0,0 +1,252 @@ +use aes::Aes128; +use cfb_mode::cipher::{AsyncStreamCipher, KeyIvInit}; +use device_identity::{ + CredentialChallenge, SignatureAlgorithm, TpmAttestationIdentity, TpmEvidence, +}; +use hmac::{Hmac, Mac}; +use p256::{ + ecdh::EphemeralSecret, ecdsa::VerifyingKey, elliptic_curve::sec1::ToEncodedPoint, PublicKey, +}; +use rand_core::{OsRng, RngCore}; +use rsa::{BigUint, Oaep, RsaPublicKey}; +use sha2::{Digest, Sha256}; + +use super::{check, verify_signature, TestResult}; + +const SHA256: u16 = 0x000b; +const NULL: u16 = 0x0010; +const ECC: u16 = 0x0023; +const ECDSA: u16 = 0x0018; +const P256: u16 = 0x0003; +const FIXED_TPM: u32 = 1 << 1; +const FIXED_PARENT: u32 = 1 << 4; +const SENSITIVE_DATA_ORIGIN: u32 = 1 << 5; +const RESTRICTED: u32 = 1 << 16; +const DECRYPT: u32 = 1 << 17; +const SIGN: u32 = 1 << 18; + +type HmacSha256 = Hmac; + +pub struct Reader<'a> { + bytes: &'a [u8], +} + +impl<'a> Reader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Self { bytes } + } + + pub fn take(&mut self, count: usize) -> TestResult<&'a [u8]> { + check(count <= self.bytes.len(), "truncated binary structure")?; + let (value, rest) = self.bytes.split_at(count); + self.bytes = rest; + Ok(value) + } + + pub fn u16(&mut self) -> TestResult { + Ok(u16::from_be_bytes(self.take(2)?.try_into().unwrap())) + } + + pub fn u32(&mut self) -> TestResult { + Ok(u32::from_be_bytes(self.take(4)?.try_into().unwrap())) + } + + pub fn sized(&mut self) -> TestResult<&'a [u8]> { + let size = self.u16()? as usize; + self.take(size) + } + + pub fn finish(self) -> TestResult<()> { + check(self.bytes.is_empty(), "unexpected trailing binary data") + } +} + +pub fn name(public: &[u8]) -> Vec { + [SHA256.to_be_bytes().as_slice(), &Sha256::digest(public)].concat() +} + +pub fn signing_key(public: &[u8], restricted: bool) -> TestResult { + let mut reader = Reader::new(public); + check(reader.u16()? == ECC, "expected an ECC key")?; + check(reader.u16()? == SHA256, "expected SHA-256 Name algorithm")?; + let attributes = reader.u32()?; + let required = FIXED_TPM | FIXED_PARENT | SENSITIVE_DATA_ORIGIN | SIGN; + check( + attributes & required == required, + "missing TPM key attributes", + )?; + check(attributes & DECRYPT == 0, "signing key must not decrypt")?; + check( + (attributes & RESTRICTED != 0) == restricted, + "incorrect restricted attribute", + )?; + reader.sized()?; + check( + reader.u16()? == NULL, + "unexpected signing key symmetric algorithm", + )?; + check(reader.u16()? == ECDSA, "expected ECDSA signature scheme")?; + check(reader.u16()? == SHA256, "expected SHA-256 signature hash")?; + check(reader.u16()? == P256, "expected NIST P-256")?; + check(reader.u16()? == NULL, "unexpected signing key KDF")?; + let x = reader.sized()?; + let y = reader.sized()?; + let key = public_point(x, y)?; + reader.finish()?; + Ok(VerifyingKey::from(key)) +} + +fn public_point(x: &[u8], y: &[u8]) -> TestResult { + check(!x.is_empty() && x.len() <= 32, "invalid P-256 x coordinate")?; + check(!y.is_empty() && y.len() <= 32, "invalid P-256 y coordinate")?; + let mut point = [0u8; 65]; + point[0] = 4; + point[33 - x.len()..33].copy_from_slice(x); + point[65 - y.len()..].copy_from_slice(y); + PublicKey::from_sec1_bytes(&point).map_err(|error| error.to_string()) +} + +pub fn verify_attestation( + metadata: &TpmAttestationIdentity, + data: &[u8], + evidence: &TpmEvidence, +) -> TestResult { + check( + evidence.ek_public == metadata.ek_public, + "EK changed during attestation", + )?; + check( + evidence.ak_public == metadata.ak_public, + "AK changed during attestation", + )?; + check( + evidence.ek_certificate_chain_der == metadata.ek_certificate_chain_der, + "EK certificates changed", + )?; + let ak = signing_key(&evidence.ak_public, true)?; + let identity = signing_key(&evidence.identity_public, false)?; + check( + evidence.signature.algorithm == SignatureAlgorithm::EcdsaSha256, + "expected ECDSA/SHA-256 attestation", + )?; + verify_signature(&ak, &evidence.attest, &evidence.signature.bytes)?; + + let mut reader = Reader::new(&evidence.attest); + check(reader.u32()? == 0xff54_4347, "invalid TPM generated magic")?; + check(reader.u16()? == 0x8017, "expected TPM_ST_ATTEST_CERTIFY")?; + reader.sized()?; + check( + reader.sized()? == &Sha256::digest(data)[..], + "attestation does not bind the exact Core data", + )?; + reader.take(17)?; + reader.take(8)?; + check( + reader.sized()? == name(&evidence.identity_public), + "certified key Name mismatch", + )?; + reader.sized()?; + reader.finish()?; + Ok(identity) +} + +pub fn make_credential( + metadata: &TpmAttestationIdentity, + secret: &[u8], +) -> TestResult { + signing_key(&metadata.ak_public, true)?; + let mut reader = Reader::new(&metadata.ek_public); + let algorithm = reader.u16()?; + check( + reader.u16()? == SHA256, + "test verifier requires a SHA-256 EK", + )?; + reader.u32()?; + reader.sized()?; + check(reader.u16()? == 0x0006, "test verifier requires an AES EK")?; + check(reader.u16()? == 128, "test verifier requires AES-128")?; + check(reader.u16()? == 0x0043, "test verifier requires AES-CFB")?; + check(reader.u16()? == NULL, "expected null EK scheme")?; + + let (seed, encrypted_secret) = match algorithm { + 0x0001 => { + check(reader.u16()? == 2048, "test verifier requires RSA-2048 EK")?; + let exponent = match reader.u32()? { + 0 => 65537, + value => value, + }; + let modulus = reader.sized()?; + let public = RsaPublicKey::new(BigUint::from_bytes_be(modulus), exponent.into()) + .map_err(|error| error.to_string())?; + let mut seed = vec![0u8; 32]; + OsRng.fill_bytes(&mut seed); + let encrypted = public + .encrypt( + &mut OsRng, + Oaep::new_with_label::("IDENTITY\0"), + &seed, + ) + .map_err(|error| error.to_string())?; + (seed, encrypted) + } + ECC => { + check(reader.u16()? == P256, "test verifier requires P-256 EK")?; + check(reader.u16()? == NULL, "expected null EK KDF")?; + let x = reader.sized()?; + let y = reader.sized()?; + let public = public_point(x, y)?; + let ephemeral = EphemeralSecret::random(&mut OsRng); + let ephemeral_public = PublicKey::from(&ephemeral).to_encoded_point(false); + let shared = ephemeral.diffie_hellman(&public); + let ek_point = public.to_encoded_point(false); + let seed = Sha256::digest( + [ + 1u32.to_be_bytes().as_slice(), + &shared.raw_secret_bytes()[..], + b"IDENTITY\0", + &ephemeral_public.x().unwrap()[..], + &ek_point.x().unwrap()[..], + ] + .concat(), + ) + .to_vec(); + let encrypted = [ + sized(ephemeral_public.x().unwrap()), + sized(ephemeral_public.y().unwrap()), + ] + .concat(); + (seed, encrypted) + } + _ => return Err("unsupported EK algorithm in test fixture".into()), + }; + reader.finish()?; + + let ak_name = name(&metadata.ak_public); + let storage_key = kdfa(&seed, b"STORAGE", &ak_name, 128); + let mut ciphertext = sized(secret); + cfb_mode::Encryptor::::new(storage_key.as_slice().into(), &[0; 16].into()) + .encrypt(&mut ciphertext); + let integrity_key = kdfa(&seed, b"INTEGRITY", &[], 256); + let mut mac = ::new_from_slice(&integrity_key).unwrap(); + mac.update(&ciphertext); + mac.update(&ak_name); + let integrity = mac.finalize().into_bytes(); + Ok(CredentialChallenge { + credential_blob: [sized(&integrity), ciphertext].concat(), + encrypted_secret, + }) +} + +fn sized(bytes: &[u8]) -> Vec { + [(bytes.len() as u16).to_be_bytes().as_slice(), bytes].concat() +} + +fn kdfa(seed: &[u8], label: &[u8], context: &[u8], bits: u32) -> Vec { + let mut mac = ::new_from_slice(seed).unwrap(); + mac.update(&1u32.to_be_bytes()); + mac.update(label); + mac.update(&[0]); + mac.update(context); + mac.update(&bits.to_be_bytes()); + mac.finalize().into_bytes()[..(bits / 8) as usize].to_vec() +} diff --git a/src-tauri/enterprise/device-identity/tests/support/verifier_tests.rs b/src-tauri/enterprise/device-identity/tests/support/verifier_tests.rs new file mode 100644 index 00000000..47a76bd5 --- /dev/null +++ b/src-tauri/enterprise/device-identity/tests/support/verifier_tests.rs @@ -0,0 +1,129 @@ +use ciborium::Value; +use device_identity::{Signature, SignatureAlgorithm, TpmAttestationIdentity, TpmEvidence}; +use p256::ecdsa::{signature::Signer, Signature as EcdsaSignature, SigningKey}; +use sha2::{Digest, Sha256}; + +use super::{apple, tpm}; + +fn key(value: u8) -> SigningKey { + SigningKey::from_slice(&[value; 32]).unwrap() +} + +fn sized(bytes: &[u8]) -> Vec { + [(bytes.len() as u16).to_be_bytes().as_slice(), bytes].concat() +} + +fn public(key: &SigningKey, restricted: bool) -> Vec { + let point = key.verifying_key().to_encoded_point(false); + let attributes = 0x0004_0072u32 | if restricted { 0x0001_0000 } else { 0 }; + [ + 0x0023u16.to_be_bytes().as_slice(), + 0x000bu16.to_be_bytes().as_slice(), + &attributes.to_be_bytes(), + &sized(&[]), + &0x0010u16.to_be_bytes(), + &0x0018u16.to_be_bytes(), + &0x000bu16.to_be_bytes(), + &0x0003u16.to_be_bytes(), + &0x0010u16.to_be_bytes(), + &sized(point.x().unwrap()), + &sized(point.y().unwrap()), + ] + .concat() +} + +#[test] +fn test_independent_tpm_verifier_checks_certify_signature_name_profile_and_data() { + let ak = key(1); + let identity = key(2); + let data = b"opaque Core data\0\xff"; + let ak_public = public(&ak, true); + let identity_public = public(&identity, false); + let attest = [ + 0xff54_4347u32.to_be_bytes().as_slice(), + &0x8017u16.to_be_bytes(), + &sized(&tpm::name(&ak_public)), + &sized(&Sha256::digest(data)), + &[0u8; 17], + &[0u8; 8], + &sized(&tpm::name(&identity_public)), + &sized(&[]), + ] + .concat(); + let signature: EcdsaSignature = ak.sign(&attest); + let metadata = TpmAttestationIdentity { + ek_public: vec![1], + ek_certificate_chain_der: vec![], + ak_public: ak_public.clone(), + }; + let evidence = TpmEvidence { + ek_public: metadata.ek_public.clone(), + ek_certificate_chain_der: vec![], + ak_public, + identity_public, + attest, + signature: Signature { + algorithm: SignatureAlgorithm::EcdsaSha256, + bytes: signature.to_der().as_bytes().to_vec(), + }, + }; + assert_eq!( + tpm::verify_attestation(&metadata, data, &evidence).unwrap(), + *identity.verifying_key() + ); + assert!(tpm::verify_attestation(&metadata, b"different Core data", &evidence).is_err()); + + let mut wrong_key = evidence.clone(); + wrong_key.identity_public = public(&key(3), false); + assert!(tpm::verify_attestation(&metadata, data, &wrong_key).is_err()); + + let mut wrong_profile = evidence.clone(); + wrong_profile.identity_public = public(&identity, true); + assert!(tpm::verify_attestation(&metadata, data, &wrong_profile).is_err()); + + let mut wrong_signature = evidence; + wrong_signature.signature.bytes[0] ^= 1; + assert!(tpm::verify_attestation(&metadata, data, &wrong_signature).is_err()); +} + +#[test] +fn test_independent_apple_assertion_verifier_checks_signature_data_key_and_counter() { + let key = key(4); + let data = b"opaque Core data\0\xff"; + let app_id_hash = Sha256::digest(b"TESTTEAM.example.defguard").to_vec(); + let auth_data = [app_id_hash.as_slice(), &[0], &1u32.to_be_bytes()].concat(); + let message = [auth_data.as_slice(), &Sha256::digest(data)].concat(); + let signature: EcdsaSignature = key.sign(&message); + let object = Value::Map(vec![ + ( + Value::Text("signature".into()), + Value::Bytes(signature.to_der().as_bytes().to_vec()), + ), + ( + Value::Text("authenticatorData".into()), + Value::Bytes(auth_data), + ), + ]); + let mut encoded = vec![]; + ciborium::into_writer(&object, &mut encoded).unwrap(); + let verifier = apple::AppleVerifier { + key: *key.verifying_key(), + app_id_hash, + counter: 0, + }; + let mut valid = verifier.clone(); + apple::verify_assertion(&mut valid, data, &encoded).unwrap(); + assert_eq!(valid.counter, 1); + assert!(apple::verify_assertion(&mut valid, data, &encoded).is_err()); + assert!( + apple::verify_assertion(&mut verifier.clone(), b"different Core data", &encoded).is_err() + ); + + let mut wrong_key = verifier.clone(); + wrong_key.key = *self::key(5).verifying_key(); + assert!(apple::verify_assertion(&mut wrong_key, data, &encoded).is_err()); + + let mut wrong_app = verifier; + wrong_app.app_id_hash[0] ^= 1; + assert!(apple::verify_assertion(&mut wrong_app, data, &encoded).is_err()); +} From 0e0b2b7841a2dcc9768a05f6e3320b37bac1d3a6 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 10:53:52 +0100 Subject: [PATCH 15/21] limit backend functions visibility to pub(super) --- .../enterprise/device-identity/src/backend/linux.rs | 10 +++++----- .../enterprise/device-identity/src/backend/macos.rs | 10 +++++----- .../enterprise/device-identity/src/backend/windows.rs | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src-tauri/enterprise/device-identity/src/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs index b27dd11d..ea14ee8e 100644 --- a/src-tauri/enterprise/device-identity/src/backend/linux.rs +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -3,28 +3,28 @@ use crate::{ DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, }; -pub(crate) fn capabilities() -> Result { +pub(super) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn create_identity() -> Result { +pub(super) async fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn attestation_identity( +pub(super) fn attestation_identity( _identity: &IdentityRef, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn attest_identity( +pub(super) async fn attest_identity( _identity: &IdentityRef, _request: &AttestationRequest, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn prove_possession( +pub(super) async fn prove_possession( _identity: &IdentityRef, _challenge: &PossessionChallenge, ) -> Result { diff --git a/src-tauri/enterprise/device-identity/src/backend/macos.rs b/src-tauri/enterprise/device-identity/src/backend/macos.rs index b27dd11d..ea14ee8e 100644 --- a/src-tauri/enterprise/device-identity/src/backend/macos.rs +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -3,28 +3,28 @@ use crate::{ DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, }; -pub(crate) fn capabilities() -> Result { +pub(super) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn create_identity() -> Result { +pub(super) async fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn attestation_identity( +pub(super) fn attestation_identity( _identity: &IdentityRef, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn attest_identity( +pub(super) async fn attest_identity( _identity: &IdentityRef, _request: &AttestationRequest, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn prove_possession( +pub(super) async fn prove_possession( _identity: &IdentityRef, _challenge: &PossessionChallenge, ) -> Result { diff --git a/src-tauri/enterprise/device-identity/src/backend/windows.rs b/src-tauri/enterprise/device-identity/src/backend/windows.rs index b27dd11d..ea14ee8e 100644 --- a/src-tauri/enterprise/device-identity/src/backend/windows.rs +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -3,28 +3,28 @@ use crate::{ DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, }; -pub(crate) fn capabilities() -> Result { +pub(super) fn capabilities() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn create_identity() -> Result { +pub(super) async fn create_identity() -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) fn attestation_identity( +pub(super) fn attestation_identity( _identity: &IdentityRef, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn attest_identity( +pub(super) async fn attest_identity( _identity: &IdentityRef, _request: &AttestationRequest, ) -> Result { Err(DeviceIdentityError::Unsupported) } -pub(crate) async fn prove_possession( +pub(super) async fn prove_possession( _identity: &IdentityRef, _challenge: &PossessionChallenge, ) -> Result { From 9b9d809b5a5baf8ea28cb0ed65d520ec74a93bd7 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 11:06:51 +0100 Subject: [PATCH 16/21] bump glib cve expiration date --- .trivyignore.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.trivyignore.yaml b/.trivyignore.yaml index dbd015a4..b0a7bfd4 100644 --- a/.trivyignore.yaml +++ b/.trivyignore.yaml @@ -1,4 +1,4 @@ vulnerabilities: - id: GHSA-wrw7-89jp-8q8g - expired_at: 2026-10-30 + expired_at: 2026-11-30 statement: "glib is a transitive dependency of Tauri which we cannot update ourselves. Waiting for tauri to finish migration to gtk4-rs: https://github.com/tauri-apps/tauri/issues/12563" From a9072add02c933d904aa0d6a09c4e00cc167b01c Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 11:16:27 +0100 Subject: [PATCH 17/21] bump libz-sys (cve) --- src-tauri/Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 244faaa0..2622819e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1402,7 +1402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 3.0.9", + "syn 2.0.119", ] [[package]] @@ -4014,9 +4014,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.30" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "libc", @@ -7772,7 +7772,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", From 84f4a0ba54c7f8edadce089db86025e56865af3f Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Fri, 30 Oct 2026 13:24:04 +0100 Subject: [PATCH 18/21] add device-identity proto --- src-tauri/Cargo.lock | 1 + src-tauri/cli/src/bin/dg.rs | 19 +++++++- src-tauri/client-proto/Cargo.toml | 1 + src-tauri/client-proto/src/lib.rs | 6 +++ .../client-proto/tests/device_identity.rs | 43 +++++++++++++++++++ src-tauri/enterprise/config-sync/src/lib.rs | 1 + src-tauri/proto | 2 +- 7 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src-tauri/client-proto/tests/device_identity.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2622819e..9c119633 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1758,6 +1758,7 @@ dependencies = [ "defguard_wireguard_rs", "prost", "serde", + "serde_json", "serde_with", "tonic", "tonic-prost", diff --git a/src-tauri/cli/src/bin/dg.rs b/src-tauri/cli/src/bin/dg.rs index ada0643a..fefe113f 100644 --- a/src-tauri/cli/src/bin/dg.rs +++ b/src-tauri/cli/src/bin/dg.rs @@ -42,6 +42,13 @@ mod proto { include!(concat!(env!("OUT_DIR"), "/defguard.client_types.rs")); } + #[allow(dead_code)] + pub mod device_identity { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/defguard.device_identity.v1.rs")); + } + } + #[allow(dead_code)] pub mod enterprise { pub mod posture { @@ -312,7 +319,11 @@ async fn enroll(base_url: &Url, token: String) -> Result { url.set_path("/api/v1/enrollment/start"); let result = client .post(url) - .json(&EnrollmentStartRequest { token }) + .json(&EnrollmentStartRequest { + token, + proposed_wireguard_pubkey: None, + device_identity: None, + }) .send() .await?; @@ -348,6 +359,7 @@ async fn enroll(base_url: &Url, token: String) -> Result { name: String::new(), pubkey: pubkey.to_string(), token: None, //Some(config.token.clone()), + attestation_response: None, }) .send() .await?; @@ -399,7 +411,10 @@ const HTTP_REQ_TIMEOUT: Duration = Duration::from_secs(5); async fn fetch_config(client: &Client, url: Url, token: String) -> Result { let result = client .post(url.clone()) - .json(&InstanceInfoRequest { token }) + .json(&InstanceInfoRequest { + token, + possession_response: None, + }) .timeout(HTTP_REQ_TIMEOUT) .send() .await?; diff --git a/src-tauri/client-proto/Cargo.toml b/src-tauri/client-proto/Cargo.toml index f144cab3..25c403af 100644 --- a/src-tauri/client-proto/Cargo.toml +++ b/src-tauri/client-proto/Cargo.toml @@ -24,4 +24,5 @@ tracing.workspace = true wmi.workspace = true [dev-dependencies] +serde_json.workspace = true x25519-dalek = { workspace = true, features = ["getrandom", "static_secrets"] } diff --git a/src-tauri/client-proto/src/lib.rs b/src-tauri/client-proto/src/lib.rs index 8d082c93..cf7d7eee 100644 --- a/src-tauri/client-proto/src/lib.rs +++ b/src-tauri/client-proto/src/lib.rs @@ -7,6 +7,12 @@ pub mod defguard { tonic::include_proto!("defguard.client_types"); } + pub mod device_identity { + pub mod v1 { + tonic::include_proto!("defguard.device_identity.v1"); + } + } + pub mod client { pub mod v1 { tonic::include_proto!("defguard.client.v1"); diff --git a/src-tauri/client-proto/tests/device_identity.rs b/src-tauri/client-proto/tests/device_identity.rs new file mode 100644 index 00000000..d3fbbf85 --- /dev/null +++ b/src-tauri/client-proto/tests/device_identity.rs @@ -0,0 +1,43 @@ +use defguard_client_proto::defguard::{ + client_types::{EnrollmentStartRequest, InstanceInfoRequest}, + device_identity::v1::{attestation_request, AttestationRequest, CredentialChallenge}, +}; +use prost::Message; + +#[test] +fn test_device_identity_attestation_requests_roundtrip_json_and_protobuf() { + for challenge in [ + attestation_request::Challenge::Tpm(CredentialChallenge { + credential_blob: vec![0, 255, 128], + encrypted_secret: vec![1, 254, 127], + }), + attestation_request::Challenge::AppleAppAttest(()), + ] { + let request = AttestationRequest { + session_id: "session".into(), + data: vec![0, 255, 128, 1], + challenge: Some(challenge), + expires_at: 1_800_000_000, + }; + let json = serde_json::to_vec(&request).unwrap(); + let decoded: AttestationRequest = serde_json::from_slice(&json).unwrap(); + assert_eq!(decoded, request); + let binary = request.encode_to_vec(); + assert_eq!( + AttestationRequest::decode(binary.as_slice()).unwrap(), + request + ); + } +} + +#[test] +fn test_device_identity_fields_can_be_absent_in_legacy_json() { + let enrollment: EnrollmentStartRequest = + serde_json::from_str(r#"{"token":"enrollment-token"}"#).unwrap(); + assert!(enrollment.device_identity.is_none()); + assert!(enrollment.proposed_wireguard_pubkey.is_none()); + + let polling: InstanceInfoRequest = + serde_json::from_str(r#"{"token":"polling-token"}"#).unwrap(); + assert!(polling.possession_response.is_none()); +} diff --git a/src-tauri/enterprise/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index d4dcaf98..895fcbd8 100644 --- a/src-tauri/enterprise/config-sync/src/lib.rs +++ b/src-tauri/enterprise/config-sync/src/lib.rs @@ -345,6 +345,7 @@ fn build_request(instance: &Instance) -> Result Ok(InstanceInfoRequest { token: (*token).clone(), + possession_response: None, }) } diff --git a/src-tauri/proto b/src-tauri/proto index 2a39d0a0..dd0b2456 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 2a39d0a09ebed4fe3ea08f1b54be5259ec63bed6 +Subproject commit dd0b2456aa9f57eabab626d799dd5052a9dd7420 From 8bdb0b4ffb05b4238c08c8f477298521b61cfa4d Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 2 Nov 2026 10:25:38 +0100 Subject: [PATCH 19/21] posession challenge --- src-tauri/core/src/mfa.rs | 6 ++++++ src-tauri/enterprise/posture/src/posture.rs | 6 ++++++ src-tauri/proto | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src-tauri/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index f5164e03..0b99264e 100644 --- a/src-tauri/core/src/mfa.rs +++ b/src-tauri/core/src/mfa.rs @@ -325,6 +325,7 @@ pub async fn mfa_start( } MfaContract::MultiStep => { let request = MfaFlowStartRequest { + possession_response: None, location_id: request.location_id, pubkey: request.pubkey, posture_data: request.posture_data, @@ -386,6 +387,11 @@ pub async fn mfa_start( message: messages.join(" "), }); } + Some(mfa_flow_start_response::Outcome::PossessionChallenge(_)) => { + return Err(MfaError::Other { + message: "Device identity possession challenge is not supported yet".into(), + }); + } None => { return Err(MfaError::Other { message: "MFA flow start response did not include an outcome".into(), diff --git a/src-tauri/enterprise/posture/src/posture.rs b/src-tauri/enterprise/posture/src/posture.rs index b1d59366..dc3c179d 100644 --- a/src-tauri/enterprise/posture/src/posture.rs +++ b/src-tauri/enterprise/posture/src/posture.rs @@ -71,6 +71,7 @@ pub async fn request_posture_authorization( pubkey: device_pubkey, device_posture_data: Some(posture_data), token: Some(token), + possession_response: None, }; let url = Url::parse(proxy_url) @@ -89,6 +90,11 @@ pub async fn request_posture_authorization( .json() .await .map_err(|e| Error::HttpError(e.to_string()))?; + if body.possession_challenge.is_some() { + return Err(Error::PostureCheckFailed( + "Device identity possession challenge is not supported yet".into(), + )); + } info!("Posture check approved for location {location_id}"); Ok((!body.preshared_key.is_empty()).then_some(body.preshared_key)) } diff --git a/src-tauri/proto b/src-tauri/proto index dd0b2456..2a278340 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit dd0b2456aa9f57eabab626d799dd5052a9dd7420 +Subproject commit 2a278340b9fdc9458784604edd5e45206c6a3b4a From 405bd5f2f41f3523b047a9b8d8effa240961bef2 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Mon, 2 Nov 2026 11:50:29 +0100 Subject: [PATCH 20/21] bump proto --- src-tauri/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/proto b/src-tauri/proto index 2a278340..c9cd0c87 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 2a278340b9fdc9458784604edd5e45206c6a3b4a +Subproject commit c9cd0c87eee32d4e7c9bc8df4cbaeafcb239231b From c311eca270b46d3f76295b2b33854ec3151c89b9 Mon Sep 17 00:00:00 2001 From: Jacek Chmielewski Date: Tue, 3 Nov 2026 11:30:37 +0100 Subject: [PATCH 21/21] rename proposed_wireguard_key -> wireguard_key --- src-tauri/cli/src/bin/dg.rs | 2 +- src-tauri/client-proto/tests/device_identity.rs | 2 +- src-tauri/proto | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/cli/src/bin/dg.rs b/src-tauri/cli/src/bin/dg.rs index fefe113f..f3d9e4ed 100644 --- a/src-tauri/cli/src/bin/dg.rs +++ b/src-tauri/cli/src/bin/dg.rs @@ -321,7 +321,7 @@ async fn enroll(base_url: &Url, token: String) -> Result { .post(url) .json(&EnrollmentStartRequest { token, - proposed_wireguard_pubkey: None, + wireguard_key: None, device_identity: None, }) .send() diff --git a/src-tauri/client-proto/tests/device_identity.rs b/src-tauri/client-proto/tests/device_identity.rs index d3fbbf85..946bd60a 100644 --- a/src-tauri/client-proto/tests/device_identity.rs +++ b/src-tauri/client-proto/tests/device_identity.rs @@ -35,7 +35,7 @@ fn test_device_identity_fields_can_be_absent_in_legacy_json() { let enrollment: EnrollmentStartRequest = serde_json::from_str(r#"{"token":"enrollment-token"}"#).unwrap(); assert!(enrollment.device_identity.is_none()); - assert!(enrollment.proposed_wireguard_pubkey.is_none()); + assert!(enrollment.wireguard_key.is_none()); let polling: InstanceInfoRequest = serde_json::from_str(r#"{"token":"polling-token"}"#).unwrap(); diff --git a/src-tauri/proto b/src-tauri/proto index c9cd0c87..587ab1e0 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit c9cd0c87eee32d4e7c9bc8df4cbaeafcb239231b +Subproject commit 587ab1e03c5d6410e6ecc5abc56379aeb167d4da