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; + } +}