Zoo

staging

← back to the zoo

11. Private streams stay private

A live stream meant for one person can't be read by someone who guesses its name.

Add `auth` to a channel in ratcloud.conf, and the app decides who may listen: nginx asks it first, with RC_CHANNEL and RC_CHANNEL_ID.

This is the code serving the zoo right now: read from disk for this request, from commit 77ff74344d (staging). The zoo's own self-check fetches this page and compares it byte for byte with the file it runs.

exhibits/11-private-streams.php sha256 ad7353cdf58f · raw

1<?php
2// Exhibit 11. A channel declared with `auth` (`channels = mine auth`) makes nginx ask the app
3// before anyone may listen: it calls index.php with RC_CHANNEL and RC_CHANNEL_ID set, and the
4// app answers 204 (yes) or 403 (no). The names are guessable on purpose ("u" + your user id):
5// the gate, not the name, keeps them private.
6return [
7    "n" => 11, "wing" => "Realtime",
8    "title" => "Private streams stay private",
9    "promise" => "A live stream meant for one person can't be read by someone who guesses its name.",
10    "block" => "Add `auth` to a channel in ratcloud.conf, and the app decides who may listen: nginx asks it first, with RC_CHANNEL and RC_CHANNEL_ID.",
11    "files" => ["public/index.php", "lib/robot.php"],
12    "channels" => [
13        "mine" => fn(string $id, ?array $me): bool => $me !== null && $id === "u" . $me["id"],
14    ],
15    "show" => function (?array $me): string {
16        $own = $me ? '<p>Your stream is <code>mine/u' . (int)$me["id"] . '</code>. <button id="ping">Send myself a ping</button></p><ul class="list" id="pings"></ul>'
17                   : '<p class="muted"><a href="' . h(rc_signin_url()) . '">Sign in</a> to get a stream of your own.</p>';
18        $guess = $me ? (int)$me["id"] + 1 : 1;
19        return $own . '<p><button id="eavesdrop" data-guess="u' . $guess . '">Listen to someone else\'s (mine/u' . $guess . ')</button> <span class="out" id="eaves-out"></span></p>';
20    },
21    "api" => function (string $do, ?array $me, array $in, bool $post): ?array {
22        if ($do !== "ping" || !$post) return null;
23        if (!$me) return ["error" => "sign in to have a stream", "status" => 401];
24        return ["published" => publish("mine", "u" . $me["id"], ["text" => "ping for @{$me['username']}", "at" => gmdate("c")])];
25    },
26    // The robot hears its own stream; nobody else, signed in or not, may listen to it.
27    "check" => function (): array {
28        require_once ZOO_ROOT . "/lib/robot.php";
29        $host = "https://" . env("RC_HOST");
30        $b = robot_at_zoo();
31        $id = "u" . robot_user()["id"];
32        [$status, $msgs, $ms] = $b->listen(["$host/rc/sub/mine/$id"], fn() => $b->post("$host/api/11/ping"),
33            fn($m) => count(array_filter($m[0], fn($x) => is_array($x) && str_starts_with($x["text"] ?? "", "ping"))) > 0, 2000);
34        if ($status[0] !== 200) return [false, "the robot was refused its own stream ($status[0])"];
35        if (!array_filter($msgs[0], fn($x) => is_array($x) && str_starts_with($x["text"] ?? "", "ping"))) return [false, "the robot's own stream opened but its ping never came"];
36        $tries = [
37            "a visitor, on the robot's stream" => [new Browser(), "$host/rc/sub/mine/$id"],
38            "the robot, on someone else's" => [$b, "$host/rc/sub/mine/u" . (robot_user()["id"] + 1)],
39            "the robot, on a stream with no name" => [$b, "$host/rc/sub/mine"],
40        ];
41        foreach ($tries as $who => [$br, $url]) {
42            [$s] = $br->listen([$url], fn() => null, fn() => true, 0);
43            if ($s[0] !== 403) return [false, "$who: answered {$s[0]}, not refused"];
44        }
45        return [true, "the robot heard its own ping ({$ms}ms); a visitor and the robot on someone else's stream were refused (403)"];
46    },
47    "script" => <<<'JS'
48(() => {
49  const list = document.getElementById("pings");
50  if (list && zoo.me) {
51    const es = new EventSource("/rc/sub/mine/u" + zoo.me);
52    es.onmessage = (m) => { const j = JSON.parse(m.data); list.insertAdjacentHTML("afterbegin", "<li>" + zoo.esc(j.text) + " · " + zoo.ago(j.at) + "</li>"); };
53    document.getElementById("ping").addEventListener("click", () => zoo.call("/api/11/ping", {}));
54  }
55  document.getElementById("eavesdrop")?.addEventListener("click", async (ev) => {
56    const out = document.getElementById("eaves-out");
57    const ctl = new AbortController();
58    try {
59      const r = await fetch("/rc/sub/mine/" + ev.target.dataset.guess, { headers: { Accept: "text/event-stream" }, signal: ctl.signal });
60      out.innerHTML = r.status === 403 ? '<span class="tag refused">refused</span>' : '<span class="tag opened">connected (' + r.status + ')</span>';
61    } catch (e) { out.textContent = "error: " + e.message; }
62    ctl.abort();
63  });
64})();
65
66JS,
67];

