Restructure README: quick start up top, tables for the trust/discovery sections, ToC
This commit is contained in:
@@ -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**<br>*(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**<br>*(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": "<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>" }
|
||||
```
|
||||
```jsonc
|
||||
// Request 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>"
|
||||
}
|
||||
```
|
||||
```jsonc
|
||||
// 200 response
|
||||
{ "serverId": "<uuid>", "token": "<base64 signed CacheToken>", "expiresAt": "<iso8601>" }
|
||||
```
|
||||
|
||||
```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": "<base64 signed CacheToken>" }
|
||||
or { "available": false }
|
||||
```
|
||||
*(no body — server reads the caller's own public IP from the connection)*
|
||||
```jsonc
|
||||
// 200 response
|
||||
{ "available": true, "token": "<base64 signed CacheToken>" }
|
||||
// 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: <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`.
|
||||
`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://<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
|
||||
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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user