= 0 ? cbor(0, $n) : cbor(1, -1 - $n); } function cbor_bytes(string $s): string { return cbor(2, strlen($s)) . $s; } function cbor_text(string $s): string { return cbor(3, strlen($s)) . $s; } function cbor_map(array $kv): string { $o = cbor(5, count($kv)); foreach ($kv as [$k, $v]) $o .= $k . $v; return $o; } function robot_path(): string { return env("RC_DATA") . "/robot.json"; } function rp_id(): string { return parse_url(env("RC_ACCOUNTS"), PHP_URL_HOST); } function account_api(Browser $b, string $name, array $in = []): array { [$code, , $j] = $b->go("POST", env("RC_ACCOUNTS") . "/api/$name", $in, ["X-RC: 1"]); return [$code, is_array($j) ? $j : []]; } // Make the robot's account (once per environment) and give it a picture. function robot_register(Browser $b): array { $key = openssl_pkey_new(["private_key_type" => OPENSSL_KEYTYPE_EC, "curve_name" => "prime256v1"]); $credId = random_bytes(16); // If a previous robot.json was lost, its name is retired for good: take the next free one. for ($i = 0; $i < 5; $i++) { $name = ROBOT_NAME . ($i ? "-$i" : ""); [$code, $o] = account_api($b, "register-start", ["username" => $name]); if ($code === 200) break; } if ($code !== 200) throw new RuntimeException("account would not register a robot: " . json_encode($o)); $ec = openssl_pkey_get_details($key)["ec"]; $cose = cbor_map([[cbor_int(1), cbor_int(2)], [cbor_int(3), cbor_int(-7)], [cbor_int(-1), cbor_int(1)], [cbor_int(-2), cbor_bytes(str_pad($ec["x"], 32, "\0", STR_PAD_LEFT))], [cbor_int(-3), cbor_bytes(str_pad($ec["y"], 32, "\0", STR_PAD_LEFT))]]); $ad = hash("sha256", rp_id(), true) . chr(0x45) . pack("N", 0) . str_repeat("\0", 16) . pack("n", 16) . $credId . $cose; $att = cbor_map([[cbor_text("fmt"), cbor_text("none")], [cbor_text("attStmt"), cbor_map([])], [cbor_text("authData"), cbor_bytes($ad)]]); $cdj = json_encode(["type" => "webauthn.create", "challenge" => $o["challenge"], "origin" => env("RC_ACCOUNTS"), "crossOrigin" => false]); [$code, $j] = account_api($b, "register-finish", ["clientDataJSON" => b64u($cdj), "attestationObject" => b64u($att)]); if ($code !== 200) throw new RuntimeException("robot registration failed: " . json_encode($j)); openssl_pkey_export($key, $pem); $r = ["username" => $j["user"]["username"], "id" => $j["user"]["id"], "cred" => b64u($credId), "key" => $pem, "count" => 0]; robot_save($r); account_api($b, "profile", ["name" => "Zoo Robot"]); account_api($b, "picture", ["image" => base64_encode(robot_picture())]); return $r; } // The robot's passkey and counter, written whole or not at all: a half-written file would read as // "no robot", the next run would register zoo-robot-1, and the real robot's name would be stranded. function robot_save(array $r): void { $tmp = robot_path() . "." . bin2hex(random_bytes(4)); $old = umask(077); $ok = file_put_contents($tmp, json_encode($r)) === strlen(json_encode($r)); umask($old); if (!$ok || !rename($tmp, robot_path())) { @unlink($tmp); throw new RuntimeException("could not save the robot's state"); } } // A little robot face, drawn here so the guestbook has a picture to show. function robot_picture(): string { $im = imagecreatetruecolor(128, 128); imagefill($im, 0, 0, imagecolorallocate($im, 46, 125, 90)); $w = imagecolorallocate($im, 240, 240, 230); imagefilledrectangle($im, 24, 34, 104, 100, $w); $k = imagecolorallocate($im, 30, 30, 30); imagefilledellipse($im, 48, 60, 16, 16, $k); imagefilledellipse($im, 80, 60, 16, 16, $k); imagefilledrectangle($im, 46, 82, 82, 88, $k); imagefilledrectangle($im, 61, 18, 67, 34, $w); ob_start(); imagepng($im); return ob_get_clean(); } // A browser signed in as the robot at account, or an exception saying why not. One sign-in per // process; it signs out again when the process ends, so robot sessions never pile up. function robot(): Browser { static $b = null; if ($b) return $b; $lock = fopen(robot_path() . ".lock", "c"); flock($lock, LOCK_EX); // the cron check and a web check never sign in with one counter value try { $nb = new Browser(); $r = json_decode((string)@file_get_contents(robot_path()), true) ?: robot_register($nb); account_api($nb, "signout"); // registering signed it in; start clean either way [$code, $o] = account_api($nb, "login-start"); if ($code !== 200) throw new RuntimeException("account login-start answered $code"); // The signature counter must go up every time. Time does, even after a restore from backup. $r["count"] = max($r["count"] + 1, time()); $ad = hash("sha256", rp_id(), true) . chr(0x05) . pack("N", $r["count"]); $cdj = json_encode(["type" => "webauthn.get", "challenge" => $o["challenge"], "origin" => env("RC_ACCOUNTS")]); openssl_sign($ad . hash("sha256", $cdj, true), $sig, openssl_pkey_get_private($r["key"]), OPENSSL_ALGO_SHA256); robot_save($r); [$code, $j] = account_api($nb, "login-finish", ["id" => $r["cred"], "clientDataJSON" => b64u($cdj), "authenticatorData" => b64u($ad), "signature" => b64u($sig)]); if ($code !== 200) throw new RuntimeException("the robot could not sign in at account ($code): " . ($j["error"] ?? "")); } finally { flock($lock, LOCK_UN); } register_shutdown_function(fn() => account_api($nb, "signout")); return $b = $nb; } function robot_user(): array { $r = json_decode((string)@file_get_contents(robot_path()), true) ?: []; return ["id" => (int)($r["id"] ?? 0), "username" => (string)($r["username"] ?? "")]; } // The robot's browser after a page load at the zoo, so it holds the zoo's own token. function robot_at_zoo(): Browser { static $done = null; $b = robot(); if (!$done) { [$code] = $b->visit("https://" . env("RC_HOST") . "/"); if ($code !== 200) throw new RuntimeException("the zoo answered the robot with $code"); $done = true; } return $b; }