OPENSSL_KEYTYPE_EC, "curve_name" => "prime256v1"]); } function ec_raw(OpenSSLAsymmetricKey $k): string { $e = openssl_pkey_get_details($k)["ec"]; return "\x04" . str_pad($e["x"], 32, "\0", STR_PAD_LEFT) . str_pad($e["y"], 32, "\0", STR_PAD_LEFT); } function ec_d(OpenSSLAsymmetricKey $k): string { return str_pad(openssl_pkey_get_details($k)["ec"]["d"], 32, "\0", STR_PAD_LEFT); } function ec_from_d(string $d): OpenSSLAsymmetricKey { $k = openssl_pkey_new(["ec" => ["curve_name" => "prime256v1", "d" => $d]]); if (!$k) throw new RuntimeException("not a P-256 private key"); return $k; } function ec_pub(string $raw): OpenSSLAsymmetricKey { if (strlen($raw) !== 65 || $raw[0] !== "\x04") throw new RuntimeException("not a P-256 public key"); $k = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode(hex2bin(P256_SPKI) . $raw), 64, "\n") . "-----END PUBLIC KEY-----\n"); if (!$k) throw new RuntimeException("not a P-256 public key"); return $k; } function ecdh(OpenSSLAsymmetricKey $priv, string $pubRaw): string { $z = openssl_pkey_derive(ec_pub($pubRaw), $priv); if ($z === false || strlen($z) !== 32) throw new RuntimeException("ECDH failed"); return $z; } function ec_pem(OpenSSLAsymmetricKey $k): string { openssl_pkey_export($k, $pem); return $pem; } function ec_load(string $pem): OpenSSLAsymmetricKey { return openssl_pkey_get_private($pem); } // --- master keys --------------------------------------------------------------------------------- function mk_id(string $k): string { return substr(hash("sha256", "rc-storage mk-id v1" . $k), 0, 32); } function mk_proof(string $k): string { return s_b64(hash_hmac("sha256", "rc-storage proof v1", $k, true)); } // DEVICE WRAP function wrap_for_device(string $pubB64, string $k): array { $dpub = s_unb64($pubB64); $eph = ec_new(); $ephPub = ec_raw($eph); $key = s_hkdf(ecdh($eph, $dpub), $ephPub . $dpub, "rc-storage device-wrap v1"); $iv = random_bytes(12); return ["eph" => s_b64($ephPub), "iv" => s_b64($iv), "ct" => s_b64(s_seal($key, $iv, $k))]; } function unwrap_for_device(OpenSSLAsymmetricKey $priv, string $pubB64, array $w): string { $ephPub = s_unb64($w["eph"]); $key = s_hkdf(ecdh($priv, $ephPub), $ephPub . s_unb64($pubB64), "rc-storage device-wrap v1"); return s_open($key, s_unb64($w["iv"]), s_unb64($w["ct"])); } // RECOVERY (Crockford base32, 20 bytes as 32 characters) const B32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; function b32enc(string $bytes): string { $bits = 0; $val = 0; $out = ""; 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; } } if ($bits > 0) $out .= B32[($val << (5 - $bits)) & 31]; return $out; } function b32norm(string $s): string { return str_replace(["I", "L", "O"], ["1", "1", "0"], preg_replace('/[\s-]/', "", strtoupper($s))); } function b32dec(string $s, int $n): ?string { $t = b32norm($s); if (strlen($t) !== (int)ceil($n * 8 / 5) || preg_match('/[^0-9A-HJKMNP-TV-Z]/', $t)) return null; $bits = 0; $val = 0; $out = ""; 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; } } return substr($out, 0, $n); } function new_recovery_key(): array { $b = random_bytes(20); return [$b, implode("-", str_split(b32enc($b), 4))]; } function recovery_wrap(string $bytes, string $k): array { $salt = random_bytes(16); $iv = random_bytes(12); return ["salt" => s_b64($salt), "iv" => s_b64($iv), "ct" => s_b64(s_seal(s_hkdf($bytes, $salt, "rc-storage recovery v1"), $iv, $k))]; } function recovery_unwrap(string $phrase, array $rec): string { $b = b32dec($phrase, 20); if ($b === null) throw new RuntimeException("a recovery key is 32 letters and digits"); return s_open(s_hkdf($b, s_unb64($rec["salt"]), "rc-storage recovery v1"), s_unb64($rec["iv"]), s_unb64($rec["ct"])); } // DEVICE APPROVAL function new_code(): string { $s = ""; foreach (str_split(random_bytes(12)) as $c) $s .= B32[ord($c) & 31]; return implode("-", str_split($s, 4)); } function approval_key(string $code): string { return s_hkdf(b32norm($code), "", "rc-storage approve v1"); } function req_msg(string $id, string $pub): string { return "req\0$id\0" . s_unb64($pub); } function ok_msg(string $id, string $pub, array $w, string $mkid): string { return "ok\0$id\0" . s_unb64($pub) . s_unb64($w["eph"]) . s_unb64($w["iv"]) . s_unb64($w["ct"]) . hex2bin($mkid); } function request_tag(string $code, string $id, string $pub): string { return s_b64(hash_hmac("sha256", req_msg($id, $pub), approval_key($code), true)); } function check_request(string $code, array $req): bool { try { return hash_equals(request_tag($code, $req["id"], $req["pub"]), (string)$req["tag"]); } catch (Throwable) { return false; } } function 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)); } function check_approval(string $code, string $id, string $pub, array $w, string $mkid, string $ok): bool { try { return hash_equals(approval_tag($code, $id, $pub, $w, $mkid), $ok); } catch (Throwable) { return false; } } // THE ROOT function root_id(string $pubB64): string { return substr(hash("sha256", "rc-root id v1" . s_unb64($pubB64)), 0, 32); } function rwk_aad(string $root, string $pubB64): string { return "rc-root rwk v1\0" . hex2bin(mk_id($root)) . s_unb64($pubB64); } function new_root_wrap_key(string $root): array { $k = ec_new(); $pub = s_b64(ec_raw($k)); $iv = random_bytes(12); 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)))]; } function open_root_wrap_key(string $root, array $e): OpenSSLAsymmetricKey { if ($e["id"] !== root_id($e["pub"])) throw new RuntimeException("root key id mismatch"); $d = s_open(s_hkdf($root, "", "rc-root rwk-wrap v1"), s_unb64($e["iv"]), s_unb64($e["ct"]), rwk_aad($root, $e["pub"])); $k = ec_from_d($d); if (ec_raw($k) !== s_unb64($e["pub"])) throw new RuntimeException("root key does not match its public half"); return $k; } function app_aad(string $app, string $mkid): string { return "rc-root app v1\0$app\0" . hex2bin($mkid); } function wrap_for_root(array $rootPub, string $app, string $appMk): array { if ($rootPub["id"] !== root_id($rootPub["pub"])) throw new RuntimeException("root key id mismatch"); $rpub = s_unb64($rootPub["pub"]); $eph = ec_new(); $ephPub = ec_raw($eph); $iv = random_bytes(12); return ["id" => $rootPub["id"], "eph" => s_b64($ephPub), "iv" => s_b64($iv), "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))))]; } function unwrap_from_root(OpenSSLAsymmetricKey $rwk, string $rootPubB64, string $app, string $mkid, array $w): string { $ephPub = s_unb64($w["eph"]); $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)); if (mk_id($mk) !== $mkid) throw new RuntimeException("app key id mismatch"); return $mk; } // KEYRING v2 function keyring_msg(array $kr, string $user, string $app): string { $r = $kr["root"] ?? []; $l = ["rc-keyring v2", "user $user", "app $app", "mk " . $kr["mk_id"], "epoch " . $kr["epoch"], "root " . implode(" ", [$r["id"], $r["pub"] ?? $r["eph"], $r["iv"], $r["ct"]])]; if ($app === "-") { $c = $kr["recovery"]; $l[] = "recovery " . implode(" ", [$c["salt"], $c["iv"], $c["ct"], $c["created"]]); } 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"])]); foreach ($kr["older"] ?? [] as $o) $l[] = "older " . implode(" ", [$o["mk_id"], $o["iv"], $o["ct"]]); return implode("\n", $l) . "\n"; } function keyring_mac(string $k, array $kr, string $user, string $app): string { return s_b64(hash_hmac("sha256", keyring_msg($kr, $user, $app), s_hkdf($k, "", "rc-keyring mac v1"), true)); } function check_keyring(string $k, array $kr, string $user, string $app): bool { try { return is_string($kr["mac"] ?? null) && hash_equals(keyring_mac($k, $kr, $user, $app), $kr["mac"]); } catch (Throwable) { return false; } } function older_aad(string $oldId): string { return "rc-keyring older v1\0" . hex2bin($oldId); } function seal_older(string $k, string $old): array { $id = mk_id($old); $iv = random_bytes(12); 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)))]; } function open_older(string $k, array $e): string { $old = s_open(s_hkdf($k, "", "rc-keyring older v1"), s_unb64($e["iv"]), s_unb64($e["ct"]), older_aad($e["mk_id"])); if (mk_id($old) !== $e["mk_id"]) throw new RuntimeException("older key id mismatch"); return $old; } // A device's view (rc-seal.js's trust()): the key its entry opens, or why it is refused. // $dev: ["id", "priv" (key), "pub" (b64), "pin" (mk_id or null), "epoch", "code" (pending, or null)]. function trust_keyring(?array $kr, array $dev, string $user, string $app): string { if (!$kr || ($kr["v"] ?? 0) !== 2) throw new RuntimeException("not a v2 keyring"); $mine = null; foreach ($kr["devices"] as $d) if ($d["id"] === $dev["id"]) $mine = $d; if (!$mine) throw new RuntimeException("this device is not in the keyring"); if ($mine["pub"] !== $dev["pub"]) throw new RuntimeException("this device's entry carries another key"); try { $k = unwrap_for_device($dev["priv"], $dev["pub"], $mine["wrap"]); } catch (Throwable) { throw new RuntimeException("this device's entry does not open"); } if (mk_id($k) !== $kr["mk_id"]) throw new RuntimeException("this device's entry is not the keyring's key"); if (($dev["pin"] ?? null) === $kr["mk_id"]) {} elseif ($dev["pin"] ?? null) { $ok = false; foreach ($kr["older"] ?? [] as $o) if ($o["mk_id"] === $dev["pin"]) { try { open_older($k, $o); $ok = true; } catch (Throwable) {} } if (!$ok) throw new RuntimeException("the keyring's key changed, and the new one does not hold the old"); } elseif (!($dev["code"] ?? null) || !isset($mine["ok"]) || !check_approval($dev["code"], $dev["id"], $dev["pub"], $mine["wrap"], $kr["mk_id"], $mine["ok"])) { throw new RuntimeException("this device's entry was not vouched for under its code"); } if (!check_keyring($k, $kr, $user, $app)) throw new RuntimeException("the keyring was changed by someone without its key"); if (($dev["epoch"] ?? 0) > $kr["epoch"]) throw new RuntimeException("the keyring is older than one this device has seen"); return $k; } // ENVELOPE (not FRAGMENTED MEDIA: the zoo seals words) const ENV_TYPE = "application/x-rc-encrypted"; function envelope_seal(string $mk, string $pt, array $meta, int $log2 = 12): string { $cs = 1 << $log2; $size = strlen($pt); $n = max(1, (int)ceil($size / $cs)); $fk = random_bytes(32); $P = random_bytes(8); $fkIv = random_bytes(12); $m = $meta + ["name" => "", "type" => "", "mtime" => (int)(microtime(true) * 1000)]; $metaCt = s_seal($fk, $P . "\xff\xff\xff\xff", json_encode($m, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "rc-meta"); $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; $hh = hash("sha256", $head, true); $out = $head; 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")); return $out; } // [meta, plaintext]. $key: a master key, or fn(mk_id) -> key|null. Refuses what rc-crypt.js refuses. function envelope_open($key, string $env): array { if (strlen($env) < 104 || substr($env, 0, 4) !== "RCE1") throw new RuntimeException("not an encrypted file"); $log2 = ord($env[4]); if ($log2 < 12 || $log2 > 24 || substr($env, 5, 3) !== "\0\0\0") throw new RuntimeException("bad header"); $size = unpack("J", substr($env, 8, 8))[1]; $mkid = bin2hex(substr($env, 16, 16)); $k = is_callable($key) ? $key($mkid) : $key; if (!$k || mk_id($k) !== $mkid) throw new RuntimeException("sealed with a different key"); $metaLen = unpack("N", substr($env, 100, 4))[1]; if ($metaLen > 65536) throw new RuntimeException("bad header"); $hl = 104 + $metaLen; $P = substr($env, 32, 8); $fk = s_open($k, substr($env, 40, 12), substr($env, 52, 48)); $meta = json_decode(s_open($fk, $P . "\xff\xff\xff\xff", substr($env, 104, $metaLen), "rc-meta"), true); $hh = hash("sha256", substr($env, 0, $hl), true); $cs = 1 << $log2; $n = max(1, (int)ceil($size / $cs)); $pt = ""; $at = $hl; for ($i = 0; $i < $n; $i++) { $len = min($cs, $size - $i * $cs) + 16; $pt .= s_open($fk, $P . pack("N", $i), substr($env, $at, $len), $hh . ($i === $n - 1 ? "\x01" : "\x00")); $at += $len; } if ($at !== strlen($env)) throw new RuntimeException("trailing bytes"); return [$meta, $pt]; }