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>
327 lines
13 KiB
C#
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;
|
|
}
|
|
}
|