From 8dcd1feaf13cf7134e700db8fff24204fa8ba397 Mon Sep 17 00:00:00 2001 From: mrvasil Date: Fri, 23 Oct 2026 17:49:23 +0300 Subject: [PATCH] Add optional flag tokenization with encrypted durable vault --- Cargo.lock | 222 +++++++++++- Cargo.toml | 4 + README.md | 12 + agent.md | 6 + docs/api.md | 19 ++ docs/flag-protection-verification.md | 15 + docs/flag-protection.md | 138 ++++++++ examples/benchmark.rs | 1 + findings.md | 6 + mirror.example.toml | 15 + progress.md | 4 + scripts/ui_fixture.py | 20 +- src/config.rs | 26 ++ src/control.rs | 15 +- src/engine.rs | 79 ++++- src/flags/mod.rs | 111 ++++++ src/flags/stream.rs | 84 +++++ src/flags/vault.rs | 489 +++++++++++++++++++++++++++ src/lib.rs | 1 + task_plan.md | 10 + tests/control_plane.rs | 2 + tests/flag_protection.rs | 408 ++++++++++++++++++++++ tests/forwarding.rs | 1 + tests/shutdown.rs | 1 + web/app.css | 4 + web/app.js | 12 +- web/index.html | 16 + 27 files changed, 1706 insertions(+), 15 deletions(-) create mode 100644 docs/flag-protection-verification.md create mode 100644 docs/flag-protection.md create mode 100644 src/flags/mod.rs create mode 100644 src/flags/stream.rs create mode 100644 src/flags/vault.rs create mode 100644 tests/flag_protection.rs diff --git a/Cargo.lock b/Cargo.lock index 5cd28e9..4b7004a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,28 @@ # 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 = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -198,6 +220,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.2" @@ -209,6 +242,30 @@ 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 = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.7" @@ -280,6 +337,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -320,6 +378,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" @@ -341,6 +411,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -450,12 +530,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -686,7 +784,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" 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]] @@ -730,6 +837,17 @@ version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -808,11 +926,15 @@ dependencies = [ "anyhow", "axum", "bytes", + "chacha20poly1305", "clap", + "fs2", "futures-util", "if-addrs", "rand 0.9.5", + "regex", "reqwest", + "rusqlite", "serde", "serde_json", "sha2", @@ -850,6 +972,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -885,6 +1013,23 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[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.6" @@ -1005,7 +1150,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -1020,6 +1165,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -1053,6 +1207,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.18" @@ -1125,6 +1291,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -1669,6 +1849,16 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[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" @@ -1705,6 +1895,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" @@ -1833,6 +2029,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7e043c6..a3d4234 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,11 +10,15 @@ license = "MIT" anyhow = "1" axum = { version = "0.8", features = ["json", "macros"] } bytes = "1" +chacha20poly1305 = "0.10" clap = { version = "4", features = ["derive"] } +fs2 = "0.4" futures-util = "0.3" if-addrs = "0.14" rand = "0.9" +regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" diff --git a/README.md b/README.md index fd899d0..c70756f 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ export MIRROR_PASSWORD Исходный CSS перенесён с нормализацией пробелов в концах строк; Bootstrap 5.1.3 и Font Awesome Free 6.0.0 той же версии включены в бинарник. Для работы панели Интернет не нужен. Небольшие изменения: клавиатурное управление, индикаторы устаревших данных, сохранение формы во время обновлений и адаптация узкого экрана. Лицензии встроенных зависимостей доступны на `/licenses.txt` и в `web/vendor/`. +## Опциональная подмена флагов + +По умолчанию выключена. После настройки `[flags]` и ключа в окружении переключатель +«Подмена флагов» в настройках сервиса включает замену входящих флагов токенами той же +длины и восстановление известных токенов в ответах. Соответствия сохраняются в +зашифрованном XChaCha20-Poly1305 кеше SQLite до отправки токена upstream. Есть TTL, +лимит записей, обработка TCP-фрагментов и статистика. Переключение действует на новые +соединения. Это токенизация с зашифрованным кешем, не авторизация клиента и не защита +от всех способов кражи. TLS/сжатые/подписанные протоколы не поддерживаются. + +[Настройка, семантика и ограничения](docs/flag-protection.md). + ## Доставка и перегрузка | Режим | При медленной реплике | При недоступной/сломавшейся реплике | diff --git a/agent.md b/agent.md index 37d07c1..1f35905 100644 --- a/agent.md +++ b/agent.md @@ -39,3 +39,9 @@ UI follow-up completed: centered/scrollable dialogs, stable full-width selected- ## Private GitHub publication — 2026-10-23 User requested a private GitHub repository. Created `https://github.com/mrvasil/ctf-ad-mirror-tool-rust` and verified PRIVATE visibility. The existing `mrvasil/ctf-ad-mirror-tool` contains unrelated older history and was not modified. Origin points to the new repository; publish local `rewrite/rust` as remote `main`, preserving local history. Runtime config/state, dist artifacts and test-results remain ignored. Before publication: native fmt/Clippy and40 tests passed; inspected86 history blobs for common secret signatures (only synthetic localhost credential-URL rejection test matched). + +## Flag protection in progress +User authorized design explanation followed by implementation. Explained random same-format tokens with XChaCha20-Poly1305 encrypted persistent mapping cache, per-service opt-in, TTL/capacity, key from environment, streaming split handling and explicit ambiguous-tail timeout. Does not distinguish checker from attacker; no TLS/compressed/signed protocol support. Original panel remains foundation. Work tracked in task_plan.md; fixtures synthetic/loopback only. + +## Flag protection completed — 2026-10-23 +User explicitly requires optional substitution, default off. Implemented per-service `flag_protection=false`, backwards-compatible omitted config/state fields, settings checkbox and indicator without redesign. Mode is captured for each TCP session; disabling affects new sessions. Random same-format tokens use an XChaCha20-Poly1305 encrypted SQLite vault, durable before forwarding, bounded worker queue, TTL/capacity and fail-closed errors. See docs/flag-protection.md and docs/flag-protection-verification.md. Fresh49 native/50 Orb tests, native fmt/Clippy, JS syntax and both release builds passed. Browser toggled on/off and persisted both; TCP tests verify byte integrity/half-close/replica equality and session mode. Live demo16700 upgraded with ignored persistent synthetic vault, original topology unchanged, protection off. No real hosts or flags used. diff --git a/docs/api.md b/docs/api.md index 3147363..b7120d7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,3 +58,22 @@ local across snapshots; Enter retries save and Escape restores the saved address Changing a direct IP preserves other replicas; the backend removes any new-primary overlap. Add-proxy drafts persist across dialog cancel/reopen, until successful save or page reload. Process configuration remains TOML-only and requires a restart. + +## Optional flag tokenization + +ServiceConfig adds `flag_protection: boolean` (default false when absent). Full PUT +must preserve this field; switching it affects new connections only. Enabling +requires global `[flags]` configuration and a successfully initialized keyed vault. +Saved topology contains only this boolean, never keys or flag mappings. + +Snapshot adds `flag_protection: null | {ready,entries,cache_hits,encoded,decoded,errors,capacity,ttl_seconds}`. +Counters are global across protected services and count occurrences, not distinct +flags (except entries). They reset on restart; entries reload from the encrypted +vault. Entries expire lazily on vault access/startup. `ready` means the worker is +available, not proof that every future disk write will succeed. Flags, tokens, +keys, and vault paths are never returned. No endpoint dumps or clears the vault. + +Known tokens are decoded only in the matching service-ID namespace. Unknown/expired +matches pass unchanged. Full-vault or persistence failure closes the protected TCP +session and increments errors; there is no plaintext fallback. See +[flag-protection.md](flag-protection.md) for format, TTL, cryptography and TCP limits. diff --git a/docs/flag-protection-verification.md b/docs/flag-protection-verification.md new file mode 100644 index 0000000..9baf48a --- /dev/null +++ b/docs/flag-protection-verification.md @@ -0,0 +1,15 @@ +# Flag tokenization verification — 2026-10-23 + +Final source uses SQLite synchronous=EXTRA. Fresh root checks: + +- macOS arm64: cargo fmt --check; cargo clippy --all-targets --locked -- -D warnings; cargo test --all-targets --locked — 49 passed; cargo build --release --locked; node --check web/app.js. +- Orb Linux amd64: cargo test --all-targets --locked — 50 passed, including Linux loopback behavior; cargo build --release --locked. +- Updated both ignored dist binaries and verified SHA256SUMS. + +Eight flag integration tests plus the vault expiry unit test cover omitted opt-in=false, missing vault rejection, format validation, every two-way split, binary integrity, unknown output, EOF, partial timeout, concurrent reuse, encrypted persistence/restart, wrong key/damage, per-service isolation, capacity and disk failure, identical primary/replica tokens, restored reply, per-connection toggle and HTTP length preservation. Existing forwarding and control tests also pass. + +Chrome isolated panel16900: settings checkbox initially off; enable/save shows indicator, reopening shows checked and disk state is true. Synthetic TCP echo returns exact original bytes. Disable/save removes indicator, disk state is false. No visual redesign. Screenshot from this feature pass is locally ignored at test-results/flags-toggle-off.png. + +Persistent demo16700 now runs the final native release with a local synthetic vault configured. Settings shows enabled checkbox control with value false. Pre/post upgrade topology compares identical. Demo18081 and isolated18281 each return all32802 test bytes exactly after TCP half-close while protection is off. Both release hashes verify. Isolated fixture is stopped after verification; live demo remains. + +No load/soak test, real tournament hosts or real flags used. No claim of universal protocol transparency: candidate tails can wait/timeout, TLS/compression/signatures need adapters, TTL/key loss affects restoration, and all clients receive reverse translation. New flag mappings incur durable-write latency; disabled sessions bypass transformation. diff --git a/docs/flag-protection.md b/docs/flag-protection.md new file mode 100644 index 0000000..0a34191 --- /dev/null +++ b/docs/flag-protection.md @@ -0,0 +1,138 @@ +# Опциональная подмена флагов + +Режим **выключен по умолчанию**, отдельно включается у каждого сервиса в его +настройках. Без него прокси передаёт байты обычным путём. Переключатель влияет +только на новые TCP-соединения; открытые сохраняют свой режим до завершения. +Существующие TOML/state-файлы совместимы: отсутствующее `flag_protection` равно false. + +## Как работает + +Видимый входящий флаг заменяется случайным токеном **той же длины и формы**. +Повторный флаг внутри одного сервиса получает тот же токен до истечения срока +записи. Сервисы разделены по неизменяемому ID. Подмена выполняется один раз до +отправки primary и репликам. В ответах primary восстанавливаются только токены, +которые есть в кеше этого сервиса. Неизвестные совпадения передаются без изменения. + +Это **токенизация**, а не самодельное шифрование с сохранением длины. Соответствия +зашифрованы XChaCha20-Poly1305 с независимым случайным 192-битным nonce на запись +(RustCrypto). SQLite хранит зашифрованные записи и служебные данные (схема, срок +истечения, число записей), но не открытые флаги/токены. Ключ — 256 бит из окружения. +В рабочей памяти соответствия доступны процессу; флаги и ключ не выдаются API, +не выводятся в панель/логи и не записываются в обычный topology state. + +Новое соответствие подтверждается SQLite transaction (`synchronous=EXTRA`, включая синхронизацию каталога журнала) **до** +отправки токена в сеть. Повторное обращение не требует записи. Один фоновый worker +владеет БД и индексами; ограниченная очередь принимает только найденные совпадения. +Сетевые задачи асинхронно ждут результат без удержания сетевых блокировок. Если +клиент исчез после приёма задания worker, начатая запись всё равно завершается. +Это добавляет задержку при первом появлении уникального флага; режим не обещает +скорость обычной пересылки. Не включайте его без проверки конкретного протокола. + +## Настройка + +Создайте ключ **один раз** и храните его отдельно от резервной копии базы. Например, +в своей защищённой директории (не в Git): + +```sh +umask 077 +mkdir -p data +# Выполнять только для новой пустой базы; не перезаписывать существующий ключ. +( set -C; openssl rand -hex 32 > data/flag-key.hex ) +export MIRROR_FLAG_KEY="$(cat data/flag-key.hex)" +``` + +Добавьте секцию в TOML: + +```toml +[flags] +database = "data/flags.sqlite3" +key_env = "MIRROR_FLAG_KEY" +prefix = "" +alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +payload_length = 31 +suffix = "=" +ttl_seconds = 86400 +max_entries = 10000 +partial_timeout_ms = 5000 +``` + +Этот пример распознаёт `[A-Z0-9]{31}=`. Формат задаётся как буквальный prefix, +фиксированное число символов из alphabet и буквальный suffix. Это не произвольный +regex. Например, для `FLAG{` + 32 hex-символа + `}` задайте prefix `FLAG{`, alphabet +`0123456789ABCDEF`, payload_length 32 и suffix `}`. Регистр важен. Совпадения ищутся +как подстроки, слева направо, без перекрытий; границы слов не подразумеваются. + +`payload_length`: 16–128, alphabet: 16–94 уникальных печатных ASCII-символа; +prefix/suffix: до 32 печатных ASCII-символов. Ёмкость: 1–100000 записей, +TTL: 60–604800 секунд, тайм-аут фрагмента: 100–60000 мс. +Путь БД отсчитывается от каталога TOML. Файлы БД и её lock имеют права0600 на Unix; +одна БД может обслуживаться только одним процессом Mirror Tool. + +После настройки перезапустите процесс с ключом в окружении. Затем кликните +название сервиса → **«Подмена флагов»** → «Сохранить». При отсутствии готового кеша +переключатель недоступен, показано объяснение. Включённый режим обозначен замком +рядом с названием; сводные счётчики кеша доступны в справке ⓘ. + +Для стартовой топологии можно указать `flag_protection = true` в нужном +`[[services]]`, но существующий `state.json` имеет приоритет над стартовой +топологией TOML. Обычный CLI `check` проверяет структуру конфигурации, но не +открывает БД и не проверяет ключ; проверка ключа выполняется при запуске. + +## Срок хранения и сбои + +- TTL отсчитывается от первого сохранения; повторный флаг не продлевает его. + Настройте его **дольше срока жизни флага плюс задержка хранения/выдачи ответа**. +- Истёкшие записи удаляются при следующем запросе к кешу либо запуске. Счётчик + entries отражает хранимые записи до этой очистки. После истечения токен неизвестен + и не восстанавливается. Изменение TTL относится к новым записям. +- Нет вытеснения живых записей ради новых. При переполнении, ошибке записи или + недоступности worker защищённая сессия завершается с ошибкой; исходный флаг + не отправляется вместо токена. Уже отправленная часть другого содержимого + не откатывается. Обычные сервисы продолжают работать независимо. +- Неверный ключ, изменение формата для существующей БД, повреждённые записи либо + превышение новой ёмкости запрещают запуск вместо молчаливого сброса кеша. +- Для восстановления нужны **и база, и прежний ключ**. Ротация ключа/формата с + миграцией живых записей не реализована. Не удаляйте БД или ключ во время игры. + Резервную копию SQLite делайте при остановленном процессе или средствами SQLite. +- Аутентификация записей обнаруживает повреждение/неверный ключ, но не защищает + от удаления всей базы или подстановки её старой копии локальным злоумышленником. + +## TCP и ограничения протокола + +Обработчик распознаёт флаги через любые границы TCP-чтений. Удерживается только +возможное начало незаконченного совпадения — максимум длина флага минус один байт. +Остальные байты передаются дальше. При EOF незавершённый фрагмент уже не может стать +флагом и отправляется как обычный текст. Если продолжения/EOF нет до тайм-аута, +соединение закрывается: выпуск такого фрагмента по таймеру мог бы пропустить флаг. + +TCP не содержит границ сообщений. Если запрос заканчивается возможным началом +флага и приложение ждёт ответа, может потребоваться другой формат или адаптер +протокола; универсально разрешить такую неоднозначность нельзя. Пустые prefix/suffix +повышают вероятность случайных совпадений. Используйте точный формат игры. + +Режим работает только с флагами, видимыми в исходных байтах. Нет декодирования +TLS, gzip, URL/base64/JSON-escape и прочих представлений. Сохранение длины оставляет +корректными длины простых сообщений, но не подписи, checksums или семантику +аутентификации. Побочные эффекты зеркалируемых серверов остаются отдельной проблемой. +Подмена уже хранящихся данных задним числом не выполняется. + +**Это не фильтр атакующих:** известные токены восстанавливаются всем клиентам. +Кража через уязвимый сервис, доступный через этот же прокси, может вернуть оригинал. +Режим скрывает оригиналы от upstream, но не заменяет исправление уязвимости. + +## Разработка + +`src/flags/mod.rs`: настройки/формат; `stream.rs`: обработка TCP-фрагментов; +`vault.rs`: криптография, SQLite, actor, TTL и статистика. Сетевой engine держит +настройку в снимке маршрута на время сессии. Проверки находятся в +`tests/flag_protection.rs` и unit-тесте expiry внутри vault. + +Изолированный UI fixture с новым тестовым ключом и **выключенной подменой**: + +```sh +python3 scripts/ui_fixture.py target/release/mirror-tool \ + --data-dir test-results/flags-ui --port-offset 200 --flag-vault +``` + +Fixture создаёт локальный файл ключа0600; повторно использует его после рестарта. +Он предназначен только для локального демо, не для настоящей игры. diff --git a/examples/benchmark.rs b/examples/benchmark.rs index 6212ba3..ffaf86f 100644 --- a/examples/benchmark.rs +++ b/examples/benchmark.rs @@ -133,6 +133,7 @@ async fn main() -> Result<()> { }, enabled: true, auto_switch: false, + flag_protection: false, replica_policy: if mode == "strict" { ReplicaPolicy::Strict } else { diff --git a/findings.md b/findings.md index 5d61d8f..f17bde7 100644 --- a/findings.md +++ b/findings.md @@ -32,3 +32,9 @@ - Browser Use fill does not trigger a native dirty-field change on blur in this environment; direct-IP behavior was verified with keyboard typing and Tab instead. Browser-wide viewport override was ineffective; tab-scoped CDP metrics produced actual390x844 and was cleared after verification. - R6 resolved by one owned admitted transaction; cancelled waiters allocate no worker. Five cancellation regressions plus independent actual HTTP red/green prove cleanup/publication/rollback convergence. Final review approved, no remaining Critical/Important. + +2026-10-23 flag protection: engine currently sends client bytes unchanged before replica fanout and primary bytes unchanged on return. ServiceConfig strict fields require adding defaulted opt-in and updating UI full-PUT serializer. A same-length authenticated ciphertext cannot generally fit original flag domain; use random format-preserving tokens plus encrypted durable map. Mapping must commit before upstream writes. SQLite encrypted records give bounded incremental durable writes; a bounded actor owns DB/maps, avoiding locks across network I/O. Generic TCP has no message boundaries: partial matching tails require bounded timeout and documented protocol limitation. + +Flag implementation: first6 synthetic tests passed (all byte splits, restart/wrong-key/tamper, capacity/SQL write failure,24 concurrent repeats, TCP primary/replica byte identity, opt-out/new-vs-existing sessions, incomplete-tail timeout). Replaced naive per-position matching with precompiled byte regex to avoid repeated full-format checks over each input buffer. Actor serializes only detected matches, no lock is held across network I/O. Existing data path remains buffer reuse when disabled. + +Final durability review: use SQLite rollback-journal synchronous=EXTRA, not FULL, so journal deletion also syncs its directory before acknowledging a new mapping. No live entries evicted; expired mappings intentionally become unknown and pass through, documented. Global format is fixed per vault, changing it with existing DB fails startup. Worker status/counters never include original/token/key/path. User explicitly reiterated per-service opt-in, default false; browser verified initial off, on round-trip, persisted off and cache bypass after disabling. diff --git a/mirror.example.toml b/mirror.example.toml index 35125cf..0bd36e4 100644 --- a/mirror.example.toml +++ b/mirror.example.toml @@ -55,3 +55,18 @@ auto_switch = false # isolate: primary stays fast; explicitly disconnect a slow/failed replica. # strict: backpressure to deliver all bytes to each healthy replica. replica_policy = "isolate" + +# Optional flag tokenization: disabled for every service unless explicitly enabled. +# Configure a persistent key in MIRROR_FLAG_KEY before uncommenting this section. +# Then set flag_protection = true in a service or enable it in the UI. +# See docs/flag-protection.md for protocol/TTL limitations. +# [flags] +# database = "data/flags.sqlite3" +# key_env = "MIRROR_FLAG_KEY" +# prefix = "" +# alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +# payload_length = 31 +# suffix = "=" +# ttl_seconds = 86400 +# max_entries = 10000 +# partial_timeout_ms = 5000 diff --git a/progress.md b/progress.md index c2651ab..3d6d46f 100644 --- a/progress.md +++ b/progress.md @@ -17,3 +17,7 @@ 2026-10-23: R6 resolved with admitted owned transaction worker shared by service/team/automatic changes. Five cancellation regressions, original HTTP red/green and independent re-review accepted. Root fresh final gates: native40/Orb41 tests, native fmt/Clippy, both release builds, ForcAD library interoperability and sample config/JS checks all passed. Final release UI start/primary/replica/robot/custom creation and exact1MiB half-close rechecked. Six final benchmark records committed; isolate disconnections3macOS/4Linux documented explicitly. Prepared git-ignored dist binaries/checksums; no deployment. 2026-10-23 UI follow-up: reproduced off-center modal at(0,0), repaired Bootstrap/native-dialog interaction and header wrapping without redesign. Added local draft/input improvements, team add/rename and configuration help. Fresh40 native/41 Orb tests and release builds pass; browser validates390px layout, CRUD, keyboard invalid IP, restart persistence, stale/recovery and exact256KiB half-close with replica integrity. Live demo migrated from temporary state to ignored data/demo, preserving primaryB/replicaA/enabled; readiness/assets and21504-byte echo verified after restart. See docs/ui-polish-verification.md. Test fixture stopped, live GUI-session job remains running. + +2026-10-23 flag feature: user selected universal configurable mode. Design explained before implementation; use same-format random tokenization plus authenticated encrypted durable SQLite records (not homemade same-length encryption). Initial cargo-add multiple-dependency feature syntax rejected; retried separate dependency additions successfully. No existing source behavior changed yet. + +2026-10-23 flag completion: per-service opt-in defaults off for existing/new configurations; UI switch and indicator preserve the legacy page. Encrypted durable cache, split-safe transformations and documented protocol limits complete. Final native49/Orb50 tests, fmt/Clippy/JS and releases passed. Browser verified on/off persistence; loopback round-trips, replicas, half-close, errors and restart covered. Demo16700 updated with original topology and protection off. Orb lacked rsync, so staging used cp; launchctl bootstrap rejected relative plist path, succeeded with the absolute path after validating plist and absent job. See docs/flag-protection-verification.md. diff --git a/scripts/ui_fixture.py b/scripts/ui_fixture.py index d3a885b..e3ca662 100644 --- a/scripts/ui_fixture.py +++ b/scripts/ui_fixture.py @@ -4,6 +4,7 @@ Usage: python3 scripts/ui_fixture.py target/debug/mirror-tool Public test-only login: local-ui-fixture-only. Default state is disposable. Use --data-dir data/demo for persistent state; --port-offset 100 isolates a test. +--flag-vault prepares an encrypted synthetic vault; services still default off. """ import argparse import asyncio @@ -32,6 +33,7 @@ async def echo(reader, writer): async def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("executable", type=Path) + parser.add_argument("--flag-vault", action="store_true", help="Configure a synthetic flag vault (service protection remains off)") parser.add_argument("--data-dir", type=Path, help="Preserve config/state here; never overwrite an existing config") parser.add_argument("--port-offset", type=int, default=0, help="Add to all fixture ports (0..30000)") args = parser.parse_args() @@ -45,7 +47,8 @@ async def main(): with context as folder: root = Path(folder) config = root / "mirror.toml" - if not config.exists(): + new_config = not config.exists() + if new_config: config.write_text(f'''[web] listen = "127.0.0.1:{ui_port}" state_file = "state.json" @@ -70,9 +73,24 @@ enabled = false auto_switch = false replica_policy = "isolate" ''') + if args.flag_vault: + if new_config: + with config.open("a") as file: + file.write('\n[flags]\ndatabase = "flags.sqlite3"\n') + elif "[flags]" not in config.read_text(): + parser.error("existing config has no [flags]; add database = \"flags.sqlite3\" under [flags] first") + key_file = root / "flag-key.hex" + if not key_file.exists(): + if (root / "flags.sqlite3").exists(): + parser.error("existing vault has no key file; restore its original key") + fd = os.open(key_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "w") as file: + file.write(os.urandom(32).hex()) servers = [await asyncio.start_server(echo, address, echo_port) for address in ("127.0.0.1", "::1")] env = os.environ.copy() env["MIRROR_PASSWORD"] = "local-ui-fixture-only" + if args.flag_vault: + env["MIRROR_FLAG_KEY"] = key_file.read_text().strip() process = await asyncio.create_subprocess_exec(executable, "--config", str(config), env=env) stop = asyncio.Event() for sig in (signal.SIGINT, signal.SIGTERM): diff --git a/src/config.rs b/src/config.rs index 18b3e34..ca4afc7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ use std::{ #[serde(default, deny_unknown_fields)] pub struct Config { pub main_team_ip: Option, + pub flags: Option, pub web: WebConfig, pub network: NetworkConfig, pub monitoring: MonitoringConfig, @@ -99,6 +100,8 @@ pub struct ServiceConfig { pub auto_switch: bool, #[serde(default)] pub replica_policy: ReplicaPolicy, + #[serde(default)] + pub flag_protection: bool, } #[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] @@ -172,6 +175,18 @@ impl Config { .unwrap_or(Path::new(".")) .join(&config.web.state_file); } + if let Some(flags) = &mut config.flags { + if flags.database.is_relative() { + flags.database = path + .parent() + .unwrap_or(Path::new(".")) + .join(&flags.database); + } + ensure!( + flags.database != path, + "flag database cannot be the TOML config" + ); + } config.validate()?; Ok(config) } @@ -236,6 +251,17 @@ impl Config { "invalid scoreboard intervals" ); } + if let Some(flags) = &self.flags { + flags.validate()?; + ensure!( + flags.database != self.web.state_file, + "flag database and topology state must be separate files" + ); + } + ensure!( + self.flags.is_some() || !self.services.iter().any(|s| s.flag_protection), + "configure [flags] and its key before enabling flag protection" + ); validate_topology(&self.teams, &self.services)?; for service in &self.services { ensure!( diff --git a/src/control.rs b/src/control.rs index b0423c3..200b05c 100644 --- a/src/control.rs +++ b/src/control.rs @@ -50,6 +50,7 @@ pub struct Snapshot { pub teams: Vec, pub services: Vec, pub scoreboard: BoardSnapshot, + pub flag_protection: Option, pub events: Vec, } @@ -76,8 +77,17 @@ pub struct Controller { impl Controller { pub async fn start(config: Config) -> Result> { let topology = Topology::load(&config)?; + let vault = if let Some(settings) = config.flags.clone() { + Some( + tokio::task::spawn_blocking(move || crate::flags::Vault::from_env(settings)) + .await + .context("initialize flag vault worker")??, + ) + } else { + None + }; let this = Arc::new_cyclic(|weak| Self { - engine: Engine::new(config.network.clone()), + engine: Engine::with_flag_vault(config.network.clone(), vault.clone()), board: Scoreboard::new(config.scoreboard.as_ref()), topology: RwLock::new(topology.clone()), operations: Arc::new(Mutex::new(())), @@ -164,6 +174,7 @@ impl Controller { teams: topology.teams, services, scoreboard: board, + flag_protection: self.engine.flag_status(), events: self.events.read().unwrap().iter().cloned().collect(), } } @@ -569,6 +580,7 @@ mod tests { replicas: vec!["b".into()], enabled: true, auto_switch: true, + flag_protection: false, replica_policy: ReplicaPolicy::Isolate, }; let health = teams @@ -731,6 +743,7 @@ mod cancellation_tests { replicas: vec![], enabled: true, auto_switch: false, + flag_protection: false, replica_policy: ReplicaPolicy::Isolate, custom: true, custom_ip: Some("127.0.0.1".parse().unwrap()), diff --git a/src/engine.rs b/src/engine.rs index 7648dcc..fa00abc 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -10,6 +10,7 @@ use std::{ }; use crate::config::{NetworkConfig, ReplicaPolicy, ServiceConfig, Team}; +use crate::flags::{FlagStatus, Transformer, Vault}; use anyhow::{Context, Result, anyhow, ensure}; use bytes::Bytes; use serde::Serialize; @@ -87,6 +88,7 @@ struct Route { primary: SocketAddr, replicas: Vec, policy: ReplicaPolicy, + protection: Option<(Arc, String)>, } struct Generation { stop: CancellationToken, @@ -104,6 +106,7 @@ struct Service { } struct Inner { settings: NetworkConfig, + vault: Option>, services: Mutex>>, operations: tokio::sync::Mutex<()>, permits: Arc, @@ -117,11 +120,15 @@ pub struct Engine { } impl Engine { pub fn new(settings: NetworkConfig) -> Self { + Self::with_flag_vault(settings, None) + } + pub fn with_flag_vault(settings: NetworkConfig, vault: Option>) -> Self { Self { inner: Arc::new(Inner { permits: Arc::new(Semaphore::new(settings.max_connections.max(1))), payload_budget: Arc::new(Semaphore::new(settings.max_buffered_bytes)), settings, + vault, services: Mutex::new(BTreeMap::new()), operations: tokio::sync::Mutex::new(()), stopped: AtomicBool::new(false), @@ -129,6 +136,9 @@ impl Engine { }), } } + pub fn flag_status(&self) -> Option { + self.inner.vault.as_ref().map(|v| v.status()) + } pub fn snapshots(&self) -> BTreeMap { self.inner .services @@ -150,6 +160,10 @@ impl Engine { pub async fn upsert(&self, service: &ServiceConfig, teams: &[Team]) -> Result<()> { let _operation = self.inner.operations.lock().await; ensure!(!self.inner.stopped.load(Relaxed), "engine is shut down"); + ensure!( + !service.flag_protection || self.inner.vault.is_some(), + "flag protection requires a configured vault" + ); let route = if service.enabled { let address = |id: &str| -> Result { Ok(SocketAddr::new( @@ -174,6 +188,11 @@ impl Engine { .map(|id| address(id)) .collect::>()?, policy: service.replica_policy, + protection: if service.flag_protection { + self.inner.vault.clone().map(|v| (v, service.id.clone())) + } else { + None + }, }) } else { None @@ -573,19 +592,46 @@ async fn forward( } let (mut client_read, mut client_write) = client.into_split(); let (mut primary_read, mut primary_write) = primary.into_split(); + let mut encoder = route + .protection + .as_ref() + .map(|(vault, id)| Transformer::new(vault.clone(), id.clone(), true)); + let mut decoder = route + .protection + .as_ref() + .map(|(vault, id)| Transformer::new(vault.clone(), id.clone(), false)); let transfer = async { let up = async { let mut buffer = vec![0; settings.buffer_size.max(1)]; loop { - let n = client_read.read(&mut buffer).await?; + let n = match &mut encoder { + Some(codec) => codec.read(&mut client_read, &mut buffer).await?, + None => client_read.read(&mut buffer).await?, + }; + let eof = n == 0; + let transformed; + let data = if let Some(codec) = &mut encoder { + transformed = codec.feed(&buffer[..n], eof).await?; + transformed.as_slice() + } else { + &buffer[..n] + }; + let n = data.len(); if n == 0 { - primary_write.shutdown().await?; - break; + if eof { + primary_write.shutdown().await?; + break; + } + continue; } // The common unmirrored path reuses its buffer without a per-read // allocation/copy. Allocate shared chunks only while replicas exist. if sinks.is_empty() { - write_counted(&mut primary_write, &buffer[..n], &counters.bytes_up).await?; + write_counted(&mut primary_write, data, &counters.bytes_up).await?; + if eof { + primary_write.shutdown().await?; + break; + } continue; } let permit = if route.policy == ReplicaPolicy::Strict { @@ -608,11 +654,15 @@ async fn forward( } sink.cancel.cancel(); } - write_counted(&mut primary_write, &buffer[..n], &counters.bytes_up).await?; + write_counted(&mut primary_write, data, &counters.bytes_up).await?; + if eof { + primary_write.shutdown().await?; + break; + } continue; }; let bytes = Bytes::from_owner(BudgetedChunk { - bytes: buffer[..n].to_vec(), + bytes: data.to_vec(), _permit: permit, }); let mut i = 0; @@ -637,6 +687,10 @@ async fn forward( } } write_counted(&mut primary_write, &bytes, &counters.bytes_up).await?; + if eof { + primary_write.shutdown().await?; + break; + } } sinks.clear(); Ok::<_, std::io::Error>(()) @@ -644,12 +698,20 @@ async fn forward( let down = async { let mut buffer = vec![0; settings.buffer_size.max(1)]; loop { - let n = primary_read.read(&mut buffer).await?; + let n = match &mut decoder { + Some(codec) => codec.read(&mut primary_read, &mut buffer).await?, + None => primary_read.read(&mut buffer).await?, + }; + if let Some(codec) = &mut decoder { + let data = codec.feed(&buffer[..n], n == 0).await?; + write_counted(&mut client_write, &data, &counters.bytes_down).await?; + } else { + write_counted(&mut client_write, &buffer[..n], &counters.bytes_down).await?; + } if n == 0 { client_write.shutdown().await?; break; } - write_counted(&mut client_write, &buffer[..n], &counters.bytes_down).await?; } Ok::<_, std::io::Error>(()) }; @@ -777,6 +839,7 @@ mod accept_tests { primary: upstream, replicas: vec![], policy: ReplicaPolicy::Isolate, + protection: None, }))), counters: Arc::new(Counters::default()), error: Arc::new(Mutex::new(None)), diff --git a/src/flags/mod.rs b/src/flags/mod.rs new file mode 100644 index 0000000..da0bc6d --- /dev/null +++ b/src/flags/mod.rs @@ -0,0 +1,111 @@ +//! Optional format-preserving tokenization with an authenticated encrypted vault. +//! This is not client authorization or encryption of the transport itself. +mod stream; +mod vault; +use anyhow::{Result, ensure}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashSet, path::PathBuf}; +pub use stream::Transformer; +pub use vault::{FlagStatus, Vault}; +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct FlagConfig { + pub database: PathBuf, + pub key_env: String, + pub prefix: String, + pub alphabet: String, + pub payload_length: usize, + pub suffix: String, + pub ttl_seconds: u64, + pub max_entries: usize, + pub partial_timeout_ms: u64, +} +impl Default for FlagConfig { + fn default() -> Self { + Self { + database: "data/flags.sqlite3".into(), + key_env: "MIRROR_FLAG_KEY".into(), + prefix: String::new(), + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789".into(), + payload_length: 31, + suffix: "=".into(), + ttl_seconds: 86400, + max_entries: 10000, + partial_timeout_ms: 5000, + } + } +} +impl FlagConfig { + pub fn validate(&self) -> Result<()> { + ensure!( + !self.database.as_os_str().is_empty(), + "flags.database is required" + ); + ensure!( + !self.key_env.is_empty() + && self + .key_env + .bytes() + .all(|c| c.is_ascii_alphanumeric() || c == b'_'), + "invalid flags.key_env" + ); + ensure!( + (16..=128).contains(&self.payload_length), + "flags.payload_length must be 16..128" + ); + let alphabet: HashSet<_> = self.alphabet.bytes().collect(); + ensure!( + (16..=94).contains(&alphabet.len()) + && alphabet.len() == self.alphabet.len() + && self.alphabet.bytes().all(|c| c.is_ascii_graphic()), + "flags.alphabet must contain 16..94 distinct printable ASCII bytes" + ); + ensure!( + self.prefix.len() <= 32 + && self.suffix.len() <= 32 + && self + .prefix + .bytes() + .chain(self.suffix.bytes()) + .all(|c| c.is_ascii_graphic()), + "flag prefix/suffix must be printable ASCII, at most 32 bytes each" + ); + ensure!( + (60..=604800).contains(&self.ttl_seconds), + "flags.ttl_seconds must be 60..604800" + ); + ensure!( + (1..=100000).contains(&self.max_entries), + "flags.max_entries must be 1..100000" + ); + ensure!( + (100..=60000).contains(&self.partial_timeout_ms), + "flags.partial_timeout_ms must be 100..60000" + ); + Ok(()) + } + pub fn width(&self) -> usize { + self.prefix.len() + self.payload_length + self.suffix.len() + } + pub fn matches_prefix(&self, bytes: &[u8]) -> bool { + bytes.len() <= self.width() + && bytes.iter().enumerate().all(|(i, &c)| { + if i < self.prefix.len() { + c == self.prefix.as_bytes()[i] + } else if i < self.prefix.len() + self.payload_length { + self.alphabet.as_bytes().contains(&c) + } else { + c == self.suffix.as_bytes()[i - self.prefix.len() - self.payload_length] + } + }) + } + pub(crate) fn format_id(&self) -> Vec { + serde_json::to_vec(&( + &self.prefix, + &self.alphabet, + self.payload_length, + &self.suffix, + )) + .expect("format serialization") + } +} diff --git a/src/flags/stream.rs b/src/flags/stream.rs new file mode 100644 index 0000000..86df9ce --- /dev/null +++ b/src/flags/stream.rs @@ -0,0 +1,84 @@ +use super::Vault; +use std::{io, sync::Arc}; +use tokio::{ + io::{AsyncRead, AsyncReadExt}, + time::{Duration, Instant, timeout_at}, +}; +/// Retain only a suffix which can still become a complete flag. Read boundaries +/// are not messages. EOF proves a suffix incomplete; timeout must fail closed. +pub struct Transformer { + vault: Arc, + service: String, + encode: bool, + pending: Vec, + deadline: Option, +} +impl Transformer { + pub fn new(vault: Arc, service: String, encode: bool) -> Self { + Self { + vault, + service, + encode, + pending: Vec::new(), + deadline: None, + } + } + pub async fn read( + &mut self, + reader: &mut R, + buffer: &mut [u8], + ) -> io::Result { + if let Some(deadline) = self.deadline { + timeout_at(deadline, reader.read(buffer)).await.map_err(|_| { + self.vault.record_error(); + io::Error::new(io::ErrorKind::TimedOut, "incomplete flag candidate timed out; protocol needs framing or a different flag format") + })? + } else { + reader.read(buffer).await + } + } + pub async fn feed(&mut self, data: &[u8], eof: bool) -> io::Result> { + let mut input = std::mem::take(&mut self.pending); + input.extend_from_slice(data); + let cfg = &self.vault.config; + let width = cfg.width(); + let mut matches = Vec::new(); + let mut positions = Vec::new(); + let mut pos = 0; + for found in self.vault.matcher.find_iter(&input) { + positions.push(found.start()); + matches.push(found.as_bytes().to_vec()); + pos = found.end(); + } + // A future match can start only within the last width-1 bytes and after + // the last complete match. Regex handles the full buffer in linear time. + pos = pos.max(input.len().saturating_sub(width - 1)); + let mut emit = input.len(); + if !eof { + while pos < input.len() { + if cfg.matches_prefix(&input[pos..]) { + emit = pos; + break; + } + pos += 1; + } + } + if !matches.is_empty() { + let translated = self + .vault + .translate(&self.service, matches, self.encode) + .await + .map_err(|e| io::Error::other(e.to_string()))?; + for (position, replacement) in positions.into_iter().zip(translated) { + input[position..position + width].copy_from_slice(&replacement); + } + } + self.pending = input.split_off(emit); + if self.pending.is_empty() { + self.deadline = None; + } else if emit > 0 || self.deadline.is_none() { + self.deadline = Some(Instant::now() + Duration::from_millis(cfg.partial_timeout_ms)); + } + Ok(input) + } +} diff --git a/src/flags/vault.rs b/src/flags/vault.rs new file mode 100644 index 0000000..15b6f4d --- /dev/null +++ b/src/flags/vault.rs @@ -0,0 +1,489 @@ +use super::FlagConfig; +use anyhow::{Context, Result, anyhow, bail, ensure}; +use chacha20poly1305::{ + XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, Payload}, +}; +use fs2::FileExt; +use rand::Rng; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeSet, HashMap, HashSet}, + fs::{File, OpenOptions}, + path::Path, + sync::{ + Arc, + atomic::{AtomicU64, Ordering::Relaxed}, + }, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::{mpsc, oneshot}; + +const AAD: &[u8] = b"mirror-tool/flag-vault/v1"; +type Key = (String, Vec); +#[derive(Default)] +struct Counters { + entries: AtomicU64, + hits: AtomicU64, + encoded: AtomicU64, + decoded: AtomicU64, + errors: AtomicU64, +} +#[derive(Clone, Debug, Serialize)] +pub struct FlagStatus { + pub ready: bool, + pub entries: u64, + pub cache_hits: u64, + pub encoded: u64, + pub decoded: u64, + pub errors: u64, + pub capacity: usize, + pub ttl_seconds: u64, +} +struct Request { + service: String, + values: Vec>, + encode: bool, + reply: oneshot::Sender>>>, +} +pub struct Vault { + pub config: FlagConfig, + pub(crate) matcher: regex::bytes::Regex, + sender: mpsc::Sender, + counters: Arc, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Entry { + service: String, + original: Vec, + token: Vec, + expires: u64, +} +struct Worker { + connection: Connection, + _lock: File, + cipher: XChaCha20Poly1305, + config: FlagConfig, + entries: HashMap, + reverse: HashMap, + expiry: BTreeSet<(u64, Key)>, + counters: Arc, +} +fn now() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("flag vault clock")? + .as_secs()) +} +fn private_file(path: &Path) -> Result { + ensure!( + !path + .symlink_metadata() + .is_ok_and(|m| m.file_type().is_symlink()), + "flag vault paths cannot be symlinks" + ); + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let file = options.open(path).context("open private flag vault file")?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Ok(file) +} +fn seal(cipher: &XChaCha20Poly1305, value: &[u8]) -> Result> { + let nonce = rand::random::<[u8; 24]>(); + let encrypted = cipher + .encrypt( + XNonce::from_slice(&nonce), + Payload { + msg: value, + aad: AAD, + }, + ) + .map_err(|_| anyhow!("flag vault encryption failed"))?; + Ok([nonce.as_slice(), &encrypted].concat()) +} +fn open(cipher: &XChaCha20Poly1305, value: &[u8]) -> Result> { + ensure!( + value.len() >= 40 && value.len() <= 4096, + "invalid encrypted flag record size" + ); + cipher + .decrypt( + XNonce::from_slice(&value[..24]), + Payload { + msg: &value[24..], + aad: AAD, + }, + ) + .map_err(|_| anyhow!("flag vault authentication failed: wrong key or damaged database")) +} +impl Vault { + /// Initialize on a blocking worker; environment key is exactly 64 hex digits. + pub fn from_env(config: FlagConfig) -> Result> { + let value = std::env::var(&config.key_env) + .context("set the configured flag key environment variable to 64 hex digits")?; + ensure!( + value.len() == 64 && value.bytes().all(|c| c.is_ascii_hexdigit()), + "flag key must contain exactly 64 hex digits" + ); + let mut key = [0u8; 32]; + for (i, byte) in key.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[i * 2..i * 2 + 2], 16) + .map_err(|_| anyhow!("invalid flag key"))?; + } + Self::with_key(config, key) + } + /// Explicit key injection is also used by synthetic isolated tests. + pub fn with_key(config: FlagConfig, key: [u8; 32]) -> Result> { + config.validate()?; + let pattern = format!( + "(?-u:{}[{}]{{{}}}{})", + regex::escape(&config.prefix), + regex::escape(&config.alphabet), + config.payload_length, + regex::escape(&config.suffix) + ); + let matcher = regex::bytes::Regex::new(&pattern).context("compile flag format")?; + let counters = Arc::new(Counters::default()); + let worker = Worker::open(config.clone(), key, counters.clone())?; + let (sender, mut receiver) = mpsc::channel::(32); + std::thread::Builder::new() + .name("mirror-flag-vault".into()) + .spawn(move || { + let mut worker = worker; + while let Some(request) = receiver.blocking_recv() { + // Once admitted, commit/publication finishes even if the TCP caller disappears. + let result = worker.translate(&request.service, request.values, request.encode); + if result.is_err() { + worker.counters.errors.fetch_add(1, Relaxed); + } + let _ = request.reply.send(result); + } + }) + .context("start flag vault worker")?; + Ok(Arc::new(Self { + config, + matcher, + sender, + counters, + })) + } + pub fn status(&self) -> FlagStatus { + FlagStatus { + ready: !self.sender.is_closed(), + entries: self.counters.entries.load(Relaxed), + cache_hits: self.counters.hits.load(Relaxed), + encoded: self.counters.encoded.load(Relaxed), + decoded: self.counters.decoded.load(Relaxed), + errors: self.counters.errors.load(Relaxed), + capacity: self.config.max_entries, + ttl_seconds: self.config.ttl_seconds, + } + } + pub fn record_error(&self) { + self.counters.errors.fetch_add(1, Relaxed); + } + pub async fn translate( + &self, + service: &str, + values: Vec>, + encode: bool, + ) -> Result>> { + ensure!( + crate::config::valid_id(service), + "invalid flag service namespace" + ); + ensure!( + values.len() <= 65536 + && values + .iter() + .all(|v| v.len() == self.config.width() && self.config.matches_prefix(v)), + "invalid flag translation batch" + ); + let (reply, receive) = oneshot::channel(); + self.sender + .send(Request { + service: service.into(), + values, + encode, + reply, + }) + .await + .map_err(|_| anyhow!("flag vault worker unavailable"))?; + receive.await.context("flag vault worker stopped")? + } +} +impl Worker { + fn open(config: FlagConfig, key: [u8; 32], counters: Arc) -> Result { + let parent = config + .database + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + std::fs::create_dir_all(parent).context("create flag vault directory")?; + let mut lock_path = config.database.as_os_str().to_os_string(); + lock_path.push(".lock"); + let lock = private_file(Path::new(&lock_path))?; + lock.try_lock_exclusive() + .context("flag vault is already in use")?; + drop(private_file(&config.database)?); + let connection = Connection::open(&config.database).context("open flag database")?; + connection.busy_timeout(std::time::Duration::from_secs(2))?; + connection.execute_batch("PRAGMA journal_mode=DELETE; PRAGMA synchronous=EXTRA; PRAGMA secure_delete=ON; PRAGMA auto_vacuum=FULL; CREATE TABLE IF NOT EXISTS flag_meta (id INTEGER PRIMARY KEY CHECK(id=1), sealed BLOB NOT NULL); CREATE TABLE IF NOT EXISTS flag_entries (id INTEGER PRIMARY KEY, expires INTEGER NOT NULL, sealed BLOB NOT NULL);")?; + let cipher = XChaCha20Poly1305::new((&key).into()); + let expected = [AAD, &config.format_id()].concat(); + let metadata: Option> = connection + .query_row("SELECT sealed FROM flag_meta WHERE id=1", [], |r| r.get(0)) + .optional()?; + if let Some(metadata) = metadata { + ensure!( + open(&cipher, &metadata)? == expected, + "flag format differs from existing vault; keep the old configuration or use a separate vault" + ); + } else { + let count: u64 = + connection.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r.get(0))?; + ensure!(count == 0, "flag vault metadata missing"); + connection.execute( + "INSERT INTO flag_meta(id,sealed) VALUES(1,?1)", + [seal(&cipher, &expected)?], + )?; + } + let count: u64 = + connection.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r.get(0))?; + ensure!(count <= 100000, "flag vault exceeds entry limit"); + let largest: u64 = connection.query_row( + "SELECT COALESCE(MAX(length(sealed)),0) FROM flag_entries", + [], + |r| r.get(0), + )?; + ensure!(largest <= 4096, "encrypted flag record exceeds size limit"); + let mut worker = Self { + connection, + _lock: lock, + cipher, + config, + entries: HashMap::new(), + reverse: HashMap::new(), + expiry: BTreeSet::new(), + counters, + }; + let rows = { + let mut statement = worker + .connection + .prepare("SELECT expires, sealed FROM flag_entries")?; + let rows = + statement.query_map([], |r| Ok((r.get::<_, u64>(0)?, r.get::<_, Vec>(1)?)))?; + rows.collect::, _>>()? + }; + for (expires, sealed) in rows { + let entry: Entry = serde_json::from_slice(&open(&worker.cipher, &sealed)?) + .context("invalid flag vault record")?; + ensure!( + entry.expires == expires + && crate::config::valid_id(&entry.service) + && entry.original.len() == worker.config.width() + && entry.token.len() == worker.config.width() + && worker.config.matches_prefix(&entry.original) + && worker.config.matches_prefix(&entry.token) + && entry.original != entry.token, + "invalid flag mapping" + ); + let original = (entry.service.clone(), entry.original.clone()); + let token = (entry.service.clone(), entry.token.clone()); + ensure!( + !worker.entries.contains_key(&original) + && !worker.reverse.contains_key(&token) + && !worker.entries.contains_key(&token) + && !worker.reverse.contains_key(&original), + "ambiguous flag vault records" + ); + worker.insert(entry); + } + worker.expire(now()?)?; + ensure!( + worker.entries.len() <= worker.config.max_entries, + "existing flag vault exceeds configured capacity" + ); + worker + .counters + .entries + .store(worker.entries.len() as u64, Relaxed); + Ok(worker) + } + fn insert(&mut self, entry: Entry) { + let key = (entry.service.clone(), entry.original.clone()); + self.reverse + .insert((entry.service.clone(), entry.token.clone()), key.clone()); + self.expiry.insert((entry.expires, key.clone())); + self.entries.insert(key, entry); + } + fn expire(&mut self, time: u64) -> Result<()> { + if self + .expiry + .first() + .is_none_or(|(expires, _)| *expires > time) + { + return Ok(()); + } + self.connection + .execute("DELETE FROM flag_entries WHERE expires<=?1", [time])?; + while self + .expiry + .first() + .is_some_and(|(expires, _)| *expires <= time) + { + let (_, key) = self.expiry.pop_first().expect("expiry entry"); + if let Some(entry) = self.entries.remove(&key) { + self.reverse.remove(&(entry.service, entry.token)); + } + } + self.counters + .entries + .store(self.entries.len() as u64, Relaxed); + Ok(()) + } + fn translate( + &mut self, + service: &str, + values: Vec>, + encode: bool, + ) -> Result>> { + let time = now()?; + self.expire(time)?; + if !encode { + let mut count = 0; + let result = values + .into_iter() + .map(|value| { + if let Some(key) = self.reverse.get(&(service.into(), value.clone())) { + count += 1; + self.entries[key].original.clone() + } else { + value + } + }) + .collect(); + self.counters.decoded.fetch_add(count, Relaxed); + return Ok(result); + } + let originals: HashSet<_> = values.iter().cloned().collect(); + let mut staged: HashMap, Entry> = HashMap::new(); + let mut tokens = HashSet::new(); + let mut result = Vec::with_capacity(values.len()); + let mut hits = 0; + for value in values { + let key = (service.into(), value.clone()); + ensure!( + !self.reverse.contains_key(&key), + "known token received as an input flag; refusing ambiguous double translation" + ); + if let Some(entry) = self.entries.get(&key).or_else(|| staged.get(&value)) { + result.push(entry.token.clone()); + hits += 1; + continue; + } + ensure!( + self.entries.len() + staged.len() < self.config.max_entries, + "flag cache full; protected connection refused" + ); + let mut token = None; + for _ in 0..128 { + let mut candidate = self.config.prefix.as_bytes().to_vec(); + let mut rng = rand::rng(); + candidate.extend((0..self.config.payload_length).map(|_| { + self.config.alphabet.as_bytes()[rng.random_range(0..self.config.alphabet.len())] + })); + candidate.extend_from_slice(self.config.suffix.as_bytes()); + let k = (service.into(), candidate.clone()); + if !originals.contains(&candidate) + && !tokens.contains(&candidate) + && !self.entries.contains_key(&k) + && !self.reverse.contains_key(&k) + { + token = Some(candidate); + break; + } + } + let Some(token) = token else { + bail!("unable to allocate unique flag token"); + }; + tokens.insert(token.clone()); + result.push(token.clone()); + staged.insert( + value.clone(), + Entry { + service: service.into(), + original: value, + token, + expires: time + self.config.ttl_seconds, + }, + ); + } + if !staged.is_empty() { + let transaction = self.connection.transaction()?; + for entry in staged.values() { + let bytes = serde_json::to_vec(entry)?; + transaction.execute( + "INSERT INTO flag_entries(expires,sealed) VALUES(?1,?2)", + params![entry.expires, seal(&self.cipher, &bytes)?], + )?; + } + transaction + .commit() + .context("persist flag mappings before forwarding")?; + for entry in staged.into_values() { + self.insert(entry); + } + self.counters + .entries + .store(self.entries.len() as u64, Relaxed); + } + self.counters.hits.fetch_add(hits, Relaxed); + self.counters + .encoded + .fetch_add(result.len() as u64, Relaxed); + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn expiration_removes_disk_and_memory_without_sliding_on_hits() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = FlagConfig { + database: tmp.path().join("vault.db"), + ..FlagConfig::default() + }; + let mut worker = Worker::open(cfg, [7; 32], Arc::new(Counters::default())).unwrap(); + let original = [vec![b'A'; 31], vec![b'=']].concat(); + let token = worker.translate("a", vec![original.clone()], true).unwrap(); + let expiry = worker.entries.values().next().unwrap().expires; + assert_eq!(worker.translate("a", vec![original], true).unwrap(), token); + assert_eq!(worker.entries.values().next().unwrap().expires, expiry); + worker.expire(expiry).unwrap(); + assert!(worker.entries.is_empty()); + assert!(worker.reverse.is_empty()); + assert_eq!( + worker + .connection + .query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r + .get::<_, u64>(0)) + .unwrap(), + 0 + ); + assert_eq!(worker.translate("a", token.clone(), false).unwrap(), token); + } +} diff --git a/src/lib.rs b/src/lib.rs index dd2d20b..e4a2449 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ pub mod auth; pub mod config; pub mod control; pub mod engine; +pub mod flags; pub mod scoreboard; pub mod store; pub mod web; diff --git a/task_plan.md b/task_plan.md index cc291b7..618f22e 100644 --- a/task_plan.md +++ b/task_plan.md @@ -34,3 +34,13 @@ One native executable with an async TCP engine and embedded web UI. No Python/ru - [complete] Fix dialog centering/overflow, selected-team wrapping and dark numeric inputs. - [complete] Preserve invalid IP/add-form drafts; expose team add/rename and compact configuration/scoreboard help through existing API. - [complete] Lightweight browser/functional checks and required Rust gates; preserve live demo state and migrate to data/demo before restart. + +## Flag protection — 2026-10-23 +- [complete] Optional per-service length-preserving tokenization; encrypted durable SQLite mapping cache; global fixed-shape format, environment key, TTL and capacity. Explain design before implementation. +- [complete] Streaming scanner handles all split positions, bounded candidate tail and timeout, EOF, unknown outgoing matches; encode once before replica fanout, decode primary replies only. +- [complete] UI opt-in/status, API/config/docs and tests for persistence, collision/capacity, wrong key, cache failure, isolation, TCP integrity/half-close. +- [complete] Fresh native/Orb gates and isolated smoke; preserve legacy UI and live demo state; journal/commit/checkpoint. + +Design limits: stateful tokenization, not stateless same-length AEAD; XChaCha20-Poly1305 protects mappings on disk. Raw visible fixed-shape ASCII only; no TLS/compression/framing adapter. Incomplete possible flags may hold a protocol tail; bounded timeout closes rather than leaking. All clients get reverse translation, so not an authorization boundary. Disabled by default; no real game hosts/flags used. + +Flag implementation issues recorded: cargo-add combined feature syntax rejected and corrected with separate calls; first module write preceded async mkdir and was retried after explicit directory creation; first test compile warned about unused Arc import, removed before Clippy gate. No failing runtime regressions so far. Final durability review upgraded SQLite synchronous from FULL to EXTRA; final native/Orb gates rerun on that source. diff --git a/tests/control_plane.rs b/tests/control_plane.rs index 216bc59..9bf55cf 100644 --- a/tests/control_plane.rs +++ b/tests/control_plane.rs @@ -247,6 +247,7 @@ async fn automatic_failover_is_durable_and_primary_requests_keep_working() { replicas: vec!["b".into()], enabled: true, auto_switch: true, + flag_protection: false, replica_policy: ReplicaPolicy::Isolate, }]; let controller = Controller::start(config.clone()).await.unwrap(); @@ -287,6 +288,7 @@ fn direct_service(listen: SocketAddr, port: u16) -> ServiceConfig { replicas: vec![], enabled: true, auto_switch: false, + flag_protection: false, replica_policy: ReplicaPolicy::Isolate, custom: true, custom_ip: Some("127.0.0.1".parse().unwrap()), diff --git a/tests/flag_protection.rs b/tests/flag_protection.rs new file mode 100644 index 0000000..ab8b526 --- /dev/null +++ b/tests/flag_protection.rs @@ -0,0 +1,408 @@ +use mirror_tool::{ + config::{Config, NetworkConfig, ReplicaPolicy, ServiceConfig, Team}, + engine::Engine, + flags::{FlagConfig, Transformer, Vault}, +}; +use std::{path::Path, time::Duration}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + time::timeout, +}; + +fn flag(byte: u8) -> Vec { + [vec![byte; 31], vec![b'=']].concat() +} +fn settings(root: &Path) -> FlagConfig { + FlagConfig { + database: root.join("vault.sqlite3"), + partial_timeout_ms: 100, + ..FlagConfig::default() + } +} +async fn released(config: &FlagConfig) { + let mut name = config.database.as_os_str().to_os_string(); + name.push(".lock"); + timeout(Duration::from_secs(2), async { + loop { + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&name) + .unwrap(); + if fs2::FileExt::try_lock_exclusive(&file).is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); +} +#[test] +fn opt_in_is_false_and_missing_vault_is_rejected() { + let base = r#"[[services]] +id="sample" +name="Sample" +listen="127.0.0.1:28181" +upstream_port=28182 +"#; + let config: Config = toml::from_str(base).unwrap(); + assert!(!config.services[0].flag_protection); + assert!(config.flags.is_none()); + config.validate().unwrap(); + let mut bad = config; + bad.services[0].flag_protection = true; + assert!(bad.validate().is_err()); + for length in [0, 15, 129] { + let cfg = FlagConfig { + payload_length: length, + ..FlagConfig::default() + }; + assert!(cfg.validate().is_err()); + } +} +#[tokio::test] +async fn every_split_binary_integrity_unknown_output_and_eof() { + let tmp = tempfile::tempdir().unwrap(); + let vault = Vault::with_key(settings(tmp.path()), [7; 32]).unwrap(); + let original = flag(b'A'); + let token = vault + .translate("a", vec![original.clone()], true) + .await + .unwrap() + .remove(0); + assert_ne!(token, original); + assert_eq!(token.len(), original.len()); + for split in 0..=original.len() { + let mut up = Transformer::new(vault.clone(), "a".into(), true); + let mut result = up.feed(&original[..split], false).await.unwrap(); + result.extend(up.feed(&original[split..], false).await.unwrap()); + result.extend(up.feed(&[], true).await.unwrap()); + assert_eq!(result, token); + let mut down = Transformer::new(vault.clone(), "a".into(), false); + let mut result = down.feed(&token[..split], false).await.unwrap(); + result.extend(down.feed(&token[split..], true).await.unwrap()); + assert_eq!(result, original); + } + let input = [ + b"\x00\xffPOST ".as_slice(), + &original, + b"\r\nbody=lowercase\x00", + &original, + b"!ABC", + ] + .concat(); + let mut up = Transformer::new(vault.clone(), "a".into(), true); + let mut output = Vec::new(); + for chunk in input.chunks(3) { + output.extend(up.feed(chunk, false).await.unwrap()); + } + output.extend(up.feed(&[], true).await.unwrap()); + let expected = [ + b"\x00\xffPOST ".as_slice(), + &token, + b"\r\nbody=lowercase\x00", + &token, + b"!ABC", + ] + .concat(); + assert_eq!(output, expected); + let unknown = flag(b'Z'); + assert_eq!( + vault + .translate("a", vec![unknown.clone()], false) + .await + .unwrap(), + vec![unknown] + ); + assert_eq!( + vault + .translate("another", vec![token.clone()], false) + .await + .unwrap(), + vec![token.clone()] + ); + assert!(vault.translate("a", vec![token], true).await.is_err()); +} +#[tokio::test] +async fn partial_candidate_times_out_without_plaintext_fallback() { + let tmp = tempfile::tempdir().unwrap(); + let vault = Vault::with_key(settings(tmp.path()), [7; 32]).unwrap(); + let mut codec = Transformer::new(vault.clone(), "a".into(), true); + assert!(codec.feed(b"AAAA", false).await.unwrap().is_empty()); + let (_writer, mut reader) = tokio::io::duplex(64); + let mut buffer = [0; 32]; + let error = timeout(Duration::from_secs(1), codec.read(&mut reader, &mut buffer)) + .await + .unwrap() + .unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::TimedOut); + assert_eq!(vault.status().errors, 1); +} +#[tokio::test] +async fn concurrent_repeats_share_one_durable_mapping_and_restart_restores() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = settings(tmp.path()); + let vault = Vault::with_key(cfg.clone(), [7; 32]).unwrap(); + assert!(Vault::with_key(cfg.clone(), [7; 32]).is_err()); // Single-owner lock. + let mut jobs = tokio::task::JoinSet::new(); + for _ in 0..24 { + let v = vault.clone(); + jobs.spawn(async move { + v.translate("a", vec![flag(b'A')], true) + .await + .unwrap() + .remove(0) + }); + } + let first = jobs.join_next().await.unwrap().unwrap(); + while let Some(result) = jobs.join_next().await { + assert_eq!(result.unwrap(), first); + } + assert_eq!(vault.status().entries, 1); + let other = vault + .translate("b", vec![flag(b'A')], true) + .await + .unwrap() + .remove(0); + assert_ne!(other, first); + let bytes = std::fs::read(&cfg.database).unwrap(); + assert!( + !bytes + .windows(32) + .any(|s| s == flag(b'A') || s == first.as_slice()) + ); + drop(vault); + released(&cfg).await; + assert!(Vault::with_key(cfg.clone(), [8; 32]).is_err()); + let mut changed = cfg.clone(); + changed.suffix = "!".into(); + assert!(Vault::with_key(changed, [7; 32]).is_err()); + let vault = Vault::with_key(cfg.clone(), [7; 32]).unwrap(); + assert_eq!( + vault + .translate("a", vec![first.clone()], false) + .await + .unwrap(), + vec![flag(b'A')] + ); + assert_eq!( + vault.translate("a", vec![flag(b'A')], true).await.unwrap(), + vec![first] + ); + drop(vault); + released(&cfg).await; + let db = rusqlite::Connection::open(&cfg.database).unwrap(); + db.execute( + "UPDATE flag_entries SET sealed=zeroblob(length(sealed))", + [], + ) + .unwrap(); + drop(db); + assert!(Vault::with_key(cfg, [7; 32]).is_err()); +} +#[tokio::test] +async fn capacity_and_disk_failure_do_not_publish_partial_mappings() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = settings(tmp.path()); + cfg.max_entries = 1; + let vault = Vault::with_key(cfg.clone(), [7; 32]).unwrap(); + assert!( + vault + .translate("a", vec![flag(b'A'), flag(b'B')], true) + .await + .is_err() + ); + assert_eq!(vault.status().entries, 0); + let db = rusqlite::Connection::open(&cfg.database).unwrap(); + db.execute_batch("CREATE TRIGGER fail_insert BEFORE INSERT ON flag_entries BEGIN SELECT RAISE(ABORT,'synthetic write failure'); END;").unwrap(); + assert!(vault.translate("a", vec![flag(b'A')], true).await.is_err()); + assert_eq!(vault.status().entries, 0); + assert_eq!( + db.query_row("SELECT COUNT(*) FROM flag_entries", [], |r| r + .get::<_, u64>(0)) + .unwrap(), + 0 + ); + db.execute_batch("DROP TRIGGER fail_insert;").unwrap(); + let token = vault.translate("a", vec![flag(b'A')], true).await.unwrap(); + assert_eq!(vault.status().entries, 1); + assert!(vault.translate("a", vec![flag(b'B')], true).await.is_err()); + assert_eq!( + vault.translate("a", token, false).await.unwrap(), + vec![flag(b'A')] + ); +} +async fn capture_echo(listener: TcpListener) -> Vec { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).await.unwrap(); + stream.write_all(&bytes).await.unwrap(); + stream.shutdown().await.unwrap(); + bytes +} +fn service(listen: std::net::SocketAddr, port: u16) -> ServiceConfig { + ServiceConfig { + id: "echo".into(), + name: "Echo".into(), + listen, + upstream_port: port, + primary: Some("a".into()), + replicas: vec!["b".into()], + enabled: true, + auto_switch: false, + replica_policy: ReplicaPolicy::Strict, + custom: false, + custom_ip: None, + flag_protection: true, + } +} +#[tokio::test] +async fn real_tcp_encodes_once_for_replicas_decodes_reply_and_toggle_is_per_connection() { + let tmp = tempfile::tempdir().unwrap(); + let vault = Vault::with_key(settings(tmp.path()), [7; 32]).unwrap(); + let primary = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = primary.local_addr().unwrap().port(); + let replica = TcpListener::bind((std::net::Ipv6Addr::LOCALHOST, port)) + .await + .unwrap(); + let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listen = reservation.local_addr().unwrap(); + drop(reservation); + let engine = Engine::with_flag_vault(NetworkConfig::default(), Some(vault.clone())); + let teams = vec![ + Team { + id: "a".into(), + name: "A".into(), + address: "127.0.0.1".parse().unwrap(), + hidden: false, + }, + Team { + id: "b".into(), + name: "B".into(), + address: "::1".parse().unwrap(), + hidden: false, + }, + ]; + let mut cfg = service(listen, port); + engine.upsert(&cfg, &teams).await.unwrap(); + let p = tokio::spawn(capture_echo(primary)); + let r = tokio::spawn(capture_echo(replica)); + let mut client = TcpStream::connect(listen).await.unwrap(); + // Capture the route by sending a complete non-candidate prefix first. + client.write_all(b"hello\n").await.unwrap(); + timeout(Duration::from_secs(2), async { + while engine.snapshots()["echo"].stats.bytes_up < 6 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + cfg.flag_protection = false; + engine.upsert(&cfg, &teams).await.unwrap(); + let original = flag(b'A'); + for chunk in original.chunks(3) { + client.write_all(chunk).await.unwrap(); + } + client.shutdown().await.unwrap(); + let mut reply = Vec::new(); + timeout(Duration::from_secs(3), client.read_to_end(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(reply, [b"hello\n".as_slice(), &original].concat()); + let a = p.await.unwrap(); + let b = timeout(Duration::from_secs(3), r).await.unwrap().unwrap(); + assert_eq!(a, b); + assert_ne!(&a[6..], &original); + assert_eq!(a.len(), reply.len()); + let primary = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) + .await + .unwrap(); + cfg.replicas.clear(); + engine.upsert(&cfg, &teams).await.unwrap(); + let p = tokio::spawn(capture_echo(primary)); + let mut client = TcpStream::connect(listen).await.unwrap(); + client.write_all(&original).await.unwrap(); + client.shutdown().await.unwrap(); + let mut reply = Vec::new(); + client.read_to_end(&mut reply).await.unwrap(); + assert_eq!(reply, original); + assert_eq!(p.await.unwrap(), original); + engine.shutdown().await; +} + +#[tokio::test] +async fn custom_literal_format_preserves_http_lengths_and_repeated_matches() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = FlagConfig { + prefix: "FLAG{".into(), + suffix: "}".into(), + alphabet: "0123456789ABCDEF".into(), + payload_length: 32, + ..settings(tmp.path()) + }; + let vault = Vault::with_key(cfg, [7; 32]).unwrap(); + let flag = b"FLAG{0123456789ABCDEF0123456789ABCDEF}"; + let body = [flag.as_slice(), b" ", flag].concat(); + let header = format!("POST / HTTP/1.1\r\nContent-Length: {}\r\n\r\n", body.len()); + let input = [header.as_bytes(), &body].concat(); + let mut up = Transformer::new(vault.clone(), "a".into(), true); + let mut encrypted = Vec::new(); + for c in input.chunks(1) { + encrypted.extend(up.feed(c, false).await.unwrap()); + } + encrypted.extend(up.feed(&[], true).await.unwrap()); + assert_eq!(encrypted.len(), input.len()); + assert!(encrypted.starts_with(header.as_bytes())); + assert_ne!(encrypted, input); + assert_eq!( + &encrypted[header.len()..header.len() + flag.len()], + &encrypted[header.len() + flag.len() + 1..] + ); + let mut down = Transformer::new(vault, "a".into(), false); + let output = down.feed(&encrypted, true).await.unwrap(); + assert_eq!(output, input); +} + +#[tokio::test] +async fn full_vault_fails_closed_before_primary_receives_flag() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = FlagConfig { + max_entries: 1, + ..settings(tmp.path()) + }; + let vault = Vault::with_key(cfg, [7; 32]).unwrap(); + vault + .translate("echo", vec![flag(b'A')], true) + .await + .unwrap(); + let primary = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = primary.local_addr().unwrap().port(); + let reservation = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listen = reservation.local_addr().unwrap(); + drop(reservation); + let engine = Engine::with_flag_vault(NetworkConfig::default(), Some(vault)); + let mut cfg = service(listen, port); + cfg.replicas.clear(); + let teams = vec![Team { + id: "a".into(), + name: "A".into(), + address: "127.0.0.1".parse().unwrap(), + hidden: false, + }]; + engine.upsert(&cfg, &teams).await.unwrap(); + let p = tokio::spawn(capture_echo(primary)); + let mut client = TcpStream::connect(listen).await.unwrap(); + client.write_all(&flag(b'B')).await.unwrap(); + client.shutdown().await.unwrap(); + let mut reply = Vec::new(); + let _ = timeout(Duration::from_secs(2), client.read_to_end(&mut reply)) + .await + .unwrap(); + assert!(p.await.unwrap().is_empty()); + assert!(reply.is_empty()); + assert_eq!(engine.snapshots()["echo"].stats.io_errors, 1); + engine.shutdown().await; +} diff --git a/tests/forwarding.rs b/tests/forwarding.rs index e3f7f4f..3a4fed3 100644 --- a/tests/forwarding.rs +++ b/tests/forwarding.rs @@ -42,6 +42,7 @@ fn service(listen: SocketAddr, upstream: SocketAddr) -> ServiceConfig { replicas: vec![], enabled: true, auto_switch: false, + flag_protection: false, replica_policy: ReplicaPolicy::Isolate, } } diff --git a/tests/shutdown.rs b/tests/shutdown.rs index afd6db0..9088250 100644 --- a/tests/shutdown.rs +++ b/tests/shutdown.rs @@ -39,6 +39,7 @@ async fn partial_http_body_cannot_keep_process_or_proxy_listener_alive() { replicas: vec![], enabled: true, auto_switch: false, + flag_protection: false, replica_policy: ReplicaPolicy::Isolate, custom: false, custom_ip: None, diff --git a/web/app.css b/web/app.css index f115e72..dfaa56e 100644 --- a/web/app.css +++ b/web/app.css @@ -938,3 +938,7 @@ button:focus-visible, [role=button]:focus-visible { outline: 2px solid #0a84ff; .help-body pre { padding: 12px; border-radius: 8px; background: rgba(0,0,0,.25); color: #ddd; font-size: 12px; } .header-actions { gap: 8px; flex-shrink: 0; } @media (max-width: 420px) { .section-title { font-size: 19px; } .header-actions { gap: 5px; } } + +.flag-option { display: flex; align-items: center; gap: 8px; } +.flag-option input { accent-color: #0a84ff; width: 16px; height: 16px; } +.flags-enabled .task-title-button::after { content: " 🔒"; font-size: 12px; } diff --git a/web/app.js b/web/app.js index a6a8cce..b88c3aa 100644 --- a/web/app.js +++ b/web/app.js @@ -48,7 +48,7 @@ return data; } function config(service) { - return Object.fromEntries(["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null }[key])])); + return Object.fromEntries(["id", "name", "listen", "upstream_port", "primary", "replicas", "enabled", "auto_switch", "replica_policy", "flag_protection", "custom", "custom_ip"].map((key) => [key, service[key] ?? ({ custom: false, custom_ip: null, flag_protection: false }[key])])); } async function refresh() { render(await api("/api/snapshot")); watchUpdates(); } async function mutate(path, method, body, errorId) { @@ -107,6 +107,7 @@ const service = serviceById(id); if (!service || state.busy || state.stale) return; state.editId = id; $("settings-error").textContent = ""; $("service-name").value = service.name; $("service-name").setCustomValidity(""); $("service-listen").value = service.listen; + $("service-flags").checked = Boolean(service.flag_protection); $("service-port").value = service.upstream_port; $("service-policy").value = service.replica_policy; $("settingsModal").showModal(); } @@ -194,13 +195,18 @@ $("board-error").textContent = board.last_error || ""; $("add-service").disabled = locked; $("manage-teams").disabled = locked; $("logout").disabled = state.busy; for (const form of [$("customProxyForm"), $("settings-form"), $("team-form")]) for (const node of form.querySelectorAll("input, select, button")) node.disabled = locked; + $("service-flags").disabled = locked || !current.flag_protection?.ready; + const vault = current.flag_protection; + $("flags-status").textContent = vault ? `Кеш подмены флагов: ${vault.ready ? "готов" : "недоступен"}; записей ${vault.entries}/${vault.capacity}; подменено ${vault.encoded}, восстановлено ${vault.decoded}, попаданий ${vault.cache_hits}, ошибок ${vault.errors}.` : "Подмена флагов не настроена. Для включения задайте [flags] в TOML и ключ в окружении, затем перезапустите процесс."; + $("flags-hint").textContent = vault?.ready ? "По умолчанию выключено. Применяется к новым соединениям. Формат и срок кеша задаются в TOML. Только открытый несжатый трафик; это не проверка прав клиента." : "Сначала настройте [flags] и ключ в окружении (см. справку ⓘ)."; for (const node of document.querySelectorAll("[data-close]")) node.disabled = state.busy; const ids = new Set(current.services.map((service) => service.id)); for (const [id, card] of state.cards) if (!ids.has(id)) { card.root.remove(); state.cards.delete(id); } for (const service of current.services) { let card = state.cards.get(service.id); if (!card) { card = createCard(service); state.cards.set(service.id, card); } - card.title.textContent = service.name; card.title.title = `Настройки: ${service.name}`; card.port.textContent = `:${portOf(service)}`; + card.root.classList.toggle("flags-enabled", Boolean(service.flag_protection)); + card.title.textContent = service.name; card.title.title = `Настройки: ${service.name}${service.flag_protection ? " · подмена флагов включена" : ""}`; card.port.textContent = `:${portOf(service)}`; card.title.setAttribute("aria-label", `Настройки сервиса ${service.name}`); card.title.disabled = locked; card.auto.classList.toggle("active", service.auto_switch); card.auto.setAttribute("aria-pressed", String(service.auto_switch)); card.auto.title = service.auto_switch ? "Авто-переключение включено" : "Авто-переключение"; card.auto.disabled = locked; @@ -280,7 +286,7 @@ $("settings-form").addEventListener("submit", async (event) => { event.preventDefault(); if (!$("service-name").value.trim()) { $("settings-error").textContent = "Введите название"; return; } - if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value }, "settings-error")) $("settingsModal").close(); + if (await updateService(state.editId, { name: $("service-name").value.trim(), listen: $("service-listen").value.trim(), upstream_port: Number($("service-port").value), replica_policy: $("service-policy").value, flag_protection: $("service-flags").checked }, "settings-error")) $("settingsModal").close(); }); // Whitespace-only names pass HTML required validation; reject before submission. for (const id of ["customName", "service-name", "team-name"]) { diff --git a/web/index.html b/web/index.html index 762dcfb..feb6904 100644 --- a/web/index.html +++ b/web/index.html @@ -56,6 +56,7 @@
+

