042fdae686
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
- Unreadable settings or an invalid delay on the location: 365 days, so nothing leaves the Bin sooner than on the old server - A passphrase change that still cannot reach a location is logged; removing a location clears its flag - A damaged slot (missing parts, invalid base64) is skipped and the next slot is tried - Tests: damaged settings scenario, damaged slot, upgrade test checks the migration and the time zone
127 lines
5.1 KiB
PHP
127 lines
5.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OCA\NgBackup\Crypto;
|
|
|
|
/**
|
|
* Holds the repository master key and derives the subkeys used for encryption and for the
|
|
* keyed blob hashes. The master key never leaves this object unencrypted, except through
|
|
* wrap(), which seals it with a key derived from the admin's passphrase.
|
|
*/
|
|
final class KeyRing {
|
|
private const KDF_CONTEXT = 'ngbackup';
|
|
private const SUBKEY_DATA = 1;
|
|
private const SUBKEY_HASH = 2;
|
|
|
|
private string $dataKey;
|
|
private string $hashKey;
|
|
|
|
public function __construct(
|
|
#[\SensitiveParameter]
|
|
private string $masterKey,
|
|
) {
|
|
if (strlen($masterKey) !== SODIUM_CRYPTO_KDF_KEYBYTES) {
|
|
throw new \InvalidArgumentException('Master key must be ' . SODIUM_CRYPTO_KDF_KEYBYTES . ' bytes');
|
|
}
|
|
$this->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|\SodiumException|\TypeError|\ValueError) {
|
|
// a wrong passphrase for this slot, or a damaged slot: the next one may still open
|
|
}
|
|
}
|
|
throw new CryptoException('Wrong passphrase or damaged key');
|
|
}
|
|
|
|
/** @param array<string, mixed> $wrapped from a kit file or a location's config: checked here, not trusted */
|
|
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
|
if (!isset($wrapped['salt'], $wrapped['nonce'], $wrapped['key'], $wrapped['ops'], $wrapped['mem'])
|
|
|| !is_string($wrapped['salt']) || !is_string($wrapped['nonce']) || !is_string($wrapped['key'])
|
|
|| !is_int($wrapped['ops']) || !is_int($wrapped['mem'])) {
|
|
throw new CryptoException('Damaged key file: a part of the key is missing');
|
|
}
|
|
$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);
|
|
}
|
|
}
|