dataKey = sodium_crypto_kdf_derive_from_key( SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_KEYBYTES, self::SUBKEY_DATA, self::KDF_CONTEXT, $masterKey); $this->hashKey = sodium_crypto_kdf_derive_from_key( SODIUM_CRYPTO_GENERICHASH_KEYBYTES, self::SUBKEY_HASH, self::KDF_CONTEXT, $masterKey); } public static function generate(): self { return new self(sodium_crypto_kdf_keygen()); } /** Raw master key, only for storing it encrypted on the server (ICrypto). */ public function exportMasterKey(): string { return $this->masterKey; } public function dataKey(): string { return $this->dataKey; } /** Keyed BLAKE2b of plaintext, so blob ids reveal nothing about the content. */ public function blobId(string $plaintext): string { return sodium_bin2hex(sodium_crypto_generichash($plaintext, $this->hashKey, 32)); } /** * Seal the master key with a passphrase (Argon2id). The result is what goes into the * repository config and the recovery kit. * * @return array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} */ public function wrap(#[\SensitiveParameter] string $passphrase): array { $salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES); $ops = SODIUM_CRYPTO_PWHASH_OPSLIMIT_MODERATE; $mem = SODIUM_CRYPTO_PWHASH_MEMLIMIT_MODERATE; $kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $salt, $ops, $mem, SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13); $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); $sealed = sodium_crypto_secretbox($this->masterKey, $nonce, $kek); sodium_memzero($kek); return [ 'v' => 1, 'salt' => sodium_bin2base64($salt, SODIUM_BASE64_VARIANT_ORIGINAL), 'ops' => $ops, 'mem' => $mem, 'nonce' => sodium_bin2base64($nonce, SODIUM_BASE64_VARIANT_ORIGINAL), 'key' => sodium_bin2base64($sealed, SODIUM_BASE64_VARIANT_ORIGINAL), ]; } /** * Open a key field that is either one wrapped key or {"slots": [{slot, label, key}, ...]}: * every slot holds the same master key under its own passphrase. */ public static function unwrapAny(array $field, #[\SensitiveParameter] string $passphrase): self { if (!isset($field['slots'])) { return self::unwrap($field, $passphrase); } foreach ($field['slots'] as $slot) { try { return self::unwrap($slot['key'], $passphrase); } catch (CryptoException) { } } throw new CryptoException('Wrong passphrase or damaged key'); } /** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */ public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self { $b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL); // ops/mem come from unauthenticated JSON (kit file, repository config): bound them, so a // damaged or crafted file cannot make the KDF use unbounded memory or time. if ($wrapped['ops'] < SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE || $wrapped['ops'] > SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE || $wrapped['mem'] < SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE || $wrapped['mem'] > SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE) { throw new CryptoException('Unsupported key derivation parameters (damaged or foreign key file)'); } $kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']), $wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13); $master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek); sodium_memzero($kek); if ($master === false) { throw new CryptoException('Wrong passphrase or damaged key'); } return new self($master); } public function __destruct() { sodium_memzero($this->masterKey); sodium_memzero($this->dataKey); sodium_memzero($this->hashKey); } }