(array)($j['keys'] ?? []), 'tokens' => (array)($j['tokens'] ?? []), 'email' => (bool)($j['email'] ?? true)]; } private static function save(string $user, array $data): bool { $dir = self::dir(); if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) return false; $file = self::file($user); $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; if (@file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === false) return false; // SSH(gitユーザー)からも読める様に @chmod($tmp, 0644); if (!@rename($tmp, $file)) { @unlink($file); if (!@rename($tmp, $file)) { @unlink($tmp); return false; } } return true; } //////// SSHの鍵 /** * 公開鍵を解析する(「ssh-ed25519 AAAA… コメント」) * * @return array|null type、key(base64)、comment、fingerprint(SHA256:…)、bits(RSA) */ public static function parse(string $line): ?array { $line = trim(preg_replace('/\s+/', ' ', $line)); if (!preg_match('/^(\S+) ([A-Za-z0-9+\/]+={0,2})(?: (.*))?$/', $line, $m) || !in_array($m[1], self::TYPES, true)) return null; $blob = base64_decode($m[2], true); if ($blob === false || strlen($blob) < 8) return null; // 最初の文字列は種類と同じ $pos = 0; $read = function() use ($blob, &$pos): ?string { if ($pos + 4 > strlen($blob)) return null; $len = unpack('N', $blob, $pos)[1]; if ($pos + 4 + $len > strlen($blob)) return null; $s = substr($blob, $pos + 4, $len); $pos += 4 + $len; return $s; }; if ($read() !== $m[1]) return null; $bits = null; if ($m[1] === 'ssh-rsa') { $e = $read(); $n = $read(); if ($e === null || $n === null) return null; $n = ltrim($n, "\0"); $bits = strlen($n) * 8 - (8 - strlen(decbin(ord($n[0] ?? "\0")))); if ($bits < 2048) return null; } else if ($m[1] === 'ssh-ed25519' || $m[1] === 'sk-ssh-ed25519@openssh.com') { if (strlen((string)$read()) !== 32) return null; } else { if ($read() === null || $read() === null) return null; } return ['type' => $m[1], 'key' => $m[2], 'comment' => trim($m[3] ?? ''), 'fingerprint' => 'SHA256:'.rtrim(base64_encode(hash('sha256', $blob, true)), '='), 'bits' => $bits]; } /** * 鍵を追加する(同じ鍵は他のユーザーでも使えない) */ public static function addKey(string $user, string $line, string $title = ''): Result { $k = self::parse($line); if ($k === null) return Result::Error('不正な公開鍵です(ssh-ed25519、ecdsa、2048ビット以上のssh-rsa等の「種類 鍵 コメント」の1行)。'); if (self::find($k['type'], $k['key']) !== null) return Result::Error('この鍵は既に登録されています。'); $data = self::load($user); $title = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $title)) ?: ($k['comment'] !== '' ? $k['comment'] : $k['type']); $data['keys'][] = ['title' => mb_substr($title, 0, 100), 'type' => $k['type'], 'key' => $k['key'], 'fingerprint' => $k['fingerprint'], 'added' => time(), 'lastUsed' => null]; return self::save($user, $data) ? Result::Success("鍵「{$title}」を追加しました。", ['fingerprint' => $k['fingerprint']]) : Result::Error('鍵を保存出来ません。'); } public static function removeKey(string $user, string $fingerprint): bool { $data = self::load($user); $before = count($data['keys']); $data['keys'] = array_values(array_filter($data['keys'], fn($k) => ($k['fingerprint'] ?? '') !== $fingerprint)); return count($data['keys']) < $before && self::save($user, $data); } /** * 鍵の持ち主 * * @return array|null [ユーザー名, 鍵] */ public static function find(string $type, string $key): ?array { foreach (self::all() as $user => $keys) { foreach ($keys as $k) if (($k['type'] ?? '') === $type && hash_equals((string)($k['key'] ?? ''), $key)) return [$user, $k]; } return null; } /** 全部の鍵(ユーザー名 => 鍵の配列) */ public static function all(): array { $out = []; foreach (glob(self::dir().'/*.json') ?: [] as $f) { $user = basename($f, '.json'); if (!preg_match('/^[A-Za-z0-9]+$/', $user)) continue; $keys = self::load($user)['keys']; if (!empty($keys)) $out[$user] = $keys; } return $out; } /** 最後に使った時刻 */ public static function touchKey(string $user, string $fingerprint): void { $data = self::load($user); foreach ($data['keys'] as $i => $k) { if (($k['fingerprint'] ?? '') !== $fingerprint) continue; // 1時間に1回だけ書く if (($k['lastUsed'] ?? 0) > time() - 3600) return; $data['keys'][$i]['lastUsed'] = time(); self::save($user, $data); return; } } /** * authorized_keysの1行(command=でgitler.php serveを実行させる) */ public static function authorizedKey(string $user, array $key): string { $php = (string)Gitler::config()['php']; $cmd = $php.' '.ROOT.'/gitler.php serve '.$user.' '.$key['fingerprint']; return 'restrict,command="'.str_replace(['\\', '"'], ['\\\\', '\\"'], $cmd).'" '.$key['type'].' '.$key['key']; } //////// アクセストークン /** * トークンを作る(表示は1回だけ、保存するのはハッシュ) * * @param bool $write プッシュも出来る(falseなら読むだけ) * @return Result data:token(平文)、id */ public static function addToken(string $user, string $name, bool $write): Result { $name = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $name)); if ($name === '' || mb_strlen($name) > 100) return Result::Error('トークンの名前を100文字以内で入力して下さい。'); $token = 'lbg_'.bin2hex(random_bytes(20)); $id = bin2hex(random_bytes(4)); $data = self::load($user); $data['tokens'][] = ['id' => $id, 'name' => $name, 'hash' => hash('sha256', $token), 'write' => $write, 'created' => time(), 'lastUsed' => null]; return self::save($user, $data) ? Result::Success("トークン「{$name}」を作りました。", ['token' => $token, 'id' => $id]) : Result::Error('トークンを保存出来ません。'); } public static function removeToken(string $user, string $id): bool { $data = self::load($user); $before = count($data['tokens']); $data['tokens'] = array_values(array_filter($data['tokens'], fn($t) => ($t['id'] ?? '') !== $id)); return count($data['tokens']) < $before && self::save($user, $data); } /** * トークンを確かめる * * @return array|null トークン(write等) */ /** Gitlerの通知をメールでも受け取るか(既定は受け取る) */ public static function setEmail(string $user, bool $on): bool { $data = self::load($user); $data['email'] = $on; return self::save($user, $data); } public static function checkToken(string $user, string $token): ?array { if (!preg_match('/^[A-Za-z0-9]+$/', $user) || !str_starts_with($token, 'lbg_')) return null; $hash = hash('sha256', $token); $data = self::load($user); foreach ($data['tokens'] as $i => $t) { if (!hash_equals((string)($t['hash'] ?? ''), $hash)) continue; if (($t['lastUsed'] ?? 0) < time() - 3600) { $data['tokens'][$i]['lastUsed'] = time(); self::save($user, $data); } return $t; } return null; } }