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
This commit is contained in:
2026-10-02 03:01:44 +02:00
parent b9e8858d76
commit d3aa07b106
26 changed files with 955 additions and 78 deletions
+109 -3
View File
@@ -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 <date>"), 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 |
+7 -1
View File
@@ -5,7 +5,7 @@
<name>NG Backup</name>
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
<description><![CDATA[Work in progress, not ready for use.]]></description>
<version>0.0.8</version>
<version>0.0.11</version>
<licence>agpl</licence>
<author>Dillard Blom</author>
<namespace>NgBackup</namespace>
@@ -35,6 +35,12 @@
<command>OCA\NgBackup\Command\Prune</command>
<command>OCA\NgBackup\Command\Retention</command>
<command>OCA\NgBackup\Command\TargetAppendOnly</command>
<command>OCA\NgBackup\Command\Trash</command>
<command>OCA\NgBackup\Command\TrashRestore</command>
<command>OCA\NgBackup\Command\KeySlots</command>
<command>OCA\NgBackup\Command\KeySlotAdd</command>
<command>OCA\NgBackup\Command\KeySlotReplace</command>
<command>OCA\NgBackup\Command\KeySlotRemove</command>
</commands>
<settings>
<admin>OCA\NgBackup\Settings\Admin</admin>
+5
View File
@@ -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'],
+5 -1
View File
@@ -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('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
$output->writeln('Removed snapshots stay in the trash on the location for <info>' . $this->keys->deleteDelayDays() . ' days</info> (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 <info>occ backup:key:kit --output=FILE</info> and confirm with <info>occ backup:key:confirm</info>.');
return 0;
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService;
class KeySlotAdd extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'add');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService;
class KeySlotRemove extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'remove');
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService;
class KeySlotReplace extends KeySlots {
public function __construct(KeyService $keys, KeySlotService $slots) {
parent::__construct($keys, $slots, 'replace');
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\KeySlotService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
/** backup:key:slots, backup:key:slot:add, backup:key:slot:replace, backup:key:slot:remove */
class KeySlots extends Command {
public function __construct(
private KeyService $keys,
private KeySlotService $slots,
private string $action = 'list',
) {
// list is the default; subclasses pick another action
parent::__construct();
}
protected function configure(): void {
$help = <<<'HELP'
Up to three passphrases ("slots") can open the backup key, so that one person leaving or dying
does not lock you out. Example: one passphrase in the safe, one with the CTO, one with the head
of IT. Each holder keeps a copy of the same recovery kit and only knows their own passphrase.
After every change: occ backup:key:kit and occ backup:key:confirm.
HELP;
match ($this->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('<error>No backup key yet: run occ backup:key:init first.</error>');
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('<error>' . $e->getMessage() . '</error>');
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;
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class Trash extends Command {
public function __construct(
private TargetService $targets,
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->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;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\PruneService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class TrashRestore extends Command {
public function __construct(
private TargetService $targets,
private PruneService $prune,
) {
parent::__construct();
}
protected function configure(): void {
$this->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('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Snapshot restored from the trash.');
return 0;
}
}
+60 -3
View File
@@ -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());
}
+17
View File
@@ -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);
+5 -1
View File
@@ -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.'));
+83 -10
View File
@@ -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<string, array{forgottenAt:int, by:string, db:?string, dbInTrash:?string, time:?string, label:string}> */
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<string> 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<array{p:string, s:int, m:int, b:list<string>}> */
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);
+5 -1
View File
@@ -35,8 +35,12 @@ class AlertService {
*
* @param array<string, string> $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]));
+1 -1
View File
@@ -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) {
+126 -9
View File
@@ -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<array{slot:int, label:string, created:int}> 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 {
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
/**
* Passphrase slots (like LUKS): up to three passphrases, each opening the same master key, so
* that one person leaving or dying does not lock the company out of its backups. Every change
* is written to all locations and announced to all administrators; a new recovery kit must be
* downloaded and confirmed afterwards (backups keep running meanwhile).
*/
class KeySlotService {
public function __construct(
private KeyService $keys,
private TargetService $targets,
private AlertService $alerts,
) {
}
public function add(#[\SensitiveParameter] string $passphrase, string $label): int {
$slot = $this->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));
}
}
}
+39 -5
View File
@@ -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<string, list<string>>, forgotten: list<string>, 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<string, list<string>>, forgotten: list<string>, purged: list<string>, 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);
}
+4 -1
View File
@@ -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);
+1 -1
View File
@@ -12,7 +12,7 @@
<template v-if="status">
<KeySetup :status="status" @changed="refresh" />
<template v-if="status.key.confirmation">
<template v-if="status.key.everConfirmed">
<Locations :status="status" @changed="refresh" />
<Backups v-if="status.targets.length" :status="status" @changed="refresh" />
<RestoreBrowser v-if="status.targets.length" :status="status" @changed="refresh" />
+6 -1
View File
@@ -17,7 +17,12 @@ async function call(method, path, data) {
export default {
status: () => call('get', '/status'),
initKey: (passphrase) => call('post', '/key', { passphrase }),
initKey: (passphrase, deleteDelayDays, label) => call('post', '/key', { passphrase, deleteDelayDays, label }),
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
removeSlot: (slot) => call('delete', `/key/slots/${slot}`),
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
kitUrl: () => url('/key/kit'),
confirmKit: (accepted, phrase) => call('post', '/key/confirm', { accepted, phrase }),
backends: () => call('get', '/backends'),
+43
View File
@@ -16,6 +16,20 @@
</div>
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
<template v-for="target in status.targets" :key="'trash' + target.id">
<div v-if="trash[target.id]?.length" class="ngb-trash">
<h3>{{ t('ng_backup', 'Trash of {name}', { name: target.name }) }}</h3>
<p class="ngb-muted">{{ t('ng_backup', 'Removed restore points can be brought back until they are deleted permanently.') }}</p>
<ul>
<li v-for="item in trash[target.id]" :key="item.id">
{{ item.time ? new Date(item.time).toLocaleString() : item.id }}{{ item.label ? ' – ' + item.label : '' }}
<span class="ngb-muted">{{ t('ng_backup', 'removed by {by}, deleted after {date}', { by: item.by, date: new Date(item.deleteAfter * 1000).toLocaleDateString() }) }}</span>
<NcButton variant="tertiary" @click="untrash(target, item)">{{ t('ng_backup', 'Restore') }}</NcButton>
</li>
</ul>
</div>
</template>
<table v-if="status.runs.length" class="ngb-table">
<thead>
<tr>
@@ -50,6 +64,8 @@ import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
import { formatSize } from '../format.js'
import { confirmPassword } from '@nextcloud/password-confirmation'
import { reactive } from 'vue'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
@@ -57,6 +73,30 @@ const schedule = ref(props.status.schedule)
const error = ref('')
watch(() => props.status.schedule, (s) => { schedule.value = s })
const trash = reactive({})
async function loadTrash() {
for (const target of props.status.targets) {
try {
trash[target.id] = await api.trash(target.id)
} catch (e) {
trash[target.id] = []
}
}
}
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
async function untrash(target, item) {
error.value = ''
try {
await confirmPassword()
await api.untrash(target.id, item.id)
await loadTrash()
emit('changed')
} catch (e) {
error.value = e?.message || ''
}
}
const isRunning = (targetId) => props.status.runs.some(r => r.targetId === targetId && r.kind === 'full' && r.status === 'running')
const targetName = (id) => props.status.targets.find(tg => tg.id === id)?.name ?? '#' + id
@@ -87,5 +127,8 @@ async function saveSchedule() {
.ngb-table { width: 100%; border-collapse: collapse; }
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
.ngb-failed { color: var(--color-error-text); }
.ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-trash { margin: 12px 0; }
.ngb-trash h3 { font-weight: bold; }
.ngb-done { color: var(--color-success-text); }
</style>
+93 -39
View File
@@ -4,34 +4,67 @@
-->
<template>
<NcSettingsSection :name="t('ng_backup', 'Backup key and recovery kit')"
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups; the recovery kit, protected by your passphrase, is what you need to restore on a new server.')">
:description="t('ng_backup', 'All backups are encrypted with one key. The server keeps it for scheduled backups. To restore on a new server you need the recovery kit and one of the passphrases.')">
<!-- 1. Create the key -->
<form v-if="!status.key.initialized" class="ngb-form" @submit.prevent="createKey">
<NcTextField v-model="label" :label="t('ng_backup', 'Who keeps this passphrase (e.g. Safe, CTO)')" />
<NcPasswordField v-model="pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password"
:error="pass2 !== '' && pass1 !== pass2" :helper-text="pass2 !== '' && pass1 !== pass2 ? t('ng_backup', 'The passphrases do not match') : ''" />
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2">
<NcTextField v-model="delay" type="number" min="1" max="365"
:label="t('ng_backup', 'Days a removed backup stays in the trash before it is really deleted')"
:helper-text="t('ng_backup', 'Protects against a malicious or mistaken cleanup. Cannot be changed later.')" />
<NcButton type="submit" variant="primary" :disabled="busy || pass1.length < 12 || pass1 !== pass2 || !(delay >= 1 && delay <= 365)">
{{ t('ng_backup', 'Create backup key') }}
</NcButton>
</form>
<template v-else>
<p>{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }}</p>
<p>
{{ t('ng_backup', 'Key fingerprint: {fingerprint}', { fingerprint: status.key.fingerprint }) }} ·
{{ t('ng_backup', 'Removed backups are kept in the trash for {days} days', { days: status.key.deleteDelayDays }) }}
</p>
<!-- 2. Download the kit and confirm -->
<!-- Passphrase slots -->
<h3>{{ t('ng_backup', 'Passphrases') }}</h3>
<p class="ngb-muted">
{{ t('ng_backup', 'Up to {max} passphrases can open the key, so one person leaving does not lock you out. For example: one in the safe, one with the CTO, one with the head of IT. Everyone keeps the same recovery kit and only knows their own passphrase.', { max: status.key.maxSlots }) }}
</p>
<table class="ngb-table">
<tbody>
<tr v-for="slot in status.key.slots" :key="slot.slot">
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
<td><strong>{{ slot.label }}</strong></td>
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
<td>
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
</td>
</tr>
</tbody>
</table>
<NcButton v-if="!slotForm && status.key.slots.length < status.key.maxSlots" @click="openSlotForm(null)">{{ t('ng_backup', 'Add passphrase') }}</NcButton>
<form v-if="slotForm" class="ngb-form" @submit.prevent="saveSlot">
<NcTextField v-model="slotForm.label" :label="t('ng_backup', 'Who keeps this passphrase')" />
<NcPasswordField v-model="slotForm.pass1" :label="t('ng_backup', 'Passphrase (at least 12 characters)')" autocomplete="new-password" />
<NcPasswordField v-model="slotForm.pass2" :label="t('ng_backup', 'Repeat passphrase')" autocomplete="new-password" :error="slotForm.pass2 !== '' && slotForm.pass1 !== slotForm.pass2" />
<div class="ngb-row">
<NcButton type="submit" variant="primary" :disabled="busy || slotForm.pass1.length < 12 || slotForm.pass1 !== slotForm.pass2">{{ t('ng_backup', 'Save') }}</NcButton>
<NcButton @click="slotForm = null">{{ t('ng_backup', 'Cancel') }}</NcButton>
</div>
</form>
<!-- Recovery kit -->
<h3>{{ t('ng_backup', 'Recovery kit') }}</h3>
<div v-if="!status.key.confirmation" class="ngb-confirm">
<NcNoteCard type="warning">
{{ t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with your passphrase, somewhere safe outside this server.') }}
<NcNoteCard :type="status.key.everConfirmed ? 'error' : 'warning'">
{{ status.key.everConfirmed
? t('ng_backup', 'The passphrases changed: download the new recovery kit, give a copy to every passphrase holder and confirm. Backups keep running meanwhile.')
: t('ng_backup', 'Before the first backup: download the recovery kit and store it, together with the passphrase, somewhere safe outside this server.') }}
</NcNoteCard>
<NcButton @click="downloadKit">
{{ t('ng_backup', 'Download recovery kit') }}
</NcButton>
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">
{{ status.statement }}
</NcCheckboxRadioSwitch>
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">
{{ status.confirmationPhrase }}
</NcButton>
<NcButton @click="downloadKit">{{ t('ng_backup', 'Download recovery kit') }}</NcButton>
<NcCheckboxRadioSwitch v-model="accepted" :disabled="!downloaded">{{ status.statement }}</NcCheckboxRadioSwitch>
<NcButton variant="primary" :disabled="!downloaded || !accepted || busy" @click="confirm">{{ status.confirmationPhrase }}</NcButton>
</div>
<p v-else>
{{ t('ng_backup', 'Recovery kit confirmed by {user} on {date}.', { user: status.key.confirmation.uid, date: new Date(status.key.confirmation.time * 1000).toLocaleString() }) }}
@@ -46,52 +79,75 @@
<script setup>
import { ref } from 'vue'
import { t } from '@nextcloud/l10n'
import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import { confirmPassword } from '@nextcloud/password-confirmation'
import '@nextcloud/password-confirmation/style.css'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import api from '../api.js'
const props = defineProps({ status: { type: Object, required: true } })
const emit = defineEmits(['changed'])
const label = ref('')
const pass1 = ref('')
const pass2 = ref('')
const delay = ref(props.status.key.deleteDelayDays)
const slotForm = ref(null)
const downloaded = ref(false)
const accepted = ref(false)
const busy = ref(false)
const error = ref('')
const kitUrl = api.kitUrl()
async function downloadKit() {
error.value = ''
try {
await confirmPassword()
window.location.href = kitUrl
downloaded.value = true
} catch (e) {
error.value = e?.message || ''
}
}
async function createKey() {
/** Run fn after a password confirmation; report errors in the card. */
async function guarded(fn) {
busy.value = true
error.value = ''
try {
await confirmPassword()
await api.initKey(pass1.value)
pass1.value = pass2.value = ''
await fn()
emit('changed')
} catch (e) {
error.value = e.message
error.value = e?.message || ''
} finally {
busy.value = false
}
}
const createKey = () => guarded(async () => {
await api.initKey(pass1.value, Number(delay.value), label.value || 'Slot 1')
pass1.value = pass2.value = ''
})
const downloadKit = () => guarded(async () => {
window.location.href = kitUrl
downloaded.value = true
})
function openSlotForm(slot) {
slotForm.value = { slot: slot?.slot ?? null, label: slot?.label ?? '', pass1: '', pass2: '' }
}
const saveSlot = () => guarded(async () => {
const f = slotForm.value
if (f.slot === null) {
await api.addSlot(f.pass1, f.label || 'Slot')
} else {
await api.replaceSlot(f.slot, f.pass1, f.label || null)
}
slotForm.value = null
downloaded.value = accepted.value = false
})
const removeSlot = (slot) => guarded(async () => {
await api.removeSlot(slot.slot)
downloaded.value = accepted.value = false
})
async function confirm() {
busy.value = true
error.value = ''
@@ -107,11 +163,9 @@ async function confirm() {
</script>
<style scoped>
.ngb-muted { color: var(--color-text-maxcontrast); margin-inline-start: 8px; }
.ngb-form, .ngb-confirm {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 600px;
}
.ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; }
.ngb-row { display: flex; gap: 8px; }
.ngb-table td { padding: 2px 12px 2px 0; }
h3 { margin-top: 16px; font-weight: bold; }
</style>
+15
View File
@@ -33,4 +33,19 @@ class KeyRingTest extends TestCase {
$this->expectException(\InvalidArgumentException::class);
new KeyRing('short');
}
public function testAnySlotOpensTheSameKey(): void {
$keys = KeyRing::generate();
$field = ['slots' => [
['slot' => 1, 'label' => 'Safe', 'key' => $keys->wrap('passphrase in the safe')],
['slot' => 2, 'label' => 'CTO', 'key' => $keys->wrap('passphrase of the cto')],
['slot' => 3, 'label' => 'Head of IT', 'key' => $keys->wrap('passphrase head of it')],
]];
foreach (['passphrase in the safe', 'passphrase of the cto', 'passphrase head of it'] as $p) {
$this->assertSame($keys->blobId('x'), KeyRing::unwrapAny($field, $p)->blobId('x'));
}
$this->assertSame($keys->blobId('x'), KeyRing::unwrapAny($keys->wrap('legacy single'), 'legacy single')->blobId('x'));
$this->expectException(CryptoException::class);
KeyRing::unwrapAny($field, 'nobody knows this');
}
}
+31 -1
View File
@@ -42,7 +42,11 @@ class PruneTest extends TestCase {
$preview = $repo->prune(true, 0.5, [$old['snapshot']]);
$this->assertGreaterThan(35 * 1048576, $preview['freedBytes'], 'dry run can preview forgetting a snapshot');
$repo->forget($old['snapshot']);
$repo->forget($old['snapshot'], 'tester');
$this->assertSame(0, $repo->prune(true)['freedBytes'], 'a snapshot in the trash keeps its data');
$this->assertArrayHasKey($old['snapshot'], $repo->trash());
$this->assertSame([], $repo->purgeTrash(3600), 'not purged before the delay');
$this->assertSame([$old['snapshot']], $repo->purgeTrash(0));
$plan = $repo->prune(true);
$this->assertSame($plan, $repo->prune(true, 0.5, []));
$this->assertGreaterThan(35 * 1048576, $plan['freedBytes']);
@@ -76,10 +80,36 @@ class PruneTest extends TestCase {
}
$b = $repo->backupDirectory($src, $a['snapshot']);
$repo->forget($a['snapshot']);
$repo->purgeTrash(0);
$r = $repo->prune(false, 0.5);
$this->assertGreaterThan(0, $r['repacked']);
$target = $this->tempDir();
Repository::openWithKey($backend, $keys)->restore($b['snapshot'], $target);
$this->assertSame(self::hashTree($src), self::hashTree($target));
}
public function testUntrashRestoresAWorkingSnapshot(): void {
$src = $this->tempDir();
$backend = new LocalBackend($this->tempDir());
$keys = KeyRing::generate();
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
file_put_contents("$src/a", random_bytes(2 * 1048576));
$repo->putObject('db/x', '{"tables":{}}');
$s = $repo->backupDirectory($src);
$expected = self::hashTree($src);
unlink("$src/a");
file_put_contents("$src/b", 'other');
$s2 = $repo->backupDirectory($src, $s['snapshot']);
$repo->forget($s['snapshot'], 'attacker');
$this->assertNotContains($s['snapshot'], $repo->listSnapshots());
$repo->prune(); // must not free the trashed snapshot's data
$repo->untrash($s['snapshot']);
$this->assertContains($s['snapshot'], $repo->listSnapshots());
$this->assertSame([], $repo->trash());
$target = $this->tempDir();
Repository::openWithKey($backend, $keys)->restore($s['snapshot'], $target);
$this->assertSame($expected, self::hashTree($target));
}
}