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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,56 +64,83 @@ public static class SecretKeyStore
|
||||
/// <exception cref="IOException">Wenn das Verzeichnis nicht beschreibbar ist.</exception>
|
||||
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;
|
||||
|
||||
var directory = AppPaths.EnsureDirectory(Directory());
|
||||
var path = Path.Combine(directory, KeyFileName);
|
||||
|
||||
if (File.Exists(path))
|
||||
lock (Gate)
|
||||
{
|
||||
var stored = Unwrap(File.ReadAllBytes(path));
|
||||
if (stored.Length == KeySize)
|
||||
{
|
||||
_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.");
|
||||
GetOrCreateKeyUnlocked();
|
||||
return _ring?.Problems ?? [];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/// <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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user