vpnhide/docs/protocol.md

711 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# vpnhide — control & stats protocol
Single source of truth for the wire format exchanged between the app and every
backend (`.ko`, KPM, Zygisk, LSPosed), and for the architecture that carries it.
This document is the **arbiter**: when two implementations disagree, this file
decides who is wrong. Every implementation (C / Rust / Kotlin) and every test
vector references it.
Status: `version 1` **frozen** — all seven OPEN items (§10) are resolved.
Key shape: one always-on Java (LSPosed) layer + exactly one active native
backend, priority `kmod > KPM >
Zygisk` (§1.5); three `kind`s — `config` (in), `stats` + `status` (out, §4.3);
UID is the key on every channel incl. Zygisk; `u64` cumulative counters; `debug`
folded into config; the `.ko` channel is one folded node `/proc/vpnhide_ctl`
(write=config, read=status+stats); KPM uses the `kpm ctl0` supercall (§7).
Threat model: an **unprivileged** app — root-level detection is out of scope.
> **Storage & activation live in [storage.md](storage.md).** This document is the
> frozen **wire** spec (the bytes exchanged at runtime). The layer above it — a
> single JSON canonical on disk, the activator that derives this wire for the
> native backends, LSPosed reading the JSON directly (it does **not** consume this
> wire), and the APatch superkey — is specified there, and supersedes the
> storage-related statements in §1.3§1.4, §6, and §7.4 below. The wire format
> (§4§5) is unchanged.
---
## 1. Architecture
### 1.1 No hub — app is the orchestrator
There is no resident root daemon. The app (Kotlin) is the only orchestrator: it
serialises config snapshots into every channel via `su`, and reads stats back
the same way. Each backend reads *its own* channel independently. There is no
intermediary process between the app and the backends.
Rationale: the exchange is rare (per user action) and pull-based; a resident
root process would add a named, enumerable component on a device where node
visibility is deliberately minimised, and it would not remove the unavoidable
in-`system_server` Kotlin reader anyway (see §3). A hub buys nothing here and
costs stealth + lifecycle complexity.
### 1.2 State is per-backend; the protocol is shared
Two orthogonal axes, kept separate:
- **State is NOT unified.** Each backend owns its own channel/file. Backends do
not know about each other. One writer per file, atomic replace
(`tmp` + `rename`). This is what prevents races and torn reads, and keeps each
SELinux label scoped to a single reader domain. Merging state into one file
would mean multi-writer across three domains and one label that must satisfy
every reader — rejected.
- **The protocol IS unified.** One grammar, one parser core per language, one
codegen'd hook registry. Format is not a shared resource, so unifying it
creates no races. This is the *only* axis we unify.
**One kernel backend at a time.** The `.ko` and KPM are alternatives, not
co-residents: they wrap the *same* kernel functions
(`rtnl_fill_ifinfo`, `inet6_fill_ifaddr`, `fib_route_seq_show`, …), and running
both at once hard-froze a device (kretprobe + KernelPatch inline hook on one
symbol, deadlocking the netlink path on the first target enumeration — observed
on a Pixel 8 Pro, 6.1). So at init each kernel backend MUST detect the other and
refuse: the KPM resolves a `.ko` marker via kallsyms (the `.ko` is a real module;
the reverse uses the KPM's `/proc` marker), and a refusal is surfaced as
`status.error = conflicting_backend` (§4.3/§5.1) — a readable state, not a silent
no-op. This does not weaken §1.2's "backends don't know about each other" for
*state*: it is a one-bit liveness check at load, not shared runtime state.
### 1.3 Channels
Every channel carries the **same text snapshot format** (§4). They differ only
in transport and in which records they carry (profiles, §6).
| Channel | Write transport | Read transport | Reader runs in |
|---|---|---|---|
| app ↔ `.ko` | `echo > /proc/vpnhide_ctl` | `cat /proc/vpnhide_ctl` (seq_file) | kernel |
| app ↔ KPM | supercall `ctl0` (`args`) | supercall `ctl0` (`out_msg`) | kernel |
| app ↔ Zygisk | `targets.txt` via module dir-fd | n/a (stats via §7 if added) | Zygisk process (zygote-forked) |
| app ↔ LSPosed | `/data/system/vpnhide_*` files | same files | `system_server` |
Channel-specific notes:
- **Zygisk** reads through the root-privileged module-directory fd Zygisk hands
the module *before* app specialisation (`openat(dir_fd, "targets.txt", …)`),
which bypasses SELinux. Read on every app launch; edits picked up on next
force-stop + relaunch. No companion, no socket.
- **LSPosed** has no privileged endpoint with the app. The only shared medium is
the filesystem: app writes via `su`, the hook in `system_server` watches via
`FileObserver` (inotify) and reloads. Files are `root:system`, mode `0640`,
label `system_data_file``system_server` reads via group `system`,
`untrusted_app` falls to "other" and gets EACCES.
- **KPM** is the only true request/response channel (no file, no node) — see §5.
### 1.4 Where protocol code lives
No hub ⇒ the serialiser lives in the app (Kotlin). Three languages total, by
process:
| Language | Process | Role in protocol |
|---|---|---|
| C | kernel (`.ko` + KPM) | parse config; emit stats. Freestanding, `kmod/shared/vpnhide_logic.h`. |
| Rust | Zygisk process | parse its config profile (`targets.txt`). |
| Kotlin | app | **serialise** all config snapshots into every channel; **parse** stats readback. The "thick" end. |
| Kotlin | `system_server` (LSPosed hook) | parse its config profile from its file. Not removable — cannot inject a `.so` into `system_server`. |
The UI itself originates *data* (package→UID via PackageManager, per-app hook
selection) but the app is also the orchestrator, so wire serialisation lives in
Kotlin here. Parser parity across C / Rust / Kotlin is held by golden vectors +
differential testing (§8), not by shared code.
### 1.5 Backend selection: one Java layer + exactly one active native
Backends split into two layers that run *together*:
- **Java layer — LSPosed, always active.** It hooks Java APIs inside
`system_server` (PackageManager, ConnectivityManager, …) — a vantage no native
backend has. It is orthogonal to the native layer and is never "the one of N".
- **Native layer — exactly one of {`.ko` kmod, KPM, Zygisk} is active.** They
cover the same native surface (netlink / proc / libc), so running two is at
best redundant double-hiding and at worst fatal: the `.ko` (kretprobe) and KPM
(KernelPatch inline) wrap the *same* kernel functions and deadlocked a device
when both fired (§1.2). So the product runs one native backend, period.
**Selection is by fixed priority `kmod > KPM > Zygisk`,** among the backends
actually *loaded* (read from each backend's `status`, §4.3). The app:
1. reads `status` from every native backend that is present;
2. picks the highest-priority loaded one as **active**, writes it the real config
snapshot, and writes the others an **empty** snapshot (no targets ⇒ hooks
installed but idle);
3. if more than one native backend is loaded, **warns the user** (a second loaded
backend is a misconfiguration to clean up, not a supported mode).
Two backstops make this safe even if the app's policy is wrong or not yet run:
- **Kernel guard.** A native kernel backend refuses to *install hooks* if the
other kernel backend is already present (KPM checks for the `.ko` via kallsyms;
the `.ko` checks the KPM's `/proc` marker) and reports
`status.error = conflicting_backend` (§5.1). This prevents the deadlock at the
source, independent of userspace.
- **No self-activation of dangerous overlap.** Boot scripts may *load* a backend,
but the single-active decision is the app's; a loaded-but-unconfigured native
backend filters nothing.
This does not contradict §1.1 (no hub) or §1.2 (per-backend state): selection is
a read-only policy the app computes from `status`, not a shared runtime store.
---
## 2. Design constraints that shaped the format
These are *why* the format is what it is. Do not "simplify" against them.
- **One consumer is freestanding kernel C** (KPM, `-nostdinc`, no allocator, no
libc). This rules out protobuf/flatbuffers/serde runtimes and JSON-in-kernel.
Text with a hand-written parser is not a compromise — it is the only thing
that compiles everywhere.
- **KPM delivery is a CLI that takes a string and returns a string.** A binary
format would have to be hex/base64-wrapped through argv anyway. Text matches
the transport.
- **Debugging is by an LLM agent reading/writing raw** over `adb` (`cat`/`echo`).
The format must survive shell quoting (no `"`, `$`, backtick, `\` in payload)
and be self-describing enough to read without external context. This is why
keywords are words, not magic numbers.
- **Pull only.** Kernel aggregates per-(uid, hook) counters; userspace reads
periodically. No push, no per-hit events on the hot path.
---
## 3. Semantics
- **Snapshot, not deltas.** One write = the entire desired state. The reader
replaces its state wholesale. There is no add/remove/clear. This makes state a
pure function of the last write — it never depends on history, which removes
the whole class of "partially-applied" / "stale leftover" bugs. It also matches
`echo > node` (truncating redirect) and `FileObserver` ("file changed",
inotify gives no deltas).
- **Mandatory header gating.** A payload without a valid header (§4.2) is
rejected *whole* — a stray `echo 'debug 0' > node` does not silently wipe
state, it errors. "Valid full state, or a loud refusal, never silent-partial."
- **Version gates compatibility.** A reader knows only its own version. A payload
whose version is greater than known is rejected whole (not parsed). This turns
any drift that slips past tests into a loud refusal instead of a silent
misparse — exactly what is wanted across the 7-KMI matrix where
userspace↔kernel skew is inevitable.
---
## 4. Wire format
### 4.1 Lexical rules
- **Encoding:** ASCII only, bytes `0x20``0x7E` plus `\n`. Any other byte in a
line → reject that line.
- **Line separator:** `\n` only. On read, a trailing `\r` (CRLF) is stripped. On
write, never emit `\r`. A trailing `\n` at end of payload is optional on read
(but see §5 for its meaning under KPM truncation).
- **Whitespace:** one or more spaces/tabs between tokens is a single separator.
On write, emit exactly one space.
- **Comments:** a line whose first non-whitespace character is `#` is ignored
whole. **No inline comments** after data — keeps the parser from scanning for
`#` mid-line.
- **Blank lines:** ignored.
- **Line order:** not guaranteed; readers must not depend on it.
### 4.2 Header
The first significant (non-blank, non-comment) line MUST be:
```
vpnhide <version> <kind>
```
- `vpnhide` — literal magic.
- `<version>` — decimal integer (this line is parsed specially; the hex rule in
§4.4 does not apply to it). Current: `1`.
- `<kind>``config` (app → backend), `stats` (backend → app), or `status`
(backend → app: module health + errors, §4.3).
A payload without this header, or with `version` > the reader's known version,
is rejected whole (§3).
"Significant" is decided by **non-blank, non-comment alone** — a non-ASCII line
is significant. So if the first significant line is non-ASCII, it *is* the line
that MUST be the header; being an invalid header, it rejects the payload whole.
The §4.1 "reject that line" (skip-and-continue) rule applies to **records**,
once a valid header is established — it does **not** let the header search step
over a non-ASCII first significant line to find a header on a later line.
### 4.3 Records
**config** (`kind = config`):
```
debug <flag>
target <uid> <hookmask>
```
- `debug <flag>``flag` is the literal `0` or `1`. At most one `debug` line;
absent ⇒ unchanged-from-default (define default as `0`).
- `target <uid> <hookmask>` — one per target app. `uid` is the app UID;
`hookmask` is a hex bitset of enabled hooks (bit N ⇔ `hook_id` N, §5/registry).
Duplicate `uid` ⇒ last-wins (producer guarantees uniqueness; consumer defends).
**UID is the key for *every* backend**, including Zygisk: the kernel backends
have no PackageManager so they cannot key on package names, and Zygisk (which
runs in the target's own process and could match either way) keys on `getuid()`
for one grammar across all channels. The package→UID resolution is the
producer's job (app / boot script), the same for all backends.
**stats** (`kind = stats`):
```
<uid> <hook_id>:<count> <hook_id>:<count> ...
```
- One line per uid that has any non-zero counter. Sparse: only non-zero
`hook_id:count` pairs are emitted. `hook_id` and `count` are hex; `count` is a
`u64` **cumulative since the backend loaded** (OPEN-3) — reads never reset it,
so two readers don't race and the app computes deltas itself.
Asymmetry is deliberate: config uses a dense mask (a *set* over a small fixed
universe, applied in the kernel in O(1)); stats uses sparse `id:count` (most
counters are zero, and adding a hook shifts no columns — an old reader simply
skips an unknown `id`).
**status** (`kind = status`) — backend health, read the same way as stats:
```
backend <id>
kver <kver>
hooks <installed_mask>
error <code>
```
- `backend <id>` — which backend answered (registry ids: `0x0` .ko, `0x1` KPM,
`0x2` Zygisk, `0x3` LSPosed).
- `kver <kver>` — running kernel version as the backend sees it (KernelPatch
`kver`, e.g. `0x6019d`); `0x0` for non-kernel backends.
- `hooks <installed_mask>` — bitset of hooks that *actually* installed, in the
same id space as a config `hookmask`. Lets the app show "requested vs active".
- `error <code>``0x0` = healthy; non-zero is the single dominant fault code
(§5.1). At most one `error` line.
`status` exists because a kernel backend can refuse or only partially install for
reasons the app cannot otherwise see (no node, no logs unless `debug`). It turns
those into a readable state instead of a silent no-op — most importantly the
mutually-exclusive-backend case (§1.2): when the KPM detects the `.ko` (or vice
versa) it refuses and reports `error = conflicting_backend`, which the app
surfaces as "disable the other backend".
### 4.4 Numeric primitive
One primitive for every data field (`uid`, `hookmask`, `hook_id`, `count`):
```
0x followed by one or more hex digits, any case on read
```
- `0x` prefix is **mandatory** on data fields. (If it is ever dropped it must be
*forbidden*, never *optional* — an optional prefix = two valid spellings of one
number = a drift seam. See OPEN-1.)
- Read accepts any case (`0xFF` == `0xff` == `0xFf`); **write always emits
lowercase**. Liberal-in / strict-out: producers are deterministic, the consumer
never trips on case. (Pin this with a vector — it is a classic silent-drift
corner: C ASCII-folds, Rust forgets, they diverge on `0xC` vs `0xc`.)
- **Widths:** `uid`, `hookmask`, `hook_id` are `u32`; `count` (stats) is `u64`
(OPEN-3). A value that overflows its field's width → reject the line (not
saturate, not wrap).
The `debug` flag (`0`/`1`) and the header `version` are the only non-hex tokens,
and both are special-cased to their keyword/line.
### 4.5 Forward compatibility
- **Unknown first token** (keyword/record type) → skip that line, do not fail the
payload. Lets a newer producer add record types without breaking older readers.
- **Unknown `hook_id`** → the app keeps it as "unknown hook N" when reading
stats; the kernel masks unknown bits when applying a config mask
(`mask & known_hooks`). New hooks never misfire on an old backend.
- **Empty payload after the header** (zero records) is valid.
### 4.6 Example
config (app → kernel):
```
vpnhide 1 config
debug 0
target 0x27fa 0x3ff
target 0x2947 0x004
```
stats (kernel → app):
```
vpnhide 1 stats
0x27fa 0x0:0x5 0x3:0xc 0x9:0x1
0x2947 0x2:0x1
```
---
## 5. Hook registry (global ID space)
The shared vocabulary between config masks and stats IDs. **Global ID space:**
one `hook_id` = one specific hook in one backend; numbering is project-wide;
bit N in a mask ⇔ `hook_id` N in stats. Adding a hook to any backend takes the
next free global id.
This is the **only** thing that is codegen'd. The wire grammar/parser is
hand-written (trivial); the registry is data referenced from five places
(`.ko`, KPM, Zygisk, app, `system_server` reader + UI labels), so id drift would
turn "hooks for app X" into the wrong hooks on another backend. One TOML →
codegen emits:
- C `enum` + names (kernel labels its counters),
- Rust `enum` + the per-backend "which bits are mine" mask,
- Kotlin `enum` (per-app picker UI + the `system_server` reader).
Registry source row (illustrative):
```toml
[[hook]]
id = 0
name = "fib_route_seq_show"
backend = "kernel"
```
Each backend applies `mask & own_hooks` and ignores foreign bits. A config mask
is global; a backend only acts on its own bits.
Current kernel hooks (`.ko` / KPM, 11): `fib_route_seq_show`,
`ipv6_route_seq_show`, `rtnl_fill_ifinfo`, `inet_fill_ifaddr`,
`inet6_fill_ifaddr`, `dev_ioctl`, `sock_ioctl`, `fib_dump_info`, `rt6_fill_node`,
`fib_nl_fill_rule`, `socket_bind_interface`. Current LSPosed Java hooks (8):
`lsposed_link_properties`,
`lsposed_network_capabilities`, `lsposed_network_info`, `lsposed_network`,
`lsposed_connectivity_result`, `lsposed_connectivity_callback`,
`lsposed_connectivity_network`, `lsposed_package_visibility`. Current Zygisk
libc hooks (8): `zygisk_ioctl`, `zygisk_getifaddrs`, `zygisk_openat`,
`zygisk_recvmsg`, `zygisk_recv`, `zygisk_recvfrom`,
`zygisk_recvfrom_chk`, `zygisk_setsockopt`.
### 5.1 Error codes (`status`)
A small fixed enum, codegen'd from the same registry TOML as the hooks, so the
numbers are one source of truth across C / Rust / Kotlin (a status reader must
not invent its own codes). The `error` field of a `status` payload (§4.3) is one
of:
| code | name | meaning |
|---|---|---|
| `0x0` | `ok` | healthy; every requested, owned hook installed |
| `0x1` | `unsupported_kver` | no offset table for the running kernel — refused, no hooks |
| `0x2` | `conflicting_backend` | the *other* kernel backend (`.ko`↔KPM) is loaded — refused (§1.2) |
| `0x3` | `symbol_resolution_failed` | a required kallsyms symbol was missing — refused |
| `0x4` | `partial_hooks` | installed, but some owned hooks did not resolve (see the `hooks` mask) |
`error` is a single dominant code, not a bitset: a refusal is one reason, and
"healthy-but-partial" is the only non-fatal non-zero. The `hooks` mask carries
the per-hook detail when `error = partial_hooks`.
---
## 6. Profiles
One grammar, N profiles. A **profile** is the subset of records a channel
carries, by hook ownership — not by "how many features we shipped". Because
per-app hooks and stats come to **all** backends, every channel carries the full
grammar; there is no "bare" profile.
The parser is **profile-agnostic**: the §4.5 "unknown keyword → skip" rule means
one parser reads any channel and acts only on records it understands. The app is
the only writer of all profiles; each backend reads its own.
| Channel | config records it acts on | emits stats? | emits status? |
|---|---|---|---|
| `.ko` / KPM | `debug`, `target` (kernel-owned mask bits) | yes | yes (§4.3) |
| Zygisk | `debug`, `target` (zygisk-owned mask bits) | no, not yet (§7) | yes, via the app heartbeat |
| LSPosed | `debug`, `target` (lsposed-owned mask bits, incl. package visibility) | yes | yes |
A backend ignores `target` mask bits it does not own (`mask & own_hooks`), so the
same `target 0x27fa 0x3ff` line is valid on every channel and each backend takes
its slice.
**Active vs idle (§1.5).** The grammar is the same on every channel, but the app
only writes a *real* config to the LSPosed channel and the **one active** native
channel; the other native channels get an empty config (or none), so a
loaded-but-not-selected native backend parses nothing to act on. Selection is the
app's policy from `status`, not part of the wire format — a backend never knows
whether it is "the active one"; it just applies whatever config it is given (and
the empty config makes it idle).
---
## 7. KPM supercall binding
KPM has no file and no node. The channel is the KernelPatch supercall, already
wired as `KPM_CTL0(vpnhide_kpm_ctl0)` with signature:
```c
static long vpnhide_kpm_ctl0(const char *args, char *__user out_msg, int outlen);
```
The config parser is *already* shared with `.ko` (`apply_targets`
`vpnhide_parse_config` from `kmod/shared/`). KPM reuses the §4 format unchanged;
only the transport differs and `out_msg`/`outlen` (currently `(void)`) become the
read-back channel for `stats` and `status`.
**Confirmed on-device (OPEN-5):** the KPatch-Next `kpatch` CLI **does forward
`out_msg` to stdout** — a probe build that wrote a marker into `out_msg` and
returned its length printed the marker via `kpatch kpm ctl0 vpnhide stats`
(Pixel 8 Pro, KSU-Next, runtime `d05`). The `long` return is **not** printed by
the CLI (a target-arg call that left `out_msg` untouched produced no stdout).
So: **data flows out only through `out_msg`/`copy_to_user`; the return value is
reserved for short codes** (bytes written, or a negative kind/error code), never
for payload.
### 7.1 Dispatch
`ctl0` dispatches on the header `kind`:
```c
static long vpnhide_kpm_ctl0(const char *args, char *__user out_msg, int outlen)
{
if (kind_is_config(args)) { /* "vpnhide 1 config\n..." */
apply_snapshot(args); /* same shared parser as .ko */
return nr_target_uids; /* short code only (NOT surfaced as text) */
}
if (kind_is_stats(args) || kind_is_status(args)) {
char buf[OUT_MAX]; /* "vpnhide 1 stats" / "...status" */
int n = kind_is_stats(args) ? format_stats(buf, sizeof buf)
: format_status(buf, sizeof buf);
if (n > outlen)
n = clamp_to_line(buf, outlen); /* never mid-record */
if (_copy_to_user && out_msg)
_copy_to_user(out_msg, buf, n); /* the ONLY path data exits */
return n;
}
return -1;
}
```
### 7.2 The one KPM-specific rule: no pagination
`out_msg`/`outlen` is a single fixed buffer per call — unlike seq_file, which
paginates. Therefore:
1. The reader passes a generously large `outlen` (stats for tens of uids is a few
KiB).
2. The module truncates **only on a line boundary** (`clamp_to_line`), never
mid-record — otherwise the app parses a garbage tail.
3. Truncation is signalled by the **absence of a trailing `\n`**. The app, seeing
no trailing `\n`, knows the snapshot is incomplete and either re-reads with a
bigger buffer or accepts the partial. (This is exactly the §4.1 rule that a
trailing `\n` is optional on read but meaningful here.)
On write, the whole snapshot must fit in `args`; bound it by `MAX_TARGET_UIDS`
and have the parser truncate honestly at that ceiling (already the case for
`vpnhide_parse_config(..., MAX_TARGET_UIDS)`).
### 7.3 Delivery
Via the KernelPatch KPM ctl0 supercall. KPatch-Next exposes this through its
keyless runtime `kpatch` CLI subcommand **`kpm ctl0`**:
```
kpatch kpm ctl0 vpnhide "<payload>"
```
Write (multi-line payload as one argv argument):
```
kpatch kpm ctl0 vpnhide "vpnhide 1 config
debug 0
target 0x27fa 0x3ff
target 0x2947 0x004"
```
Read (response on stdout — `out_msg`, §7.1):
```
kpatch kpm ctl0 vpnhide "vpnhide 1 stats"
kpatch kpm ctl0 vpnhide "vpnhide 1 status"
```
**The userspace entry must match the running KernelPatch runtime.** The `.kpm`
module itself is cross-version (it loaded and ran on both `c02` and `d05`), but
the userspace ABI is **not** portable. The low 16-bit KPM command ids are the
same (`load=0x1020`, `ctl0=0x1022`, `list=0x1031`), and both runtimes currently
dispatch on those low bits, but the calling conventions differ:
- **APatch:** syscall number `45`, arg0 is the real SuperKey, command word uses
APatch's `0x1158` marker. APatch's public `apd` CLI manages APM ZIP modules
only; it does **not** expose `kpm load/ctl0`. APatch's own app controls KPMs
through native JNI `sc_kpm_*` calls, so the vpnhide activator does the same
directly. The activator probes `SUPERCALL_HELLO` before load/control, so a
missing KernelPatch runtime or stale SuperKey fails before a stray original
syscall can run.
- **KPatch-Next:** syscall number `45`, arg0 is `NULL`, and the kernel side gates
calls by root UID rather than a SuperKey. The activator issues no raw
command-word marker here — it drives KPatch-Next through the runtime's own
`kpatch kpm ...` CLI. With the standalone
KPatch-Next-Module that binary lives under
`/data/adb/modules/KPatch-Next/bin/kpatch`.
Do not ship one generic `kpatch` binary for both runtimes.
Caveats from CLI/shell delivery on KPatch-Next: shell-quote the payload (single
quotes; the format contains no `'`). APatch does not pass the key through an
external CLI argv; the activator calls the supercall from its own process.
### 7.4 Distribution & boot integration
The `.kpm` ships as a **thin flashable module** (Magisk/KSU format; APatch reads
it too). The module delivers the binary and a boot script; the actual KPM-load
mechanism is delegated to the activator and runtime. KPatch-Next is keyless;
APatch can also activate at boot when the user has opted into persisting the
SuperKey under `/data/adb/vpnhide/superkey` (root-only DE storage):
- **KPatch-Next (Magisk / KSU), `d05`, keyless — the recommended path.** The boot
script does everything itself: detect the runtime, enforce single-active (skip
KPM if the `.ko` is loaded, §1.5), `kpatch kpm load vpnhide.kpm`, then apply the
persisted config snapshot via `kpatch kpm ctl0`. Fully automatic, no user
interaction. This is what we recommend users run.
- **APatch, `c02`, SuperKey-required.** If the user enabled
`rememberSuperkey`, the app persists the key root-only at
`/data/adb/vpnhide/superkey`, and the boot activator loads/configures APatch
via direct supercalls after the `SUPERCALL_HELLO` probe. Without that saved
key, boot writes `awaiting_superkey`; activation resumes after the app supplies
the key.
This keeps KPatch-Next fully automatic, while APatch is automatic only when the
user explicitly stores the root-only SuperKey for boot.
---
## 8. Drift prevention (C ↔ Rust ↔ Kotlin parity)
Program equivalence is undecidable; "never diverge" is unachievable as a proof.
The achievable goal: make drift **loud** — caught in CI before merge with
probability empirically indistinguishable from never. Layered, each layer
catching what the previous misses.
- **Layer 0 — this spec.** "Compatible" is undefined without an arbiter. Every
ambiguous decision in §4 (0x policy, hex case, overflow, duplicate uid,
unknown id/keyword, truncation, trailing `\n`) is a pin point and MUST have a
vector.
- **Layer 1 — shared golden vectors.** One language-independent file of
`(input → expected parse)` and `(struct → expected string)`, both directions.
The C host test and the Rust/Kotlin tests run the *same* vectors against the
spec. Not codegen'd — the parser is not generated, so a runtime-read data file
(TSV / `|`-delimited, ten-line C harness) is enough; there is no generated code
to itself drift. Catches mutual bugs on covered cases; misses uncovered
corners.
- **Layer 2 — differential testing (the key layer).** Compare the two
implementations against *each other* over a large generated input space. The C
parser is already host-compilable (`__VPNHIDE_HOST_TEST`); link it via
`cc` + `bindgen` into the Rust test binary (**test only** — production stays
pure Rust, the freestanding constraint is untouched) and assert
`c_parse(s) == rust_parse(s)` and `c_serialize(x) == rust_serialize(x)` over
`proptest` inputs. Catches divergence on the long tail; misses *mutual* bugs
(both wrong identically) — which is why Layer 1 anchors correctness to the spec.
- **Layer 3 — fuzzing the diff oracle.** `cargo-fuzz` harness: raw bytes → both
parsers → assert they agree (both reject, or both accept with identical
structure). libFuzzer links freestanding C trivially. Input is trusted
(root-only channel), so this is not about hostile input — it is about "never
silently misparse" under pressure: NUL mid-string, overlong lines, leading
zeros, u32-overflow boundary, mid-line truncation, non-ASCII, duplicate uid.
The showcase case is KPM truncation: property
`parse(truncate(serialize(x), n))` is always a valid prefix-subset.
- **Layer 4 — version fuse.** `version` in the header turns any drift that slips
all tests into a loud refusal rather than a silent misparse (§3).
CI currently gates C host vectors, Rust/Kotlin vectors, the Rust↔C proptest
differential oracle, and the registry drift-check (TOML regenerated and
committed). Layer 3 remains the next hardening step; no fuzz-smoke gate is
claimed until a bounded PR corpus and scheduled long run are actually wired.
The spec is always the correctness oracle; the diff says "they diverged", the
spec says "who is wrong".
If the format ever grows real structure, the parity cost of two implementations
rises faster than the cost of a single C implementation linked into the hub via
`cc`+`bindgen` (one source, no second parser) — revisit then. For the current
flat text, two impls + this harness is the cheaper, catability-preserving choice.
---
## 9. Rejected alternatives (and why)
Recorded so they are not re-litigated.
- **JSON / protobuf / flatbuffers at the kernel boundary** — their runtimes do
not compile in freestanding KPM. (JSON-in-userspace-C, as a fork does via a
daemon, is internally consistent but adds a daemon; we have no hub.)
- **netlink / genetlink** — solves push; we are pull-only, and it is single-
backend (`.ko`, no KPM equivalent), so it does not unify anything. Reserved for
a future low-rate kernel→userspace *event* channel if one is ever needed.
- **Binary fixed-layout structs at the kernel boundary (ioctl)** — type-safe and
a clean single source via `bindgen`, but loses catability (no `cat`/`echo` for
the agent debugger), and `MAX_*` arrays bake ceilings into the ABI: a new field
shifts layout and forces a synchronous rebuild across 7 KMIs. Fragile to
evolution. (If binary is ever needed, prefer TLV over flat structs.)
- **Per-op commands / deltas** (`set`/`add`/`del`/`clear`) — friendlier to
`echo >>`, but reintroduces stateful, history-dependent application: an
interrupted batch leaves a half-applied state not derivable from `cat`. Snapshot
is a pure function of the last write. Rejected.
- **A resident root hub / daemon** — reconsidered explicitly (and after reviewing
`vpnhide_next`, whose daemon is a narrow netlink poller justified by an IP-spoof
feature we do not have; its real "hub" is the kmod's `/dev` misc device, which
couples everything to the `.ko` being loaded — incompatible with "any one of N
native backends"). Re-rejected: a resident named root process is enumerable
(`ps`/`/proc`), exactly the fingerprint this project minimises; it adds
keep-alive/SELinux/IPC surface; and it does not remove the irreducible
in-`system_server` Kotlin reader. Its one real win — *live* reaction to events
without the app open — is not a requirement (targets change rarely; boot +
app-open re-resolution suffice), and its other wins (single code path, root
syscalls, coordinated single-active) are had by the app-as-orchestrator + a
thin boot script + the kernel guard (§1.5) with no resident process. Revisit
only if a feature needs a live event loop. The narrow KPM coordination it would
buy is already covered by the kernel guard.
- **Unified single state file across backends** — multi-writer across three
SELinux domains, torn reads, one label for all readers. State stays
per-backend.
- **Routing LSPosed/Java state through the kmod via ioctl** (a fork's approach) —
centralises state but couples LSPosed to the kmod being loaded and needs
`system_server` sepolicy for the device node. We keep backends autonomous.
---
## 10. Open decisions (resolve before freezing version 1)
- **OPEN-1 — `0x` prefix. RESOLVED: mandatory on data fields.** It is a
visual/parse anchor at ~zero cost, and "mandatory" (not "optional") removes the
two-spellings-of-one-number drift seam.
- **OPEN-2 — agent self-description level. RESOLVED: positional-after-keyword.**
`target <uid> <hookmask>` — the keyword names the record, fields are positional.
`key=value` buys marginal readability for extra bytes and a second split; the
keyword already self-describes for an agent reading raw.
- **OPEN-3 — stats counter type. RESOLVED: `u64` cumulative-since-load.** Reads
are non-destructive (no reset-on-read race between two readers), it never
wraps in practice, and deltas are the app's job — which suits the pull model
(§2). `count` is therefore a `u64` hex value; kernel counters are per-CPU `u64`.
- **OPEN-4 — `.ko` stats node. RESOLVED: one folded node, renamed
`/proc/vpnhide_ctl`.** `.proc_write` takes the config snapshot, `.proc_read`
returns status+stats — one node, no new ones. Renamed from `vpnhide_targets`
for **semantic accuracy** (it is now a control channel, not just targets), NOT
for stealth: the node is `0600` root-only and the threat model is an
*unprivileged* app (root-level detection is explicitly out of scope — you cannot
defend a device whose owner gives root to a VPN-detector), so the name is never
observable by an adversary. The persistent `/data` files keep their names.
Debug folds in here too (OPEN-6), so `/proc/vpnhide_debug` goes away. (KPM has
no node either way — supercall read.)
- **OPEN-5 — KPM userspace transport. RESOLVED (on-device).** KPatch-Next
exposes `kpm ctl0 <name> <payload>` through its keyless `kpatch` CLI, and the
CLI **does forward `out_msg` to stdout** — so no extra root binary is needed
for read-back there. APatch does not expose `kpm load/ctl0` through `apd`; the
activator calls the runtime-specific supercalls directly with the saved
SuperKey. The `.kpm` is cross-version, but the userspace transport is not.
- **OPEN-6 — `debug` placement. RESOLVED: folded into the config snapshot.**
`debug <flag>` is the one global (non-`target`) config record (§4.3). Today
each backend has its own `debug_logging` file (and the `.ko` a
`/proc/vpnhide_debug` node); folding it into the config snapshot removes those
per-backend files/nodes (less enumerable surface), makes it atomic with the
rest of config, and needs one parse instead of a second channel.
- **OPEN-7 — self-documenting read. RESOLVED: yes, emit an in-band header.** The
read side prepends a one-line `#` comment (`# vpnhide v1 — WRITE REPLACES ENTIRE
STATE …`) before the current state. It is a comment line (ignored by parsers,
§4.1), so it costs nothing structurally and lets an agent learn the grammar +
the replace-whole semantics from a single `cat` (§2). All seven OPEN items are
now resolved; `version 1` is frozen.