From 4f10376061e14e85e55a06a707cccbe35d9dd0db Mon Sep 17 00:00:00 2001 From: Alexander Ververis Date: Mon, 17 Aug 2026 14:57:29 +0800 Subject: [PATCH 01/12] fix: bound structured stream scanning (#52) --- src/response.rs | 115 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 8 deletions(-) diff --git a/src/response.rs b/src/response.rs index c230cf3..baf0878 100644 --- a/src/response.rs +++ b/src/response.rs @@ -207,6 +207,8 @@ where )) } +const STRUCTURED_SCAN_WINDOW_BYTES: usize = 64 * 1024; + /// Parse, sanitize, and emit one bounded SSE event or NDJSON record at a time. pub fn sanitize_structured_stream( upstream: S, @@ -226,14 +228,17 @@ where format, max_record_bytes, forbidden_fields, + record_ready: false, finished: false, }; Box::pin(stream::unfold(state, |mut state| async move { loop { - if let Some((record, consumed)) = - next_record(&state.pending, state.format, state.finished) + if (state.record_ready || state.finished) + && let Some((record, consumed)) = + next_record(&state.pending, state.format, state.finished) { state.pending.drain(..consumed); + state.record_ready = false; if record.len() > state.max_record_bytes { state.pending.zeroize(); state.finished = true; @@ -253,12 +258,21 @@ where return None; } if state.incoming_offset < state.incoming.len() { - state.pending.push(state.incoming[state.incoming_offset]); - state.incoming_offset += 1; - let framing_allowance = match state.format { - StructuredStreamFormat::Sse => 4, - StructuredStreamFormat::Ndjson => 1, - }; + let remaining = &state.incoming[state.incoming_offset..]; + let scan_length = remaining.len().min(STRUCTURED_SCAN_WINDOW_BYTES); + let scan = &remaining[..scan_length]; + let boundary_length = + bytes_through_next_boundary(&state.pending, scan, state.format); + let framing_allowance = framing_allowance(state.format); + let append_limit = state + .max_record_bytes + .saturating_add(framing_allowance) + .saturating_sub(state.pending.len()) + .saturating_add(1); + let append_length = boundary_length.unwrap_or(scan.len()).min(append_limit); + state.pending.extend_from_slice(&scan[..append_length]); + state.incoming_offset += append_length; + state.record_ready = boundary_length == Some(append_length); if state.pending.len() > state.max_record_bytes.saturating_add(framing_allowance) { state.pending.zeroize(); state.finished = true; @@ -269,6 +283,11 @@ where state, )); } + if boundary_length != Some(append_length) + && state.incoming_offset < state.incoming.len() + { + tokio::task::yield_now().await; + } continue; } match state.upstream.next().await { @@ -310,6 +329,7 @@ struct StructuredState { format: StructuredStreamFormat, max_record_bytes: usize, forbidden_fields: Vec, + record_ready: bool, finished: bool, } @@ -319,6 +339,46 @@ impl Drop for StructuredState { } } +const fn framing_allowance(format: StructuredStreamFormat) -> usize { + match format { + StructuredStreamFormat::Sse => 4, + StructuredStreamFormat::Ndjson => 1, + } +} + +fn bytes_through_next_boundary( + pending: &[u8], + incoming: &[u8], + format: StructuredStreamFormat, +) -> Option { + let mut earliest = None; + let delimiters: &[&[u8]] = match format { + StructuredStreamFormat::Sse => &[b"\n\n", b"\r\n\r\n"], + StructuredStreamFormat::Ndjson => &[b"\n"], + }; + for delimiter in delimiters { + for pending_length in 1..delimiter.len() { + let incoming_length = delimiter.len() - pending_length; + if pending.len() >= pending_length + && incoming.len() >= incoming_length + && pending.ends_with(&delimiter[..pending_length]) + && incoming.starts_with(&delimiter[pending_length..]) + { + earliest = Some(earliest.map_or(incoming_length, |current: usize| { + current.min(incoming_length) + })); + } + } + if let Some(index) = find_bytes(incoming, delimiter) { + let through_boundary = index + delimiter.len(); + earliest = Some(earliest.map_or(through_boundary, |current: usize| { + current.min(through_boundary) + })); + } + } + earliest +} + fn next_record( pending: &[u8], format: StructuredStreamFormat, @@ -583,6 +643,45 @@ mod tests { assert_eq!(output, b"data: {\"n\":1}\n\ndata: {\"n\":2}\n\n"); } + #[tokio::test] + async fn structured_stream_rejects_a_large_delimiter_free_chunk() { + let oversized = Bytes::from(vec![b'x'; super::STRUCTURED_SCAN_WINDOW_BYTES * 3]); + let result = super::sanitize_structured_stream( + stream::iter([Ok::<_, std::io::Error>(oversized)]), + crate::broker::StructuredStreamFormat::Ndjson, + super::STRUCTURED_SCAN_WINDOW_BYTES * 2, + Vec::new(), + ) + .collect::>() + .await; + + assert_eq!(result.len(), 1); + assert!(result[0].is_err()); + } + + #[tokio::test] + async fn structured_stream_detects_delimiters_split_between_chunks() { + let upstream = stream::iter([ + Ok::<_, std::io::Error>(Bytes::from_static(b"data: {\"n\":1}\r\n")), + Ok(Bytes::from_static(b"\r\ndata: {\"n\":2}\n")), + Ok(Bytes::from_static(b"\n")), + ]); + let output = super::sanitize_structured_stream( + upstream, + crate::broker::StructuredStreamFormat::Sse, + 32, + Vec::new(), + ) + .collect::>() + .await + .into_iter() + .collect::>>() + .unwrap_or_else(|error| panic!("{error}")) + .concat(); + + assert_eq!(output, b"data: {\"n\":1}\n\ndata: {\"n\":2}\n\n"); + } + #[test] fn strips_sessions_and_denies_reflection_in_retained_headers() { let mut headers = HeaderMap::new(); From ab9ec34ead4ecf21fe6e26ffff4b4a607ceb2f15 Mon Sep 17 00:00:00 2001 From: Alexander Ververis Date: Tue, 18 Aug 2026 13:53:21 +0800 Subject: [PATCH 02/12] feat: establish durable approval broker core (#53) --- Cargo.lock | 197 +++++- Cargo.toml | 4 + contracts/approval-canonicalization.md | 3 +- contracts/approval-channel.md | 14 +- contracts/approval-decision.schema.json | 6 +- deny.toml | 1 + docs/adr/0005-human-approval-broker.md | 30 +- docs/approval-broker-operations.md | 27 +- docs/integration-boundaries.md | 8 + docs/threat-model.md | 7 +- src/approval.rs | 868 ++++++++++++++++++++++++ src/lib.rs | 1 + tests/contracts.rs | 2 +- 13 files changed, 1132 insertions(+), 36 deletions(-) create mode 100644 src/approval.rs diff --git a/Cargo.lock b/Cargo.lock index 7baf071..35dbb1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -232,6 +242,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -243,6 +264,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "charon" version = "0.1.0" @@ -251,8 +285,10 @@ dependencies = [ "async-trait", "axum", "base64 0.23.1", + "chacha20poly1305", "ed25519-dalek", "futures-util", + "getrandom 0.4.3", "http", "http-body-util", "hyper", @@ -260,9 +296,11 @@ dependencies = [ "percent-encoding", "rcgen", "reqwest", + "rusqlite", "rustls", "secrecy", "serde", + "serde_jcs", "serde_json", "sha2", "tempfile", @@ -275,6 +313,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "cmake" version = "0.1.58" @@ -341,6 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -474,6 +524,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -498,6 +560,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -629,12 +697,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "http" version = "1.5.0" @@ -850,7 +936,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", ] [[package]] @@ -947,6 +1042,17 @@ version = "0.2.188" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1083,6 +1189,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1127,6 +1239,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1229,7 +1352,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1342,6 +1465,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1467,6 +1615,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "same-file" version = "1.0.6" @@ -1553,6 +1707,17 @@ dependencies = [ "syn 3.0.2", ] +[[package]] +name = "serde_jcs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cacecf649bc1a7c5f0e299cc813977c6a78116abda2b93b1ee01735b71ead9a8" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -1691,6 +1856,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2071,6 +2248,16 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2101,6 +2288,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 1ed82c7..12e748d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,18 +13,22 @@ anyhow = "1.0" async-trait = "0.1" axum = "0.8" base64 = "0.23" +chacha20poly1305 = "0.10" ed25519-dalek = "2.2" futures-util = "0.3" +getrandom = "0.4" http = "1.4" percent-encoding = "2.3" hyper = { version = "1.8", features = ["client", "http1", "http2", "server"] } hyper-util = { version = "0.1", features = ["tokio"] } rcgen = { version = "0.14", features = ["pem", "x509-parser"] } reqwest = { version = "0.13", default-features = false, features = ["http2", "rustls", "stream"] } +rusqlite = { version = "0.38", features = ["bundled"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } secrecy = "0.10" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_jcs = "0.1" sha2 = "0.10" tokio = { version = "1.0", features = ["macros", "net", "process", "rt-multi-thread", "signal", "time"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } diff --git a/contracts/approval-canonicalization.md b/contracts/approval-canonicalization.md index 48499cf..e47d423 100644 --- a/contracts/approval-canonicalization.md +++ b/contracts/approval-canonicalization.md @@ -55,7 +55,8 @@ Every callback contains only an opaque random callback token. The approval broker resolves that token to one pending request and verifies: - callback token is single-use and unexpired; -- Telegram numeric user and chat IDs match the operator allowlist; +- Telegram reports a private chat and its numeric actor and chat IDs both equal + the adapter's immutable `CHARON_TELEGRAM_USER_ID`; - pending request ID and stored request digest match; - message is the current pending message; - requested grant is permitted for the action and risk tier; and diff --git a/contracts/approval-channel.md b/contracts/approval-channel.md index 935b3c1..184c687 100644 --- a/contracts/approval-channel.md +++ b/contracts/approval-channel.md @@ -6,6 +6,12 @@ Version: 1 first adapter; decision semantics and reusable-rule policy remain channel-independent. +The Telegram adapter reads exactly two identity/credential environment values at +startup: `CHARON_TELEGRAM_BOT_TOKEN` and `CHARON_TELEGRAM_USER_ID`. The user ID +is one positive numeric Telegram ID and is immutable for the process lifetime. +The adapter has no runtime pairing, rebinding, allowlist, group, channel, or +username-based authorization path. + An adapter accepts a presentation model containing: - opaque pending-message ID; @@ -38,8 +44,10 @@ non-reusable and requires an explicit higher-risk confirmation. ## Required behavior -- Authenticate Telegram by operator-configured numeric user and chat IDs, never - usernames or display names. +- Accept a Telegram update only when `chat.type == "private"`, `from.id` equals + `CHARON_TELEGRAM_USER_ID`, and `chat.id` equals that same configured value. + Usernames, display names, groups, channels, lists, pairing, and + first-user-wins behavior are never authorization inputs. - Use random opaque callback tokens; Telegram callback data contains no request digest, identity, command, rule, or secret. - Deliver one mutable message for each pending approval and allow only its @@ -47,7 +55,7 @@ non-reusable and requires an explicit higher-risk confirmation. - Edit the message to a terminal redacted state after decision, timeout, or cancellation. - Treat duplicate, stale, edited, migrated-chat, unknown-user, unknown-chat, - and mismatched-message events as denials. + forwarded/copied, and mismatched-message events as denials. - Bound delivery retries and callback age. - Rate-limit per issuer, tenant, workload, chat, and action to prevent approval notification flooding. diff --git a/contracts/approval-decision.schema.json b/contracts/approval-decision.schema.json index 6b60e14..2d4dd86 100644 --- a/contracts/approval-decision.schema.json +++ b/contracts/approval-decision.schema.json @@ -35,7 +35,7 @@ "properties": { "channel": { "const": "telegram" }, "user_id": { "$ref": "#/$defs/numericIdentifier" }, - "chat_id": { "$ref": "#/$defs/signedNumericIdentifier" } + "chat_id": { "$ref": "#/$defs/numericIdentifier" } } }, "rule_id": { "$ref": "#/$defs/nonce" }, @@ -92,10 +92,6 @@ "type": "string", "pattern": "^[0-9]{1,20}$" }, - "signedNumericIdentifier": { - "type": "string", - "pattern": "^-?[0-9]{1,20}$" - }, "timestamp": { "type": "integer", "minimum": 0 diff --git a/deny.toml b/deny.toml index a70a98e..22948a1 100644 --- a/deny.toml +++ b/deny.toml @@ -10,6 +10,7 @@ allow = [ "ISC", "MIT", "Unicode-3.0", + "Zlib", ] [bans] diff --git a/docs/adr/0005-human-approval-broker.md b/docs/adr/0005-human-approval-broker.md index bf46bdf..322a8a2 100644 --- a/docs/adr/0005-human-approval-broker.md +++ b/docs/adr/0005-human-approval-broker.md @@ -26,8 +26,8 @@ integrating control plane and its workload-identity issuer: 3. The broker atomically evaluates durable, revocable reusable rules. 4. If no rule applies, the broker sends a redacted presentation through an `ApprovalChannel`. -5. An allowlisted numeric Telegram user/chat may deny or choose one of the - broker-provided bounded grant options. +5. The one statically configured numeric Telegram private-chat user may deny or + choose one of the broker-provided bounded grant options. 6. The broker records a terminal decision, optionally creates one structured rule, and signs a short-lived approval assertion with a dedicated Ed25519 key. @@ -64,8 +64,8 @@ content is non-reusable and requires a higher-risk allow-once confirmation. authenticate channel events, persist decisions, enforce expiry/revocation, rate-limit prompts, and protect its signing key. - **Approval channel:** trusted only to deliver a presentation and authenticate - channel-native numeric actor/conversation IDs. It has no rule or assertion - authority. + the one statically configured private user/chat equality invariant. It has no + rule or assertion authority. - **Human approver:** trusted within an operator-defined tenant/persona/risk scope. Account takeover remains an external authorization risk. - **Identity issuer:** trusts the broker's dedicated public key but must recheck @@ -118,12 +118,22 @@ change risk classification or the set of buttons presented. ## Telegram lifecycle -Telegram authorization uses operator-configured numeric user and chat IDs. -Usernames and display names are presentation-only. Bot-token rotation preserves -no pending callback tokens. Bot removal, account recovery, or chat migration -activates emergency disable until an administrator updates the allowlist, -rotates the bot token, invalidates pending requests, and explicitly re-enables -issuance. +The adapter requires `CHARON_TELEGRAM_BOT_TOKEN` and one positive numeric +`CHARON_TELEGRAM_USER_ID` at startup. Every accepted update must be a private +chat whose `from.id` and `chat.id` both equal that configured ID. Usernames, +display names, groups, channels, lists, pairing, first-user-wins behavior, and +runtime rebinding are unsupported. Changing either value requires restart. +Bot-token rotation preserves no pending callback tokens. Bot removal, account +recovery, or chat migration activates emergency disable until an administrator +replaces startup configuration, rotates the bot token, invalidates pending +requests, restarts the adapter, and explicitly re-enables issuance. + +The approval engine has two consumers. Capability approval returns a signed +assertion to an identity issuer, which rechecks lifecycle and policy state and +mints a fresh single-use Charon manifest. Semantic resident-agent tool approval +returns a bounded decision to the local admission service and never creates a +credential capability or bypasses Charon gateway authorization. Session coding +harnesses retain their native inline approval systems. The bot token is a channel credential, never a secret-store backend for Charon. diff --git a/docs/approval-broker-operations.md b/docs/approval-broker-operations.md index dcb84e5..121b380 100644 --- a/docs/approval-broker-operations.md +++ b/docs/approval-broker-operations.md @@ -13,12 +13,15 @@ access. 3. Provision transactional durable storage for requests, callback hashes, decisions, rules, assertion nonces, rate limits, emergency generation, and audit correlation. Encrypt storage and backups with operator-managed keys. -4. Create a Telegram bot and store its token in the broker's protected runtime - secret mechanism. Do not place it in Charon, workload images, repository - settings, command arguments, logs, or messages. -5. Configure exact Telegram numeric user and chat IDs. Verify IDs through an - authenticated enrollment procedure; never copy authorization from a username - or display name. +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 + 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 + bot secrets or private content. Do not authorize usernames, display names, + groups, channels, lists, pairing, or first-user-wins behavior. Open the + Charon bot's private chat and press Start before testing delivery. 6. Load an operator-owned action/risk registry. Unknown actions default to critical and offer only deny or allow once. 7. Start with emergency disable enabled. Exercise delivery, callback expiry, @@ -34,7 +37,7 @@ access. - Session rules reference an active workspace lease and inactivity deadline. - Assertion nonce consumption is atomic at the issuer. - Prompt and callback rate limits are enforced per issuer, tenant, workload, - action, user, and chat. + action and the fixed equal private user/chat ID. - Audit sinks contain approved identifiers and digests, never request commands when classified sensitive, callback tokens, assertions, keys, bot tokens, credentials, provider references, or message bodies. @@ -62,9 +65,11 @@ matches continue according to their own bounds. 1. Enable emergency disable. 2. Invalidate all pending callback tokens and terminally mark their messages when possible. -3. Rotate the Telegram bot token and protected runtime value. -4. Reverify bot identity, numeric allowlists, delivery, callback binding, and - terminal message edits. +3. Rotate `CHARON_TELEGRAM_BOT_TOKEN` and restart the adapter. Changing + `CHARON_TELEGRAM_USER_ID` likewise requires a restart; there is no runtime + mutation path. +4. Reverify bot identity, private user/chat equality, delivery, callback + binding, and terminal message edits. 5. Increment emergency generation and explicitly re-enable issuance. ## Account recovery or chat migration @@ -74,7 +79,7 @@ as an authorization incident: 1. Enable emergency disable. 2. Revoke affected rules and invalidate pending requests. -3. Remove old numeric user/chat IDs. +3. Replace the configured numeric user ID and restart the adapter. 4. Complete an out-of-band operator identity check. 5. Enroll new numeric IDs and rotate the bot token when exposure is possible. 6. Review audit records from the last known-good human authentication event. diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index fa93e1e..0f5b574 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -73,6 +73,14 @@ receives approval requests, decisions, rules, assertions, callback tokens, bot tokens, or presentation text. 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 +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 diff --git a/docs/threat-model.md b/docs/threat-model.md index e73e5fa..cdfa02d 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -33,8 +33,9 @@ capability without placing the underlying credential in that workload. authenticates approval-channel events, and signs short-lived assertions for the workload identity issuer. Charon never calls it. - **Approval channel and human:** Telegram is the first presentation adapter. - Numeric user/chat allowlists authenticate the human boundary; usernames and - display text do not. The channel cannot create rules or assertions. + 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 intermediate CA per control-plane persona. No realm can read another realm's @@ -144,7 +145,7 @@ capability without placing the underlying credential in that workload. 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 numeric allowlisted actor/chat. + request and the immutable equal private actor/chat ID. 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 diff --git a/src/approval.rs b/src/approval.rs new file mode 100644 index 0000000..a502126 --- /dev/null +++ b/src/approval.rs @@ -0,0 +1,868 @@ +//! Durable, channel-neutral human-approval broker primitives. + +use std::{ + fs::{self, OpenOptions}, + io::{Read as _, Write as _}, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _, PermissionsExt as _}, + path::{Path, PathBuf}, + sync::Mutex, +}; + +use anyhow::{Context, Result, ensure}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use chacha20poly1305::{ + ChaCha20Poly1305, KeyInit as _, Nonce, + aead::{Aead as _, Payload}, +}; +use ed25519_dalek::{Signer as _, SigningKey}; +use rusqlite::{Connection, OptionalExtension as _, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest as _, Sha256}; +use zeroize::Zeroize as _; + +const SCHEMA_VERSION: i64 = 1; +const KEY_BYTES: usize = 32; +const NONCE_BYTES: usize = 12; + +/// Configuration for one approval broker state owner. +pub struct BrokerConfig { + /// `SQLite` state path. Its parent directory must already be private. + pub database: PathBuf, + /// Protected 32-byte key used only to encrypt normalized requests at rest. + pub state_key: PathBuf, + /// Protected 32-byte Ed25519 seed dedicated to approval assertions. + pub signing_key: PathBuf, + /// Stable key identifier pinned by assertion consumers. + pub signing_key_id: String, + /// Assertion issuer value. + pub issuer: String, + /// Assertion lifetime, bounded by request expiry. + pub assertion_ttl_seconds: u64, + /// Required owner UID for the database, keys, and their directories. + pub owner_uid: u32, +} + +/// A schema-shaped normalized approval request. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalEnvelope { + /// Contract version. + pub api_version: String, + /// Normalized authorization input. + pub request: NormalizedRequest, + /// JCS SHA-256 digest of `request`. + pub request_digest: String, +} + +/// Authorization inputs bound into every decision. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct NormalizedRequest { + /// Unpredictable request nonce. + pub request_id: String, + /// Authenticated issuer identity. + pub issuer: String, + /// Tenant identity. + pub tenant: String, + /// Persona identity. + pub persona: String, + /// Workspace identity. + pub workspace: String, + /// Active lease identity. + pub lease: String, + /// Workload identity. + pub workload: String, + /// Operation correlation identity. + pub operation: String, + /// Named capability requested from the issuer. + pub capability: String, + /// Destination service classification. + pub service: String, + /// Exact structured resource. + pub resource: Resource, + /// Registry-owned action. + pub action: String, + /// Exact structured command. + pub command: Command, + /// Operator-owned risk classification. + pub risk_tier: RiskTier, + /// Active policy generation. + pub policy_generation: u64, + /// Requested Charon manifest lifetime. + pub requested_manifest_ttl_seconds: u64, + /// Creation time as Unix seconds. + pub created_at: u64, + /// Expiry time as Unix seconds. + pub expires_at: u64, +} + +/// Exact resource identity supported by the first registry version. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Resource { + /// Registry resource kind. + pub kind: String, + /// Resource owner. + pub owner: String, + /// Resource name. + pub name: String, +} + +/// Exact operation tokenization. Tokens must never contain secrets. +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Command { + /// Executable class. + pub program: String, + /// Ordered authorization-relevant arguments. + pub arguments: Vec, +} + +/// Closed operator risk classification. +#[derive(Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum RiskTier { + /// Routine reversible operation. + Low, + /// Operation requiring informed attention. + Medium, + /// Sensitive operation; reusable approval requires explicit registry policy. + High, + /// Non-reusable or unknown operation. + Critical, +} + +/// Terminal result of consuming an allow-once decision. +pub struct SignedApproval { + /// Opaque decision identifier. + pub decision_id: String, + /// Compact JCS Ed25519 assertion. + pub assertion: String, +} + +/// Durable approval broker. It intentionally has no channel or provider client. +pub struct ApprovalBroker { + connection: Mutex, + state_key: SecretKey, + signing_key: SigningKey, + issuer: String, + assertion_ttl_seconds: u64, +} + +struct SecretKey([u8; KEY_BYTES]); + +impl Drop for SecretKey { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Serialize)] +struct AssertionClaims<'a> { + iss: &'a str, + aud: &'a str, + sub: &'a str, + request_id: &'a str, + request_digest: &'a str, + decision_id: &'a str, + grant: &'static str, + tenant: &'a str, + persona: &'a str, + workspace: &'a str, + lease: &'a str, + workload: &'a str, + operation: &'a str, + capability: &'a str, + service: &'a str, + resource_digest: String, + action: &'a str, + command_digest: String, + policy_generation: u64, + max_manifest_ttl_seconds: u64, + jti: String, + iat: u64, + nbf: u64, + exp: u64, +} + +impl ApprovalEnvelope { + /// Parse strictly, validate bounded fields, and verify the JCS request digest. + /// + /// # Errors + /// + /// Returns an error for malformed, unknown, out-of-bound, expired, or + /// digest-mismatched input. + pub fn parse(bytes: &[u8], now: u64) -> Result { + let value: Value = + serde_json::from_slice(bytes).context("invalid approval request JSON")?; + let envelope: Self = serde_json::from_value(value).context("invalid approval request")?; + envelope.validate(now)?; + Ok(envelope) + } + + fn validate(&self, now: u64) -> Result<()> { + ensure!( + self.api_version == "charon.approval/v1", + "unsupported API version" + ); + let request = &self.request; + for (name, value) in [ + ("request_id", request.request_id.as_str()), + ("tenant", request.tenant.as_str()), + ("persona", request.persona.as_str()), + ("workspace", request.workspace.as_str()), + ("lease", request.lease.as_str()), + ("workload", request.workload.as_str()), + ("operation", request.operation.as_str()), + ("capability", request.capability.as_str()), + ("service", request.service.as_str()), + ] { + validate_identifier(name, value)?; + } + ensure!( + !request.issuer.is_empty() && request.issuer.len() <= 256, + "invalid issuer" + ); + ensure!( + request.resource.kind == "github_repository", + "unknown resource kind" + ); + validate_resource_identifier("resource owner", &request.resource.owner)?; + validate_resource_identifier("resource name", &request.resource.name)?; + validate_action(&request.action)?; + ensure!( + !request.command.program.is_empty() && request.command.program.len() <= 64, + "invalid program" + ); + ensure!( + request.command.arguments.len() <= 32, + "too many command arguments" + ); + for argument in &request.command.arguments { + ensure!( + !argument.is_empty() && argument.len() <= 512, + "invalid command argument" + ); + ensure!( + !argument.chars().any(char::is_control), + "command argument contains control characters" + ); + } + ensure!( + (1..=300).contains(&request.requested_manifest_ttl_seconds), + "invalid manifest TTL" + ); + ensure!(request.policy_generation > 0, "invalid policy generation"); + ensure!( + request.created_at <= now, + "request was created in the future" + ); + ensure!(request.expires_at > now, "request is expired"); + ensure!( + request.expires_at > request.created_at, + "invalid request window" + ); + let digest = digest_jcs(&request)?; + ensure!( + constant_time_equal(self.request_digest.as_bytes(), digest.as_bytes()), + "request digest mismatch" + ); + Ok(()) + } +} + +impl ApprovalBroker { + /// Open protected state, validate keys, migrate schema, and expire ambiguous requests. + /// + /// # Errors + /// + /// Returns an error when configuration, ownership, permissions, keys, + /// database state, or schema validation fails. + pub fn open(config: &BrokerConfig, now: u64) -> Result { + ensure!( + (1..=300).contains(&config.assertion_ttl_seconds), + "invalid assertion TTL" + ); + validate_identifier("signing key ID", &config.signing_key_id)?; + ensure!( + !config.issuer.is_empty() && config.issuer.len() <= 256, + "invalid broker issuer" + ); + let state_key = SecretKey(read_protected_key(&config.state_key, config.owner_uid)?); + let signing_key = + SigningKey::from_bytes(&read_protected_key(&config.signing_key, config.owner_uid)?); + let connection = open_database(&config.database, config.owner_uid)?; + migrate(&connection)?; + bind_signing_key_id(&connection, &config.signing_key_id)?; + connection.execute( + "UPDATE requests SET state = 'timed_out', terminal_at = ?1 WHERE state = 'pending'", + params![to_i64(now)?], + )?; + Ok(Self { + connection: Mutex::new(connection), + state_key, + signing_key, + issuer: config.issuer.clone(), + assertion_ttl_seconds: config.assertion_ttl_seconds, + }) + } + + /// Persist a validated request. Repeated identical request IDs are idempotent. + /// + /// # Errors + /// + /// Returns an error for invalid requests, conflicting request IDs, + /// encryption failure, or durable-state failure. + pub fn submit(&self, envelope: &ApprovalEnvelope, now: u64) -> Result<()> { + envelope.validate(now)?; + let plaintext = serde_jcs::to_vec(&envelope.request)?; + let encrypted = encrypt( + &self.state_key.0, + envelope.request_digest.as_bytes(), + &plaintext, + )?; + let connection = self + .connection + .lock() + .map_err(|_| anyhow::anyhow!("approval state lock poisoned"))?; + let existing: Option = connection + .query_row( + "SELECT request_digest FROM requests WHERE request_id = ?1", + params![envelope.request.request_id], + |row| row.get(0), + ) + .optional()?; + if let Some(existing) = existing { + ensure!( + constant_time_equal(existing.as_bytes(), envelope.request_digest.as_bytes()), + "request ID is already bound to different content" + ); + return Ok(()); + } + connection.execute( + "INSERT INTO requests (request_id, request_digest, issuer, tenant, encrypted_request, created_at, expires_at, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 'pending')", + params![ + envelope.request.request_id, + envelope.request_digest, + envelope.request.issuer, + envelope.request.tenant, + encrypted, + to_i64(envelope.request.created_at)?, + to_i64(envelope.request.expires_at)?, + ], + )?; + Ok(()) + } + + /// Atomically allow one pending request and issue one short-lived assertion. + /// + /// # Errors + /// + /// Returns an error unless the request is current, pending, digest-bound, + /// emergency issuance is enabled, and the state transition commits. + pub fn allow_once( + &self, + request_id: &str, + request_digest: &str, + audience: &str, + now: u64, + ) -> Result { + validate_identifier("request ID", request_id)?; + ensure!( + !audience.is_empty() && audience.len() <= 256, + "invalid assertion audience" + ); + let mut connection = self + .connection + .lock() + .map_err(|_| anyhow::anyhow!("approval state lock poisoned"))?; + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + ensure!( + !emergency_disabled(&transaction)?, + "approval issuance is disabled" + ); + let stored = transaction.query_row( + "SELECT request_digest, encrypted_request, expires_at, state FROM requests WHERE request_id = ?1", + params![request_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?)), + ).optional()?.context("approval request not found")?; + ensure!( + stored.3 == "pending", + "approval request is already terminal" + ); + ensure!( + constant_time_equal(stored.0.as_bytes(), request_digest.as_bytes()), + "request digest mismatch" + ); + ensure!(stored.2 > to_i64(now)?, "approval request is expired"); + let plaintext = decrypt(&self.state_key.0, stored.0.as_bytes(), &stored.1)?; + let request: NormalizedRequest = + serde_json::from_slice(&plaintext).context("stored approval request is invalid")?; + let decision_id = random_id()?; + let jti = random_id()?; + let exp = now + .saturating_add(self.assertion_ttl_seconds) + .min(request.expires_at); + ensure!(exp > now, "approval assertion has no valid lifetime"); + let claims = AssertionClaims { + iss: &self.issuer, + aud: audience, + sub: &request.workload, + request_id: &request.request_id, + request_digest, + decision_id: &decision_id, + grant: "once", + tenant: &request.tenant, + persona: &request.persona, + workspace: &request.workspace, + lease: &request.lease, + workload: &request.workload, + operation: &request.operation, + capability: &request.capability, + service: &request.service, + resource_digest: digest_jcs(&request.resource)?, + action: &request.action, + command_digest: digest_jcs(&request.command)?, + policy_generation: request.policy_generation, + max_manifest_ttl_seconds: request.requested_manifest_ttl_seconds, + jti, + iat: now, + nbf: now, + exp, + }; + let assertion = self.sign_assertion(&claims)?; + let changed = transaction.execute( + "UPDATE requests SET state = 'allowed', terminal_at = ?1, decision_id = ?2, assertion_jti = ?3 WHERE request_id = ?4 AND state = 'pending'", + params![to_i64(now)?, decision_id, claims.jti, request_id], + )?; + ensure!(changed == 1, "approval request lost terminal decision race"); + transaction.commit()?; + Ok(SignedApproval { + decision_id, + assertion, + }) + } + + /// Atomically deny one pending request without creating authorization state. + /// + /// # Errors + /// + /// Returns an error if the request is absent, expired, terminal, or cannot + /// be durably transitioned. + pub fn deny(&self, request_id: &str, now: u64) -> Result<()> { + validate_identifier("request ID", request_id)?; + let connection = self + .connection + .lock() + .map_err(|_| anyhow::anyhow!("approval state lock poisoned"))?; + let changed = connection.execute( + "UPDATE requests SET state = 'denied', terminal_at = ?1, decision_id = ?2 WHERE request_id = ?3 AND state = 'pending' AND expires_at > ?1", + params![to_i64(now)?, random_id()?, request_id], + )?; + ensure!( + changed == 1, + "approval request is absent, expired, or terminal" + ); + Ok(()) + } + + /// Set the monotonic emergency-disable state transactionally. + /// + /// # Errors + /// + /// Returns an error for a zero or stale generation or a storage failure. + pub fn set_emergency_disable(&self, disabled: bool, generation: u64) -> Result<()> { + ensure!(generation > 0, "invalid emergency generation"); + let connection = self + .connection + .lock() + .map_err(|_| anyhow::anyhow!("approval state lock poisoned"))?; + let changed = connection.execute( + "UPDATE emergency SET disabled = ?1, generation = ?2 WHERE singleton = 1 AND generation < ?2", + params![i64::from(disabled), to_i64(generation)?], + )?; + ensure!(changed == 1, "emergency generation is stale"); + Ok(()) + } + + fn sign_assertion(&self, claims: &T) -> Result { + let payload = serde_jcs::to_vec(claims)?; + let encoded = URL_SAFE_NO_PAD.encode(&payload); + let signature = self.signing_key.sign(encoded.as_bytes()); + Ok(format!( + "{encoded}.{}", + URL_SAFE_NO_PAD.encode(signature.to_bytes()) + )) + } +} + +fn open_database(path: &Path, owner_uid: u32) -> Result { + ensure_private_parent(path, owner_uid)?; + let existed = path.exists(); + let connection = Connection::open(path).context("failed to open approval state")?; + if !existed { + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + validate_private_regular_file(path, owner_uid)?; + connection.pragma_update(None, "journal_mode", "WAL")?; + connection.pragma_update(None, "synchronous", "FULL")?; + connection.pragma_update(None, "foreign_keys", "ON")?; + Ok(connection) +} + +fn migrate(connection: &Connection) -> Result<()> { + connection.execute_batch( + "BEGIN IMMEDIATE; + CREATE TABLE IF NOT EXISTS metadata (schema_version INTEGER NOT NULL); + INSERT INTO metadata(schema_version) SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM metadata); + CREATE TABLE IF NOT EXISTS settings (name TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS requests ( + request_id TEXT PRIMARY KEY, + request_digest TEXT NOT NULL UNIQUE, + issuer TEXT NOT NULL, + tenant TEXT NOT NULL, + encrypted_request BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK(state IN ('pending','allowed','denied','timed_out','cancelled')), + terminal_at INTEGER, + decision_id TEXT UNIQUE, + assertion_jti TEXT UNIQUE + ); + CREATE TABLE IF NOT EXISTS emergency ( + singleton INTEGER PRIMARY KEY CHECK(singleton = 1), + disabled INTEGER NOT NULL CHECK(disabled IN (0,1)), + generation INTEGER NOT NULL + ); + INSERT OR IGNORE INTO emergency(singleton, disabled, generation) VALUES(1, 1, 1); + COMMIT;", + )?; + let version: i64 = + connection.query_row("SELECT schema_version FROM metadata", [], |row| row.get(0))?; + ensure!( + version == SCHEMA_VERSION, + "unsupported approval database schema" + ); + Ok(()) +} + +fn bind_signing_key_id(connection: &Connection, key_id: &str) -> Result<()> { + let existing: Option = connection + .query_row( + "SELECT value FROM settings WHERE name = 'signing_key_id'", + [], + |row| row.get(0), + ) + .optional()?; + if let Some(existing) = existing { + ensure!( + constant_time_equal(existing.as_bytes(), key_id.as_bytes()), + "configured signing key ID does not match durable state" + ); + } else { + connection.execute( + "INSERT INTO settings(name, value) VALUES('signing_key_id', ?1)", + params![key_id], + )?; + } + Ok(()) +} + +fn emergency_disabled(transaction: &rusqlite::Transaction<'_>) -> Result { + let value: i64 = transaction.query_row( + "SELECT disabled FROM emergency WHERE singleton = 1", + [], + |row| row.get(0), + )?; + Ok(value != 0) +} + +fn encrypt(key: &[u8; KEY_BYTES], aad: &[u8], plaintext: &[u8]) -> Result> { + let cipher = ChaCha20Poly1305::new(key.into()); + let mut nonce = [0_u8; NONCE_BYTES]; + getrandom::fill(&mut nonce).context("secure randomness unavailable")?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| anyhow::anyhow!("request encryption failed"))?; + let mut result = Vec::with_capacity(NONCE_BYTES + ciphertext.len()); + result.extend_from_slice(&nonce); + result.extend_from_slice(&ciphertext); + Ok(result) +} + +fn decrypt(key: &[u8; KEY_BYTES], aad: &[u8], encrypted: &[u8]) -> Result> { + ensure!( + encrypted.len() > NONCE_BYTES, + "stored encrypted request is invalid" + ); + let (nonce, ciphertext) = encrypted.split_at(NONCE_BYTES); + ChaCha20Poly1305::new(key.into()) + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| anyhow::anyhow!("stored approval request authentication failed")) +} + +fn digest_jcs(value: &T) -> Result { + Ok(format!( + "sha256:{:x}", + Sha256::digest(serde_jcs::to_vec(value)?) + )) +} + +fn random_id() -> Result { + let mut bytes = [0_u8; 24]; + getrandom::fill(&mut bytes).context("secure randomness unavailable")?; + Ok(URL_SAFE_NO_PAD.encode(bytes)) +} + +fn read_protected_key(path: &Path, owner_uid: u32) -> Result<[u8; KEY_BYTES]> { + validate_private_regular_file(path, owner_uid)?; + let mut file = fs::File::open(path).context("failed to open protected key")?; + let mut key = [0_u8; KEY_BYTES]; + file.read_exact(&mut key) + .context("protected key must contain exactly 32 bytes")?; + let mut extra = [0_u8; 1]; + ensure!( + file.read(&mut extra)? == 0, + "protected key must contain exactly 32 bytes" + ); + Ok(key) +} + +fn validate_private_regular_file(path: &Path, owner_uid: u32) -> Result<()> { + let metadata = fs::symlink_metadata(path).context("protected file is unavailable")?; + ensure!( + metadata.file_type().is_file(), + "protected path must be a regular file" + ); + ensure!( + metadata.uid() == owner_uid, + "protected file has the wrong owner" + ); + ensure!( + metadata.mode().trailing_zeros() >= 6, + "protected file must not grant group or other access" + ); + Ok(()) +} + +fn ensure_private_parent(path: &Path, owner_uid: u32) -> Result<()> { + let parent = path.parent().context("state path has no parent")?; + let metadata = fs::symlink_metadata(parent).context("state parent is unavailable")?; + ensure!(metadata.is_dir(), "state parent must be a directory"); + ensure!( + metadata.uid() == owner_uid, + "state parent has the wrong owner" + ); + ensure!( + metadata.mode().trailing_zeros() >= 6, + "state parent must not grant group or other access" + ); + Ok(()) +} + +fn validate_identifier(name: &str, value: &str) -> Result<()> { + ensure!((1..=128).contains(&value.len()), "invalid {name}"); + ensure!( + value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')), + "invalid {name}" + ); + Ok(()) +} + +fn validate_resource_identifier(name: &str, value: &str) -> Result<()> { + ensure!(value.len() <= 100, "invalid {name}"); + validate_identifier(name, value) +} + +fn validate_action(value: &str) -> Result<()> { + ensure!( + (1..=128).contains(&value.len()) && value.contains('.'), + "invalid action" + ); + ensure!( + value.bytes().all(|byte| byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'.' | b'_' | b'-')), + "invalid action" + ); + ensure!( + value.as_bytes().first().is_some_and(u8::is_ascii_lowercase), + "invalid action" + ); + for segment in value.split('.') { + ensure!( + !segment.is_empty() && segment.as_bytes()[0].is_ascii_lowercase(), + "invalid action" + ); + } + Ok(()) +} + +fn constant_time_equal(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right) + .fold(0_u8, |difference, (a, b)| difference | (a ^ b)) + == 0 +} + +fn to_i64(value: u64) -> Result { + i64::try_from(value).context("timestamp exceeds storage range") +} + +/// Create a protected binary key file without overwriting an existing key. +/// +/// # Errors +/// +/// Returns an error if the parent is not private, randomness is unavailable, +/// the path exists, or the durable write fails. +pub fn create_key(path: &Path, owner_uid: u32) -> Result<()> { + ensure_private_parent(path, owner_uid)?; + let mut key = [0_u8; KEY_BYTES]; + getrandom::fill(&mut key).context("secure randomness unavailable")?; + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) + .context("failed to create protected key")?; + file.write_all(&key)?; + file.sync_all()?; + key.zeroize(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn fixture(now: u64) -> Result<(TempDir, ApprovalBroker, ApprovalEnvelope)> { + let directory = TempDir::new()?; + fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o700))?; + let owner_uid = fs::metadata(directory.path())?.uid(); + let state_key = directory.path().join("state.key"); + let signing_key = directory.path().join("signing.key"); + create_key(&state_key, owner_uid)?; + create_key(&signing_key, owner_uid)?; + let request = NormalizedRequest { + request_id: "request_123456789".into(), + issuer: "issuer.example".into(), + tenant: "tenant-1".into(), + persona: "persona-1".into(), + workspace: "workspace-1".into(), + lease: "lease-1".into(), + workload: "workload-1".into(), + operation: "operation-123456".into(), + capability: "github-api".into(), + service: "github".into(), + resource: Resource { + kind: "github_repository".into(), + owner: "example".into(), + name: "repo".into(), + }, + action: "github.issue.create".into(), + command: Command { + program: "gh".into(), + arguments: vec!["issue".into(), "create".into()], + }, + risk_tier: RiskTier::Medium, + policy_generation: 7, + requested_manifest_ttl_seconds: 60, + created_at: now, + expires_at: now + 120, + }; + let envelope = ApprovalEnvelope { + api_version: "charon.approval/v1".into(), + request_digest: digest_jcs(&request)?, + request, + }; + let broker = ApprovalBroker::open( + &BrokerConfig { + database: directory.path().join("state.db"), + state_key, + signing_key, + signing_key_id: "approval-key-1".into(), + issuer: "approval.example".into(), + assertion_ttl_seconds: 30, + owner_uid, + }, + now, + )?; + broker.set_emergency_disable(false, 2)?; + Ok((directory, broker, envelope)) + } + + #[test] + fn allow_once_is_atomic_and_replay_fails() -> Result<()> { + let now = 1_800_000_000; + let (_directory, broker, envelope) = fixture(now)?; + broker.submit(&envelope, now)?; + let signed = broker.allow_once( + &envelope.request.request_id, + &envelope.request_digest, + "issuer.example", + now + 1, + )?; + assert_eq!(signed.assertion.split('.').count(), 2); + assert!( + broker + .allow_once( + &envelope.request.request_id, + &envelope.request_digest, + "issuer.example", + now + 2 + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn restart_expires_pending_requests() -> Result<()> { + let now = 1_800_000_000; + let (directory, broker, envelope) = fixture(now)?; + broker.submit(&envelope, now)?; + drop(broker); + let reopened = ApprovalBroker::open( + &BrokerConfig { + database: directory.path().join("state.db"), + state_key: directory.path().join("state.key"), + signing_key: directory.path().join("signing.key"), + signing_key_id: "approval-key-1".into(), + issuer: "approval.example".into(), + assertion_ttl_seconds: 30, + owner_uid: fs::metadata(directory.path())?.uid(), + }, + now + 1, + )?; + assert!( + reopened + .allow_once( + &envelope.request.request_id, + &envelope.request_digest, + "issuer.example", + now + 1 + ) + .is_err() + ); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 301bd9d..fcbf916 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ //! Charon's configuration, credential-provider, and HTTP proxy primitives. +pub mod approval; pub mod broker; pub mod ca; pub mod config; diff --git a/tests/contracts.rs b/tests/contracts.rs index ce7ce23..ce12fdd 100644 --- a/tests/contracts.rs +++ b/tests/contracts.rs @@ -48,7 +48,7 @@ fn approval_request_digest_vector_is_stable() -> Result<()> { let request = document .get("request") .context("approval request example has no normalized request")?; - let canonical = serde_json::to_vec(request)?; + let canonical = serde_jcs::to_vec(request)?; let digest = format!("sha256:{:x}", Sha256::digest(canonical)); assert_eq!( document.get("request_digest").and_then(Value::as_str), From 75402891488ef55e083ceccdee5f20d9023fa440 Mon Sep 17 00:00:00 2001 From: Alexander Ververis Date: Thu, 20 Aug 2026 12:00:13 +0800 Subject: [PATCH 03/12] fix(deps): update h2 for RUSTSEC-2026-0258 (#54) --- Cargo.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 35dbb1f..594b504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -521,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -680,9 +680,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -1328,7 +1328,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1524,7 +1524,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1582,7 +1582,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1932,7 +1932,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2428,7 +2428,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] From f2d245630731b4cc75bc5f556803e9c52a34b0d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:04:39 +0800 Subject: [PATCH 04/12] Bump library/rust from `14bc9c5` to `0e2bcae` (#44) Bumps library/rust from `14bc9c5` to `0e2bcae`. --- updated-dependencies: - dependency-name: library/rust dependency-version: 1.97.1-bookworm dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index dc6b924..3f387a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.97.1-bookworm@sha256:14bc9c5966e7b3a385794b3d5389a8765668342025fbcc7b2e3d2866ac4bd8c3 AS build +FROM docker.io/library/rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS build WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY src ./src From d10b5cd4b022613953c8631029521dcc615847f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:09:35 +0800 Subject: [PATCH 05/12] chore(deps): bump futures-util from 0.3.33 to 0.3.34 (#47) Bumps [futures-util](https://github.com/rust-lang/futures-rs) from 0.3.33 to 0.3.34. - [Release notes](https://github.com/rust-lang/futures-rs/releases) - [Changelog](https://github.com/rust-lang/futures-rs/blob/main/CHANGELOG.md) - [Commits](https://github.com/rust-lang/futures-rs/compare/0.3.33...0.3.34) --- updated-dependencies: - dependency-name: futures-util dependency-version: 0.3.34 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- Cargo.lock | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 594b504..66907c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -592,44 +592,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.2", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", From 58035286c6a22ded59347b442086a706a999e45a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:13:35 +0800 Subject: [PATCH 06/12] chore(deps): bump http-body-util from 0.1.4 to 0.1.5 (#48) Bumps [http-body-util](https://github.com/hyperium/http-body) from 0.1.4 to 0.1.5. - [Release notes](https://github.com/hyperium/http-body/releases) - [Commits](https://github.com/hyperium/http-body/compare/http-body-util-v0.1.4...http-body-util-v0.1.5) --- updated-dependencies: - dependency-name: http-body-util dependency-version: 0.1.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 66907c7..6cae2ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -743,9 +743,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", From 9ef7e6a35c3cf4fbab874b19deeb1b80e6329a33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:18:13 +0800 Subject: [PATCH 07/12] Bump hatchling from 1.31.0 to 1.32.0 in /integrations/hermes (#49) Bumps [hatchling](https://github.com/pypa/hatch) from 1.31.0 to 1.32.0. - [Release notes](https://github.com/pypa/hatch/releases) - [Commits](https://github.com/pypa/hatch/compare/hatchling-v1.31.0...hatchling-v1.32.0) --- updated-dependencies: - dependency-name: hatchling dependency-version: 1.32.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- integrations/hermes/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/hermes/pyproject.toml b/integrations/hermes/pyproject.toml index 2ab7654..2140a90 100644 --- a/integrations/hermes/pyproject.toml +++ b/integrations/hermes/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling==1.31.0"] +requires = ["hatchling==1.32.0"] build-backend = "hatchling.build" [project] From ed83002e62552b4d02267d1339fb52943de68add Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:22:28 +0800 Subject: [PATCH 08/12] chore(deps): bump rcgen from 0.14.8 to 0.14.9 (#50) Bumps [rcgen](https://github.com/rustls/rcgen) from 0.14.8 to 0.14.9. - [Release notes](https://github.com/rustls/rcgen/releases) - [Commits](https://github.com/rustls/rcgen/compare/v0.14.8...v/0.14.9) --- updated-dependencies: - dependency-name: rcgen dependency-version: 0.14.9 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6cae2ef..48cca3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1383,9 +1383,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +checksum = "091e7a8e7d86e6feb87a27ce8e2cba29d49eff9507afeebefab7eeb2ca667fb4" dependencies = [ "pem", "ring", From c538bebc50736c7480b48c6077fa20ae6d8a3f67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:27:24 +0800 Subject: [PATCH 09/12] chore(deps): bump async-trait from 0.1.91 to 0.1.92 (#51) Bumps [async-trait](https://github.com/dtolnay/async-trait) from 0.1.91 to 0.1.92. - [Release notes](https://github.com/dtolnay/async-trait/releases) - [Commits](https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92) --- updated-dependencies: - dependency-name: async-trait dependency-version: 0.1.92 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 48cca3c..416a4c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -68,9 +68,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", From f1aae295fc32be065fbfcf1690da73b051005eeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:31:44 +0800 Subject: [PATCH 10/12] Bump distroless/cc-debian12 from `fccdbb0` to `adcd20c` (#45) Bumps distroless/cc-debian12 from `fccdbb0` to `adcd20c`. --- updated-dependencies: - dependency-name: distroless/cc-debian12 dependency-version: nonroot dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Alexander Ververis --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3f387a9..4f18372 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ COPY Cargo.toml Cargo.lock ./ COPY src ./src RUN cargo build --locked --release -FROM gcr.io/distroless/cc-debian12:nonroot@sha256:fccdbb0a547c14e23fcf4ce8ad62ca5d43b4faae8d22cd292f490fef9946c96e +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 USER nonroot:nonroot From 3d3a4d83619c228a2799efe0928a7d7df740b076 Mon Sep 17 00:00:00 2001 From: Alexander Ververis Date: Sun, 4 Oct 2026 01:16:25 +0800 Subject: [PATCH 11/12] feat: exclusive workload TLS gateway for Hermes and Rust 1.99 (#68) * feat: add exclusive workload TLS gateway for Hermes * fix: refresh available gh fixture package pins --- .github/workflows/ci.yml | 4 +- Cargo.lock | 28 +- Cargo.toml | 4 +- Dockerfile | 2 +- README.md | 26 +- contracts/README.md | 9 + contracts/forward-proxy.md | 9 + contracts/workload-gateway.md | 139 ++++ docs/adr/0008-exclusive-workload-proxy.md | 49 ++ docs/conventions.md | 3 +- docs/deployment.md | 9 + docs/hermes-gateway.md | 120 +++ docs/index.md | 6 + docs/integration-boundaries.md | 21 +- docs/security-review-workload-gateway.md | 64 ++ docs/threat-model.md | 56 +- examples/hermes-gateway.toml | 176 +++++ integrations/hermes/README.md | 9 + integrations/vertical/Dockerfile | 4 +- mise.toml | 2 +- rust-toolchain.toml | 2 +- src/config.rs | 2 +- src/dns.rs | 35 +- src/gateway.rs | 841 ++++++++++++++++++++++ src/gateway_tests.rs | 778 ++++++++++++++++++++ src/lib.rs | 2 + src/main.rs | 43 +- src/proxy.rs | 60 +- src/receipt.rs | 15 + 29 files changed, 2452 insertions(+), 66 deletions(-) create mode 100644 contracts/workload-gateway.md create mode 100644 docs/adr/0008-exclusive-workload-proxy.md create mode 100644 docs/hermes-gateway.md create mode 100644 docs/security-review-workload-gateway.md create mode 100644 examples/hermes-gateway.toml create mode 100644 src/gateway.rs create mode 100644 src/gateway_tests.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28a31f4..f53b5f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.97.1 + toolchain: 1.99.0 components: rustfmt, clippy - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 - run: cargo fmt --all -- --check @@ -64,7 +64,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.97.1 + toolchain: 1.99.0 - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 - name: Install pinned cargo-deny run: cargo install cargo-deny --version 0.20.2 --locked diff --git a/Cargo.lock b/Cargo.lock index 416a4c3..da981d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,9 +91,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -101,9 +101,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -521,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1328,7 +1328,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1524,14 +1524,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -1582,7 +1582,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1593,9 +1593,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -1932,7 +1932,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2428,7 +2428,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 12e748d..7bb6452 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "charon" version = "0.1.0" edition = "2024" -rust-version = "1.97" +rust-version = "1.99" description = "Credential-injecting forward proxy for AI agents and other workloads" license = "MIT" repository = "https://github.com/ak5/charon" @@ -24,7 +24,7 @@ hyper-util = { version = "0.1", features = ["tokio"] } rcgen = { version = "0.14", features = ["pem", "x509-parser"] } reqwest = { version = "0.13", default-features = false, features = ["http2", "rustls", "stream"] } rusqlite = { version = "0.38", features = ["bundled"] } -rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustls = { version = "0.23.45", default-features = false, features = ["ring", "std", "tls12"] } secrecy = "0.10" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/Dockerfile b/Dockerfile index 4f18372..296d598 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/library/rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS build +FROM docker.io/library/rust:1.99.0-bookworm@sha256:59037199c44290f2befcdd58dcc540164763fc296950255aaefeef096a1866b0 AS build WORKDIR /src COPY Cargo.toml Cargo.lock ./ COPY src ./src diff --git a/README.md b/README.md index 42e6a5e..31aac95 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@

CI status Container image - Rust 1.97 or newer + Rust 1.99 or newer MIT license

@@ -16,7 +16,8 @@

Charon is a transparent gateway and forward proxy that adds credentials to -approved outbound requests. +approved outbound requests. It also forwards secretless and caller-authenticated +traffic through an exclusive workload gateway for ordinary proxy clients. It lets a workload call an API without putting the API credential in that workload's environment, filesystem, or container image. @@ -25,7 +26,7 @@ narrowly scoped access to an authenticated API without placing the long-lived credential inside the agent runtime. The same model works for CLIs, builds, development containers, and other programs. -The workload sends an opaque capability reference instead of a real credential. Charon +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 the request sent upstream. @@ -51,13 +52,13 @@ anything that compromises it. Charon moves the credential into a smaller, separately operated process. A request is allowed only when all of these agree: -- a signed, short-lived, single-use workload manifest in explicit-proxy mode, - or an Infra-isolated listener bound to one workload in transparent mode; +- a signed, short-lived, single-use manifest in signed proxy mode, or an + Infra-isolated listener bound to one workload in transparent/exclusive mode; - a named capability in Charon's configuration; and - the actual destination hostname, HTTP method, and path. The workload cannot choose a secret, a secret-store item, or an unconfigured -destination. Charon does not return credentials to workloads and does not +destination. Charon does not return brokered credentials to workloads and does not follow redirects after adding one. Applications keep using their ordinary credential settings. The configured @@ -91,7 +92,7 @@ identifiers supplied by the issuer. They are integration context, not secret selectors or core Charon concepts. This part of the public contract is under review before 1.0. -## How a request works +## How a signed credential request works 1. A trusted issuer gives the workload a signed manifest for a named capability. @@ -118,7 +119,7 @@ the Infra routing boundary. Charon's ordinary health endpoints are described by ## Development -The project requires Rust 1.97 or newer. [mise](https://mise.jdx.dev/) is +The project requires Rust 1.99 or newer. [mise](https://mise.jdx.dev/) is optional; it installs the pinned toolchain and provides short names for common development commands. @@ -240,3 +241,12 @@ mise run check ## License Charon is licensed under the [MIT License](LICENSE). + +## Exclusive Hermes network gateway + +The [exclusive workload gateway](contracts/workload-gateway.md) provides a separate +`--gateway-config` mode for ordinary HTTP(S) proxy clients, including reusable +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. diff --git a/contracts/README.md b/contracts/README.md index ecb3070..c383e4c 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -54,3 +54,12 @@ the [`approval broker operator runbook`](../docs/approval-broker-operations.md). Tool admission and receipt contracts are also implemented outside Charon. Their first adapter is [`charon-hermes`](../integrations/hermes/README.md). + +## Exclusive Hermes network gateway + +The [exclusive workload gateway](workload-gateway.md) provides a separate +`--gateway-config` mode for ordinary HTTP(S) proxy clients, including reusable +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. diff --git a/contracts/forward-proxy.md b/contracts/forward-proxy.md index b7b623e..7e8d475 100644 --- a/contracts/forward-proxy.md +++ b/contracts/forward-proxy.md @@ -79,3 +79,12 @@ Charon has no request-path callback to an issuer or application database. It exposes no workload API to create, rotate, revoke, enumerate, or choose secrets, providers, realms, or policy. Realm lifecycle objects are separate operator/reconciler contracts. + +## Exclusive Hermes network gateway + +The [exclusive workload gateway](workload-gateway.md) provides a separate +`--gateway-config` mode for ordinary HTTP(S) proxy clients, including reusable +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. diff --git a/contracts/workload-gateway.md b/contracts/workload-gateway.md new file mode 100644 index 0000000..48032df --- /dev/null +++ b/contracts/workload-gateway.md @@ -0,0 +1,139 @@ +# Exclusive workload explicit proxy contract + +Version: 1 + +Start `charon --gateway-config ` with a complete policy conforming to +`GatewayConfig` in `src/gateway.rs`. Validate it with +`charon gateway validate ` before enabling routing. Unknown fields and +unsupported versions fail closed. The exact synthetic deployment example is +[`examples/hermes-gateway.toml`](../examples/hermes-gateway.toml). + +This is a separate process/listener mode from `--config`. The signed explicit +proxy continues to require a fresh signed single-use manifest. Transparent +service listeners continue to bind one service. Neither becomes an anonymous +shared proxy. Hermes semantic tool admission is independent and unchanged. + +## Identity and transport + +One listener binds one fixed `realm` and `workload`. `exclusive_network = true` +is a mandatory operator acknowledgement, not proof of isolation. Infra must +make the listener reachable only from that workload, block all direct remote +TCP egress, prevent access from other workloads/host namespaces, and deny IPv6, +UDP/443, QUIC, and alternate routes. Source IP, URL, header, or capability text +is not authentication. A shared listener is prohibited. Charon never queries +an orchestrator or application database. + +Ordinary clients use an HTTP proxy URL without proxy authentication. HTTPS +requires `CONNECT exact-host:443`, TLS interception, and origin-form HTTP/1.1 +or HTTP/2. CONNECT, TLS SNI, HTTP `Host`, and HTTP/2 `:authority` must agree; +conflicting/malformed/duplicate authority forms fail closed. All permitted +HTTPS terminates at Charon; no splice, direct tunnel, or TLS fallback exists. +Every request on a reused or multiplexed connection is authorized afresh. +CONNECT itself grants no operation or credential. Tunnels expire after one +hour; each operation has its own shorter configured time limits. + +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 +variables cannot change Charon's upstream route. No upstream intermediary is +configured in this mode. Infra owns DNS, public CA trust, and egress isolation. + +Secretless HTTP is available only through an explicit `scheme = "http"` route +on port 80; credential mediation requires HTTPS/443. The Hermes example grants +no plaintext route. Nonstandard ports, nested CONNECT, upgrades, HTTP/3, raw +TCP protocols, and WebSockets are denied. Clients receive a fixed denial; +TLS/CONNECT failures close the connection without upstream diagnostics. + +## Operation grants and credential ownership + +`routes` grant an exact DNS host, scheme, methods, exact `paths` and/or one +literal slash-terminated `path_prefix`, query permission, credential/session +header permission, mediation type, and stream limits. No hostname glob exists. +An explicit `/` prefix grants that named host's API surface; it does not grant +another destination. Overlapping grants for a host/scheme/method fail validation. +Encoded path separators, controls, traversal, URL user information, and routing +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 +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 +Authorization, Cookie, X-Api-Key, Api-Key, X-Goog-Api-Key, X-Auth-Token, and +X-Session-Token. Supplying these outside its grant is denied. Other application +headers (for example ChatGPT-Account-Id and SDK version headers) pass through; +hop-by-hop, framing, Host and proxy authentication do not. + +Credential grants are closed typed sinks: + +- `header`: Authorization or a named credential header from that set excluding + Cookie, a policy-owned `secret_ref`, and a value template containing exactly + one `{secret}`. Supply `{{charon.}}` at that exact sink, rendered + with the template (for example `Bearer {{charon.github-user}}`). +- `basic`: fixed `username` and policy-owned `secret_ref`. Supply standard Basic + auth with password `{{charon.}}`, usable by ordinary curl/Git + credential configuration. + +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 +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. + +## Streaming and sessions + +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, +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 +prompt/body inspection. Binary downloads retain their bytes on secretless +routes. Credential routes redact known raw/rendered credentials and URL/base64 +encodings even across chunk boundaries. This can alter an opaque payload that +contains a brokered credential; it is intentional fail-safe mediation. + +HTTP/1.1 keepalive and HTTP/2 multiplexing are supported downstream; reqwest +negotiates HTTP/1.1 or HTTP/2 upstream with certificate validation. Compression +is identity-only: request Accept-Encoding is set to identity, encoded uploads +and non-identity encoded responses fail closed. Clients must handle the fixed +403 for unsupported compression or protocol. No compressed bytes silently +bypass redaction. WebSocket/101 upgrades fail closed. + +`session_response_headers` can retain Set-Cookie, WWW-Authenticate, +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. +Charon is not a sanitizer for secrets already owned by the caller, including +OAuth token responses; forwarding those to their owner is necessary behavior. + +## Receipts, logs, and verification + +The required receipt journal reserves capacity before any operation; failure +denies execution before lookup. Accepted and denied operations contain only +fixed realm/workload, policy capability/service, authorized destination/method, +status, counts, elapsed time, and outcome. Its existing `path` field contains +`/capability/` (or `/denied`), **never a request URL/path/query**. +Preauthorization denials use fixed `denied` identifiers. Authority/handshake +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 +so dependency transport debug logs cannot expose traffic. Embedders must apply +the same logging restriction. The interception CA/key, provider 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 +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/docs/adr/0008-exclusive-workload-proxy.md b/docs/adr/0008-exclusive-workload-proxy.md new file mode 100644 index 0000000..4862fdd --- /dev/null +++ b/docs/adr/0008-exclusive-workload-proxy.md @@ -0,0 +1,49 @@ +# ADR 0008: exclusive workload explicit proxy + +- Status: accepted for implementation; deployment requires Infra verification +- Date: 2026-10-04 + +## Context + +Ordinary Hermes HTTP clients, curl, gh, Git and SDKs use reusable explicit proxy +connections. Signed single-use manifests and a transparent listener per service +do not provide that transport. The owner authorizes TLS termination for every +policy-permitted HTTPS connection. Infra will use one outbound gateway and +retains separate Hermes tool admission and its convergence guard. + +## Decision + +Add a distinct `--gateway-config` process mode with schema version 1, one fixed +realm/workload, mandatory exclusive-network acknowledgement, required CA and +metadata receipts, and exact host/scheme/method/path grants. Preserve the +signed and transparent modes. Routing isolation is workload authentication; +this mode must not be exposed to a shared network. + +Separate forwarding grants (no provider work) from credential grants with +closed header/Basic capability sinks. Caller-owned OAuth, bot tokens and +session headers can pass under explicit route policy. No client selects a +provider reference or asks for arbitrary body/path substitution. + +Terminate every HTTPS CONNECT, compare SNI/HTTP authority, verify upstream TLS, +preauthorize public pinned DNS before credential lookup, and reauthorize every +request on HTTP/1.1 and HTTP/2 connections. Never splice. Keep redirects +caller-followed and independently authorized. Stream bounded uploads/downloads +with known-secret redaction; reject compression and WebSockets clearly. + +## Consequences + +The network namespace and listener reachability become a trusted identity +boundary. Compromising Charon or its CA exposes intercepted traffic, including +caller-owned secrets. The gateway cannot prove semantic tool-call origin. +Infra must block direct egress, IPv6/QUIC and other bypass routes and verify +trust, mediation, backups, config CD and tool admission before cutover. + +The separate schema avoids a permissive fallback in signed authentication and +avoids forcing provider references/hydration onto ordinary routes. Its receipts +use policy identifiers instead of traffic paths because Telegram and OAuth can +put credentials in URLs. Executable logging excludes dependency traffic traces. + +The implementation contract and verified synthetic configuration are owned by +[workload-gateway.md](../../contracts/workload-gateway.md) and +[hermes-gateway.md](../hermes-gateway.md). Publishing an image is not a deployment +or permission to remove Infra's convergence guard. diff --git a/docs/conventions.md b/docs/conventions.md index 9a09e23..68a0002 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -5,7 +5,8 @@ Charon is a Rust package with contracts, deployment examples, first-party integrations, and durable design documentation in the same repository. Cargo owns Rust build and test behavior. Each integration owns its native package and -tests under `integrations//`. `mise.toml` pins Rust, Python, and +tests under `integrations//`. `mise.toml` pins Rust 1.99.0 with a minimal compiler profile plus Clippy/rustfmt, +Python, and `cargo-deny` and provides stable operator tasks across formatting, linting, tests, deployment-contract tests, and dependency policy. This cross-tool quality gate is why diff --git a/docs/deployment.md b/docs/deployment.md index 3fb94f3..214a85b 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -214,3 +214,12 @@ previous immutable image plus its complete policy/CA/provider 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. + +## Exclusive Hermes network gateway + +The [exclusive workload gateway](../contracts/workload-gateway.md) provides a separate +`--gateway-config` mode for ordinary HTTP(S) proxy clients, including reusable +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. diff --git a/docs/hermes-gateway.md b/docs/hermes-gateway.md new file mode 100644 index 0000000..7978324 --- /dev/null +++ b/docs/hermes-gateway.md @@ -0,0 +1,120 @@ +# Hermes single outbound HTTP(S) gateway + +The exclusive workload gateway is specified by +[its contract](../contracts/workload-gateway.md). Its complete synthetic policy +is [hermes-gateway.toml](../examples/hermes-gateway.toml). Use a distinct process +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 +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 +service continue to have their own contracts. + +## Exact destination inventory + +The example is a reviewable provider profile, not every Hermes provider/tool. +Remove inactive provider routes. A custom model base URL, new provider, web +search, external download, MCP server, or redirect destination requires a new +exact hostname and operation grant. No wildcard/allow-all destination exists. + +| Exact destination | Allowed purpose in example | Ownership | +| --- | --- | --- | +| `auth.openai.com` | POST `/oauth/token`, `/api/accounts/deviceauth/usercode`, `/api/accounts/deviceauth/token` | Caller-owned device login and token refresh | +| `chatgpt.com` | GET/POST `/backend-api/codex/` prefix | Caller-owned Codex OAuth, account/session headers, streaming | +| `portal.nousresearch.com` | POST `/api/oauth/device/code`, `/api/oauth/token`, `/oauth/code`, `/oauth/token` | Caller-owned Nous OAuth | +| `inference-api.nousresearch.com` | GET/POST `/v1/` prefix | Caller-owned Nous model credential | +| `openrouter.ai` | GET/POST `/api/v1/` prefix | Caller-owned OpenRouter model credential | +| `api.openai.com` | GET/POST/DELETE `/v1/` prefix | Caller-owned models and file uploads/downloads | +| `api.telegram.org` | GET/POST `/` prefix | Caller-owned bot-token paths, polling, upload and file download | +| `api.github.com` | API operations under `/` | Caller-owned gh/SDK Authorization | +| `github.com` | GET/POST `/` with Basic capability | Policy-owned synthetic Git credential | + +Source inventory: pinned Hermes +`3c27eb6234bf91b8ceee9e9071591b31e9b148cb`, +[`hermes_cli/auth.py`](https://github.com/NousResearch/hermes-agent/blob/3c27eb6234bf91b8ceee9e9071591b31e9b148cb/hermes_cli/auth.py) +and +[`plugins/platforms/telegram/adapter.py`](https://github.com/NousResearch/hermes-agent/blob/3c27eb6234bf91b8ceee9e9071591b31e9b148cb/plugins/platforms/telegram/adapter.py). +Model path prefixes accommodate vendor file/response identifiers without +recording them. Telegram bot tokens appear inside its request paths: they must +never appear in receipts, request diagnostics, traces or captures. Browser +login takes place in the operator's browser; redirects to interactive login +hosts are not automatically granted to the workload. + +GitHub alternate hosts (`raw.githubusercontent.com`, `objects.githubusercontent.com`, +`codeload.github.com`, release asset domains and Copilot endpoints) are absent +and denied. Infra must inventory the actual workload's required routes and +independently grant any needed exact destination. Adding a hostname does not +bypass the CONNECT/SNI/authority or public-address checks. + +## Client trust and proxy settings + +Install the public CA/root chain in the workload's OS trust store. Keep its +signing key readable only by Charon; never mount it into Hermes. Python's +certifi-based clients also need a CA bundle containing both the deployment root +and ordinary public roots. Set `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE` and +`CURL_CA_BUNDLE` to that bundle as appropriate. Configure Node with +`NODE_EXTRA_CA_CERTS` if used. Confirm Go/gh and Git trust the installed root. +Never use TLS verification disable flags. + +Set HTTP_PROXY/HTTPS_PROXY and lowercase equivalents to the exclusive listener, +for example `http://charon:18080`, without proxy authentication. Set Telegram's +explicit `TELEGRAM_PROXY` to that same URL where the Hermes adapter needs it. +NO_PROXY may name local control/admission endpoints only. Hermes must not gain +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. + +## Feature verification and Infra handoff + +Before a release, `mise run check` includes real local TLS fixtures. To rerun +just the new feature: + +```sh +mise exec -- cargo test gateway --lib +mise exec -- cargo run -- gateway validate examples/hermes-gateway.toml +``` + +The client fixture requires curl and, on Linux, gh. Linux CI exercises both +unmodified clients. On macOS it exercises curl only: existing Go/gh builds use +Keychain trust rather than the fixture's `SSL_CERT_FILE`. Deployment verification +must still prove gh with the installed CA; tests do not change system trust. + +The fixture upstream uses a separate synthetic CA and verified TLS; its local +routing override is confined to tests. Runtime has no private-address exception +or custom trust override. Verify the candidate deployment with synthetic +credentials and status-only assertions, without verbose curl traces or body +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 + 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. +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. +5. Inspect metadata receipts and fixed errors for fake sentinel values; ensure + no URL, OAuth token path/query, prompt or body was recorded. +6. Independently prove direct TCP egress remains blocked with all proxy + variables removed, IPv6/QUIC cannot escape, and alternate GitHub routes are + denied. Charon's unit tests cannot prove host/network rules. +7. Confirm config CD, independent tool admission and encrypted backup behavior + remain intact. Retain the installed Compose layout, immutable pins and config + revision for rollback before any convergence. + +Only Infra's reviewed completion and operator-authorized cutover can remove its +full convergence guard. Rollback restores the installed layout and pins; it +never restores unrestricted egress. An immutable release reference is published +by successful trusted `main` CI as `ghcr.io//:sha-`; +operators must record the actual registry digest, not infer one from a Git SHA. diff --git a/docs/index.md b/docs/index.md index e95c0f8..c683797 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,6 +6,10 @@ Start with the document that owns the question you are trying to answer. - [README](../README.md): product overview and fastest development path. - [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. +- [Workload gateway contract](../contracts/workload-gateway.md): ordinary client + CONNECT, secretless forwarding and typed credential routes. - [Transparent gateway contract](../contracts/transparent-gateway.md): capability references, typed hydration, streaming mediation, and routing. - [Approval broker operations](approval-broker-operations.md): approval-broker @@ -35,5 +39,7 @@ Start with the document that owns the question you are trying to answer. security-reporting policy. - [Repository conventions](conventions.md): commands, repository shape, 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. diff --git a/docs/integration-boundaries.md b/docs/integration-boundaries.md index 0f5b574..57cd2cd 100644 --- a/docs/integration-boundaries.md +++ b/docs/integration-boundaries.md @@ -8,7 +8,8 @@ control plane. Integrations are explicit, versioned, and fail closed. | Boundary | Direction | Protocol and contract | Trust | Charon owns | | --- | --- | --- | --- | --- | -| Workload | workload → Charon | HTTP forward proxy; `CONNECT` for HTTPS; signed manifest in `Proxy-Authorization` | untrusted | destination, operation, capability reference, size, redirect, replay, and expiry enforcement | +| Exclusive workload | workload → Charon | dedicated explicit HTTP proxy / intercepted CONNECT; fixed realm/workload | Infra network isolation | exact host/method/path grants, typed header/Basic sinks, caller-session policy, metadata-only receipts; no manifest | +| Signed workload | workload → Charon | HTTP forward proxy; `CONNECT` for HTTPS; signed manifest in `Proxy-Authorization` | untrusted | destination, operation, capability reference, size, redirect, replay, and expiry enforcement | | 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 | @@ -25,7 +26,7 @@ control plane. Integrations are explicit, versioned, and fail closed. ## Workload protocol -The workload sends a normal absolute-form HTTP proxy request, or an HTTP +In signed proxy mode, the workload sends a normal absolute-form HTTP proxy request, or an HTTP `CONNECT` request followed by TLS. It supplies a signed manifest but cannot select a realm, provider, provider account, item, secret reference, destination outside configured policy, or credential rendering rule. @@ -68,9 +69,10 @@ 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. Charon never calls the broker or Telegram and never -receives approval requests, decisions, rules, assertions, callback tokens, bot -tokens, or presentation text. ADR 0005 and the approval artifacts in +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 +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 @@ -153,3 +155,12 @@ It cannot replace direct-egress denial or Charon's manifest and local policy checks. A deployment that needs receipts resistant to workload compromise must place execution and receipt signing behind an independently isolated tool gateway. + +## Exclusive Hermes network gateway + +The [exclusive workload gateway](../contracts/workload-gateway.md) provides a separate +`--gateway-config` mode for ordinary HTTP(S) proxy clients, including reusable +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. diff --git a/docs/security-review-workload-gateway.md b/docs/security-review-workload-gateway.md new file mode 100644 index 0000000..2e87a6c --- /dev/null +++ b/docs/security-review-workload-gateway.md @@ -0,0 +1,64 @@ +# Exclusive workload gateway implementation review + +- Date: 2026-10-04 +- Reviewer: Codex source and test review; this is not an independent audit +- Authorization: owner task explicitly authorizes policy-permitted HTTPS TLS + termination and a single outbound gateway; live Infra changes are excluded +- Scope: `src/gateway.rs`, shared authority/DNS/receipt helpers, process command, + example policy, contracts, threat model, and Rust/TLS build dependencies + +## Boundary findings and resolutions + +- Signed authentication is unchanged. Network identity exists only on a + separate explicitly selected fixed-workload listener/process. Exclusive + isolation acknowledgement is required, and documentation states it is not + proof of network enforcement. +- CONNECT/SNI/Host/HTTP2 authority agreement, standard ports, userinfo rejection, + and per-request grants precede forwarding. Shared helpers also reject + malformed or duplicated Host and authority user information. +- Public DNS authorization precedes provider calls and uses the connector's + pin. Concurrent first resolutions retain the winning pin. Private, metadata, + shared-address and benchmark destinations cannot enter the public pin. +- Forwarding has no lookup/health call. Credential mediation requires exact + policy-declared header or Basic references and fixed provider IDs. Framing, + encoded uploads, caller credential rules and journal capacity fail before + resolution. No untyped string/body replacement exists. +- Streaming applies configured byte/deadline/idle limits and known-secret + redaction. Upstream certificates verify normally; redirects never follow + inside Charon. Compression is checked on raw upstream headers before + hop-by-hop filtering, so Connection nomination cannot hide an encoding. +- Receipt paths are fixed policy IDs. Errors/logs contain no traffic values, + and the process excludes dependency debug traces regardless of RUST_LOG. + Caller-owned sessions are explicit policy; retained headers cannot reflect a + known brokered secret. Header values are marked sensitive at the client API. +- Dependency policy detected RUSTSEC-2026-0285 in the existing TLS dependency. + The minimum Rustls version and lockfile now select patched 0.23.45. Rust + 1.99.0 is pinned locally, in CI, as the package minimum and in the digest- + pinned official Docker build base. + +## Required release evidence + +`mise run check` must pass on the final revision with Rust 1.99.0. Gateway tests +exercise real downstream and upstream local TLS, ordinary clients, secretless +provider counts, policy denial before lookup, typed header/Basic hydration, +repeated HTTP/1.1/HTTP2 authorization, authority/SNI mismatches, untrusted TLS, +private DNS, streaming before completion, upload/download limits, compression +(including nominated headers), upgrades and sentinel-safe logs/errors/receipts. +The complete Hermes check also runs against the pinned upstream plugin API. + +## Residual risks and release scope + +No local test can attest Infra's network policy or CA distribution. The +listener is unsafe when another principal can reach it; TLS interception places +all permitted traffic inside Charon's trusted boundary. Caller-owned token +responses are returned to their owner, not semantically sanitized. Known-secret +redaction cannot recognize arbitrary re-encodings generated by a destination. +Streaming cannot retract partial uploads or responses. Connection/resource +limits remain an Infra responsibility. HTTP/3, WebSockets and compression are +unsupported and fail closed. No independent security audit or live cutover is +claimed by this review. + +Keep full convergence guarded until Infra verifies the actual immutable image, +exact policy, client trust, successful origin-side hydration, direct-egress and +IPv6/QUIC denial, separate admission, config CD and encrypted backups. Rollback +restores installed layout/pins/config and preserves egress isolation. diff --git a/docs/threat-model.md b/docs/threat-model.md index cdfa02d..baf892c 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -59,10 +59,11 @@ capability without placing the underlying credential in that workload. ## Security invariants -1. The workload never receives the real credential. +1. The workload never receives a Charon-brokered credential. Caller-owned + OAuth/session tokens on exclusive forwarding routes remain client-owned. 2. Direct workload internet access is denied; otherwise it can bypass Charon. 3. Policies use exact destination hosts and caller-independent secret references. -4. A credential is injected only when the canonical public capability reference +4. A brokered credential is injected only when the canonical public capability reference is present in its policy-declared sink. 5. Redirects are disabled so credentials cannot cross authorization boundaries. 6. Hop-by-hop and proxy-authorization headers are not forwarded. @@ -74,7 +75,7 @@ capability without placing the underlying credential in that workload. 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. -10. Every forwarded request carries a signed manifest bound to the exact +10. Each signed-mode forwarded request carries a manifest bound to the exact issuer, audience, workload, persona, named capability, validity window, and single-use nonce. Identity and operation authorization complete before credential resolution. @@ -92,8 +93,9 @@ capability without placing the underlying credential in that workload. 13. The vertical test workload joins only an internal Docker network. Charon alone joins the upstream network and chains through the configured Squid egress, so a workload cannot bypass policy with a direct connection. -14. Request and response bodies stream through backpressured counted adapters - with independent 16 MiB aggregate limits. Declared oversize requests fail +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 downstream headers; unknown-length overflows terminate their stream. 15. An authenticated upstream proxy uses a fixed configured username and an @@ -172,9 +174,9 @@ capability without placing the underlying credential in that workload. accepted process-lifetime resolution. IPv6 listeners and answers are rejected. Charon has no UDP or HTTP/3 transport; Infra rejects UDP/443 and blocks direct egress so QUIC cannot bypass mediation. -34. Response policy explicitly selects structured SSE/NDJSON streaming, +34. Signed/transparent response policy explicitly selects structured SSE/NDJSON streaming, bounded JSON buffering, rolling text streaming, or allowlisted opaque - streaming. Authentication, session, and framing headers are removed first. + streaming. Authentication, session, and framing headers are removed first in those modes. 35. Compression is identity-only or rejected. Opaque compressed response policy is reserved and fails validation until bounded decompression and sanitization exist. WebSocket upgrade is denied. Sanitization failure after @@ -187,9 +189,43 @@ capability without placing the underlying credential in that workload. reconciles a stale checkpoint, and rejects an invalid chain or unrecognized checkpoint. Journal data is synced before checkpoint replacement. -## Known milestone-0 limitations +37. A separate exclusive-workload explicit listener binds a fixed realm/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, + 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 + 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 + replace an accepted pin. Shared-address, benchmark, private, metadata, + multicast, reserved and IPv6 addresses are denied. Ambient upstream proxy + variables cannot bypass this resolver. +40. Exclusive gateway receipts replace the request path with a policy route + identifier, never record queries/URLs, and reserve journal capacity before + 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. +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 + session response policy are explicit. Streaming overflow cannot retract a + partial upload or already delivered response. -- CONNECT interception supports HTTP/2 and HTTP/1.1 with one authorized inner +The [workload gateway contract](../contracts/workload-gateway.md) and +[Hermes deployment handoff](hermes-gateway.md) own the complete exclusive-mode +schema, exact host inventory, isolation requirements and feature verification. +Infra isolation is part of workload identity: any other principal able to +reach the listener gains that workload's grants. The interception CA/key and +plaintext TLS traffic are trusted Charon assets; compromise exposes caller-owned +and brokered credentials. The network gateway cannot prove that a socket came +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. @@ -223,6 +259,6 @@ capability without placing the underlying credential in that workload. - Transparent listener identity depends on Infra isolation and is weaker than a signed per-request manifest. Generic clients cannot securely correlate a network request to one Hermes tool call, so Charon authorizes it independently. -- Structured request hydration buffers JSON and form bodies to the request +- 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. diff --git a/examples/hermes-gateway.toml b/examples/hermes-gateway.toml new file mode 100644 index 0000000..a525b88 --- /dev/null +++ b/examples/hermes-gateway.toml @@ -0,0 +1,176 @@ +# Synthetic identifiers only. Infra owns actual paths, isolation, CA and mapping. +version = 1 +workload = "hermes" +listen = "0.0.0.0:18080" +exclusive_network = true + +[realm] +id = "hermes-realm" +tenant = "synthetic-tenant" +persona = "hermes" +generation = 1 + +[tls] +ca_certificate = "/etc/charon/ca.pem" +ca_private_key = "/run/charon/ca-key.pem" + +[provider] +kind = "environment" + +[receipts] +journal_path = "/var/lib/charon/gateway-receipts.jsonl" +state_path = "/var/lib/charon/gateway-receipts.chain" +queue_capacity = 1024 + +[[routes]] +name = "codex-oauth" +host = "auth.openai.com" +scheme = "https" +methods = ["POST"] +paths = ["/oauth/token", "/api/accounts/deviceauth/usercode", "/api/accounts/deviceauth/token"] +allow_query = true +caller_headers = ["authorization", "cookie"] +session_response_headers = ["set-cookie", "www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +[routes.mediation] +kind = "forward" + +[[routes]] +name = "codex-model" +host = "chatgpt.com" +scheme = "https" +methods = ["GET", "POST"] +paths = [] +allow_query = true +caller_headers = ["authorization", "cookie"] +session_response_headers = ["set-cookie", "www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/backend-api/codex/" +[routes.mediation] +kind = "forward" + +[[routes]] +name = "nous-oauth" +host = "portal.nousresearch.com" +scheme = "https" +methods = ["POST"] +paths = ["/api/oauth/device/code", "/api/oauth/token", "/oauth/code", "/oauth/token"] +allow_query = true +caller_headers = ["authorization", "cookie"] +session_response_headers = ["set-cookie", "www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +[routes.mediation] +kind = "forward" + +[[routes]] +name = "nous-model" +host = "inference-api.nousresearch.com" +scheme = "https" +methods = ["GET", "POST"] +paths = [] +allow_query = true +caller_headers = ["authorization"] +session_response_headers = ["www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/v1/" +[routes.mediation] +kind = "forward" + +[[routes]] +name = "openrouter-model" +host = "openrouter.ai" +scheme = "https" +methods = ["GET", "POST"] +paths = [] +allow_query = true +caller_headers = ["authorization"] +session_response_headers = ["www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/api/v1/" +[routes.mediation] +kind = "forward" + +[[routes]] +name = "openai-model" +host = "api.openai.com" +scheme = "https" +methods = ["GET", "POST", "DELETE"] +paths = [] +allow_query = true +caller_headers = ["authorization"] +session_response_headers = ["www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/v1/" +[routes.mediation] +kind = "forward" + +[[routes]] +name = "telegram" +host = "api.telegram.org" +scheme = "https" +methods = ["GET", "POST"] +paths = [] +allow_query = true +caller_headers = ["authorization", "cookie"] +session_response_headers = ["set-cookie"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/" +[routes.mediation] +kind = "forward" + +[[routes]] +name = "github-caller" +host = "api.github.com" +scheme = "https" +methods = ["GET", "HEAD", "POST", "PATCH", "PUT", "DELETE"] +paths = [] +allow_query = true +caller_headers = ["authorization"] +session_response_headers = ["www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/" +[routes.mediation] +kind = "forward" + +[[routes]] +name = "github-git" +host = "github.com" +scheme = "https" +methods = ["GET", "POST"] +paths = [] +allow_query = true +caller_headers = [] +session_response_headers = ["www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +path_prefix = "/" +[routes.mediation] +kind = "basic" +secret_ref = "CHARON_SYNTHETIC_GITHUB_TOKEN" +username = "x-access-token" diff --git a/integrations/hermes/README.md b/integrations/hermes/README.md index 5714234..b2fd2f8 100644 --- a/integrations/hermes/README.md +++ b/integrations/hermes/README.md @@ -203,3 +203,12 @@ CI checks out the immutable upstream commit and fails if its Telegram inventory contains an unclassified tool, the recommended policy is incomplete, the reviewed classification digest changes, or the real `PluginContext` hook API is incompatible. + +## Exclusive Hermes network gateway + +The [exclusive workload gateway](../../contracts/workload-gateway.md) provides a separate +`--gateway-config` mode for ordinary HTTP(S) proxy clients, including reusable +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. diff --git a/integrations/vertical/Dockerfile b/integrations/vertical/Dockerfile index d6c7ca8..772b9e0 100644 --- a/integrations/vertical/Dockerfile +++ b/integrations/vertical/Dockerfile @@ -1,8 +1,8 @@ FROM docker.io/library/alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 RUN apk add --no-cache \ - ca-certificates=20260611-r0 \ - curl=8.20.0-r0 \ + ca-certificates=20260909-r0 \ + curl=8.22.0-r0 \ github-cli=2.83.0-r6 RUN adduser -D -u 10001 workload diff --git a/mise.toml b/mise.toml index 33b36c8..470c6e1 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -rust = "1.97.1" +rust = { version = "1.99.0", profile = "minimal", components = ["clippy", "rustfmt"] } python = "3.12.10" "github:EmbarkStudios/cargo-deny" = "0.20.2" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 98eaf31..77621c4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.97.1" +channel = "1.99.0" profile = "minimal" components = ["clippy", "rustfmt"] diff --git a/src/config.rs b/src/config.rs index d208e09..685c4e2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -718,7 +718,7 @@ fn is_environment_reference(value: &str) -> bool { }) } -fn is_exact_host(value: &str) -> bool { +pub(crate) fn is_exact_host(value: &str) -> bool { if value.is_empty() || value.len() > 253 || !value.is_ascii() diff --git a/src/dns.rs b/src/dns.rs index 24b9cc4..419e2df 100644 --- a/src/dns.rs +++ b/src/dns.rs @@ -69,13 +69,15 @@ impl Resolve for PinnedResolver { )) as Box); } - cache + let addresses = cache .lock() .map_err(|_| { Box::new(io::Error::other("DNS pin cache is unavailable")) as Box })? - .insert(hostname, addresses.clone()); + .entry(hostname) + .or_insert(addresses) + .clone(); Ok(Box::new(addresses.into_iter()) as Addrs) }) } @@ -88,6 +90,35 @@ fn is_public_ipv4(ip: Ipv4Addr) -> bool { || ip.is_broadcast() || ip.is_documentation() || ip.is_unspecified() + || (ip.octets()[0] == 100 && (64..=127).contains(&ip.octets()[1])) + || (ip.octets()[0] == 198 && matches!(ip.octets()[1], 18 | 19)) + || (ip.octets()[0] == 192 && ip.octets()[1] == 0 && ip.octets()[2] == 0) || ip.octets()[0] == 0 || ip.octets()[0] >= 224) } + +#[cfg(test)] +mod tests { + use super::is_public_ipv4; + #[test] + fn denies_nonpublic_and_metadata_ranges() { + for ip in [ + [0, 0, 0, 1], + [10, 0, 0, 1], + [127, 0, 0, 1], + [169, 254, 169, 254], + [172, 16, 0, 1], + [192, 168, 0, 1], + [100, 64, 0, 1], + [100, 100, 100, 200], + [198, 18, 0, 1], + [192, 0, 0, 8], + [224, 0, 0, 1], + [240, 0, 0, 1], + [255, 255, 255, 255], + ] { + assert!(!is_public_ipv4(ip.into())); + } + assert!(is_public_ipv4([8, 8, 8, 8].into())); + } +} diff --git a/src/gateway.rs b/src/gateway.rs new file mode 100644 index 0000000..4d472cb --- /dev/null +++ b/src/gateway.rs @@ -0,0 +1,841 @@ +//! Exclusive-workload explicit proxy: independent of signed-manifest listeners. + +use crate::{ + config::{ProviderConfig, RealmConfig, ReceiptConfig, TlsConfig}, + dns::PinnedResolver, + provider::{SecretProvider, SecretRef}, + receipt::{DataPlaneReceipt, ReceiptJournal, ReceiptOutcome, receipt_stream}, + response::{guard_stream, redact_text_stream}, + tls::TlsAuthority, +}; +use anyhow::{Context, Result, bail, ensure}; +use axum::{ + Router, + body::Body, + extract::{Request, State}, + http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + response::{IntoResponse, Response}, +}; +use base64::Engine as _; +use secrecy::{ExposeSecret as _, SecretString}; +use serde::Deserialize; +use std::{ + collections::HashSet, convert::Infallible, net::SocketAddr, path::Path, sync::Arc, + time::Duration, +}; +use tokio::time::timeout; +use tokio_rustls::TlsAcceptor; + +/// A complete policy for a separate, network-authenticated process/listener. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GatewayConfig { + /// Schema generation, currently 1. + pub version: u32, + /// Fixed ownership context; never supplied by the client. + pub realm: RealmConfig, + /// Fixed protected workload identifier. + pub workload: String, + /// IPv4 address reachable exclusively by this workload. + pub listen: SocketAddr, + /// Explicit acknowledgement of the network identity boundary. + 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, + /// Required durable metadata journal. + pub receipts: ReceiptConfig, + /// Exact-host operation grants, evaluated for every request. + pub routes: Vec, +} + +/// One fixed-workload capability grant. Hostname wildcards are forbidden. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Route { + /// Safe identifier used in receipts instead of request paths. + pub name: String, + /// One exact lowercase DNS name. + pub host: String, + /// HTTPS or secretless HTTP. + pub scheme: String, + /// Exact methods. + pub methods: Vec, + /// Exact paths, excluding query strings. + #[serde(default)] + pub paths: Vec, + /// Optional literal path prefix, terminated with slash (no glob syntax). + pub path_prefix: Option, + /// Whether query strings may be forwarded, never recorded. + pub allow_query: bool, + /// Credential/session request headers permitted to remain caller-owned. + pub caller_headers: Vec, + /// Session/authentication response headers permitted to remain caller-owned. + pub session_response_headers: Vec, + /// Closed secretless/credential mode. + pub mediation: Mediation, + /// Aggregate streamed upload bound. + pub max_request_bytes: usize, + /// Aggregate streamed download bound. + pub max_response_bytes: usize, + /// Total request lifetime, including upstream headers and upload. + pub max_duration_seconds: u64, + /// Idle body interval. + pub idle_timeout_seconds: u64, +} + +/// Policy-owned typed credential sinks; no caller-selected provider reference. +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum Mediation { + /// Forward without any provider lookup. + Forward, + /// Inject only into Authorization or one named API-key header. + Header { + /// Policy-owned opaque provider identifier. + secret_ref: String, + /// Exact capability reference required in this header. + name: String, + /// One `{secret}` marker, applied only to this declared header value. + value_template: String, + }, + /// Standard Basic-auth capability for ordinary Git/curl clients. + Basic { + /// Policy-owned opaque provider identifier. + secret_ref: String, + /// Fixed upstream username. + username: String, + }, +} + +fn identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.')) +} +fn sensitive(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "authorization" + | "cookie" + | "x-api-key" + | "api-key" + | "x-goog-api-key" + | "x-auth-token" + | "x-session-token" + ) +} +fn session(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "set-cookie" | "www-authenticate" | "authentication-info" | "x-session-token" + ) +} + +impl GatewayConfig { + /// Load the exclusive listener schema, with data-free parse errors. + /// # Errors + /// Invalid or unavailable policy fails closed. + pub fn load(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).context("gateway policy unavailable")?; + let policy: Self = + toml::from_str(&raw).map_err(|_| anyhow::anyhow!("gateway policy is invalid"))?; + policy.validate()?; + Ok(policy) + } + /// Reject ambiguous grants and unsupported transport before opening sockets. + /// # Errors + /// Returns fixed, non-secret configuration diagnostics. + #[allow(clippy::too_many_lines)] + pub fn validate(&self) -> Result<()> { + ensure!( + self.version == 1 && self.exclusive_network && self.listen.is_ipv4(), + "exclusive IPv4 workload gateway required" + ); + ensure!( + [ + &self.workload, + &self.realm.id, + &self.realm.tenant, + &self.realm.persona + ] + .iter() + .all(|v| identifier(v)) + && self.realm.generation > 0, + "invalid fixed workload realm" + ); + ensure!( + self.tls.ca_certificate.is_absolute() && self.tls.ca_private_key.is_absolute(), + "absolute CA paths required" + ); + ensure!( + self.receipts.journal_path.is_absolute() + && self.receipts.state_path.is_absolute() + && self.receipts.journal_path != self.receipts.state_path + && (1..=65536).contains(&self.receipts.queue_capacity), + "invalid receipt configuration" + ); + ensure!(!self.routes.is_empty(), "gateway requires routes"); + let mut names = HashSet::new(); + for route in &self.routes { + ensure!( + identifier(&route.name) && names.insert(&route.name), + "invalid or duplicate capability" + ); + crate::broker::CapabilityReference::parse(&format!("{{{{charon.{}}}}}", route.name))?; + ensure!( + crate::config::is_exact_host(&route.host) + && route.host == route.host.to_ascii_lowercase() + && route.host.parse::().is_err(), + "exact DNS destination required" + ); + ensure!( + matches!(route.scheme.as_str(), "https" | "http"), + "unsupported route scheme" + ); + ensure!( + !route.methods.is_empty() + && route.methods.iter().all(|m| matches!( + m.as_str(), + "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" + )), + "unsupported route method" + ); + let valid_path = |p: &str| { + p.starts_with('/') + && !p.contains(['?', '#', '*', '\\', '%']) + && !p.split('/').any(|s| matches!(s, "." | "..")) + }; + ensure!( + !route.paths.is_empty() || route.path_prefix.is_some(), + "route requires path policy" + ); + ensure!( + route.paths.iter().all(|p| valid_path(p)) + && route + .path_prefix + .as_deref() + .is_none_or(|p| valid_path(p) && p.ends_with('/')), + "invalid path policy" + ); + ensure!( + (1..=1024 * 1024 * 1024).contains(&route.max_request_bytes) + && (1..=1024 * 1024 * 1024).contains(&route.max_response_bytes) + && (1..=3600).contains(&route.max_duration_seconds) + && (1..=300).contains(&route.idle_timeout_seconds), + "invalid stream limits" + ); + for name in &route.caller_headers { + ensure!( + name.parse::().is_ok_and(|n| sensitive(&n)), + "unsupported caller credential header" + ); + } + for name in &route.session_response_headers { + ensure!( + name.parse::().is_ok_and(|n| session(&n)), + "unsupported session response header" + ); + } + let secret_ref = match &route.mediation { + Mediation::Forward => None, + Mediation::Header { + secret_ref, + name, + value_template, + } => { + let header: HeaderName = name.parse().context("invalid mediation header")?; + ensure!( + sensitive(&header) && header != http::header::COOKIE, + "unsupported typed credential sink" + ); + ensure!( + value_template.matches("{secret}").count() == 1 + && value_template.len() <= 1024 + && !value_template.contains(['\r', '\n']), + "invalid header template" + ); + ensure!( + !route + .caller_headers + .iter() + .any(|n| n.eq_ignore_ascii_case(name)), + "sink cannot be caller-owned" + ); + Some(secret_ref) + } + Mediation::Basic { + secret_ref, + username, + } => { + ensure!( + identifier(username) + && !route + .caller_headers + .iter() + .any(|n| n.eq_ignore_ascii_case("authorization")), + "invalid Basic policy" + ); + Some(secret_ref) + } + }; + if let Some(reference) = secret_ref { + ensure!( + 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" + ), + } + } + } + // 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..] { + if left.host == right.host + && left.scheme == right.scheme + && left.methods.iter().any(|m| right.methods.contains(m)) + { + let overlaps = left.paths.iter().any(|p| right.matches_path(p)) + || right.paths.iter().any(|p| left.matches_path(p)) + || left + .path_prefix + .as_ref() + .zip(right.path_prefix.as_ref()) + .is_some_and(|(a, b)| a.starts_with(b) || b.starts_with(a)); + ensure!(!overlaps, "overlapping operation grants"); + } + } + } + Ok(()) + } +} +impl Route { + fn matches_path(&self, path: &str) -> bool { + self.paths.iter().any(|p| p == path) + || self + .path_prefix + .as_deref() + .is_some_and(|p| path.starts_with(p)) + } +} + +/// Runtime for exactly one isolated workload. Contains secret-bearing objects. +pub struct Gateway { + config: GatewayConfig, + client: reqwest::Client, + resolver: Arc, + secrets: Arc, + tls: Arc, + receipts: ReceiptJournal, +} +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 { + config.validate()?; + let hosts = config.routes.iter().map(|r| r.host.clone()).collect(); + let resolver: Arc = + Arc::new(PinnedResolver::new(hosts, HashSet::new())); + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .dns_resolver(Arc::clone(&resolver)) + .build()?; + let tls = Arc::new(TlsAuthority::load(&config.tls)?); + let receipts = ReceiptJournal::start(&config.receipts)?; + Ok(Self { + config, + client, + resolver, + secrets, + tls, + receipts, + }) + } + /// Build a router for this exclusive listener only. + pub fn router(self: Arc) -> Router { + Router::new() + .route( + "/healthz", + axum::routing::get(|| async { StatusCode::NO_CONTENT }), + ) + .route( + "/readyz", + axum::routing::get(|State(state): State>| async move { + if state.receipts.is_healthy() { + StatusCode::NO_CONTENT + } else { + StatusCode::SERVICE_UNAVAILABLE + } + }), + ) + .fallback(entry) + .with_state(self) + } +} + +async fn entry(State(state): State>, mut request: Request) -> Response { + if request.method() == Method::CONNECT { + match prepare_connect(&state, &request) { + Ok((host, config)) => { + let upgrade = hyper::upgrade::on(&mut request); + tokio::spawn(async move { + if intercept(state, upgrade, host, config).await.is_err() { + tracing::warn!(outcome = "gateway_tunnel_closed", "gateway tunnel closed"); + } + }); + StatusCode::OK.into_response() + } + Err(_) => denied(), + } + } else { + // Only explicit plaintext HTTP is accepted outside CONNECT. + if request.uri().scheme_str() != Some("http") { + return denied(); + } + handle(&state, request).await + } +} +fn denied() -> Response { + (StatusCode::FORBIDDEN, "gateway request denied").into_response() +} +fn prepare_connect( + state: &Gateway, + request: &Request, +) -> Result<(String, Arc)> { + ensure!( + !request + .headers() + .contains_key(http::header::PROXY_AUTHORIZATION), + "workload listener does not accept identity overrides" + ); + let authority = request + .uri() + .authority() + .context("CONNECT authority required")?; + ensure!(authority.port_u16() == Some(443), "CONNECT port denied"); + ensure!( + !authority.as_str().contains('@'), + "CONNECT user information denied" + ); + let host = authority.host().to_ascii_lowercase(); + ensure!( + state + .config + .routes + .iter() + .any(|r| r.host == host && r.scheme == "https"), + "CONNECT host denied" + ); + crate::proxy::validate_request_authority( + request.headers(), + &reqwest::Url::parse(&format!("https://{authority}/"))?, + )?; + Ok((host.clone(), state.tls.server_config(&host)?)) +} +async fn intercept( + state: Arc, + upgrade: hyper::upgrade::OnUpgrade, + host: String, + config: Arc, +) -> Result<()> { + use hyper::{ + server::conn::{http1, http2}, + service::service_fn, + }; + use hyper_util::rt::{TokioExecutor, TokioIo}; + let upgraded = timeout(Duration::from_secs(10), upgrade).await??; + let tls = timeout( + Duration::from_secs(10), + TlsAcceptor::from(config).accept(TokioIo::new(upgraded)), + ) + .await??; + ensure!( + tls.get_ref() + .1 + .server_name() + .is_some_and(|s| s.eq_ignore_ascii_case(&host)), + "SNI authority mismatch" + ); + let alpn = tls.get_ref().1.alpn_protocol().map(<[u8]>::to_vec); + let lifetime = Duration::from_hours(1); + let service = service_fn(move |request: hyper::Request| { + let state = Arc::clone(&state); + let host = host.clone(); + async move { + let response = if request + .headers() + .contains_key(http::header::PROXY_AUTHORIZATION) + { + denied() + } else { + match crate::proxy::prepare_tunneled_request(request, &host, None) { + Ok(request) => handle(&state, request).await, + Err(response) => *response, + } + }; + Ok::<_, Infallible>(response) + } + }); + match alpn.as_deref() { + Some(b"h2") => { + timeout( + lifetime, + http2::Builder::new(TokioExecutor::new()) + .serve_connection(TokioIo::new(tls), service), + ) + .await??; + } + Some(b"http/1.1") | None => { + timeout( + lifetime, + http1::Builder::new().serve_connection(TokioIo::new(tls), service), + ) + .await??; + } + _ => bail!("unsupported TLS application protocol"), + } + Ok(()) +} +async fn handle(state: &Gateway, request: Request) -> Response { + if let Ok(response) = forward(state, request).await { + response + } else { + tracing::warn!(outcome = "gateway_request_denied", "gateway request denied"); + denied() + } +} + +#[allow(clippy::too_many_lines)] +async fn forward(state: &Gateway, request: Request) -> Result { + let started = std::time::Instant::now(); + let permit = state.receipts.reserve()?; + let mut attempt = Attempt { + permit: Some(permit), + started, + receipt: DataPlaneReceipt { + realm: state.config.realm.id.clone(), + workload: state.config.workload.clone(), + capability: "denied".into(), + service: "denied".into(), + destination: "denied.invalid".into(), + method: "DENIED".into(), + path: "/denied".into(), + status: Some(403), + delivered_bytes: 0, + elapsed_ms: 0, + outcome: ReceiptOutcome::Denied, + }, + }; + let (parts, body) = request.into_parts(); + ensure!( + !parts.headers.contains_key(http::header::UPGRADE) + && parts.method != Method::CONNECT + && !parts + .headers + .contains_key(http::header::PROXY_AUTHORIZATION), + "unsupported protocol or identity override" + ); + let target = reqwest::Url::parse(&parts.uri.to_string()) + .map_err(|_| anyhow::anyhow!("invalid target"))?; + let host = target.host_str().context("missing destination")?; + ensure!( + target.username().is_empty() && target.password().is_none() && target.fragment().is_none(), + "target userinfo denied" + ); + ensure!( + matches!( + (target.scheme(), target.port_or_known_default()), + ("https", Some(443)) | ("http", Some(80)) + ), + "transport denied" + ); + crate::proxy::validate_request_authority(&parts.headers, &target)?; + // Reject encoded separators/dot segments rather than disagreeing with upstream routing. + let path = parts.uri.path(); + ensure!( + path == target.path() + && !path.contains('\\') + && !path.split('/').any(|s| matches!(s, "." | "..")), + "ambiguous path denied" + ); + let route = state + .config + .routes + .iter() + .find(|r| { + r.host == host + && r.scheme == target.scheme() + && r.methods.iter().any(|m| m == parts.method.as_str()) + && r.matches_path(path) + && (r.allow_query || target.query().is_none()) + }) + .context("operation denied")?; + for segment in path.split('/') { + let decoded = percent_encoding::percent_decode_str(segment) + .decode_utf8() + .map_err(|_| anyhow::anyhow!("invalid path encoding"))?; + ensure!( + !decoded.contains(['/', '\\']) + && !decoded.chars().any(char::is_control) + && !matches!(decoded.as_ref(), "." | ".."), + "ambiguous encoded path denied" + ); + } + attempt.receipt.capability.clone_from(&route.name); + attempt.receipt.service.clone_from(&route.name); + attempt.receipt.destination.clone_from(&route.host); + attempt.receipt.method = parts.method.to_string(); + attempt.receipt.path = format!("/capability/{}", route.name); + crate::proxy::enforce_content_length(&parts.headers, route.max_request_bytes, "request")?; + let mut headers = HeaderMap::new(); + for (name, value) in crate::proxy::filtered_headers(&parts.headers)? { + if sensitive(&name) { + let sink = match &route.mediation { + Mediation::Header { name: sink, .. } => name.as_str().eq_ignore_ascii_case(sink), + Mediation::Basic { .. } => name == http::header::AUTHORIZATION, + Mediation::Forward => false, + }; + ensure!( + sink || route + .caller_headers + .iter() + .any(|n| n.eq_ignore_ascii_case(name.as_str())), + "caller credential header denied" + ); + } + headers.append(name, value); + } + ensure!( + !parts.headers.contains_key(http::header::CONTENT_ENCODING), + "encoded uploads unsupported" + ); + headers.insert( + http::header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + let mut addresses = timeout( + Duration::from_secs(10) + .min(Duration::from_secs(route.max_duration_seconds).saturating_sub(started.elapsed())), + state.resolver.resolve(host.parse()?), + ) + .await + .map_err(|_| anyhow::anyhow!("DNS authorization timed out"))? + .map_err(|_| anyhow::anyhow!("destination address denied"))?; + ensure!(addresses.next().is_some(), "destination address denied"); + let mut protected = Vec::new(); + match &route.mediation { + Mediation::Forward => {} + Mediation::Header { + secret_ref, + name, + value_template, + } => { + let reference = format!("{{{{charon.{}}}}}", route.name); + let expected = value_template.replace("{secret}", &reference); + ensure!( + parts.headers.get(name).and_then(|v| v.to_str().ok()) == Some(expected.as_str()) + && 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"))??; + ensure!( + (8..=16384).contains(&secret.expose_secret().len()), + "credential bound violated" + ); + let rendered = crate::broker::render_secret(value_template, &secret)?; + headers.insert( + name.parse::()?, + HeaderValue::from_str(rendered.expose_secret()) + .map_err(|_| anyhow::anyhow!("invalid credential header"))?, + ); + protected.extend([secret, rendered]); + } + Mediation::Basic { + secret_ref, + username, + } => { + let reference = format!("{{{{charon.{}}}}}", route.name); + let expected = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{reference}")) + ); + ensure!( + parts + .headers + .get(http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + == Some(expected.as_str()) + && 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"))??; + ensure!( + (8..=16384).contains(&secret.expose_secret().len()), + "credential bound violated" + ); + let plain = SecretString::from(format!("{username}:{}", secret.expose_secret())); + let rendered = SecretString::from(format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(plain.expose_secret()) + )); + headers.insert( + http::header::AUTHORIZATION, + HeaderValue::from_str(rendered.expose_secret()) + .map_err(|_| anyhow::anyhow!("invalid credential header"))?, + ); + protected.extend([secret, plain, rendered]); + } + } + if !protected.is_empty() { + for (name, value) in &mut headers { + if sensitive(name) { + value.set_sensitive(true); + } + } + } + let encodings = protected + .iter() + .flat_map(|v| { + let raw = v.expose_secret(); + [ + SecretString::from( + url::form_urlencoded::byte_serialize(raw.as_bytes()).collect::(), + ), + SecretString::from(base64::engine::general_purpose::STANDARD.encode(raw)), + SecretString::from(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw)), + ] + }) + .collect::>(); + protected.extend(encodings); + let upload = guard_stream( + body.into_data_stream(), + route.max_request_bytes, + Duration::from_secs(route.max_duration_seconds), + Duration::from_secs(route.idle_timeout_seconds), + ); + let remaining = + Duration::from_secs(route.max_duration_seconds).saturating_sub(started.elapsed()); + let response = timeout( + remaining, + state + .client + .request(parts.method.clone(), target) + .headers(headers) + .body(reqwest::Body::wrap_stream(upload)) + .send(), + ) + .await + .map_err(|_| anyhow::anyhow!("request timed out"))? + .map_err(|_| anyhow::anyhow!("upstream unavailable"))?; + ensure!( + response.status() != StatusCode::SWITCHING_PROTOCOLS, + "upstream upgrade denied" + ); + crate::proxy::enforce_content_length(response.headers(), route.max_response_bytes, "response")?; + for encoding in response.headers().get_all(http::header::CONTENT_ENCODING) { + ensure!( + encoding + .to_str() + .is_ok_and(|v| v.eq_ignore_ascii_case("identity")), + "compressed response denied" + ); + } + let mut downstream = Response::builder().status(response.status()); + for (name, value) in crate::proxy::filtered_headers(response.headers())? { + if session(&name) + && !route + .session_response_headers + .iter() + .any(|n| n.eq_ignore_ascii_case(name.as_str())) + { + continue; + } + ensure!( + !protected.iter().any(|s| value + .as_bytes() + .windows(s.expose_secret().len()) + .any(|w| w == s.expose_secret().as_bytes())), + "protected response header denied" + ); + downstream = downstream.header(name, value); + } + let status = response.status().as_u16(); + let remaining = + Duration::from_secs(route.max_duration_seconds).saturating_sub(started.elapsed()); + let guarded = guard_stream( + response.bytes_stream(), + route.max_response_bytes, + remaining, + Duration::from_secs(route.idle_timeout_seconds), + ); + let sanitized = redact_text_stream(guarded, protected, route.max_response_bytes); + // Path is a policy identifier, never an untrusted URL or token-bearing path. + let receipt = DataPlaneReceipt { + realm: state.config.realm.id.clone(), + workload: state.config.workload.clone(), + capability: route.name.clone(), + service: route.name.clone(), + destination: route.host.clone(), + method: parts.method.to_string(), + path: format!("/capability/{}", route.name), + status: Some(status), + delivered_bytes: 0, + elapsed_ms: 0, + outcome: ReceiptOutcome::Interrupted, + }; + Ok(downstream.body(Body::from_stream(receipt_stream( + sanitized, + attempt.permit.take().context("receipt reservation lost")?, + receipt, + started, + )))?) +} + +struct Attempt { + permit: Option, + receipt: DataPlaneReceipt, + started: std::time::Instant, +} +impl Drop for Attempt { + fn drop(&mut self) { + if let Some(permit) = self.permit.take() { + self.receipt.elapsed_ms = + u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX); + permit.record(self.receipt.clone()); + } + } +} + +#[cfg(test)] +#[path = "gateway_tests.rs"] +mod tests; diff --git a/src/gateway_tests.rs b/src/gateway_tests.rs new file mode 100644 index 0000000..dc8df1d --- /dev/null +++ b/src/gateway_tests.rs @@ -0,0 +1,778 @@ +//! 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}, +}; + +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 { + Box::pin(async { + Ok( + Box::new(vec![SocketAddr::from(([8, 8, 8, 8], 443))].into_iter()) + as reqwest::dns::Addrs, + ) + }) + } +} +#[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(), + host: "allowed.test".into(), + scheme: "https".into(), + methods: vec!["GET".into(), "POST".into()], + paths: vec![path.into()], + path_prefix: None, + allow_query: true, + caller_headers: vec!["cookie".into()], + session_response_headers: vec!["set-cookie".into()], + mediation, + max_request_bytes: 1024 * 1024, + max_response_bytes: 1024 * 1024, + max_duration_seconds: 10, + idle_timeout_seconds: 3, + } +} +struct Fixture { + directory: tempfile::TempDir, + gateway: Arc, + provider: Arc, + address: SocketAddr, + upstream_address: SocketAddr, + ca: reqwest::Certificate, + tasks: Vec>, +} +impl Drop for Fixture { + fn drop(&mut self) { + for task in &self.tasks { + task.abort(); + } + } +} +#[allow(clippy::too_many_lines)] +async fn fixture() -> Result { + let directory = tempfile::tempdir()?; + let cert = directory.path().join("ca.pem"); + let key = directory.path().join("key.pem"); + crate::ca::generate("Synthetic Test CA", &cert, &key)?; + let ca = reqwest::Certificate::from_pem(&std::fs::read(&cert)?)?; + let tls_config = TlsConfig { + ca_certificate: cert, + ca_private_key: key, + }; + let authority = TlsAuthority::load(&tls_config)?; + let mut server = authority.server_config("allowed.test")?; + Arc::make_mut(&mut server).alpn_protocols = vec![b"http/1.1".to_vec()]; + let upstream = TcpListener::bind("127.0.0.1:0").await?; + let upstream_addr = upstream.local_addr()?; + // Fixture-only HTTP CONNECT intermediary routes to a real local TLS origin. + let upstream_task = tokio::spawn(async move { + while let Ok((mut socket, _)) = upstream.accept().await { + let server = Arc::clone(&server); + tokio::spawn(async move { + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") && headers.len() < 4096 { + let mut byte = [0]; + if socket.read_exact(&mut byte).await.is_err() { + return; + } + headers.push(byte[0]); + } + 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( + |request: hyper::Request| async move { + let path = request.uri().path().to_owned(); + let auth = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let cookie = request.headers().get("cookie").cloned(); + let upload = request.into_body().collect().await; + let mut response = Response::builder() + .header("content-type", "text/event-stream") + .header("set-cookie", "session=synthetic; Secure"); + if let Some(value) = cookie { + response = response.header("x-cookie-preserved", value); + } + if path == "/redirect" { + response = response + .status(302) + .header("location", "https://denied.test/next"); + } + if path == "/header-echo" { + response = response.header("x-echo", SENTINEL); + } + if path == "/compressed" || path == "/compressed-hop" { + response = response.header("content-encoding", "gzip"); + if path == "/compressed-hop" { + response = response.header("connection", "content-encoding"); + } + } + let body = if path == "/upload" { + Body::from( + upload + .map(http_body_util::Collected::to_bytes) + .unwrap_or_default(), + ) + } else if path == "/stream" { + Body::from_stream(futures_util::stream::unfold( + 0_u8, + |index| async move { + if index == 2 { + return None; + } + if index == 1 { + tokio::time::sleep(Duration::from_millis(800)).await; + } + Some(( + Ok::<_, std::io::Error>(axum::body::Bytes::from_static( + b"data: streaming\n\n", + )), + index + 1, + )) + }, + )) + } else { + let chunks = vec![ + Ok::<_, std::io::Error>(axum::body::Bytes::from(format!( + "data: {path}\n\n" + ))), + Ok(axum::body::Bytes::from(format!("data: {auth}\n\n"))), + ]; + Body::from_stream(futures_util::stream::iter(chunks)) + }; + Ok::<_, Infallible>(response.body(body).unwrap_or_else(|_| denied())) + }, + ); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(tls), service) + .await; + }); + } + }); + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let credential = || Mediation::Header { + secret_ref: "CHARON_SYNTHETIC_TEST".into(), + name: "authorization".into(), + value_template: "Bearer {secret}".into(), + }; + let config = GatewayConfig { + version: 1, + realm: RealmConfig { + id: "realm-test".into(), + tenant: "tenant-test".into(), + persona: "persona-test".into(), + generation: 1, + }, + workload: "hermes-test".into(), + listen: address, + exclusive_network: true, + tls: tls_config, + provider: ProviderConfig::Environment, + receipts: ReceiptConfig { + journal_path: directory.path().join("receipts.jsonl"), + state_path: directory.path().join("checkpoint"), + queue_capacity: 64, + }, + routes: vec![ + route("stream", "/stream", Mediation::Forward), + route("plain", "/plain", Mediation::Forward), + route("credential", "/credential", credential()), + route( + "gh-user", + "/api/v3/user", + Mediation::Header { + secret_ref: "CHARON_SYNTHETIC_TEST".into(), + name: "authorization".into(), + value_template: "token {secret}".into(), + }, + ), + route( + "basic", + "/basic", + Mediation::Basic { + secret_ref: "CHARON_SYNTHETIC_TEST".into(), + username: "fixed-user".into(), + }, + ), + route("redirect", "/redirect", Mediation::Forward), + route("upload", "/upload", Mediation::Forward), + route("header", "/header-echo", credential()), + route("compressed", "/compressed", Mediation::Forward), + route("compressed-hop", "/compressed-hop", Mediation::Forward), + ], + }; + let provider = Arc::new(FakeProvider(AtomicUsize::new(0))); + let mut gateway = Gateway::new(config, provider.clone())?; + gateway.resolver = Arc::new(PublicFixtureResolver); + gateway.client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .proxy(reqwest::Proxy::all(format!("http://{upstream_addr}"))?) + .add_root_certificate(ca.clone()) + .build()?; + let gateway = Arc::new(gateway); + let router = Arc::clone(&gateway).router(); + let gateway_task = tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + Ok(Fixture { + directory, + gateway, + provider, + address, + upstream_address: upstream_addr, + ca, + tasks: vec![upstream_task, gateway_task], + }) +} +fn client(f: &Fixture) -> Result { + Ok(reqwest::Client::builder() + .http1_only() + .no_proxy() + .proxy(reqwest::Proxy::all(format!("http://{}", f.address))?) + .add_root_certificate(f.ca.clone()) + .redirect(reqwest::redirect::Policy::none()) + .build()?) +} +#[tokio::test] +async fn ordinary_clients_forward_stream_upload_and_keep_sessions_without_lookup() -> Result<()> { + let f = fixture().await?; + let client = client(&f)?; + for _ in 0..2 { + let response = client + .get("https://allowed.test/plain?token=synthetic-query") + .header("cookie", "session=synthetic") + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert!(response.headers().contains_key("set-cookie")); + assert_eq!( + response.headers()["x-cookie-preserved"], + "session=synthetic" + ); + assert!(response.text().await?.contains("data: /plain")); + } + let response = client + .post("https://allowed.test/upload") + .body("synthetic upload") + .send() + .await?; + assert_eq!(response.text().await?, "synthetic upload"); + let response = client.get("https://allowed.test/redirect").send().await?; + assert_eq!(response.status(), 302); + assert!(client.get("https://denied.test/next").send().await.is_err()); + let response = client + .get("https://allowed.test/denied") + .header("authorization", SENTINEL) + .send() + .await?; + assert_eq!(response.status(), 403); + assert!(!response.text().await?.contains(SENTINEL)); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + Ok(()) +} +#[tokio::test] +async fn hydration_is_typed_and_sentinel_never_reaches_errors_or_journal() -> Result<()> { + let f = fixture().await?; + let client = client(&f)?; + for value in [ + SENTINEL, + "Bearer {{charon.plain}}", + "Bearer {{charon.CHARON_SYNTHETIC_TEST}}", + ] { + let response = client + .get("https://allowed.test/credential") + .header("authorization", value) + .send() + .await?; + assert_eq!(response.status(), 403); + assert!(!response.text().await?.contains(SENTINEL)); + } + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + let response = client + .get("https://allowed.test/credential") + .header("authorization", "Bearer {{charon.credential}}") + .send() + .await?; + assert_eq!(response.status(), 200); + let text = response.text().await?; + assert!(!text.contains(SENTINEL)); + assert!(text.contains("[REDACTED]")); + let response = client + .get("https://allowed.test/header-echo") + .header("authorization", "Bearer {{charon.header}}") + .send() + .await?; + assert_eq!(response.status(), 403); + assert!(!response.text().await?.contains(SENTINEL)); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 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)); + assert!(!journal.contains("synthetic-query")); + assert!(!journal.contains("CHARON_SYNTHETIC_TEST")); + Ok(()) +} +#[tokio::test] +async fn reusable_tls_connection_reauthorizes_each_request_and_authority() -> Result<()> { + let f = fixture().await?; + let mut socket = TcpStream::connect(f.address).await?; + socket + .write_all(b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\n") + .await?; + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + socket.read_exact(&mut byte).await?; + headers.push(byte[0]); + } + assert!(headers.starts_with(b"HTTP/1.1 200")); + let mut roots = rustls::RootCertStore::empty(); + roots.add(rustls::pki_types::CertificateDer::from_pem_slice( + &std::fs::read(&f.gateway.config.tls.ca_certificate)?, + )?)?; + let config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + let tls = tokio_rustls::TlsConnector::from(Arc::new(config)) + .connect( + rustls::pki_types::ServerName::try_from("allowed.test")?, + socket, + ) + .await?; + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(tls)).await?; + let task = tokio::spawn(connection); + for (path, host, status) in [ + ("/plain", "allowed.test", 200), + ("/denied", "allowed.test", 403), + ("/plain", "wrong.test", 421), + ("/plain", "allowed.test", 200), + ] { + let response = sender + .send_request( + hyper::Request::builder() + .uri(path) + .header("host", host) + .body(http_body_util::Empty::::new())?, + ) + .await?; + assert_eq!(response.status().as_u16(), status); + response.into_body().collect().await?; + } + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + task.abort(); + Ok(()) +} +#[tokio::test] +async fn unsupported_protocols_and_compression_fail_closed() -> Result<()> { + let f = fixture().await?; + let client = client(&f)?; + for path in ["compressed", "compressed-hop"] { + let response = client + .get(format!("https://allowed.test/{path}")) + .send() + .await?; + assert_eq!(response.status(), 403); + } + let tls = tunnel(&f, "allowed.test", vec![b"http/1.1".to_vec()]).await?; + let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(tls)).await?; + let task = tokio::spawn(connection); + for (name, value) in [ + ("upgrade", "websocket"), + ("proxy-authorization", "caller-override"), + ("content-encoding", "gzip"), + ] { + let response = sender + .send_request( + hyper::Request::builder() + .uri("/plain") + .header("host", "allowed.test") + .header(name, value) + .body(http_body_util::Empty::::new())?, + ) + .await?; + assert_eq!(response.status(), 403); + response.into_body().collect().await?; + } + task.abort(); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + Ok(()) +} +#[test] +fn schema_rejects_shared_and_ambiguous_policy() -> Result<()> { + let config = GatewayConfig::load(Path::new("examples/hermes-gateway.toml"))?; + let mut invalid = config.clone(); + invalid.exclusive_network = false; + assert!(invalid.validate().is_err()); + let mut invalid = config.clone(); + invalid.routes.push(invalid.routes[0].clone()); + assert!(invalid.validate().is_err()); + let mut invalid = config.clone(); + invalid.routes[0].host = "*.example.com".into(); + assert!(invalid.validate().is_err()); + let mut invalid = config.clone(); + invalid.routes[0].host = "127.0.0.1".into(); + assert!(invalid.validate().is_err()); + let mut invalid = config; + invalid.routes[0].mediation = Mediation::Header { + secret_ref: "CHARON_TEST".into(), + name: "host".into(), + value_template: "{secret}".into(), + }; + assert!(invalid.validate().is_err()); + Ok(()) +} + +#[tokio::test] +async fn streaming_delivers_before_completion_and_basic_is_a_typed_sink() -> Result<()> { + let f = fixture().await?; + let client = client(&f)?; + let mut response = client.get("https://allowed.test/stream").send().await?; + assert_eq!(response.status(), 200); + let first = timeout(Duration::from_millis(400), response.chunk()).await??; + assert!(first.is_some()); + let rest = response.text().await?; + assert!(rest.contains("streaming")); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + let response = client + .get("https://allowed.test/basic") + .basic_auth("fixed-user", Some("{{charon.basic}}")) + .send() + .await?; + assert_eq!(response.status(), 200); + let body = response.text().await?; + assert!(!body.contains(SENTINEL)); + assert!(body.contains("[REDACTED]")); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 1); + Ok(()) +} + +async fn tunnel( + f: &Fixture, + sni: &str, + alpn: Vec>, +) -> Result> { + let mut socket = TcpStream::connect(f.address).await?; + socket + .write_all(b"CONNECT allowed.test:443 HTTP/1.1\r\nHost: allowed.test:443\r\n\r\n") + .await?; + let mut headers = Vec::new(); + while !headers.ends_with(b"\r\n\r\n") && headers.len() < 4096 { + let mut byte = [0]; + socket.read_exact(&mut byte).await?; + headers.push(byte[0]); + } + ensure!( + headers.starts_with(b"HTTP/1.1 200"), + "synthetic CONNECT failed" + ); + let mut roots = rustls::RootCertStore::empty(); + roots.add(rustls::pki_types::CertificateDer::from_pem_slice( + &std::fs::read(&f.gateway.config.tls.ca_certificate)?, + )?)?; + let mut config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions()? + .with_root_certificates(roots) + .with_no_client_auth(); + config.alpn_protocols = alpn; + Ok(tokio_rustls::TlsConnector::from(Arc::new(config)) + .connect( + rustls::pki_types::ServerName::try_from(sni.to_owned())?, + socket, + ) + .await?) +} +#[tokio::test] +async fn http2_reuse_reauthorizes_authority_and_sni_is_required() -> Result<()> { + let f = fixture().await?; + let tls = tunnel(&f, "allowed.test", vec![b"h2".to_vec()]).await?; + assert_eq!(tls.get_ref().1.alpn_protocol(), Some(b"h2".as_slice())); + let (mut sender, connection) = hyper::client::conn::http2::handshake( + hyper_util::rt::TokioExecutor::new(), + TokioIo::new(tls), + ) + .await?; + let task = tokio::spawn(connection); + for (uri, status) in [ + ("https://allowed.test/plain", 200), + ("https://allowed.test/no-grant", 403), + ("https://wrong.test/plain", 421), + ("https://allowed.test:444/plain", 421), + ("http://allowed.test/plain", 421), + ("https://user@allowed.test/plain", 421), + ("https://allowed.test/plain", 200), + ] { + let response = sender + .send_request( + hyper::Request::builder() + .uri(uri) + .body(http_body_util::Empty::::new())?, + ) + .await?; + assert_eq!(response.status().as_u16(), status); + response.into_body().collect().await?; + } + task.abort(); + assert!(tunnel(&f, "wrong.test", Vec::new()).await.is_err()); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + Ok(()) +} + +#[tokio::test] +async fn rejects_private_dns_before_provider_and_invalid_upstream_tls() -> Result<()> { + let f = fixture().await?; + let mut config = f.gateway.config.clone(); + config.receipts.journal_path = f.directory.path().join("private-receipts.jsonl"); + config.receipts.state_path = f.directory.path().join("private-chain"); + config.routes.retain(|r| r.name == "credential"); + config.routes[0].host = "localhost".into(); + let gateway = Gateway::new(config, f.provider.clone())?; + let response = handle( + &gateway, + Request::builder() + .uri("https://localhost/credential") + .header("authorization", "Bearer {{charon.credential}}") + .body(Body::empty())?, + ) + .await; + assert_eq!(response.status(), 403); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + let no_trust = reqwest::Client::builder() + .no_proxy() + .proxy(reqwest::Proxy::all(format!("http://{}", f.address))?) + .build()?; + assert!( + no_trust + .get("https://allowed.test/plain") + .send() + .await + .is_err() + ); + let mut config = f.gateway.config.clone(); + config.receipts.journal_path = f.directory.path().join("untrusted-receipts.jsonl"); + config.receipts.state_path = f.directory.path().join("untrusted-chain"); + let mut gateway = Gateway::new(config, f.provider.clone())?; + gateway.resolver = Arc::new(PublicFixtureResolver); + gateway.client = reqwest::Client::builder() + .no_proxy() + .proxy(reqwest::Proxy::all(format!( + "http://{}", + f.upstream_address + ))?) + .build()?; + let response = handle( + &gateway, + Request::builder() + .uri("https://allowed.test/plain") + .body(Body::empty())?, + ) + .await; + assert_eq!(response.status(), 403); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + Ok(()) +} + +#[derive(Clone)] +struct LogWriter(Arc>>); +impl std::io::Write for LogWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .map_err(|_| std::io::Error::other("log fixture unavailable"))? + .extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} +#[tokio::test] +async fn secret_bearing_upstream_error_is_redacted_in_logs_and_receipts() -> Result<()> { + let f = fixture().await?; + let logs = Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = Arc::clone(&logs); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .without_time() + .with_max_level(tracing::Level::INFO) + .with_writer(move || LogWriter(Arc::clone(&sink))) + .finish(); + // Keep callsites enabled throughout concurrent tests; a scoped subscriber + // can race with the global no-subscriber maximum-level cache. + tracing::subscriber::set_global_default(subscriber)?; + let request = Request::builder() + .uri(format!("https://allowed.test/header-echo?token={SENTINEL}")) + .header("authorization", "Bearer {{charon.header}}") + .body(Body::from("synthetic private prompt"))?; + let response = handle(&f.gateway, request).await; + assert_eq!(response.status(), 403); + let body = axum::body::to_bytes(response.into_body(), 1024).await?; + assert_eq!(body, "gateway request denied"); + let log = String::from_utf8( + logs.lock() + .map_err(|_| anyhow::anyhow!("log fixture unavailable"))? + .clone(), + )?; + assert!(log.contains("gateway_request_denied")); + for forbidden in [ + SENTINEL, + "synthetic private prompt", + "header-echo", + "CHARON_SYNTHETIC_TEST", + ] { + assert!(!log.contains(forbidden)); + } + tokio::time::sleep(Duration::from_millis(50)).await; + let journal = std::fs::read_to_string(&f.gateway.config.receipts.journal_path)?; + assert!(journal.contains("denied")); + for forbidden in [ + SENTINEL, + "synthetic private prompt", + "header-echo", + "CHARON_SYNTHETIC_TEST", + ] { + assert!(!journal.contains(forbidden)); + } + Ok(()) +} + +#[tokio::test] +async fn configured_upload_and_download_bounds_are_enforced() -> Result<()> { + let f = fixture().await?; + let mut config = f.gateway.config.clone(); + config.receipts.journal_path = f.directory.path().join("bounded-receipts.jsonl"); + config.receipts.state_path = f.directory.path().join("bounded-chain"); + for route in &mut config.routes { + route.max_request_bytes = 16; + route.max_response_bytes = 16; + } + let mut gateway = Gateway::new(config, f.provider.clone())?; + gateway.resolver = Arc::new(PublicFixtureResolver); + gateway.client = f.gateway.client.clone(); + let response = handle( + &gateway, + Request::builder() + .uri("https://allowed.test/credential") + .header("authorization", "Bearer {{charon.credential}}") + .header("content-length", "32") + .body(Body::from("synthetic oversized upload"))?, + ) + .await; + assert_eq!(response.status(), 403); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + let response = handle( + &gateway, + Request::builder() + .uri("https://allowed.test/stream") + .body(Body::empty())?, + ) + .await; + assert_eq!(response.status(), 200); + assert!( + axum::body::to_bytes(response.into_body(), 1024) + .await + .is_err() + ); + assert_eq!(f.provider.0.load(Ordering::SeqCst), 0); + Ok(()) +} + +fn executable(name: &str) -> Option { + std::env::var_os("PATH").and_then(|path| { + std::env::split_paths(&path) + .map(|dir| dir.join(name)) + .find(|p| p.is_file()) + }) +} +#[tokio::test] +async fn unmodified_curl_and_gh_use_standard_proxy_and_credential_settings() -> Result<()> { + let curl = executable("curl").context("curl required for ordinary client proof")?; + let f = fixture().await?; + let proxy = format!("http://{}", f.address); + let curl_output = tokio::process::Command::new(curl) + .env_clear() + .arg("--silent") + .arg("--show-error") + .arg("--noproxy") + .arg("") + .arg("--proxy") + .arg(&proxy) + .arg("--cacert") + .arg(&f.gateway.config.tls.ca_certificate) + .arg("--max-time") + .arg("10") + .arg("https://allowed.test/plain") + .output() + .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); + // 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. + if cfg!(target_os = "macos") { + return Ok(()); + } + let gh = executable("gh").context("gh required for ordinary client proof")?; + // Clear all ambient authentication/config: these are fake credentials and a + // loopback TLS origin, never the operator's signed-in GitHub session. + let gh_output = tokio::time::timeout( + Duration::from_secs(20), + tokio::process::Command::new(gh) + .kill_on_drop(true) + .env_clear() + .env("HOME", f.directory.path()) + .env("GH_CONFIG_DIR", f.directory.path().join("gh")) + .env("GH_HOST", "allowed.test") + .env("GH_ENTERPRISE_TOKEN", "{{charon.gh-user}}") + .env("HTTPS_PROXY", &proxy) + .env("SSL_CERT_FILE", &f.gateway.config.tls.ca_certificate) + .arg("api") + .arg("user") + .output(), + ) + .await??; + ensure!(gh_output.status.success(), "synthetic gh proof failed"); + 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); + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index fcbf916..0ef4d74 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,3 +11,5 @@ pub mod proxy; pub mod receipt; pub mod response; pub mod tls; + +pub mod gateway; diff --git a/src/main.rs b/src/main.rs index de013c7..c047ba3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result, bail, ensure}; use charon::{ ca, config::Config, + gateway::{Gateway, GatewayConfig}, provider, proxy::{AppState, app, serve_transparent}, }; @@ -16,13 +17,43 @@ use tracing_subscriber::EnvFilter; #[tokio::main] #[allow(clippy::too_many_lines)] async fn main() -> Result<()> { + let command = parse_command()?; + let gateway_command = matches!( + command, + Command::GatewayServe(_) | Command::GatewayValidate(_) + ); tracing_subscriber::fmt() .json() - .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "charon=info".into())) + .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()) + }) .init(); - let command = parse_command()?; let config_path = match command { + Command::GatewayValidate(path) => { + GatewayConfig::load(&path)?; + println!("valid workload gateway v1"); + return Ok(()); + } + 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 listener = TcpListener::bind(listen) + .await + .context("gateway bind failed")?; + info!(%listen, "exclusive workload gateway listening"); + axum::serve(listener, gateway.router()) + .with_graceful_shutdown(shutdown_signal()) + .await + .context("gateway server failed")?; + return Ok(()); + } Command::Healthcheck(url) => return healthcheck(&url).await, Command::CaGenerate { name, @@ -134,6 +165,8 @@ async fn main() -> Result<()> { } enum Command { + GatewayServe(PathBuf), + GatewayValidate(PathBuf), Serve(PathBuf), Healthcheck(String), CaGenerate { @@ -157,6 +190,10 @@ enum Command { fn parse_command() -> Result { let args = std::env::args_os().skip(1).collect::>(); match args.as_slice() { + [flag, path] if flag == "--gateway-config" => Ok(Command::GatewayServe(path.into())), + [gateway, validate, path] if gateway == "gateway" && validate == "validate" => { + Ok(Command::GatewayValidate(path.into())) + } [flag, path] if flag == "--config" => Ok(Command::Serve(path.into())), [command, url] if command == "healthcheck" => { Ok(Command::Healthcheck(url.clone().into_string().map_err( @@ -195,7 +232,7 @@ fn parse_command() -> Result { Ok(Command::PolicyInventory(path.into())) } _ => bail!( - "usage: charon --config | charon healthcheck | charon policy validate | charon policy inventory | charon ca generate | charon ca validate | charon ca fingerprint | charon ca export " + "usage: charon --gateway-config | charon gateway validate | charon --config | charon healthcheck | charon policy validate | charon policy inventory | charon ca generate | charon ca validate | charon ca fingerprint | charon ca export " ), } } diff --git a/src/proxy.rs b/src/proxy.rs index de9c382..2e57a56 100644 --- a/src/proxy.rs +++ b/src/proxy.rs @@ -1075,7 +1075,7 @@ where }) } -fn enforce_content_length(headers: &HeaderMap, limit: usize, kind: &str) -> Result<()> { +pub(crate) fn enforce_content_length(headers: &HeaderMap, limit: usize, kind: &str) -> Result<()> { let Some(value) = headers.get(http::header::CONTENT_LENGTH) else { return Ok(()); }; @@ -1259,7 +1259,7 @@ async fn tunneled_request( connect_host: &str, token: &str, ) -> Response { - match prepare_tunneled_request(request, connect_host, token) { + match prepare_tunneled_request(request, connect_host, Some(token)) { Ok(request) => { if let Ok(response) = forward_inner(state, request).await { response @@ -1272,10 +1272,10 @@ async fn tunneled_request( } } -fn prepare_tunneled_request( +pub(crate) fn prepare_tunneled_request( request: hyper::Request, connect_host: &str, - token: &str, + token: Option<&str>, ) -> std::result::Result> { if request .uri() @@ -1287,11 +1287,37 @@ fn prepare_tunneled_request( )); } let uri_authority = request.uri().authority().cloned(); + if request.headers().get_all(http::header::HOST).iter().count() > 1 { + return Err(Box::new( + (StatusCode::MISDIRECTED_REQUEST, "TLS authority mismatch").into_response(), + )); + } let host_authority = request .headers() - .get("host") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.parse::().ok()); + .get(http::header::HOST) + .map(|value| { + value + .to_str() + .ok() + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| { + Box::new( + (StatusCode::MISDIRECTED_REQUEST, "TLS authority mismatch").into_response(), + ) + }) + }) + .transpose()?; + if uri_authority + .as_ref() + .is_some_and(|a| a.as_str().contains('@')) + || host_authority + .as_ref() + .is_some_and(|a| a.as_str().contains('@')) + { + return Err(Box::new( + (StatusCode::MISDIRECTED_REQUEST, "TLS authority mismatch").into_response(), + )); + } if let (Some(uri), Some(host)) = (&uri_authority, &host_authority) && !same_tls_authority(uri, host) { @@ -1318,10 +1344,12 @@ fn prepare_tunneled_request( parts.uri = absolute.parse().map_err(|_| { Box::new((StatusCode::BAD_REQUEST, "request target is invalid").into_response()) })?; - let identity = HeaderValue::from_str(&format!("Charon {token}")).map_err(|_| { - Box::new((StatusCode::UNAUTHORIZED, "workload identity is invalid").into_response()) - })?; - parts.headers.insert(IDENTITY_HEADER, identity); + if let Some(token) = token { + let identity = HeaderValue::from_str(&format!("Charon {token}")).map_err(|_| { + Box::new((StatusCode::UNAUTHORIZED, "workload identity is invalid").into_response()) + })?; + parts.headers.insert(IDENTITY_HEADER, identity); + } Ok(Request::from_parts(parts, Body::new(body))) } @@ -1431,7 +1459,10 @@ fn absolute_target(uri: &Uri) -> Result { Ok(target) } -fn validate_request_authority(headers: &HeaderMap, target: &reqwest::Url) -> Result<()> { +pub(crate) fn validate_request_authority(headers: &HeaderMap, target: &reqwest::Url) -> Result<()> { + if headers.get_all(http::header::HOST).iter().count() > 1 { + anyhow::bail!("multiple Host headers are forbidden"); + } let Some(value) = headers.get(http::header::HOST) else { return Ok(()); }; @@ -1440,6 +1471,9 @@ fn validate_request_authority(headers: &HeaderMap, target: &reqwest::Url) -> Res .context("Host header is invalid")? .parse() .context("Host header authority is invalid")?; + if authority.as_str().contains('@') { + anyhow::bail!("Host user information is forbidden"); + } let target_host = target.host_str().context("target hostname is missing")?; let target_port = target.port_or_known_default(); let authority_port = authority @@ -1452,7 +1486,7 @@ fn validate_request_authority(headers: &HeaderMap, target: &reqwest::Url) -> Res Ok(()) } -fn filtered_headers(headers: &HeaderMap) -> Result> { +pub(crate) fn filtered_headers(headers: &HeaderMap) -> Result> { let mut blocked = HOP_BY_HOP_HEADERS .iter() .map(|name| HeaderName::from_static(name)) diff --git a/src/receipt.rs b/src/receipt.rs index 4df6d59..9274a96 100644 --- a/src/receipt.rs +++ b/src/receipt.rs @@ -83,6 +83,21 @@ pub struct ReceiptPermit { permit: Option>, } +impl ReceiptPermit { + /// Finalize a reserved metadata receipt for a request denied before streaming. + pub fn record(mut self, receipt: DataPlaneReceipt) { + if let Some(permit) = self.permit.take() { + let sender = permit.send(receipt); + if sender.is_closed() { + tracing::error!( + outcome = "receipt_lost", + "receipt writer closed during finalization" + ); + } + } + } +} + impl ReceiptJournal { /// Open protected journal state and start its single append worker. /// From f1d11bbd59a7c80be0c731a565f8077993a5f841 Mon Sep 17 00:00:00 2001 From: Alexander Ververis Date: Sun, 4 Oct 2026 01:26:33 +0800 Subject: [PATCH 12/12] fix: match Hermes provider grants to pinned source (#70) --- docs/hermes-gateway.md | 3 ++- examples/hermes-gateway.toml | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/docs/hermes-gateway.md b/docs/hermes-gateway.md index 7978324..beecca6 100644 --- a/docs/hermes-gateway.md +++ b/docs/hermes-gateway.md @@ -24,7 +24,8 @@ exact hostname and operation grant. No wildcard/allow-all destination exists. | --- | --- | --- | | `auth.openai.com` | POST `/oauth/token`, `/api/accounts/deviceauth/usercode`, `/api/accounts/deviceauth/token` | Caller-owned device login and token refresh | | `chatgpt.com` | GET/POST `/backend-api/codex/` prefix | Caller-owned Codex OAuth, account/session headers, streaming | -| `portal.nousresearch.com` | POST `/api/oauth/device/code`, `/api/oauth/token`, `/oauth/code`, `/oauth/token` | Caller-owned Nous OAuth | +| `chatgpt.com` | GET `/backend-api/wham/usage` | Caller-owned Codex quota probe | +| `portal.nousresearch.com` | POST `/api/oauth/device/code`, `/api/oauth/token` | Caller-owned Nous OAuth | | `inference-api.nousresearch.com` | GET/POST `/v1/` prefix | Caller-owned Nous model credential | | `openrouter.ai` | GET/POST `/api/v1/` prefix | Caller-owned OpenRouter model credential | | `api.openai.com` | GET/POST/DELETE `/v1/` prefix | Caller-owned models and file uploads/downloads | diff --git a/examples/hermes-gateway.toml b/examples/hermes-gateway.toml index a525b88..ca4335a 100644 --- a/examples/hermes-gateway.toml +++ b/examples/hermes-gateway.toml @@ -55,12 +55,28 @@ path_prefix = "/backend-api/codex/" [routes.mediation] kind = "forward" +[[routes]] +name = "codex-usage" +host = "chatgpt.com" +scheme = "https" +methods = ["GET"] +paths = ["/backend-api/wham/usage"] +allow_query = false +caller_headers = ["authorization", "cookie"] +session_response_headers = ["set-cookie", "www-authenticate"] +max_request_bytes = 268435456 +max_response_bytes = 1073741824 +max_duration_seconds = 3600 +idle_timeout_seconds = 120 +[routes.mediation] +kind = "forward" + [[routes]] name = "nous-oauth" host = "portal.nousresearch.com" scheme = "https" methods = ["POST"] -paths = ["/api/oauth/device/code", "/api/oauth/token", "/oauth/code", "/oauth/token"] +paths = ["/api/oauth/device/code", "/api/oauth/token"] allow_query = true caller_headers = ["authorization", "cookie"] session_response_headers = ["set-cookie", "www-authenticate"]