diff --git a/README.md b/README.md index 42dd098..d98f7f8 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,50 @@ # 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). +> Local network content cache for PawletOS device updates — the same idea as +> Apple's Content Caching service, scoped to what `BgUpd` fetches (component +> APKs/manifests: system apps, custom apps, webview providers). + +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. + 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. +## Contents + +- [Quick start](#quick-start) +- [Why trust a box some rando plugged into the LAN?](#why-trust-a-box-some-rando-plugged-into-the-lan) +- [Discovery](#discovery-per-deployment-policy) +- [Central registry API](#central-registry-api-implemented-on-oxmcme-not-in-this-repo) +- [Device-facing asset protocol](#device-facing-asset-protocol) +- [Package layout](#package-layout) + +--- + +## Quick start + +```bash +git clone https://git.oxmc.me/PawletOS/cache-server.git pawletcache-server +cd pawletcache-server +npm install + +sudo mkdir -p /etc/pawletcache +sudo cp config.example.yml /etc/pawletcache/config.yml +# edit enrollmentToken, hostname + +sudo node src/index.js +``` + +Requires Node ≥ 20 (uses global `fetch` and `Readable.fromWeb`). No build +step — CommonJS throughout. + +Testing locally without a real `oxmc.me` account? See +[`dev-registry/`](dev-registry/) and `scripts/smoke-test.sh` — a throwaway +stand-in registry plus an end-to-end script that exercises register → +token → cache miss/hit → SSRF rejection. --- @@ -18,80 +52,96 @@ of `oxmc.me` directly. 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. +| Layer | What it stops | How | +|---|---|---| +| **Content integrity**
*(already exists, unrelated to this project)* | A malicious cache serving tampered/wrong bytes | `BgUpd`'s `SignatureVerifier` checks the downloaded APK's signing certificate against the SHA-256 the manifest declared, regardless of which host served it. A malicious cache can serve garbage or nothing; it cannot get bad code installed. | +| **Server identity**
*(what this project adds)* | A malicious cache impersonating a legitimate one — DoS'ing updates, or passively fingerprinting which components/versions a device runs | Signed registration + TLS pinned to the attested fingerprint (below). | -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. +**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. -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`. +**TLS pinned to that fingerprint.** A device doesn't trust whatever's +speaking the cache protocol on port 8443 — it pins the connection to the +exact key fingerprint the signed token attests to. + +``` +central authority private key (never leaves oxmc.me) + │ signs + ▼ +cache token (pubkey + TLS SPKI fingerprint + expiry) + │ verified by device against + ▼ +pinned public key baked into PawletCacheService +``` --- -## Discovery (both, per deployment policy) +## Discovery (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`. +Both discovery paths run by default; either can be pinned or disabled via +policy (see the table below). -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. +| Method | Trigger | Reach | Notes | +|---|---|---|---| +| **mDNS/DNS-SD** | Always tried on LAN (fallback) | Same broadcast domain only | Server advertises `_pawletcache._tcp.local.` with the signed cache token in a TXT record. Zero WAN dependency. | +| **Central lookup** | Preferred by default | Any VLAN sharing one WAN egress | Server registers its *public* IP with `oxmc.me`. Device asks "is there a registered cache behind my public IP?" — the lookup uses the request's own source IP, the same trick Apple's content caching uses (nothing client-supplied to spoof). Needs `oxmc.me` reachable. | + +Policy is resolved from three tiers, **most specific wins**: + +1. **Runtime override** — `/data/misc/pawletcache/policy.json`. Written by + a `vesperprofiled` `content-cache` payload (MDM-pushed, can arrive any + time after first boot). Can pin `mode: lan` / `central` / `disabled`, or + pin an exact server + fingerprint, skipping discovery entirely. See + [`vesperprofiled-config-schema/schema/profile.schema.yml`](../vesperprofiled-config-schema/schema/profile.schema.yml). +2. **Vendor/OEM default** — `/vendor/etc/pawletcache/policy.json`. Same + file shape, read-only, set at build time. Lets a device builder ship a + standing default with no MDM enrollment needed. See + [`android_packages_apps_PawletCache/vendor-config/README.md`](../android_packages_apps_PawletCache/vendor-config/README.md). +3. **Compiled-in fallback** — central lookup preferred, mDNS as fallback if + central is unreachable or returns nothing. + +See `PolicyOverride.kt` for the exact tiering logic. --- ## 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: +This daemon is a client of two endpoints. They're out-of-tree (server +infra), documented here so both sides agree on the contract. -``` +```http POST https://oxmc.me/apis/aosp/cache/register - Body: { "hostname": "cache.local.lan", "port": 8443, - "pubkeyEd25519": "", "tlsSpkiSha256": "", - "signedAt": "", - "signature": "", - "enrollmentToken": "" } - -> 200 { "serverId": "", "token": "", - "expiresAt": "" } +``` +```jsonc +// Request body +{ + "hostname": "cache.local.lan", + "port": 8443, + "pubkeyEd25519": "", + "tlsSpkiSha256": "", + "signedAt": "", + "signature": "", + "enrollmentToken": "" +} +``` +```jsonc +// 200 response +{ "serverId": "", "token": "", "expiresAt": "" } +``` +```http 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 } +``` +*(no body — server reads the caller's own public IP from the connection)* +```jsonc +// 200 response +{ "available": true, "token": "" } +// or +{ "available": false } ``` `enrollmentToken` is how you keep randoms from registering a cache server @@ -100,7 +150,8 @@ against your `oxmc.me` account — issue one per deployment out of band. The 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): +### `CacheToken` — the signed payload (JSON before base64+signing) + ```json { "serverId": "uuid", @@ -113,37 +164,45 @@ to `serverId` on first registration and expects renewals signed by it). "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). + +- 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 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. +replaced with the real deployment key before shipping**). + +> For local development, see [`dev-registry/`](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: -**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`. +`registration.js`'s `canonicalize()`). + +Device-side verification: base64-decode → JSON-parse → re-canonicalize +`payload` → verify `signature` against the pinned root public key → check +`expiresAt`. --- @@ -157,15 +216,15 @@ 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 +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 happens 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. +server later just by pointing their own resolvers at it. -`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. +> **SSRF guard:** `url` is restricted to `config.yml`'s `allowedOrigins`. +> Without that check this would be an open proxy pivot for anything on the +> LAN that can reach port 8443. --- @@ -175,33 +234,16 @@ reach port 8443. pawletcache-server/ ├── package.json ├── config.example.yml +├── dev-registry/ Local stand-in for the oxmc.me registry — see its own README +├── scripts/ +│ ├── dummy-origin.js Throwaway HTTPS origin for the smoke test +│ └── smoke-test.sh End-to-end register → cache → asset-fetch check └── 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 + ├── 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 ```