syncova-backup/docs/immutability.md
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

244 lines
12 KiB
Markdown

# Unveränderlichkeit und Aufbewahrung
Phase 11. Der Teil der Anlage, der Backups gegen den Fall schützt, für den man
sie am dringendsten braucht: Jemand — Ransomware, ein Bedienfehler, ein
kompromittierter Control Server — will sie loswerden.
Der Implementierungsplan setzt für diese Phase eine Grenze, die den ganzen
Aufbau bestimmt:
> Do not claim true immutability if the underlying storage cannot enforce it.
## Die Durchsetzungsstufe wird gemessen, nicht behauptet
„Unveränderlich" ist in Backup-Produkten ein Wort, das oft eine Datenbankspalte
meint: Die Software weigert sich zu löschen, und jeder mit Dateizugriff löscht
trotzdem. Wer darauf eine Ransomware-Strategie baut, verliert seine Backups.
`POST /repositories/{id}/enforcement/measure` stellt deshalb fest, was das
Dateisystem **tatsächlich** verhindert. Die Messung legt Probedateien im
Metadatenverzeichnis an und versucht, sie zu löschen:
| Stufe | Bedeutung |
| --- | --- |
| `none` | Kein Schutz. |
| `advisory` | Nur diese Software hält sich daran. Wer Dateizugriff hat, löscht trotzdem. |
| `filesystem` | Das Dateisystem verweigert die Löschung. Ein Systemverwalter kann den Schutz weiterhin aufheben. |
| `storage` | Die Speicherebene verhindert es (S3 Object Lock, WORM). **Nicht umgesetzt** — wird nie vergeben. |
Der Bericht nennt die Versuche einzeln, nicht nur das Ergebnis:
```json
{
"level": "filesystem",
"observations": [
{ "attempt": "Eine Datei mit den Rechten 0400 loeschen", "prevented": false,
"detail": "Die Loeschung gelang. Dateirechte schuetzen unter POSIX nicht vor dem Loeschen …" },
{ "attempt": "Eine Datei mit Unveraenderlich-Kennzeichen loeschen", "prevented": true,
"detail": "Das Betriebssystem verweigerte die Loeschung: … operation not permitted" }
]
}
```
### Der Irrtum, mit dem diese Phase begann
Der ursprüngliche gehärtete Modus legte Manifeste mit den Rechten `0400` ab und
nannte das Löschschutz. **Das verhindert kein Löschen.** Unter POSIX hängt das
Entfernen einer Datei am Schreibrecht ihres *Verzeichnisses*, nicht an ihren
eigenen Rechten. Gemessen, bestätigt, und als Regressionstest festgehalten
(`TestReadOnlyPermissionsDoNotPreventDeletion`).
Den Löschschutz leistet das Unveränderlich-Kennzeichen des Dateisystems:
`UF_IMMUTABLE` über `chflags` auf macOS/BSD, `FS_IMMUTABLE_FL` über `ioctl` auf
Linux (ext2/3/4, xfs, btrfs; braucht `CAP_LINUX_IMMUTABLE`). Windows kennt nur
ein Schreibschutz-Attribut, das nicht vor Löschung schützt — dort meldet die
Messung `advisory`, statt etwas zu behaupten.
## Was geschützt wird — und warum alles davon
Zwei Angriffsversuche waren zunächst erfolgreich. Beide Male überlebte das
Manifest, und das Backup war trotzdem verloren:
1. **Die Chunks fehlten.** Zurück blieb ein Manifest, das auf nichts mehr zeigte
— ein Backup, das sich für vollständig ausgibt und leer ist.
2. **Descriptor und Datenschlüssel fehlten.** Die Daten waren da, aber weder als
Repository erkennbar noch entschlüsselbar. Der Datenschlüssel ist das
lohnendste Ziel des gesamten Repositorys: Wer diese eine kleine Datei löscht,
vernichtet jedes verschlüsselte Backup, ohne einen Datenblock anzufassen.
In einem gehärteten Repository trägt deshalb **jede** Datei das Kennzeichen, die
zur Wiederherstellung gebraucht wird:
| Datei | Geschützt | Grund |
| --- | --- | --- |
| `manifests/*.manifest.json` | ja | Das Backup selbst. |
| `manifests/*.hold.json` | ja | Sonst hübe ein `rm` einer kleinen Datei den Legal Hold auf. |
| `chunks/**` | ja | Ohne sie ist das Manifest wertlos. |
| `format/repository.json` | ja | Einstiegspunkt jedes Wiederaufbaus. |
| `metadata/data-key.json` | ja | Ohne ihn ist alles Geheimtext. |
| `indexes/catalog.json` | **nein** | Nur ein Beschleuniger, wird laufend überschrieben und bei Verlust neu gebaut. |
Nachgewiesen: `rm -rf` gegen ein gehärtetes Repository meldet 15 verweigerte
Löschungen, alle geschützten Dateien überleben, und der anschließende
Tiefenscan meldet das Backup als vollständig.
Die Kehrseite steht ausdrücklich hier: **Ein gehärtetes Repository lässt sich
nicht mit `rm -rf` entfernen.** Wer es ausmustern will, hebt den Schutz Datei für
Datei auf (`ReleaseImmutableFlagForMaintenance`). Eine rekursive Fassung gibt es
nicht — sie wäre genau das Werkzeug, das ein Angreifer sucht.
## Aufbewahrungsschutz und Legal Hold
Die Frist eines Repositorys steht in seinem Descriptor, nicht in der Datenbank:
Wer das Repository an einen fremden Server anhängt, soll die geltende Frist
vorfinden und nicht die des neuen Servers untergeschoben bekommen. Ohne Angabe
gelten 30 Tage.
- **Verlängern ja, verkürzen nie** — auch nicht für einen Administrator. Wäre es
möglich, bestünde der Schutz nur aus einer Zahl, die der Angreifer zuallererst
ändern würde.
- **Legal Hold** ist unbefristet und endet nicht von selbst. Er verlangt eine
Begründung: Ohne sie wäre er nach einem Jahr nicht mehr auflösbar, weil sich
niemand traut, einen Schutz aufzuheben, dessen Anlass unbekannt ist.
- Ein aufgehobener Legal Hold gibt ein Backup **nicht** frei, das noch unter
Frist steht. Beide Schutzarten gelten nebeneinander.
### Der Schutzvermerk liegt neben dem Manifest
Verlängerung und Legal Hold stehen in `manifests/<id>.hold.json`, nicht im
Manifest. Das Manifest ist mit seinem `ContentHash` **versiegelt** — es ist der
Abschlussvermerk des Backups. Ein Feld darin nachträglich zu ändern machte
entweder den Hash ungültig oder verlangte, ihn neu zu bilden; im zweiten Fall
wäre das Manifest nicht mehr das, was beim Commit geprüft wurde. Beides
untergrübe die Eigenschaft, auf der die gesamte Integritätsprüfung beruht.
Der Vermerk führt seine eigene Historie mit — wer wann was warum geändert hat.
Ein Repository muss ohne Control Server deutbar bleiben, also gehört die Frage
„warum wird das noch gehalten?" ins Repository und nicht nur in die Datenbank.
**Ein unlesbarer Schutzvermerk gilt als Schutz**, nicht als dessen Abwesenheit.
Das ist der gefährlichste Fehlerfall der Phase: Die umgekehrte Auslegung machte
aus einer beschädigten Datei einen Datenverlust.
## Aufbewahrungsregeln
Zwei Formen, kombinierbar (PROMPT.md §16):
```text
7 / 14 / 30 / 90 Tage, 1 Jahr → keep_within
GFS: 30 täglich, 8 wöchentlich, → keep_daily / keep_weekly /
12 monatlich, 7 jährlich keep_monthly / keep_yearly
```
Die Reihenfolge der Prüfungen ist die Sicherheitsarchitektur: Erst **alle**
Gründe zu behalten, dann erst die Löschung. Ein Backup fällt nur heraus, wenn
keine Regel es hält.
- **`keep_last` schützt das letzte vorhandene Backup.** Jede mitgelieferte Regel
enthält es. Ohne diese Ergänzung löschte „7 Tage" bei einem System, das drei
Wochen nicht gesichert hat, *jedes* Backup — die Regel träfe genau dann zu,
wenn man die Daten am dringendsten braucht.
- **Eine Regel ohne jede Haltevorgabe wird abgelehnt**, nicht als „behalte
nichts" ausgeführt. Der Unterschied ist der zwischen einem Tippfehler und
einem Datenverlust.
- Wird `keep_last` bei sonst gültiger Regel weggelassen, ergänzt die API eine 1
**und sagt es** in der Antwort. Eine stille Korrektur der Eingabe fällt sonst
erst auf, wenn die Regel etwas anderes tut als gedacht.
- **Die Zeitzone gehört zur Regel.** Ohne sie liefe die Tageseinteilung in UTC,
und wer täglich um 00:30 deutscher Zeit sichert, verlöre systematisch die
falschen Backups.
- **Der Aufbewahrungsschutz steht über jeder Regel.** Ein fälliges, aber
geschütztes Backup erscheint im Plan als „wäre fällig, ist aber geschützt" —
das erklärt, warum der Speicher trotz Aufräumen nicht kleiner wird.
### Elternbackups einer Kette
Bei Syncova trägt **jedes** Manifest die Blockverweise aller Objekte, auch das
einer Zusatzsicherung (Phase 6). Ein Elternbackup zu löschen beschädigt das Kind
nicht. Diese Zusicherung steht seit Phase 11 im Manifest selbst
(`self_contained_restore`).
Der Grund ist ein Fund aus dem Nachweis: Ohne die Angabe wäre in einer
fortlaufenden Kette **jedes** Backup außer dem jüngsten ein Elternteil — die
Aufbewahrung räumte nie auf, während der Betreiber glaubt, eine zu haben. Ein
Manifest ohne das Feld (ältere Läufe, fremde Repositories) bedeutet weiterhin
„unbekannt" und schützt seine Kette.
Der Regressionstest löscht das Elternbackup und liest anschließend jeden Block
des Kindes — die Behauptung allein genügt nicht.
### „Gelöscht, aber nichts frei"
Wegen der Deduplizierung gibt ein gelöschtes Backup nur den Speicher frei, den
kein anderes mehr braucht. Bei wachsenden Daten verweist das jüngste Backup auf
sämtliche Blöcke der älteren; dann werden Backups gelöscht und **kein Byte**
frei. Die Zusammenfassung sagt das ausdrücklich, sonst erzeugt jede solche
Ausgabe eine Rückfrage.
## Endpunkte
| Methode | Pfad | Recht |
| --- | --- | --- |
| GET | `/api/v1/retention-policies` | `repositories.read` |
| POST | `/api/v1/retention-policies` | `retention.write` |
| PATCH | `/api/v1/retention-policies/{id}` | `retention.write` |
| DELETE | `/api/v1/retention-policies/{id}` | `retention.write` |
| POST | `/api/v1/repositories/{id}/retention/preview` | `repositories.read` |
| POST | `/api/v1/repositories/{id}/retention/apply` | `retention.write` |
| POST | `/api/v1/repositories/{id}/enforcement/measure` | `repositories.write` |
| GET | `/api/v1/backups/{id}/protection` | `backups.read` |
| POST | `/api/v1/backups/{id}/retention/extend` | `immutability.manage` |
| POST | `/api/v1/backups/{id}/legal-hold` | `immutability.manage` |
| DELETE | `/api/v1/backups/{id}/legal-hold` | `immutability.manage` |
| DELETE | `/api/v1/backups/{id}` | `backups.delete` |
**Die Trennung von `backups.delete` und `immutability.manage` ist der Kern der
Zugriffssteuerung dieser Phase.** Wer aufräumen darf, darf deshalb noch lange
keinen Aufbewahrungsschutz aufheben: Das Aufheben ist der Schritt, der einem
Angreifer den Weg öffnet. `immutability.manage` haben nur Super Administrator
und Security Administrator.
### Bestätigungen
Zwei Stellen verlangen eine wörtliche Wiederholung:
- `DELETE /backups/{id}` — `confirm_backup_id` wiederholt die Kennung des
Backups im Repository.
- `POST …/retention/apply` — `confirm_deletion` wiederholt die **Zahl** der zu
löschenden Backups aus der Vorschau.
Löscht ein Lauf nichts, entfällt die Bestätigung: Ein Ritual ohne Anlass gewöhnt
das Wegklicken an.
## Nachgewiesen
Gegen den laufenden Dienst mit echten Repositories:
| Versuch | Ergebnis |
| --- | --- |
| Durchsetzungsstufe messen | `filesystem`, mit beiden Beobachtungen belegt |
| Backup im gehärteten Repository löschen (Recht + Bestätigung) | 409 — Aufbewahrungsschutz |
| Löschen ohne / mit falscher Bestätigung | 422 |
| Legal Hold ohne Begründung | 422 |
| Frist verkürzen | 422 — „verlängern ja, verkürzen nie" |
| `rm -rf` gegen das gehärtete Repository | 15 Löschungen verweigert, Backup danach vollständig |
| Aufbewahrung: 4 Backups, Regel „nur das letzte" | 2 gelöscht, 2 behalten, Tiefenscan sauber |
| `backup_operator` setzt Legal Hold | 403 |
| Abgewehrter Löschversuch | `BACKUP_DELETION_DENIED` im Audit |
## Bekannte Grenzen
- **Kein S3 Object Lock, kein WORM.** Die Stufe `storage` existiert als Begriff
und wird von der Messung nie vergeben.
- **Der Schutz gilt gegenüber dem Dienstbenutzer, nicht gegenüber root.** Wer
Systemverwalter ist, kann das Kennzeichen entfernen. Das steht in jeder
Erklärung der Stufe `filesystem` — verschwiegen wird es nicht. Ein echter
Schutz gegen root verlangt ein zweites System (Repository-Appliance mit
eigenem Benutzerkreis) oder Speicher-WORM.
- **Aufbewahrung läuft nicht nach Zeitplan.** Sie wird über die API angestoßen;
eine Anbindung an den Scheduler fehlt. Bis dahin gilt: Wer nicht anwendet,
räumt nicht auf.
- **`minimum_retention_seconds` wird gespeichert, aber noch nicht erzwungen.**
Die Sperre gegen das Absenken der Repository-Frist fehlt; die Frist eines
einzelnen Backups lässt sich bereits nicht verkürzen.