From 9b03f98d0d5f690e4e70783679b7d2fb9daba466 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Fri, 2 Oct 2026 03:01:44 +0200 Subject: [PATCH] M5: deletion delay with trash, passphrase slots, ransomware documentation - removed snapshots go to a trash on the location and their data is only freed after the deletion delay, which is chosen when the key is created (default 7 days, 1-365) and cannot be changed afterwards; any admin can bring them back (occ backup:trash, backup:trash:restore, web UI) - up to three passphrase slots open the same master key (like LUKS), so one person leaving or dying does not lock the company out; slot changes are written to every location, announced to all admins and require a new recovery kit confirmation, while backups keep running - occ backup:key:slots, backup:key:slot:add|replace|remove; key init takes --label and --delete-delay-days - web UI: deletion delay and first slot at key creation, slot management, trash per location with restore; all behind password confirmation - README: key, slots (safe / CTO / head of IT), and protection against ransomware: object lock in compliance mode (e.g. 90 days), credentials without delete rights, provider snapshots, off-site copy --- README.md | 112 ++++++++++++++++++++- appinfo/info.xml | 8 +- appinfo/routes.php | 5 + lib/Command/KeyInit.php | 6 +- lib/Command/KeySlotAdd.php | 17 ++++ lib/Command/KeySlotRemove.php | 17 ++++ lib/Command/KeySlotReplace.php | 17 ++++ lib/Command/KeySlots.php | 97 ++++++++++++++++++ lib/Command/Trash.php | 49 ++++++++++ lib/Command/TrashRestore.php | 42 ++++++++ lib/Controller/AdminApiController.php | 63 +++++++++++- lib/Crypto/KeyRing.php | 17 ++++ lib/Notification/Notifier.php | 6 +- lib/Repository/Repository.php | 93 ++++++++++++++++-- lib/Service/AlertService.php | 6 +- lib/Service/BackupService.php | 2 +- lib/Service/KeyService.php | 135 ++++++++++++++++++++++++-- lib/Service/KeySlotService.php | 61 ++++++++++++ lib/Service/PruneService.php | 44 ++++++++- lib/SetupCheck/BackupStatusCheck.php | 5 +- src/AdminSettings.vue | 2 +- src/api.js | 7 +- src/components/Backups.vue | 43 ++++++++ src/components/KeySetup.vue | 132 +++++++++++++++++-------- tests/Unit/Crypto/KeyRingTest.php | 15 +++ tests/Unit/Repository/PruneTest.php | 32 +++++- 26 files changed, 955 insertions(+), 78 deletions(-) create mode 100644 lib/Command/KeySlotAdd.php create mode 100644 lib/Command/KeySlotRemove.php create mode 100644 lib/Command/KeySlotReplace.php create mode 100644 lib/Command/KeySlots.php create mode 100644 lib/Command/Trash.php create mode 100644 lib/Command/TrashRestore.php create mode 100644 lib/Service/KeySlotService.php diff --git a/README.md b/README.md index 2f66eeb..432e58c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,114 @@ # NG Backup -Encrypted, incremental backups of Nextcloud with restore from the web interface. -Works without shell access: no external programs, no extra PHP extensions beyond sodium. +Encrypted, incremental backups of Nextcloud (database, configuration and data) with restore +from the web interface. Works without shell access: no external programs, no extra PHP +extensions beyond sodium. Runs on Nextcloud All-in-One, regular installations and hosted +Nextcloud. -**Status: phase 0 (prototype). Not usable yet.** See `PLAN.md` for the design. +**Status: in development (phase 1). Not ready for production use.** See `PLAN.md` for the design. License: AGPL-3.0-or-later. + +## How it works + +- Backups go to a **location**: S3 (and S3-compatible storage), SFTP, WebDAV, FTP, Swift or a + local path. NG Backup uses the connection code of Nextcloud's External storage app, but never + mounts the location: it is only connected while a backup or restore runs, and it never + appears in Files, for anyone. +- Data is cut into blocks, deduplicated, compressed and encrypted (XChaCha20-Poly1305) before it + leaves the server, and streamed straight to the location: no local copy, no double disk space. + Unchanged files are not read again; only new data is uploaded. +- The database is dumped through Nextcloud's own connection, inside one consistent transaction. +- Restores go through Nextcloud, so overwritten files keep their old content as a version and + removed files go to the trash. + +## Quick start + +``` +occ backup:key:init --label="Safe" # first passphrase, deletion delay (default 7 days) +occ backup:key:slot:add --label="CTO" # optional second and third passphrase +occ backup:key:slot:add --label="Head of IT" +occ backup:key:kit --output=/root/ng_backup-kit.json +occ backup:key:confirm +occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=... -o key=... -o secret=... +occ backup:run +``` + +Or use **Administration settings → NG Backup**. + +## The backup key, passphrases and the recovery kit + +All backups of an installation are encrypted with one master key. + +- The **server** keeps the master key, encrypted with the instance secret, so scheduled backups + and restores work without anyone typing a passphrase. Any administrator can restore from the + web interface. Consequence: whoever fully controls the server can read the backups; whoever + only gets hold of the backup location cannot. +- For a **restore on a new server** (disaster recovery) you need the **recovery kit** and **one + passphrase**. The kit contains the master key, encrypted separately with each passphrase. +- Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not + lock the company out. A suggested setup: + + | Slot | Kept by | + |---|---| + | 1 | in the safe (sealed envelope) | + | 2 | the CTO | + | 3 | the head of IT | + + Every holder keeps a copy of the same recovery kit and only knows their own passphrase. Using + a single slot is possible, but then that one passphrase is a single point of failure. +- When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download + the new kit and confirm it. Note: an old kit together with the old passphrase still opens the + same master key; to revoke it completely, start new locations with a new key. +- Downloading the kit, and every change to the slots, requires the administrator's password, is + written to the audit log and is announced to all administrators. + +## Protection against ransomware and a compromised server + +A backup is only useful if an attacker cannot destroy it. An attacker with administrator access +to Nextcloud could try to remove old restore points so that only infected data remains. +NG Backup makes this hard and visible, but **software alone can never guarantee it**: the +location must refuse deletions itself. + +What NG Backup does: + +- **Deletion delay.** Removed restore points first go to a trash on the location and are only + deleted after the delay chosen at installation (default 7 days, cannot be changed afterwards). + Within that time any administrator can bring them back (`occ backup:trash:restore`, or in the + web interface). +- **Notifications to all administrators** when restore points are removed, the retention is + changed, append-only is turned off, a location is removed or the recovery kit is downloaded, + and an entry in the audit log (admin_audit). +- **Append-only locations** (`occ backup:target:append-only NAME on`): NG Backup never deletes + anything there; old data is cleaned up by the location's own lifecycle rules. +- Removing a location in NG Backup only removes the connection. The backups on the location are + not touched and nothing keeps cleaning up in the background. + +What you should do on the location (strongly recommended): + +- **S3: enable Object Lock** on the bucket, in **compliance mode**, with a default retention of + for example **90 days**, plus versioning. Then nobody, not even with the server's credentials, + can delete or overwrite backups younger than 90 days. Combine with an append-only location in + NG Backup and a lifecycle rule that removes old versions after the lock expires. +- Use **credentials without delete rights** for the backup location where the storage supports it. +- **SFTP / storage boxes:** enable the provider's own snapshots (e.g. daily, kept 30-90 days) and + give the backup user access to its own folder only, without a shell. +- Keep at least one location **off-site**, on a different network than the Nextcloud server. + +## Restore + +- **Files and folders** of a user: in the web interface or `occ backup:restore`, into a new folder + ("Restored "), merged into the original place, or replacing it. +- **Raw restore** of any path (for example `config/`) to a local directory: `occ backup:restore ... --to-directory=DIR`. +- **Whole server** (disaster recovery onto a fresh installation): planned for phase 2. + +## Commands + +| Command | Purpose | +|---|---| +| `backup:key:init`, `backup:key:kit`, `backup:key:confirm` | create the key, recovery kit, confirmation | +| `backup:key:slots`, `backup:key:slot:add/replace/remove` | passphrase slots | +| `backup:target:backends/add/list/remove/append-only` | backup locations | +| `backup:run`, `backup:status`, `backup:list` | back up, status, restore points | +| `backup:browse`, `backup:restore` | browse and restore | +| `backup:retention`, `backup:prune`, `backup:trash`, `backup:trash:restore` | retention, cleanup, trash | diff --git a/appinfo/info.xml b/appinfo/info.xml index 48a24a6..9e0c632 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ NG Backup Encrypted, incremental backups of your Nextcloud with restore from the web interface - 0.0.8 + 0.0.11 agpl Dillard Blom NgBackup @@ -35,6 +35,12 @@ OCA\NgBackup\Command\Prune OCA\NgBackup\Command\Retention OCA\NgBackup\Command\TargetAppendOnly + OCA\NgBackup\Command\Trash + OCA\NgBackup\Command\TrashRestore + OCA\NgBackup\Command\KeySlots + OCA\NgBackup\Command\KeySlotAdd + OCA\NgBackup\Command\KeySlotReplace + OCA\NgBackup\Command\KeySlotRemove OCA\NgBackup\Settings\Admin diff --git a/appinfo/routes.php b/appinfo/routes.php index 95b78ce..de72e81 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -11,6 +11,11 @@ return [ ['name' => 'AdminApi#initKey', 'url' => '/api/key', 'verb' => 'POST'], ['name' => 'AdminApi#downloadKit', 'url' => '/api/key/kit', 'verb' => 'GET'], ['name' => 'AdminApi#confirmKit', 'url' => '/api/key/confirm', 'verb' => 'POST'], + ['name' => 'AdminApi#addSlot', 'url' => '/api/key/slots', 'verb' => 'POST'], + ['name' => 'AdminApi#replaceSlot', 'url' => '/api/key/slots/{slot}', 'verb' => 'PUT'], + ['name' => 'AdminApi#removeSlot', 'url' => '/api/key/slots/{slot}', 'verb' => 'DELETE'], + ['name' => 'AdminApi#trash', 'url' => '/api/targets/{targetId}/trash', 'verb' => 'GET'], + ['name' => 'AdminApi#untrash', 'url' => '/api/targets/{targetId}/trash/{snapshotId}', 'verb' => 'POST'], ['name' => 'AdminApi#backends', 'url' => '/api/backends', 'verb' => 'GET'], ['name' => 'AdminApi#addTarget', 'url' => '/api/targets', 'verb' => 'POST'], ['name' => 'AdminApi#removeTarget', 'url' => '/api/targets/{id}', 'verb' => 'DELETE'], diff --git a/lib/Command/KeyInit.php b/lib/Command/KeyInit.php index 8bec0f4..eb09eea 100644 --- a/lib/Command/KeyInit.php +++ b/lib/Command/KeyInit.php @@ -25,6 +25,8 @@ class KeyInit extends Command { $this->setName('backup:key:init') ->setDescription('Create the backup key, protected by a passphrase you keep safe') ->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking') + ->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label of the first passphrase slot (who/where), e.g. "Safe"', 'Slot 1') + ->addOption('delete-delay-days', null, InputOption::VALUE_REQUIRED, 'Days a removed snapshot stays in the trash before its data is deleted (1-365, fixed after this)', (string)KeyService::DEFAULT_DELETE_DELAY) ->setHelp(<<<'HELP' Creates the master key that encrypts all backups of this installation. @@ -57,12 +59,14 @@ HELP); } } try { - $this->keys->initialize($pass); + $this->keys->initialize($pass, (int)$input->getOption('delete-delay-days'), (string)$input->getOption('label')); } catch (\InvalidArgumentException $e) { $output->writeln('' . $e->getMessage() . ''); return 1; } $output->writeln('Backup key created, fingerprint ' . $this->keys->fingerprint() . '.'); + $output->writeln('Removed snapshots stay in the trash on the location for ' . $this->keys->deleteDelayDays() . ' days (cannot be changed later).'); + $output->writeln('Tip: add up to two more passphrases for other people with occ backup:key:slot:add (e.g. CTO, head of IT).'); $output->writeln('Next: save the recovery kit with occ backup:key:kit --output=FILE and confirm with occ backup:key:confirm.'); return 0; } diff --git a/lib/Command/KeySlotAdd.php b/lib/Command/KeySlotAdd.php new file mode 100644 index 0000000..fd6e7be --- /dev/null +++ b/lib/Command/KeySlotAdd.php @@ -0,0 +1,17 @@ +action) { + 'add' => $this->setName('backup:key:slot:add')->setDescription('Add a passphrase slot') + ->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Who or where, e.g. "CTO" or "Safe"', 'Slot'), + 'replace' => $this->setName('backup:key:slot:replace')->setDescription('Set a new passphrase for a slot (e.g. its holder left)') + ->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)') + ->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'New label'), + 'remove' => $this->setName('backup:key:slot:remove')->setDescription('Remove a passphrase slot (not the last one)') + ->addArgument('slot', InputArgument::REQUIRED, 'Slot number (see backup:key:slots)'), + default => $this->setName('backup:key:slots')->setDescription('List the passphrase slots of the backup key'), + }; + if ($this->action === 'add' || $this->action === 'replace') { + $this->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file'); + } + $this->setHelp($help); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if (!$this->keys->isInitialized()) { + $output->writeln('No backup key yet: run occ backup:key:init first.'); + return 1; + } + try { + switch ($this->action) { + case 'add': + $n = $this->slots->add($this->passphrase($input, $output), (string)$input->getOption('label')); + $output->writeln("Slot $n added."); + break; + case 'replace': + $this->slots->replace((int)$input->getArgument('slot'), $this->passphrase($input, $output), $input->getOption('label')); + $output->writeln('Slot ' . (int)$input->getArgument('slot') . ' has a new passphrase.'); + break; + case 'remove': + $this->slots->remove((int)$input->getArgument('slot')); + $output->writeln('Slot ' . (int)$input->getArgument('slot') . ' removed.'); + break; + default: + (new Table($output))->setHeaders(['slot', 'label', 'set on']) + ->setRows(array_map(fn ($s) => [$s['slot'], $s['label'], $s['created'] ? date('Y-m-d H:i', $s['created']) : ''], $this->keys->slots()))->render(); + return 0; + } + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Now download and confirm the new recovery kit: occ backup:key:kit -o FILE && occ backup:key:confirm'); + return 0; + } + + private function passphrase(InputInterface $input, OutputInterface $output): string { + if ($file = $input->getOption('passphrase-file')) { + return rtrim((string)@file_get_contents($file), "\r\n"); + } + $h = $this->getHelper('question'); + $p = (string)$h->ask($input, $output, (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false)); + if ($p !== (string)$h->ask($input, $output, (new Question('Repeat: '))->setHidden(true)->setHiddenFallback(false))) { + throw new \InvalidArgumentException('The passphrases do not match'); + } + return $p; + } +} diff --git a/lib/Command/Trash.php b/lib/Command/Trash.php new file mode 100644 index 0000000..3ca0e84 --- /dev/null +++ b/lib/Command/Trash.php @@ -0,0 +1,49 @@ +setName('backup:trash') + ->setDescription('List removed snapshots that can still be restored (backup:trash:restore)') + ->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $name = $input->getArgument('target'); + $delay = $this->keys->deleteDelayDays() * 86400; + $rows = []; + foreach ($name === null ? $this->targets->list() : [$this->targets->get($name)] as $t) { + foreach ($this->targets->repository($t)->trash() as $id => $info) { + $rows[] = [$t->getName(), $id, (string)($info['time'] ?? ''), $info['label'], date('Y-m-d H:i', $info['forgottenAt']), $info['by'], + date('Y-m-d H:i', $info['forgottenAt'] + $delay)]; + } + } + if ($rows === []) { + $output->writeln('The trash is empty.'); + return 0; + } + (new Table($output))->setHeaders(['location', 'snapshot', 'made', 'label', 'removed', 'by', 'deleted after'])->setRows($rows)->render(); + return 0; + } +} diff --git a/lib/Command/TrashRestore.php b/lib/Command/TrashRestore.php new file mode 100644 index 0000000..f05d866 --- /dev/null +++ b/lib/Command/TrashRestore.php @@ -0,0 +1,42 @@ +setName('backup:trash:restore') + ->setDescription('Bring a removed snapshot back from the trash') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:trash)'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->prune->untrash($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot')); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Snapshot restored from the trash.'); + return 0; + } +} diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index 30e5bdb..30c9deb 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -45,6 +45,8 @@ class AdminApiController extends Controller { private IJobList $jobs, private IUserSession $session, private \OCA\NgBackup\Service\AlertService $alerts, + private \OCA\NgBackup\Service\KeySlotService $slots, + private \OCA\NgBackup\Service\PruneService $prune, ) { parent::__construct(Application::APP_ID, $request); } @@ -58,7 +60,9 @@ class AdminApiController extends Controller { $runs = array_map(fn (Run $r) => $this->runJson($r), $this->runs->findRecent(15)); return new JSONResponse([ 'key' => ['initialized' => $this->keys->isInitialized(), 'fingerprint' => $this->keys->fingerprint(), - 'confirmation' => $this->keys->recoveryKitConfirmation()], + 'confirmation' => $this->keys->recoveryKitConfirmation(), 'everConfirmed' => $this->keys->isInitialized() && $this->keys->everConfirmed(), + 'slots' => $this->keys->isInitialized() ? $this->keys->slots() : [], 'maxSlots' => \OCA\NgBackup\Service\KeyService::MAX_SLOTS, + 'deleteDelayDays' => $this->keys->isInitialized() ? $this->keys->deleteDelayDays() : \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY], 'statement' => KeyConfirm::STATEMENT, 'confirmationPhrase' => KeyConfirm::CONFIRMATION, 'targets' => $targets, @@ -68,9 +72,9 @@ class AdminApiController extends Controller { } #[PasswordConfirmationRequired] - public function initKey(string $passphrase): JSONResponse { + public function initKey(string $passphrase, int $deleteDelayDays = \OCA\NgBackup\Service\KeyService::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): JSONResponse { try { - $this->keys->initialize($passphrase); + $this->keys->initialize($passphrase, $deleteDelayDays, $label); } catch (\Throwable $e) { return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); } @@ -102,6 +106,59 @@ class AdminApiController extends Controller { return new JSONResponse(['confirmation' => $this->keys->recoveryKitConfirmation()]); } + #[PasswordConfirmationRequired] + public function addSlot(string $passphrase, string $label): JSONResponse { + try { + return new JSONResponse(['slot' => $this->slots->add($passphrase, $label)]); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + #[PasswordConfirmationRequired] + public function replaceSlot(int $slot, string $passphrase, ?string $label = null): JSONResponse { + try { + $this->slots->replace($slot, $passphrase, $label); + return new JSONResponse([]); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + #[PasswordConfirmationRequired] + public function removeSlot(int $slot): JSONResponse { + try { + $this->slots->remove($slot); + return new JSONResponse([]); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + public function trash(int $targetId): JSONResponse { + try { + $delay = $this->keys->deleteDelayDays() * 86400; + $out = []; + foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) { + $out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'], + 'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay]; + } + return new JSONResponse($out); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + + #[PasswordConfirmationRequired] + public function untrash(int $targetId, string $snapshotId): JSONResponse { + try { + $this->prune->untrash($this->targets->get((string)$targetId), $snapshotId); + return new JSONResponse([]); + } catch (\Throwable $e) { + return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST); + } + } + public function backends(): JSONResponse { return new JSONResponse($this->targets->availableBackends()); } diff --git a/lib/Crypto/KeyRing.php b/lib/Crypto/KeyRing.php index 6a7bce9..9db9d68 100644 --- a/lib/Crypto/KeyRing.php +++ b/lib/Crypto/KeyRing.php @@ -76,6 +76,23 @@ final class KeyRing { ]; } + /** + * 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); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index ca26210..906e821 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -53,7 +53,11 @@ class Notifier implements INotifier { 'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']), 'append_only_off' => $l->t('%1$s turned off append-only for backup location %2$s', [$who, $p['target'] ?? '']), 'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']), - 'snapshots_forgotten' => $l->t('%1$s removed %2$s snapshots from %3$s', [$who, $p['count'] ?? '', $p['target'] ?? '']), + 'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']), + 'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']), + 'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']), + 'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']), + 'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']), default => $l->t('NG Backup security event'), }); $notification->setParsedMessage($l->t('If this was not you or another administrator, check who has admin access.')); diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 6c1e48f..1b28877 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -101,13 +101,20 @@ final class Repository { return $config; } + /** Replace the passphrase-wrapped key slots in this repository's config (after a slot change). */ + public function updateWrappedKey(array $wrappedKey): void { + $config = self::readConfig($this->backend); + $config['key'] = $wrappedKey; + self::putString($this->backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + } + public function id(): string { return $this->repositoryId; } public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self { $config = self::readConfig($backend); - $repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']); + $repo = new self($backend, KeyRing::unwrapAny($config['key'], $passphrase), $config['id']); $repo->index->load(); return $repo; } @@ -273,19 +280,70 @@ final class Repository { return $data; } - /** Remove a snapshot (and its database dump). Its data is freed by the next prune(). */ - public function forget(string $snapshotId): void { - try { - $meta = $this->snapshotMeta($snapshotId); - } catch (\Throwable) { - $meta = []; + /** + * Move a snapshot (and its database dump) to the trash on the location. Its data stays in + * use until purgeTrash() removes it after the deletion delay, so a malicious or mistaken + * cleanup can be undone with untrash() within that time. + */ + public function forget(string $snapshotId, string $by = ''): void { + $meta = $this->snapshotMeta($snapshotId); + $dbInTrash = null; + if (isset($meta['db']) && is_string($meta['db']) && $this->backend->exists($meta['db'])) { + $dbInTrash = 'trash/' . $meta['db']; + $this->putObject($dbInTrash, $this->getObject($meta['db'])); } - if (isset($meta['db']) && is_string($meta['db']) && str_starts_with($meta['db'], 'db/')) { + // The snapshot object is bound to its id (not its path), so it can be copied as-is. + $this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId)); + $this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null, + 'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR)); + if ($dbInTrash !== null) { $this->backend->delete($meta['db']); } $this->backend->delete('snapshots/' . $snapshotId); } + /** Undo forget(): move a snapshot back from the trash. */ + public function untrash(string $snapshotId): void { + $info = $this->trashInfo($snapshotId); + if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) { + $this->putObject($info['db'], $this->getObject($info['dbInTrash'])); + $this->backend->delete($info['dbInTrash']); + } + $this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId)); + $this->backend->delete('trash/snapshots/' . $snapshotId); + $this->backend->delete('trash/info/' . $snapshotId); + } + + /** @return array */ + public function trash(): array { + $out = []; + foreach ($this->backend->list('trash/info') as $path) { + $out[basename($path)] = $this->trashInfo(basename($path)); + } + return $out; + } + + /** Permanently delete trash entries older than $delaySeconds. @return list purged snapshot ids */ + public function purgeTrash(int $delaySeconds): array { + $purged = []; + foreach ($this->trash() as $id => $info) { + if (time() - $info['forgottenAt'] < $delaySeconds) { + continue; + } + if ($info['dbInTrash'] !== null) { + $this->backend->delete($info['dbInTrash']); + } + $this->backend->delete('trash/snapshots/' . $id); + $this->backend->delete('trash/info/' . $id); + $purged[] = $id; + } + return $purged; + } + + private function trashInfo(string $snapshotId): array { + return json_decode($this->getObject('trash/info/' . $snapshotId), true, 512, JSON_THROW_ON_ERROR); + } + /** * Delete packs no snapshot or user export uses any more, and repack packs where more than * $repackThreshold of the bytes are unused. Must not run while a backup to this repository @@ -314,6 +372,21 @@ final class Repository { } } } + // Snapshots in the trash keep their data until purgeTrash() removed them. + foreach ($this->trash() as $sid => $info) { + foreach ($this->streamEntries($sid, 'trash/snapshots/') as $e) { + foreach ($e['b'] as $b) { + $used[$b] = true; + } + } + if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) { + foreach (json_decode($this->getObject($info['dbInTrash']), true, 512, JSON_THROW_ON_ERROR)['tables'] as $t) { + foreach ($t['blobs'] as $b) { + $used[$b] = true; + } + } + } + } foreach ($this->backend->list('users') as $path) { foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) { foreach ($e['b'] ?? [] as $b) { @@ -465,9 +538,9 @@ final class Repository { } /** @return \Generator}> */ - private function streamEntries(string $snapshotId): \Generator { + private function streamEntries(string $snapshotId, string $dir = 'snapshots/'): \Generator { $plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b'); - $fh = $this->backend->get('snapshots/' . $snapshotId); + $fh = $this->backend->get($dir . $snapshotId); $this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId); fclose($fh); rewind($plain); diff --git a/lib/Service/AlertService.php b/lib/Service/AlertService.php index a4e54a0..3166f86 100644 --- a/lib/Service/AlertService.php +++ b/lib/Service/AlertService.php @@ -35,8 +35,12 @@ class AlertService { * * @param array $params */ + public function actor(): string { + return $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown'); + } + public function securityEvent(string $event, array $params = [], bool $notify = true): void { - $actor = $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown'); + $actor = $this->actor(); $params = ['actor' => $actor] + $params; $this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent( 'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor])); diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index da92dd7..0190d70 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -46,7 +46,7 @@ class BackupService { } public function start(Target $target, string $label = ''): Run { - if ($this->keys->recoveryKitConfirmation() === null) { + if (!$this->keys->everConfirmed()) { throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)'); } if ($this->config->getSystemValue('objectstore', null) !== null) { diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php index d92cc68..5695c96 100644 --- a/lib/Service/KeyService.php +++ b/lib/Service/KeyService.php @@ -27,6 +27,9 @@ class KeyService { private const KEY_WRAPPED = 'master_key_wrapped'; private const KEY_CONFIRMED = 'recovery_kit_confirmed'; private const KEY_FINGERPRINT = 'master_key_fingerprint'; + private const KEY_DELETE_DELAY = 'delete_delay_days'; + private const KEY_KIT_VERSION = 'recovery_kit_version'; + public const DEFAULT_DELETE_DELAY = 7; private ?KeyRing $cached = null; @@ -40,7 +43,15 @@ class KeyService { return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== ''; } - public function initialize(#[\SensitiveParameter] string $passphrase): void { + /** + * @param int $deleteDelayDays how long forgotten snapshots stay in the trash on the location + * before their data is really deleted. Fixed here, at installation: + * there is deliberately no way to change it later from NG Backup. + */ + public function initialize(#[\SensitiveParameter] string $passphrase, int $deleteDelayDays = self::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): void { + if ($deleteDelayDays < 1 || $deleteDelayDays > 365) { + throw new \InvalidArgumentException('The deletion delay must be between 1 and 365 days'); + } if ($this->isInitialized()) { throw new \RuntimeException('A backup key already exists'); } @@ -50,9 +61,11 @@ class KeyService { $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->cached = $keys; + $this->saveSlots([['slot' => 1, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $keys->wrap($passphrase)]]); $this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true); + $this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $deleteDelayDays, true); + $this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true); $this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED); $this->cached = $keys; } @@ -68,9 +81,99 @@ class KeyService { return $this->cached; } + /** Deletion delay in days, fixed at installation (installations from before this setting get the default once). */ + public function deleteDelayDays(): int { + $days = $this->appConfig->getValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, 0, true); + if ($days === 0 && $this->isInitialized()) { + $days = self::DEFAULT_DELETE_DELAY; + $this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $days, true); + } + return $days; + } + + public const MAX_SLOTS = 3; + + /** @return list passphrase slots, without key material */ + public function slots(): array { + return array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots']); + } + + /** Add a passphrase slot (e.g. one per person who must be able to restore). Returns the slot number. */ + public function addSlot(#[\SensitiveParameter] string $passphrase, string $label): int { + self::checkPassphrase($passphrase); + $slots = $this->wrappedKey()['slots']; + if (count($slots) >= self::MAX_SLOTS) { + throw new \InvalidArgumentException('At most ' . self::MAX_SLOTS . ' passphrase slots'); + } + $used = array_column($slots, 'slot'); + $n = min(array_diff(range(1, self::MAX_SLOTS), $used)); + $slots[] = ['slot' => $n, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $this->keyRing()->wrap($passphrase)]; + usort($slots, fn ($a, $b) => $a['slot'] <=> $b['slot']); + $this->saveSlots($slots); + return $n; + } + + /** New passphrase for a slot (e.g. its holder left). The master key and all backups stay the same. */ + public function replaceSlot(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void { + self::checkPassphrase($passphrase); + $slots = $this->wrappedKey()['slots']; + $found = false; + foreach ($slots as &$s) { + if ($s['slot'] === $slot) { + $s['key'] = $this->keyRing()->wrap($passphrase); + $s['created'] = time(); + if ($label !== null) { + $s['label'] = self::cleanLabel($label); + } + $found = true; + } + } + unset($s); + if (!$found) { + throw new \InvalidArgumentException("There is no slot $slot"); + } + $this->saveSlots($slots); + } + + public function removeSlot(int $slot): void { + $slots = array_values(array_filter($this->wrappedKey()['slots'], fn ($s) => $s['slot'] !== $slot)); + if (count($slots) === count($this->wrappedKey()['slots'])) { + throw new \InvalidArgumentException("There is no slot $slot"); + } + if ($slots === []) { + throw new \InvalidArgumentException('The last passphrase slot cannot be removed'); + } + $this->saveSlots($slots); + } + + /** Every change to the slots asks for a new recovery kit (and its confirmation). */ + private function saveSlots(array $slots): void { + $this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode(['slots' => $slots], JSON_THROW_ON_ERROR), true, true); + $this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, $this->kitVersion() + 1, true); + } + + private static function checkPassphrase(string $passphrase): void { + if (strlen($passphrase) < 12) { + throw new \InvalidArgumentException('The passphrase must be at least 12 characters'); + } + } + + private static function cleanLabel(string $label): string { + $label = trim(preg_replace('/[\x00-\x1f]/', '', $label) ?? ''); + return mb_substr($label === '' ? 'Slot' : $label, 0, 64); + } + + public function kitVersion(): int { + return $this->appConfig->getValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true); + } + /** 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); + $data = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR); + if (!isset($data['slots'])) { + $data = ['slots' => $data === [] ? [] : [['slot' => 1, 'label' => 'Slot 1', 'created' => 0, 'key' => $data]]]; + } + return $data; } /** Short identifier of the key, shown in the UI and the kit (not secret). */ @@ -84,23 +187,37 @@ class KeyService { 'version' => 1, 'app' => 'NG Backup', 'fingerprint' => $this->fingerprint(), + 'kit_version' => $this->kitVersion(), '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.', + 'slots' => $this->slots(), + 'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. ' + . 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. ' + . 'Without the kit (or the location) and a passphrase, 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); + json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true); } - /** Confirmation for the current key, or null. */ + /** + * Whether a recovery kit for this key was ever confirmed (any passphrase version). Backups + * keep running after a passphrase change, so changing it cannot be used to stop backups; the + * missing re-confirmation is reported as an error in the admin overview instead. + */ + public function everConfirmed(): bool { + $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(); + } + + /** Confirmation for the current key and kit version, 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; + return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint() && ($data['kit_version'] ?? 1) === $this->kitVersion()) ? $data : null; } private function fingerprintOf(KeyRing $keys): string { diff --git a/lib/Service/KeySlotService.php b/lib/Service/KeySlotService.php new file mode 100644 index 0000000..fd07026 --- /dev/null +++ b/lib/Service/KeySlotService.php @@ -0,0 +1,61 @@ +keys->addSlot($passphrase, $label); + $this->apply('slot_added', $slot); + return $slot; + } + + public function replace(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void { + $this->keys->replaceSlot($slot, $passphrase, $label); + $this->apply('slot_replaced', $slot); + } + + public function remove(int $slot): void { + $this->keys->removeSlot($slot); + $this->apply('slot_removed', $slot); + } + + private function apply(string $event, int $slot): void { + $wrapped = $this->keys->wrappedKey(); + $failed = []; + foreach ($this->targets->list() as $t) { + try { + $this->targets->repository($t)->updateWrappedKey($wrapped); + } catch (\Throwable $e) { + $failed[] = $t->getName(); + } + } + $label = ''; + foreach ($this->keys->slots() as $s) { + if ($s['slot'] === $slot) { + $label = $s['label']; + } + } + $this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]); + if ($failed !== []) { + throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed)); + } + } +} diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php index 3de77ec..f1a0998 100644 --- a/lib/Service/PruneService.php +++ b/lib/Service/PruneService.php @@ -28,9 +28,33 @@ class PruneService { private IAppConfig $appConfig, private ILockingProvider $locking, private AlertService $alerts, + private KeyService $keys, ) { } + public function untrash(Target $target, string $snapshotId): void { + $repo = $this->targets->repository($target); + if (!array_key_exists($snapshotId, $repo->trash())) { + throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)'); + } + $repo->untrash($snapshotId); + // Put it back in the local snapshot list as well. + $meta = $repo->snapshotMeta($snapshotId); + $s = new \OCA\NgBackup\Db\Snapshot(); + $s->setTargetId($target->getId()); + $s->setSnapshotId($snapshotId); + $s->setKind((string)($meta['kind'] ?? 'full')); + $s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null); + $s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now'))); + $s->setFiles((int)($meta['stats']['files'] ?? 0)); + $s->setBytes((int)($meta['stats']['bytes'] ?? 0)); + try { + $this->snapshots->insert($s); + } catch (\Throwable) { + } + $this->alerts->securityEvent('snapshot_untrashed', ['target' => $target->getName(), 'snapshot' => $snapshotId]); + } + public static function lockKey(Target $target): string { return 'ng_backup/target/' . $target->getId(); } @@ -51,7 +75,10 @@ class PruneService { } /** - * @return array{kept: array>, forgotten: list, prune: array} + * Snapshots the policy drops go to the trash on the location; trash entries older than the + * deletion delay are purged, and only then is their data freed. + * + * @return array{kept: array>, forgotten: list, purged: list, prune: array} */ public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array { if ($target->getAppendOnly()) { @@ -74,9 +101,11 @@ class PruneService { } $keep = $this->policy()->keep($times); $forget = array_values(array_diff(array_keys($times), array_keys($keep))); + $actor = $scheduled ? 'retention policy' : $this->alerts->actor(); + $purged = []; if (!$dryRun) { foreach ($forget as $id) { - $repo->forget($id); + $repo->forget($id, $actor); try { $this->snapshots->delete($this->snapshots->findOne($target->getId(), $id)); } catch (\Throwable) { @@ -84,12 +113,17 @@ class PruneService { } } // Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone. + $delay = $this->keys->deleteDelayDays(); if (!$dryRun && $forget !== []) { // Scheduled cleanup follows the policy: audit log only, no notification every week. - $this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], !$scheduled); + $this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget), + 'until' => date('Y-m-d', time() + $delay * 86400)], !$scheduled); } - $prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []); - return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune]; + if (!$dryRun) { + $purged = $repo->purgeTrash($delay * 86400); + } + $prune = $repo->prune($dryRun); + return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune]; } finally { $this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE); } diff --git a/lib/SetupCheck/BackupStatusCheck.php b/lib/SetupCheck/BackupStatusCheck.php index d1c6a17..6788805 100644 --- a/lib/SetupCheck/BackupStatusCheck.php +++ b/lib/SetupCheck/BackupStatusCheck.php @@ -39,9 +39,12 @@ class BackupStatusCheck implements ISetupCheck { public function run(): SetupResult { $link = $this->url->linkToRouteAbsolute('settings.AdminSettings.index', ['section' => 'ng_backup']); - if (!$this->keys->isInitialized() || $this->keys->recoveryKitConfirmation() === null) { + if (!$this->keys->isInitialized() || !$this->keys->everConfirmed()) { return SetupResult::error($this->l->t('Backups are not set up: create the backup key and confirm the recovery kit.'), $link); } + if ($this->keys->recoveryKitConfirmation() === null) { + return SetupResult::error($this->l->t('The backup passphrase was changed: download and confirm the new recovery kit.'), $link); + } $targets = $this->targets->list(); if ($targets === []) { return SetupResult::warning($this->l->t('No backup location is configured.'), $link); diff --git a/src/AdminSettings.vue b/src/AdminSettings.vue index dd29f0f..c901f1a 100644 --- a/src/AdminSettings.vue +++ b/src/AdminSettings.vue @@ -12,7 +12,7 @@