# pawletcache-server Local network content cache for PawletOS device updates — same idea as Apple's Content Caching service, scoped for now to what `BgUpd` already fetches (component APKs/manifests: system apps, custom apps, webview providers). OTA system images and general media caching are explicitly out of scope for this daemon; they'd be a separate cache class behind the same client-side resolver (`PawletCacheService` on-device), not this binary. Runs on a box on your LAN (NAS, Raspberry Pi, home server) — not on the device. Devices discover it, verify it's a legitimately registered cache (not a rogue LAN box), and route `BgUpd` asset downloads through it instead of `oxmc.me` directly. --- ## Why trust a box some rando plugged into the LAN? Two independent layers, deliberately overlapping: 1. **Content integrity** — already exists, unrelated to this project. `BgUpd`'s `SignatureVerifier` checks the downloaded APK's signing certificate against the SHA-256 the manifest declared, regardless of which host served the bytes. A malicious cache can serve garbage or nothing; it cannot get bad code installed, silently or otherwise. 2. **Server identity** — what this project adds. Without it, a malicious cache could still (a) impersonate a cache server to DoS updates for a whole LAN, or (b) passively learn exactly which components/versions a device is checking, a fingerprinting/recon signal you don't want handed to an unauthenticated box. So: - **Signed registration** — the server generates an Ed25519 keypair on first run, registers with a central authority (`oxmc.me`), and gets back a short signed *cache token* binding its identity to a TLS certificate fingerprint. Devices verify that signature against a public key baked into the OS — no network round-trip required, so this also works for LAN-only/offline enterprise deployments. - **TLS to the pinned fingerprint** — the device don't just trust whatever's speaking the cache protocol on port 8443; it pins the connection to the exact key fingerprint the signed token attests to. Trust chain: `central authority private key` (never leaves `oxmc.me`) signs → `cache token` (server's pubkey + TLS SPKI fingerprint + expiry) → device verifies against the pinned public key baked into `PawletCacheService`. --- ## Discovery (both, per deployment policy) - **mDNS/DNS-SD** (default fallback, always tried on LAN): server advertises `_pawletcache._tcp.local.` with the signed cache token in a TXT record. Zero WAN dependency, same-broadcast-domain only. - **Central lookup** (default preference): server also registers its *public* IP with `oxmc.me`. Device asks `oxmc.me` "is there a registered cache behind my public IP" (the lookup uses the request's own source IP, same trick Apple's content caching uses — no client-supplied IP to spoof). Works across VLANs sharing one WAN egress; needs `oxmc.me` reachable. - **Enterprise override** (`/data/misc/pawletcache/policy.json`, runtime) — a `vesperprofiled` `content-cache` payload can pin `mode: lan` / `mode: central` / `mode: disabled`, or pin an exact server + fingerprint, skipping discovery entirely. See `vesperprofiled-config-schema/schema/profile.schema.yml`. - **Vendor/OEM default** (`/vendor/etc/pawletcache/policy.json`, build-time) — same file shape, one tier below the runtime override. Lets a device builder ship a standing default (no MDM enrollment needed) — see `android_packages_apps_PawletCache/vendor-config/README.md`. Device policy resolution, most specific wins: runtime override → vendor default → compiled-in fallback (central lookup preferred, mDNS as fallback if central is unreachable or returns nothing). See `PolicyOverride.kt` for the exact tiering. --- ## Central registry API (implemented on `oxmc.me`, not in this repo) This daemon is a client of these two endpoints. They're out-of-tree (server infra), documented here so both sides agree on the contract: ``` POST https://oxmc.me/apis/aosp/cache/register Body: { "hostname": "cache.local.lan", "port": 8443, "pubkeyEd25519": "", "tlsSpkiSha256": "", "signedAt": "", "signature": "", "enrollmentToken": "" } -> 200 { "serverId": "", "token": "", "expiresAt": "" } GET https://oxmc.me/apis/aosp/cache/lookup (no body — server reads the caller's own public IP from the connection) -> 200 { "available": true, "token": "" } or { "available": false } ``` `enrollmentToken` is how you keep randoms from registering a cache server against your `oxmc.me` account — issue one per deployment out of band. The `signature` proves the *same* server is renewing (its persisted Ed25519 key signs every registration/renewal; the central registry pins `pubkeyEd25519` to `serverId` on first registration and expects renewals signed by it). `CacheToken` (the signed payload, JSON before base64+signing): ```json { "serverId": "uuid", "pubkeyEd25519": "base64", "tlsSpkiSha256": "base64", "hostname": "cache.local.lan", "lanHost": "192.168.1.50", "port": "8443", "issuedAt": "2026-07-24T00:00:00Z", "expiresAt": "2026-08-23T00:00:00Z" } ``` Every field is a string, `port` included (kept a string so device-side canonicalization can treat every payload field uniformly — see `CacheTokenVerifier.kt`). `lanHost`/`port` are what the device actually connects to; deliberately *inside* the signed payload rather than sitting next to `token` in the lookup response, so nothing on the path between device and registry can redirect a device to a different host without invalidating the signature. `hostname` stays separate — it's the server's self-reported identity (matches what it advertises via mDNS), `lanHost` is what the central registry resolved/was told to hand back for *this* device's lookup (may differ once you're doing anything more than single-subnet matching). Signed with the central authority's Ed25519 private key (held only by `oxmc.me`). The corresponding public key is compiled into `PawletCacheService` (see `Constants.CACHE_TRUST_ROOT_PUBKEY` — **placeholder value, must be replaced with the real deployment key before shipping**). For local development, see `dev-registry/` — a throwaway stand-in registry you run yourself, generating its own root keypair, so you can test the whole register → discover → verify → cache flow without touching real `oxmc.me` infra or its signing key. **Wire format** — every `token` string (register response, lookup response, mDNS TXT record) is the same envelope: ``` base64( JSON.stringify({ payload: , signature: base64(...) }) ) ``` `signature` is the central authority's Ed25519 signature over the canonical JSON of `payload` alone (`JSON.stringify` with keys sorted, matching `registration.js`'s `canonicalize()`). Device-side verification: base64-decode → JSON-parse → re-canonicalize `payload` → verify `signature` against the pinned root public key → check `expiresAt`. --- ## Device-facing asset protocol Once a device trusts a cache server (mDNS or central lookup + signature verified), `BgUpd` rewrites its download from `oxmc.me` to the cache: ``` GET https://:/asset?url= ``` On a miss, the daemon downloads the full origin URL to disk first, then serves it (to the request that triggered the miss, and every request after) from disk — concurrent misses for the same URL collapse into one origin fetch. No parsing of `BgUpd`'s manifest format needed here — it's a dumb reverse-proxy cache keyed by URL, which is why OTA/media can reuse the same server later just by having their resolvers point at it too. `url` is restricted to `config.yml`'s `allowedOrigins` — without that check this would be an open SSRF/proxy pivot for anything on the LAN that can reach port 8443. --- ## Package layout ``` pawletcache-server/ ├── package.json ├── config.example.yml └── src/ ├── index.js Entry point, wires every subsystem together ├── config.js config.yml loading (js-yaml) ├── identity.js Ed25519 keypair + self-signed TLS keypair persistence ├── registration.js POST /register against oxmc.me, token refresh loop ├── mdns.js _pawletcache._tcp advertiser (bonjour-service) ├── cache.js Asset fetch-through cache (disk-backed) └── server.js HTTPS listener serving /asset ``` CommonJS throughout (`require`/`module.exports`), Node >= 20 (uses global `fetch` and `Readable.fromWeb`). ## Building ```bash cd pawletcache-server npm install ``` ## Running ```bash sudo mkdir -p /etc/pawletcache sudo cp config.example.yml /etc/pawletcache/config.yml # edit enrollmentToken, hostname sudo node src/index.js # or, after `npm link` / global install: sudo pawletcache-server ```