RECryptor public protocol source snapshot — Rapid v0.2.4 This is release source, not remote attestation of the running server. Configuration, credentials, keys and user records are not included. ===== public/api.php ===== 24000) { throw new SLException('送信データが大きすぎます。', 413); } $raw = file_get_contents('php://input', false, null, 0, 24001); if (strlen($raw) > 24000) { throw new SLException('送信データが大きすぎます。', 413); } $b = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); if (!is_array($b) || !isset($b['action']) || !is_string($b['action'])) { throw new SLException('操作を指定してください。'); } $ip = $_SERVER['REMOTE_ADDR'] ?? 'local'; // Do not trust client-supplied forwarding headers. switch ($b['action']) { case 'session': $result = sl_session_info(); break; case 'logout': $result = sl_session_logout(); break; case 'code-request': if (!isset($b['email']) || !is_string($b['email'])) { throw new SLException('メールアドレスを入力してください。'); } $result = sl_code_request($b['email'], $ip); break; case 'code-confirm': if (!isset($b['code']) || !is_string($b['code'])) { throw new SLException('6桁の確認コードを入力してください。'); } $result = sl_code_confirm($b['code'], $ip); break; case 'create': $verified = sl_session_verified(); sl_limit('create-ip', $ip, sl_config()['create_per_hour'], 3600); sl_limit('create-global', 'all', sl_config()['create_global_per_hour'], 3600); $result = sl_create($verified['email'], $verified['bindingHash']); break; case 'check': sl_limit('check', $ip, sl_config()['check_per_minute'], 60); if (!isset($b['id'], $b['nonce']) || !is_string($b['id']) || !is_string($b['nonce'])) { throw new SLException('撮影IDを入力してください。'); } $result = sl_check(sl_id($b['id']), $b['nonce']); break; case 'integrity-baseline': case 'integrity-report': case 'begin': case 'chunk': case 'finish': sl_limit('write', $ip, sl_config()['write_per_minute'], 60); if (!isset($b['id'], $b['token']) || !is_string($b['id']) || !is_string($b['token'])) { throw new SLException('撮影情報の形式を確認してください。'); } $verified = sl_session_verified(); sl_locked(sl_id($b['id']), function ($dir, $r) use ($verified) { if (!isset($r['sessionBindingHash']) || !hash_equals($r['sessionBindingHash'], $verified['bindingHash']) || $r['email'] !== $verified['email']) { throw new SLException('撮影を開始したセッションから操作してください。', 403); } }); if ($b['action'] === 'begin' && (!isset($b['header']) || !is_string($b['header']))) { throw new SLException('撮影情報がありません。'); } if ($b['action'] === 'integrity-baseline') { $result = sl_integrity_baseline($b); break; } if ($b['action'] === 'integrity-report') { $result = sl_integrity_report($b); break; } $fn = 'sl_' . $b['action']; $result = $fn($b); break; default: throw new SLException('この操作には対応していません。', 404); } echo sl_json(['ok' => true, 'data' => $result]); } catch (SLException $e) { http_response_code($e->status); echo sl_json(['ok' => false, 'error' => $e->getMessage()]); } catch (JsonException $e) { http_response_code(400); echo sl_json(['ok' => false, 'error' => 'データ形式が正しくありません。']); } catch (Throwable $e) { error_log('RECryptor internal failure: ' . get_class($e)); http_response_code(503); echo sl_json(['ok' => false, 'error' => '処理を完了できませんでした。しばらく待ってからお試しください。']); } ===== public/lib/core.php ===== status = $status; } } function sl_config(): array { static $c; return $c ??= require dirname(__DIR__) . '/config.php'; } function sl_now(): string { return gmdate('Y-m-d\TH:i:s\Z'); } function sl_b64(string $s): string { return rtrim(strtr(base64_encode($s), '+/', '-_'), '='); } function sl_unb64(string $s, ?int $length = null): string { if (!preg_match('/^[A-Za-z0-9_-]+$/D', $s) || strlen($s) > 24000) { throw new SLException('データ形式が正しくありません。'); } $v = base64_decode(strtr($s, '-_', '+/'), true); if ($v === false || sl_b64($v) !== $s || ($length !== null && strlen($v) !== $length)) { throw new SLException('データ形式が正しくありません。'); } return $v; } function sl_json(array $v): string { return json_encode($v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } function sl_id(string $id): string { if (!preg_match('/^SL-[a-f0-9]{32}$/D', $id)) { throw new SLException('撮影IDの形式を確認してください。'); } return $id; } function sl_storage(bool $ready = true): string { $path = sl_config()['storage_dir']; $real = realpath($path); if ($real === false) { throw new SLException('サービスの保存領域が準備されていません。', 503); } $documentRoot = !empty($_SERVER['DOCUMENT_ROOT']) ? realpath($_SERVER['DOCUMENT_ROOT']) : false; foreach ([realpath(dirname(__DIR__)), $documentRoot] as $web) { if ($web && (strcasecmp($real, $web) === 0 || str_starts_with(strtolower($real . DIRECTORY_SEPARATOR), strtolower($web . DIRECTORY_SEPARATOR)))) { throw new SLException('保存領域は公開ディレクトリの外に設定してください。', 503); } } if ($ready && (!is_dir($real . '/limits') || !is_dir($real . '/records'))) { throw new SLException('サービスが初期化されていません。', 503); } return $real; } function sl_atomic(string $path, string $content): void { $tmp = $path . '.' . bin2hex(random_bytes(8)) . '.tmp'; $fp = fopen($tmp, 'xb'); if (!$fp) { throw new SLException('保存に失敗しました。', 503); } chmod($tmp, 0600); try { if (PHP_OS_FAMILY !== 'Windows') { $parent = dirname($path); $owner = fileowner($parent); $group = filegroup($parent); if ($owner === false || $group === false || (fileowner($tmp) !== $owner && !chown($tmp, $owner)) || (filegroup($tmp) !== $group && !chgrp($tmp, $group))) { throw new SLException('保存ファイルの所有者を保存領域と揃えられません。', 503); } } sl_write($fp, $content); if (!fflush($fp) || (function_exists('fsync') && !fsync($fp))) { throw new SLException('保存に失敗しました。', 503); } fclose($fp); $fp = null; if (!rename($tmp, $path)) { throw new SLException('保存に失敗しました。', 503); } } finally { if (is_resource($fp)) { fclose($fp); } if (is_file($tmp)) { unlink($tmp); } } } function sl_write($fp, string $s): void { for ($offset = 0, $n = strlen($s); $offset < $n;) { $count = fwrite($fp, substr($s, $offset)); if (!$count) { throw new SLException('書き込みに失敗しました。', 503); } $offset += $count; } } function sl_ec_new(): array { $key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); if (!$key || !openssl_pkey_export($key, $pem)) { throw new SLException('鍵を生成できませんでした。', 503); } return [$pem, openssl_pkey_get_details($key)['key']]; } function sl_der(string $pem): string { return base64_decode(preg_replace('/-----[^-]+-----|\s/', '', $pem), true); } function sl_public(string $der): OpenSSLAsymmetricKey { $key = openssl_pkey_get_public("-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n"); if (!$key) { throw new SLException('公開鍵を読み込めません。'); } $d = openssl_pkey_get_details($key); if ($d['type'] !== OPENSSL_KEYTYPE_EC || ($d['ec']['curve_name'] ?? '') !== 'prime256v1') { throw new SLException('対応していない公開鍵です。'); } return $key; } function sl_private(string $path): OpenSSLAsymmetricKey { $pem = is_file($path) ? file_get_contents($path) : false; $key = $pem !== false ? openssl_pkey_get_private($pem) : false; if (!$key) { throw new SLException('秘密鍵を利用できません。運営へお問い合わせください。', 503); } return $key; } function sl_sign(array $payload): array { $raw = sl_json($payload); if (!openssl_sign($raw, $sig, sl_private(sl_storage() . '/receipt-private.pem'), OPENSSL_ALGO_SHA256)) { throw new SLException('受信証明を作成できません。', 503); } return ['payload' => $raw, 'signature' => sl_b64($sig)]; } function sl_verify_receipt(array $receipt, string $publicDer): array { if (!isset($receipt['payload'], $receipt['signature']) || !is_string($receipt['payload']) || !is_string($receipt['signature']) || strlen($receipt['payload']) > 4000) { throw new SLException('受信証明が壊れています。'); } if (openssl_verify($receipt['payload'], sl_unb64($receipt['signature']), sl_public($publicDer), OPENSSL_ALGO_SHA256) !== 1) { throw new SLException('受信証明の署名が一致しません。'); } return json_decode($receipt['payload'], true, 16, JSON_THROW_ON_ERROR); } function sl_record_path(string $id): string { return sl_storage() . '/records/' . sl_id($id); } function sl_lock_file(string $path, string $ownerDirectory) { if (is_link($path)) { throw new SLException('ロックファイルの保存先が正しくありません。', 503); } $fp = fopen($path, 'c'); if (!$fp) { throw new SLException('保存領域をロックできません。', 503); } // Root CLI jobs must not leave root-owned 0600 locks that the PHP-FPM user cannot reopen. if (PHP_OS_FAMILY !== 'Windows') { clearstatcache(true, $path); $owner = fileowner($ownerDirectory); $group = filegroup($ownerDirectory); if ($owner === false || $group === false || (fileowner($path) !== $owner && !chown($path, $owner)) || (filegroup($path) !== $group && !chgrp($path, $group))) { fclose($fp); throw new SLException('ロックファイルの所有者を保存領域と揃えられません。', 503); } } chmod($path, 0600); return $fp; } function sl_read_record(string $dir): array { if (!is_file($dir . '/record.json')) { throw new SLException('該当する撮影IDが見つかりません。', 404); } return json_decode(file_get_contents($dir . '/record.json'), true, 32, JSON_THROW_ON_ERROR); } function sl_locked(string $id, callable $fn) { $dir = sl_record_path($id); if (!is_dir($dir)) { throw new SLException('該当する撮影IDが見つかりません。', 404); } $lock = sl_lock_file($dir . '/lock', $dir); if (!$lock || !flock($lock, LOCK_EX)) { throw new SLException('保存領域が使用中です。', 503); } chmod($dir . '/lock', 0600); try { $r = sl_read_record($dir); sl_recover($dir, $r); return $fn($dir, $r); } finally { flock($lock, LOCK_UN); fclose($lock); } } function sl_recover(string $dir, array &$r): void { // A durable receipt journal is written before its metadata. Recover a crash between the two writes. if (!is_file($dir . '/receipts.jsonl')) { return; } $fp = fopen($dir . '/receipts.jsonl', 'c+b'); $size = filesize($dir . '/receipts.jsonl'); if ($size > 8192) { fseek($fp, -8192, SEEK_END); fgets($fp); } $last = null; $goodEnd = ftell($fp); while (($line = fgets($fp)) !== false) { if (str_ends_with($line, "\n")) { $last = json_decode($line, true, 16, JSON_THROW_ON_ERROR); $goodEnd = ftell($fp); } } if ($goodEnd < $size) { ftruncate($fp, $goodEnd); fflush($fp); if (function_exists('fsync')) { fsync($fp); } } fclose($fp); if (!$last) { return; } $p = sl_verify_receipt($last, sl_unb64($r['receiptPublicKey'])); if ($p['id'] !== $r['id'] || $p['headerHash'] !== $r['headerHash']) { throw new SLException('受信記録が一致しません。', 503); } if ($p['kind'] === 'chunk' && $p['seq'] > $r['lastSeq']) { $r['lastSeq'] = $p['seq']; $r['lastHash'] = $p['hash']; $r['lastReceipt'] = $last; sl_atomic($dir . '/record.json', sl_json($r)); } elseif ($p['kind'] === 'end' && $r['state'] !== 'complete') { $r['state'] = 'complete'; $r['finalReceipt'] = $last; sl_atomic($dir . '/record.json', sl_json($r)); } } function sl_auth(array $r, array $body): void { if (!isset($body['token']) || !is_string($body['token']) || strlen($body['token']) > 100 || !hash_equals($r['tokenHash'], hash('sha256', $body['token']))) { throw new SLException('撮影の認証情報が一致しません。', 403); } } function sl_limit(string $category, string $identity, int $limit, int $seconds): void { $path = sl_storage() . '/limits/' . hash('sha256', $category . ':' . $identity) . '.json'; $fp = fopen($path, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { throw new SLException('サービスが使用中です。', 503); } chmod($path, 0600); try { $data = json_decode(stream_get_contents($fp), true) ?: ['time' => time(), 'count' => 0]; if (time() - $data['time'] >= $seconds) { $data = ['time' => time(), 'count' => 0]; } if ($data['count'] >= $limit) { throw new SLException('アクセスが多いため、しばらく待ってからお試しください。', 429); } $data['count']++; rewind($fp); ftruncate($fp, 0); sl_write($fp, sl_json($data)); fflush($fp); } finally { flock($fp, LOCK_UN); fclose($fp); } } function sl_create(string $email, ?string $bindingHash = null): array { $email = trim($email); if (strlen($email) > 254 || ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL))) { throw new SLException('メールアドレスの形式を確認してください。'); } $id = 'SL-' . bin2hex(random_bytes(16)); $token = sl_b64(random_bytes(32)); [$private, $public] = $bindingHash === null ? sl_ec_new() : ['', '']; $receiptDer = sl_der(openssl_pkey_get_details(sl_private(sl_storage() . '/receipt-private.pem'))['key']); $captureTime = sl_capture_time(); $r = ['id' => $id, 'createdAt' => $captureTime['utc'], 'createdAtClock' => $captureTime['clock'], 'email' => $email, 'tokenHash' => hash('sha256', $token), 'publicKey' => $public ? sl_b64(sl_der($public)) : '', 'keyFingerprint' => $public ? hash('sha256', sl_der($public)) : '', 'receiptPublicKey' => sl_b64($receiptDer), 'state' => 'issued', 'headerHash' => null, 'lastSeq' => -1, 'lastHash' => null]; $dir = sl_storage() . '/records/' . $id; $creationLock = sl_lock_file(sl_storage() . '/records-create.lock', sl_storage()); if (!$creationLock || !flock($creationLock, LOCK_EX)) { if ($creationLock) fclose($creationLock); throw new SLException('撮影IDの発行をロックできません。', 503); } chmod(sl_storage() . '/records-create.lock', 0600); try { if ($bindingHash !== null) { $key = sl_email_key(sl_email($email)); $r['emailKeyId'] = $key['emailKeyId']; $r['publicKey'] = $key['publicKey']; $r['keyFingerprint'] = $key['keyFingerprint']; $r['sessionBindingHash'] = $bindingHash; } if (!mkdir($dir, 0700)) { throw new SLException('撮影IDを保存できません。', 503); } sl_atomic($dir . '/lock', ''); if ($bindingHash === null) sl_atomic($dir . '/private.pem', $private); sl_atomic($dir . '/record.json', sl_json($r)); return ['id' => $id, 'token' => $token, 'createdAt' => $r['createdAt'], 'publicKey' => $r['publicKey'], 'keyFingerprint' => $r['keyFingerprint'], 'receiptPublicKey' => $r['receiptPublicKey']]; } finally { flock($creationLock, LOCK_UN); fclose($creationLock); } } function sl_key_status(array $r, string $dir): string { $privatePath = sl_media_private_path($r, $dir); if (!is_file($privatePath)) { return 'missing'; } try { $private = sl_private($privatePath); $pub = sl_der(openssl_pkey_get_details($private)['key']); if (!hash_equals($r['keyFingerprint'], hash('sha256', $pub))) { return 'mismatch'; } [$testPriv, $testPub] = sl_ec_new(); $one = openssl_pkey_derive($testPub, $private); $two = openssl_pkey_derive(sl_public($pub), $testPriv); if ($one === false || $two === false || !hash_equals($one, $two)) { return 'unusable'; } return 'available'; } catch (Throwable $e) { return 'unusable'; } } function sl_check(string $id, string $nonce): array { sl_unb64($nonce, 32); return sl_locked($id, function ($dir, $r) use ($nonce) { $status = sl_key_status($r, $dir); $at = sl_now(); $sig = null; if ($status === 'available') { $message = "SEMILIVE-KEY-CHECK-V1\n" . $r['id'] . "\n" . $nonce . "\n" . $at; if (!openssl_sign($message, $signature, sl_private(sl_media_private_path($r, $dir)), OPENSSL_ALGO_SHA256)) { $status = 'unusable'; } else { $sig = sl_b64($signature); } } return ['id' => $r['id'], 'status' => $status, 'checkedAt' => $at, 'nonce' => $nonce, 'publicKey' => $r['publicKey'], 'keyFingerprint' => $r['keyFingerprint'], 'signature' => $sig]; }); } require __DIR__ . '/product.php'; // recryptor-product-v1 function sl_header(string $raw): array { if (strlen($raw) > 16000) { throw new SLException('ファイルヘッダーが大きすぎます。'); } $h = json_decode($raw, true, 16, JSON_THROW_ON_ERROR); if (!is_array($h) || ($h['format'] ?? '') !== 'semilive' || ($h['version'] ?? 0) !== 1 || ($h['suite'] ?? '') !== 'P256-HKDF-SHA256-AES256GCM') { throw new SLException('対応していないファイル形式です。'); } foreach (['id','serverPublicKey','serverKeyFingerprint','clientPublicKey','salt','ivPrefix','mediaType','receiptPublicKey','createdAt'] as $key) { if (!isset($h[$key]) || !is_string($h[$key])) { throw new SLException('ファイルヘッダーが壊れています。'); } } sl_product($h); sl_id($h['id']); sl_public(sl_unb64($h['serverPublicKey'])); sl_public(sl_unb64($h['clientPublicKey'])); sl_public(sl_unb64($h['receiptPublicKey'])); sl_unb64($h['salt'], 32); sl_unb64($h['ivPrefix'], 8); if (!preg_match('/^(video\/(webm|mp4))(;[a-zA-Z0-9=.,_ -]+)?$/D', $h['mediaType'])) { throw new SLException('対応していない動画形式です。'); } if (!hash_equals(hash('sha256', sl_unb64($h['serverPublicKey'])), $h['serverKeyFingerprint'])) { throw new SLException('公開鍵の指紋が一致しません。'); } return $h; } function sl_begin(array $b): array { return sl_locked(sl_id($b['id']), function ($dir, $r) use ($b) { sl_auth($r, $b); $raw = sl_unb64($b['header']); $h = sl_header($raw); $hash = hash('sha256', $raw); $product = sl_product($h); if ($product !== null && $product['plan'] !== 'Rapid') { throw new SLException('このWebサービスではRECryptor Rapidの撮影だけを受け付けます。'); } if ($h['id'] !== $r['id'] || $h['serverPublicKey'] !== $r['publicKey'] || $h['receiptPublicKey'] !== $r['receiptPublicKey'] || $h['createdAt'] !== $r['createdAt']) { throw new SLException('撮影情報が一致しません。'); } if ($r['state'] !== 'issued') { if ($r['state'] === 'recording' && $r['headerHash'] === $hash) { return ['headerHash' => $hash]; } throw new SLException('この撮影IDは使用済みです。', 409); } if (sl_key_status($r, $dir) !== 'available') { throw new SLException('秘密鍵を利用できないため撮影を開始できません。', 503); } $captureTime = sl_capture_time(); $r['state'] = 'recording'; $r['startedAt'] = $captureTime['utc']; $r['startedAtClock'] = $captureTime['clock']; $r['headerHash'] = $hash; $r['lastHash'] = $hash; $r['product'] = $product; $r['serviceProduct'] = ['family' => 'RECryptor', 'plan' => 'Rapid', 'serviceVersion' => '0.2.4']; sl_email_key_touch($r); sl_atomic($dir . '/record.json', sl_json($r)); return ['headerHash' => $hash]; }); } function sl_journal(string $dir, array $receipt): void { $fp = fopen($dir . '/receipts.jsonl', 'ab'); if (!$fp) { throw new SLException('受信記録を保存できません。', 503); } chmod($dir . '/receipts.jsonl', 0600); try { sl_write($fp, sl_json($receipt) . "\n"); if (!fflush($fp) || (function_exists('fsync') && !fsync($fp))) { throw new SLException('受信記録を保存できません。', 503); } } finally { fclose($fp); } } function sl_chunk(array $b): array { if (!isset($b['seq'], $b['hash'], $b['previousHash']) || !is_int($b['seq']) || !is_string($b['hash']) || !is_string($b['previousHash']) || !preg_match('/^[a-f0-9]{64}$/D', $b['hash']) || !preg_match('/^[a-f0-9]{64}$/D', $b['previousHash'])) { throw new SLException('受信記録の形式が正しくありません。'); } return sl_locked(sl_id($b['id']), function ($dir, $r) use ($b) { sl_auth($r, $b); if ($r['state'] !== 'recording') { throw new SLException('撮影は受付中ではありません。', 409); } if ($b['seq'] === $r['lastSeq'] && hash_equals($b['hash'], $r['lastHash'])) { return $r['lastReceipt']; } if ($b['seq'] !== $r['lastSeq'] + 1 || !hash_equals($r['lastHash'], $b['previousHash']) || $b['seq'] >= sl_config()['max_chunks']) { throw new SLException('受信記録の順序が一致しません。', 409); } $captureTime = sl_capture_time(); $receipt = sl_sign(['kind' => 'chunk', 'id' => $r['id'], 'seq' => $b['seq'], 'hash' => $b['hash'], 'headerHash' => $r['headerHash'], 'receivedAt' => $captureTime['utc'], 'clock' => $captureTime['clock']]); sl_journal($dir, $receipt); sl_email_key_touch($r); $r['lastSeq'] = $b['seq']; $r['lastHash'] = $b['hash']; $r['lastReceipt'] = $receipt; sl_atomic($dir . '/record.json', sl_json($r)); return $receipt; }); } function sl_finish(array $b): array { return sl_locked(sl_id($b['id']), function ($dir, $r) use ($b) { sl_auth($r, $b); if (!isset($b['seq'], $b['hash']) || !is_int($b['seq']) || !is_string($b['hash']) || $b['seq'] !== $r['lastSeq'] || $b['hash'] !== $r['lastHash'] || $r['lastSeq'] < 0) { throw new SLException('終了時の記録が一致しません。', 409); } if ($r['state'] === 'complete') { return $r['finalReceipt']; } if ($r['state'] !== 'recording') { throw new SLException('撮影は受付中ではありません。', 409); } $captureTime = sl_capture_time(); $receipt = sl_sign(['kind' => 'end', 'id' => $r['id'], 'seq' => $r['lastSeq'], 'hash' => $r['lastHash'], 'headerHash' => $r['headerHash'], 'receivedAt' => $captureTime['utc'], 'clock' => $captureTime['clock']]); sl_journal($dir, $receipt); $r['state'] = 'complete'; $r['finalReceipt'] = $receipt; sl_email_key_touch($r); sl_atomic($dir . '/record.json', sl_json($r)); return $receipt; }); } require __DIR__ . '/auth.php'; require __DIR__ . '/clock.php'; // recryptor-external-clock-v1 ===== public/lib/clock.php ===== sl_now(), 'clock' => ['basis' => 'server-only', 'status' => 'not-enabled', 'warning' => false]]; if (empty(sl_config()['external_clock_enabled']) && getenv('RECRYPTOR_CLOCK_ENABLED') !== '1' && !is_file(sl_storage() . '/clock-enabled')) return $fallback; $fallback['clock'] = ['basis' => 'server-fallback', 'status' => 'unavailable', 'warning' => true]; $path = sl_storage() . '/external-clock.json'; try { if (!is_file($path) || is_link($path) || filesize($path) > 32000) return $fallback; $state = json_decode(file_get_contents($path), true, 16, JSON_THROW_ON_ERROR); if (!is_array($state) || ($state['version'] ?? 0) !== 1) return $fallback; $proc = getenv('RECRYPTOR_CLOCK_PROC_DIR') ?: '/proc'; $bootId = trim(file_get_contents($proc . '/sys/kernel/random/boot_id')); $boot = (float)explode(' ', file_get_contents($proc . '/uptime'))[0]; $age = $boot - ($state['bootSeconds'] ?? -1); if (($state['bootId'] ?? '') !== $bootId || $age < 0 || $age > 180) { $fallback['clock']['status'] = 'stale-or-reboot'; return $fallback; } $evidence = array_intersect_key($state, array_flip(['basis', 'status', 'warning', 'configuredSources', 'requiredVotes', 'acceptedSources', 'toleranceSeconds', 'serverDifferenceSeconds', 'sources'])); $evidence['sampleAgeSeconds'] = round($age, 2); if (($state['basis'] ?? '') !== 'external-ntp-majority') { $fallback['clock'] = $evidence + $fallback['clock']; return $fallback; } $epoch = $state['epoch'] ?? null; if (!is_numeric($epoch) || !is_finite((float)$epoch) || $epoch < 1577836800 || $epoch >= 4102444800 || count($state['acceptedSources'] ?? []) < ($state['requiredVotes'] ?? 100)) return $fallback; return ['utc' => gmdate('Y-m-d\TH:i:s\Z', (int)floor($epoch + $age)), 'clock' => $evidence]; } catch (Throwable $error) { return $fallback; } } ===== public/lib/product.php ===== 48 || !preg_match('/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/D', $p['appVersion']) || !array_key_exists('edition', $p) || ($p['plan'] === 'Business' ? !in_array($p['edition'], ['Hotel', '1on1', 'TrueLover'], true) : $p['edition'] !== null)) { throw new SLException('ファイルの製品情報が正しくありません。'); } return ['family' => $p['family'], 'plan' => $p['plan'], 'edition' => $p['edition'], 'appVersion' => $p['appVersion'], 'schemaVersion' => $p['schemaVersion']]; } function sl_product_label(?array $p): string { if ($p === null) { return '旧版ファイル(プラン・アプリのバージョンは未記録)'; } $edition = $p['edition'] === 'TrueLover' ? 'True Lover' : $p['edition']; return 'RECryptor ' . $p['plan'] . ($edition !== null ? ' ' . $edition . ' Edition' : '') . ' / v' . $p['appVersion']; } ===== public/lib/client-integrity.php ===== 'client-integrity-baseline', 'version' => 1, 'id' => $r['id'], 'nonce' => $b['nonce'], 'files' => $files, 'issuedAt' => $time['utc']]); }); } function sl_integrity_report(array $b): array { if (!isset($b['reportId'], $b['stage'], $b['result'], $b['issues']) || !is_string($b['reportId']) || !preg_match('/^[a-f0-9]{32}$/D', $b['reportId']) || !in_array($b['stage'], ['start', 'recording', 'finish'], true) || !in_array($b['result'], ['checked', 'change-detected', 'check-unavailable'], true) || !is_array($b['issues']) || count($b['issues']) > 16) { throw new SLException('検査報告の形式が正しくありません。'); } foreach ($b['issues'] as $issue) { if (!is_string($issue) || !preg_match('/^[a-zA-Z0-9._-]{1,64}$/D', $issue)) { throw new SLException('検査報告の形式が正しくありません。'); } } return sl_locked(sl_id($b['id']), function ($dir, $r) use ($b) { sl_auth($r, $b); $baseline = $b['baseline'] ?? null; $baselineHash = null; if ($baseline !== null) { if (!is_array($baseline)) { throw new SLException('検査基準の形式が正しくありません。'); } $payload = sl_verify_receipt($baseline, sl_unb64($r['receiptPublicKey'])); if (($payload['kind'] ?? '') !== 'client-integrity-baseline' || ($payload['id'] ?? '') !== $r['id']) { throw new SLException('検査基準が撮影と一致しません。'); } $baselineHash = hash('sha256', $baseline['payload']); } if ($b['result'] === 'checked' && ($baseline === null || count($b['issues']) !== 0)) { throw new SLException('検査結果と基準が一致しません。'); } $path = $dir . '/client-check-' . $b['reportId'] . '.json'; if (is_link($path)) { throw new SLException('検査報告の保存先が正しくありません。', 503); } if (is_file($path)) { $saved = json_decode(file_get_contents($path), true, 16, JSON_THROW_ON_ERROR); $p = sl_verify_receipt($saved['receipt'], sl_unb64($r['receiptPublicKey'])); if ($p['stage'] !== $b['stage'] || $p['result'] !== $b['result'] || $p['issues'] !== array_values($b['issues']) || $p['baselineHash'] !== $baselineHash) { throw new SLException('検査報告の再送内容が一致しません。', 409); } return $saved['receipt']; } if (count(glob($dir . '/client-check-*.json') ?: []) >= 8) { throw new SLException('検査報告の上限に達しました。', 429); } $time = sl_capture_time(); $receipt = sl_sign(['kind' => 'client-integrity-report', 'version' => 1, 'id' => $r['id'], 'reportId' => $b['reportId'], 'stage' => $b['stage'], 'result' => $b['result'], 'issues' => array_values($b['issues']), 'baselineHash' => $baselineHash, 'basis' => 'browser-self-report', 'meaning' => 'server-received-client-claim-not-attestation', 'receivedAt' => $time['utc'], 'clock' => $time['clock']]); sl_atomic($path, sl_json(['receipt' => $receipt, 'baseline' => $baseline])); return $receipt; }); }