Files
nextcloud-ng_backup/lib/Command/KeyImport.php
T
dillard 569077fa69 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.
2026-11-07 12:22:38 +01:00

89 lines
3.7 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use 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\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
final class KeyImport extends Command {
public function __construct(
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:import')
->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')
->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.
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=...
occ backup:restore:full offsite <snapshot>
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>');
return 1;
}
$kitFile = (string)$input->getArgument('kit-file');
$raw = @file_get_contents($kitFile);
if ($raw === false) {
$output->writeln("<error>Cannot read $kitFile</error>");
return 1;
}
try {
$kit = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$output->writeln('<error>Not a valid recovery kit file: ' . $e->getMessage() . '</error>');
return 1;
}
$file = $input->getOption('passphrase-file');
if ($file !== null) {
$raw = @file_get_contents($file);
if ($raw === false) {
$output->writeln('<error>Cannot read the passphrase file</error>');
return 1;
}
$pass = rtrim($raw, "\r\n");
} else {
$helper = $this->getHelper('question');
$q = (new Question('Passphrase of one of the kit\'s slots: '))->setHidden(true)->setHiddenFallback(false);
$pass = (string)$helper->ask($input, $output, $q);
}
try {
$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>.');
// 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).');
$output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.');
return 0;
}
}