Zoo

staging

← back to the zoo

26. Sealed means sealed

A file an app seals for you opens on your devices and nowhere else; the server keeps only what it cannot read.

storage's sealing block: rc-crypt.js and rc-seal.js (https://storage.<domain>/seal/v1/) seal in the browser with a key only your devices and your recovery key open; the app's server forwards keyring calls and stores envelopes it cannot read.

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/26-sealed.php sha256 b62680f74d5c · raw

1<?php
2// Exhibit 26. The zoo seals a postcard the way any app would with storage's sealing block: the
3// page loads rc-crypt.js and rc-seal.js from storage (pinned by hash), the key is made and kept in
4// the browser (wrapped to the root storage's window hands over, never this server), the words are
5// sealed there into an envelope, and the envelope goes through the public paths: app/begin, a PUT
6// straight to B2, app/commit, app/urls. This server only forwards (/api/26/storage, below) and
7// never sees a key. The self-check plays the person's browser in PHP (lib/robotseal.php).
8require_once ZOO_ROOT . "/lib/bulk.php";
9
10// storage's scripts, by hash: a page that runs whatever storage serves would run whatever storage's
11// server says; pinned, a changed file is refused and this card says so.
12const SEAL_JS = [
13    "rc-crypt.js" => "sha384-bC+ArqaU/QjwcbSzAMUu6nMqClMDFOG4cHed3KFtzzYt052HX2z+hJ0Timbw2yoX",
14    "rc-seal.js" => "sha384-9iwPEKMkzo7FSo5YaKL+wOCJSA+zaGiotVBJcMkzBnGex/wljIj/v1Nkz5mHu2rF",
15];
16const POSTCARD_MAX = 65536;   // the zoo seals postcards of words, nothing bigger, into a person's storage
17
18return [
19    "n" => 26, "try" => "seal a postcard", "wing" => "Sealed", "kind" => "self",
20    "title" => "Sealed means sealed",
21    "promise" => "A file an app seals for you opens on your devices and nowhere else; the server keeps only what it cannot read.",
22    "block" => "storage's sealing block: rc-crypt.js and rc-seal.js (https://storage.<domain>/seal/v1/) seal in the browser with a key only your devices and your recovery key open; the app's server forwards keyring calls and stores envelopes it cannot read.",
23    "files" => ["lib/bulk.php", "lib/robotseal.php", "lib/seal.php"],
24    "show" => function (?array $me): string {
25        $s = stored(26);
26        $last = $s && $s["ok"] ? '<p class="muted small">The self-check seals a fresh postcard for the robot each time, '
27            . ago((int)$s["at"]) . ': ' . (int)($s["data"]["size"] ?? 0) . ' bytes in the bucket, none of them its words.</p>' : "";
28        if (!$me) return '<p class="muted"><a href="' . h(rc_signin_url()) . '">Sign in</a> to seal a postcard only your devices can open.</p>' . $last;
29        $tags = "";
30        foreach (SEAL_JS as $f => $sri) $tags .= '<script src="' . h(rc_app_url("storage")) . "/seal/v1/$f\" integrity=\"$sri\" crossorigin=\"anonymous\"></script>";
31        return $tags . '<div id="seal26" class="sealed" data-user="' . (int)$me["id"] . '"><p class="muted">Loading…</p></div>' . $last;
32    },
33    // The app's own route to storage for its page: keyring calls as they are; begin only for the
34    // postcard, sealed; commit and urls as they are. Storage checks the person and the folder.
35    "api" => function (string $do, ?array $me, array $in, bool $post): ?array {
36        if ($do !== "storage" || !$post) return null;
37        if (!$me) return ["error" => "sign in first", "status" => 401];
38        $op = (string)($in["op"] ?? "");
39        $body = is_array($in["body"] ?? null) ? $in["body"] : [];
40        if (!in_array($op, ["keyring", "begin", "commit", "urls"], true)) return ["error" => "no such call", "status" => 400];
41        if ($op === "begin") {
42            $f = $body["files"] ?? [];
43            if (count($f) !== 1 || ($f[0]["path"] ?? "") !== "sealed/postcard.rce" || ($f[0]["type"] ?? "") !== "application/x-rc-encrypted" || (int)($f[0]["size"] ?? 0) > POSTCARD_MAX)
44                return ["error" => "the zoo saves one sealed postcard, at most 64 KB", "status" => 400];
45        }
46        if ($op === "urls") $body = ["paths" => ["sealed/postcard.rce"]];
47        [$s, $r] = storage_call($op, $body);
48        return $r + ["status" => $s];
49    },
50    "check" => function (): array {
51        require_once ZOO_ROOT . "/lib/robotseal.php";
52        $s = robot_sealing();
53        [$k, $kr] = robot_zoo_key($s);
54        $words = postcard_words();
55        [$file, $env] = robot_seal_postcard($k, $words);
56        [$u, $held] = robot_postcard_bytes();
57        $word = substr($words, strrpos($words, " ") + 1, -1);
58        if ($held !== $env) return [false, "the bucket holds other bytes than the robot sealed"];
59        if (substr($held, 0, 4) !== "RCE1") return [false, "the stored object does not start with an envelope header"];
60        if (str_contains($held, $word) || str_contains($held, "robot")) return [false, "the stored bytes contain the words"];
61        try { [$meta, $back] = envelope_open(fn($id) => $id === $kr["mk_id"] ? $k : null, $held); }
62        catch (Throwable $e) { return [false, "the robot's own device cannot open what it sealed: " . $e->getMessage()]; }
63        if ($back !== $words) return [false, "the robot's device opened other words than it sealed"];
64        try { envelope_open(random_bytes(32), $held); return [false, "a key that is not the robot's opened the postcard"]; } catch (Throwable) {}
65        // Back of house (ST-2): last night's sample of sealed objects, each starting with an envelope header.
66        [$c, $sc] = [0, null];
67        $h = curl_init(rc_app_url("storage") . "/sealcheck.json");
68        curl_setopt_array($h, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10]);
69        $sc = json_decode((string)curl_exec($h), true);
70        $night = is_array($sc) && $sc["at"] ? ($sc["ok"] ? "last night's header check: {$sc['sampled']} of {$sc['sealed']} sealed objects sampled, every one an envelope" : "last night's header check found {$sc['bad']} sealed object(s) without an envelope header") : "the nightly header check has not run yet";
71        $data = ["id" => $file["id"], "size" => strlen($held), "at" => $file["updated"], "head" => bin2hex(substr($held, 0, 24)), "words" => strlen($words), "sealcheck" => $sc];
72        if (is_array($sc) && $sc["at"] && !$sc["ok"]) return [false, $night, $data];
73        if (is_array($sc) && $sc["at"] && time() - (int)$sc["at"] > 26 * 3600) $data["amber"] = true;
74        return [true, "sealed " . strlen($words) . " characters for the robot into a " . strlen($held) . "-byte envelope (Apps/Zoo/sealed/postcard.rce, file {$file['id']}): the bucket's bytes hold none of the words and open only with the robot's key; $night", $data];
75    },
76    "script" => <<<'JS'
77(async () => {
78  const box = document.getElementById("seal26");
79  if (!box) return;
80  const say = (html) => { box.innerHTML = html; };
81  if (!window.RCSeal) { say('<p class="tag bad">storage\'s sealing scripts did not load, or not the ones this page pins</p>'); return; }
82  const call = async (op, body) => { const j = await zoo.call("/api/26/storage", { op, body }); if (j.error) throw new Error(j.error); return j; };
83  const seal = RCSeal.app({ app: "zoo", user: box.dataset.user, call: (body) => call("keyring", body) });
84  window.zooSeal = seal;
85  const hex = (u) => [...u].map((x) => x.toString(16).padStart(2, "0")).join(" ");
86  const when = (t) => new Date(t * 1000).toLocaleString(undefined, { hour: "2-digit", minute: "2-digit", month: "short", day: "numeric" });
87
88  // The postcard as storage and the bucket hold it, and, on a device that can, opened.
89  async function postcard() {
90    const u = (await call("urls", {})).files[0];
91    if (!u || !u.url) return null;
92    const bytes = new Uint8Array(await (await fetch(u.url)).arrayBuffer());
93    let opened = null, why = "";
94    if (seal.status === "ready") {
95      try { const d = await RCCrypt.decrypt((id) => seal.keyFor(id), new Blob([bytes])); opened = { words: await d.blob.text(), meta: d.meta }; }
96      catch (e) { why = e.message; }
97    }
98    return { u, bytes, opened, why };
99  }
100  function held(p) {
101    const head = hex(p.bytes.slice(0, 48));
102    return '<div class="held"><p><b>What storage and the bucket hold</b> <span class="muted small">(' + p.bytes.length + ' bytes, as B2 serves them)</span></p>'
103      + '<pre class="log hexdump">' + zoo.esc(head) + ' …</pre>'
104      + '<p class="muted small">It starts <code>RCE1</code>: an envelope. Your words are not in it. What the server knows: ' + p.u.size + ' bytes, saved ' + zoo.esc(when(p.u.updated)) + ', a random file name. Not the words, not that they are words.</p></div>';
105  }
106  async function render(flash) {
107    try { await seal.load(); } catch (e) { say('<p class="tag bad">' + zoo.esc(e.message) + '</p>'); return; }
108    const st = seal.status;
109    if (st === "unsupported") { say('<p class="muted">This browser cannot seal (no WebCrypto or IndexedDB here).</p>'); return; }
110    if (st === "nostorage" || st === "none") {
111      say('<p>The zoo seals a postcard with a key of its own, kept under yours: only your devices and your recovery key open it.</p>'
112        + '<p><button class="primary" id="seal-setup">' + (st === "nostorage" ? "Set up sealing" : "Let the zoo seal with your key") + '</button> <span class="out" id="seal-out"></span></p>'
113        + '<p class="muted small">' + (st === "nostorage" ? "Storage opens in a small window: you make your key and write down a recovery key, once." : "Storage opens in a small window and hands the zoo your key's public half.") + '</p>');
114      document.getElementById("seal-setup").onclick = async (ev) => {
115        ev.target.disabled = true; document.getElementById("seal-out").textContent = "in storage's window…";
116        try { await seal.setup(); render("The zoo now seals with its own key, kept under yours."); }
117        catch (e) { ev.target.disabled = false; document.getElementById("seal-out").textContent = e.message; }
118      };
119      return;
120    }
121    const p = await postcard().catch(() => null);
122    if (st === "locked") {
123      say('<p><span class="tag bad">locked</span> This browser cannot open your sealed postcard.' + (seal.why ? ' <span class="muted small">' + zoo.esc(seal.why) + '</span>' : '') + ' Card 27 lets it in.</p>' + (p ? held(p) : ''));
124      return;
125    }
126    let html = (flash ? '<p class="tag ok">' + zoo.esc(flash) + '</p>' : '')
127      + '<form id="seal-form" class="row"><input id="seal-words" maxlength="500" placeholder="Your words, for nobody but you" required><button class="primary">Seal a postcard</button></form><p class="out" id="seal-out"></p>';
128    if (p) {
129      html += '<div class="sealpair"><div class="opened"><p><b>Opened on this device</b></p>'
130        + (p.opened ? '<blockquote id="seal-opened">' + zoo.esc(p.opened.words) + '</blockquote><p class="muted small">sealed ' + zoo.esc(new Date(p.opened.meta.mtime).toLocaleString()) + '</p>'
131                    : '<p class="tag bad">does not open: ' + zoo.esc(p.why) + '</p>')
132        + '</div>' + held(p) + '</div>';
133    }
134    say(html);
135    document.getElementById("seal-form").onsubmit = async (ev) => {
136      ev.preventDefault();
137      const out = document.getElementById("seal-out"), words = document.getElementById("seal-words").value;
138      out.textContent = "sealing in this browser…";
139      try {
140        await seal.load();
141        if (seal.status !== "ready") throw new Error("this browser cannot seal right now: " + (seal.why || seal.status));
142        const env = await RCCrypt.encrypt(seal.key, new Blob([words]), { name: "postcard.txt", type: "text/plain" });
143        const b = (await call("begin", { files: [{ path: "sealed/postcard.rce", size: env.size, type: RCCrypt.TYPE }] })).files[0];
144        if (!b.url) throw new Error(b.error || "storage would not begin it");
145        const put = await fetch(b.url, { method: "PUT", headers: { "Content-Type": b.type }, body: env });
146        if (!put.ok) throw new Error("B2 answered " + put.status);
147        const c = (await call("commit", { ids: [b.id] })).files[0];
148        if (!c.file) throw new Error(c.error || "storage would not commit it");
149        render("Sealed here, sent straight to B2 as " + env.size + " bytes.");
150      } catch (e) { out.textContent = e.message; }
151    };
152  }
153  window.zooSealRender = render;
154  render();
155})();
156JS,
157];

