Files
RichardandClaude Opus 5 99f8a89fd0 Schluesselbund: ein Hauptschluessel, mehrfach verpackt (#190)
SecretKeyStore.Wrap band den Hauptschluessel unter Windows per DPAPI ans
Benutzerkonto und gab ihn sonst ungewrappt zurueck - eine Zeile, die man beim
Lesen ueberspringt und die auf dem Zielsystem "im Klartext" bedeutet.

Dahinter steht jetzt ein SecretKeyRing: derselbe Hauptschluessel, mehrfach
verpackt, je Fach ein ISecretProvider (dpapi, passphrase, file). Fuer Aufrufer
aendert sich nichts - GetOrCreateKey gibt weiterhin 32 Byte zurueck. Genau dafuer
war die Naht da.

WARUM MEHRERE FAECHER UND NICHT EIN BESSERES
Ein Schluessel an der Maschine ueberlebt keinen Umzug; einer an einer Passphrase
verlangt bei jedem Start jemanden, der tippt - und Dauerbetrieb ohne Menschen ist
der Zweck (#214). Der Bund entscheidet sich nicht: Im Alltag oeffnet das
Maschinenfach ohne Rueckfrage, nach einem Umzug die Passphrase, und beide fuehren
zu DEMSELBEN Schluessel. Alle enc:v2-Werte bleiben lesbar.

Und deshalb darf die Schluesseldatei jetzt ueberhaupt in eine Sicherung: Sobald
ein Wiederherstellungsfach da ist, steht der Hauptschluessel in keiner Fassung im
Klartext darin.

Nicht zu verwechseln mit der Sicherung mit Passphrase: Die schluesselt jeden WERT
einzeln ins Archiv um, fuer den Transport einer Instanz. Das Wiederherstellungs-
fach schluesselt den HAUPTSCHLUESSEL um, damit eine bestehende Installation
weiterlaeuft - unbeaufsichtigt ueber CLAWD_SECRET_PASSPHRASE. Die Unterscheidung
steht in docs/Roadmap.md bei #190.

Beide Altformate werden uebernommen: 32 rohe Byte werden zum Fach file, ein
DPAPI-Block zum Fach dpapi. Derselbe Schluessel, nur anders verpackt.

Zwei Zusagen, mit Tests festgehalten:
- Geht KEIN Fach auf, wird abgebrochen statt ein neuer Schluessel erzeugt. Ein
  neuer Hauptschluessel machte jeden bestehenden Wert wortlos unlesbar.
- Ein Umzug legt den Schluessel NICHT im Klartext ab, nur damit der naechste Start
  bequemer wird.

Beim automatischen Weg wird nur hinzugefuegt, nie weggenommen; weggenommen wird
nur in SetRecoveryPassphrase, wo ein Mensch gerade getippt hat.

#190 bleibt offen: Unter Linux greift weiterhin das Fach file. Neu ist, dass es
beim Start als Warnung im Protokoll steht statt unsichtbar zu sein. Womit das
Maschinenfach dort gefuellt wird, ist Entscheidung #180 - diese Schnittstelle ist
die Stelle, an der die Antwort eingesetzt wird.

834 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 15:34:16 +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>
/// Der Schlüsselbund: ein Hauptschlüssel, mehrfach verpackt (#190).
///
/// <para><b>Worum es geht.</b> 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.</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 { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
private static SecretKeyRing Bund(params ISecretProvider[] anbieter) => new(anbieter);
/// <summary>
/// Der Kern der Sache: Zwei Fächer, ein Schlüssel.
///
/// <para>Führten sie zu verschiedenen Schlüsseln, wäre der ganze Aufbau wertlos — der
/// Wiederherstellungsweg öffnete dann zwar etwas, aber nicht die vorhandenen Werte.</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");
// Nur mit der Passphrase, ohne das Maschinenfach - der Umzug.
var nachUmzug = Bund(PassphraseSecretProvider.With("geheim")).Load(Pfad);
nachUmzug.ShouldBe(schluessel);
}
/// <summary>
/// Der Fall, um den es bei #190 geht: Die Maschine wechselt.
///
/// <para>Das Maschinenfach geht nicht mehr auf. Ohne Wiederherstellungsfach wären
/// hier alle Zugangsdaten verloren.</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>
/// Und danach läuft der Alltag wieder ohne Passphrase: Das Maschinenfach wird auf der
/// neuen Maschine neu angelegt.
///
/// <para>Das setzt voraus, dass die neue Maschine überhaupt ein Fach anzubieten hat.
/// Auf einem System, dessen einziger Weg <c>file</c> wäre, geschieht es
/// <em>nicht</em> — siehe <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");
// 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);
}
/// <summary>
/// Ein Umzug legt den Hauptschlüssel <b>nicht</b> im Klartext ab, nur damit der
/// nächste Start bequemer wird.
///
/// <para>Auf einem System ohne sicheres Maschinenfach — also Linux vor #180 — bleibt
/// die Passphrase der Weg. Ein <c>file</c>-Fach danebenzulegen, machte genau den
/// Schutz zunichte, für den sich jemand gerade entschieden hat.</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>
/// Eine falsche Passphrase öffnet nichts — und erzeugt vor allem keinen neuen
/// Schlüssel.
/// </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>Die wichtigste Zusage überhaupt.</b> Geht kein Fach auf, wird abgebrochen —
/// nicht stillschweigend ein neuer Schlüssel erzeugt.
///
/// <para>Ein neuer Hauptschlüssel machte jeden bestehenden <c>enc:v2</c>-Wert
/// unlesbar, und zwar wortlos: Die Anwendung liefe weiter und meldete lauter kaputte
/// Zugangsdaten, ohne dass jemand den Zusammenhang sähe.</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>
/// Der Hauptschlüssel darf in der Datei nirgends im Klartext stehen — erst das macht
/// sie sicherungsfähig.
/// </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())
{
// Mit einem Maschinenfach faellt das ungeschuetzte weg.
inhalt.ShouldNotContain("\"file\"");
}
}
/// <summary>
/// Ein Bund ohne Wiederherstellungsweg funktioniert tadellos — und versagt an dem
/// einen Tag vollständig. Deshalb wird er vorher benannt.
/// </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>
/// Ohne Passphrase entsteht kein Wiederherstellungsfach — es gibt ja nichts, womit.
/// </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>
/// Steht eine Passphrase bereit, wird sie genommen — auch beim nachträglichen Öffnen
/// eines Bundes, der noch keines hat.
///
/// <para>Wer <c>CLAWD_SECRET_PASSPHRASE</c> 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.</para>
/// </summary>
[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 ───
/// <summary>
/// Der Linux-Bestand: 32 rohe Byte in der Datei. Wer aktualisiert, soll nichts
/// merken — derselbe Schlüssel, nur anders verpackt.
/// </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>
/// Und nach der Übernahme steht das Fächerformat in der Datei — der alte Rohschlüssel
/// ist weg, der Wert derselbe.
/// </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");
// Und der Wert hat sich nicht geaendert - sonst waeren alle enc:v2-Werte hin.
Bund(PassphraseSecretProvider.With("neu")).Load(Pfad).ShouldBe(alt);
}
/// <summary>
/// Eine Datei falscher Länge ist kaputt und wird nicht ersetzt: Das machte alle
/// verschlüsselten Werte unlesbar, ohne dass jemand erführe, warum.
/// </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>Ein Maschinenfach, das hier funktioniert — der Ersatz fuer DPAPI im 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>Ein Fach, das nie aufgeht — die neue Maschine, das fremde Benutzerkonto.</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;
}
}