M5: chained catalog against rollback and hidden snapshots
Every change to the set of snapshots or trash entries writes an encrypted catalog generation bound to the repository and its position, with the hash of the previous generation. The server anchors the newest generation it has seen in its app config; on open, the location must show at least that generation and chain back to it, and every snapshot listed must exist. A rolled-back or tampered location is refused (the run fails and administrators are notified). Opening with the passphrase on a new server accepts the newest valid chain. occ backup:target:trust-current accepts a legitimately older state (e.g. restored from the provider's snapshot) after typing the location name; audited and announced to all administrators. Tests: chain advances, rollback, hidden snapshot, swapped generation, disaster recovery without anchor. Tested on NC34 by deleting the newest catalog generation on the SFTP location.
This commit is contained in:
@@ -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 OCA\NgBackup\Db\AppConfigCatalogAnchor;
|
||||
use OCA\NgBackup\Service\AlertService;
|
||||
use OCA\NgBackup\Service\TargetService;
|
||||
use OCP\IAppConfig;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Question\Question;
|
||||
|
||||
/**
|
||||
* Accept the state a location shows now, after NG Backup refused it as rolled back. Only for
|
||||
* legitimate cases (e.g. the location was restored from the provider's own snapshot), only from
|
||||
* occ, audited and announced to all administrators.
|
||||
*/
|
||||
class TargetTrustCurrent extends Command {
|
||||
public function __construct(
|
||||
private TargetService $targets,
|
||||
private IAppConfig $appConfig,
|
||||
private AlertService $alerts,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void {
|
||||
$this->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('<comment>Only continue if you know why the location shows an older state.</comment>');
|
||||
$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('<error>Not confirmed.</error>');
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Db;
|
||||
|
||||
use OCA\NgBackup\AppInfo\Application;
|
||||
use OCA\NgBackup\Repository\ICatalogAnchor;
|
||||
use OCP\IAppConfig;
|
||||
|
||||
/** Keeps the newest catalog generation seen per repository in the app config (outside the location). */
|
||||
class AppConfigCatalogAnchor implements ICatalogAnchor {
|
||||
public function __construct(
|
||||
private IAppConfig $appConfig,
|
||||
) {
|
||||
}
|
||||
|
||||
public function get(string $repositoryId): ?array {
|
||||
$raw = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '']),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
use OCA\NgBackup\Crypto\StreamCipher;
|
||||
|
||||
/**
|
||||
* Chained catalog of a repository's state, against rollback and hidden snapshots.
|
||||
*
|
||||
* Every change to the set of snapshots or trash entries writes catalog/<generation>: 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<string, true> */
|
||||
public array $snapshots = [];
|
||||
/** @var array<string, true> */
|
||||
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<int> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Repository;
|
||||
|
||||
/**
|
||||
* Where the server remembers the newest catalog generation it has seen for a repository, outside
|
||||
* the (untrusted) location. A location that presents an older generation, or a chain that does
|
||||
* not lead back to this anchor, is rolled back or tampered with.
|
||||
*/
|
||||
interface ICatalogAnchor {
|
||||
/** @return array{gen:int, hash:string}|null */
|
||||
public function get(string $repositoryId): ?array;
|
||||
|
||||
public function set(string $repositoryId, int $gen, string $hash): void;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ final class Repository {
|
||||
private StreamCipher $cipher;
|
||||
private BlobIndex $index;
|
||||
private ?PackWriter $runPacks = null;
|
||||
private Catalog $catalog;
|
||||
|
||||
private function __construct(
|
||||
private IBackend $backend,
|
||||
@@ -52,7 +53,7 @@ final class Repository {
|
||||
* 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 {
|
||||
public static function initWithKey(IBackend $backend, KeyRing $keys, array $wrappedKey, ?ICatalogAnchor $anchor = null): self {
|
||||
if ($backend->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<string, array{s:int, m:int, b:list<string>}> */
|
||||
@@ -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<string, array{forgottenAt:int, by:string, db:?string, dbInTrash:?string, time:?string, label:string}> */
|
||||
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<string> */
|
||||
/**
|
||||
* 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<string>
|
||||
*/
|
||||
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 {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user