Files
ClawdDotNet/tests/ClawdDotNet.Core.Tests/Security/SecretKeyRingTests.cs
T
RichardandClaude Opus 5.5 837a634a22 Stufe 4c (#764): Kommentare in C#, WebUI-Skripten, CSS und HTML auf Englisch
Rund 2.600 Kommentarblöcke in 310 C#-Dateien übersetzt, Begründungen vollständig
erhalten: blockweise gleiche Anzahl wie vorher, kein Block unter 60 % seiner
Länge, der Code außerhalb der Kommentare per Tokenizer byte-identisch geprüft.
Zitate deutscher Oberflächentexte und Testdaten bleiben deutsch.

WebUI: Kommentare in JS/CSS/HTML übersetzt, JSDoc-@param an die in 4b
umbenannten Parameter angeglichen.

Dabei behoben:
- 4b hatte "tag" (HTML-Tag) als deutsches "Tag" gelesen und zu "dayValue"
  gemacht (makeElement, svg, Tagesübersicht) - zurück auf tag/day.
- Aufgaben "InProgress" bekamen die Plakettenart "laufend", für die es nie eine
  CSS-Klasse gab - jetzt "success" wie laufende Instanzen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:45:03 +02:00

327 lines
13 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 Bund(params ISecretProvider[] anbieter) => new(anbieter);
/// <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 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);
}
/// <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 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);
}
/// <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 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);
}
/// <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 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");
}
/// <summary>
/// A wrong passphrase opens nothing — and above all does not generate a new
/// key.
/// </summary>
[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<CryptographicException>(() => falsch.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 Wenn_kein_Fach_aufgeht_wird_abgebrochen_statt_neu_erzeugt()
{
Bund(new PlainFileSecretProvider()).Load(Pfad);
var vorher = File.ReadAllBytes(Pfad);
Should.Throw<CryptographicException>(() => Bund(new NieOeffnendesFach()).Load(Pfad));
File.ReadAllBytes(Pfad).ShouldBe(vorher, "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 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\"");
}
}
/// <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 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"));
}
/// <summary>
/// Without a passphrase no recovery slot comes about — there is nothing to make it with.
/// </summary>
[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");
}
/// <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 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 ───
/// <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 Ein_roher_Altschluessel_wird_uebernommen()
{
var alt = RandomNumberGenerator.GetBytes(32);
File.WriteAllBytes(Pfad, alt);
Bund(new PlainFileSecretProvider()).Load(Pfad).ShouldBe(alt);
}
/// <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 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<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.
Bund(PassphraseSecretProvider.With("neu")).Load(Pfad).ShouldBe(alt);
}
/// <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 Eine_beschaedigte_Datei_wird_nicht_stillschweigend_ersetzt()
{
File.WriteAllBytes(Pfad, RandomNumberGenerator.GetBytes(17));
Should.Throw<CryptographicException>(() => Bund(new PlainFileSecretProvider()).Load(Pfad));
}
/// <summary>A machine slot that works here — the substitute for DPAPI in the test.</summary>
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;
}
/// <summary>A slot that never opens — the new machine, the foreign user account.</summary>
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;
}
}