From cf9cf5028609027fe5e322a3a85ee1bc92b7dad2 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Sun, 8 Nov 2026 11:53:55 +0100 Subject: [PATCH] Replace a backup key that was never used by the key of existing backups A key made by mistake on a server that is being restored left no way back: key:import and the passphrase route refused because a key existed, and nothing removed it. As long as the key never protected anything (no location created or adopted with it, no location or run in the database), occ backup:key:import --replace-unused-key and occ backup:target:add --replace-unused-key replace it, after the new key was opened. A key that was used is never replaced, also after its location was removed. The refusal names the option, and all administrators are notified of a replacement. --- CHANGELOG.md | 6 +- docs/DISASTER-RECOVERY.md | 7 +++ lib/Command/KeyImport.php | 33 +++++++++-- lib/Command/TargetAdd.php | 35 +++++++++-- lib/Notification/Notifier.php | 1 + lib/Service/KeyService.php | 46 ++++++++++++-- lib/Service/TargetService.php | 79 +++++++++++++++++++++---- tests/integration/recovery-scenarios.sh | 69 +++++++++++++++++++++ 8 files changed, 250 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f5509..90ac40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,11 @@ shown under 1.0.0. The git tag of a release candidate goes after the date. - `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. +- The key page warns against creating a new key when you are restoring a server. A key made there + by mistake can still make way for the key of the backups, as long as it was never used (no + location, no backup): `occ backup:key:import --replace-unused-key` with the kit, or + `occ backup:target:add --replace-unused-key` with a passphrase of the old server. A key that + protected a location is never replaced. All administrators are notified of a replacement. ### Changed - Reading files for a backup is much faster on disks where every request waits, such as network and diff --git a/docs/DISASTER-RECOVERY.md b/docs/DISASTER-RECOVERY.md index f03e73e..ba0bdb7 100644 --- a/docs/DISASTER-RECOVERY.md +++ b/docs/DISASTER-RECOVERY.md @@ -86,6 +86,13 @@ If it says "No backup key yet, and no backups at this location", the connection brand new key, and your backups were encrypted with the old one. NG Backup then refuses the location ("created with a different key"). +**Made a new key by mistake** (with `backup:key:init` or on the page) before adding the location? +As long as that key was never used (no location added with it, no backup), it can make way for the +key of your backups. NG Backup says so when it refuses the location. Add `--replace-unused-key` to +`occ backup:key:import` (with the kit) or to `occ backup:target:add` (without the kit; it asks for +a passphrase of the old server). The passphrases and kit of the unused key then stop working. A key +that was used for a location is never replaced: its own backups depend on it. + **6. List the restore points** and pick one, normally the newest: occ backup:list offsite diff --git a/lib/Command/KeyImport.php b/lib/Command/KeyImport.php index 9e839e5..d626ebd 100644 --- a/lib/Command/KeyImport.php +++ b/lib/Command/KeyImport.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\NgBackup\Command; use OCA\NgBackup\Service\KeyService; +use OCA\NgBackup\Service\TargetService; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -18,6 +19,7 @@ use Symfony\Component\Console\Question\Question; final class KeyImport extends Command { public function __construct( private KeyService $keys, + private TargetService $targets, ) { parent::__construct(); } @@ -27,11 +29,16 @@ final class KeyImport extends Command { ->setDescription('Disaster recovery: adopt the backup key from a recovery kit on a fresh installation') ->addArgument('kit-file', InputArgument::REQUIRED, 'The recovery kit file (occ backup:key:kit on the original installation)') ->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking') + ->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'A new key was made here by mistake: replace it by the kit\'s key, if it was never used (no location, no backup)') ->setHelp(<<<'HELP' For a fresh installation only (occ backup:key:init must not have been run here yet). Reads one 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. +A new key was made here by mistake before the location was added? As long as it was never used +(no location, no backup), --replace-unused-key replaces it by the kit's key. A key that was used +is never replaced. + 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. @@ -43,10 +50,24 @@ HELP); } protected function execute(InputInterface $input, OutputInterface $output): int { - if ($this->keys->isInitialized()) { - $output->writeln('A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '). occ backup:key:import is only for a fresh installation.'); + $replace = (bool)$input->getOption('replace-unused-key'); + if ($replace && !$this->keys->isInitialized()) { + $output->writeln('There is no backup key here to replace: leave out --replace-unused-key.'); return 1; } + if ($this->keys->isInitialized()) { + if (!$this->targets->keyIsUnused()) { + $output->writeln('A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . ') and it has been used for a backup location: it is never replaced.'); + $output->writeln('occ backup:key:import is for a server that is being restored.'); + return 1; + } + if (!$replace) { + $output->writeln('A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '), but it was never used: no location, no backup.'); + $output->writeln('Made by mistake on a server that is being restored? Run this command again with --replace-unused-key:'); + $output->writeln('the kit\'s key then takes its place, and the passphrases and recovery kit of the unused key stop working.'); + return 1; + } + } $kitFile = (string)$input->getArgument('kit-file'); $raw = @file_get_contents($kitFile); if ($raw === false) { @@ -73,12 +94,16 @@ HELP); $pass = (string)$helper->ask($input, $output, $q); } try { - $this->keys->importRecoveryKit($kit, $pass); + if ($replace) { + $this->targets->importKitReplacingUnusedKey($kit, $pass); + } else { + $this->keys->importRecoveryKit($kit, $pass); + } } catch (\Throwable $e) { $output->writeln('' . $e->getMessage() . ''); return 1; } - $output->writeln('Backup key imported, fingerprint ' . $this->keys->fingerprint() . '.'); + $output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key imported, fingerprint ' . $this->keys->fingerprint() . '.'); // Read from the kit file, which is not authenticated beyond the wrapped key: show it so a // wrong or altered value stands out. $output->writeln('Deletion delay for forgotten snapshots: ' . $this->keys->deleteDelayDays() . ' days (from the kit).'); diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php index c7ed05e..b168973 100644 --- a/lib/Command/TargetAdd.php +++ b/lib/Command/TargetAdd.php @@ -35,6 +35,7 @@ final class TargetAdd extends Command { ->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)') + ->addOption('replace-unused-key', null, InputOption::VALUE_NONE, 'Disaster recovery after a new key was made here by mistake: replace that key, if it was never used, by the key of the backups at this location (asks for a passphrase of the old server)') ->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. @@ -43,6 +44,10 @@ Disaster recovery without the recovery kit: on a server without a backup key, a 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. +A new key was made here by mistake (occ backup:key:init or the web page) before the location +was added? As long as that key was never used (no location, no backup), --replace-unused-key +replaces it by the key of the backups. A key that was used is never replaced. + 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. @@ -74,7 +79,27 @@ HELP); $path = (string)$input->getOption('path'); $passphrase = null; $repair = (bool)$input->getOption('repair-config'); - if (!$this->keys->isInitialized()) { + $replace = (bool)$input->getOption('replace-unused-key'); + if ($replace) { + if ($repair) { + $output->writeln('--replace-unused-key needs the config at the location; with the config gone only the kit helps: occ backup:key:import --replace-unused-key, then --repair-config.'); + return 1; + } + if (!$this->keys->isInitialized()) { + $output->writeln('There is no backup key here to replace: leave out --replace-unused-key.'); + return 1; + } + if (!$this->targets->keyIsUnused()) { + $output->writeln('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced.'); + return 1; + } + $output->writeln('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') was never used. It is replaced by the key of the backups at'); + $output->writeln('this location, once a passphrase of the old server opens it. Its own passphrases and recovery kit then stop working.'); + $passphrase = $this->passphrase($input, $output); + if ($passphrase === null) { + return 1; + } + } elseif (!$this->keys->isInitialized()) { try { $state = $this->targets->locationState($backend, $auth, $options, $path); } catch (\Throwable $e) { @@ -111,10 +136,10 @@ HELP); try { $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)); + : $this->targets->addWithPassphrase($name, $backend, $auth, $options, $path, $passphrase, self::progress($output), $replace); } catch (\OCA\NgBackup\Crypto\CryptoException $e) { $output->writeln('' . $e->getMessage() . ''); - if (!$this->keys->isInitialized()) { + if (!$this->keys->isInitialized() || $replace) { $output->writeln('Nothing was changed. Run the command again to try another passphrase, or use the recovery kit (occ backup:key:import).'); } return 1; @@ -123,7 +148,7 @@ HELP); $output->write("\r\033[K"); // clear an unfinished progress line } $output->writeln('' . $e->getMessage() . ''); - if (str_contains($e->getMessage(), 'different key')) { + if (str_contains($e->getMessage(), 'different key') && !$replace) { $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.'); } @@ -135,7 +160,7 @@ HELP); $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(($replace ? 'The unused key was replaced. ' : '') . '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).'); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index f6e5d5d..69b9581 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -71,6 +71,7 @@ final class Notifier implements INotifier { $who = (string)($p['actor'] ?? ''); $notification->setParsedSubject(match ($p['event'] ?? '') { 'kit_downloaded' => $l->t('%s downloaded the backup recovery kit', [$who]), + 'key_replaced' => $l->t('%1$s replaced the unused backup key %2$s by the key of existing backups, %3$s; download and confirm the new recovery kit', [$who, $p['old'] ?? '', $p['new'] ?? '']), 'target_removed' => $l->t('%1$s removed backup location %2$s', [$who, $p['target'] ?? '']), 'target_changed' => $l->t('%1$s changed the connection settings of backup location %2$s', [$who, $p['target'] ?? '']), 'schedule_off' => $l->t('%1$s turned off the schedule of backup location %2$s', [$who, $p['target'] ?? '']), diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php index 1f68711..efb658a 100644 --- a/lib/Service/KeyService.php +++ b/lib/Service/KeyService.php @@ -30,6 +30,8 @@ final class KeyService { private const KEY_DELETE_DELAY = 'delete_delay_days'; private const KEY_KIT_VERSION = 'recovery_kit_version'; private const KEY_KIT_CODE = 'recovery_kit_code'; + /** Set once a location was created or adopted with this key: from then on it is never replaced. */ + private const KEY_USED = 'master_key_used'; public const DEFAULT_DELETE_DELAY = 7; /** * The location holds the passphrase-wrapped key too, so whoever can read it can try @@ -83,14 +85,15 @@ final class KeyService { * @param array $kit as produced by recoveryKit() * @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase, or a damaged/foreign kit */ - public function importRecoveryKit(array $kit, #[\SensitiveParameter] string $passphrase): void { - if ($this->isInitialized()) { + public function importRecoveryKit(array $kit, #[\SensitiveParameter] string $passphrase, bool $replaceUnused = false): void { + if ($this->isInitialized() && !$replaceUnused) { throw new \RuntimeException('A backup key already exists'); } if (!isset($kit['wrapped_key'])) { throw new \InvalidArgumentException('Not a NG Backup recovery kit file'); } $keys = KeyRing::unwrapAny($kit['wrapped_key'], $passphrase); + $this->discardUnused($keys, $replaceUnused); // Kits from before this field existed fall back to the default. $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 @@ -105,13 +108,48 @@ final class KeyService { * * @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()) { + public function adoptKey(KeyRing $keys, array $wrappedKey, ?int $deleteDelayDays, bool $replaceUnused = false): void { + if ($this->isInitialized() && !$replaceUnused) { throw new \RuntimeException('A backup key already exists'); } + $this->discardUnused($keys, $replaceUnused); $this->store($keys, $wrappedKey, $deleteDelayDays ?? self::DEFAULT_DELETE_DELAY, 1); } + /** Whether a location was ever created or adopted with this key (TargetService::keyIsUnused() also looks at the database). */ + public function wasUsed(): bool { + return $this->appConfig->getValueBool(Application::APP_ID, self::KEY_USED, false, true); + } + + public function markUsed(): void { + if (!$this->wasUsed()) { + $this->appConfig->setValueBool(Application::APP_ID, self::KEY_USED, true, true); + } + } + + /** + * A key made by mistake on a server that is being restored: remove it, so the key of the + * backups can take its place. Only after the new key was opened with its passphrase, and + * only when the caller checked that the old key never protected anything + * (TargetService::keyIsUnused()). Its passphrases and recovery kit become worthless. + */ + private function discardUnused(KeyRing $replacement, bool $replaceUnused): void { + if (!$replaceUnused || !$this->isInitialized()) { + return; + } + if ($this->wasUsed()) { + throw new \RuntimeException('The backup key of this server has been used for a backup location: it is never replaced'); + } + if ($this->fingerprintOf($replacement) === $this->fingerprint()) { + throw new \RuntimeException('This server already has this backup key: nothing to replace'); + } + foreach ([self::KEY_MASTER, self::KEY_WRAPPED, self::KEY_CONFIRMED, self::KEY_FINGERPRINT, self::KEY_DELETE_DELAY, + self::KEY_KIT_VERSION, self::KEY_KIT_CODE, self::KEY_USED] as $key) { + $this->appConfig->deleteKey(Application::APP_ID, $key); + } + $this->cached = null; + } + 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); diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index 884adcc..b466b56 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -36,6 +36,7 @@ final class TargetService { private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor, private SnapshotMapper $snapshots, private AlertService $alerts, + private \OCA\NgBackup\Db\RunMapper $runs, ) { } @@ -57,16 +58,25 @@ final class TargetService { $this->requireUsableBackend($backend); $keys = $this->keys->keyRing(); $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; - } else { - $repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor); - $created = true; + try { + 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; + } else { + $repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor); + $created = true; + } + } catch (\OCA\NgBackup\Repository\RepositoryException $e) { + $otherKey = str_contains($e->getMessage(), 'different key') || str_contains($e->getMessage(), 'do not open with this key'); + if ($otherKey && $this->keyIsUnused()) { + throw new \OCA\NgBackup\Repository\RepositoryException(rtrim($e->getMessage(), '.') . '. ' . self::UNUSED_KEY_HINT, 0, $e); + } + throw $e; } + $this->keys->markUsed(); $this->recordSettings($repo); foreach ($this->mapper->findAll() as $existing) { @@ -97,6 +107,44 @@ final class TargetService { return ['target' => $target, 'created' => $created]; } + public const UNUSED_KEY_HINT = 'The backup key of this server was never used (no location, no backup), so it can make way for the key of these backups: ' + . 'with the old server\'s recovery kit, occ backup:key:import --replace-unused-key; without the kit, ' + . 'occ backup:target:add with the same details and --replace-unused-key (asks for a passphrase of the old server)'; + + /** + * A key that never protected anything: no location was created or adopted with it, and the + * database has no location and no run (installations from before the flag existed). Only such + * a key may make way for the key of existing backups, e.g. after a new key was made by + * mistake on a server that is being restored. + */ + public function keyIsUnused(): bool { + return $this->keys->isInitialized() && !$this->keys->wasUsed() + && $this->mapper->findAll() === [] && $this->runs->countAll() === 0; + } + + /** + * occ backup:key:import on a server whose own key was never used: the kit's key takes its place. + * + * @param array $kit + * @throws \OCA\NgBackup\Crypto\CryptoException wrong passphrase: nothing changed + */ + public function importKitReplacingUnusedKey(array $kit, #[\SensitiveParameter] string $passphrase): void { + $old = $this->requireUnusedKey(); + $this->keys->importRecoveryKit($kit, $passphrase, true); + $this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]); + } + + /** @return string the fingerprint of the unused key */ + private function requireUnusedKey(): string { + if (!$this->keys->isInitialized()) { + throw new \RuntimeException('There is no backup key here to replace'); + } + if (!$this->keyIsUnused()) { + throw new \RuntimeException('The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced'); + } + return $this->keys->fingerprint(); + } + public const LOCATION_REPOSITORY = 'repository'; public const LOCATION_DATA_WITHOUT_CONFIG = 'data-without-config'; public const LOCATION_EMPTY = 'empty'; @@ -120,6 +168,7 @@ final class TargetService { * 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. + * With $replaceUnusedKey, a key this server has but never used makes way (keyIsUnused()). * * @param array $options * @param ?callable(int, int): void $progress @@ -127,8 +176,11 @@ final class TargetService { * @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()) { + #[\SensitiveParameter] string $passphrase, ?callable $progress = null, bool $replaceUnusedKey = false): array { + $old = null; + if ($replaceUnusedKey) { + $old = $this->requireUnusedKey(); + } elseif ($this->keys->isInitialized()) { throw new \RuntimeException('A backup key already exists here'); } $this->validateName($name); @@ -144,7 +196,10 @@ final class TargetService { } $delay = Repository::settingsAt($storageBackend, $keys)['delete_delay_days'] ?? null; $delay = is_int($delay) ? $delay : null; - $this->keys->adoptKey($keys, $wrapped, $delay); + $this->keys->adoptKey($keys, $wrapped, $delay, $replaceUnusedKey); + if ($old !== null) { + $this->alerts->securityEvent('key_replaced', ['old' => $old, 'new' => $this->keys->fingerprint()]); + } return $this->add($name, $backend, $auth, $options, $basePath, $progress) + ['deleteDelayDays' => $delay]; } diff --git a/tests/integration/recovery-scenarios.sh b/tests/integration/recovery-scenarios.sh index 41aebf8..1083b7f 100755 --- a/tests/integration/recovery-scenarios.sh +++ b/tests/integration/recovery-scenarios.sh @@ -14,6 +14,11 @@ # no-config-kit config gone, kit there: --repair-config writes it again, the backups open # other-kit the kit of another server: refused, told to use the old server's kit # empty nothing at the location: told to check the details, not to make a new key +# unused-key-kit a new key was made by mistake: key:import refuses and names +# --replace-unused-key; with it the kit's key takes its place +# unused-key-no-kit the same without a kit: target:add with --replace-unused-key and a passphrase +# of the old server; a wrong one keeps the unused key +# used-key a key that protected a location is never replaced, also after the location was removed set -euo pipefail IMAGE="nextcloud:${1:-35-apache}" APP=$(cd "$(dirname "$0")/../.." && pwd) @@ -200,4 +205,68 @@ expect "$OUT" "Check the connection details and --path" "empty" no_key ngb-rs-new "empty" ok "empty: told to check the details, no key made" +# unused-key-kit: a key made by mistake, then the kit +scenario unused-key-kit +run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "unused-key-kit: key init" +MISTAKE=$(key_of ngb-rs-new) +OUT=$(must_fail ngb-rs-new "unused-key-kit without the option" backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass) +expect "$OUT" "never used" "unused-key-kit" +expect "$OUT" "--replace-unused-key" "unused-key-kit: names the option" +[ "$(key_of ngb-rs-new)" = "$MISTAKE" ] || fail "unused-key-kit: the key changed without the option" +OUT=$(must_fail ngb-rs-new "unused-key-kit wrong passphrase" backup:key:import /tmp/kit.json --passphrase-file=/tmp/wrongpass --replace-unused-key) +expect "$OUT" "Wrong passphrase" "unused-key-kit wrong passphrase" +[ "$(key_of ngb-rs-new)" = "$MISTAKE" ] || fail "unused-key-kit: a wrong passphrase changed the key" +OUT=$(run_on ngb-rs-new backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key 2>&1) || { printf '%s\n' "$OUT"; fail "unused-key-kit: replace"; } +expect "$OUT" "The unused key was replaced" "unused-key-kit" +expect "$OUT" "fingerprint $FP" "unused-key-kit" +expect "$OUT" "21 days (from the kit)" "unused-key-kit" +# shellcheck disable=SC2046 +OUT=$(run_on ngb-rs-new backup:target:add offsite $(location unused-key-kit) --no-interaction 2>&1) || { printf '%s\n' "$OUT"; fail "unused-key-kit: target add"; } +expect "$OUT" "found and adopted" "unused-key-kit" +run_on ngb-rs-new backup:run offsite >/dev/null || fail "unused-key-kit: a backup after the replacement" +ok "unused-key-kit: refused without the option, a wrong passphrase changes nothing, replaced with it, backups run" + +# unused-key-no-kit: a key made by mistake, no kit +scenario unused-key-no-kit +docker exec ngb-rs-new rm /tmp/kit.json +run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "unused-key-no-kit: key init" +MISTAKE=$(key_of ngb-rs-new) +# shellcheck disable=SC2046 +OUT=$(must_fail ngb-rs-new "unused-key-no-kit without the option" backup:target:add offsite $(location unused-key-no-kit) --no-interaction) +expect "$OUT" "different key" "unused-key-no-kit" +expect "$OUT" "never used" "unused-key-no-kit: says the key can make way" +expect "$OUT" "--replace-unused-key" "unused-key-no-kit: names the option" +# shellcheck disable=SC2046 +OUT=$(must_fail ngb-rs-new "unused-key-no-kit wrong passphrase" backup:target:add offsite $(location unused-key-no-kit) --replace-unused-key --passphrase-file=/tmp/wrongpass) +expect "$OUT" "Nothing was changed" "unused-key-no-kit wrong passphrase" +[ "$(key_of ngb-rs-new)" = "$MISTAKE" ] || fail "unused-key-no-kit: a wrong passphrase changed the key" +[ -z "$(run_on ngb-rs-new backup:target:list 2>&1 | grep offsite || true)" ] || fail "unused-key-no-kit: a location was left behind" +# shellcheck disable=SC2046 +OUT=$(run_on ngb-rs-new backup:target:add offsite $(location unused-key-no-kit) --replace-unused-key --passphrase-file=/tmp/pass 2>&1) || { printf '%s\n' "$OUT"; fail "unused-key-no-kit: replace"; } +expect "$OUT" "The unused key was replaced" "unused-key-no-kit" +expect "$OUT" "fingerprint $FP" "unused-key-no-kit" +expect "$OUT" "download a new recovery kit" "unused-key-no-kit: asks for a new kit" +run_on ngb-rs-new backup:list offsite | grep -q "$SNAP" || fail "unused-key-no-kit: the snapshot is not listed" +OUT=$(must_fail ngb-rs-new "unused-key-no-kit backup before a new kit" backup:run offsite) +expect "$OUT" "Confirm the recovery kit" "unused-key-no-kit: the old key's kit confirmation does not count" +ok "unused-key-no-kit: hint on the refusal, a wrong passphrase changes nothing, replaced with a passphrase, a new kit is needed" + +# used-key: a key that protected a location stays, also after that location is removed +scenario used-key +docker exec ngb-rs-new sh -c 'mkdir -p /mnt/shared/own && chown www-data /mnt/shared/own' +run_on ngb-rs-new backup:key:init --passphrase-file=/tmp/pass >/dev/null || fail "used-key: key init" +OWN=$(key_of ngb-rs-new) +run_on ngb-rs-new backup:target:add own local -a null::null -o datadir=/mnt/shared/own --path=repo >/dev/null || fail "used-key: own location" +run_on ngb-rs-new backup:target:remove own >/dev/null || fail "used-key: remove the own location" +# shellcheck disable=SC2046 +OUT=$(must_fail ngb-rs-new "used-key target:add" backup:target:add offsite $(location used-key) --replace-unused-key --passphrase-file=/tmp/pass) +expect "$OUT" "never replaced" "used-key target:add" +# shellcheck disable=SC2046 +OUT=$(must_fail ngb-rs-new "used-key target:add without the option" backup:target:add offsite $(location used-key) --no-interaction) +not_expect "$OUT" "--replace-unused-key" "used-key: offers a replacement for a used key" +OUT=$(must_fail ngb-rs-new "used-key key:import" backup:key:import /tmp/kit.json --passphrase-file=/tmp/pass --replace-unused-key) +expect "$OUT" "never replaced" "used-key key:import" +[ "$(key_of ngb-rs-new)" = "$OWN" ] || fail "used-key: the key changed" +ok "used-key: never replaced, by kit or passphrase, also after its location was removed" + echo "RECOVERY SCENARIOS PASS"