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:
2026-10-02 03:01:44 +02:00
parent 7b154e322b
commit 9b03f98d0d
26 changed files with 955 additions and 78 deletions
+15
View File
@@ -33,4 +33,19 @@ class KeyRingTest extends TestCase {
$this->expectException(\InvalidArgumentException::class);
new KeyRing('short');
}
public function testAnySlotOpensTheSameKey(): void {
$keys = KeyRing::generate();
$field = ['slots' => [
['slot' => 1, 'label' => 'Safe', 'key' => $keys->wrap('passphrase in the safe')],
['slot' => 2, 'label' => 'CTO', 'key' => $keys->wrap('passphrase of the cto')],
['slot' => 3, 'label' => 'Head of IT', 'key' => $keys->wrap('passphrase head of it')],
]];
foreach (['passphrase in the safe', 'passphrase of the cto', 'passphrase head of it'] as $p) {
$this->assertSame($keys->blobId('x'), KeyRing::unwrapAny($field, $p)->blobId('x'));
}
$this->assertSame($keys->blobId('x'), KeyRing::unwrapAny($keys->wrap('legacy single'), 'legacy single')->blobId('x'));
$this->expectException(CryptoException::class);
KeyRing::unwrapAny($field, 'nobody knows this');
}
}
+31 -1
View File
@@ -42,7 +42,11 @@ class PruneTest extends TestCase {
$preview = $repo->prune(true, 0.5, [$old['snapshot']]);
$this->assertGreaterThan(35 * 1048576, $preview['freedBytes'], 'dry run can preview forgetting a snapshot');
$repo->forget($old['snapshot']);
$repo->forget($old['snapshot'], 'tester');
$this->assertSame(0, $repo->prune(true)['freedBytes'], 'a snapshot in the trash keeps its data');
$this->assertArrayHasKey($old['snapshot'], $repo->trash());
$this->assertSame([], $repo->purgeTrash(3600), 'not purged before the delay');
$this->assertSame([$old['snapshot']], $repo->purgeTrash(0));
$plan = $repo->prune(true);
$this->assertSame($plan, $repo->prune(true, 0.5, []));
$this->assertGreaterThan(35 * 1048576, $plan['freedBytes']);
@@ -76,10 +80,36 @@ class PruneTest extends TestCase {
}
$b = $repo->backupDirectory($src, $a['snapshot']);
$repo->forget($a['snapshot']);
$repo->purgeTrash(0);
$r = $repo->prune(false, 0.5);
$this->assertGreaterThan(0, $r['repacked']);
$target = $this->tempDir();
Repository::openWithKey($backend, $keys)->restore($b['snapshot'], $target);
$this->assertSame(self::hashTree($src), self::hashTree($target));
}
public function testUntrashRestoresAWorkingSnapshot(): void {
$src = $this->tempDir();
$backend = new LocalBackend($this->tempDir());
$keys = KeyRing::generate();
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
file_put_contents("$src/a", random_bytes(2 * 1048576));
$repo->putObject('db/x', '{"tables":{}}');
$s = $repo->backupDirectory($src);
$expected = self::hashTree($src);
unlink("$src/a");
file_put_contents("$src/b", 'other');
$s2 = $repo->backupDirectory($src, $s['snapshot']);
$repo->forget($s['snapshot'], 'attacker');
$this->assertNotContains($s['snapshot'], $repo->listSnapshots());
$repo->prune(); // must not free the trashed snapshot's data
$repo->untrash($s['snapshot']);
$this->assertContains($s['snapshot'], $repo->listSnapshots());
$this->assertSame([], $repo->trash());
$target = $this->tempDir();
Repository::openWithKey($backend, $keys)->restore($s['snapshot'], $target);
$this->assertSame($expected, self::hashTree($target));
}
}