From 569077fa6987610303a9e3358cf8881b598a6750 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Sat, 7 Nov 2026 12:22:38 +0100 Subject: [PATCH] Restore without the recovery kit: the key from the location, with a passphrase On a server without a backup key, backup:target:add finds the backups at the location and takes their key from its config with one of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The deletion delay and the repository id are now stored on the location, sealed with the key, so the delay survives a restore without a kit and --repair-config can write a lost config again from the kit. The key page warns against creating a new key when restoring a server. New test: tests/integration/recovery-scenarios.sh, and a no-kit mode in full-restore-roundtrip.sh. --- CHANGELOG.md | 9 + PLAN.md | 7 + README.md | 5 +- docs/DISASTER-RECOVERY.md | 33 ++- docs/HOWTO.md | 4 + lib/Command/KeyImport.php | 3 + lib/Command/TargetAdd.php | 101 ++++++++- lib/Repository/Repository.php | 102 ++++++++- lib/Service/BackupService.php | 2 + lib/Service/KeyService.php | 34 ++- lib/Service/TargetService.php | 67 +++++- src/components/KeySetup.vue | 5 +- tests/Unit/Repository/KeyFromLocationTest.php | 111 ++++++++++ tests/integration/full-restore-roundtrip.sh | 44 +++- tests/integration/recovery-scenarios.sh | 203 ++++++++++++++++++ 15 files changed, 687 insertions(+), 43 deletions(-) create mode 100644 tests/Unit/Repository/KeyFromLocationTest.php create mode 100755 tests/integration/recovery-scenarios.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d2e4d8..eab931b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,15 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. background scan) pause while a backup runs, at most 6 hours in a row, then they get an hour. No app is turned off, and a job that is running goes on. On by default; turn it off under Backups or with `occ backup:pause-jobs off`. +- Disaster recovery without the recovery kit: on a new server without a backup key, + `occ backup:target:add` finds the backups on the location and takes their key from there with one + of the old server's passphrases. Backups then wait until a new recovery kit is confirmed. The + deletion delay is now also stored on the location, sealed with the key, from its first backup + with this version; a location backed up only by an older version gives the default of 7 days. +- `occ backup:target:add --repair-config`: when the config file of a location is gone, the recovery + kit writes it again (for locations backed up by this version or newer). Before, the message said + this was possible, but it was not. +- The key page warns against creating a new key when you are restoring a server. ### Changed - Locations are backed up one after the other: a backup that has to wait shows as queued. A restore diff --git a/PLAN.md b/PLAN.md index 6a0e625..a9ac92d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -592,6 +592,13 @@ in README "Known limitations": als reserve als `config` ontbreekt of niet opent. Vergt een andere volgorde van `target:add` en sleutelimport (het gevoeligste pad), daarom niet in rc1. Daarna de DR-test op PVE02 herhalen zonder kit. + **Gebouwd (2026-11-07):** `backup:target:add` op een server zonder sleutel kijkt eerst of de + locatie een repository heeft, vraagt dan een wachtwoordzin (of `--passphrase-file`), opent de + slots uit `config`, controleert de sleutel tegen de `check` en slaat hem pas dan op; geen + kitbevestiging, dus backups wachten op een nieuwe kit. Nieuw bestand `settings` op de locatie, + versleuteld met de hoofdsleutel en eenmalig geschreven (bij `target:add` en bij het begin van + een backup): de verwijdertermijn, anders de standaard 7 dagen. Getest: unit-tests en + `full-restore-roundtrip.sh 35-apache pgsql no-kit`. Nog open: de DR-test op PVE02 zonder kit. 2. Instelling "zware achtergrondjobs pauzeren tijdens een backup" (standaard aan, uit te zetten): Memories, files_antivirus BackgroundScanner, Recognize, Preview Generator, fulltextsearch. Via de reservering/last_run van die jobs (geen apps uitzetten), crashbestendig, maximaal diff --git a/README.md b/README.md index 3b6300a..8536863 100644 --- a/README.md +++ b/README.md @@ -87,8 +87,9 @@ All backups of an installation are encrypted with one master key. - The **recovery kit** is your own copy of that encrypted key. Still keep it off the server and off the location: an attacker who gets write access to the location can damage or delete its `config` file on purpose, and without the kit that makes every backup there unreadable. -- For a **restore on a new server** (disaster recovery) you currently need the **recovery kit** - and **one passphrase**. +- For a **restore on a new server** (disaster recovery) **one passphrase** is enough: the new + server takes the key from the location. With the **recovery kit** you can restore even when the + location's `config` file is damaged or gone. - Planned: an option to not store the key on the location at all. Then the kit is the only copy, and losing it means losing every backup; that option will come with an explicit warning. - Up to **three passphrases** ("slots") can open the key, so one person leaving or dying does not diff --git a/docs/DISASTER-RECOVERY.md b/docs/DISASTER-RECOVERY.md index 745f7be..f03e73e 100644 --- a/docs/DISASTER-RECOVERY.md +++ b/docs/DISASTER-RECOVERY.md @@ -5,7 +5,8 @@ Nextcloud back from an NG Backup location. Do them in this order. ## What you need -- [ ] The **recovery kit** file (`ng_backup-kit.json`) and **one passphrase** of it. +- [ ] **One passphrase** of the old server, and if you have it, the **recovery kit** file + (`ng_backup-kit.json`). - [ ] The **connection details of the backup location**: host, user, password or keys, bucket, folder. - [ ] A new server with the **same Nextcloud major version** as the old one (for example 34), a database (PostgreSQL, MySQL/MariaDB or SQLite; it may differ from the old one) and room for @@ -15,8 +16,10 @@ Nextcloud back from an NG Backup location. Do them in this order. - [ ] A list of the apps that are **not in the app store** (your own builds, apps copied in by hand). Keep their packages somewhere safe beforehand. -No kit? If the location still holds its `config` file, the key is there too, but this version -can only import it from a kit. Keep the kit off the server, next to the passphrase. +No kit? Since version 1.1.0 the passphrase is enough: the key is also stored on the location (in +its `config` file), locked with the same passphrases. Skip step 4. The kit is still the safer +copy: it helps when that file on the location is damaged. Keep it off the server, next to the +passphrase. ## Steps @@ -40,7 +43,8 @@ This keeps the new server quiet (no mail syncs, no notifications, no backups) un **3. Install NG Backup** (the same version or newer) and enable it. -**4. Import the backup key.** Copy the kit to the new server so the web server user can read it. +**4. Import the backup key** (with a kit; without one, go to step 5). Copy the kit to the new +server so the web server user can read it. From your own computer: @@ -67,13 +71,20 @@ the index and shows how far it is, for example occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... -o root=... --path=... -If it says "No backup key yet", the key is not imported. Run step 4: +Without a kit (step 4 skipped), it finds the backups and asks for a passphrase of the old server; +you type it yourself. It then shows the key fingerprint and the deletion delay of the old server. +A wrong passphrase changes nothing: run the command again. Backups made before version 1.1.0 did +not store the deletion delay on the location; it is then set to 7 days. - occ backup:key:import /tmp/ng_backup-kit.json +If it says that the location's **config file is gone**, the key is only in the recovery kit: +import it (step 4), then add the location with `--repair-config` at the end of the same command. +That writes the config again from the kit; it works for locations backed up by version 1.1.0 or +newer. -Do **not** run `backup:key:init`, even though the message suggests it: that makes a brand new key, -and your backups were encrypted with the old one. NG Backup then refuses the location ("created -with a different key"). +If it says "No backup key yet, and no backups at this location", the connection details or +`--path` differ from the old server's. Check them; do **not** run `backup:key:init`: that makes a +brand new key, and your backups were encrypted with the old one. NG Backup then refuses the +location ("created with a different key"). **6. List the restore points** and pick one, normally the newest: @@ -164,6 +175,10 @@ The command then ends with exit code 2 instead of 0. The same option exists for **11. Make a backup of the new server** to the same location (`occ backup:run offsite`). The old server must stay off: two servers must never write to the same location. +Restored without a kit? Then backups wait until you have a new one: download it on the backup +page (or `occ backup:key:kit --output=...`), store it off the server and confirm it +(`occ backup:key:confirm`). + ## Never - Never let the old server write to the location again if it comes back. Switch its backups off diff --git a/docs/HOWTO.md b/docs/HOWTO.md index e640f95..ee9b1b1 100644 --- a/docs/HOWTO.md +++ b/docs/HOWTO.md @@ -157,6 +157,10 @@ occ backup:list offsite # the snapshots must be lis occ backup:restore:full offsite ``` +Without the kit, leave out `backup:key:import`: `backup:target:add` finds the backups and takes +the key from the location with a passphrase (`--passphrase-file=/path/passphrase`, or it asks). +Backups then wait until a new kit is downloaded and confirmed. + The command refuses on an installation that already has more than one user, and asks before it starts; `--force` skips both (also needed to rerun it after it stopped halfway). diff --git a/lib/Command/KeyImport.php b/lib/Command/KeyImport.php index f982965..9e839e5 100644 --- a/lib/Command/KeyImport.php +++ b/lib/Command/KeyImport.php @@ -32,6 +32,9 @@ For a fresh installation only (occ backup:key:init must not have been run here y of the kit's passphrase slots to recover the original master key, so occ backup:target:add opens the existing repository on the location instead of creating a new, empty one. +No kit? Skip this command: occ backup:target:add then takes the key from the location with one +of the old server's passphrases. The kit still helps when the location's config file is damaged. + Example: occ backup:key:import /root/ng_backup-recovery-kit.json occ backup:target:add offsite sftp -a password::password -o host=... -o user=... -o password=... diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php index 0d24c1b..c7ed05e 100644 --- a/lib/Command/TargetAdd.php +++ b/lib/Command/TargetAdd.php @@ -13,7 +13,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; final class TargetAdd extends Command { public function __construct( @@ -31,10 +33,20 @@ final class TargetAdd extends Command { ->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)') ->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)') ->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup') + ->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Disaster recovery without the recovery kit: read the passphrase from this file instead of asking') + ->addOption('repair-config', null, InputOption::VALUE_NONE, 'The location\'s config file is gone: write it again with the key of this server or of the imported recovery kit (locations backed up by 1.1.0 or newer)') ->setHelp(<<<'HELP' The location is reached with files_external's own connection code, but it is never added as an external storage: it does not appear in Files for anyone. Credentials are stored encrypted. +Disaster recovery without the recovery kit: on a server without a backup key, a location that +holds backups gives its key with one of the old server's passphrases. Run this command first +(no occ backup:key:import), then occ backup:list and occ backup:restore:full. + +With the recovery kit: occ backup:key:import first; this command then asks for nothing more. +If the location's config file is gone, only the kit has the key: import it, then add the +location with --repair-config. + Examples: occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud @@ -43,13 +55,6 @@ HELP); } protected function execute(InputInterface $input, OutputInterface $output): int { - if (!$this->keys->isInitialized()) { - $output->writeln('No backup key yet.'); - $output->writeln('Restoring from a location that already has backups (disaster recovery)? Import the key from the recovery kit first:'); - $output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json'); - $output->writeln('Only for a new location without backups: create a new key with occ backup:key:init. A new key cannot read existing backups.'); - return 1; - } $options = []; foreach ($input->getOption('option') as $pair) { if (!str_contains($pair, '=')) { @@ -63,24 +68,102 @@ HELP); $output->writeln('--auth is required (see occ backup:target:backends)'); return 1; } + $name = (string)$input->getArgument('name'); + $backend = (string)$input->getArgument('backend'); + $auth = (string)$input->getOption('auth'); + $path = (string)$input->getOption('path'); + $passphrase = null; + $repair = (bool)$input->getOption('repair-config'); + if (!$this->keys->isInitialized()) { + try { + $state = $this->targets->locationState($backend, $auth, $options, $path); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + if ($state === TargetService::LOCATION_EMPTY) { + $output->writeln('No backup key yet, and no backups at this location.'); + $output->writeln('Restoring a server (disaster recovery)? Check the connection details and --path: they must be the same as on the old server.'); + $output->writeln('Only for a new location: create a new key first with occ backup:key:init. A new key cannot read existing backups.'); + return 1; + } + if ($state === TargetService::LOCATION_DATA_WITHOUT_CONFIG) { + $output->writeln('This location holds backups, but its config file is gone, and the key was stored in it.'); + $output->writeln('Now only the recovery kit has the key. Import it, then run this command again with --repair-config:'); + $output->writeln(' occ backup:key:import /path/to/ng_backup-kit.json'); + $output->writeln('Do not run occ backup:key:init: a new key cannot read these backups.'); + return 1; + } + if ($repair) { + $output->writeln('The config at this location is there: leave out --repair-config.'); + return 1; + } + $output->writeln('No backup key on this server yet, and this location holds backups. Their key is stored there too,'); + $output->writeln('locked with the passphrases of the old server: one of them unlocks it (the recovery kit is not needed).'); + $passphrase = $this->passphrase($input, $output); + if ($passphrase === null) { + return 1; + } + } // Adopting an existing repository reads its whole index first, which takes a while on a // large backup or a slow line; show how far it is instead of looking stuck. $output->writeln('Connecting to the location; with existing backups this reads their index first ...'); try { - $r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, - (string)$input->getOption('path'), self::progress($output)); + $r = $passphrase === null + ? $this->targets->add($name, $backend, $auth, $options, $path, self::progress($output), $repair) + : $this->targets->addWithPassphrase($name, $backend, $auth, $options, $path, $passphrase, self::progress($output)); + } catch (\OCA\NgBackup\Crypto\CryptoException $e) { + $output->writeln('' . $e->getMessage() . ''); + if (!$this->keys->isInitialized()) { + $output->writeln('Nothing was changed. Run the command again to try another passphrase, or use the recovery kit (occ backup:key:import).'); + } + return 1; } catch (\Throwable $e) { if ($output->isDecorated()) { $output->write("\r\033[K"); // clear an unfinished progress line } $output->writeln('' . $e->getMessage() . ''); + if (str_contains($e->getMessage(), 'different key')) { + $output->writeln('The backup key here (fingerprint ' . $this->keys->fingerprint() . ') is not the one these backups were made with.'); + $output->writeln('Restoring a server? The recovery kit must be the old server\'s: its fingerprint is in the kit file. Nothing was changed.'); + } return 1; } + if ($repair) { + $output->writeln('The config of the location was written again from the recovery kit.'); + } $output->writeln(sprintf('Location %s added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(), $r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted')); + if ($passphrase !== null) { + $output->writeln('Backup key taken from the location, fingerprint ' . $this->keys->fingerprint() . '.'); + $output->writeln(array_key_exists('deleteDelayDays', $r) && $r['deleteDelayDays'] !== null + ? 'Deletion delay for forgotten snapshots: ' . $this->keys->deleteDelayDays() . ' days (from the location).' + : 'Deletion delay for forgotten snapshots: ' . $this->keys->deleteDelayDays() . ' days (the default: the location has none stored, its backups were made before NG Backup 1.1.0).'); + $output->writeln('Next: occ backup:list and occ backup:restore:full. Before backups run again, download a new recovery kit and confirm it (occ backup:key:kit, occ backup:key:confirm).'); + } return 0; } + private function passphrase(InputInterface $input, OutputInterface $output): ?string { + $file = $input->getOption('passphrase-file'); + if ($file !== null) { + $raw = @file_get_contents((string)$file); + if ($raw === false) { + $output->writeln('Cannot read the passphrase file'); + return null; + } + return rtrim($raw, "\r\n"); + } + if (!$input->isInteractive()) { + $output->writeln('Give the passphrase with --passphrase-file, or run the command without --no-interaction to type it.'); + return null; + } + $q = (new Question('Passphrase of the old server (one of its slots): '))->setHidden(true)->setHiddenFallback(false); + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + return (string)$helper->ask($input, $output, $q); + } + /** * Progress of reading the index: on a terminal one line that is updated in place, otherwise * (a log, a pipe) a line every 30 seconds. The time left is estimated from the rate so far. diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index e68b347..8f05b2b 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -18,6 +18,7 @@ use OCA\NgBackup\Crypto\StreamCipher; * * Layout on the backend: * config plaintext JSON: format version, repository id, wrapped master key + * settings encrypted JSON: settings fixed at installation (deletion delay) * packs/xx/ encrypted blobs + encrypted pack header * index/ encrypted list of the blobs in that pack * snapshots/ encrypted JSON lines: one meta line, then one line per file @@ -28,6 +29,7 @@ use OCA\NgBackup\Crypto\StreamCipher; final class Repository { public const FORMAT = 1; public const BLOB_SIZE = 4 * 1048576; + private const SETTINGS = 'settings'; private const FLAG_DEFLATE = 1; /** Blobs from this size on get a quick compression test before the full compression. */ private const DEFLATE_PROBE_FROM = 256 * 1024; @@ -79,7 +81,7 @@ final class Repository { // opens this data; refuse instead. foreach (['index', 'snapshots', 'trash/snapshots', 'catalog'] as $dir) { if ($backend->listFiles($dir) !== []) { - throw new RepositoryException("This location holds backup data ($dir/) but no repository config could be read: not creating a new repository over it. Check the connection and the folder; a missing config can be restored from the recovery kit."); + throw new RepositoryException("This location holds backup data ($dir/) but no repository config could be read: not creating a new repository over it. Check the connection and the folder. If the config is really gone, occ backup:target:add with --repair-config writes it again with this server's key (on a new server: import the recovery kit first, occ backup:key:import)."); } } $id = bin2hex(random_bytes(16)); @@ -95,12 +97,7 @@ final class Repository { /** @param ?callable(int, int): void $progress index files downloaded so far, and in all (see BlobIndex::load()) */ public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null, ?callable $progress = null): self { $config = self::readConfig($backend); - try { - $ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id']; - } catch (\OCA\NgBackup\Crypto\CryptoException) { - $ok = false; - } - if (!$ok) { + if (!self::configMatches($config, $keys)) { throw new RepositoryException('This repository was created with a different key'); } $repo = new self($backend, $keys, $config['id']); @@ -196,6 +193,97 @@ final class Repository { return $config; } + private static function configMatches(array $config, KeyRing $keys): bool { + try { + return isset($config['check'], $config['id']) + && (new StreamCipher($keys))->decryptString((string)base64_decode((string)$config['check'], true), 'config-check') === $config['id']; + } catch (\OCA\NgBackup\Crypto\CryptoException) { + return false; + } + } + + /** + * The passphrase-wrapped key slots in the config at this location: disaster recovery without + * the recovery kit, where any one of their passphrases gives back the master key. + */ + public static function wrappedKeyAt(IBackend $backend): array { + $config = self::readConfig($backend); + if (!isset($config['key']) || !is_array($config['key'])) { + throw new RepositoryException('The repository config at this location holds no key'); + } + return $config['key']; + } + + /** Whether the repository at this location was created with this key. Does not read the index. */ + public static function madeWithKey(IBackend $backend, KeyRing $keys): bool { + return self::configMatches(self::readConfig($backend), $keys); + } + + /** + * Settings of the installation that never change (the deletion delay), sealed with the + * master key so that, unlike the config and the recovery kit, nobody can alter them unseen. + * Empty when the location has none yet (made before 1.1.0, until its next backup) or they + * cannot be read. + * + * @return array + */ + public static function settingsAt(IBackend $backend, KeyRing $keys): array { + try { + if (!$backend->exists(self::SETTINGS)) { + return []; + } + $fh = $backend->get(self::SETTINGS); + $raw = (string)stream_get_contents($fh); + fclose($fh); + $settings = json_decode((new StreamCipher($keys))->decryptString($raw, 'settings'), true, 16, JSON_THROW_ON_ERROR); + return is_array($settings) ? $settings : []; + } catch (BackendException|\OCA\NgBackup\Crypto\CryptoException|\JsonException) { + return []; + } + } + + /** + * Write the settings once; a location that has them keeps them. The repository id goes with + * them: with it and the key from the recovery kit, a lost config can be written again. + */ + public function recordSettings(array $settings): void { + if ($this->backend->exists(self::SETTINGS)) { + return; + } + $settings['repository'] = $this->repositoryId; + self::putString($this->backend, self::SETTINGS, $this->cipher->encryptString(json_encode($settings, JSON_THROW_ON_ERROR), 'settings')); + } + + /** Whether the location holds backup data, with or without a config. */ + public static function holdsData(IBackend $backend): bool { + foreach (['index', 'snapshots', 'catalog'] as $dir) { + if ($backend->listFiles($dir) !== []) { + return true; + } + } + return false; + } + + /** + * Write a lost config again, from the key (recovery kit) and the repository id in the + * sealed settings. Only where there is no config and the settings open with this key, so + * nothing that works is ever overwritten and a wrong key writes nothing. + */ + public static function repairConfig(IBackend $backend, KeyRing $keys, array $wrappedKey): void { + if ($backend->exists('config')) { + throw new RepositoryException('This location has a config: nothing to repair, leave out --repair-config'); + } + $id = self::settingsAt($backend, $keys)['repository'] ?? null; + if (!is_string($id) || !preg_match('/^[0-9a-f]{32}$/', $id)) { + throw new RepositoryException($backend->exists(self::SETTINGS) + ? 'The settings at this location do not open with this key: the recovery kit is of another server, or the settings are damaged. The config cannot be written again.' + : 'This location has no settings file (it was backed up only by NG Backup before 1.1.0), so its config cannot be written again.'); + } + $config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey, + 'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check')), 'repaired' => true]; + self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)); + } + /** 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); diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index 534a6cd..fc71455 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -225,6 +225,8 @@ final class BackupService { if (!isset($state['dump'])) { // It may have been queued for hours: the restore point has the time of its data. $state['files']['started'] = gmdate('c'); + // Locations from before 1.1.0 get their sealed settings at their next backup. + $this->targets->recordSettings($repo); } $dumper = new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_')); $dbStats = $state['dbStats'] ?? []; diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php index cf5c832..1f68711 100644 --- a/lib/Service/KeyService.php +++ b/lib/Service/KeyService.php @@ -91,20 +91,38 @@ final class KeyService { throw new \InvalidArgumentException('Not a NG Backup recovery kit file'); } $keys = KeyRing::unwrapAny($kit['wrapped_key'], $passphrase); - $this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER, - $this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true); - $this->cached = $keys; - $this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode($kit['wrapped_key'], JSON_THROW_ON_ERROR), true, true); - $this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true); // Kits from before this field existed fall back to the default. - $delay = (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY); - $this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, ($delay >= 1 && $delay <= 365) ? $delay : self::DEFAULT_DELETE_DELAY, true); - $this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, (int)($kit['kit_version'] ?? 1), true); + $this->store($keys, $kit['wrapped_key'], (int)($kit['delete_delay_days'] ?? self::DEFAULT_DELETE_DELAY), (int)($kit['kit_version'] ?? 1)); // Having and successfully using a real kit file is stronger proof than the normal // download-and-confirm flow it replaces. $this->confirmRecoveryKit('cli-import'); } + /** + * Disaster recovery without the recovery kit: adopt the master key that a passphrase opened + * from the key slots in a location's config (TargetService::addWithPassphrase). There is no + * kit, so none is confirmed: backups wait until a new kit is downloaded and confirmed. + * + * @param ?int $deleteDelayDays from the location's sealed settings, null when it has none + */ + public function adoptKey(KeyRing $keys, array $wrappedKey, ?int $deleteDelayDays): void { + if ($this->isInitialized()) { + throw new \RuntimeException('A backup key already exists'); + } + $this->store($keys, $wrappedKey, $deleteDelayDays ?? self::DEFAULT_DELETE_DELAY, 1); + } + + private function store(KeyRing $keys, array $wrappedKey, int $deleteDelayDays, int $kitVersion): void { + $this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER, + $this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true); + $this->cached = $keys; + $this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode($wrappedKey, JSON_THROW_ON_ERROR), true, true); + $this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true); + $this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, + ($deleteDelayDays >= 1 && $deleteDelayDays <= 365) ? $deleteDelayDays : self::DEFAULT_DELETE_DELAY, true); + $this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, max(1, $kitVersion), true); + } + /** * The master key and the recovery kit code are stored encrypted with Nextcloud's own * instance secret (ICrypto), not with themselves; the wrapped key is not (it is sealed with diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index 84858d5..884adcc 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -52,11 +52,14 @@ final class TargetService { * @param ?callable(int, int): void $progress reading an existing repository's index: files done, and in all * @return array{target: Target, created: bool} */ - public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null): array { + public function add(string $name, string $backend, string $auth, array $options, string $basePath, ?callable $progress = null, bool $repairConfig = false): array { $this->validateName($name); $this->requireUsableBackend($backend); $keys = $this->keys->keyRing(); - $storageBackend = $this->buildBackend($backend, $auth, $options, $basePath); + $storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, !$repairConfig); + if ($repairConfig) { + Repository::repairConfig($storageBackend, $keys, $this->keys->wrappedKey()); + } if ($storageBackend->exists('config')) { $repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor, $progress); $created = false; @@ -64,6 +67,7 @@ final class TargetService { $repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor); $created = true; } + $this->recordSettings($repo); foreach ($this->mapper->findAll() as $existing) { if ($existing->getRepositoryId() === $repo->id()) { @@ -93,6 +97,65 @@ final class TargetService { return ['target' => $target, 'created' => $created]; } + public const LOCATION_REPOSITORY = 'repository'; + public const LOCATION_DATA_WITHOUT_CONFIG = 'data-without-config'; + public const LOCATION_EMPTY = 'empty'; + + /** + * What these settings lead to: a repository, backup data whose config is gone, or nothing + * of NG Backup. Creates nothing. + * + * @return self::LOCATION_* + */ + public function locationState(string $backend, string $auth, array $options, string $basePath): string { + $this->requireUsableBackend($backend); + $storage = $this->buildBackend($backend, $auth, $options, $basePath, false); + if ($storage->exists('config')) { + return self::LOCATION_REPOSITORY; + } + return Repository::holdsData($storage) ? self::LOCATION_DATA_WITHOUT_CONFIG : self::LOCATION_EMPTY; + } + + /** + * Disaster recovery without the recovery kit, on an installation without a backup key: take + * the key from the location's config with one of its passphrases, then add the location as + * usual. The key is only kept when it really is the one the repository was made with. + * + * @param array $options + * @param ?callable(int, int): void $progress + * @return array{target: Target, created: bool, deleteDelayDays: ?int} the deletion delay the location had stored, if any + * @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase + */ + public function addWithPassphrase(string $name, string $backend, string $auth, array $options, string $basePath, + #[\SensitiveParameter] string $passphrase, ?callable $progress = null): array { + if ($this->keys->isInitialized()) { + throw new \RuntimeException('A backup key already exists here'); + } + $this->validateName($name); + $this->requireUsableBackend($backend); + $storageBackend = $this->buildBackend($backend, $auth, $options, $basePath, false); + if (!$storageBackend->exists('config')) { + throw new \InvalidArgumentException("No NG Backup repository found in folder '" . trim($basePath, '/') . "'"); + } + $wrapped = Repository::wrappedKeyAt($storageBackend); + $keys = \OCA\NgBackup\Crypto\KeyRing::unwrapAny($wrapped, $passphrase); + if (!Repository::madeWithKey($storageBackend, $keys)) { + throw new \RuntimeException('The key in the config at this location does not open its backups: the config is damaged or was replaced. Only the recovery kit can help now: occ backup:key:import , then this command again.'); + } + $delay = Repository::settingsAt($storageBackend, $keys)['delete_delay_days'] ?? null; + $delay = is_int($delay) ? $delay : null; + $this->keys->adoptKey($keys, $wrapped, $delay); + return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay]; + } + + /** Seal the settings fixed at installation on the location, once. A failure is tried again at the next backup. */ + public function recordSettings(Repository $repo): void { + try { + $repo->recordSettings(['delete_delay_days' => $this->keys->deleteDelayDays()]); + } catch (\Throwable) { + } + } + /** * Change how an existing location is reached (credentials, host, folder, even the connection * type) or its name. The new settings must lead to the same repository: the history stays diff --git a/src/components/KeySetup.vue b/src/components/KeySetup.vue index 7c81dee..adc2a98 100644 --- a/src/components/KeySetup.vue +++ b/src/components/KeySetup.vue @@ -4,8 +4,11 @@ -->