Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
327 lines
12 KiB
C#
327 lines
12 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Security;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Security;
|
|
|
|
/// <summary>
|
|
/// The keyring: one master key, wrapped several times (#190).
|
|
///
|
|
/// <para><b>What this is about.</b> 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.</para>
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// The heart of the matter: two slots, one key.
|
|
///
|
|
/// <para>If they led to different keys, the whole setup would be worthless — the
|
|
/// recovery path would then open something, but not the existing values.</para>
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The case #190 is about: the machine changes.
|
|
///
|
|
/// <para>The machine slot no longer opens. Without a recovery slot
|
|
/// all credentials would be lost here.</para>
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// And afterwards day-to-day operation runs without a passphrase again: the machine slot is
|
|
/// created anew on the new machine.
|
|
///
|
|
/// <para>That presupposes that the new machine has a slot to offer at all.
|
|
/// On a system whose only way would be <c>file</c>, it does
|
|
/// <em>not</em> happen — see <see cref="Ein_Umzug_legt_den_Schluessel_nicht_im_Klartext_ab"/>.</para>
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A move does <b>not</b> store the master key in plain text just so that the
|
|
/// next start is more convenient.
|
|
///
|
|
/// <para>On a system without a secure machine slot — i.e. Linux before #180 — the
|
|
/// passphrase stays the way. Putting a <c>file</c> slot next to it would destroy exactly the
|
|
/// protection someone has just chosen.</para>
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// A wrong passphrase opens nothing — and above all does not generate a new
|
|
/// key.
|
|
/// </summary>
|
|
[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<CryptographicException>(() => wrong.Load(Pfad));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>The most important promise of all.</b> If no slot opens, it aborts —
|
|
/// rather than silently generating a new key.
|
|
///
|
|
/// <para>A new master key would make every existing <c>enc:v2</c> value
|
|
/// unreadable, and silently so: the application would keep running and report nothing but broken
|
|
/// credentials without anyone seeing the connection.</para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void If_no_slot_opens_it_aborts_instead_of_regenerating()
|
|
{
|
|
Ring(new PlainFileSecretProvider()).Load(Pfad);
|
|
var before = File.ReadAllBytes(Pfad);
|
|
|
|
Should.Throw<CryptographicException>(() => Ring(new NeverOpeningSlot()).Load(Pfad));
|
|
|
|
File.ReadAllBytes(Pfad).ShouldBe(before, "Die Datei darf dabei nicht angefasst werden.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The master key must not stand anywhere in plain text in the file — only that makes
|
|
/// it fit for backups.
|
|
/// </summary>
|
|
[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\"");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// A keyring without a recovery path works flawlessly — and fails completely on that
|
|
/// one day. That's why it is named beforehand.
|
|
/// </summary>
|
|
[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"));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Without a passphrase no recovery slot comes about — there is nothing to make it with.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// If a passphrase is available, it is used — even when later opening
|
|
/// a keyring that does not have one yet.
|
|
///
|
|
/// <para>Whoever sets <c>CLAWD_SECRET_PASSPHRASE</c> did so on purpose. Not creating the
|
|
/// slot would mean ignoring them and then reproaching them that they
|
|
/// should please store a passphrase.</para>
|
|
/// </summary>
|
|
[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 ───
|
|
|
|
/// <summary>
|
|
/// The Linux legacy: 32 raw bytes in the file. Whoever updates should notice
|
|
/// nothing — the same key, just wrapped differently.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// And after the takeover the slot format is in the file — the old raw key
|
|
/// is gone, the value the same.
|
|
/// </summary>
|
|
[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<SecretKeyRingFile>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A file of the wrong length is broken and is not replaced: that would make all
|
|
/// encrypted values unreadable without anyone finding out why.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_damaged_file_is_not_replaced_silently()
|
|
{
|
|
File.WriteAllBytes(Pfad, RandomNumberGenerator.GetBytes(17));
|
|
|
|
Should.Throw<CryptographicException>(() => Ring(new PlainFileSecretProvider()).Load(Pfad));
|
|
}
|
|
|
|
/// <summary>A machine slot that works here — the substitute for DPAPI in the test.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>A slot that never opens — the new machine, the foreign user account.</summary>
|
|
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;
|
|
}
|
|
}
|