M1: key management, backup locations and occ commands

- migration: ngb_targets, ngb_runs, ngb_snapshots
- KeyService: master key stored encrypted with the instance secret
  (unattended backups), passphrase-wrapped copy in every repository and
  in the recovery kit, recorded admin confirmation per key fingerprint
- TargetService: locations built from files_external backends without a
  mount; files_external is loaded through IAppManager::loadApp() and its
  enabled state is left alone; credentials encrypted with ICrypto;
  existing repositories are adopted only if made with this key, and a
  repository cannot be registered twice
- Repository: initWithKey/openWithKey with an encrypted key check
- occ: backup:key:init|kit|confirm, backup:target:backends|add|list|remove
This commit is contained in:
2026-10-02 00:37:18 +02:00
parent 4a13f8e791
commit b42acb41c5
16 changed files with 818 additions and 4 deletions
+52
View File
@@ -0,0 +1,52 @@
<?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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
class KeyConfirm extends Command {
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. '
. 'I understand that keeping these keys safe is my own responsibility and that backups cannot be restored without them.';
public const CONFIRMATION = 'Yes, I confirm';
public function __construct(
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:confirm')
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$this->keys->isInitialized()) {
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
return 1;
}
$output->writeln('Key fingerprint: <info>' . $this->keys->fingerprint() . '</info>');
$output->writeln('');
$output->writeln(wordwrap(self::STATEMENT, 78));
$output->writeln('');
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
if (trim($answer) !== self::CONFIRMATION) {
$output->writeln('<error>Not confirmed.</error>');
return 1;
}
$this->keys->confirmRecoveryKit((string)$input->getOption('user'));
$output->writeln('Confirmed. Backups can now run.');
return 0;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\Question;
class KeyInit extends Command {
public function __construct(
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:init')
->setDescription('Create the backup key, protected by a passphrase you keep safe')
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
->setHelp(<<<'HELP'
Creates the master key that encrypts all backups of this installation.
The server keeps the key (encrypted with this instance's secret) so scheduled backups can run.
A copy protected by your passphrase goes into every backup location and into the recovery kit
(occ backup:key:kit). To restore on a new server you need the recovery kit and the passphrase.
Example:
occ backup:key:init
occ backup:key:kit --output=/root/ng_backup-recovery-kit.json
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if ($this->keys->isInitialized()) {
$output->writeln('<error>A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').</error>');
return 1;
}
$file = $input->getOption('passphrase-file');
if ($file !== null) {
$pass = rtrim((string)@file_get_contents($file), "\r\n");
} else {
$helper = $this->getHelper('question');
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
$pass = (string)$helper->ask($input, $output, $q);
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
if ($pass !== $again) {
$output->writeln('<error>The passphrases do not match.</error>');
return 1;
}
}
try {
$this->keys->initialize($pass);
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
$output->writeln('Next: save the recovery kit with <info>occ backup:key:kit --output=FILE</info> and confirm with <info>occ backup:key:confirm</info>.');
return 0;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class KeyKit extends Command {
public function __construct(
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:key:kit')
->setDescription('Write the recovery kit (passphrase-protected key) to a file or stdout')
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'File to write (default: stdout)');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$this->keys->isInitialized()) {
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
return 1;
}
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
$file = $input->getOption('output');
if ($file === null) {
$output->write($json, false, OutputInterface::OUTPUT_RAW);
return 0;
}
if (file_put_contents($file, $json) === false) {
$output->writeln("<error>Cannot write $file</error>");
return 1;
}
@chmod($file, 0600);
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
return 0;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\KeyService;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class TargetAdd extends Command {
public function __construct(
private TargetService $targets,
private KeyService $keys,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:add')
->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it')
->addArgument('name', InputArgument::REQUIRED, 'Name of the location')
->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)')
->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')
->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.
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
occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$this->keys->isInitialized()) {
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
return 1;
}
$options = [];
foreach ($input->getOption('option') as $pair) {
if (!str_contains($pair, '=')) {
$output->writeln("<error>Option '$pair' must be key=value</error>");
return 1;
}
[$k, $v] = explode('=', $pair, 2);
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
}
if ($input->getOption('auth') === null) {
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
return 1;
}
try {
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
} catch (\Throwable $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;
}
$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'));
return 0;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OC\Core\Command\Base;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class TargetBackends extends Base {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('backup:target:backends')
->setDescription('List the storage backends (from files_external) that can be used as backup location');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$backends = $this->targets->availableBackends();
if ($input->getOption('output') !== self::OUTPUT_FORMAT_PLAIN) {
$this->writeMixedInOutputFormat($input, $output, $backends);
return 0;
}
foreach ($backends as $b) {
$output->writeln(sprintf('<info>%s</info> (%s) auth schemes: %s', $b['id'], $b['name'], implode(', ', $b['auth'])));
foreach ($b['parameters'] as $key => $p) {
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
}
}
return 0;
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OC\Core\Command\Base;
use OCA\NgBackup\Service\TargetService;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
class TargetList extends Base {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
parent::configure();
$this->setName('backup:target:list')
->setDescription('List backup locations')
->addOption('test', 't', InputOption::VALUE_NONE, 'Also connect to each location and check the repository');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$rows = [];
foreach ($this->targets->list() as $t) {
$row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())];
if ($input->getOption('test')) {
try {
$repo = $this->targets->repository($t);
$row['status'] = 'ok, ' . count($repo->listSnapshots()) . ' snapshots';
} catch (\Throwable $e) {
$row['status'] = 'error: ' . $e->getMessage();
}
}
$rows[] = $row;
}
if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
if ($rows === []) {
$output->writeln('No backup locations yet (occ backup:target:add).');
return 0;
}
$table = new \Symfony\Component\Console\Helper\Table($output);
$table->setHeaders(array_keys($rows[0]))->setRows(array_map('array_values', $rows))->render();
} else {
$this->writeMixedInOutputFormat($input, $output, $rows);
}
return 0;
}
}
+40
View File
@@ -0,0 +1,40 @@
<?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\TargetService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class TargetRemove extends Command {
public function __construct(
private TargetService $targets,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:target:remove')
->setDescription('Remove a backup location from NG Backup (the backups on it are kept)')
->addArgument('target', InputArgument::REQUIRED, 'Name or id');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$t = $this->targets->get($input->getArgument('target'));
} catch (\Throwable) {
$output->writeln('<error>No such location.</error>');
return 1;
}
$this->targets->remove($t);
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
return 0;
}
}
+5
View File
@@ -37,6 +37,11 @@ final class KeyRing {
return new self(sodium_crypto_kdf_keygen());
}
/** Raw master key, only for storing it encrypted on the server (ICrypto). */
public function exportMasterKey(): string {
return $this->masterKey;
}
public function dataKey(): string {
return $this->dataKey;
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\Entity;
/**
* @method string getName()
* @method void setName(string $v)
* @method string getBackend()
* @method void setBackend(string $v)
* @method string getAuth()
* @method void setAuth(string $v)
* @method string getOptions()
* @method void setOptions(string $v)
* @method string getBasePath()
* @method void setBasePath(string $v)
* @method string|null getRepositoryId()
* @method void setRepositoryId(?string $v)
* @method int getCreatedAt()
* @method void setCreatedAt(int $v)
*/
class Target extends Entity {
protected string $name = '';
protected string $backend = '';
protected string $auth = '';
protected string $options = '';
protected string $basePath = '';
protected ?string $repositoryId = null;
protected int $createdAt = 0;
public function __construct() {
$this->addType('createdAt', 'integer');
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
/** @template-extends QBMapper<Target> */
class TargetMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'ngb_targets', Target::class);
}
public function find(int $id): Target {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
return $this->findEntity($qb);
}
public function findByName(string $name): Target {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('name', $qb->createNamedParameter($name)));
return $this->findEntity($qb);
}
/** @return list<Target> */
public function findAll(): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')->from($this->getTableName())->orderBy('name');
return $this->findEntities($qb);
}
}
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version000001Date20261002000000 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
if (!$schema->hasTable('ngb_targets')) {
$t = $schema->createTable('ngb_targets');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 64]);
$t->addColumn('backend', Types::STRING, ['notnull' => true, 'length' => 64]);
$t->addColumn('auth', Types::STRING, ['notnull' => true, 'length' => 64]);
// Backend/auth options as JSON, encrypted with ICrypto (they contain credentials).
$t->addColumn('options', Types::TEXT, ['notnull' => true]);
$t->addColumn('base_path', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => '']);
$t->addColumn('repository_id', Types::STRING, ['notnull' => false, 'length' => 32]);
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addUniqueIndex(['name'], 'ngb_targets_name');
}
if (!$schema->hasTable('ngb_runs')) {
$t = $schema->createTable('ngb_runs');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
$t->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]);
$t->addColumn('phase', Types::STRING, ['notnull' => true, 'length' => 32]);
// Resumable state (JSON). Large lists live in the repository, not here.
$t->addColumn('state', Types::TEXT, ['notnull' => true]);
$t->addColumn('stats', Types::TEXT, ['notnull' => false]);
$t->addColumn('error', Types::TEXT, ['notnull' => false]);
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => false, 'length' => 32]);
$t->addColumn('started_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('updated_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('finished_at', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addIndex(['target_id', 'status'], 'ngb_runs_target_status');
}
if (!$schema->hasTable('ngb_snapshots')) {
// Cache of the snapshots in each repository, for listing without reading the target.
$t = $schema->createTable('ngb_snapshots');
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => true, 'length' => 32]);
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
$t->addColumn('label', Types::STRING, ['notnull' => false, 'length' => 255]);
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
$t->addColumn('files', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$t->addColumn('bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
$t->setPrimaryKey(['id']);
$t->addUniqueIndex(['target_id', 'snapshot_id'], 'ngb_snap_target_snap');
}
return $schema;
}
}
+38 -3
View File
@@ -43,17 +43,43 @@ final class Repository {
}
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
$keys = KeyRing::generate();
return self::initWithKey($backend, $keys, $keys->wrap($passphrase));
}
/**
* Create a repository with an existing master key. $wrappedKey is the passphrase-wrapped
* copy (KeyRing::wrap) stored in the repository config, so the repository can be opened
* with the passphrase from the recovery kit even when the server is gone.
*/
public static function initWithKey(IBackend $backend, KeyRing $keys, array $wrappedKey): self {
if ($backend->exists('config')) {
throw new RepositoryException('A repository already exists at this location');
}
$keys = KeyRing::generate();
$id = bin2hex(random_bytes(16));
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $keys->wrap($passphrase)];
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))];
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
return new self($backend, $keys, $id);
}
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
/** Open with the master key the server holds (scheduled backups, no passphrase needed). */
public static function openWithKey(IBackend $backend, KeyRing $keys): 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) {
throw new RepositoryException('This repository was created with a different key');
}
$repo = new self($backend, $keys, $config['id']);
$repo->index->load();
return $repo;
}
private static function readConfig(IBackend $backend): array {
try {
$fh = $backend->get('config');
} catch (BackendException) {
@@ -64,6 +90,15 @@ final class Repository {
if (($config['format'] ?? 0) !== self::FORMAT) {
throw new RepositoryException('Unsupported repository format');
}
return $config;
}
public function id(): string {
return $this->repositoryId;
}
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
$config = self::readConfig($backend);
$repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']);
$repo->index->load();
return $repo;
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\AppInfo\Application;
use OCA\NgBackup\Crypto\KeyRing;
use OCP\IAppConfig;
use OCP\Security\ICrypto;
/**
* The installation's backup master key.
*
* - The server keeps the master key encrypted with Nextcloud's own secret (ICrypto), so
* scheduled backups run without anyone typing a passphrase. Whoever fully controls the server
* can therefore read the backups; whoever only gets the backup location cannot.
* - A copy wrapped with the admin's passphrase (Argon2id) goes into every repository config and
* into the recovery kit, so backups can be restored on a fresh server with the passphrase.
* - The admin must confirm having stored the recovery kit before the first backup runs.
*/
class KeyService {
private const KEY_MASTER = 'master_key';
private const KEY_WRAPPED = 'master_key_wrapped';
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
private const KEY_FINGERPRINT = 'master_key_fingerprint';
private ?KeyRing $cached = null;
public function __construct(
private IAppConfig $appConfig,
private ICrypto $crypto,
) {
}
public function isInitialized(): bool {
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
}
public function initialize(#[\SensitiveParameter] string $passphrase): void {
if ($this->isInitialized()) {
throw new \RuntimeException('A backup key already exists');
}
if (strlen($passphrase) < 12) {
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
}
$keys = KeyRing::generate();
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED,
json_encode($keys->wrap($passphrase), JSON_THROW_ON_ERROR), true, true);
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
$this->cached = $keys;
}
public function keyRing(): KeyRing {
if ($this->cached === null) {
$enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
if ($enc === '') {
throw new \RuntimeException('No backup key yet: run occ backup:key:init');
}
$this->cached = new KeyRing(base64_decode($this->crypto->decrypt($enc), true));
}
return $this->cached;
}
/** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */
public function wrappedKey(): array {
return json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
}
/** Short identifier of the key, shown in the UI and the kit (not secret). */
public function fingerprint(): string {
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
}
/** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */
public function recoveryKit(): array {
return [
'version' => 1,
'app' => 'NG Backup',
'fingerprint' => $this->fingerprint(),
'created' => gmdate('c'),
'wrapped_key' => $this->wrappedKey(),
'instructions' => 'Keep this file and your passphrase safe, outside this server. To restore on a new server: install NG Backup, '
. 'add the backup location, and enter this kit with your passphrase. Without both, the backups cannot be decrypted.',
];
}
public function confirmRecoveryKit(string $uid): void {
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint()], JSON_THROW_ON_ERROR), true);
}
/** Confirmation for the current key, or null. */
public function recoveryKitConfirmation(): ?array {
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
$data = $raw === '' ? null : json_decode($raw, true);
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint()) ? $data : null;
}
private function fingerprintOf(KeyRing $keys): string {
$id = $keys->blobId('ng_backup key fingerprint');
return implode('-', str_split(substr($id, 0, 16), 4));
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
use OCA\NgBackup\Backend\ExternalStorageFactory;
use OCA\NgBackup\Backend\IBackend;
use OCA\NgBackup\Backend\StorageBackend;
use OCA\NgBackup\Db\Target;
use OCA\NgBackup\Db\TargetMapper;
use OCA\NgBackup\Repository\Repository;
use OCP\App\IAppManager;
use OCP\Security\ICrypto;
use OCP\Server;
/**
* Backup targets: a files_external backend + auth mechanism + options + base path. The storage
* object is built only while it is used; nothing is mounted. files_external is loaded for its
* classes but its enabled/disabled state is left as the admin set it.
*/
class TargetService {
public function __construct(
private TargetMapper $mapper,
private KeyService $keys,
private ICrypto $crypto,
private IAppManager $appManager,
) {
}
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
public function availableBackends(): array {
return $this->factory()->describeBackends();
}
/**
* Add a target: connect, create the repository there (or adopt an existing one made with this
* installation's key), then save it.
*
* @param array<string, mixed> $options
* @return array{target: Target, created: bool}
*/
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
}
try {
$this->mapper->findByName($name);
throw new \InvalidArgumentException("A target named '$name' already exists");
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
}
$keys = $this->keys->keyRing();
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
if ($storageBackend->exists('config')) {
$repo = Repository::openWithKey($storageBackend, $keys);
$created = false;
} else {
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey());
$created = true;
}
foreach ($this->mapper->findAll() as $existing) {
if ($existing->getRepositoryId() === $repo->id()) {
throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'");
}
}
$target = new Target();
$target->setName($name);
$target->setBackend($backend);
$target->setAuth($auth);
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
$target->setBasePath(trim($basePath, '/'));
$target->setRepositoryId($repo->id());
$target->setCreatedAt(time());
return ['target' => $this->mapper->insert($target), 'created' => $created];
}
/** @return list<Target> */
public function list(): array {
return $this->mapper->findAll();
}
public function get(string $nameOrId): Target {
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
}
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
public function remove(Target $target): void {
$this->mapper->delete($target);
}
public function backend(Target $target): IBackend {
return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath());
}
public function repository(Target $target): Repository {
$repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing());
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
throw new \RuntimeException('The target now contains a different repository than when it was added');
}
return $repo;
}
/** @return array<string, mixed> */
public function options(Target $target): array {
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
}
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
}
private function factory(): ExternalStorageFactory {
// Load files_external's classes without enabling the app (its state stays as the admin set it).
if (!class_exists(\OCA\Files_External\Service\BackendService::class)) {
$this->appManager->loadApp('files_external');
}
return new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
}
}