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
```