<?phpnamespace App\Model;use JumPHP\Save\Storage;/** * The saved passwords of the servers are never written in clear in var/servers.json: each one is sealed here. * - With a master password (Tools > Master Password…, like XenCenter's): the key is made from it (Argon2id with libsodium, else * PBKDF2-SHA256) and a random salt, kept in var/keyring.json with a sealed check text (a wrong password is told apart). It is asked * once at start (MasterPasswordForm) before the saved servers reconnect. * - Without one: a random key kept in var/keyring.key (0600). This only protects servers.json against casual reading: whoever reads * your files reads the key too. * Ciphers, by what PHP has: libsodium's secretbox (XSalsa20-Poly1305, "sb1:"), else OpenSSL AES-256-GCM ("gcm1:"), else (the browser's * PHP has neither) HMAC-SHA256 as a counter-mode stream cipher, encrypt-then-MAC with HMAC-SHA256 ("hm1:"). A sealed text names its own. * Passwords are never logged, shown or put in an exception message. */final class Keyring{ public const STATE = 'keyring.json'; public const KEY_FILE = 'keyring.key'; public const CIPHERS = ['sodium', 'gcm', 'hmac']; private const CHECK = 'xencenter-keyring-v1'; private const PBKDF2_ROUNDS = 120000; private ?string $key = null; /** @var array<string, mixed> */ private array $state; private string $cipher; public function __construct(private Storage $storage, ?string $cipher = null) { $this->cipher = $cipher ?? self::bestCipher(); $state = $storage->readJson(self::STATE, []); $this->state = is_array($state) ? $state : []; } public static function bestCipher(): string { if (function_exists('sodium_crypto_secretbox')) { return 'sodium'; } if (function_exists('openssl_encrypt') && in_array('aes-256-gcm', openssl_get_cipher_methods(), true)) { return 'gcm'; } return 'hmac'; } public function cipher(): string { return $this->cipher; } /** In words, for the Master Password dialog. */ public function describe(): string { $kdf = (string)($this->state['kdf'] ?? ($this->cipher === 'sodium' ? 'argon2id' : 'pbkdf2')); $cipher = match ($this->cipher) { 'sodium' => 'XSalsa20-Poly1305 (libsodium)', 'gcm' => 'AES-256-GCM (OpenSSL)', default => 'HMAC-SHA256 stream + HMAC'}; return $this->hasMaster() ? ($kdf === 'argon2id' ? 'Argon2id' : 'PBKDF2-SHA256') . ' + ' . $cipher : $cipher . ', random key in var/' . self::KEY_FILE; } public function hasMaster(): bool { return !empty($this->state['master']); } /** Can seal and open now: always without a master password, after unlock() with one. */ public function isUnlocked(): bool { return $this->key !== null || !$this->hasMaster(); } /** Tries a master password: true when it is the right one (the key is then kept for this run). */ public function unlock(string $master): bool { if (!$this->hasMaster()) { return true; } $key = $this->derive($master, (string)base64_decode((string)($this->state['salt'] ?? '')), (string)($this->state['kdf'] ?? 'pbkdf2')); if ($key === null || $this->openWith((string)($this->state['check'] ?? ''), $key) !== self::CHECK) { return false; } $this->key = $key; return true; } /** A new master password: a new salt, a new key (what was sealed before must be sealed again: Session::save()). */ public function setMaster(string $master): void { $kdf = $this->cipher === 'sodium' && function_exists('sodium_crypto_pwhash') ? 'argon2id' : 'pbkdf2'; $salt = random_bytes(16); $key = (string)$this->derive($master, $salt, $kdf); $this->state = ['master' => true, 'kdf' => $kdf, 'salt' => base64_encode($salt), 'check' => $this->sealWith(self::CHECK, $key)]; $this->storage->writeJson(self::STATE, $this->state); $this->storage->delete(self::KEY_FILE); $this->key = $key; } /** Back to the random key of var/keyring.key. */ public function removeMaster(): void { $this->state = []; $this->storage->delete(self::STATE); $this->key = $this->localKey(true); } /** Everything goes: the master password, the keys (the sealed passwords cannot be opened any more). */ public function forget(): void { $this->state = []; $this->key = null; $this->storage->delete(self::STATE); $this->storage->delete(self::KEY_FILE); } /** A password sealed with the current key; null while locked by a master password not given yet. */ public function seal(string $plain): ?string { if (!$this->isUnlocked()) { return null; } $this->key ??= $this->localKey(true); return $this->key === null ? null : $this->sealWith($plain, $this->key); } /** The password back; null when locked, damaged or sealed with another key. */ public function open(string $sealed): ?string { if (!$this->isUnlocked()) { return null; } $this->key ??= $this->localKey(false); return $this->key === null ? null : $this->openWith($sealed, $this->key); } public static function isSealed(string $text): bool { return (bool)preg_match('/^(sb1|gcm1|hm1):/', $text); } // the saved servers ------------------------------------------------------------------------------------------------------- /** * After var/servers.json is read: the sealed passwords are opened (when the key is there), and a file of an older version that * held clear passwords is written again, sealed. */ public function restore(Session $session): void { $migrate = false; foreach ($session->connections as $c) { $p = $c->profile; if ($p->password !== '') { $migrate = $migrate || ($p->remember && $p->secret === ''); continue; } if ($p->secret !== '' && $this->isUnlocked()) { $p->password = $this->open($p->secret) ?? ''; } } if ($migrate && $this->isUnlocked()) { $session->save(); } } /** True when saved passwords wait for the master password (the start of the app asks it). */ public function locked(Session $session): bool { if ($this->isUnlocked()) { return false; } foreach ($session->connections as $c) { if ($c->profile->remember && $c->profile->secret !== '' && $c->profile->password === '') { return true; } } return false; } /** The master password given at start: the saved passwords are opened. */ public function unlockSession(Session $session, string $master): bool { if (!$this->unlock($master)) { return false; } $this->restore($session); return true; } /** Sets, changes ($new) or removes ('' / null) the master password, and seals every saved password again. */ public function changeMaster(Session $session, ?string $new): void { $new === null || $new === '' ? $this->removeMaster() : $this->setMaster($new); foreach ($session->connections as $c) { $c->profile->secret = ''; } $session->save(); } /** "Forget saved passwords": no password is kept any more (the servers stay, asked for their password at the next connection). */ public function forgetAll(Session $session): void { $this->forget(); foreach ($session->connections as $c) { if ($c->profile->method !== 'demo') { $c->profile->password = ''; } $c->profile->secret = ''; } $session->save(); } // the ciphers ------------------------------------------------------------------------------------------------------------- private function localKey(bool $create): ?string { $text = $this->storage->read(self::KEY_FILE); $key = $text === null ? '' : (string)base64_decode(trim($text), true); if (strlen($key) === 32) { return $key; } if (!$create) { return null; } $key = random_bytes(32); $this->storage->write(self::KEY_FILE, base64_encode($key) . "\n"); @chmod($this->storage->path(self::KEY_FILE), 0600); return $key; } private function derive(string $password, string $salt, string $kdf): ?string { if ($kdf === 'argon2id') { if (!function_exists('sodium_crypto_pwhash')) { return null; // made where libsodium exists: not openable here } return sodium_crypto_pwhash(32, $password, $salt, SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13); } return hash_pbkdf2('sha256', $password, $salt, self::PBKDF2_ROUNDS, 32, true); } private function sealWith(string $plain, string $key): string { switch ($this->cipher) { case 'sodium': $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); return 'sb1:' . base64_encode($nonce . sodium_crypto_secretbox($plain, $nonce, $key)); case 'gcm': $iv = random_bytes(12); $tag = ''; $data = (string)openssl_encrypt($plain, 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag, 'xencenter', 16); return 'gcm1:' . base64_encode($iv . $tag . $data); default: $nonce = random_bytes(16); $data = $plain ^ self::stream($key, $nonce, strlen($plain)); return 'hm1:' . base64_encode($nonce . $data . hash_hmac('sha256', 'hm1' . $nonce . $data, hash_hmac('sha256', 'mac', $key, true), true)); } } private function openWith(string $sealed, string $key): ?string { if (!preg_match('/^(sb1|gcm1|hm1):(.*)$/s', $sealed, $m)) { return null; } $raw = base64_decode($m[2], true); if ($raw === false) { return null; } switch ($m[1]) { case 'sb1': if (!function_exists('sodium_crypto_secretbox_open') || strlen($raw) < SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES) { return null; } $plain = sodium_crypto_secretbox_open(substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES), $key); return $plain === false ? null : $plain; case 'gcm1': if (!function_exists('openssl_decrypt') || strlen($raw) < 28) { return null; } $plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16), 'xencenter'); return $plain === false ? null : $plain; default: if (strlen($raw) < 48) { return null; } $nonce = substr($raw, 0, 16); $data = substr($raw, 16, -32); $mac = hash_hmac('sha256', 'hm1' . $nonce . $data, hash_hmac('sha256', 'mac', $key, true), true); if (!hash_equals($mac, substr($raw, -32))) { return null; } return $data ^ self::stream($key, $nonce, strlen($data)); } } /** HMAC-SHA256(enc key, nonce || counter) blocks: a keystream (the PRF in counter mode). */ private static function stream(string $key, string $nonce, int $length): string { $encKey = hash_hmac('sha256', 'enc', $key, true); $out = ''; for ($i = 0; strlen($out) < $length; $i++) { $out .= hash_hmac('sha256', $nonce . pack('N', $i), $encKey, true); } return substr($out, 0, $length); }}