diff --git a/appinfo/info.xml b/appinfo/info.xml index 12484c4..1bbef8c 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ NG Backup Encrypted, incremental backups of your Nextcloud with restore from the web interface - 0.0.13 + 0.0.15 agpl Dillard Blom NgBackup @@ -35,6 +35,7 @@ OCA\NgBackup\Command\Prune OCA\NgBackup\Command\Retention OCA\NgBackup\Command\TargetAppendOnly + OCA\NgBackup\Command\TargetTrustCurrent OCA\NgBackup\Command\Trash OCA\NgBackup\Command\TrashRestore OCA\NgBackup\Command\KeySlots diff --git a/lib/Command/TargetTrustCurrent.php b/lib/Command/TargetTrustCurrent.php new file mode 100644 index 0000000..3618d6c --- /dev/null +++ b/lib/Command/TargetTrustCurrent.php @@ -0,0 +1,57 @@ +setName('backup:target:trust-current') + ->setDescription('Accept the current state of a location that was refused as rolled back') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->setHelp("NG Backup refuses a location whose backup catalog is older than what this server saw\n" + . "before, because an attacker could have restored an old state. If you restored the location\n" + . "yourself (e.g. from the provider's snapshot), accept it with this command."); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $t = $this->targets->get($input->getArgument('target')); + $output->writeln('Only continue if you know why the location shows an older state.'); + $answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type the location name to accept its current state: ')); + if ($answer !== $t->getName()) { + $output->writeln('Not confirmed.'); + return 1; + } + $this->appConfig->deleteKey('ng_backup', 'catalog_' . preg_replace('/[^a-f0-9]/', '', (string)$t->getRepositoryId())); + $repo = $this->targets->repository($t); // re-anchors on the newest valid catalog + $this->alerts->securityEvent('catalog_trusted', ['target' => $t->getName(), 'generation' => (string)$repo->catalogGeneration()]); + $output->writeln(sprintf('Accepted generation %d of %s (%d snapshots).', $repo->catalogGeneration(), $t->getName(), count($repo->listSnapshots()))); + return 0; + } +} diff --git a/lib/Db/AppConfigCatalogAnchor.php b/lib/Db/AppConfigCatalogAnchor.php new file mode 100644 index 0000000..a7ad178 --- /dev/null +++ b/lib/Db/AppConfigCatalogAnchor.php @@ -0,0 +1,34 @@ +appConfig->getValueString(Application::APP_ID, $this->key($repositoryId), '', true); + $data = $raw === '' ? null : json_decode($raw, true); + return is_array($data) && isset($data['gen'], $data['hash']) ? ['gen' => (int)$data['gen'], 'hash' => (string)$data['hash']] : null; + } + + public function set(string $repositoryId, int $gen, string $hash): void { + $this->appConfig->setValueString(Application::APP_ID, $this->key($repositoryId), json_encode(['gen' => $gen, 'hash' => $hash]), true); + } + + private function key(string $repositoryId): string { + return 'catalog_' . preg_replace('/[^a-f0-9]/', '', $repositoryId); + } +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 906e821..2a47c45 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -55,6 +55,7 @@ class Notifier implements INotifier { 'retention_changed' => $l->t('%1$s changed the backup retention to %2$s', [$who, $p['policy'] ?? '']), 'snapshots_forgotten' => $l->t('%1$s moved %2$s snapshots of %3$s to the trash; they are deleted permanently after %4$s unless restored', [$who, $p['count'] ?? '', $p['target'] ?? '', $p['until'] ?? '']), 'snapshot_untrashed' => $l->t('%1$s restored snapshot %2$s of %3$s from the trash', [$who, $p['snapshot'] ?? '', $p['target'] ?? '']), + 'catalog_trusted' => $l->t('%1$s accepted an older state of backup location %2$s (catalog generation %3$s)', [$who, $p['target'] ?? '', $p['generation'] ?? '']), 'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']), 'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']), 'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']), diff --git a/lib/Repository/Catalog.php b/lib/Repository/Catalog.php new file mode 100644 index 0000000..56ec8fd --- /dev/null +++ b/lib/Repository/Catalog.php @@ -0,0 +1,129 @@ +: the + * encrypted list of snapshot and trash ids plus the hash of the previous generation. The server + * keeps the newest generation it has seen (ICatalogAnchor). On open, the newest catalog on the + * location must be at least the anchored generation and lead back to it through the chain; + * snapshots listed in the catalog must exist. Otherwise the location was rolled back or + * tampered with, and the repository is refused. + */ +final class Catalog { + private const KEEP = 200; // generations kept on the location (enough to verify long gaps) + + public int $gen = 0; + /** @var array */ + public array $snapshots = []; + /** @var array */ + public array $trash = []; + public string $hash = ''; + + public function __construct( + private IBackend $backend, + private StreamCipher $cipher, + private string $repositoryId, + private ?ICatalogAnchor $anchor, + ) { + } + + /** Load and verify the newest catalog; create the first one for repositories that have none. */ + public function load(callable $listSnapshots, callable $listTrash): void { + $gens = $this->generations(); + if ($gens === []) { + // First use (new or pre-catalog repository): adopt the current state as generation 1. + $anchored = $this->anchor?->get($this->repositoryId); + if ($anchored !== null) { + throw new RepositoryException('The catalog of this repository has disappeared from the location (possible tampering)'); + } + $this->snapshots = array_fill_keys($listSnapshots(), true); + $this->trash = array_fill_keys($listTrash(), true); + $this->write(); + return; + } + $head = max($gens); + [$data, $hash] = $this->read($head); + $anchored = $this->anchor?->get($this->repositoryId); + if ($anchored !== null) { + if ($head < $anchored['gen']) { + throw new RepositoryException(sprintf('The location shows catalog generation %d, but this server has seen %d: the backups were rolled back', $head, $anchored['gen'])); + } + // Walk back to the anchored generation, checking every link. + [$cur, $curHash] = [$data, $hash]; + for ($g = $head; $g > $anchored['gen']; $g--) { + [$prev, $prevHash] = $this->read($g - 1); + if (!hash_equals($prevHash, (string)$cur['prev'])) { + throw new RepositoryException("Catalog chain broken at generation $g (possible tampering)"); + } + [$cur, $curHash] = [$prev, $prevHash]; + } + if (!hash_equals($anchored['hash'], $curHash)) { + throw new RepositoryException('The catalog does not match what this server saw before (possible tampering)'); + } + } + $this->gen = $head; + $this->hash = $hash; + $this->snapshots = array_fill_keys($data['snapshots'], true); + $this->trash = array_fill_keys($data['trash'], true); + $this->anchor?->set($this->repositoryId, $this->gen, $this->hash); + } + + /** Write the next generation after the in-memory state was changed. */ + public function write(): void { + $next = $this->gen + 1; + $plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash, + 'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash)], JSON_THROW_ON_ERROR); + $encrypted = $this->cipher->encryptString($plain, $this->ad($next)); + $stream = fopen('php://memory', 'w+b'); + fwrite($stream, $encrypted); + rewind($stream); + $this->backend->put($this->path($next), $stream); + $this->gen = $next; + $this->hash = hash('sha256', $encrypted); + $this->anchor?->set($this->repositoryId, $this->gen, $this->hash); + $old = $next - self::KEEP; + if ($old > 0) { + $this->backend->delete($this->path($old)); + } + } + + /** @return array{0: array, 1: string} decoded catalog and the hash of its encrypted bytes */ + private function read(int $gen): array { + try { + $fh = $this->backend->get($this->path($gen)); + } catch (\Throwable) { + throw new RepositoryException("Catalog generation $gen is missing (possible tampering)"); + } + $encrypted = (string)stream_get_contents($fh); + fclose($fh); + $data = json_decode($this->cipher->decryptString($encrypted, $this->ad($gen)), true, 512, JSON_THROW_ON_ERROR); + if (($data['repository'] ?? '') !== $this->repositoryId || ($data['gen'] ?? 0) !== $gen) { + throw new RepositoryException("Catalog generation $gen belongs to another repository or position"); + } + return [$data, hash('sha256', $encrypted)]; + } + + /** @return list */ + private function generations(): array { + return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog')); + } + + private function path(int $gen): string { + return sprintf('catalog/%010d', $gen); + } + + private function ad(int $gen): string { + return 'catalog:' . $this->repositoryId . ':' . $gen; + } +} diff --git a/lib/Repository/ICatalogAnchor.php b/lib/Repository/ICatalogAnchor.php new file mode 100644 index 0000000..4c49e5e --- /dev/null +++ b/lib/Repository/ICatalogAnchor.php @@ -0,0 +1,20 @@ +exists('config')) { throw new RepositoryException('A repository already exists at this location'); } @@ -60,11 +61,13 @@ final class Repository { $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); + $repo = new self($backend, $keys, $id); + $repo->loadCatalog($anchor); + return $repo; } /** Open with the master key the server holds (scheduled backups, no passphrase needed). */ - public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null): self { + public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null, ?ICatalogAnchor $anchor = null): self { $config = self::readConfig($backend); try { $ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id']; @@ -79,9 +82,22 @@ final class Repository { $repo->index->setCache($cache, $config['id']); } $repo->index->load(); + $repo->loadCatalog($anchor); return $repo; } + private function loadCatalog(?ICatalogAnchor $anchor): void { + $this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor); + $this->catalog->load( + fn () => array_map('basename', $this->backend->list('snapshots')), + fn () => array_map('basename', $this->backend->list('trash/info'))); + } + + /** Current catalog generation (for status and tests). */ + public function catalogGeneration(): int { + return $this->catalog->gen; + } + /** Index files downloaded when this repository was opened (0 when the local cache was complete). */ public function indexDownloads(): int { return $this->index->downloaded; @@ -116,6 +132,7 @@ final class Repository { $config = self::readConfig($backend); $repo = new self($backend, KeyRing::unwrapAny($config['key'], $passphrase), $config['id']); $repo->index->load(); + $repo->loadCatalog(null); // disaster recovery on a new server: no anchor yet, trust the newest valid chain return $repo; } @@ -156,6 +173,8 @@ final class Repository { rewind($encrypted); $this->backend->put('snapshots/' . $snapshotId, $encrypted); fclose($tree); + $this->catalog->snapshots[$snapshotId] = true; + $this->catalog->write(); return ['snapshot' => $snapshotId] + $stats; } @@ -263,6 +282,8 @@ final class Repository { fclose($tree); rewind($encrypted); $this->backend->put('snapshots/' . $snapshotId, $encrypted); + $this->catalog->snapshots[$snapshotId] = true; + $this->catalog->write(); } /** @return array}> */ @@ -321,6 +342,9 @@ final class Repository { $this->backend->put('trash/snapshots/' . $snapshotId, $this->backend->get('snapshots/' . $snapshotId)); $this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null, 'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR)); + unset($this->catalog->snapshots[$snapshotId]); + $this->catalog->trash[$snapshotId] = true; + $this->catalog->write(); if ($dbInTrash !== null) { $this->backend->delete($meta['db']); } @@ -335,6 +359,9 @@ final class Repository { $this->backend->delete($info['dbInTrash']); } $this->backend->put('snapshots/' . $snapshotId, $this->backend->get('trash/snapshots/' . $snapshotId)); + $this->catalog->snapshots[$snapshotId] = true; + unset($this->catalog->trash[$snapshotId]); + $this->catalog->write(); $this->backend->delete('trash/snapshots/' . $snapshotId); $this->backend->delete('trash/info/' . $snapshotId); } @@ -342,8 +369,12 @@ final class Repository { /** @return array */ public function trash(): array { $out = []; - foreach ($this->backend->list('trash/info') as $path) { - $out[basename($path)] = $this->trashInfo(basename($path)); + foreach (array_keys($this->catalog->trash) as $id) { + try { + $out[$id] = $this->trashInfo($id); + } catch (\Throwable) { + throw new RepositoryException("Trash entry $id listed in the catalog is missing on the location (possible tampering)"); + } } return $out; } @@ -360,8 +391,12 @@ final class Repository { } $this->backend->delete('trash/snapshots/' . $id); $this->backend->delete('trash/info/' . $id); + unset($this->catalog->trash[$id]); $purged[] = $id; } + if ($purged !== []) { + $this->catalog->write(); + } return $purged; } @@ -470,9 +505,22 @@ final class Repository { return $stats; } - /** @return list */ + /** + * Snapshots according to the verified catalog. A snapshot the catalog lists but the location + * no longer has means it was deleted or hidden behind NG Backup's back. + * + * @return list + */ public function listSnapshots(): array { - return array_map('basename', $this->backend->list('snapshots')); + $present = array_flip(array_map('basename', $this->backend->list('snapshots'))); + $listed = array_keys($this->catalog->snapshots); + foreach ($listed as $id) { + if (!isset($present[$id])) { + throw new RepositoryException("Snapshot $id is missing on the location (deleted or hidden outside NG Backup)"); + } + } + sort($listed); + return $listed; } public function blobCount(): int { diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index 2a3e499..889a209 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -29,6 +29,7 @@ class TargetService { private ICrypto $crypto, private IAppManager $appManager, private \OCA\NgBackup\Db\DbIndexCache $indexCache, + private \OCA\NgBackup\Db\AppConfigCatalogAnchor $anchor, ) { } @@ -56,10 +57,10 @@ class TargetService { $keys = $this->keys->keyRing(); $storageBackend = $this->buildBackend($backend, $auth, $options, $basePath); if ($storageBackend->exists('config')) { - $repo = Repository::openWithKey($storageBackend, $keys); + $repo = Repository::openWithKey($storageBackend, $keys, $this->indexCache, $this->anchor); $created = false; } else { - $repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey()); + $repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey(), $this->anchor); $created = true; } @@ -104,7 +105,7 @@ class TargetService { } public function repository(Target $target): Repository { - $repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing(), $this->indexCache); + $repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing(), $this->indexCache, $this->anchor); if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) { throw new \RuntimeException('The target now contains a different repository than when it was added'); } diff --git a/tests/Unit/Repository/CatalogTest.php b/tests/Unit/Repository/CatalogTest.php new file mode 100644 index 0000000..c287c09 --- /dev/null +++ b/tests/Unit/Repository/CatalogTest.php @@ -0,0 +1,90 @@ +root = $this->tempDir(); + $this->backend = new LocalBackend($this->root); + $this->keys = KeyRing::generate(); + $this->anchor = new class implements ICatalogAnchor { + public array $data = []; + public function get(string $r): ?array { return $this->data[$r] ?? null; } + public function set(string $r, int $g, string $h): void { $this->data[$r] = ['gen' => $g, 'hash' => $h]; } + }; + $this->src = $this->tempDir(); + file_put_contents("$this->src/a", random_bytes(100000)); + } + + private function open(): Repository { + return Repository::openWithKey($this->backend, $this->keys, null, $this->anchor); + } + + public function testNormalUseAdvancesTheChain(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $s1 = $repo->backupDirectory($this->src); + file_put_contents("$this->src/b", 'x'); + $s2 = $repo->backupDirectory($this->src, $s1['snapshot']); + $repo->forget($s1['snapshot']); + $again = $this->open(); + $this->assertSame([$s2['snapshot']], $again->listSnapshots()); + $this->assertArrayHasKey($s1['snapshot'], $again->trash()); + $this->assertSame(4, $again->catalogGeneration()); + } + + public function testRollbackIsDetected(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $repo->backupDirectory($this->src); + $repo->backupDirectory($this->src); + // The location "loses" the newest generation and presents an older, validly encrypted state. + $catalogs = $this->backend->list('catalog'); + unlink($this->root . '/' . end($catalogs)); + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('rolled back'); + $this->open(); + } + + public function testHiddenSnapshotIsDetected(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $s = $repo->backupDirectory($this->src); + unlink($this->root . '/snapshots/' . $s['snapshot']); + $this->expectException(RepositoryException::class); + $this->open()->listSnapshots(); + } + + public function testSwappedGenerationIsRejected(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $repo->backupDirectory($this->src); + $repo->backupDirectory($this->src); + $c = $this->backend->list('catalog'); + // Put generation 2's bytes where generation 3 should be. + copy($this->root . '/' . $c[1], $this->root . '/' . $c[2]); + $this->expectException(\RuntimeException::class); + $this->open(); + } + + public function testWithoutAnchorTheNewestChainIsAccepted(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); + $s = $repo->backupDirectory($this->src); + // Disaster recovery on a new server: opened with the passphrase, no anchor. + $this->assertSame([$s['snapshot']], Repository::open($this->backend, 'pw')->listSnapshots()); + } +}