Files
cache-server/README.md
T
oxmc 685c14ba97 Initial commit: local network content cache for BgUpd component downloads
Node.js daemon: Ed25519 identity + self-signed TLS, signed registration/
renewal with the central registry, mDNS advertising, disk-backed
fetch-through asset cache with an origin allowlist. Includes a local
dev-registry stand-in for the real oxmc.me endpoints and an end-to-end
smoke test (register -> token -> cache miss/hit -> SSRF rejection).

See README.md for the full protocol and trust model.
2026-07-25 00:06:20 -07:00

208 lines
9.0 KiB
Markdown

# 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": "<base64 SPKI>", "tlsSpkiSha256": "<base64>",
"signedAt": "<iso8601>",
"signature": "<base64 Ed25519 sig over the 5 fields above,
canonicalized as JSON with sorted keys>",
"enrollmentToken": "<admin-issued>" }
-> 200 { "serverId": "<uuid>", "token": "<base64 signed CacheToken>",
"expiresAt": "<iso8601>" }
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": "<base64 signed CacheToken>" }
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: <CacheToken fields>, 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://<cache-host>:<port>/asset?url=<url-encoded original download_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
```