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.
This commit is contained in:
+5
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '). occ backup:key:import is only for a fresh installation.</error>');
|
||||
$replace = (bool)$input->getOption('replace-unused-key');
|
||||
if ($replace && !$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
|
||||
return 1;
|
||||
}
|
||||
if ($this->keys->isInitialized()) {
|
||||
if (!$this->targets->keyIsUnused()) {
|
||||
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . ') and it has been used for a backup location: it is never replaced.</error>');
|
||||
$output->writeln('occ backup:key:import is for a server that is being restored.');
|
||||
return 1;
|
||||
}
|
||||
if (!$replace) {
|
||||
$output->writeln('<error>A backup key already exists here (fingerprint ' . $this->keys->fingerprint() . '), but it was never used: no location, no backup.</error>');
|
||||
$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('<error>' . $e->getMessage() . '</error>');
|
||||
return 1;
|
||||
}
|
||||
$output->writeln('Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
// 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: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).');
|
||||
|
||||
@@ -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('<error>--replace-unused-key needs the config at the location; with the config gone only the kit helps: occ backup:key:import <kit file> --replace-unused-key, then --repair-config.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!$this->keys->isInitialized()) {
|
||||
$output->writeln('<error>There is no backup key here to replace: leave out --replace-unused-key.</error>');
|
||||
return 1;
|
||||
}
|
||||
if (!$this->targets->keyIsUnused()) {
|
||||
$output->writeln('<error>The backup key of this server (fingerprint ' . $this->keys->fingerprint() . ') has been used for a backup location: it is never replaced.</error>');
|
||||
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('<error>' . $e->getMessage() . '</error>');
|
||||
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('<error>' . $e->getMessage() . '</error>');
|
||||
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 <info>%s</info> 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 <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
$output->writeln(($replace ? 'The unused key was replaced. ' : '') . 'Backup key taken from the location, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||
$output->writeln(array_key_exists('deleteDelayDays', $r) && $r['deleteDelayDays'] !== null
|
||||
? 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the location).'
|
||||
: 'Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (the default: the location has none stored, its backups were made before NG Backup 1.1.0).');
|
||||
|
||||
@@ -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'] ?? '']),
|
||||
|
||||
@@ -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<string, mixed> $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);
|
||||
|
||||
@@ -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 <kit file> --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<string, mixed> $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<string, mixed> $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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user