lib/bulk.php sha256 2eac5b94c6db · raw

1<?php
2// Storage in bulk, the way any app calls it (storage's README, "For an app: in bulk"): a JSON call
3// to storage with rc_app_headers("storage", true), and the bytes straight to B2 and back on the
4// presigned URLs it hands out. No storage key, and no byte passes through storage's PHP.
5//
6//     [$s, $r] = storage_call("begin", ["files" => [["path" => "postcards/a.png", "size" => 812, "type" => "image/png"]]]);
7//     // $r["files"][0]: {id, url, type, size, expires}; PUT the bytes to url with that Content-Type
8//     storage_call("commit", ["ids" => [$id, ...]]);       // one B2 listing for the whole begin
9//     storage_call("urls", ["ids" => [$id, ...]]);         // presigned GETs, five minutes each
10//     storage_call("list", ["after" => 0, "limit" => 1000]);   // what the zoo saved for this person
11
12function storage_call(string $op, array $in = []): array {
13    [$s, $out] = call_app("storage", "/app/$op", true, "POST", json_encode($in), ["Content-Type: application/json"], 55);
14    $j = json_decode($out, true);
15    return [$s, is_array($j) ? $j : ["error" => "storage answered $s: " . substr($out, 0, 160)]];
16}
17
18// PUT many bodies to their presigned URLs, 16 at a time: [status, ...] in order.
19// $jobs: [[url, bytes, content type], ...]
20function put_many(array $jobs): array {
21    return many(array_map(function ($j) {
22        $c = curl_init($j[0]);
23        curl_setopt_array($c, [CURLOPT_CUSTOMREQUEST => "PUT", CURLOPT_POSTFIELDS => $j[1], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30,
24            CURLOPT_HTTPHEADER => ["Content-Type: $j[2]", "Expect:"]]);
25        return $c;
26    }, $jobs));
27}
28
29// GET many presigned URLs: [[status, body], ...] in order.
30function get_many(array $urls): array {
31    return many(array_map(function ($u) {
32        $c = curl_init($u);
33        curl_setopt_array($c, [CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30]);
34        return $c;
35    }, $urls), true);
36}
37
38function many(array $handles, bool $bodies = false): array {
39    $mh = curl_multi_init();
40    $out = [];
41    foreach (array_chunk($handles, 16, true) as $chunk) {
42        foreach ($chunk as $c) curl_multi_add_handle($mh, $c);
43        do { curl_multi_exec($mh, $running); curl_multi_select($mh, 0.2); } while ($running);
44        foreach ($chunk as $i => $c) {
45            $code = (int)curl_getinfo($c, CURLINFO_HTTP_CODE);
46            $out[$i] = $bodies ? [$code, (string)curl_multi_getcontent($c)] : $code;
47            curl_multi_remove_handle($mh, $c);
48        }
49    }
50    return $out;
51}

