From cb0d4aaa638dbc321363e516ae8c26e42aece424 Mon Sep 17 00:00:00 2001 From: Jay Gowdy Date: Wed, 3 Jun 2026 18:37:39 -0700 Subject: [PATCH] Drop all legacy enclaveapp-* workspace members (v0.2.6) All consuming apps now use hardware-enclave directly: - sshenc force-software: uses hardware-enclave MockSigner (PR #250) - sshenc-tpm-bridge: uses hardware_enclave::bridge_server::BridgeServer - sshenc fuzz: uses hardware_enclave::bridge_server::{BridgeResponse, read_line_bounded} - gocode-dev: already on hardware-enclave since v0.1.6 Deleted: enclaveapp-core, enclaveapp-bridge, enclaveapp-windows, enclaveapp-windows-webauthn, enclaveapp-tpm-bridge, enclaveapp-test-software, enclaveapp-test-support Added to bridge_server public API: BridgeResponse, read_line_bounded, BridgeRequestCompat, BridgeParamsCompat, TpmSigningStorage --- Cargo.lock | 218 +-- Cargo.toml | 29 +- crates/enclaveapp-bridge/Cargo.toml | 17 - crates/enclaveapp-bridge/README.md | 49 - crates/enclaveapp-bridge/src/client.rs | 1653 ----------------- crates/enclaveapp-bridge/src/lib.rs | 14 - crates/enclaveapp-bridge/src/protocol.rs | 561 ------ crates/enclaveapp-core/Cargo.toml | 26 - crates/enclaveapp-core/README.md | 34 - crates/enclaveapp-core/src/bin_discovery.rs | 469 ----- crates/enclaveapp-core/src/config.rs | 189 -- crates/enclaveapp-core/src/config_block.rs | 637 ------- crates/enclaveapp-core/src/daemon.rs | 801 -------- crates/enclaveapp-core/src/error.rs | 285 --- crates/enclaveapp-core/src/lib.rs | 26 - crates/enclaveapp-core/src/metadata.rs | 1414 -------------- crates/enclaveapp-core/src/platform.rs | 100 - crates/enclaveapp-core/src/process.rs | 285 --- crates/enclaveapp-core/src/quoting.rs | 188 -- crates/enclaveapp-core/src/signing.rs | 92 - crates/enclaveapp-core/src/timeout.rs | 617 ------ crates/enclaveapp-core/src/traits.rs | 126 -- crates/enclaveapp-core/src/types.rs | 385 ---- crates/enclaveapp-test-software/Cargo.toml | 28 - .../enclaveapp-test-software/src/encrypt.rs | 733 -------- .../src/key_storage.rs | 398 ---- crates/enclaveapp-test-software/src/lib.rs | 35 - crates/enclaveapp-test-software/src/sign.rs | 386 ---- crates/enclaveapp-test-support/Cargo.toml | 16 - crates/enclaveapp-test-support/README.md | 43 - crates/enclaveapp-test-support/src/lib.rs | 12 - crates/enclaveapp-test-support/src/mock.rs | 855 --------- crates/enclaveapp-tpm-bridge/Cargo.toml | 22 - crates/enclaveapp-tpm-bridge/src/lib.rs | 1158 ------------ crates/enclaveapp-tpm-bridge/src/tpm.rs | 541 ------ crates/enclaveapp-windows-webauthn/Cargo.toml | 19 - crates/enclaveapp-windows-webauthn/src/lib.rs | 143 -- .../enclaveapp-windows-webauthn/src/stub.rs | 46 - .../src/windows_impl.rs | 570 ------ .../tests/hardware_smoke.rs | 80 - crates/enclaveapp-windows/Cargo.toml | 32 - crates/enclaveapp-windows/README.md | 52 - crates/enclaveapp-windows/src/convert.rs | 542 ------ crates/enclaveapp-windows/src/dpapi.rs | 102 - .../enclaveapp-windows/src/dpapi_encrypt.rs | 511 ----- .../enclaveapp-windows/src/dpapi_fallback.rs | 645 ------- crates/enclaveapp-windows/src/encrypt.rs | 933 ---------- crates/enclaveapp-windows/src/export.rs | 62 - crates/enclaveapp-windows/src/hello_gate.rs | 299 --- crates/enclaveapp-windows/src/key.rs | 244 --- crates/enclaveapp-windows/src/lib.rs | 58 - crates/enclaveapp-windows/src/meta_hmac.rs | 412 ---- .../src/meta_migration_marker.rs | 199 -- crates/enclaveapp-windows/src/meta_tag.rs | 408 ---- .../enclaveapp-windows/src/password_gate.rs | 346 ---- crates/enclaveapp-windows/src/provider.rs | 81 - crates/enclaveapp-windows/src/sign.rs | 374 ---- crates/enclaveapp-windows/src/state.rs | 308 --- crates/enclaveapp-windows/src/ui_policy.rs | 362 ---- crates/hardware-enclave/src/bridge_server.rs | 6 +- 60 files changed, 77 insertions(+), 19199 deletions(-) delete mode 100644 crates/enclaveapp-bridge/Cargo.toml delete mode 100644 crates/enclaveapp-bridge/README.md delete mode 100644 crates/enclaveapp-bridge/src/client.rs delete mode 100644 crates/enclaveapp-bridge/src/lib.rs delete mode 100644 crates/enclaveapp-bridge/src/protocol.rs delete mode 100644 crates/enclaveapp-core/Cargo.toml delete mode 100644 crates/enclaveapp-core/README.md delete mode 100644 crates/enclaveapp-core/src/bin_discovery.rs delete mode 100644 crates/enclaveapp-core/src/config.rs delete mode 100644 crates/enclaveapp-core/src/config_block.rs delete mode 100644 crates/enclaveapp-core/src/daemon.rs delete mode 100644 crates/enclaveapp-core/src/error.rs delete mode 100644 crates/enclaveapp-core/src/lib.rs delete mode 100644 crates/enclaveapp-core/src/metadata.rs delete mode 100644 crates/enclaveapp-core/src/platform.rs delete mode 100644 crates/enclaveapp-core/src/process.rs delete mode 100644 crates/enclaveapp-core/src/quoting.rs delete mode 100644 crates/enclaveapp-core/src/signing.rs delete mode 100644 crates/enclaveapp-core/src/timeout.rs delete mode 100644 crates/enclaveapp-core/src/traits.rs delete mode 100644 crates/enclaveapp-core/src/types.rs delete mode 100644 crates/enclaveapp-test-software/Cargo.toml delete mode 100644 crates/enclaveapp-test-software/src/encrypt.rs delete mode 100644 crates/enclaveapp-test-software/src/key_storage.rs delete mode 100644 crates/enclaveapp-test-software/src/lib.rs delete mode 100644 crates/enclaveapp-test-software/src/sign.rs delete mode 100644 crates/enclaveapp-test-support/Cargo.toml delete mode 100644 crates/enclaveapp-test-support/README.md delete mode 100644 crates/enclaveapp-test-support/src/lib.rs delete mode 100644 crates/enclaveapp-test-support/src/mock.rs delete mode 100644 crates/enclaveapp-tpm-bridge/Cargo.toml delete mode 100644 crates/enclaveapp-tpm-bridge/src/lib.rs delete mode 100644 crates/enclaveapp-tpm-bridge/src/tpm.rs delete mode 100644 crates/enclaveapp-windows-webauthn/Cargo.toml delete mode 100644 crates/enclaveapp-windows-webauthn/src/lib.rs delete mode 100644 crates/enclaveapp-windows-webauthn/src/stub.rs delete mode 100644 crates/enclaveapp-windows-webauthn/src/windows_impl.rs delete mode 100644 crates/enclaveapp-windows-webauthn/tests/hardware_smoke.rs delete mode 100644 crates/enclaveapp-windows/Cargo.toml delete mode 100644 crates/enclaveapp-windows/README.md delete mode 100644 crates/enclaveapp-windows/src/convert.rs delete mode 100644 crates/enclaveapp-windows/src/dpapi.rs delete mode 100644 crates/enclaveapp-windows/src/dpapi_encrypt.rs delete mode 100644 crates/enclaveapp-windows/src/dpapi_fallback.rs delete mode 100644 crates/enclaveapp-windows/src/encrypt.rs delete mode 100644 crates/enclaveapp-windows/src/export.rs delete mode 100644 crates/enclaveapp-windows/src/hello_gate.rs delete mode 100644 crates/enclaveapp-windows/src/key.rs delete mode 100644 crates/enclaveapp-windows/src/lib.rs delete mode 100644 crates/enclaveapp-windows/src/meta_hmac.rs delete mode 100644 crates/enclaveapp-windows/src/meta_migration_marker.rs delete mode 100644 crates/enclaveapp-windows/src/meta_tag.rs delete mode 100644 crates/enclaveapp-windows/src/password_gate.rs delete mode 100644 crates/enclaveapp-windows/src/provider.rs delete mode 100644 crates/enclaveapp-windows/src/sign.rs delete mode 100644 crates/enclaveapp-windows/src/state.rs delete mode 100644 crates/enclaveapp-windows/src/ui_policy.rs diff --git a/Cargo.lock b/Cargo.lock index ee00a603..505f3dbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -55,9 +55,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base16ct" @@ -65,12 +65,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -85,15 +79,29 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitfield" -version = "0.14.0" +version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d7e60934ceec538daadb9d8432424ed043a904d8e0243f3c6446bce549a46ac" +checksum = "21ba6517c6b0f2bf08be60e187ab64b038438f22dd755614d8fe4d4098c46419" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f48d6ace212fdf1b45fd6b566bb40808415344642b76c3224c07c8df9da81e97" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "bitflags" -version = "2.11.1" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" [[package]] name = "block-buffer" @@ -234,13 +242,13 @@ dependencies = [ [[package]] name = "dbus" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" dependencies = [ "libc", "libdbus-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -332,98 +340,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "enclaveapp-bridge" -version = "0.2.4" -dependencies = [ - "base64 0.22.1", - "enclaveapp-core", - "serde", - "serde_json", -] - -[[package]] -name = "enclaveapp-core" -version = "0.2.4" -dependencies = [ - "dirs", - "fs2", - "libc", - "serde", - "serde_json", - "sha2", - "thiserror", - "toml 0.8.23", - "tracing", - "windows", -] - -[[package]] -name = "enclaveapp-test-software" -version = "0.2.4" -dependencies = [ - "aes-gcm", - "dirs", - "elliptic-curve", - "enclaveapp-core", - "p256", - "rand", - "serde", - "serde_json", - "sha2", - "zeroize", -] - -[[package]] -name = "enclaveapp-test-support" -version = "0.2.4" -dependencies = [ - "enclaveapp-core", - "rand", - "serde_json", -] - -[[package]] -name = "enclaveapp-tpm-bridge" -version = "0.2.4" -dependencies = [ - "base64 0.22.1", - "enclaveapp-bridge", - "enclaveapp-core", - "enclaveapp-windows", - "enclaveapp-windows-webauthn", - "serde", - "serde_json", -] - -[[package]] -name = "enclaveapp-windows" -version = "0.2.4" -dependencies = [ - "aes-gcm", - "dirs", - "elliptic-curve", - "enclaveapp-core", - "p256", - "rand", - "serde", - "serde_json", - "sha2", - "tracing", - "windows", - "zeroize", -] - -[[package]] -name = "enclaveapp-windows-webauthn" -version = "0.2.4" -dependencies = [ - "ciborium", - "enclaveapp-core", - "thiserror", - "windows", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -583,11 +499,11 @@ dependencies = [ [[package]] name = "hardware-enclave" -version = "0.2.4" +version = "0.2.6" dependencies = [ "aes-gcm", "anyhow", - "base64 0.22.1", + "base64", "ciborium", "dirs", "elliptic-curve", @@ -624,9 +540,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -671,7 +587,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -720,9 +636,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -735,9 +651,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -756,9 +672,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "log" -version = "0.4.29" +version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" [[package]] name = "matchers" @@ -781,9 +697,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "num-derive" @@ -855,9 +771,9 @@ dependencies = [ [[package]] name = "picky-asn1" -version = "0.8.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "295eea0f33c16be21e2a98b908fdd4d73c04dd48c8480991b76dbcf0cb58b212" +checksum = "2ff038f9360b934342fb3c0a1d6e82c438a2624b51c3c6e3e6d7cf252b6f3ee3" dependencies = [ "oid", "serde", @@ -866,9 +782,9 @@ dependencies = [ [[package]] name = "picky-asn1-der" -version = "0.4.1" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5df7873a9e36d42dadb393bea5e211fe83d793c172afad5fb4ec846ec582793f" +checksum = "d413165e4bf7f808b9a27cbaba657657a2921f0965db833f488c4d4be96dcd2e" dependencies = [ "picky-asn1", "serde", @@ -877,11 +793,11 @@ dependencies = [ [[package]] name = "picky-asn1-x509" -version = "0.12.0" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5f20f71a68499ff32310f418a6fad8816eac1a2859ed3f0c5c741389dd6208" +checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" dependencies = [ - "base64 0.21.7", + "base64", "oid", "picky-asn1", "picky-asn1-der", @@ -1192,9 +1108,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1362,7 +1278,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] @@ -1403,7 +1319,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.3", ] [[package]] @@ -1466,13 +1382,13 @@ dependencies = [ [[package]] name = "tss-esapi" -version = "7.6.0" +version = "7.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ea9ccde878b029392ac97b5be1f470173d06ea41d18ad0bb3c92794c16a0f2" +checksum = "3f10b25a84912b894d0e6d68f4a3771c923e9c44ddaaed7920cde92ed28aa84e" dependencies = [ "bitfield", "enumflags2", - "getrandom 0.2.17", + "getrandom 0.4.2", "hostname-validator", "log", "mbox", @@ -1489,9 +1405,9 @@ dependencies = [ [[package]] name = "tss-esapi-sys" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535cd192581c2ec4d5f82e670b1d3fbba6a23ccce8c85de387642051d7cad5b5" +checksum = "a7f972672926a3d3d18ecc04524720e4d20b7d1664a3fb73dbf7d4274196dbd9" dependencies = [ "pkg-config", "target-lexicon", @@ -1499,9 +1415,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -1539,11 +1455,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -1552,7 +1468,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -1857,9 +1773,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" [[package]] name = "winresource" @@ -1880,6 +1796,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -1961,18 +1883,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 01703e62..d46d88d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,20 +1,8 @@ [workspace] members = [ "crates/hardware-enclave", - # Support crates still used via path deps by consuming repos: - # enclaveapp-core, enclaveapp-test-software — sshenc force-software feature - # enclaveapp-tpm-bridge — sshenc-tpm-bridge binary - # enclaveapp-bridge, enclaveapp-windows, enclaveapp-windows-webauthn — tpm-bridge deps - # enclaveapp-test-support — hardware-enclave dev tests - "crates/enclaveapp-core", - "crates/enclaveapp-test-software", - "crates/enclaveapp-test-support", - "crates/enclaveapp-tpm-bridge", - "crates/enclaveapp-bridge", - "crates/enclaveapp-windows", - "crates/enclaveapp-windows-webauthn", ] -# Pure implementation crates whose code now lives inside hardware-enclave/src/internal/. +# Legacy implementation crates whose code now lives inside hardware-enclave/src/internal/. # Kept in the repository for reference but excluded from the workspace. exclude = [ "crates/enclaveapp-apple", @@ -29,7 +17,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "0.2.5" +version = "0.2.6" edition = "2021" license = "MIT" rust-version = "1.75" @@ -38,19 +26,6 @@ repository = "https://github.com/godaddy/hardware-enclave" [workspace.dependencies] # Internal crates hardware-enclave = { path = "crates/hardware-enclave" } -# Legacy source dirs kept for path-dep consumers (sshenc force-software feature). -# Not workspace members — just available as path deps via workspace.dependencies -# so their own Cargo.toml files (which use workspace = true internally) continue -# to parse correctly when referenced via path deps from consuming repos. -enclaveapp-core = { path = "crates/enclaveapp-core" } -enclaveapp-bridge = { path = "crates/enclaveapp-bridge" } -enclaveapp-windows = { path = "crates/enclaveapp-windows" } -enclaveapp-windows-webauthn = { path = "crates/enclaveapp-windows-webauthn" } -# default-features = false so that workspace members can opt out of the -# `linux-tpm` (libtss2) backend by not enabling it; members that want it -# forward `enclaveapp-app-storage/linux-tpm` via their own `linux-tpm` -# feature. (Cargo ignores a member's `default-features = false` on a -# `workspace = true` dep unless the workspace entry sets it here.) # Serialization serde = { version = "1", features = ["derive"] } diff --git a/crates/enclaveapp-bridge/Cargo.toml b/crates/enclaveapp-bridge/Cargo.toml deleted file mode 100644 index eb5eae07..00000000 --- a/crates/enclaveapp-bridge/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "enclaveapp-bridge" -description = "TPM bridge for WSL (JSON-RPC over stdin/stdout)" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[dependencies] -enclaveapp-core = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -base64 = { workspace = true } diff --git a/crates/enclaveapp-bridge/README.md b/crates/enclaveapp-bridge/README.md deleted file mode 100644 index 4e3194f1..00000000 --- a/crates/enclaveapp-bridge/README.md +++ /dev/null @@ -1,49 +0,0 @@ -# enclaveapp-bridge - -JSON-RPC TPM bridge for WSL-to-Windows communication. - -When running inside WSL, applications can't access the Windows TPM directly. This crate provides the protocol and client for a bridge pattern: a small Windows executable reads JSON-RPC requests from stdin and writes responses to stdout, performing TPM operations on behalf of the WSL process. - -## Protocol - -Single-line JSON over stdin/stdout: - -``` --> {"method":"encrypt","params":{"data":"","access_policy":"none","app_name":"awsenc","key_label":"cache-key"}} -<- {"result":"","error":null} -``` - -### Methods - -| Method | Description | -|---|---| -| `init` | Initialize TPM key for the given app | -| `encrypt` | Encrypt base64-encoded data | -| `decrypt` | Decrypt base64-encoded data | -| `destroy` | Delete the TPM key | - -## Client - -The client discovers the bridge executable on the Windows filesystem from within WSL: - -```rust -use enclaveapp_bridge::{bridge_decrypt, bridge_encrypt, bridge_init, find_bridge}; - -if let Some(bridge) = find_bridge("awsenc") { - bridge_init(&bridge, "awsenc", "cache-key", enclaveapp_core::AccessPolicy::None)?; - let ciphertext = bridge_encrypt(&bridge, "awsenc", "cache-key", plaintext, enclaveapp_core::AccessPolicy::None)?; - let plaintext = bridge_decrypt(&bridge, "awsenc", "cache-key", &ciphertext, enclaveapp_core::AccessPolicy::None)?; -} -``` - -Discovery paths: -- `/mnt/c/Program Files//-bridge.exe` -- `/mnt/c/ProgramData//-bridge.exe` - -The higher-level `enclaveapp-app-storage` crate also adds app-specific -`-tpm-bridge.exe` fallbacks and optional absolute override paths for -consumers such as `awsenc` and `sso-jwt`. - -## Server - -This crate provides protocol types and the client. The server binary (which runs on Windows and performs actual TPM operations) is implemented by consuming applications using `enclaveapp-windows` for the crypto. diff --git a/crates/enclaveapp-bridge/src/client.rs b/crates/enclaveapp-bridge/src/client.rs deleted file mode 100644 index 25c6493f..00000000 --- a/crates/enclaveapp-bridge/src/client.rs +++ /dev/null @@ -1,1653 +0,0 @@ -// Copyright 2026 Jay Gowdy -// SPDX-License-Identifier: MIT - -//! Bridge client for WSL/Linux. Spawns the Windows bridge binary and -//! communicates via JSON-RPC over stdin/stdout. - -use crate::protocol::*; -use enclaveapp_core::timeout::{wait_with_timeout, LineReaderWithTimeout, TimeoutResult}; -use enclaveapp_core::{AccessPolicy, Result}; -use std::collections::HashMap; -use std::io::Write; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{Arc, Mutex, OnceLock}; -use std::time::Duration; - -/// Maximum response size from the bridge (64 KB). -const MAX_BRIDGE_RESPONSE_BYTES: usize = 64 * 1024; - -/// Default timeout for a single bridge request/response cycle. -/// -/// 240 s — sized to cover four overlapping cold-start cases without -/// erroring: -/// -/// 1. TPM service warmup (5–30 s typical, up to ~60 s on contended -/// boxes). The Windows TBS service may not be running yet on first -/// call after boot or after a Hyper-V cycle. -/// 2. Windows Hello / biometric prompt (up to ~60 s in practice while -/// the user finds their security key, fingerprint, or PIN). -/// 3. Authenticode verification + first-time process spawn on a cold -/// disk (antivirus on-access scan, slow disk). -/// 4. Some combination of the above on the same call. -/// -/// Override via `ENCLAVEAPP_BRIDGE_TIMEOUT_SECS` for pathological -/// hardware. -const DEFAULT_BRIDGE_REQUEST_TIMEOUT: Duration = Duration::from_secs(240); - -/// Timeout for bridge shutdown (after we close stdin, it should exit promptly). -const BRIDGE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); - -fn bridge_request_timeout() -> Duration { - std::env::var("ENCLAVEAPP_BRIDGE_TIMEOUT_SECS") - .ok() - .and_then(|s| s.parse::().ok()) - .map(Duration::from_secs) - .unwrap_or(DEFAULT_BRIDGE_REQUEST_TIMEOUT) -} - -/// Find the bridge executable on the Windows filesystem (from WSL). -/// -/// Discovery order: -/// 1. The `ENCLAVEAPP_BRIDGE_PATH` environment variable (or the app-specific -/// `{APP_NAME_UPPER}_BRIDGE_PATH`) — explicit opt-in by the user. Needed for -/// non-admin installs like scoop where the bridge lives under -/// `%USERPROFILE%\scoop\apps\...`, which is not one of the default -/// candidate paths. -/// 2. Among the fixed install locations under `/mnt/c/Program Files/` and -/// `/mnt/c/ProgramData/` that actually exist, the one with the newest -/// modification time. This avoids silently picking a stale binary left -/// behind by a previous installer when the user has since installed a -/// newer version to a different location. -/// -/// Only fixed admin-path install locations are included in the auto-discovery -/// fallback. PATH-based lookup via `which` was removed intentionally — a -/// user-writable `$PATH` entry on the WSL side could substitute a malicious -/// bridge binary. Users who install to non-admin paths (scoop, a manual -/// build) must set `ENCLAVEAPP_BRIDGE_PATH` explicitly. -/// -/// The resolved executable is additionally gated by -/// [`require_bridge_is_authenticode_signed`] at spawn time — but only if -/// this build was compiled with `ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED=1`. -/// Builds without that flag (including the current default release -/// pipeline) skip the signature check. See [`BUILD_REQUIRES_SIGNED`] -/// and THREAT_MODEL.md for the rationale. -#[allow(clippy::print_stderr)] // user-facing warning for misconfigured env var -pub fn find_bridge(app_name: &str) -> Option { - // 1. Explicit env var override (app-specific, then generic). - let app_specific = format!("{}_BRIDGE_PATH", app_name.to_uppercase().replace('-', "_")); - for var in [app_specific.as_str(), "ENCLAVEAPP_BRIDGE_PATH"] { - if let Ok(value) = std::env::var(var) { - let p = PathBuf::from(&value); - if p.exists() { - return Some(p); - } - // Env var was set but the path is missing — surface clearly - // rather than silently falling through to auto-discovery. - eprintln!( - "warning: {var}={value} is set but the file does not exist; falling back to auto-discovery" - ); - } - } - - // 2. Auto-discovery: newest-mtime wins among existing admin-path candidates. - let candidates = [ - format!("/mnt/c/Program Files/{app_name}/{app_name}-tpm-bridge.exe"), - format!("/mnt/c/ProgramData/{app_name}/{app_name}-tpm-bridge.exe"), - format!("/mnt/c/Program Files/{app_name}/{app_name}-bridge.exe"), - format!("/mnt/c/ProgramData/{app_name}/{app_name}-bridge.exe"), - ]; - candidates - .iter() - .filter_map(|path| { - let p = PathBuf::from(path); - let mtime = std::fs::metadata(&p).ok()?.modified().ok()?; - Some((p, mtime)) - }) - .max_by_key(|(_, mtime)| *mtime) - .map(|(path, _)| path) -} - -/// Whether this build claims to be distributed as Authenticode-signed. -/// -/// Set by the release workflow *at build time* via -/// `ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED=1` when the signing pipeline is -/// actually configured. Default (unset) is **off** — matching the -/// current distribution reality where releases ship unsigned. When on, -/// [`require_bridge_is_authenticode_signed`] refuses unsigned bridges. -/// -/// There is deliberately **no runtime opt-out**. Security is not a -/// runtime toggle — if the build enforces signing, it enforces it. -/// Operators who need to run with an unsigned bridge (local dev, a -/// custom fork) must compile without `ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED` -/// set. This prevents a compromised WSL-side attacker from flipping -/// the security posture with a single env var. -const BUILD_REQUIRES_SIGNED: bool = option_env!("ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED").is_some(); - -/// Refuse to spawn a bridge binary that lacks an Authenticode signature -/// block — but only if this build was compiled with -/// `ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED=1`. -/// -/// Parses the PE header's Certificate Table data directory -/// (`IMAGE_DIRECTORY_ENTRY_SECURITY`, index 4) and rejects the binary if -/// that directory is absent or zero-sized. This does NOT verify the -/// signature cryptographically or chase the certificate chain — that -/// requires `WinVerifyTrust` on Windows, which isn't reachable from the -/// WSL side without an extra helper process. What it does catch is the -/// concrete attack in the threat model: an attacker who replaces the -/// bridge binary with one they compiled themselves (e.g. ad-hoc `cargo -/// build` output that ships no signature at all). Admin-held replacement -/// with a validly-signed-but-malicious binary is out of scope and -/// acknowledged as such in the threat model. -/// -/// Builds that are **not** compiled with `ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED` -/// skip the check entirely (see [`BUILD_REQUIRES_SIGNED`]). This is -/// because the library is consumed by apps whose release pipeline does -/// not currently sign binaries; requiring signatures there would make -/// apps unable to spawn their own just-installed bridges. The trade-off -/// is documented in THREAT_MODEL.md. -pub fn require_bridge_is_authenticode_signed(bridge_path: &Path) -> Result<()> { - check_bridge_signature(bridge_path, BUILD_REQUIRES_SIGNED) -} - -/// Testable inner function — `require` is the compile-time default -/// [`BUILD_REQUIRES_SIGNED`] in production, but tests pass it explicitly -/// so they can exercise both branches without recompiling. -fn check_bridge_signature(bridge_path: &Path, require: bool) -> Result<()> { - if !require { - return Ok(()); - } - // Only PE binaries have Authenticode. Anything that's not `.exe` - // (test shell scripts, developer wrappers) bypasses this check — - // the real WSL bridge is always `*.exe`. - let is_exe = bridge_path - .extension() - .and_then(|e| e.to_str()) - .is_some_and(|e| e.eq_ignore_ascii_case("exe")); - if !is_exe { - return Ok(()); - } - match pe_has_authenticode_table(bridge_path) { - Ok(true) => Ok(()), - Ok(false) => Err(enclaveapp_core::Error::KeyOperation { - operation: "bridge_verify".into(), - detail: format!( - "bridge binary at {} has no Authenticode signature; refusing to spawn. \ - This build was compiled with ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED=1; \ - reinstall from a signed release or recompile without that flag.", - bridge_path.display() - ), - }), - Err(detail) => Err(enclaveapp_core::Error::KeyOperation { - operation: "bridge_verify".into(), - detail, - }), - } -} - -/// Inspect a PE file's optional-header data-directory table and return -/// whether the `IMAGE_DIRECTORY_ENTRY_SECURITY` (index 4) slot points to -/// a non-empty certificate table. -/// -/// Layout references: -/// - DOS header: 64 bytes, with `e_lfanew: u32` at offset 0x3C -/// pointing to the NT header. -/// - NT signature: "PE\0\0" (4 bytes). -/// - COFF file header: 20 bytes; `machine` at offset 0. -/// - Optional header: starts immediately after COFF. First 2 bytes are -/// the magic (PE32 = 0x10b, PE32+ = 0x20b) which determines whether -/// the data directory table starts at offset 96 (PE32) or 112 (PE32+). -/// - Data directory `IMAGE_DIRECTORY_ENTRY_SECURITY` is slot 4: -/// `virtual_address: u32, size: u32` — size > 0 means the binary -/// has an Authenticode cert table appended. -fn pe_has_authenticode_table(path: &Path) -> std::result::Result { - let data = std::fs::read(path).map_err(|e| format!("read {}: {e}", path.display()))?; - if data.len() < 0x40 { - return Err("file too small for DOS header".into()); - } - if &data[0..2] != b"MZ" { - return Err("not a PE image (missing MZ magic)".into()); - } - let e_lfanew = u32::from_le_bytes( - data[0x3C..0x40] - .try_into() - .map_err(|_| "bad e_lfanew slice".to_string())?, - ) as usize; - if data.len() < e_lfanew + 0x18 { - return Err("file too small for NT header".into()); - } - if &data[e_lfanew..e_lfanew + 4] != b"PE\0\0" { - return Err("not a PE image (missing PE signature)".into()); - } - let coff_start = e_lfanew + 4; - let opt_header_start = coff_start + 20; - if data.len() < opt_header_start + 2 { - return Err("file too small for optional header magic".into()); - } - let magic = u16::from_le_bytes( - data[opt_header_start..opt_header_start + 2] - .try_into() - .map_err(|_| "bad optional-header magic slice".to_string())?, - ); - // Offset within the optional header to DataDirectory[0]. - let data_dir_offset = match magic { - 0x10b => 96, // PE32 - 0x20b => 112, // PE32+ - _ => return Err(format!("unknown PE optional-header magic 0x{magic:x}")), - }; - // Each DataDirectory is 8 bytes; SECURITY is index 4. - let security_dir = opt_header_start + data_dir_offset + 4 * 8; - if data.len() < security_dir + 8 { - return Err("file too small for data directory table".into()); - } - let size = u32::from_le_bytes( - data[security_dir + 4..security_dir + 8] - .try_into() - .map_err(|_| "bad security dir size slice".to_string())?, - ); - Ok(size > 0) -} - -struct BridgeSession { - child: std::process::Child, - reader: LineReaderWithTimeout, - finished: bool, - request_timeout: Duration, -} - -impl BridgeSession { - fn spawn(bridge_path: &Path) -> Result { - require_bridge_is_authenticode_signed(bridge_path)?; - let mut child = Command::new(bridge_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| enclaveapp_core::Error::KeyOperation { - operation: "bridge_spawn".into(), - detail: e.to_string(), - })?; - - let stdout = child - .stdout - .take() - .ok_or_else(|| enclaveapp_core::Error::KeyOperation { - operation: "bridge_read".into(), - detail: "no stdout".into(), - })?; - - Ok(Self { - child, - // The bridge is across a process boundary and may be - // compromised or buggy; cap the per-line read at - // MAX_BRIDGE_RESPONSE_BYTES so a malicious peer can't - // drive unbounded heap allocation by withholding the - // newline. The reader returns InvalidData if the cap is - // hit before the terminator. - reader: LineReaderWithTimeout::with_max_line_bytes(stdout, MAX_BRIDGE_RESPONSE_BYTES), - finished: false, - request_timeout: bridge_request_timeout(), - }) - } - - fn request(&mut self, request: &BridgeRequest) -> Result { - let request_json = serde_json::to_string(request) - .map_err(|e| enclaveapp_core::Error::Serialization(e.to_string()))?; - - if let Some(ref mut stdin) = self.child.stdin { - writeln!(stdin, "{request_json}").map_err(enclaveapp_core::Error::Io)?; - stdin.flush().map_err(enclaveapp_core::Error::Io)?; - } - - let line = match self.reader.recv_line(self.request_timeout) { - TimeoutResult::Completed(Ok(line)) => line, - TimeoutResult::Completed(Err(e)) => { - // Kill the child on read errors too — the most common - // case is the bounded reader's InvalidData cap-hit, - // which means the bridge is misbehaving and the - // session is unrecoverable. Surface it as a typed - // KeyOperation so the operator sees the size cap in - // the error rather than a raw io::Error chain. - let cap_hit = e.kind() == std::io::ErrorKind::InvalidData; - drop(self.child.kill()); - drop(self.child.wait()); - self.finished = true; - return if cap_hit { - Err(enclaveapp_core::Error::KeyOperation { - operation: "bridge_read".into(), - detail: format!( - "bridge response exceeded {MAX_BRIDGE_RESPONSE_BYTES}-byte cap \ - before newline ({e})" - ), - }) - } else { - Err(enclaveapp_core::Error::Io(e)) - }; - } - TimeoutResult::TimedOut => { - // Kill the child so we're not leaving a stuck TPM op running. - drop(self.child.kill()); - drop(self.child.wait()); - self.finished = true; - return Err(enclaveapp_core::Error::KeyOperation { - operation: "bridge_read".into(), - detail: format!( - "bridge did not respond within {}s (set ENCLAVEAPP_BRIDGE_TIMEOUT_SECS to override)", - self.request_timeout.as_secs() - ), - }); - } - }; - - if line.trim().is_empty() { - return Err(enclaveapp_core::Error::KeyOperation { - operation: "bridge_read".into(), - detail: "bridge returned no response".into(), - }); - } - - let response: BridgeResponse = serde_json::from_str(&line) - .map_err(|e| enclaveapp_core::Error::Serialization(format!("bridge response: {e}")))?; - - Ok(response) - } -} - -impl Drop for BridgeSession { - fn drop(&mut self) { - if self.finished { - return; - } - // Close stdin first: the bridge protocol exits cleanly on - // EOF, and waiting for that beats SIGKILL when feasible. - // We bound the wait so a wedged TPM op can't pin us. - drop(self.child.stdin.take()); - match wait_with_timeout(&mut self.child, BRIDGE_SHUTDOWN_TIMEOUT) { - Ok(TimeoutResult::Completed(_)) => { - self.finished = true; - return; - } - Ok(TimeoutResult::TimedOut) | Err(_) => { - // Fall through to kill. - } - } - drop(self.child.kill()); - drop(self.child.wait()); - self.finished = true; - } -} - -/// Process-shared map of `bridge_path` → persistent session. -/// -/// Each entry is a `Mutex>` shared via `Arc`. -/// The `Option` holds the (lazily-spawned) child process; `None` -/// before first use, after a session error that forced a respawn, -/// or after the subprocess died. The `Mutex` serializes calls -/// against a single bridge subprocess (same UX semantics as the -/// previous `BRIDGE_SESSION_LOCK` — Windows Hello prompts must not -/// race; TPM key slot contention; interleaved-stderr ambiguity). -/// -/// Why per-path rather than truly global: a single process could in -/// principle have multiple distinct bridge installs reachable -/// (e.g. a sshenc + an awsenc both exposing their own bridge.exe). -/// Serializing per-path lets independent bridges run in parallel -/// while a single bridge's calls remain ordered. In practice every -/// known consumer uses one path per process, so this collapses to -/// the same behavior as the previous global lock. -/// -/// The bridge subprocess is **persistent across calls** within a -/// process (the architectural change in this commit). Previously -/// every `call_bridge` / `call_bridge_after_init` spawned a fresh -/// `sshenc-tpm-bridge.exe`, paying the WSL→Windows interop + -/// TPM-service warmup cost on every call. With the persistent -/// session, the subprocess is spawned once on first call and -/// reused — agent-side bridge ops drop from "5-10s cold first -/// call, ~50ms warm thereafter" to "5-10s once at first call, -/// effectively zero subprocess overhead thereafter." -/// -/// Server-side state across multiple `init` calls on the same -/// connection: handle_request in enclaveapp-tpm-bridge replaces -/// `Option` on each `init`, so reusing the connection -/// across different (app, label, policy) tuples is safe. -/// -/// On any session error (subprocess died, IO failure, request -/// timeout fired and killed the child), the `Option` is cleared -/// and the next call will respawn. Callers retry exactly once -/// per call so a transient subprocess loss is invisible. -type PersistentSessionMap = HashMap>>>; -static PERSISTENT_BRIDGES: OnceLock> = OnceLock::new(); - -/// Get-or-create the per-path persistent session handle. Map lock -/// is held briefly (just to look up / insert); the per-path session -/// mutex is what serializes actual calls. -fn persistent_session(bridge_path: &Path) -> Arc>> { - let map = PERSISTENT_BRIDGES.get_or_init(|| Mutex::new(HashMap::new())); - let mut guard = match map.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - }; - guard - .entry(bridge_path.to_path_buf()) - .or_insert_with(|| Arc::new(Mutex::new(None))) - .clone() -} - -fn lock_session( - arc: &Arc>>, -) -> std::sync::MutexGuard<'_, Option> { - match arc.lock() { - Ok(g) => g, - Err(p) => p.into_inner(), - } -} - -/// Send `request` over the persistent bridge session for -/// `bridge_path`. Spawns a fresh session if none exists. Retries -/// exactly once (with a freshly spawned session) if the existing -/// session errors — covers the case where a previous request's -/// timeout killed the subprocess or the subprocess crashed -/// between calls. -pub fn call_bridge(bridge_path: &Path, request: &BridgeRequest) -> Result { - let session_arc = persistent_session(bridge_path); - let mut guard = lock_session(&session_arc); - let mut last_err: Option = None; - for _attempt in 0..2 { - if guard.is_none() { - *guard = Some(BridgeSession::spawn(bridge_path)?); - } - match guard.as_mut().expect("just-spawned").request(request) { - Ok(resp) => return Ok(resp), - Err(e) => { - // Drop the dead session. Drop impl kills + waits. - drop(guard.take()); - last_err = Some(e); - } - } - } - Err(last_err.expect("retry loop never recorded an error")) -} - -fn init_request(app_name: &str, key_label: &str, access_policy: AccessPolicy) -> BridgeRequest { - BridgeRequest { - method: "init".to_string(), - params: BridgeParams::new( - String::new(), - access_policy, - app_name.to_string(), - key_label.to_string(), - ), - } -} - -/// Send `init` then `request` atomically against the same -/// persistent session. Same retry semantics as `call_bridge`: -/// one transparent re-spawn-and-retry on session-level errors. -fn call_bridge_after_init( - bridge_path: &Path, - app_name: &str, - key_label: &str, - access_policy: AccessPolicy, - request: &BridgeRequest, -) -> Result { - let session_arc = persistent_session(bridge_path); - let mut guard = lock_session(&session_arc); - let mut last_err: Option = None; - for _attempt in 0..2 { - if guard.is_none() { - *guard = Some(BridgeSession::spawn(bridge_path)?); - } - let session = guard.as_mut().expect("just-spawned"); - let result = (|| -> Result { - session - .request(&init_request(app_name, key_label, access_policy))? - .require_ok("bridge_init")?; - session.request(request) - })(); - match result { - Ok(resp) => return Ok(resp), - Err(e) => { - drop(guard.take()); - last_err = Some(e); - } - } - } - Err(last_err.expect("retry loop never recorded an error")) -} - -/// Initialize the bridge-side key lifecycle for a specific app/label pair. -pub fn bridge_init( - bridge_path: &Path, - app_name: &str, - key_label: &str, - access_policy: AccessPolicy, -) -> Result<()> { - let response = call_bridge( - bridge_path, - &init_request(app_name, key_label, access_policy), - )?; - response.require_ok("bridge_init") -} - -/// Convenience: encrypt data via the bridge. -pub fn bridge_encrypt( - bridge_path: &Path, - app_name: &str, - key_label: &str, - plaintext: &[u8], - access_policy: AccessPolicy, -) -> Result> { - let request = BridgeRequest { - method: "encrypt".to_string(), - params: BridgeParams::new( - encode_data(plaintext), - access_policy, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = - call_bridge_after_init(bridge_path, app_name, key_label, access_policy, &request)?; - response.decode_result("bridge_encrypt") -} - -/// Convenience: decrypt data via the bridge. -pub fn bridge_decrypt( - bridge_path: &Path, - app_name: &str, - key_label: &str, - ciphertext: &[u8], - access_policy: AccessPolicy, -) -> Result> { - let request = BridgeRequest { - method: "decrypt".to_string(), - params: BridgeParams::new( - encode_data(ciphertext), - access_policy, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = - call_bridge_after_init(bridge_path, app_name, key_label, access_policy, &request)?; - response.decode_result("bridge_decrypt") -} - -/// Destroy the bridge-side key for a specific app/label pair. -/// Sends "delete" on the wire for backward compatibility with existing bridge servers. -pub fn bridge_destroy(bridge_path: &Path, app_name: &str, key_label: &str) -> Result<()> { - let request = BridgeRequest { - method: "delete".to_string(), - params: BridgeParams::new( - String::new(), - AccessPolicy::None, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = call_bridge(bridge_path, &request)?; - response.require_ok("bridge_destroy") -} - -/// Alias for backward compatibility. -pub fn bridge_delete(bridge_path: &Path, app_name: &str, key_label: &str) -> Result<()> { - bridge_destroy(bridge_path, app_name, key_label) -} - -// --------------------------------------------------------------------------- -// Signing bridge operations -// --------------------------------------------------------------------------- - -fn init_signing_request( - app_name: &str, - key_label: &str, - access_policy: AccessPolicy, -) -> BridgeRequest { - BridgeRequest { - method: "init_signing".to_string(), - params: BridgeParams::new( - String::new(), - access_policy, - app_name.to_string(), - key_label.to_string(), - ), - } -} - -fn call_bridge_after_signing_init( - bridge_path: &Path, - app_name: &str, - key_label: &str, - access_policy: AccessPolicy, - request: &BridgeRequest, -) -> Result { - // Same pattern as call_bridge_after_init: persistent session + - // one retry on session-level error. - let session_arc = persistent_session(bridge_path); - let mut guard = lock_session(&session_arc); - let mut last_err: Option = None; - for _attempt in 0..2 { - if guard.is_none() { - *guard = Some(BridgeSession::spawn(bridge_path)?); - } - let session = guard.as_mut().expect("just-spawned"); - let result = (|| -> Result { - session - .request(&init_signing_request(app_name, key_label, access_policy))? - .require_ok("bridge_init_signing")?; - session.request(request) - })(); - match result { - Ok(resp) => return Ok(resp), - Err(e) => { - drop(guard.take()); - last_err = Some(e); - } - } - } - Err(last_err.expect("retry loop never recorded an error")) -} - -/// Initialize the bridge-side signing key lifecycle for a specific app/label pair. -pub fn bridge_init_signing( - bridge_path: &Path, - app_name: &str, - key_label: &str, - access_policy: AccessPolicy, -) -> Result<()> { - let response = call_bridge( - bridge_path, - &init_signing_request(app_name, key_label, access_policy), - )?; - response.require_ok("bridge_init_signing") -} - -/// Convenience: sign data via the bridge. -pub fn bridge_sign( - bridge_path: &Path, - app_name: &str, - key_label: &str, - data: &[u8], - access_policy: AccessPolicy, -) -> Result> { - let request = BridgeRequest { - method: "sign".to_string(), - params: BridgeParams::new( - encode_data(data), - access_policy, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = - call_bridge_after_signing_init(bridge_path, app_name, key_label, access_policy, &request)?; - response.decode_result("bridge_sign") -} - -/// Read the public key for an existing (app_name, key_label) pair -/// via the bridge. Standalone: does NOT prefix with `init_signing`. -/// -/// Callers that need create-if-missing semantics (e.g. keygen) should -/// invoke `bridge_init_signing` explicitly first, then call this to -/// fetch the resulting public key. Read-only callers (e.g. agent -/// identity enumeration, sshenc-se's proxy fetching pubkey bytes for -/// a known label) get a clean read with no side effect; if the key -/// is missing, the bridge surfaces the underlying `KeyNotFound` -/// shape rather than silently creating it. -/// -/// History: paired with `bridge_list_keys` becoming standalone -/// (enclave PR #110), this finishes off the -/// `call_bridge_after_signing_init` audit -- of the public bridge -/// helpers, only `bridge_sign` legitimately needs the init prelude -/// (signing requires a real key for the given label). -pub fn bridge_public_key( - bridge_path: &Path, - app_name: &str, - key_label: &str, - access_policy: AccessPolicy, -) -> Result> { - let request = BridgeRequest { - method: "public_key".to_string(), - params: BridgeParams::new( - String::new(), - access_policy, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = call_bridge(bridge_path, &request)?; - response.decode_result("bridge_public_key") -} - -/// Convenience: list signing keys via the bridge. -/// -/// Sends `list_keys` directly without the `init_signing` prelude that -/// other helpers go through. This is deliberately *not* using -/// `call_bridge_after_signing_init`: doing so would create a signing -/// key with the configured `key_label` (defaults to `"default"`) as a -/// side effect of every list call, which surfaced as the agent's -/// identity-enumeration silently leaking a `default` key on every -/// matrix run. The server-side `list_keys` handler is now standalone -/// and enumerates by `app_name`, so the per-label `init_signing` is -/// no longer needed (and was never semantically right for a list-all -/// operation in the first place). -pub fn bridge_list_keys( - bridge_path: &Path, - app_name: &str, - _key_label: &str, - access_policy: AccessPolicy, -) -> Result> { - let request = BridgeRequest { - method: "list_keys".to_string(), - params: BridgeParams::new( - String::new(), - access_policy, - app_name.to_string(), - String::new(), - ), - }; - let response = call_bridge(bridge_path, &request)?; - let result_str = response.require_result("bridge_list_keys")?; - serde_json::from_str(result_str) - .map_err(|e| enclaveapp_core::Error::Serialization(format!("list_keys JSON: {e}"))) -} - -/// Delete a signing key via the bridge. -pub fn bridge_delete_signing(bridge_path: &Path, app_name: &str, key_label: &str) -> Result<()> { - let request = BridgeRequest { - method: "delete_signing".to_string(), - params: BridgeParams::new( - String::new(), - AccessPolicy::None, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = call_bridge(bridge_path, &request)?; - response.require_ok("bridge_delete_signing") -} - -/// Check if a signing key exists on the bridge side without creating it. -/// -/// Unlike `bridge_public_key` / `bridge_list_keys`, this does NOT invoke -/// `init_signing`, so the TPM key is never created as a side effect of -/// the check. Use this for duplicate-label guards. -pub fn bridge_signing_key_exists( - bridge_path: &Path, - app_name: &str, - key_label: &str, -) -> Result { - let request = BridgeRequest { - method: "signing_key_exists".to_string(), - params: BridgeParams::new( - String::new(), - AccessPolicy::None, - app_name.to_string(), - key_label.to_string(), - ), - }; - let response = call_bridge(bridge_path, &request)?; - let result = response.require_result("bridge_signing_key_exists")?; - match result { - "true" => Ok(true), - "false" => Ok(false), - other => Err(enclaveapp_core::Error::KeyOperation { - operation: "bridge_signing_key_exists".into(), - detail: format!("unexpected result: {other}"), - }), - } -} - -// ============================================================ -// WebAuthn / SK convenience wrappers -// ============================================================ -// -// These let WSL Linux callers route SK keygen / sign / delete / -// availability through the Windows-side `webauthn.dll` via the -// existing JSON-RPC bridge. Hello prompt fires on the Windows -// desktop; the SSH key lives in WSL `~/.ssh`. Wire format is -// identical to native-Windows SK signing -- the bridge only -// shuttles the WebAuthn primitives. - -/// Probe the bridge for `WebAuthNIsUserVerifyingPlatformAuthenticatorAvailable`. -/// Returns `Ok(true)` only when the Windows host's WebAuthn API -/// reports an available platform authenticator (Hello enrolled). -pub fn bridge_webauthn_is_available(bridge_path: &Path) -> Result { - let request = BridgeRequest { - method: "webauthn_is_available".to_string(), - params: BridgeParams::default(), - }; - let response = call_bridge(bridge_path, &request)?; - let result = response.require_result("bridge_webauthn_is_available")?; - Ok(result == "true") -} - -/// Make a TPM-bound credential through the Windows-side WebAuthn -/// platform authenticator. Fires a Hello prompt on the Windows -/// desktop. Returns the parsed `WebauthnMakeCredentialResult` -/// (credential id + EC pubkey x/y + authenticator data). -#[allow(clippy::too_many_arguments)] -pub fn bridge_webauthn_make_credential( - bridge_path: &Path, - rp_id: &str, - rp_name: &str, - user_id: &[u8], - user_name: &str, - user_display_name: &str, - timeout_ms: u32, -) -> Result { - let params = BridgeParams { - rp_id: Some(rp_id.to_string()), - rp_name: Some(rp_name.to_string()), - user_id_b64: Some(encode_data(user_id)), - user_name: Some(user_name.to_string()), - user_display_name: Some(user_display_name.to_string()), - timeout_ms: Some(timeout_ms), - ..BridgeParams::default() - }; - let request = BridgeRequest { - method: "webauthn_make_credential".to_string(), - params, - }; - let response = call_bridge(bridge_path, &request)?; - let result_json = response.require_result("bridge_webauthn_make_credential")?; - serde_json::from_str(result_json).map_err(|e| enclaveapp_core::Error::KeyOperation { - operation: "bridge_webauthn_make_credential".into(), - detail: format!("failed to parse make_credential result: {e}"), - }) -} - -/// Sign `client_data` through the Windows-side WebAuthn platform -/// authenticator using a previously-made `credential_id`. Fires a -/// Hello prompt on the Windows desktop. Returns the parsed -/// `WebauthnAssertionResult` (DER signature + authenticator data -/// + flags + counter). -pub fn bridge_webauthn_get_assertion( - bridge_path: &Path, - rp_id: &str, - credential_id: &[u8], - client_data: &[u8], - timeout_ms: u32, -) -> Result { - let params = BridgeParams { - rp_id: Some(rp_id.to_string()), - credential_id_b64: Some(encode_data(credential_id)), - client_data_b64: Some(encode_data(client_data)), - timeout_ms: Some(timeout_ms), - ..BridgeParams::default() - }; - let request = BridgeRequest { - method: "webauthn_get_assertion".to_string(), - params, - }; - let response = call_bridge(bridge_path, &request)?; - let result_json = response.require_result("bridge_webauthn_get_assertion")?; - serde_json::from_str(result_json).map_err(|e| enclaveapp_core::Error::KeyOperation { - operation: "bridge_webauthn_get_assertion".into(), - detail: format!("failed to parse get_assertion result: {e}"), - }) -} - -/// Remove a previously-made platform credential from the user's -/// Windows passkey list. Best-effort -- if the credential is -/// already gone (user pruned via Settings > Passkeys), the -/// underlying API returns an error we generally want to ignore. -pub fn bridge_webauthn_delete_platform_credential( - bridge_path: &Path, - credential_id: &[u8], -) -> Result<()> { - let params = BridgeParams { - credential_id_b64: Some(encode_data(credential_id)), - ..BridgeParams::default() - }; - let request = BridgeRequest { - method: "webauthn_delete_platform_credential".to_string(), - params, - }; - let response = call_bridge(bridge_path, &request)?; - response.require_ok("bridge_webauthn_delete_platform_credential") -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic)] -mod tests { - use super::*; - - #[cfg(unix)] - use std::fs; - #[cfg(unix)] - use std::os::unix::fs::PermissionsExt; - #[cfg(unix)] - use std::sync::atomic::{AtomicU64, Ordering}; - #[cfg(unix)] - use std::sync::Mutex; - - #[cfg(unix)] - static SCRIPT_COUNTER: AtomicU64 = AtomicU64::new(0); - #[cfg(unix)] - static SCRIPT_TEST_MUTEX: Mutex<()> = Mutex::new(()); - - #[cfg(unix)] - fn temp_script(name: &str, body: &str) -> PathBuf { - let id = SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst); - let path = std::env::temp_dir().join(format!( - "enclaveapp-bridge-test-{}-{}-{}", - std::process::id(), - id, - name - )); - fs::write(&path, body).unwrap(); - let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_mode(0o755); - fs::set_permissions(&path, perms).unwrap(); - path - } - - #[cfg(unix)] - fn cleanup_script(path: &Path) { - drop(fs::remove_file(path)); - } - - #[test] - fn find_bridge_returns_none_when_not_found() { - let result = find_bridge("enclaveapp-nonexistent-test-app"); - assert!(result.is_none()); - } - - // ---- Authenticode / PE signature check ----------------------------- - - #[cfg(unix)] - fn make_pe_bytes(security_size: u32, magic: u16) -> Vec { - // DOS header (64 bytes) + NT sig (4) + COFF (20) + optional header - // + data directories. - // - // Optional header size varies by magic (PE32 vs PE32+). - let opt_header_size: usize = if magic == 0x10b { - 96 + 16 * 8 - } else { - 112 + 16 * 8 - }; - let total = 0x40 + 4 + 20 + opt_header_size; - let mut buf = vec![0_u8; total]; - // "MZ" - buf[0] = b'M'; - buf[1] = b'Z'; - // e_lfanew → NT headers start at offset 0x40. - buf[0x3C..0x40].copy_from_slice(&0x40_u32.to_le_bytes()); - // "PE\0\0" - buf[0x40] = b'P'; - buf[0x41] = b'E'; - // COFF at 0x44 is all zeros (acceptable for the check). - // Optional header starts at 0x40 + 4 + 20 = 0x58. - let opt_header_start = 0x58; - buf[opt_header_start..opt_header_start + 2].copy_from_slice(&magic.to_le_bytes()); - // Data directory offset within the optional header. - let data_dir_offset = if magic == 0x10b { 96 } else { 112 }; - // Security directory is index 4: 8 bytes per entry, skip 4. - let security_dir = opt_header_start + data_dir_offset + 4 * 8; - // VirtualAddress (unused by the check). - buf[security_dir..security_dir + 4].copy_from_slice(&0_u32.to_le_bytes()); - // Size — this is what we check. - buf[security_dir + 4..security_dir + 8].copy_from_slice(&security_size.to_le_bytes()); - buf - } - - #[cfg(unix)] - #[test] - fn pe_has_authenticode_table_detects_signed_pe32() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let bytes = make_pe_bytes(1024, 0x10b); - let path = std::env::temp_dir().join(format!( - "enclaveapp-pe-signed-{}-{}.exe", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - fs::write(&path, &bytes).unwrap(); - assert!(pe_has_authenticode_table(&path).unwrap()); - drop(fs::remove_file(&path)); - } - - #[cfg(unix)] - #[test] - fn pe_has_authenticode_table_detects_signed_pe32plus() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let bytes = make_pe_bytes(4096, 0x20b); - let path = std::env::temp_dir().join(format!( - "enclaveapp-pe-signed64-{}-{}.exe", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - fs::write(&path, &bytes).unwrap(); - assert!(pe_has_authenticode_table(&path).unwrap()); - drop(fs::remove_file(&path)); - } - - #[cfg(unix)] - #[test] - fn pe_has_authenticode_table_detects_unsigned() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let bytes = make_pe_bytes(0, 0x10b); - let path = std::env::temp_dir().join(format!( - "enclaveapp-pe-unsigned-{}-{}.exe", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - fs::write(&path, &bytes).unwrap(); - assert!(!pe_has_authenticode_table(&path).unwrap()); - drop(fs::remove_file(&path)); - } - - #[cfg(unix)] - #[test] - fn pe_has_authenticode_table_rejects_non_pe() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let path = std::env::temp_dir().join(format!( - "enclaveapp-pe-notpe-{}-{}.exe", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - // Write > 64 bytes of arbitrary non-PE content so we clear the - // "too small for DOS header" check and actually hit the - // "missing MZ magic" branch. - let content: Vec = b"#!/bin/sh\n# not a PE image\nexit 0\n" - .iter() - .copied() - .chain(std::iter::repeat(b'x').take(128)) - .collect(); - fs::write(&path, &content).unwrap(); - let err = pe_has_authenticode_table(&path).unwrap_err(); - assert!(err.contains("MZ"), "expected MZ-missing error, got: {err}"); - drop(fs::remove_file(&path)); - } - - #[cfg(unix)] - #[test] - fn check_bridge_signature_skips_non_exe_paths_when_required() { - // Shell scripts (our test-bridge pattern) must pass the gate - // unconditionally so existing integration tests keep working, - // even when signing is required. - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script("unsigned-script.sh", "#!/bin/sh\nexit 0\n"); - check_bridge_signature(&script, true).unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn check_bridge_signature_rejects_unsigned_exe_when_required() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let bytes = make_pe_bytes(0, 0x10b); - let path = std::env::temp_dir().join(format!( - "enclaveapp-pe-rejected-{}-{}.exe", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - fs::write(&path, &bytes).unwrap(); - let err = check_bridge_signature(&path, true).unwrap_err(); - assert!( - err.to_string().contains("no Authenticode signature"), - "got: {err}" - ); - drop(fs::remove_file(&path)); - } - - #[cfg(unix)] - #[test] - fn check_bridge_signature_accepts_unsigned_exe_when_not_required() { - // When the build is not compiled with - // ENCLAVEAPP_BRIDGE_REQUIRE_SIGNED, unsigned binaries are accepted. - // This is the default posture for the current release pipeline. - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let bytes = make_pe_bytes(0, 0x10b); - let path = std::env::temp_dir().join(format!( - "enclaveapp-pe-default-{}-{}.exe", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - fs::write(&path, &bytes).unwrap(); - check_bridge_signature(&path, false).unwrap(); - drop(fs::remove_file(&path)); - } - - #[cfg(unix)] - #[test] - fn find_bridge_env_var_override_wins_when_path_exists() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - // Create a temp file and point the env var at it. - let script = temp_script("override-bridge", "#!/bin/sh\nexit 0\n"); - std::env::set_var("ENCLAVEAPP_BRIDGE_PATH", &script); - let found = find_bridge("some-app-that-has-no-admin-install"); - std::env::remove_var("ENCLAVEAPP_BRIDGE_PATH"); - assert_eq!(found.as_deref(), Some(script.as_path())); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn find_bridge_env_var_override_ignored_when_path_missing() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - std::env::set_var("ENCLAVEAPP_BRIDGE_PATH", "/nonexistent/path/to/bridge.exe"); - let found = find_bridge("another-nonexistent-app"); - std::env::remove_var("ENCLAVEAPP_BRIDGE_PATH"); - // Env var points at nothing and no admin-path candidate exists → None. - assert!(found.is_none()); - } - - #[cfg(unix)] - #[test] - fn bridge_init_sends_key_label() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "init.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"init"'*'"app_name":"awsenc"'*'"key_label":"cache-key"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - bridge_init(&script, "awsenc", "cache-key", AccessPolicy::BiometricOnly).unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_encrypt_initializes_before_encrypting() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "encrypt.sh", - r#"#!/bin/sh -read init_line -case "$init_line" in - *'"method":"init"'*'"key_label":"cache-key"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected init request"}\n'; exit 0 ;; -esac -read request_line -case "$request_line" in - *'"method":"encrypt"'*) printf '{"result":"aGVsbG8=","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - let plaintext = bridge_encrypt( - &script, - "awsenc", - "cache-key", - b"ignored", - AccessPolicy::BiometricOnly, - ) - .unwrap(); - assert_eq!(plaintext, b"hello"); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_delete_sends_delete_request() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "delete.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"delete"'*'"key_label":"cache-key"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - bridge_destroy(&script, "awsenc", "cache-key").unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_encrypt_rejects_missing_result_payload() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "encrypt-missing-result.sh", - r#"#!/bin/sh -read init_line -printf '{"result":"","error":null}\n' -read request_line -printf '{"result":null,"error":null}\n' -"#, - ); - let error = bridge_encrypt( - &script, - "awsenc", - "cache-key", - b"ignored", - AccessPolicy::None, - ) - .unwrap_err(); - assert!(error.to_string().contains("missing result payload")); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_init_rejects_missing_result_payload() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "init-missing-result.sh", - r#"#!/bin/sh -read request_line -printf '{"result":null,"error":null}\n' -"#, - ); - let error = bridge_init(&script, "awsenc", "cache-key", AccessPolicy::None).unwrap_err(); - assert!(error.to_string().contains("missing result payload")); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_rejects_oversized_response() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "oversized.sh", - "#!/bin/sh\nread req\npython3 -c \"print('{\\\"result\\\":\\\"' + 'A' * 70000 + '\\\",\\\"error\\\":null}')\"\n", - ); - let request = BridgeRequest { - method: "init".to_string(), - params: BridgeParams::new( - String::new(), - AccessPolicy::None, - "test".into(), - "key".into(), - ), - }; - let err = call_bridge(&script, &request).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("byte limit") || msg.contains("bridge response"), - "expected size limit error, got: {msg}" - ); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_decrypt_initializes_before_decrypting() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "decrypt.sh", - r#"#!/bin/sh -read init_line -case "$init_line" in - *'"method":"init"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected init"}\n'; exit 0 ;; -esac -read request_line -case "$request_line" in - *'"method":"decrypt"'*) printf '{"result":"cGxhaW50ZXh0","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - let result = - bridge_decrypt(&script, "test-app", "key", b"ignored", AccessPolicy::None).unwrap(); - assert_eq!(result, b"plaintext"); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_decrypt_rejects_missing_result_payload() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "decrypt-missing.sh", - r#"#!/bin/sh -read init_line -printf '{"result":"","error":null}\n' -read request_line -printf '{"result":null,"error":null}\n' -"#, - ); - let err = - bridge_decrypt(&script, "test-app", "key", b"ignored", AccessPolicy::None).unwrap_err(); - assert!(err.to_string().contains("missing result payload")); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_destroy_sends_delete_method_on_wire() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "wire-method.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"delete"'*) printf '{"result":"","error":null}\n' ;; - *'"method":"destroy"'*) printf '{"result":null,"error":"got destroy instead of delete"}\n' ;; - *) printf '{"result":null,"error":"unexpected method"}\n' ;; -esac -"#, - ); - bridge_destroy(&script, "test-app", "key").unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_delete_alias_works() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "delete-alias.sh", - r#"#!/bin/sh -read req -printf '{"result":"","error":null}\n' -"#, - ); - bridge_delete(&script, "test-app", "key").unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_session_drop_kills_child() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script("drop-kill.sh", "#!/bin/sh\nwhile true; do sleep 1; done\n"); - // Spawn a session, grab the child PID, then drop it - let child_pid: u32; - { - let session = BridgeSession::spawn(&script).unwrap(); - child_pid = session.child.id(); - // Session dropped here — Drop should kill + wait the child - } - // Give the OS a moment to reap - std::thread::sleep(Duration::from_millis(100)); - // Verify the process is gone using `kill -0 ` (signal 0 checks existence) - let status = Command::new("kill") - .args(["-0", &child_pid.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - assert!( - status.is_err() || !status.unwrap().success(), - "bridge child (pid={child_pid}) should no longer exist after Drop" - ); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_init_encodes_access_policy_only() { - // The legacy `biometric: bool` compat field has been removed. - // Accept the init when `access_policy` is present and reject - // if a legacy `biometric` field is observed on the wire — we - // must not regress to encoding it. - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "access-policy-only.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"biometric"'*) printf '{"result":null,"error":"legacy biometric field leaked onto wire"}\n' ;; - *'"access_policy":"biometric_only"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"missing access_policy"}\n' ;; -esac -"#, - ); - bridge_init(&script, "test-app", "key", AccessPolicy::BiometricOnly).unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_rejects_empty_response() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script("empty-response.sh", "#!/bin/sh\nread req\n"); - let request = BridgeRequest { - method: "init".to_string(), - params: BridgeParams::new(String::new(), AccessPolicy::None, "t".into(), "k".into()), - }; - let err = call_bridge(&script, &request).unwrap_err(); - assert!( - err.to_string().contains("no response") || err.to_string().contains("bridge"), - "expected bridge error, got: {}", - err - ); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_rejects_invalid_json_response() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script("invalid-json.sh", "#!/bin/sh\nread req\necho 'not json'\n"); - let request = BridgeRequest { - method: "init".to_string(), - params: BridgeParams::new(String::new(), AccessPolicy::None, "t".into(), "k".into()), - }; - let err = call_bridge(&script, &request).unwrap_err(); - assert!(err.to_string().contains("bridge response")); - cleanup_script(&script); - } - - // ----- Signing bridge client tests ----- - - #[cfg(unix)] - #[test] - fn bridge_init_signing_sends_init_signing_method() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "init-signing.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"init_signing"'*'"app_name":"sshenc"'*'"key_label":"default"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - bridge_init_signing(&script, "sshenc", "default", AccessPolicy::None).unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_sign_initializes_before_signing() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "sign.sh", - r#"#!/bin/sh -read init_line -case "$init_line" in - *'"method":"init_signing"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected init request"}\n'; exit 0 ;; -esac -read request_line -case "$request_line" in - *'"method":"sign"'*) printf '{"result":"c2lnbmF0dXJl","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - let signature = - bridge_sign(&script, "sshenc", "default", b"data", AccessPolicy::None).unwrap(); - assert_eq!(signature, b"signature"); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_public_key_is_standalone() { - // Locks in the new contract (parallel of bridge_list_keys - // becoming standalone in PR #110): bridge_public_key no - // longer prefixes the request with init_signing. The - // previous "initializes before requesting" behaviour was - // the source of read-only callers (sshenc-se's proxy - // fetching pubkey bytes for an unknown label, the agent's - // identity-enumeration during a list-keys cache miss) - // silently creating a "default" signing key as a side - // effect of every read. Mock script reads exactly one line - // and replies with a public_key-shaped result. - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "pubkey.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"public_key"'*) printf '{"result":"cHVia2V5","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - let pubkey = bridge_public_key(&script, "sshenc", "default", AccessPolicy::None).unwrap(); - assert_eq!(pubkey, b"pubkey"); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_list_keys_is_standalone() { - // Locks in the new contract: bridge_list_keys does NOT prefix - // the request with init_signing. The previous "initializes - // before requesting" behavior was the source of the agent's - // identity-enumeration silently creating a "default" signing - // key as a side effect of every list. Mock script reads - // exactly one line and replies with a list_keys-shaped result. - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let dir = std::env::temp_dir().join("bridge-list-keys-test"); - fs::create_dir_all(&dir).unwrap(); - let resp = dir.join("resp.json"); - fs::write( - &resp, - "{\"result\":\"[\\\"key1\\\",\\\"key2\\\"]\",\"error\":null}\n", - ) - .unwrap(); - let script = temp_script( - "list-keys.sh", - &format!("#!/bin/sh\nread request_line\ncat {}\n", resp.display()), - ); - let keys = bridge_list_keys(&script, "sshenc", "default", AccessPolicy::None).unwrap(); - assert_eq!(keys, vec!["key1".to_string(), "key2".to_string()]); - drop(fs::remove_dir_all(&dir)); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_delete_signing_sends_delete_signing_method() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "delete-signing.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"delete_signing"'*'"key_label":"default"'*) printf '{"result":"","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - bridge_delete_signing(&script, "sshenc", "default").unwrap(); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_signing_key_exists_returns_true() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "exists-true.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"signing_key_exists"'*'"key_label":"mine"'*) printf '{"result":"true","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - let exists = bridge_signing_key_exists(&script, "sshenc", "mine").unwrap(); - assert!(exists); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_signing_key_exists_returns_false() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - let script = temp_script( - "exists-false.sh", - r#"#!/bin/sh -read request_line -case "$request_line" in - *'"method":"signing_key_exists"'*) printf '{"result":"false","error":null}\n' ;; - *) printf '{"result":null,"error":"unexpected request"}\n' ;; -esac -"#, - ); - let exists = bridge_signing_key_exists(&script, "sshenc", "missing").unwrap(); - assert!(!exists); - cleanup_script(&script); - } - - #[cfg(unix)] - #[test] - fn bridge_signing_key_exists_does_not_call_init_signing() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - // Bridge that records every request into a sentinel file and - // rejects any init_signing request. - let sentinel = std::env::temp_dir().join(format!( - "enclaveapp-bridge-exists-nolog-{}-{}", - std::process::id(), - SCRIPT_COUNTER.fetch_add(1, Ordering::SeqCst) - )); - drop(fs::remove_file(&sentinel)); - let script = temp_script( - "exists-no-init.sh", - &format!( - r#"#!/bin/sh -read request_line -echo "$request_line" >> "{sentinel}" -case "$request_line" in - *'"method":"init_signing"'*) printf '{{"result":null,"error":"should not init"}}\n'; exit 0 ;; - *'"method":"signing_key_exists"'*) printf '{{"result":"false","error":null}}\n' ;; - *) printf '{{"result":null,"error":"unexpected method"}}\n' ;; -esac -"#, - sentinel = sentinel.display() - ), - ); - let exists = bridge_signing_key_exists(&script, "sshenc", "probe").unwrap(); - assert!(!exists); - let log = fs::read_to_string(&sentinel).unwrap_or_default(); - assert!( - !log.contains("init_signing"), - "exists-check must not invoke init_signing, log was: {log}" - ); - cleanup_script(&script); - drop(fs::remove_file(&sentinel)); - } - - #[cfg(unix)] - #[test] - fn bridge_read_times_out_on_silent_bridge() { - let _lock = SCRIPT_TEST_MUTEX.lock().unwrap(); - // Bridge that accepts a request but never responds. - let script = temp_script("silent-bridge.sh", "#!/bin/sh\nread req\nsleep 120\n"); - // Force a very short timeout for this test. - std::env::set_var("ENCLAVEAPP_BRIDGE_TIMEOUT_SECS", "1"); - let start = std::time::Instant::now(); - let request = BridgeRequest { - method: "init".to_string(), - params: BridgeParams::new(String::new(), AccessPolicy::None, "t".into(), "k".into()), - }; - let err = call_bridge(&script, &request).unwrap_err(); - std::env::remove_var("ENCLAVEAPP_BRIDGE_TIMEOUT_SECS"); - // Should fail well before the bridge's 120s sleep finishes. - assert!( - start.elapsed() < Duration::from_secs(10), - "timeout should fire quickly, took {:?}", - start.elapsed() - ); - let msg = err.to_string(); - assert!( - msg.contains("did not respond") || msg.contains("timeout"), - "expected timeout error, got: {msg}" - ); - cleanup_script(&script); - } -} diff --git a/crates/enclaveapp-bridge/src/lib.rs b/crates/enclaveapp-bridge/src/lib.rs deleted file mode 100644 index 3448c5ae..00000000 --- a/crates/enclaveapp-bridge/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2026 Jay Gowdy -// SPDX-License-Identifier: MIT - -//! TPM bridge for WSL (JSON-RPC over stdin/stdout). -//! -//! On Windows, a bridge server reads JSON-RPC requests from stdin and writes -//! responses to stdout. On Linux/WSL, the client spawns the Windows bridge -//! binary and communicates via the same protocol. - -mod client; -mod protocol; - -pub use client::*; -pub use protocol::*; diff --git a/crates/enclaveapp-bridge/src/protocol.rs b/crates/enclaveapp-bridge/src/protocol.rs deleted file mode 100644 index 5274a25d..00000000 --- a/crates/enclaveapp-bridge/src/protocol.rs +++ /dev/null @@ -1,561 +0,0 @@ -// Copyright 2026 Jay Gowdy -// SPDX-License-Identifier: MIT - -//! JSON-RPC protocol types shared between server and client. - -use base64::Engine; -use enclaveapp_core::AccessPolicy; -use serde::{Deserialize, Serialize}; - -/// Bridge request sent from WSL client to Windows server. -#[derive(Debug, Serialize, Deserialize)] -pub struct BridgeRequest { - /// Method: "init", "encrypt", "decrypt", "destroy" - pub method: String, - /// Parameters. - pub params: BridgeParams, -} - -/// Bridge request parameters. -/// -/// The legacy `biometric: bool` field from earlier releases has been -/// removed. `access_policy` is now the only accepted encoding of the -/// key's access policy on the wire. See THREAT_MODEL.md T5 — the -/// legacy field opened a silent-downgrade path for a malicious bridge -/// peer that honored `biometric` and ignored `access_policy`. -/// -/// The `webauthn_*` fields are populated only by the -/// `webauthn_make_credential` / `webauthn_get_assertion` / -/// `webauthn_delete_platform_credential` methods (used to give WSL -/// callers parity with native-Windows SK keygen / sign by routing -/// through `webauthn.dll` on the Windows host). Other methods leave -/// them empty and the server ignores them. -#[derive(Debug, Default, Serialize, Deserialize)] -pub struct BridgeParams { - /// Base64-encoded data (plaintext for encrypt, ciphertext for decrypt). - #[serde(default)] - pub data: String, - /// Access policy to enforce on key use. - #[serde(default)] - pub access_policy: AccessPolicy, - /// Application name (determines TPM key name). - #[serde(default)] - pub app_name: String, - /// Key label within the application namespace. - #[serde(default)] - pub key_label: String, - - // --- WebAuthn / SK fields --- - // - // All optional; populated only by the `webauthn_*` methods. - // Skipped on serialize when `None` so non-SK requests stay on - // the existing wire shape. - /// FIDO2 Relying-Party identifier (e.g. `sshenc-.local`). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rp_id: Option, - /// Human-readable RP name surfaced in the Hello prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub rp_name: Option, - /// Per-user opaque ID (base64). Used at make-credential time. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_id_b64: Option, - /// Username surfaced in the Hello prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_name: Option, - /// Display name surfaced in the Hello prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub user_display_name: Option, - /// Credential identifier (base64). Required for - /// `webauthn_get_assertion` and `webauthn_delete_platform_credential`. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub credential_id_b64: Option, - /// Bytes that webauthn.dll hashes with SHA-256 and signs as the - /// `clientDataHash`. For SSH-SK signing this is the raw SSH - /// session-binding payload (we exploit the bytes-of-JSON - /// contract -- see `enclaveapp-windows-webauthn` for the - /// brittleness note). Base64. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub client_data_b64: Option, - /// Hello prompt timeout in milliseconds (default 60_000). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub timeout_ms: Option, -} - -impl BridgeParams { - /// Access policy requested by this message. Kept as a method for - /// source-compatibility with the legacy `effective_access_policy()` - /// call sites that used to reconcile `access_policy` vs a legacy - /// `biometric: bool` flag. - #[must_use] - pub fn effective_access_policy(&self) -> AccessPolicy { - self.access_policy - } - - /// Build a new `BridgeParams` for the legacy (TPM/CNG) methods. - /// The WebAuthn-side fields are left `None`. - #[must_use] - pub fn new( - data: String, - access_policy: AccessPolicy, - app_name: String, - key_label: String, - ) -> Self { - Self { - data, - access_policy, - app_name, - key_label, - rp_id: None, - rp_name: None, - user_id_b64: None, - user_name: None, - user_display_name: None, - credential_id_b64: None, - client_data_b64: None, - timeout_ms: None, - } - } -} - -/// JSON payload returned in `BridgeResponse::result` for a successful -/// `webauthn_make_credential` call. Caller `serde_json::from_str`s -/// the result string to recover the structure. -#[derive(Debug, Serialize, Deserialize)] -pub struct WebauthnMakeCredentialResult { - /// Base64 of the platform-authenticator credential identifier. - pub credential_id_b64: String, - /// Hex-encoded ECDSA P-256 public-key X coordinate (32 bytes). - pub public_key_x_hex: String, - /// Hex-encoded ECDSA P-256 public-key Y coordinate (32 bytes). - pub public_key_y_hex: String, - /// Base64 of the raw `authenticator_data` from the make-credential - /// response. Caller can use it for audit / debugging; not - /// required for downstream signing. - pub authenticator_data_b64: String, - /// Whether the platform authenticator made the credential - /// resident (Win11 26200+ forces resident regardless of the - /// `bRequireResidentKey` hint). - pub resident: bool, -} - -/// JSON payload returned in `BridgeResponse::result` for a successful -/// `webauthn_get_assertion` call. -#[derive(Debug, Serialize, Deserialize)] -pub struct WebauthnAssertionResult { - /// Base64 of the DER-encoded ECDSA signature (`SEQUENCE { INTEGER r, INTEGER s }`). - pub signature_der_b64: String, - /// Base64 of the `authenticator_data` blob (32-byte rpIdHash + flags + counter, possibly + extensions). - pub authenticator_data_b64: String, - /// `authenticator_data[32]`. Bit 0 = User Present, bit 2 = User Verified. - pub flags: u8, - /// Big-endian u32 from `authenticator_data[33..37]`. The TPM - /// increments this on every assertion. - pub counter: u32, -} - -/// Bridge response from Windows server to WSL client. -#[derive(Debug, Serialize, Deserialize)] -pub struct BridgeResponse { - /// Base64-encoded result data (on success). - pub result: Option, - /// Error message (on failure). - pub error: Option, -} - -impl BridgeResponse { - /// Create a successful response with data. - pub fn success(data: &str) -> Self { - BridgeResponse { - result: Some(data.to_string()), - error: None, - } - } - - /// Create an error response. - pub fn error(msg: &str) -> Self { - BridgeResponse { - result: None, - error: Some(msg.to_string()), - } - } - - /// Create a successful response with no data. - pub fn ok() -> Self { - BridgeResponse { - result: Some(String::new()), - error: None, - } - } - - /// Require that the response contains a success result payload. - pub fn require_result(&self, operation: &str) -> enclaveapp_core::Result<&str> { - if let Some(error) = &self.error { - return Err(enclaveapp_core::Error::KeyOperation { - operation: operation.into(), - detail: error.clone(), - }); - } - self.result - .as_deref() - .ok_or_else(|| enclaveapp_core::Error::KeyOperation { - operation: operation.into(), - detail: "bridge response missing result payload".into(), - }) - } - - /// Require an acknowledged success response. - pub fn require_ok(&self, operation: &str) -> enclaveapp_core::Result<()> { - let _unused = self.require_result(operation)?; - Ok(()) - } - - /// Decode a base64-encoded success payload. - pub fn decode_result(&self, operation: &str) -> enclaveapp_core::Result> { - decode_data(self.require_result(operation)?) - } -} - -/// Encode binary data as base64 for the bridge protocol. -pub fn encode_data(data: &[u8]) -> String { - base64::engine::general_purpose::STANDARD.encode(data) -} - -/// Decode base64 data from the bridge protocol. -pub fn decode_data(encoded: &str) -> enclaveapp_core::Result> { - base64::engine::general_purpose::STANDARD - .decode(encoded) - .map_err(|e| enclaveapp_core::Error::Serialization(format!("base64 decode: {e}"))) -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::panic)] -mod tests { - use super::*; - - #[test] - fn bridge_request_serde_roundtrip() { - let request = BridgeRequest { - method: "encrypt".to_string(), - params: BridgeParams::new( - "aGVsbG8=".to_string(), - AccessPolicy::BiometricOnly, - "test-app".to_string(), - "cache-key".to_string(), - ), - }; - let json = serde_json::to_string(&request).unwrap(); - let parsed: BridgeRequest = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.method, "encrypt"); - assert_eq!(parsed.params.data, "aGVsbG8="); - assert_eq!(parsed.params.access_policy, AccessPolicy::BiometricOnly); - assert_eq!(parsed.params.app_name, "test-app"); - assert_eq!(parsed.params.key_label, "cache-key"); - } - - #[test] - fn bridge_request_defaults_for_missing_fields() { - let json = r#"{"method":"init","params":{}}"#; - let parsed: BridgeRequest = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.method, "init"); - assert_eq!(parsed.params.data, ""); - assert_eq!(parsed.params.access_policy, AccessPolicy::None); - assert_eq!(parsed.params.app_name, ""); - assert_eq!(parsed.params.key_label, ""); - } - - #[test] - fn bridge_request_ignores_legacy_biometric_field() { - // Older callers may still include `biometric: true` on the wire. - // We must not silently honor it — access_policy is authoritative. - // Unknown fields are ignored by serde's default, so the legacy - // flag simply has no effect. - let json = r#"{"method":"encrypt","params":{"biometric":true,"access_policy":"none","app_name":"a","key_label":"k"}}"#; - let parsed: BridgeRequest = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.params.access_policy, AccessPolicy::None); - assert_eq!(parsed.params.effective_access_policy(), AccessPolicy::None); - } - - #[test] - fn bridge_response_success_construction() { - let resp = BridgeResponse::success("c29tZSBkYXRh"); - assert_eq!(resp.result, Some("c29tZSBkYXRh".to_string())); - assert!(resp.error.is_none()); - } - - #[test] - fn bridge_response_error_construction() { - let resp = BridgeResponse::error("something went wrong"); - assert!(resp.result.is_none()); - assert_eq!(resp.error, Some("something went wrong".to_string())); - } - - #[test] - fn bridge_response_ok_construction() { - let resp = BridgeResponse::ok(); - assert_eq!(resp.result, Some(String::new())); - assert!(resp.error.is_none()); - } - - #[test] - fn bridge_response_serde_roundtrip() { - let resp = BridgeResponse::success("dGVzdA=="); - let json = serde_json::to_string(&resp).unwrap(); - let parsed: BridgeResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.result, Some("dGVzdA==".to_string())); - assert!(parsed.error.is_none()); - } - - #[test] - fn encode_decode_roundtrip_empty() { - let data = b""; - let encoded = encode_data(data); - let decoded = decode_data(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn encode_decode_roundtrip_small() { - let data = b"hello, world!"; - let encoded = encode_data(data); - let decoded = decode_data(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn encode_decode_roundtrip_large() { - let data: Vec = (0..10_000).map(|i| (i % 256) as u8).collect(); - let encoded = encode_data(&data); - let decoded = decode_data(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn decode_data_rejects_invalid_base64() { - let result = decode_data("not valid base64!!!"); - assert!(result.is_err()); - } - - #[test] - fn bridge_request_all_methods() { - for method in &["init", "encrypt", "decrypt", "destroy", "delete"] { - let request = BridgeRequest { - method: (*method).to_string(), - params: BridgeParams::new( - String::new(), - AccessPolicy::None, - "test".to_string(), - "default".to_string(), - ), - }; - let json = serde_json::to_string(&request).unwrap(); - let parsed: BridgeRequest = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.method, *method); - } - } - - #[test] - fn bridge_response_success_with_empty_result() { - let resp = BridgeResponse::ok(); - assert_eq!(resp.result, Some(String::new())); - assert!(resp.error.is_none()); - - // Verify it roundtrips through JSON - let json = serde_json::to_string(&resp).unwrap(); - let parsed: BridgeResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.result, Some(String::new())); - } - - #[test] - fn bridge_response_error_preserves_message() { - let msg = "TPM device not found: error code 0x8028000F"; - let resp = BridgeResponse::error(msg); - assert_eq!(resp.error.as_deref(), Some(msg)); - assert!(resp.result.is_none()); - - let json = serde_json::to_string(&resp).unwrap(); - let parsed: BridgeResponse = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.error.as_deref(), Some(msg)); - } - - #[test] - fn encode_decode_binary_data_with_null_bytes() { - let data: Vec = vec![0x00, 0x01, 0x00, 0xFF, 0x00, 0xFE]; - let encoded = encode_data(&data); - let decoded = decode_data(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn encode_decode_large_data_1mb() { - let data: Vec = (0..1_000_000).map(|i| (i % 256) as u8).collect(); - let encoded = encode_data(&data); - let decoded = decode_data(&encoded).unwrap(); - assert_eq!(decoded, data); - } - - #[test] - fn decode_data_invalid_base64_returns_error() { - let result = decode_data("!!!not-base64!!!"); - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!(err_msg.contains("base64")); - } - - #[test] - fn decode_data_empty_string_returns_empty_vec() { - let result = decode_data("").unwrap(); - assert!(result.is_empty()); - } - - #[test] - fn bridge_params_default_values() { - let json = r#"{}"#; - let params: BridgeParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.data, ""); - assert_eq!(params.access_policy, AccessPolicy::None); - assert_eq!(params.app_name, ""); - assert_eq!(params.key_label, ""); - } - - #[cfg(target_os = "macos")] - #[test] - fn find_bridge_returns_none_on_macos() { - // On macOS there's no /mnt/c/ and no bridge binary - let result = crate::find_bridge("sshenc"); - assert!(result.is_none()); - } - - #[test] - fn bridge_params_biometric_only_access_policy() { - let json = r#"{"access_policy":"biometric_only","app_name":"t","key_label":"k"}"#; - let params: BridgeParams = serde_json::from_str(json).unwrap(); - assert_eq!( - params.effective_access_policy(), - AccessPolicy::BiometricOnly - ); - } - - #[test] - fn bridge_params_any_access_policy() { - let json = r#"{"access_policy":"any","app_name":"t","key_label":"k"}"#; - let params: BridgeParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.effective_access_policy(), AccessPolicy::Any); - } - - #[test] - fn bridge_params_wire_format_omits_biometric_field() { - // We must not serialize a `biometric` field on the wire — old - // servers that preferred it over `access_policy` would observe - // a false value and silently downgrade. Note: the - // `biometric_only` access-policy enum variant string contains - // the substring `biometric`, so the assertion must check for - // the quoted JSON key specifically. - let params = BridgeParams::new( - String::new(), - AccessPolicy::BiometricOnly, - "app".into(), - "key".into(), - ); - let json = serde_json::to_string(¶ms).unwrap(); - assert!(!json.contains("\"biometric\"")); - assert!(json.contains("\"access_policy\":\"biometric_only\"")); - } - - #[test] - fn bridge_response_require_result_rejects_null() { - let resp = BridgeResponse { - result: None, - error: None, - }; - let err = resp.require_result("test_op").unwrap_err(); - assert!(err.to_string().contains("missing result payload")); - } - - #[test] - fn bridge_response_require_result_rejects_error() { - let resp = BridgeResponse::error("boom"); - let err = resp.require_result("test_op").unwrap_err(); - assert!(err.to_string().contains("boom")); - } - - #[test] - fn bridge_response_decode_result_works() { - let resp = BridgeResponse::success("aGVsbG8="); - let data = resp.decode_result("test_op").unwrap(); - assert_eq!(data, b"hello"); - } - - #[test] - fn bridge_response_require_ok_succeeds_on_ok() { - let resp = BridgeResponse::ok(); - assert!(resp.require_ok("test").is_ok()); - } - - #[test] - fn bridge_response_require_ok_rejects_null() { - let resp = BridgeResponse { - result: None, - error: None, - }; - let err = resp.require_ok("test").unwrap_err(); - assert!(err.to_string().contains("missing result payload")); - } - - #[test] - fn bridge_response_require_ok_rejects_error() { - let resp = BridgeResponse::error("fail"); - let err = resp.require_ok("test").unwrap_err(); - assert!(err.to_string().contains("fail")); - } - - #[test] - fn bridge_response_decode_result_empty_string() { - let resp = BridgeResponse::ok(); - let data = resp.decode_result("test").unwrap(); - assert!(data.is_empty()); - } - - #[test] - fn bridge_response_decode_result_rejects_invalid_base64() { - let resp = BridgeResponse::success("not-valid-base64!!!"); - let err = resp.decode_result("test").unwrap_err(); - assert!(err.to_string().contains("base64")); - } - - #[test] - fn effective_access_policy_with_all_variants() { - let params = BridgeParams::new(String::new(), AccessPolicy::Any, "a".into(), "k".into()); - assert_eq!(params.effective_access_policy(), AccessPolicy::Any); - - let params = BridgeParams::new( - String::new(), - AccessPolicy::PasswordOnly, - "a".into(), - "k".into(), - ); - assert_eq!(params.effective_access_policy(), AccessPolicy::PasswordOnly); - - let params = BridgeParams::new(String::new(), AccessPolicy::None, "a".into(), "k".into()); - assert_eq!(params.effective_access_policy(), AccessPolicy::None); - } - - #[test] - fn bridge_params_roundtrip_preserves_all_fields() { - let original = BridgeParams::new( - "dGVzdA==".into(), - AccessPolicy::BiometricOnly, - "my-app".into(), - "my-key".into(), - ); - let json = serde_json::to_string(&original).unwrap(); - let parsed: BridgeParams = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.data, "dGVzdA=="); - assert_eq!(parsed.access_policy, AccessPolicy::BiometricOnly); - assert_eq!( - parsed.effective_access_policy(), - AccessPolicy::BiometricOnly - ); - assert_eq!(parsed.app_name, "my-app"); - assert_eq!(parsed.key_label, "my-key"); - } -} diff --git a/crates/enclaveapp-core/Cargo.toml b/crates/enclaveapp-core/Cargo.toml deleted file mode 100644 index e3acb557..00000000 --- a/crates/enclaveapp-core/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "enclaveapp-core" -description = "Platform-agnostic types, traits, and utilities for hardware-backed key management" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -repository.workspace = true - -[lints] -workspace = true - -[dependencies] -serde = { workspace = true } -serde_json = { workspace = true } -toml = { workspace = true } -dirs = { workspace = true } -thiserror = { workspace = true } -libc = { workspace = true } -fs2 = { workspace = true } -sha2 = { workspace = true } -tracing = "0.1" - -# Windows-only: SetProcessMitigationPolicy for `harden_process`. -[target.'cfg(windows)'.dependencies] -windows = { workspace = true } diff --git a/crates/enclaveapp-core/README.md b/crates/enclaveapp-core/README.md deleted file mode 100644 index 27c67df7..00000000 --- a/crates/enclaveapp-core/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# enclaveapp-core - -Platform-agnostic types, traits, and utilities for hardware-backed key management. - -This crate contains no FFI or platform-specific code. It defines the abstractions that platform backends implement and the shared infrastructure they use. - -## Traits - -- **`EnclaveKeyManager`** -- key lifecycle: generate, list, get, delete, availability check -- **`EnclaveSigner`** -- ECDSA P-256 signing (extends `EnclaveKeyManager`) -- **`EnclaveEncryptor`** -- ECIES encryption/decryption (extends `EnclaveKeyManager`) - -## Types - -- **`KeyType`** -- `Signing` or `Encryption` -- **`AccessPolicy`** -- `None`, `Any`, `BiometricOnly`, `PasswordOnly` -- **`KeyMeta`** -- JSON-serializable metadata with app-specific extension fields - -## Utilities - -- **Metadata** -- `save_meta`/`load_meta`, `save_pub_key`/`load_pub_key`, `list_labels`, `delete_key_files`, `rename_key_files` -- **File I/O** -- `atomic_write` (temp file + rename), `DirLock` (flock-based), `ensure_dir` (with 0700 permissions) -- **Config** -- `load_toml`/`save_toml` with silent defaults for missing files -- **Validation** -- `validate_label` (alphanumeric + hyphens/underscores, max 64 chars), `validate_p256_point` (65-byte SEC1) -- **Platform** -- `is_macos()`, `is_windows()`, `is_wsl()`, `hardware_name()` - -## Key storage layout - -``` -~/.config//keys/ -