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:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user