@@ -95,6 +96,21 @@ url = "http://scoreboard.example:8080" poll_interval_ms = 3000 stale_after_ms = 15000

Адрес замените на URL вашей игры. Имена команд и сервисов должны совпадать с scoreboard. После правки TOML перезапустите процесс. Настройки маршрутов из панели применяются сразу.

+
Подмена флагов — опционально
+

+

В настройках сервиса включается отдельно, по умолчанию выключена. Входящий флаг заменяется токеном той же длины; известный токен в ответе восстанавливается. Кеш хранится зашифрованным, ключ — только в окружении процесса.

+
[flags]
+database = "data/flags.sqlite3"
+key_env = "MIRROR_FLAG_KEY"
+prefix = ""
+alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+payload_length = 31
+suffix = "="
+ttl_seconds = 86400
+max_entries = 10000
+partial_timeout_ms = 5000
+

Ключ: 32 случайных байта в виде 64 hex-символов. Храните его отдельно от базы и используйте после рестарта. Без прежнего ключа кеш не откроется. TTL должен превышать срок жизни флагов и максимальную задержку ответов. Формат — буквальный prefix + payload из alphabet + буквальный suffix, не произвольный regex.

+

Подходит для открытого несжатого TCP с совместимым протоколом. Незаконченный возможный флаг удерживается до продолжения или EOF; по тайм-ауту соединение закрывается с ошибкой. Изменение длины, подписанные сообщения, TLS и сжатие не поддерживаются. Ответы восстанавливаются всем клиентам, поэтому это не защита от эксплуатации сервиса через прокси.

Управление маршрутами

Кружок выбирает primary, строка — реплику. Робот включает автоматический выбор из назначенных реплик. Новые соединения используют новый маршрут; открытые остаются на прежнем.

Название сервиса открывает настройки портов и режима реплик. При isolate медленная реплика отключается; при strict общий поток ждёт её.