diff --git a/appinfo/info.xml b/appinfo/info.xml
index 6d80cdf..e64dad3 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -16,4 +16,13 @@
sodium
+
+ OCA\NgBackup\Command\KeyInit
+ OCA\NgBackup\Command\KeyKit
+ OCA\NgBackup\Command\KeyConfirm
+ OCA\NgBackup\Command\TargetBackends
+ OCA\NgBackup\Command\TargetAdd
+ OCA\NgBackup\Command\TargetList
+ OCA\NgBackup\Command\TargetRemove
+
diff --git a/lib/Command/KeyConfirm.php b/lib/Command/KeyConfirm.php
new file mode 100644
index 0000000..31ed327
--- /dev/null
+++ b/lib/Command/KeyConfirm.php
@@ -0,0 +1,52 @@
+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('No backup key yet: run occ backup:key:init first.');
+ return 1;
+ }
+ $output->writeln('Key fingerprint: ' . $this->keys->fingerprint() . '');
+ $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('Not confirmed.');
+ return 1;
+ }
+ $this->keys->confirmRecoveryKit((string)$input->getOption('user'));
+ $output->writeln('Confirmed. Backups can now run.');
+ return 0;
+ }
+}
diff --git a/lib/Command/KeyInit.php b/lib/Command/KeyInit.php
new file mode 100644
index 0000000..8bec0f4
--- /dev/null
+++ b/lib/Command/KeyInit.php
@@ -0,0 +1,69 @@
+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('A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').');
+ 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('The passphrases do not match.');
+ return 1;
+ }
+ }
+ try {
+ $this->keys->initialize($pass);
+ } catch (\InvalidArgumentException $e) {
+ $output->writeln('' . $e->getMessage() . '');
+ return 1;
+ }
+ $output->writeln('Backup key created, fingerprint ' . $this->keys->fingerprint() . '.');
+ $output->writeln('Next: save the recovery kit with occ backup:key:kit --output=FILE and confirm with occ backup:key:confirm.');
+ return 0;
+ }
+}
diff --git a/lib/Command/KeyKit.php b/lib/Command/KeyKit.php
new file mode 100644
index 0000000..a149523
--- /dev/null
+++ b/lib/Command/KeyKit.php
@@ -0,0 +1,48 @@
+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('No backup key yet: run occ backup:key:init first.');
+ 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("Cannot write $file");
+ return 1;
+ }
+ @chmod($file, 0600);
+ $output->writeln("Recovery kit written to $file. Copy it off this server, then run occ backup:key:confirm.");
+ return 0;
+ }
+}
diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php
new file mode 100644
index 0000000..3422628
--- /dev/null
+++ b/lib/Command/TargetAdd.php
@@ -0,0 +1,73 @@
+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('No backup key yet: run occ backup:key:init first.');
+ return 1;
+ }
+ $options = [];
+ foreach ($input->getOption('option') as $pair) {
+ if (!str_contains($pair, '=')) {
+ $output->writeln("Option '$pair' must be key=value");
+ 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('--auth is required (see occ backup:target:backends)');
+ 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('' . $e->getMessage() . '');
+ return 1;
+ }
+ $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'));
+ return 0;
+ }
+}
diff --git a/lib/Command/TargetBackends.php b/lib/Command/TargetBackends.php
new file mode 100644
index 0000000..3ad3e10
--- /dev/null
+++ b/lib/Command/TargetBackends.php
@@ -0,0 +1,42 @@
+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('%s (%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;
+ }
+}
diff --git a/lib/Command/TargetList.php b/lib/Command/TargetList.php
new file mode 100644
index 0000000..e36cdcc
--- /dev/null
+++ b/lib/Command/TargetList.php
@@ -0,0 +1,57 @@
+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;
+ }
+}
diff --git a/lib/Command/TargetRemove.php b/lib/Command/TargetRemove.php
new file mode 100644
index 0000000..800d27e
--- /dev/null
+++ b/lib/Command/TargetRemove.php
@@ -0,0 +1,40 @@
+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('No such location.');
+ return 1;
+ }
+ $this->targets->remove($t);
+ $output->writeln('Removed ' . $t->getName() . '. The backups on the location itself were not touched; add it again to use them.');
+ return 0;
+ }
+}
diff --git a/lib/Crypto/KeyRing.php b/lib/Crypto/KeyRing.php
index a7a0019..6a7bce9 100644
--- a/lib/Crypto/KeyRing.php
+++ b/lib/Crypto/KeyRing.php
@@ -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;
}
diff --git a/lib/Db/Target.php b/lib/Db/Target.php
new file mode 100644
index 0000000..71688c2
--- /dev/null
+++ b/lib/Db/Target.php
@@ -0,0 +1,40 @@
+addType('createdAt', 'integer');
+ }
+}
diff --git a/lib/Db/TargetMapper.php b/lib/Db/TargetMapper.php
new file mode 100644
index 0000000..d815c57
--- /dev/null
+++ b/lib/Db/TargetMapper.php
@@ -0,0 +1,38 @@
+ */
+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 */
+ public function findAll(): array {
+ $qb = $this->db->getQueryBuilder();
+ $qb->select('*')->from($this->getTableName())->orderBy('name');
+ return $this->findEntities($qb);
+ }
+}
diff --git a/lib/Migration/Version000001Date20261002000000.php b/lib/Migration/Version000001Date20261002000000.php
new file mode 100644
index 0000000..a88ec99
--- /dev/null
+++ b/lib/Migration/Version000001Date20261002000000.php
@@ -0,0 +1,72 @@
+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;
+ }
+}
diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php
index 8f21b97..3d9fe4d 100644
--- a/lib/Repository/Repository.php
+++ b/lib/Repository/Repository.php
@@ -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;
diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php
new file mode 100644
index 0000000..d92cc68
--- /dev/null
+++ b/lib/Service/KeyService.php
@@ -0,0 +1,110 @@
+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));
+ }
+}
diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php
new file mode 100644
index 0000000..8c0d4d5
--- /dev/null
+++ b/lib/Service/TargetService.php
@@ -0,0 +1,124 @@
+, 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 $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 */
+ 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 */
+ 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));
+ }
+}
diff --git a/tests/.phpunit.result.cache b/tests/.phpunit.result.cache
index 129766d..29ad6fc 100644
--- a/tests/.phpunit.result.cache
+++ b/tests/.phpunit.result.cache
@@ -1 +1 @@
-{"version":2,"defects":[],"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.151,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.412,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.002,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.29,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":4.049,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":6.114,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":2.122,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.127,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.183,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.042,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.248,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":0.645,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.289,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.188,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.529}}
\ No newline at end of file
+{"version":2,"defects":[],"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.115,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.074,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.003,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.262,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":4.044,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":6.03,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":1.997,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.012,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.223,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.307,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.14,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.281,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.327,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.618,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.669}}
\ No newline at end of file