lib/robotseal.php sha256 3a66a9061aa2 · raw

1<?php
2/*
3 * The robot's sealing, for the self-checks of exhibits 26 and 27. The robot plays a person's browsers
4 * the way rc-crypt.js and rc-seal.js do in a real one (lib/seal.php has the same bytes): at storage
5 * (/api/seal, with its storage sign-in), and at the zoo, whose server forwards to storage like any
6 * app's (/api/26/storage). It holds what a person holds: each device's private key and the
7 * recovery key, in $RC_DATA/robot-seal.json (the zoo's own data, like the robot's passkey), never a
8 * key that opens a file; those are unwrapped again at every check.
9 */
10require_once __DIR__ . "/robot.php";
11require_once __DIR__ . "/seal.php";
12require_once __DIR__ . "/bulk.php";
13
14function rseal_path(): string { return env("RC_DATA") . "/robot-seal.json"; }
15function rseal_save(array $s): void {
16    $tmp = rseal_path() . "." . bin2hex(random_bytes(4));
17    $old = umask(077);
18    $ok = file_put_contents($tmp, json_encode($s, JSON_UNESCAPED_SLASHES)) !== false;
19    umask($old);
20    if (!$ok || !rename($tmp, rseal_path())) { @unlink($tmp); throw new RuntimeException("could not save the robot's sealing state"); }
21}
22
23function robot_at_storage(): Browser {
24    static $done = false;
25    $b = robot();
26    if (!$done) {
27        [$code] = $b->visit(rc_app_url("storage") . "/");
28        if ($code !== 200) throw new RuntimeException("storage answered the robot with $code");
29        $done = true;
30    }
31    return $b;
32}
33// Storage's own page API, as the robot's browser at storage.
34function robot_storage_seal(array $in): array {
35    [$code, , $j] = robot_at_storage()->post(rc_app_url("storage") . "/api/seal", $in);
36    if ($code !== 200) throw new RuntimeException("storage's /api/seal answered $code: " . (is_array($j) ? ($j["error"] ?? "") : substr((string)$j, 0, 120)));
37    return $j;
38}
39// The zoo's own route to storage, as the zoo's page calls it.
40function robot_zoo_storage(string $op, array $body): array {
41    [$code, , $j] = robot_at_zoo()->post("https://" . env("RC_HOST") . "/api/26/storage", ["op" => $op, "body" => $body]);
42    if ($code !== 200) throw new RuntimeException("the zoo's /api/26/storage ($op) answered $code: " . (is_array($j) ? ($j["error"] ?? "") : substr((string)$j, 0, 120)));
43    return $j;
44}
45
46function rdev(array $d): array { return ["id" => $d["id"], "priv" => ec_load($d["pem"]), "pub" => $d["pub"], "pin" => $d["pin"] ?? null, "epoch" => $d["epoch"] ?? 0, "code" => null]; }
47function new_rdev(): array { $k = ec_new(); return ["id" => s_b64url(random_bytes(18)), "pem" => ec_pem($k), "pub" => s_b64(ec_raw($k))]; }
48function rdev_entry(array $d, string $k, string $via, string $name): array {
49    return ["id" => $d["id"], "name" => $name, "pub" => $d["pub"], "wrap" => wrap_for_device($d["pub"], $k), "created" => time(), "via" => $via];
50}
51
52// The robot's devices, set up once per environment: a storage device holding its root (and the
53// recovery key), and a zoo device holding the zoo's key for it.
54function robot_sealing(): array {
55    $uid = (string)robot_user()["id"];
56    $s = json_decode((string)@file_get_contents(rseal_path()), true);
57    if (is_array($s) && ($s["user"] ?? null) === $uid) return $s;
58    robot_at_storage();
59    $uid = (string)robot_user()["id"];
60    $have = robot_storage_seal(["op" => "get", "fresh" => true]);
61    if ($have["keyring"] !== null) throw new RuntimeException("the robot has a keyring at storage but not its devices (robot-seal.json was lost): its sealed postcard cannot be opened");
62    $root = random_bytes(32);
63    [$rb, $phrase] = new_recovery_key();
64    $sd = new_rdev();
65    $e = new_root_wrap_key($root);
66    $kr = ["v" => 2, "mk_id" => mk_id($root), "epoch" => 1, "root" => $e, "recovery" => recovery_wrap($rb, $root) + ["created" => time()],
67           "devices" => [rdev_entry($sd, $root, "created", "Zoo robot")], "older" => []];
68    $kr["mac"] = keyring_mac($root, $kr, $uid, "-");
69    robot_storage_seal(["op" => "put", "keyring" => $kr, "newproof" => mk_proof($root)]);
70    $sd["pin"] = $kr["mk_id"]; $sd["epoch"] = 1;
71    // The zoo's key, wrapped to the root's public half: the robot holds the root here, so it opens
72    // the entry and the public half it wraps to is the root's own (in a browser: storage's window).
73    open_root_wrap_key($root, $e);
74    $app = random_bytes(32);
75    $zd = new_rdev();
76    $akr = ["v" => 2, "mk_id" => mk_id($app), "epoch" => 1, "root" => wrap_for_root(["id" => $e["id"], "pub" => $e["pub"]], "zoo", $app),
77            "devices" => [rdev_entry($zd, $app, "created", "Zoo robot")], "older" => []];
78    $akr["mac"] = keyring_mac($app, $akr, $uid, "zoo");
79    robot_zoo_storage("keyring", ["op" => "put", "keyring" => $akr, "newproof" => mk_proof($app)]);
80    $zd["pin"] = $akr["mk_id"]; $zd["epoch"] = 1;
81    $s = ["user" => $uid, "storage_dev" => $sd, "zoo_dev" => $zd, "phrase" => $phrase, "made" => time()];
82    rseal_save($s);
83    return $s;
84}
85
86// The zoo's key for the robot, as its zoo device opens it (rc-seal.js's trust()): [key, keyring].
87function robot_zoo_key(array &$s): array {
88    $kr = robot_zoo_storage("keyring", ["op" => "get", "fresh" => true])["keyring"] ?? null;
89    $k = trust_keyring($kr, rdev($s["zoo_dev"]), $s["user"], "zoo");
90    $s["zoo_dev"]["pin"] = $kr["mk_id"]; $s["zoo_dev"]["epoch"] = $kr["epoch"];
91    rseal_save($s);
92    return [$k, $kr];
93}
94
95// A keyring the robot changes: one more epoch, its MAC, the proof of the key it holds.
96function robot_zoo_write(array &$s, string $k, array $kr, array $devices): array {
97    $next = array_merge($kr, ["epoch" => $kr["epoch"] + 1, "devices" => $devices]);
98    unset($next["mac"]);
99    $next["mac"] = keyring_mac($k, $next, $s["user"], "zoo");
100    $kr = robot_zoo_storage("keyring", ["op" => "put", "keyring" => $next, "proof" => mk_proof($k)])["keyring"];
101    $s["zoo_dev"]["epoch"] = $kr["epoch"];
102    rseal_save($s);
103    return $kr;
104}
105
106const POSTCARD = "sealed/postcard.rce";
107
108// Seal words under the zoo's key and save them as the robot's postcard, through the public paths:
109// begin, the bytes straight to B2, commit. [file, envelope bytes].
110function robot_seal_postcard(string $k, string $words): array {
111    $env = envelope_seal($k, $words, ["name" => "postcard.txt", "type" => "text/plain"]);
112    $b = robot_zoo_storage("begin", ["files" => [["path" => POSTCARD, "size" => strlen($env), "type" => ENV_TYPE]]])["files"][0] ?? [];
113    if (empty($b["url"])) throw new RuntimeException("storage would not begin the postcard: " . json_encode($b));
114    $put = put_many([[$b["url"], $env, $b["type"]]])[0];
115    if ($put !== 200) throw new RuntimeException("B2 answered the PUT with $put");
116    $c = robot_zoo_storage("commit", ["ids" => [$b["id"]]])["files"][0] ?? [];
117    if (empty($c["file"])) throw new RuntimeException("storage would not commit the postcard: " . json_encode($c));
118    return [$c["file"], $env];
119}
120
121// The robot's postcard as the bucket holds it (a presigned GET, like a browser's).
122function robot_postcard_bytes(): array {
123    $u = robot_zoo_storage("urls", ["paths" => [POSTCARD]])["files"][0] ?? [];
124    if (empty($u["url"])) throw new RuntimeException("storage has no postcard for the robot: " . json_encode($u));
125    [[$code, $bytes]] = get_many([$u["url"]]);
126    if ($code !== 200) throw new RuntimeException("the bucket answered $code");
127    return [$u, $bytes];
128}
129
130function postcard_words(): string {
131    $w = ["otter", "lantern", "harbour", "juniper", "comet", "pebble", "meadow", "falcon", "teacup", "glacier", "cinnamon", "lighthouse"];
132    return "Sealed at " . gmdate("H:i") . " UTC by the zoo's robot. Today's word: " . $w[random_int(0, count($w) - 1)] . "-" . bin2hex(random_bytes(3)) . ".";
133}

