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