diff --git a/docs/Roadmap.md b/docs/Roadmap.md
index 35b2931..186175f 100644
--- a/docs/Roadmap.md
+++ b/docs/Roadmap.md
@@ -373,6 +373,41 @@ Steht vor #190, weil die Antwort das Backend bestimmt, das dort gebaut wird.
### [SEC] #190 — L1: Der Hauptschlüssel liegt unter Linux im Klartext
+> **Stand 2026-09-10: Die Naht ist gebaut, die Linux-Antwort steht noch aus.**
+>
+> `SecretKeyStore` hält nicht mehr einen verpackten Schlüssel, sondern einen
+> **Schlüsselbund** (`SecretKeyRing`): denselben Hauptschlüssel, mehrfach verpackt.
+> Jedes Fach gehört einem `ISecretProvider` — `dpapi` (Windows), `passphrase`
+> (Wiederherstellung), `file` (nur Dateirechte, der Zustand *dieses* Befunds, jetzt
+> beim Namen genannt).
+>
+> **Warum mehrere Fächer und nicht ein besseres.** Ein Schlüssel an der Maschine
+> überlebt keinen Umzug; einer an einer Passphrase verlangt bei jedem Start jemanden,
+> der tippt — und der Dauerbetrieb ohne Menschen ist der Zweck (#214). Der Bund
+> entscheidet sich nicht: Im Alltag öffnet das Maschinenfach ohne Rückfrage, nach
+> einem Umzug die Passphrase, und beide führen zu **demselben** Schlüssel. Alle
+> `enc:v2`-Werte bleiben lesbar, nichts muss neu eingetragen werden.
+>
+> **Zwei Wege, die sich ähneln und nicht dasselbe sind** — das ist die Stelle, an der
+> man beim Lesen falsch abbiegt:
+>
+> | | Sicherung mit Passphrase (`BackupService`) | Wiederherstellungsfach (`SecretKeyRing`) |
+> |---|---|---|
+> | Was umgeschlüsselt wird | jeder **Wert** einzeln, in das Archiv hinein | der **Hauptschlüssel**, in der Schlüsseldatei |
+> | Wofür | eine Instanz auf eine andere Maschine bringen | eine bestehende Installation weiterbenutzen, wenn das Maschinenfach nicht mehr aufgeht |
+> | Wann entschieden | beim Ziehen der Sicherung | einmal, vorher |
+> | Ohne Menschen | nein, die Passphrase gehört zur Sicherung | ja, über `CLAWD_SECRET_PASSPHRASE` |
+>
+> **Was noch fehlt, ist genau #180:** Womit das Maschinenfach unter Linux gefüllt wird.
+> Bis dahin greift dort `file` — dieselbe Schutzstufe wie vorher, aber sie steht jetzt
+> als Warnung im Protokoll und in `SecretKeyStore.Problems`, statt eine stille Zeile
+> `: key` zu sein. Der Befund bleibt deshalb offen.
+>
+> Zwei Zusagen sind mit Tests festgehalten: Geht **kein** Fach auf, wird abgebrochen
+> statt ein neuer Schlüssel erzeugt (der machte jeden bestehenden Wert wortlos
+> unlesbar). Und ein Umzug legt den Schlüssel **nicht** im Klartext ab, nur damit der
+> nächste Start bequemer wird.
+
`SecretKeyStore.Wrap` bindet den Schlüssel unter Windows per DPAPI ans Benutzerkonto
und gibt ihn sonst ungewrappt zurück; der einzige Schutz ist dann `0600` aus
`AppPaths.RestrictToOwner`. Auf einer dauerhaft laufenden, netzgebundenen Maschine ist
diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index 60b700c..0c93625 100644
--- a/src/ClawdDotNet.App/AppHost.cs
+++ b/src/ClawdDotNet.App/AppHost.cs
@@ -307,6 +307,22 @@ public sealed class AppHost : IAsyncDisposable
ResolveBackupDirectory(instance, instancePath));
}
+ // Der Schluesselbund, mit dem die Zugangsdaten verschluesselt sind (#190). Was
+ // hier fehlt, faellt im Alltag nicht auf und kostet an dem einen Tag alles: Ein
+ // Bund ohne Wiederherstellungsfach laeuft tadellos, bis die Maschine wechselt -
+ // und dann ist jeder verschluesselte Wert verloren, auch der aus einer Sicherung.
+ // Deshalb steht es beim Start da und nicht erst in der Fehlermeldung danach.
+ try
+ {
+ foreach (var problem in SecretKeyStore.Problems)
+ logger.LogWarning("Schlüsselbund: {Problem}", problem);
+ }
+ catch (Exception ex)
+ {
+ // Der Bund wird gleich ohnehin gebraucht; hier nur melden, nicht abbrechen.
+ logger.LogError(ex, "Der Schlüsselbund liess sich nicht pruefen.");
+ }
+
// ─── 4. Lizenz ───
//
// Gefuehrt ist, wer es nachweisen kann (#535). Der Schalter --managed allein
diff --git a/src/ClawdDotNet.Core/Security/ISecretProvider.cs b/src/ClawdDotNet.Core/Security/ISecretProvider.cs
new file mode 100644
index 0000000..ad23e28
--- /dev/null
+++ b/src/ClawdDotNet.Core/Security/ISecretProvider.cs
@@ -0,0 +1,92 @@
+using System.Text.Json.Serialization;
+
+namespace ClawdDotNet.Core.Security;
+
+///
+/// Ein Fach im Schlüsselbund: derselbe Hauptschlüssel, von einem Anbieter verpackt.
+///
+/// Was hier steht, ist ausschließlich Verpackung und Beiwerk — der Hauptschlüssel
+/// selbst kommt in keiner Fassung im Klartext vor. Deshalb darf die Schlüsseldatei
+/// überhaupt in eine Sicherung.
+///
+/// Wer dieses Fach öffnen kann (dpapi, passphrase, file).
+/// Der verpackte Hauptschlüssel, Base64.
+/// Wann das Fach angelegt wurde — für die Anzeige, nicht für die Krypto.
+///
+/// Was der Anbieter zum Öffnen braucht: Salz, Rundenzahl, Verfahren. Nie das Geheimnis
+/// selbst.
+///
+public sealed record SecretSlot(
+ string Provider,
+ string Payload,
+ DateTimeOffset CreatedAt,
+ [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ Dictionary? Meta = null)
+{
+ public string? Get(string key)
+ => Meta is not null && Meta.TryGetValue(key, out var wert) ? wert : null;
+}
+
+///
+/// Ein Weg, den Hauptschlüssel zu verpacken und wieder zu öffnen.
+///
+/// Warum es diese Naht gibt. Bis hierher stand in
+/// genau eine Zeile darüber, wie der Hauptschlüssel
+/// gesichert wird: unter Windows DPAPI, sonst gar nicht. „Sonst gar nicht" heißt auf dem
+/// Zielsystem — einer dauerhaft laufenden, netzgebundenen Linux-Maschine — im Klartext,
+/// geschützt allein durch Dateirechte (#190).
+///
+/// Und warum mehrere gleichzeitig. Ein einziger Weg zwingt zu einer
+/// Entscheidung, die niemand gut treffen kann: Bindet man den Schlüssel an die Maschine,
+/// überlebt er keinen Umzug; bindet man ihn an eine Passphrase, muss sie bei jedem Start
+/// jemand eintippen — und der Dauerbetrieb ohne Menschen ist der eigentliche Zweck
+/// (#214). Der Schlüsselbund umgeht die Wahl: Derselbe Hauptschlüssel liegt mehrfach
+/// verpackt nebeneinander. Im Alltag öffnet ihn das Maschinenfach ohne Rückfrage,
+/// nach einem Umzug das Wiederherstellungsfach mit der Passphrase. Beide führen zu
+/// demselben Schlüssel, also bleiben alle enc:v2-Werte lesbar.
+///
+/// Was hier noch nicht entschieden ist. Womit unter Linux das Maschinenfach
+/// gefüllt wird — Secret Service über D-Bus oder systemd-creds mit TPM2 — ist die offene
+/// Entscheidung #180. Diese Schnittstelle ist die Stelle, an der die Antwort eingesetzt
+/// wird; sie nimmt sie nicht vorweg.
+///
+public interface ISecretProvider
+{
+ /// Der Name, unter dem Fächer dieses Anbieters in der Datei stehen.
+ string Name { get; }
+
+ /// Wie das Fach dem Menschen gegenüber heißt.
+ string Description { get; }
+
+ ///
+ /// Kann dieser Anbieter hier überhaupt arbeiten?
+ ///
+ /// DPAPI etwa nur unter Windows. Ein nicht verfügbarer Anbieter ist kein
+ /// Fehler — sein Fach wird beim Öffnen übersprungen, und andere kommen zum Zug.
+ ///
+ bool IsAvailable { get; }
+
+ ///
+ /// Überlebt ein Fach dieses Anbieters den Umzug auf eine andere Maschine?
+ ///
+ /// Das ist die Eigenschaft, um die es bei #190 geht. Ein Schlüsselbund, in dem
+ /// kein Fach das überlebt, ist eine Sicherung, die im Ernstfall nichts
+ /// nützt — und sagt das dann auch.
+ ///
+ bool SurvivesMachineChange { get; }
+
+ ///
+ /// Verpackt den Hauptschlüssel. null heißt: gerade nicht möglich — etwa weil
+ /// die Passphrase fehlt.
+ ///
+ SecretSlot? Wrap(byte[] masterKey);
+
+ ///
+ /// Öffnet ein Fach. null heißt: nicht zuständig oder gerade nicht möglich.
+ ///
+ /// Kein Wurf bei einem falschen Kennwort. Ein Fach, das sich nicht
+ /// öffnen lässt, ist ein Fach weniger — der Schlüsselbund probiert das nächste. Erst
+ /// wenn keines aufgeht, gibt es einen Fehler, und der nennt dann alle Wege.
+ ///
+ byte[]? TryUnwrap(SecretSlot slot);
+}
diff --git a/src/ClawdDotNet.Core/Security/SecretKeyRing.cs b/src/ClawdDotNet.Core/Security/SecretKeyRing.cs
new file mode 100644
index 0000000..0576201
--- /dev/null
+++ b/src/ClawdDotNet.Core/Security/SecretKeyRing.cs
@@ -0,0 +1,353 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.Core.Security;
+
+/// Der Inhalt von secret.key im Fächerformat.
+public sealed class SecretKeyRingFile
+{
+ /// 3 — davor gab es kein Format, sondern rohe Bytes.
+ [JsonPropertyName("version")]
+ public int Version { get; set; } = 3;
+
+ [JsonPropertyName("slots")]
+ public List Slots { get; set; } = [];
+}
+
+///
+/// Der Schlüsselbund: ein Hauptschlüssel, mehrfach verpackt.
+///
+/// Das Problem, das er löst. Ein Hauptschlüssel, der an die Maschine
+/// gebunden ist, überlebt keinen Umzug — eine Sicherung, die man auf einem neuen Rechner
+/// zurückspielt, enthält dann lauter Werte, die niemand mehr lesen kann. Ein
+/// Hauptschlüssel, der an eine Passphrase gebunden ist, überlebt den Umzug, verlangt aber
+/// bei jedem Start, dass jemand tippt — und der Dauerbetrieb ohne Menschen ist der Zweck
+/// der Übung (#214).
+///
+/// Der Ausweg ist, sich nicht zu entscheiden. Wie bei einer verschlüsselten
+/// Festplatte mit mehreren Schlüsselfächern liegt derselbe Hauptschlüssel mehrfach
+/// verpackt nebeneinander: eines fürs Maschinenkonto, eines für die
+/// Wiederherstellungs-Passphrase. Im Alltag öffnet das erste ohne Rückfrage. Nach einem
+/// Umzug öffnet das zweite. Beide führen zu demselben Schlüssel — also bleiben
+/// alle enc:v2-Werte lesbar, ohne dass irgendetwas neu eingetragen werden muss.
+/// Danach wird das Maschinenfach auf der neuen Maschine neu angelegt, und der Alltag
+/// läuft wieder ohne Passphrase.
+///
+/// Und deshalb darf die Datei jetzt in eine Sicherung. Bisher lag sie
+/// bewusst außerhalb des Instanzverzeichnisses, weil sie den Schlüssel im Klartext
+/// enthielt (unter Linux) beziehungsweise nur auf einem Rechner nützte (unter Windows).
+/// Im Fächerformat steht der Hauptschlüssel in keiner Fassung im Klartext darin — was in
+/// eine Sicherung gerät, ist ohne Passphrase wertlos.
+///
+/// Was bewusst offen bleibt. Womit das Maschinenfach unter Linux gefüllt
+/// wird — Secret Service oder TPM2 —, ist Entscheidung #180. Bis dahin greift dort das
+/// Fach file, also der Zustand vor diesem Umbau; sagt
+/// deutlich, dass das kein Schutz ist.
+///
+public sealed class SecretKeyRing
+{
+ private const int KeySize = 32; // AES-256
+
+ private static readonly JsonSerializerOptions Json = new()
+ {
+ WriteIndented = true,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
+ private readonly IReadOnlyList _providers;
+
+ public SecretKeyRing(IEnumerable providers)
+ => _providers = [.. providers];
+
+ ///
+ /// Die vorgegebene Bestückung: Maschinenfach, Wiederherstellung, Rückfall.
+ ///
+ /// Die Reihenfolge ist die Reihenfolge beim Öffnen, und sie ist mit Absicht so:
+ /// erst das Fach, das ohne Rückfrage aufgeht, dann das mit der Passphrase, zuletzt
+ /// das ungeschützte. Wer eine Passphrase hinterlegt hat, wird sie im Alltag nicht
+ /// gefragt.
+ ///
+ public static SecretKeyRing Default => new(
+ [
+ new DpapiSecretProvider(),
+ PassphraseSecretProvider.FromEnvironment(),
+ new PlainFileSecretProvider()
+ ]);
+
+ ///
+ /// Was an diesem Schlüsselbund nicht in Ordnung ist — leer heißt: alles gut.
+ ///
+ /// Zum Anzeigen gedacht, nicht zum Abbrechen. Ein Schlüsselbund ohne
+ /// Wiederherstellungsfach funktioniert tadellos; er versagt erst an dem Tag, an dem
+ /// die Maschine wechselt, und dann vollständig. Genau solche Fehler gehören
+ /// vorher gesagt.
+ ///
+ public IReadOnlyList Problems { get; private set; } = [];
+
+ /// Welche Fächer der Bund führt — für die Anzeige in den Einstellungen.
+ public IReadOnlyList Slots { get; private set; } = [];
+
+ ///
+ /// Öffnet den Schlüsselbund oder legt ihn an.
+ ///
+ /// Wenn Fächer da sind, aber keines aufgeht, wird abgebrochen — nicht
+ /// etwa ein neuer Schlüssel erzeugt. Ein neuer Hauptschlüssel machte jeden
+ /// bestehenden enc:v2-Wert unlesbar, und zwar wortlos: Die Anwendung liefe
+ /// weiter und meldete lauter kaputte Zugangsdaten, ohne dass jemand den Zusammenhang
+ /// sähe.
+ ///
+ public byte[] Load(string path)
+ {
+ if (!File.Exists(path))
+ return Create(path);
+
+ var datei = Read(path);
+ Slots = datei.Slots;
+
+ foreach (var fach in datei.Slots)
+ {
+ var anbieter = _providers.FirstOrDefault(
+ p => p.Name == fach.Provider && p.IsAvailable);
+
+ if (anbieter?.TryUnwrap(fach) is not { } schluessel) continue;
+
+ if (schluessel.Length != KeySize)
+ {
+ throw new CryptographicException(
+ $"Das Fach '{fach.Provider}' in {path} enthält {schluessel.Length} statt "
+ + $"{KeySize} Byte. Es darf nicht ersetzt werden, ohne die verschlüsselten "
+ + "Werte neu zu setzen.");
+ }
+
+ // Das Maschinenfach kann fehlen, weil die Maschine gewechselt hat und nur
+ // die Passphrase aufging. Dann wird es hier neu angelegt - der Alltag laeuft
+ // danach wieder ohne Rueckfrage.
+ EnsureSlots(path, datei, schluessel);
+
+ Bewerten(datei);
+ return schluessel;
+ }
+
+ throw new CryptographicException(
+ $"Keines der {datei.Slots.Count} Fächer in {path} ließ sich öffnen "
+ + $"(vorhanden: {string.Join(", ", datei.Slots.Select(s => s.Provider))}). "
+ + "Stammt die Datei von einer anderen Maschine, hilft die "
+ + $"Wiederherstellungs-Passphrase über {PassphraseSecretProvider.EnvironmentVariable}. "
+ + "Die Datei darf nicht gelöscht werden, solange verschlüsselte Werte in den "
+ + "Einstellungen stehen — sie wären danach verloren.");
+ }
+
+ ///
+ /// Legt ein Wiederherstellungsfach an oder ersetzt es.
+ ///
+ /// Der Vorgang, der aus einer Installation eine umzugsfähige macht. Er braucht
+ /// den geöffneten Hauptschlüssel, weil er ihn ein weiteres Mal verpackt — er erzeugt
+ /// keinen neuen.
+ ///
+ public void SetRecoveryPassphrase(string path, byte[] masterKey, string passphrase)
+ {
+ if (string.IsNullOrWhiteSpace(passphrase))
+ throw new ArgumentException("Eine leere Passphrase schützt nichts.", nameof(passphrase));
+
+ var datei = File.Exists(path) ? Read(path) : new SecretKeyRingFile();
+
+ var neu = PassphraseSecretProvider.With(passphrase).Wrap(masterKey)
+ ?? throw new CryptographicException("Das Wiederherstellungsfach ließ sich nicht anlegen.");
+
+ datei.Slots.RemoveAll(s => s.Provider == "passphrase");
+ datei.Slots.Add(neu);
+
+ // Mit einer Passphrase im Bund ist das ungeschuetzte Fach nicht mehr noetig -
+ // und ein Fach, das den Schluessel im Klartext fuehrt, gehoert weg, sobald es
+ // einen besseren Weg gibt.
+ if (datei.Slots.Any(s => s.Provider is "dpapi"))
+ datei.Slots.RemoveAll(s => s.Provider == "file");
+
+ Write(path, datei);
+ Slots = datei.Slots;
+ Bewerten(datei);
+ }
+
+ private byte[] Create(string path)
+ {
+ var schluessel = RandomNumberGenerator.GetBytes(KeySize);
+ var datei = new SecretKeyRingFile();
+
+ Fuellen(datei, schluessel);
+ Write(path, datei);
+
+ Slots = datei.Slots;
+ Bewerten(datei);
+ return schluessel;
+ }
+
+ ///
+ /// Ergänzt fehlende Fächer auf dieser Maschine.
+ ///
+ /// Hier wird nur hinzugefügt, nie weggenommen. Der Unterschied ist
+ /// wichtig genug für einen eigenen Absatz: Dies ist der automatische Weg, der bei
+ /// jedem Start läuft. Ein Fach hier zu entfernen, weil gerade eine Umgebungsvariable
+ /// gesetzt ist, wäre eine unumkehrbare Änderung, die niemand verlangt hat — und wenn
+ /// die Passphrase danach abhandenkommt, ist alles weg. Weggenommen wird nur in
+ /// , wo ein Mensch sie gerade eingetippt hat.
+ ///
+ /// Ergänzt wird auch das Wiederherstellungsfach, wenn eine Passphrase
+ /// bereitsteht: Wer CLAWD_SECRET_PASSPHRASE setzt, hat das mit Absicht getan.
+ /// Es nicht anzulegen hieße, ihn zu ignorieren und ihm anschließend vorzuhalten, er
+ /// möge doch eine Passphrase hinterlegen.
+ ///
+ private void EnsureSlots(string path, SecretKeyRingFile datei, byte[] masterKey)
+ {
+ var namen = datei.Slots.Select(s => s.Provider).ToHashSet(StringComparer.Ordinal);
+ var neue = new List();
+
+ foreach (var anbieter in _providers)
+ {
+ if (!anbieter.IsAvailable || namen.Contains(anbieter.Name)) continue;
+
+ // Das ungeschuetzte Fach nur, wenn sonst gar keines da waere. Es nachtraeglich
+ // dazuzulegen, wo schon ein geschuetztes liegt, hiesse den Schluessel im
+ // Klartext danebenzulegen und die anderen Faecher damit zu entwerten.
+ if (anbieter.Name == "file" && datei.Slots.Count > 0) continue;
+
+ if (anbieter.Wrap(masterKey) is { } fach) neue.Add(fach);
+ }
+
+ if (neue.Count == 0) return;
+
+ datei.Slots.AddRange(neue);
+ Write(path, datei);
+ }
+
+ private void Fuellen(SecretKeyRingFile datei, byte[] masterKey)
+ {
+ foreach (var anbieter in _providers)
+ {
+ if (!anbieter.IsAvailable) continue;
+
+ // Das ungeschuetzte Fach nur, wenn sonst gar keines zustande kaeme - sonst
+ // laege der Schluessel im Klartext daneben und machte die anderen wertlos.
+ if (anbieter.Name == "file" && datei.Slots.Count > 0) continue;
+
+ if (anbieter.Wrap(masterKey) is { } fach) datei.Slots.Add(fach);
+ }
+
+ if (datei.Slots.Count == 0)
+ {
+ throw new CryptographicException(
+ "Es ließ sich kein einziges Schlüsselfach anlegen — der Hauptschlüssel "
+ + "könnte nicht gespeichert werden.");
+ }
+ }
+
+ private void Bewerten(SecretKeyRingFile datei)
+ {
+ var probleme = new List();
+
+ var umzugsfaehig = datei.Slots.Any(s => s.Provider == "passphrase");
+ var ungeschuetzt = datei.Slots.Any(s => s.Provider == "file");
+
+ if (!umzugsfaehig)
+ {
+ probleme.Add(
+ "Es gibt kein Wiederherstellungsfach. Wechselt die Maschine oder das "
+ + "Benutzerkonto, sind alle verschlüsselten Zugangsdaten verloren — auch die "
+ + "aus einer Sicherung. Abhilfe: eine Wiederherstellungs-Passphrase festlegen.");
+ }
+
+ if (ungeschuetzt)
+ {
+ probleme.Add(
+ "Der Hauptschlüssel liegt in der Schlüsseldatei ungeschützt (Fach 'file'); "
+ + "es schützen ihn allein die Dateirechte. Wer die Datei lesen kann, kann alle "
+ + "Zugangsdaten lesen (#190).");
+ }
+
+ Problems = probleme;
+ }
+
+ // ─── Datei ───
+
+ private static SecretKeyRingFile Read(string path)
+ {
+ var roh = File.ReadAllBytes(path);
+
+ if (Uebernehmen(roh) is { } uebernommen) return uebernommen;
+
+ try
+ {
+ return JsonSerializer.Deserialize(roh, Json)
+ ?? throw new CryptographicException(
+ $"Die Schlüsseldatei {path} ist leer.");
+ }
+ catch (JsonException ex)
+ {
+ throw new CryptographicException(
+ $"Die Schlüsseldatei {path} ist beschädigt. Sie darf nicht ersetzt werden, "
+ + "ohne die verschlüsselten Werte neu zu setzen.", ex);
+ }
+ }
+
+ ///
+ /// Übernimmt die beiden Altformate: rohe 32 Byte (Linux) oder ein DPAPI-Block
+ /// (Windows).
+ ///
+ /// Ohne Zutun und ohne Verlust. Wer aktualisiert, soll nichts merken —
+ /// der bestehende Hauptschlüssel wird weiterverwendet, nur anders verpackt. Ein
+ /// Umbau, der die Zugangsdaten aller bestehenden Installationen entwertet, wäre
+ /// schlimmer als das Problem, das er löst.
+ ///
+ /// null heißt: kein Altformat, also das Fächerformat.
+ ///
+ private static SecretKeyRingFile? Uebernehmen(byte[] roh)
+ {
+ // JSON faengt mit '{' an - alles andere ist alt. Ein roher Schluessel kann
+ // theoretisch mit 0x7B beginnen; deshalb wird unten trotzdem geprueft, ob sich
+ // das Ganze als JSON lesen laesst.
+ if (roh.Length > 0 && roh[0] == (byte)'{')
+ {
+ try
+ {
+ if (JsonSerializer.Deserialize(roh, Json) is { Slots.Count: > 0 })
+ return null;
+ }
+ catch (JsonException) { /* dann eben doch ein alter Schluessel */ }
+ }
+
+ if (roh.Length == KeySize)
+ {
+ // Der Linux-Fall: 32 rohe Byte. Das wird zum Fach 'file' - dieselbe
+ // Schutzstufe wie bisher, aber jetzt benannt und in Problems gemeldet.
+ return new SecretKeyRingFile
+ {
+ Slots =
+ [
+ new SecretSlot("file", Convert.ToBase64String(roh), DateTimeOffset.UtcNow)
+ ]
+ };
+ }
+
+ if (OperatingSystem.IsWindows())
+ {
+ // Der Windows-Fall: ein DPAPI-Block. Ob er zu diesem Konto gehoert, zeigt
+ // erst der Versuch - misslingt er, ist es kein uebernehmbares Altformat.
+ var probe = new DpapiSecretProvider();
+ var fach = new SecretSlot("dpapi", Convert.ToBase64String(roh), DateTimeOffset.UtcNow);
+
+ if (probe.TryUnwrap(fach) is { Length: KeySize })
+ return new SecretKeyRingFile { Slots = [fach] };
+ }
+
+ return null;
+ }
+
+ private static void Write(string path, SecretKeyRingFile datei)
+ {
+ // Ueber AtomicFile, damit kein halb geschriebener Bund entsteht - der wuerde alle
+ // Geheimnisse dieser Installation unlesbar machen.
+ AtomicFile.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(datei, Json));
+ AppPaths.RestrictToOwner(path);
+ }
+}
diff --git a/src/ClawdDotNet.Core/Security/SecretKeyStore.cs b/src/ClawdDotNet.Core/Security/SecretKeyStore.cs
index 98af8e1..365ac18 100644
--- a/src/ClawdDotNet.Core/Security/SecretKeyStore.cs
+++ b/src/ClawdDotNet.Core/Security/SecretKeyStore.cs
@@ -1,4 +1,3 @@
-using System.Security.Cryptography;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
@@ -7,29 +6,28 @@ namespace ClawdDotNet.Core.Security;
/// Verwaltet den lokalen Schlüssel, mit dem die
/// enc:v2-Werte sichert.
///
-/// Ein Schlüssel je Benutzer und Rechner. Er liegt als
-/// secret.key in — bewusst außerhalb
-/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
-/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
-/// nützt auf einem anderen Rechner nichts.
+/// Ein Schlüssel, mehrere Fächer. Er liegt als secret.key in
+/// und ist dort nicht mehr ein nackter Wert,
+/// sondern ein : derselbe Hauptschlüssel, mehrfach verpackt —
+/// einmal fürs Maschinenkonto, einmal für die Wiederherstellungs-Passphrase. Warum das
+/// so ist und was es löst, steht bei .
///
-/// Rechte. Unter Unix 0600. Unter Windows erbt die Datei die
-/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
-/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
-/// plattformübergreifend ist.
+/// Was sich für Aufrufer ändert: nichts.
+/// gibt weiterhin 32 Byte zurück. Der Umbau aus #190 sitzt
+/// vollständig hinter dieser Naht — genau dafür war sie da.
+///
+/// Rechte. Unter Unix 0600. Unter Windows erbt die Datei die Rechte
+/// des Benutzerprofils; die Bindung ans Benutzerkonto leistet dort das
+/// DPAPI-Fach.
///
public static class SecretKeyStore
{
private const string KeyFileName = "secret.key";
- private const int KeySize = 32; // AES-256
-
- /// DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows).
- private static readonly byte[] KeyEntropy =
- System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
private static readonly Lock Gate = new();
private static byte[]? _cached;
private static string? _overrideDirectory;
+ private static SecretKeyRing? _ring;
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
@@ -43,6 +41,20 @@ public static class SecretKeyStore
{
_overrideDirectory = directory;
_cached = null;
+ _ring = null;
+ }
+ }
+
+ ///
+ /// Setzt den Schlüsselbund — für Tests, die eine bestimmte Bestückung brauchen.
+ /// null stellt die Vorgabe wieder her.
+ ///
+ public static void UseRing(SecretKeyRing? ring)
+ {
+ lock (Gate)
+ {
+ _ring = ring;
+ _cached = null;
}
}
@@ -52,56 +64,83 @@ public static class SecretKeyStore
/// Wenn das Verzeichnis nicht beschreibbar ist.
public static byte[] GetOrCreateKey()
{
- lock (Gate)
+ lock (Gate) return GetOrCreateKeyUnlocked();
+ }
+
+ ///
+ /// Was am Schlüsselbund nicht in Ordnung ist — leer heißt: alles gut.
+ ///
+ /// Zum Anzeigen in den Einstellungen und zum Protokollieren beim Start. Der
+ /// wichtigste Eintrag ist der fehlende Wiederherstellungsweg: Er stört im Alltag
+ /// nicht und kostet beim Maschinenwechsel alles.
+ ///
+ public static IReadOnlyList Problems
+ {
+ get
{
- if (_cached is not null) return _cached;
-
- var directory = AppPaths.EnsureDirectory(Directory());
- var path = Path.Combine(directory, KeyFileName);
-
- if (File.Exists(path))
+ lock (Gate)
{
- var stored = Unwrap(File.ReadAllBytes(path));
- if (stored.Length == KeySize)
- {
- _cached = stored;
- return _cached;
- }
-
- // Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen
- // würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt,
- // warum — deshalb hier abbrechen und den Pfad nennen.
- throw new CryptographicException(
- $"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). "
- + "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen.");
+ GetOrCreateKeyUnlocked();
+ return _ring?.Problems ?? [];
}
-
- var key = RandomNumberGenerator.GetBytes(KeySize);
-
- // Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der
- // würde alle Geheimnisse dieser Installation unlesbar machen.
- AtomicFile.WriteAllBytes(path, Wrap(key));
- AppPaths.RestrictToOwner(path);
-
- _cached = key;
- return _cached;
}
}
+ /// Die vorhandenen Fächer — für die Anzeige.
+ public static IReadOnlyList Slots
+ {
+ get
+ {
+ lock (Gate)
+ {
+ GetOrCreateKeyUnlocked();
+ return _ring?.Slots ?? [];
+ }
+ }
+ }
+
+ ///
+ /// Legt eine Wiederherstellungs-Passphrase fest oder ersetzt sie.
+ ///
+ /// Der Vorgang, der aus einer Installation eine umzugsfähige macht. Er
+ /// erzeugt keinen neuen Hauptschlüssel — er verpackt den vorhandenen ein weiteres
+ /// Mal. Alle bestehenden enc:v2-Werte bleiben also gültig, und eine Sicherung,
+ /// die ab jetzt gezogen wird, lässt sich auf einer anderen Maschine öffnen.
+ ///
+ public static void SetRecoveryPassphrase(string passphrase)
+ {
+ lock (Gate)
+ {
+ var schluessel = GetOrCreateKeyUnlocked();
+ var ring = _ring ??= SecretKeyRing.Default;
+
+ ring.SetRecoveryPassphrase(KeyFilePath, schluessel, passphrase);
+ }
+ }
+
+ /// Hat der Schlüsselbund einen Weg, der einen Maschinenwechsel überlebt?
+ public static bool HasRecoveryPath
+ {
+ get
+ {
+ lock (Gate)
+ {
+ GetOrCreateKeyUnlocked();
+ return _ring?.Slots.Any(s => s.Provider == "passphrase") ?? false;
+ }
+ }
+ }
+
+ private static byte[] GetOrCreateKeyUnlocked()
+ {
+ if (_cached is not null) return _cached;
+
+ var directory = AppPaths.EnsureDirectory(Directory());
+ var ring = _ring ??= SecretKeyRing.Default;
+
+ _cached = ring.Load(Path.Combine(directory, KeyFileName));
+ return _cached;
+ }
+
private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
-
- /// Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden.
- private static byte[] Wrap(byte[] key)
- => OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key;
-
- private static byte[] Unwrap(byte[] stored)
- => OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored;
-
- [System.Runtime.Versioning.SupportedOSPlatform("windows")]
- private static byte[] ProtectWithDpapi(byte[] key)
- => ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser);
-
- [System.Runtime.Versioning.SupportedOSPlatform("windows")]
- private static byte[] UnprotectWithDpapi(byte[] stored)
- => ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser);
}
diff --git a/src/ClawdDotNet.Core/Security/SecretProviders.cs b/src/ClawdDotNet.Core/Security/SecretProviders.cs
new file mode 100644
index 0000000..555a2a2
--- /dev/null
+++ b/src/ClawdDotNet.Core/Security/SecretProviders.cs
@@ -0,0 +1,227 @@
+using System.Runtime.Versioning;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ClawdDotNet.Core.Security;
+
+///
+/// Das Maschinenfach unter Windows: DPAPI im Benutzerkontext.
+///
+/// Öffnet sich ohne Rückfrage, solange derselbe Benutzer auf derselben Maschine
+/// arbeitet — und gar nicht mehr, sobald eines von beidem wechselt. Genau deshalb braucht
+/// ein Schlüsselbund, der einen Umzug überstehen soll, ein zweites Fach.
+///
+public sealed class DpapiSecretProvider : ISecretProvider
+{
+ public string Name => "dpapi";
+
+ public string Description => "Windows-Benutzerkonto (DPAPI)";
+
+ public bool IsAvailable => OperatingSystem.IsWindows();
+
+ public bool SurvivesMachineChange => false;
+
+ private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
+
+ public SecretSlot? Wrap(byte[] masterKey)
+ {
+ if (!OperatingSystem.IsWindows()) return null;
+
+ return new SecretSlot(
+ Name, Convert.ToBase64String(Protect(masterKey)), DateTimeOffset.UtcNow);
+ }
+
+ public byte[]? TryUnwrap(SecretSlot slot)
+ {
+ if (!OperatingSystem.IsWindows() || slot.Provider != Name) return null;
+
+ try { return Unprotect(Convert.FromBase64String(slot.Payload)); }
+ catch (Exception ex) when (ex is CryptographicException or FormatException)
+ {
+ // Anderer Benutzer, andere Maschine, oder das Profil wurde neu angelegt.
+ // Kein Fehler, den dieser Anbieter zu melden haette - ein Fach weniger.
+ return null;
+ }
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static byte[] Protect(byte[] key)
+ => ProtectedData.Protect(key, Entropy, DataProtectionScope.CurrentUser);
+
+ [SupportedOSPlatform("windows")]
+ private static byte[] Unprotect(byte[] stored)
+ => ProtectedData.Unprotect(stored, Entropy, DataProtectionScope.CurrentUser);
+}
+
+///
+/// Das Wiederherstellungsfach: eine Passphrase, die der Mensch kennt.
+///
+/// Das einzige Fach, das einen Maschinenwechsel überlebt. Es hängt an nichts
+/// als der Passphrase — nicht am Benutzerkonto, nicht am Rechner, nicht am
+/// Betriebssystem. Wer eine Sicherung auf einer neuen Maschine zurückspielt und die
+/// Passphrase kennt, kommt an alle Zugangsdaten; wer sie nicht kennt, an keine.
+///
+/// Deshalb ist es kein Ersatz fürs Maschinenfach, sondern dessen Ergänzung.
+/// Eine Passphrase bei jedem Start einzutippen verträgt sich nicht mit einem Dienst, der
+/// durchläuft (#214). Im Alltag öffnet das Maschinenfach; dieses hier liegt daneben und
+/// wird gebraucht, wenn das andere nicht mehr aufgeht.
+///
+/// PBKDF2 mit 600.000 Runden. Die Passphrase kommt von einem Menschen und
+/// hat entsprechend wenig Entropie; die Rundenzahl ist das, was zwischen ihr und einem
+/// Wörterbuchangriff steht. Sie steht im Fach, damit ein später erhöhter Wert alte
+/// Fächer nicht unlesbar macht.
+///
+///
+/// Woher die Passphrase kommt. null heißt: gerade keine da — dann ruht dieses
+/// Fach, statt zu scheitern.
+///
+public sealed class PassphraseSecretProvider(Func passphraseSource) : ISecretProvider
+{
+ ///
+ /// Für den unbeaufsichtigten Fall: Wiederherstellung ohne jemanden, der tippt.
+ ///
+ /// Eine Passphrase in einer Umgebungsvariablen ist schwächer als eine
+ /// eingetippte — sie steht in der Prozessumgebung und womöglich in einer
+ /// Dienstdatei. Für den Umzug einer Maschine ist sie trotzdem der richtige Weg: Die
+ /// Alternative ist, dass niemand herankommt.
+ ///
+ public const string EnvironmentVariable = "CLAWD_SECRET_PASSPHRASE";
+
+ /// Liest die Passphrase aus der Umgebung — der vorgegebene Weg.
+ public static PassphraseSecretProvider FromEnvironment()
+ => new(() => Environment.GetEnvironmentVariable(EnvironmentVariable));
+
+ /// Eine feste Passphrase, etwa aus einem Dialog.
+ public static PassphraseSecretProvider With(string passphrase)
+ => new(() => passphrase);
+
+ private const int Iterations = 600_000;
+ private const int SaltSize = 16;
+ private const int NonceSize = 12;
+ private const int TagSize = 16;
+
+ private static readonly byte[] AssociatedData =
+ Encoding.UTF8.GetBytes("ClawdDotNet.SecretKeyRing.passphrase.v1");
+
+ public string Name => "passphrase";
+
+ public string Description => "Wiederherstellungs-Passphrase";
+
+ public bool IsAvailable => !string.IsNullOrEmpty(passphraseSource());
+
+ public bool SurvivesMachineChange => true;
+
+ public SecretSlot? Wrap(byte[] masterKey)
+ {
+ var passphrase = passphraseSource();
+ if (string.IsNullOrEmpty(passphrase)) return null;
+
+ var salt = RandomNumberGenerator.GetBytes(SaltSize);
+ var nonce = RandomNumberGenerator.GetBytes(NonceSize);
+ var cipher = new byte[masterKey.Length];
+ var tag = new byte[TagSize];
+
+ using (var aes = new AesGcm(Derive(passphrase, salt, Iterations), TagSize))
+ aes.Encrypt(nonce, masterKey, cipher, tag, AssociatedData);
+
+ // nonce ‖ tag ‖ cipher - feste Laengen vorn, damit das Zerlegen eindeutig ist.
+ var payload = new byte[NonceSize + TagSize + cipher.Length];
+ nonce.CopyTo(payload, 0);
+ tag.CopyTo(payload, NonceSize);
+ cipher.CopyTo(payload, NonceSize + TagSize);
+
+ return new SecretSlot(
+ Name, Convert.ToBase64String(payload), DateTimeOffset.UtcNow,
+ new Dictionary
+ {
+ ["kdf"] = "pbkdf2-sha256",
+ ["iterations"] = Iterations.ToString(),
+ ["salt"] = Convert.ToBase64String(salt)
+ });
+ }
+
+ public byte[]? TryUnwrap(SecretSlot slot)
+ {
+ if (slot.Provider != Name) return null;
+
+ var passphrase = passphraseSource();
+ if (string.IsNullOrEmpty(passphrase)) return null;
+
+ if (slot.Get("salt") is not { } saltText
+ || !int.TryParse(slot.Get("iterations"), out var runden)
+ || runden <= 0)
+ {
+ return null;
+ }
+
+ try
+ {
+ var raw = Convert.FromBase64String(slot.Payload);
+ if (raw.Length < NonceSize + TagSize) return null;
+
+ var nonce = raw.AsSpan(0, NonceSize);
+ var tag = raw.AsSpan(NonceSize, TagSize);
+ var cipher = raw.AsSpan(NonceSize + TagSize);
+ var plain = new byte[cipher.Length];
+
+ using (var aes = new AesGcm(
+ Derive(passphrase, Convert.FromBase64String(saltText), runden), TagSize))
+ {
+ aes.Decrypt(nonce, cipher, tag, plain, AssociatedData);
+ }
+
+ return plain;
+ }
+ catch (Exception ex) when (ex is CryptographicException or FormatException)
+ {
+ // Falsche Passphrase. Ein Fach weniger, kein Fehler - der Schluesselbund
+ // probiert das naechste und meldet erst, wenn keines aufgeht.
+ return null;
+ }
+ }
+
+ private static byte[] Derive(string passphrase, byte[] salt, int iterations)
+ => Rfc2898DeriveBytes.Pbkdf2(
+ Encoding.UTF8.GetBytes(passphrase), salt, iterations, HashAlgorithmName.SHA256, 32);
+}
+
+///
+/// Der Hauptschlüssel als Datei, geschützt allein durch Dateirechte.
+///
+/// Das ist der Zustand aus #190, und er heißt hier so, wie er ist. Vorher
+/// stand er als OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key in einer
+/// Zeile — ein stiller Sonderfall, den man beim Lesen überspringt. Als eigener Anbieter
+/// mit dem Namen file ist er das, was er ist: kein Schutz gegen jemanden, der als
+/// dieser Benutzer läuft, und keiner gegen ein Backup, das die Datei mitnimmt.
+///
+/// Er bleibt trotzdem, weil er als Rückfalloption gebraucht wird: Ohne ihn
+/// käme auf einer Linux-Maschine ohne Passphrase kein Schlüsselbund zustande, und die
+/// Anwendung startete nicht mehr. Der Schlüsselbund warnt statt zu scheitern — und die
+/// Warnung nennt den Ausweg.
+///
+public sealed class PlainFileSecretProvider : ISecretProvider
+{
+ public string Name => "file";
+
+ public string Description => "Nur Dateirechte (kein zusätzlicher Schutz)";
+
+ public bool IsAvailable => true;
+
+ ///
+ /// Wahr, und das ist keine Empfehlung: Die Datei mitzunehmen bedeutet, den
+ /// Hauptschlüssel mitzunehmen. Der Schlüsselbund zählt dieses Fach deshalb nicht als
+ /// Wiederherstellungsweg.
+ ///
+ public bool SurvivesMachineChange => true;
+
+ public SecretSlot? Wrap(byte[] masterKey)
+ => new(Name, Convert.ToBase64String(masterKey), DateTimeOffset.UtcNow);
+
+ public byte[]? TryUnwrap(SecretSlot slot)
+ {
+ if (slot.Provider != Name) return null;
+
+ try { return Convert.FromBase64String(slot.Payload); }
+ catch (FormatException) { return null; }
+ }
+}
diff --git a/tests/ClawdDotNet.Core.Tests/Security/SecretKeyRingTests.cs b/tests/ClawdDotNet.Core.Tests/Security/SecretKeyRingTests.cs
new file mode 100644
index 0000000..bc2345f
--- /dev/null
+++ b/tests/ClawdDotNet.Core.Tests/Security/SecretKeyRingTests.cs
@@ -0,0 +1,326 @@
+using System.Security.Cryptography;
+using System.Text.Json;
+using ClawdDotNet.Core.Security;
+using Shouldly;
+
+namespace ClawdDotNet.Core.Tests.Security;
+
+///
+/// Der Schlüsselbund: ein Hauptschlüssel, mehrfach verpackt (#190).
+///
+/// Worum es geht. Ein Hauptschlüssel, der an die Maschine gebunden ist,
+/// überlebt keinen Umzug — eine Sicherung auf einem neuen Rechner enthält dann lauter
+/// Werte, die niemand mehr lesen kann. Einer, der an eine Passphrase gebunden ist,
+/// überlebt den Umzug, verlangt aber bei jedem Start jemanden, der tippt. Der Bund löst
+/// das, indem er sich nicht entscheidet.
+///
+public sealed class SecretKeyRingTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(
+ Path.GetTempPath(), "clawd-tests", "ring-" + Guid.NewGuid().ToString("N")[..8]);
+
+ private string Pfad => Path.Combine(_dir, "secret.key");
+
+ public SecretKeyRingTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); }
+ catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
+ }
+
+ private static SecretKeyRing Bund(params ISecretProvider[] anbieter) => new(anbieter);
+
+ ///
+ /// Der Kern der Sache: Zwei Fächer, ein Schlüssel.
+ ///
+ /// Führten sie zu verschiedenen Schlüsseln, wäre der ganze Aufbau wertlos — der
+ /// Wiederherstellungsweg öffnete dann zwar etwas, aber nicht die vorhandenen Werte.
+ ///
+ [Fact]
+ public void Zwei_Faecher_fuehren_zu_demselben_Schluessel()
+ {
+ var maschine = new PlainFileSecretProvider();
+ var bund = Bund(maschine, PassphraseSecretProvider.With("geheim"));
+
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "geheim");
+
+ // Nur mit der Passphrase, ohne das Maschinenfach - der Umzug.
+ var nachUmzug = Bund(PassphraseSecretProvider.With("geheim")).Load(Pfad);
+
+ nachUmzug.ShouldBe(schluessel);
+ }
+
+ ///
+ /// Der Fall, um den es bei #190 geht: Die Maschine wechselt.
+ ///
+ /// Das Maschinenfach geht nicht mehr auf. Ohne Wiederherstellungsfach wären
+ /// hier alle Zugangsdaten verloren.
+ ///
+ [Fact]
+ public void Nach_einem_Maschinenwechsel_oeffnet_die_Passphrase()
+ {
+ var bund = Bund(new NieOeffnendesFach(), PassphraseSecretProvider.With("umzug"));
+ var schluessel = bund.Load(Pfad);
+
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "umzug");
+
+ var neueMaschine = Bund(new NieOeffnendesFach(), PassphraseSecretProvider.With("umzug"));
+ neueMaschine.Load(Pfad).ShouldBe(schluessel);
+ }
+
+ ///
+ /// Und danach läuft der Alltag wieder ohne Passphrase: Das Maschinenfach wird auf der
+ /// neuen Maschine neu angelegt.
+ ///
+ /// Das setzt voraus, dass die neue Maschine überhaupt ein Fach anzubieten hat.
+ /// Auf einem System, dessen einziger Weg file wäre, geschieht es
+ /// nicht — siehe .
+ ///
+ [Fact]
+ public void Nach_dem_Umzug_entsteht_das_Maschinenfach_neu()
+ {
+ var bund = Bund(new NieOeffnendesFach(), PassphraseSecretProvider.With("umzug"));
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "umzug");
+
+ // Neue Maschine mit funktionierendem Maschinenfach: Es oeffnet zwar noch nicht,
+ // aber die Passphrase tut es - und danach wird es angelegt.
+ var neueMaschine = Bund(new MaschinenFach(), PassphraseSecretProvider.With("umzug"));
+ neueMaschine.Load(Pfad).ShouldBe(schluessel);
+
+ // Ab jetzt reicht das Maschinenfach allein, ohne Passphrase.
+ Bund(new MaschinenFach()).Load(Pfad).ShouldBe(schluessel);
+ }
+
+ ///
+ /// Ein Umzug legt den Hauptschlüssel nicht im Klartext ab, nur damit der
+ /// nächste Start bequemer wird.
+ ///
+ /// Auf einem System ohne sicheres Maschinenfach — also Linux vor #180 — bleibt
+ /// die Passphrase der Weg. Ein file-Fach danebenzulegen, machte genau den
+ /// Schutz zunichte, für den sich jemand gerade entschieden hat.
+ ///
+ [Fact]
+ public void Ein_Umzug_legt_den_Schluessel_nicht_im_Klartext_ab()
+ {
+ var bund = Bund(new NieOeffnendesFach(), PassphraseSecretProvider.With("umzug"));
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "umzug");
+
+ var ohneMaschinenfach = Bund(
+ new PlainFileSecretProvider(), PassphraseSecretProvider.With("umzug"));
+
+ ohneMaschinenfach.Load(Pfad).ShouldBe(schluessel);
+ ohneMaschinenfach.Slots.ShouldNotContain(s => s.Provider == "file");
+ }
+
+ ///
+ /// Eine falsche Passphrase öffnet nichts — und erzeugt vor allem keinen neuen
+ /// Schlüssel.
+ ///
+ [Fact]
+ public void Eine_falsche_Passphrase_oeffnet_nichts()
+ {
+ var bund = Bund(new NieOeffnendesFach(), PassphraseSecretProvider.With("richtig"));
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "richtig");
+
+ var falsch = Bund(new NieOeffnendesFach(), PassphraseSecretProvider.With("falsch"));
+
+ Should.Throw(() => falsch.Load(Pfad));
+ }
+
+ ///
+ /// Die wichtigste Zusage überhaupt. Geht kein Fach auf, wird abgebrochen —
+ /// nicht stillschweigend ein neuer Schlüssel erzeugt.
+ ///
+ /// Ein neuer Hauptschlüssel machte jeden bestehenden enc:v2-Wert
+ /// unlesbar, und zwar wortlos: Die Anwendung liefe weiter und meldete lauter kaputte
+ /// Zugangsdaten, ohne dass jemand den Zusammenhang sähe.
+ ///
+ [Fact]
+ public void Wenn_kein_Fach_aufgeht_wird_abgebrochen_statt_neu_erzeugt()
+ {
+ Bund(new PlainFileSecretProvider()).Load(Pfad);
+ var vorher = File.ReadAllBytes(Pfad);
+
+ Should.Throw(() => Bund(new NieOeffnendesFach()).Load(Pfad));
+
+ File.ReadAllBytes(Pfad).ShouldBe(vorher, "Die Datei darf dabei nicht angefasst werden.");
+ }
+
+ ///
+ /// Der Hauptschlüssel darf in der Datei nirgends im Klartext stehen — erst das macht
+ /// sie sicherungsfähig.
+ ///
+ [Fact]
+ public void Mit_Passphrase_steht_der_Schluessel_nirgends_im_Klartext()
+ {
+ var bund = Bund(new DpapiSecretProvider(), PassphraseSecretProvider.With("geheim"),
+ new PlainFileSecretProvider());
+
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "geheim");
+
+ var inhalt = File.ReadAllText(Pfad);
+ var klartext = Convert.ToBase64String(schluessel);
+
+ inhalt.ShouldNotContain(klartext);
+
+ if (OperatingSystem.IsWindows())
+ {
+ // Mit einem Maschinenfach faellt das ungeschuetzte weg.
+ inhalt.ShouldNotContain("\"file\"");
+ }
+ }
+
+ ///
+ /// Ein Bund ohne Wiederherstellungsweg funktioniert tadellos — und versagt an dem
+ /// einen Tag vollständig. Deshalb wird er vorher benannt.
+ ///
+ [Fact]
+ public void Ein_Bund_ohne_Wiederherstellung_meldet_das()
+ {
+ var bund = Bund(new PlainFileSecretProvider());
+ bund.Load(Pfad);
+
+ bund.Problems.ShouldNotBeEmpty();
+ bund.Problems.ShouldContain(p => p.Contains("Wiederherstellungsfach"));
+ bund.Problems.ShouldContain(p => p.Contains("ungeschützt"));
+ }
+
+ [Fact]
+ public void Mit_Passphrase_ist_der_Bund_in_Ordnung()
+ {
+ var bund = Bund(new DpapiSecretProvider(), PassphraseSecretProvider.With("geheim"),
+ new PlainFileSecretProvider());
+
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "geheim");
+
+ bund.Problems.ShouldNotContain(p => p.Contains("Wiederherstellungsfach"));
+ }
+
+ ///
+ /// Ohne Passphrase entsteht kein Wiederherstellungsfach — es gibt ja nichts, womit.
+ ///
+ [Fact]
+ public void Ohne_Passphrase_gibt_es_kein_Wiederherstellungsfach()
+ {
+ var bund = Bund(new PlainFileSecretProvider(), new PassphraseSecretProvider(() => null));
+ bund.Load(Pfad);
+
+ bund.Slots.ShouldNotContain(s => s.Provider == "passphrase");
+ bund.Slots.ShouldContain(s => s.Provider == "file");
+ }
+
+ ///
+ /// Steht eine Passphrase bereit, wird sie genommen — auch beim nachträglichen Öffnen
+ /// eines Bundes, der noch keines hat.
+ ///
+ /// Wer CLAWD_SECRET_PASSPHRASE setzt, hat das mit Absicht getan. Das
+ /// Fach nicht anzulegen hieße, ihn zu ignorieren und ihm anschließend vorzuhalten, er
+ /// möge doch eine Passphrase hinterlegen.
+ ///
+ [Fact]
+ public void Eine_bereitstehende_Passphrase_wird_nachtraeglich_uebernommen()
+ {
+ // Erst ohne: Es entsteht nur das ungeschuetzte Fach.
+ Bund(new PlainFileSecretProvider(), new PassphraseSecretProvider(() => null)).Load(Pfad);
+
+ // Dann mit: Das Wiederherstellungsfach kommt hinzu, das ungeschuetzte faellt weg.
+ var mit = Bund(new PlainFileSecretProvider(), PassphraseSecretProvider.With("spaeter"));
+ var schluessel = mit.Load(Pfad);
+
+ mit.Slots.ShouldContain(s => s.Provider == "passphrase");
+
+ // Das ungeschuetzte Fach bleibt: Der automatische Weg nimmt nichts weg. Es
+ // wegzuraeumen, weil gerade eine Umgebungsvariable gesetzt ist, waere eine
+ // unumkehrbare Aenderung, die niemand verlangt hat.
+ mit.Slots.ShouldContain(s => s.Provider == "file");
+ mit.Problems.ShouldContain(p => p.Contains("ungeschützt"));
+
+ // Und es ist derselbe Schluessel wie vorher.
+ Bund(PassphraseSecretProvider.With("spaeter")).Load(Pfad).ShouldBe(schluessel);
+ }
+
+ // ─── Übernahme der Altformate ───
+
+ ///
+ /// Der Linux-Bestand: 32 rohe Byte in der Datei. Wer aktualisiert, soll nichts
+ /// merken — derselbe Schlüssel, nur anders verpackt.
+ ///
+ [Fact]
+ public void Ein_roher_Altschluessel_wird_uebernommen()
+ {
+ var alt = RandomNumberGenerator.GetBytes(32);
+ File.WriteAllBytes(Pfad, alt);
+
+ Bund(new PlainFileSecretProvider()).Load(Pfad).ShouldBe(alt);
+ }
+
+ ///
+ /// Und nach der Übernahme steht das Fächerformat in der Datei — der alte Rohschlüssel
+ /// ist weg, der Wert derselbe.
+ ///
+ [Fact]
+ public void Nach_der_Uebernahme_steht_das_Faecherformat_in_der_Datei()
+ {
+ var alt = RandomNumberGenerator.GetBytes(32);
+ File.WriteAllBytes(Pfad, alt);
+
+ var bund = Bund(new PlainFileSecretProvider(), PassphraseSecretProvider.With("neu"));
+ var schluessel = bund.Load(Pfad);
+ bund.SetRecoveryPassphrase(Pfad, schluessel, "neu");
+
+ var datei = JsonSerializer.Deserialize(File.ReadAllText(Pfad))!;
+ datei.Version.ShouldBe(3);
+ datei.Slots.ShouldContain(s => s.Provider == "passphrase");
+
+ // Und der Wert hat sich nicht geaendert - sonst waeren alle enc:v2-Werte hin.
+ Bund(PassphraseSecretProvider.With("neu")).Load(Pfad).ShouldBe(alt);
+ }
+
+ ///
+ /// Eine Datei falscher Länge ist kaputt und wird nicht ersetzt: Das machte alle
+ /// verschlüsselten Werte unlesbar, ohne dass jemand erführe, warum.
+ ///
+ [Fact]
+ public void Eine_beschaedigte_Datei_wird_nicht_stillschweigend_ersetzt()
+ {
+ File.WriteAllBytes(Pfad, RandomNumberGenerator.GetBytes(17));
+
+ Should.Throw(() => Bund(new PlainFileSecretProvider()).Load(Pfad));
+ }
+
+ /// Ein Maschinenfach, das hier funktioniert — der Ersatz fuer DPAPI im Test.
+ private sealed class MaschinenFach : ISecretProvider
+ {
+ public string Name => "dpapi";
+ public string Description => "Maschinenfach (Test)";
+ public bool IsAvailable => true;
+ public bool SurvivesMachineChange => false;
+
+ public SecretSlot? Wrap(byte[] masterKey)
+ => new(Name, Convert.ToBase64String(masterKey), DateTimeOffset.UtcNow);
+
+ public byte[]? TryUnwrap(SecretSlot slot)
+ => slot.Provider == Name ? Convert.FromBase64String(slot.Payload) : null;
+ }
+
+ /// Ein Fach, das nie aufgeht — die neue Maschine, das fremde Benutzerkonto.
+ private sealed class NieOeffnendesFach : ISecretProvider
+ {
+ public string Name => "dpapi";
+ public string Description => "Maschinenfach (Test: geht nie auf)";
+ public bool IsAvailable => true;
+ public bool SurvivesMachineChange => false;
+
+ public SecretSlot? Wrap(byte[] masterKey)
+ => new(Name, Convert.ToBase64String(masterKey), DateTimeOffset.UtcNow);
+
+ public byte[]? TryUnwrap(SecretSlot slot) => null;
+ }
+}