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:
@@ -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
|
### [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
|
`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
|
und gibt ihn sonst ungewrappt zurück; der einzige Schutz ist dann `0600` aus
|
||||||
`AppPaths.RestrictToOwner`. Auf einer dauerhaft laufenden, netzgebundenen Maschine ist
|
`AppPaths.RestrictToOwner`. Auf einer dauerhaft laufenden, netzgebundenen Maschine ist
|
||||||
|
|||||||
@@ -307,6 +307,22 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
ResolveBackupDirectory(instance, instancePath));
|
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 ───
|
// ─── 4. Lizenz ───
|
||||||
//
|
//
|
||||||
// Gefuehrt ist, wer es nachweisen kann (#535). Der Schalter --managed allein
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using ClawdDotNet.Core.Storage;
|
using ClawdDotNet.Core.Storage;
|
||||||
|
|
||||||
namespace ClawdDotNet.Core.Security;
|
namespace ClawdDotNet.Core.Security;
|
||||||
@@ -7,29 +6,28 @@ namespace ClawdDotNet.Core.Security;
|
|||||||
/// Verwaltet den lokalen Schlüssel, mit dem <see cref="SecretProtector"/> die
|
/// Verwaltet den lokalen Schlüssel, mit dem <see cref="SecretProtector"/> die
|
||||||
/// <c>enc:v2</c>-Werte sichert.
|
/// <c>enc:v2</c>-Werte sichert.
|
||||||
///
|
///
|
||||||
/// <para><b>Ein Schlüssel je Benutzer und Rechner.</b> Er liegt als
|
/// <para><b>Ein Schlüssel, mehrere Fächer.</b> Er liegt als <c>secret.key</c> in
|
||||||
/// <c>secret.key</c> in <see cref="AppPaths.ConfigDirectory"/> — bewusst außerhalb
|
/// <see cref="AppPaths.ConfigDirectory"/> und ist dort nicht mehr ein nackter Wert,
|
||||||
/// des Instanzverzeichnisses, damit eine Sicherung der Instanz ihn nicht mitnimmt.
|
/// sondern ein <see cref="SecretKeyRing"/>: derselbe Hauptschlüssel, mehrfach verpackt —
|
||||||
/// Das hält die Schutzstufe, die DPAPI zuvor bot: Die Konfigurationsdatei allein
|
/// einmal fürs Maschinenkonto, einmal für die Wiederherstellungs-Passphrase. Warum das
|
||||||
/// nützt auf einem anderen Rechner nichts.</para>
|
/// 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
|
/// <para><b>Was sich für Aufrufer ändert: nichts.</b>
|
||||||
/// Rechte des Benutzerprofils und wird zusätzlich per DPAPI gesichert — dort bleibt
|
/// <see cref="GetOrCreateKey"/> gibt weiterhin 32 Byte zurück. Der Umbau aus #190 sitzt
|
||||||
/// die Bindung an das Benutzerkonto also erhalten, obwohl das Format
|
/// vollständig hinter dieser Naht — genau dafür war sie da.</para>
|
||||||
/// plattformübergreifend ist.</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>
|
/// </summary>
|
||||||
public static class SecretKeyStore
|
public static class SecretKeyStore
|
||||||
{
|
{
|
||||||
private const string KeyFileName = "secret.key";
|
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 readonly Lock Gate = new();
|
||||||
private static byte[]? _cached;
|
private static byte[]? _cached;
|
||||||
private static string? _overrideDirectory;
|
private static string? _overrideDirectory;
|
||||||
|
private static SecretKeyRing? _ring;
|
||||||
|
|
||||||
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
|
public static string KeyFilePath => Path.Combine(Directory(), KeyFileName);
|
||||||
|
|
||||||
@@ -43,6 +41,20 @@ public static class SecretKeyStore
|
|||||||
{
|
{
|
||||||
_overrideDirectory = directory;
|
_overrideDirectory = directory;
|
||||||
_cached = null;
|
_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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,56 +64,83 @@ public static class SecretKeyStore
|
|||||||
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
|
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
|
||||||
public static byte[] GetOrCreateKey()
|
public static byte[] GetOrCreateKey()
|
||||||
{
|
{
|
||||||
lock (Gate)
|
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
|
||||||
{
|
{
|
||||||
if (_cached is not null) return _cached;
|
lock (Gate)
|
||||||
|
|
||||||
var directory = AppPaths.EnsureDirectory(Directory());
|
|
||||||
var path = Path.Combine(directory, KeyFileName);
|
|
||||||
|
|
||||||
if (File.Exists(path))
|
|
||||||
{
|
{
|
||||||
var stored = Unwrap(File.ReadAllBytes(path));
|
GetOrCreateKeyUnlocked();
|
||||||
if (stored.Length == KeySize)
|
return _ring?.Problems ?? [];
|
||||||
{
|
|
||||||
_cached = stored;
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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 ring = _ring ??= SecretKeyRing.Default;
|
||||||
|
|
||||||
|
_cached = ring.Load(Path.Combine(directory, KeyFileName));
|
||||||
|
return _cached;
|
||||||
|
}
|
||||||
|
|
||||||
private static string Directory() => _overrideDirectory ?? AppPaths.ConfigDirectory;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user