public/index.php sha256 60760e49711f · raw

1<?php
2/*
3 * The zoo's one entry point.
4 *
5 *   /                    every exhibit and its light
6 *   /api/<n>/<do>        an exhibit's JSON (check: run its check now; seen: "this worked for me")
7 *   /how/<n>             the exhibit's source, read from the running checkout (exhibit 19)
8 *   /lights.json         every light, for tests/exhibits.php and anyone curious
9 *   /index.txt           one line per exhibit (promise and building block), for a model's brief
10 *   /how/<n>.txt         exhibit n's running files as plain text (the builder's example(n))
11 *   /platform.md         what an app gets: root's PLATFORM.md, the running version, for anyone
12 *
13 * nginx also calls this file to ask whether a browser may listen on an `auth` channel; then
14 * RC_CHANNEL and RC_CHANNEL_ID are set and the answer is 204 (yes) or 403 (no).
15 */
16require __DIR__ . "/../lib/zoo.php";
17
18$channel = getenv("RC_CHANNEL");
19if ($channel !== false && $channel !== "") {
20    $ok = false;
21    foreach (exhibits() as $e) {
22        if (isset($e["channels"][$channel])) $ok = ($e["channels"][$channel])((string)getenv("RC_CHANNEL_ID"), auth_user());
23    }
24    http_response_code($ok ? 204 : 403);
25    exit;
26}
27
28$path = (string)parse_url($_SERVER["REQUEST_URI"] ?? "/", PHP_URL_PATH);
29$me = auth_user();
30
31function reply(array $j, int $code = 200): never {
32    http_response_code($code);
33    header("Content-Type: application/json");
34    header("Cache-Control: no-store");
35    echo json_encode($j, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), "\n";
36    exit;
37}
38
39if (preg_match('#^/api/(\d+)/([a-z-]+)$#D', $path, $m)) {
40    $e = exhibits()[(int)$m[1]] ?? reply(["error" => "no such exhibit"], 404);
41    $do = $m[2];
42    if (!rc_csrf_ok()) reply(["error" => "refused: not from the zoo's own page"], 403);
43    $post = ($_SERVER["REQUEST_METHOD"] ?? "GET") === "POST";
44    $in = $post ? (json_decode((string)file_get_contents("php://input"), true) ?: []) : $_GET;
45    try {
46        if ($do === "check" && $e["check"]) {
47            // POST runs it now, unless it ran (or started) in the last 15 seconds: anyone can press
48            // this, and a run costs a robot sign-in, a model call, B2 and push. The slot is taken
49            // atomically, so a burst runs it once. GET (an <img> anywhere) only reads the light.
50            if ($post && claim_check($e["n"], 15)) run_check($e);
51            $s = stored($e["n"]);
52            if (!$s) reply(["n" => $e["n"], "title" => $e["title"], "ok" => false, "detail" => "not checked yet", "at" => null, "data" => null]);
53            reply(["n" => $e["n"], "title" => $e["title"], "ok" => (bool)$s["ok"], "detail" => $s["detail"], "at" => gmdate("c", $s["at"]), "data" => $s["data"]]);
54        }
55        if ($do === "seen" && $post && $e["kind"] === "human") {
56            if (!$me) reply(["error" => "sign in first, so the zoo can say who saw it work"], 401);
57            seen($e["n"], $me);
58            reply(["ok" => true, "light" => light($e)]);
59        }
60        if ($e["api"]) {
61            $r = ($e["api"])($do, $me, $in, $post);
62            if ($r !== null) reply($r, (int)($r["status"] ?? 200));
63        }
64    } catch (Throwable $x) {
65        error_log("zoo: /api/{$e['n']}/$do: " . $x->getMessage());
66        reply(["error" => $x->getMessage()], 500);
67    }
68    reply(["error" => "exhibit {$e['n']} has no '$do'"], 404);
69}
70
71if (preg_match('#^/how/(\d+)$#D', $path, $m)) {
72    require ZOO_ROOT . "/lib/how.php";
73    how((int)$m[1]);
74    exit;
75}
76
77if ($path === "/lights.json") {
78    $out = ["env" => env("RC_ENV"), "checked" => ($t = meta("checked_at")) ? gmdate("c", (int)$t) : null, "lights" => []];
79    foreach (exhibits() as $n => $e) {
80        $l = light($e);
81        $out["lights"][] = ["n" => $n, "title" => $e["title"], "kind" => $e["kind"], "color" => $l["color"],
82                            "text" => $l["text"], "at" => isset($l["at"]) ? gmdate("c", $l["at"]) : null];
83    }
84    require_once ZOO_ROOT . "/lib/alerts.php";
85    $out["alerts"] = array_map(fn($a) => ["at" => gmdate("c", (int)$a["at"]), "kind" => $a["kind"], "title" => $a["title"], "body" => $a["body"], "results" => $a["results"]], alerts_recent(10));
86    reply($out);
87}
88
89// Root publishes PLATFORM.md into every app's RC_FACTS; the zoo, being the documentation, serves
90// its copy so a model working in a clone (no RC_FACTS there) reads the version that is running.
91// The builder's brief lists the exhibits, and its model reads one's running source as an example:
92// plain text, from the same checkout "how" shows (exhibit 19), so it is the code that is live.
93if ($path === "/index.txt") {
94    header("Content-Type: text/plain; charset=utf-8");
95    header("Cache-Control: max-age=300");
96    foreach (exhibits() as $n => $e) {
97        echo "$n. {$e["title"]} ({$e["wing"]}" . ($e["kind"] === "waiting" ? ", not built yet" : "") . "): {$e["block"]}\n";
98    }
99    exit;
100}
101if (preg_match('#^/how/(\d+)\.txt$#D', $path, $m)) {
102    require ZOO_ROOT . "/lib/how.php";
103    $e = exhibits()[(int)$m[1]] ?? null;
104    header("Content-Type: text/plain; charset=utf-8");
105    header("Cache-Control: no-store");
106    if (!$e) { http_response_code(404); exit("no such exhibit\n"); }
107    echo "Exhibit {$e["n"]}: {$e["title"]}\nPromise: {$e["promise"]}\nBlock: {$e["block"]}\n";
108    foreach (how_files($e) as $f) echo "\n=== $f ===\n", file_get_contents(ZOO_ROOT . "/$f");
109    exit;
110}
111
112if ($path === "/platform.md") {
113    $doc = @file_get_contents(env("RC_FACTS") . "/PLATFORM.md");
114    http_response_code($doc === false ? 404 : 200);
115    header("Content-Type: text/markdown; charset=utf-8");
116    header("Cache-Control: max-age=300");
117    echo $doc === false ? "PLATFORM.md is not published here yet.\n" : $doc;
118    exit;
119}
120
121if ($path === "/") {
122    require ZOO_ROOT . "/lib/page.php";
123    page($me);
124    exit;
125}
126
127http_response_code(404);
128header("Content-Type: text/html; charset=utf-8");
129header("Cache-Control: no-store");
130echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">',
131     '<meta name="color-scheme" content="light dark"><title>Not found · Zoo</title>', rc_head(), '</head><body>', rc_header("Zoo", $me, "/api/3/signout", false),
132     '<main class="rc-main"><div class="rc-empty"><b>Nothing here</b><p>The zoo has no page at this address.</p>',
133     '<a class="button primary" href="/">Go to the zoo</a></div></main></body></html>';

