commit 685c14ba973d281c9c0b5d59abe147a4d704d1aa Author: oxmc7769 Date: Sat Jul 25 00:06:20 2026 -0700 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b04a236 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dev-registry/data/ +.smoke-test-tmp-*/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..42dd098 --- /dev/null +++ b/README.md @@ -0,0 +1,207 @@ +# 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": "", "tlsSpkiSha256": "", + "signedAt": "", + "signature": "", + "enrollmentToken": "" } + -> 200 { "serverId": "", "token": "", + "expiresAt": "" } + +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 } +``` + +`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: , 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://:/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 +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 +``` diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..107f88f --- /dev/null +++ b/config.example.yml @@ -0,0 +1,14 @@ +# Copy to /etc/pawletcache/config.yml and fill in enrollmentToken + hostname. + +hostname: cache.local.lan # must resolve/be reachable on the LAN +listenAddr: "0.0.0.0" +listenPort: 8443 + +storageDir: /var/lib/pawletcache/assets +identityDir: /var/lib/pawletcache/identity + +centralUrl: https://oxmc.me +enrollmentToken: "REPLACE_ME" # issued out-of-band per deployment + +allowedOrigins: + - oxmc.me diff --git a/dev-registry/README.md b/dev-registry/README.md new file mode 100644 index 0000000..35b699d --- /dev/null +++ b/dev-registry/README.md @@ -0,0 +1,59 @@ +# pawletcache-dev-registry + +Throwaway local stand-in for the `oxmc.me` `/apis/aosp/cache/register` and +`/apis/aosp/cache/lookup` endpoints documented in `../README.md`. Exists so +`pawletcache-server` (and, separately, the Android `CentralLookupClient`) can +be exercised end-to-end without touching real `oxmc.me` infra or its actual +signing key. + +**Not production code.** Two shortcuts that would be wrong on the real +`oxmc.me`: +- `lookup()` hands back whichever server registered most recently, full + stop — no public-IP/NAT matching, because on localhost there's no NAT to + match. The real registry has to match the *caller's* public IP against a + registered server's public IP (see main README's Apple-content-caching + comparison). +- Zero persistence hardening — `data/registrations.json` is a flat JSON + file, fine for a laptop, not for anything real. + +Everything about the actual wire protocol (request/response shape, what's +signed, the CacheToken format) is the real thing, though — this is a +faithful implementation of the spec, just with a self-issued root key and a +dumb `lookup()`. + +## Running + +```bash +cd dev-registry +DEV_ENROLLMENT_TOKENS=dev-local-token node src/index.js +``` + +First run generates a root Ed25519 keypair under `data/identity/` and prints +its public half — paste that into +`android_packages_apps_PawletCache/src/os/pawlet/cache/Constants.kt`'s +`CACHE_TRUST_ROOT_PUBKEY_BASE64` if you want a real device/emulator to trust +tokens this registry issues. + +Env vars: +| Var | Default | | +|---|---|---| +| `DEV_REGISTRY_PORT` | `4000` | | +| `DEV_REGISTRY_DATA_DIR` | `./data` | | +| `DEV_ENROLLMENT_TOKENS` | `dev-local-token` | comma-separated allowlist | +| `DEV_TOKEN_TTL_MS` | `600000` (10 min) | short by default so you can actually see `pawletcache-server`'s renewal loop fire during a normal test session | + +## Pointing pawletcache-server at it + +In `../config.yml`: +```yaml +centralUrl: http://localhost:4000 +enrollmentToken: dev-local-token +``` +(Plain `http://` — this dev registry doesn't bother with TLS; the real +`oxmc.me` obviously would. `pawletcache-server`'s own asset-serving HTTPS +endpoint is unaffected either way, that's a separate TLS cert.) + +## See also + +`../scripts/smoke-test.sh` — drives this registry + `pawletcache-server` +together and checks the whole register → token → cache flow with curl. diff --git a/dev-registry/package.json b/dev-registry/package.json new file mode 100644 index 0000000..f1a44b0 --- /dev/null +++ b/dev-registry/package.json @@ -0,0 +1,14 @@ +{ + "name": "pawletcache-dev-registry", + "version": "1.0.0", + "description": "Throwaway local stand-in for oxmc.me's cache registry API — for testing pawletcache-server end-to-end without touching real infra.", + "license": "Apache-2.0", + "type": "commonjs", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js" + }, + "engines": { + "node": ">=20" + } +} diff --git a/dev-registry/src/db.js b/dev-registry/src/db.js new file mode 100644 index 0000000..de95ac0 --- /dev/null +++ b/dev-registry/src/db.js @@ -0,0 +1,33 @@ +const { existsSync, readFileSync, writeFileSync, mkdirSync } = require("node:fs"); +const { join, dirname } = require("node:path"); + +/** + * Tiny JSON-file "database" — one row per hostname. Good enough for local + * testing; a real oxmc.me implementation would use an actual database and + * proper public-IP/NAT matching for lookup() instead of "most recent wins". + */ +class Db { + constructor(path) { + this.path = path; + mkdirSync(dirname(path), { recursive: true }); + this.rows = existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {}; + } + + get(hostname) { + return this.rows[hostname] ?? null; + } + + put(hostname, row) { + this.rows[hostname] = row; + writeFileSync(this.path, JSON.stringify(this.rows, null, 2)); + } + + /** Dev-only stand-in for "which server is on my public IP" — see README.md. */ + mostRecentlySeen() { + const all = Object.values(this.rows); + if (all.length === 0) return null; + return all.reduce((a, b) => (a.lastSeenAt > b.lastSeenAt ? a : b)); + } +} + +module.exports = { Db }; diff --git a/dev-registry/src/index.js b/dev-registry/src/index.js new file mode 100644 index 0000000..777d08f --- /dev/null +++ b/dev-registry/src/index.js @@ -0,0 +1,157 @@ +#!/usr/bin/env node +const { createServer } = require("node:http"); +const { randomUUID, verify: edVerify, createPublicKey } = require("node:crypto"); +const { join } = require("node:path"); +const { Db } = require("./db.js"); +const { loadOrCreateRootKey, signCacheToken, canonicalize, pemToBase64Spki } = require("./signing.js"); + +const PORT = Number(process.env.DEV_REGISTRY_PORT || 4000); +const DATA_DIR = process.env.DEV_REGISTRY_DATA_DIR || join(__dirname, "..", "data"); +const ENROLLMENT_TOKENS = new Set( + (process.env.DEV_ENROLLMENT_TOKENS || "dev-local-token").split(",").map((s) => s.trim()), +); +const TOKEN_TTL_MS = Number(process.env.DEV_TOKEN_TTL_MS || 10 * 60_000); // short by default, see README + +const root = loadOrCreateRootKey(join(DATA_DIR, "identity")); +const db = new Db(join(DATA_DIR, "registrations.json")); + +function readJsonBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + resolve(body ? JSON.parse(body) : {}); + } catch (err) { + reject(err); + } + }); + req.on("error", reject); + }); +} + +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(body) }); + res.end(body); +} + +function verifyRequestSignature(payload, pubkeyEd25519Base64, signatureBase64) { + try { + const publicKey = createPublicKey({ + key: Buffer.from(pubkeyEd25519Base64, "base64"), + format: "der", + type: "spki", + }); + return edVerify(null, Buffer.from(canonicalize(payload)), publicKey, Buffer.from(signatureBase64, "base64")); + } catch { + return false; + } +} + +async function handleRegister(req, res) { + let body; + try { + body = await readJsonBody(req); + } catch { + return sendJson(res, 400, { error: "malformed JSON body" }); + } + + const { hostname, port, pubkeyEd25519, tlsSpkiSha256, signedAt, signature, enrollmentToken } = body; + if (!hostname || !port || !pubkeyEd25519 || !tlsSpkiSha256 || !signedAt || !signature) { + return sendJson(res, 400, { error: "missing required field" }); + } + if (!ENROLLMENT_TOKENS.has(enrollmentToken)) { + return sendJson(res, 403, { error: "invalid enrollmentToken" }); + } + + const signedPayload = { hostname, port, pubkeyEd25519, tlsSpkiSha256, signedAt }; + if (!verifyRequestSignature(signedPayload, pubkeyEd25519, signature)) { + return sendJson(res, 403, { error: "registration request signature invalid" }); + } + + // Continuity check: if this hostname registered before, the same Ed25519 + // key must be renewing it — a valid enrollmentToken alone isn't enough to + // take over an already-registered hostname with a different key. + const existing = db.get(hostname); + if (existing && existing.pubkeyEd25519 !== pubkeyEd25519) { + return sendJson(res, 409, { error: `hostname ${hostname} already registered with a different key` }); + } + + const serverId = existing?.serverId ?? randomUUID(); + const now = Date.now(); + const remoteAddress = req.socket.remoteAddress?.replace(/^::ffff:/, "") ?? "127.0.0.1"; + + const tokenPayload = { + serverId, + pubkeyEd25519, + tlsSpkiSha256, + hostname, + // Dev shortcut: trust the caller's own claimed LAN address if it looks + // like one, else fall back to the socket's peer address. Real oxmc.me + // would resolve this properly (VPN/relay-aware, etc). + lanHost: body.lanHost || remoteAddress, + port: String(port), + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + TOKEN_TTL_MS).toISOString(), + }; + const token = signCacheToken(tokenPayload, root.privateKeyPem); + + db.put(hostname, { + serverId, + hostname, + port, + pubkeyEd25519, + tlsSpkiSha256, + lastSeenAt: now, + }); + + console.log(`[register] ${hostname} -> serverId=${serverId}, expires ${tokenPayload.expiresAt}`); + sendJson(res, 200, { serverId, token, expiresAt: tokenPayload.expiresAt }); +} + +async function handleLookup(req, res) { + // Dev shortcut: real oxmc.me would match the caller's public IP against a + // registered cache server's public IP. Locally everything's on loopback, + // so this just hands back whichever server registered most recently — + // fine for exercising the protocol, not a NAT-matching implementation. + const row = db.mostRecentlySeen(); + if (!row) return sendJson(res, 200, { available: false }); + + const now = Date.now(); + const tokenPayload = { + serverId: row.serverId, + pubkeyEd25519: row.pubkeyEd25519, + tlsSpkiSha256: row.tlsSpkiSha256, + hostname: row.hostname, + lanHost: row.hostname, + port: String(row.port || 8443), + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + TOKEN_TTL_MS).toISOString(), + }; + const token = signCacheToken(tokenPayload, root.privateKeyPem); + sendJson(res, 200, { available: true, token }); +} + +const server = createServer((req, res) => { + const url = new URL(req.url, "http://placeholder"); + if (req.method === "POST" && url.pathname === "/apis/aosp/cache/register") { + return void handleRegister(req, res).catch((err) => sendJson(res, 500, { error: err.message })); + } + if (req.method === "GET" && url.pathname === "/apis/aosp/cache/lookup") { + return void handleLookup(req, res).catch((err) => sendJson(res, 500, { error: err.message })); + } + if (req.method === "GET" && url.pathname === "/health") { + return sendJson(res, 200, { ok: true }); + } + sendJson(res, 404, { error: "not found" }); +}); + +server.listen(PORT, () => { + console.log(`pawletcache-dev-registry listening on http://localhost:${PORT}`); + console.log(`Enrollment token(s): ${[...ENROLLMENT_TOKENS].join(", ")}`); + console.log(`Token TTL: ${TOKEN_TTL_MS}ms`); + console.log(""); + console.log("Root public key (paste into Constants.CACHE_TRUST_ROOT_PUBKEY_BASE64 for local device testing):"); + console.log(pemToBase64Spki(root.publicKeyPem)); +}); diff --git a/dev-registry/src/signing.js b/dev-registry/src/signing.js new file mode 100644 index 0000000..b01a770 --- /dev/null +++ b/dev-registry/src/signing.js @@ -0,0 +1,55 @@ +const { generateKeyPairSync, sign: edSign, createPrivateKey } = require("node:crypto"); +const { mkdirSync, existsSync, readFileSync, writeFileSync } = require("node:fs"); +const { join } = require("node:path"); + +/** + * The dev registry's own root Ed25519 identity — stands in for the real + * oxmc.me signing key. Generated once, persisted, printed at startup so you + * can paste the public half into Constants.CACHE_TRUST_ROOT_PUBKEY_BASE64 + * for local device/emulator testing. The private half never leaves this + * process — same shape as the real deployment, just self-issued. + */ +function loadOrCreateRootKey(dir) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const pubPath = join(dir, "root_ed25519.pub.pem"); + const privPath = join(dir, "root_ed25519.priv.pem"); + + if (existsSync(pubPath) && existsSync(privPath)) { + return { + publicKeyPem: readFileSync(pubPath, "utf8"), + privateKeyPem: readFileSync(privPath, "utf8"), + }; + } + + const { publicKey, privateKey } = generateKeyPairSync("ed25519", { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + writeFileSync(pubPath, publicKey, { mode: 0o644 }); + writeFileSync(privPath, privateKey, { mode: 0o600 }); + return { publicKeyPem: publicKey, privateKeyPem: privateKey }; +} + +/** Deterministic key order — must match CacheTokenVerifier.kt's canonicalize(). */ +function canonicalize(obj) { + return JSON.stringify(obj, Object.keys(obj).sort()); +} + +/** + * Signs a CacheToken payload and wraps it in the wire envelope: + * base64( JSON.stringify({ payload, signature }) ). + */ +function signCacheToken(payload, rootPrivateKeyPem) { + const signature = edSign(null, Buffer.from(canonicalize(payload)), createPrivateKey(rootPrivateKeyPem)); + const envelope = { payload, signature: signature.toString("base64") }; + return Buffer.from(JSON.stringify(envelope), "utf8").toString("base64"); +} + +function pemToBase64Spki(pem) { + return pem + .replace(/-----BEGIN PUBLIC KEY-----/, "") + .replace(/-----END PUBLIC KEY-----/, "") + .replace(/\s+/g, ""); +} + +module.exports = { loadOrCreateRootKey, signCacheToken, canonicalize, pemToBase64Spki }; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f15d6b6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,151 @@ +{ + "name": "pawletcache-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pawletcache-server", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "bonjour-service": "^1.2.1", + "js-yaml": "^4.1.0", + "selfsigned": "^2.4.1" + }, + "bin": { + "pawletcache-server": "src/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/node-forge": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", + "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/bonjour-service": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.4.3.tgz", + "integrity": "sha512-2Kd5UYlFUVgAKMTyuBLl6w49wqfOnbxHqmuH0oCl/n7TfAikR0zoowNOP5BU4dfXmm+Vr9JyEN370auSMx+CNg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/selfsigned": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", + "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "license": "MIT", + "dependencies": { + "@types/node-forge": "^1.3.0", + "node-forge": "^1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..37d194f --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "pawletcache-server", + "version": "1.0.0", + "description": "Local network content cache for PawletOS device updates (BgUpd component assets).", + "license": "Apache-2.0", + "type": "commonjs", + "main": "src/index.js", + "bin": { + "pawletcache-server": "src/index.js" + }, + "scripts": { + "start": "node src/index.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "bonjour-service": "^1.2.1", + "js-yaml": "^4.1.0", + "selfsigned": "^2.4.1" + } +} diff --git a/scripts/dummy-origin.js b/scripts/dummy-origin.js new file mode 100644 index 0000000..3eabb8c --- /dev/null +++ b/scripts/dummy-origin.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// Throwaway HTTPS origin server for scripts/smoke-test.sh — cache.js only +// proxies https:// origins, so the smoke test needs *something* self-signed +// to fetch-through, not a real oxmc.me dependency. Not used outside the +// smoke test. +const { createServer } = require("node:https"); +const selfsigned = require("selfsigned"); + +const port = Number(process.argv[2] || 9443); +const body = Buffer.from("hello from dummy origin\n"); + +const pems = selfsigned.generate([{ name: "commonName", value: "localhost" }], { days: 1 }); + +createServer({ cert: pems.cert, key: pems.private }, (req, res) => { + res.writeHead(200, { "content-type": "text/plain", "content-length": body.length }); + res.end(body); +}).listen(port, () => { + console.log(`dummy-origin listening on https://localhost:${port}`); +}); diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh new file mode 100644 index 0000000..694350e --- /dev/null +++ b/scripts/smoke-test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# End-to-end local smoke test: dev-registry + pawletcache-server + a throwaway +# HTTPS origin, wired together, verifying register -> signed token -> asset +# fetch-through -> disk cache actually works. No real oxmc.me dependency. +# +# Requires: `npm install` already run in pawletcache-server/ (bonjour-service, +# js-yaml, selfsigned). dev-registry/ has zero deps, nothing to install there. +set -euo pipefail +cd "$(dirname "$0")/.." # pawletcache-server/ + +ORIGIN_PORT=9443 +REGISTRY_PORT=4100 +CACHE_PORT=8543 +ENROLLMENT_TOKEN=smoke-test-token +# Path handling here is fussy on Windows+Git-Bash: native Windows node.exe +# doesn't understand MSYS-style POSIX paths at all (not just /tmp — `pwd`'s +# /c/Users/... gets misread as literal drive-root segments, e.g. resolves to +# C:\c\Users\...). `pwd -W` gives the Windows-style form (C:/Users/...), +# which both node.exe and Bash/`ls` resolve to the same real location — use +# that for anything written into a file Node will read a path back out of. +# (On real POSIX systems `pwd -W` doesn't exist; fall back to plain pwd.) +WORKDIR="$(pwd -W 2>/dev/null || pwd)/.smoke-test-tmp-$$" +mkdir -p "$WORKDIR" + +PIDS=() +cleanup() { + for pid in "${PIDS[@]:-}"; do kill "$pid" 2>/dev/null || true; done + rm -rf "$WORKDIR" +} +trap cleanup EXIT + +pass() { echo "PASS: $1"; } +fail() { echo "FAIL: $1"; cleanup; exit 1; } + +wait_for() { + local url=$1 tries=${2:-30} + for _ in $(seq 1 "$tries"); do + curl -sk -o /dev/null "$url" && return 0 + sleep 0.5 + done + return 1 +} + +echo "== starting dummy HTTPS origin on :$ORIGIN_PORT ==" +node scripts/dummy-origin.js "$ORIGIN_PORT" > "$WORKDIR/origin.log" 2>&1 & +PIDS+=($!) +wait_for "https://localhost:$ORIGIN_PORT/" || fail "dummy origin never came up" + +echo "== starting dev-registry on :$REGISTRY_PORT ==" +DEV_REGISTRY_PORT=$REGISTRY_PORT \ +DEV_REGISTRY_DATA_DIR="$WORKDIR/registry-data" \ +DEV_ENROLLMENT_TOKENS=$ENROLLMENT_TOKEN \ +DEV_TOKEN_TTL_MS=600000 \ + node dev-registry/src/index.js > "$WORKDIR/registry.log" 2>&1 & +PIDS+=($!) +wait_for "http://localhost:$REGISTRY_PORT/health" || fail "dev-registry never came up" +pass "dev-registry up" + +echo "== writing pawletcache-server config ==" +cat > "$WORKDIR/config.yml" < "$WORKDIR/cache-server.log" 2>&1 & +PIDS+=($!) +wait_for "https://localhost:$CACHE_PORT/asset" || fail "pawletcache-server never came up" + +echo "== waiting for registration to land in dev-registry ==" +registered=false +for _ in $(seq 1 20); do + lookup=$(curl -s "http://localhost:$REGISTRY_PORT/apis/aosp/cache/lookup") + if echo "$lookup" | grep -q '"available":true'; then + registered=true + break + fi + sleep 0.5 +done +[ "$registered" = true ] || fail "pawletcache-server never registered with dev-registry (see $WORKDIR/cache-server.log, $WORKDIR/registry.log)" +pass "pawletcache-server registered, dev-registry issued a signed token" + +echo "== verifying the CacheToken carries the right lanHost/port ==" +token=$(node -e "console.log(JSON.parse(process.argv[1]).token)" "$lookup") +node -e " +const env = JSON.parse(Buffer.from(process.argv[1], 'base64').toString('utf8')); +const p = env.payload; +if (p.hostname !== 'localhost') throw new Error('hostname mismatch: ' + p.hostname); +if (p.port !== String($CACHE_PORT)) throw new Error('port mismatch: ' + p.port); +if (!p.tlsSpkiSha256) throw new Error('missing tlsSpkiSha256'); +console.log('token payload OK:', JSON.stringify(p)); +" "$token" || fail "CacheToken payload didn't look right" +pass "CacheToken payload shape OK" + +echo "== fetching an asset through the cache (miss, then hit) ==" +origin_url="https%3A%2F%2Flocalhost%3A${ORIGIN_PORT}%2Ffile" +body1=$(curl -sk "https://localhost:$CACHE_PORT/asset?url=$origin_url") +[ "$body1" = "hello from dummy origin" ] || fail "first fetch-through returned wrong body: '$body1'" +pass "cache miss fetch-through returned correct content" + +body2=$(curl -sk "https://localhost:$CACHE_PORT/asset?url=$origin_url") +[ "$body2" = "hello from dummy origin" ] || fail "second (cached) fetch returned wrong body: '$body2'" +pass "cache hit returned correct content" + +[ -n "$(ls -A "$WORKDIR/assets" 2>/dev/null)" ] || fail "nothing landed in storageDir — cache didn't persist to disk" +pass "asset persisted to disk cache" + +echo "== verifying the SSRF allowlist actually blocks non-allowlisted origins ==" +status=$(curl -sk -o /dev/null -w '%{http_code}' "https://localhost:$CACHE_PORT/asset?url=https%3A%2F%2Fevil.example%2Ffile") +[ "$status" = "403" ] || fail "expected 403 for a non-allowlisted origin, got $status" +pass "non-allowlisted origin correctly rejected (403)" + +echo "" +echo "ALL CHECKS PASSED" diff --git a/src/cache.js b/src/cache.js new file mode 100644 index 0000000..ff631f1 --- /dev/null +++ b/src/cache.js @@ -0,0 +1,100 @@ +const { createHash } = require("node:crypto"); +const { mkdirSync, existsSync, statSync, renameSync, unlinkSync, createWriteStream, createReadStream } = require("node:fs"); +const { join } = require("node:path"); +const { Readable } = require("node:stream"); + +/** Thrown for anything that should map to an HTTP error at the server layer. */ +class CacheError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +/** + * Dumb fetch-through cache keyed by the origin URL's SHA-256. Deliberately + * has no knowledge of BgUpd's manifest format — it just proxies+stores GET + * responses for allowlisted origin hosts, which is what lets a future OTA + * or media resolver reuse this same daemon. + */ +class AssetCache { + constructor(cfg) { + this.storageDir = cfg.storageDir; + this.allowedOrigins = new Set(cfg.allowedOrigins); + mkdirSync(this.storageDir, { recursive: true, mode: 0o755 }); + this.inFlight = new Map(); // url -> Promise, collapses concurrent misses + } + + /** @returns {Promise<{path: string, size: number}>} */ + async get(originUrl) { + this.validateOrigin(originUrl); + const finalPath = this.pathFor(originUrl); + + if (existsSync(finalPath)) { + return { path: finalPath, size: statSync(finalPath).size }; + } + + const existing = this.inFlight.get(originUrl); + if (existing) return existing; + + const promise = this.fetchThrough(originUrl, finalPath).finally(() => { + this.inFlight.delete(originUrl); + }); + this.inFlight.set(originUrl, promise); + return promise; + } + + validateOrigin(originUrl) { + let parsed; + try { + parsed = new URL(originUrl); + } catch { + throw new CacheError(400, "invalid url"); + } + if (parsed.protocol !== "https:") { + throw new CacheError(400, "only https origins are proxied"); + } + if (!this.allowedOrigins.has(parsed.hostname)) { + throw new CacheError(403, `origin ${parsed.hostname} is not allowlisted`); + } + } + + pathFor(originUrl) { + const key = createHash("sha256").update(originUrl).digest("hex"); + return join(this.storageDir, key); + } + + async fetchThrough(originUrl, finalPath) { + const res = await fetch(originUrl); + if (!res.ok || !res.body) { + throw new CacheError(502, `origin returned HTTP ${res.status}`); + } + + const tmpPath = `${finalPath}.part-${process.pid}-${Date.now()}`; + try { + await new Promise((resolve, reject) => { + const out = createWriteStream(tmpPath); + Readable.fromWeb(res.body).pipe(out); + out.on("finish", resolve); + out.on("error", reject); + }); + } catch (err) { + try { + unlinkSync(tmpPath); + } catch { + // best-effort cleanup + } + throw new CacheError(502, `failed downloading origin: ${err.message}`); + } + + renameSync(tmpPath, finalPath); + return { path: finalPath, size: statSync(finalPath).size }; + } + + /** For the request handler to stream the cached file back. */ + readStream(path) { + return createReadStream(path); + } +} + +module.exports = { AssetCache, CacheError }; diff --git a/src/config.js b/src/config.js new file mode 100644 index 0000000..7e96f51 --- /dev/null +++ b/src/config.js @@ -0,0 +1,31 @@ +const { readFileSync } = require("node:fs"); +const yaml = require("js-yaml"); + +const DEFAULTS = { + storageDir: "/var/lib/pawletcache/assets", + identityDir: "/var/lib/pawletcache/identity", + listenAddr: "0.0.0.0", + listenPort: 8443, + centralUrl: "https://oxmc.me", + allowedOrigins: ["oxmc.me"], +}; + +/** Loads /etc/pawletcache/config.yml (or the given path). */ +function loadConfig(path) { + const raw = readFileSync(path, "utf8"); + const parsed = yaml.load(raw) ?? {}; + const cfg = { ...DEFAULTS, ...parsed }; + + if (!cfg.hostname) { + throw new Error("config: hostname is required"); + } + if (!cfg.enrollmentToken) { + throw new Error("config: enrollmentToken is required"); + } + if (!Array.isArray(cfg.allowedOrigins) || cfg.allowedOrigins.length === 0) { + throw new Error("config: allowedOrigins must not be empty"); + } + return cfg; +} + +module.exports = { loadConfig }; diff --git a/src/identity.js b/src/identity.js new file mode 100644 index 0000000..6ae5693 --- /dev/null +++ b/src/identity.js @@ -0,0 +1,71 @@ +const { generateKeyPairSync, X509Certificate, createHash } = require("node:crypto"); +const { mkdirSync, readFileSync, writeFileSync, existsSync } = require("node:fs"); +const { join } = require("node:path"); +const selfsigned = require("selfsigned"); + +/** + * This server's long-lived key material: + * - an Ed25519 keypair, whose public half is what the central registry's + * signed CacheToken attests to (see registration.js) + * - a self-signed TLS keypair the asset HTTPS server terminates with + * + * Devices pin to the TLS cert's SPKI SHA-256 fingerprint carried inside the + * signed CacheToken rather than trusting a CA chain, so the TLS cert never + * needs to be "real" beyond having a stable, fingerprint-able public key. + */ +function loadOrCreateIdentity(dir, hostname) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + + const ed25519 = loadOrCreateEd25519(join(dir, "identity_ed25519")); + const tls = loadOrCreateTlsCert(join(dir, "tls.crt"), join(dir, "tls.key"), hostname); + + const cert = new X509Certificate(tls.cert); + const spkiDer = cert.publicKey.export({ type: "spki", format: "der" }); + const tlsSpkiSha256 = createHash("sha256").update(spkiDer).digest(); + + return { + ed25519PublicKey: ed25519.publicKey, + ed25519PrivateKey: ed25519.privateKey, + tlsCert: tls.cert, + tlsKey: tls.key, + tlsSpkiSha256, // Buffer — base64 this when sending to the central registry + }; +} + +function loadOrCreateEd25519(basePath) { + const pubPath = `${basePath}.pub.pem`; + const privPath = `${basePath}.priv.pem`; + if (existsSync(pubPath) && existsSync(privPath)) { + return { + publicKey: readFileSync(pubPath, "utf8"), + privateKey: readFileSync(privPath, "utf8"), + }; + } + + const { publicKey, privateKey } = generateKeyPairSync("ed25519", { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + }); + writeFileSync(pubPath, publicKey, { mode: 0o644 }); + writeFileSync(privPath, privateKey, { mode: 0o600 }); + return { publicKey, privateKey }; +} + +function loadOrCreateTlsCert(certPath, keyPath, hostname) { + if (existsSync(certPath) && existsSync(keyPath)) { + return { cert: readFileSync(certPath, "utf8"), key: readFileSync(keyPath, "utf8") }; + } + + const attrs = [{ name: "commonName", value: hostname }]; + const pems = selfsigned.generate(attrs, { + days: 3650, + algorithm: "sha256", + keySize: 2048, + extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: hostname }] }], + }); + writeFileSync(certPath, pems.cert, { mode: 0o644 }); + writeFileSync(keyPath, pems.private, { mode: 0o600 }); + return { cert: pems.cert, key: pems.private }; +} + +module.exports = { loadOrCreateIdentity }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..c0d9307 --- /dev/null +++ b/src/index.js @@ -0,0 +1,34 @@ +#!/usr/bin/env node +const { loadConfig } = require("./config.js"); +const { loadOrCreateIdentity } = require("./identity.js"); +const { startRegistrationLoop } = require("./registration.js"); +const { startMdnsAdvertiser } = require("./mdns.js"); +const { AssetCache } = require("./cache.js"); +const { startAssetServer } = require("./server.js"); + +function main() { + const configPath = process.argv[2] || "/etc/pawletcache/config.yml"; + const cfg = loadConfig(configPath); + + const identity = loadOrCreateIdentity(cfg.identityDir, cfg.hostname); + const cache = new AssetCache(cfg); + + const server = startAssetServer(cfg, identity, cache); + + const mdns = startMdnsAdvertiser(cfg); + startRegistrationLoop(cfg, identity, (token) => { + mdns.update(token); + console.log("[registration] token refreshed, mDNS advertisement updated"); + }); + + const shutdown = () => { + console.log("shutting down"); + mdns.stop(); + server.close(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); +} + +main(); diff --git a/src/mdns.js b/src/mdns.js new file mode 100644 index 0000000..eb0804a --- /dev/null +++ b/src/mdns.js @@ -0,0 +1,58 @@ +const { Bonjour } = require("bonjour-service"); + +const SERVICE_TYPE = "pawletcache"; +const PROTOCOL = "tcp"; +// DNS TXT limits each string to 255 bytes; split the signed token across +// numbered keys so it survives regardless of length. +const CHUNK_SIZE = 200; + +/** + * Advertises this cache server on the LAN as `_pawletcache._tcp.local.`. + * The TXT record carries the signed CacheToken so a device can verify this + * server's identity purely from the mDNS response, no central lookup + * needed (see pawletcache-server/README.md's trust model). + * + * Call `update(token)` whenever registration.js hands back a fresh token + * (e.g. after renewal) to re-publish without a restart. + */ +function startMdnsAdvertiser(cfg) { + const bonjour = new Bonjour(); + let service = null; + + function publish(token) { + if (service) { + service.stop(() => {}); + } + service = bonjour.publish({ + name: cfg.hostname, + type: SERVICE_TYPE, + protocol: PROTOCOL, + port: cfg.listenPort, + txt: chunkToken(token), + }); + } + + return { + update(token) { + publish(token); + }, + stop() { + if (service) service.stop(() => {}); + bonjour.destroy(); + }, + }; +} + +function chunkToken(token) { + const chunks = []; + for (let i = 0; i < token.length; i += CHUNK_SIZE) { + chunks.push(token.slice(i, i + CHUNK_SIZE)); + } + const txt = { chunks: String(chunks.length) }; + chunks.forEach((chunk, i) => { + txt[`token${i}`] = chunk; + }); + return txt; +} + +module.exports = { startMdnsAdvertiser }; diff --git a/src/registration.js b/src/registration.js new file mode 100644 index 0000000..df38147 --- /dev/null +++ b/src/registration.js @@ -0,0 +1,104 @@ +const { sign: edSign, createPrivateKey } = require("node:crypto"); +const { readFileSync, writeFileSync, existsSync } = require("node:fs"); +const { join } = require("node:path"); + +const TOKEN_FILE = "token.json"; +const MIN_RETRY_MS = 5_000; +const MAX_RETRY_MS = 10 * 60_000; + +/** + * Registers (or renews) with the central registry (oxmc.me) and keeps the + * signed CacheToken fresh in the background. The token is what devices + * verify locally (offline, no central round-trip) before trusting this + * server via mDNS or central lookup — see pawletcache-server/README.md. + * + * @param {object} cfg parsed config.yml + * @param {object} identity from identity.js + * @param {(token: string, expiresAt: string) => void} onToken called with a + * fresh signed token every time registration/renewal succeeds + */ +function startRegistrationLoop(cfg, identity, onToken) { + const tokenPath = join(cfg.identityDir, TOKEN_FILE); + + // Serve a previously-persisted token immediately (offline/LAN-only boot), + // while a renewal attempt runs in the background. + const cached = loadPersistedToken(tokenPath); + if (cached) onToken(cached.token, cached.expiresAt); + + let retryMs = MIN_RETRY_MS; + const attempt = async () => { + try { + const { token, expiresAt } = await register(cfg, identity); + writeFileSync(tokenPath, JSON.stringify({ token, expiresAt }, null, 2), { mode: 0o600 }); + onToken(token, expiresAt); + retryMs = MIN_RETRY_MS; + + const ttlMs = new Date(expiresAt).getTime() - Date.now(); + const renewInMs = Math.max(ttlMs * 0.8, MIN_RETRY_MS); + setTimeout(attempt, renewInMs); + } catch (err) { + console.error(`[registration] failed, retrying in ${Math.round(retryMs / 1000)}s:`, err.message); + setTimeout(attempt, retryMs); + retryMs = Math.min(retryMs * 2, MAX_RETRY_MS); + } + }; + attempt(); +} + +function loadPersistedToken(tokenPath) { + if (!existsSync(tokenPath)) return null; + try { + const { token, expiresAt } = JSON.parse(readFileSync(tokenPath, "utf8")); + if (new Date(expiresAt).getTime() <= Date.now()) return null; // expired, don't hand out + return { token, expiresAt }; + } catch { + return null; + } +} + +async function register(cfg, identity) { + const signedAt = new Date().toISOString(); + const payload = { + hostname: cfg.hostname, + port: cfg.listenPort, + pubkeyEd25519: pemToBase64Spki(identity.ed25519PublicKey), + tlsSpkiSha256: identity.tlsSpkiSha256.toString("base64"), + signedAt, + }; + // Proves continued possession of the same long-lived identity across + // renewals — the central registry pins pubkeyEd25519 to this server's + // server_id on first registration and expects every renewal signed by it. + const signature = edSign(null, Buffer.from(canonicalize(payload)), createPrivateKey(identity.ed25519PrivateKey)); + + const res = await fetch(`${cfg.centralUrl}/apis/aosp/cache/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...payload, + signature: signature.toString("base64"), + enrollmentToken: cfg.enrollmentToken, + }), + }); + if (!res.ok) { + throw new Error(`registry returned HTTP ${res.status}: ${await res.text().catch(() => "")}`); + } + const body = await res.json(); + if (!body.token || !body.expiresAt) { + throw new Error("registry response missing token/expiresAt"); + } + return { token: body.token, expiresAt: body.expiresAt }; +} + +function pemToBase64Spki(pem) { + return pem + .replace(/-----BEGIN PUBLIC KEY-----/, "") + .replace(/-----END PUBLIC KEY-----/, "") + .replace(/\s+/g, ""); +} + +/** Deterministic key order so the signature is reproducible cross-language. */ +function canonicalize(obj) { + return JSON.stringify(obj, Object.keys(obj).sort()); +} + +module.exports = { startRegistrationLoop }; diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..6a373fe --- /dev/null +++ b/src/server.js @@ -0,0 +1,47 @@ +const { createServer } = require("node:https"); +const { CacheError } = require("./cache.js"); + +/** + * HTTPS asset server. One route: `GET /asset?url=`. TLS + * terminates with the self-signed identity cert — devices trust it via the + * SPKI fingerprint pinned in the signed CacheToken, not a CA chain, so + * there's nothing more to configure here. + */ +function startAssetServer(cfg, identity, cache) { + const server = createServer({ cert: identity.tlsCert, key: identity.tlsKey }, (req, res) => { + handleRequest(req, res, cache).catch((err) => { + const status = err instanceof CacheError ? err.status : 500; + if (!(err instanceof CacheError)) console.error("[server] unhandled error:", err); + res.writeHead(status, { "content-type": "text/plain" }).end(err.message); + }); + }); + + server.listen(cfg.listenPort, cfg.listenAddr, () => { + console.log(`[server] listening on https://${cfg.listenAddr}:${cfg.listenPort}`); + }); + return server; +} + +async function handleRequest(req, res, cache) { + if (req.method !== "GET") { + throw new CacheError(405, "only GET is supported"); + } + const requestUrl = new URL(req.url, "https://placeholder"); + if (requestUrl.pathname !== "/asset") { + throw new CacheError(404, "not found"); + } + const originUrl = requestUrl.searchParams.get("url"); + if (!originUrl) { + throw new CacheError(400, "missing url query parameter"); + } + + const { path, size } = await cache.get(originUrl); + res.writeHead(200, { + "content-type": "application/octet-stream", + "content-length": size, + "cache-control": "public, max-age=31536000, immutable", + }); + cache.readStream(path).pipe(res); +} + +module.exports = { startAssetServer };