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:
Richard
2026-09-10 15:34:16 +02:00
co-authored by Claude Opus 5
parent e3403087c2
commit 99f8a89fd0
7 changed files with 1148 additions and 60 deletions
+35
View File
@@ -373,6 +373,41 @@ Steht vor #190, weil die Antwort das Backend bestimmt, das dort gebaut wird.
### [SEC] #190 — L1: Der Hauptschlüssel liegt unter Linux im Klartext
> **Stand 2026-09-10: Die Naht ist gebaut, die Linux-Antwort steht noch aus.**
>
> `SecretKeyStore` hält nicht mehr einen verpackten Schlüssel, sondern einen
> **Schlüsselbund** (`SecretKeyRing`): denselben Hauptschlüssel, mehrfach verpackt.
> Jedes Fach gehört einem `ISecretProvider` — `dpapi` (Windows), `passphrase`
> (Wiederherstellung), `file` (nur Dateirechte, der Zustand *dieses* Befunds, jetzt
> beim Namen genannt).
>
> **Warum mehrere Fächer und nicht ein besseres.** Ein Schlüssel an der Maschine
> überlebt keinen Umzug; einer an einer Passphrase verlangt bei jedem Start jemanden,
> der tippt — und der Dauerbetrieb ohne Menschen ist der Zweck (#214). Der Bund
> entscheidet sich nicht: Im Alltag öffnet das Maschinenfach ohne Rückfrage, nach
> einem Umzug die Passphrase, und beide führen zu **demselben** Schlüssel. Alle
> `enc:v2`-Werte bleiben lesbar, nichts muss neu eingetragen werden.
>
> **Zwei Wege, die sich ähneln und nicht dasselbe sind** — das ist die Stelle, an der
> man beim Lesen falsch abbiegt:
>
> | | Sicherung mit Passphrase (`BackupService`) | Wiederherstellungsfach (`SecretKeyRing`) |
> |---|---|---|
> | Was umgeschlüsselt wird | jeder **Wert** einzeln, in das Archiv hinein | der **Hauptschlüssel**, in der Schlüsseldatei |
> | Wofür | eine Instanz auf eine andere Maschine bringen | eine bestehende Installation weiterbenutzen, wenn das Maschinenfach nicht mehr aufgeht |
> | Wann entschieden | beim Ziehen der Sicherung | einmal, vorher |
> | Ohne Menschen | nein, die Passphrase gehört zur Sicherung | ja, über `CLAWD_SECRET_PASSPHRASE` |
>
> **Was noch fehlt, ist genau #180:** Womit das Maschinenfach unter Linux gefüllt wird.
> Bis dahin greift dort `file` — dieselbe Schutzstufe wie vorher, aber sie steht jetzt
> als Warnung im Protokoll und in `SecretKeyStore.Problems`, statt eine stille Zeile
> `: key` zu sein. Der Befund bleibt deshalb offen.
>
> Zwei Zusagen sind mit Tests festgehalten: Geht **kein** Fach auf, wird abgebrochen
> statt ein neuer Schlüssel erzeugt (der machte jeden bestehenden Wert wortlos
> unlesbar). Und ein Umzug legt den Schlüssel **nicht** im Klartext ab, nur damit der
> nächste Start bequemer wird.
`SecretKeyStore.Wrap` bindet den Schlüssel unter Windows per DPAPI ans Benutzerkonto
und gibt ihn sonst ungewrappt zurück; der einzige Schutz ist dann `0600` aus
`AppPaths.RestrictToOwner`. Auf einer dauerhaft laufenden, netzgebundenen Maschine ist
+16
View File
@@ -307,6 +307,22 @@ public sealed class AppHost : IAsyncDisposable
ResolveBackupDirectory(instance, instancePath));
}
// Der Schluesselbund, mit dem die Zugangsdaten verschluesselt sind (#190). Was
// hier fehlt, faellt im Alltag nicht auf und kostet an dem einen Tag alles: Ein
// Bund ohne Wiederherstellungsfach laeuft tadellos, bis die Maschine wechselt -
// und dann ist jeder verschluesselte Wert verloren, auch der aus einer Sicherung.
// Deshalb steht es beim Start da und nicht erst in der Fehlermeldung danach.
try
{
foreach (var problem in SecretKeyStore.Problems)
logger.LogWarning("Schlüsselbund: {Problem}", problem);
}
catch (Exception ex)
{
// Der Bund wird gleich ohnehin gebraucht; hier nur melden, nicht abbrechen.
logger.LogError(ex, "Der Schlüsselbund liess sich nicht pruefen.");
}
// ─── 4. Lizenz ───
//
// Gefuehrt ist, wer es nachweisen kann (#535). Der Schalter --managed allein
@@ -0,0 +1,92 @@
using System.Text.Json.Serialization;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Ein Fach im Schlüsselbund: derselbe Hauptschlüssel, von einem Anbieter verpackt.
///
/// <para>Was hier steht, ist ausschließlich Verpackung und Beiwerk — der Hauptschlüssel
/// selbst kommt in keiner Fassung im Klartext vor. Deshalb darf die Schlüsseldatei
/// überhaupt in eine Sicherung.</para>
/// </summary>
/// <param name="Provider">Wer dieses Fach öffnen kann (<c>dpapi</c>, <c>passphrase</c>, <c>file</c>).</param>
/// <param name="Payload">Der verpackte Hauptschlüssel, Base64.</param>
/// <param name="CreatedAt">Wann das Fach angelegt wurde — für die Anzeige, nicht für die Krypto.</param>
/// <param name="Meta">
/// Was der Anbieter zum Öffnen braucht: Salz, Rundenzahl, Verfahren. Nie das Geheimnis
/// selbst.
/// </param>
public sealed record SecretSlot(
string Provider,
string Payload,
DateTimeOffset CreatedAt,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
Dictionary<string, string>? Meta = null)
{
public string? Get(string key)
=> Meta is not null && Meta.TryGetValue(key, out var wert) ? wert : null;
}
/// <summary>
/// Ein Weg, den Hauptschlüssel zu verpacken und wieder zu öffnen.
///
/// <para><b>Warum es diese Naht gibt.</b> Bis hierher stand in
/// <see cref="SecretKeyStore"/> genau eine Zeile darüber, wie der Hauptschlüssel
/// gesichert wird: unter Windows DPAPI, sonst gar nicht. „Sonst gar nicht" heißt auf dem
/// Zielsystem — einer dauerhaft laufenden, netzgebundenen Linux-Maschine — im Klartext,
/// geschützt allein durch Dateirechte (#190).</para>
///
/// <para><b>Und warum mehrere gleichzeitig.</b> Ein einziger Weg zwingt zu einer
/// Entscheidung, die niemand gut treffen kann: Bindet man den Schlüssel an die Maschine,
/// überlebt er keinen Umzug; bindet man ihn an eine Passphrase, muss sie bei jedem Start
/// jemand eintippen — und der Dauerbetrieb ohne Menschen ist der eigentliche Zweck
/// (#214). Der Schlüsselbund umgeht die Wahl: <b>Derselbe Hauptschlüssel liegt mehrfach
/// verpackt nebeneinander.</b> Im Alltag öffnet ihn das Maschinenfach ohne Rückfrage,
/// nach einem Umzug das Wiederherstellungsfach mit der Passphrase. Beide führen zu
/// demselben Schlüssel, also bleiben alle <c>enc:v2</c>-Werte lesbar.</para>
///
/// <para><b>Was hier noch nicht entschieden ist.</b> Womit unter Linux das Maschinenfach
/// gefüllt wird — Secret Service über D-Bus oder systemd-creds mit TPM2 — ist die offene
/// Entscheidung #180. Diese Schnittstelle ist die Stelle, an der die Antwort eingesetzt
/// wird; sie nimmt sie nicht vorweg.</para>
/// </summary>
public interface ISecretProvider
{
/// <summary>Der Name, unter dem Fächer dieses Anbieters in der Datei stehen.</summary>
string Name { get; }
/// <summary>Wie das Fach dem Menschen gegenüber heißt.</summary>
string Description { get; }
/// <summary>
/// Kann dieser Anbieter hier überhaupt arbeiten?
///
/// <para>DPAPI etwa nur unter Windows. Ein nicht verfügbarer Anbieter ist kein
/// Fehler — sein Fach wird beim Öffnen übersprungen, und andere kommen zum Zug.</para>
/// </summary>
bool IsAvailable { get; }
/// <summary>
/// Überlebt ein Fach dieses Anbieters den Umzug auf eine andere Maschine?
///
/// <para>Das ist die Eigenschaft, um die es bei #190 geht. Ein Schlüsselbund, in dem
/// <b>kein</b> Fach das überlebt, ist eine Sicherung, die im Ernstfall nichts
/// nützt — und <see cref="SecretKeyRing"/> sagt das dann auch.</para>
/// </summary>
bool SurvivesMachineChange { get; }
/// <summary>
/// Verpackt den Hauptschlüssel. <c>null</c> heißt: gerade nicht möglich — etwa weil
/// die Passphrase fehlt.
/// </summary>
SecretSlot? Wrap(byte[] masterKey);
/// <summary>
/// Öffnet ein Fach. <c>null</c> heißt: nicht zuständig oder gerade nicht möglich.
///
/// <para><b>Kein Wurf bei einem falschen Kennwort.</b> Ein Fach, das sich nicht
/// öffnen lässt, ist ein Fach weniger — der Schlüsselbund probiert das nächste. Erst
/// wenn keines aufgeht, gibt es einen Fehler, und der nennt dann alle Wege.</para>
/// </summary>
byte[]? TryUnwrap(SecretSlot slot);
}
@@ -0,0 +1,353 @@
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
/// <summary>Der Inhalt von <c>secret.key</c> im Fächerformat.</summary>
public sealed class SecretKeyRingFile
{
/// <summary>3 — davor gab es kein Format, sondern rohe Bytes.</summary>
[JsonPropertyName("version")]
public int Version { get; set; } = 3;
[JsonPropertyName("slots")]
public List<SecretSlot> Slots { get; set; } = [];
}
/// <summary>
/// Der Schlüsselbund: <b>ein</b> Hauptschlüssel, mehrfach verpackt.
///
/// <para><b>Das Problem, das er löst.</b> Ein Hauptschlüssel, der an die Maschine
/// gebunden ist, überlebt keinen Umzug — eine Sicherung, die man auf einem neuen Rechner
/// zurückspielt, enthält dann lauter Werte, die niemand mehr lesen kann. Ein
/// Hauptschlüssel, der an eine Passphrase gebunden ist, überlebt den Umzug, verlangt aber
/// bei jedem Start, dass jemand tippt — und der Dauerbetrieb ohne Menschen ist der Zweck
/// der Übung (#214).</para>
///
/// <para><b>Der Ausweg ist, sich nicht zu entscheiden.</b> Wie bei einer verschlüsselten
/// Festplatte mit mehreren Schlüsselfächern liegt derselbe Hauptschlüssel mehrfach
/// verpackt nebeneinander: eines fürs Maschinenkonto, eines für die
/// Wiederherstellungs-Passphrase. Im Alltag öffnet das erste ohne Rückfrage. Nach einem
/// Umzug öffnet das zweite. Beide führen zu <em>demselben</em> Schlüssel — also bleiben
/// alle <c>enc:v2</c>-Werte lesbar, ohne dass irgendetwas neu eingetragen werden muss.
/// Danach wird das Maschinenfach auf der neuen Maschine neu angelegt, und der Alltag
/// läuft wieder ohne Passphrase.</para>
///
/// <para><b>Und deshalb darf die Datei jetzt in eine Sicherung.</b> Bisher lag sie
/// bewusst außerhalb des Instanzverzeichnisses, weil sie den Schlüssel im Klartext
/// enthielt (unter Linux) beziehungsweise nur auf einem Rechner nützte (unter Windows).
/// Im Fächerformat steht der Hauptschlüssel in keiner Fassung im Klartext darin — was in
/// eine Sicherung gerät, ist ohne Passphrase wertlos.</para>
///
/// <para><b>Was bewusst offen bleibt.</b> Womit das Maschinenfach unter Linux gefüllt
/// wird — Secret Service oder TPM2 —, ist Entscheidung #180. Bis dahin greift dort das
/// Fach <c>file</c>, also der Zustand vor diesem Umbau; <see cref="Problems"/> sagt
/// deutlich, dass das kein Schutz ist.</para>
/// </summary>
public sealed class SecretKeyRing
{
private const int KeySize = 32; // AES-256
private static readonly JsonSerializerOptions Json = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private readonly IReadOnlyList<ISecretProvider> _providers;
public SecretKeyRing(IEnumerable<ISecretProvider> providers)
=> _providers = [.. providers];
/// <summary>
/// Die vorgegebene Bestückung: Maschinenfach, Wiederherstellung, Rückfall.
///
/// <para>Die Reihenfolge ist die Reihenfolge beim Öffnen, und sie ist mit Absicht so:
/// erst das Fach, das ohne Rückfrage aufgeht, dann das mit der Passphrase, zuletzt
/// das ungeschützte. Wer eine Passphrase hinterlegt hat, wird sie im Alltag nicht
/// gefragt.</para>
/// </summary>
public static SecretKeyRing Default => new(
[
new DpapiSecretProvider(),
PassphraseSecretProvider.FromEnvironment(),
new PlainFileSecretProvider()
]);
/// <summary>
/// Was an diesem Schlüsselbund nicht in Ordnung ist — leer heißt: alles gut.
///
/// <para>Zum Anzeigen gedacht, nicht zum Abbrechen. Ein Schlüsselbund ohne
/// Wiederherstellungsfach funktioniert tadellos; er versagt erst an dem Tag, an dem
/// die Maschine wechselt, und dann vollständig. Genau solche Fehler gehören
/// vorher gesagt.</para>
/// </summary>
public IReadOnlyList<string> Problems { get; private set; } = [];
/// <summary>Welche Fächer der Bund führt — für die Anzeige in den Einstellungen.</summary>
public IReadOnlyList<SecretSlot> Slots { get; private set; } = [];
/// <summary>
/// Öffnet den Schlüsselbund oder legt ihn an.
///
/// <para><b>Wenn Fächer da sind, aber keines aufgeht, wird abgebrochen</b> — nicht
/// etwa ein neuer Schlüssel erzeugt. 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>
public byte[] Load(string path)
{
if (!File.Exists(path))
return Create(path);
var datei = Read(path);
Slots = datei.Slots;
foreach (var fach in datei.Slots)
{
var anbieter = _providers.FirstOrDefault(
p => p.Name == fach.Provider && p.IsAvailable);
if (anbieter?.TryUnwrap(fach) is not { } schluessel) continue;
if (schluessel.Length != KeySize)
{
throw new CryptographicException(
$"Das Fach '{fach.Provider}' in {path} enthält {schluessel.Length} statt "
+ $"{KeySize} Byte. Es darf nicht ersetzt werden, ohne die verschlüsselten "
+ "Werte neu zu setzen.");
}
// Das Maschinenfach kann fehlen, weil die Maschine gewechselt hat und nur
// die Passphrase aufging. Dann wird es hier neu angelegt - der Alltag laeuft
// danach wieder ohne Rueckfrage.
EnsureSlots(path, datei, schluessel);
Bewerten(datei);
return schluessel;
}
throw new CryptographicException(
$"Keines der {datei.Slots.Count} Fächer in {path} ließ sich öffnen "
+ $"(vorhanden: {string.Join(", ", datei.Slots.Select(s => s.Provider))}). "
+ "Stammt die Datei von einer anderen Maschine, hilft die "
+ $"Wiederherstellungs-Passphrase über {PassphraseSecretProvider.EnvironmentVariable}. "
+ "Die Datei darf nicht gelöscht werden, solange verschlüsselte Werte in den "
+ "Einstellungen stehen — sie wären danach verloren.");
}
/// <summary>
/// Legt ein Wiederherstellungsfach an oder ersetzt es.
///
/// <para>Der Vorgang, der aus einer Installation eine umzugsfähige macht. Er braucht
/// den geöffneten Hauptschlüssel, weil er ihn ein weiteres Mal verpackt — er erzeugt
/// keinen neuen.</para>
/// </summary>
public void SetRecoveryPassphrase(string path, byte[] masterKey, string passphrase)
{
if (string.IsNullOrWhiteSpace(passphrase))
throw new ArgumentException("Eine leere Passphrase schützt nichts.", nameof(passphrase));
var datei = File.Exists(path) ? Read(path) : new SecretKeyRingFile();
var neu = PassphraseSecretProvider.With(passphrase).Wrap(masterKey)
?? throw new CryptographicException("Das Wiederherstellungsfach ließ sich nicht anlegen.");
datei.Slots.RemoveAll(s => s.Provider == "passphrase");
datei.Slots.Add(neu);
// Mit einer Passphrase im Bund ist das ungeschuetzte Fach nicht mehr noetig -
// und ein Fach, das den Schluessel im Klartext fuehrt, gehoert weg, sobald es
// einen besseren Weg gibt.
if (datei.Slots.Any(s => s.Provider is "dpapi"))
datei.Slots.RemoveAll(s => s.Provider == "file");
Write(path, datei);
Slots = datei.Slots;
Bewerten(datei);
}
private byte[] Create(string path)
{
var schluessel = RandomNumberGenerator.GetBytes(KeySize);
var datei = new SecretKeyRingFile();
Fuellen(datei, schluessel);
Write(path, datei);
Slots = datei.Slots;
Bewerten(datei);
return schluessel;
}
/// <summary>
/// Ergänzt fehlende Fächer auf dieser Maschine.
///
/// <para><b>Hier wird nur hinzugefügt, nie weggenommen.</b> Der Unterschied ist
/// wichtig genug für einen eigenen Absatz: Dies ist der automatische Weg, der bei
/// jedem Start läuft. Ein Fach hier zu entfernen, weil gerade eine Umgebungsvariable
/// gesetzt ist, wäre eine unumkehrbare Änderung, die niemand verlangt hat — und wenn
/// die Passphrase danach abhandenkommt, ist alles weg. Weggenommen wird nur in
/// <see cref="SetRecoveryPassphrase"/>, wo ein Mensch sie gerade eingetippt hat.</para>
///
/// <para>Ergänzt wird auch das Wiederherstellungsfach, wenn eine Passphrase
/// bereitsteht: Wer <c>CLAWD_SECRET_PASSPHRASE</c> setzt, hat das mit Absicht getan.
/// Es nicht anzulegen hieße, ihn zu ignorieren und ihm anschließend vorzuhalten, er
/// möge doch eine Passphrase hinterlegen.</para>
/// </summary>
private void EnsureSlots(string path, SecretKeyRingFile datei, byte[] masterKey)
{
var namen = datei.Slots.Select(s => s.Provider).ToHashSet(StringComparer.Ordinal);
var neue = new List<SecretSlot>();
foreach (var anbieter in _providers)
{
if (!anbieter.IsAvailable || namen.Contains(anbieter.Name)) continue;
// Das ungeschuetzte Fach nur, wenn sonst gar keines da waere. Es nachtraeglich
// dazuzulegen, wo schon ein geschuetztes liegt, hiesse den Schluessel im
// Klartext danebenzulegen und die anderen Faecher damit zu entwerten.
if (anbieter.Name == "file" && datei.Slots.Count > 0) continue;
if (anbieter.Wrap(masterKey) is { } fach) neue.Add(fach);
}
if (neue.Count == 0) return;
datei.Slots.AddRange(neue);
Write(path, datei);
}
private void Fuellen(SecretKeyRingFile datei, byte[] masterKey)
{
foreach (var anbieter in _providers)
{
if (!anbieter.IsAvailable) continue;
// Das ungeschuetzte Fach nur, wenn sonst gar keines zustande kaeme - sonst
// laege der Schluessel im Klartext daneben und machte die anderen wertlos.
if (anbieter.Name == "file" && datei.Slots.Count > 0) continue;
if (anbieter.Wrap(masterKey) is { } fach) datei.Slots.Add(fach);
}
if (datei.Slots.Count == 0)
{
throw new CryptographicException(
"Es ließ sich kein einziges Schlüsselfach anlegen — der Hauptschlüssel "
+ "könnte nicht gespeichert werden.");
}
}
private void Bewerten(SecretKeyRingFile datei)
{
var probleme = new List<string>();
var umzugsfaehig = datei.Slots.Any(s => s.Provider == "passphrase");
var ungeschuetzt = datei.Slots.Any(s => s.Provider == "file");
if (!umzugsfaehig)
{
probleme.Add(
"Es gibt kein Wiederherstellungsfach. Wechselt die Maschine oder das "
+ "Benutzerkonto, sind alle verschlüsselten Zugangsdaten verloren — auch die "
+ "aus einer Sicherung. Abhilfe: eine Wiederherstellungs-Passphrase festlegen.");
}
if (ungeschuetzt)
{
probleme.Add(
"Der Hauptschlüssel liegt in der Schlüsseldatei ungeschützt (Fach 'file'); "
+ "es schützen ihn allein die Dateirechte. Wer die Datei lesen kann, kann alle "
+ "Zugangsdaten lesen (#190).");
}
Problems = probleme;
}
// ─── Datei ───
private static SecretKeyRingFile Read(string path)
{
var roh = File.ReadAllBytes(path);
if (Uebernehmen(roh) is { } uebernommen) return uebernommen;
try
{
return JsonSerializer.Deserialize<SecretKeyRingFile>(roh, Json)
?? throw new CryptographicException(
$"Die Schlüsseldatei {path} ist leer.");
}
catch (JsonException ex)
{
throw new CryptographicException(
$"Die Schlüsseldatei {path} ist beschädigt. Sie darf nicht ersetzt werden, "
+ "ohne die verschlüsselten Werte neu zu setzen.", ex);
}
}
/// <summary>
/// Übernimmt die beiden Altformate: rohe 32 Byte (Linux) oder ein DPAPI-Block
/// (Windows).
///
/// <para><b>Ohne Zutun und ohne Verlust.</b> Wer aktualisiert, soll nichts merken —
/// der bestehende Hauptschlüssel wird weiterverwendet, nur anders verpackt. Ein
/// Umbau, der die Zugangsdaten aller bestehenden Installationen entwertet, wäre
/// schlimmer als das Problem, das er löst.</para>
///
/// <para><c>null</c> heißt: kein Altformat, also das Fächerformat.</para>
/// </summary>
private static SecretKeyRingFile? Uebernehmen(byte[] roh)
{
// JSON faengt mit '{' an - alles andere ist alt. Ein roher Schluessel kann
// theoretisch mit 0x7B beginnen; deshalb wird unten trotzdem geprueft, ob sich
// das Ganze als JSON lesen laesst.
if (roh.Length > 0 && roh[0] == (byte)'{')
{
try
{
if (JsonSerializer.Deserialize<SecretKeyRingFile>(roh, Json) is { Slots.Count: > 0 })
return null;
}
catch (JsonException) { /* dann eben doch ein alter Schluessel */ }
}
if (roh.Length == KeySize)
{
// Der Linux-Fall: 32 rohe Byte. Das wird zum Fach 'file' - dieselbe
// Schutzstufe wie bisher, aber jetzt benannt und in Problems gemeldet.
return new SecretKeyRingFile
{
Slots =
[
new SecretSlot("file", Convert.ToBase64String(roh), DateTimeOffset.UtcNow)
]
};
}
if (OperatingSystem.IsWindows())
{
// Der Windows-Fall: ein DPAPI-Block. Ob er zu diesem Konto gehoert, zeigt
// erst der Versuch - misslingt er, ist es kein uebernehmbares Altformat.
var probe = new DpapiSecretProvider();
var fach = new SecretSlot("dpapi", Convert.ToBase64String(roh), DateTimeOffset.UtcNow);
if (probe.TryUnwrap(fach) is { Length: KeySize })
return new SecretKeyRingFile { Slots = [fach] };
}
return null;
}
private static void Write(string path, SecretKeyRingFile datei)
{
// Ueber AtomicFile, damit kein halb geschriebener Bund entsteht - der wuerde alle
// Geheimnisse dieser Installation unlesbar machen.
AtomicFile.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(datei, Json));
AppPaths.RestrictToOwner(path);
}
}
+96 -57
View File
@@ -1,4 +1,3 @@
using System.Security.Cryptography;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Security;
@@ -7,29 +6,28 @@ namespace ClawdDotNet.Core.Security;
/// Verwaltet den lokalen Schlüssel, mit dem <see cref="SecretProtector"/> die
/// <c>enc:v2</c>-Werte sichert.
///
/// <para><b>Ein Schlüssel je Benutzer und Rechner.</b> Er liegt als
/// <c>secret.key</c> in <see cref="AppPaths.ConfigDirectory"/> — bewusst außerhalb
/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
/// nützt auf einem anderen Rechner nichts.</para>
/// <para><b>Ein Schlüssel, mehrere Fächer.</b> Er liegt als <c>secret.key</c> in
/// <see cref="AppPaths.ConfigDirectory"/> und ist dort nicht mehr ein nackter Wert,
/// sondern ein <see cref="SecretKeyRing"/>: derselbe Hauptschlüssel, mehrfach verpackt —
/// einmal fürs Maschinenkonto, einmal für die Wiederherstellungs-Passphrase. Warum das
/// so ist und was es löst, steht bei <see cref="SecretKeyRing"/>.</para>
///
/// <para><b>Rechte.</b> Unter Unix <c>0600</c>. Unter Windows erbt die Datei die
/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
/// plattformübergreifend ist.</para>
/// <para><b>Was sich für Aufrufer ändert: nichts.</b>
/// <see cref="GetOrCreateKey"/> gibt weiterhin 32 Byte zurück. Der Umbau aus #190 sitzt
/// vollständig hinter dieser Naht — genau dafür war sie da.</para>
///
/// <para><b>Rechte.</b> Unter Unix <c>0600</c>. Unter Windows erbt die Datei die Rechte
/// des Benutzerprofils; die Bindung ans Benutzerkonto leistet dort das
/// DPAPI-Fach.</para>
/// </summary>
public static class SecretKeyStore
{
private const string KeyFileName = "secret.key";
private const int KeySize = 32; // AES-256
/// <summary>DPAPI-Zusatzkontext für die Schlüsseldatei (nur Windows).</summary>
private static readonly byte[] KeyEntropy =
System.Text.Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
private static readonly Lock Gate = new();
private static byte[]? _cached;
private static string? _overrideDirectory;
private static SecretKeyRing? _ring;
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
@@ -43,6 +41,20 @@ public static class SecretKeyStore
{
_overrideDirectory = directory;
_cached = null;
_ring = null;
}
}
/// <summary>
/// Setzt den Schlüsselbund — für Tests, die eine bestimmte Bestückung brauchen.
/// <c>null</c> stellt die Vorgabe wieder her.
/// </summary>
public static void UseRing(SecretKeyRing? ring)
{
lock (Gate)
{
_ring = ring;
_cached = null;
}
}
@@ -51,57 +63,84 @@ public static class SecretKeyStore
/// </summary>
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
public static byte[] GetOrCreateKey()
{
lock (Gate) return GetOrCreateKeyUnlocked();
}
/// <summary>
/// Was am Schlüsselbund nicht in Ordnung ist — leer heißt: alles gut.
///
/// <para>Zum Anzeigen in den Einstellungen und zum Protokollieren beim Start. Der
/// wichtigste Eintrag ist der fehlende Wiederherstellungsweg: Er stört im Alltag
/// nicht und kostet beim Maschinenwechsel alles.</para>
/// </summary>
public static IReadOnlyList<string> Problems
{
get
{
lock (Gate)
{
GetOrCreateKeyUnlocked();
return _ring?.Problems ?? [];
}
}
}
/// <summary>Die vorhandenen Fächer — für die Anzeige.</summary>
public static IReadOnlyList<SecretSlot> Slots
{
get
{
lock (Gate)
{
GetOrCreateKeyUnlocked();
return _ring?.Slots ?? [];
}
}
}
/// <summary>
/// Legt eine Wiederherstellungs-Passphrase fest oder ersetzt sie.
///
/// <para><b>Der Vorgang, der aus einer Installation eine umzugsfähige macht.</b> Er
/// erzeugt keinen neuen Hauptschlüssel — er verpackt den vorhandenen ein weiteres
/// Mal. Alle bestehenden <c>enc:v2</c>-Werte bleiben also gültig, und eine Sicherung,
/// die ab jetzt gezogen wird, lässt sich auf einer anderen Maschine öffnen.</para>
/// </summary>
public static void SetRecoveryPassphrase(string passphrase)
{
lock (Gate)
{
var schluessel = GetOrCreateKeyUnlocked();
var ring = _ring ??= SecretKeyRing.Default;
ring.SetRecoveryPassphrase(KeyFilePath, schluessel, passphrase);
}
}
/// <summary>Hat der Schlüsselbund einen Weg, der einen Maschinenwechsel überlebt?</summary>
public static bool HasRecoveryPath
{
get
{
lock (Gate)
{
GetOrCreateKeyUnlocked();
return _ring?.Slots.Any(s => s.Provider == "passphrase") ?? false;
}
}
}
private static byte[] GetOrCreateKeyUnlocked()
{
if (_cached is not null) return _cached;
var directory = AppPaths.EnsureDirectory(Directory());
var path = Path.Combine(directory, KeyFileName);
var ring = _ring ??= SecretKeyRing.Default;
if (File.Exists(path))
{
var stored = Unwrap(File.ReadAllBytes(path));
if (stored.Length == KeySize)
{
_cached = stored;
_cached = ring.Load(Path.Combine(directory, KeyFileName));
return _cached;
}
// Eine Datei falscher Länge ist kaputt. Sie stillschweigend zu ersetzen
// würde alle bestehenden Werte unlesbar machen, ohne dass jemand erfährt,
// warum — deshalb hier abbrechen und den Pfad nennen.
throw new CryptographicException(
$"Die Schlüsseldatei {path} ist beschädigt ({stored.Length} statt {KeySize} Byte). "
+ "Sie darf nicht ersetzt werden, ohne die verschlüsselten Werte neu zu setzen.");
}
var key = RandomNumberGenerator.GetBytes(KeySize);
// Über AtomicFile, damit kein halb geschriebener Schlüssel entsteht — der
// würde alle Geheimnisse dieser Installation unlesbar machen.
AtomicFile.WriteAllBytes(path, Wrap(key));
AppPaths.RestrictToOwner(path);
_cached = key;
return _cached;
}
}
private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
/// <summary>Unter Windows zusätzlich per DPAPI an das Benutzerkonto binden.</summary>
private static byte[] Wrap(byte[] key)
=> OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key;
private static byte[] Unwrap(byte[] stored)
=> OperatingSystem.IsWindows() ? UnprotectWithDpapi(stored) : stored;
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static byte[] ProtectWithDpapi(byte[] key)
=> ProtectedData.Protect(key, KeyEntropy, DataProtectionScope.CurrentUser);
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static byte[] UnprotectWithDpapi(byte[] stored)
=> ProtectedData.Unprotect(stored, KeyEntropy, DataProtectionScope.CurrentUser);
}
@@ -0,0 +1,227 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace ClawdDotNet.Core.Security;
/// <summary>
/// Das Maschinenfach unter Windows: DPAPI im Benutzerkontext.
///
/// <para>Öffnet sich ohne Rückfrage, solange derselbe Benutzer auf derselben Maschine
/// arbeitet — und gar nicht mehr, sobald eines von beidem wechselt. Genau deshalb braucht
/// ein Schlüsselbund, der einen Umzug überstehen soll, ein zweites Fach.</para>
/// </summary>
public sealed class DpapiSecretProvider : ISecretProvider
{
public string Name => "dpapi";
public string Description => "Windows-Benutzerkonto (DPAPI)";
public bool IsAvailable => OperatingSystem.IsWindows();
public bool SurvivesMachineChange => false;
private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("ClawdDotNet.SecretKey.v2");
public SecretSlot? Wrap(byte[] masterKey)
{
if (!OperatingSystem.IsWindows()) return null;
return new SecretSlot(
Name, Convert.ToBase64String(Protect(masterKey)), DateTimeOffset.UtcNow);
}
public byte[]? TryUnwrap(SecretSlot slot)
{
if (!OperatingSystem.IsWindows() || slot.Provider != Name) return null;
try { return Unprotect(Convert.FromBase64String(slot.Payload)); }
catch (Exception ex) when (ex is CryptographicException or FormatException)
{
// Anderer Benutzer, andere Maschine, oder das Profil wurde neu angelegt.
// Kein Fehler, den dieser Anbieter zu melden haette - ein Fach weniger.
return null;
}
}
[SupportedOSPlatform("windows")]
private static byte[] Protect(byte[] key)
=> ProtectedData.Protect(key, Entropy, DataProtectionScope.CurrentUser);
[SupportedOSPlatform("windows")]
private static byte[] Unprotect(byte[] stored)
=> ProtectedData.Unprotect(stored, Entropy, DataProtectionScope.CurrentUser);
}
/// <summary>
/// Das Wiederherstellungsfach: eine Passphrase, die der Mensch kennt.
///
/// <para><b>Das einzige Fach, das einen Maschinenwechsel überlebt.</b> Es hängt an nichts
/// als der Passphrase — nicht am Benutzerkonto, nicht am Rechner, nicht am
/// Betriebssystem. Wer eine Sicherung auf einer neuen Maschine zurückspielt und die
/// Passphrase kennt, kommt an alle Zugangsdaten; wer sie nicht kennt, an keine.</para>
///
/// <para><b>Deshalb ist es kein Ersatz fürs Maschinenfach, sondern dessen Ergänzung.</b>
/// Eine Passphrase bei jedem Start einzutippen verträgt sich nicht mit einem Dienst, der
/// durchläuft (#214). Im Alltag öffnet das Maschinenfach; dieses hier liegt daneben und
/// wird gebraucht, wenn das andere nicht mehr aufgeht.</para>
///
/// <para><b>PBKDF2 mit 600.000 Runden.</b> Die Passphrase kommt von einem Menschen und
/// hat entsprechend wenig Entropie; die Rundenzahl ist das, was zwischen ihr und einem
/// Wörterbuchangriff steht. Sie steht im Fach, damit ein später erhöhter Wert alte
/// Fächer nicht unlesbar macht.</para>
/// </summary>
/// <param name="passphraseSource">
/// Woher die Passphrase kommt. <c>null</c> heißt: gerade keine da — dann ruht dieses
/// Fach, statt zu scheitern.
/// </param>
public sealed class PassphraseSecretProvider(Func<string?> passphraseSource) : ISecretProvider
{
/// <summary>
/// Für den unbeaufsichtigten Fall: Wiederherstellung ohne jemanden, der tippt.
///
/// <para>Eine Passphrase in einer Umgebungsvariablen ist schwächer als eine
/// eingetippte — sie steht in der Prozessumgebung und womöglich in einer
/// Dienstdatei. Für den Umzug einer Maschine ist sie trotzdem der richtige Weg: Die
/// Alternative ist, dass niemand herankommt.</para>
/// </summary>
public const string EnvironmentVariable = "CLAWD_SECRET_PASSPHRASE";
/// <summary>Liest die Passphrase aus der Umgebung — der vorgegebene Weg.</summary>
public static PassphraseSecretProvider FromEnvironment()
=> new(() => Environment.GetEnvironmentVariable(EnvironmentVariable));
/// <summary>Eine feste Passphrase, etwa aus einem Dialog.</summary>
public static PassphraseSecretProvider With(string passphrase)
=> new(() => passphrase);
private const int Iterations = 600_000;
private const int SaltSize = 16;
private const int NonceSize = 12;
private const int TagSize = 16;
private static readonly byte[] AssociatedData =
Encoding.UTF8.GetBytes("ClawdDotNet.SecretKeyRing.passphrase.v1");
public string Name => "passphrase";
public string Description => "Wiederherstellungs-Passphrase";
public bool IsAvailable => !string.IsNullOrEmpty(passphraseSource());
public bool SurvivesMachineChange => true;
public SecretSlot? Wrap(byte[] masterKey)
{
var passphrase = passphraseSource();
if (string.IsNullOrEmpty(passphrase)) return null;
var salt = RandomNumberGenerator.GetBytes(SaltSize);
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
var cipher = new byte[masterKey.Length];
var tag = new byte[TagSize];
using (var aes = new AesGcm(Derive(passphrase, salt, Iterations), TagSize))
aes.Encrypt(nonce, masterKey, cipher, tag, AssociatedData);
// nonce ‖ tag ‖ cipher - feste Laengen vorn, damit das Zerlegen eindeutig ist.
var payload = new byte[NonceSize + TagSize + cipher.Length];
nonce.CopyTo(payload, 0);
tag.CopyTo(payload, NonceSize);
cipher.CopyTo(payload, NonceSize + TagSize);
return new SecretSlot(
Name, Convert.ToBase64String(payload), DateTimeOffset.UtcNow,
new Dictionary<string, string>
{
["kdf"] = "pbkdf2-sha256",
["iterations"] = Iterations.ToString(),
["salt"] = Convert.ToBase64String(salt)
});
}
public byte[]? TryUnwrap(SecretSlot slot)
{
if (slot.Provider != Name) return null;
var passphrase = passphraseSource();
if (string.IsNullOrEmpty(passphrase)) return null;
if (slot.Get("salt") is not { } saltText
|| !int.TryParse(slot.Get("iterations"), out var runden)
|| runden <= 0)
{
return null;
}
try
{
var raw = Convert.FromBase64String(slot.Payload);
if (raw.Length < NonceSize + TagSize) return null;
var nonce = raw.AsSpan(0, NonceSize);
var tag = raw.AsSpan(NonceSize, TagSize);
var cipher = raw.AsSpan(NonceSize + TagSize);
var plain = new byte[cipher.Length];
using (var aes = new AesGcm(
Derive(passphrase, Convert.FromBase64String(saltText), runden), TagSize))
{
aes.Decrypt(nonce, cipher, tag, plain, AssociatedData);
}
return plain;
}
catch (Exception ex) when (ex is CryptographicException or FormatException)
{
// Falsche Passphrase. Ein Fach weniger, kein Fehler - der Schluesselbund
// probiert das naechste und meldet erst, wenn keines aufgeht.
return null;
}
}
private static byte[] Derive(string passphrase, byte[] salt, int iterations)
=> Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(passphrase), salt, iterations, HashAlgorithmName.SHA256, 32);
}
/// <summary>
/// Der Hauptschlüssel als Datei, geschützt allein durch Dateirechte.
///
/// <para><b>Das ist der Zustand aus #190, und er heißt hier so, wie er ist.</b> Vorher
/// stand er als <c>OperatingSystem.IsWindows() ? ProtectWithDpapi(key) : key</c> in einer
/// Zeile — ein stiller Sonderfall, den man beim Lesen überspringt. Als eigener Anbieter
/// mit dem Namen <c>file</c> ist er das, was er ist: kein Schutz gegen jemanden, der als
/// dieser Benutzer läuft, und keiner gegen ein Backup, das die Datei mitnimmt.</para>
///
/// <para><b>Er bleibt trotzdem</b>, weil er als Rückfalloption gebraucht wird: Ohne ihn
/// käme auf einer Linux-Maschine ohne Passphrase kein Schlüsselbund zustande, und die
/// Anwendung startete nicht mehr. Der Schlüsselbund warnt statt zu scheitern — und die
/// Warnung nennt den Ausweg.</para>
/// </summary>
public sealed class PlainFileSecretProvider : ISecretProvider
{
public string Name => "file";
public string Description => "Nur Dateirechte (kein zusätzlicher Schutz)";
public bool IsAvailable => true;
/// <summary>
/// Wahr, und das ist keine Empfehlung: Die Datei mitzunehmen bedeutet, den
/// Hauptschlüssel mitzunehmen. Der Schlüsselbund zählt dieses Fach deshalb nicht als
/// Wiederherstellungsweg.
/// </summary>
public bool SurvivesMachineChange => true;
public SecretSlot? Wrap(byte[] masterKey)
=> new(Name, Convert.ToBase64String(masterKey), DateTimeOffset.UtcNow);
public byte[]? TryUnwrap(SecretSlot slot)
{
if (slot.Provider != Name) return null;
try { return Convert.FromBase64String(slot.Payload); }
catch (FormatException) { return null; }
}
}
@@ -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;
}
}