using System.Security.Cryptography; using System.Text.Json; using ClawdDotNet.Core.Security; using Shouldly; namespace ClawdDotNet.Core.Tests.Security; /// /// The keyring: one master key, wrapped several times (#190). /// /// What this is about. A master key that is bound to the machine /// survives no move — a backup on a new machine then contains nothing but /// values nobody can read any more. One bound to a passphrase /// survives the move, but demands someone who types at every start. The keyring solves /// that by not deciding. /// 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 { /* A leftover test folder does not turn a run red. */ } } private static SecretKeyRing Bund(params ISecretProvider[] anbieter) => new(anbieter); /// /// The heart of the matter: two slots, one key. /// /// If they led to different keys, the whole setup would be worthless — the /// recovery path would then open something, but not the existing values. /// [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"); // Only with the passphrase, without the machine slot - the move. var nachUmzug = Bund(PassphraseSecretProvider.With("geheim")).Load(Pfad); nachUmzug.ShouldBe(schluessel); } /// /// The case #190 is about: the machine changes. /// /// The machine slot no longer opens. Without a recovery slot /// all credentials would be lost here. /// [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); } /// /// And afterwards day-to-day operation runs without a passphrase again: the machine slot is /// created anew on the new machine. /// /// That presupposes that the new machine has a slot to offer at all. /// On a system whose only way would be file, it does /// not happen — see . /// [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"); // New machine with a working machine slot: it does not open yet, // but the passphrase does - and afterwards the slot is created. var neueMaschine = Bund(new MaschinenFach(), PassphraseSecretProvider.With("umzug")); neueMaschine.Load(Pfad).ShouldBe(schluessel); // From now on the machine slot alone suffices, without a passphrase. Bund(new MaschinenFach()).Load(Pfad).ShouldBe(schluessel); } /// /// A move does not store the master key in plain text just so that the /// next start is more convenient. /// /// On a system without a secure machine slot — i.e. Linux before #180 — the /// passphrase stays the way. Putting a file slot next to it would destroy exactly the /// protection someone has just chosen. /// [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"); } /// /// A wrong passphrase opens nothing — and above all does not generate a new /// key. /// [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)); } /// /// The most important promise of all. If no slot opens, it aborts — /// rather than silently generating a new key. /// /// A new master key would make every existing enc:v2 value /// unreadable, and silently so: the application would keep running and report nothing but broken /// credentials without anyone seeing the connection. /// [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."); } /// /// The master key must not stand anywhere in plain text in the file — only that makes /// it fit for backups. /// [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()) { // With a machine slot the unprotected one drops away. inhalt.ShouldNotContain("\"file\""); } } /// /// A keyring without a recovery path works flawlessly — and fails completely on that /// one day. That's why it is named beforehand. /// [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")); } /// /// Without a passphrase no recovery slot comes about — there is nothing to make it with. /// [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"); } /// /// If a passphrase is available, it is used — even when later opening /// a keyring that does not have one yet. /// /// Whoever sets CLAWD_SECRET_PASSPHRASE did so on purpose. Not creating the /// slot would mean ignoring them and then reproaching them that they /// should please store a passphrase. /// [Fact] public void Eine_bereitstehende_Passphrase_wird_nachtraeglich_uebernommen() { // First without: only the unprotected slot comes about. Bund(new PlainFileSecretProvider(), new PassphraseSecretProvider(() => null)).Load(Pfad); // Then with: the recovery slot is added, the unprotected one drops away. var mit = Bund(new PlainFileSecretProvider(), PassphraseSecretProvider.With("spaeter")); var schluessel = mit.Load(Pfad); mit.Slots.ShouldContain(s => s.Provider == "passphrase"); // The unprotected slot stays: the automatic path takes nothing away. Removing // it because an environment variable happens to be set would be an // irreversible change nobody asked for. mit.Slots.ShouldContain(s => s.Provider == "file"); mit.Problems.ShouldContain(p => p.Contains("ungeschützt")); // And it is the same key as before. Bund(PassphraseSecretProvider.With("spaeter")).Load(Pfad).ShouldBe(schluessel); } // ─── Taking over the legacy formats ─── /// /// The Linux legacy: 32 raw bytes in the file. Whoever updates should notice /// nothing — the same key, just wrapped differently. /// [Fact] public void Ein_roher_Altschluessel_wird_uebernommen() { var alt = RandomNumberGenerator.GetBytes(32); File.WriteAllBytes(Pfad, alt); Bund(new PlainFileSecretProvider()).Load(Pfad).ShouldBe(alt); } /// /// And after the takeover the slot format is in the file — the old raw key /// is gone, the value the same. /// [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"); // And the value has not changed - otherwise all enc:v2 values would be lost. Bund(PassphraseSecretProvider.With("neu")).Load(Pfad).ShouldBe(alt); } /// /// A file of the wrong length is broken and is not replaced: that would make all /// encrypted values unreadable without anyone finding out why. /// [Fact] public void Eine_beschaedigte_Datei_wird_nicht_stillschweigend_ersetzt() { File.WriteAllBytes(Pfad, RandomNumberGenerator.GetBytes(17)); Should.Throw(() => Bund(new PlainFileSecretProvider()).Load(Pfad)); } /// A machine slot that works here — the substitute for DPAPI in the 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; } /// A slot that never opens — the new machine, the foreign user account. 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; } }