로그인
KO
<?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);    }}