lib/robot.php sha256 d7d4eb72ec40 · raw

1<?php
2/*
3 * The zoo's robot: a real account, `zoo-robot`, visible in the directory like anyone, that the
4 * self-check signs in as. Its passkey is a software key kept in $RC_DATA/robot.json (the zoo's
5 * own data; nothing of root's or account's). It signs in exactly as a person does: a WebAuthn
6 * assertion to account, then the silent upgrade into the zoo.
7 *
8 * Live and staging each have their own account, so each environment has its own robot.
9 */
10
11require_once __DIR__ . "/web.php";
12
13const ROBOT_NAME = "zoo-robot";
14
15function b64u(string $b): string { return rtrim(strtr(base64_encode($b), "+/", "-_"), "="); }
16
17// CBOR, just enough for a COSE key and an attestation object.
18function cbor(int $major, int $n): string {
19    return $n < 24 ? chr(($major << 5) | $n) : ($n < 256 ? chr(($major << 5) | 24) . chr($n) : chr(($major << 5) | 25) . pack("n", $n));
20}
21function cbor_int(int $n): string { return $n >= 0 ? cbor(0, $n) : cbor(1, -1 - $n); }
22function cbor_bytes(string $s): string { return cbor(2, strlen($s)) . $s; }
23function cbor_text(string $s): string { return cbor(3, strlen($s)) . $s; }
24function cbor_map(array $kv): string { $o = cbor(5, count($kv)); foreach ($kv as [$k, $v]) $o .= $k . $v; return $o; }
25
26function robot_path(): string { return env("RC_DATA") . "/robot.json"; }
27function rp_id(): string { return parse_url(env("RC_ACCOUNTS"), PHP_URL_HOST); }
28
29function account_api(Browser $b, string $name, array $in = []): array {
30    [$code, , $j] = $b->go("POST", env("RC_ACCOUNTS") . "/api/$name", $in, ["X-RC: 1"]);
31    return [$code, is_array($j) ? $j : []];
32}
33
34// Make the robot's account (once per environment) and give it a picture.
35function robot_register(Browser $b): array {
36    $key = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_EC, "curve_name" => "prime256v1"]);
37    $credId = random_bytes(16);
38    // If a previous robot.json was lost, its name is retired for good: take the next free one.
39    for ($i = 0; $i < 5; $i++) {
40        $name = ROBOT_NAME . ($i ? "-$i" : "");
41        [$code, $o] = account_api($b, "register-start", ["username" => $name]);
42        if ($code === 200) break;
43    }
44    if ($code !== 200) throw new RuntimeException("account would not register a robot: " . json_encode($o));
45    $ec = openssl_pkey_get_details($key)["ec"];
46    $cose = cbor_map([[cbor_int(1), cbor_int(2)], [cbor_int(3), cbor_int(-7)], [cbor_int(-1), cbor_int(1)],
47        [cbor_int(-2), cbor_bytes(str_pad($ec["x"], 32, "\0", STR_PAD_LEFT))], [cbor_int(-3), cbor_bytes(str_pad($ec["y"], 32, "\0", STR_PAD_LEFT))]]);
48    $ad = hash("sha256", rp_id(), true) . chr(0x45) . pack("N", 0) . str_repeat("\0", 16) . pack("n", 16) . $credId . $cose;
49    $att = cbor_map([[cbor_text("fmt"), cbor_text("none")], [cbor_text("attStmt"), cbor_map([])], [cbor_text("authData"), cbor_bytes($ad)]]);
50    $cdj = json_encode(["type" => "webauthn.create", "challenge" => $o["challenge"], "origin" => env("RC_ACCOUNTS"), "crossOrigin" => false]);
51    [$code, $j] = account_api($b, "register-finish", ["clientDataJSON" => b64u($cdj), "attestationObject" => b64u($att)]);
52    if ($code !== 200) throw new RuntimeException("robot registration failed: " . json_encode($j));
53    openssl_pkey_export($key, $pem);
54    $r = ["username" => $j["user"]["username"], "id" => $j["user"]["id"], "cred" => b64u($credId), "key" => $pem, "count" => 0];
55    robot_save($r);
56    account_api($b, "profile", ["name" => "Zoo Robot"]);
57    account_api($b, "picture", ["image" => base64_encode(robot_picture())]);
58    return $r;
59}
60
61// The robot's passkey and counter, written whole or not at all: a half-written file would read as
62// "no robot", the next run would register zoo-robot-1, and the real robot's name would be stranded.
63function robot_save(array $r): void {
64    $tmp = robot_path() . "." . bin2hex(random_bytes(4));
65    $old = umask(077);
66    $ok = file_put_contents($tmp, json_encode($r)) === strlen(json_encode($r));
67    umask($old);
68    if (!$ok || !rename($tmp, robot_path())) { @unlink($tmp); throw new RuntimeException("could not save the robot's state"); }
69}
70
71// A little robot face, drawn here so the guestbook has a picture to show.
72function robot_picture(): string {
73    $im = imagecreatetruecolor(128, 128);
74    imagefill($im, 0, 0, imagecolorallocate($im, 46, 125, 90));
75    $w = imagecolorallocate($im, 240, 240, 230);
76    imagefilledrectangle($im, 24, 34, 104, 100, $w);
77    $k = imagecolorallocate($im, 30, 30, 30);
78    imagefilledellipse($im, 48, 60, 16, 16, $k);
79    imagefilledellipse($im, 80, 60, 16, 16, $k);
80    imagefilledrectangle($im, 46, 82, 82, 88, $k);
81    imagefilledrectangle($im, 61, 18, 67, 34, $w);
82    ob_start(); imagepng($im); return ob_get_clean();
83}
84
85// A browser signed in as the robot at account, or an exception saying why not. One sign-in per
86// process; it signs out again when the process ends, so robot sessions never pile up.
87function robot(): Browser {
88    static $b = null;
89    if ($b) return $b;
90    $lock = fopen(robot_path() . ".lock", "c");
91    flock($lock, LOCK_EX);   // the cron check and a web check never sign in with one counter value
92    try {
93        $nb = new Browser();
94        $r = json_decode((string)@file_get_contents(robot_path()), true) ?: robot_register($nb);
95        account_api($nb, "signout");   // registering signed it in; start clean either way
96        [$code, $o] = account_api($nb, "login-start");
97        if ($code !== 200) throw new RuntimeException("account login-start answered $code");
98        // The signature counter must go up every time. Time does, even after a restore from backup.
99        $r["count"] = max($r["count"] + 1, time());
100        $ad = hash("sha256", rp_id(), true) . chr(0x05) . pack("N", $r["count"]);
101        $cdj = json_encode(["type" => "webauthn.get", "challenge" => $o["challenge"], "origin" => env("RC_ACCOUNTS")]);
102        openssl_sign($ad . hash("sha256", $cdj, true), $sig, openssl_pkey_get_private($r["key"]), OPENSSL_ALGO_SHA256);
103        robot_save($r);
104        [$code, $j] = account_api($nb, "login-finish", ["id" => $r["cred"], "clientDataJSON" => b64u($cdj),
105            "authenticatorData" => b64u($ad), "signature" => b64u($sig)]);
106        if ($code !== 200) throw new RuntimeException("the robot could not sign in at account ($code): " . ($j["error"] ?? ""));
107    } finally {
108        flock($lock, LOCK_UN);
109    }
110    register_shutdown_function(fn() => account_api($nb, "signout"));
111    return $b = $nb;
112}
113
114function robot_user(): array {
115    $r = json_decode((string)@file_get_contents(robot_path()), true) ?: [];
116    return ["id" => (int)($r["id"] ?? 0), "username" => (string)($r["username"] ?? "")];
117}
118
119// The robot's browser after a page load at the zoo, so it holds the zoo's own token.
120function robot_at_zoo(): Browser {
121    static $done = null;
122    $b = robot();
123    if (!$done) {
124        [$code] = $b->visit("https://" . env("RC_HOST") . "/");
125        if ($code !== 200) throw new RuntimeException("the zoo answered the robot with $code");
126        $done = true;
127    }
128    return $b;
129}