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>
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user