lib/seal.php sha256 650e373c23a0 · raw

1<?php
2/*
3 * seal: rc-crypt.js's formats in PHP, byte for byte (storage's seal/v1/rc-crypt.js is the spec), for
4 * the zoo's robot. A person's browser does all of this in rc-crypt.js; the self-checks of exhibits 26
5 * and 27 play that browser (and, to show what is refused, the server), so they need the same bytes.
6 * tests/seal.php checks every function against storage's vectors (RatcloudKit's fixtures, KEYRING v2)
7 * and has rc-crypt.js open what this makes.
8 *
9 * P-256 through openssl (keys as OpenSSLAsymmetricKey, public halves as the 65-byte uncompressed
10 * point, base64), AES-256-GCM as ciphertext || 16-byte tag (WebCrypto's layout), HKDF-SHA256.
11 */
12
13function s_b64(string $b): string { return base64_encode($b); }
14function s_unb64(string $s): string { $d = base64_decode($s, true); if ($d === false) throw new RuntimeException("not base64"); return $d; }
15function s_b64url(string $b): string { return rtrim(strtr(base64_encode($b), "+/", "-_"), "="); }
16
17function s_hkdf(string $ikm, string $salt, string $info): string { return hash_hkdf("sha256", $ikm, 32, $info, $salt); }
18function s_seal(string $key, string $iv, string $pt, string $aad = ""): string {
19    $ct = openssl_encrypt($pt, "aes-256-gcm", $key, OPENSSL_RAW_DATA, $iv, $tag, $aad, 16);
20    if ($ct === false) throw new RuntimeException("seal failed");
21    return $ct . $tag;
22}
23function s_open(string $key, string $iv, string $ct, string $aad = ""): string {
24    if (strlen($ct) < 16) throw new RuntimeException("too short");
25    $pt = openssl_decrypt(substr($ct, 0, -16), "aes-256-gcm", $key, OPENSSL_RAW_DATA, $iv, substr($ct, -16), $aad);
26    if ($pt === false) throw new RuntimeException("does not open");
27    return $pt;
28}
29
30// --- P-256 ---------------------------------------------------------------------------------------
31const P256_SPKI = "3059301306072a8648ce3d020106082a8648ce3d030107034200";
32function ec_new(): OpenSSLAsymmetricKey { return openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_EC, "curve_name" => "prime256v1"]); }
33function ec_raw(OpenSSLAsymmetricKey $k): string {
34    $e = openssl_pkey_get_details($k)["ec"];
35    return "\x04" . str_pad($e["x"], 32, "\0", STR_PAD_LEFT) . str_pad($e["y"], 32, "\0", STR_PAD_LEFT);
36}
37function ec_d(OpenSSLAsymmetricKey $k): string { return str_pad(openssl_pkey_get_details($k)["ec"]["d"], 32, "\0", STR_PAD_LEFT); }
38function ec_from_d(string $d): OpenSSLAsymmetricKey {
39    $k = openssl_pkey_new(["ec" => ["curve_name" => "prime256v1", "d" => $d]]);
40    if (!$k) throw new RuntimeException("not a P-256 private key");
41    return $k;
42}
43function ec_pub(string $raw): OpenSSLAsymmetricKey {
44    if (strlen($raw) !== 65 || $raw[0] !== "\x04") throw new RuntimeException("not a P-256 public key");
45    $k = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode(hex2bin(P256_SPKI) . $raw), 64, "\n") . "-----END PUBLIC KEY-----\n");
46    if (!$k) throw new RuntimeException("not a P-256 public key");
47    return $k;
48}
49function ecdh(OpenSSLAsymmetricKey $priv, string $pubRaw): string {
50    $z = openssl_pkey_derive(ec_pub($pubRaw), $priv);
51    if ($z === false || strlen($z) !== 32) throw new RuntimeException("ECDH failed");
52    return $z;
53}
54function ec_pem(OpenSSLAsymmetricKey $k): string { openssl_pkey_export($k, $pem); return $pem; }
55function ec_load(string $pem): OpenSSLAsymmetricKey { return openssl_pkey_get_private($pem); }
56
57// --- master keys ---------------------------------------------------------------------------------
58function mk_id(string $k): string { return substr(hash("sha256", "rc-storage mk-id v1" . $k), 0, 32); }
59function mk_proof(string $k): string { return s_b64(hash_hmac("sha256", "rc-storage proof v1", $k, true)); }
60
61// DEVICE WRAP
62function wrap_for_device(string $pubB64, string $k): array {
63    $dpub = s_unb64($pubB64);
64    $eph = ec_new();
65    $ephPub = ec_raw($eph);
66    $key = s_hkdf(ecdh($eph, $dpub), $ephPub . $dpub, "rc-storage device-wrap v1");
67    $iv = random_bytes(12);
68    return ["eph" => s_b64($ephPub), "iv" => s_b64($iv), "ct" => s_b64(s_seal($key, $iv, $k))];
69}
70function unwrap_for_device(OpenSSLAsymmetricKey $priv, string $pubB64, array $w): string {
71    $ephPub = s_unb64($w["eph"]);
72    $key = s_hkdf(ecdh($priv, $ephPub), $ephPub . s_unb64($pubB64), "rc-storage device-wrap v1");
73    return s_open($key, s_unb64($w["iv"]), s_unb64($w["ct"]));
74}
75
76// RECOVERY (Crockford base32, 20 bytes as 32 characters)
77const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
78function b32enc(string $bytes): string {
79    $bits = 0; $val = 0; $out = "";
80    foreach (str_split($bytes) as $c) { $val = (($val << 8) | ord($c)) & 0xffff; $bits += 8; while ($bits >= 5) { $out .= B32[($val >> ($bits - 5)) & 31]; $bits -= 5; } }
81    if ($bits > 0) $out .= B32[($val << (5 - $bits)) & 31];
82    return $out;
83}
84function b32norm(string $s): string { return str_replace(["I", "L", "O"], ["1", "1", "0"], preg_replace('/[\s-]/', "", strtoupper($s))); }
85function b32dec(string $s, int $n): ?string {
86    $t = b32norm($s);
87    if (strlen($t) !== (int)ceil($n * 8 / 5) || preg_match('/[^0-9A-HJKMNP-TV-Z]/', $t)) return null;
88    $bits = 0; $val = 0; $out = "";
89    foreach (str_split($t) as $c) { $val = (($val << 5) | strpos(B32, $c)) & 0xffff; $bits += 5; if ($bits >= 8) { $out .= chr(($val >> ($bits - 8)) & 255); $bits -= 8; } }
90    return substr($out, 0, $n);
91}
92function new_recovery_key(): array { $b = random_bytes(20); return [$b, implode("-", str_split(b32enc($b), 4))]; }
93function recovery_wrap(string $bytes, string $k): array {
94    $salt = random_bytes(16); $iv = random_bytes(12);
95    return ["salt" => s_b64($salt), "iv" => s_b64($iv), "ct" => s_b64(s_seal(s_hkdf($bytes, $salt, "rc-storage recovery v1"), $iv, $k))];
96}
97function recovery_unwrap(string $phrase, array $rec): string {
98    $b = b32dec($phrase, 20);
99    if ($b === null) throw new RuntimeException("a recovery key is 32 letters and digits");
100    return s_open(s_hkdf($b, s_unb64($rec["salt"]), "rc-storage recovery v1"), s_unb64($rec["iv"]), s_unb64($rec["ct"]));
101}
102
103// DEVICE APPROVAL
104function new_code(): string { $s = ""; foreach (str_split(random_bytes(12)) as $c) $s .= B32[ord($c) & 31]; return implode("-", str_split($s, 4)); }
105function approval_key(string $code): string { return s_hkdf(b32norm($code), "", "rc-storage approve v1"); }
106function req_msg(string $id, string $pub): string { return "req\0$id\0" . s_unb64($pub); }
107function ok_msg(string $id, string $pub, array $w, string $mkid): string {
108    return "ok\0$id\0" . s_unb64($pub) . s_unb64($w["eph"]) . s_unb64($w["iv"]) . s_unb64($w["ct"]) . hex2bin($mkid);
109}
110function request_tag(string $code, string $id, string $pub): string { return s_b64(hash_hmac("sha256", req_msg($id, $pub), approval_key($code), true)); }
111function check_request(string $code, array $req): bool {
112    try { return hash_equals(request_tag($code, $req["id"], $req["pub"]), (string)$req["tag"]); } catch (Throwable) { return false; }
113}
114function approval_tag(string $code, string $id, string $pub, array $w, string $mkid): string { return s_b64(hash_hmac("sha256", ok_msg($id, $pub, $w, $mkid), approval_key($code), true)); }
115function check_approval(string $code, string $id, string $pub, array $w, string $mkid, string $ok): bool {
116    try { return hash_equals(approval_tag($code, $id, $pub, $w, $mkid), $ok); } catch (Throwable) { return false; }
117}
118
119// THE ROOT
120function root_id(string $pubB64): string { return substr(hash("sha256", "rc-root id v1" . s_unb64($pubB64)), 0, 32); }
121function rwk_aad(string $root, string $pubB64): string { return "rc-root rwk v1\0" . hex2bin(mk_id($root)) . s_unb64($pubB64); }
122function new_root_wrap_key(string $root): array {
123    $k = ec_new();
124    $pub = s_b64(ec_raw($k));
125    $iv = random_bytes(12);
126    return ["id" => root_id($pub), "pub" => $pub, "iv" => s_b64($iv), "ct" => s_b64(s_seal(s_hkdf($root, "", "rc-root rwk-wrap v1"), $iv, ec_d($k), rwk_aad($root, $pub)))];
127}
128function open_root_wrap_key(string $root, array $e): OpenSSLAsymmetricKey {
129    if ($e["id"] !== root_id($e["pub"])) throw new RuntimeException("root key id mismatch");
130    $d = s_open(s_hkdf($root, "", "rc-root rwk-wrap v1"), s_unb64($e["iv"]), s_unb64($e["ct"]), rwk_aad($root, $e["pub"]));
131    $k = ec_from_d($d);
132    if (ec_raw($k) !== s_unb64($e["pub"])) throw new RuntimeException("root key does not match its public half");
133    return $k;
134}
135function app_aad(string $app, string $mkid): string { return "rc-root app v1\0$app\0" . hex2bin($mkid); }
136function wrap_for_root(array $rootPub, string $app, string $appMk): array {
137    if ($rootPub["id"] !== root_id($rootPub["pub"])) throw new RuntimeException("root key id mismatch");
138    $rpub = s_unb64($rootPub["pub"]);
139    $eph = ec_new(); $ephPub = ec_raw($eph);
140    $iv = random_bytes(12);
141    return ["id" => $rootPub["id"], "eph" => s_b64($ephPub), "iv" => s_b64($iv),
142            "ct" => s_b64(s_seal(s_hkdf(ecdh($eph, $rpub), $ephPub . $rpub, "rc-root app-wrap v1"), $iv, $appMk, app_aad($app, mk_id($appMk))))];
143}
144function unwrap_from_root(OpenSSLAsymmetricKey $rwk, string $rootPubB64, string $app, string $mkid, array $w): string {
145    $ephPub = s_unb64($w["eph"]);
146    $mk = s_open(s_hkdf(ecdh($rwk, $ephPub), $ephPub . s_unb64($rootPubB64), "rc-root app-wrap v1"), s_unb64($w["iv"]), s_unb64($w["ct"]), app_aad($app, $mkid));
147    if (mk_id($mk) !== $mkid) throw new RuntimeException("app key id mismatch");
148    return $mk;
149}
150
151// KEYRING v2
152function keyring_msg(array $kr, string $user, string $app): string {
153    $r = $kr["root"] ?? [];
154    $l = ["rc-keyring v2", "user $user", "app $app", "mk " . $kr["mk_id"], "epoch " . $kr["epoch"],
155          "root " . implode(" ", [$r["id"], $r["pub"] ?? $r["eph"], $r["iv"], $r["ct"]])];
156    if ($app === "-") { $c = $kr["recovery"]; $l[] = "recovery " . implode(" ", [$c["salt"], $c["iv"], $c["ct"], $c["created"]]); }
157    foreach ($kr["devices"] ?? [] as $d) $l[] = "device " . implode(" ", [$d["id"], $d["pub"], $d["wrap"]["eph"], $d["wrap"]["iv"], $d["wrap"]["ct"], $d["ok"] ?? "-", $d["via"], $d["created"], s_b64($d["name"])]);
158    foreach ($kr["older"] ?? [] as $o) $l[] = "older " . implode(" ", [$o["mk_id"], $o["iv"], $o["ct"]]);
159    return implode("\n", $l) . "\n";
160}
161function keyring_mac(string $k, array $kr, string $user, string $app): string {
162    return s_b64(hash_hmac("sha256", keyring_msg($kr, $user, $app), s_hkdf($k, "", "rc-keyring mac v1"), true));
163}
164function check_keyring(string $k, array $kr, string $user, string $app): bool {
165    try { return is_string($kr["mac"] ?? null) && hash_equals(keyring_mac($k, $kr, $user, $app), $kr["mac"]); } catch (Throwable) { return false; }
166}
167function older_aad(string $oldId): string { return "rc-keyring older v1\0" . hex2bin($oldId); }
168function seal_older(string $k, string $old): array {
169    $id = mk_id($old); $iv = random_bytes(12);
170    return ["mk_id" => $id, "iv" => s_b64($iv), "ct" => s_b64(s_seal(s_hkdf($k, "", "rc-keyring older v1"), $iv, $old, older_aad($id)))];
171}
172function open_older(string $k, array $e): string {
173    $old = s_open(s_hkdf($k, "", "rc-keyring older v1"), s_unb64($e["iv"]), s_unb64($e["ct"]), older_aad($e["mk_id"]));
174    if (mk_id($old) !== $e["mk_id"]) throw new RuntimeException("older key id mismatch");
175    return $old;
176}
177
178// A device's view (rc-seal.js's trust()): the key its entry opens, or why it is refused.
179// $dev: ["id", "priv" (key), "pub" (b64), "pin" (mk_id or null), "epoch", "code" (pending, or null)].
180function trust_keyring(?array $kr, array $dev, string $user, string $app): string {
181    if (!$kr || ($kr["v"] ?? 0) !== 2) throw new RuntimeException("not a v2 keyring");
182    $mine = null;
183    foreach ($kr["devices"] as $d) if ($d["id"] === $dev["id"]) $mine = $d;
184    if (!$mine) throw new RuntimeException("this device is not in the keyring");
185    if ($mine["pub"] !== $dev["pub"]) throw new RuntimeException("this device's entry carries another key");
186    try { $k = unwrap_for_device($dev["priv"], $dev["pub"], $mine["wrap"]); } catch (Throwable) { throw new RuntimeException("this device's entry does not open"); }
187    if (mk_id($k) !== $kr["mk_id"]) throw new RuntimeException("this device's entry is not the keyring's key");
188    if (($dev["pin"] ?? null) === $kr["mk_id"]) {}
189    elseif ($dev["pin"] ?? null) {
190        $ok = false;
191        foreach ($kr["older"] ?? [] as $o) if ($o["mk_id"] === $dev["pin"]) { try { open_older($k, $o); $ok = true; } catch (Throwable) {} }
192        if (!$ok) throw new RuntimeException("the keyring's key changed, and the new one does not hold the old");
193    } elseif (!($dev["code"] ?? null) || !isset($mine["ok"]) || !check_approval($dev["code"], $dev["id"], $dev["pub"], $mine["wrap"], $kr["mk_id"], $mine["ok"])) {
194        throw new RuntimeException("this device's entry was not vouched for under its code");
195    }
196    if (!check_keyring($k, $kr, $user, $app)) throw new RuntimeException("the keyring was changed by someone without its key");
197    if (($dev["epoch"] ?? 0) > $kr["epoch"]) throw new RuntimeException("the keyring is older than one this device has seen");
198    return $k;
199}
200
201// ENVELOPE (not FRAGMENTED MEDIA: the zoo seals words)
202const ENV_TYPE = "application/x-rc-encrypted";
203function envelope_seal(string $mk, string $pt, array $meta, int $log2 = 12): string {
204    $cs = 1 << $log2; $size = strlen($pt); $n = max(1, (int)ceil($size / $cs));
205    $fk = random_bytes(32); $P = random_bytes(8); $fkIv = random_bytes(12);
206    $m = $meta + ["name" => "", "type" => "", "mtime" => (int)(microtime(true) * 1000)];
207    $metaCt = s_seal($fk, $P . "\xff\xff\xff\xff", json_encode($m, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "rc-meta");
208    $head = "RCE1" . chr($log2) . "\0\0\0" . pack("J", $size) . hex2bin(mk_id($mk)) . $P . $fkIv . s_seal($mk, $fkIv, $fk) . pack("N", strlen($metaCt)) . $metaCt;
209    $hh = hash("sha256", $head, true);
210    $out = $head;
211    for ($i = 0; $i < $n; $i++) $out .= s_seal($fk, $P . pack("N", $i), substr($pt, $i * $cs, $cs), $hh . ($i === $n - 1 ? "\x01" : "\x00"));
212    return $out;
213}
214// [meta, plaintext]. $key: a master key, or fn(mk_id) -> key|null. Refuses what rc-crypt.js refuses.
215function envelope_open($key, string $env): array {
216    if (strlen($env) < 104 || substr($env, 0, 4) !== "RCE1") throw new RuntimeException("not an encrypted file");
217    $log2 = ord($env[4]);
218    if ($log2 < 12 || $log2 > 24 || substr($env, 5, 3) !== "\0\0\0") throw new RuntimeException("bad header");
219    $size = unpack("J", substr($env, 8, 8))[1];
220    $mkid = bin2hex(substr($env, 16, 16));
221    $k = is_callable($key) ? $key($mkid) : $key;
222    if (!$k || mk_id($k) !== $mkid) throw new RuntimeException("sealed with a different key");
223    $metaLen = unpack("N", substr($env, 100, 4))[1];
224    if ($metaLen > 65536) throw new RuntimeException("bad header");
225    $hl = 104 + $metaLen;
226    $P = substr($env, 32, 8);
227    $fk = s_open($k, substr($env, 40, 12), substr($env, 52, 48));
228    $meta = json_decode(s_open($fk, $P . "\xff\xff\xff\xff", substr($env, 104, $metaLen), "rc-meta"), true);
229    $hh = hash("sha256", substr($env, 0, $hl), true);
230    $cs = 1 << $log2; $n = max(1, (int)ceil($size / $cs));
231    $pt = ""; $at = $hl;
232    for ($i = 0; $i < $n; $i++) {
233        $len = min($cs, $size - $i * $cs) + 16;
234        $pt .= s_open($fk, $P . pack("N", $i), substr($env, $at, $len), $hh . ($i === $n - 1 ? "\x01" : "\x00"));
235        $at += $len;
236    }
237    if ($at !== strlen($env)) throw new RuntimeException("trailing bytes");
238    return [$meta, $pt];
239}