M1: key management, backup locations and occ commands
- migration: ngb_targets, ngb_runs, ngb_snapshots - KeyService: master key stored encrypted with the instance secret (unattended backups), passphrase-wrapped copy in every repository and in the recovery kit, recorded admin confirmation per key fingerprint - TargetService: locations built from files_external backends without a mount; files_external is loaded through IAppManager::loadApp() and its enabled state is left alone; credentials encrypted with ICrypto; existing repositories are adopted only if made with this key, and a repository cannot be registered twice - Repository: initWithKey/openWithKey with an encrypted key check - occ: backup:key:init|kit|confirm, backup:target:backends|add|list|remove
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Crypto\KeyRing;
|
||||
use OCP\IAppConfig;
|
||||
use OCP\Security\ICrypto;
|
||||
|
||||
/**
|
||||
* The installation's backup master key.
|
||||
*
|
||||
* - The server keeps the master key encrypted with Nextcloud's own secret (ICrypto), so
|
||||
* scheduled backups run without anyone typing a passphrase. Whoever fully controls the server
|
||||
* can therefore read the backups; whoever only gets the backup location cannot.
|
||||
* - A copy wrapped with the admin's passphrase (Argon2id) goes into every repository config and
|
||||
* into the recovery kit, so backups can be restored on a fresh server with the passphrase.
|
||||
* - The admin must confirm having stored the recovery kit before the first backup runs.
|
||||
*/
|
||||
class KeyService {
|
||||
private const KEY_MASTER = 'master_key';
|
||||
private const KEY_WRAPPED = 'master_key_wrapped';
|
||||
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
|
||||
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
||||
|
||||
private ?KeyRing $cached = null;
|
||||
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
private ICrypto $crypto,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isInitialized(): bool {
|
||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
|
||||
}
|
||||
|
||||
public function initialize(#[\SensitiveParameter] string $passphrase): void {
|
||||
if ($this->isInitialized()) {
|
||||
throw new \RuntimeException('A backup key already exists');
|
||||
}
|
||||
if (strlen($passphrase) < 12) {
|
||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
||||
}
|
||||
$keys = KeyRing::generate();
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
||||
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED,
|
||||
json_encode($keys->wrap($passphrase), JSON_THROW_ON_ERROR), true, true);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
|
||||
$this->cached = $keys;
|
||||
}
|
||||
|
||||
public function keyRing(): KeyRing {
|
||||
if ($this->cached === null) {
|
||||
$enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
|
||||
if ($enc === '') {
|
||||
throw new \RuntimeException('No backup key yet: run occ backup:key:init');
|
||||
}
|
||||
$this->cached = new KeyRing(base64_decode($this->crypto->decrypt($enc), true));
|
||||
}
|
||||
return $this->cached;
|
||||
}
|
||||
|
||||
/** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */
|
||||
public function wrappedKey(): array {
|
||||
return json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** Short identifier of the key, shown in the UI and the kit (not secret). */
|
||||
public function fingerprint(): string {
|
||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
|
||||
}
|
||||
|
||||
/** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */
|
||||
public function recoveryKit(): array {
|
||||
return [
|
||||
'version' => 1,
|
||||
'app' => 'NG Backup',
|
||||
'fingerprint' => $this->fingerprint(),
|
||||
'created' => gmdate('c'),
|
||||
'wrapped_key' => $this->wrappedKey(),
|
||||
'instructions' => 'Keep this file and your passphrase safe, outside this server. To restore on a new server: install NG Backup, '
|
||||
. 'add the backup location, and enter this kit with your passphrase. Without both, the backups cannot be decrypted.',
|
||||
];
|
||||
}
|
||||
|
||||
public function confirmRecoveryKit(string $uid): void {
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
|
||||
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint()], JSON_THROW_ON_ERROR), true);
|
||||
}
|
||||
|
||||
/** Confirmation for the current key, or null. */
|
||||
public function recoveryKitConfirmation(): ?array {
|
||||
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
|
||||
$data = $raw === '' ? null : json_decode($raw, true);
|
||||
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint()) ? $data : null;
|
||||
}
|
||||
|
||||
private function fingerprintOf(KeyRing $keys): string {
|
||||
$id = $keys->blobId('ng_backup key fingerprint');
|
||||
return implode('-', str_split(substr($id, 0, 16), 4));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
use OCA\NgBackup\Backend\ExternalStorageFactory;
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
use OCA\NgBackup\Backend\StorageBackend;
|
||||
use OCA\NgBackup\Db\Target;
|
||||
use OCA\NgBackup\Db\TargetMapper;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
use OCP\App\IAppManager;
|
||||
use OCP\Security\ICrypto;
|
||||
use OCP\Server;
|
||||
|
||||
/**
|
||||
* Backup targets: a files_external backend + auth mechanism + options + base path. The storage
|
||||
* object is built only while it is used; nothing is mounted. files_external is loaded for its
|
||||
* classes but its enabled/disabled state is left as the admin set it.
|
||||
*/
|
||||
class TargetService {
|
||||
public function __construct(
|
||||
private TargetMapper $mapper,
|
||||
private KeyService $keys,
|
||||
private ICrypto $crypto,
|
||||
private IAppManager $appManager,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
|
||||
public function availableBackends(): array {
|
||||
return $this->factory()->describeBackends();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a target: connect, create the repository there (or adopt an existing one made with this
|
||||
* installation's key), then save it.
|
||||
*
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{target: Target, created: bool}
|
||||
*/
|
||||
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
|
||||
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
||||
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
||||
}
|
||||
try {
|
||||
$this->mapper->findByName($name);
|
||||
throw new \InvalidArgumentException("A target named '$name' already exists");
|
||||
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||
}
|
||||
$keys = $this->keys->keyRing();
|
||||
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
||||
if ($storageBackend->exists('config')) {
|
||||
$repo = Repository::openWithKey($storageBackend, $keys);
|
||||
$created = false;
|
||||
} else {
|
||||
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey());
|
||||
$created = true;
|
||||
}
|
||||
|
||||
foreach ($this->mapper->findAll() as $existing) {
|
||||
if ($existing->getRepositoryId() === $repo->id()) {
|
||||
throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'");
|
||||
}
|
||||
}
|
||||
|
||||
$target = new Target();
|
||||
$target->setName($name);
|
||||
$target->setBackend($backend);
|
||||
$target->setAuth($auth);
|
||||
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
||||
$target->setBasePath(trim($basePath, '/'));
|
||||
$target->setRepositoryId($repo->id());
|
||||
$target->setCreatedAt(time());
|
||||
return ['target' => $this->mapper->insert($target), 'created' => $created];
|
||||
}
|
||||
|
||||
/** @return list<Target> */
|
||||
public function list(): array {
|
||||
return $this->mapper->findAll();
|
||||
}
|
||||
|
||||
public function get(string $nameOrId): Target {
|
||||
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
|
||||
}
|
||||
|
||||
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
||||
public function remove(Target $target): void {
|
||||
$this->mapper->delete($target);
|
||||
}
|
||||
|
||||
public function backend(Target $target): IBackend {
|
||||
return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath());
|
||||
}
|
||||
|
||||
public function repository(Target $target): Repository {
|
||||
$repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing());
|
||||
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
||||
throw new \RuntimeException('The target now contains a different repository than when it was added');
|
||||
}
|
||||
return $repo;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function options(Target $target): array {
|
||||
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
|
||||
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
||||
}
|
||||
|
||||
private function factory(): ExternalStorageFactory {
|
||||
// Load files_external's classes without enabling the app (its state stays as the admin set it).
|
||||
if (!class_exists(\OCA\Files_External\Service\BackendService::class)) {
|
||||
$this->appManager->loadApp('files_external');
|
||||
}
|
||||
return new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user