From 011980baef00afc8ca3a4963c8fce109eef2d0bf Mon Sep 17 00:00:00 2001 From: Alexander Ververis Date: Wed, 14 Oct 2026 01:23:39 +0800 Subject: [PATCH 1/2] Use KeePass with passkey admin and Telegram approvals (#75) * feat: use KeePass with passkey admin and Telegram approvals * fix: include license notices in the container build context --- .dockerignore | 1 + .github/workflows/image.yml | 1 - Cargo.lock | 1057 ++++++++++++- Cargo.toml | 8 +- Dockerfile | 7 +- README.md | 98 +- THIRD_PARTY_NOTICES.md | 22 + contracts/README.md | 6 +- contracts/admin.md | 55 + contracts/forward-proxy.md | 12 +- contracts/openapi.yaml | 10 +- contracts/transparent-gateway.md | 14 +- contracts/workload-gateway.md | 36 +- deny.toml | 32 + deploy/charon.example.toml | 24 +- deploy/compose.yaml | 8 +- deploy/redeploy.sh | 3 +- docs/admin.md | 120 ++ docs/adr/0003-persona-realms.md | 49 +- docs/adr/0004-keepass-store.md | 50 + docs/adr/0004-secret-provider-adapters.md | 77 - docs/adr/0005-human-approval-broker.md | 18 +- .../0007-transparent-capability-gateway.md | 2 +- docs/approval-broker-operations.md | 5 +- docs/comparisons/infisical-agent-vault.md | 76 + docs/conventions.md | 9 +- docs/deployment.md | 108 +- docs/hermes-gateway.md | 17 +- docs/index.md | 9 +- docs/integration-boundaries.md | 72 +- docs/persona-realm-contract.md | 37 +- docs/security-verification-keepass-admin.md | 70 + docs/telegram-setup.md | 85 ++ docs/threat-model.md | 140 +- examples/charon.dev.toml | 25 +- examples/charon.toml | 29 +- examples/hermes-gateway.toml | 25 +- examples/transparent-gateway.toml | 25 +- examples/vertical_fixture.rs | 25 +- integrations/hermes/README.md | 2 +- integrations/vertical/README.md | 142 +- integrations/vertical/charon.example.toml | 21 +- integrations/vertical/compose.yaml | 23 +- integrations/vertical/prepare-linux-bw.sh | 37 - integrations/vertical/run-in-workload.sh | 8 +- integrations/vertical/run.sh | 30 +- mise.toml | 12 + src/admin.rs | 1343 +++++++++++++++++ src/admin/admin.css | 1 + src/admin/admin.js | 55 + src/admin/index.html | 1 + src/config.rs | 209 +-- src/gateway.rs | 130 +- src/gateway_tests.rs | 141 +- src/identity.rs | 7 +- src/keepass.rs | 273 ++++ src/lib.rs | 13 +- src/main.rs | 77 +- src/provider.rs | 402 ----- src/proxy.rs | 33 +- src/receipt.rs | 2 +- src/telegram.rs | 660 ++++++++ src/tls.rs | 2 +- tests/admin_browser.mjs | 66 + tests/common/mod.rs | 76 + tests/connect_proxy.rs | 37 +- tests/deploy_redeploy.sh | 12 +- tests/e2e/README.md | 110 ++ tests/e2e/fixture.rs | 499 ++++++ tests/e2e/live_telegram.py | 103 ++ tests/e2e/mod.rs | 294 ++++ tests/e2e/password_api.rs | 107 ++ tests/e2e/request.sh | 13 + tests/e2e/telegram_api.rs | 120 ++ tests/http_proxy.rs | 120 +- tests/keepass_store.rs | 214 +++ tests/persona_realms.rs | 66 +- tests/vaultwarden_provider.rs | 168 --- 78 files changed, 6586 insertions(+), 1510 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 contracts/admin.md create mode 100644 docs/admin.md create mode 100644 docs/adr/0004-keepass-store.md delete mode 100644 docs/adr/0004-secret-provider-adapters.md create mode 100644 docs/comparisons/infisical-agent-vault.md create mode 100644 docs/security-verification-keepass-admin.md create mode 100644 docs/telegram-setup.md delete mode 100755 integrations/vertical/prepare-linux-bw.sh create mode 100644 src/admin.rs create mode 100644 src/admin/admin.css create mode 100644 src/admin/admin.js create mode 100644 src/admin/index.html create mode 100644 src/keepass.rs delete mode 100644 src/provider.rs create mode 100644 src/telegram.rs create mode 100644 tests/admin_browser.mjs create mode 100644 tests/common/mod.rs create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/fixture.rs create mode 100644 tests/e2e/live_telegram.py create mode 100644 tests/e2e/mod.rs create mode 100644 tests/e2e/password_api.rs create mode 100644 tests/e2e/request.sh create mode 100644 tests/e2e/telegram_api.rs create mode 100644 tests/keepass_store.rs delete mode 100644 tests/vaultwarden_provider.rs diff --git a/.dockerignore b/.dockerignore index 53aad61..c5ad94e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,6 @@ !Cargo.toml !Cargo.lock !Dockerfile +!THIRD_PARTY_NOTICES.md !src !src/** diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 447b193..f8a5f07 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -43,7 +43,6 @@ jobs: run: | sh -n integrations/vertical/run.sh sh -n integrations/vertical/run-in-workload.sh - sh -n integrations/vertical/prepare-linux-bw.sh - name: Build secretless gh workload fixture uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: diff --git a/Cargo.lock b/Cargo.lock index b86be5c..7e237f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,16 +2,33 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +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", + "crypto-common 0.1.7", "generic-array", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -21,28 +38,80 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive 0.5.1", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "asn1-rs" version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ - "asn1-rs-derive", + "asn1-rs-derive 0.6.0", "asn1-rs-impl", "displaydoc", "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.19", "time", ] +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "asn1-rs-derive" version = "0.6.0" @@ -66,6 +135,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -164,6 +255,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "axum-server" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" +dependencies = [ + "arc-swap", + "bytes", + "either", + "fs-err", + "http", + "http-body", + "hyper", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[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" @@ -182,6 +301,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "base64urlsafedata" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b08e33815c87d8cadcddb1e74ac307368a3751fbe40c961538afa21a1899f21c" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + [[package]] name = "bit-vec" version = "0.9.1" @@ -191,12 +321,28 @@ dependencies = [ "serde", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2b_simd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3560a7b1951efe814fcd721938313adc56753ca39f4b23847d7e9a2402f5dbff" +dependencies = [ + "arrayvec", + "constant_time_eq", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -206,18 +352,57 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-modes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2211b0817f061502a8dd9f11a37e879e79763e3c698d2418cf824d8cb2f21e" + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.3.0" @@ -249,17 +434,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher 0.5.2", "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -272,7 +458,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -282,8 +468,8 @@ name = "charon" version = "0.1.0" dependencies = [ "anyhow", - "async-trait", "axum", + "axum-server", "base64 0.23.1", "chacha20poly1305", "ed25519-dalek", @@ -293,6 +479,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "keepass", "percent-encoding", "rcgen", "reqwest", @@ -302,29 +489,56 @@ dependencies = [ "serde", "serde_jcs", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "tokio-rustls", "toml", + "tower", "tracing", "tracing-subscriber", "url", - "x509-parser", + "uuid", + "webauthn-authenticator-rs", + "webauthn-rs", + "x509-parser 0.18.1", "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.7", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "cmake" version = "0.1.58" @@ -334,6 +548,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -350,6 +570,18 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.10.1" @@ -366,6 +598,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -384,6 +622,27 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -395,6 +654,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -404,7 +681,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -434,17 +711,31 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "zeroize", ] +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs 0.6.2", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "der-parser" version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", "displaydoc", "nom", "num-bigint", @@ -464,8 +755,20 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -504,11 +807,17 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + [[package]] name = "equivalent" version = "1.0.2" @@ -555,6 +864,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -567,6 +887,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -576,19 +911,45 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-err" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5c95b673b8f6f7235229ae11c5642d81b04c2e64c1e2fb417bc0cf73ca45f29" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -597,6 +958,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -632,6 +1004,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -665,6 +1038,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -674,7 +1059,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -698,6 +1083,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -722,6 +1118,27 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hex-literal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -767,6 +1184,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -827,6 +1253,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -938,6 +1388,8 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -949,6 +1401,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -973,7 +1435,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.19", "walkdir", "windows-link", ] @@ -1031,6 +1493,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keepass" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83630a8882d1f2a8c03ee00911b67904c1b878daf1d51efe3fed133d20fc9b87" +dependencies = [ + "aes", + "base64 0.23.1", + "block-modes", + "byteorder", + "cbc", + "chacha20 0.10.2", + "chrono", + "cipher 0.5.2", + "flate2", + "getrandom 0.4.3", + "hex", + "hex-literal", + "hmac", + "hybrid-array", + "indexmap", + "js-sys", + "quick-xml", + "rust-argon2", + "salsa20", + "secrecy", + "serde", + "sha2 0.11.0", + "thiserror 2.0.19", + "twofish", + "uuid", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1111,6 +1607,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1157,6 +1663,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1175,13 +1692,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs 0.6.2", +] + [[package]] name = "oid-registry" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", ] [[package]] @@ -1197,10 +1723,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pastey" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pem" @@ -1266,6 +1835,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1275,6 +1853,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1289,7 +1877,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.19", "tokio", "tracing", "web-time", @@ -1305,14 +1893,14 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.19", "tinyvec", "tracing", "web-time", @@ -1341,23 +1929,49 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1367,6 +1981,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_core" version = "0.10.1" @@ -1392,7 +2015,7 @@ dependencies = [ "ring", "rustls-pki-types", "time", - "x509-parser", + "x509-parser 0.18.1", "yasna", ] @@ -1473,7 +2096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror", + "thiserror 2.0.19", ] [[package]] @@ -1482,7 +2105,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1491,6 +2114,18 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-argon2" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae76b7506744d254fd0eb2c0ff5c5d108201ccbb083111ac04a44eeda105680" +dependencies = [ + "base64 0.22.1", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1521,7 +2156,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -1622,6 +2257,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1655,7 +2300,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -1688,6 +2333,26 @@ dependencies = [ "serde_derive", ] +[[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_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -1772,7 +2437,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -1809,6 +2485,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -1936,13 +2618,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2057,6 +2759,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -2132,7 +2846,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -2237,6 +2951,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "twofish" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0218ef702a91e18ba84dd516edf8df33a9f9fb4431d6ada13e6f9502c3bcb6fc" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "typenum" version = "1.20.1" @@ -2249,13 +2972,22 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "universal-hash" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -2275,6 +3007,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2283,6 +3016,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -2326,6 +3071,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -2414,6 +3168,107 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6475c0bbd1a3f04afaa3e98880408c5be61680c5e6bd3c6f8c250990d5d3e18e" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c548915e0e92ee946bbf2aecf01ea21bef53d974b0793cc6732ba81a03fc422" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296d2d501feb715d80b8e186fb88bab1073bca17f460303a1013d17b673bea6a" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser 9.0.0", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.9.5", + "rand_chacha", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser 0.16.0", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37393beac9c1ed1ca6dbb30b1e01783fb316ab3a45d90ecd48c99052dd7ef1e" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + [[package]] name = "webpki-root-certs" version = "1.0.9" @@ -2432,12 +3287,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -2526,27 +3434,50 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs 0.6.2", + "data-encoding", + "der-parser 9.0.0", + "lazy_static", + "nom", + "oid-registry 0.7.1", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + [[package]] name = "x509-parser" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ - "asn1-rs", + "asn1-rs 0.7.2", "data-encoding", - "der-parser", + "der-parser 10.0.0", "lazy_static", "nom", - "oid-registry", + "oid-registry 0.8.1", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.19", "time", ] @@ -2583,6 +3514,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -2609,6 +3560,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -2643,6 +3608,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index d897995..c0e79dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,6 @@ publish = false [dependencies] anyhow = "1.0" -async-trait = "0.1" axum = "0.8" base64 = "0.23" chacha20poly1305 = "0.10" @@ -37,11 +36,18 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } url = "2.5" zeroize = "1.8" +keepass = { version = "0.15.2", default-features = false } +webauthn-rs = "0.5.5" +axum-server = { version = "0.8", default-features = false, features = ["tls-rustls-no-provider"] } +uuid = { version = "1.28.0", features = ["v4", "serde"] } [dev-dependencies] +keepass = { version = "0.15.2", default-features = false, features = ["save_kdbx4"] } http-body-util = "0.1" tempfile = "3.23" x509-parser = "0.18" +webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } +tower = { version = "0.5", features = ["util"] } [lints.rust] diff --git a/Dockerfile b/Dockerfile index 296d598..4c7ab3a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,16 @@ FROM docker.io/library/rust:1.99.0-bookworm@sha256:59037199c44290f2befcdd58dcc54 WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY src ./src -RUN cargo build --locked --release +RUN cargo build --locked --release \ + && mkdir /runtime-libs \ + && cp /usr/lib/*/libssl.so.3 /usr/lib/*/libcrypto.so.3 /runtime-libs/ FROM gcr.io/distroless/cc-debian12:nonroot@sha256:adcd20c7b4c988b73cbfbddb26d2eee574571e6d7c9ffea29b3821e0690efb77 LABEL org.opencontainers.image.source="https://github.com/ak5/charon" COPY --from=build /src/target/release/charon /usr/local/bin/charon +COPY --from=build /runtime-libs/ /usr/lib/ +COPY --from=build /usr/share/doc/libssl3/copyright /usr/share/doc/libssl3/copyright +COPY THIRD_PARTY_NOTICES.md /usr/share/doc/charon/THIRD_PARTY_NOTICES.md USER nonroot:nonroot ENTRYPOINT ["/usr/local/bin/charon"] CMD ["--config", "/etc/charon/charon.toml"] diff --git a/README.md b/README.md index 31aac95..72d8ad0 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ development containers, and other programs. For credential mediation, the workload sends an opaque capability reference instead of a real credential. Charon checks authorization, matches the request against local policy, obtains the -credential from the configured secret store, and hydrates the reference only in +credential from its unlocked KeePass database, and hydrates the reference only in the request sent upstream. ```text @@ -38,7 +38,7 @@ capability reference resolve credential + sanitize response ``` Charon is an early-stage project. Its explicit proxy, transparent TLS gateway, -authorization, structured hydration, response mediation, provider adapters, +authorization, structured hydration, response mediation, KeePass credential storage, metadata-only receipts, and isolated tests are implemented. ## Why use it? @@ -82,10 +82,10 @@ These names appear in the configuration and protocol: | **Workload** | The program making the outbound request, such as an AI agent, CLI, build, or development container. | | **Manifest** | A short-lived, signed authorization used by explicit-proxy clients. It identifies the workload and names one capability. Each manifest can be used once. | | **Capability** | A named permission in Charon's local policy, for example “read the current GitHub user.” It maps to one service and an exact set of methods and paths. | -| **Capability reference** | Public syntax such as `{{charon.github.read}}`. It names local policy, never a provider item or secret. | -| **Service** | An exact destination, typed hydration rule, provider reference, response mode, and resource limits. | -| **Secret provider** | The adapter Charon uses to obtain a credential. The current implementations are an environment provider for disposable development and a Vaultwarden provider. | -| **Realm** | One isolated Charon deployment: a process, configuration, secret-provider session, and policy. | +| **Capability reference** | Public syntax such as `{{charon.github.read}}`. It names local policy, never a database entry or secret. | +| **Service** | An exact destination, typed hydration rule, KeePass reference, response mode, and resource limits. | +| **KeePass store** | A read-only KDBX database unlocked by its owner through the HTTPS admin page. Only exact mapped Password fields resolve. | +| **Realm** | One isolated Charon deployment: a process, configuration, KeePass database and unlocked memory, and policy. | The current pre-1.0 manifest also carries tenant, persona, workspace, and lease identifiers supplied by the issuer. They are integration context, not secret @@ -104,7 +104,8 @@ review before 1.0. 3. Charon verifies the signature, expiry, realm identity, and single-use nonce. 4. Charon resolves the capability from its own configuration and checks the request's exact host, method, and path. -5. Charon asks its configured provider for the policy-owned secret reference. +5. When configured, Charon requests a Telegram allow-once decision. It then + resolves the policy-owned reference from its unlocked KeePass snapshot. 6. Charon hydrates only the declared sink at the outbound boundary, mediates the response using its explicit streaming mode, and writes a metadata-only receipt. @@ -141,10 +142,11 @@ That command is equivalent to: cargo run -- --config examples/charon.dev.toml ``` -The development configuration listens only on `127.0.0.1:3129`, uses the -environment provider, and contains no real credential. It is suitable for -starting the process and inspecting its health endpoints; exercising an -authenticated proxy request also requires issuing a valid test manifest. +The development example contains synthetic paths and identifiers. Provision +its KeePass file, private admin-state directory, and HTTPS certificate before +starting it. Its workload proxy listens on `127.0.0.1:3129`; the separate admin +listener uses the configured HTTPS origin. The store starts locked. Tests create +disposable synthetic KDBX files without requiring operator setup. Run the individual checks directly if you do not use mise: @@ -159,7 +161,7 @@ cargo deny check [`examples/charon.dev.toml`](examples/charon.dev.toml) is a minimal local configuration. [`examples/charon.toml`](examples/charon.toml) shows the -Vaultwarden, TLS, identity, capability, and service settings used in an +KeePass, HTTPS admin, TLS, identity, capability, and service settings used in an operator-managed explicit proxy. [`examples/transparent-gateway.toml`](examples/transparent-gateway.toml) shows a synthetic policy-bound transparent listener. @@ -174,29 +176,54 @@ Configuration is deny-by-default: are explicit policy; - unknown configuration fields are rejected. -### Secret-store adapters - -Secret stores sit behind the Rust `SecretProvider` interface. An adapter -receives an opaque reference chosen by local policy and returns a -`SecretString`; callers never choose a backend item. - -The binary currently includes: - -- `environment`, intended only for disposable local development; and -- `vaultwarden`, using an isolated Bitwarden CLI session and exact item UUID - mappings. - -More backends can be added without changing the workload protocol. Providers -are compiled into the binary and selected by trusted realm configuration; -Charon does not load credential-handling plugins dynamically or fall back to a -different provider during an outage. The extension rules are documented in -[ADR 0004](docs/adr/0004-secret-provider-adapters.md). +### KeePass and owner enrollment + +Charon uses one concrete, read-only KeePass KDBX store. `[keepass]` names the +private database and optional private key file. Each `[[keepass.items]]` maps a +local `secret_ref` and realm persona to an exact canonical entry UUID. Charon +reads only that entry's protected Password field; workloads cannot select entries. + +The process boots locked. On first startup it prints a random enrollment code +to the operator console, valid for five minutes. Retrieve it over SSH and open +the configured HTTPS admin origin on your own device. Enter the code and create +a passkey. Charon persists public registration data, never the passkey private +key. Sign in with that passkey, enter the KeePass master password, and unlock. +The master password is discarded after decryption; mapped values remain in +secret-holding memory until explicit lock or process restart. Enroll a second +passkey while signed in for recovery. There is no default admin password. + +The admin listener needs a stable DNS origin, browser-trusted certificate, and +its own TLS key. It is separate from workload listeners and the interception CA. +Keep public passkey state in a private writable directory. See +[owner operations](docs/admin.md) for configuration and recovery. + +`[admin]` is required by the binary in either serving mode. +`mise run browser-admin-check` separately verifies the UI with Chrome and Node 22+. +The main quality gate does not run that browser-dependent test. + +### Telegram decisions + +See [BotFather setup](docs/telegram-setup.md) and the +[two-password E2E fixture](tests/e2e/README.md). + +Optional `[admin.telegram]` settings select one positive private user/chat ID +and exact capability or route names requiring approval. Store the bot token in +an operator-only protected KeePass Password field and set +`keepass.telegram_bot_token_entry_id` to its exact UUID, outside workload mappings. +Start a private chat with the bot before using it. After HTTPS owner unlock, +Charon sends an unlocked notification and allow-once/deny buttons for selected +requests. Lock cancels pending approvals and stops the channel; restart requires +another owner unlock. No bot token or master password is read from environment +variables or password files. Each decision expires after at most two minutes; +gateway deadlines may shorten it. Unavailable delivery, timeout, denial, and +cancellation fail closed. Telegram never receives the vault password, enrollment +code, or workload credential values. ## Project boundaries Charon owns the request-time data path: identity or isolated-listener binding, local policy, credential lookup, typed hydration, response mediation, proxying, -and metadata-only receipts. +metadata-only receipts, owner enrollment, and optional local Telegram decisions. It does not own: @@ -204,7 +231,7 @@ It does not own: - issuance of workload manifests; - secret-store provisioning and backup; - deployment or network policy; or -- human-approval workflows. +- external control-plane approval rules and manifest issuance. Those systems integrate through signed data and versioned contracts; Charon does not query an application's database on the request path. See the @@ -213,8 +240,8 @@ does not query an application's database on the request path. See the ## Security -Charon handles credentials, so changes to authorization, proxying, provider -adapters, TLS, or logging deserve careful review. Please read +Charon handles credentials, so changes to authorization, proxying, KeePass +storage, admin authentication, Telegram decisions, TLS, or logging deserve careful review. Please read [`SECURITY.md`](SECURITY.md) before reporting a vulnerability and see the [threat model](docs/threat-model.md) for the detailed guarantees, assumptions, and remaining risks. @@ -225,6 +252,11 @@ The full local quality gate is: mise run check ``` +## Other projects like Charon + +- [Infisical Agent Vault](docs/comparisons/infisical-agent-vault.md): when to use + its credential-management workflows and when to use Charon with KeePass. + ## Documentation - [Documentation index](docs/index.md) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..580f5c0 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,22 @@ +# Third-party notices + +Charon uses the unmodified `webauthn-rs` implementation by the Kanidm project for +passkey verification. Version 0.5.5 of these crates is licensed under MPL-2.0: + +- `webauthn-rs` +- `webauthn-rs-core` +- `webauthn-rs-proto` +- `webauthn-attestation-ca` +- `base64urlsafedata` +- `webauthn-authenticator-rs` (test dependency only) + +Original source, notices, and license text are available at +https://github.com/kanidm/webauthn-rs/tree/d2c10d53ca5ef033d37ee6462e936e9eb72ad98c and in the published source +packages at https://crates.io. Exact resolved packages are recorded in +`Cargo.lock`. Charon does not modify these dependency source files. Their license +notices remain part of the source packages. The container includes this notice +at `/usr/share/doc/charon/THIRD_PARTY_NOTICES.md`. + +The WebAuthn dependency exceptions in `deny.toml` apply only to these exact +package versions. Other dependencies remain subject to the default license +policy and release SBOM. diff --git a/contracts/README.md b/contracts/README.md index c383e4c..67ade50 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -45,7 +45,9 @@ transport authentication, idempotency, ownership-marker checks, filesystem permissions, and network policy remain operator responsibilities documented in [`docs/persona-realm-contract.md`](../docs/persona-realm-contract.md). -Approval contracts are implemented outside Charon. Their trust boundaries, +The external broker approval contracts are implemented outside the data plane. +Charon's local allow-once Telegram gate is documented in +[owner operations](../docs/admin.md) and does not implement reusable broker rules. Their trust boundaries, grant semantics, non-reusable operations, outage behavior, and rejected alternatives are recorded in [`ADR 0005`](../docs/adr/0005-human-approval-broker.md). @@ -63,3 +65,5 @@ CONNECT tunnels. It binds one fixed isolated workload, supports secretless forwarding and typed credential sinks, and requires its own validated schema. Signed proxy authentication, transparent service lanes, and Hermes tool admission remain independent controls. Infra owns isolation, trust and cutover. + +- [Owner administration](admin.md): independent HTTPS enrollment, passkey sign-in, and vault unlock/lock. diff --git a/contracts/admin.md b/contracts/admin.md new file mode 100644 index 0000000..15a6105 --- /dev/null +++ b/contracts/admin.md @@ -0,0 +1,55 @@ +# Owner administration + +The owner API runs only on the separate configured HTTPS admin listener. It is +absent from workload routers. This interface does not expose credential values, +select credential references, mutate policy, or issue workload manifests. + +Every POST requires the exact configured `Origin`. Endpoints with structured +input limit JSON bodies to 16 KiB, accept exactly `application/json`, and reject +unknown fields. Endpoints listed with no input do not parse a request body. Challenges expire after five minutes and are stored server-side. +The listener limits origin-accepted non-GET requests to 30 per 60-second window and serves responses with +no-store caching, restrictive CSP, HSTS, no-referrer and nosniff headers. + +| Endpoint | Input | Authorization | Success | +| --- | --- | --- | --- | +| `GET /` | none | public | owner UI | +| `GET /admin.js`, `GET /admin.css` | none | public | static local assets | +| `GET /api/status` | none | public; session controls unlocked-state visibility | `{enrolled, authenticated, unlocked}` booleans | +| `POST /api/register/start` | `{code}` | first enrollment: unexpired console code; subsequent enrollment: session | `{id, options}` WebAuthn creation challenge | +| `POST /api/register/finish` | `{id, credential}` | unexpired one-shot ceremony, bound session for additional keys | `204` after verified credential and atomic public-state persistence | +| `POST /api/login/start` | none | existing enrollment | `{id, options}` WebAuthn assertion challenge | +| `POST /api/login/finish` | `{id, credential}` | unexpired one-shot ceremony and valid assertion | `204` and session cookie | +| `POST /api/unlock` | `{password}` | session | `204` after complete mapped snapshot decrypts | +| `POST /api/lock` | none | session | `204`, retained values discarded | +| `POST /api/logout` | none | session revoked if present | `204`, cookie cleared | + +`credential` uses the standard JSON representation consumed by `webauthn-rs`. +The browser converts binary WebAuthn fields to unpadded base64url. The passkey's +RP ID is the exact configured DNS hostname, and verification checks its HTTPS +origin. There is one owner, at most eight registered keys, 32 pending ceremonies and +32 active sessions, and no username or +password fallback. Recovery-key enrollment excludes existing credential IDs. + +The opaque session token is in a `__Host-charon-admin` cookie with Path `/`, +Secure, HttpOnly, SameSite Strict and a ten-minute lifetime. Only its hash is +stored server-side. Restart revokes every session. Logout does not lock the vault. + +Capacity is checked at both ceremony start and finish. First enrollment code +state is random, hashed in memory, single-use and expires +five minutes after boot. Expiry is checked at both ceremony start and finish. +There is no HTTP endpoint to retrieve or reset that code. Invalid authorization +and unlock failure return generic `403` responses; unavailable state returns +`503`; rate saturation returns `429`. Passwords and backend errors are never +returned. Credential-store unlock runs on a blocking thread behind a single gate. + +Public registrations and signature counters persist to a private atomic file. +The KeePass master password, parsed database, mapped decrypted snapshot, +challenges and sessions do not persist. See [owner operations](../docs/admin.md) +for configuration, TLS renewal and console recovery. + +When Telegram is configured, `keepass.telegram_bot_token_entry_id` identifies a +dedicated protected Password entry outside workload mappings. Its validation is +part of unlock. Telegram starts after unlock and has no environment-token input. +Lock discards the token snapshot and cancels polling and pending decisions; +restart starts locked without a Telegram notification. Channel outages retry +without taking down the owner HTTPS listener. diff --git a/contracts/forward-proxy.md b/contracts/forward-proxy.md index 7e8d475..005d5d2 100644 --- a/contracts/forward-proxy.md +++ b/contracts/forward-proxy.md @@ -40,17 +40,19 @@ Before resolving a secret, Charon: and replay state; 3. resolves the named capability from local policy; 4. authorizes the concrete service, exact hostname, method, path, and injection - rule; and + rule; 5. removes proxy authentication plus static and dynamically nominated - hop-by-hop headers. + hop-by-hop headers; +6. verifies the capability reference in its declared sink and, when configured + for that capability, obtains a local Telegram allow-once decision. Only then may Charon pass the policy-owned opaque reference to its configured -secret-store adapter. +unlocked KeePass store. ## Credential rendering Local service policy defines a typed hydration sink, formatting rule, response -mode, limits, and opaque provider reference. The workload supplies +mode, limits, and opaque credential reference. The workload supplies `{{charon.}}` in that sink; the reference must name the same capability as the signed manifest. Caller-supplied routing and framing headers cannot be injection targets. The resolved value is never returned to the @@ -66,7 +68,7 @@ Charon intentionally exposes coarse outcomes: | `401` | manifest missing, malformed, invalid, expired, replayed, or bound to another realm | | `403` | authenticated capability does not authorize the concrete operation | | `413` | configured request or response size limit exceeded | -| `502` | authorized destination, egress, TLS, or secret-store dependency failed | +| `502` | forwarding dependency, locked store, or local approval failed | Upstream response statuses otherwise pass through the policy-selected bounded streaming sanitizer described by `transparent-gateway.md`. No response diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index 08b9ecf..3822f91 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -3,7 +3,9 @@ info: title: Charon runtime probe API version: 1.0.0 description: > - Liveness and credential-free readiness endpoints. Charon's forward-proxy + Signed/transparent listener liveness and credential-free readiness endpoints. + The exclusive gateway returns bodyless 204/503 readiness, documented in + workload-gateway.md. Owner administration is documented in admin.md. Charon's forward-proxy data plane and external reconciler are separate protocols and are not represented as REST operations. paths: @@ -17,16 +19,16 @@ paths: /readyz: get: operationId: getReadiness - summary: Report realm identity, provider, and receipt-writer readiness + summary: Report realm identity, KeePass store, and receipt-writer readiness responses: "200": - description: Configured provider and receipt writer are ready without resolving a secret + description: KeePass is unlocked and the configured receipt writer is healthy, without resolving a secret content: application/json: schema: $ref: "#/components/schemas/Readiness" "503": - description: Provider or receipt writer is unavailable + description: KeePass is locked/unavailable or the configured receipt writer is unhealthy content: application/json: schema: diff --git a/contracts/transparent-gateway.md b/contracts/transparent-gateway.md index 3d449bf..0405e1e 100644 --- a/contracts/transparent-gateway.md +++ b/contracts/transparent-gateway.md @@ -13,12 +13,12 @@ The only public reference syntax is `{{charon.}}`. A capability name is 1–128 ASCII letters, digits, dots, hyphens, or underscores. It cannot start or end with a dot or contain consecutive dots. The complete reference is at most 256 bytes. Nested, concatenated, partial, malformed, oversized, and unknown -references are denied before provider resolution. +references are denied before credential resolution. A reference names local policy. It never names a provider, account, secret, -Vaultwarden item, environment variable, or credential. Policy binds the +KeePass entry or credential. Policy binds the capability to one realm persona, service, exact destination, methods, exact -paths, hydration sink, formatting rule, provider reference, response mode, and +paths, hydration sink, formatting rule, credential reference, response mode, and resource limits. ## Workload identity and transport @@ -101,7 +101,7 @@ a fail-closed response. Cookies and authentication/session headers are removed. The optional explicit-proxy journal and mandatory transparent-gateway journal contain only the closed metadata shape in `data-plane-receipt.schema.json`. They contain no headers, queries, request bodies, response bodies, arguments, -results, provider references, credentials, or memory content. The asynchronous +results, credential references, credentials, or memory content. The asynchronous queue is bounded. An unhealthy or full queue denies a new request before credential resolution; a queue failure after partial delivery is logged as `receipt_lost` and cannot retract already delivered bytes. @@ -118,16 +118,16 @@ checkpoint is atomically replaced and its directory is synced. ## Ownership and correlation -Charon owns this generic broker, policy validation, provider calls, hydration, +Charon owns this generic broker, policy validation, KeePass lookups, hydration, response mediation, and data-plane receipts. An application integration owns which capabilities a workload may reference. Infra owns isolated routing, direct-egress denial, listener reachability, UDP/QUIC rejection, image pins, CA -deployment, provider mappings, filesystem ownership, and convergence. The +deployment, KeePass mappings, filesystem ownership, and convergence. The secret store owns private values. For the Hermes deployment specifically, `hermes-config` owns the capability references made available to Hermes; Infra owns routing, immutable image pins, -CA deployment, provider mappings, and host convergence; and Vaultwarden owns +CA deployment, KeePass mappings, and host convergence; and the operator owns the private values. None of that deployment data belongs in this repository. Hermes tool admission, Hermes's dangerous-command human approval, Charon diff --git a/contracts/workload-gateway.md b/contracts/workload-gateway.md index adf293b..632f103 100644 --- a/contracts/workload-gateway.md +++ b/contracts/workload-gateway.md @@ -44,7 +44,7 @@ The runtime validates upstream certificates with platform trust and pins public IPv4 DNS addresses for the process lifetime. Literal IP routes, private, loopback, link-local, metadata, shared-address, benchmark, multicast, reserved, and IPv6 destinations are unavailable. Authorized DNS is checked before any -provider lookup and the HTTP connector uses the same pin. Ambient proxy +credential lookup and the HTTP connector uses the same pin. Ambient proxy variables cannot change Charon's upstream route. No upstream intermediary is configured in this mode. Infra owns DNS, public CA trust, and egress isolation. @@ -66,7 +66,7 @@ ambiguities fail before lookup. Query values are forwarded only when allowed and are never recorded. Redirects are returned without following them; every caller-followed hop must pass a new request authorization. -`mediation.kind = "forward"` performs **no secret-provider lookup or health +`mediation.kind = "forward"` performs **no secret-credential lookup or health call**. Tokens in OAuth bodies, Telegram URL paths, and allowed caller-owned Authorization/cookies remain client-owned. Ordinary API requests need no capability placeholder. Explicit `caller_headers` permits the closed set @@ -87,11 +87,25 @@ Credential grants are closed typed sinks: Authorization, framing, caller-header rules, capability sink presence, and public address checks complete before resolution. A client cannot choose a -provider reference, inject into routing/framing, or ask for arbitrary string +credential reference, inject into routing/framing, or ask for arbitrary string replacement. There is no global body/path replacement. Other typed sinks remain available through the signed/transparent contracts; this mode does not pretend to support them. Credential routes accept only credentials of 8–16384 -bytes. Provider failures return data-free denials. +bytes. Store failures return data-free denials. + +## Owner administration and local approval + +The binary requires `[admin]` for the independent HTTPS owner listener, including +in this mode. `[keepass]` maps exact entry UUIDs; a forwarding-only configuration +still supplies `items = []`. See [owner operations](../docs/admin.md). + +Optional `[admin.telegram]` names exact route names requiring a fresh allow-once +decision. Identity, operation, caller-header and credential-sink checks precede +the prompt; the decision precedes lookup/forwarding. Denial, cancellation, +capacity exhaustion, delivery failure and timeout return fixed denials. Approval +waits count against `max_duration_seconds` and expire after at most two minutes. +The bot uses direct Charon-only HTTPS egress, independent of workload proxy routes. +It never receives request bodies, brokered values or master passwords. ## Streaming and sessions @@ -99,7 +113,7 @@ Uploads and downloads use backpressure, cancellation, byte counts, total time and idle bounds. The example allows 256 MiB uploads and 1 GiB downloads; these are policy choices and do not change other listener modes. Unknown-length uploads can reach the origin partially before an overflow; streaming cannot -retract a remote side effect. Total operation time includes provider/DNS work, +retract a remote side effect. Total operation time includes approval waits, credential lookup and DNS work, upstream headers and upload. Body streams stop on overflow, idle timeout, total timeout, dependency error, or disconnect. Bodies are not buffered to completion. SSE/model responses stream through rolling byte redaction, without semantic @@ -119,7 +133,7 @@ bypass redaction. WebSocket/101 upgrades fail closed. Authentication-Info, and X-Session-Token on caller-owned routes. They are not globally removed. Every retained header is checked for known brokered secret material. Credentials reflected in Location or another retained header deny -the response. A provider credential cannot be returned as a session value. +the response. A brokered credential cannot be returned as a session value. Charon is not a sanitizer for secrets already owned by the caller, including OAuth token responses; forwarding those to their owner is necessary behavior. @@ -135,13 +149,15 @@ failures have fixed metadata log outcomes, without a request receipt. Streaming completion/cancellation uses the existing hash-chained journal. Errors and logs omit headers, tokens, URLs, prompts, bodies and provider -references. Gateway commands enforce `off,charon=info` regardless of RUST_LOG +references. All Charon commands enforce `off,charon=info` regardless of RUST_LOG so dependency transport debug logs cannot expose traffic. Embedders must apply -the same logging restriction. The interception CA/key, provider credentials, +the same logging restriction. The interception CA/key, KeePass credentials, and decrypted traffic remain within the trusted Charon boundary. -`/healthz` and `/readyz` return 204 for process and journal availability; neither -proves mediation, provider readiness, isolation, or client trust. Run the real +`/healthz` returns 204 for process liveness. `/readyz` returns 204 when the receipt +journal is healthy and, if any route mediates credentials, KeePass is unlocked; +otherwise it returns 503. Pure forwarding gateways do not check the store. +Neither probe verifies Telegram delivery, isolation, or client trust. Run the real TLS fixture suite (`mise exec -- cargo test gateway --lib`) and the deployment verification procedure in [`docs/hermes-gateway.md`](../docs/hermes-gateway.md). Never remove an Infra convergence guard based on generic liveness. diff --git a/deny.toml b/deny.toml index 22948a1..e8a171e 100644 --- a/deny.toml +++ b/deny.toml @@ -13,6 +13,38 @@ allow = [ "Zlib", ] +# Reviewed WebAuthn implementation, used unchanged; see THIRD_PARTY_NOTICES.md. +[[licenses.exceptions]] +name = "base64urlsafedata" +version = "=0.5.5" +allow = ["MPL-2.0"] + +[[licenses.exceptions]] +name = "webauthn-attestation-ca" +version = "=0.5.5" +allow = ["MPL-2.0"] + +[[licenses.exceptions]] +name = "webauthn-rs" +version = "=0.5.5" +allow = ["MPL-2.0"] + +[[licenses.exceptions]] +name = "webauthn-rs-core" +version = "=0.5.5" +allow = ["MPL-2.0"] + +[[licenses.exceptions]] +name = "webauthn-rs-proto" +version = "=0.5.5" +allow = ["MPL-2.0"] + +[[licenses.exceptions]] +name = "webauthn-authenticator-rs" +version = "=0.5.5" +allow = ["MPL-2.0"] + + [bans] multiple-versions = "warn" wildcards = "deny" diff --git a/deploy/charon.example.toml b/deploy/charon.example.toml index 0906bf8..fa6491d 100644 --- a/deploy/charon.example.toml +++ b/deploy/charon.example.toml @@ -10,10 +10,26 @@ generation = 1 [upstream_proxy] url = "http://egress.example.internal:8888" -# Milestone-0 only. The durable deployment switches to the Vaultwarden provider -# after its provider identity and item mapping have been provisioned. -[provider] -kind = "environment" +[keepass] +# Optional operator-only protected Password entry; never include in items. +# telegram_bot_token_entry_id = "00000000-0000-4000-8000-000000000003" +database = "/var/lib/charon/vault.kdbx" + +[[keepass.items]] +secret_ref = "CHARON_TEST_CREDENTIAL" +persona = "milestone-0-test" +entry_id = "00000000-0000-4000-8000-000000000001" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" + +# [admin.telegram] +# user_id = 123456789 +# approval_capabilities = ["github-read-user"] [identity] issuer = "example-control-plane" diff --git a/deploy/compose.yaml b/deploy/compose.yaml index 01948f0..cb19606 100644 --- a/deploy/compose.yaml +++ b/deploy/compose.yaml @@ -12,12 +12,14 @@ services: - /tmp:mode=1777,size=16m ports: - "${CHARON_BIND_IP:?set CHARON_BIND_IP to the approved internal address}:3129:3129" - environment: - CHARON_TEST_CREDENTIAL: ${CHARON_TEST_CREDENTIAL:?set a disposable test credential} - RUST_LOG: charon=info + - "${CHARON_ADMIN_BIND_IP:?set the owner admin address}:8444:8444" volumes: - ./charon.toml:/etc/charon/charon.toml:ro - ./receipts:/var/lib/charon/receipts + - ./vault.kdbx:/var/lib/charon/vault.kdbx:ro + - ./admin-state:/var/lib/charon/admin + - ./admin-tls/fullchain.pem:/etc/charon/admin/fullchain.pem:ro + - ./admin-tls/privkey.pem:/run/credentials/charon-admin-key.pem:ro healthcheck: test: ["CMD", "/usr/local/bin/charon", "healthcheck", "http://127.0.0.1:3129/healthz"] interval: 10s diff --git a/deploy/redeploy.sh b/deploy/redeploy.sh index 8922ba9..99107dc 100755 --- a/deploy/redeploy.sh +++ b/deploy/redeploy.sh @@ -141,7 +141,8 @@ case "$bind_ip" in ;; esac negative_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' \ - --max-time 5 --proxy "http://$bind_ip:3129" http://api.github.com/ || true)" + --max-time 5 --proxy "http://$bind_ip:3129" \ + --request-target https://api.github.com/user http://api.github.com/user || true)" running_revision="$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' charon)" if [ "$health_code" != 204 ] || [ "$negative_code" != 401 ] || [ "$running_revision" != "$image_sha" ]; then diff --git a/docs/admin.md b/docs/admin.md new file mode 100644 index 0000000..d0d52b2 --- /dev/null +++ b/docs/admin.md @@ -0,0 +1,120 @@ +# Owner enrollment and vault operations + +Charon boots locked. The owner admin page runs on an independent HTTPS listener; +workload listeners do not expose enrollment, passkey authentication, or unlock. +There is no admin username or default password. + +## Provision + +Configure `[keepass]` with an absolute private KDBX path. Mount it read-only. +`keepass.items` is required; use `items = []` for a purely secretless gateway. +Map exact canonical entry UUIDs using `[[keepass.items]]`; each mapping must use +the realm's persona. The Password field must be protected, nonempty, and at most +16 KiB. An optional `key_file` must also be a private regular file, at most 1 MiB. +Database input is at most 64 MiB. Charon does not modify the database. + +The binary requires `[admin]` in both `--config` and `--gateway-config` modes. +Configure it with a separate listener, canonical stable HTTPS DNS origin +without a trailing slash, certificate chain, independent private TLS key, and +absolute `passkeys_file` path. The parent of the public passkey file must already +exist, be a real directory, and have mode 0700. Private input files must have no +group/other permissions. Container deployments must give Charon's UID access to +the inputs and write access to the admin-state and receipt directories. + +Use a browser-trusted certificate for the exact origin. The operator owns DNS, +certificate issuance and renewal, port/firewall policy, and TLS-file provisioning. +The admin certificate does not replace the workload interception CA. A changed +certificate for the same origin does not change the passkey identity; changing the +origin, including its port, requires new enrollment. Charon loads TLS files on startup, so restart after certificate +rotation and unlock again. Do not expose the enrollment code in shared logs. + +## First boot + +1. Start Charon. Retrieve its enrollment code from the operator console over SSH. + It expires after five minutes and is not sent through Telegram. +2. Open the configured HTTPS origin on your own device. Enter the code and create + a passkey with your browser's authenticator. The private key stays there. +3. Sign in with the passkey. Enter the KeePass master password and unlock. +4. While signed in, add a second passkey on a recovery device or security key. + +Charon persists only public passkey registration data and signature counters. +Challenges and opaque admin sessions remain in process memory. Admin cookies are +Secure, HttpOnly and SameSite Strict; sessions expire after ten minutes. Unlock, +lock and registration require a valid session and the exact configured Origin. +First enrollment requires the console code instead of a session. There is a +global limit of 30 origin-accepted non-GET requests per 60-second window; +repeated attempts can temporarily block the owner. Challenges are bounded to +32 pending ceremonies, sessions to 32 active tokens, and registrations to eight +passkeys. Capacity is rechecked when ceremonies finish. + +## Normal boot and lock + +Existing public registrations survive restart; the decrypted snapshot and admin +sessions do not. Sign in and enter the master password after every restart. +Explicit lock drops the mapped values and cancels any in-flight unlock commit. +Signing out revokes that browser session; it does not lock the vault. +Lock denies future lookups; requests that already obtained a credential may +finish within their configured operation bounds. A failed unlock does not replace an already unlocked snapshot. To load an updated KDBX, +lock and unlock again. Policy mapping changes require a configuration restart. + +A secretless forwarding route can work while the store is locked. Credential +routes fail closed until unlock. Workload liveness is independent of unlocked +readiness. Signed/transparent `/readyz` returns realm identity as JSON with +`200` or `503`, checking the store and optional receipt writer. Exclusive gateway +`/readyz` returns `204` or `503`, checking the journal and checking the store when +any route mediates credentials. Pure forwarding gateways can be ready while locked. +Neither readiness probe verifies Telegram delivery or host/network isolation. + +## Telegram + +Follow the [BotFather setup guide](telegram-setup.md) to create a dedicated bot, +pin your private numeric ID, and run a live approval test. + +Create a dedicated bot and start a private chat with it. Charon talks directly +to `https://api.telegram.org` using outbound HTTPS and long polling; this channel +does not use `upstream_proxy` or ambient proxy variables. Permit Charon-only +HTTPS/DNS egress to that host when Telegram is enabled. Configure its owner as +one immutable positive numeric `user_id`, and configure `approval_capabilities` +with exact capability names (signed/transparent mode) or route names (exclusive +gateway mode). Unknown names are configuration errors. No name is inferred from +request content. Store the bot token in a dedicated protected Password field in +KeePass. Set `keepass.telegram_bot_token_entry_id` to that entry's canonical UUID. +It must not appear in `keepass.items`: workload credential mappings cannot access +it. There is no environment token or master-password file. Deployment env files +contain only nonsecret listener/image/revision settings. + +Charon starts HTTPS while the vault is locked and makes no Bot API calls until +unlock. After passkey sign-in and successful master-password unlock, it loads the +bot token and sends an unlocked notification with the admin URL. A missing, +unprotected or malformed mapped token rejects unlock with the same generic error +as any invalid mapped entry. Missing token-entry configuration rejects startup. +A network or Telegram failure leaves HTTPS available; the bot retries and gated +requests fail closed while no valid decision is available. + +Selected requests receive allow-once and deny buttons before credential resolution +or upstream forwarding. The callback must match the owner, private chat, exact +sent message and random expiring request handle. It can be consumed once. +Decisions do not create durable rules or bypass identity, sink, destination, +method, path, receipt, or resource checks. Approval expires after two minutes or +the gateway's shorter deadline. Lock drops the token snapshot, cancels polling +and pending decisions; unlocking starts a fresh channel session. Decisions from +before a lock cannot authorize requests after another unlock. Restart requires +owner unlock and cannot send a locked-boot notification. + +The bot sees capability names and HTTP methods, not credentials, URLs with query +strings, payloads, enrollment codes, or master passwords. Workload Telegram traffic +must use a different bot identity; do not share the approval bot token with it. + +## Recovery + +Use a second enrolled passkey if one device is lost. There is no password fallback. +If all passkeys are lost, stop Charon, move the public registration file out of its +configured path using trusted console access, and restart to enroll again. Keep +the old file privately if needed for investigation. This resets all admin sessions +and requires unlock again. Console access is recovery authority. Changing the +origin requires the same deliberate reset and new registrations. There is no +unauthenticated HTTP reset endpoint. + +Back up the encrypted KDBX, optional key file, and public registration state under +operator policy. Preserve the database's master-password recovery independently; +a passkey authenticates the admin page and cannot decrypt a lost KeePass key. diff --git a/docs/adr/0003-persona-realms.md b/docs/adr/0003-persona-realms.md index b80af37..c57632e 100644 --- a/docs/adr/0003-persona-realms.md +++ b/docs/adr/0003-persona-realms.md @@ -7,15 +7,15 @@ Status: accepted for implementation Production Charon runs as one isolated process/container per control-plane persona credential realm. Multiple workspaces owned by that persona may share the realm. A realm declares one stable realm ID, tenant ID, and persona ID. Every -capability and Vaultwarden mapping in its configuration must name that persona; +capability and KeePass mapping in its configuration must name that persona; mixed-persona configuration is rejected before the listener opens. Each realm has its own: - immutable Charon container and runtime UID where the platform supports it; - validated configuration and listener; -- least-privilege Vaultwarden account, encrypted `bw` app-data directory, - memory-backed unlock-session file, and provider cache; +- private encrypted KDBX, optional key file, public admin registration state, + and an unlocked in-memory snapshot; - root-signed intermediate certificate and protected private key; and - health, audit, restart, rotation, revocation, backup, and removal lifecycle. @@ -37,7 +37,7 @@ Rotation stages a second root-signed intermediate in the same realm, validates it, atomically selects it for new leaves, drains connections signed by the old intermediate, then removes and destroys the old key. Workspace trust is unchanged because both intermediates chain to the installed root. Credential, -Vaultwarden-session, signing-key, and intermediate rotations are independent. +admin passkey, signing-key, and intermediate rotations are independent. Possession of a trusted intermediate may allow certificates outside the persona's intended scope when client DNS-name-constraint enforcement is not @@ -48,16 +48,18 @@ endpoint assignment, and direct-egress denial remain mandatory. ## Isolation contract Infrastructure supplies the paths described in -`docs/persona-realm-contract.md`. Charon rejects relative paths, paths outside -the declared realm roots, group/other-accessible private material, mixed -persona mappings, mismatched CA certificates/keys, duplicate IDs, wildcard -hosts, and unknown fields. Runtime mounts are deny-by-default. No parent -directory containing another realm is mounted. - -Provider values remain in secret-holding types and never implement `Debug`, -serialization, or response conversion. The memory cache belongs to one realm -and has a bounded TTL. Unlock sessions are memory-backed and are neither backed -up nor restored. Configuration replacement is validate-then-atomic; failure +`docs/persona-realm-contract.md`. The reconciler must confine paths and mounts +to the declared realm roots, verify ownership markers and exclude shared writable +ancestors. Charon validates absolute KeePass/admin paths, private-file type and +mode, the private admin-state parent, realm-consistent mappings, matching CA +certificates/keys, duplicate IDs, exact hosts and unknown fields. Charon does not +implement realm-root confinement or host mount/network policy. Runtime mounts +are deny-by-default; no parent containing another realm may be mounted. + +Credential values remain in secret-holding types and never implement `Debug`, +serialization, or response conversion. The unlocked snapshot belongs to one realm and +is discarded on lock or restart. The master password is neither persisted nor +restored; the owner unlocks through its HTTPS admin listener. Configuration replacement is validate-then-atomic; failure leaves the last validated generation active. ## External reconciler @@ -66,12 +68,13 @@ Charon does not create realms or query the integrating control plane. A separate reconciler owns create, validate, start, health, replace, rotate, revoke, and remove operations through the versioned declarative contract. Operations are idempotent, use desired and observed generations, and return structured -secret-free results. Charon exposes only liveness/readiness and redacted runtime -identity; it exposes no credential or policy mutation API. +secret-free results. Charon exposes workload liveness/readiness and redacted runtime identity. Its +separate owner HTTPS listener supports enrollment, authentication and vault +unlock/lock; it exposes no credential values or policy mutation API. Revocation first prevents new manifest issuance and workspace network access, -then stops the realm, revokes its Vaultwarden session, removes runtime policy, -and destroys intermediate/provider runtime material. Removal cannot target a +then stops the realm, removes runtime policy, and destroys intermediate/runtime +material. Credential rotation is owned by the operator. Removal cannot target a path unless its resolved realm ID and ownership marker match the request. ## Audit and correlation @@ -80,8 +83,8 @@ Successful receipts may contain realm, tenant, persona, workspace, lease, workload, operation, capability, service, exact host, method, path, status, policy generation, and outcome. Denials contain only fields authenticated before the failure. Receipts never contain a manifest/token, nonce, request/response -headers or bodies, credential reference, Vaultwarden server/account/item/session, -provider output, CA key, or upstream proxy password. +headers or bodies, credential reference, KeePass paths/entry IDs, +credential values, CA key, or upstream proxy password. ## Review model @@ -94,7 +97,7 @@ the recorded owner decision. ## Rejected alternatives -- One multi-persona process or Vaultwarden identity expands a process/session +- One multi-persona process or shared decrypted database expands a process compromise to unrelated personas and is not the first production design. - One root/private CA in each workspace or runtime host exposes the global trust anchor and makes realm rotation unsafe. @@ -106,6 +109,6 @@ the recorded owner decision. outage into unsafe ambiguity. A future shared multi-realm process requires a separate ADR and security review -that proves OS-equivalent blast-radius isolation, independent provider sessions -and caches, non-confusable routing, shared replay protection, and operational +that proves OS-equivalent blast-radius isolation, independent stores +and unlocked snapshots, non-confusable routing, shared replay protection, and operational benefit that justifies the larger trusted computing base. diff --git a/docs/adr/0004-keepass-store.md b/docs/adr/0004-keepass-store.md new file mode 100644 index 0000000..0ee0adc --- /dev/null +++ b/docs/adr/0004-keepass-store.md @@ -0,0 +1,50 @@ +# ADR 0004: Concrete KeePass store and interactive owner unlock + +Status: accepted + +## Decision + +Charon uses one concrete, read-only `KeePassStore` per realm. There is no generic +provider trait, backend selector, credential plugin or fallback source. Local +policy maps an opaque reference and exact realm persona to one canonical entry +UUID. The mapped protected Password field resolves as `SecretString`; failures +use the data-free `StoreError` taxonomy. Workloads never select entries. + +The process starts locked. The owner enters the master password through a +separate HTTPS admin page after passkey authentication. A blocking decrypt holds +no persistent master password and retains only mapped password values in +secret-holding memory. Explicit lock drops those values and invalidates in-flight +unlock work. Restart discards all unlocked state. The optional key file is a +private operator-provisioned input, distinct from the master password. + +## Owner authentication + +The admin listener uses one stable canonical HTTPS DNS origin and an independent +browser-trusted TLS certificate/key. First startup creates a random console-only +code that expires after five minutes. Successful WebAuthn enrollment consumes +it and atomically persists public registration data. Challenges remain server-side; +passkey authentication issues bounded ten-minute opaque sessions in Secure, +HttpOnly, SameSite Strict cookies. Administrative POSTs check the exact Origin. +An authenticated owner can add a recovery passkey. Console access is recovery +authority if every passkey is lost; there is no unauthenticated reset endpoint. + +## Telegram + +A dedicated protected KeePass Password entry, excluded from workload mappings, +enables post-unlock notifications and optional exact-name allow-once gates. +Telegram is idle before unlock; lock cancels polling and pending decisions. No +bot token is read from the environment, and locked boot cannot notify the owner. Telegram transports the admin URL and capability/method metadata, +never the master password, enrollment code or credential values. Each decision +binds an immutable private numeric user/chat ID, original message ID, random +expiring token, and one pending request. No durable approval rule is created. +Denial, outage, timeout, saturation and cancellation fail closed. This additional +gate cannot widen network or identity policy and does not query a control-plane API. + +## Consequences + +Deployment needs the encrypted database, optional key file, public registration +state and TLS inputs, without a vault service or CLI session. Restart requires +manual unlock. Public passkey state needs private write access; the KDBX remains +read-only. The operator owns backup, TLS renewal, passkey recovery, and database +rotation. The parser temporarily handles the full decrypted database and joins +the trusted computing base; process resource bounds remain necessary. diff --git a/docs/adr/0004-secret-provider-adapters.md b/docs/adr/0004-secret-provider-adapters.md deleted file mode 100644 index bf11e9d..0000000 --- a/docs/adr/0004-secret-provider-adapters.md +++ /dev/null @@ -1,77 +0,0 @@ -# ADR 0004: Compile-time secret-provider adapters - -Status: accepted - -## Context - -Charon must support secret stores without making a store, account, item, or -credential reference caller-selectable. Provider implementations execute in the -credential-holding process and therefore join Charon's trusted computing base. - -## Decision - -Charon uses the `SecretProvider` Rust trait as its adapter boundary. An adapter: - -- receives a typed opaque `SecretRef` borrowed from validated local policy; -- exposes a credential-free readiness check; -- returns only `secrecy::SecretString`; -- reports only the closed, provider-neutral `ProviderError` taxonomy; -- owns any backend client, authentication session, and bounded cache; and -- never derives backend routing from workload requests or signed manifests. - -Adapters are registered and selected from deny-unknown-fields configuration at -compile time. The current binary includes environment and Vaultwarden adapters. -Adding another backend requires: - -1. a narrowly scoped configuration variant; -2. backend-specific validation before listener bind; -3. an adapter implementation and construction branch; -4. locked, unavailable, missing, rotation, cache, and leakage tests; and -5. threat-model and operator-documentation updates. - -The closed failure taxonomy is `Locked`, `Unavailable`, -`ReferenceNotMapped`, `SecretUnavailable`, and `InvalidResponse`. Variants -carry no backend error, account, item, reference, or secret material. Backend -SDK and protocol errors must be reduced to one of these values inside the -adapter. - -Every production adapter must run the same behavioral contract: credential-free -health, successful resolution of a policy-owned mapping, fail-closed rejection -of an unmapped reference, secret-holding return types, and provider-neutral -errors. Backend-specific suites additionally cover lock, outage, deletion, -rotation, bounded caching, protected runtime material, and leakage. - -One isolated realm owns one provider instance. This keeps accounts, sessions, -caches, readiness, and failure domains aligned with the persona boundary. - -## Multiple backends - -The adapter interface supports any number of implementations in the codebase, -but one running realm currently selects exactly one. Supporting several -instances in one realm would require trusted policy to bind every service to a -configured provider ID. The workload and manifest would still carry neither -provider ID nor secret reference. - -That extension requires a follow-up ADR proving: - -- provider IDs and references cannot be confused across adapters; -- readiness and failure of one provider cannot cause fallback to another; -- caches and sessions remain isolated; -- duplicate references are rejected or explicitly namespaced; and -- audit output reveals neither provider routing nor references. - -## Rejected alternatives - -- **Caller-selected backend or item:** creates a credential-discovery and - confused-deputy interface. -- **Online control-plane secret resolution:** couples request-path availability - to an application and sends credential material across an additional - boundary. -- **Dynamically loaded native plugins:** arbitrary plugin code would execute - inside the credential boundary and make the reviewed binary non-reproducible. -- **Provider subprocess protocol by default:** adds environment, argument, - standard-I/O, lifecycle, and binary-authenticity leakage surfaces. A future - sandboxed protocol may be proposed separately when a backend cannot safely be - linked. -- **Automatic provider fallback:** makes outages capable of changing credential - identity and violates fail-closed routing. diff --git a/docs/adr/0005-human-approval-broker.md b/docs/adr/0005-human-approval-broker.md index 322a8a2..97038bf 100644 --- a/docs/adr/0005-human-approval-broker.md +++ b/docs/adr/0005-human-approval-broker.md @@ -35,10 +35,12 @@ integrating control plane and its workload-identity issuer: lease and policy generation, then mints a fresh Charon manifest for exactly one operation. -Charon does not receive approval requests, decisions, rules, assertions, -Telegram messages, or callbacks. It continues verifying workload manifests -offline and independently enforcing concrete service, hostname, method, path, -and injection policy. +Charon does not receive this external broker's normalized requests, reusable +rules or signed assertions. It verifies workload manifests offline and enforces +concrete service, hostname, method, path and injection policy. Its optional local +Telegram allow-once gate, defined in [ADR 0004](0004-keepass-store.md), can narrow +those grants after manifest verification. That gate has no reusable rule or +assertion authority and uses a separate dedicated bot token. The versioned contracts are: @@ -70,7 +72,8 @@ content is non-reusable and requires a higher-risk allow-once confirmation. scope. Account takeover remains an external authorization risk. - **Identity issuer:** trusts the broker's dedicated public key but must recheck current lifecycle and policy state before minting a manifest. -- **Charon:** trusts only its existing issuer key and local policy; approval +- **Charon:** verifies the issuer key and local policy, then any configured + local allow-once gate; external approval cannot widen a Charon capability. The broker API uses operator-managed mutual TLS identities. Issuers and @@ -164,8 +167,9 @@ signatures, keys, credentials, provider references, and Telegram bot tokens. ## Rejected alternatives -- **Telegram inside Charon:** would add an online chat dependency to the - credential data plane and hold requests open near resolved credentials. +- **Reusable grant engine inside Charon:** would couple durable rule and issuer + authority to the credential data plane. Local allow-once gates remain bounded + and wait before credential resolution. - **Telegram as policy engine:** presentation text and callback buttons are not canonical authorization inputs. - **Issuer-internal unsigned decision:** prevents independently verifiable diff --git a/docs/adr/0007-transparent-capability-gateway.md b/docs/adr/0007-transparent-capability-gateway.md index 4cd7b4f..0e14e63 100644 --- a/docs/adr/0007-transparent-capability-gateway.md +++ b/docs/adr/0007-transparent-capability-gateway.md @@ -27,7 +27,7 @@ workload binding. Applications use canonical `{{charon.}}` references in one typed policy sink. The reference names only local policy. Authorization completes before the provider receives its opaque policy reference. Existing -`SecretProvider`, exact service/capability policy, TLS authority, request +`KeePassStore`, exact service/capability policy, TLS authority, request forwarder, redirect denial, and size guards remain canonical; there is no parallel proxy or provider system. diff --git a/docs/approval-broker-operations.md b/docs/approval-broker-operations.md index 121b380..450f4d4 100644 --- a/docs/approval-broker-operations.md +++ b/docs/approval-broker-operations.md @@ -2,7 +2,8 @@ This runbook applies to the optional external human-approval broker specified by ADR 0005. It is not deployed inside Charon and receives no credential-provider -access. +access. The separate in-process allow-once gate is configured through +[owner operations](admin.md); this broker runbook does not configure that gate. ## Provisioning @@ -15,7 +16,7 @@ access. audit correlation. Encrypt storage and backups with operator-managed keys. 4. Create a Telegram bot with BotFather and inject its token only into the separate adapter as `CHARON_TELEGRAM_BOT_TOKEN`. Never print the token or - place it in the broker, Charon, workload images, repository settings, command + share it with the broker, Charon local gate, workload images, repository settings, command arguments, logs, probes, receipts, or messages. 5. Set `CHARON_TELEGRAM_USER_ID` to one positive numeric user ID. `@userinfobot` can help discover it, but is an unrelated third-party bot: never send that diff --git a/docs/comparisons/infisical-agent-vault.md b/docs/comparisons/infisical-agent-vault.md new file mode 100644 index 0000000..a21e371 --- /dev/null +++ b/docs/comparisons/infisical-agent-vault.md @@ -0,0 +1,76 @@ +# Charon and Infisical Agent Vault + +Use **Infisical Agent Vault** when you want a credential broker that also manages +agent access and credential lifecycles. Use **Charon** when you already manage +secrets in KeePass and want a focused gateway with operator-defined request +policy and manual owner unlock. + +Both proxy outbound requests and inject credentials outside the agent runtime. +That mechanism is shared; it is not a Charon differentiator. This comparison is +with [Agent Vault](https://github.com/Infisical/agent-vault), rather than the +entire Infisical secrets-management platform. Reviewed on 2026-10-12 against +its published documentation and Charon's current KeePass implementation. + +## Use Infisical Agent Vault when + +- You want agents to discover services and propose new credentials or access, + then let a person review those proposals in a browser. +- You need multiple vaults, users, agent memberships, and access roles. +- You need OAuth authorization and automatic access-token refresh, or want to + connect an existing Infisical credential store. + +Agent Vault documents these workflows in its +[agent protocol](https://docs.agent-vault.dev/agents/protocol), +[vault management](https://docs.agent-vault.dev/learn/vaults), and +[credential management](https://docs.agent-vault.dev/learn/credentials) guides. +Charon does not provide these management features. + +## Use Charon when + +- KeePass is already your source of truth. You want to mount a read-only KDBX, + map exact entry UUIDs, and keep editing and backing up secrets with your + existing KeePass tools. +- You accept an owner signing in with a passkey and entering the KeePass master + password over HTTPS after a restart. Credential routes stay unavailable + until unlock; the master password is discarded after decryption. +- You have a small, known destination inventory and want exact hostnames, + methods, paths, and credential locations specified by the operator. +- You want selected requests to require a private Telegram allow-once decision, + with denial or unavailable approval preventing credential lookup and forwarding. +- Your deployment already supplies workload identity or network isolation, and + you want the gateway's request path independent of an orchestrator database. + +See [owner operations](../admin.md), +[the workload contract](../../contracts/workload-gateway.md), and +[integration boundaries](../integration-boundaries.md). + +## Practical differences + +| Decision | Infisical Agent Vault | Charon | +| --- | --- | --- | +| Where secrets are managed | Built-in encrypted credentials or an external credential store | Operator-managed read-only KeePass database | +| Changing access | Agent proposals can request service and credential changes for human review | Operator changes local configuration and KeePass mappings | +| Approval scope | Browser proposals can change stored credentials and service access | Telegram decision authorizes one pending request without changing policy | +| Destination policy | Host/path patterns; unmatched traffic is forwarded by default, with a configurable strict deny mode | Exact configured destinations; unmatched access denied | +| OAuth lifecycle | Authorization flows and automatic refresh | No built-in OAuth refresh; operator maintains stored values | +| Operations | Vault, user, and agent management | One isolated realm, local configuration, HTTPS owner access, and optional Telegram | + +Agent Vault's [services guide](https://docs.agent-vault.dev/learn/services) +documents its matching and unmatched-host policy. Strict deny mode can be +configured there too; deny-by-default is a Charon default, not an exclusive +capability. + +## Limits to consider + +Charon's lightweight aim means fewer management responsibilities and one +concrete secret backend. It is not a demonstrated binary-size, memory, or +performance advantage: there are no comparative benchmarks yet. Charon is +also early-stage. Its connected synthetic E2E and browser checks cover the +KeePass/admin/approval/proxy flow; container deployment and real Telegram +verification remain separate checks. See [verification evidence](../security-verification-keepass-admin.md). + +The Telegram token is stored in an operator-only KeePass entry and becomes +available after HTTPS owner unlock. Charon's deployment env files contain only +nonsecret settings. HTTPS and interception TLS keys still need separate operator +provisioning. Manual vault unlock means credential-bearing workloads need an +owner after a restart, and Telegram cannot send a locked-boot notification. diff --git a/docs/conventions.md b/docs/conventions.md index 5a97373..03b7877 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -14,6 +14,13 @@ tests, and dependency policy. This cross-tool quality gate is why Its `strict-tls-check` also selects Python 3.13.5 and HTTPX 0.28.1 in a disposable venv to verify generated CONNECT chains with Hermes-matched strict TLS clients. This client test does not change the admission package's Python pin. +`mise run browser-admin-check` is an additional owner-UI proof that requires +Chrome and Node 22 or newer. It starts isolated synthetic HTTPS/Chrome state, +uses a virtual passkey, and stops its server and browser when finished. + +`mise run e2e-flow` runs the connected offline KDBX/admin/approval/proxy/API +fixture using curl. `mise run e2e` runs it and the Chrome proof. See +[the E2E guide](../tests/e2e/README.md); live Telegram is a separate explicit opt-in. Repository-root `tmp/` is an ignored workspace for disposable artifacts. Use `tmp//` to avoid collisions. Never store credentials there. Move @@ -38,7 +45,7 @@ deletion. The project does not use a merge queue. | Changed surface | Documentation owner | | --- | --- | -| Public purpose, quick start, supported provider summary | `README.md` | +| Public purpose, quick start, KeePass and owner enrollment summary | `README.md` | | HTTP and workload wire behavior | `contracts/` | | Configuration schema or integration responsibility | `docs/integration-boundaries.md` and examples | | Trust boundary, credential flow, authorization, redirects, or logging | `docs/threat-model.md` | diff --git a/docs/deployment.md b/docs/deployment.md index e64616f..5c176da 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,4 +1,4 @@ -# Non-production deployment +# Deployment On a successful `CI` run for a push to `main`, `.github/workflows/release.yml` checks out that exact commit and publishes @@ -15,13 +15,21 @@ The public workflows stop after publishing the image and do not access a deployment host. Operators can use the project-owned files under `deploy/` from their own trusted deployment system. -The operator provisions `/opt/apps/charon/runtime.env` (mode `0600`) with only: +The operator provisions `/opt/apps/charon/runtime.env` with nonsecret Compose +listener settings only: ```dotenv CHARON_BIND_IP= -CHARON_TEST_CREDENTIAL= +CHARON_ADMIN_BIND_IP= ``` +The deploy script adds the nonsecret image name and commit revision to `.env`. +Neither file contains application secrets. Store the optional bot token in a +dedicated protected KeePass Password entry, identified by +`keepass.telegram_bot_token_entry_id` and excluded from workload mappings. Telegram +starts only after owner unlock; no locked-boot notification is available. TLS +private keys are provisioned as separate private files so HTTPS can start locked. + The operator provisions a rootless Docker daemon for the deploy account, ownership of only `/opt/apps/charon`, and `curl`. If registry authentication is required, the trusted deployment system streams a short-lived package-read @@ -33,11 +41,17 @@ socket shared by the secrets stack. ## Verification and failure behavior Compose waits for `/healthz`. The deploy script then checks that an unauthenticated -proxy request is denied with `401` before credential resolution and that the running OCI revision label equals +absolute-form HTTPS-target proxy request is denied with `401` before credential resolution and that the running OCI revision label equals the requested main commit. If pull, startup, health, denial, or revision checks fail, the script restores `.env.previous` and recreates the last known-good image. The Compose and policy files are staged and restored with the image, so a bad configuration change cannot make the rollback use the candidate contract. +The smoke test uses curl's `--request-target https://api.github.com/user` over +the plaintext proxy connection to reach manifest validation without CONNECT or +a credential. Plaintext remote targets are rejected before identity validation +and are not a valid identity smoke test. Deployment success does not imply +unlocked readiness or Telegram delivery. + The currently selected revision is observable with: ```sh @@ -59,7 +73,7 @@ rebuild or retag it. The project-owned host command is: ## Removal -This removes the milestone-0 instance but retains its small deployment files for +This removes the Charon instance but retains its small deployment files for audit and later redeployment: ```sh @@ -69,46 +83,38 @@ docker compose --env-file .env down After confirming the container and listener are gone, infra may remove the project directory, deploy account/key, private-network policy, and workload -registry entry. No Vaultwarden state or production credential is part of this -deployment. - -## Vaultwarden provider cutover - -The durable provider uses Vaultwarden through a pinned native Bitwarden CLI, -which is compatible with the Vaultwarden client API. Infra, not a workload, -owns these three mounts: - -- `/usr/local/bin/bw` is a read-only, checksum-verified CLI executable; -- `/var/lib/charon/bitwarden-cli` is a Charon-only directory containing the - encrypted CLI vault and server selection; -- `/run/credentials/charon-vaultwarden-session` is a `0400`, memory-backed file - containing the current unlock session. - -The provider maps each opaque policy reference and exact persona to one exact -vault item UUID. Startup rejects any capability whose persona does not own its -service's mapping. It runs only `bw status`, `bw sync`, and -`bw get password `. The session is -passed through the child environment, never an argument, response, or log. A -missing, empty, whitespace-containing, or non-unlocked session fails as locked. -Charon starts a fresh CLI process on a cache miss, zeroizes captured output, and -caches resolved values in `SecretString` for at most 300 seconds (30 seconds in -the example). A process restart starts with an empty value cache. - -Provision the encrypted CLI state interactively under the dedicated Charon host -account, select the exact Vaultwarden server, log in with a least-privilege -persona vault account, and sync once. Materialize the unlock session only into -the memory-backed credential file immediately before starting Charon. Rotation -is: update the mapped item, sync, atomically replace the session file if the -vault was re-unlocked, wait one configured cache TTL or restart Charon, then -revoke the prior value. Never place the API key, master password, session, CLI -data, or resolved values in Compose environment, image layers, GitHub Actions, -or the workload. - -Locked or missing sessions deny as `Vaultwarden provider is locked`; CLI/server -failure denies as `Vaultwarden provider is unavailable`; revoked or deleted -items deny as `Vaultwarden item is unavailable`. These messages are deliberately -value-free. Removing the session file locks future cache misses; restart Charon -when immediate invalidation of already-cached values is required. +registry entry. Remove encrypted databases, optional key files and public passkey state only +under the operator's retention and backup policy. + +## KeePass and HTTPS admin setup + +Provision the encrypted KDBX, exact UUID mappings, optional private key file, +browser-trusted admin certificate and independent TLS key. Create a mode-0700 +admin-state directory writable by Charon. The supplied Compose mounts the KDBX +read-only and persists public passkey registrations separately from receipts. +The image runs as UID/GID 65532 by default. Set host file/directory ownership +for that identity (including the rootless daemon's UID mapping) or configure a +reviewed dedicated `user`. Private files must be accessible only to Charon's UID. Set `admin.origin` to the +stable HTTPS DNS origin your browser will actually use. The owner admin listener +is separate from workload ports; firewall it away from the workload network. +Publishing port 8444 only on an owner IP does not prevent access through a +container's workload-network address when `admin.listen` is `0.0.0.0`. Infra must +deny workload-to-admin traffic as well as restricting the host-published port. +When Telegram is enabled, allow Charon direct HTTPS/DNS egress to +`api.telegram.org`; the bot does not use the configured egress proxy. + +First boot prints a five-minute console enrollment code. Retrieve it over SSH, +open the HTTPS admin origin, create a passkey, sign in and unlock with the KeePass +master password. Add a recovery passkey. Restart always requires another unlock; +rollback to another image also starts locked. Do not route credential traffic +until `/readyz` succeeds. Liveness can succeed while the vault is locked. + +For rotation, update the encrypted database outside Charon, lock the store and +unlock again to load its current mapped values, then revoke the prior credential. +Changing policy mappings requires a validated configuration restart. Preserve +public passkey state across image replacement; changing the origin requires +new enrollment. Charon never writes a master-password file or modifies the KDBX. +See [owner operations](admin.md) for certificates, recovery and optional Telegram. ## CONNECT interception CA @@ -125,14 +131,14 @@ For a live non-production proof, issue a fresh single-use manifest and run `scripts/prove-gh-proxy.sh`. It configures an unmodified `gh api user` through `HTTPS_PROXY`, provides only the public CA and capability-reference GitHub token to the workload, and relies on Charon to resolve the mapped value. The CA key, -Vaultwarden session, mapped item, and resolved value remain absent from the +KeePass database, optional key file, mapped entry, and resolved value remain absent from the workload. Rotate a persona intermediate independently using the bounded overlap in the realm contract: validate a second root-signed intermediate, select it for new leaves, drain old tunnels, then destroy the retiring key. Workspace trust does not change. A compromised intermediate requires immediate isolation of that -realm endpoint, key/session revocation, and realm rebuild; unrelated realms and +realm endpoint, key revocation and credential rotation, and realm rebuild; unrelated realms and the offline root remain unchanged. Production credentials remain prohibited until the explicit owner security review covers ADR 0002 and ADR 0003. @@ -173,7 +179,7 @@ generations. [`examples/transparent-gateway.toml`](../examples/transparent-gateway.toml) is the public contract consumed by downstream Infra. Run Charon as a dedicated -non-root UID/GID. That identity alone owns the provider session, CA private key, +non-root UID/GID. That identity alone owns the KeePass database, admin state, TLS private keys, receipt journal, and hash-chain state. Directories are `0700`; private files are `0400` or `0600`; the public CA can be `0644`. Do not mount application state, Hermes `/opt/data`, Git credential files, Docker sockets, or control-plane data. @@ -187,7 +193,7 @@ iptables, nftables, Compose, or host policy. The normal `listen` address remains the network-restricted control and explicit proxy endpoint. `/healthz` proves the process is alive. `/readyz` checks the -provider and receipt writer without resolving a credential. A failed provider, +KeePass store and receipt writer without resolving a credential. A locked store, receipt state, CA, listener bind, policy validation, or DNS/TLS operation fails closed. Restart policy should be `on-failure`; readiness failure removes the gateway from service but must not open a bypass route. @@ -210,7 +216,7 @@ charon healthcheck http://127.0.0.1:3129/readyz Safe rollout uses a synthetic credential and isolated upstream first. Prove an exact read and mutation, each configured hydration sink in use, response -streaming/cancellation, an omitted capability denial before provider access, +streaming/cancellation, an omitted capability denial before credential lookup, service-loss denial, direct-egress denial, IPv6/QUIC denial, a metadata-only receipt, and restart without credential artifacts. For Git, disable hooks, credential helpers, recursive submodules, alternate object stores, and @@ -218,7 +224,7 @@ caller-controlled URL rewrites in the workload policy; allow only the exact smart-HTTP hostname and paths and select an opaque Git media type. Rollback removes the transparent route before stopping Charon, restores the -previous immutable image plus its complete policy/CA/provider mapping, verifies +previous immutable image plus its complete policy/CA/KeePass mapping, verifies readiness, then restores the route. Application state remains on its independent persistent volume. For Hermes that means `/opt/data` survives every Charon, plugin, and image change; Charon neither mounts nor modifies it. diff --git a/docs/hermes-gateway.md b/docs/hermes-gateway.md index 480f7aa..a3fe8a9 100644 --- a/docs/hermes-gateway.md +++ b/docs/hermes-gateway.md @@ -7,7 +7,7 @@ and `charon --gateway-config /etc/charon/hermes-gateway.toml`; do not pass this schema to `--config`. Validate the candidate against the exact released binary with `charon gateway validate /etc/charon/hermes-gateway.toml`. -Infra owns Compose isolation, CA installation/trust, provider mappings, image +Infra owns Compose isolation, CA installation/trust, KeePass mappings, image pins, Ansible, backup and cutover. Charon owns the protocol and validated schema. This repository neither modifies live infrastructure nor removes its full convergence guard. Config-only CD and the independent Hermes tool admission @@ -84,10 +84,11 @@ a direct-egress lane when variables are absent or a library ignores them. For a caller-owned route, curl, gh and SDKs supply their usual credentials. For the example Git Basic grant, use fixed username `x-access-token` and the -synthetic capability password `{{charon.github-git}}`. The private Git token -is the Charon-only `CHARON_SYNTHETIC_GITHUB_TOKEN` provider value, never a client -variable. Real deployments should map that reference through Vaultwarden and -use protected provider inputs. No secret values belong in TOML or command logs. +synthetic capability password `{{charon.github-git}}`. Charon resolves the private +Git token from the KeePass entry mapped to local reference +`CHARON_SYNTHETIC_GITHUB_TOKEN`. The client supplies only the route placeholder. +Provision a private KDBX and unlock through the HTTPS admin page. No secret +values belong in TOML or command logs. ## Feature verification and Infra handoff @@ -110,7 +111,7 @@ and runs a real local intercepted CONNECT request against a generated CA and verified TLS fixture origin. It keeps Python's default strict flags, required certificate verification and hostname checks; checks trusted urllib/HTTPX, reuse, denied operations, untrusted CA and wrong hostname; and proves zero -provider lookups. Linux CI runs the same required test. The normal Rust suite +credential lookups. Linux CI runs the same required test. The normal Rust suite also checks the emitted CA/leaf roles, critical extensions and identifier link. The fixture upstream uses a separate synthetic CA and verified TLS; its local @@ -122,11 +123,11 @@ captures: 1. Validate its complete policy using the pinned image. Test OS, Python, curl, gh and SDK trust without disabling certificate validation. 2. Send an ordinary secretless request; confirm streaming, session headers, - upload/download and no provider lookup. Send a typed credential reference + upload/download and no credential lookup. Send a typed credential reference and assert the fixture origin receives hydration only at its permitted sink. 3. Reuse one TLS tunnel for an allowed request, a denied operation, another allowed request, and a mismatched authority. Test HTTP/2 separately. Denials - must happen before provider work. + must happen before credential lookup. 4. Assert unknown hosts, metadata/private DNS, alternate ports, wrong SNI, untrusted upstream TLS, nested CONNECT, compression, and WebSocket upgrades fail closed. Follow a redirect only by a separately authorized request. diff --git a/docs/index.md b/docs/index.md index c683797..29262d4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,6 +5,8 @@ Start with the document that owns the question you are trying to answer. ## Use and operate Charon - [README](../README.md): product overview and fastest development path. +- [Telegram/BotFather setup](telegram-setup.md): bot creation, owner ID and live approval testing. +- [Owner enrollment and vault operations](admin.md): HTTPS setup, passkeys, unlock, recovery, and Telegram decisions. - [Deployment](deployment.md): deployment configuration and operating steps. - [Hermes single outbound gateway](hermes-gateway.md): exclusive-workload explicit proxy, exact destination inventory and deployment verification. @@ -21,6 +23,10 @@ Start with the document that owns the question you are trying to answer. - [Hermes v2026.8.3 compatibility](hermes-v2026.8.3-compatibility.md): exact Telegram inventory, classifications, runtime gates, and upgrade contract. +## Compare alternatives + +- [Infisical Agent Vault and Charon](comparisons/infisical-agent-vault.md): practical selection guide. + ## Understand the design - [Integration boundaries](integration-boundaries.md): system ownership and @@ -41,5 +47,4 @@ Start with the document that owns the question you are trying to answer. documentation ownership, scratch space, and bootstrap choices. - [Exclusive gateway implementation review](security-review-workload-gateway.md): reviewed boundaries, regression evidence and deployment limitations. -- [Security audit](security-audit-2026-07-27.md): current audit evidence and - residual risks. +- [KeePass/admin verification](security-verification-keepass-admin.md): implementation evidence and deployment verification limits. diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index 57cd2cd..b6c1dc6 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -13,9 +13,11 @@ control plane. Integrations are explicit, versioned, and fail closed. | Transparent workload lane | Infra-routed workload → dedicated Charon listener | intercepted TCP/TLS; capability reference in one policy sink; [`transparent-gateway.md`](../contracts/transparent-gateway.md) | trusted only when isolated routing prevents spoofing and bypass | exact listener/service binding, SNI/authority agreement, capability, hydration, response mediation | | Identity issuer | control plane → workload → Charon | Ed25519 signed compact manifest; [`workload-claims.schema.json`](../contracts/workload-claims.schema.json) | trusted only to assert identity and capability | offline signature verification and independent concrete-operation policy | | Approval broker | control plane ↔ broker → identity issuer | normalized request and signed approval assertion; [`approval-broker.openapi.yaml`](../contracts/approval-broker.openapi.yaml) | trusted control-plane authorization component | outside Charon; cannot select destinations, secrets, or widen local policy | -| Approval channel | broker ↔ Telegram/human | [`ApprovalChannel`](../contracts/approval-channel.md) presentation and opaque callback contract | trusted only for delivery and numeric actor/chat authentication | outside Charon; no decision, rule, assertion, or credential authority | +| External broker approval channel | broker ↔ Telegram/human | [`ApprovalChannel`](../contracts/approval-channel.md) presentation and opaque callback contract | trusted only for delivery and numeric actor/chat authentication | outside Charon; no decision, rule, assertion, or credential authority | | Operator configuration | operator → Charon | deny-unknown-fields TOML; example in [`examples/charon.toml`](../examples/charon.toml) | trusted administrative input | validation before listener bind | -| Secret provider | Charon → provider adapter | Rust `SecretProvider`; opaque policy-owned reference in, `SecretString` out, closed `ProviderError` failures | trusted with only its realm's secrets | readiness, bounded caching, non-disclosure, fail-closed errors | +| KeePass store | Charon → private read-only KDBX | exact policy-owned entry UUID; `SecretString` out, data-free `StoreError` | trusted operator database and parser | locked boot, mapped protected Password fields, explicit lock, non-disclosure | +| Owner admin | owner browser → separate Charon HTTPS listener | console-code first enrollment; origin-bound passkeys; short-lived opaque sessions | trusted owner and browser authenticator | public registrations, origin checks, bounded challenges, authenticated unlock/lock | +| Local Telegram decisions | Charon ↔ private Telegram owner | outbound bot HTTPS and long-poll callbacks | dedicated bot token and immutable private numeric owner ID | exact-policy allow-once gate, callback actor/chat/message binding, bounded pending state | | Realm reconciler | operator → runtime | declarative desired/observed objects; schemas in [`contracts/`](../contracts/) | privileged control plane | no lifecycle mutation API; only runtime health and identity | | Runtime probe | operator → Charon | HTTP origin endpoints; [`openapi.yaml`](../contracts/openapi.yaml) | network-restricted observer | liveness and credential-free readiness | | Destination | Charon → exact host | HTTPS on port 443, optionally through configured egress; literal loopback HTTP only for local fixtures | untrusted response source | credential injection after authorization; redirects disabled | @@ -69,53 +71,53 @@ approval assertion to the identity issuer. The issuer verifies and consumes that assertion, rechecks the current tenant/persona/workspace/lease tuple and policy generation, and mints one fresh -single-use Charon manifest. The data plane never calls the broker and never -receives control-plane approval requests, decisions, rules, assertions, callback -tokens, approval-channel bot tokens, or presentation text. Caller-owned Telegram network requests are a +single-use Charon manifest. The data plane never calls the external broker or receives its rules and +assertions. Its optional local Telegram gate uses a dedicated operator-only KeePass bot token +and pending callback handles, and narrows the configured network policy. Caller-owned Telegram network requests are a separate exclusive forwarding route. ADR 0005 and the approval artifacts in [`contracts/`](../contracts/) define this separate control-plane boundary. Resident-agent semantic approval uses the same grant engine but terminates at a local admission decision; it cannot manufacture a capability or skip Charon's independent network authorization. Session coding harnesses keep their native -inline approval systems. The Telegram process and broker are separate optional +inline approval systems. The external broker's Telegram adapter and broker are separate optional processes joined by a protected Unix socket: the broker has durable state and the assertion key but no Telegram token, while the adapter has the token and immutable private-user binding but no rule or assertion authority. -## Provider adapter contract - -`SecretProvider` is the in-process adapter boundary. A provider receives only an -opaque reference selected by trusted service policy and returns a -`secrecy::SecretString`. Provider values must not implement `Debug`, -serialization, or response conversion. - -Adapter failures cross the boundary only as the closed, data-free -`ProviderError` variants. Raw SDK, HTTP, CLI, account, item, and reference -details remain inside the adapter. This makes coarse non-disclosure enforceable -by the Rust type system instead of relying only on implementation convention. - -The current binary registers environment and Vaultwarden adapters at compile -time. Adding a provider requires a configuration variant, validation, adapter -construction, and fail-closed tests. Charon intentionally does not load -third-party dynamic plugins into its credential-holding process. - -The interface supports any number of reviewed backend implementations in the -binary. A realm deliberately selects exactly one instance, so an outage cannot -trigger backend fallback or change credential identity. “Multiple stores” -therefore means interchangeable configured adapters across realms, not -caller-selected routing within a request. - -One provider instance belongs to one realm. If a future realm needs several -providers, local policy—not a manifest or request—must bind each service to a -named provider instance. That change requires a new ADR because it expands -configuration and failure-isolation complexity. +## KeePass and owner administration + +One concrete `KeePassStore` belongs to one realm. Trusted configuration names a +private KDBX file and optional private key file. Each exact canonical entry UUID +maps to a policy reference and the realm's persona. The workload protocol cannot +select a database, entry, password field, or fallback source. Only protected, +nonempty, bounded Password fields resolve as `SecretString`; parser failures are +reduced to the closed data-free `StoreError` taxonomy. + +The owner unlocks through an independent HTTPS admin listener on a stable DNS +origin. First enrollment requires a short-lived operator-console code; subsequent +sign-in verifies an enrolled passkey. Public key registrations persist in a +private directory; challenges and ten-minute sessions remain in memory. An +origin-checked session authorizes unlock, lock and recovery-passkey enrollment. +Master passwords are not persisted. Lock and restart discard the mapped snapshot. +The operator owns certificates and renewal, database backup and credential rotation. +See [owner operations](admin.md) and [ADR 0004](adr/0004-keepass-store.md). + +Optional local Telegram approvals bind exact configured capability/route names. +Requests pass identity, policy and sink checks before they create a prompt, and +must receive a fresh allow-once decision before credential resolution or upstream +forwarding. Each callback verifies numeric actor and private chat identity, +original message ID, random token and expiry. Failure denies the request. The bot +receives only capability/method metadata and the admin URL. It never transports +enrollment codes, master passwords or credential values. The bot token is loaded from an operator-only protected KeePass entry after +owner unlock. Lock cancels the channel and pending decisions. The token +belongs only to Charon and must not be shared with a workload's Telegram bot. ## Reconciler contract -Charon exposes no create, rotate, revoke, or delete endpoint. An external +Charon exposes no realm create, rotate, revoke, or delete endpoint. An external operator reconciles the versioned desired object into isolated files, -networking, provider state, PKI, and an immutable Charon process, then publishes +networking, encrypted database, admin state, PKI, and an immutable Charon process, then publishes a redacted observation. The schemas describe exchanged data; they do not grant authority or prescribe a transport. diff --git a/docs/persona-realm-contract.md b/docs/persona-realm-contract.md index ae73803..a16a26b 100644 --- a/docs/persona-realm-contract.md +++ b/docs/persona-realm-contract.md @@ -19,8 +19,8 @@ runtime_root: /run/charon/realms/ ca_generation: stable generation ID selected for new leaves ``` -The renderer, not a caller or workload, supplies Vaultwarden account/session, -item mappings, endpoint, listener, and filesystem paths. The desired object +The renderer, not a caller or workload, supplies KeePass database and optional key-file paths, +exact UUID mappings, endpoint, listener, and filesystem paths. The desired object contains no credential value, unlock session, private key, or root-key input. ## Filesystem layout @@ -30,21 +30,26 @@ contains no credential value, unlock session, private key, or root-key input. charon.toml root:realm 0440 ownership root:root 0444 (realm/tenant/persona marker) /var/lib/charon/realms// - bw/ realm:realm 0700 (encrypted CLI app-data) + vault.kdbx realm:realm 0400 (encrypted read-only database) + admin/ realm:realm 0700 + passkeys.json realm:realm 0600 (public registrations) /run/charon/realms// tmpfs, realm:realm 0700 credentials/ - vaultwarden-session realm:realm 0400 + keepass-key-file realm:realm 0400 when configured + admin-tls-key.pem realm:realm 0400 upstream-proxy-password realm:realm 0400 when configured ca// intermediate.pem realm:realm 0444 intermediate-key.pem realm:realm 0400 ``` -Provider cache and nonce state are process memory and are never files. The root +Unlocked credential snapshot, challenges, sessions and nonce state are process memory and are never files. The root certificate may be mounted read-only for chain validation; the root private key is forbidden on the runtime host. A realm container receives only its exact -config/state/runtime subdirectories. Symlinks, traversal, shared writable -parents, and group/other-readable private files fail validation. +config/state/runtime subdirectories. Charon rejects symlink private input files, symlink admin-state parents, and +group/other-accessible private files. The reconciler must reject traversal, +shared writable ancestors and mounts outside the owned realm roots; these are +not Charon runtime checks. ## Operations @@ -54,14 +59,14 @@ coarse state, redacted reason code, and timestamps. | Operation | Postcondition | | --- | --- | -| `create` | isolated paths/account/listener exist; no process until validation | +| `create` | isolated paths/database/admin state/listeners exist; no process until validation | | `validate` | image/config/files/permissions/CA chain and realm-persona consistency pass | -| `start` | immutable digest runs as realm UID; readiness reports expected IDs/generation | -| `health` | liveness plus config/provider-lock/network readiness, without fetching a credential | +| `start` | immutable digest runs as realm UID and starts locked; observed config identity is recorded, credential routing waits for unlock/readiness | +| `health` | liveness plus config/store-lock/network readiness, without fetching a credential | | `replace` | candidate generation validates, atomically becomes active, old generation retained for rollback window | | `rotate-ca` | new intermediate validates and signs new leaves; old connections drain during bounded overlap | | `revoke` | issuance/network access disabled, process stopped, sessions/caches/keys invalidated | -| `remove` | ownership marker rechecked, runtime destroyed, durable non-secret tombstone/audit retained | +| `remove` | ownership marker rechecked, runtime destroyed, durable metadata-only audit retained | `replace` and `rotate-ca` are independent from credential replacement. No operation falls back to another realm, account, listener, item, or generation. @@ -71,7 +76,7 @@ operation falls back to another realm, account, listener, item, or generation. - `provisioning`: not routable; - `ready`: exact observed generation is safe for issuance/routing; - `degraded`: still denies unsafe operations; operator action required; -- `locked`: provider unavailable; all credential use denied; +- `locked`: KeePass store locked or unavailable; all credential use denied; - `revoked`: issuance and network reachability disabled; - `removing` / `removed`: never routable. @@ -83,8 +88,8 @@ ready. A workspace can reach only its derived persona realm endpoint and necessary internal control services. It cannot reach other realm listeners or governed -destinations directly. Charon alone can reach its persona Vaultwarden endpoint -and the configured Squid egress. Exact destination authorization occurs again +destinations directly. Charon alone can read its persona KDBX and reach +the configured egress. The workload cannot reach the owner admin listener. Exact destination authorization occurs again inside Charon. Network policy and Charon authorization are both required. ## CA overlap @@ -98,8 +103,8 @@ realm endpoint immediately; certificate revocation alone is not relied upon. ## Disposable acceptance fixture -The fixture creates Alice and Bob with separate Vaultwarden identities, item -mappings, app-data, sessions, configs, caches, listeners, UIDs/containers, and +The fixture creates Alice and Bob with separate synthetic KeePass databases, entry +mappings, admin state, configs, unlocked snapshots, listeners, UIDs/containers, and intermediate keys under one disposable root. It must prove: 1. two Alice workspaces can use Alice's assigned endpoint; diff --git a/docs/security-verification-keepass-admin.md b/docs/security-verification-keepass-admin.md new file mode 100644 index 0000000..871b4b8 --- /dev/null +++ b/docs/security-verification-keepass-admin.md @@ -0,0 +1,70 @@ +# KeePass and owner-admin verification + +This implementation verification covers the concrete credential store, owner +HTTPS boundary and optional local Telegram gate. It does not constitute an +owner production-activation review or a live deployment proof. + +## Verified behavior + +- `tests/e2e/` connects a real encrypted two-password KDBX, HTTPS admin, + software passkey, actual Telegram worker with local Bot API transport, curl + workload, intercepted TLS and a password-checking Rust JSON API. It verifies + both mappings, deny-before-lookup, actor/replay/timeout rejection, lock and + component restart without persisted credential values. + +- Real synthetic encrypted KDBX tests cover locked boot, wrong passwords, + required optional key file, exact mapped lookup, unknown references, missing + entries, unprotected Password fields, private-file permissions, symlink + rejection, read-only database behavior, and loss of unlocked state on lock. +- A software WebAuthn authenticator exercises first enrollment, one-shot challenge + replay rejection, passkey sign-in, authenticated unlock, origin rejection, + wrong master passwords, recovery-key enrollment, logout and restart with + persisted public keys but a locked store. Code expiry is enforced at finish. + Overlapping registration/login tests enforce the eight-key and 32-session + limits again when ceremonies complete. +- A real headless Chrome proof uses an isolated synthetic profile and virtual + passkey to exercise the actual UI: enrollment, sign-in, unlock, lock, logout + and sign-in again. It checks that password/code inputs clear. The separate + HTTPS test verifies certificate trust and real network JSON parsing. +- Telegram callback tests bind private actor/chat identity and original message + ID, reject unsent/expired/canceled handles, consume decisions once, and exercise + the allow-once wait path. A local fake bot API verifies post-unlock notifications, + prompt buttons and callback polling. An intercepted TLS request proves that + denial causes no credential lookup and allow-once permits exactly one lookup. + Unknown policy names fail configuration validation. The connected fixture loads + an operator-only protected bot entry from KDBX, proves no locked bot messages, + notification-failure retry without losing HTTPS, pending cancellation on lock, + and rejection of pre-lock callbacks after restart. Store tests reject bot entry + overlap with workload mappings and missing/unprotected/malformed bot values. +- Existing signed, transparent and exclusive gateway suites use real synthetic + KeePass snapshots and continue to verify denial before credential lookup, + destination/identity/sink enforcement, no redirects, response non-disclosure, + TLS/SNI/authority agreement, limits, and realm isolation. + +The complete `mise run check` includes formatting, warnings-denied Clippy, all +Rust targets, dependency policy, deployment-script tests, Hermes admission tests +and strict Python/HTTPX CONNECT-chain verification. Run it for the final tree; +focused results do not replace this required gate. + +## Boundaries and deployment limits + +The master password enters only the HTTPS admin boundary, is parsed into a +secret-holding type, and is discarded after decrypt. Mapped protected passwords +remain in memory until lock/restart. The parser temporarily handles other +database content. Operator-controlled database KDF/decompression settings can +consume substantial resources; apply process resource bounds. + +TLS issuance/renewal, DNS, network isolation, backup, runtime UID/mount ownership, +passkey-device recovery and deployment startup remain operator responsibilities. +The bot token is a protected operator-only KeePass entry, excluded from workload +mappings. Telegram stays idle before unlock and stops on lock; no bot token +environment variable or master-password file exists. Use a dedicated approval bot inaccessible to the +workload. Telegram sees policy names and methods and is an approval authority, +but has no vault unlock authority. + +The Docker vertical fixture requires an operator-controlled Docker daemon, +browser-trusted admin TLS, an encrypted disposable KDBX, and approved egress. +It requires manual HTTPS unlock before issuing test manifests. Container builds, +actual host/firewall isolation and live Telegram delivery must be verified on +that deployment; local software-authenticator and callback tests alone do not +prove those external conditions. diff --git a/docs/telegram-setup.md b/docs/telegram-setup.md new file mode 100644 index 0000000..6c59fa0 --- /dev/null +++ b/docs/telegram-setup.md @@ -0,0 +1,85 @@ +# Set up the Charon approval bot + +Use a dedicated bot for Charon and a different bot for disposable live testing. +The approval bot token must not be shared with the workload or an external broker. + +## BotFather + +1. Open Telegram's official BotFather: + + https://t.me/BotFather + +2. Send `/newbot`. Choose a display name and an available username ending in + `bot`, for example your own unique `charon_approval_test_bot` username. +3. Keep the returned token private. Store it in a dedicated protected KeePass Password + entry. The ID-discovery helper also accepts it at a hidden prompt. Do not paste it + into this repository, chat, a browser URL or a command argument. +4. Send `/setjoingroups`, select this bot, and disable group joining. Charon accepts + only the configured private user/chat even if the bot is added elsewhere. +5. Open your new bot's private chat and press **Start** (or send `/start`) before + starting Charon. A bot cannot initiate this private conversation itself. + +These commands and username rules are documented in Telegram's +[official BotFather guide](https://core.telegram.org/bots/features#botfather). +The private-chat requirement is described in its +[bot tutorial](https://core.telegram.org/bots/tutorial#sending-messages). +No `/setcommands`, inline mode, Telegram login widget, webhook or bot web app is +required for Charon's inline Allow once/Deny buttons. + +## Find and pin your private numeric ID + +With Charon stopped and a fresh `/start` sent to the dedicated test bot: + +```sh +mise exec -- python tests/e2e/live_telegram.py --discover-owner +``` + +Enter the token at the hidden prompt. The helper uses the official Bot API, +prints only recent private-chat numeric IDs, and does not enroll a user or print +messages. Verify your own ID explicitly; never authorize a username, display +name or whichever user contacted the bot first. + +Configure Charon using your actual numeric ID and exact local policy names: + +```toml +# In the existing [keepass] section, before any [[keepass.items]]: +[keepass] +telegram_bot_token_entry_id = "00000000-0000-4000-8000-000000000003" + +[admin.telegram] +user_id = 123456789 +approval_capabilities = ["first", "second"] +``` + +The example names belong to the E2E fixture. For deployment, use names that exist +in your `capabilities` (`--config`) or `routes` (`--gateway-config`). Use your bot +entry's actual UUID and never include it in workload `keepass.items` mappings. +No bot token goes into environment variables or deployment env files. Rotate the +token by editing KeePass, then lock/unlock to reload it. Changing the owner ID or +entry UUID requires a configuration update and restart; workload input cannot +change either. + +Charon uses long polling. Use a new dedicated bot with no webhook and no other +poller. If reusing a bot, deliberately remove its previous webhook through +Telegram's `deleteWebhook` API; Charon does not silently change bot ownership or +webhook state. Telegram documents the polling/webhook exclusion in the +[Bot API](https://core.telegram.org/bots/api#getupdates). + +## Verify + +Run the [offline E2E](../tests/e2e/README.md) first. For live delivery, follow that +guide to create an operator-owned test KDBX with two synthetic API passwords and +the test bot token. The wrapper reads that database, prompts privately for its +master password and sends it only over child stdin. No real token is generated +into test fixtures or passed through the environment. You Allow once for the +first password API and Deny for the second; the test checks lookup/upstream +behavior. Ordinary automated checks send no real messages. + +For a deployed Charon, open the configured HTTPS admin URL yourself, sign in with +a passkey and unlock. The bot then sends an unlocked notification and can prompt +for a gated route. Confirm Deny/timeout blocks it and Allow once authorizes only +that pending request. Lock should cancel pending decisions and stop the channel; +restart remains locked and sends no notification until another owner unlock. +A missing/unprotected/invalid token entry rejects unlock. Wrong ID, a bot you have +not started, network failure, or an active competing webhook/poller prevents +reliable approvals and must be corrected before treating the channel as ready. diff --git a/docs/threat-model.md b/docs/threat-model.md index baf892c..ff6222f 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -12,10 +12,23 @@ capability without placing the underlying credential in that workload. - **Charon:** trusted credential-injection data plane. It receives workload identity, resolves secrets, enforces destination policy, and emits redacted audit events. -- **Credential provider:** a configured adapter that resolves opaque, - policy-owned references. Vaultwarden is the production implementation; - milestone 0 uses Charon-process environment variables only to exercise the - boundary. +- **KeePass database:** one private, read-only KDBX file per realm. Local policy + maps exact entry UUIDs to references. The process starts locked and retains + only mapped protected Password fields after an operator unlocks it. +- **HTTPS admin and owner:** an independent TLS listener on one stable DNS + origin. An expiring console-only enrollment code authorizes initial passkey + registration. WebAuthn verifies registration and authentication; only public + registration data persists. Authenticated, origin-checked sessions can unlock + or lock the store and enroll a recovery passkey. The master password enters + only this HTTPS boundary and is not stored in a password file. +- **Local Telegram approval:** an optional outbound HTTPS bot uses an operator-only + protected KeePass token entry, never the KeePass master password. The token UUID + cannot also be a workload mapping. No Bot API call occurs before unlock; lock + drops the snapshot, cancels polling and pending decisions. Exact configured capabilities require a + fresh allow-once decision before credential lookup or upstream forwarding. + Callbacks bind the private actor, chat, message, random token, and expiry. + Telegram transports the admin URL and metadata, never enrollment codes, + passwords, credential values, or request bodies. - **Upstream egress:** an optional authenticated forward proxy. It controls network route/source IP but does not decide which persona credential to use. Its Basic-auth password is a Charon-only protected file, never URL/config @@ -36,8 +49,8 @@ capability without placing the underlying credential in that workload. One immutable positive numeric user ID authenticates a private chat only when actor ID, chat ID, and configured ID are equal; usernames and display text do not. The channel cannot create rules or assertions. -- **Persona realm:** one Charon process/container, Vaultwarden identity/session, - configuration, cache, listener, runtime filesystem, and delegated +- **Persona realm:** one Charon process/container, KeePass database, + configuration, unlocked snapshot, listener, runtime filesystem, and delegated intermediate CA per control-plane persona. No realm can read another realm's state. - **Realm reconciler:** infrastructure control plane implementing the declarative @@ -54,7 +67,7 @@ capability without placing the underlying credential in that workload. - **Workload tool adapter:** optional integration code inside an untrusted agent runtime. It can request local semantic admission and emit metadata-only receipts, but it cannot select a Charon credential, capability, destination, - provider, or secret reference. Its observations are not trusted against a + database, or secret reference. Its observations are not trusted against a compromised workload unless execution moves behind an isolated tool gateway. ## Security invariants @@ -70,8 +83,8 @@ capability without placing the underlying credential in that workload. 7. Forwarded-request audit events contain service, host, method, path, status, and outcome. Denial/error events contain only the applicable fields known at the failure boundary. No audit event contains request headers, bodies, - provider responses, or credentials. -8. Provider or policy failure denies the request. + store responses, or credentials. +8. Credential-store or policy failure denies the request. 9. Deployment selects a full commit-SHA image tag, verifies the running OCI revision, health, and credential-less denial, and restores the prior image and project-owned configuration on failure. Mutable image tags are not used. @@ -79,11 +92,12 @@ capability without placing the underlying credential in that workload. issuer, audience, workload, persona, named capability, validity window, and single-use nonce. Identity and operation authorization complete before credential resolution. -11. Vaultwarden references map an exact persona to an exact configured item - UUID; capability and mapping personas must agree. Unlock material is - readable only by Charon, is never placed on a command line, and provider - output is zeroized after conversion to a secret-holding type. Resolved values - expire from the in-memory cache within the configured bound. +11. KeePass references map one exact persona to one configured entry UUID. + Only protected, nonempty, bounded Password fields can resolve. The master + password is discarded after decryption; mapped values use `SecretString`. + Explicit lock and process restart remove the retained snapshot. A concurrent + lock prevents an in-flight unlock from committing. Files are private regular + files, not symlinks; database and optional key-file reads are bounded. 12. CONNECT allows only exact configured hosts on port 443. Charon-issued leaf identity, TLS SNI, the HTTP/1.1 `Host` or HTTP/2 `:authority`, service, method, and path must all agree before the single-use manifest is consumed @@ -96,27 +110,28 @@ capability without placing the underlying credential in that workload. 14. Signed/transparent request bodies stream through backpressured counted adapters with a 16 MiB aggregate limit; response bounds are policy-selected. Exclusive gateway uploads/downloads have explicit independent route limits. Declared oversize requests fail - before identity and provider work; declared oversize responses fail before + before identity and credential lookup; declared oversize responses fail before downstream headers; unknown-length overflows terminate their stream. 15. An authenticated upstream proxy uses a fixed configured username and an absolute protected password file. Embedded URL credentials, incomplete authentication configuration, whitespace-bearing values, and unavailable files fail closed before Charon listens. 16. One process serves exactly one declared tenant/persona realm. Every - capability and Vaultwarden item mapping must match it. Workloads cannot + capability and KeePass entry mapping must match it. Workloads cannot choose a realm, provider, account, item, listener, or credential reference. 17. Manifests and successful receipts bind stable tenant, persona, workspace, active lease, workload, operation correlation, and named capability context. These identifiers are non-secret but are accepted only from a verified issuer, never from routing inputs. -18. Each realm has isolated config, encrypted `bw` app-data, tmpfs session, - cache, listener, and intermediate key. Lock, revocation, failure, rotation, +18. Each realm has isolated config, encrypted KDBX, public passkey state, + unlocked snapshot, listener, and intermediate key. Lock, revocation, failure, rotation, or removal of one realm does not expose or interrupt another. 19. Workspace images trust one offline root certificate. Only a realm's root-signed intermediate key is deployed. PKI does not replace endpoint isolation, exact request authorization, or direct-egress denial. -20. Credential-bearing remote requests require HTTPS on port 443. Plaintext - HTTP is accepted only for literal loopback addresses used by local fixtures. +20. Credential-bearing remote requests require HTTPS on port 443. + Signed/transparent plaintext HTTP is limited to literal loopback fixtures. + Exclusive forwarding can explicitly grant secretless public HTTP on port 80. URL user information, conflicting `Host`/authority values, and alternate TLS ports fail before identity consumption or credential resolution. 21. Static and `Connection`-nominated hop-by-hop fields, caller-supplied @@ -126,28 +141,28 @@ capability without placing the underlying credential in that workload. 22. Workload manifests, audit identifiers, TTL, and clock skew have explicit size/time bounds. Audit identifiers accept only a small printable identifier alphabet. -23. Vaultwarden subprocesses inherit no ambient Charon environment. The - executable is an exact absolute non-writable file; vault state is a private - directory; and session, interception-key, and upstream-proxy password inputs - are private regular files. +23. KeePass decryption runs on a blocking thread behind a single unlock gate. + The database is never written by Charon. TLS interception keys, admin TLS + keys, optional key files, and upstream-proxy passwords remain private inputs. + The admin TLS key is independent of the interception CA. 24. Public container builds pin both Docker stages by manifest digest and emit provenance and SBOM attestations. Dependabot covers Rust, GitHub Actions, and Docker dependencies. -25. Secret-store adapters return only `SecretString` values and the closed, - data-free `ProviderError` taxonomy. Raw backend errors, account identifiers, - item identifiers, policy references, and response bodies cannot cross the - adapter boundary. Each realm selects exactly one adapter instance; there is - no automatic fallback. -26. Human approval is resolved before manifest issuance by an external broker. +25. The concrete KeePass store returns only `SecretString` and the closed, + data-free `StoreError` taxonomy. Parser errors and database content never + enter responses or logs. There is no fallback credential source. +26. External broker approval is resolved before manifest issuance. Assertions bind the normalized request digest, active lease, resource, action, risk tier, policy generation, and TTL bound. The issuer consumes - each assertion and mints a new single-use manifest; Charon never performs an - online approval lookup. -27. Reusable approvals are structured, expiring, use-bounded, revocable, and + each assertion and mints a new single-use manifest. Charon never queries that + broker. Its optional local Telegram gate runs after local authorization, + before credential resolution, and cannot widen destination policy. +27. External broker reusable approvals are structured, expiring, use-bounded, revocable, and cannot contain wildcards or natural-language predicates. Critical and unknown operations cannot receive reusable approval. Telegram callbacks are opaque, random, single-use, expiring, and bound server-side to one pending - request and the immutable equal private actor/chat ID. + request and the immutable equal private actor/chat ID. The local gate also + binds the original message ID and creates no reusable rule. 28. Workload tool adapters submit only tool name, classification, argument-key names, identifiers, and a digest of canonical arguments for admission. They exclude raw arguments, commands, credentials, manifests, and provider @@ -162,7 +177,7 @@ capability without placing the underlying credential in that workload. closed. Charon semantic admission does not replace Hermes command approval, Charon proxy enforcement, workload-manifest issuance, or network isolation. 31. Capability references use canonical `{{charon.}}` syntax and - identify local policy, never a provider or secret. Charon accepts one only + identify local policy, never a database entry or secret. Charon accepts one only from the configured Authorization, named header, Basic, Git smart-HTTP, path, query, JSON, or form sink. Unknown, malformed, nested, concatenated, and misplaced references fail before credential resolution. @@ -193,10 +208,10 @@ capability without placing the underlying credential in that workload. through mandatory Infra network isolation. It is never a shared anonymous proxy and does not weaken the signed manifest listener. Every reused HTTP/1.1 request or HTTP/2 stream is authorized independently. -38. Exclusive forwarding grants perform no provider lookup. Caller-owned OAuth, +38. Exclusive forwarding grants perform no credential lookup. Caller-owned OAuth, bot tokens, cookies, and session headers pass only under declared route policy. Brokered grants require a typed header/Basic capability sink; - provider references are always local policy. All HTTPS terminates, with + credential references are always local policy. All HTTPS terminates, with CONNECT/SNI/HTTP authority agreement and verified upstream certificates. 39. Gateway DNS public-address authorization precedes credential resolution; the connector uses the same process pin. Concurrent DNS resolution cannot @@ -208,6 +223,8 @@ capability without placing the underlying credential in that workload. execution. Prestream denials finalize metadata receipts. TLS/authority failures emit only fixed log outcomes. The executable restricts gateway logging to Charon metadata even if RUST_LOG requests dependency traces. + The binary applies this restriction in every mode. Enrollment codes are + emitted separately only to the trusted operator console. 41. Exclusive gateway streams have configured upload/download, total-time and idle bounds. Identity-only compression, protocol upgrade denial, caller- followed independently authorized redirects, known-secret redaction and @@ -226,26 +243,35 @@ from an admitted Hermes tool. The separate tool admission service is unchanged. ## Current limitations - Signed CONNECT interception supports HTTP/2 and HTTP/1.1 with one authorized inner - request or stream per tunnel. The owner security review and disposable - secretless GitHub vertical proof are complete; production credentials remain - gated on the documented deployment cutover. -- The deployment policy still selects the disposable environment provider until - the operator provisions the pinned CLI, encrypted vault state, session credential, - and exact item mapping. The Vaultwarden provider itself is implemented and - tested through both a disposable CLI-compatible fixture and the live - disposable vertical proof. + request or stream per tunnel. Recorded security reviews apply to their + recorded revisions. The current KeePass/admin + changes have implementation verification in + `docs/security-verification-keepass-admin.md`; container builds, live Telegram + delivery and combined host/network isolation require deployment verification. +- The operator must provision a private KDBX file, exact UUID mappings, a + browser-trusted HTTPS certificate, and a stable admin DNS origin. Changing + origin requires a deliberate enrollment migration; passkeys are origin-bound. + Losing all passkeys requires console access and explicit enrollment-state reset. +- Unlock is manual after every process restart. Telegram starts only after + unlock, so it cannot notify the owner while booted locked. No bot token or + master password is read from the environment. Bot + compromise exposes approval metadata and permits decisions as the configured + Telegram user, but does not reveal the vault password or values. +- KeePass parsing and cryptography are trusted dependencies. Only mapped + protected values are retained; the parser also temporarily allocates other + database content. Encrypted input is bounded, but operator-supplied KDF and + decompression parameters can consume substantial resources. Deployment must + bound process memory and CPU and exclude untrusted database writers. - Production uses one process per persona; a shared multi-realm process requires a separate ADR and review meeting ADR 0003's criteria. The current in-memory replay cache is intentionally per-realm and single-instance; horizontal replicas require a shared atomic nonce store or issuer-side one-shot exchange. -- Charon has bounded requests, responses, handshakes, tunnels, provider - commands, and manifest lifetimes, but no application-level connection or - request-rate limiter. Deployments must bound connections and resources at the - listener/container/network layer. -- The configured Vaultwarden CLI and the system WebPKI/DNS path are trusted - dependencies. Runtime validates CLI path/type/permissions but does not - independently attest the executable digest; deployment must pin and verify - that artifact. +- Charon has bounded requests, responses, handshakes, tunnels, approval + waits, and manifest lifetimes. The admin listener limits origin-accepted + non-GET requests to 30 per minute, with at most eight passkeys, 32 pending + ceremonies and 32 sessions. Capacity is checked at ceremony start and finish. + Workload listeners have no connection/request-rate limiter; deployments must + bound connections and resources at the listener/container/network layer. - The optional approval broker, its durable rule/nonce store, signing key, Telegram bot account, allowlisted human accounts, and identity issuer checks extend the trusted control plane. Telegram outage or account-recovery @@ -262,3 +288,11 @@ from an admitted Hermes tool. The separate tool admission service is unchanged. - Signed/transparent structured request hydration buffers JSON and form bodies to the request limit. WebSockets, IPv6, UDP, and QUIC are denied rather than partially supported. + +- Workload/admin network isolation is enforced by Infra. A separate listener and + owner-IP host publication do not block workload access through a container + interface when admin listens on all interfaces. Infra must deny those paths. +- Local Telegram calls go directly to `api.telegram.org` over verified HTTPS; + they ignore upstream and ambient proxies. Permit only Charon to use that + egress. Signed/transparent local decisions wait at most two minutes; + exclusive decisions additionally count against the route operation deadline. diff --git a/examples/charon.dev.toml b/examples/charon.dev.toml index 78c1ab5..dce605e 100644 --- a/examples/charon.dev.toml +++ b/examples/charon.dev.toml @@ -8,8 +8,29 @@ tenant = "example" persona = "developer" generation = 1 -[provider] -kind = "environment" + +[keepass] +# Optional operator-only protected Password entry; never include in items. +# telegram_bot_token_entry_id = "00000000-0000-4000-8000-000000000003" +database = "/var/lib/charon/vault.kdbx" +# Optional: key_file = "/run/credentials/charon-key-file" + +[[keepass.items]] +secret_ref = "CHARON_DEMO_TOKEN" +persona = "developer" +entry_id = "00000000-0000-4000-8000-000000000001" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" + +# Optional Telegram starts after owner unlock; configure its KeePass entry above. +# [admin.telegram] +# user_id = 123456789 +# approval_capabilities = [] # Exact capability/route names; allow once per request. [identity] issuer = "example-control-plane" diff --git a/examples/charon.toml b/examples/charon.toml index 25f6f37..0a9fe76 100644 --- a/examples/charon.toml +++ b/examples/charon.toml @@ -11,21 +11,34 @@ generation = 1 # username = "egress" # password_file = "/run/credentials/egress-proxy-password" -[provider] -kind = "vaultwarden" -cli_path = "/usr/local/bin/bw" -appdata_dir = "/var/lib/charon/bitwarden-cli" -session_file = "/run/credentials/charon-vaultwarden-session" -cache_ttl_seconds = 30 [tls] ca_certificate = "/run/charon-ca/ca.pem" ca_private_key = "/run/credentials/charon-ca-key.pem" -[[provider.items]] + +[keepass] +# Optional operator-only protected Password entry; never include in items. +# telegram_bot_token_entry_id = "00000000-0000-4000-8000-000000000003" +database = "/var/lib/charon/vault.kdbx" +# Optional: key_file = "/run/credentials/charon-key-file" + +[[keepass.items]] secret_ref = "github/developer" persona = "developer" -item_id = "00000000-0000-4000-8000-000000000001" +entry_id = "00000000-0000-4000-8000-000000000001" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" + +# Optional Telegram starts after owner unlock; configure its KeePass entry above. +# [admin.telegram] +# user_id = 123456789 +# approval_capabilities = [] # Exact capability/route names; allow once per request. [identity] issuer = "example-control-plane" diff --git a/examples/hermes-gateway.toml b/examples/hermes-gateway.toml index ca4335a..663c485 100644 --- a/examples/hermes-gateway.toml +++ b/examples/hermes-gateway.toml @@ -14,8 +14,29 @@ generation = 1 ca_certificate = "/etc/charon/ca.pem" ca_private_key = "/run/charon/ca-key.pem" -[provider] -kind = "environment" + +[keepass] +# Optional operator-only protected Password entry; never include in items. +# telegram_bot_token_entry_id = "00000000-0000-4000-8000-000000000003" +database = "/var/lib/charon/vault.kdbx" +# Optional: key_file = "/run/credentials/charon-key-file" + +[[keepass.items]] +secret_ref = "CHARON_SYNTHETIC_GITHUB_TOKEN" +persona = "hermes" +entry_id = "00000000-0000-4000-8000-000000000001" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" + +# Optional Telegram starts after owner unlock; configure its KeePass entry above. +# [admin.telegram] +# user_id = 123456789 +# approval_capabilities = [] # Exact capability/route names; allow once per request. [receipts] journal_path = "/var/lib/charon/gateway-receipts.jsonl" diff --git a/examples/transparent-gateway.toml b/examples/transparent-gateway.toml index aa30328..2a89d7f 100644 --- a/examples/transparent-gateway.toml +++ b/examples/transparent-gateway.toml @@ -9,13 +9,34 @@ tenant = "example" persona = "example-agent" generation = 1 -[provider] -kind = "environment" [tls] ca_certificate = "/run/charon-ca/ca.pem" ca_private_key = "/run/credentials/charon-ca-key.pem" +[keepass] +# Optional operator-only protected Password entry; never include in items. +# telegram_bot_token_entry_id = "00000000-0000-4000-8000-000000000003" +database = "/var/lib/charon/vault.kdbx" +# Optional: key_file = "/run/credentials/charon-key-file" + +[[keepass.items]] +secret_ref = "CHARON_EXAMPLE_TOKEN" +persona = "example-agent" +entry_id = "00000000-0000-4000-8000-000000000001" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" + +# Optional Telegram starts after owner unlock; configure its KeePass entry above. +# [admin.telegram] +# user_id = 123456789 +# approval_capabilities = [] # Exact capability/route names; allow once per request. + [identity] issuer = "example-control-plane" audience = "charon" diff --git a/examples/vertical_fixture.rs b/examples/vertical_fixture.rs index 4aaf41a..47828aa 100644 --- a/examples/vertical_fixture.rs +++ b/examples/vertical_fixture.rs @@ -1,4 +1,4 @@ -//! Prepare non-secret CA/identity material and issue short-lived vertical manifests. +//! Prepare isolated fixture CA/identity material and short-lived vertical manifests. use std::{ fs::{self, OpenOptions}, @@ -33,7 +33,7 @@ fn main() -> Result<()> { issue(Path::new(&directory)) } _ => bail!( - "usage: cargo run --example vertical_fixture -- init | issue " + "usage: cargo run --example vertical_fixture -- init | issue " ), } } @@ -127,7 +127,7 @@ fn issue(directory: &Path) -> Result<()> { now.saturating_sub(120), now.saturating_sub(60), ), - ("provider-failure", now, now, now + 50), + ("store-locked", now, now, now + 50), ]; let manifest_directory = directory.join(MANIFEST_DIRECTORY); for (name, iat, nbf, exp) in manifests { @@ -216,17 +216,20 @@ url = "http://egress.example.internal:8888" username = "egress" password_file = "/run/credentials/egress-proxy-password" -[provider] -kind = "vaultwarden" -cli_path = "/usr/local/bin/bw" -appdata_dir = "/var/lib/charon/bitwarden-cli" -session_file = "/run/credentials/charon-vaultwarden-session" -cache_ttl_seconds = 5 +[keepass] +database = "/var/lib/charon/vault.kdbx" -[[provider.items]] +[[keepass.items]] secret_ref = "github/vertical-developer" persona = "vertical-developer" -item_id = "{item_id}" +entry_id = "{item_id}" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" [tls] ca_certificate = "/run/charon-ca/ca.pem" diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index b2fd2f8..d6bf7ae 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -109,7 +109,7 @@ agent: Run both containers as UID/GID `10000:10000`. Mount one dedicated socket volume at `/run/charon-hermes` in both containers. Mount the read-only policy and a private receipt-state volume only in the admission container. Do not share -Docker's socket, Charon manifests, Vaultwarden state, provider references, or +Docker's socket, Charon manifests, KeePass state, credential references, or credentials. ```text diff --git a/integrations/vertical/README.md b/integrations/vertical/README.md index 9ed12d8..aa3d26a 100644 --- a/integrations/vertical/README.md +++ b/integrations/vertical/README.md @@ -1,101 +1,67 @@ -# Secretless GitHub vertical fixture +# GitHub vertical fixture -This fixture runs an unmodified `gh api user` in a disposable workload that has -only `gh`, curl, a public Charon CA, public capability references, and fresh workload -manifests. It mounts no GitHub or Vaultwarden credential. Its only Docker network -is `internal`; Charon alone also joins the upstream network and is configured to -chain allowed requests through an operator-configured egress proxy. The example -uses the reserved name `egress.example.internal:8888`. +This operator-run fixture sends an unmodified `gh api user` through Charon. The +workload receives only public CA trust, capability references and fresh signed +manifests. It mounts no credential database, master password, admin state or +private key. Its Docker network is internal; Charon alone joins the upstream +network and chains through an operator-configured egress proxy. -## Inputs - -Prepare a disposable GitHub credential with read-only access sufficient for -`GET /user` and store it as the password of a dedicated Vaultwarden login item. -Do not put the value in this directory, Compose, an image, or an environment -variable. Initialize a fresh ignored runtime directory with the exact item UUID: +Prepare a disposable read-only GitHub credential sufficient for `GET /user` in +a dedicated protected KeePass Password field. Keep its private KDBX outside this +repository. Initialize a fresh operator-owned directory with the entry UUID: ```sh -cargo run --locked --example vertical_fixture -- \ - init .vertical-runtime +cargo run --locked --example vertical_fixture -- init /protected/vertical-runtime ``` -This creates a fresh offline root, a root-signed persona intermediate, -workload-identity key, reviewed config, and manifest directory without printing -private material. The root private key remains in the operator-only fixture -directory and is never mounted into Charon or the workload. Initialization -refuses to replace an existing directory. Remove the directory after the proof. - -Download the pinned Linux-native CLI matching the immutable image's `linux/amd64` -platform. The helper verifies the exact SHA-256 digest published in Bitwarden's -GitHub release metadata and refuses to replace an existing file: - -```sh -integrations/vertical/prepare-linux-bw.sh \ - "$PWD/.vertical-runtime/bin/bw" -``` +Initialization creates an offline root, persona intermediate, identity issuer +key and config without printing private keys. The offline root key and issuer +key remain outside containers. Review the generated config, including egress +address and admin origin. Supply a browser-trusted certificate and independent +private TLS key matching that stable origin. No KDBX master password is written +to disk or passed as an environment variable. -Provide absolute host paths and an immutable Charon image: +Set absolute paths and an immutable Charon image: ```sh export CHARON_IMAGE=ghcr.io//:sha-<40-character-main-commit> -export CHARON_FIXTURE_DIRECTORY="$PWD/.vertical-runtime" -export CHARON_BW_CLI_PATH="$PWD/.vertical-runtime/bin/bw" -export CHARON_BW_APPDATA_PATH=/protected/bitwarden-cli -export CHARON_BW_SESSION_PATH=/run/user//charon-vaultwarden-session -export CHARON_EGRESS_PROXY_PASSWORD_PATH=/run/user//charon-egress-password -``` - -The harness creates a private receipt-state directory inside the disposable -fixture directory and mounts it only into Charon. Receipts contain metadata, -not request/response bodies or credential values. - -The native `bw` binary is pinned to 2026.6.0 and checksum-verified. The -appdata directory contains the encrypted vault and exact Vaultwarden server -selection. The session should be memory-backed. The appdata, session, generated -config, and CA/issuer private keys are owned by the invoking operator UID; -Charon runs as that UID so protected mode-`0400` mounts remain readable without -broadening host permissions. None of their parent directories is mounted into -the workload. - -If the configured egress proxy requires authentication, provision a dedicated, -least-privilege password through the operator's secret-delivery process. Write -only the password value to `CHARON_EGRESS_PROXY_PASSWORD_PATH` with mode `0400`. -Charon reads it into a secret-holding value and configures proxy Basic auth; it -is not embedded in a URL, config, environment variable, command argument, or -log. - -The workload trusts only `root-ca.pem`; Charon serves its persona intermediate -with each leaf. The harness builds the workload first, then issues five value-only manifest -files named `allowed`, `forbidden-host`, `forbidden-operation`, `expired`, and -`provider-failure` immediately before startup. They bind persona -`vertical-developer` and capability `github-read-user`, use unique random -nonces, target the integration issuer/audience, and expire within 50 seconds. -The issuer key remains outside Charon and every container. - -Run immediately after issuance: - -```sh +export CHARON_FIXTURE_DIRECTORY=/protected/vertical-runtime +export CHARON_KDBX_PATH=/protected/disposable.kdbx +export CHARON_ADMIN_CERTIFICATE_PATH=/protected/admin/fullchain.pem +export CHARON_ADMIN_PRIVATE_KEY_PATH=/protected/admin/privkey.pem +export CHARON_ADMIN_BIND_IP= +export CHARON_EGRESS_PROXY_PASSWORD_PATH=/protected/egress-password integrations/vertical/run.sh ``` -## Assertions and evidence - -The workload checks one allow and five denies: forbidden host, forbidden -operation, expired identity, locked provider, and direct bypass. The second -Charon instance intentionally lacks the session mount for the locked-provider -case. Both Charon instances have upstream access; the workload network itself -has no external route. - -The harness preserves `cases.jsonl`, the stopped workload's Docker inspect, and -Charon's redacted logs under `integrations/vertical/evidence` (or -`CHARON_EVIDENCE_DIRECTORY`). It fails if inspect/log output contains known -GitHub token prefixes, provider/CA-private inputs, a manifest, the public -capability reference, or the fixture sentinel. It also checks that exactly one allow and -five denies were recorded. Review the Charon events to correlate realm, tenant, -persona, workspace, lease, workload, operation, generation, capability, -service, method, path, status, and outcome without bearer material. - -The fixture must run only on an operator-controlled host with Docker and access -to the configured secret store and egress proxy, never on pull requests from -untrusted code. The owner review in -`docs/security-review-connect-interception.md` is complete. +Provision all private inputs under the invoking operator UID with mode 0400 or +0600. Charon runs as that UID. The script creates separate private receipt and +public-registration directories for the primary and locked instances. The +workload receives none of those directories. Egress credentials are optional in +Charon generally; this specific fixture config uses protected Basic-auth egress. + +The script starts both instances locked, prints only the primary enrollment +console line, and waits while you enroll a passkey and unlock through HTTPS. +It checks primary readiness and verifies the second instance is unready, +then issues fresh manifests, and immediately runs the +workload. The second instance remains locked and publishes no owner port. Its +public registration and receipt files are isolated from the primary instance. + +The assertions cover one allow and five denials: unknown destination, forbidden +operation, expired identity, locked store and direct bypass. Manifests bind the +`vertical-developer` persona and `github-read-user` capability and expire within +50 seconds. The workload trusts only the generated root certificate. + +Evidence includes outcome JSONL, container/image inspection and redacted Charon +logs. Enrollment-code lines are removed before writing evidence. Prefix and +manifest scans reject credential-like artifacts. Docker console logs themselves +are private operator material; do not export them into shared logging. The +script removes its containers on exit and leaves operator-owned fixture inputs +and evidence for review. Both containers listen on admin port 8444 internally; +Infra must deny workload-to-admin traffic on that port. Host publication alone +does not establish this isolation. Remove disposable credentials and keys under your own +retention policy after the proof. + +Run only on an operator-controlled Docker host with approved upstream access. +The Rust suite independently exercises synthetic encrypted KDBX, passkeys, +authorization, TLS and callback rejection without requiring a live bot or token. diff --git a/integrations/vertical/charon.example.toml b/integrations/vertical/charon.example.toml index d1828d6..4a9d85f 100644 --- a/integrations/vertical/charon.example.toml +++ b/integrations/vertical/charon.example.toml @@ -10,18 +10,21 @@ url = "http://egress.example.internal:8888" username = "egress" password_file = "/run/credentials/egress-proxy-password" -[provider] -kind = "vaultwarden" -cli_path = "/usr/local/bin/bw" -appdata_dir = "/var/lib/charon/bitwarden-cli" -session_file = "/run/credentials/charon-vaultwarden-session" -cache_ttl_seconds = 5 +[keepass] +database = "/var/lib/charon/vault.kdbx" -[[provider.items]] +[[keepass.items]] secret_ref = "github/vertical-developer" persona = "vertical-developer" -# Replace with the exact disposable Vaultwarden item UUID. -item_id = "00000000-0000-4000-8000-000000000001" +# Replace with the exact disposable KeePass entry UUID. +entry_id = "00000000-0000-4000-8000-000000000001" + +[admin] +listen = "0.0.0.0:8444" +origin = "https://charon.example.test:8444" +certificate = "/etc/charon/admin/fullchain.pem" +private_key = "/run/credentials/charon-admin-key.pem" +passkeys_file = "/var/lib/charon/admin/passkeys.json" [tls] ca_certificate = "/run/charon-ca/ca.pem" diff --git a/integrations/vertical/compose.yaml b/integrations/vertical/compose.yaml index 94cda9e..c8cad3a 100644 --- a/integrations/vertical/compose.yaml +++ b/integrations/vertical/compose.yaml @@ -12,12 +12,14 @@ x-charon: &charon - upstream volumes: - "${CHARON_CONFIG_PATH:?set the reviewed vertical config}:/etc/charon/charon.toml:ro" - - "${CHARON_BW_CLI_PATH:?set the pinned native bw executable}:/usr/local/bin/bw:ro" - - "${CHARON_BW_APPDATA_PATH:?set encrypted CLI state}:/var/lib/charon/bitwarden-cli" - "${CHARON_CA_CERTIFICATE_PATH:?set public CA path}:/run/charon-ca/ca.pem:ro" - "${CHARON_CA_PRIVATE_KEY_PATH:?set private CA key path}:/run/credentials/charon-ca-key.pem:ro" - "${CHARON_EGRESS_PROXY_PASSWORD_PATH:?set protected Squid password path}:/run/credentials/egress-proxy-password:ro" - "${CHARON_RECEIPT_STATE_PATH:?set protected receipt state directory}:/var/lib/charon/receipts" + - "${CHARON_KDBX_PATH:?set private encrypted KDBX}:/var/lib/charon/vault.kdbx:ro" + - "${CHARON_ADMIN_CERTIFICATE_PATH:?set browser-trusted admin certificate}:/etc/charon/admin/fullchain.pem:ro" + - "${CHARON_ADMIN_PRIVATE_KEY_PATH:?set private admin TLS key}:/run/credentials/charon-admin-key.pem:ro" + - "${CHARON_ADMIN_STATE_PATH:?set private admin state}:/var/lib/charon/admin" command: ["--config", "/etc/charon/charon.toml"] healthcheck: test: ["CMD", "/usr/local/bin/charon", "healthcheck", "http://127.0.0.1:3129/healthz"] @@ -28,19 +30,22 @@ x-charon: &charon services: charon: + <<: *charon + ports: + - "${CHARON_ADMIN_BIND_IP:?set the owner-only HTTPS address}:8444:8444" + + charon-locked: <<: *charon volumes: - "${CHARON_CONFIG_PATH:?set the reviewed vertical config}:/etc/charon/charon.toml:ro" - - "${CHARON_BW_CLI_PATH:?set the pinned native bw executable}:/usr/local/bin/bw:ro" - - "${CHARON_BW_APPDATA_PATH:?set encrypted CLI state}:/var/lib/charon/bitwarden-cli" - - "${CHARON_BW_SESSION_PATH:?set the memory-backed unlock session}:/run/credentials/charon-vaultwarden-session:ro" + - "${CHARON_KDBX_PATH:?set private encrypted KDBX}:/var/lib/charon/vault.kdbx:ro" + - "${CHARON_ADMIN_CERTIFICATE_PATH:?set browser-trusted admin certificate}:/etc/charon/admin/fullchain.pem:ro" + - "${CHARON_ADMIN_PRIVATE_KEY_PATH:?set private admin TLS key}:/run/credentials/charon-admin-key.pem:ro" + - "${CHARON_LOCKED_ADMIN_STATE_PATH:?set private locked-instance admin state}:/var/lib/charon/admin" - "${CHARON_CA_CERTIFICATE_PATH:?set public CA path}:/run/charon-ca/ca.pem:ro" - "${CHARON_CA_PRIVATE_KEY_PATH:?set private CA key path}:/run/credentials/charon-ca-key.pem:ro" - "${CHARON_EGRESS_PROXY_PASSWORD_PATH:?set protected Squid password path}:/run/credentials/egress-proxy-password:ro" - - "${CHARON_RECEIPT_STATE_PATH:?set protected receipt state directory}:/var/lib/charon/receipts" - - charon-locked: - <<: *charon + - "${CHARON_LOCKED_RECEIPT_STATE_PATH:?set isolated locked receipt state}:/var/lib/charon/receipts" workload: build: diff --git a/integrations/vertical/prepare-linux-bw.sh b/integrations/vertical/prepare-linux-bw.sh deleted file mode 100755 index 325a2ba..0000000 --- a/integrations/vertical/prepare-linux-bw.sh +++ /dev/null @@ -1,37 +0,0 @@ -#!/bin/sh -set -eu - -version=2026.6.0 -destination=${1:-} - -if [ -z "$destination" ]; then - printf 'usage: %s \n' "$0" >&2 - exit 2 -fi - -asset="bw-linux-$version.zip" -digest=392549496c712ab86bfbd6c27302df9fd2c431cfc7a47e26941ac3e3893f4d27 - -if [ -e "$destination" ]; then - printf 'refusing to replace existing path: %s\n' "$destination" >&2 - exit 1 -fi - -temporary=$(mktemp -d "${TMPDIR:-/tmp}/charon-bw.XXXXXX") -cleanup() { - rm -rf "$temporary" -} -trap cleanup EXIT INT TERM - -url="https://github.com/bitwarden/clients/releases/download/cli-v$version/$asset" -curl --fail --location --silent --show-error "$url" --output "$temporary/$asset" -actual=$(shasum -a 256 "$temporary/$asset" | awk '{print $1}') -if [ "$actual" != "$digest" ]; then - printf 'Bitwarden CLI checksum mismatch\n' >&2 - exit 1 -fi - -mkdir -p "$(dirname "$destination")" -unzip -p "$temporary/$asset" bw >"$destination" -chmod 0555 "$destination" -printf 'installed checksum-verified Bitwarden CLI %s for Linux amd64\n' "$version" diff --git a/integrations/vertical/run-in-workload.sh b/integrations/vertical/run-in-workload.sh index 956750d..8c8a181 100755 --- a/integrations/vertical/run-in-workload.sh +++ b/integrations/vertical/run-in-workload.sh @@ -53,13 +53,13 @@ else fi if GH_TOKEN='{{charon.github-read-user}}' \ - HTTPS_PROXY="$(proxy provider-failure charon-locked)" \ + HTTPS_PROXY="$(proxy store-locked charon-locked)" \ SSL_CERT_FILE=/etc/charon-ca/ca.pem \ - gh api user >/tmp/provider.out 2>/tmp/provider.err; then - result provider-failure unexpected-allow + gh api user >/tmp/store.out 2>/tmp/store.err; then + result store-locked unexpected-allow exit 1 else - result provider-failure denied + result store-locked denied fi if HTTPS_PROXY='' HTTP_PROXY='' ALL_PROXY='' NO_PROXY='*' \ diff --git a/integrations/vertical/run.sh b/integrations/vertical/run.sh index fc545e0..39d3937 100755 --- a/integrations/vertical/run.sh +++ b/integrations/vertical/run.sh @@ -16,8 +16,8 @@ cleanup() { trap cleanup EXIT INT TERM for name in \ - CHARON_IMAGE CHARON_FIXTURE_DIRECTORY CHARON_BW_CLI_PATH \ - CHARON_BW_APPDATA_PATH CHARON_BW_SESSION_PATH \ + CHARON_IMAGE CHARON_FIXTURE_DIRECTORY CHARON_KDBX_PATH \ + CHARON_ADMIN_CERTIFICATE_PATH CHARON_ADMIN_PRIVATE_KEY_PATH CHARON_ADMIN_BIND_IP \ CHARON_EGRESS_PROXY_PASSWORD_PATH; do eval "value=\${$name:-}" if [ -z "$value" ]; then @@ -31,16 +31,31 @@ export CHARON_CA_CERTIFICATE_PATH="$CHARON_FIXTURE_DIRECTORY/ca.pem" export CHARON_CA_PRIVATE_KEY_PATH="$CHARON_FIXTURE_DIRECTORY/ca-key.pem" export CHARON_ROOT_CA_CERTIFICATE_PATH="$CHARON_FIXTURE_DIRECTORY/root-ca.pem" export CHARON_RECEIPT_STATE_PATH="$CHARON_FIXTURE_DIRECTORY/receipts" -mkdir -p "$CHARON_RECEIPT_STATE_PATH" -chmod 700 "$CHARON_RECEIPT_STATE_PATH" +export CHARON_LOCKED_RECEIPT_STATE_PATH="$CHARON_FIXTURE_DIRECTORY/locked-receipts" +export CHARON_ADMIN_STATE_PATH="$CHARON_FIXTURE_DIRECTORY/admin-state" +export CHARON_LOCKED_ADMIN_STATE_PATH="$CHARON_FIXTURE_DIRECTORY/locked-admin-state" +for path in "$CHARON_RECEIPT_STATE_PATH" "$CHARON_LOCKED_RECEIPT_STATE_PATH" "$CHARON_ADMIN_STATE_PATH" "$CHARON_LOCKED_ADMIN_STATE_PATH"; do + mkdir -p "$path" + chmod 700 "$path" +done export CHARON_MANIFEST_DIRECTORY="$CHARON_FIXTURE_DIRECTORY/manifests" export CHARON_RUNTIME_UID="${CHARON_RUNTIME_UID:-$(id -u)}" compose build workload -cargo run --locked --quiet --example vertical_fixture -- issue "$CHARON_FIXTURE_DIRECTORY" mkdir -p "$evidence_dir" compose up --detach --no-build --wait --wait-timeout 30 charon charon-locked +# Enrollment output is operator-console-only, never retained with evidence. +compose logs --no-color charon | sed -n '/Charon enrollment code (expires in five minutes): /p' >&2 +printf 'Enroll and unlock at the configured HTTPS admin origin, then press Enter. +' >&2 +read -r _ +compose exec -T charon /usr/local/bin/charon healthcheck http://127.0.0.1:3129/readyz +if compose exec -T charon-locked /usr/local/bin/charon healthcheck http://127.0.0.1:3129/readyz >/dev/null 2>&1; then + printf 'locked fixture unexpectedly ready\n' >&2 + exit 1 +fi +cargo run --locked --quiet --example vertical_fixture -- issue "$CHARON_FIXTURE_DIRECTORY" compose run --name "$workload_name" workload >"$evidence_dir/cases.jsonl" docker inspect "$workload_name" >"$evidence_dir/workload-inspect.json" @@ -48,10 +63,10 @@ docker inspect "$(compose ps --quiet charon)" "$(compose ps --quiet charon-locke >"$evidence_dir/charon-inspect.json" docker history --no-trunc "$(compose images --quiet workload)" \ >"$evidence_dir/workload-image-history.txt" -compose logs --no-color charon charon-locked >"$evidence_dir/charon.log" +compose logs --no-color charon charon-locked | sed '/Charon enrollment code (expires in five minutes): /d' >"$evidence_dir/charon.log" if grep -Eiq \ - 'github_pat_|ghp_|gho_|ghu_|ghs_|ghr_|CHARON_(BW|CA_PRIVATE)|VAULTWARDEN_SESSION|offline-root-key|BEGIN [A-Z ]*PRIVATE KEY' \ + 'github_pat_|ghp_|gho_|ghu_|ghs_|ghr_|CHARON_(KDBX|ADMIN_PRIVATE|CA_PRIVATE)|offline-root-key|BEGIN [A-Z ]*PRIVATE KEY' \ "$evidence_dir/workload-inspect.json"; then printf 'credential-like material found in workload inspect output\n' >&2 exit 1 @@ -77,6 +92,5 @@ test "$(grep -c '"outcome":"denied"' "$evidence_dir/cases.jsonl")" = 5 grep -q '"outcome":"forwarded"' "$evidence_dir/charon.log" grep -q '"outcome":"identity_denied"' "$evidence_dir/charon.log" grep -q '"outcome":"tunneled_request_denied"' "$evidence_dir/charon.log" -grep -q '"error":"Vaultwarden provider is locked"' "$evidence_dir/charon.log" printf 'vertical evidence: %s\n' "$evidence_dir" diff --git a/mise.toml b/mise.toml index d62a904..4250f99 100644 --- a/mise.toml +++ b/mise.toml @@ -46,3 +46,15 @@ run = "cargo deny check" [tasks.dev] description = "Run Charon with the local development policy" run = "cargo run -- --config examples/charon.dev.toml" + +[tasks.browser-admin-check] +description = "Prove the HTTPS owner UI with real Chrome and a synthetic virtual passkey (Chrome and Node 22+ required)" +run = "cargo test --lib admin::tests::chrome_passkey_ui -- --ignored --nocapture" + +[tasks.e2e-flow] +description = "Run connected offline two-password KeePass/admin/Telegram/proxy/API E2E (curl required)" +run = "cargo test --lib gateway::e2e::two_passwords_approval_and_restart -- --ignored --nocapture" + +[tasks.e2e] +description = "Run connected credential-flow and real Chrome owner-UI E2E" +depends = ["e2e-flow", "browser-admin-check"] diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..6d721a0 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,1343 @@ +//! HTTPS-only single-owner passkey enrollment, authentication, and vault unlock. +use crate::keepass::{KeePassStore, ensure_private_file}; +use anyhow::{Result, ensure}; +use axum::{ + Json, Router, + body::to_bytes, + extract::{Request, State}, + http::{HeaderMap, StatusCode, header}, + middleware::{self, Next}, + response::{Html, IntoResponse, Response}, + routing::{get, post}, +}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use secrecy::{ExposeSecret as _, SecretString}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sha2::{Digest as _, Sha256}; +use std::{ + collections::HashMap, + fs::{self, OpenOptions}, + io::Write as _, + net::SocketAddr, + path::PathBuf, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use webauthn_rs::prelude::*; +use zeroize::Zeroize as _; + +const TTL: Duration = Duration::from_secs(300); +const SESSION_TTL: Duration = Duration::from_secs(600); +const COOKIE: &str = "__Host-charon-admin"; + +/// Independent HTTPS admin listener; never reuse the interception CA key. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AdminConfig { + /// HTTPS listener, separate from workload listeners. + pub listen: SocketAddr, + /// Exact stable HTTPS origin, including any nonstandard port. + pub origin: String, + /// Browser-trusted server certificate chain in PEM format. + pub certificate: PathBuf, + /// Private HTTPS server key, accessible only to Charon. + pub private_key: PathBuf, + /// Private file containing only public passkey registration data. + pub passkeys_file: PathBuf, + /// Optional private Telegram notifications and per-request approval prompts. + pub telegram: Option, +} +impl AdminConfig { + /// Validate a strict HTTPS origin and absolute storage paths. + /// + /// # Errors + /// Rejects plaintext, user information, URL paths, or missing DNS origin. + pub fn validate(&self) -> Result<()> { + let origin = Url::parse(&self.origin)?; + ensure!( + origin.scheme() == "https" + && origin.username().is_empty() + && origin.password().is_none() + && origin.query().is_none() + && origin.fragment().is_none() + && origin.path() == "/" + && origin.host_str().is_some_and(crate::config::is_exact_host) + && origin + .host_str() + .is_some_and(|h| h.parse::().is_err()), + "admin requires an exact HTTPS DNS origin" + ); + ensure!( + self.origin == origin.origin().ascii_serialization(), + "admin origin must be canonical without a trailing slash" + ); + ensure!( + self.certificate.is_absolute() + && self.private_key.is_absolute() + && self.passkeys_file.is_absolute(), + "admin paths must be absolute" + ); + if let Some(telegram) = &self.telegram { + telegram.validate()?; + } + Ok(()) + } +} +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Registrations { + origin: String, + owner: Uuid, + keys: Vec, +} +enum Ceremony { + Registration(PasskeyRegistration, Option<[u8; 32]>), + Authentication(PasskeyAuthentication), +} +struct Pending { + deadline: Instant, + ceremony: Ceremony, +} +struct Runtime { + registrations: Registrations, + enrollment: Option<([u8; 32], Instant)>, + pending: HashMap, + sessions: HashMap<[u8; 32], Instant>, + rate_started: Instant, + rate_count: u32, +} +/// Trusted admin state holds `WebAuthn` challenges server-side, never in browser tokens. +pub struct Admin { + config: AdminConfig, + webauthn: Webauthn, + runtime: Mutex, + store: Arc, + telegram: Option>, +} +impl Admin { + /// Load public registrations or create one console-only, expiring setup code. + /// + /// # Errors + /// Rejects invalid public state, insecure permissions, and mismatched origins. + pub fn new( + config: AdminConfig, + store: Arc, + ) -> Result<(Arc, Option)> { + config.validate()?; + let telegram = config + .telegram + .as_ref() + .map(|t| crate::telegram::Telegram::new(t, &config.origin, store.clone())) + .transpose()?; + Self::load(config, store, telegram) + } + #[cfg(test)] + pub(crate) fn new_for_e2e( + config: AdminConfig, + store: Arc, + telegram: Arc, + ) -> Result<(Arc, Option)> { + config.validate()?; + Self::load(config, store, Some(telegram)) + } + fn load( + config: AdminConfig, + store: Arc, + telegram: Option>, + ) -> Result<(Arc, Option)> { + let origin = Url::parse(&config.origin)?; + let webauthn = WebauthnBuilder::new( + origin + .host_str() + .ok_or_else(|| anyhow::anyhow!("admin origin has no host"))?, + &origin, + )? + .rp_name("Charon") + .timeout(TTL) + .build()?; + let parent = config + .passkeys_file + .parent() + .ok_or_else(|| anyhow::anyhow!("passkey directory missing"))?; + let metadata = fs::symlink_metadata(parent)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + ensure!( + metadata.permissions().mode().trailing_zeros() >= 6, + "passkey directory must be private" + ); + } + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "passkey directory must be a real directory" + ); + let registrations = match fs::symlink_metadata(&config.passkeys_file) { + Ok(_) => { + ensure_private_file(&config.passkeys_file, "passkey state")?; + let bytes = fs::read(&config.passkeys_file)?; + ensure!(bytes.len() <= 65536, "passkey state too large"); + let value: Registrations = serde_json::from_slice(&bytes)?; + ensure!( + value.origin == config.origin + && !value.keys.is_empty() + && value.keys.len() <= 8, + "invalid passkey state" + ); + value + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Registrations { + origin: config.origin.clone(), + owner: Uuid::new_v4(), + keys: vec![], + }, + Err(error) => return Err(error.into()), + }; + let code = if registrations.keys.is_empty() { + Some(SecretString::from(random_token()?)) + } else { + None + }; + let enrollment = code + .as_ref() + .map(|c| (digest(c.expose_secret()), Instant::now() + TTL)); + Ok(( + Arc::new(Self { + config, + webauthn, + store, + telegram, + runtime: Mutex::new(Runtime { + registrations, + enrollment, + pending: HashMap::new(), + sessions: HashMap::new(), + rate_started: Instant::now(), + rate_count: 0, + }), + }), + code, + )) + } + /// Admin-only routes. Serve this router directly over TLS, separately from the proxy. + pub fn router(self: &Arc) -> Router { + Router::new() + .route( + "/", + get(|| async { Html(include_str!("admin/index.html")) }), + ) + .route( + "/admin.js", + get(|| async { + ( + [(header::CONTENT_TYPE, "text/javascript")], + include_str!("admin/admin.js"), + ) + }), + ) + .route( + "/admin.css", + get(|| async { + ( + [(header::CONTENT_TYPE, "text/css")], + include_str!("admin/admin.css"), + ) + }), + ) + .route("/api/status", get(status)) + .route("/api/register/start", post(register_start)) + .route("/api/register/finish", post(register_finish)) + .route("/api/login/start", post(login_start)) + .route("/api/login/finish", post(login_finish)) + .route("/api/unlock", post(unlock)) + .route("/api/lock", post(lock)) + .route("/api/logout", post(logout)) + .layer(axum::extract::DefaultBodyLimit::max(16384)) + .layer(middleware::from_fn_with_state(self.clone(), protect)) + .with_state(self.clone()) + } + /// Run the independent TLS listener with graceful shutdown. + /// + /// # Errors + /// Rejects invalid TLS inputs and bind/server failures. + pub async fn serve(self: Arc, handle: axum_server::Handle) -> Result<()> { + ensure_private_file(&self.config.private_key, "admin TLS key")?; + let tls = axum_server::tls_rustls::RustlsConfig::from_pem_file( + &self.config.certificate, + &self.config.private_key, + ) + .await?; + let server = axum_server::bind_rustls(self.config.listen, tls) + .handle(handle) + .serve(self.router().into_make_service()); + if let Some(telegram) = &self.telegram { + tokio::select! { result = server => result?, result = telegram.clone().run() => {result?; anyhow::bail!("Telegram channel stopped");} } + } else { + server.await?; + } + Ok(()) + } + /// Shared local decision coordinator; no credential-store authority. + pub fn approvals(&self) -> Option> { + self.telegram.clone() + } + fn session(&self, headers: &HeaderMap) -> Option<[u8; 32]> { + let cookie = headers.get(header::COOKIE)?.to_str().ok()?; + let mut tokens = cookie + .split(';') + .filter_map(|v| v.trim().strip_prefix(&format!("{COOKIE}="))); + let value = tokens.next()?; + if tokens.next().is_some() { + return None; + } + let hash = digest(value); + let runtime = self.runtime.lock().ok()?; + runtime + .sessions + .get(&hash) + .filter(|expiry| **expiry > Instant::now()) + .map(|_| hash) + } + fn persist(&self, registrations: &Registrations) -> Result<()> { + let path = &self.config.passkeys_file; + let temporary = path.with_extension(format!("{}.new", random_token()?)); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + let result = (|| -> Result<()> { + let mut file = options.open(&temporary)?; + file.write_all(&serde_json::to_vec(registrations)?)?; + file.sync_all()?; + fs::rename(&temporary, path)?; + fs::File::open( + path.parent() + .ok_or_else(|| anyhow::anyhow!("missing state directory"))?, + )? + .sync_all()?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&temporary); + } + result + } +} +fn digest(value: &str) -> [u8; 32] { + Sha256::digest(value.as_bytes()).into() +} +fn random_token() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|_| anyhow::anyhow!("randomness unavailable"))?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} +fn denied() -> Response { + (StatusCode::FORBIDDEN, "Request denied").into_response() +} +fn failed() -> Response { + (StatusCode::SERVICE_UNAVAILABLE, "Operation unavailable").into_response() +} +fn cookie(token: &str) -> String { + format!("{COOKIE}={token}; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=600") +} +fn secret_string<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result { + String::deserialize(deserializer).map(SecretString::from) +} +async fn body(request: Request) -> Result { + ensure!( + request + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v == "application/json"), + "JSON required" + ); + let bytes = to_bytes(request.into_body(), 16384).await?; + // Network frames can share Hyper's read buffer; do not reject valid requests. + // Our owned parsing copy is zeroized, independently of transport buffers. + let mut owned = zeroize::Zeroizing::new(bytes.to_vec()); + drop(bytes); + let result = serde_json::from_slice(&owned); + owned.zeroize(); + Ok(result?) +} +async fn protect(State(admin): State>, request: Request, next: Next) -> Response { + if request.method() != http::Method::GET { + if request + .headers() + .get(header::ORIGIN) + .and_then(|v| v.to_str().ok()) + != Some(admin.config.origin.as_str()) + { + return secure_response(denied()); + } + let Ok(mut runtime) = admin.runtime.lock() else { + return secure_response(failed()); + }; + if runtime.rate_started.elapsed() >= Duration::from_secs(60) { + runtime.rate_started = Instant::now(); + runtime.rate_count = 0; + } + runtime.rate_count += 1; + if runtime.rate_count > 30 { + return secure_response( + (StatusCode::TOO_MANY_REQUESTS, "Try again later").into_response(), + ); + } + } + secure_response(next.run(request).await) +} +fn secure_response(mut response: Response) -> Response { + let headers = response.headers_mut(); + for (name, value) in [ + ("cache-control", "no-store"), + ( + "content-security-policy", + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + ), + ("x-content-type-options", "nosniff"), + ("referrer-policy", "no-referrer"), + ("strict-transport-security", "max-age=31536000"), + ( + "permissions-policy", + "publickey-credentials-get=(self), publickey-credentials-create=(self)", + ), + ] { + headers.insert( + http::HeaderName::from_static(name), + http::HeaderValue::from_static(value), + ); + } + response +} +async fn status(State(admin): State>, headers: HeaderMap) -> Response { + let enrolled = match admin.runtime.lock() { + Ok(r) => !r.registrations.keys.is_empty(), + Err(_) => return failed(), + }; + let authenticated = admin.session(&headers).is_some(); + Json(serde_json::json!({"enrolled":enrolled,"authenticated":authenticated,"unlocked": authenticated && admin.store.health().is_ok()})).into_response() +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Setup { + #[serde(deserialize_with = "secret_string")] + code: SecretString, +} +async fn register_start(State(admin): State>, request: Request) -> Response { + let session = admin.session(request.headers()); + let Ok(setup) = body::(request).await else { + return denied(); + }; + let Ok(mut r) = admin.runtime.lock() else { + return failed(); + }; + r.pending.retain(|_, p| p.deadline > Instant::now()); + let initial = r.registrations.keys.is_empty(); + if initial { + let Some((expected, deadline)) = r.enrollment else { + return denied(); + }; + let actual = digest(setup.code.expose_secret()); + if deadline <= Instant::now() + || expected + .iter() + .zip(actual) + .fold(0u8, |a, (b, c)| a | (b ^ c)) + != 0 + { + return denied(); + } + if r.pending + .values() + .any(|p| matches!(p.ceremony, Ceremony::Registration(_, None))) + { + return denied(); + } + } else if session.is_none() || r.registrations.keys.len() >= 8 { + return denied(); + } + if r.pending.len() >= 32 { + return failed(); + } + let excluded = Some( + r.registrations + .keys + .iter() + .map(|k| k.cred_id().clone()) + .collect(), + ); + let Ok((challenge, state)) = admin.webauthn.start_passkey_registration( + r.registrations.owner, + "owner", + "Charon owner", + excluded, + ) else { + return failed(); + }; + let Ok(id) = random_token() else { + return failed(); + }; + r.pending.insert( + id.clone(), + Pending { + deadline: Instant::now() + TTL, + ceremony: Ceremony::Registration(state, session), + }, + ); + Json(serde_json::json!({"id":id,"options":challenge})).into_response() +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Registration { + id: String, + credential: RegisterPublicKeyCredential, +} +async fn register_finish(State(admin): State>, request: Request) -> Response { + let session = admin.session(request.headers()); + let Ok(input) = body::(request).await else { + return denied(); + }; + let Ok(mut r) = admin.runtime.lock() else { + return failed(); + }; + let Some(pending) = r.pending.remove(&input.id) else { + return denied(); + }; + let Ceremony::Registration(state, bound_session) = pending.ceremony else { + return denied(); + }; + if pending.deadline <= Instant::now() + || r.registrations.keys.len() >= 8 + || bound_session != session + || (bound_session.is_none() && !r.registrations.keys.is_empty()) + { + return denied(); + } + if bound_session.is_none() + && r.enrollment + .is_none_or(|(_, deadline)| deadline <= Instant::now()) + { + return denied(); + } + let Ok(key) = admin + .webauthn + .finish_passkey_registration(&input.credential, &state) + else { + return denied(); + }; + let mut updated = Registrations { + origin: r.registrations.origin.clone(), + owner: r.registrations.owner, + keys: r.registrations.keys.clone(), + }; + if updated.keys.iter().any(|k| k.cred_id() == key.cred_id()) { + return denied(); + } + updated.keys.push(key); + if admin.persist(&updated).is_err() { + return failed(); + } + r.registrations = updated; + r.enrollment = None; + StatusCode::NO_CONTENT.into_response() +} +async fn login_start(State(admin): State>) -> Response { + let Ok(mut r) = admin.runtime.lock() else { + return failed(); + }; + r.pending.retain(|_, p| p.deadline > Instant::now()); + r.sessions.retain(|_, e| *e > Instant::now()); + if r.registrations.keys.is_empty() || r.pending.len() >= 32 || r.sessions.len() >= 32 { + return denied(); + } + let Ok((challenge, state)) = admin + .webauthn + .start_passkey_authentication(&r.registrations.keys) + else { + return failed(); + }; + let Ok(id) = random_token() else { + return failed(); + }; + r.pending.insert( + id.clone(), + Pending { + deadline: Instant::now() + TTL, + ceremony: Ceremony::Authentication(state), + }, + ); + Json(serde_json::json!({"id":id,"options":challenge})).into_response() +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Authentication { + id: String, + credential: PublicKeyCredential, +} +async fn login_finish(State(admin): State>, request: Request) -> Response { + let Ok(input) = body::(request).await else { + return denied(); + }; + let Ok(mut r) = admin.runtime.lock() else { + return failed(); + }; + let Some(pending) = r.pending.remove(&input.id) else { + return denied(); + }; + let Ceremony::Authentication(state) = pending.ceremony else { + return denied(); + }; + r.sessions.retain(|_, expiry| *expiry > Instant::now()); + if pending.deadline <= Instant::now() || r.sessions.len() >= 32 { + return denied(); + } + let Ok(result) = admin + .webauthn + .finish_passkey_authentication(&input.credential, &state) + else { + return denied(); + }; + let mut keys = r.registrations.keys.clone(); + for key in &mut keys { + key.update_credential(&result); + } + let updated = Registrations { + origin: r.registrations.origin.clone(), + owner: r.registrations.owner, + keys, + }; + if admin.persist(&updated).is_err() { + return failed(); + } + let Ok(token) = random_token() else { + return failed(); + }; + r.registrations = updated; + r.sessions + .insert(digest(&token), Instant::now() + SESSION_TTL); + ( + [(header::SET_COOKIE, cookie(&token))], + StatusCode::NO_CONTENT, + ) + .into_response() +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Unlock { + #[serde(deserialize_with = "secret_string")] + password: SecretString, +} +async fn unlock(State(admin): State>, request: Request) -> Response { + if admin.session(request.headers()).is_none() { + return denied(); + } + let Ok(input) = body::(request).await else { + return denied(); + }; + let password = input.password; + let store = admin.store.clone(); + match tokio::task::spawn_blocking(move || store.unlock(password)).await { + Ok(Ok(())) => StatusCode::NO_CONTENT.into_response(), + _ => (StatusCode::FORBIDDEN, "Unable to unlock vault").into_response(), + } +} +async fn lock(State(admin): State>, headers: HeaderMap) -> Response { + if admin.session(&headers).is_none() { + return denied(); + } + if admin.store.lock().is_err() { + return failed(); + } + StatusCode::NO_CONTENT.into_response() +} +async fn logout(State(admin): State>, headers: HeaderMap) -> Response { + if let Some(session) = admin.session(&headers) + && let Ok(mut r) = admin.runtime.lock() + { + r.sessions.remove(&session); + } + ( + [( + header::SET_COOKIE, + format!("{COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=0"), + )], + StatusCode::NO_CONTENT, + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use keepass::{ + Database, DatabaseKey, + db::{EntryId, fields}, + }; + use serde_json::{Value, json}; + use tower::ServiceExt as _; + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + struct Fixture { + directory: tempfile::TempDir, + admin: Arc, + code: SecretString, + } + impl Fixture { + fn new() -> Result { + let directory = tempfile::tempdir()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?; + } + let mut config = crate::test_support::config("developer"); + config.items.truncate(1); + config.database = directory.path().join("synthetic.kdbx"); + let mut db = Database::new(); + { + let mut root = db.root_mut(); + let mut entry = root.add_entry_with_id(EntryId::from_uuid(Uuid::parse_str( + &config.items[0].entry_id, + )?))?; + entry.set_protected(fields::PASSWORD, "synthetic-entry-value"); + } + db.save( + &mut fs::File::create(&config.database)?, + DatabaseKey::new().with_password("synthetic-master"), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&config.database, fs::Permissions::from_mode(0o600))?; + } + let config = AdminConfig { + listen: "127.0.0.1:8444".parse()?, + origin: "https://charon.example.test".into(), + certificate: directory.path().join("cert.pem"), + private_key: directory.path().join("key.pem"), + passkeys_file: directory.path().join("passkeys.json"), + telegram: None, + }; + let (admin, code) = Admin::new( + config, + Arc::new(KeePassStore::new(config_for_store(&directory))?), + )?; + Ok(Self { + directory, + admin, + code: code.ok_or_else(|| anyhow::anyhow!("missing setup code"))?, + }) + } + } + fn config_for_store(directory: &tempfile::TempDir) -> crate::config::KeePassConfig { + let mut config = crate::test_support::config("developer"); + config.items.truncate(1); + config.database = directory.path().join("synthetic.kdbx"); + config + } + async fn post( + admin: &Arc, + path: &str, + value: Value, + session: Option<&str>, + origin: Option<&str>, + ) -> Result { + let mut request = http::Request::builder() + .method("POST") + .uri(format!("/api/{path}")) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(origin) = origin { + request = request.header(header::ORIGIN, origin); + } + if let Some(session) = session { + request = request.header(header::COOKIE, session); + } + Ok(admin + .router() + .oneshot(request.body(Body::from(serde_json::to_vec(&value)?))?) + .await?) + } + async fn value(response: Response) -> Result { + Ok(serde_json::from_slice( + &to_bytes(response.into_body(), 16384).await?, + )?) + } + async fn enroll( + admin: &Arc, + auth: &mut WebauthnAuthenticator, + code: &str, + session: Option<&str>, + ) -> Result { + let start = post( + admin, + "register/start", + json!({"code":code}), + session, + Some(&admin.config.origin), + ) + .await?; + assert_eq!(start.status(), StatusCode::OK); + let start = value(start).await?; + let credential = auth.do_registration( + Url::parse(&admin.config.origin)?, + serde_json::from_value(start["options"].clone())?, + )?; + let finish = json!({"id":start["id"],"credential":credential}); + let response = post( + admin, + "register/finish", + finish.clone(), + session, + Some(&admin.config.origin), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + Ok(finish) + } + async fn login( + admin: &Arc, + auth: &mut WebauthnAuthenticator, + ) -> Result { + let start = value( + post( + admin, + "login/start", + json!({}), + None, + Some(&admin.config.origin), + ) + .await?, + ) + .await?; + let credential = auth.do_authentication( + Url::parse(&admin.config.origin)?, + serde_json::from_value(start["options"].clone())?, + )?; + let finish = json!({"id":start["id"],"credential":credential}); + let response = post( + admin, + "login/finish", + finish.clone(), + None, + Some(&admin.config.origin), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let cookie = response + .headers() + .get(header::SET_COOKIE) + .ok_or_else(|| anyhow::anyhow!("missing cookie"))? + .to_str()? + .to_owned(); + assert!(cookie.contains("Secure; HttpOnly; SameSite=Strict")); + assert_eq!( + post( + admin, + "login/finish", + finish, + None, + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + Ok(cookie + .split(';') + .next() + .ok_or_else(|| anyhow::anyhow!("empty cookie"))? + .into()) + } + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn passkey_enrollment_login_unlock_recovery_and_restart() -> Result<()> { + let fixture = Fixture::new()?; + let admin = &fixture.admin; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let replay = enroll(admin, &mut auth, fixture.code.expose_secret(), None).await?; + assert_eq!( + post( + admin, + "register/finish", + replay, + None, + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + let session = login(admin, &mut auth).await?; + assert_eq!( + post( + admin, + "unlock", + json!({"password":"synthetic-master"}), + None, + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + post( + admin, + "unlock", + json!({"password":"synthetic-master"}), + Some(&session), + Some("https://other.example.test") + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + post( + admin, + "unlock", + json!({"password":"wrong-synthetic"}), + Some(&session), + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + post( + admin, + "unlock", + json!({"password":"synthetic-master"}), + Some(&session), + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::NO_CONTENT + ); + admin.store.health()?; + let mut recovery = WebauthnAuthenticator::new(SoftPasskey::new(true)); + enroll(admin, &mut recovery, "", Some(&session)).await?; + let recovery_session = login(admin, &mut recovery).await?; + assert_eq!( + post( + admin, + "lock", + json!({}), + Some(&recovery_session), + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::NO_CONTENT + ); + assert!(admin.store.health().is_err()); + assert_eq!( + post( + admin, + "logout", + json!({}), + Some(&session), + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::NO_CONTENT + ); + assert_eq!( + post( + admin, + "lock", + json!({}), + Some(&session), + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + let persisted = fs::read_to_string(&admin.config.passkeys_file)?; + assert!(!persisted.contains("synthetic-master")); + assert!(!persisted.contains("synthetic-entry-value")); + assert!(!persisted.contains(fixture.code.expose_secret())); + let (restarted, code) = Admin::new( + admin.config.clone(), + Arc::new(KeePassStore::new(config_for_store(&fixture.directory))?), + )?; + assert!(code.is_none()); + assert!(restarted.store.health().is_err()); + login(&restarted, &mut recovery).await?; + Ok(()) + } + #[tokio::test] + async fn setup_code_origin_and_expiry_are_enforced() -> Result<()> { + let fixture = Fixture::new()?; + let admin = &fixture.admin; + for origin in [None, Some("https://other.example.test")] { + let response = post( + admin, + "register/start", + json!({"code":fixture.code.expose_secret()}), + None, + origin, + ) + .await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|v| v.to_str().ok()), + Some("no-store") + ); + } + assert_eq!( + post( + admin, + "register/start", + json!({"code":"wrong"}), + None, + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + let start = value( + post( + admin, + "register/start", + json!({"code":fixture.code.expose_secret()}), + None, + Some(&admin.config.origin), + ) + .await?, + ) + .await?; + let mut auth = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let credential = auth.do_registration( + Url::parse(&admin.config.origin)?, + serde_json::from_value(start["options"].clone())?, + )?; + { + let mut runtime = admin + .runtime + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))?; + runtime.enrollment = Some(( + digest(fixture.code.expose_secret()), + Instant::now() + .checked_sub(Duration::from_secs(1)) + .ok_or_else(|| anyhow::anyhow!("test clock underflow"))?, + )); + } + assert_eq!( + post( + admin, + "register/finish", + json!({"id":start["id"],"credential":credential}), + None, + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + assert!(!admin.config.passkeys_file.exists()); + assert_eq!( + post( + admin, + "register/start", + json!({"code":fixture.code.expose_secret()}), + None, + Some(&admin.config.origin) + ) + .await? + .status(), + StatusCode::FORBIDDEN + ); + Ok(()) + } + #[tokio::test] + async fn independent_https_listener_serves_ui_and_rejects_untrusted_tls() -> Result<()> { + let fixture = Fixture::new()?; + let mut config = fixture.admin.config.clone(); + config.listen = "127.0.0.1:0".parse()?; + let key = rcgen::KeyPair::generate()?; + let mut params = rcgen::CertificateParams::new(vec!["charon.example.test".into()])?; + params.is_ca = rcgen::IsCa::ExplicitNoCa; + params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![rcgen::ExtendedKeyUsagePurpose::ServerAuth]; + let certificate = params.self_signed(&key)?; + fs::write(&config.certificate, certificate.pem())?; + fs::write(&config.private_key, key.serialize_pem())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&config.private_key, fs::Permissions::from_mode(0o600))?; + } + let (admin, enrollment) = Admin::new(config, fixture.admin.store.clone())?; + let _ = rustls::crypto::ring::default_provider().install_default(); + let handle = axum_server::Handle::new(); + let server_handle = handle.clone(); + let task = tokio::spawn(async move { admin.serve(server_handle).await }); + let address = tokio::time::timeout(Duration::from_secs(5), handle.listening()) + .await? + .ok_or_else(|| anyhow::anyhow!("admin did not bind"))?; + let result = async { + let client = reqwest::Client::builder() + .no_proxy() + .resolve("charon.example.test", address) + .add_root_certificate(reqwest::Certificate::from_pem( + certificate.pem().as_bytes(), + )?) + .build()?; + let response = client.get("https://charon.example.test/").send().await?; + assert!(response.status().is_success()); + assert!(response.headers().contains_key("strict-transport-security")); + assert!(response.text().await?.contains("Create passkey")); + let setup = enrollment + .as_ref() + .ok_or_else(|| anyhow::anyhow!("missing enrollment code"))?; + let response = client + .post("https://charon.example.test/api/register/start") + .header(header::ORIGIN, "https://charon.example.test") + .header(header::CONTENT_TYPE, "application/json") + .body(serde_json::to_vec(&json!({"code":setup.expose_secret()}))?) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + let untrusted = reqwest::Client::builder() + .no_proxy() + .resolve("charon.example.test", address) + .build()?; + assert!( + untrusted + .get("https://charon.example.test/") + .send() + .await + .is_err() + ); + let response = client + .post("https://charon.example.test/api/unlock") + .header(header::ORIGIN, "https://charon.example.test") + .header(header::CONTENT_TYPE, "application/json") + .body(r#"{"password":"synthetic-master"}"#) + .send() + .await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + Ok::<_, anyhow::Error>(()) + } + .await; + handle.graceful_shutdown(Some(Duration::from_secs(1))); + task.await??; + result + } + #[tokio::test] + #[ignore = "requires Chrome and Node 22+; run mise run browser-admin-check"] + #[allow(clippy::too_many_lines)] + async fn chrome_passkey_ui() -> Result<()> { + use tokio::io::AsyncWriteExt as _; + let fixture = Fixture::new()?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + drop(listener); + let mut config = fixture.admin.config.clone(); + config.listen = address; + config.origin = format!("https://charon.example.test:{}", address.port()); + let certificate = rcgen::generate_simple_self_signed(vec!["charon.example.test".into()])?; + fs::write(&config.certificate, certificate.cert.pem())?; + fs::write(&config.private_key, certificate.signing_key.serialize_pem())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&config.private_key, fs::Permissions::from_mode(0o600))?; + } + let origin = config.origin.clone(); + let (admin, code) = Admin::new(config, fixture.admin.store.clone())?; + let _ = rustls::crypto::ring::default_provider().install_default(); + let handle = axum_server::Handle::new(); + let server_handle = handle.clone(); + let server = tokio::spawn(async move { admin.serve(server_handle).await }); + ensure!( + tokio::time::timeout(Duration::from_secs(5), handle.listening()) + .await? + .is_some(), + "admin did not bind" + ); + let profile = tempfile::tempdir()?; + let chrome = std::env::var("CHARON_TEST_CHROME").unwrap_or_else(|_| { + if cfg!(target_os = "macos") { + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome".into() + } else { + "google-chrome".into() + } + }); + // Fresh synthetic profile, ephemeral debug port, and one test-only DNS mapping. + // Certificate trust is independently verified by the HTTPS listener test. + let mut chrome = tokio::process::Command::new(chrome) + .args([ + "--headless=new", + "--no-first-run", + "--no-default-browser-check", + "--remote-debugging-port=0", + "--ignore-certificate-errors", + "--host-resolver-rules=MAP charon.example.test 127.0.0.1", + "--no-proxy-server", + ]) + .arg(format!("--user-data-dir={}", profile.path().display())) + .arg("about:blank") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn()?; + let result=async { + let port_file=profile.path().join("DevToolsActivePort"); + let details=tokio::time::timeout(Duration::from_secs(15),async { + loop {if let Ok(details)=fs::read_to_string(&port_file){break details;}tokio::time::sleep(Duration::from_millis(100)).await;} + }).await?; + let mut lines=details.lines();let port=lines.next().ok_or_else(||anyhow::anyhow!("missing debug port"))?; + let path=lines.next().ok_or_else(||anyhow::anyhow!("missing debug path"))?; + let input=json!({"devtools":format!("ws://127.0.0.1:{port}{path}"),"origin":origin,"code":code.as_ref().ok_or_else(||anyhow::anyhow!("missing setup code"))?.expose_secret(),"password":"synthetic-master","screenshot":std::env::var("CHARON_TEST_ADMIN_SCREENSHOT").ok()}); + let mut node=tokio::process::Command::new("node").arg("tests/admin_browser.mjs").stdin(std::process::Stdio::piped()).kill_on_drop(true).spawn()?; + let mut stdin=node.stdin.take().ok_or_else(||anyhow::anyhow!("missing node stdin"))?; + stdin.write_all(&serde_json::to_vec(&input)?).await?;stdin.shutdown().await?;drop(stdin); + ensure!(tokio::time::timeout(Duration::from_secs(60),node.wait()).await??.success(),"Chrome UI proof failed"); + Ok::<_,anyhow::Error>(()) + }.await; + let _ = chrome.kill().await; + handle.graceful_shutdown(Some(Duration::from_secs(1))); + server.await??; + result + } + #[tokio::test] + async fn overlapping_registrations_cannot_exceed_eight_passkeys() -> Result<()> { + let fixture = Fixture::new()?; + let admin = &fixture.admin; + let mut owner = WebauthnAuthenticator::new(SoftPasskey::new(true)); + enroll(admin, &mut owner, fixture.code.expose_secret(), None).await?; + let session = login(admin, &mut owner).await?; + for _ in 0..6 { + let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + enroll(admin, &mut key, "", Some(&session)).await?; + } + let mut finishes = Vec::new(); + for _ in 0..2 { + let start = value( + post( + admin, + "register/start", + json!({"code":""}), + Some(&session), + Some(&admin.config.origin), + ) + .await?, + ) + .await?; + let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let credential = key.do_registration( + Url::parse(&admin.config.origin)?, + serde_json::from_value(start["options"].clone())?, + )?; + finishes.push(json!({"id":start["id"],"credential":credential})); + } + for (index, finish) in finishes.into_iter().enumerate() { + let response = post( + admin, + "register/finish", + finish, + Some(&session), + Some(&admin.config.origin), + ) + .await?; + assert_eq!( + response.status(), + if index == 0 { + StatusCode::NO_CONTENT + } else { + StatusCode::FORBIDDEN + } + ); + } + let persisted: Registrations = + serde_json::from_slice(&fs::read(&admin.config.passkeys_file)?)?; + assert_eq!(persisted.keys.len(), 8); + let (_, code) = Admin::new(admin.config.clone(), admin.store.clone())?; + assert!(code.is_none()); + Ok(()) + } + #[tokio::test] + async fn overlapping_logins_cannot_exceed_session_capacity() -> Result<()> { + let fixture = Fixture::new()?; + let admin = &fixture.admin; + let mut owner = WebauthnAuthenticator::new(SoftPasskey::new(true)); + enroll(admin, &mut owner, fixture.code.expose_secret(), None).await?; + login(admin, &mut owner).await?; + let mut finishes = Vec::new(); + for _ in 0..2 { + let start = value( + post( + admin, + "login/start", + json!({}), + None, + Some(&admin.config.origin), + ) + .await?, + ) + .await?; + let credential = owner.do_authentication( + Url::parse(&admin.config.origin)?, + serde_json::from_value(start["options"].clone())?, + )?; + finishes.push(json!({"id":start["id"],"credential":credential})); + } + { + let mut runtime = admin + .runtime + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))?; + for index in 0..30 { + runtime.sessions.insert( + digest(&format!("synthetic-session-{index}")), + Instant::now() + SESSION_TTL, + ); + } + } + for (index, finish) in finishes.into_iter().enumerate() { + let response = post( + admin, + "login/finish", + finish, + None, + Some(&admin.config.origin), + ) + .await?; + assert_eq!( + response.status(), + if index == 0 { + StatusCode::NO_CONTENT + } else { + StatusCode::FORBIDDEN + } + ); + } + assert_eq!( + admin + .runtime + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))? + .sessions + .len(), + 32 + ); + Ok(()) + } +} diff --git a/src/admin/admin.css b/src/admin/admin.css new file mode 100644 index 0000000..e00a9cb --- /dev/null +++ b/src/admin/admin.css @@ -0,0 +1 @@ +:root{font-family:system-ui,sans-serif;color:#e9e6df;background:#151918;color-scheme:dark}body{margin:0}main{max-width:540px;margin:8vh auto;padding:32px}.eyebrow{letter-spacing:.25em;color:#a9c6af;font-size:12px}h1{font-size:clamp(36px,7vw,56px);line-height:1.08;font-weight:500;letter-spacing:-.04em}h2{font-size:22px}#status{display:inline-block;border:1px solid #46574b;border-radius:24px;padding:8px 16px;color:#b8d5bf}section{margin-top:32px}p{line-height:1.6;color:#a9b4ad}label{display:block;margin-bottom:8px}input{box-sizing:border-box;width:100%;padding:14px;font-size:16px;background:#202824;border:1px solid #536057;border-radius:8px}button{padding:12px 20px;border:0;border-radius:8px;background:#bdd5b7;color:#142018;font-size:15px;cursor:pointer;margin-top:16px}button:disabled{opacity:.5;cursor:wait}.secondary{display:flex;gap:12px;margin-top:24px}.secondary button{background:#29372d;color:#d1ddd4}#error{color:#f1a89c;min-height:24px}[hidden]{display:none!important}input:focus-visible,button:focus-visible{outline:2px solid #d4edcd;outline-offset:3px} diff --git a/src/admin/admin.js b/src/admin/admin.js new file mode 100644 index 0000000..070e2c1 --- /dev/null +++ b/src/admin/admin.js @@ -0,0 +1,55 @@ +'use strict'; +const $ = id => document.getElementById(id); +let busy = false; +function decode(value) { + const bytes = atob(value.replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(bytes, c => c.charCodeAt(0)); +} +function encode(value) { + return btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +async function api(path, value) { + const response = await fetch(`/api/${path}`, value === undefined ? { cache: 'no-store' } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(value), cache: 'no-store' }); + if (!response.ok) throw new Error(response.status === 429 ? 'Too many attempts. Please wait a minute.' : 'Request failed. Check your credentials and try again.'); + return response.status === 204 ? null : response.json(); +} +async function refresh() { + const state = await api('status'); + $('enrollment').hidden = state.enrolled; + $('login').hidden = !state.enrolled || state.authenticated; + $('vault').hidden = !state.authenticated; + $('unlock-form').hidden = state.unlocked; + $('lock').hidden = !state.unlocked; + $('status').textContent = state.unlocked ? 'Vault unlocked' : state.enrolled ? 'Vault locked' : 'Enrollment required'; +} +async function register(code) { + const result = await api('register/start', { code }); + const options = result.options.publicKey; + options.challenge = decode(options.challenge); + options.user.id = decode(options.user.id); + for (const credential of options.excludeCredentials || []) credential.id = decode(credential.id); + const credential = await navigator.credentials.create({ publicKey: options }); + await api('register/finish', { id: result.id, credential: { id: credential.id, rawId: encode(credential.rawId), type: credential.type, response: { attestationObject: encode(credential.response.attestationObject), clientDataJSON: encode(credential.response.clientDataJSON), transports: credential.response.getTransports?.() || [] }, extensions: credential.getClientExtensionResults() } }); +} +async function login() { + const result = await api('login/start', {}); + const options = result.options.publicKey; + options.challenge = decode(options.challenge); + for (const credential of options.allowCredentials || []) credential.id = decode(credential.id); + const credential = await navigator.credentials.get({ publicKey: options }); + await api('login/finish', { id: result.id, credential: { id: credential.id, rawId: encode(credential.rawId), type: credential.type, response: { authenticatorData: encode(credential.response.authenticatorData), clientDataJSON: encode(credential.response.clientDataJSON), signature: encode(credential.response.signature), userHandle: credential.response.userHandle ? encode(credential.response.userHandle) : null }, extensions: credential.getClientExtensionResults() } }); +} +async function action(fn) { + if (busy) return; + busy = true; $('error').textContent = ''; + for (const button of document.querySelectorAll('button')) button.disabled = true; + try { await fn(); await refresh(); } catch (error) { $('error').textContent = error.name === 'NotAllowedError' ? 'Passkey confirmation was cancelled or timed out.' : error.message; } + finally { busy = false; for (const button of document.querySelectorAll('button')) button.disabled = false; } +} +$('enroll-form').addEventListener('submit', event => { event.preventDefault(); const code = $('code').value; $('code').value = ''; action(async () => { await register(code); await login(); }); }); +$('sign-in').addEventListener('click', () => action(login)); +$('add-passkey').addEventListener('click', () => action(() => register(''))); +$('unlock-form').addEventListener('submit', event => { event.preventDefault(); const password = $('password').value; $('password').value = ''; action(() => api('unlock', { password })); }); +$('lock').addEventListener('click', () => action(() => api('lock', {}))); +$('sign-out').addEventListener('click', () => action(() => api('logout', {}))); +refresh().catch(() => { $('status').textContent = 'Unable to reach Charon'; }); diff --git a/src/admin/index.html b/src/admin/index.html new file mode 100644 index 0000000..d3bff6e --- /dev/null +++ b/src/admin/index.html @@ -0,0 +1 @@ +Charon · Vault control

CHARON

Your vault.
Your permission.

Checking vault status…

diff --git a/src/config.rs b/src/config.rs index 685c4e2..8b539fc 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,8 +26,10 @@ pub struct Config { pub listen: SocketAddr, /// Optional upstream proxy and protected authentication input. pub upstream_proxy: Option, - /// Credential-provider implementation and its narrowly scoped inputs. - pub provider: ProviderConfig, + /// Read-only `KeePass` store, unlocked interactively after boot. + pub keepass: KeePassConfig, + /// Separate HTTPS administration and interactive unlock configuration. + pub admin: Option, /// Optional Charon-owned interception CA used only for HTTPS CONNECT. pub tls: Option, /// Signed workload-manifest verification policy. @@ -88,42 +90,63 @@ pub struct TlsConfig { pub ca_private_key: std::path::PathBuf, } -/// Credential-provider selection. -#[derive(Clone, Debug, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] -pub enum ProviderConfig { - /// Prototype environment provider for disposable development credentials. - Environment, - /// Vaultwarden access through the official Bitwarden CLI compatibility API. - Vaultwarden(VaultwardenConfig), -} - -/// Locked-by-default Vaultwarden provider configuration. +/// Read-only `KeePass` database and policy-owned entry mappings. #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] -pub struct VaultwardenConfig { - /// Absolute path to the pinned `bw` CLI executable. - pub cli_path: std::path::PathBuf, - /// Dedicated directory containing only the encrypted CLI vault state. - pub appdata_dir: std::path::PathBuf, - /// Root-readable file containing a short-lived unlock session. - pub session_file: std::path::PathBuf, - /// Maximum in-memory lifetime of resolved values. - pub cache_ttl_seconds: u64, - /// Explicit opaque-reference to vault-item mapping. - pub items: Vec, +pub struct KeePassConfig { + /// Absolute path to the encrypted KDBX database. + pub database: PathBuf, + /// Optional protected key file, required alongside the interactive password. + pub key_file: Option, + /// Optional operator-only protected Password entry for the Telegram bot token. + pub telegram_bot_token_entry_id: Option, + /// Exact policy reference to password-entry mappings. + pub items: Vec, } -/// One exact Charon reference mapped to one exact Vaultwarden item UUID. +/// One exact policy reference mapped to a `KeePass` entry UUID. #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] -pub struct VaultItemMapping { - /// Opaque reference used by service policy. +pub struct KeePassItemMapping { + /// Reference chosen only by local service policy. pub secret_ref: String, - /// Exact persona allowed to use the mapped item. + /// Persona owning this mapped entry. pub persona: String, - /// Exact Vaultwarden item UUID read with `bw get password`. - pub item_id: String, + /// Canonical UUID of the entry whose Password field is used. + pub entry_id: String, +} + +impl KeePassConfig { + /// Validate paths and exact mappings before a listener is opened. + /// + /// # Errors + /// Rejects relative paths, duplicate references/entries, and wrong personas. + pub fn validate(&self, persona: &str) -> Result<()> { + if !self.database.is_absolute() || self.key_file.as_ref().is_some_and(|p| !p.is_absolute()) + { + bail!("KeePass paths must be absolute"); + } + let mut refs = HashSet::new(); + let mut ids = HashSet::new(); + for item in &self.items { + if item.secret_ref.is_empty() + || item.secret_ref.len() > 256 + || item.persona != persona + || !is_uuid(&item.entry_id) + || uuid::Uuid::parse_str(&item.entry_id)?.to_string() != item.entry_id + || !refs.insert(&item.secret_ref) + || !ids.insert(&item.entry_id) + { + bail!("invalid KeePass entry mapping"); + } + } + if let Some(id) = &self.telegram_bot_token_entry_id + && (!is_uuid(id) || uuid::Uuid::parse_str(id)?.to_string() != *id || ids.contains(id)) + { + bail!("invalid or workload-accessible Telegram token entry"); + } + Ok(()) + } } /// Verification parameters for short-lived workload manifests. @@ -168,8 +191,7 @@ pub struct ServicePolicy { pub hosts: Vec, /// Typed, policy-owned credential hydration rule. pub hydration: HydrationPolicy, - /// Credential provider key. The prototype environment provider treats this - /// as an environment-variable name in the Charon process only. + /// Policy-owned reference mapped to one exact configured `KeePass` entry. pub secret_ref: String, /// Explicit response mediation and resource limits. pub response: ResponsePolicy, @@ -352,7 +374,22 @@ impl Config { if self.capabilities.is_empty() { bail!("at least one capability policy is required"); } - let vault_refs = self.validate_provider()?; + if let Some(admin) = &self.admin { + admin.validate()?; + if let Some(telegram) = &admin.telegram { + telegram.validate_inventory(self.capabilities.iter().map(|c| c.name.as_str()))?; + } + if admin.listen == self.listen { + bail!("admin and proxy listeners must differ"); + } + } + self.keepass.validate(&self.realm.persona)?; + let vault_refs = self + .keepass + .items + .iter() + .map(|i| i.secret_ref.as_str()) + .collect::>(); let mut names = HashSet::new(); let mut hosts = HashSet::new(); @@ -399,19 +436,9 @@ impl Config { service.name ); } - if matches!(&self.provider, ProviderConfig::Environment) - && !is_environment_reference(&service.secret_ref) - { + if !vault_refs.contains(service.secret_ref.as_str()) { bail!( - "service {} environment reference must use the CHARON_ namespace", - service.name - ); - } - if let Some(refs) = &vault_refs - && !refs.contains(service.secret_ref.as_str()) - { - bail!( - "service {} references an unmapped Vaultwarden item", + "service {} references an unmapped KeePass entry", service.name ); } @@ -530,24 +557,6 @@ impl Config { capability.name ); } - if let ProviderConfig::Vaultwarden(provider) = &self.provider { - let service = self - .services - .iter() - .find(|service| service.name == capability.service) - .context("validated capability service disappeared")?; - let mapping = provider - .items - .iter() - .find(|mapping| mapping.secret_ref == service.secret_ref) - .context("validated Vaultwarden mapping disappeared")?; - if mapping.persona != capability.persona { - bail!( - "capability {} persona does not own its Vaultwarden item", - capability.name - ); - } - } if capability.methods.is_empty() || capability.paths.is_empty() { bail!( "capability {} must declare methods and paths", @@ -574,38 +583,6 @@ impl Config { Ok(()) } - fn validate_provider(&self) -> Result>> { - let ProviderConfig::Vaultwarden(provider) = &self.provider else { - return Ok(None); - }; - if !provider.cli_path.is_absolute() - || !provider.appdata_dir.is_absolute() - || !provider.session_file.is_absolute() - { - bail!("Vaultwarden CLI, appdata, and session-file paths must be absolute"); - } - if provider.cache_ttl_seconds == 0 || provider.cache_ttl_seconds > 300 { - bail!("Vaultwarden cache TTL must be between 1 and 300 seconds"); - } - if provider.items.is_empty() { - bail!("Vaultwarden provider requires explicit item mappings"); - } - let mut refs = HashSet::new(); - let mut ids = HashSet::new(); - for item in &provider.items { - if item.secret_ref.is_empty() || item.persona.is_empty() || !is_uuid(&item.item_id) { - bail!("Vaultwarden item mapping is invalid"); - } - if !refs.insert(item.secret_ref.as_str()) || !ids.insert(item.item_id.as_str()) { - bail!("Vaultwarden item mappings must be unique"); - } - if item.persona != self.realm.persona { - bail!("Vaultwarden item belongs to another persona realm"); - } - } - Ok(Some(refs)) - } - /// Resolve an exact destination host to one policy. #[must_use] pub fn service_for_host(&self, host: &str) -> Option<&ServicePolicy> { @@ -708,16 +685,6 @@ fn is_identifier(value: &str) -> bool { .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } -fn is_environment_reference(value: &str) -> bool { - value.strip_prefix("CHARON_").is_some_and(|suffix| { - !suffix.is_empty() - && suffix.len() <= 120 - && suffix - .bytes() - .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_') - }) -} - pub(crate) fn is_exact_host(value: &str) -> bool { if value.is_empty() || value.len() > 253 @@ -748,8 +715,8 @@ mod tests { use ed25519_dalek::SigningKey; use super::{ - CapabilityPolicy, Config, HydrationPolicy, IdentityConfig, ProviderConfig, RealmConfig, - ResponsePolicy, ServicePolicy, UpstreamProxyConfig, VaultItemMapping, VaultwardenConfig, + CapabilityPolicy, Config, HydrationPolicy, IdentityConfig, RealmConfig, ResponsePolicy, + ServicePolicy, UpstreamProxyConfig, }; use crate::broker::{CompressionPolicy, HydrationSink, ResponseMode}; @@ -765,7 +732,8 @@ mod tests { .parse() .unwrap_or_else(|error| panic!("{error}")), upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: crate::test_support::config("developer"), + admin: None, tls: None, identity: IdentityConfig { issuer: "test-issuer".into(), @@ -928,30 +896,13 @@ mod tests { } #[test] - fn vaultwarden_requires_exact_bounded_mappings() { + fn keepass_requires_exact_mappings() { let mut candidate = config(); - candidate.services[0].secret_ref = "github/developer".into(); - candidate.provider = ProviderConfig::Vaultwarden(VaultwardenConfig { - cli_path: "/usr/local/bin/bw".into(), - appdata_dir: "/var/lib/charon/bw".into(), - session_file: "/run/credentials/bw-session".into(), - cache_ttl_seconds: 30, - items: vec![VaultItemMapping { - secret_ref: "github/developer".into(), - persona: "developer".into(), - item_id: "00000000-0000-4000-8000-000000000001".into(), - }], - }); assert!(candidate.validate().is_ok()); - - candidate.services[0].secret_ref = "github/another-persona".into(); + candidate.services[0].secret_ref = "unmapped".into(); assert!(candidate.validate().is_err()); - - candidate.services[0].secret_ref = "github/developer".into(); - let ProviderConfig::Vaultwarden(provider) = &mut candidate.provider else { - panic!("test provider must be Vaultwarden"); - }; - provider.items[0].persona = "another-persona".into(); + candidate = config(); + candidate.keepass.items[0].persona = "another-persona".into(); assert!(candidate.validate().is_err()); } } diff --git a/src/gateway.rs b/src/gateway.rs index 4d472cb..8f8de27 100644 --- a/src/gateway.rs +++ b/src/gateway.rs @@ -1,9 +1,9 @@ //! Exclusive-workload explicit proxy: independent of signed-manifest listeners. use crate::{ - config::{ProviderConfig, RealmConfig, ReceiptConfig, TlsConfig}, + config::{KeePassConfig, RealmConfig, ReceiptConfig, TlsConfig}, dns::PinnedResolver, - provider::{SecretProvider, SecretRef}, + keepass::{KeePassStore, SecretRef}, receipt::{DataPlaneReceipt, ReceiptJournal, ReceiptOutcome, receipt_stream}, response::{guard_stream, redact_text_stream}, tls::TlsAuthority, @@ -42,8 +42,10 @@ pub struct GatewayConfig { pub exclusive_network: bool, /// Interception CA; there is no splice mode. pub tls: TlsConfig, - /// Provider instantiated only for policy-owned credential mediation. - pub provider: ProviderConfig, + /// Concrete `KeePass` store configuration for policy-owned credential mediation. + pub keepass: KeePassConfig, + /// Separate HTTPS administration and interactive unlock configuration. + pub admin: Option, /// Required durable metadata journal. pub receipts: ReceiptConfig, /// Exact-host operation grants, evaluated for every request. @@ -287,24 +289,26 @@ impl GatewayConfig { route.scheme == "https" && !reference.is_empty() && reference.len() <= 256, "invalid credential route" ); - match &self.provider { - ProviderConfig::Environment => ensure!( - reference.starts_with("CHARON_") - && reference - .bytes() - .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'), - "invalid environment reference" - ), - ProviderConfig::Vaultwarden(vault) => ensure!( - vault - .items - .iter() - .any(|i| &i.secret_ref == reference && i.persona == self.realm.persona), - "unmapped realm credential" - ), - } + ensure!( + self.keepass + .items + .iter() + .any(|i| &i.secret_ref == reference && i.persona == self.realm.persona), + "unmapped realm credential" + ); + } + } + if let Some(admin) = &self.admin { + admin.validate()?; + if let Some(telegram) = &admin.telegram { + telegram.validate_inventory(self.routes.iter().map(|r| r.name.as_str()))?; } + ensure!( + admin.listen != self.listen, + "admin and proxy listeners must differ" + ); } + self.keepass.validate(&self.realm.persona)?; // Overlap must not let grant order change which credential is selected. for (index, left) in self.routes.iter().enumerate() { for right in &self.routes[index + 1..] { @@ -341,7 +345,8 @@ pub struct Gateway { config: GatewayConfig, client: reqwest::Client, resolver: Arc, - secrets: Arc, + secrets: Arc, + approvals: Option>, tls: Arc, receipts: ReceiptJournal, } @@ -349,7 +354,7 @@ impl Gateway { /// Build with verified upstream TLS, no ambient proxy, and public-only DNS. /// # Errors /// Invalid policy, CA, journal, or client construction fails startup. - pub fn new(config: GatewayConfig, secrets: Arc) -> Result { + pub fn new(config: GatewayConfig, secrets: Arc) -> Result { config.validate()?; let hosts = config.routes.iter().map(|r| r.host.clone()).collect(); let resolver: Arc = @@ -367,10 +372,17 @@ impl Gateway { client, resolver, secrets, + approvals: None, tls, receipts, }) } + /// Attach the optional local human-decision coordinator. + #[must_use] + pub fn with_approvals(mut self, approvals: Option>) -> Self { + self.approvals = approvals; + self + } /// Build a router for this exclusive listener only. pub fn router(self: Arc) -> Router { Router::new() @@ -381,7 +393,14 @@ impl Gateway { .route( "/readyz", axum::routing::get(|State(state): State>| async move { - if state.receipts.is_healthy() { + if state.receipts.is_healthy() + && (state + .config + .routes + .iter() + .all(|route| matches!(route.mediation, Mediation::Forward)) + || state.secrets.health().is_ok()) + { StatusCode::NO_CONTENT } else { StatusCode::SERVICE_UNAVAILABLE @@ -643,8 +662,27 @@ async fn forward(state: &Gateway, request: Request) -> Result { .map_err(|_| anyhow::anyhow!("destination address denied"))?; ensure!(addresses.next().is_some(), "destination address denied"); let mut protected = Vec::new(); + ensure!( + state.approvals.is_some() + || state + .config + .admin + .as_ref() + .is_none_or(|admin| admin.telegram.is_none()), + "approval channel unavailable" + ); match &route.mediation { - Mediation::Forward => {} + Mediation::Forward => { + if let Some(approvals) = &state.approvals { + timeout( + Duration::from_secs(route.max_duration_seconds) + .saturating_sub(started.elapsed()), + approvals.authorize(&route.name, parts.method.as_str()), + ) + .await + .map_err(|_| anyhow::anyhow!("human approval timed out"))??; + } + } Mediation::Header { secret_ref, name, @@ -657,12 +695,20 @@ async fn forward(state: &Gateway, request: Request) -> Result { && headers.contains_key(name), "typed capability reference required" ); - let secret = timeout( - Duration::from_secs(route.max_duration_seconds).saturating_sub(started.elapsed()), - state.secrets.resolve(&SecretRef::from_policy(secret_ref)), - ) - .await - .map_err(|_| anyhow::anyhow!("credential mediation timed out"))??; + if let Some(approvals) = &state.approvals { + timeout( + Duration::from_secs(route.max_duration_seconds) + .saturating_sub(started.elapsed()), + approvals.authorize(&route.name, parts.method.as_str()), + ) + .await + .map_err(|_| anyhow::anyhow!("human approval timed out"))??; + } + ensure!( + started.elapsed() < Duration::from_secs(route.max_duration_seconds), + "credential mediation timed out" + ); + let secret = state.secrets.resolve(&SecretRef::from_policy(secret_ref))?; ensure!( (8..=16384).contains(&secret.expose_secret().len()), "credential bound violated" @@ -693,12 +739,20 @@ async fn forward(state: &Gateway, request: Request) -> Result { && headers.contains_key(http::header::AUTHORIZATION), "typed Basic capability required" ); - let secret = timeout( - Duration::from_secs(route.max_duration_seconds).saturating_sub(started.elapsed()), - state.secrets.resolve(&SecretRef::from_policy(secret_ref)), - ) - .await - .map_err(|_| anyhow::anyhow!("credential mediation timed out"))??; + if let Some(approvals) = &state.approvals { + timeout( + Duration::from_secs(route.max_duration_seconds) + .saturating_sub(started.elapsed()), + approvals.authorize(&route.name, parts.method.as_str()), + ) + .await + .map_err(|_| anyhow::anyhow!("human approval timed out"))??; + } + ensure!( + started.elapsed() < Duration::from_secs(route.max_duration_seconds), + "credential mediation timed out" + ); + let secret = state.secrets.resolve(&SecretRef::from_policy(secret_ref))?; ensure!( (8..=16384).contains(&secret.expose_secret().len()), "credential bound violated" @@ -839,3 +893,7 @@ impl Drop for Attempt { #[cfg(test)] #[path = "gateway_tests.rs"] mod tests; + +#[cfg(test)] +#[path = "../tests/e2e/mod.rs"] +mod e2e; diff --git a/src/gateway_tests.rs b/src/gateway_tests.rs index dee5cfe..6d17bb2 100644 --- a/src/gateway_tests.rs +++ b/src/gateway_tests.rs @@ -1,18 +1,15 @@ //! Real local TLS fixtures for the exclusive workload gateway. use super::*; -use crate::provider::{ProviderError, ProviderResult}; -use async_trait::async_trait; use http_body_util::BodyExt as _; use hyper_util::rt::TokioIo; use rustls::pki_types::pem::PemObject as _; -use std::sync::atomic::{AtomicUsize, Ordering}; use tokio::{ io::{AsyncReadExt as _, AsyncWriteExt as _}, net::{TcpListener, TcpStream}, }; +use tower::ServiceExt as _; const SENTINEL: &str = "FAKE_SENTINEL_CREDENTIAL_0123456789"; -struct FakeProvider(AtomicUsize); struct PublicFixtureResolver; impl reqwest::dns::Resolve for PublicFixtureResolver { fn resolve(&self, _: reqwest::dns::Name) -> reqwest::dns::Resolving { @@ -24,16 +21,6 @@ impl reqwest::dns::Resolve for PublicFixtureResolver { }) } } -#[async_trait] -impl SecretProvider for FakeProvider { - async fn resolve(&self, _: &SecretRef<'_>) -> ProviderResult { - self.0.fetch_add(1, Ordering::SeqCst); - Ok(SecretString::from(SENTINEL)) - } - async fn health(&self) -> ProviderResult<()> { - Err(ProviderError::Unavailable) - } -} fn route(name: &str, path: &str, mediation: Mediation) -> Route { Route { name: name.into(), @@ -55,7 +42,7 @@ fn route(name: &str, path: &str, mediation: Mediation) -> Route { struct Fixture { directory: tempfile::TempDir, gateway: Arc, - provider: Arc, + provider: Arc, address: SocketAddr, upstream_address: SocketAddr, ca: reqwest::Certificate, @@ -68,8 +55,13 @@ impl Drop for Fixture { } } } -#[allow(clippy::too_many_lines)] async fn fixture() -> Result { + fixture_with_approvals(None).await +} +#[allow(clippy::too_many_lines)] +async fn fixture_with_approvals( + approvals: Option>, +) -> Result { let directory = tempfile::tempdir()?; let cert = directory.path().join("ca.pem"); let key = directory.path().join("key.pem"); @@ -199,7 +191,8 @@ async fn fixture() -> Result { listen: address, exclusive_network: true, tls: tls_config, - provider: ProviderConfig::Environment, + keepass: crate::test_support::config("persona-test"), + admin: None, receipts: ReceiptConfig { journal_path: directory.path().join("receipts.jsonl"), state_path: directory.path().join("checkpoint"), @@ -233,8 +226,13 @@ async fn fixture() -> Result { route("compressed-hop", "/compressed-hop", Mediation::Forward), ], }; - let provider = Arc::new(FakeProvider(AtomicUsize::new(0))); - let mut gateway = Gateway::new(config, provider.clone())?; + let provider = Arc::new(crate::test_support::store( + std::collections::HashMap::from([ + ("CHARON_SYNTHETIC_TEST".into(), SENTINEL.into()), + ("CHARON_TEST".into(), SENTINEL.into()), + ]), + )?); + let mut gateway = Gateway::new(config, provider.clone())?.with_approvals(approvals); gateway.resolver = Arc::new(PublicFixtureResolver); gateway.client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) @@ -300,7 +298,7 @@ async fn ordinary_clients_forward_stream_upload_and_keep_sessions_without_lookup .await?; assert_eq!(response.status(), 403); assert!(!response.text().await?.contains(SENTINEL)); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } #[tokio::test] @@ -320,7 +318,7 @@ async fn hydration_is_typed_and_sentinel_never_reaches_errors_or_journal() -> Re assert_eq!(response.status(), 403); assert!(!response.text().await?.contains(SENTINEL)); } - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); let response = client .get("https://allowed.test/credential") .header("authorization", "Bearer {{charon.credential}}") @@ -337,7 +335,7 @@ async fn hydration_is_typed_and_sentinel_never_reaches_errors_or_journal() -> Re .await?; assert_eq!(response.status(), 403); assert!(!response.text().await?.contains(SENTINEL)); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 2); + assert_eq!(f.provider.lookup_count(), 2); tokio::time::sleep(Duration::from_millis(50)).await; let journal = std::fs::read_to_string(&f.gateway.config.receipts.journal_path)?; assert!(!journal.contains(SENTINEL)); @@ -394,7 +392,7 @@ async fn reusable_tls_connection_reauthorizes_each_request_and_authority() -> Re assert_eq!(response.status().as_u16(), status); response.into_body().collect().await?; } - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); task.abort(); Ok(()) } @@ -430,7 +428,7 @@ async fn unsupported_protocols_and_compression_fail_closed() -> Result<()> { response.into_body().collect().await?; } task.abort(); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } #[test] @@ -468,7 +466,7 @@ async fn streaming_delivers_before_completion_and_basic_is_a_typed_sink() -> Res assert!(first.is_some()); let rest = response.text().await?; assert!(rest.contains("streaming")); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); let response = client .get("https://allowed.test/basic") .basic_auth("fixed-user", Some("{{charon.basic}}")) @@ -478,7 +476,7 @@ async fn streaming_delivers_before_completion_and_basic_is_a_typed_sink() -> Res let body = response.text().await?; assert!(!body.contains(SENTINEL)); assert!(body.contains("[REDACTED]")); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 1); + assert_eq!(f.provider.lookup_count(), 1); Ok(()) } @@ -551,7 +549,7 @@ async fn http2_reuse_reauthorizes_authority_and_sni_is_required() -> Result<()> } task.abort(); assert!(tunnel(&f, "wrong.test", Vec::new()).await.is_err()); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } @@ -573,7 +571,7 @@ async fn rejects_private_dns_before_provider_and_invalid_upstream_tls() -> Resul ) .await; assert_eq!(response.status(), 403); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); let no_trust = reqwest::Client::builder() .no_proxy() .proxy(reqwest::Proxy::all(format!("http://{}", f.address))?) @@ -605,7 +603,7 @@ async fn rejects_private_dns_before_provider_and_invalid_upstream_tls() -> Resul ) .await; assert_eq!(response.status(), 403); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } @@ -696,7 +694,7 @@ async fn configured_upload_and_download_bounds_are_enforced() -> Result<()> { ) .await; assert_eq!(response.status(), 403); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); let response = handle( &gateway, Request::builder() @@ -710,7 +708,7 @@ async fn configured_upload_and_download_bounds_are_enforced() -> Result<()> { .await .is_err() ); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } @@ -753,7 +751,7 @@ async fn python_313_strict_clients_accept_generated_chain() -> Result<()> { String::from_utf8(output.stdout)?.contains("PASS:"), "client proof missing" ); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } @@ -823,7 +821,7 @@ async fn intercepted_chain_has_explicit_ca_and_server_certificate_roles() -> Res subject_id(&leaf).context("leaf subject key ID missing")?, [] as [u8; 0] ); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); Ok(()) } #[tokio::test] @@ -848,7 +846,7 @@ async fn unmodified_curl_and_gh_use_standard_proxy_and_credential_settings() -> .await?; ensure!(curl_output.status.success(), "synthetic curl proof failed"); assert!(String::from_utf8(curl_output.stdout)?.contains("data: /plain")); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + assert_eq!(f.provider.lookup_count(), 0); // Go's macOS certificate verifier in existing gh builds uses Keychain, // not SSL_CERT_FILE. Exercise gh in Linux CI without installing a test CA // into the operator's system trust or disabling certificate verification. @@ -878,6 +876,79 @@ async fn unmodified_curl_and_gh_use_standard_proxy_and_credential_settings() -> let body = String::from_utf8(gh_output.stdout)?; assert!(!body.contains(SENTINEL)); assert!(body.contains("[REDACTED]")); - assert_eq!(f.provider.0.load(Ordering::SeqCst), 1); + assert_eq!(f.provider.lookup_count(), 1); + Ok(()) +} + +#[tokio::test] +async fn human_decision_precedes_credential_lookup_and_cannot_widen_policy() -> Result<()> { + let approvals = crate::telegram::Telegram::test_coordinator(vec!["credential".into()])?; + let fixture = fixture_with_approvals(Some(approvals.clone())).await?; + let client = client(&fixture)?; + let response = client + .get("https://allowed.test/credential") + .header("authorization", "Bearer {{charon.wrong}}") + .send() + .await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(approvals.test_pending_count()?, 0); + assert_eq!(fixture.provider.lookup_count(), 0); + for allow in [false, true] { + let request = client + .get("https://allowed.test/credential") + .header("authorization", "Bearer {{charon.credential}}"); + let waiting = tokio::spawn(async move { request.send().await }); + for _ in 0..1000 { + if approvals.test_pending_count()? > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(1)).await; + } + assert_eq!(approvals.test_pending_count()?, 1); + assert_eq!(fixture.provider.lookup_count(), 0); + assert_eq!(approvals.test_decide_next(allow).await?, "credential"); + let response = waiting.await??; + assert_eq!( + response.status(), + if allow { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + } + ); + assert!(!response.text().await?.contains(SENTINEL)); + } + assert_eq!(fixture.provider.lookup_count(), 1); + Ok(()) +} + +#[tokio::test] +async fn credential_gateway_readiness_requires_unlock() -> Result<()> { + let fixture = fixture().await?; + let client = reqwest::Client::builder().no_proxy().build()?; + let url = format!("http://{}/readyz", fixture.address); + assert_eq!( + client.get(&url).send().await?.status(), + StatusCode::NO_CONTENT + ); + fixture.provider.lock()?; + assert_eq!( + client.get(url).send().await?.status(), + StatusCode::SERVICE_UNAVAILABLE + ); + let mut config = fixture.gateway.config.clone(); + config + .routes + .retain(|route| matches!(route.mediation, Mediation::Forward)); + let gateway = Arc::new(Gateway::new(config, fixture.provider.clone())?); + let response = gateway + .router() + .oneshot( + http::Request::builder() + .uri("/readyz") + .body(Body::empty())?, + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); Ok(()) } diff --git a/src/identity.rs b/src/identity.rs index 6a3566b..894c5f5 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -264,9 +264,7 @@ mod tests { use ed25519_dalek::{Signer as _, SigningKey}; use super::{IdentityVerifier, WorkloadClaims}; - use crate::config::{ - CapabilityPolicy, Config, IdentityConfig, ProviderConfig, RealmConfig, ServicePolicy, - }; + use crate::config::{CapabilityPolicy, Config, IdentityConfig, RealmConfig, ServicePolicy}; const NOW: u64 = 1_800_000_000; @@ -286,7 +284,8 @@ mod tests { .parse() .unwrap_or_else(|error| panic!("{error}")), upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: crate::test_support::config("developer"), + admin: None, tls: None, identity: IdentityConfig { issuer: "test-issuer".into(), diff --git a/src/keepass.rs b/src/keepass.rs new file mode 100644 index 0000000..1bc87fe --- /dev/null +++ b/src/keepass.rs @@ -0,0 +1,273 @@ +//! Concrete, locked-on-boot, read-only `KeePass` credential store. + +use crate::config::KeePassConfig; +use anyhow::{Context, Result, bail}; +use keepass::{ + Database, DatabaseKey, + db::{EntryId, fields}, +}; +use secrecy::{ExposeSecret as _, SecretString}; +use std::{ + collections::HashMap, + io::{Cursor, Read as _}, + path::Path, + sync::{ + Mutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, + }, +}; +use zeroize::Zeroizing; + +/// Data-free failures that cannot disclose database content or entry identifiers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreError { + /// No decrypted credentials are held in this process. + Locked, + /// A private input or the store state is unavailable. + Unavailable, + /// The policy reference is not mapped. + ReferenceNotMapped, + /// A mapped password is absent, unprotected, empty, or oversized. + SecretUnavailable, + /// The password/key cannot open the database, or its format is invalid. + UnlockFailed, +} +impl std::fmt::Display for StoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Locked => "KeePass store is locked", + Self::Unavailable => "KeePass store is unavailable", + Self::ReferenceNotMapped => "secret reference is not mapped", + Self::SecretUnavailable => "mapped password is unavailable", + Self::UnlockFailed => "KeePass unlock failed", + }) + } +} +impl std::error::Error for StoreError {} +/// Result with a closed, non-disclosing error shape. +pub type StoreResult = std::result::Result; + +/// A reference selected by validated local policy, never by a workload. +pub struct SecretRef<'a>(&'a str); +impl<'a> SecretRef<'a> { + /// Borrow a policy reference. + #[must_use] + pub const fn from_policy(value: &'a str) -> Self { + Self(value) + } +} + +/// One realm's concrete `KeePass` store. No decrypted database or password is persisted. +pub struct KeePassStore { + config: KeePassConfig, + values: Mutex>, + changes: tokio::sync::watch::Sender, + generation: AtomicU64, + lookups: AtomicUsize, + unlock_gate: Mutex<()>, +} +struct Snapshot { + values: HashMap, + telegram: Option, +} +impl KeePassStore { + /// Construct a locked store. Database presence is checked on unlock, not startup. + /// + /// # Errors + /// Rejects ambiguous or invalid configuration. + pub fn new(config: KeePassConfig) -> Result { + let persona = config.items.first().map_or("", |i| i.persona.as_str()); + config.validate(persona)?; + Ok(Self { + config, + values: Mutex::new(None), + changes: tokio::sync::watch::channel(0).0, + generation: AtomicU64::new(0), + lookups: AtomicUsize::new(0), + unlock_gate: Mutex::new(()), + }) + } + /// Decrypt on a blocking thread, retain only mapped Password fields, and discard the key. + /// Call this only from the authenticated admin boundary. + /// + /// # Errors + /// Fails closed for invalid keys, inputs, or mapped passwords. A concurrent lock cancels it. + pub fn unlock(&self, password: SecretString) -> StoreResult<()> { + let _gate = self + .unlock_gate + .try_lock() + .map_err(|_| StoreError::Unavailable)?; + let generation = self.generation.load(Ordering::SeqCst); + if password.expose_secret().is_empty() || password.expose_secret().len() > 4096 { + return Err(StoreError::UnlockFailed); + } + let bytes = private_bytes(&self.config.database, 64 * 1024 * 1024)?; + // Restrict to KDBX; legacy KDB and unencrypted XML are not credential sources. + if bytes.get(..8) != Some(&[0x03, 0xd9, 0xa2, 0x9a, 0x67, 0xfb, 0x4b, 0xb5]) { + return Err(StoreError::UnlockFailed); + } + let mut key = DatabaseKey::new().with_password(password.expose_secret()); + drop(password); + if let Some(path) = &self.config.key_file { + let key_bytes = private_bytes(path, 1024 * 1024)?; + key = key + .with_keyfile(&mut Cursor::new(key_bytes.as_slice())) + .map_err(|_| StoreError::UnlockFailed)?; + } + let database = Database::open(&mut Cursor::new(bytes.as_slice()), key) + .map_err(|_| StoreError::UnlockFailed)?; + let mut values = HashMap::new(); + for mapping in &self.config.items { + let id = uuid::Uuid::parse_str(&mapping.entry_id) + .map_err(|_| StoreError::ReferenceNotMapped)?; + let entry = database + .entry(EntryId::from_uuid(id)) + .ok_or(StoreError::SecretUnavailable)?; + let value = entry + .fields + .get(fields::PASSWORD) + .ok_or(StoreError::SecretUnavailable)?; + // Protected fields use zeroizing secret-holding memory in the parser as well. + if !value.is_protected() || value.get().is_empty() || value.get().len() > 16384 { + return Err(StoreError::SecretUnavailable); + } + values.insert( + mapping.secret_ref.clone(), + SecretString::from(value.get().clone()), + ); + } + let telegram = self + .config + .telegram_bot_token_entry_id + .as_ref() + .map(|id| { + let id = uuid::Uuid::parse_str(id).map_err(|_| StoreError::SecretUnavailable)?; + let entry = database + .entry(EntryId::from_uuid(id)) + .ok_or(StoreError::SecretUnavailable)?; + let value = entry + .fields + .get(fields::PASSWORD) + .ok_or(StoreError::SecretUnavailable)?; + if !value.is_protected() || !crate::telegram::valid_token(value.get()) { + return Err(StoreError::SecretUnavailable); + } + Ok(SecretString::from(value.get().clone())) + }) + .transpose()?; + drop(database); + let mut current = self.values.lock().map_err(|_| StoreError::Unavailable)?; + if generation != self.generation.load(Ordering::SeqCst) { + return Err(StoreError::Locked); + } + *current = Some(Snapshot { values, telegram }); + self.changes + .send_modify(|epoch| *epoch = epoch.wrapping_add(1)); + Ok(()) + } + /// Drop retained credentials and prevent an in-flight unlock from committing. + /// + /// # Errors + /// Reports unavailable state without disclosing values. + pub fn lock(&self) -> StoreResult<()> { + let mut values = self.values.lock().map_err(|_| StoreError::Unavailable)?; + self.generation.fetch_add(1, Ordering::SeqCst); + *values = None; + self.changes + .send_modify(|epoch| *epoch = epoch.wrapping_add(1)); + Ok(()) + } + /// Check unlocked state without accessing any entry. + /// + /// # Errors + /// Reports locked or unavailable state. + pub fn health(&self) -> StoreResult<()> { + if self + .values + .lock() + .map_err(|_| StoreError::Unavailable)? + .is_some() + { + Ok(()) + } else { + Err(StoreError::Locked) + } + } + /// Resolve only a mapped reference from the unlocked in-memory snapshot. + /// + /// # Errors + /// Denies locked or unmapped access. + pub fn resolve(&self, reference: &SecretRef<'_>) -> StoreResult { + self.lookups.fetch_add(1, Ordering::Relaxed); + let state = self.values.lock().map_err(|_| StoreError::Unavailable)?; + state + .as_ref() + .ok_or(StoreError::Locked)? + .values + .get(reference.0) + .cloned() + .ok_or(StoreError::ReferenceNotMapped) + } + pub(crate) fn has_telegram_entry(&self) -> bool { + self.config.telegram_bot_token_entry_id.is_some() + } + pub(crate) fn telegram_token(&self) -> StoreResult { + let state = self.values.lock().map_err(|_| StoreError::Unavailable)?; + state + .as_ref() + .ok_or(StoreError::Locked)? + .telegram + .clone() + .ok_or(StoreError::SecretUnavailable) + } + pub(crate) fn changes(&self) -> tokio::sync::watch::Receiver { + self.changes.subscribe() + } + /// Aggregate resolution attempts, containing no credential or reference data. + #[must_use] + pub fn lookup_count(&self) -> usize { + self.lookups.load(Ordering::Relaxed) + } +} + +/// Read one bounded private regular file without accepting symlinks or an inode swap. +fn private_bytes(path: &Path, limit: u64) -> StoreResult>> { + ensure_private_file(path, "KeePass input").map_err(|_| StoreError::Unavailable)?; + let before = std::fs::symlink_metadata(path).map_err(|_| StoreError::Unavailable)?; + let file = std::fs::File::open(path).map_err(|_| StoreError::Unavailable)?; + let after = file.metadata().map_err(|_| StoreError::Unavailable)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + if before.ino() != after.ino() || before.dev() != after.dev() { + return Err(StoreError::Unavailable); + } + } + if !after.is_file() || after.len() > limit { + return Err(StoreError::Unavailable); + } + let mut bytes = Zeroizing::new(Vec::new()); + file.take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|_| StoreError::Unavailable)?; + if bytes.len() as u64 > limit { + return Err(StoreError::Unavailable); + } + Ok(bytes) +} + +pub(crate) fn ensure_private_file(path: &Path, description: &str) -> Result<()> { + let metadata = + std::fs::symlink_metadata(path).with_context(|| format!("{description} is unavailable"))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + if metadata.permissions().mode() & 0o077 != 0 { + bail!("{description} must be private"); + } + } + if !metadata.file_type().is_file() { + bail!("{description} must be a regular file"); + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 0ef4d74..91447bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,24 @@ -//! Charon's configuration, credential-provider, and HTTP proxy primitives. +//! Charon's configuration, `KeePass` store, administration, and HTTP proxy primitives. +#[cfg(test)] +extern crate self as charon; + +pub mod admin; pub mod approval; pub mod broker; pub mod ca; pub mod config; pub mod dns; pub mod identity; -pub mod provider; +pub mod keepass; + pub mod proxy; pub mod receipt; pub mod response; +pub mod telegram; +#[cfg(test)] +#[path = "../tests/common/mod.rs"] +mod test_support; pub mod tls; pub mod gateway; diff --git a/src/main.rs b/src/main.rs index c047ba3..38c6435 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,11 @@ use std::{future::IntoFuture as _, path::PathBuf}; use anyhow::{Context, Result, bail, ensure}; use charon::{ + admin::{Admin, AdminConfig}, ca, config::Config, gateway::{Gateway, GatewayConfig}, - provider, + keepass::KeePassStore, proxy::{AppState, app, serve_transparent}, }; use tokio::net::TcpListener; @@ -17,20 +18,11 @@ use tracing_subscriber::EnvFilter; #[tokio::main] #[allow(clippy::too_many_lines)] async fn main() -> Result<()> { + let _ = rustls::crypto::ring::default_provider().install_default(); let command = parse_command()?; - let gateway_command = matches!( - command, - Command::GatewayServe(_) | Command::GatewayValidate(_) - ); tracing_subscriber::fmt() .json() - .with_env_filter(if gateway_command { - // Dependency transport traces can contain URLs/headers. This lane permits - // only Charon's fixed metadata events, irrespective of ambient RUST_LOG. - EnvFilter::new("off,charon=info") - } else { - EnvFilter::try_from_default_env().unwrap_or_else(|_| "charon=info".into()) - }) + .with_env_filter(EnvFilter::new("off,charon=info")) .init(); let config_path = match command { @@ -42,16 +34,24 @@ async fn main() -> Result<()> { Command::GatewayServe(path) => { let config = GatewayConfig::load(&path)?; let listen = config.listen; - let secrets = provider::build(&config.provider)?; - let gateway = std::sync::Arc::new(Gateway::new(config, secrets)?); + let secrets = std::sync::Arc::new(KeePassStore::new(config.keepass.clone())?); + let (mut admin_task, admin_handle, approvals) = + start_admin(config.admin.clone(), secrets.clone())?; + let gateway = + std::sync::Arc::new(Gateway::new(config, secrets)?.with_approvals(approvals)); let listener = TcpListener::bind(listen) .await .context("gateway bind failed")?; info!(%listen, "exclusive workload gateway listening"); - axum::serve(listener, gateway.router()) + let server = axum::serve(listener, gateway.router()) .with_graceful_shutdown(shutdown_signal()) - .await - .context("gateway server failed")?; + .into_future(); + tokio::select! { + result = server => { result.context("gateway server failed")?; } + result = &mut admin_task => { result??; bail!("admin server stopped unexpectedly"); } + } + admin_handle.graceful_shutdown(Some(std::time::Duration::from_secs(5))); + admin_task.await??; return Ok(()); } Command::Healthcheck(url) => return healthcheck(&url).await, @@ -105,7 +105,9 @@ async fn main() -> Result<()> { }; let config = Config::load(&config_path)?; let listen = config.listen; - let secrets = provider::build(&config.provider)?; + let secrets = std::sync::Arc::new(KeePassStore::new(config.keepass.clone())?); + let (mut admin_task, admin_handle, approvals) = + start_admin(config.admin.clone(), secrets.clone())?; let transparent = config .services .iter() @@ -115,7 +117,7 @@ async fn main() -> Result<()> { .map(|address| (service.name.clone(), address)) }) .collect::>(); - let state = std::sync::Arc::new(AppState::new(config, secrets)?); + let state = std::sync::Arc::new(AppState::new(config, secrets)?.with_approvals(approvals)); let listener = TcpListener::bind(listen) .await .with_context(|| format!("failed to bind {listen}"))?; @@ -144,6 +146,7 @@ async fn main() -> Result<()> { tokio::pin!(control); tokio::select! { result = &mut control => result.context("server failed")?, + result = &mut admin_task => { result??; bail!("admin server stopped unexpectedly"); } () = shutdown_signal() => { let _ = shutdown_tx.send(true); control.await.context("server failed during graceful shutdown")?; @@ -158,6 +161,8 @@ async fn main() -> Result<()> { } } let _ = shutdown_tx.send(true); + admin_handle.graceful_shutdown(Some(std::time::Duration::from_secs(5))); + admin_task.await??; while let Some(result) = tasks.join_next().await { result.context("transparent listener task failed")??; } @@ -279,3 +284,37 @@ async fn shutdown_signal() { () = terminate => {}, } } + +/// Start locked-boot administration independently of workload listeners. +type AdminRuntime = ( + tokio::task::JoinHandle>, + axum_server::Handle, + Option>, +); + +fn start_admin( + config: Option, + store: std::sync::Arc, +) -> Result { + let config = + config.context("HTTPS admin configuration is required for interactive KeePass unlock")?; + let origin = config.origin.clone(); + let (admin, enrollment) = Admin::new(config, store)?; + if let Some(code) = enrollment { + use secrecy::ExposeSecret as _; + // Explicit operator-console bootstrap output, never a structured log or Telegram message. + eprintln!( + "Charon enrollment code (expires in five minutes): {}", + code.expose_secret() + ); + } + info!(%origin, "HTTPS admin starting; vault locked"); + let approvals = admin.approvals(); + let handle = axum_server::Handle::new(); + let task_handle = handle.clone(); + Ok(( + tokio::spawn(async move { admin.serve(task_handle).await }), + handle, + approvals, + )) +} diff --git a/src/provider.rs b/src/provider.rs deleted file mode 100644 index 86e76e8..0000000 --- a/src/provider.rs +++ /dev/null @@ -1,402 +0,0 @@ -//! Secret-provider boundary and locked-by-default Vaultwarden implementation. - -use std::{ - collections::HashMap, - path::Path, - sync::{Arc, Mutex}, - time::{Duration, Instant}, -}; - -use anyhow::{Context, Result, bail}; -use async_trait::async_trait; -use secrecy::{ExposeSecret, SecretString}; -use tokio::{process::Command, time::timeout}; -use zeroize::Zeroize; - -use crate::config::{ProviderConfig, VaultwardenConfig}; - -const COMMAND_TIMEOUT: Duration = Duration::from_secs(15); - -/// Provider-neutral failures safe to cross the secret-store adapter boundary. -/// -/// Variants deliberately carry no backend error, account, item, reference, or -/// secret material. Adapters may log only approved aggregate metadata before -/// converting backend failures into one of these values. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ProviderError { - /// The provider requires an operator unlock or session refresh. - Locked, - /// The provider, its client, or its cache cannot currently serve requests. - Unavailable, - /// Trusted policy names no secret in this provider instance. - ReferenceNotMapped, - /// The mapped secret is missing, revoked, empty, or otherwise unusable. - SecretUnavailable, - /// The backend returned a response that violates its adapter contract. - InvalidResponse, -} - -impl std::fmt::Display for ProviderError { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(match self { - Self::Locked => "secret provider is locked", - Self::Unavailable => "secret provider is unavailable", - Self::ReferenceNotMapped => "secret reference is not mapped", - Self::SecretUnavailable => "secret is unavailable", - Self::InvalidResponse => "secret provider returned an invalid response", - }) - } -} - -impl std::error::Error for ProviderError {} - -/// Result type enforced for all secret-store operations. -pub type ProviderResult = std::result::Result; - -/// An opaque, policy-selected provider reference. -/// -/// It is not secret material, but provider errors and audit events must not -/// disclose it because backend identifiers can reveal operational metadata. -pub struct SecretRef<'a>(&'a str); - -impl<'a> SecretRef<'a> { - /// Borrow an already validated reference from trusted Charon policy. - #[must_use] - pub const fn from_policy(value: &'a str) -> Self { - Self(value) - } - - /// Expose the reference to a provider adapter. - #[must_use] - pub const fn as_str(&self) -> &'a str { - self.0 - } -} - -/// Resolves an opaque policy reference to secret material. -/// -/// Implementations are in-process members of Charon's trusted computing base. -/// They must fail closed, return values only in secret-holding types, avoid -/// logging references or values, and perform no caller-selected routing. -#[async_trait] -pub trait SecretProvider: Send + Sync { - /// Check provider readiness without resolving a credential. - async fn health(&self) -> ProviderResult<()> { - Ok(()) - } - - /// Resolve one secret. Implementations must never log the result. - async fn resolve(&self, secret_ref: &SecretRef<'_>) -> ProviderResult; -} - -/// Construct the configured, compile-time registered provider adapter. -/// -/// # Errors -/// -/// Returns an error when the selected adapter cannot validate its protected -/// runtime inputs. Charon does not load dynamic provider code. -pub fn build(config: &ProviderConfig) -> Result> { - match config { - ProviderConfig::Environment => Ok(Arc::new(EnvironmentProvider)), - ProviderConfig::Vaultwarden(provider) => { - Ok(Arc::new(VaultwardenProvider::new(provider.clone())?)) - } - } -} - -/// Prototype provider that reads secrets from Charon's own process environment. -/// -/// This proves that the workload can remain secretless. It is not the final -/// storage design; the self-hosted milestone replaces it with Vaultwarden. -#[derive(Debug, Default)] -pub struct EnvironmentProvider; - -#[async_trait] -impl SecretProvider for EnvironmentProvider { - async fn resolve(&self, secret_ref: &SecretRef<'_>) -> ProviderResult { - std::env::var(secret_ref.as_str()) - .map(SecretString::from) - .map_err(|_| ProviderError::SecretUnavailable) - } -} - -struct CachedSecret { - value: SecretString, - expires_at: Instant, -} - -/// Resolves exact item UUIDs through a short-lived, non-interactive `bw` CLI. -/// -/// The encrypted CLI vault may persist on the Charon host. The unlock session -/// is read from a protected file for each cache miss and exists only in Charon -/// and its short-lived child process, never in a developer workload. -pub struct VaultwardenProvider { - config: VaultwardenConfig, - items: HashMap, - cache: Mutex>, -} - -impl VaultwardenProvider { - /// Construct a provider from already validated configuration. - /// - /// # Errors - /// - /// Returns an error if the CLI is not an executable file or an item mapping - /// is ambiguous. - pub fn new(config: VaultwardenConfig) -> Result { - ensure_executable(&config.cli_path)?; - ensure_private_directory(&config.appdata_dir)?; - if config.session_file.try_exists().unwrap_or(false) { - ensure_private_file(&config.session_file, "Vaultwarden session")?; - } - let mut items = HashMap::new(); - for mapping in &config.items { - if items - .insert(mapping.secret_ref.clone(), mapping.item_id.clone()) - .is_some() - { - bail!("Vaultwarden item mapping is ambiguous"); - } - } - Ok(Self { - config, - items, - cache: Mutex::new(HashMap::new()), - }) - } - - fn cached(&self, secret_ref: &str) -> ProviderResult> { - let mut cache = self.cache.lock().map_err(|_| ProviderError::Unavailable)?; - cache.retain(|_, entry| entry.expires_at > Instant::now()); - Ok(cache.get(secret_ref).map(|entry| entry.value.clone())) - } - - fn session(&self) -> ProviderResult { - ensure_private_file(&self.config.session_file, "Vaultwarden session") - .map_err(|_| ProviderError::Locked)?; - let session = std::fs::read_to_string(&self.config.session_file) - .map_err(|_| ProviderError::Locked)?; - if session.is_empty() || session.chars().any(char::is_whitespace) { - return Err(ProviderError::Locked); - } - Ok(SecretString::from(session)) - } - - async fn command( - &self, - session: &SecretString, - args: &[&str], - ) -> ProviderResult { - let mut command = Command::new(&self.config.cli_path); - command - .args(args) - .env_clear() - .env("PATH", "/usr/bin:/bin") - .env("BW_SESSION", session.expose_secret()) - .env("BITWARDENCLI_APPDATA_DIR", &self.config.appdata_dir) - .kill_on_drop(true); - timeout(COMMAND_TIMEOUT, command.output()) - .await - .map_err(|_| ProviderError::Unavailable)? - .map_err(|_| ProviderError::Unavailable) - } - - async fn ensure_unlocked(&self, session: &SecretString) -> ProviderResult<()> { - let mut output = self.command(session, &["status"]).await?; - if !output.status.success() { - output.stdout.zeroize(); - output.stderr.zeroize(); - return Err(ProviderError::Unavailable); - } - let status: VaultStatus = match serde_json::from_slice(&output.stdout) { - Ok(status) => status, - Err(error) => { - tracing::warn!( - outcome = "vaultwarden_status_invalid", - command_success = output.status.success(), - stdout_bytes = output.stdout.len(), - stderr_bytes = output.stderr.len(), - json_error_line = error.line(), - json_error_column = error.column(), - "Vaultwarden CLI returned an invalid status shape" - ); - output.stdout.zeroize(); - output.stderr.zeroize(); - return Err(ProviderError::InvalidResponse); - } - }; - output.stdout.zeroize(); - output.stderr.zeroize(); - if status.status != "unlocked" { - return Err(ProviderError::Locked); - } - Ok(()) - } - - async fn refresh(&self, session: &SecretString) -> ProviderResult<()> { - let mut output = self.command(session, &["sync"]).await?; - let success = output.status.success(); - output.stdout.zeroize(); - output.stderr.zeroize(); - if !success { - return Err(ProviderError::Unavailable); - } - Ok(()) - } - - async fn item_password( - &self, - session: &SecretString, - item_id: &str, - ) -> ProviderResult { - let mut output = self.command(session, &["get", "password", item_id]).await?; - output.stderr.zeroize(); - if !output.status.success() { - output.stdout.zeroize(); - return Err(ProviderError::SecretUnavailable); - } - let mut value = match String::from_utf8(std::mem::take(&mut output.stdout)) { - Ok(value) => value, - Err(error) => { - let mut bytes = error.into_bytes(); - bytes.zeroize(); - return Err(ProviderError::InvalidResponse); - } - }; - while value.ends_with(['\n', '\r']) { - value.pop(); - } - if value.is_empty() { - value.zeroize(); - return Err(ProviderError::SecretUnavailable); - } - Ok(SecretString::from(value)) - } -} - -#[cfg(unix)] -pub(crate) fn ensure_private_file(path: &Path, description: &str) -> Result<()> { - use std::os::unix::fs::PermissionsExt as _; - - let metadata = - std::fs::symlink_metadata(path).with_context(|| format!("{description} is unavailable"))?; - if !metadata.file_type().is_file() || metadata.permissions().mode() & 0o077 != 0 { - bail!("{description} must be a private regular file"); - } - Ok(()) -} - -#[cfg(not(unix))] -pub(crate) fn ensure_private_file(path: &Path, description: &str) -> Result<()> { - if !path.is_file() { - bail!("{description} must be a private regular file"); - } - Ok(()) -} - -#[cfg(unix)] -fn ensure_private_directory(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt as _; - - let metadata = std::fs::symlink_metadata(path) - .context("Vaultwarden encrypted vault state is unavailable")?; - if !metadata.file_type().is_dir() || metadata.permissions().mode() & 0o077 != 0 { - bail!("Vaultwarden encrypted vault state must be a private directory"); - } - Ok(()) -} - -#[cfg(not(unix))] -fn ensure_private_directory(path: &Path) -> Result<()> { - if !path.is_dir() { - bail!("Vaultwarden encrypted vault state must be a private directory"); - } - Ok(()) -} - -#[cfg(unix)] -fn ensure_executable(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt as _; - - let metadata = std::fs::symlink_metadata(path).context("Vaultwarden CLI is unavailable")?; - let mode = metadata.permissions().mode(); - if !metadata.file_type().is_file() || mode & 0o100 == 0 || mode & 0o022 != 0 { - bail!("Vaultwarden CLI must be a non-writable executable file"); - } - Ok(()) -} - -#[cfg(not(unix))] -fn ensure_executable(path: &Path) -> Result<()> { - if !path.is_file() { - bail!("Vaultwarden CLI must be an executable file"); - } - Ok(()) -} - -#[derive(serde::Deserialize)] -struct VaultStatus { - status: String, -} - -#[async_trait] -impl SecretProvider for VaultwardenProvider { - async fn health(&self) -> ProviderResult<()> { - let session = self.session()?; - self.ensure_unlocked(&session).await - } - - async fn resolve(&self, secret_ref: &SecretRef<'_>) -> ProviderResult { - let secret_ref = secret_ref.as_str(); - let item_id = self - .items - .get(secret_ref) - .ok_or(ProviderError::ReferenceNotMapped)?; - if let Some(value) = self.cached(secret_ref)? { - return Ok(value); - } - let session = self.session()?; - self.ensure_unlocked(&session).await?; - self.refresh(&session).await?; - let value = self.item_password(&session, item_id).await?; - self.cache - .lock() - .map_err(|_| ProviderError::Unavailable)? - .insert( - secret_ref.to_owned(), - CachedSecret { - value: value.clone(), - expires_at: Instant::now() + Duration::from_secs(self.config.cache_ttl_seconds), - }, - ); - Ok(value) - } -} - -#[cfg(test)] -mod tests { - use super::ProviderError; - - #[test] - fn provider_failures_are_closed_and_data_free() { - let failures = [ - (ProviderError::Locked, "secret provider is locked"), - (ProviderError::Unavailable, "secret provider is unavailable"), - ( - ProviderError::ReferenceNotMapped, - "secret reference is not mapped", - ), - (ProviderError::SecretUnavailable, "secret is unavailable"), - ( - ProviderError::InvalidResponse, - "secret provider returned an invalid response", - ), - ]; - - for (failure, expected) in failures { - assert_eq!(failure.to_string(), expected); - assert!(!failure.to_string().contains('/')); - assert!(!failure.to_string().contains(':')); - } - } -} diff --git a/src/proxy.rs b/src/proxy.rs index 2e57a56..ea06b19 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -26,7 +26,7 @@ use crate::{ config::{Config, ServicePolicy}, dns::PinnedResolver, identity::{AuthorizedWorkload, IDENTITY_HEADER, IdentityVerifier}, - provider::{SecretProvider, SecretRef, ensure_private_file}, + keepass::{KeePassStore, SecretRef, ensure_private_file}, receipt::{DataPlaneReceipt, ReceiptJournal, ReceiptOutcome, receipt_stream}, response::{ guard_stream, redact_text_stream, sanitize_headers, sanitize_json_document, @@ -54,7 +54,8 @@ const HOP_BY_HOP_HEADERS: [&str; 10] = [ pub struct AppState { config: Config, client: Client, - secrets: Arc, + secrets: Arc, + approvals: Option>, identities: IdentityVerifier, tls: Option>, receipts: Option, @@ -67,7 +68,7 @@ impl AppState { /// /// Returns an error for invalid policy, upstream-proxy configuration, or /// HTTP client construction failure. - pub fn new(config: Config, secrets: Arc) -> Result { + pub fn new(config: Config, secrets: Arc) -> Result { config.validate()?; let service_hosts = config .services @@ -119,11 +120,18 @@ impl AppState { config, client, secrets, + approvals: None, identities, tls, receipts, }) } + /// Attach the optional local human-decision coordinator. + #[must_use] + pub fn with_approvals(mut self, approvals: Option>) -> Self { + self.approvals = approvals; + self + } } /// Build the HTTP application. @@ -374,7 +382,7 @@ async fn readiness(State(state): State>) -> impl IntoResponse { persona: state.config.realm.persona.clone(), generation: state.config.realm.generation, }; - if state.secrets.health().await.is_err() { + if state.secrets.health().is_err() { return ( StatusCode::SERVICE_UNAVAILABLE, axum::Json(Readiness { @@ -504,10 +512,23 @@ async fn forward_inner_as( return Ok((StatusCode::FORBIDDEN, "capability reference is missing").into_response()); } + anyhow::ensure!( + state.approvals.is_some() + || state + .config + .admin + .as_ref() + .is_none_or(|admin| admin.telegram.is_none()), + "approval channel unavailable" + ); + if let Some(approvals) = &state.approvals { + approvals + .authorize(&identity.capability, parts.method.as_str()) + .await?; + } let secret = state .secrets - .resolve(&SecretRef::from_policy(&policy.secret_ref)) - .await?; + .resolve(&SecretRef::from_policy(&policy.secret_ref))?; if secret.expose_secret().len() > policy.response.max_secret_bytes { anyhow::bail!("resolved credential exceeds the configured bound"); } diff --git a/src/receipt.rs b/src/receipt.rs index 9274a96..7e800d1 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -18,7 +18,7 @@ use sha2::{Digest as _, Sha256}; use tokio::sync::mpsc; use zeroize::Zeroize; -use crate::{config::ReceiptConfig, provider::ensure_private_file}; +use crate::{config::ReceiptConfig, keepass::ensure_private_file}; const MAX_RECEIPT_LINE_BYTES: usize = 64 * 1024; diff --git a/src/telegram.rs b/src/telegram.rs new file mode 100644 index 0000000..684c791 --- /dev/null +++ b/src/telegram.rs @@ -0,0 +1,660 @@ +//! Optional private Telegram allow-once/deny prompts. No vault password enters this channel. +use anyhow::{Result, ensure}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use secrecy::{ExposeSecret as _, SecretString}; +use serde::Deserialize; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; +use tokio::sync::{mpsc, oneshot}; +const APPROVAL_TTL: Duration = Duration::from_secs(120); + +/// Trusted operator settings. The bot token comes only from the unlocked `KeePass` store. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TelegramConfig { + /// One immutable positive numeric private user/chat ID. + pub user_id: i64, + /// Exact capability/route names requiring a fresh human decision for each request. + pub approval_capabilities: Vec, +} +impl TelegramConfig { + /// Validate the private recipient and exact approval policy names. + /// + /// # Errors + /// Rejects invalid recipients, names, and duplicates. + pub fn validate(&self) -> Result<()> { + ensure!( + self.user_id > 0, + "Telegram requires one positive private user ID" + ); + let mut names = HashSet::new(); + for name in &self.approval_capabilities { + ensure!( + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) + && names.insert(name), + "invalid Telegram approval capability" + ); + } + Ok(()) + } + /// Reject approval rules that do not name a configured capability or route. + /// + /// # Errors + /// Rejects unknown policy names. + pub fn validate_inventory<'a>(&self, names: impl Iterator) -> Result<()> { + self.validate()?; + let names: HashSet<_> = names.collect(); + ensure!( + self.approval_capabilities + .iter() + .all(|name| names.contains(name.as_str())), + "Telegram approval policy names an unknown capability" + ); + Ok(()) + } +} +struct Pending { + expires: Instant, + message: Option, + reply: oneshot::Sender, +} +struct Prompt { + token: SecretString, + capability: String, + method: String, +} +/// Local approval coordinator, with bounded pending state and single-use callbacks. +pub struct Telegram { + user: i64, + capabilities: HashSet, + store: Option>, + #[cfg(test)] + token: Option, + client: reqwest::Client, + pending: Mutex>, + send: mpsc::Sender, + receive: tokio::sync::Mutex>, + origin: String, + #[cfg(test)] + api_origin: Option, +} +impl Telegram { + /// Construct a locked channel backed by the operator-only `KeePass` token entry. + /// + /// # Errors + /// Rejects missing entry configuration or invalid approval policy. + pub fn new( + config: &TelegramConfig, + origin: &str, + store: Arc, + ) -> Result> { + ensure!( + store.has_telegram_entry(), + "Telegram KeePass token entry missing" + ); + let mut channel = Self::build(config, origin)?; + Arc::get_mut(&mut channel) + .ok_or_else(|| anyhow::anyhow!("Telegram unavailable"))? + .store = Some(store); + Ok(channel) + } + #[cfg(test)] + fn with_token(config: &TelegramConfig, origin: &str, token: SecretString) -> Result> { + ensure!(valid_token(token.expose_secret()), "invalid Telegram token"); + let mut channel = Self::build(config, origin)?; + Arc::get_mut(&mut channel) + .ok_or_else(|| anyhow::anyhow!("Telegram unavailable"))? + .token = Some(token); + Ok(channel) + } + fn build(config: &TelegramConfig, origin: &str) -> Result> { + config.validate()?; + let mut capabilities = HashSet::new(); + for name in &config.approval_capabilities { + ensure!( + !name.is_empty() + && name.len() <= 128 + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_')) + && capabilities.insert(name.clone()), + "invalid Telegram approval capability" + ); + } + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build()?; + let (send, receive) = mpsc::channel(32); + Ok(Arc::new(Self { + user: config.user_id, + capabilities, + store: None, + #[cfg(test)] + token: None, + client, + pending: Mutex::new(HashMap::new()), + send, + receive: tokio::sync::Mutex::new(receive), + origin: origin.into(), + #[cfg(test)] + api_origin: None, + })) + } + #[cfg(test)] + pub(crate) fn test_coordinator(names: Vec) -> Result> { + Self::with_token( + &TelegramConfig { + user_id: 123, + approval_capabilities: names, + }, + "https://charon.example", + SecretString::from("synthetic:token"), + ) + } + #[cfg(test)] + pub(crate) fn test_api( + config: &TelegramConfig, + origin: &str, + endpoint: &str, + store: Arc, + ) -> Result> { + config.validate()?; + let mut telegram = Self::new(config, origin, store)?; + Arc::get_mut(&mut telegram) + .ok_or_else(|| anyhow::anyhow!("test coordinator shared"))? + .api_origin = Some(endpoint.into()); + Ok(telegram) + } + #[cfg(test)] + pub(crate) fn test_pending_count(&self) -> Result { + Ok(self + .pending + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))? + .len()) + } + #[cfg(test)] + pub(crate) async fn test_decide_next(&self, allow: bool) -> Result { + let prompt = tokio::time::timeout(Duration::from_secs(5), self.receive.lock().await.recv()) + .await? + .ok_or_else(|| anyhow::anyhow!("no prompt"))?; + let hash: [u8; 32] = Sha256::digest(prompt.token.expose_secret().as_bytes()).into(); + let pending = self + .pending + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))? + .remove(&hash) + .ok_or_else(|| anyhow::anyhow!("no pending decision"))?; + pending + .reply + .send(allow) + .map_err(|_| anyhow::anyhow!("request canceled"))?; + Ok(prompt.capability) + } + /// Require an allow-once decision for a configured capability, otherwise return immediately. + /// + /// # Errors + /// Denies timeout, cancellation, queue saturation, channel failure, or a human denial. + pub async fn authorize(&self, capability: &str, method: &str) -> Result<()> { + if !self.capabilities.contains(capability) { + return Ok(()); + } + let mut epoch = self.store.as_ref().map(|store| store.changes()); + if let Some(store) = &self.store { + store.health()?; + } + let generation = epoch.as_ref().map(|epoch| *epoch.borrow()); + let mut bytes = [0u8; 24]; + getrandom::fill(&mut bytes).map_err(|_| anyhow::anyhow!("approval unavailable"))?; + let token = SecretString::from(URL_SAFE_NO_PAD.encode(bytes)); + let hash: [u8; 32] = Sha256::digest(token.expose_secret().as_bytes()).into(); + let (reply, receive) = oneshot::channel(); + { + let mut pending = self + .pending + .lock() + .map_err(|_| anyhow::anyhow!("approval unavailable"))?; + pending.retain(|_, p| p.expires > Instant::now() && !p.reply.is_closed()); + ensure!(pending.len() < 32, "approval capacity unavailable"); + pending.insert( + hash, + Pending { + expires: Instant::now() + APPROVAL_TTL, + message: None, + reply, + }, + ); + } + let sent = self + .send + .try_send(Prompt { + token, + capability: capability.into(), + method: method.into(), + }) + .is_ok(); + let result = if sent { + if let Some(epoch) = epoch.as_mut() { + tokio::select! { + result = tokio::time::timeout(APPROVAL_TTL, receive) => matches!(result, Ok(Ok(true))), + _changed = epoch.changed() => false, + } + } else { + matches!( + tokio::time::timeout(APPROVAL_TTL, receive).await, + Ok(Ok(true)) + ) + } + } else { + false + }; + if let Ok(mut pending) = self.pending.lock() { + pending.remove(&hash); + } + ensure!(result, "human approval denied"); + if let Some(store) = &self.store { + store.health()?; + ensure!( + generation == Some(*store.changes().borrow()), + "approval expired with vault state" + ); + } + Ok(()) + } + /// Run outbound notifications and authenticated callback polling. + /// + /// # Errors + /// Retries channel failures without exposing HTTP errors or bot URLs. + /// Returns only when store-state notifications are unavailable. + pub async fn run(self: Arc) -> Result<()> { + let Some(store) = &self.store else { + return self.run_active().await; + }; + let mut changes = store.changes(); + loop { + changes.borrow_and_update(); + if store.health().is_ok() { + tokio::select! { + result = self.clone().run_active() => { + if result.is_err() { tracing::warn!("Telegram channel unavailable"); } + self.clear_pending(); + tokio::select! { () = tokio::time::sleep(Duration::from_secs(2)) => {}, result = changes.changed() => { result?; } } + } + result = changes.changed() => { result?; self.clear_pending(); } + } + } else { + self.clear_pending(); + changes.changed().await?; + } + } + } + fn clear_pending(&self) { + if let Ok(mut pending) = self.pending.lock() { + pending.clear(); + } + } + async fn run_active(self: Arc) -> Result<()> { + self.call("sendMessage",json!({"chat_id":self.user,"text":format!("Charon vault unlocked. Owner page: {}",self.origin),"link_preview_options":{"is_disabled":true}})).await?; + let mut receiver = self.receive.lock().await; + let mut offset = 0i64; + loop { + tokio::select! { + prompt=receiver.recv()=>{ + let Some(prompt)=prompt else{return Ok(());}; + let hash: [u8;32]=Sha256::digest(prompt.token.expose_secret().as_bytes()).into(); + let live=self.pending.lock().is_ok_and(|p|p.get(&hash).is_some_and(|p|p.expires>Instant::now()&&!p.reply.is_closed())); + if !live{continue;} + let sent=self.call("sendMessage",json!({"chat_id":self.user,"text":format!("Allow one request?\nCapability: {}\nMethod: {}\nExpires in two minutes.",prompt.capability,prompt.method),"reply_markup":{"inline_keyboard":[[{"text":"Allow once","callback_data":format!("a:{}",prompt.token.expose_secret())},{"text":"Deny","callback_data":format!("d:{}",prompt.token.expose_secret())}]]}})).await; + if let Ok(mut pending)=self.pending.lock(){ + match sent.ok().and_then(|v|v.get("message_id").and_then(Value::as_i64)) { + Some(id)=>if let Some(p)=pending.get_mut(&hash){p.message=Some(id);}, + None=>{pending.remove(&hash);} + } + } + } + updates=self.call("getUpdates",json!({"offset":offset,"timeout":10,"allowed_updates":["callback_query"]}))=>{ + match updates { + Ok(Value::Array(updates))=>for update in updates { + if let Some(id)=update.get("update_id").and_then(Value::as_i64){offset=offset.max(id.saturating_add(1));} + if let Some(callback)=update.get("callback_query"){ + let decided=self.callback(callback); + if let Some(id)=callback.get("id").and_then(Value::as_str){let _=self.call("answerCallbackQuery",json!({"callback_query_id":id,"text":if decided{"Decision recorded"}else{"Request expired or unavailable"}})).await;} + if decided && let Some(message)=callback.pointer("/message/message_id").and_then(Value::as_i64){let _=self.call("editMessageReplyMarkup",json!({"chat_id":self.user,"message_id":message,"reply_markup":{"inline_keyboard":[]}})).await;} + } + }, + _=>{tokio::time::sleep(Duration::from_secs(2)).await;} + } + } + } + } + } + fn callback(&self, callback: &Value) -> bool { + if callback.pointer("/from/id").and_then(Value::as_i64) != Some(self.user) + || callback.pointer("/message/chat/id").and_then(Value::as_i64) != Some(self.user) + || callback + .pointer("/message/chat/type") + .and_then(Value::as_str) + != Some("private") + { + return false; + } + let Some(data) = callback.get("data").and_then(Value::as_str) else { + return false; + }; + let Some((decision, token)) = data.split_once(':') else { + return false; + }; + if !matches!(decision, "a" | "d") || token.len() != 32 { + return false; + } + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + let Ok(mut pending) = self.pending.lock() else { + return false; + }; + let Some(p) = pending.get(&hash) else { + return false; + }; + let Some(message) = callback + .pointer("/message/message_id") + .and_then(Value::as_i64) + .filter(|id| *id > 0) + else { + return false; + }; + if p.expires <= Instant::now() || p.reply.is_closed() || p.message != Some(message) { + return false; + } + let Some(p) = pending.remove(&hash) else { + return false; + }; + p.reply.send(decision == "a").is_ok() + } + async fn call(&self, method: &str, body: Value) -> Result { + // Never return/log reqwest errors: Telegram URLs contain the bot token. + let origin = "https://api.telegram.org"; + #[cfg(test)] + let origin = self.api_origin.as_deref().unwrap_or(origin); + let token = if let Some(store) = &self.store { + store.telegram_token()? + } else { + #[cfg(test)] + { + self.token + .clone() + .ok_or_else(|| anyhow::anyhow!("Telegram unavailable"))? + } + #[cfg(not(test))] + { + anyhow::bail!("Telegram unavailable"); + } + }; + let mut response = self + .client + .post(format!("{origin}/bot{}/{method}", token.expose_secret())) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(serde_json::to_vec(&body).map_err(|_| anyhow::anyhow!("Telegram unavailable"))?) + .send() + .await + .map_err(|_| anyhow::anyhow!("Telegram unavailable"))?; + ensure!(response.status().is_success(), "Telegram unavailable"); + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| anyhow::anyhow!("Telegram unavailable"))? + { + ensure!( + bytes.len().saturating_add(chunk.len()) <= 262_144, + "Telegram response too large" + ); + bytes.extend_from_slice(&chunk); + } + let value: Value = + serde_json::from_slice(&bytes).map_err(|_| anyhow::anyhow!("Telegram unavailable"))?; + ensure!( + value.get("ok").and_then(Value::as_bool) == Some(true), + "Telegram unavailable" + ); + Ok(value.get("result").cloned().unwrap_or(Value::Null)) + } +} + +pub(crate) fn valid_token(token: &str) -> bool { + !token.is_empty() + && token.len() <= 256 + && token + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b':' | b'_' | b'-')) +} + +#[cfg(test)] +mod tests { + use super::*; + fn coordinator() -> Result> { + Telegram::with_token( + &TelegramConfig { + user_id: 123, + approval_capabilities: vec!["write".into()], + }, + "https://charon.example", + SecretString::from("synthetic:token"), + ) + } + #[test] + fn configured_channel_requires_operator_entry_without_reading_a_secret_at_boot() -> Result<()> { + let config = TelegramConfig { + user_id: 123, + approval_capabilities: vec!["write".into()], + }; + let mut keepass = crate::test_support::config("developer"); + keepass.items.clear(); + let store = Arc::new(crate::keepass::KeePassStore::new(keepass.clone())?); + assert!(Telegram::new(&config, "https://charon.example", store).is_err()); + keepass.telegram_bot_token_entry_id = Some(uuid::Uuid::from_u128(999).to_string()); + let store = Arc::new(crate::keepass::KeePassStore::new(keepass)?); + Telegram::new(&config, "https://charon.example", store.clone())?; + assert!(store.health().is_err()); + Ok(()) + } + fn callback(token: &str) -> Value { + json!({"from":{"id":123},"message":{"message_id":456,"chat":{"id":123,"type":"private"}},"data":format!("a:{token}")}) + } + fn pending( + t: &Telegram, + token: &str, + message: Option, + expires: Instant, + ) -> Result> { + let (send, receive) = oneshot::channel(); + t.pending + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))? + .insert( + Sha256::digest(token.as_bytes()).into(), + Pending { + expires, + message, + reply: send, + }, + ); + Ok(receive) + } + #[tokio::test] + async fn callback_binds_actor_chat_message_and_consumes_once() -> Result<()> { + let t = coordinator()?; + let token = "abcdefghijklmnopqrstuvwxyz012345"; + let receive = pending(&t, token, Some(456), Instant::now() + APPROVAL_TTL)?; + for pointer in ["/from/id", "/message/chat/id", "/message/message_id"] { + let mut wrong = callback(token); + *wrong + .pointer_mut(pointer) + .ok_or_else(|| anyhow::anyhow!("missing fixture field"))? = json!(999); + assert!(!t.callback(&wrong)); + } + let mut group = callback(token); + group["message"]["chat"]["type"] = json!("group"); + assert!(!t.callback(&group)); + let mut missing = callback(token); + missing["message"]["message_id"] = Value::Null; + assert!(!t.callback(&missing)); + assert!(t.callback(&callback(token))); + assert!(receive.await?); + assert!(!t.callback(&callback(token))); + Ok(()) + } + #[tokio::test] + async fn expired_unsent_canceled_and_denied_requests_fail_closed() -> Result<()> { + let t = coordinator()?; + let token = "abcdefghijklmnopqrstuvwxyz012345"; + let _expired = pending( + &t, + token, + Some(456), + Instant::now() + .checked_sub(Duration::from_secs(1)) + .ok_or_else(|| anyhow::anyhow!("test clock underflow"))?, + )?; + assert!(!t.callback(&callback(token))); + let _unsent = pending(&t, token, None, Instant::now() + APPROVAL_TTL)?; + let mut missing = callback(token); + missing["message"]["message_id"] = Value::Null; + assert!(!t.callback(&missing)); + assert!(!t.callback(&callback(token))); + let canceled = pending(&t, token, Some(456), Instant::now() + APPROVAL_TTL)?; + drop(canceled); + assert!(!t.callback(&callback(token))); + let receive = pending(&t, token, Some(456), Instant::now() + APPROVAL_TTL)?; + let mut deny = callback(token); + deny["data"] = json!(format!("d:{token}")); + assert!(t.callback(&deny)); + assert!(!receive.await?); + Ok(()) + } + #[tokio::test] + async fn authorize_requires_live_decision_and_policy_is_exact() -> Result<()> { + let t = coordinator()?; + t.authorize("read", "GET").await?; + let waiting = t.clone(); + let task = tokio::spawn(async move { waiting.authorize("write", "POST").await }); + let prompt = t + .receive + .lock() + .await + .recv() + .await + .ok_or_else(|| anyhow::anyhow!("no prompt"))?; + let token = prompt.token.expose_secret(); + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + t.pending + .lock() + .map_err(|_| anyhow::anyhow!("test mutex"))? + .get_mut(&hash) + .ok_or_else(|| anyhow::anyhow!("no pending request"))? + .message = Some(456); + assert!(t.callback(&callback(token))); + task.await??; + let cfg = TelegramConfig { + user_id: 123, + approval_capabilities: vec!["typo".into()], + }; + assert!(cfg.validate_inventory(["write"].into_iter()).is_err()); + Ok(()) + } + #[tokio::test] + async fn worker_sends_boot_url_and_buttons_and_polls_the_decision() -> Result<()> { + use axum::{Json, Router, extract::State, routing::post}; + #[derive(Clone)] + struct Api { + sent: mpsc::Sender, + callbacks: Arc>>, + } + async fn send(State(api): State, Json(body): Json) -> Json { + let _ = api.sent.send(body).await; + Json(json!({"ok":true,"result":{"message_id":456}})) + } + async fn updates(State(api): State) -> Json { + let callback = + tokio::time::timeout(Duration::from_millis(20), api.callbacks.lock().await.recv()) + .await; + Json( + json!({"ok":true,"result":match callback {Ok(Some(value))=>vec![json!({"update_id":1,"callback_query":value})],_=>vec![]}}), + ) + } + async fn ack() -> Json { + Json(json!({"ok":true,"result":true})) + } + let (sent, mut messages) = mpsc::channel(4); + let (callbacks, incoming) = mpsc::channel(4); + let api = Api { + sent, + callbacks: Arc::new(tokio::sync::Mutex::new(incoming)), + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let router = Router::new() + .route("/botsynthetic:token/sendMessage", post(send)) + .route("/botsynthetic:token/getUpdates", post(updates)) + .route("/botsynthetic:token/answerCallbackQuery", post(ack)) + .route("/botsynthetic:token/editMessageReplyMarkup", post(ack)) + .with_state(api); + let api_task = tokio::spawn(async move { axum::serve(listener, router).await }); + let mut t = coordinator()?; + Arc::get_mut(&mut t) + .ok_or_else(|| anyhow::anyhow!("coordinator shared"))? + .api_origin = Some(format!("http://{address}")); + let worker = t.clone(); + let worker_task = tokio::spawn(async move { worker.run().await }); + let boot = tokio::time::timeout(Duration::from_secs(5), messages.recv()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing boot message"))?; + assert!( + boot["text"] + .as_str() + .is_some_and(|text| text.contains("https://charon.example")) + ); + assert!(boot.get("reply_markup").is_none()); + let gate = t.clone(); + let request = tokio::spawn(async move { gate.authorize("write", "POST").await }); + let prompt = tokio::time::timeout(Duration::from_secs(5), messages.recv()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing approval prompt"))?; + assert_eq!(prompt["chat_id"], json!(123)); + assert_eq!( + prompt["reply_markup"]["inline_keyboard"][0][0]["text"], + json!("Allow once") + ); + let data = prompt["reply_markup"]["inline_keyboard"][0][0]["callback_data"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing callback"))?; + let token = data + .strip_prefix("a:") + .ok_or_else(|| anyhow::anyhow!("missing decision"))?; + // The send response must bind the message before a callback can authorize. + for _ in 0..100 { + let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); + if t.pending + .lock() + .is_ok_and(|p| p.get(&hash).is_some_and(|p| p.message == Some(456))) + { + break; + } + tokio::task::yield_now().await; + } + callbacks.send(callback(token)).await?; + tokio::time::timeout(Duration::from_secs(5), request).await???; + worker_task.abort(); + api_task.abort(); + Ok(()) + } +} diff --git a/src/tls.rs b/src/tls.rs index 22731ec..dce1177 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -10,7 +10,7 @@ use rustls::{ }; use secrecy::{ExposeSecret as _, SecretString}; -use crate::{config::TlsConfig, provider::ensure_private_file}; +use crate::{config::TlsConfig, keepass::ensure_private_file}; /// CA signer that intentionally has no `Debug`, serialization, or response /// conversion implementation. diff --git a/tests/admin_browser.mjs b/tests/admin_browser.mjs new file mode 100644 index 0000000..7a41724 --- /dev/null +++ b/tests/admin_browser.mjs @@ -0,0 +1,66 @@ +// Real Chrome UI/WebAuthn proof, invoked only by the synthetic Rust fixture. +import { readFileSync, writeFileSync } from 'node:fs'; +const input = JSON.parse(readFileSync(0, 'utf8')); +const socket = new WebSocket(input.devtools); +await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); +let nextId = 1; +let failedPath = ""; +const pending = new Map(); +socket.onmessage = event => { + const message = JSON.parse(event.data); + if (message.method === "Network.responseReceived" && message.params.response.status >= 400 && message.params.response.url.startsWith(input.origin + "/api/")) failedPath = new URL(message.params.response.url).pathname + " (" + message.params.response.status + ")"; + const waiter = pending.get(message.id); + if (!waiter) return; + pending.delete(message.id); + if (message.error) waiter.reject(new Error('Chrome protocol request failed')); + else waiter.resolve(message.result); +}; +function command(method, params = {}, sessionId) { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { pending.delete(id); reject(new Error('Chrome protocol timeout')); }, 15000); + pending.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } }); + socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) })); + }); +} +const { targetId } = await command('Target.createTarget', { url: 'about:blank' }); +const { sessionId } = await command('Target.attachToTarget', { targetId, flatten: true }); +const cdp = (method, params) => command(method, params, sessionId); +await cdp('Page.enable'); +await cdp('Network.enable'); +await cdp('WebAuthn.enable'); +await cdp('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } }); +await cdp('Page.navigate', { url: input.origin }); +async function evaluate(expression) { + const result = await cdp('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error('Admin page evaluation failed'); + return result.result.value; +} +async function waitFor(expression) { + for (let i = 0; i < 150; i++) { + if (await evaluate(expression)) return; + await new Promise(resolve => setTimeout(resolve, 100)); + } + const error = await evaluate("document.getElementById('error')?.textContent || ''"); + throw new Error(`Admin UI did not reach expected state${error ? ': ' + error : ''} ${failedPath}`); +} +await waitFor("document.getElementById('enrollment') && !document.getElementById('enrollment').hidden"); +await evaluate(`document.getElementById('code').value = ${JSON.stringify(input.code)}; document.querySelector('#enroll-form button').click()`); +await waitFor("!document.getElementById('vault').hidden && document.getElementById('status').textContent === 'Vault locked'"); +await evaluate(`document.getElementById('password').value = ${JSON.stringify(input.password)}; document.querySelector('#unlock-form button').click()`); +await waitFor("document.getElementById('status').textContent === 'Vault unlocked'"); +if (!await evaluate("document.getElementById('password').value === '' && document.getElementById('code').value === ''")) throw new Error('Admin UI retained secret input'); +await evaluate("document.getElementById('lock').click()"); +await waitFor("document.getElementById('status').textContent === 'Vault locked' && !document.getElementById('lock').disabled"); +await evaluate("document.getElementById('sign-out').click()"); +await waitFor("!document.getElementById('login').hidden && !document.getElementById('sign-in').disabled"); +await evaluate("document.getElementById('sign-in').click()"); +await waitFor("!document.getElementById('vault').hidden && !document.getElementById('sign-out').disabled"); +if (input.screenshot) { + await cdp('Emulation.setDeviceMetricsOverride', { width: 1000, height: 900, deviceScaleFactor: 1, mobile: false }); + const screenshot = await cdp('Page.captureScreenshot', { format: 'png' }); + writeFileSync(input.screenshot, Buffer.from(screenshot.data, 'base64')); +} +await command('Target.closeTarget', { targetId }); +socket.close(); +console.log('Chrome passkey enrollment, sign-in, unlock, lock, and sign-out passed'); diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..7ba1995 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,76 @@ +//! Synthetic KDBX fixtures shared by transport tests; never real credentials. +#![allow(dead_code)] +use anyhow::Result; +use keepass::{ + Database, DatabaseKey, + db::{EntryId, fields}, +}; +use secrecy::SecretString; +use std::{ + collections::HashMap, + fs::{File, Permissions}, + os::unix::fs::PermissionsExt as _, +}; + +use charon::{ + config::{KeePassConfig, KeePassItemMapping}, + keepass::KeePassStore, +}; + +pub const REFERENCES: &[&str] = &[ + "CHARON_GITHUB_TOKEN", + "CHARON_SYNTHETIC_TEST", + "CHARON_TEST", + "CHARON_ALLOWED_TOKEN", + "CHARON_MOCK_TOKEN", + "CHARON_REDIRECT_TOKEN", + "CHARON_STREAM_TOKEN", + "CHARON_TOKEN", + "fixture/whoami", + "CHARON_FIXTURE_ALICE", + "CHARON_FIXTURE_BOB", +]; +pub fn config(persona: &str) -> KeePassConfig { + KeePassConfig { + database: "/synthetic/test.kdbx".into(), + key_file: None, + telegram_bot_token_entry_id: None, + items: REFERENCES + .iter() + .enumerate() + .map(|(i, r)| KeePassItemMapping { + secret_ref: (*r).into(), + persona: persona.into(), + entry_id: uuid::Uuid::from_u128(i as u128 + 1).to_string(), + }) + .collect(), + } +} +pub fn store(mut values: HashMap) -> Result { + let directory = tempfile::tempdir()?; + let path = directory.path().join("synthetic.kdbx"); + let mut config = config("developer"); + config.database.clone_from(&path); + config.items.retain(|i| values.contains_key(&i.secret_ref)); + let mut db = Database::new(); + for mapping in &config.items { + let mut root = db.root_mut(); + let mut entry = root.add_entry_with_id(EntryId::from_uuid(uuid::Uuid::parse_str( + &mapping.entry_id, + )?))?; + entry.set_protected( + fields::PASSWORD, + values + .remove(&mapping.secret_ref) + .ok_or_else(|| anyhow::anyhow!("missing synthetic value"))?, + ); + } + db.save( + &mut File::create(&path)?, + DatabaseKey::new().with_password("synthetic-unlock-only"), + )?; + std::fs::set_permissions(&path, Permissions::from_mode(0o600))?; + let store = KeePassStore::new(config)?; + store.unlock(SecretString::from("synthetic-unlock-only"))?; + Ok(store) +} diff --git a/tests/connect_proxy.rs b/tests/connect_proxy.rs index 2ad1615..9e595fe 100644 --- a/tests/connect_proxy.rs +++ b/tests/connect_proxy.rs @@ -2,6 +2,8 @@ #![cfg(unix)] +mod common; + use std::{ collections::HashMap, os::unix::fs::PermissionsExt as _, @@ -10,15 +12,13 @@ use std::{ }; use anyhow::{Result, bail}; -use async_trait::async_trait; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use charon::{ config::{ - CapabilityPolicy, Config, IdentityConfig, ProviderConfig, RealmConfig, ReceiptConfig, - ServicePolicy, TlsConfig, + CapabilityPolicy, Config, IdentityConfig, RealmConfig, ReceiptConfig, ServicePolicy, + TlsConfig, }, identity::WorkloadClaims, - provider::{ProviderError, ProviderResult, SecretProvider, SecretRef}, proxy::{AppState, app, serve_transparent}, }; use ed25519_dalek::{Signer as _, SigningKey}; @@ -30,7 +30,6 @@ use rcgen::{ KeyUsagePurpose, }; use rustls::{ClientConfig, RootCertStore, pki_types::ServerName}; -use secrecy::SecretString; use tempfile::TempDir; use tokio::{ io::{AsyncReadExt as _, AsyncWriteExt as _}, @@ -38,20 +37,6 @@ use tokio::{ }; use tokio_rustls::{TlsConnector, client::TlsStream}; -#[derive(Debug)] -struct StaticProvider(HashMap); - -#[async_trait] -impl SecretProvider for StaticProvider { - async fn resolve(&self, secret_ref: &SecretRef<'_>) -> ProviderResult { - self.0 - .get(secret_ref.as_str()) - .cloned() - .map(SecretString::from) - .ok_or(ProviderError::SecretUnavailable) - } -} - fn signing_key() -> SigningKey { SigningKey::from_bytes(&[21_u8; 32]) } @@ -194,7 +179,8 @@ async fn proxy(tls: TlsConfig) -> Result { realm: realm(), listen: address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: Some(tls), identity: IdentityConfig { issuer: "connect-test".into(), @@ -224,10 +210,10 @@ async fn proxy(tls: TlsConfig) -> Result { transparent_listen: None, }], }; - let provider = StaticProvider(HashMap::from([( + let provider = common::store(HashMap::from([( "CHARON_ALLOWED_TOKEN".into(), "fixture-secret".into(), - )])); + )]))?; let state = Arc::new(AppState::new(config, Arc::new(provider))?); tokio::spawn(async move { axum::serve(listener, app(state)).await }); Ok(address) @@ -242,7 +228,8 @@ async fn transparent_listener_derives_capability_and_denies_unknown_before_egres realm: realm(), listen: "127.0.0.1:0".parse()?, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: Some(tls), identity: IdentityConfig { issuer: "connect-test".into(), @@ -278,10 +265,10 @@ async fn transparent_listener_derives_capability_and_denies_unknown_before_egres }; let state = Arc::new(AppState::new( config, - Arc::new(StaticProvider(HashMap::from([( + Arc::new(common::store(HashMap::from([( "CHARON_ALLOWED_TOKEN".into(), "fixture-secret".into(), - )]))), + )]))?), )?); let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); let task = tokio::spawn(serve_transparent( diff --git a/tests/deploy_redeploy.sh b/tests/deploy_redeploy.sh index 41cf550..c731653 100644 --- a/tests/deploy_redeploy.sh +++ b/tests/deploy_redeploy.sh @@ -9,7 +9,7 @@ trap cleanup EXIT HUP INT TERM deploy_dir="$test_root/deploy" mock_bin="$test_root/bin" mkdir -p "$deploy_dir" "$mock_bin" -printf '%s\n' 'CHARON_BIND_IP=127.0.0.1' 'CHARON_TEST_CREDENTIAL=disposable' >"$deploy_dir/runtime.env" +printf '%s\n' 'CHARON_BIND_IP=127.0.0.1' 'CHARON_ADMIN_BIND_IP=127.0.0.1' >"$deploy_dir/runtime.env" printf '%s\n' 'services: {}' >"$deploy_dir/compose.yaml.next" printf '%s\n' '[server]' >"$deploy_dir/charon.toml.next" @@ -50,6 +50,16 @@ EOF cat >"$mock_bin/curl" <<'EOF' #!/bin/sh +set -eu +found=false +while [ "$#" -gt 0 ]; do + if [ "$1" = --request-target ]; then + [ "$2" = https://api.github.com/user ] + found=true + fi + shift +done +[ "$found" = true ] printf 401 EOF chmod +x "$mock_bin/docker" "$mock_bin/curl" diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..0792218 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,110 @@ +# KeePass, owner admin, approval and proxy E2E + +Run the deterministic local flow: + +```sh +mise run e2e-flow +``` + +Run it plus the real Chrome owner UI proof: + +```sh +mise run e2e +``` + +The connected test requires curl. The browser proof additionally requires Chrome +and Node 22+. Every listener uses a free loopback port. No Docker, live bot, +external API, real KeePass file or real credential is needed. The fixture creates +private files in a disposable system temporary directory and removes them on +exit; it never writes them into the repository. All seeds are synthetic: + +| KDBX input | Value | +| --- | --- | +| Master password | `synthetic-master-e2e-only` | +| Protected Password field, entry UUID `00000000-0000-0000-0000-000000000001` | `synthetic-first-password-e2e` | +| Protected Password field, entry UUID `00000000-0000-0000-0000-000000000002` | `synthetic-second-password-e2e` | +| Operator-only protected bot Password, entry UUID `00000000-0000-0000-0000-000000000003` | `synthetic:token` | + +`fixture.rs` writes and reopens that real encrypted KDBX and verifies the two API +protected fields; the actual bot worker loads the third from the same KDBX. The test rejects plaintext seeds in the encrypted file, +response body, public registration state, and receipt files; it also proves that +Charon never modifies the database. + +`password_api.rs` is a small Rust TLS HTTP server. Its `/first` and `/second` +POST endpoints require the matching Bearer password and return only +`{"ok":true,"endpoint":"first"}` or the corresponding second endpoint JSON. +A wrong credential returns 401 and `ok:false`, without reflecting the password. + +`request.sh` is an ordinary curl workload. It sends public capability references +through Charon's actual explicit proxy and trusts the fixture's public CA. +The fixture and API know the synthetic seed values; the workload script knows +neither password. Charon loads the mapped values from KDBX and writes them only +into the mediated upstream request. Its proxy URL and public CA path are supplied +by the harness; the API and proxy stop after each run. + +`telegram_api.rs` is an offline Bot API transport. The actual Telegram worker +sends notifications and buttons, long-polls updates, and validates callbacks. +The connected test covers locked boot, HTTPS passkey enrollment and sign-in, +unauthenticated/wrong-master rejection, correct unlock, both mapped passwords, +Allow once, Deny, wrong actor, replay, no-decision timeout, explicit lock +canceling an outstanding prompt, no bot activity while locked, +component restart, persisted passkey sign-in and another unlock. A pre-lock +callback cannot approve a request after restart. The bot entry cannot be mapped +to a workload credential reference. Assertions prove +that denied requests never reach the password API and that human denial performs +no credential lookup. Existing callback tests separately cover wrong chat/message +and expired/unsent handles. + +The connected flow uses a cryptographic software WebAuthn authenticator over +real HTTPS. `browser-admin-check` separately exercises the actual HTML/JavaScript +with an isolated Chrome profile and virtual passkey. Test-only seams route the +reserved `password.test` host and Bot API to local fixtures. They do not exist +in the production binary or relax its DNS/HTTPS/configuration rules. This is an +assembled-component E2E proof, not a deployed container or firewall proof. + +## Live Telegram pass + +Create a dedicated test bot using [the BotFather setup guide](../../docs/telegram-setup.md). +Stop other consumers of that bot. Discover your numeric private ID if needed: + +```sh +mise exec -- python tests/e2e/live_telegram.py --discover-owner +``` + +Create a separate operator-owned KDBX in your KeePass application, outside this +repository, with three protected Password fields: `synthetic-first-password-e2e`, +`synthetic-second-password-e2e`, and your dedicated test bot token. Record the +canonical UUID of each entry. Keep the file private (mode `0600`). The harness +reads this file and never writes it, copies it, or adds real tokens to generated +fixtures. Do not use your production vault. + +Then explicitly run the live pass with your actual private ID and entry UUIDs: + +```sh +mise exec -- python tests/e2e/live_telegram.py \ + --user-id 123456789 --database /absolute/path/to/test-bot.kdbx \ + --bot-entry-id YOUR_BOT_ENTRY_UUID \ + --first-entry-id YOUR_FIRST_ENTRY_UUID --second-entry-id YOUR_SECOND_ENTRY_UUID +``` + +The wrapper asks for the KeePass master password without echo and passes it over +child stdin, never through environment variables, command arguments, or a file. +Environment values contain only the public entry UUIDs, file path, and owner ID. +The bot token is read from the supplied encrypted database by the production +store/channel code after HTTPS unlock. Start the bot's private chat first; it +must have no webhook or competing poller. The discovery helper can check the +webhook and obtain candidate IDs with a hidden token prompt; it does not launch +Charon or persist that token. + +The live test runs the password API and proxy against this operator-owned KDBX. +It scripts owner enrollment/unlock and sends real Telegram messages. In your +bot's private chat, press **Allow once** for `first`: curl must get successful +JSON. Then press **Deny** for `second`: curl must get 403 and the second API must +not be called. Each decision expires after two minutes. The unlocked notification +URL uses the reserved fixture hostname; do not use it as a production admin +address. This pass verifies real delivery/buttons/polling; Chrome verifies UI. + +The live test is opt-in and is never run by `mise run check` or `mise run e2e`. +Run it only against a test bot, then rotate/revoke that token if disposing of it. +Cloud TLS issuance, container builds, public DNS and host network isolation need +separate deployment verification. Review the chosen E2E passes before shipping the change. diff --git a/tests/e2e/fixture.rs b/tests/e2e/fixture.rs new file mode 100644 index 0000000..b40cd7a --- /dev/null +++ b/tests/e2e/fixture.rs @@ -0,0 +1,499 @@ +//! Two-entry KDBX and actual HTTPS admin/gateway fixtures. Inputs are synthetic. +use super::{password_api, telegram_api::Bot}; +use crate::{ + admin::{Admin, AdminConfig}, + config::{KeePassConfig, KeePassItemMapping, RealmConfig, ReceiptConfig, TlsConfig}, + gateway::{Gateway, GatewayConfig, Mediation, Route}, + keepass::KeePassStore, + tls::TlsAuthority, +}; +use anyhow::{Result, ensure}; +use keepass::{ + Database, DatabaseKey, + db::{EntryId, fields}, +}; +use secrecy::{ExposeSecret as _, SecretString}; +use serde_json::{Value, json}; +use std::{ + fs::{self, File}, + net::SocketAddr, + path::Path, + sync::{Arc, atomic::AtomicUsize}, +}; +use tokio::net::TcpListener; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +pub const MASTER: &str = "synthetic-master-e2e-only"; +pub const PASSWORDS: [&str; 2] = [ + "synthetic-first-password-e2e", + "synthetic-second-password-e2e", +]; +pub const BOT_TOKEN: &str = "synthetic:token"; +pub const BOT_ENTRY: &str = "00000000-0000-0000-0000-000000000003"; +const ORIGIN: &str = "https://admin.charon.test"; +struct PublicResolver; +impl reqwest::dns::Resolve for PublicResolver { + fn resolve(&self, _: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async { + Ok( + Box::new(vec![SocketAddr::from(([8, 8, 8, 8], 443))].into_iter()) + as reqwest::dns::Addrs, + ) + }) + } +} +fn private(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} +fn database(directory: &Path) -> Result { + let mut database = Database::new(); + let mut items = Vec::new(); + for (index, (name, password)) in ["first", "second"].into_iter().zip(PASSWORDS).enumerate() { + let id = uuid::Uuid::from_u128(index as u128 + 1); + let mut root = database.root_mut(); + let mut entry = root.add_entry_with_id(EntryId::from_uuid(id))?; + entry.set_unprotected(fields::TITLE, format!("E2E {name}")); + entry.set_protected(fields::PASSWORD, password); + items.push(KeePassItemMapping { + secret_ref: format!("e2e/{name}"), + persona: "e2e-owner".into(), + entry_id: id.to_string(), + }); + } + { + let mut root = database.root_mut(); + let mut entry = + root.add_entry_with_id(EntryId::from_uuid(uuid::Uuid::parse_str(BOT_ENTRY)?))?; + entry.set_unprotected(fields::TITLE, "Operator Telegram bot"); + entry.set_protected(fields::PASSWORD, BOT_TOKEN); + } + let path = directory.join("two-passwords.kdbx"); + database.save( + &mut File::create(&path)?, + DatabaseKey::new().with_password(MASTER), + )?; + private(&path)?; + // Reopen the encrypted file to establish that the fixture really contains both entries. + let reopened = Database::open( + &mut File::open(&path)?, + DatabaseKey::new().with_password(MASTER), + )?; + for (index, item) in items.iter().enumerate() { + let entry = reopened + .entry(EntryId::from_uuid(uuid::Uuid::parse_str(&item.entry_id)?)) + .ok_or_else(|| anyhow::anyhow!("fixture entry absent"))?; + let password = entry + .fields + .get(fields::PASSWORD) + .ok_or_else(|| anyhow::anyhow!("fixture password absent"))?; + ensure!( + password.is_protected() && password.get() == PASSWORDS[index], + "fixture password invalid" + ); + } + Ok(KeePassConfig { + database: path, + key_file: None, + telegram_bot_token_entry_id: Some(BOT_ENTRY.into()), + items, + }) +} +fn route(name: &str) -> Route { + Route { + name: name.into(), + host: "password.test".into(), + scheme: "https".into(), + methods: vec!["POST".into()], + paths: vec![format!("/{name}")], + path_prefix: None, + allow_query: false, + caller_headers: vec![], + session_response_headers: vec![], + mediation: Mediation::Header { + secret_ref: format!("e2e/{name}"), + name: "authorization".into(), + value_template: "Bearer {secret}".into(), + }, + max_request_bytes: 4096, + max_response_bytes: 4096, + max_duration_seconds: 2, + idle_timeout_seconds: 1, + } +} +pub struct Fixture { + pub directory: tempfile::TempDir, + pub store: Arc, + pub calls: Arc, + pub bot: Bot, + pub proxy: SocketAddr, + pub admin_client: reqwest::Client, + pub admin_config: AdminConfig, + pub code: Option, + pub auth: WebauthnAuthenticator, + pub session: Option, + admin_handle: axum_server::Handle, + admin_task: tokio::task::JoinHandle>, + gateway_task: tokio::task::JoinHandle<()>, + website_task: tokio::task::JoinHandle<()>, + website: SocketAddr, + config: GatewayConfig, + live: bool, +} +impl Drop for Fixture { + fn drop(&mut self) { + self.admin_handle.shutdown(); + self.admin_task.abort(); + self.gateway_task.abort(); + self.website_task.abort(); + self.bot.task.abort(); + } +} +impl Fixture { + pub async fn new() -> Result { + Self::start(None).await + } + pub async fn live(user_id: i64) -> Result { + Self::start(Some(user_id)).await + } + #[allow(clippy::too_many_lines)] + async fn start(live_user: Option) -> Result { + let _ = rustls::crypto::ring::default_provider().install_default(); + let directory = tempfile::tempdir()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?; + } + let keepass = if live_user.is_some() { + // The operator owns this encrypted input. Never write real tokens into fixtures. + KeePassConfig { + database: std::path::PathBuf::from(std::env::var("CHARON_E2E_KDBX")?), + key_file: None, + telegram_bot_token_entry_id: Some(std::env::var("CHARON_E2E_BOT_ENTRY")?), + items: ["first", "second"] + .into_iter() + .enumerate() + .map(|(index, name)| KeePassItemMapping { + secret_ref: format!("e2e/{name}"), + persona: "e2e-owner".into(), + entry_id: std::env::var(if index == 0 { + "CHARON_E2E_FIRST_ENTRY" + } else { + "CHARON_E2E_SECOND_ENTRY" + }) + .unwrap_or_default(), + }) + .collect(), + } + } else { + database(directory.path())? + }; + let store = Arc::new(KeePassStore::new(keepass.clone())?); + let certificate = rcgen::generate_simple_self_signed(vec!["admin.charon.test".into()])?; + let certificate_path = directory.path().join("admin-cert.pem"); + let key_path = directory.path().join("admin-key.pem"); + fs::write(&certificate_path, certificate.cert.pem())?; + fs::write(&key_path, certificate.signing_key.serialize_pem())?; + private(&key_path)?; + let mut admin_config = AdminConfig { + listen: "127.0.0.1:0".parse()?, + origin: ORIGIN.into(), + certificate: certificate_path, + private_key: key_path, + passkeys_file: directory.path().join("passkeys.json"), + telegram: Some(Bot::config()), + }; + let mut bot = Bot::start(ORIGIN, store.clone()).await?; + if let Some(user_id) = live_user { + let mut policy = Bot::config(); + policy.user_id = user_id; + bot.channel = crate::telegram::Telegram::new(&policy, ORIGIN, store.clone())?; + admin_config.telegram = Some(policy); + } + let (admin, code) = + Admin::new_for_e2e(admin_config.clone(), store.clone(), bot.channel.clone())?; + let handle = axum_server::Handle::new(); + let task_handle = handle.clone(); + let admin_task = tokio::spawn(async move { admin.serve(task_handle).await }); + let admin_address = + tokio::time::timeout(std::time::Duration::from_secs(5), handle.listening()) + .await? + .ok_or_else(|| anyhow::anyhow!("admin bind failed"))?; + let admin_client = admin_client(&admin_config, admin_address)?; + let tls = TlsConfig { + ca_certificate: directory.path().join("public-ca.pem"), + ca_private_key: directory.path().join("ca-key.pem"), + }; + crate::ca::generate("Synthetic E2E CA", &tls.ca_certificate, &tls.ca_private_key)?; + let authority = TlsAuthority::load(&tls)?; + let calls = Arc::new(AtomicUsize::new(0)); + let (website, website_task) = + password_api::start(&authority, PASSWORDS.map(SecretString::from), calls.clone()) + .await?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let proxy = listener.local_addr()?; + let mut config = GatewayConfig { + version: 1, + realm: RealmConfig { + id: "e2e-realm".into(), + tenant: "e2e-tenant".into(), + persona: "e2e-owner".into(), + generation: 1, + }, + workload: "e2e-workload".into(), + listen: proxy, + exclusive_network: true, + tls: tls.clone(), + keepass, + admin: Some(admin_config.clone()), + receipts: ReceiptConfig { + journal_path: directory.path().join("receipts.jsonl"), + state_path: directory.path().join("receipts.chain"), + queue_capacity: 64, + }, + routes: vec![route("first"), route("second")], + }; + if live_user.is_some() { + for route in &mut config.routes { + route.max_duration_seconds = 180; + } + } + let mut gateway = + Gateway::new(config.clone(), store.clone())?.with_approvals(Some(bot.channel.clone())); + gateway.resolver = Arc::new(PublicResolver); + gateway.client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .proxy(reqwest::Proxy::all(format!("http://{website}"))?) + .add_root_certificate(reqwest::Certificate::from_pem(&fs::read( + &tls.ca_certificate, + )?)?) + .build()?; + let gateway = Arc::new(gateway); + let gateway_task = tokio::spawn(async move { + let _ = axum::serve(listener, gateway.router()).await; + }); + Ok(Self { + directory, + store, + calls, + bot, + proxy, + admin_client, + admin_config, + code, + auth: WebauthnAuthenticator::new(SoftPasskey::new(true)), + session: None, + admin_handle: handle, + admin_task, + gateway_task, + website_task, + website, + config, + live: live_user.is_some(), + }) + } + pub async fn verify_password_api(&self) -> Result<()> { + let client = reqwest::Client::builder() + .no_proxy() + .proxy(reqwest::Proxy::all(format!("http://{}", self.website))?) + .add_root_certificate(reqwest::Certificate::from_pem(&fs::read( + &self.config.tls.ca_certificate, + )?)?) + .build()?; + let response = client + .post("https://password.test/first") + .header( + http::header::AUTHORIZATION, + "Bearer wrong-synthetic-password", + ) + .send() + .await?; + ensure!( + response.status() == 401, + "password API accepted an invalid password" + ); + let body: Value = serde_json::from_str(&response.text().await?)?; + ensure!( + body == json!({"ok":false,"endpoint":"first"}), + "password API returned unexpected denial JSON" + ); + ensure!( + self.calls.load(std::sync::atomic::Ordering::SeqCst) == 1, + "API negative control was not observed" + ); + self.calls.store(0, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + pub async fn post( + &self, + path: &str, + body: Value, + authenticated: bool, + ) -> Result { + let mut request = self + .admin_client + .post(format!("{ORIGIN}/api/{path}")) + .header(http::header::ORIGIN, ORIGIN) + .header(http::header::CONTENT_TYPE, "application/json") + .body(serde_json::to_vec(&body)?); + if authenticated { + request = request.header( + http::header::COOKIE, + self.session + .as_deref() + .ok_or_else(|| anyhow::anyhow!("session missing"))?, + ); + } + Ok(request.send().await?) + } + pub async fn enroll(&mut self) -> Result<()> { + let code = self + .code + .as_ref() + .ok_or_else(|| anyhow::anyhow!("code missing"))?; + let start = self + .post( + "register/start", + json!({"code":code.expose_secret()}), + false, + ) + .await?; + ensure!(start.status().is_success(), "enrollment start failed"); + let start: Value = serde_json::from_str(&start.text().await?)?; + let credential = self.auth.do_registration( + url::Url::parse(ORIGIN)?, + serde_json::from_value(start["options"].clone())?, + )?; + ensure!( + self.post( + "register/finish", + json!({"id":start["id"],"credential":credential}), + false + ) + .await? + .status() + == 204, + "enrollment finish failed" + ); + self.code = None; + self.login().await + } + pub async fn login(&mut self) -> Result<()> { + let start = self.post("login/start", json!({}), false).await?; + ensure!(start.status().is_success(), "login start failed"); + let start: Value = serde_json::from_str(&start.text().await?)?; + let credential = self.auth.do_authentication( + url::Url::parse(ORIGIN)?, + serde_json::from_value(start["options"].clone())?, + )?; + let response = self + .post( + "login/finish", + json!({"id":start["id"],"credential":credential}), + false, + ) + .await?; + ensure!(response.status() == 204, "login finish failed"); + self.session = Some( + response + .headers() + .get(http::header::SET_COOKIE) + .ok_or_else(|| anyhow::anyhow!("cookie missing"))? + .to_str()? + .split(';') + .next() + .ok_or_else(|| anyhow::anyhow!("cookie empty"))? + .into(), + ); + Ok(()) + } + pub fn request(&self, name: &str) -> tokio::task::JoinHandle> { + let proxy = format!("http://{}", self.proxy); + let certificate = self.config.tls.ca_certificate.clone(); + let name = name.to_owned(); + let timeout = if self.live { "130" } else { "10" }; + tokio::spawn(async move { + let output = tokio::process::Command::new("sh") + .arg("tests/e2e/request.sh") + .arg(name) + .env("CHARON_E2E_PROXY_URL", proxy) + .env("CHARON_E2E_CA", certificate) + .env("CHARON_E2E_TIMEOUT", timeout) + .kill_on_drop(true) + .output() + .await?; + ensure!(output.status.success(), "curl transport failed"); + let output = String::from_utf8(output.stdout)?; + let (body, status) = output + .rsplit_once('\n') + .ok_or_else(|| anyhow::anyhow!("HTTP status missing"))?; + Ok((status.parse()?, body.to_owned())) + }) + } + pub async fn restart(&mut self) -> Result<()> { + self.admin_handle.shutdown(); + (&mut self.admin_task).await??; + let store = Arc::new(KeePassStore::new(self.config.keepass.clone())?); + ensure!( + self.store.health().is_err(), + "old store must be locked before restart" + ); + self.store = store; + self.bot.channel = crate::telegram::Telegram::test_api( + &Bot::config(), + ORIGIN, + &self.bot.endpoint, + self.store.clone(), + )?; + let (admin, code) = Admin::new_for_e2e( + self.admin_config.clone(), + self.store.clone(), + self.bot.channel.clone(), + )?; + ensure!(code.is_none(), "restart unexpectedly requested enrollment"); + let handle = axum_server::Handle::new(); + let task_handle = handle.clone(); + self.admin_task = tokio::spawn(async move { admin.serve(task_handle).await }); + let address = tokio::time::timeout(std::time::Duration::from_secs(5), handle.listening()) + .await? + .ok_or_else(|| anyhow::anyhow!("restarted admin bind failed"))?; + self.admin_client = admin_client(&self.admin_config, address)?; + self.admin_handle = handle; + self.session = None; + self.gateway_task.abort(); + let _ = (&mut self.gateway_task).await; + let listener = TcpListener::bind("127.0.0.1:0").await?; + self.proxy = listener.local_addr()?; + self.config.listen = self.proxy; + let mut gateway = Gateway::new(self.config.clone(), self.store.clone())? + .with_approvals(Some(self.bot.channel.clone())); + gateway.resolver = Arc::new(PublicResolver); + gateway.client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .proxy(reqwest::Proxy::all(format!("http://{}", self.website))?) + .add_root_certificate(reqwest::Certificate::from_pem(&fs::read( + &self.config.tls.ca_certificate, + )?)?) + .build()?; + let gateway = Arc::new(gateway); + self.gateway_task = tokio::spawn(async move { + let _ = axum::serve(listener, gateway.router()).await; + }); + Ok(()) + } +} +fn admin_client(config: &AdminConfig, address: SocketAddr) -> Result { + Ok(reqwest::Client::builder() + .no_proxy() + .resolve("admin.charon.test", address) + .add_root_certificate(reqwest::Certificate::from_pem(&fs::read( + &config.certificate, + )?)?) + .build()?) +} diff --git a/tests/e2e/live_telegram.py b/tests/e2e/live_telegram.py new file mode 100644 index 0000000..b43fdd9 --- /dev/null +++ b/tests/e2e/live_telegram.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Explicit live Telegram test using an operator-owned encrypted KeePass file.""" +import argparse +import getpass +import json +import os +from pathlib import Path +import re +import subprocess +import uuid +import urllib.error +import urllib.request + + +class NoRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def call(token, method, payload): + request = urllib.request.Request( + f"https://api.telegram.org/bot{token}/{method}", + data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirects()) + try: + with opener.open(request, timeout=15) as response: + data = response.read(262145) + if len(data) > 262144: + raise ValueError("oversized reply") + reply = json.loads(data) + if reply.get("ok") is not True: + raise ValueError("API denial") + return reply.get("result") + except (urllib.error.URLError, ValueError, OSError): + # HTTP exceptions may contain the token-bearing URL; do not expose them. + raise SystemExit("Telegram API unavailable; check the dedicated test bot and network.") from None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--discover-owner", action="store_true", help="Read recent private-chat numeric IDs; does not configure an owner") + parser.add_argument("--user-id", type=int, help="Explicit positive private owner ID for the live test") + parser.add_argument("--database", type=Path, help="Existing operator-owned KDBX containing the test passwords and bot entry") + parser.add_argument("--bot-entry-id", help="Canonical UUID of the dedicated bot Password entry") + parser.add_argument("--first-entry-id", help="UUID of the first synthetic API password entry") + parser.add_argument("--second-entry-id", help="UUID of the second synthetic API password entry") + args = parser.parse_args() + if args.discover_owner: + token = getpass.getpass("Dedicated TEST bot token (hidden): ") + if not re.fullmatch(r"[A-Za-z0-9:_-]{1,256}", token): + raise SystemExit("Invalid token format.") + info = call(token, "getWebhookInfo", {}) + if not isinstance(info, dict) or info.get("url"): + raise SystemExit("The test bot has a webhook. Use a fresh dedicated bot; Charon requires long polling.") + updates = call(token, "getUpdates", {"timeout": 0, "allowed_updates": ["message"]}) + ids = set() + for update in updates or []: + message = update.get("message", {}) + chat = message.get("chat", {}) + actor = message.get("from", {}) + user = actor.get("id") + if chat.get("type") == "private" and isinstance(user, int) and user > 0 and user == chat.get("id"): + ids.add(user) + if not ids: + raise SystemExit("No private ID found. Stop Charon, send /start to the test bot, then retry.") + print("Recent private user IDs (verify your own ID; none is enrolled automatically):") + for user in sorted(ids): + print(user) + return + if args.user_id is None or args.user_id <= 0: + parser.error("--user-id must explicitly name a positive private owner ID") + if args.database is None or not args.database.is_file() or not args.bot_entry_id: + parser.error("--database and --bot-entry-id are required; provision the KDBX yourself") + for entry in (args.bot_entry_id, args.first_entry_id, args.second_entry_id): + try: + if str(uuid.UUID(entry)) != entry: + raise ValueError() + except (ValueError, TypeError, AttributeError): + parser.error("all three entry IDs must be canonical UUIDs") + password = getpass.getpass("KeePass master password (hidden; child stdin only): ") + if not password or len(password) > 4096 or "\n" in password or "\r" in password: + raise SystemExit("Invalid master password input.") + env = os.environ.copy() + env.pop("CHARON_TELEGRAM_BOT_TOKEN", None) + env["CHARON_E2E_TELEGRAM_USER_ID"] = str(args.user_id) + env["CHARON_E2E_KDBX"] = str(args.database.resolve()) + env["CHARON_E2E_BOT_ENTRY"] = args.bot_entry_id + env["CHARON_E2E_FIRST_ENTRY"] = args.first_entry_id + env["CHARON_E2E_SECOND_ENTRY"] = args.second_entry_id + print("Live test: reads your KDBX without modifying it; sends an unlocked notification and two approval prompts.", flush=True) + command = ["mise", "exec", "--", "cargo", "test", "--lib", "gateway::e2e::live_telegram_allow_and_deny", "--", "--ignored", "--nocapture"] + result = subprocess.run(command, env=env, input=password + "\n", text=True, check=False) + raise SystemExit(result.returncode) + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + raise SystemExit("Live test canceled.") from None diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs new file mode 100644 index 0000000..cde3585 --- /dev/null +++ b/tests/e2e/mod.rs @@ -0,0 +1,294 @@ +//! Connected, offline E2E: encrypted KDBX, passkey HTTPS admin, Telegram worker, +//! curl workload, intercepted TLS, password API, lock and restart. +mod fixture; +mod password_api; +mod telegram_api; +use anyhow::{Result, ensure}; +use fixture::{BOT_TOKEN, Fixture, MASTER, PASSWORDS}; +use serde_json::{Value, json}; +use std::sync::atomic::Ordering; + +async fn decision(fixture: &mut Fixture, name: &str, allow: bool) -> Result<(u16, String)> { + let request = fixture.request(name); + let prompt = fixture.bot.message().await?; + ensure!( + prompt["text"] + .as_str() + .is_some_and(|text| text.contains(&format!("Capability: {name}"))), + "wrong approval capability" + ); + fixture.bot.decide(&prompt, allow, 123).await?; + tokio::time::timeout(std::time::Duration::from_secs(5), request).await?? +} +fn no_values(body: &str) -> Result<()> { + ensure!( + !body.contains(MASTER) + && !body.contains(BOT_TOKEN) + && PASSWORDS.iter().all(|password| !body.contains(password)), + "response disclosed a seeded credential" + ); + Ok(()) +} +#[tokio::test] +#[ignore = "starts local HTTPS services and curl; run mise run e2e-flow"] +#[allow(clippy::too_many_lines)] +async fn two_passwords_approval_and_restart() -> Result<()> { + let mut fixture = Fixture::new().await?; + fixture.verify_password_api().await?; + let encrypted_before = std::fs::read(fixture.directory.path().join("two-passwords.kdbx"))?; + for value in [MASTER, PASSWORDS[0], PASSWORDS[1], BOT_TOKEN] { + ensure!( + !encrypted_before + .windows(value.len()) + .any(|window| window == value.as_bytes()), + "KDBX contains plaintext seed" + ); + } + fixture.bot.assert_idle().await?; + ensure!(fixture.store.health().is_err(), "store should boot locked"); + let (status, body) = fixture.request("first").await??; + ensure!(status == 403, "locked request allowed"); + no_values(&body)?; + ensure!( + fixture.calls.load(Ordering::SeqCst) == 0, + "locked request reached API" + ); + fixture.enroll().await?; + ensure!( + fixture + .post("unlock", json!({"password":MASTER}), false) + .await? + .status() + == 403, + "unauthenticated unlock accepted" + ); + ensure!( + fixture + .post("unlock", json!({"password":"wrong-synthetic-master"}), true) + .await? + .status() + == 403, + "wrong master accepted" + ); + fixture.bot.fail_next_message(); + ensure!( + fixture + .post("unlock", json!({"password":MASTER}), true) + .await? + .status() + == 204, + "correct master rejected" + ); + fixture.store.health()?; + ensure!( + fixture + .admin_client + .get("https://admin.charon.test/api/status") + .send() + .await? + .status() + .is_success(), + "Telegram notification failure stopped HTTPS admin" + ); + let started = fixture.bot.message().await?; + ensure!( + started["text"] + .as_str() + .is_some_and(|text| text.contains("unlocked")), + "unlock notification absent" + ); + for name in ["first", "second"] { + let (status, body) = decision(&mut fixture, name, true).await?; + ensure!(status == 200, "allowed request did not succeed"); + no_values(&body)?; + let body: Value = serde_json::from_str(&body)?; + ensure!( + body == json!({"ok":true,"endpoint":name}), + "API saw the wrong credential or endpoint" + ); + } + ensure!( + fixture.calls.load(Ordering::SeqCst) == 2, + "both API endpoints were not reached" + ); + let lookups = fixture.store.lookup_count(); + let (status, body) = decision(&mut fixture, "first", false).await?; + ensure!(status == 403, "denial ignored"); + no_values(&body)?; + ensure!( + fixture.store.lookup_count() == lookups && fixture.calls.load(Ordering::SeqCst) == 2, + "denial performed lookup or API call" + ); + let waiting = fixture.request("second"); + let prompt = fixture.bot.message().await?; + fixture.bot.decide(&prompt, true, 999).await?; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + ensure!( + !waiting.is_finished() && fixture.calls.load(Ordering::SeqCst) == 2, + "wrong user approved request" + ); + fixture.bot.decide(&prompt, false, 123).await?; + let (status, body) = + tokio::time::timeout(std::time::Duration::from_secs(5), waiting).await???; + ensure!(status == 403, "owner denial ignored"); + no_values(&body)?; + // A consumed callback must not authorize the next request to the same route. + let waiting = fixture.request("second"); + let next = fixture.bot.message().await?; + fixture.bot.decide(&prompt, true, 123).await?; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + ensure!( + !waiting.is_finished() && fixture.calls.load(Ordering::SeqCst) == 2, + "replayed decision approved new request" + ); + fixture.bot.decide(&next, false, 123).await?; + ensure!( + tokio::time::timeout(std::time::Duration::from_secs(5), waiting) + .await??? + .0 + == 403, + "replay denial ignored" + ); + let waiting = fixture.request("first"); + let _ = fixture.bot.message().await?; + let (status, body) = + tokio::time::timeout(std::time::Duration::from_secs(5), waiting).await???; + ensure!( + status == 403 && fixture.calls.load(Ordering::SeqCst) == 2, + "missing decision did not time out closed" + ); + no_values(&body)?; + let interrupted = fixture.request("first"); + let old_prompt = fixture.bot.message().await?; + ensure!( + fixture.post("lock", json!({}), true).await?.status() == 204, + "lock failed" + ); + ensure!( + tokio::time::timeout(std::time::Duration::from_secs(1), interrupted) + .await??? + .0 + == 403, + "lock did not cancel pending approval" + ); + let (status, body) = fixture.request("second").await??; + fixture.bot.assert_idle().await?; + ensure!( + status == 403 && fixture.calls.load(Ordering::SeqCst) == 2, + "lock did not prevent use" + ); + no_values(&body)?; + fixture.restart().await?; + fixture.bot.assert_idle().await?; + ensure!( + fixture.store.health().is_err(), + "restart retained decrypted snapshot" + ); + let (status, body) = fixture.request("first").await??; + ensure!(status == 403, "restart boot allowed use"); + no_values(&body)?; + fixture.login().await?; + ensure!( + fixture + .post("unlock", json!({"password":MASTER}), true) + .await? + .status() + == 204, + "unlock after restart failed" + ); + let _started = fixture.bot.message().await?; + let waiting = fixture.request("second"); + let new_prompt = fixture.bot.message().await?; + fixture.bot.decide(&old_prompt, true, 123).await?; + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + ensure!( + !waiting.is_finished(), + "pre-lock callback authorized after restart" + ); + fixture.bot.decide(&new_prompt, true, 123).await?; + let (status, body) = waiting.await??; + ensure!(status == 200, "second entry unavailable after restart"); + no_values(&body)?; + ensure!( + fixture.calls.load(Ordering::SeqCst) == 3, + "unexpected API request count" + ); + ensure!( + encrypted_before == std::fs::read(fixture.directory.path().join("two-passwords.kdbx"))?, + "Charon modified database" + ); + for path in ["passkeys.json", "receipts.jsonl", "receipts.chain"] { + let bytes = std::fs::read(fixture.directory.path().join(path))?; + for value in [MASTER, PASSWORDS[0], PASSWORDS[1], BOT_TOKEN] { + ensure!( + !bytes + .windows(value.len()) + .any(|window| window == value.as_bytes()), + "persistent state disclosed a seeded credential" + ); + } + } + println!( + "E2E passed: real KDBX with two API passwords and operator bot token, HTTPS passkeys/unlock, curl proxy/API, bot allow/deny/actor/replay/timeout, lock and restart" + ); + Ok(()) +} + +#[tokio::test] +#[ignore = "sends real Telegram test prompts; run the explicit live wrapper"] +async fn live_telegram_allow_and_deny() -> Result<()> { + let user_id = std::env::var("CHARON_E2E_TELEGRAM_USER_ID")?.parse::()?; + ensure!(user_id > 0, "positive private user ID required"); + let password = { + let mut input = zeroize::Zeroizing::new(String::new()); + std::io::stdin().read_line(&mut input)?; + secrecy::SecretString::from(input.trim_end_matches(['\r', '\n']).to_owned()) + }; + let mut fixture = Fixture::live(user_id).await?; + fixture.enroll().await?; + ensure!( + fixture + .post( + "unlock", + json!({"password":secrecy::ExposeSecret::expose_secret(&password)}), + true + ) + .await? + .status() + == 204, + "test unlock failed" + ); + drop(password); + println!( + "In your test bot's private chat, press Allow once for capability first (within two minutes)." + ); + let (status, body) = tokio::time::timeout( + std::time::Duration::from_secs(130), + fixture.request("first"), + ) + .await???; + ensure!( + status == 200 + && serde_json::from_str::(&body)? == json!({"ok":true,"endpoint":"first"}), + "live allow did not reach the password API" + ); + no_values(&body)?; + println!("Now press Deny for capability second."); + let lookups = fixture.store.lookup_count(); + let (status, body) = tokio::time::timeout( + std::time::Duration::from_secs(130), + fixture.request("second"), + ) + .await???; + ensure!( + status == 403 + && fixture.calls.load(Ordering::SeqCst) == 1 + && fixture.store.lookup_count() == lookups, + "live denial performed lookup or reached the API" + ); + no_values(&body)?; + println!( + "Live Telegram E2E passed: first API call allowed, second denied before lookup/egress." + ); + Ok(()) +} diff --git a/tests/e2e/password_api.rs b/tests/e2e/password_api.rs new file mode 100644 index 0000000..0387fed --- /dev/null +++ b/tests/e2e/password_api.rs @@ -0,0 +1,107 @@ +//! Real local TLS password API reached through the fixture CONNECT intermediary. +use crate::tls::TlsAuthority; +use anyhow::Result; +use axum::{ + body::Body, + http::{Response, StatusCode}, +}; +use hyper_util::rt::TokioIo; +use secrecy::{ExposeSecret as _, SecretString}; +use serde_json::json; +use std::{ + convert::Infallible, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, +}; +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::TcpListener, +}; +use tokio_rustls::TlsAcceptor; + +pub async fn start( + authority: &TlsAuthority, + passwords: [SecretString; 2], + calls: Arc, +) -> Result<(std::net::SocketAddr, tokio::task::JoinHandle<()>)> { + let mut server = authority.server_config("password.test")?; + Arc::make_mut(&mut server).alpn_protocols = vec![b"http/1.1".to_vec()]; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let passwords = Arc::new(passwords); + let task = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let server = server.clone(); + let passwords = passwords.clone(); + let calls = calls.clone(); + tokio::spawn(async move { + // Fixture-only connector: no request to a public DNS address is made. + let mut prelude = Vec::new(); + while !prelude.ends_with(b"\r\n\r\n") && prelude.len() < 4096 { + let mut byte = [0]; + if socket.read_exact(&mut byte).await.is_err() { + return; + } + prelude.push(byte[0]); + } + if !prelude.starts_with(b"CONNECT password.test:443 ") { + return; + } + if socket + .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n") + .await + .is_err() + { + return; + } + let Ok(tls) = TlsAcceptor::from(server).accept(socket).await else { + return; + }; + let service = hyper::service::service_fn( + move |request: hyper::Request| { + let passwords = passwords.clone(); + let calls = calls.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + let (name, index) = match request.uri().path() { + "/first" => ("first", 0), + "/second" => ("second", 1), + _ => ("unknown", 0), + }; + let expected = SecretString::from(format!( + "Bearer {}", + passwords[index].expose_secret() + )); + let matched = name != "unknown" + && request.method() == http::Method::POST + && request + .headers() + .get(http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + == Some(expected.expose_secret()); + let body = + Body::from(json!({"ok":matched,"endpoint":name}).to_string()); + let response = Response::builder() + .status(if matched { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + }) + .header("content-type", "application/json") + .body(body); + Ok::<_, Infallible>( + response.unwrap_or_else(|_| Response::new(Body::empty())), + ) + } + }, + ); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service) + .await; + }); + } + }); + Ok((address, task)) +} diff --git a/tests/e2e/request.sh b/tests/e2e/request.sh new file mode 100644 index 0000000..626edf9 --- /dev/null +++ b/tests/e2e/request.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Workload input is public policy identity, never either KeePass password. +set -eu +name=${1:-} +case "$name" in first|second) ;; *) echo 'usage: request.sh first|second' >&2; exit 2;; esac +: "${CHARON_E2E_PROXY_URL:?set the fixture proxy URL}" +: "${CHARON_E2E_CA:?set the public fixture CA file}" +curl --silent --show-error --noproxy '' --max-time "${CHARON_E2E_TIMEOUT:-10}" \ + --proxy "$CHARON_E2E_PROXY_URL" --cacert "$CHARON_E2E_CA" \ + --request POST --header 'Content-Type: application/json' \ + --header "Authorization: Bearer {{charon.$name}}" \ + --data '{"operation":"synthetic-e2e"}' \ + --write-out '\n%{http_code}' "https://password.test/$name" diff --git a/tests/e2e/telegram_api.rs b/tests/e2e/telegram_api.rs new file mode 100644 index 0000000..039b153 --- /dev/null +++ b/tests/e2e/telegram_api.rs @@ -0,0 +1,120 @@ +//! Offline Bot API transport. The production worker handles every callback. +use crate::telegram::{Telegram, TelegramConfig}; +use anyhow::{Result, ensure}; +use axum::{Json, Router, extract::State, routing::post}; +use serde_json::{Value, json}; +use std::sync::{ + Arc, + atomic::{AtomicBool, AtomicI64, Ordering}, +}; +use tokio::{net::TcpListener, sync::mpsc}; +#[derive(Clone)] +struct Api { + messages: mpsc::Sender, + updates: Arc>>, + next: Arc, + fail_next: Arc, +} +async fn send(State(api): State, Json(mut body): Json) -> Json { + if api.fail_next.swap(false, Ordering::SeqCst) { + return Json(json!({"ok":false})); + } + let id = api.next.fetch_add(1, Ordering::SeqCst); + body["message_id"] = json!(id); + let _ = api.messages.send(body).await; + Json(json!({"ok":true,"result":{"message_id":id}})) +} +async fn updates(State(api): State) -> Json { + let update = tokio::time::timeout( + std::time::Duration::from_millis(25), + api.updates.lock().await.recv(), + ) + .await; + Json(json!({"ok":true,"result":match update{Ok(Some(value))=>vec![value],_=>vec![]}})) +} +async fn ack() -> Json { + Json(json!({"ok":true,"result":true})) +} +pub struct Bot { + pub channel: Arc, + pub endpoint: String, + fail_next: Arc, + pub messages: mpsc::Receiver, + updates: mpsc::Sender, + next_update: i64, + pub task: tokio::task::JoinHandle<()>, +} +impl Bot { + pub async fn start(origin: &str, store: Arc) -> Result { + let (messages, receive) = mpsc::channel(32); + let (update_sender, incoming) = mpsc::channel(32); + let fail_next = Arc::new(AtomicBool::new(false)); + let api = Api { + messages, + updates: Arc::new(tokio::sync::Mutex::new(incoming)), + next: Arc::new(AtomicI64::new(1)), + fail_next: fail_next.clone(), + }; + let router = Router::new() + .route("/botsynthetic:token/sendMessage", post(send)) + .route("/botsynthetic:token/getUpdates", post(updates)) + .route("/botsynthetic:token/answerCallbackQuery", post(ack)) + .route("/botsynthetic:token/editMessageReplyMarkup", post(ack)) + .with_state(api); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let task = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + let endpoint = format!("http://{address}"); + let channel = Telegram::test_api(&Self::config(), origin, &endpoint, store)?; + Ok(Self { + channel, + endpoint, + fail_next, + messages: receive, + updates: update_sender, + next_update: 1, + task, + }) + } + pub fn config() -> TelegramConfig { + TelegramConfig { + user_id: 123, + approval_capabilities: vec!["first".into(), "second".into()], + } + } + pub fn fail_next_message(&self) { + self.fail_next.store(true, Ordering::SeqCst); + } + pub async fn assert_idle(&mut self) -> Result<()> { + ensure!( + tokio::time::timeout(std::time::Duration::from_millis(150), self.messages.recv()) + .await + .is_err(), + "locked bot sent a message" + ); + Ok(()) + } + pub async fn message(&mut self) -> Result { + tokio::time::timeout(std::time::Duration::from_secs(5), self.messages.recv()) + .await? + .ok_or_else(|| anyhow::anyhow!("missing bot message")) + } + pub async fn decide(&mut self, prompt: &Value, allow: bool, user: i64) -> Result<()> { + let button = usize::from(!allow); + let data = prompt["reply_markup"]["inline_keyboard"][0][button]["callback_data"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("missing callback"))?; + ensure!( + prompt["chat_id"].as_i64() == Some(123), + "wrong prompt recipient" + ); + let callback = json!({"id":format!("synthetic-{}",self.next_update),"from":{"id":user},"message":{"message_id":prompt["message_id"],"chat":{"id":123,"type":"private"}},"data":data}); + self.updates + .send(json!({"update_id":self.next_update,"callback_query":callback})) + .await?; + self.next_update += 1; + Ok(()) + } +} diff --git a/tests/http_proxy.rs b/tests/http_proxy.rs index 471da02..86ce3a8 100644 --- a/tests/http_proxy.rs +++ b/tests/http_proxy.rs @@ -1,5 +1,6 @@ //! End-to-end tests for the milestone-0 HTTP forward proxy. +mod common; use std::{ collections::HashMap, sync::{ @@ -13,7 +14,6 @@ use std::{ use std::os::unix::fs::PermissionsExt as _; use anyhow::{Result, anyhow}; -use async_trait::async_trait; use axum::{ Router, body::{Body, Bytes}, @@ -25,16 +25,13 @@ use axum::{ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use charon::{ config::{ - CapabilityPolicy, Config, IdentityConfig, ProviderConfig, RealmConfig, ServicePolicy, - UpstreamProxyConfig, + CapabilityPolicy, Config, IdentityConfig, RealmConfig, ServicePolicy, UpstreamProxyConfig, }, identity::WorkloadClaims, - provider::{ProviderError, ProviderResult, SecretProvider, SecretRef}, proxy::{AppState, app}, }; use ed25519_dalek::{Signer as _, SigningKey}; use futures_util::{StreamExt as _, stream}; -use secrecy::SecretString; use tokio::{ io::{AsyncReadExt as _, AsyncWriteExt as _}, net::TcpListener, @@ -42,20 +39,6 @@ use tokio::{ time::{Duration, timeout}, }; -#[derive(Debug)] -struct StaticProvider(HashMap); - -#[async_trait] -impl SecretProvider for StaticProvider { - async fn resolve(&self, secret_ref: &SecretRef<'_>) -> ProviderResult { - self.0 - .get(secret_ref.as_str()) - .cloned() - .map(SecretString::from) - .ok_or(ProviderError::SecretUnavailable) - } -} - fn signing_key() -> SigningKey { SigningKey::from_bytes(&[11_u8; 32]) } @@ -125,7 +108,8 @@ async fn hydrates_capability_reference_without_exposing_secret_to_client() -> Re realm: realm(), listen: proxy_address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: Some(charon::config::ReceiptConfig { @@ -154,10 +138,10 @@ async fn hydrates_capability_reference_without_exposing_secret_to_client() -> Re }; // Tests use an IP destination; production configuration intentionally // rejects hosts containing ports, while URI matching receives host only. - let secrets = StaticProvider(HashMap::from([( + let secrets = common::store(HashMap::from([( "CHARON_MOCK_TOKEN".into(), "real-secret".into(), - )])); + )]))?; let state = Arc::new(AppState::new(config, Arc::new(secrets))?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); @@ -238,7 +222,8 @@ async fn authenticates_to_upstream_proxy_from_a_protected_file() -> Result<()> { username: Some("egress".into()), password_file: Some(password_file), }), - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: None, @@ -261,10 +246,10 @@ async fn authenticates_to_upstream_proxy_from_a_protected_file() -> Result<()> { transparent_listen: None, }], }; - let secrets = StaticProvider(HashMap::from([( + let secrets = common::store(HashMap::from([( "CHARON_TOKEN".into(), "synthetic-token".into(), - )])); + )]))?; let state = Arc::new(AppState::new(config, Arc::new(secrets))?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); @@ -293,7 +278,8 @@ async fn denies_unlisted_destinations() -> Result<()> { realm: realm(), listen: proxy_address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: None, @@ -316,7 +302,7 @@ async fn denies_unlisted_destinations() -> Result<()> { transparent_listen: None, }], }; - let secrets = StaticProvider(HashMap::from([("CHARON_TOKEN".into(), "secret".into())])); + let secrets = common::store(HashMap::from([("CHARON_TOKEN".into(), "secret".into())]))?; let state = Arc::new(AppState::new(config, Arc::new(secrets))?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); @@ -332,19 +318,6 @@ async fn denies_unlisted_destinations() -> Result<()> { Ok(()) } -#[derive(Debug)] -struct CountingProvider { - calls: Arc, -} - -#[async_trait] -impl SecretProvider for CountingProvider { - async fn resolve(&self, _secret_ref: &SecretRef<'_>) -> ProviderResult { - self.calls.fetch_add(1, Ordering::SeqCst); - Ok(SecretString::from("must-not-be-read")) - } -} - #[tokio::test] async fn rejects_identity_before_resolving_a_credential() -> Result<()> { let upstream_listener = TcpListener::bind("127.0.0.1:0").await?; @@ -355,7 +328,8 @@ async fn rejects_identity_before_resolving_a_credential() -> Result<()> { realm: realm(), listen: proxy_address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: None, @@ -378,11 +352,12 @@ async fn rejects_identity_before_resolving_a_credential() -> Result<()> { transparent_listen: None, }], }; - let calls = Arc::new(AtomicUsize::new(0)); - let provider = CountingProvider { - calls: calls.clone(), - }; - let state = Arc::new(AppState::new(config, Arc::new(provider))?); + let provider = Arc::new(common::store(HashMap::from([( + "CHARON_MOCK_TOKEN".into(), + "synthetic-not-resolved".into(), + )]))?); + let calls = Arc::clone(&provider); + let state = Arc::new(AppState::new(config, provider)?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); let client = reqwest::Client::builder() @@ -395,7 +370,7 @@ async fn rejects_identity_before_resolving_a_credential() -> Result<()> { .await?; assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED); - assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(calls.lookup_count(), 0); Ok(()) } @@ -407,7 +382,8 @@ async fn rejects_declared_oversize_before_credential_resolution() -> Result<()> realm: realm(), listen: proxy_address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: None, @@ -430,11 +406,12 @@ async fn rejects_declared_oversize_before_credential_resolution() -> Result<()> transparent_listen: None, }], }; - let calls = Arc::new(AtomicUsize::new(0)); - let provider = CountingProvider { - calls: Arc::clone(&calls), - }; - let state = Arc::new(AppState::new(config, Arc::new(provider))?); + let provider = Arc::new(common::store(HashMap::from([( + "CHARON_MOCK_TOKEN".into(), + "synthetic-not-resolved".into(), + )]))?); + let calls = Arc::clone(&provider); + let state = Arc::new(AppState::new(config, provider)?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); let manifest = token("upload", "nonce-oversize-1234")?; @@ -450,7 +427,7 @@ async fn rejects_declared_oversize_before_credential_resolution() -> Result<()> let mut response = Vec::new(); timeout(Duration::from_secs(2), stream.read_to_end(&mut response)).await??; assert!(response.starts_with(b"HTTP/1.1 413")); - assert_eq!(calls.load(Ordering::SeqCst), 0); + assert_eq!(calls.lookup_count(), 0); Ok(()) } @@ -480,7 +457,8 @@ async fn never_follows_upstream_redirects_with_an_injected_credential() -> Resul realm: realm(), listen: proxy_address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: None, @@ -503,10 +481,10 @@ async fn never_follows_upstream_redirects_with_an_injected_credential() -> Resul transparent_listen: None, }], }; - let secrets = StaticProvider(HashMap::from([( + let secrets = common::store(HashMap::from([( "CHARON_REDIRECT_TOKEN".into(), "fixture-secret".into(), - )])); + )]))?; let state = Arc::new(AppState::new(config, Arc::new(secrets))?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); @@ -577,7 +555,8 @@ async fn streams_request_and_response_without_full_body_buffering() -> Result<() realm: realm(), listen: proxy_address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config("developer"), + admin: None, tls: None, identity: identity_config(), receipts: None, @@ -600,10 +579,10 @@ async fn streams_request_and_response_without_full_body_buffering() -> Result<() transparent_listen: None, }], }; - let secrets = StaticProvider(HashMap::from([( + let secrets = common::store(HashMap::from([( "CHARON_STREAM_TOKEN".into(), "fixture-secret".into(), - )])); + )]))?; let state = Arc::new(AppState::new(config, Arc::new(secrets))?); tokio::spawn(async move { axum::serve(proxy_listener, app(state)).await }); @@ -664,3 +643,24 @@ async fn streams_request_and_response_without_full_body_buffering() -> Result<() assert!(response.chunk().await?.is_none()); Ok(()) } + +#[tokio::test] +async fn deployment_smoke_target_reaches_identity_check_without_a_secret() -> Result<()> { + use tower::ServiceExt as _; + let mut config = + charon::config::Config::load(std::path::Path::new("deploy/charon.example.toml"))?; + config.receipts = None; + let store = Arc::new(charon::keepass::KeePassStore::new(config.keepass.clone())?); + let state = Arc::new(AppState::new(config, store.clone())?); + let response = app(state) + .oneshot( + http::Request::builder() + .uri("https://api.github.com/user") + .header("host", "api.github.com") + .body(axum::body::Body::empty())?, + ) + .await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(store.lookup_count(), 0); + Ok(()) +} diff --git a/tests/keepass_store.rs b/tests/keepass_store.rs new file mode 100644 index 0000000..18fe521 --- /dev/null +++ b/tests/keepass_store.rs @@ -0,0 +1,214 @@ +//! Real encrypted synthetic KDBX coverage for the concrete store. +#![cfg(unix)] +mod common; +use anyhow::Result; +use charon::{ + config::KeePassConfig, + keepass::{KeePassStore, SecretRef, StoreError}, +}; +use keepass::{ + Database, DatabaseKey, + db::{EntryId, fields}, +}; +use secrecy::{ExposeSecret as _, SecretString}; +use std::{ + collections::HashMap, + fs::{File, Permissions}, + os::unix::fs::{PermissionsExt as _, symlink}, +}; + +struct Fixture { + directory: tempfile::TempDir, + config: KeePassConfig, +} +impl Fixture { + fn new(protected: bool, key_file: bool) -> Result { + let directory = tempfile::tempdir()?; + let mut config = common::config("developer"); + config.items.truncate(1); + config.database = directory.path().join("synthetic.kdbx"); + let mut db = Database::new(); + { + let mut root = db.root_mut(); + let mut entry = root.add_entry_with_id(EntryId::from_uuid(uuid::Uuid::parse_str( + &config.items[0].entry_id, + )?))?; + if protected { + entry.set_protected(fields::PASSWORD, "synthetic-entry-value"); + } else { + entry.set_unprotected(fields::PASSWORD, "synthetic-entry-value"); + } + } + let mut key = DatabaseKey::new().with_password("synthetic-master"); + if key_file { + let path = directory.path().join("synthetic.key"); + std::fs::write(&path, b"synthetic-key-file-material-only")?; + std::fs::set_permissions(&path, Permissions::from_mode(0o600))?; + key = key.with_keyfile(&mut File::open(&path)?)?; + config.key_file = Some(path); + } + db.save(&mut File::create(&config.database)?, key)?; + std::fs::set_permissions(&config.database, Permissions::from_mode(0o600))?; + Ok(Self { directory, config }) + } +} +#[test] +fn boot_locked_wrong_key_rejected_unlock_maps_only_and_lock_discards() -> Result<()> { + let fixture = Fixture::new(true, false)?; + let store = KeePassStore::new(fixture.config.clone())?; + assert_eq!(store.health(), Err(StoreError::Locked)); + assert_eq!( + store.unlock(SecretString::from("wrong-synthetic-master")), + Err(StoreError::UnlockFailed) + ); + assert_eq!(store.health(), Err(StoreError::Locked)); + store.unlock(SecretString::from("synthetic-master"))?; + let value = store.resolve(&SecretRef::from_policy(common::REFERENCES[0]))?; + assert_eq!(value.expose_secret(), "synthetic-entry-value"); + assert!(matches!( + store.resolve(&SecretRef::from_policy("caller-selected")), + Err(StoreError::ReferenceNotMapped) + )); + store.lock()?; + assert!(matches!( + store.resolve(&SecretRef::from_policy(common::REFERENCES[0])), + Err(StoreError::Locked) + )); + assert_eq!( + KeePassStore::new(fixture.config)?.health(), + Err(StoreError::Locked) + ); + Ok(()) +} +#[test] +fn optional_key_file_is_required_and_database_remains_read_only() -> Result<()> { + let fixture = Fixture::new(true, true)?; + let before = std::fs::read(&fixture.config.database)?; + let store = KeePassStore::new(fixture.config.clone())?; + store.unlock(SecretString::from("synthetic-master"))?; + assert_eq!(before, std::fs::read(&fixture.config.database)?); + let mut no_key = fixture.config.clone(); + no_key.key_file = None; + assert_eq!( + KeePassStore::new(no_key)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::UnlockFailed) + ); + if let Some(path) = fixture.config.key_file { + std::fs::write(path, b"different-synthetic-key")?; + } + assert_eq!( + store.unlock(SecretString::from("synthetic-master")), + Err(StoreError::UnlockFailed) + ); + Ok(()) +} +#[test] +fn unsafe_files_unprotected_or_missing_mappings_and_xml_fail_closed() -> Result<()> { + let fixture = Fixture::new(false, false)?; + assert_eq!( + KeePassStore::new(fixture.config)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::SecretUnavailable) + ); + let fixture = Fixture::new(true, false)?; + let mut missing = fixture.config.clone(); + missing.items[0].entry_id = uuid::Uuid::from_u128(999).to_string(); + assert_eq!( + KeePassStore::new(missing)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::SecretUnavailable) + ); + std::fs::set_permissions(&fixture.config.database, Permissions::from_mode(0o644))?; + assert_eq!( + KeePassStore::new(fixture.config.clone())?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::Unavailable) + ); + std::fs::set_permissions(&fixture.config.database, Permissions::from_mode(0o600))?; + let path = fixture.directory.path().join("link.kdbx"); + symlink(&fixture.config.database, &path)?; + let mut linked = fixture.config.clone(); + linked.database = path; + assert_eq!( + KeePassStore::new(linked)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::Unavailable) + ); + std::fs::write(&fixture.config.database, b"")?; + assert_eq!( + KeePassStore::new(fixture.config)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::UnlockFailed) + ); + Ok(()) +} +#[test] +fn transport_fixture_holds_only_a_decrypted_snapshot() -> Result<()> { + let store = common::store(HashMap::from([( + common::REFERENCES[0].into(), + "synthetic".into(), + )]))?; + store.health()?; + store.lock()?; + assert!( + store + .unlock(SecretString::from("synthetic-unlock-only")) + .is_err() + ); + Ok(()) +} + +#[test] +fn telegram_entry_is_operator_only_and_required_protected_on_unlock() -> Result<()> { + let fixture = Fixture::new(true, false)?; + let mut config = fixture.config.clone(); + let id = config.items[0].entry_id.clone(); + config.telegram_bot_token_entry_id = Some(id.clone()); + assert!( + KeePassStore::new(config.clone()).is_err(), + "bot entry reachable by workload" + ); + config.items.clear(); + let store = KeePassStore::new(config.clone())?; + store.unlock(SecretString::from("synthetic-master"))?; + assert!(matches!( + store.resolve(&SecretRef::from_policy(&id)), + Err(StoreError::ReferenceNotMapped) + )); + store.lock()?; + config.telegram_bot_token_entry_id = Some(uuid::Uuid::from_u128(999).to_string()); + let missing = KeePassStore::new(config)?; + assert_eq!( + missing.unlock(SecretString::from("synthetic-master")), + Err(StoreError::SecretUnavailable) + ); + assert_eq!(missing.health(), Err(StoreError::Locked)); + let fixture = Fixture::new(true, false)?; + let mut invalid = fixture.config.clone(); + invalid.telegram_bot_token_entry_id = Some(invalid.items[0].entry_id.clone()); + invalid.items.clear(); + let mut database = Database::open( + &mut File::open(&invalid.database)?, + DatabaseKey::new().with_password("synthetic-master"), + )?; + database + .entry_mut(EntryId::from_uuid(uuid::Uuid::parse_str( + invalid + .telegram_bot_token_entry_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("missing bot ID"))?, + )?)) + .ok_or_else(|| anyhow::anyhow!("missing bot entry"))? + .set_protected(fields::PASSWORD, "invalid token / with spaces"); + database.save( + &mut File::create(&invalid.database)?, + DatabaseKey::new().with_password("synthetic-master"), + )?; + assert_eq!( + KeePassStore::new(invalid)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::SecretUnavailable) + ); + let mut fixture = Fixture::new(false, false)?; + fixture.config.telegram_bot_token_entry_id = Some(fixture.config.items[0].entry_id.clone()); + fixture.config.items.clear(); + assert_eq!( + KeePassStore::new(fixture.config)?.unlock(SecretString::from("synthetic-master")), + Err(StoreError::SecretUnavailable) + ); + Ok(()) +} diff --git a/tests/persona_realms.rs b/tests/persona_realms.rs index f030576..4077faa 100644 --- a/tests/persona_realms.rs +++ b/tests/persona_realms.rs @@ -1,65 +1,24 @@ //! Disposable two-persona realm isolation fixture. +mod common; use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, + sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; use anyhow::Result; -use async_trait::async_trait; use axum::{Router, http::HeaderMap, routing::any}; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use charon::{ - config::{ - CapabilityPolicy, Config, IdentityConfig, ProviderConfig, RealmConfig, ServicePolicy, - }, + config::{CapabilityPolicy, Config, IdentityConfig, RealmConfig, ServicePolicy}, identity::WorkloadClaims, - provider::{ProviderError, ProviderResult, SecretProvider, SecretRef}, + keepass::KeePassStore, proxy::{AppState, app}, }; use ed25519_dalek::{Signer as _, SigningKey}; -use secrecy::SecretString; use serde_json::Value; use tokio::net::TcpListener; -struct DisposableProvider { - value: SecretString, - unavailable: AtomicBool, -} - -impl DisposableProvider { - fn new(value: &str) -> Self { - Self { - value: SecretString::from(value), - unavailable: AtomicBool::new(false), - } - } - - fn set_unavailable(&self, unavailable: bool) { - self.unavailable.store(unavailable, Ordering::SeqCst); - } -} - -#[async_trait] -impl SecretProvider for DisposableProvider { - async fn health(&self) -> ProviderResult<()> { - if self.unavailable.load(Ordering::SeqCst) { - return Err(ProviderError::Unavailable); - } - Ok(()) - } - - async fn resolve(&self, _secret_ref: &SecretRef<'_>) -> ProviderResult { - if self.unavailable.load(Ordering::SeqCst) { - return Err(ProviderError::Unavailable); - } - Ok(self.value.clone()) - } -} - fn signing_key() -> SigningKey { SigningKey::from_bytes(&[31_u8; 32]) } @@ -109,7 +68,8 @@ fn realm_config(address: std::net::SocketAddr, realm: &str, tenant: &str, person }, listen: address, upstream_proxy: None, - provider: ProviderConfig::Environment, + keepass: common::config(persona), + admin: None, tls: None, identity: IdentityConfig { issuer: "fixture-control-plane".into(), @@ -145,7 +105,7 @@ async fn spawn_realm( realm: &str, tenant: &str, persona: &str, - provider: Arc, + provider: Arc, ) -> Result { let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; @@ -186,8 +146,14 @@ async fn two_persona_realms_fail_closed_and_fail_independently() -> Result<()> { })); tokio::spawn(async move { axum::serve(upstream_listener, upstream).await }); - let alice_provider = Arc::new(DisposableProvider::new("alice-fixture-value")); - let bob_provider = Arc::new(DisposableProvider::new("bob-fixture-value")); + let alice_provider = Arc::new(common::store(std::collections::HashMap::from([( + "CHARON_FIXTURE_ALICE".into(), + "alice-fixture-value".into(), + )]))?); + let bob_provider = Arc::new(common::store(std::collections::HashMap::from([( + "CHARON_FIXTURE_BOB".into(), + "bob-fixture-value".into(), + )]))?); let alice = spawn_realm( "realm-alice", "tenant-fixture", @@ -276,7 +242,7 @@ async fn two_persona_realms_fail_closed_and_fail_independently() -> Result<()> { .is_success() ); - alice_provider.set_unavailable(true); + alice_provider.lock()?; assert_eq!( reqwest::get(format!("http://{alice}/readyz")) .await? diff --git a/tests/vaultwarden_provider.rs b/tests/vaultwarden_provider.rs deleted file mode 100644 index 2e6f539..0000000 --- a/tests/vaultwarden_provider.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Disposable Vaultwarden-CLI compatibility fixture tests. - -#![cfg(unix)] - -use std::{os::unix::fs::PermissionsExt as _, path::Path, time::Duration}; - -use anyhow::Result; -use charon::{ - config::{VaultItemMapping, VaultwardenConfig}, - provider::{ProviderError, SecretProvider, SecretRef, VaultwardenProvider}, -}; -use secrecy::ExposeSecret as _; -use tempfile::TempDir; - -const ITEM_ID: &str = "00000000-0000-4000-8000-000000000001"; - -fn write(path: &Path, value: &str) -> Result<()> { - std::fs::write(path, value)?; - Ok(()) -} - -fn fixture() -> Result<(TempDir, VaultwardenConfig)> { - let directory = tempfile::tempdir()?; - let cli = directory.path().join("bw"); - std::fs::copy("tests/fixtures/fake-bw.sh", &cli)?; - let mut permissions = std::fs::metadata(&cli)?.permissions(); - permissions.set_mode(0o700); - std::fs::set_permissions(&cli, permissions)?; - write( - &directory.path().join("expected-session"), - "fixture-session\n", - )?; - write(&directory.path().join("session"), "fixture-session")?; - write(&directory.path().join("secret"), "first-secret\n")?; - std::fs::create_dir(directory.path().join("appdata"))?; - for path in [ - directory.path().join("session"), - directory.path().join("appdata"), - ] { - let mut permissions = std::fs::metadata(&path)?.permissions(); - permissions.set_mode(0o700); - std::fs::set_permissions(path, permissions)?; - } - let config = VaultwardenConfig { - cli_path: cli, - appdata_dir: directory.path().join("appdata"), - session_file: directory.path().join("session"), - cache_ttl_seconds: 1, - items: vec![VaultItemMapping { - secret_ref: "github/developer".into(), - persona: "developer".into(), - item_id: ITEM_ID.into(), - }], - }; - Ok((directory, config)) -} - -#[tokio::test] -async fn cache_is_bounded_and_restart_observes_rotation() -> Result<()> { - let (fixture, config) = fixture()?; - let provider = VaultwardenProvider::new(config.clone())?; - let reference = SecretRef::from_policy("github/developer"); - let first = provider.resolve(&reference).await?; - assert_eq!(first.expose_secret(), "first-secret"); - assert!(!fixture.path().join("inherited-environment").exists()); - - write(&fixture.path().join("secret"), "rotated-secret\n")?; - let cached = provider.resolve(&reference).await?; - assert_eq!(cached.expose_secret(), "first-secret"); - - let restarted = VaultwardenProvider::new(config)?; - let after_restart = restarted.resolve(&reference).await?; - assert_eq!(after_restart.expose_secret(), "rotated-secret"); - - tokio::time::sleep(Duration::from_millis(1_100)).await; - let after_expiry = provider.resolve(&reference).await?; - assert_eq!(after_expiry.expose_secret(), "rotated-secret"); - Ok(()) -} - -#[tokio::test] -async fn satisfies_the_secret_provider_contract() -> Result<()> { - let (_fixture, config) = fixture()?; - let provider = VaultwardenProvider::new(config)?; - - provider.health().await?; - let value = provider - .resolve(&SecretRef::from_policy("github/developer")) - .await?; - assert_eq!(value.expose_secret(), "first-secret"); - - let Err(error) = provider - .resolve(&SecretRef::from_policy( - "policy/reference-that-is-not-mapped", - )) - .await - else { - panic!("an unmapped trusted-policy reference must fail closed"); - }; - assert_eq!(error, ProviderError::ReferenceNotMapped); - assert!(!error.to_string().contains("policy")); - assert!(!error.to_string().contains("github")); - Ok(()) -} - -#[test] -fn rejects_over_permissive_session_and_vault_state() -> Result<()> { - let (_fixture, config) = fixture()?; - let mut permissions = std::fs::metadata(&config.session_file)?.permissions(); - permissions.set_mode(0o644); - std::fs::set_permissions(&config.session_file, permissions)?; - assert!(VaultwardenProvider::new(config.clone()).is_err()); - - let mut permissions = std::fs::metadata(&config.session_file)?.permissions(); - permissions.set_mode(0o600); - std::fs::set_permissions(&config.session_file, permissions)?; - let mut permissions = std::fs::metadata(&config.appdata_dir)?.permissions(); - permissions.set_mode(0o755); - std::fs::set_permissions(&config.appdata_dir, permissions)?; - assert!(VaultwardenProvider::new(config).is_err()); - Ok(()) -} - -#[tokio::test] -async fn lock_outage_and_deleted_item_fail_without_leaking_values() -> Result<()> { - let (fixture, config) = fixture()?; - write(&fixture.path().join("outage"), "")?; - assert!( - VaultwardenProvider::new(config.clone())? - .health() - .await - .is_err() - ); - let Err(error) = VaultwardenProvider::new(config.clone())? - .resolve(&SecretRef::from_policy("github/developer")) - .await - else { - panic!("fixture outage must fail"); - }; - assert_eq!(error, ProviderError::Unavailable); - assert!(!error.to_string().contains("first-secret")); - - std::fs::remove_file(fixture.path().join("outage"))?; - write(&fixture.path().join("deleted"), "")?; - let Err(error) = VaultwardenProvider::new(config.clone())? - .resolve(&SecretRef::from_policy("github/developer")) - .await - else { - panic!("deleted fixture item must fail"); - }; - assert_eq!(error, ProviderError::SecretUnavailable); - - std::fs::remove_file(fixture.path().join("deleted"))?; - std::fs::remove_file(fixture.path().join("session"))?; - let health_provider = VaultwardenProvider::new(config.clone())?; - let Err(error) = health_provider.health().await else { - panic!("missing session must fail readiness"); - }; - assert_eq!(error, ProviderError::Locked); - let Err(error) = VaultwardenProvider::new(config)? - .resolve(&SecretRef::from_policy("github/developer")) - .await - else { - panic!("missing session must stay locked"); - }; - assert_eq!(error, ProviderError::Locked); - Ok(()) -} From 529968e7bcb052f4bef3cbfc29c2cca7daffa381 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:25:41 +0000 Subject: [PATCH 2/2] chore(deps): bump docker/setup-buildx-action from 4.2.0 to 4.4.1 Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 4.2.0 to 4.4.1. - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...f87e5991a6d7451dcb8d9637bfbc97413f497069) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.4.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/image.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index f8a5f07..7fe611c 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - id: image name: Derive fork-local image name run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09741e1..d800336 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,7 +55,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.authorize.outputs.revision }} - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io