mirror of
https://github.com/okhsunrog/vpnhide.git
synced 2026-07-25 00:43:53 +00:00
448 lines
22 KiB
Markdown
448 lines
22 KiB
Markdown
# vpnhide — storage & activation architecture
|
||
|
||
How configuration is **stored**, who **reads** it, and how it is **activated** into
|
||
each backend. This is the layer *above* the wire protocol: [protocol.md](protocol.md)
|
||
defines the bytes exchanged with the kernel/native backends at runtime; this file
|
||
defines the single on-disk source of truth those bytes are derived from.
|
||
|
||
> **Status.** This is the current storage/activation design: one canonical JSON
|
||
> desired-state file, Rust activators that derive runtime state for native and
|
||
> ports backends, and LSPosed reading the JSON directly from `system_server`.
|
||
> Older per-backend text files are migration inputs or derived runtime state, not
|
||
> user-managed config.
|
||
|
||
---
|
||
|
||
## 1. Two layers, one boundary
|
||
|
||
Two formats, by function — not one format stretched over everything:
|
||
|
||
- **On disk = JSON.** A single canonical "desired state" file. Package-keyed (so it
|
||
survives reinstalls), rich (roles, per-hook selection, debug, app settings),
|
||
trivially import/export-able. Read by the app (Kotlin), the LSPosed hook
|
||
(Kotlin), and the Rust activators.
|
||
- **Runtime IPC = the text protocol** ([protocol.md](protocol.md), v1 frozen).
|
||
uid-keyed, hand-parsed, kernel-safe. The runtime *config* wire for the
|
||
**native** backends (kmod / KPM / Zygisk): config in, stats/status out. Its
|
||
text format is also reused for LSPosed's read-only state file (§3).
|
||
- **The bridge = the activator** (Rust). It projects the JSON onto the wire for
|
||
whichever native backend is installed.
|
||
|
||
Why split: the kernel consumer (KPM, freestanding C) cannot parse JSON (see
|
||
protocol.md §9), and the injected Zygisk `.so` should not carry a JSON parser into
|
||
every app process. But the *storage* layer has no such constraint, and JSON buys
|
||
one-file import/export and natural Kotlin/Rust structs. So JSON serves the high
|
||
level (storage + the Java hook, where `org.json` is free), the text protocol serves
|
||
the low level (kernel / injected native), and the activator translates between
|
||
them. The "agent reads/writes raw over adb" property (protocol.md §2) is preserved:
|
||
the live kernel state is still `cat /proc/vpnhide_ctl` text, and JSON is itself
|
||
readable text.
|
||
|
||
The number-keying difference is deliberate and bridged by the activator:
|
||
|
||
| Layer | Keyed by | Why |
|
||
|---|---|---|
|
||
| Canonical JSON | **package name** | survives reinstall (UID rotates, package doesn't) |
|
||
| Runtime protocol | **UID** | the kernel has no PackageManager |
|
||
|
||
---
|
||
|
||
## 2. The canonical config (the only file you manage)
|
||
|
||
- **Path:** `/data/system/vpnhide_config.json`
|
||
- **Perms / label:** `0640 root:system`, SELinux `system_data_file`.
|
||
- **Writer:** the app, via `su` (atomic `tmp`+`rename`).
|
||
- **Readers:** the app (`su`), the LSPosed hook (directly, from `system_server`),
|
||
the activator (root). **Not** readable by unprivileged apps or non-root `adb`
|
||
(see §7).
|
||
- **Holds:** schema version, the global `debug` flag (effective logging intent),
|
||
`debugSwitch` (user toggle intent), per-package roles + (optional) per-hook
|
||
selection, and app settings. It does **not** hold stats, status, or the APatch
|
||
superkey (those are runtime/secret — §5, §6).
|
||
- **Import / export = copy this one file.** It contains no device-specific secret,
|
||
so it is safe to back up / move / share. (This is *why* the superkey is **not**
|
||
stored here — §6.)
|
||
|
||
Shape (illustrative):
|
||
|
||
```json
|
||
{
|
||
"version": 1,
|
||
"debug": false,
|
||
"debugSwitch": false,
|
||
"apps": {
|
||
"com.example.bank": { "java": true, "native": true, "appHiding": false, "ports": false },
|
||
"org.example.proxy": {
|
||
"java": ["lsposed_network_capabilities"],
|
||
"native": true,
|
||
"appHiding": true,
|
||
"ports": true,
|
||
"portPolicy": {
|
||
"mode": "preset",
|
||
"preset": "common_proxy",
|
||
"rules": [
|
||
{ "protocol": "both", "start": 1080 },
|
||
{ "protocol": "both", "start": 7890, "end": 7892 }
|
||
]
|
||
}
|
||
}
|
||
},
|
||
"settings": {
|
||
"rememberSuperkey": false
|
||
}
|
||
}
|
||
```
|
||
|
||
`debugSwitch` is the user intent stored by the app toggle; `debug` is the
|
||
effective in-runtime value. On a capture path, `debug` may temporarily become
|
||
`true` while `debugSwitch` remains `false`. Startup reconciliation restores
|
||
`debug := debugSwitch` when they diverge.
|
||
|
||
Migration from pre-switch configs:
|
||
|
||
- If `debugSwitch` is missing, it is treated as `debug` while parsing so older
|
||
canonical JSONs keep working.
|
||
|
||
Per-hook granularity ("раздельное управление хуками"): the coarse `"java": true`
|
||
or `"native": true` is the common case (enable every hook in that role for the
|
||
app). Java per-hook control uses an explicit hook list, e.g.
|
||
`"java": ["lsposed_network_capabilities"]`. Native per-hook control is
|
||
backend-family-specific because `.ko`/KPM kernel hooks and Zygisk libc hooks are
|
||
different hook ids:
|
||
|
||
```json
|
||
"native": {
|
||
"enabled": true,
|
||
"kernel": ["fib_route_seq_show", "sock_ioctl"],
|
||
"zygisk": ["zygisk_ioctl", "zygisk_recvfrom_chk"]
|
||
}
|
||
```
|
||
|
||
The old `"native": ["fib_route_seq_show", "sock_ioctl"]` shape is still accepted
|
||
as a legacy **kernel** override. For any active native backend family whose list
|
||
is absent, the role means "all hooks for that family"; switching from Zygisk to
|
||
KPM preserves the Zygisk override but uses all kernel hooks until the user
|
||
configures kernel hooks explicitly, and vice versa. The native activator maps the
|
||
active family list to protocol `hookmask` bits. LSPosed reads the Java list
|
||
directly in `system_server`; app-hiding package visibility is still controlled
|
||
by `appHiding`, not by the Java VPN hook list. The JSON is the *desired*
|
||
selection; the native wire carries the resolved `hookmask`.
|
||
|
||
The `rememberSuperkey` boolean lives here (it is a preference, not a secret). The
|
||
superkey itself does **not** (§6).
|
||
|
||
Ports are intentionally controlled in the canonical JSON, not in the native text
|
||
protocol. `"ports": true` with no `portPolicy` is the legacy/default behavior:
|
||
the ports activator blocks all TCP/UDP connections from that app UID to
|
||
`127.0.0.1` and `::1`. When `portPolicy` is present, `rules` are the materialized
|
||
source of truth for import/export and activation; `mode`/`preset` are UI metadata.
|
||
Each rule uses `protocol: "both" | "tcp" | "udp"`, `start`, and optional `end`
|
||
(inclusive, `1..65535`). Presets may change in later app versions, but an exported
|
||
config remains stable because it carries the resolved `rules`.
|
||
|
||
---
|
||
|
||
## 3. Java layer (LSPosed) — independent, self-reading
|
||
|
||
The LSPosed backend is an **APK + a `system_server` hook**, not a Magisk module. Two
|
||
consequences drive the design:
|
||
|
||
1. **It has no `service.sh`** — nothing external should "populate" a file for it.
|
||
2. **It must work standalone.** The native modules are optional and
|
||
mutually-exclusive plug-ins; the Java layer must be fully functional with *zero*
|
||
native modules installed. So it cannot depend on a native module's boot script to
|
||
configure it.
|
||
|
||
The resolution: the **hook itself is the Java layer's boot mechanism** — LSPosed
|
||
loads it into `system_server` on every boot. So it reads the canonical config
|
||
**directly** and configures itself. No activator, no derived file.
|
||
|
||
How it reads, carefully (this runs in the bootloop-critical `system_server`):
|
||
|
||
- **Config in:** read `/data/system/vpnhide_config.json` directly (the hook is in
|
||
`system_server`, which *can* read `system_data_file`). `org.json` is built into
|
||
Android — no dependency. Re-read on `FileObserver` change. `debug` comes from the
|
||
same JSON (no separate debug file).
|
||
- **UID resolution without re-entry:** the hook matches the *caller* by UID
|
||
(`Binder.getCallingUid()`), so it needs the target packages' UIDs. It resolves
|
||
them via **`/data/system/packages.list`** (the stable AOSP `pkg uid …` map) —
|
||
**not** via `PackageManager`, because the hook *is* hooking PackageManager and a
|
||
PM call could re-enter its own hooks (recursion → deadlock → bootloop). Reading
|
||
`packages.list` is a plain file read, hook-free. The same self-read + `packages.list`
|
||
resolution also drives the **Apps** role (package visibility) — it just keys on the
|
||
observer UIDs and hidden packages instead of the target set, so both LSPosed jobs
|
||
share one hook-free resolver.
|
||
- **Multi-profile for free:** match `callingUid % 100000 ∈ targetAppIds`. Any
|
||
profile's instance of a target app matches, with no per-profile enumeration.
|
||
- **State out:** the hook writes `/data/system/vpnhide_lsposed_state`, a single
|
||
protocol-shaped readback file containing `vpnhide 1 status` plus LSPosed
|
||
metadata (`version`/`boot_id`/`broken_fields`) and `vpnhide 1 stats` counters.
|
||
Config-in and state-out are still different files / opposite directions:
|
||
`system_server` can't expose a `/proc`-style node, so it can't multiplex write
|
||
config + read state on one channel the way the kmod node does.
|
||
|
||
Older releases used a derived `vpnhide_uids.txt` file for this path. Current code
|
||
does not: the hook self-reads the canonical JSON and resolves UIDs in-process.
|
||
|
||
---
|
||
|
||
## 4. The activator and its backends (native + ports)
|
||
|
||
The native backends are **mutually exclusive** — exactly one is installed (the app
|
||
warns/errors and asks the user to remove the extra otherwise). So the activator
|
||
never writes "all three": it writes the **one** channel of the one installed native
|
||
backend.
|
||
|
||
### 4.1 The activator — a Rust workspace, thin bins
|
||
|
||
The projection is shared for all three native backends (take the `native`-role
|
||
packages → resolve to UIDs → emit a `vpnhide 1 config` snapshot), but the
|
||
resolved hookmask uses the active backend family: `.ko`/KPM consume `kernel`
|
||
overrides, while Zygisk consumes `zygisk` overrides. Only the **delivery sink**
|
||
differs after that. Ports use the same canonical parser and package→UID
|
||
resolver, but project `ports: true` roles into iptables rules instead of the text
|
||
wire. So: one shared core, thin per-target front-ends.
|
||
|
||
```
|
||
crates/
|
||
protocol/ # lib, NO serde — the wire (parse/format), shared by
|
||
# the Zygisk .so and the activator.
|
||
activator/
|
||
src/lib.rs # shared core: JSON schema (serde), pkg→uid resolution
|
||
# (`pm`), project_native(json) -> String
|
||
src/bin/kmod.rs # project_native(json) → write("/proc/vpnhide_ctl")
|
||
src/bin/kpm.rs # project_native(json) → APatch/FolkPatch supercall / KPatch-Next `kpatch`
|
||
src/bin/zygisk.rs # project_native(json) → write_atomic(module_dir file)
|
||
src/bin/ports.rs # project_ports(json) → iptables-restore/ip6tables-restore
|
||
zygisk/ # cdylib — the injected .so. deps: protocol (+ shadowhook).
|
||
# Lean: no serde, no JSON in every app process.
|
||
```
|
||
|
||
- **Why a workspace, not feature-flags or one crate with `src/bin/`+cdylib:**
|
||
`cdylib` and `bin` are different crate-types; feature-flags don't select artifact
|
||
type. A single crate that is *both* a cdylib and a bin risks pulling `serde` into
|
||
the injected `.so`. The workspace keeps the `.so`'s dependency tree lean (protocol
|
||
only) while the activator (a normal executable) freely uses `serde`. The
|
||
**activators are executables**, so they fit `src/bin/` perfectly over a shared
|
||
`src/lib.rs`.
|
||
- **Each native module ships only its own activator bin** (kmod module → `kmod`,
|
||
KPM module → `kpm`, Zygisk module → `zygisk`). Each does exactly its one channel,
|
||
no detection, no runtime branching.
|
||
- **The ports module ships `ports`**, which reads the same canonical JSON but applies
|
||
iptables state instead of writing the native text protocol.
|
||
- **When it runs:** at boot from that module's `service.sh` as
|
||
`activator --boot-wait`, and on Save from the app (`su <path>/activator`).
|
||
Boot mode waits indefinitely for PackageManager readiness; Save mode keeps a
|
||
bounded wait so the UI cannot hang forever. Then it reads the canonical,
|
||
resolves, and writes its channel.
|
||
|
||
### 4.2 The native channels
|
||
|
||
| Backend | Channel | Kind | Note |
|
||
|---|---|---|---|
|
||
| kmod | `/proc/vpnhide_ctl` | proc node | write = config (kernel parses to memory); read = status+stats |
|
||
| KPM | KPM ctl0 supercall | supercall | APatch/FolkPatch direct supercall or KPatch-Next runtime `kpatch kpm ctl0`; no file |
|
||
| Zygisk | file in its module dir | file | the `.so` reads it via the `get_module_dir()` fd |
|
||
|
||
Why Zygisk needs a file in its module dir even though there is one canonical: the
|
||
`.so`'s **only** privileged read handle per fork is the module-dir fd Zygisk hands
|
||
it (§7). This is a *mechanism* constraint, not a format one — the activator simply
|
||
derives the protocol config there. (Note: the protocol text — not JSON — keeps the
|
||
injected `.so` free of a JSON parser, and carries the per-hook `hookmask` + `debug`
|
||
+ the native stats the way a flat package list never could.)
|
||
|
||
---
|
||
|
||
## 5. Stats & status — and what "hooks" means
|
||
|
||
### 5.1 `config` hookmask vs `status` hooks — two different axes
|
||
|
||
A common confusion (they are *not* the same):
|
||
|
||
- **`config`: `target <uid> <hookmask>`** — per-UID, what you *want*: which hooks are
|
||
enabled for that app. This is the flexible per-target control.
|
||
- **`status`: `hooks <mask>`** — per-backend, what is *actually installed*: did all
|
||
the backend's hooks register this boot? It is capability/health, not per-target.
|
||
Lets the app show "requested vs active" and detect a partial install
|
||
(`error = partial_hooks`). So `hooks 0x20003ff` in a status read means "all 11 kernel
|
||
hooks are installed in this backend", **not** "this target's hooks".
|
||
|
||
### 5.2 Config and stats don't collide
|
||
|
||
They are **opposite directions of one channel**, distinguished by the `kind`
|
||
header — not interleaved in one stored blob. Write feeds config (into memory); read
|
||
emits status+stats (from separate counters). Example on the kmod node:
|
||
|
||
```sh
|
||
# write config (kind=config) — kernel parses into targets[]+debug, nothing echoed
|
||
# printf 'vpnhide 1 config\ndebug 0\ntarget 0x27fa 0x20003ff\n' > /proc/vpnhide_ctl
|
||
|
||
# read status+stats (kind=status, kind=stats) — never returns the config you wrote
|
||
# cat /proc/vpnhide_ctl
|
||
# vpnhide v1 — a WRITE replaces ENTIRE state; this read is status+stats
|
||
vpnhide 1 status
|
||
backend 0x0
|
||
kver 0x6019d
|
||
hooks 0x20003ff
|
||
error 0x0
|
||
vpnhide 1 stats
|
||
0x27fa 0x0:0x5 0x3:0xc 0x9:0x1
|
||
```
|
||
|
||
`ctl_write` touches `targets[]`; `ctl_show` serialises counters — different structs,
|
||
different syscalls, own locks. For file channels (Zygisk) the config-in file is
|
||
single-writer / atomic-replace, and stats are never written back into it.
|
||
|
||
### 5.3 Stats scope
|
||
|
||
- **kmod / KPM: yes** — counters live in one place (kernel memory), so a
|
||
pull-read is natural. They emit cumulative-since-load non-zero
|
||
`(uid, hook_id)` cells for hooks that actually hid or rewrote a result.
|
||
- **Zygisk: deferred.** Its counters would live **per app process** with no shared
|
||
aggregation point. A unix socket is out — it violates protocol.md §2 (pull-only,
|
||
no per-hit push) and needs a collector process (a rejected resident root daemon,
|
||
§1.1). Shared memory is the pull-compatible direction, but there is no clean way
|
||
to share *writable* memory across all injected app-domain processes without a
|
||
daemon (the module-dir file isn't writable by `untrusted_app` under SELinux; a
|
||
zygote-inherited memfd must be created in the zygote, where our code does not run).
|
||
So Zygisk consumes per-target hookmasks but does not emit per-hook stats yet;
|
||
stats remain a future problem, not a blocker for this design.
|
||
- **LSPosed: yes** — counters live in `system_server`, so the hook aggregates
|
||
cumulative-since-boot non-zero `(uid, hook_id)` cells and writes them into
|
||
`/data/system/vpnhide_lsposed_state` next to its `status` block.
|
||
|
||
---
|
||
|
||
## 6. APatch SuperKey
|
||
|
||
KernelPatch's control entry is a **syscall** (the supercall), and syscalls are not
|
||
UID-gated — *any* process could invoke it. The **superkey** is the capability token
|
||
that authenticates "may control the kernel patch". So it defends against the same
|
||
**unprivileged** adversary this project already cares about: without it, a non-root
|
||
app could `ctl0` our config or grant itself root. (KPatch-Next `d05` under
|
||
Magisk/KSU is **keyless**; this whole section is **APatch/FolkPatch-only**.)
|
||
|
||
- **Default:** session-only, re-prompt each session (APatch's own model).
|
||
- **Optional flag `rememberSuperkey`** (in the canonical `settings`): persist it so
|
||
it loads automatically — convenient for users who prefer it, and for development
|
||
(no manual entry each time).
|
||
- **When the flag is on**, the key is stored at:
|
||
- **Path:** `/data/adb/vpnhide/superkey`, `0600 root`.
|
||
- **Why there, not elsewhere:**
|
||
- **Not in the canonical JSON** — that is the export file; the key would leak on
|
||
export, and it is device-specific (useless on another device) anyway.
|
||
- **Not in CE app-private** (`/data/data/<pkg>`, credential-encrypted) — that is
|
||
locked until the user unlocks the device, so a **boot** script (which runs
|
||
*before* first unlock) can't read it.
|
||
- **`/data/adb` is DE-accessible** (device-encrypted, decrypted at boot — Magisk
|
||
reads modules from there pre-unlock), and root-only. So the boot activator
|
||
(root) **can** read it before unlock.
|
||
- **What it unlocks: APatch boot activation.** With the key on disk, the `kpm`
|
||
activator reads it at boot and uses APatch's KernelPatch supercall ABI directly
|
||
to load/configure the KPM — so APatch reaches **parity with keyless KPatch-Next**
|
||
(protection active after a reboot, no app open needed). Some FolkPatch/APatch
|
||
builds expose a trusted `su` token to already-authorized root callers; on those
|
||
builds the activator can use that token and no saved SuperKey is needed.
|
||
Without either token, boot records `awaiting_superkey`.
|
||
- **Plain file, not Keystore.** The boot activator is a Rust binary, not an Android
|
||
app, so it cannot call Android Keystore (a framework/binder API). Keystore-wrapping
|
||
could protect the *app-time* use only, not the boot path. So the boot-usable copy
|
||
is a plain file; FBE DE-encryption protects it at rest on a powered-off device.
|
||
- **Threat-model fit:** readable by **root only** (trusted in this project's model),
|
||
never by unprivileged apps or non-root `adb` (§7). The APatch/FolkPatch activator
|
||
calls the supercall directly, so the key is not passed through an external
|
||
`kpatch` argv.
|
||
|
||
---
|
||
|
||
## 7. SELinux & visibility — who can read what
|
||
|
||
Threat model: an **unprivileged** app; we do **not** hide from root. The whole layout
|
||
falls out of one fact — three reader contexts with different reach:
|
||
|
||
| Reader | Context | Can reach |
|
||
|---|---|---|
|
||
| app | `su` (root) | anything |
|
||
| activator / boot scripts | root | anything |
|
||
| LSPosed hook | `system_server` domain, **no root** | `system_data_file` only — **not** `/data/adb` |
|
||
| Zygisk `.so` | per-fork, module-dir **fd** only | only what that fd points at |
|
||
|
||
Consequences:
|
||
|
||
- **Canonical → `/data/system`** (`system_data_file`, `0640 root:system`): app(su) +
|
||
activator(root) + LSPosed(`system_server`) all read it; unprivileged apps and
|
||
non-root `adb` get EACCES. (The one place `system_server` can read.)
|
||
- **Superkey → `/data/adb`** (`0600 root`, DE): root-only and boot-readable.
|
||
- **Zygisk → a copy in its module dir**: because its only privileged handle is the
|
||
module-dir fd. A direct `open()` of the canonical from the zygote domain would need
|
||
a fragile `sepolicy.rule`; a companion would be a rejected daemon. So: a cheap
|
||
derived copy, written by the activator.
|
||
|
||
Visibility of the two sensitive files, concretely:
|
||
|
||
| Reader | `/data/system/vpnhide_config.json` (0640 root:system) | `/data/adb/vpnhide/superkey` (0600 root) |
|
||
|---|---|---|
|
||
| unprivileged app | no | no |
|
||
| root app | yes | yes |
|
||
| `adb shell` (no root) | **no** (DAC + SELinux) | **no** |
|
||
| `adb` with root (userdebug) | yes | yes |
|
||
| `system_server` | yes (group `system`) | no |
|
||
|
||
So `adb` without root reads **neither**; both are root-only beyond that — which is
|
||
exactly the threat model.
|
||
|
||
---
|
||
|
||
## 8. File inventory
|
||
|
||
What you actually manage shrinks to **one** file; everything else is generated or
|
||
optional.
|
||
|
||
| File | Role | Lifetime |
|
||
|---|---|---|
|
||
| `/data/system/vpnhide_config.json` | **the** canonical config (managed, exported) | persistent |
|
||
| `/proc/vpnhide_ctl` | kmod runtime channel (node, not a file) | per-boot, in-kernel |
|
||
| KPM ctl0 supercall | KPM runtime channel (supercall, no file) | per-boot, in-kernel |
|
||
| `/data/adb/modules/vpnhide_zygisk/<cfg>` | Zygisk runtime channel (derived copy) | regenerated |
|
||
| `/data/system/vpnhide_lsposed_state` | LSPosed status + stats (hook → dashboard) | per-boot |
|
||
| `/data/adb/vpnhide/superkey` | APatch superkey (optional, flag-gated) | persistent, root-only |
|
||
|
||
No longer user-managed config vs. the pre-redesign layout — kept only as migration
|
||
inputs (read when the canonical JSON is absent) and best-effort cleanup until
|
||
removed (see §9):
|
||
|
||
- the four per-backend `targets.txt` lists → folded into the **one** canonical;
|
||
- `vpnhide_uids.txt` / `vpnhide_hidden_pkgs.txt` / `vpnhide_observer_uids.txt` →
|
||
**no longer user-managed** (LSPosed reads the canonical + `packages.list` and
|
||
derives them in-process).
|
||
|
||
---
|
||
|
||
## 9. Migration
|
||
|
||
On first run after the upgrade, if `/data/system/vpnhide_config.json` is absent, the
|
||
app builds it **once** from whatever old per-backend text files exist (package
|
||
lists, observers, hidden, debug flag), writes the canonical, then runs the
|
||
activator. After that the old files are unused; app startup removes the retired
|
||
legacy inputs best-effort once canonical JSON exists. The canonical is the single
|
||
source from then on. Keep the legacy read paths for a few public releases as a
|
||
migration shim, then remove them.
|
||
|
||
---
|
||
|
||
## 10. Relationship to [protocol.md](protocol.md)
|
||
|
||
protocol.md remains the frozen **v1 wire** specification, and is still the runtime
|
||
IPC for the native backends. This document supersedes its statements about the
|
||
*storage/activation layer*, specifically:
|
||
|
||
- **LSPosed does not consume the wire** — it reads the canonical JSON directly
|
||
(§3). protocol.md's "LSPosed parses its config profile from its file" / the
|
||
LSPosed rows in its channel + profile tables are superseded here.
|
||
- **APatch/FolkPatch boot is configurable** when the superkey is persisted (§6)
|
||
or the runtime grants the trusted `su` token; without either, boot records
|
||
`awaiting_superkey` and activation resumes after the app supplies the key.
|
||
- **The app does not hand-build per-channel configs** — the activator does
|
||
(§4). The serialiser is the Rust `protocol` crate, shared with the Zygisk `.so`.
|
||
|
||
The wire format itself (protocol.md §4–§5) is unchanged.
|