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