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}