Zoo

staging

← back to the zoo

27. A new device needs an old one

A device that never opened your sealed files gets in only with a code typed on one that has, or with your recovery key.

rc-seal.js: seal.request() shows a code, seal.approve(code) on a device that has the key lets the new one in, seal.useStorage() lets storage's window do it with your recovery key; every keyring is checked under the key it guards.

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/27-new-device.php sha256 40885e8fd586 · raw

1<?php
2// Exhibit 27. A browser that never opened your sealed postcard gets in only two ways: a code it
3// shows, typed on a device that already can (both ends check an HMAC keyed by the code, so the
4// server can neither swap the new device's key nor hand it a key of its own), or your recovery key
5// (in storage's window, so the root never reaches this page). rc-seal.js does both; the card uses
6// exhibit 26's instance. The self-check plays three of the robot's browsers, and the server twice.
7
8return [
9    "n" => 27, "wing" => "Sealed", "kind" => "self",
10    "title" => "A new device needs an old one",
11    "promise" => "A device that never opened your sealed files gets in only with a code typed on one that has, or with your recovery key.",
12    "block" => "rc-seal.js: seal.request() shows a code, seal.approve(code) on a device that has the key lets the new one in, seal.useStorage() lets storage's window do it with your recovery key; every keyring is checked under the key it guards.",
13    "files" => ["lib/robotseal.php", "lib/seal.php"],
14    "show" => function (?array $me): string {
15        if (!$me) return '<p class="muted"><a href="' . h(rc_signin_url()) . '">Sign in</a>, seal a postcard (26), then open this page in a private window.</p>';
16        return '<div id="seal27"><p class="muted">Loading…</p></div>';
17    },
18    "check" => function (): array {
19        require_once ZOO_ROOT . "/lib/robotseal.php";
20        $s = robot_sealing();
21        [$kA, $kr] = robot_zoo_key($s);
22        $words = postcard_words();
23        robot_seal_postcard($kA, $words);
24        $steps = [];
25        // B: a browser that never opened it. It shows a code and asks.
26        $bk = ec_new();
27        $B = ["id" => s_b64url(random_bytes(18)), "priv" => $bk, "pub" => s_b64(ec_raw($bk)), "pin" => null, "epoch" => 0, "code" => new_code()];
28        try {
29            robot_zoo_storage("keyring", ["op" => "request", "id" => $B["id"], "name" => "Robot's new device", "pub" => $B["pub"], "tag" => request_tag($B["code"], $B["id"], $B["pub"])]);
30            try { trust_keyring($kr, $B, $s["user"], "zoo"); return [false, "a device that was never let in opened the keyring"]; } catch (Throwable) {}
31            [, $held] = robot_postcard_bytes();
32            try { envelope_open(fn() => null, $held); return [false, "the postcard opened with no key"]; } catch (Throwable) {}
33            $steps[] = "locked, showing a code";
34            // A, the robot's first device, is told a wrong code: no waiting device matches it.
35            $reqs = robot_zoo_storage("keyring", ["op" => "get", "fresh" => true])["requests"];
36            $mine = array_values(array_filter($reqs, fn($q) => $q["id"] === $B["id"]));
37            if (!$mine) return [false, "storage does not show the new device's request"];
38            $wrong = new_code();
39            foreach ($reqs as $q) if (check_request($wrong, $q)) return [false, "a wrong code matched a waiting device"];
40            $steps[] = "a wrong code refused";
41            // The server swaps the waiting device's public key for one of its own (the check plays the server).
42            $swapped = $mine[0]; $swapped["pub"] = s_b64(ec_raw(ec_new()));
43            if (check_request($B["code"], $swapped)) return [false, "a key the server swapped into the request was accepted"];
44            $steps[] = "a key the server swapped into the request refused";
45            // The right code: A wraps the zoo's key for B and vouches for it under the code.
46            $q = $mine[0];
47            if (!check_request($B["code"], $q)) return [false, "the right code did not match the request"];
48            $w = wrap_for_device($q["pub"], $kA);
49            $kr = robot_zoo_write($s, $kA, $kr, [...$kr["devices"], ["id" => $q["id"], "name" => $q["name"], "pub" => $q["pub"], "wrap" => $w,
50                "ok" => approval_tag($B["code"], $q["id"], $q["pub"], $w, $kr["mk_id"]), "created" => time(), "via" => "approved"]]);
51            // The server hands B a wrap of a key of its own instead (keeping A's tag): refused.
52            $fake = $kr;
53            foreach ($fake["devices"] as &$d) if ($d["id"] === $B["id"]) $d["wrap"] = wrap_for_device($B["pub"], random_bytes(32));
54            unset($d);
55            try { trust_keyring($fake, $B, $s["user"], "zoo"); return [false, "a wrap of a key the server chose was accepted"]; } catch (Throwable) {}
56            $steps[] = "a key the server swapped in for the new device refused";
57            $kB = trust_keyring(robot_zoo_storage("keyring", ["op" => "get", "fresh" => true])["keyring"], $B, $s["user"], "zoo");
58            [, $held] = robot_postcard_bytes();
59            [, $back] = envelope_open(fn($id) => $id === $kr["mk_id"] ? $kB : null, $held);
60            if ($back !== $words) return [false, "the new device opened other words"];
61            $steps[] = "with the right code, the new device opens the postcard";
62            // C: nothing but the recovery key. In a browser this happens in storage's window.
63            $root = robot_storage_seal(["op" => "get", "fresh" => true])["keyring"];
64            $R = recovery_unwrap($s["phrase"], $root["recovery"]);
65            if (mk_id($R) !== $root["mk_id"] || !check_keyring($R, $root, $s["user"], "-")) return [false, "the recovery key does not open a keyring that checks out"];
66            $M = unwrap_from_root(open_root_wrap_key($R, $root["root"]), $root["root"]["pub"], "zoo", $kr["mk_id"], $kr["root"]);
67            if (!check_keyring($M, $kr, $s["user"], "zoo")) return [false, "the zoo's keyring does not check out under the key the recovery key opened"];
68            [, $back] = envelope_open(fn($id) => $id === $kr["mk_id"] ? $M : null, $held);
69            if ($back !== $words) return [false, "the recovery key opened other words"];
70            $steps[] = "the recovery key opens it too";
71        } finally {
72            // B leaves again, so the robot's keyring does not grow every five minutes.
73            try {
74                [$kA, $kr] = robot_zoo_key($s);
75                if (array_filter($kr["devices"], fn($d) => $d["id"] === $B["id"])) robot_zoo_write($s, $kA, $kr, array_values(array_filter($kr["devices"], fn($d) => $d["id"] !== $B["id"])));
76                robot_zoo_storage("keyring", ["op" => "decline", "id" => $B["id"]]);
77            } catch (Throwable) {}
78        }
79        return [true, "a new device of the robot's: " . implode("; ", $steps), ["steps" => $steps]];
80    },
81    "script" => <<<'JS'
82(async () => {
83  const box = document.getElementById("seal27");
84  if (!box) return;
85  for (let i = 0; i < 100 && !window.zooSeal; i++) await new Promise((r) => setTimeout(r, 100));
86  const seal = window.zooSeal;
87  if (!seal) { box.innerHTML = '<p class="muted">Card 26 did not start (see there).</p>'; return; }
88  let stop = null;
89  async function render(flash) {
90    if (stop) { stop(); stop = null; }
91    for (let i = 0; i < 100 && seal.status === "loading"; i++) await new Promise((r) => setTimeout(r, 100));
92    const st = seal.status;
93    if (st === "nostorage" || st === "none" || st === "unsupported") { box.innerHTML = '<p class="muted">Seal a postcard first (26).</p>'; return; }
94    if (st === "locked") {
95      let code = "…";
96      try { code = await seal.request(); } catch (e) { code = ""; }
97      box.innerHTML = '<p>This browser has never opened your postcard. On a device that has, type this code in card 27:</p>'
98        + '<p class="code" id="seal-code">' + zoo.esc(code) + '</p><p class="muted small">It works for 15 minutes. Waiting…</p>'
99        + '<p><button id="seal-recover">Use your recovery key</button> <span class="out" id="seal-rout"></span></p>'
100        + '<p class="muted small">Storage opens in a small window; your recovery key is typed there, never here.</p>';
101      document.getElementById("seal-recover").onclick = async (ev) => {
102        ev.target.disabled = true; document.getElementById("seal-rout").textContent = "in storage's window…";
103        try { await seal.useStorage(); window.zooSealRender && window.zooSealRender("Let in with your recovery key, in storage's window."); render(); }
104        catch (e) { ev.target.disabled = false; document.getElementById("seal-rout").textContent = e.message; }
105      };
106      stop = seal.poll((s) => { if (s === "ready") { window.zooSealRender && window.zooSealRender("Let in by a device that had the key."); render(); } });
107      return;
108    }
109    const devs = seal.devices.map((d) => '<li><span>' + zoo.esc(d.name) + (d.mine ? ' <span class="tag ok">this browser</span>' : '') + ' <small class="muted">' + zoo.esc(d.via) + '</small></span></li>').join("");
110    box.innerHTML = (flash ? '<p class="tag ok">' + zoo.esc(flash) + '</p>' : '')
111      + '<p>Open this page in a private window (or on another device), sign in, and type the code it shows here:</p>'
112      + '<form id="seal-approve" class="row"><input id="seal-code-in" placeholder="XXXX-XXXX-XXXX" autocomplete="off" spellcheck="false" required><button class="primary">Let it in</button></form><p class="out" id="seal-aout"></p>'
113      + '<p class="muted small">Your devices that open the zoo\'s postcards:</p><ul class="list">' + devs + '</ul>';
114    document.getElementById("seal-approve").onsubmit = async (ev) => {
115      ev.preventDefault();
116      const out = document.getElementById("seal-aout");
117      out.textContent = "checking the code…";
118      try { const n = await seal.approve(document.getElementById("seal-code-in").value); render("Let in: " + n + "."); }
119      catch (e) { out.textContent = e.message; }
120    };
121  }
122  render();
123})();
124JS,
125];

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}