diff --git a/.devcontainer/dev-machine/Dockerfile b/.devcontainer/dev-machine/Dockerfile index 8fcafca13..d932ee4eb 100644 --- a/.devcontainer/dev-machine/Dockerfile +++ b/.devcontainer/dev-machine/Dockerfile @@ -9,7 +9,7 @@ RUN echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/ libpam0g-dev libudev-dev pamtester \ # Required for keyring dbus dbus-x11 gnome-keyring \ - libdbus-1-dev pkg-config \ + libdbus-1-dev pkg-config libtss2-dev \ # Required for packaging nfpm \ # Required for OSquery diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 42ed5164b..6274a64ab 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,8 +39,8 @@ jobs: with: languages: ${{ matrix.language-platform.language }} - name: Install libraries - if: ${{ matrix.language-platform.language == 'ubuntu-24.04' }} - run: sudo apt-get update && sudo apt-get install -y libpam0g-dev libudev-dev + if: ${{ matrix.language-platform.platform == 'ubuntu-24.04' }} + run: sudo apt-get update && sudo apt-get install -y libpam0g-dev libudev-dev pkg-config libdbus-1-dev libtss2-dev - name: Autobuild uses: github/codeql-action/autobuild@v4.37.4 - name: Perform CodeQL Analysis diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5596bc207..575347a5e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,8 @@ jobs: runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v5 + - if: ${{ startsWith(matrix.platform, 'ubuntu') }} + run: make ci-install-deps - uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1 with: rustflags: "" diff --git a/Cargo.lock b/Cargo.lock index b8aeaf19c..60775c755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -39,6 +49,21 @@ dependencies = [ "cpufeatures 0.3.0", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -155,6 +180,7 @@ dependencies = [ "serde", "serde_json", "tokio", + "tonic", "tracing", "url", "uuid", @@ -232,6 +258,7 @@ dependencies = [ name = "ak-platform" version = "0.50.5" dependencies = [ + "ak-platform-keyring", "base64 0.23.0", "bytes", "chrono", @@ -328,13 +355,13 @@ dependencies = [ name = "ak-platform-keyring" version = "0.50.5" dependencies = [ - "ak-platform", "chrono", - "core-foundation", + "core-foundation 0.10.1", "eyre", + "hardware-enclave", "keyring-core", "secret-service", - "security-framework", + "security-framework 3.7.0", "serde", "serde_json", "tokio", @@ -574,7 +601,7 @@ dependencies = [ "libc", "portable-atomic", "rustc-hash", - "rustix", + "rustix 1.1.4", "tokio", "tokio-stream", "xattr", @@ -631,7 +658,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -662,7 +689,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -688,7 +715,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -1250,6 +1277,26 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitfield" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45721c9db4c7a20899d05efb7ad9235f50b256e980db30ffb229abf732934c3" +dependencies = [ + "bitfield-macros", +] + +[[package]] +name = "bitfield-macros" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cb6f3d4773a2107b94cbeccaa5b5f0b35a88389b5d522d13d659f64317b22d" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1608,7 +1655,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -1924,6 +1971,16 @@ dependencies = [ "url", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1947,7 +2004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -1960,7 +2017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -2039,7 +2096,7 @@ dependencies = [ "document-features", "mio", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook 0.3.18", "signal-hook-mio", "winapi", @@ -2079,6 +2136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -2172,6 +2230,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -2334,6 +2401,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -3060,6 +3137,26 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "fs4" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c6b3bd49c37d2aa3f3f2220233b29a7cd23f79d1fe70e5337d25fb390793de" +dependencies = [ + "rustix 0.38.44", + "windows-sys 0.52.0", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -3292,7 +3389,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -3335,6 +3432,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.32.3" @@ -3549,6 +3656,39 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hardware-enclave" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d400c6f49b846b3f9f2ea8318d8fbfc9fa71db3933d8bd510c25f23e893482" +dependencies = [ + "aes-gcm", + "anyhow", + "base64 0.22.1", + "ciborium", + "dirs", + "elliptic-curve", + "fs2", + "fs4", + "keyring", + "libc", + "p256", + "rand 0.9.5", + "serde", + "serde_json", + "sha2 0.10.9", + "shlex 1.3.0", + "subtle", + "tempfile", + "thiserror 2.0.19", + "toml 0.8.2", + "tracing", + "tss-esapi", + "windows 0.58.0", + "winresource", + "zeroize", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3685,6 +3825,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + [[package]] name = "html5ever" version = "0.38.0" @@ -4439,6 +4585,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "keyring-core" version = "1.0.0" @@ -4599,6 +4760,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4674,6 +4841,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "mbox" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d142aeadbc4e8c679fc6d93fbe7efe1c021fa7d80629e615915b519e3bc6de" +dependencies = [ + "libc", + "stable_deref_trait", +] + [[package]] name = "memchr" version = "2.8.3" @@ -5261,6 +5438,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "oid" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c19903c598813dba001b53beeae59bb77ad4892c5c1b9b3500ce4293a0d06c2" +dependencies = [ + "serde", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -5282,6 +5468,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.4.0" @@ -5370,7 +5562,7 @@ source = "git+https://github.com/BeryJu/osquery-rs#229ed01743168f73314fb4dee4df7 dependencies = [ "cc", "sha2 0.11.0", - "shlex", + "shlex 2.0.1", "ureq", ] @@ -5772,6 +5964,41 @@ dependencies = [ "siphasher", ] +[[package]] +name = "picky-asn1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff038f9360b934342fb3c0a1d6e82c438a2624b51c3c6e3e6d7cf252b6f3ee3" +dependencies = [ + "oid", + "serde", + "serde_bytes", +] + +[[package]] +name = "picky-asn1-der" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d413165e4bf7f808b9a27cbaba657657a2921f0965db833f488c4d4be96dcd2e" +dependencies = [ + "picky-asn1", + "serde", + "serde_bytes", +] + +[[package]] +name = "picky-asn1-x509" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d4117bd1b1dc5646359ee7243c50c5000c0920ea2d1fb120335a2f4c684b8" +dependencies = [ + "base64 0.22.1", + "oid", + "picky-asn1", + "picky-asn1-der", + "serde", +] + [[package]] name = "pin-project" version = "1.1.13" @@ -5891,10 +6118,22 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.14.0" @@ -6662,6 +6901,19 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -6671,7 +6923,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -6712,7 +6964,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -6731,7 +6983,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -6740,7 +6992,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki 0.103.13", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -6920,6 +7172,19 @@ dependencies = [ "zbus", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6927,7 +7192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.1", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -7117,6 +7382,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -7315,6 +7590,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -7819,7 +8100,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -8162,7 +8443,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -8192,7 +8473,7 @@ checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" dependencies = [ "bitflags 2.13.1", "parking_lot", - "rustix", + "rustix 1.1.4", "signal-hook 0.3.18", "windows-sys 0.61.2", ] @@ -8792,6 +9073,39 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tss-esapi" +version = "7.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f10b25a84912b894d0e6d68f4a3771c923e9c44ddaaed7920cde92ed28aa84e" +dependencies = [ + "bitfield", + "enumflags2", + "getrandom 0.4.3", + "hostname-validator", + "log", + "mbox", + "num-derive", + "num-traits", + "oid", + "picky-asn1", + "picky-asn1-x509", + "regex", + "serde", + "tss-esapi-sys", + "zeroize", +] + +[[package]] +name = "tss-esapi-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f972672926a3d3d18ecc04524720e4d20b7d1664a3fb73dbf7d4274196dbd9" +dependencies = [ + "pkg-config", + "target-lexicon", +] + [[package]] name = "typeid" version = "1.0.3" @@ -8912,6 +9226,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -10056,6 +10380,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winresource" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +dependencies = [ + "toml 1.1.3+spec-1.1.0", + "version_check", +] + [[package]] name = "wiremock" version = "0.6.5" @@ -10195,7 +10529,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -10249,7 +10583,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tokio", diff --git a/Makefile b/Makefile index e8a215b9b..ee7f030e6 100644 --- a/Makefile +++ b/Makefile @@ -16,7 +16,6 @@ clean: .PHONY: gen gen: rs-gen-proto ee/psso/gen - go generate ./... rs-gen-proto: cargo install protoc-gen-prost @@ -42,7 +41,8 @@ ifeq ($(PLATFORM),gnu/linux) ifeq ($(CI),true) sudo apt-get update sudo apt-get install -y \ - libpam0g-dev libudev-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + libpam0g-dev libudev-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \ + pkg-config libdbus-1-dev libtss2-dev endif endif @@ -72,7 +72,7 @@ test-e2e-convert: $(call rs_e2e_coverage_convert) test-setup: - go run -v ./cmd/cli setup -v http://authentik:9000 + cargo run -p ak-cli -v config setup --authentik-url http://authentik:9000 test-ssh: ssh -i akadmin@ak-platform-test-machine diff --git a/README.md b/README.md index deb182099..347c39fda 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ brew install gmake rustup swift ### Linux Dependencies ```shell -sudo apt-get install libpam0g-dev libudev-dev +sudo apt-get install libpam0g-dev libudev-dev pkg-config libdbus-1-dev libtss2-dev \ + libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf ``` ### Windows Dependencies @@ -68,17 +69,17 @@ CLI tool (`ak`), built in Rust. Used to interact with the agent. Runs on macOS, Requirements: Rust toolchain. -#### `sysd/%` +#### `ak-sysd/%` System agent daemon (`ak-sysd`), built in Go. Runs on macOS, Linux, and Windows. -Requirements: Go (version from `go.mod`). On Windows, `goversioninfo` is invoked automatically as a Go tool to embed version resources. +Requirements: Rust toolchain. -#### `agent/%` +#### `ak-agent/%` Local agent / systray app (`ak-agent`), built in Go. Runs on macOS, Linux, and Windows. On Windows, built with `-H=windowsgui` so it runs as a true background systray process. -Requirements: Go (version from `go.mod`). On Windows, also uses `goversioninfo` (Go tool) to embed version resources. +Requirements: Rust toolchain. #### `browser-ext/%` diff --git a/ak-agent-desktop/Makefile b/ak-agent-desktop/Makefile index 5a208809c..c708ef602 100644 --- a/ak-agent-desktop/Makefile +++ b/ak-agent-desktop/Makefile @@ -15,7 +15,7 @@ build: ifeq ($(PLATFORM),gnu/linux) ifeq ($(CI),true) sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libtss2-dev endif endif mkdir -p "${TOP}/bin/${TARGET}" diff --git a/ak-agent/Makefile b/ak-agent/Makefile index 3b50ea779..a3032678a 100644 --- a/ak-agent/Makefile +++ b/ak-agent/Makefile @@ -17,6 +17,12 @@ build: $(call cargo_build,$(TARGET)) cp "${TOP}/cache/shared/release/${BIN}" "${TOP}/bin/${TARGET}/${BIND}" +build-local: + mkdir -p "${TOP}/bin/${TARGET}" + mkdir -p "${TOP}/cache/shared" + $(call cargo_build_local,$(TARGET)) + cp "${TOP}/cache/shared/release/${BIN}" "${TOP}/bin/${TARGET}/${BIND}" + test-deploy: build $(TME) dpkg -i /workspaces/bin/${TARGET}/authentik-${TARGET}_${VERSION}+_${ARCH}.deb $(TME) systemctl restart ssh diff --git a/ak-agent/src/config/mod.rs b/ak-agent/src/config/mod.rs index 01d2cc2e9..95cf58ab7 100644 --- a/ak-agent/src/config/mod.rs +++ b/ak-agent/src/config/mod.rs @@ -1,18 +1,34 @@ use std::{collections::HashMap, fmt::Debug}; use ak_meta::user_agent; -use ak_platform::dpop::DpopKeyPair; +use ak_platform::dpop::{DpopKeyPair, DpopSigner}; use ak_platform::log::LevelFilter; use ak_platform::log::set_log_level; use ak_platform::paths::DEFAULT_PROFILE; use ak_platform::storage::cfgmgr::schema::Config; use ak_platform_keyring; use ak_platform_keyring::KeyringStore; +use ak_platform_keyring::hardware::HardwareSigningKey; use authentik_client::apis::configuration::Configuration; use eyre::Result; use reqwest::Client; use serde::{Deserialize, Serialize}; +/// `hardware-enclave` app identity for DPoP keys — grouped under the same +/// dev/prod credential namespace as the other keyring-stored secrets. +pub(crate) fn dpop_hardware_app_name() -> String { + ak_platform_keyring::service("dpop") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DpopKeyBackend { + #[default] + None, + Software, + Hardware, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ConfigV1 { pub debug: bool, @@ -42,16 +58,22 @@ pub struct ConfigV1Profile { pub fallback_access_token: String, #[serde(rename = "refresh_token")] pub fallback_refresh_token: String, - // Empty string if this profile is not DPoP key-bound. + // Empty string unless dpop_key_backend == Software. #[serde(rename = "dpop_private_key", default)] pub fallback_dpop_private_key: String, + // Which key backend (if any) this profile's DPoP proofs are signed with. + // Plain, non-secret — stored directly in the config JSON, not the keyring. + #[serde(default)] + pub dpop_key_backend: DpopKeyBackend, // Not saved to JSON, loaded from keychain #[serde(skip)] _access_token: String, #[serde(skip)] _refresh_token: String, - // PKCS#8 PEM; empty string if this profile is not DPoP key-bound. + // PKCS#8 PEM; only meaningful when dpop_key_backend == Software. A + // Hardware-backed key has no material to store here — the enclave itself + // durably persists it, addressable again via (app_name, profile_name). #[serde(skip)] _dpop_private_key: String, @@ -71,6 +93,7 @@ impl Debug for ConfigV1Profile { "fallback_dpop_private_key", &self.fallback_dpop_private_key.len(), ) + .field("dpop_key_backend", &self.dpop_key_backend) .field("_access_token", &self._access_token.len()) .field("_refresh_token", &self._refresh_token.len()) .field("_dpop_private_key", &self._dpop_private_key.len()) @@ -80,13 +103,16 @@ impl Debug for ConfigV1Profile { } impl ConfigV1Profile { + /// Builds a fresh, non-DPoP profile. Profiles that use a DPoP key are + /// created ahead of time by the `PrepareDpopKey` RPC (see + /// `grpc/agent_ctrl.rs::prepare_dpop_key`); `Setup` only fills in tokens + /// onto that existing entry, preserving its `dpop_key_backend`. pub fn from_tokens( authentik_url: String, app_slug: String, client_id: String, access_token: String, refresh_token: String, - dpop_private_key: String, ) -> Self { ConfigV1Profile { authentik_url, @@ -95,9 +121,10 @@ impl ConfigV1Profile { fallback_access_token: "".to_string(), fallback_refresh_token: "".to_string(), fallback_dpop_private_key: "".to_string(), + dpop_key_backend: DpopKeyBackend::None, _access_token: access_token, _refresh_token: refresh_token, - _dpop_private_key: dpop_private_key, + _dpop_private_key: String::new(), _http_client: None, } } @@ -118,17 +145,32 @@ impl ConfigV1Profile { self._refresh_token = t.to_string() } - /// Whether this profile has a DPoP keypair bound to it. + /// Sets the profile's stored DPoP private-key material. Only meaningful + /// for `DpopKeyBackend::Software` — pass an empty string for `None`/`Hardware`. + pub fn set_dpop_private_key(&mut self, t: T) { + self._dpop_private_key = t.to_string() + } + + /// Whether this profile has a DPoP key bound to it. pub fn dpop_enabled(&self) -> bool { - !self._dpop_private_key.is_empty() + self.dpop_key_backend != DpopKeyBackend::None } - /// The profile's DPoP keypair, if it has one. - pub fn dpop_keypair(&self) -> Result> { - if self._dpop_private_key.is_empty() { - return Ok(None); + /// The profile's DPoP signer, if it has one. `profile_name` is the + /// caller's own key into `ConfigV1::profiles` (not stored redundantly on + /// `ConfigV1Profile` itself) — for a hardware-backed profile it doubles as + /// the enclave key label, so it must be the exact same name used when the + /// key was created via `PrepareDpopKey`. + pub fn dpop_signer(&self, profile_name: &str) -> Result> { + match self.dpop_key_backend { + DpopKeyBackend::None => Ok(None), + DpopKeyBackend::Software => Ok(Some(DpopSigner::Software( + DpopKeyPair::from_pkcs8_pem(&self._dpop_private_key)?, + ))), + DpopKeyBackend::Hardware => Ok(Some(DpopSigner::Hardware( + HardwareSigningKey::open_or_generate(&dpop_hardware_app_name(), profile_name)?, + ))), } - Ok(Some(DpopKeyPair::from_pkcs8_pem(&self._dpop_private_key)?)) } pub fn http_client(mut self) -> Client { diff --git a/ak-agent/src/grpc/agent_auth.rs b/ak-agent/src/grpc/agent_auth.rs index 7ea4673ac..35615173c 100644 --- a/ak-agent/src/grpc/agent_auth.rs +++ b/ak-agent/src/grpc/agent_auth.rs @@ -1,11 +1,13 @@ use ak_meta::user_agent; use ak_platform::{ + dpop::build_proof, generated::{ agent::{ResponseHeader, Token}, agent_auth::{ AuthorizeRequest, AuthorizeResponse, CurrentTokenRequest, CurrentTokenResponse, - DeviceTokenExchangeRequest, TokenExchangeRequest, TokenExchangeResponse, WhoAmIRequest, - WhoAmIResponse, agent_auth_server::AgentAuth, current_token_request::Type, + DeviceTokenExchangeRequest, SignDpopProofRequest, SignDpopProofResponse, + TokenExchangeRequest, TokenExchangeResponse, WhoAmIRequest, WhoAmIResponse, + agent_auth_server::AgentAuth, current_token_request::Type, }, }, net::server::creds::ProcCredentials, @@ -334,6 +336,63 @@ impl AgentAuth for AgentGRPCServer { })) } + async fn sign_dpop_proof( + &self, + request: Request, + ) -> Result, Status> { + let pc = request.extensions().get::().cloned(); + let inner = request.into_inner(); + let profile_name = inner + .header + .as_ref() + .ok_or(Status::invalid_argument("missing header"))? + .profile + .clone(); + let profile = self.profile_for_request(inner.header).await?; + + let pn1 = profile_name.clone(); + let pn2 = profile_name.clone(); + AuthorizeAction { + message: Box::new(move |c| { + let cmd = c.clone().proc_info()?.parent_cmdline()?; + Ok(PlatformString::new() + .with_darwin(format!("authorize DPoP signing for '{pn1}' in '{cmd}'")) + .with_windows(format!( + "'{cmd}' is attempting to sign a DPoP proof for '{pn1}'" + )) + .with_linux(format!( + "'{cmd}' is attempting to sign a DPoP proof for '{pn1}'" + ))) + }), + uid: Box::new(move |c| { + let pid = c.clone().proc_info()?.unique_process_id()?; + Ok(format!("{pn2}:{pid}")) + }), + timeout_success: Duration::from_secs(30 * 60), + timeout_denied: Duration::from_secs(1), + } + .prompt_grpc(pc) + .await?; + + let signer = profile + .dpop_signer(&profile_name) + .map_err(|e| Status::from_error(e.into()))? + .ok_or_else(|| Status::failed_precondition("profile has no DPoP key"))?; + + let proof = build_proof( + &signer, + &inner.htm, + &inner.htu, + inner.code_for_c_s256.as_deref(), + ) + .map_err(|e| Status::from_error(e.into()))?; + + Ok(Response::new(SignDpopProofResponse { + header: Some(ResponseHeader { successful: true }), + proof, + })) + } + async fn authorize( &self, request: Request, diff --git a/ak-agent/src/grpc/agent_ctrl.rs b/ak-agent/src/grpc/agent_ctrl.rs index 4853beb19..7c2c5d268 100644 --- a/ak-agent/src/grpc/agent_ctrl.rs +++ b/ak-agent/src/grpc/agent_ctrl.rs @@ -1,13 +1,15 @@ +use ak_platform::dpop::{DpopKeyPair, DpopSigner}; use ak_platform::generated::{ agent::{RequestHeader, ResponseHeader}, agent_ctrl::{ - CurrentProfileResponse, ListProfilesResponse, Profile, SetupRequest, SetupResponse, - agent_ctrl_server::AgentCtrl, + CurrentProfileResponse, ListProfilesResponse, PrepareDpopKeyRequest, + PrepareDpopKeyResponse, Profile, SetupRequest, SetupResponse, agent_ctrl_server::AgentCtrl, }, }; +use ak_platform_keyring::hardware::{HardwareKeyError, HardwareSigningKey}; use tonic::{Request, Response, Status}; -use crate::config::ConfigV1Profile; +use crate::config::{ConfigV1Profile, DpopKeyBackend, dpop_hardware_app_name}; use crate::grpc::AgentGRPCServer; #[tonic::async_trait] @@ -56,17 +58,32 @@ impl AgentCtrl for AgentGRPCServer { .profile; { let mut cfg = self.agent.cfg.write().await; - cfg.profiles.insert( - profile_name.clone(), - ConfigV1Profile::from_tokens( - req.authentik_url, - req.app_slug, - req.client_id, - req.access_token, - req.refresh_token, - req.dpop_private_key, - ), - ); + // A DPoP-bound profile already exists at this point — created by + // `prepare_dpop_key` before the device flow started — so only its + // tokens need filling in, preserving `dpop_key_backend` and any + // key material. The non-DPoP case (no prior PrepareDpopKey call) + // creates the profile fresh, as before. + match cfg.profiles.get_mut(&profile_name) { + Some(profile) => { + profile.authentik_url = req.authentik_url; + profile.app_slug = req.app_slug; + profile.client_id = req.client_id; + profile.set_access_token(req.access_token); + profile.set_refresh_token(req.refresh_token); + } + None => { + cfg.profiles.insert( + profile_name.clone(), + ConfigV1Profile::from_tokens( + req.authentik_url, + req.app_slug, + req.client_id, + req.access_token, + req.refresh_token, + ), + ); + } + } if cfg.active_profile.is_empty() { cfg.active_profile = profile_name.clone(); } @@ -82,6 +99,96 @@ impl AgentCtrl for AgentGRPCServer { })) } + async fn prepare_dpop_key( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + let profile_name = req + .header + .ok_or(Status::invalid_argument("missing header"))? + .profile; + + let (signer, dpop_key_backend, dpop_private_key, hardware_backed) = + match HardwareSigningKey::open_or_generate(&dpop_hardware_app_name(), &profile_name) { + Ok(hw) => ( + DpopSigner::Hardware(hw), + DpopKeyBackend::Hardware, + String::new(), + true, + ), + Err(HardwareKeyError::NotAvailable) => { + tracing::info!( + profile = profile_name, + "no hardware key storage available on this device, using a software DPoP key" + ); + let kp = DpopKeyPair::generate(); + let pem = kp + .to_pkcs8_pem() + .map_err(|e| Status::from_error(e.into()))?; + ( + DpopSigner::Software(kp), + DpopKeyBackend::Software, + pem, + false, + ) + } + Err(HardwareKeyError::Other(e)) => { + tracing::warn!( + profile = profile_name, + error = %e, + "hardware DPoP key generation failed, falling back to a software key" + ); + let kp = DpopKeyPair::generate(); + let pem = kp + .to_pkcs8_pem() + .map_err(|e| Status::from_error(e.into()))?; + ( + DpopSigner::Software(kp), + DpopKeyBackend::Software, + pem, + false, + ) + } + }; + let dpop_jkt = signer + .thumbprint() + .map_err(|e| Status::from_error(e.into()))?; + + { + let mut cfg = self.agent.cfg.write().await; + let profile = cfg.profiles.entry(profile_name.clone()).or_insert_with(|| { + ConfigV1Profile::from_tokens( + req.authentik_url.clone(), + req.app_slug.clone(), + req.client_id.clone(), + String::new(), + String::new(), + ) + }); + profile.authentik_url = req.authentik_url; + profile.app_slug = req.app_slug; + profile.client_id = req.client_id; + profile.dpop_key_backend = dpop_key_backend; + profile.set_dpop_private_key(dpop_private_key); + } + if let Err(e) = self.agent.cfg.save().await { + tracing::warn!("failed to save config: {e:?}"); + return Err(Status::from_error(e.into())); + } + + tracing::info!( + profile = profile_name, + hardware_backed, + "prepared DPoP key for profile" + ); + Ok(Response::new(PrepareDpopKeyResponse { + header: Some(ResponseHeader { successful: true }), + dpop_jkt, + hardware_backed, + })) + } + async fn switch_profile( &self, request: Request, diff --git a/ak-agent/src/token/profile.rs b/ak-agent/src/token/profile.rs index fff488d6c..2af892cd5 100644 --- a/ak-agent/src/token/profile.rs +++ b/ak-agent/src/token/profile.rs @@ -192,7 +192,7 @@ impl ProfileTokenManager { } async fn renew(&self) -> Result<()> { - let (token_url, refresh_token, client_id, dpop_keypair) = { + let (token_url, refresh_token, client_id, dpop_signer) = { let config = self.cfg.read().await; let profile = config .profiles @@ -202,7 +202,7 @@ impl ProfileTokenManager { format!("{}/application/o/token/", profile.authentik_url), profile.refresh_token().clone(), profile.client_id.clone(), - profile.dpop_keypair()?, + profile.dpop_signer(&self.profile_name)?, ) }; @@ -220,8 +220,8 @@ impl ProfileTokenManager { ) .header(reqwest::header::USER_AGENT, user_agent()); - if let Some(kp) = &dpop_keypair { - let proof = ak_platform::dpop::build_proof(kp, "POST", &token_url, None)?; + if let Some(signer) = &dpop_signer { + let proof = ak_platform::dpop::build_proof(signer, "POST", &token_url, None)?; req = req.header("DPoP", proof); } diff --git a/ak-cli/Cargo.toml b/ak-cli/Cargo.toml index b99657a4a..3e2972090 100644 --- a/ak-cli/Cargo.toml +++ b/ak-cli/Cargo.toml @@ -26,6 +26,7 @@ ratatui = "0.30.1" serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } +tonic = { workspace = true } url = { workspace = true } eyre.workspace = true color-eyre.workspace = true diff --git a/ak-cli/src/commands/config.rs b/ak-cli/src/commands/config.rs index c8ea12bf4..e6a022604 100644 --- a/ak-cli/src/commands/config.rs +++ b/ak-cli/src/commands/config.rs @@ -69,7 +69,6 @@ pub async fn setup( ) -> Result<()> { let access_token: String; let refresh_token: String; - let mut dpop_private_key = String::new(); if let Ok(at) = env::var("AK_CLI_ACCESS_TOKEN") && let Ok(rt) = env::var("AK_CLI_REFRESH_TOKEN") { @@ -82,6 +81,7 @@ pub async fn setup( app_slug: app_slug.to_owned(), client_id: client_id.to_owned(), dpop_enabled: dpop, + agent: app.clone().user().await?, url_callback: None, }) .await @@ -94,9 +94,6 @@ pub async fn setup( } else { bail!("Device-flow setup did not return access/refresh token"); } - if let Some(key) = prof.dpop_private_key_pem { - dpop_private_key = key; - } } let res = app @@ -113,7 +110,6 @@ pub async fn setup( client_id: client_id.to_owned(), access_token: access_token.clone(), refresh_token: refresh_token.clone(), - dpop_private_key, }) .await .wrap_err("failed to register profile with agent")? diff --git a/ak-cli/src/setup/mod.rs b/ak-cli/src/setup/mod.rs index 8d388581a..5b771e078 100644 --- a/ak-cli/src/setup/mod.rs +++ b/ak-cli/src/setup/mod.rs @@ -1,9 +1,14 @@ use crate::format; use crate::setup::ak::urls_for_profile; use ak_meta::user_agent; -use ak_platform::dpop::DpopKeyPair; +use ak_platform::client::user::{AnyService, Client}; +use ak_platform::dpop::DpopProver; +use ak_platform::generated::agent::RequestHeader; +use ak_platform::generated::agent_auth::SignDpopProofRequest; +use ak_platform::generated::agent_auth::agent_auth_client::AgentAuthClient; +use ak_platform::generated::agent_ctrl::PrepareDpopKeyRequest; use ak_platform::oauth::device_flow::{poll_for_device_token, request_device_authorization}; -use eyre::Result; +use eyre::{Result, WrapErr}; use open::that; use ratatui::text::Line; use url::Url; @@ -18,6 +23,7 @@ pub struct Options { pub app_slug: String, pub client_id: String, pub dpop_enabled: bool, + pub agent: Client, pub url_callback: Option, } @@ -27,8 +33,6 @@ pub struct Profile { pub client_id: String, pub access_token: Option, pub refresh_token: Option, - /// PKCS#8 PEM DPoP private key, when `Options::dpop_enabled` was set. - pub dpop_private_key_pem: Option, } impl Profile { @@ -39,11 +43,38 @@ impl Profile { client_id, access_token: None, refresh_token: None, - dpop_private_key_pem: None, } } } +/// Signs DPoP proofs by asking ak-agent over gRPC — the device flow here runs +/// from ak-cli, which never holds the profile's DPoP key itself (it may be +/// hardware-backed and non-exportable; ak-agent is the sole owner). +struct RpcDpopProver { + auth: AgentAuthClient, + profile_name: String, +} + +#[tonic::async_trait] +impl DpopProver for RpcDpopProver { + async fn prove(&self, htm: &str, htu: &str, code_for_c_s256: Option<&str>) -> Result { + let mut auth = self.auth.clone(); + let res = auth + .sign_dpop_proof(SignDpopProofRequest { + header: Some(RequestHeader { + profile: self.profile_name.clone(), + }), + htm: htm.to_string(), + htu: htu.to_string(), + code_for_c_s256: code_for_c_s256.map(|s| s.to_string()), + }) + .await + .wrap_err("failed to sign DPoP proof")? + .into_inner(); + Ok(res.proof) + } +} + pub async fn setup(opts: Options) -> Result { let urls = urls_for_profile(Profile::new( opts.authentik_url.clone(), @@ -70,11 +101,39 @@ pub async fn setup(opts: Options) -> Result { }, }; - let dpop_keypair = opts.dpop_enabled.then(DpopKeyPair::generate); - let dpop_jkt = dpop_keypair - .as_ref() - .map(DpopKeyPair::thumbprint) - .transpose()?; + let mut dpop_jkt = None; + let mut dpop_prover: Option = None; + if opts.dpop_enabled { + let res = opts + .agent + .clone() + .ctrl() + .prepare_dpop_key(PrepareDpopKeyRequest { + header: Some(RequestHeader { + profile: opts.profile_name.clone(), + }), + authentik_url: opts.authentik_url.to_string(), + app_slug: opts.app_slug.clone(), + client_id: opts.client_id.clone(), + }) + .await + .wrap_err("failed to prepare DPoP key")? + .into_inner(); + + if res.hardware_backed { + eprintln!("Enrolling with a hardware-backed DPoP key."); + } else { + eprintln!( + "Hardware key storage unavailable on this device — using a software-protected DPoP key." + ); + } + + dpop_jkt = Some(res.dpop_jkt); + dpop_prover = Some(RpcDpopProver { + auth: opts.agent.clone().auth(), + profile_name: opts.profile_name.clone(), + }); + } let mut scopes = vec![ "openid", @@ -107,22 +166,16 @@ pub async fn setup(opts: Options) -> Result { &urls.token_url, &opts.client_id, &auth, - dpop_keypair.as_ref(), + dpop_prover.as_ref().map(|p| p as &dyn DpopProver), &user_agent(), ) .await?; - let dpop_private_key_pem = dpop_keypair - .as_ref() - .map(DpopKeyPair::to_pkcs8_pem) - .transpose()?; - Ok(Profile { authentik_url: opts.authentik_url.clone(), app_slug: opts.app_slug.clone(), client_id: opts.client_id.clone(), access_token: Some(token_response.access_token), refresh_token: token_response.refresh_token, - dpop_private_key_pem, }) } diff --git a/ak-platform-keyring/Cargo.toml b/ak-platform-keyring/Cargo.toml index 98887e088..8449c6348 100644 --- a/ak-platform-keyring/Cargo.toml +++ b/ak-platform-keyring/Cargo.toml @@ -5,12 +5,12 @@ edition.workspace = true license.workspace = true [dependencies] -ak-platform = { path = "../ak-platform" } eyre = { workspace = true } serde = { workspace = true } chrono = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } +hardware-enclave = { version = "0.2", default-features = false, features = ["signing", "encryption"] } [target.'cfg(target_os = "macos")'.dependencies] security-framework = { version = "3.7", features = ["OSX_10_15"] } diff --git a/ak-platform-keyring/src/hardware.rs b/ak-platform-keyring/src/hardware.rs new file mode 100644 index 000000000..2454d1895 --- /dev/null +++ b/ak-platform-keyring/src/hardware.rs @@ -0,0 +1,73 @@ +//! Hardware-backed (Secure Enclave / TPM 2.0 / OS keyring) P-256 signing keys, +//! via the `hardware-enclave` crate. + +use std::fmt::Display; + +use hardware_enclave::{AccessPolicy, EnclaveConfig, Error, create_signer}; + +/// A P-256 signing key backed by the platform's hardware security module. +/// Private key material never leaves the enclave; only signing operations +/// and the public key are ever produced. +pub struct HardwareSigningKey { + signer: hardware_enclave::SignerHandle, + label: String, +} + +#[derive(Debug)] +pub enum HardwareKeyError { + /// No usable Secure Enclave / TPM / keyring backend exists on this device. + NotAvailable, + Other(eyre::Report), +} + +impl Display for HardwareKeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HardwareKeyError::NotAvailable => write!(f, "hardware key storage not available"), + HardwareKeyError::Other(e) => e.fmt(f), + } + } +} +impl std::error::Error for HardwareKeyError {} + +fn map_err(e: Error) -> HardwareKeyError { + match e { + Error::NotAvailable => HardwareKeyError::NotAvailable, + other => HardwareKeyError::Other(eyre::eyre!(other)), + } +} + +impl HardwareSigningKey { + /// Opens the key for `label` if it already exists, generating it otherwise. + /// + /// Generation uses `AccessPolicy::None` (no biometric/PIN prompt): this key + /// signs silently on every background token refresh, so gating it behind + /// user presence would break unattended renewal. + pub fn open_or_generate(app_name: &str, label: &str) -> Result { + let signer = create_signer(&EnclaveConfig::new(app_name, label)).map_err(map_err)?; + match signer.public_key(label) { + Ok(_) => {} + Err(Error::KeyNotFound { .. }) => { + signer + .generate_key(label, AccessPolicy::None) + .map_err(map_err)?; + } + Err(e) => return Err(map_err(e)), + } + Ok(Self { + signer, + label: label.to_string(), + }) + } + + /// The uncompressed SEC1 public key: `0x04 || X (32 bytes) || Y (32 bytes)`. + pub fn public_key_sec1(&self) -> Result, HardwareKeyError> { + self.signer.public_key(&self.label).map_err(map_err) + } + + /// Sign `data` (SHA-256 applied internally by the enclave). Returns a + /// DER-encoded ECDSA P-256 signature. + pub fn sign_der(&self, data: &[u8]) -> Result, HardwareKeyError> { + self.signer.sign(&self.label, data).map_err(map_err) + } +} diff --git a/ak-platform-keyring/src/lib.rs b/ak-platform-keyring/src/lib.rs index 34769638f..ffc83eee3 100644 --- a/ak-platform-keyring/src/lib.rs +++ b/ak-platform-keyring/src/lib.rs @@ -15,6 +15,7 @@ pub mod windows; pub mod memory; pub mod cache; +pub mod hardware; /// A backend capable of storing, retrieving and deleting credentials. /// diff --git a/ak-platform/Cargo.toml b/ak-platform/Cargo.toml index eca0578fd..ae0be5182 100644 --- a/ak-platform/Cargo.toml +++ b/ak-platform/Cargo.toml @@ -11,6 +11,7 @@ name = "log" name = "grpc_client" [dependencies] +ak-platform-keyring = { path = "../ak-platform-keyring" } base64 = "0.23.0" jsonwebtoken = { version = "11", features = ["rust_crypto"] } p256 = { version = "0.13", features = ["pkcs8", "jwk"] } diff --git a/ak-platform/src/dpop.rs b/ak-platform/src/dpop.rs index e3fc0854a..52a211b24 100644 --- a/ak-platform/src/dpop.rs +++ b/ak-platform/src/dpop.rs @@ -1,12 +1,15 @@ //! RFC 9449 DPoP (Demonstrating Proof-of-Possession) proof generation, as //! required by authentik's OpenID Key Binding feature. +use ak_platform_keyring::hardware::HardwareSigningKey; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use chrono::Utc; use eyre::{Result, eyre}; -use jsonwebtoken::{Algorithm, EncodingKey, Header, encode, jwk::Jwk}; -use p256::SecretKey; +use jsonwebtoken::jwk::Jwk; +use p256::ecdsa::signature::Signer; +use p256::ecdsa::{Signature, SigningKey}; use p256::pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}; +use p256::{PublicKey, SecretKey}; use serde::Serialize; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -42,36 +45,97 @@ impl DpopKeyPair { /// The public key as a JOSE JWK, suitable for the DPoP proof header. pub fn public_jwk(&self) -> Result { - let jwk_ec = self.0.public_key().to_jwk(); - let value = serde_json::to_value(&jwk_ec)?; - Ok(serde_json::from_value(value)?) + jwk_from_public_key(&self.0.public_key()) } /// RFC 7638 JWK thumbprint (`dpop_jkt`): base64url(SHA-256(canonical JWK)). pub fn thumbprint(&self) -> Result { - let jwk_ec = self.0.public_key().to_jwk(); - let value = serde_json::to_value(&jwk_ec)?; - let x = value - .get("x") - .and_then(|v| v.as_str()) - .ok_or_else(|| eyre!("EC JWK missing x coordinate"))?; - let y = value - .get("y") - .and_then(|v| v.as_str()) - .ok_or_else(|| eyre!("EC JWK missing y coordinate"))?; - // RFC 7638 requires the exact required member set for an EC key, - // in lexicographic order, with no insignificant whitespace. Built by - // hand rather than via a serde_json::Map, since key ordering there is - // an implementation detail this computation must not depend on. - let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#); - Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))) - } - - /// A signing key usable with `jsonwebtoken::encode`. - fn encoding_key(&self) -> Result { - let pem = self.to_pkcs8_pem()?; - Ok(EncodingKey::from_ec_pem(pem.as_bytes())?) + thumbprint_from_public_key(&self.0.public_key()) } + + /// Sign `data`, returning the raw (R || S) 64-byte ECDSA P-256 signature + /// JOSE/JWS expects (SHA-256 is applied internally by the signing key). + fn sign_raw(&self, data: &[u8]) -> Result> { + let signing_key = SigningKey::from(&self.0); + let sig: Signature = signing_key.sign(data); + Ok(sig.to_bytes().to_vec()) + } +} + +/// Either a locally-held software keypair or a hardware-enclave-backed key +/// (Secure Enclave / TPM 2.0 / OS keyring) used to sign DPoP proofs. Both +/// variants are driven through the same manually-assembled JWS in +/// [`build_proof`], since a hardware-backed key's private material can never +/// be exported into a `jsonwebtoken::EncodingKey`. +pub enum DpopSigner { + Software(DpopKeyPair), + Hardware(HardwareSigningKey), +} + +impl DpopSigner { + pub fn public_jwk(&self) -> Result { + match self { + Self::Software(kp) => kp.public_jwk(), + Self::Hardware(hk) => { + jwk_from_public_key(&public_key_from_sec1(&hk.public_key_sec1()?)?) + } + } + } + + pub fn thumbprint(&self) -> Result { + match self { + Self::Software(kp) => kp.thumbprint(), + Self::Hardware(hk) => { + thumbprint_from_public_key(&public_key_from_sec1(&hk.public_key_sec1()?)?) + } + } + } + + fn sign_raw(&self, data: &[u8]) -> Result> { + match self { + Self::Software(kp) => kp.sign_raw(data), + Self::Hardware(hk) => { + let der = hk.sign_der(data)?; + Ok(Signature::from_der(&der)?.to_bytes().to_vec()) + } + } + } +} + +fn public_key_from_sec1(sec1: &[u8]) -> Result { + Ok(PublicKey::from_sec1_bytes(sec1)?) +} + +fn jwk_from_public_key(pubkey: &PublicKey) -> Result { + let jwk_ec = pubkey.to_jwk(); + let value = serde_json::to_value(&jwk_ec)?; + Ok(serde_json::from_value(value)?) +} + +fn thumbprint_from_public_key(pubkey: &PublicKey) -> Result { + let jwk_ec = pubkey.to_jwk(); + let value = serde_json::to_value(&jwk_ec)?; + let x = value + .get("x") + .and_then(|v| v.as_str()) + .ok_or_else(|| eyre!("EC JWK missing x coordinate"))?; + let y = value + .get("y") + .and_then(|v| v.as_str()) + .ok_or_else(|| eyre!("EC JWK missing y coordinate"))?; + // RFC 7638 requires the exact required member set for an EC key, + // in lexicographic order, with no insignificant whitespace. Built by + // hand rather than via a serde_json::Map, since key ordering there is + // an implementation detail this computation must not depend on. + let canonical = format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#); + Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()))) +} + +#[derive(Serialize)] +struct DpopHeader { + typ: &'static str, + alg: &'static str, + jwk: Jwk, } #[derive(Serialize)] @@ -92,16 +156,22 @@ struct DpopClaims<'a> { /// claim. `iat` and `jti` are generated fresh on every call; callers cannot /// (and must not be able to) reuse them, since that would defeat the replay /// protection the claims exist for. +/// +/// The JWS is assembled by hand (rather than via `jsonwebtoken::encode`) so +/// that a hardware-backed [`DpopSigner::Hardware`] key — whose private +/// material never leaves the enclave — can sign the same way a software key +/// does: only the raw signing-input bytes cross the `DpopSigner` boundary. pub fn build_proof( - key: &DpopKeyPair, + signer: &DpopSigner, htm: &str, htu: &str, code_for_c_s256: Option<&str>, ) -> Result { - let mut header = Header::new(Algorithm::ES256); - header.typ = Some(DPOP_JWT_TYP.to_string()); - header.jwk = Some(key.public_jwk()?); - + let header = DpopHeader { + typ: DPOP_JWT_TYP, + alg: "ES256", + jwk: signer.public_jwk()?, + }; let claims = DpopClaims { htm, htu, @@ -110,13 +180,46 @@ pub fn build_proof( c_s256: code_for_c_s256.map(|code| URL_SAFE_NO_PAD.encode(Sha256::digest(code.as_bytes()))), }; - Ok(encode(&header, &claims, &key.encoding_key()?)?) + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?); + let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let signing_input = format!("{header_b64}.{claims_b64}"); + let signature_b64 = URL_SAFE_NO_PAD.encode(signer.sign_raw(signing_input.as_bytes())?); + + Ok(format!("{signing_input}.{signature_b64}")) +} + +/// Produces a DPoP proof for a single request, without exposing whatever key +/// material (or lack thereof) backs it. +/// +/// The device flow in [`crate::oauth::device_flow`] runs from `ak-cli`, which +/// never holds a `DpopSigner` itself — DPoP keys live in `ak-agent` (and may be +/// hardware-backed, i.e. non-exportable). Implementations there proxy this +/// call over gRPC to whichever process actually holds the key; [`LocalDpopProver`] +/// is the trivial implementation for callers (like `ak-agent`'s own token +/// refresh) that already hold a `DpopSigner` locally. +#[tonic::async_trait] +pub trait DpopProver: Send + Sync { + async fn prove(&self, htm: &str, htu: &str, code_for_c_s256: Option<&str>) -> Result; +} + +/// Trivial [`DpopProver`] wrapping a locally-held [`DpopSigner`]. +pub struct LocalDpopProver<'a>(pub &'a DpopSigner); + +#[tonic::async_trait] +impl DpopProver for LocalDpopProver<'_> { + async fn prove(&self, htm: &str, htu: &str, code_for_c_s256: Option<&str>) -> Result { + build_proof(self.0, htm, htu, code_for_c_s256) + } } #[cfg(test)] mod tests { use super::*; + fn software(key: DpopKeyPair) -> DpopSigner { + DpopSigner::Software(key) + } + #[test] fn thumbprint_is_well_formed() -> Result<()> { let key = DpopKeyPair::generate(); @@ -158,8 +261,9 @@ mod tests { #[test] fn build_proof_has_expected_shape() -> Result<()> { let key = DpopKeyPair::generate(); + let signer = software(key); let jwt = build_proof( - &key, + &signer, "POST", "https://example.com/application/o/token/", None, @@ -185,8 +289,9 @@ mod tests { #[test] fn build_proof_includes_c_s256_when_code_given() -> Result<()> { let key = DpopKeyPair::generate(); + let signer = software(key); let jwt = build_proof( - &key, + &signer, "POST", "https://example.com/application/o/token/", Some("abc123"), @@ -200,15 +305,15 @@ mod tests { #[test] fn build_proof_generates_fresh_jti_each_time() -> Result<()> { - let key = DpopKeyPair::generate(); + let signer = software(DpopKeyPair::generate()); let jwt1 = build_proof( - &key, + &signer, "POST", "https://example.com/application/o/token/", None, )?; let jwt2 = build_proof( - &key, + &signer, "POST", "https://example.com/application/o/token/", None, @@ -216,4 +321,28 @@ mod tests { assert_ne!(jwt1, jwt2); Ok(()) } + + #[test] + fn build_proof_verifies_against_jsonwebtoken() -> Result<()> { + // The JWS is now hand-assembled rather than produced by + // `jsonwebtoken::encode` — confirm a standard JOSE decoder still + // accepts it and that the signature verifies against the embedded jwk. + let key = DpopKeyPair::generate(); + let jwk = key.public_jwk()?; + let signer = software(key); + let jwt = build_proof( + &signer, + "POST", + "https://example.com/application/o/token/", + None, + )?; + + let decoding_key = jsonwebtoken::DecodingKey::from_jwk(&jwk)?; + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::ES256); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + let data = jsonwebtoken::decode::(&jwt, &decoding_key, &validation)?; + assert_eq!(data.claims["htm"], "POST"); + Ok(()) + } } diff --git a/ak-platform/src/generated/agent_auth/agent_auth.rs b/ak-platform/src/generated/agent_auth/agent_auth.rs index a1ea517a6..25cc50451 100644 --- a/ak-platform/src/generated/agent_auth/agent_auth.rs +++ b/ak-platform/src/generated/agent_auth/agent_auth.rs @@ -99,6 +99,29 @@ pub struct AuthorizeResponse { #[prost(message, optional, tag="1")] pub header: ::core::option::Option, } +/// Signs an RFC 9449 DPoP proof for a request using the calling profile's DPoP +/// key. The key itself (which may be hardware-backed and non-exportable) never +/// leaves ak-agent; only the resulting signed proof is returned. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SignDpopProofRequest { + #[prost(message, optional, tag="1")] + pub header: ::core::option::Option, + #[prost(string, tag="2")] + pub htm: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub htu: ::prost::alloc::string::String, + /// Set only for authorization_code/device_code exchanges (c_s256 over the + /// raw code); omitted for refresh_token proofs. + #[prost(string, optional, tag="4")] + pub code_for_c_s256: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SignDpopProofResponse { + #[prost(message, optional, tag="1")] + pub header: ::core::option::Option, + #[prost(string, tag="2")] + pub proof: ::prost::alloc::string::String, +} include!("agent_auth.tonic.rs"); include!("agent_auth.serde.rs"); // @@protoc_insertion_point(module) diff --git a/ak-platform/src/generated/agent_auth/agent_auth.serde.rs b/ak-platform/src/generated/agent_auth/agent_auth.serde.rs index df08b1d7e..398d67b97 100644 --- a/ak-platform/src/generated/agent_auth/agent_auth.serde.rs +++ b/ak-platform/src/generated/agent_auth/agent_auth.serde.rs @@ -650,6 +650,257 @@ impl<'de> serde::Deserialize<'de> for DeviceTokenExchangeRequest { deserializer.deserialize_struct("agent_auth.DeviceTokenExchangeRequest", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for SignDpopProofRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.header.is_some() { + len += 1; + } + if !self.htm.is_empty() { + len += 1; + } + if !self.htu.is_empty() { + len += 1; + } + if self.code_for_c_s256.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("agent_auth.SignDpopProofRequest", len)?; + if let Some(v) = self.header.as_ref() { + struct_ser.serialize_field("header", v)?; + } + if !self.htm.is_empty() { + struct_ser.serialize_field("htm", &self.htm)?; + } + if !self.htu.is_empty() { + struct_ser.serialize_field("htu", &self.htu)?; + } + if let Some(v) = self.code_for_c_s256.as_ref() { + struct_ser.serialize_field("codeForCS256", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for SignDpopProofRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "header", + "htm", + "htu", + "code_for_c_s256", + "codeForCS256", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Header, + Htm, + Htu, + CodeForCS256, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "header" => Ok(GeneratedField::Header), + "htm" => Ok(GeneratedField::Htm), + "htu" => Ok(GeneratedField::Htu), + "codeForCS256" | "code_for_c_s256" => Ok(GeneratedField::CodeForCS256), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = SignDpopProofRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct agent_auth.SignDpopProofRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut header__ = None; + let mut htm__ = None; + let mut htu__ = None; + let mut code_for_c_s256__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Header => { + if header__.is_some() { + return Err(serde::de::Error::duplicate_field("header")); + } + header__ = map_.next_value()?; + } + GeneratedField::Htm => { + if htm__.is_some() { + return Err(serde::de::Error::duplicate_field("htm")); + } + htm__ = Some(map_.next_value()?); + } + GeneratedField::Htu => { + if htu__.is_some() { + return Err(serde::de::Error::duplicate_field("htu")); + } + htu__ = Some(map_.next_value()?); + } + GeneratedField::CodeForCS256 => { + if code_for_c_s256__.is_some() { + return Err(serde::de::Error::duplicate_field("codeForCS256")); + } + code_for_c_s256__ = map_.next_value()?; + } + } + } + Ok(SignDpopProofRequest { + header: header__, + htm: htm__.unwrap_or_default(), + htu: htu__.unwrap_or_default(), + code_for_c_s256: code_for_c_s256__, + }) + } + } + deserializer.deserialize_struct("agent_auth.SignDpopProofRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for SignDpopProofResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.header.is_some() { + len += 1; + } + if !self.proof.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("agent_auth.SignDpopProofResponse", len)?; + if let Some(v) = self.header.as_ref() { + struct_ser.serialize_field("header", v)?; + } + if !self.proof.is_empty() { + struct_ser.serialize_field("proof", &self.proof)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for SignDpopProofResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "header", + "proof", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Header, + Proof, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "header" => Ok(GeneratedField::Header), + "proof" => Ok(GeneratedField::Proof), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = SignDpopProofResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct agent_auth.SignDpopProofResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut header__ = None; + let mut proof__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Header => { + if header__.is_some() { + return Err(serde::de::Error::duplicate_field("header")); + } + header__ = map_.next_value()?; + } + GeneratedField::Proof => { + if proof__.is_some() { + return Err(serde::de::Error::duplicate_field("proof")); + } + proof__ = Some(map_.next_value()?); + } + } + } + Ok(SignDpopProofResponse { + header: header__, + proof: proof__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("agent_auth.SignDpopProofResponse", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for TokenExchangeRequest { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/ak-platform/src/generated/agent_auth/agent_auth.tonic.rs b/ak-platform/src/generated/agent_auth/agent_auth.tonic.rs index d9d68a9f2..68fcef501 100644 --- a/ak-platform/src/generated/agent_auth/agent_auth.tonic.rs +++ b/ak-platform/src/generated/agent_auth/agent_auth.tonic.rs @@ -207,6 +207,30 @@ pub mod agent_auth_client { .insert(GrpcMethod::new("agent_auth.AgentAuth", "Authorize")); self.inner.unary(req, path, codec).await } + pub async fn sign_dpop_proof( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/agent_auth.AgentAuth/SignDpopProof", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("agent_auth.AgentAuth", "SignDpopProof")); + self.inner.unary(req, path, codec).await + } } } /// Generated server implementations. @@ -254,6 +278,13 @@ pub mod agent_auth_server { tonic::Response, tonic::Status, >; + async fn sign_dpop_proof( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; } #[derive(Debug)] pub struct AgentAuthServer { @@ -556,6 +587,51 @@ pub mod agent_auth_server { }; Box::pin(fut) } + "/agent_auth.AgentAuth/SignDpopProof" => { + #[allow(non_camel_case_types)] + struct SignDpopProofSvc(pub Arc); + impl< + T: AgentAuth, + > tonic::server::UnaryService + for SignDpopProofSvc { + type Response = super::SignDpopProofResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::sign_dpop_proof(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SignDpopProofSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } _ => { Box::pin(async move { let mut response = http::Response::new( diff --git a/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs b/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs index 142d32833..a8b3e30f3 100644 --- a/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs +++ b/ak-platform/src/generated/agent_ctrl/agent_ctrl.rs @@ -14,15 +14,38 @@ pub struct SetupRequest { pub access_token: ::prost::alloc::string::String, #[prost(string, tag="6")] pub refresh_token: ::prost::alloc::string::String, - /// PKCS#8 PEM DPoP private key; empty if this profile is not key-bound. - #[prost(string, tag="7")] - pub dpop_private_key: ::prost::alloc::string::String, } #[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] pub struct SetupResponse { #[prost(message, optional, tag="1")] pub header: ::core::option::Option, } +/// Establishes (generating if needed) the DPoP key for a profile before the +/// device flow starts, since the resulting thumbprint must ride the initial +/// device-authorization request. header.profile names the profile being +/// enrolled; it need not exist yet — this call creates it. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PrepareDpopKeyRequest { + #[prost(message, optional, tag="1")] + pub header: ::core::option::Option, + #[prost(string, tag="2")] + pub authentik_url: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub app_slug: ::prost::alloc::string::String, + #[prost(string, tag="4")] + pub client_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PrepareDpopKeyResponse { + #[prost(message, optional, tag="1")] + pub header: ::core::option::Option, + #[prost(string, tag="2")] + pub dpop_jkt: ::prost::alloc::string::String, + /// True if backed by the platform Secure Enclave/TPM; false if this device + /// had no usable hardware backend and a software key was used instead. + #[prost(bool, tag="3")] + pub hardware_backed: bool, +} #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct Profile { #[prost(string, tag="1")] diff --git a/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs b/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs index 7f6d8e7f5..3e94c1ab8 100644 --- a/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs +++ b/ak-platform/src/generated/agent_ctrl/agent_ctrl.serde.rs @@ -215,6 +215,278 @@ impl<'de> serde::Deserialize<'de> for ListProfilesResponse { deserializer.deserialize_struct("agent_ctrl.ListProfilesResponse", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for PrepareDpopKeyRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.header.is_some() { + len += 1; + } + if !self.authentik_url.is_empty() { + len += 1; + } + if !self.app_slug.is_empty() { + len += 1; + } + if !self.client_id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("agent_ctrl.PrepareDpopKeyRequest", len)?; + if let Some(v) = self.header.as_ref() { + struct_ser.serialize_field("header", v)?; + } + if !self.authentik_url.is_empty() { + struct_ser.serialize_field("authentikUrl", &self.authentik_url)?; + } + if !self.app_slug.is_empty() { + struct_ser.serialize_field("appSlug", &self.app_slug)?; + } + if !self.client_id.is_empty() { + struct_ser.serialize_field("clientId", &self.client_id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for PrepareDpopKeyRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "header", + "authentik_url", + "authentikUrl", + "app_slug", + "appSlug", + "client_id", + "clientId", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Header, + AuthentikUrl, + AppSlug, + ClientId, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "header" => Ok(GeneratedField::Header), + "authentikUrl" | "authentik_url" => Ok(GeneratedField::AuthentikUrl), + "appSlug" | "app_slug" => Ok(GeneratedField::AppSlug), + "clientId" | "client_id" => Ok(GeneratedField::ClientId), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = PrepareDpopKeyRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct agent_ctrl.PrepareDpopKeyRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut header__ = None; + let mut authentik_url__ = None; + let mut app_slug__ = None; + let mut client_id__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Header => { + if header__.is_some() { + return Err(serde::de::Error::duplicate_field("header")); + } + header__ = map_.next_value()?; + } + GeneratedField::AuthentikUrl => { + if authentik_url__.is_some() { + return Err(serde::de::Error::duplicate_field("authentikUrl")); + } + authentik_url__ = Some(map_.next_value()?); + } + GeneratedField::AppSlug => { + if app_slug__.is_some() { + return Err(serde::de::Error::duplicate_field("appSlug")); + } + app_slug__ = Some(map_.next_value()?); + } + GeneratedField::ClientId => { + if client_id__.is_some() { + return Err(serde::de::Error::duplicate_field("clientId")); + } + client_id__ = Some(map_.next_value()?); + } + } + } + Ok(PrepareDpopKeyRequest { + header: header__, + authentik_url: authentik_url__.unwrap_or_default(), + app_slug: app_slug__.unwrap_or_default(), + client_id: client_id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("agent_ctrl.PrepareDpopKeyRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for PrepareDpopKeyResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.header.is_some() { + len += 1; + } + if !self.dpop_jkt.is_empty() { + len += 1; + } + if self.hardware_backed { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("agent_ctrl.PrepareDpopKeyResponse", len)?; + if let Some(v) = self.header.as_ref() { + struct_ser.serialize_field("header", v)?; + } + if !self.dpop_jkt.is_empty() { + struct_ser.serialize_field("dpopJkt", &self.dpop_jkt)?; + } + if self.hardware_backed { + struct_ser.serialize_field("hardwareBacked", &self.hardware_backed)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for PrepareDpopKeyResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "header", + "dpop_jkt", + "dpopJkt", + "hardware_backed", + "hardwareBacked", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Header, + DpopJkt, + HardwareBacked, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "header" => Ok(GeneratedField::Header), + "dpopJkt" | "dpop_jkt" => Ok(GeneratedField::DpopJkt), + "hardwareBacked" | "hardware_backed" => Ok(GeneratedField::HardwareBacked), + _ => Err(serde::de::Error::unknown_field(value, FIELDS)), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = PrepareDpopKeyResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct agent_ctrl.PrepareDpopKeyResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut header__ = None; + let mut dpop_jkt__ = None; + let mut hardware_backed__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Header => { + if header__.is_some() { + return Err(serde::de::Error::duplicate_field("header")); + } + header__ = map_.next_value()?; + } + GeneratedField::DpopJkt => { + if dpop_jkt__.is_some() { + return Err(serde::de::Error::duplicate_field("dpopJkt")); + } + dpop_jkt__ = Some(map_.next_value()?); + } + GeneratedField::HardwareBacked => { + if hardware_backed__.is_some() { + return Err(serde::de::Error::duplicate_field("hardwareBacked")); + } + hardware_backed__ = Some(map_.next_value()?); + } + } + } + Ok(PrepareDpopKeyResponse { + header: header__, + dpop_jkt: dpop_jkt__.unwrap_or_default(), + hardware_backed: hardware_backed__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("agent_ctrl.PrepareDpopKeyResponse", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for Profile { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -421,9 +693,6 @@ impl serde::Serialize for SetupRequest { if !self.refresh_token.is_empty() { len += 1; } - if !self.dpop_private_key.is_empty() { - len += 1; - } let mut struct_ser = serializer.serialize_struct("agent_ctrl.SetupRequest", len)?; if let Some(v) = self.header.as_ref() { struct_ser.serialize_field("header", v)?; @@ -443,9 +712,6 @@ impl serde::Serialize for SetupRequest { if !self.refresh_token.is_empty() { struct_ser.serialize_field("refreshToken", &self.refresh_token)?; } - if !self.dpop_private_key.is_empty() { - struct_ser.serialize_field("dpopPrivateKey", &self.dpop_private_key)?; - } struct_ser.end() } } @@ -467,8 +733,6 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { "accessToken", "refresh_token", "refreshToken", - "dpop_private_key", - "dpopPrivateKey", ]; #[allow(clippy::enum_variant_names)] @@ -479,7 +743,6 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { ClientId, AccessToken, RefreshToken, - DpopPrivateKey, } impl<'de> serde::Deserialize<'de> for GeneratedField { fn deserialize(deserializer: D) -> std::result::Result @@ -507,7 +770,6 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { "clientId" | "client_id" => Ok(GeneratedField::ClientId), "accessToken" | "access_token" => Ok(GeneratedField::AccessToken), "refreshToken" | "refresh_token" => Ok(GeneratedField::RefreshToken), - "dpopPrivateKey" | "dpop_private_key" => Ok(GeneratedField::DpopPrivateKey), _ => Err(serde::de::Error::unknown_field(value, FIELDS)), } } @@ -533,7 +795,6 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { let mut client_id__ = None; let mut access_token__ = None; let mut refresh_token__ = None; - let mut dpop_private_key__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::Header => { @@ -572,12 +833,6 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { } refresh_token__ = Some(map_.next_value()?); } - GeneratedField::DpopPrivateKey => { - if dpop_private_key__.is_some() { - return Err(serde::de::Error::duplicate_field("dpopPrivateKey")); - } - dpop_private_key__ = Some(map_.next_value()?); - } } } Ok(SetupRequest { @@ -587,7 +842,6 @@ impl<'de> serde::Deserialize<'de> for SetupRequest { client_id: client_id__.unwrap_or_default(), access_token: access_token__.unwrap_or_default(), refresh_token: refresh_token__.unwrap_or_default(), - dpop_private_key: dpop_private_key__.unwrap_or_default(), }) } } diff --git a/ak-platform/src/generated/agent_ctrl/agent_ctrl.tonic.rs b/ak-platform/src/generated/agent_ctrl/agent_ctrl.tonic.rs index 10051c6f0..525c043fe 100644 --- a/ak-platform/src/generated/agent_ctrl/agent_ctrl.tonic.rs +++ b/ak-platform/src/generated/agent_ctrl/agent_ctrl.tonic.rs @@ -135,6 +135,30 @@ pub mod agent_ctrl_client { .insert(GrpcMethod::new("agent_ctrl.AgentCtrl", "Setup")); self.inner.unary(req, path, codec).await } + pub async fn prepare_dpop_key( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/agent_ctrl.AgentCtrl/PrepareDpopKey", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("agent_ctrl.AgentCtrl", "PrepareDpopKey")); + self.inner.unary(req, path, codec).await + } pub async fn switch_profile( &mut self, request: impl tonic::IntoRequest, @@ -209,6 +233,13 @@ pub mod agent_ctrl_server { &self, request: tonic::Request, ) -> std::result::Result, tonic::Status>; + async fn prepare_dpop_key( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; async fn switch_profile( &self, request: tonic::Request, @@ -383,6 +414,51 @@ pub mod agent_ctrl_server { }; Box::pin(fut) } + "/agent_ctrl.AgentCtrl/PrepareDpopKey" => { + #[allow(non_camel_case_types)] + struct PrepareDpopKeySvc(pub Arc); + impl< + T: AgentCtrl, + > tonic::server::UnaryService + for PrepareDpopKeySvc { + type Response = super::PrepareDpopKeyResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::prepare_dpop_key(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = PrepareDpopKeySvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } "/agent_ctrl.AgentCtrl/SwitchProfile" => { #[allow(non_camel_case_types)] struct SwitchProfileSvc(pub Arc); diff --git a/ak-platform/src/oauth/device_flow.rs b/ak-platform/src/oauth/device_flow.rs index fe6b08405..e1192ab79 100644 --- a/ak-platform/src/oauth/device_flow.rs +++ b/ak-platform/src/oauth/device_flow.rs @@ -12,7 +12,7 @@ use eyre::{Result, bail, eyre}; use serde::Deserialize; use url::Url; -use crate::dpop::{DpopKeyPair, build_proof}; +use crate::dpop::DpopProver; const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(5); const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(5); @@ -114,13 +114,13 @@ struct TokenErrorResponse { /// Poll the token endpoint until the user completes authorization (RFC 8628 /// section 3.4/3.5), or bail out on denial/expiry. /// -/// `dpop_keypair`, when set, attaches a fresh `DPoP` proof header (with +/// `dpop_prover`, when set, attaches a fresh `DPoP` proof header (with /// `c_s256` bound to `auth.device_code`) to every poll attempt. pub async fn poll_for_device_token( token_url: &Url, client_id: &str, auth: &DeviceAuthorization, - dpop_keypair: Option<&DpopKeyPair>, + dpop_prover: Option<&dyn DpopProver>, user_agent: &str, ) -> Result { let mut interval = auth.interval; @@ -146,8 +146,10 @@ pub async fn poll_for_device_token( ) .header(reqwest::header::USER_AGENT, user_agent); - if let Some(kp) = dpop_keypair { - let proof = build_proof(kp, "POST", token_url.as_str(), Some(&auth.device_code))?; + if let Some(prover) = dpop_prover { + let proof = prover + .prove("POST", token_url.as_str(), Some(&auth.device_code)) + .await?; req = req.header("DPoP", proof); } @@ -184,6 +186,7 @@ pub async fn poll_for_device_token( #[cfg(test)] mod tests { use super::*; + use crate::dpop::{DpopKeyPair, DpopSigner, LocalDpopProver}; use serde_json::json; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -295,9 +298,10 @@ mod tests { let url = Url::parse(&format!("{}/token/", server.uri()))?; let auth = test_auth(Duration::from_millis(10))?; - let keypair = DpopKeyPair::generate(); + let signer = DpopSigner::Software(DpopKeyPair::generate()); + let prover = LocalDpopProver(&signer); let result = - poll_for_device_token(&url, "client-id", &auth, Some(&keypair), "test-agent").await?; + poll_for_device_token(&url, "client-id", &auth, Some(&prover), "test-agent").await?; assert_eq!(result.access_token, "at123"); diff --git a/containers/builder/Dockerfile b/containers/builder/Dockerfile index 9b38e213f..9ab97f01c 100644 --- a/containers/builder/Dockerfile +++ b/containers/builder/Dockerfile @@ -13,7 +13,9 @@ RUN apt-get update && \ libssl-dev \ curl git \ ca-certificates \ - libpam0g-dev libudev-dev zstd && \ + libpam0g-dev libudev-dev zstd \ + libdbus-1-dev \ + libtss2-dev && \ rm -rf /var/lib/apt/lists/* RUN ARCH=$(uname -m) && \ diff --git a/protobuf/agent_auth.proto b/protobuf/agent_auth.proto index 083b40a65..f6c659950 100644 --- a/protobuf/agent_auth.proto +++ b/protobuf/agent_auth.proto @@ -11,6 +11,7 @@ service AgentAuth { rpc CachedTokenExchange(TokenExchangeRequest) returns (TokenExchangeResponse); rpc DeviceTokenExchange(DeviceTokenExchangeRequest) returns (TokenExchangeResponse); rpc Authorize(AuthorizeRequest) returns (AuthorizeResponse); + rpc SignDpopProof(SignDpopProofRequest) returns (SignDpopProofResponse); } message WhoAmIRequest { @@ -64,3 +65,20 @@ message AuthorizeRequest { message AuthorizeResponse { agent.ResponseHeader header = 1; } + +// Signs an RFC 9449 DPoP proof for a request using the calling profile's DPoP +// key. The key itself (which may be hardware-backed and non-exportable) never +// leaves ak-agent; only the resulting signed proof is returned. +message SignDpopProofRequest { + agent.RequestHeader header = 1; + string htm = 2; + string htu = 3; + // Set only for authorization_code/device_code exchanges (c_s256 over the + // raw code); omitted for refresh_token proofs. + optional string code_for_c_s256 = 4; +} + +message SignDpopProofResponse { + agent.ResponseHeader header = 1; + string proof = 2; +} diff --git a/protobuf/agent_ctrl.proto b/protobuf/agent_ctrl.proto index 08d7d6f79..60aef5602 100644 --- a/protobuf/agent_ctrl.proto +++ b/protobuf/agent_ctrl.proto @@ -10,6 +10,7 @@ import "google/protobuf/timestamp.proto"; service AgentCtrl { rpc ListProfiles(google.protobuf.Empty) returns (ListProfilesResponse); rpc Setup(SetupRequest) returns (SetupResponse); + rpc PrepareDpopKey(PrepareDpopKeyRequest) returns (PrepareDpopKeyResponse); rpc SwitchProfile(agent.RequestHeader) returns (agent.ResponseHeader); rpc CurrentProfile(google.protobuf.Empty) returns (CurrentProfileResponse); } @@ -21,14 +22,31 @@ message SetupRequest { string client_id = 4; string access_token = 5; string refresh_token = 6; - // PKCS#8 PEM DPoP private key; empty if this profile is not key-bound. - string dpop_private_key = 7; } message SetupResponse { agent.ResponseHeader header = 1; } +// Establishes (generating if needed) the DPoP key for a profile before the +// device flow starts, since the resulting thumbprint must ride the initial +// device-authorization request. header.profile names the profile being +// enrolled; it need not exist yet — this call creates it. +message PrepareDpopKeyRequest { + agent.RequestHeader header = 1; + string authentik_url = 2; + string app_slug = 3; + string client_id = 4; +} + +message PrepareDpopKeyResponse { + agent.ResponseHeader header = 1; + string dpop_jkt = 2; + // True if backed by the platform Secure Enclave/TPM; false if this device + // had no usable hardware backend and a software key was used instead. + bool hardware_backed = 3; +} + message Profile { string name = 1; string username = 2;