Zoo

staging

← back to the zoo

4. Your name and picture follow you

Change your name and picture at account, and every app shows the new ones.

rc_users($ids) turns stored ids into current names and pictures, one call per page. An app keeps ids, never copies of names or pictures.

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/04-guestbook.php sha256 f6f4ff0d1ff5 · raw

1<?php
2// Exhibit 4. The guestbook stores only user ids. Names and pictures are asked of account at
3// render time, in one batch call (rc_users), so a change at account shows on the next load.
4// Storing a copy of someone's name or picture is how an app ends up showing the old one.
5
6function guestbook(): array {
7    $rows = q("SELECT user_id, note, at FROM guestbook ORDER BY at DESC LIMIT 60")->fetchAll();
8    $users = rc_users(array_column($rows, "user_id"));      // one call for the whole list
9    $out = [];
10    // A deleted person's entry is not shown: an unnamed line reads as a broken one. The row stays
11    // (account deletes the person, not what they wrote). If account answered nobody at all, it is
12    // more likely down than everyone gone, so then the entries show without names.
13    $down = $rows && !$users;
14    $gone = ["id" => 0, "username" => "", "name" => "someone", "picture" => ""];
15    foreach ($rows as $r) {
16        if (!isset($users[$r["user_id"]]) && !$down) continue;
17        $out[] = ["user" => $users[$r["user_id"]] ?? $gone, "note" => $r["note"], "at" => gmdate("c", $r["at"])];
18    }
19    return $out;
20}
21
22return [
23    "n" => 4, "try" => "sign the guestbook", "wing" => "Identity",
24    "title" => "Your name and picture follow you",
25    "promise" => "Change your name and picture at account, and every app shows the new ones.",
26    "block" => "rc_users(\$ids) turns stored ids into current names and pictures, one call per page. An app keeps ids, never copies of names or pictures.",
27    "files" => ["lib/robot.php"],
28    "show" => function (?array $me): string {
29        $list = "";
30        foreach (guestbook() as $g) $list .= '<div' . ($me && $g["user"]["id"] === (int)$me["id"] ? ' class="mine"' : "") . '>' . person($g["user"]) . '<div class="note">' . h($g["note"]) . " · " . ago(strtotime($g["at"])) . "</div></div>";
31        $form = $me ? '<form class="row" id="gb-form"><input name="note" maxlength="140" placeholder="Say hello (140 characters)" required><button class="primary">Sign</button></form>'
32                    : '<p class="muted"><a href="' . h(rc_signin_url()) . '">Sign in</a> to sign the guestbook.</p>';
33        return $form . '<p class="muted">Change your name or picture at <a href="' . h(env("RC_ACCOUNTS")) . '/">account</a>, then reload: your entry changes too. (The robot renames itself to the time of every check, which is how this exhibit checks itself.)</p>'
34             . '<p class="rc-note ok" id="gb-done" role="status" hidden></p><div class="people" id="gb-list">' . ($list ?: '<p class="muted">Nobody has signed yet.</p>') . "</div>";
35    },
36    "api" => function (string $do, ?array $me, array $in, bool $post): ?array {
37        if ($do === "list") return ["env" => env("RC_ENV"), "instance" => meta("instance"), "guestbook" => guestbook()];
38        if ($do === "sign" && $post) {
39            if (!$me) return ["error" => "sign in to sign the guestbook", "status" => 401];
40            $note = trim(mb_substr((string)($in["note"] ?? ""), 0, 140));
41            if ($note === "") return ["error" => "say something", "status" => 400];
42            q("INSERT INTO guestbook (user_id, note, at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET note = excluded.note, at = excluded.at",
43              [$me["id"], $note, time()]);
44            return ["ok" => true];
45        }
46        return null;
47    },
48    // The robot renames itself at account, then reads the guestbook as a visitor would.
49    "check" => function (): array {
50        require_once ZOO_ROOT . "/lib/robot.php";
51        $b = robot_at_zoo();
52        $host = "https://" . env("RC_HOST");
53        [$code, , $j] = $b->post("$host/api/4/sign", ["note" => "The robot was here, on " . env("RC_ENV") . "."]);
54        if ($code !== 200) return [false, "the robot could not sign the guestbook ($code)"];
55        $name = "Zoo Robot " . gmdate("H:i");
56        [$code] = account_api($b, "profile", ["name" => $name]);
57        if ($code !== 200) return [false, "account would not rename the robot ($code)"];
58        [, , $j] = (new Browser())->get("$host/api/4/list");
59        $mine = array_values(array_filter($j["guestbook"] ?? [], fn($g) => $g["user"]["id"] === robot_user()["id"]))[0] ?? null;
60        if (!$mine) return [false, "the robot's entry is missing from the guestbook"];
61        if ($mine["user"]["name"] !== $name) return [false, "renamed to \"$name\" at account, but the guestbook still says \"{$mine['user']['name']}\""];
62        return [true, "renamed the robot to \"$name\" at account; the guestbook showed the new name on the next load"];
63    },
64    "script" => <<<'JS'
65document.getElementById("gb-form")?.addEventListener("submit", async (ev) => {
66  ev.preventDefault();
67  const btn = ev.target.querySelector("button");
68  btn.disabled = true;
69  const j = await zoo.call("/api/4/sign", { note: ev.target.note.value });
70  btn.disabled = false;
71  if (!j.error) {
72    // Her entry, in place: the list again from the server, hers marked, and a line saying so.
73    // No reload, so the card stays open where she is looking.
74    const l = await zoo.call("/api/4/list");
75    if (!l.error) {
76      document.getElementById("gb-list").innerHTML = l.guestbook.map(g =>
77        '<div' + (g.user.id === zoo.me ? ' class="mine"' : "") + ">" + zoo.person(g.user) + '<div class="note">' + zoo.esc(g.note) + " · " + zoo.ago(g.at) + "</div></div>").join("");
78    }
79    const done = document.getElementById("gb-done");
80    done.textContent = "Signed. Your entry is at the top of the guestbook.";
81    done.hidden = false;
82    ev.target.reset();
83    document.querySelector("#gb-list .mine")?.scrollIntoView({ block: "nearest", behavior: "smooth" });
84    return;
85  }
86  let e = ev.target.nextElementSibling;
87  if (!e || !e.classList.contains("form-err")) { e = document.createElement("p"); e.className = "rc-note bad form-err"; ev.target.after(e); }
88  e.textContent = j.error;
89});
90
91JS,
92];

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}