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 Ring(params ISecretProvider[] provider) => new(provider); /// /// 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 Two_slots_lead_to_the_same_key() { var machine = new PlainFileSecretProvider(); var ring = Ring(machine, PassphraseSecretProvider.With("geheim")); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "geheim"); // Only with the passphrase, without the machine slot - the move. var afterMove = Ring(PassphraseSecretProvider.With("geheim")).Load(Pfad); afterMove.ShouldBe(key); } /// /// 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 After_a_machine_change_the_passphrase_opens() { var ring = Ring(new NeverOpeningSlot(), PassphraseSecretProvider.With("umzug")); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "umzug"); var newMachine = Ring(new NeverOpeningSlot(), PassphraseSecretProvider.With("umzug")); newMachine.Load(Pfad).ShouldBe(key); } /// /// 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 After_the_move_the_machine_slot_is_created_anew() { var ring = Ring(new NeverOpeningSlot(), PassphraseSecretProvider.With("umzug")); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "umzug"); // New machine with a working machine slot: it does not open yet, // but the passphrase does - and afterwards the slot is created. var newMachine = Ring(new MachineSlot(), PassphraseSecretProvider.With("umzug")); newMachine.Load(Pfad).ShouldBe(key); // From now on the machine slot alone suffices, without a passphrase. Ring(new MachineSlot()).Load(Pfad).ShouldBe(key); } /// /// 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 ring = Ring(new NeverOpeningSlot(), PassphraseSecretProvider.With("umzug")); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "umzug"); var withoutMachineSlot = Ring( new PlainFileSecretProvider(), PassphraseSecretProvider.With("umzug")); withoutMachineSlot.Load(Pfad).ShouldBe(key); withoutMachineSlot.Slots.ShouldNotContain(s => s.Provider == "file"); } /// /// A wrong passphrase opens nothing — and above all does not generate a new /// key. /// [Fact] public void A_wrong_passphrase_opens_nothing() { var ring = Ring(new NeverOpeningSlot(), PassphraseSecretProvider.With("richtig")); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "richtig"); var wrong = Ring(new NeverOpeningSlot(), PassphraseSecretProvider.With("falsch")); Should.Throw(() => wrong.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 If_no_slot_opens_it_aborts_instead_of_regenerating() { Ring(new PlainFileSecretProvider()).Load(Pfad); var before = File.ReadAllBytes(Pfad); Should.Throw(() => Ring(new NeverOpeningSlot()).Load(Pfad)); File.ReadAllBytes(Pfad).ShouldBe(before, "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 With_passphrase_the_key_is_nowhere_in_plaintext() { var ring = Ring(new DpapiSecretProvider(), PassphraseSecretProvider.With("geheim"), new PlainFileSecretProvider()); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "geheim"); var content = File.ReadAllText(Pfad); var plaintext = Convert.ToBase64String(key); content.ShouldNotContain(plaintext); if (OperatingSystem.IsWindows()) { // With a machine slot the unprotected one drops away. content.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 A_ring_without_recovery_reports_it() { var ring = Ring(new PlainFileSecretProvider()); ring.Load(Pfad); ring.Problems.ShouldNotBeEmpty(); ring.Problems.ShouldContain(p => p.Contains("Wiederherstellungsfach")); ring.Problems.ShouldContain(p => p.Contains("ungeschützt")); } [Fact] public void With_passphrase_the_ring_is_fine() { var ring = Ring(new DpapiSecretProvider(), PassphraseSecretProvider.With("geheim"), new PlainFileSecretProvider()); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "geheim"); ring.Problems.ShouldNotContain(p => p.Contains("Wiederherstellungsfach")); } /// /// Without a passphrase no recovery slot comes about — there is nothing to make it with. /// [Fact] public void Without_passphrase_there_is_no_recovery_slot() { var ring = Ring(new PlainFileSecretProvider(), new PassphraseSecretProvider(() => null)); ring.Load(Pfad); ring.Slots.ShouldNotContain(s => s.Provider == "passphrase"); ring.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 A_ready_passphrase_is_adopted_afterwards() { // First without: only the unprotected slot comes about. Ring(new PlainFileSecretProvider(), new PassphraseSecretProvider(() => null)).Load(Pfad); // Then with: the recovery slot is added, the unprotected one drops away. var withValue = Ring(new PlainFileSecretProvider(), PassphraseSecretProvider.With("spaeter")); var key = withValue.Load(Pfad); withValue.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. withValue.Slots.ShouldContain(s => s.Provider == "file"); withValue.Problems.ShouldContain(p => p.Contains("ungeschützt")); // And it is the same key as before. Ring(PassphraseSecretProvider.With("spaeter")).Load(Pfad).ShouldBe(key); } // ─── 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 A_raw_legacy_key_is_adopted() { var old = RandomNumberGenerator.GetBytes(32); File.WriteAllBytes(Pfad, old); Ring(new PlainFileSecretProvider()).Load(Pfad).ShouldBe(old); } /// /// And after the takeover the slot format is in the file — the old raw key /// is gone, the value the same. /// [Fact] public void After_adoption_the_slot_format_is_in_the_file() { var old = RandomNumberGenerator.GetBytes(32); File.WriteAllBytes(Pfad, old); var ring = Ring(new PlainFileSecretProvider(), PassphraseSecretProvider.With("neu")); var key = ring.Load(Pfad); ring.SetRecoveryPassphrase(Pfad, key, "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. Ring(PassphraseSecretProvider.With("neu")).Load(Pfad).ShouldBe(old); } /// /// 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 A_damaged_file_is_not_replaced_silently() { File.WriteAllBytes(Pfad, RandomNumberGenerator.GetBytes(17)); Should.Throw(() => Ring(new PlainFileSecretProvider()).Load(Pfad)); } /// A machine slot that works here — the substitute for DPAPI in the test. private sealed class MachineSlot : 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 NeverOpeningSlot : 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; } }