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.
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:
-
Content integrity — already exists, unrelated to this project.
BgUpd'sSignatureVerifierchecks 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. -
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.
- Signed registration — the server generates an Ed25519 keypair on
first run, registers with a central authority (
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 asksoxmc.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; needsoxmc.mereachable. - Enterprise override (
/data/misc/pawletcache/policy.json, runtime) — avesperprofiledcontent-cachepayload can pinmode: lan/mode: central/mode: disabled, or pin an exact server + fingerprint, skipping discovery entirely. Seevesperprofiled-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) — seeandroid_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):
{
"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
cd pawletcache-server
npm install
Running
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