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.
This commit is contained in:
2026-07-25 00:06:20 -07:00
commit 685c14ba97
19 changed files with 1304 additions and 0 deletions
+59
View File
@@ -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.
+14
View File
@@ -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"
}
}
+33
View File
@@ -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 };
+157
View File
@@ -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));
});
+55
View File
@@ -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 };