| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Gitler; |
| 39 | |
| 40 | use Result; |
| 41 | |
| 42 | /** |
| 43 | * GitlerのSSHの鍵、アクセストークン、通知のメールの設定(data/gitler/users/{ユーザー名}.json) |
| 44 | * |
| 45 | * SSH:sshdのAuthorizedKeysCommand(gitler.php keys)が鍵からユーザーを探し、command=でgitler.php serveを実行させる。 |
| 46 | * HTTPS:ユーザー名とアクセストークン(又は二段階認証が無ければパスワード)。 |
| 47 | */ |
| 48 | final class Keys { |
| 49 | /** 保存先(テスト用) */ |
| 50 | public static ?string $dir = null; |
| 51 | |
| 52 | public const TYPES = ['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'sk-ssh-ed25519@openssh.com', 'sk-ecdsa-sha2-nistp256@openssh.com']; |
| 53 | |
| 54 | private static function dir(): string { |
| 55 | return rtrim(self::$dir ?? ROOT.'/data/gitler/users', '/\\'); |
| 56 | } |
| 57 | |
| 58 | private static function file(string $user): string { |
| 59 | if (!preg_match('/^[A-Za-z0-9]+$/', $user)) throw new \InvalidArgumentException("不正なユーザー名:{$user}"); |
| 60 | return self::dir()."/{$user}.json"; |
| 61 | } |
| 62 | |
| 63 | /** @return array keys、tokens、email(通知をメールでも受け取る) */ |
| 64 | public static function load(string $user): array { |
| 65 | $j = json_decode((string)@file_get_contents(self::file($user)), true); |
| 66 | $j = is_array($j) ? $j : []; |
| 67 | return ['keys' => (array)($j['keys'] ?? []), 'tokens' => (array)($j['tokens'] ?? []), 'email' => (bool)($j['email'] ?? true)]; |
| 68 | } |
| 69 | |
| 70 | private static function save(string $user, array $data): bool { |
| 71 | $dir = self::dir(); |
| 72 | if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) return false; |
| 73 | $file = self::file($user); |
| 74 | $tmp = $file.'.'.bin2hex(random_bytes(4)).'.tmp'; |
| 75 | if (@file_put_contents($tmp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) === false) return false; |
| 76 | // SSH(gitユーザー)からも読める様に |
| 77 | @chmod($tmp, 0644); |
| 78 | if (!@rename($tmp, $file)) { |
| 79 | @unlink($file); |
| 80 | if (!@rename($tmp, $file)) { |
| 81 | @unlink($tmp); |
| 82 | return false; |
| 83 | } |
| 84 | } |
| 85 | return true; |
| 86 | } |
| 87 | |
| 88 | //////// SSHの鍵 |
| 89 | |
| 90 | /** |
| 91 | * 公開鍵を解析する(「ssh-ed25519 AAAA… コメント」) |
| 92 | * |
| 93 | * @return array|null type、key(base64)、comment、fingerprint(SHA256:…)、bits(RSA) |
| 94 | */ |
| 95 | public static function parse(string $line): ?array { |
| 96 | $line = trim(preg_replace('/\s+/', ' ', $line)); |
| 97 | if (!preg_match('/^(\S+) ([A-Za-z0-9+\/]+={0,2})(?: (.*))?$/', $line, $m) || !in_array($m[1], self::TYPES, true)) return null; |
| 98 | $blob = base64_decode($m[2], true); |
| 99 | if ($blob === false || strlen($blob) < 8) return null; |
| 100 | // 最初の文字列は種類と同じ |
| 101 | $pos = 0; |
| 102 | $read = function() use ($blob, &$pos): ?string { |
| 103 | if ($pos + 4 > strlen($blob)) return null; |
| 104 | $len = unpack('N', $blob, $pos)[1]; |
| 105 | if ($pos + 4 + $len > strlen($blob)) return null; |
| 106 | $s = substr($blob, $pos + 4, $len); |
| 107 | $pos += 4 + $len; |
| 108 | return $s; |
| 109 | }; |
| 110 | if ($read() !== $m[1]) return null; |
| 111 | $bits = null; |
| 112 | if ($m[1] === 'ssh-rsa') { |
| 113 | $e = $read(); |
| 114 | $n = $read(); |
| 115 | if ($e === null || $n === null) return null; |
| 116 | $n = ltrim($n, "\0"); |
| 117 | $bits = strlen($n) * 8 - (8 - strlen(decbin(ord($n[0] ?? "\0")))); |
| 118 | if ($bits < 2048) return null; |
| 119 | } else if ($m[1] === 'ssh-ed25519' || $m[1] === 'sk-ssh-ed25519@openssh.com') { |
| 120 | if (strlen((string)$read()) !== 32) return null; |
| 121 | } else { |
| 122 | if ($read() === null || $read() === null) return null; |
| 123 | } |
| 124 | return ['type' => $m[1], 'key' => $m[2], 'comment' => trim($m[3] ?? ''), 'fingerprint' => 'SHA256:'.rtrim(base64_encode(hash('sha256', $blob, true)), '='), 'bits' => $bits]; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * 鍵を追加する(同じ鍵は他のユーザーでも使えない) |
| 129 | */ |
| 130 | public static function addKey(string $user, string $line, string $title = ''): Result { |
| 131 | $k = self::parse($line); |
| 132 | if ($k === null) return Result::Error('不正な公開鍵です(ssh-ed25519、ecdsa、2048ビット以上のssh-rsa等の「種類 鍵 コメント」の1行)。'); |
| 133 | if (self::find($k['type'], $k['key']) !== null) return Result::Error('この鍵は既に登録されています。'); |
| 134 | $data = self::load($user); |
| 135 | $title = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $title)) ?: ($k['comment'] !== '' ? $k['comment'] : $k['type']); |
| 136 | $data['keys'][] = ['title' => mb_substr($title, 0, 100), 'type' => $k['type'], 'key' => $k['key'], 'fingerprint' => $k['fingerprint'], 'added' => time(), 'lastUsed' => null]; |
| 137 | return self::save($user, $data) ? Result::Success("鍵「{$title}」を追加しました。", ['fingerprint' => $k['fingerprint']]) : Result::Error('鍵を保存出来ません。'); |
| 138 | } |
| 139 | |
| 140 | public static function removeKey(string $user, string $fingerprint): bool { |
| 141 | $data = self::load($user); |
| 142 | $before = count($data['keys']); |
| 143 | $data['keys'] = array_values(array_filter($data['keys'], fn($k) => ($k['fingerprint'] ?? '') !== $fingerprint)); |
| 144 | return count($data['keys']) < $before && self::save($user, $data); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * 鍵の持ち主 |
| 149 | * |
| 150 | * @return array|null [ユーザー名, 鍵] |
| 151 | */ |
| 152 | public static function find(string $type, string $key): ?array { |
| 153 | foreach (self::all() as $user => $keys) { |
| 154 | foreach ($keys as $k) if (($k['type'] ?? '') === $type && hash_equals((string)($k['key'] ?? ''), $key)) return [$user, $k]; |
| 155 | } |
| 156 | return null; |
| 157 | } |
| 158 | |
| 159 | /** 全部の鍵(ユーザー名 => 鍵の配列) */ |
| 160 | public static function all(): array { |
| 161 | $out = []; |
| 162 | foreach (glob(self::dir().'/*.json') ?: [] as $f) { |
| 163 | $user = basename($f, '.json'); |
| 164 | if (!preg_match('/^[A-Za-z0-9]+$/', $user)) continue; |
| 165 | $keys = self::load($user)['keys']; |
| 166 | if (!empty($keys)) $out[$user] = $keys; |
| 167 | } |
| 168 | return $out; |
| 169 | } |
| 170 | |
| 171 | /** 最後に使った時刻 */ |
| 172 | public static function touchKey(string $user, string $fingerprint): void { |
| 173 | $data = self::load($user); |
| 174 | foreach ($data['keys'] as $i => $k) { |
| 175 | if (($k['fingerprint'] ?? '') !== $fingerprint) continue; |
| 176 | // 1時間に1回だけ書く |
| 177 | if (($k['lastUsed'] ?? 0) > time() - 3600) return; |
| 178 | $data['keys'][$i]['lastUsed'] = time(); |
| 179 | self::save($user, $data); |
| 180 | return; |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * authorized_keysの1行(command=でgitler.php serveを実行させる) |
| 186 | */ |
| 187 | public static function authorizedKey(string $user, array $key): string { |
| 188 | $php = (string)Gitler::config()['php']; |
| 189 | $cmd = $php.' '.ROOT.'/gitler.php serve '.$user.' '.$key['fingerprint']; |
| 190 | return 'restrict,command="'.str_replace(['\\', '"'], ['\\\\', '\\"'], $cmd).'" '.$key['type'].' '.$key['key']; |
| 191 | } |
| 192 | |
| 193 | //////// アクセストークン |
| 194 | |
| 195 | /** |
| 196 | * トークンを作る(表示は1回だけ、保存するのはハッシュ) |
| 197 | * |
| 198 | * @param bool $write プッシュも出来る(falseなら読むだけ) |
| 199 | * @return Result data:token(平文)、id |
| 200 | */ |
| 201 | public static function addToken(string $user, string $name, bool $write): Result { |
| 202 | $name = trim(preg_replace('/[\x00-\x1f\x7f]/', '', $name)); |
| 203 | if ($name === '' || mb_strlen($name) > 100) return Result::Error('トークンの名前を100文字以内で入力して下さい。'); |
| 204 | $token = 'lbg_'.bin2hex(random_bytes(20)); |
| 205 | $id = bin2hex(random_bytes(4)); |
| 206 | $data = self::load($user); |
| 207 | $data['tokens'][] = ['id' => $id, 'name' => $name, 'hash' => hash('sha256', $token), 'write' => $write, 'created' => time(), 'lastUsed' => null]; |
| 208 | return self::save($user, $data) ? Result::Success("トークン「{$name}」を作りました。", ['token' => $token, 'id' => $id]) : Result::Error('トークンを保存出来ません。'); |
| 209 | } |
| 210 | |
| 211 | public static function removeToken(string $user, string $id): bool { |
| 212 | $data = self::load($user); |
| 213 | $before = count($data['tokens']); |
| 214 | $data['tokens'] = array_values(array_filter($data['tokens'], fn($t) => ($t['id'] ?? '') !== $id)); |
| 215 | return count($data['tokens']) < $before && self::save($user, $data); |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * トークンを確かめる |
| 220 | * |
| 221 | * @return array|null トークン(write等) |
| 222 | */ |
| 223 | /** Gitlerの通知をメールでも受け取るか(既定は受け取る) */ |
| 224 | public static function setEmail(string $user, bool $on): bool { |
| 225 | $data = self::load($user); |
| 226 | $data['email'] = $on; |
| 227 | return self::save($user, $data); |
| 228 | } |
| 229 | |
| 230 | public static function checkToken(string $user, string $token): ?array { |
| 231 | if (!preg_match('/^[A-Za-z0-9]+$/', $user) || !str_starts_with($token, 'lbg_')) return null; |
| 232 | $hash = hash('sha256', $token); |
| 233 | $data = self::load($user); |
| 234 | foreach ($data['tokens'] as $i => $t) { |
| 235 | if (!hash_equals((string)($t['hash'] ?? ''), $hash)) continue; |
| 236 | if (($t['lastUsed'] ?? 0) < time() - 3600) { |
| 237 | $data['tokens'][$i]['lastUsed'] = time(); |
| 238 | self::save($user, $data); |
| 239 | } |
| 240 | return $t; |
| 241 | } |
| 242 | return null; |
| 243 | } |
| 244 | } |