Reflect the crypto hardening in the EN/RU specification:
- Section 6: transport keys now come from Noise Split() over the chaining
key ck (includes the ee DH secret), giving forward secrecy; added the
rationale for why keys must NOT come from the handshake hash h, and the
wire-version-5 gate.
- Section 8: documented the handshake-trial CPU-DoS defense (per-key
secret/marker caching + trial-path token bucket).
- Corrected the handshake replay window (±300s / 5min, was mis-stated as
±30s) and PSK derivation (HKDF-SHA256).
- Karn's algorithm: drop_acked_frames no longer samples RTT from frames
that were retransmitted (last_sent is bumped on each retransmit, so an
ACK for the original transmission would measure a spuriously small RTT
and drag SRTT/RTO down). Added CongestionController::on_ack_no_rtt for
the case where every acked frame was ambiguous, so the window still
advances without polluting the RTT estimator. Refactored the shared
window-growth into grow_window.
- Frame decode: header+payload+pad length now uses checked_add. payload_len
is a u32 from the header and on 32-bit targets (MIPS/ARMv7 routers are
supported) the sum could wrap usize and slip past the truncation check.
- Replay cache: a full cache used to reject ALL new handshakes globally
until the next tick, letting one flooding key-holder deny service to
everyone. Now it reclaims expired entries and, if still full, evicts the
single oldest — new handshakes always get in. Fixed the mislabelled
"100000" log (cap is 50000) and named it REPLAY_CACHE_MAX.
The resumption module (SessionTicket/TicketValidator) was never wired into
the client or server — nothing issued or validated tickets, and no Resume
frame was ever sent. But it "encrypted" tickets by XOR-ing them with a
single static keystream SHA256(psk || const) and had no MAC (despite a doc
comment claiming HMAC): a textbook many-time-pad, trivially broken from a
couple of captured tickets, and malleable. Leaving it in-tree invited
someone to wire up a broken 0-RTT path later.
Removed the module, its FrameKind::Resume wire variant, and the protocol
handler for it. 0-RTT can be reintroduced later on a real AEAD-sealed
ticket if desired.
Two classes of issue:
- Hot-path/attacker-triggerable events logged at info/error with internal
detail: a per-handshake info! byte dump (raw_vec[0..6]) and a per-packet
error! on session-id mismatch that dumped expected/got session ids.
Both are log-flood + info-leak surfaces; downgraded to debug and
stripped of the sensitive detail. Close/Resume frame handling likewise
moved from info to debug.
- The access key (a shared secret) was written to logs verbatim in three
places (session drop, key-created UI event, API create-user) and as an
8-char prefix in one. Added key_fp() — a short SHA-256 fingerprint — and
routed all key logging through it so operators can still correlate
events without the secret ever hitting the log.
Every datagram from an unrecognized source ran the full key-trial loop:
for each registered access key, an HKDF (derive_all_secrets) plus two
HMACs (junk markers) plus a Noise read. A garbage flood from spoofed
sources could therefore force unbounded O(N_keys) crypto per packet — a
CPU-amplification DoS with no throttle (the existing token bucket only
guarded the roaming path, not this one).
Two mitigations:
- Memoize the per-key derived secrets (pure function of key+version) and
the per-window junk markers, so the trial loop is now cheap comparisons
plus one Noise read per key instead of HKDF+2*HMAC per key per packet.
Also speeds up every legitimate new connection. Caches are pruned in
on_tick when keys are deleted.
- Gate the trial path behind a global token bucket (TRIAL_RATE=100/s,
same burst). The established-session fast path and roaming are not
gated, so live sessions are unaffected; only unknown-datagram trials
are bounded. Over-budget datagrams are dropped silently.
CRITICAL forward-secrecy fix. Session transport keys were derived as
SHA256(get_handshake_hash() || label). The Noise handshake hash `h` only
ever absorbs PUBLIC transcript data (ephemeral pubkeys + on-wire
ciphertexts, via MixHash); the ephemeral ee DH result is mixed via MixKey
into the chaining key `ck` ONLY, never into `h` (confirmed in snow 0.9.6
symmetricstate.rs). So the data-transport keys depended on the PSK and the
public transcript but NOT on the DH secret, meaning:
- zero forward secrecy: anyone who later learns the access-key PSK can
decrypt all recorded past sessions from the observed handshake alone;
- any PSK holder can passively decrypt any other session on that key;
- the ephemeral Diffie-Hellman was cryptographically wasted.
Fix: take the two directional keys from Noise's Split() over the final `ck`
via snow's dangerously_get_raw_split (risky-raw-split feature). These keys
depend on ee, restoring forward secrecy. The custom out-of-order AEAD,
explicit nonces, session_id AAD, framing and reordering are all unchanged
- only the key SOURCE moved. The dead into_transport()/handshake_hash()
paths and the unreachable NoiseSession::Transport variant are removed.
Wire-breaking: PROTOCOL_VERSION 4 -> 5 so pre-fix peers derive different
keys and cannot interop (version gate is invisible on the wire).
Added noise unit tests for the .0/.1 -> send/recv role mapping and the
not-finished guard.
A script parameter named exactly $Switch collides with PowerShell's `switch`
statement keyword - confirmed by bisection - and made every `$X = switch (...)
{...}` in the script silently evaluate to empty instead of erroring. This is
what produced the malformed "v0.4.1-.0" tag on the last release attempt
(Channel resolved to "" instead of "stable", Iteration to 0). Renaming the
parameter is the only fix; nothing else about the switch statement itself
was wrong.
master had UAC/SmartScreen fix, RTT/speed-display toggle, and a run-name CI
tweak that never made it back into alpha. Alpha already independently
contains equivalent (UAC fix is byte-identical) or superior (run-name
handles the newer alpha/beta/nightly channel scheme master's version
doesn't know about) versions of all three, so this merge is a pure
reconciliation - alpha's side wins on every conflicting hunk.
No source ever existed in this tree for it - only a built dist/ and a
full node_modules/ dump (6200+ files), and nothing in the codebase
references "ostp-license" anywhere. Leftover from the old commercial-
license-gated era before the AGPLv3 switch; pure bloat since.
Also added **/node_modules/ to .gitignore - its absence is exactly how
this got committed in the first place.
'ostp prober' was never a real subcommand - ostp-prober is a separate,
gitignored standalone tool, not part of the ostp binary's CLI surface
(no Prober variant in the Commands enum, no handler in main.rs). Also
fixed misaligned columns on the proxy-env/proxy-env-clear lines.
The repo switched to AGPLv3 back on 2026-06-18 (commit 9ce9e6d), and
Cargo.toml/README have said AGPL-3.0 ever since — but that license-change
commit was never carried forward into the 0.4.x rebuild branch, so the
actual LICENSE file silently reverted to the pre-rebuild BSL 1.1 text
(with a "converts to MIT in 2030" clause that hasn't applied for months).
Restored the real AGPLv3 text from 9ce9e6d.
Also added the missing `license` field to a few crate manifests that
didn't declare one (ostp-gui/src-tauri, ostp-jni, ostp-tun-helper), and
dropped the Tauri template placeholder authors/description.
Traced the whole traffic-counter pipeline (Dart -> MethodChannel ->
Kotlin -> JNI -> Bridge) end to end; it's architecturally identical to
the working desktop implementation, so no code-level bug was found.
Previously a getMetrics exception was only reported as a PlatformException
that Dart swallows with a bare debugPrint, invisible in the in-app log
viewer users actually have access to. Now it's also written to the
native log buffer via OstpClientSdk.addLog, so if the counter breaks
again the actual cause (exception vs. genuinely-zero atomics) shows up
in View Logs instead of requiring adb.
Mirrors ostp-gui's "Show Speed" / "Show RTT" client settings toggles
(both default on): the home screen now shows live download/upload
throughput (computed from byte deltas between 1s polls, same as
desktop's poll()) as a subtitle under the existing cumulative
Download/Upload totals, and the RTT box is now hideable. Also fixed
"Test Ping" to actually query getMetrics instead of just faking a
500ms spinner with no real measurement.
stealth_sni was never actually consumed to construct any wire bytes —
verified dead in bridge.rs (only stored, never read). It implied
TLS/HTTP SNI mimicry that this project deliberately does not do
(zapret-like: packet-level DPI obfuscation only, no protocol
mimicry). Removed from the runtime schema (config.rs, bridge.rs),
both CLI/GUI local config shapes and their JSON templates, the
Flutter profile model/UI/share-link logic, and README feature docs.
migrate.rs now drops the field from legacy configs with a note
instead of carrying it forward.
- Profile edit dialog: moved junk packets + TCP fragmentation into their own
modals (tap-to-configure), replacing 5 inline field rows with a compact
2-button row. These are occasional/advanced settings, not something every
profile edit needs to see up front.
- Profile card: subtitle repeated the server address verbatim whenever a
profile had no custom name (name falls back to serverAddr) — showing
"1.2.3.4:50000" as both title AND subtitle, with transport mode tacked on
the end of the second copy. Now only shown once; added maxLines/ellipsis
so long addresses truncate instead of wrapping awkwardly.
- Mobile: removed the "Bypass Processes" field entirely (editor UI, prefs
key, config JSON). Android per-app selection (Configure Split Tunneling)
is the real, correct control here — a process-name text field doesn't map
to anything meaningful on Android the way it does on desktop.
- Share icon changed from a QR icon (redundant — the modal already shows a
QR code) to the standard Material share glyph. Share modal title no
longer interpolates the profile's name, which — same root cause as
above — can silently BE the raw server address; title is now generic
("Share Profile") so a screenshot/recording can't leak host:port through it.
- Contrast: the monochrome theme's colorScheme.primary is pure white
(0xFFFFFFFF); several buttons hardcoded white text/icons on top of it
(Bypass/Proxy mode toggles, Copy Link), making them invisible when active.
Added an _onColor() helper (luminance-based black/white pick) and applied
it everywhere a button's foreground sits on a theme color.
- "Configure Split Tunneling" appeared to hang for 10-15s before doing
anything: MainActivity.kt's getInstalledApps handler enumerated every
installed package AND decoded+re-encoded each one's icon synchronously
inside the MethodChannel callback, which runs on the main/UI thread by
default — blocking it for the whole duration meant Flutter couldn't
render ANY frame, not even the loading spinner, until it finished. Moved
the work onto a background Thread; only the final result.success() hops
back via runOnUiThread(). Navigation + spinner now show immediately.
assets/logo.png had NO real alpha transparency — both the background and
the eagle shape were fully opaque (A=255 everywhere), just baked in as
near-black (3,3,3) vs near-white (253,253,253) RGB. Applying `color:
Colors.white` to tint it painted the WHOLE bounding square white (alpha
being 255 across the entire image gives BlendMode nothing to mask against),
which at low Opacity looked like a flat gray square instead of a silhouette.
Converted the asset in place: since it was already grayscale (R=G=B), each
pixel's luminance became its new alpha channel, RGB set to pure white. The
background (near-black, low luminance) is now near-transparent; the eagle
(near-white, high luminance) is now near-opaque. This is the same effect the
desktop GUI gets for free from its logo.svg (a vector eagle path with no
background element at all — inherently transparent), just reproduced for a
raster asset without adding flutter_svg as a new dependency.
The `color: Colors.white` tint in both watermark call sites is now
redundant (the asset is already a pure-white silhouette) and removed.
Previous scheme conflated "which release is this" with "how many times has
it been rebuilt": every run bumped the patch version, so by the time a build
was ready to promote to master the version number had already crept forward
by however many alpha/beta iterations it took to get there.
Now a release cycle has one fixed target version (e.g. 0.4.1) that stays in
every manifest unchanged through all alpha/beta iterations; only a
per-channel counter increments, and that counter lives ONLY in the git tag,
never in Cargo.toml:
v0.4.1-alpha.1 -> v0.4.1-alpha.2 -> ... -> v0.4.1-alpha.N
v0.4.1-beta.1 -> v0.4.1-beta.2 -> ... -> v0.4.1-beta.N
v0.4.1 <- master, iteration dropped
Deliberately "0.4.1-alpha.N" (dot AFTER the hyphen — a semver pre-release
identifier), not "0.4.1.N-alpha" (a 4th dot component before the hyphen):
the latter isn't valid semver and Cargo's version parser rejects it outright,
so it can never appear in Cargo.toml. That's also why the target version
itself never needs to change on a plain iteration — bumping every manifest +
refreshing both Cargo.locks is now skipped entirely unless -Switch actually
changes the target, making a routine alpha/beta push fast (just the state
file's counter + a tag).
Also fixes a real bug found while touching this: release.yml's push trigger
is tags-only ("v*") with no branch trigger, so the old `git push origin
$branch`-only path for alpha/pre-release never actually started a CI run —
only the master path (which already pushed a tag) worked. Every channel now
always pushes a real tag, which is what actually triggers the build.
release.yml's resolve-channel needed no changes: its tag-channel detection
already does substring matching (*-alpha*/*-beta*), so it classifies
"v0.4.1-alpha.37" correctly without modification.
-Prefix is gone — channel was always 1:1 with -Branch (alpha/pre-release/
master), so it was a redundant, independently-settable axis that could
silently drift from the branch (e.g. -Branch alpha -Prefix beta).
Merges the best of both lineages instead of a blind revert to v0.3.21:
kept from v0.3.21: multi-profile management (add via QR scan/link/manual,
single-select active profile, auto-mode transport/MTU probing). Kept from
current: Share Config (QR generation), Check for Updates, curated stealth-SNI
domain list, and actually-rendered exclusions fields (v0.3.21 loaded/saved
them but never showed them in the UI — dead code).
New: junk packets (pc/ps min/max) and TCP fragmentation (chunk/sleep) are now
per-profile fields in the profile editor, mirroring the desktop GUI's profile
object shape 1:1 (ostp-gui/src/main.js) so behavior matches across platforms.
WSS is gone — removed from the model, the UI, and the config builder. The
core dropped TLS-mimicry transports entirely (see §A of the rebuild), so
there was nothing left for it to configure.
Config building now targets the flat single-server schema
(ostp_client::config::ClientConfig) built from ONE active profile, not the
old modular inbounds/outbounds/urltest-failover config — the core no longer
supports connecting to multiple servers at once, matching how the desktop
GUI already works (single activeId). Also dropped a dead nested "tun": {...}
object that neither version's JSON producer nor the real ClientConfig struct
ever actually used — serde silently ignored it.
resolve-channel treated EVERY v* tag as stable, so v0.4.6-beta got published
as a non-prerelease "Latest" release, sitting on top of the release line. Now
a pushed tag is used as-is and its suffix decides the channel: v*-alpha / v*-beta
are prereleases, only a bare vX.Y.Z is stable. (A tag is never recomputed from
Cargo.toml, so the release can't upload to a different tag than the one pushed.)
Version bumped 0.4.5 -> 0.4.7 (0.4.6 is already taken by the mislabelled beta).
This commit's tip is what gets tagged v0.4.7-beta to cut the beta build.
The junk marker was a per-key CONSTANT sent in plaintext at a fixed offset in
junk frames. Junk is meant to look like random noise (zapret-style), but a
constant prefix is a recognizable per-user structure: an on-path observer
watching one user sees the same 4 bytes on every junk packet, i.e. an OSTP
fingerprint. (The earlier fix only removed the GLOBAL constant.)
Now the marker rotates every 60s window: junk_marker = HKDF(key, ver, 0x04 ||
window). To an observer the prefix changes each window (no fixed signature),
and a captured marker is only valid for ~1 window — the "bit of protection"
against a leaked marker. Only a key holder can compute it, so an outsider still
can't forge a silently-dropped junk packet (and silent-drop is cheaper than
normal processing anyway, so junk spam was never a DoS lever to begin with).
- core: derive_junk_marker(key, window) + current_junk_window() (60s window),
same version-gated HKDF scheme; junk_marker dropped from DerivedSecrets.
- client: stamps junk with the current window's marker.
- server: checks current AND previous window per key (absorbs ~1 window of
clock skew) before falling through to unauthorized-probe handling.
- Not a wire break: only junk framing changes; real handshake/data untouched.
During mixed rollout, unmatched junk merely logs as a probe (cosmetic).
Every process (CLI daemon, GUI, TUN helper) and every subsystem (tracing, the
core event logger, the helper IPC, panic hook) wrote its own file: ostp-cli.log
+ ostp-core.log + ostp-helper.log + ostp-crash.log — a pile per run. Now they
all funnel into a single ostp.log next to the exe.
- logging: LOG_FILE_NAME/log_file_path() as the one source of truth; init_tracing
gains a `truncate` arg. Truncation is gated twice: Windows-only (cfg!(windows))
AND daemon-only. One-shot commands (gk/check/init/-V/...) and the elevated TUN
helper pass truncate=false so they can never wipe a running daemon's log;
invocation_is_daemon() detects the daemon from argv. On Linux the server always
appends (history kept, OS-rotated) as requested.
- runner/helper manual writers + panic hook now target log_file_path(), so their
output lands in the same ostp.log instead of separate files.
The run-name expression contains the GHA string literal 'Release build: {0}'
— an unquoted YAML plain scalar treats ": " as starting a nested mapping,
which invalidated the entire workflow file at parse time (before any job
runs). Every push since that line was introduced failed instantly with
"Invalid workflow file ... line 11", silently burning an Actions-minutes
run each time for nothing. Wrapping the whole expression in double quotes
fixes it — verified with `npx js-yaml` that the file now parses and the
run-name value round-trips intact.
All three on-disk config.json shapes (client, server, relay) used to be
declared locally inside ostp/src/main.rs, invisible to any other consumer —
which is exactly how ostp_client::migrate ended up matching against loosely
typed serde_json::Value instead of a real schema, with no guarantee its
hand-built output actually matched what the CLI parser expected.
Moved every one of those definitions (AppMode, UnifiedConfig, ServerConfig,
RelayServerConfig, ClientFileConfig, TunConfig, ExcludeConfig, MuxConfig,
TransportConfigRaw, ApiConfig, FallbackCfg, ListenConfig, UserConfig) into
ostp_client::config — the same file that already held the runtime
ClientConfig/OstpConfig/etc. main.rs now imports them instead of
re-declaring them (`ClientFileConfig as ClientConfig` to avoid colliding
with the runtime ClientConfig, which stays separate on purpose: it's the
engine's internal shape — handshake/io timeouts and the like a user never
sets in config.json — built FROM one of these via the mapping in
run_client_directly, not the same thing).
ServerConfig.dns is now Option<serde_json::Value> rather than
Option<ostp_server::dns::DnsConfig> — ostp-client doesn't (and shouldn't)
depend on ostp-server just to name that type. main.rs, which already depends
on both crates, deserializes it right before handing it to run_server().
cmd_migrate now proves its output against this schema before ever writing
to disk (serde_json::from_value::<UnifiedConfig>(migrated)) — a migrator/
schema drift is now a hard error at migrate time, not a corrupted
config.json discovered later. Added a matching unit test
(every_migrated_output_matches_the_canonical_schema) that exercises this
same check on all three migration paths (modular, legacy-flat, server).
- fix(cli): stop printing the startup banner ("ostp-cli vX.Y.Z | OS: ...")
to stderr on every single command invocation. init_tracing() ran
unconditionally before command dispatch, so `ostp -V`, `ostp gk`, etc. all
showed it. It's still written to the log file (useful there), just no
longer echoed via the stderr tracing layer for one-shot commands.
- feat(client): add ostp-client::migrate, the ONE place config migration
runs. Previously there were three uncoordinated migration paths: a Python
snippet embedded in scripts/install.sh (only touched server api.* fields,
ran on every update), the old 0.3.x line's auto-migration on every hot
reload (silent besides a log warning), and nothing at all for the current
rebuild. Consolidated into one module covering every config shape that's
actually existed:
- v0.3.1-v0.3.21 modular (inbounds/outbounds/routing) -> current flat
schema, including correctly resolving routing.default_outbound through
a urltest/selector group to the real server, and reporting (not
silently dropping) every additional server a multi-server config had.
- pre-0.3.1 flat configs carrying now-dead fields (tun.wintun_path,
tun.ipv4_address, transport.wss) -> dropped with an explicit reason,
everything else passes through untouched.
- server configs -> backfills api.* defaults and drops legacy api.token
(ported straight from the install.sh Python, same behavior, correct
place).
6 unit tests cover all of the above against realistic fixtures. Wired up
as `ostp migrate` (was missing from Commands entirely) — no other code
path calls into this module, so a config's shape only ever changes when
explicitly asked.
- feat(cli): `ostp import <url>` now asks the same TUN/mux/debug questions
`ostp connect <url>` always did. Previously import just wrote flat
defaults to disk with no way to turn any of that on short of hand-editing
the resulting config.json afterward. Extracted the shared prompt into
prompt_client_options() so both paths stay in sync.
- chore(install): remove the embedded Python config-migration snippet from
install.sh; schema migration must never happen implicitly during an
install/update. Points users at `ostp migrate` instead.