Version: 0.11, transcript-bound classified-record transport and adaptive shaper beta snapshot
Implementation language: C++20, Boost.Asio, Boost.Test, Boost.JSON, Boost.Log and OpenSSL.
FPS is an experimental covert datagram transport carried over live TLS cover
sessions. The current product adapter, and the first production-facing use case,
maps those datagrams to a leased L3 TUN VPN service.
On the fps_client <-> fps_server link an observer should see a TCP stream
made of TLS Application Data records. Real browser/origin TLS bytes are not
terminated by FPS. After upgrade, ordinary carrier TLS records continue to be
forwarded byte-for-byte; FPS inserts separate classified TLS Application Data
records for opaque datagram/control payloads and consumes them before they
reach the real TLS endpoints.
The expanded project name, Free Porn Storage, is a deliberate misdirection. It is not descriptive branding; it is meant to make casual discovery and search by unprepared users or classifiers less useful. The protocol and implementation documents should still use the short name, FPS, for technical clarity.
Linux is the active target platform for both client and server. Android client
work has started through a command-line Kotlin/NDK scaffold; the current build
separates protocol core, carrier/datagram core, TUN adapter and Linux runtime
targets so future adapters can reuse authenticated carriers without inheriting
Linux TUN or ip orchestration.
Browser / cover client
| server TUN
v |
fps_client == TLS-application-record-shaped FPS link == fps_server
| |
client TUN v
real HTTPS origin
Key v5 properties:
security.zero_rtt is the only supported carrier authentication mechanism.Implementation boundary:
fps_protocol_core owns protocol primitives: TLS record parsing/wrapping,
transcript-bound Zero-RTT, classified FPS records, envelope/frame codecs and
shaping decisions. It does not open sockets or TUN devices.fps_datagram_core owns the generic unreliable datagram transport contract:
CovertDatagramTransport schedules opaque datagram frames across abstract
CovertCarrier handles identified by CarrierId. This layer does not assume
that the carrier is TLS, TCP, SSH, WebRTC or any other concrete protocol.fps_tls_tcp_carrier owns the current TLS-over-TCP carrier implementation.TlsTcpCarrierSession is the current concrete carrier implementation. It is
deliberately named TLS/TCP because it owns TCP socket reads/writes, TLS record
slicing, carrier transcript tracking, Zero-RTT state transitions and
insertion/removal of classified FPS TLS Application Data records.TunTunnelAdapter is the first product adapter above the datagram transport:
it maps leased IPv4 TUN packets to opaque datagrams and enforces server-side
lease/source/destination routing. Future adapters can target the same
datagram contract without inheriting TUN semantics.fps_linux_runtime composes the production Linux relay, config/CLI, status
socket, Linux TUN runtime and operator-facing daemon behavior.Carrier origin resolution is an operator concern. For browser-created carrier
sessions in beta deployments, the recommended client-side mechanism is a
minimal /etc/hosts override that maps the carrier hostname to the local
fps_client listener while keeping the browser-visible hostname, SNI and
Host header unchanged. FPS does not currently include a DNS proxy or global
resolver helper.
Every byte added by FPS on the fps_client <-> fps_server link must be wrapped
in an outer TLS record:
+------------+-------------+-------------+------------------+
| type = 23 | version | length | opaque bytes |
+------------+-------------+-------------+------------------+
| 1 byte | 2 bytes | 2 bytes | length bytes |
+------------+-------------+-------------+------------------+
Rules:
23).Wire-shape checks:
tools/capture_tls_wire.sh --port PORT -- COMMAND;tshark is installed, the generated TLS summary should show parseable
TLS records rather than Wireshark falling back to an opaque TCP stream.ZeroRttUpgradeEngine uses X25519, HKDF-SHA256 and ChaCha20-Poly1305 through
OpenSSL.
Current construction:
client_uuid; FPS
deterministically derives the client’s X25519 key pair from that UUID.client_uuid is a per-device/per-profile bearer secret. Shared or group UUIDs
are unsupported because one UUID maps to one client public key and one
persistent TUN lease identity.Security constraints:
client_uuid and server_public_key_base64.server_private_key_base64,
server_public_key_base64 and allowed_client_uuids.Implemented v5 client-auth precheck layout inside the TLS Application Data payload:
server_hint[8] | client_hint[8] | encrypted_capsule | capsule_tag[16]
Hints are derived from the pre-candidate transcript snapshot:
server_hint = H("fps/zero-rtt/client-auth/server-hint/v5" ||
bidirectional_transcript_snapshot ||
server_public_key || profile_id)[0..8]
client_hint = H("fps/zero-rtt/client-auth/client-hint/v5" ||
bidirectional_transcript_snapshot || client_public_key ||
server_public_key || profile_id)[0..8]
Server-side verification first rejects candidates with a wrong server_hint,
then scans the configured UUID-derived client public keys for client_hint, and
only then attempts capsule decryption for the likely client. The encrypted
client-auth capsule contains protocol version, capabilities, the client
ephemeral public key and opaque client payload.
The server accept record uses the same visible shape with server-accept labels:
server_hint[8] | client_hint[8] | encrypted_accept_capsule | accept_tag[16]
The encrypted accept capsule contains protocol version, capabilities, the server ephemeral public key and opaque server payload. Session keys are derived from static-static DH, client-ephemeral/server-static DH, server-ephemeral/client-static DH, ephemeral-ephemeral DH, the bidirectional transcript snapshot and both encrypted auth/accept wire records.
This is not a complete active CPU DoS defense because an attacker that knows the
public construction can create plausible server_hint values. Its main value is
removing visible public-key-shaped handshake material and avoiding full decrypt
work for ordinary random carrier records.
The server accept record is not guaranteed to be the very next TLS record observed by the client after it sends the Zero-RTT auth record. Browser and origin TCP streams are independent, and ordinary origin-to-browser TLS records can race ahead of the FPS server’s accept record.
Required client behavior: after sending an auth candidate, the client must trial-decrypt plausible peer-direction TLS Application Data records as possible server accepts. If a record does not classify as a valid accept record, the client must forward it byte-for-byte to the browser as cover traffic and keep waiting until the accept arrives or a bounded policy expires. Only after a valid accept does the client treat the carrier as authenticated. Treating the first peer-direction record as mandatory accept is a correctness bug and can break otherwise valid cover sessions.
The same transcript-bound idea can become a later no-bootstrap FPS record classifier: every visible TLS Application Data record could be tested as either ordinary carrier bytes or an FPS candidate without a prior explicit authentication record. This is a larger future design, not the beta baseline. It must prove:
After upgrade, FPS no longer wraps the entire carrier TLS byte stream. Each ordinary carrier TLS record is forwarded byte-for-byte and included in the per-direction transcript. Covert traffic is inserted as a separate TLS Application Data record:
server_hint[8] | client_hint[8] | encrypted_record | record_tag[16]
Hints are derived from direction, profile id, authenticated client/server public keys, session keys, the per-direction carrier transcript snapshot, visible record index and implicit FPS sequence. The AEAD associated data binds the same metadata plus the visible payload length.
Encrypted plaintext contains protocol version, flags, implicit sequence, an opaque datagram/control frame bundle and padding. It does not contain real carrier TLS bytes.
Sequence and nonce discipline:
The protocol core exposes a best-effort opaque datagram transport over authenticated carrier sessions. The transport does not know whether the payload is an IP packet, a control message for a higher-level adapter or a future application-specific payload.
CovertDatagramTransport maintains the generic carrier pool:
CovertCarrier handles are registered only after Zero-RTT
authentication;wrong_executor;opaque_datagram frames from registered carriers are reassembled when
needed and delivered with their source carrier handle.The Linux VPN runtime is implemented as TunTunnelAdapter on top of that
generic transport:
Duplicate UUID policy:
replace_old: the newer instance supersedes older
carriers for that UUID/lease;fps_client generates a random per-process client_instance_id at startup
and reuses it for all carriers created by that process;Opaque datagram fragmentation:
opaque_datagram is used for datagrams <= codec.max_frame_payload;opaque_datagram_fragment;packet_id,
fragment_index, fragment_count and total_size;packet_id, so fragments
from different carriers or different datagrams can be interleaved without
sharing state;Server-assigned IPv4 leases:
tun.lease_pool,
tun.server_address and persistent tun.lease_file;control frame
containing client-instance metadata. With a server lease pool, the server
registers the carrier only after that metadata arrives;control frame containing
a tun_lease: version, IPv4 family, prefix length, client IPv4, server IPv4,
network IPv4 and MTU;tun.auto_configure=true, using
ip addr replace <lease>/<prefix> dev <tun> and
ip link set dev <tun> up mtu <mtu>;tun.client_isolation=true by default: the server drops inbound IPv4 TUN
packets addressed to another leased client in the same pool;Current implemented scope:
opaque_datagram is too
large for the sampled TLS record but the record can carry at least one
fragment header plus one byte of data, FPS expands that datagram into ordered
opaque_datagram_fragment records on the same carrier; if even the smallest
fragment cannot fit, the datagram stays queued and injection is blocked for
that scheduling attempt;shaper.record_size_cdf_c2s and shaper.record_size_cdf_s2c buckets are full
outer TLS record wire sizes, including the 5-byte TLS record header. A sampled
size smaller than the classified-record overhead, or larger than the configured
classified-record padding capacity, blocks injection until another scheduling
attempt. codec.max_frame_padding also limits classified-record padding in the
current Linux relay config. shaper.inter_record_delay_us_cdf_c2s and
shaper.inter_record_delay_us_cdf_s2c buckets are inter-record delays in
microseconds. Public CDF config uses compact [value, cumulative_probability]
pairs, for example [[512, 0.4], [1500, 1.0]].
Adaptive behavior:
Shaper instance when the shaper is
enabled; all authenticated and pre-auth carrier sessions in that process
train the same adaptive model;recv/read_some chunk boundaries do not affect the model;adaptive.decay to keep the model inertial
while still allowing it to follow long-running carrier changes;Deferred work:
Config format: JSON parsed through Boost.JSON.
Minimal server-side v5 shape:
{
"network": {
"listen": "127.0.0.1:8443",
"origin": "127.0.0.1:9443",
"read_buffer_size": 65536,
"tcp_no_delay": true
},
"security": {
"zero_rtt": {
"enabled": true,
"profile_id": "example-origin-v5",
"server_private_key_base64": "PASTE_server_private_key_base64_HERE",
"server_public_key_base64": "PASTE_server_public_key_base64_HERE",
"allowed_client_uuids": ["123e4567-e89b-42d3-a456-426614174000"],
"version": 5,
"min_records_before_trial": 1,
"upgrade_direction": "client_to_server",
"client_upgrade_delay_ms": 0,
"client_upgrade_delay_sigma_ms": 0
}
},
"codec": {
"max_frame_payload": 1280,
"max_frame_padding": 64,
"allow_fragmentation": true
},
"tun": {
"enabled": true,
"name": "fps0",
"mtu": 1280,
"max_write_queue_packets": 64,
"lease_pool": "10.66.0.0/30",
"server_address": "10.66.0.1",
"lease_file": "leases.json",
"client_isolation": true
},
"limits": {
"max_session_write_queue_bytes": 1048576
},
"logging": {
"level": "info"
},
"ops": {
"status_socket": "/run/fps/server.status"
}
}
Client config uses network.server, client_uuid,
server_public_key_base64 and, when desired, tun.auto_configure=true. Server
config uses only inline padded RFC4648 base64 fields
server_private_key_base64/server_public_key_base64 and
allowed_client_uuids.
security.zero_rtt.version is optional and defaults to 5. When present it
must be 5; pre-production wire formats are intentionally not accepted as
compatibility modes.
security.zero_rtt.client_upgrade_delay_ms defaults to 2000 for client
configs and 0 for server configs. The delay is checked when more carrier TLS
records arrive after bidirectional Application Data is observed; it is intended
for continuous carrier sessions, not as a wall-clock timer that injects FPS
bytes into an idle carrier.
security.zero_rtt.client_upgrade_delay_sigma_ms defaults to one third of
client_upgrade_delay_ms for client configs and 0 for server configs. Each
client carrier samples one effective delay when the bidirectional TLS
Application Data channel first becomes eligible for upgrade:
clamp(client_upgrade_delay_ms + N(0, sigma_ms), 0, 2 * client_upgrade_delay_ms).
Set the sigma field to 0 for reproducible tests and packet-capture
experiments. FPS still never sends the client auth record before observing TLS
Application Data in both carrier directions and the required transcript records.
Optional shaper adaptive fields live under shaper.adaptive:
{
"shaper": {
"enabled": true,
"profile_id": "example-origin-v5",
"record_size_cdf_c2s": [[512, 0.4], [1500, 1.0]],
"record_size_cdf_s2c": [[512, 0.4], [1500, 1.0]],
"inter_record_delay_us_cdf_c2s": [[20000, 0.5], [100000, 1.0]],
"inter_record_delay_us_cdf_s2c": [[20000, 0.5], [100000, 1.0]],
"adaptive": {
"enabled": true,
"min_records": 16,
"min_observation_ms": 2000,
"decay": 0.98,
"snapshot_interval_ms": 30000
}
}
}
Android client profiles use the same inline shaper object and compact CDF
arrays when static shaping is configured. Android intentionally rejects
shaper.profile_file: generated/imported mobile profiles must remain
self-contained. Kotlin validates the JSON shape with Android’s JSON runtime,
then passes primitive CDF arrays through JNI; native code constructs the shared
platform-neutral Shaper and attaches it to TlsTcpCarrierSession.
network.tcp_no_delay defaults to true and is applied to both accepted and
outbound FPS relay TCP sockets. This disables Nagle on the FPS link so shaped
classified TLS records are not delayed or coalesced by an implicit TCP policy.
Other TCP knobs such as corking, socket buffer sizes, keepalive and quick ACK
are intentionally not part of the beta config; batching belongs to the shaper,
and platform-specific TCP heuristics can create new fingerprints.
Validation rules:
tun.enabled=true requires security.zero_rtt.enabled=true;codec.max_frame_payload must be positive;codec.allow_fragmentation=true and TUN,
codec.max_frame_payload must be larger than the fragment header;codec.allow_fragmentation=false, tun.mtu must not exceed
codec.max_frame_payload;tun.lease_pool requires tun.lease_file, and tun.server_address
must be a usable IPv4 address inside the pool.Lease-management CLI:
fps_server --lease-list --config server.json prints JSON summary with pool,
server address, IP leases, public-key fingerprints and allowlist status;fps_server --lease-revoke-client-uuid UUID --config server.json removes the
lease for the UUID-derived client public key; idempotent not_found exits 0;fps_server --lease-prune --config server.json removes leases that are no
longer derived from current allowed_client_uuids;Operational status:
ops.status_socket enables a local UNIX socket;fps_client --status --config client.json and
fps_server --status --config server.json query the configured socket;--status-socket PATH overrides the config path for ad-hoc queries;sessions.last_closed, bounded sessions.recent_closed, auth counters under
auth, classified-record counters under classified_record, TUN packet/drop
counters, shaper/backpressure counters and, when enabled,
shaper.profile with a non-secret compact CDF snapshot;auth contains candidate, authenticated, precheck failure, unknown-client,
decrypt failure and server-accept failure counters;classified_record contains decode/encode failure, tamper/invalid and
records-decoded/records-encoded counters;session_id, authentication state, close reason
and non-secret direction/component/stage/error names;Shaper profile export CLI:
fps_client --write-shaper-profile --config client.json --output profile.json
[--force] and the same fps_server command write a normalized shaper profile
JSON file with 0600 permissions;ops.status_socket or --status-socket PATH is reachable, the exported
profile uses the live adaptive CDF snapshot from shaper.profile;--format json is supported in the current schema.Server keypair CLI:
fps_server --generate-server-keypair prints text fields for manual use;fps_server --generate-server-keypair --format json prints a flat JSON object
with server_private_key_base64 and server_public_key_base64, matching the
server config field names;Offline shaper profile tooling:
tools/pcap_to_shaper_profile.py carrier.pcap --port 443 --output
profile.json builds the same compact JSON shaper profile from a captured TLS
carrier TCP session;recv boundaries;--port PORT is used as a
service-port hint;Client profile CLI:
fps_server --generate-client-profile --config server.json --client-uuid UUID
--server-endpoint HOST:PORT prints a valid fps_client JSON profile;--client-status-socket PATH adds ops.status_socket to that generated
client profile when the operator wants status to work immediately;--format uri prints the same profile as fps://v1/<base64url-json-profile>;--output PATH [--force] writes generated JSON or URI output as secret
material with 0600 permissions; existing files are not overwritten unless
--force is set;fps_client --print-config-from-uri URI decodes an fps://v1 URI back to
client JSON;fps_client --write-config-from-uri URI --output PATH [--force] decodes and
writes client JSON with the same secret-file overwrite rules;fps://v1 decoding is implemented in platform-neutral core. It normalizes the
JSON profile and validates the client UUID plus server public key before the
Linux CLI writes or prints it;client_uuid, server_public_key_base64,
profile id, carrier endpoint, codec settings and client-side TUN
auto-configuration defaults;--generate-client-uuid prints only one raw canonical UUID line;server_private_key_base64,
allowed_client_uuids, lease file paths or server lease-pool internals;fps_core is the narrow platform-neutral layer intended for Android reuse:
crypto, Zero-RTT, classified-record codec, fps://v1 client profile
normalization and generic datagram scheduling. TUN framing/adaptation and the
TLS/TCP carrier are explicit opt-in targets above that core. The current Android
scaffold adds a headless Kotlin runtime boundary: it parses client JSON and
fps://v1 profiles, models carrier probes and split-tunnel allowlists, models
the VPN startup state machine, provides live OkHttp HTTPS/WSS probe/keepalive
traffic for app-owned carrier sessions, owns the first lease-triggered Android
VpnService TUN file descriptor, keeps platform operations behind hooks, and
builds an NDK library that reuses FPS native core pieces without linking Linux
runtime code.
Linux-specific runtime is separate:
fps_linux_runtime contains relay CLI app, Linux TUN open and production
TunRuntime;TunRuntime is injected into the relay app and provides TUN opening plus
semantic link/address operations. The Linux implementation translates those
operations to no-shell ip execution; Android should later back the same
operations with VpnService;VpnService.Builder
fd ownership adapter and JNI runtime handle with explicit native io_context
executor lifecycle. The native runtime can start a non-protocol TUN pump
skeleton that reads from the duplicated fd, parses IPv4 TCP/UDP 5-tuples,
exposes bounded metadata for Kotlin split-tunnel policy decisions and records
non-secret counters/drop reasons. Packet bytes stay in native state. A
policy allow decision now attempts to hand the native-owned packet to an
outbound native transport seam. The Android runtime can also take a protected
raw TCP socket, expose a loopback local-cover listener and create a
TlsTcpCarrierSession that bridges the local cover socket to that protected
socket. The bridge now installs real client-side Zero-RTT options derived
from the validated Android profile, receives encrypted server-accept lease
metadata and reports tampered server-accept failure without registering a
lease. Until an authenticated carrier is attached, the default runtime
reports no_carrier_transport with explicit enqueue rejected counters instead
of silently treating a packet as forwarded. Debug-only Android tests can also
register an in-process fake carrier that exercises the production
CovertDatagramTransport path without opening network sockets. TUN
reattach/clear drops pending and in-flight policy packets from the old fd;Socket hook
before connect;TcpSocketProtector/platform hooks, hostname resolution through
Android’s underlying network, two-phase lease-before-TUN startup and split
tunnel by default.TlsTcpCarrierSession. Android can establish and own a VpnService fd after
a server lease, installing the leased IPv4 address and leased-subnet route. It
requires tun.enabled=true before lease-triggered TUN establishment, exposes
non-secret runtime snapshots, and has a headless Kotlin/native lifecycle
bridge that duplicates the TUN fd into a native runtime handle with explicit
tunFdOwnership=owned_duplicate metadata. The native runtime can start/stop
its Boost.Asio executor, post deterministic test commands and start/stop a
first TUN pump skeleton. That pump reads packets, parses TCP/UDP 5-tuples,
exposes metadata to Kotlin policy and accepts allow/drop completion
decisions. ALLOW reaches a native outbound seam and is counted separately
as attempted/accepted/rejected enqueue. Inbound datagrams from authenticated
carriers go through the same shared CovertDatagramTransport and are written
back to the native-owned duplicated TUN fd with metadata-only counters.
Instrumented debug tests can attach fake carriers and inject inbound
datagrams to prove these seams use shared core paths rather than
Android-specific packet handling; those JNI hooks are not exported by release
native builds. The native runtime also has a protected raw TLS/TCP bridge:
after native opens and Kotlin protects the outbound socket, native binds a
loopback listener, accepts the app-owned cover side and starts a shared
TlsTcpCarrierSession over both sockets with real client-side Zero-RTT and
encrypted lease delivery. The production service now starts this path through
a coordinated runner with bounded retry/backoff and a raw HTTPS local cover
client. That HTTPS client bounds per-response draining with
max_response_bytes profile metadata so unexpected origin responses fail the
carrier instead of hanging or consuming unbounded data. Android keeps WSS as
probe-support/future external-carrier material; first versions intentionally
do not add a raw WSS internal carrier. Inline static shaper profiles are
parsed by Kotlin, passed as primitive CDF arrays through JNI and installed as
the shared native Shaper for raw TLS/TCP carrier sessions. Android still
lacks full UI polish beyond the minimal foreground/status surface.protocol, source/destination IPv4 and ports). Android
should use this boundary to call the platform connection-owner API and
fail closed for UIDs outside the configured split-tunnel allowlist. The hook
must not log UUIDs, keys, raw packets or payload bytes.Dockerfile.android.
Runtime validation is intentionally separate: the connected instrumented smoke
loads the native library and calls the core smoke on an attached device or
emulator. A heavier Dockerfile.android-emulator child image provides an
opt-in Gradle Managed Device lane with /dev/kvm; emulator execution is not
required for ordinary Linux CI.Runtime logs use the project FPS_LOG_* facade. Linux uses Boost.Log behind
that facade; Android native code uses an __android_log_print backend at the
same macro boundary.
Service structs that carry operational counters or state should be annotated
with Boost.Describe and logged through the project describe-to-JSON helper
instead of repeating every field by hand. The current log sink still emits text
fields such as event=session_stats stats={...}, but the structured tail is
valid JSON so operators can gradually move from grep to jq-style tooling.
Allowed logs:
Forbidden logs:
Ordinary non-sudo ctest should cover:
Opt-in sudo/TUN suite should cover:
Quality/safety checks also include clang-20 warning build, ASan/UBSan, Valgrind
unit pass, llvm-cov gate and bounded libFuzzer smoke for TLS record parsing,
covert frame-bundle/classified-record decode, Zero-RTT candidates and
TUN/control payload parsing.
Product-level Docker simulations cover one-client UDP iperf3, two-client lease
routing/spoof-drop, duplicate UUID replace_old behavior and the official Dante
SOCKS5 overlay example smoke.
See testing.md.
Near productionization gaps:
fps://v1 profile URI;See beta-status.md and client-profiles.md.
fps_client and fps_server.