M5: deletion delay with trash, passphrase slots, ransomware documentation
- removed snapshots go to a trash on the location and their data is only freed after the deletion delay, which is chosen when the key is created (default 7 days, 1-365) and cannot be changed afterwards; any admin can bring them back (occ backup:trash, backup:trash:restore, web UI) - up to three passphrase slots open the same master key (like LUKS), so one person leaving or dying does not lock the company out; slot changes are written to every location, announced to all admins and require a new recovery kit confirmation, while backups keep running - occ backup:key:slots, backup:key:slot:add|replace|remove; key init takes --label and --delete-delay-days - web UI: deletion delay and first slot at key creation, slot management, trash per location with restore; all behind password confirmation - README: key, slots (safe / CTO / head of IT), and protection against ransomware: object lock in compliance mode (e.g. 90 days), credentials without delete rights, provider snapshots, off-site copy
This commit is contained in:
@@ -35,8 +35,12 @@ class AlertService {
|
||||
*
|
||||
* @param array<string, string> $params
|
||||
*/
|
||||
public function actor(): string {
|
||||
return $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
|
||||
}
|
||||
|
||||
public function securityEvent(string $event, array $params = [], bool $notify = true): void {
|
||||
$actor = $this->session->getUser()?->getUID() ?? (PHP_SAPI === 'cli' ? 'occ' : 'unknown');
|
||||
$actor = $this->actor();
|
||||
$params = ['actor' => $actor] + $params;
|
||||
$this->events->dispatchTyped(new \OCP\Log\Audit\CriticalActionPerformedEvent(
|
||||
'NG Backup: %s by %s ' . json_encode(array_diff_key($params, ['actor' => 1]), JSON_UNESCAPED_SLASHES), ['event' => $event, 'actor' => $actor]));
|
||||
|
||||
@@ -46,7 +46,7 @@ class BackupService {
|
||||
}
|
||||
|
||||
public function start(Target $target, string $label = ''): Run {
|
||||
if ($this->keys->recoveryKitConfirmation() === null) {
|
||||
if (!$this->keys->everConfirmed()) {
|
||||
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
|
||||
}
|
||||
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||
|
||||
+126
-9
@@ -27,6 +27,9 @@ class KeyService {
|
||||
private const KEY_WRAPPED = 'master_key_wrapped';
|
||||
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
|
||||
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
||||
private const KEY_DELETE_DELAY = 'delete_delay_days';
|
||||
private const KEY_KIT_VERSION = 'recovery_kit_version';
|
||||
public const DEFAULT_DELETE_DELAY = 7;
|
||||
|
||||
private ?KeyRing $cached = null;
|
||||
|
||||
@@ -40,7 +43,15 @@ class KeyService {
|
||||
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
|
||||
}
|
||||
|
||||
public function initialize(#[\SensitiveParameter] string $passphrase): void {
|
||||
/**
|
||||
* @param int $deleteDelayDays how long forgotten snapshots stay in the trash on the location
|
||||
* before their data is really deleted. Fixed here, at installation:
|
||||
* there is deliberately no way to change it later from NG Backup.
|
||||
*/
|
||||
public function initialize(#[\SensitiveParameter] string $passphrase, int $deleteDelayDays = self::DEFAULT_DELETE_DELAY, string $label = 'Slot 1'): void {
|
||||
if ($deleteDelayDays < 1 || $deleteDelayDays > 365) {
|
||||
throw new \InvalidArgumentException('The deletion delay must be between 1 and 365 days');
|
||||
}
|
||||
if ($this->isInitialized()) {
|
||||
throw new \RuntimeException('A backup key already exists');
|
||||
}
|
||||
@@ -50,9 +61,11 @@ class KeyService {
|
||||
$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->cached = $keys;
|
||||
$this->saveSlots([['slot' => 1, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $keys->wrap($passphrase)]]);
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $deleteDelayDays, true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
|
||||
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
|
||||
$this->cached = $keys;
|
||||
}
|
||||
@@ -68,9 +81,99 @@ class KeyService {
|
||||
return $this->cached;
|
||||
}
|
||||
|
||||
/** Deletion delay in days, fixed at installation (installations from before this setting get the default once). */
|
||||
public function deleteDelayDays(): int {
|
||||
$days = $this->appConfig->getValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, 0, true);
|
||||
if ($days === 0 && $this->isInitialized()) {
|
||||
$days = self::DEFAULT_DELETE_DELAY;
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_DELETE_DELAY, $days, true);
|
||||
}
|
||||
return $days;
|
||||
}
|
||||
|
||||
public const MAX_SLOTS = 3;
|
||||
|
||||
/** @return list<array{slot:int, label:string, created:int}> passphrase slots, without key material */
|
||||
public function slots(): array {
|
||||
return array_map(fn ($s) => ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots']);
|
||||
}
|
||||
|
||||
/** Add a passphrase slot (e.g. one per person who must be able to restore). Returns the slot number. */
|
||||
public function addSlot(#[\SensitiveParameter] string $passphrase, string $label): int {
|
||||
self::checkPassphrase($passphrase);
|
||||
$slots = $this->wrappedKey()['slots'];
|
||||
if (count($slots) >= self::MAX_SLOTS) {
|
||||
throw new \InvalidArgumentException('At most ' . self::MAX_SLOTS . ' passphrase slots');
|
||||
}
|
||||
$used = array_column($slots, 'slot');
|
||||
$n = min(array_diff(range(1, self::MAX_SLOTS), $used));
|
||||
$slots[] = ['slot' => $n, 'label' => self::cleanLabel($label), 'created' => time(), 'key' => $this->keyRing()->wrap($passphrase)];
|
||||
usort($slots, fn ($a, $b) => $a['slot'] <=> $b['slot']);
|
||||
$this->saveSlots($slots);
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** New passphrase for a slot (e.g. its holder left). The master key and all backups stay the same. */
|
||||
public function replaceSlot(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
|
||||
self::checkPassphrase($passphrase);
|
||||
$slots = $this->wrappedKey()['slots'];
|
||||
$found = false;
|
||||
foreach ($slots as &$s) {
|
||||
if ($s['slot'] === $slot) {
|
||||
$s['key'] = $this->keyRing()->wrap($passphrase);
|
||||
$s['created'] = time();
|
||||
if ($label !== null) {
|
||||
$s['label'] = self::cleanLabel($label);
|
||||
}
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
unset($s);
|
||||
if (!$found) {
|
||||
throw new \InvalidArgumentException("There is no slot $slot");
|
||||
}
|
||||
$this->saveSlots($slots);
|
||||
}
|
||||
|
||||
public function removeSlot(int $slot): void {
|
||||
$slots = array_values(array_filter($this->wrappedKey()['slots'], fn ($s) => $s['slot'] !== $slot));
|
||||
if (count($slots) === count($this->wrappedKey()['slots'])) {
|
||||
throw new \InvalidArgumentException("There is no slot $slot");
|
||||
}
|
||||
if ($slots === []) {
|
||||
throw new \InvalidArgumentException('The last passphrase slot cannot be removed');
|
||||
}
|
||||
$this->saveSlots($slots);
|
||||
}
|
||||
|
||||
/** Every change to the slots asks for a new recovery kit (and its confirmation). */
|
||||
private function saveSlots(array $slots): void {
|
||||
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED, json_encode(['slots' => $slots], JSON_THROW_ON_ERROR), true, true);
|
||||
$this->appConfig->setValueInt(Application::APP_ID, self::KEY_KIT_VERSION, $this->kitVersion() + 1, true);
|
||||
}
|
||||
|
||||
private static function checkPassphrase(string $passphrase): void {
|
||||
if (strlen($passphrase) < 12) {
|
||||
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
||||
}
|
||||
}
|
||||
|
||||
private static function cleanLabel(string $label): string {
|
||||
$label = trim(preg_replace('/[\x00-\x1f]/', '', $label) ?? '');
|
||||
return mb_substr($label === '' ? 'Slot' : $label, 0, 64);
|
||||
}
|
||||
|
||||
public function kitVersion(): int {
|
||||
return $this->appConfig->getValueInt(Application::APP_ID, self::KEY_KIT_VERSION, 1, true);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
$data = json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!isset($data['slots'])) {
|
||||
$data = ['slots' => $data === [] ? [] : [['slot' => 1, 'label' => 'Slot 1', 'created' => 0, 'key' => $data]]];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Short identifier of the key, shown in the UI and the kit (not secret). */
|
||||
@@ -84,23 +187,37 @@ class KeyService {
|
||||
'version' => 1,
|
||||
'app' => 'NG Backup',
|
||||
'fingerprint' => $this->fingerprint(),
|
||||
'kit_version' => $this->kitVersion(),
|
||||
'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.',
|
||||
'slots' => $this->slots(),
|
||||
'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. '
|
||||
. 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. '
|
||||
. 'Without the kit (or the location) and a passphrase, 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);
|
||||
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint(), 'kit_version' => $this->kitVersion()], JSON_THROW_ON_ERROR), true);
|
||||
}
|
||||
|
||||
/** Confirmation for the current key, or null. */
|
||||
/**
|
||||
* Whether a recovery kit for this key was ever confirmed (any passphrase version). Backups
|
||||
* keep running after a passphrase change, so changing it cannot be used to stop backups; the
|
||||
* missing re-confirmation is reported as an error in the admin overview instead.
|
||||
*/
|
||||
public function everConfirmed(): bool {
|
||||
$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();
|
||||
}
|
||||
|
||||
/** Confirmation for the current key and kit version, 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;
|
||||
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint() && ($data['kit_version'] ?? 1) === $this->kitVersion()) ? $data : null;
|
||||
}
|
||||
|
||||
private function fingerprintOf(KeyRing $keys): string {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
namespace OCA\NgBackup\Service;
|
||||
|
||||
/**
|
||||
* Passphrase slots (like LUKS): up to three passphrases, each opening the same master key, so
|
||||
* that one person leaving or dying does not lock the company out of its backups. Every change
|
||||
* is written to all locations and announced to all administrators; a new recovery kit must be
|
||||
* downloaded and confirmed afterwards (backups keep running meanwhile).
|
||||
*/
|
||||
class KeySlotService {
|
||||
public function __construct(
|
||||
private KeyService $keys,
|
||||
private TargetService $targets,
|
||||
private AlertService $alerts,
|
||||
) {
|
||||
}
|
||||
|
||||
public function add(#[\SensitiveParameter] string $passphrase, string $label): int {
|
||||
$slot = $this->keys->addSlot($passphrase, $label);
|
||||
$this->apply('slot_added', $slot);
|
||||
return $slot;
|
||||
}
|
||||
|
||||
public function replace(int $slot, #[\SensitiveParameter] string $passphrase, ?string $label = null): void {
|
||||
$this->keys->replaceSlot($slot, $passphrase, $label);
|
||||
$this->apply('slot_replaced', $slot);
|
||||
}
|
||||
|
||||
public function remove(int $slot): void {
|
||||
$this->keys->removeSlot($slot);
|
||||
$this->apply('slot_removed', $slot);
|
||||
}
|
||||
|
||||
private function apply(string $event, int $slot): void {
|
||||
$wrapped = $this->keys->wrappedKey();
|
||||
$failed = [];
|
||||
foreach ($this->targets->list() as $t) {
|
||||
try {
|
||||
$this->targets->repository($t)->updateWrappedKey($wrapped);
|
||||
} catch (\Throwable $e) {
|
||||
$failed[] = $t->getName();
|
||||
}
|
||||
}
|
||||
$label = '';
|
||||
foreach ($this->keys->slots() as $s) {
|
||||
if ($s['slot'] === $slot) {
|
||||
$label = $s['label'];
|
||||
}
|
||||
}
|
||||
$this->alerts->securityEvent($event, ['slot' => (string)$slot, 'label' => $label]);
|
||||
if ($failed !== []) {
|
||||
throw new \RuntimeException('Saved, but these locations could not be updated (they still accept the previous passphrases): ' . implode(', ', $failed));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,33 @@ class PruneService {
|
||||
private IAppConfig $appConfig,
|
||||
private ILockingProvider $locking,
|
||||
private AlertService $alerts,
|
||||
private KeyService $keys,
|
||||
) {
|
||||
}
|
||||
|
||||
public function untrash(Target $target, string $snapshotId): void {
|
||||
$repo = $this->targets->repository($target);
|
||||
if (!array_key_exists($snapshotId, $repo->trash())) {
|
||||
throw new \InvalidArgumentException('That snapshot is not in the trash (or was already deleted)');
|
||||
}
|
||||
$repo->untrash($snapshotId);
|
||||
// Put it back in the local snapshot list as well.
|
||||
$meta = $repo->snapshotMeta($snapshotId);
|
||||
$s = new \OCA\NgBackup\Db\Snapshot();
|
||||
$s->setTargetId($target->getId());
|
||||
$s->setSnapshotId($snapshotId);
|
||||
$s->setKind((string)($meta['kind'] ?? 'full'));
|
||||
$s->setLabel(($meta['label'] ?? '') !== '' ? $meta['label'] : null);
|
||||
$s->setCreatedAt((int)strtotime((string)($meta['time'] ?? 'now')));
|
||||
$s->setFiles((int)($meta['stats']['files'] ?? 0));
|
||||
$s->setBytes((int)($meta['stats']['bytes'] ?? 0));
|
||||
try {
|
||||
$this->snapshots->insert($s);
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
$this->alerts->securityEvent('snapshot_untrashed', ['target' => $target->getName(), 'snapshot' => $snapshotId]);
|
||||
}
|
||||
|
||||
public static function lockKey(Target $target): string {
|
||||
return 'ng_backup/target/' . $target->getId();
|
||||
}
|
||||
@@ -51,7 +75,10 @@ class PruneService {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>, prune: array}
|
||||
* Snapshots the policy drops go to the trash on the location; trash entries older than the
|
||||
* deletion delay are purged, and only then is their data freed.
|
||||
*
|
||||
* @return array{kept: array<string, list<string>>, forgotten: list<string>, purged: list<string>, prune: array}
|
||||
*/
|
||||
public function apply(Target $target, bool $dryRun = false, bool $scheduled = false): array {
|
||||
if ($target->getAppendOnly()) {
|
||||
@@ -74,9 +101,11 @@ class PruneService {
|
||||
}
|
||||
$keep = $this->policy()->keep($times);
|
||||
$forget = array_values(array_diff(array_keys($times), array_keys($keep)));
|
||||
$actor = $scheduled ? 'retention policy' : $this->alerts->actor();
|
||||
$purged = [];
|
||||
if (!$dryRun) {
|
||||
foreach ($forget as $id) {
|
||||
$repo->forget($id);
|
||||
$repo->forget($id, $actor);
|
||||
try {
|
||||
$this->snapshots->delete($this->snapshots->findOne($target->getId(), $id));
|
||||
} catch (\Throwable) {
|
||||
@@ -84,12 +113,17 @@ class PruneService {
|
||||
}
|
||||
}
|
||||
// Also removes leftovers of interrupted runs. A dry run counts the snapshots it would forget as gone.
|
||||
$delay = $this->keys->deleteDelayDays();
|
||||
if (!$dryRun && $forget !== []) {
|
||||
// Scheduled cleanup follows the policy: audit log only, no notification every week.
|
||||
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget)], !$scheduled);
|
||||
$this->alerts->securityEvent('snapshots_forgotten', ['target' => $target->getName(), 'count' => (string)count($forget),
|
||||
'until' => date('Y-m-d', time() + $delay * 86400)], !$scheduled);
|
||||
}
|
||||
$prune = $repo->prune($dryRun, 0.5, $dryRun ? $forget : []);
|
||||
return ['kept' => $keep, 'forgotten' => $forget, 'prune' => $prune];
|
||||
if (!$dryRun) {
|
||||
$purged = $repo->purgeTrash($delay * 86400);
|
||||
}
|
||||
$prune = $repo->prune($dryRun);
|
||||
return ['kept' => $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune];
|
||||
} finally {
|
||||
$this->locking->releaseLock(self::lockKey($target), ILockingProvider::LOCK_EXCLUSIVE);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user