diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b537d11e5..afeb77372 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" @@ -1342,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]] @@ -1351,7 +1411,7 @@ version = "3.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe0d3f8b069ea5c3da6d9d0d483a39cd09df25457aff86033ae8f09661be0396" dependencies = [ - "aes", + "aes 0.9.3", "anyhow", "base64 0.23.1", "byteorder", @@ -1698,6 +1758,7 @@ dependencies = [ "defguard_wireguard_rs", "prost", "serde", + "serde_json", "serde_with", "tonic", "tonic-prost", @@ -1802,7 +1863,7 @@ dependencies = [ "blake2", "chacha20poly1305", "hex", - "hmac", + "hmac 0.13.0", "ip_network", "ip_network_table", "libc", @@ -1871,6 +1932,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" @@ -1915,6 +1987,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +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]] name = "digest" version = "0.10.7" @@ -1922,7 +2013,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]] @@ -1932,7 +2025,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", ] @@ -2115,6 +2208,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" @@ -2124,6 +2231,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" @@ -2325,6 +2453,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" @@ -2669,6 +2807,7 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2838,6 +2977,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" @@ -3004,13 +3154,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]] @@ -3395,6 +3563,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" @@ -3719,6 +3896,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" @@ -4286,6 +4466,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" @@ -4350,6 +4546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4817,6 +5014,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" @@ -4914,6 +5123,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" @@ -5021,6 +5239,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" @@ -5142,6 +5381,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" @@ -5461,13 +5709,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", ] @@ -5482,6 +5740,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" @@ -5492,6 +5760,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" @@ -5544,7 +5821,7 @@ dependencies = [ "paste", "profiling", "rand 0.9.5", - "rand_chacha", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.21", "v_frame", @@ -5789,6 +6066,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" @@ -5835,6 +6122,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" @@ -6046,6 +6353,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" @@ -6336,6 +6657,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" @@ -6467,6 +6798,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" @@ -6601,8 +6942,8 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", @@ -7432,7 +7773,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", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 19d7b1805..f59fd782a 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/device-identity"] default-members = ["cli", "client-cli", "daemon", "."] [workspace.dependencies] diff --git a/src-tauri/cli/src/bin/dg.rs b/src-tauri/cli/src/bin/dg.rs index ada0643ae..f3d9e4ed4 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, + wireguard_key: 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 f144cab32..25c403af3 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 8d082c93a..cf7d7eee9 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 000000000..946bd60a5 --- /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.wireguard_key.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/core/src/mfa.rs b/src-tauri/core/src/mfa.rs index f5164e03e..0b99264ed 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/config-sync/src/lib.rs b/src-tauri/enterprise/config-sync/src/lib.rs index d4dcaf981..895fcbd8a 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/enterprise/device-identity/Cargo.toml b/src-tauri/enterprise/device-identity/Cargo.toml new file mode 100644 index 000000000..0352e565b --- /dev/null +++ b/src-tauri/enterprise/device-identity/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "device-identity" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license-file.workspace = true +rust-version.workspace = true +version.workspace = true + +[dependencies] +thiserror.workspace = true +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/backend/linux.rs b/src-tauri/enterprise/device-identity/src/backend/linux.rs new file mode 100644 index 000000000..ea14ee8ec --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/backend/linux.rs @@ -0,0 +1,32 @@ +use crate::{ + AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, + DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, +}; + +pub(super) fn capabilities() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async fn create_identity() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) fn attestation_identity( + _identity: &IdentityRef, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async fn attest_identity( + _identity: &IdentityRef, + _request: &AttestationRequest, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async 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 000000000..ea14ee8ec --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/backend/macos.rs @@ -0,0 +1,32 @@ +use crate::{ + AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, + DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, +}; + +pub(super) fn capabilities() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async fn create_identity() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) fn attestation_identity( + _identity: &IdentityRef, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async fn attest_identity( + _identity: &IdentityRef, + _request: &AttestationRequest, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async 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 000000000..ea14ee8ec --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/backend/windows.rs @@ -0,0 +1,32 @@ +use crate::{ + AttestationIdentity, AttestationRequest, AttestationResponse, Capabilities, + DeviceIdentityError, IdentityRef, PossessionChallenge, PossessionProof, +}; + +pub(super) fn capabilities() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async fn create_identity() -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) fn attestation_identity( + _identity: &IdentityRef, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async fn attest_identity( + _identity: &IdentityRef, + _request: &AttestationRequest, +) -> Result { + Err(DeviceIdentityError::Unsupported) +} + +pub(super) async 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 new file mode 100644 index 000000000..500c3c1f6 --- /dev/null +++ b/src-tauri/enterprise/device-identity/src/lib.rs @@ -0,0 +1,388 @@ +use thiserror::Error; + +#[cfg(target_os = "linux")] +#[path = "backend/linux.rs"] +mod backend; +#[cfg(target_os = "macos")] +#[path = "backend/macos.rs"] +mod backend; +#[cfg(windows)] +#[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 + pub id: AlgorithmId, + /// 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( + /// 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( + /// 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 + 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(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, +} + +/// 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 + pub id: String, +} + +/// 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, + }, +} + +/// Public TPM keys used by Core to prepare MakeCredential +#[derive(Debug, Clone, PartialEq, Eq)] +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-encoded challenge for a proof of identity key possession +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PossessionChallenge { + /// 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 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AttestationChallenge { + /// Credential activation challenge bound to the TPM endorsement and attestation keys + Tpm(CredentialChallenge), + /// 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 { + /// 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, +} + +/// 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 accompanying the credential activation result +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TpmEvidence { + /// 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, + /// Certified identity key encoded as marshalled TPMT_PUBLIC + pub identity_public: Vec, + /// 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, + /// 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 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, + /// 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 identity + #[error("hardware identity challenge is invalid")] + InvalidChallenge, + /// Backend failure not represented by a more specific variant + #[error("hardware identity backend failed")] + BackendFailure, +} + +/// Returns client-reported backend capabilities. +pub fn capabilities() -> Result { + backend::capabilities() +} + +/// 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 async fn create_identity() -> Result { + backend::create_identity().await +} + +/// Returns the identity's public attestation data for enrollment with Core. +pub fn attestation_identity( + identity: &IdentityRef, +) -> Result { + backend::attestation_identity(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 async fn attest_identity( + identity: &IdentityRef, + request: &AttestationRequest, +) -> Result { + 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 async fn prove_possession( + identity: &IdentityRef, + challenge: &PossessionChallenge, +) -> Result { + backend::prove_possession(identity, challenge).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_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()); + } +} 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 000000000..89a34699b --- /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 000000000..0aa3b5107 --- /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 000000000..a96d0f66f --- /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 000000000..a97f8e90f --- /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 000000000..00b59b4ff --- /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 000000000..47a76bd54 --- /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()); +} diff --git a/src-tauri/enterprise/posture/src/posture.rs b/src-tauri/enterprise/posture/src/posture.rs index b1d59366b..dc3c179d6 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 85351455e..587ab1e03 160000 --- a/src-tauri/proto +++ b/src-tauri/proto @@ -1 +1 @@ -Subproject commit 85351455ecf6e986fc6940cdbc4c403e3daf3bde +Subproject commit 587ab1e03c5d6410e6ecc5abc56379aeb167d4da