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}