fix(setup): Dateirechte bei Geheimnissen, plattformabhaengige Zielnamen

Zwei Beobachtungen aus der laufenden Integration.

1. Der Installer schrieb Geheimnisse weltlesbar
   SetupWriter benutzte File.WriteAllText ohne Rechteanpassung - unter Linux
   also die Standardmaske und damit ueblicherweise 644. In genau dieser Datei
   stehen Lizenzschluessel und Anwendungstoken; jeder Benutzer des Systems
   konnte sie lesen. Der Lizenz-Cache in StateStore wird aus demselben Grund
   seit jeher auf 600 gesetzt - der Installer zog nicht nach.
   Enthaelt ein Ziel mindestens einen geheimen Wert, wird die Datei jetzt auf
   den eigenen Benutzer beschraenkt. Als geheim gilt type=secret UND
   source=provision: ein so geholtes Token traegt oft den Typ "string", ist
   aber genauso schutzbeduerftig. Unter Windows bleibt es beim Profil-ACL.

2. Zielnamen koennen plattformabhaengig unterschiedlich sein
   %APPDATA%\MeineAnwendung gegen $XDG_CONFIG_HOME/meineanwendung - eine
   setup.json kannte nur eine Schreibweise. Die kleingeschriebene Form allein
   traegt, weil NTFS die Schreibweise ignoriert, aber nur solange das
   Dateisystem tatsaechlich unempfindlich ist; auf APFS mit Beachtung der
   Schreibweise oder bei groesseren Unterschieden entstuende ein zweites,
   leeres Verzeichnis neben dem, aus dem die Anwendung liest.
   Ziele haben deshalb optional fileWindows, fileLinux und fileMacOS; ohne
   Angabe gilt weiterhin file.

Die Dokumentation haelt ausserdem fest, dass der Installer bewusst Klartext
schreibt und die Anwendung selbst entscheidet, ob und wie sie ihn danach
schuetzt - und dass eine Entschluesselung, die Klartext durchreicht, deshalb
kein Altlast-Zweig mehr ist, sondern ein aktiv genutzter Pfad.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-13 21:37:01 +02:00
co-authored by Claude Opus 5
parent 7a3a5dad69
commit 56b1d2631a
6 changed files with 183 additions and 7 deletions
@@ -51,6 +51,34 @@ namespace Deploymentcenter.Client.Models
[JsonPropertyName("file")]
public string File { get; set; } = string.Empty;
/// <summary>
/// Abweichender Pfad unter Windows.
///
/// Konfigurationsordner heissen plattformabhaengig unterschiedlich:
/// unter Windows ist "%APPDATA%\MeineAnwendung" ueblich, unter Linux
/// "$XDG_CONFIG_HOME/meineanwendung" klein. Meist genuegt es, die
/// kleingeschriebene Form anzugeben - NTFS unterscheidet nicht nach
/// Gross- und Kleinschreibung, umgekehrt entstuende unter Linux ein
/// zweites, leeres Verzeichnis neben dem, aus dem die Anwendung liest.
///
/// Das traegt aber nur, solange das Dateisystem tatsaechlich
/// unempfindlich ist - auf APFS mit Beachtung der Schreibweise oder
/// einem per fsutil umgestellten NTFS-Ordner nicht mehr. Und es hilft
/// gar nicht, wenn sich die Namen um mehr als die Schreibweise
/// unterscheiden. Fuer diese Faelle sind die folgenden Felder da; ohne
/// Angabe gilt <see cref="File"/>.
/// </summary>
[JsonPropertyName("fileWindows")]
public string FileWindows { get; set; } = string.Empty;
/// <summary>Abweichender Pfad unter Linux.</summary>
[JsonPropertyName("fileLinux")]
public string FileLinux { get; set; } = string.Empty;
/// <summary>Abweichender Pfad unter macOS.</summary>
[JsonPropertyName("fileMacOS")]
public string FileMacOS { get; set; } = string.Empty;
/// <summary>
/// Wo die Datei liegt:
///
@@ -24,7 +24,7 @@ namespace Deploymentcenter.Client
/// <param name="installDir">Installationsverzeichnis der Anwendung.</param>
public static string Resolve(SetupTarget target, string installDir)
{
string file = Expand(target?.File ?? string.Empty);
string file = Expand(FileFor(target));
// Ein absoluter Pfad ist bereits die Antwort - etwa nachdem
// ${XDG_CONFIG_HOME} ersetzt wurde.
@@ -37,6 +37,28 @@ namespace Deploymentcenter.Client
return Path.GetFullPath(Path.Combine(root, file.Replace('/', Path.DirectorySeparatorChar)));
}
/// <summary>
/// Waehlt den Pfad fuer die laufende Plattform.
///
/// Konfigurationsordner heissen plattformabhaengig unterschiedlich -
/// "ClawdDotNet" unter Windows, "clawddotnet" unter Linux. Die
/// kleingeschriebene Form allein traegt nur, solange das Dateisystem
/// die Schreibweise ignoriert; auf einem case-sensitiven Volume
/// entstuende sonst ein zweites, leeres Verzeichnis neben dem, aus dem
/// die Anwendung liest.
/// </summary>
public static string FileFor(SetupTarget? target)
{
if (target == null)
return string.Empty;
string specific = OperatingSystemHelpers.IsWindows() ? target.FileWindows
: OperatingSystemHelpers.IsMacOS() ? target.FileMacOS
: target.FileLinux;
return string.IsNullOrWhiteSpace(specific) ? (target.File ?? string.Empty) : specific;
}
/// <summary>
/// Wurzelverzeichnis fuer eine Ortsangabe.
///
@@ -12,11 +12,14 @@ namespace Deploymentcenter.Client
/// <summary>Ein zu schreibender Konfigurationswert.</summary>
public sealed class SetupValue
{
public SetupValue(string key, string value, string type = "string")
private readonly bool? _isSecret;
public SetupValue(string key, string value, string type = "string", bool? isSecret = null)
{
Key = key;
Value = value;
Type = type;
_isSecret = isSecret;
}
/// <summary>Schluessel, Ebenen durch Doppelpunkt getrennt.</summary>
@@ -26,6 +29,15 @@ namespace Deploymentcenter.Client
/// <summary>string | secret | url | int | bool | enum</summary>
public string Type { get; }
/// <summary>
/// Ist der Wert schutzbeduerftig? Ohne ausdrueckliche Angabe wird der
/// Typ herangezogen. Ein per <c>source: "provision"</c> geholtes Token
/// traegt haeufig den Typ "string" und muss deshalb gesondert
/// gekennzeichnet werden.
/// </summary>
public bool IsSecret =>
_isSecret ?? string.Equals(Type, "secret", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
@@ -137,13 +149,68 @@ namespace Deploymentcenter.Client
/// <summary>Schreibt in das Format, das zum Ziel passt.</summary>
public static void Write(string path, string format, IEnumerable<SetupValue> values)
{
var list = values as IList<SetupValue> ?? new List<SetupValue>(values);
if (string.Equals(format, "env", StringComparison.OrdinalIgnoreCase))
{
WriteEnv(path, values);
return;
WriteEnv(path, list);
}
else
{
WriteJson(path, list);
}
WriteJson(path, values);
// Enthaelt die Datei Geheimnisse, gehoert sie niemandem sonst.
foreach (var value in list)
{
if (value.IsSecret)
{
RestrictPermissions(path);
break;
}
}
}
/// <summary>
/// Beschraenkt die Datei auf den eigenen Benutzer.
///
/// Ohne das schreibt .NET mit der Standardmaske - unter Linux
/// ueblicherweise 644, also weltlesbar. In dieser Datei stehen der
/// Lizenzschluessel und das Token der Anwendung; jeder Benutzer des
/// Systems koennte sie lesen. Der Lizenz-Cache in
/// <see cref="StateStore"/> wird aus demselben Grund auf 600 gesetzt.
///
/// Unter Windows erben Dateien in %APPDATA% die Zugriffsrechte des
/// Profils; dort ist nichts zu tun.
/// </summary>
public static void RestrictPermissions(string path)
{
if (OperatingSystemHelpers.IsWindows())
return;
#if NET8_0_OR_GREATER
try
{
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch
{
// Auf Dateisystemen ohne Unix-Rechte schlicht nicht anwendbar.
}
#else
try
{
var proc = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "chmod",
Arguments = $"600 \"{path}\"",
UseShellExecute = false,
CreateNoWindow = true
});
proc?.WaitForExit();
}
catch { }
#endif
}
/// <summary>
@@ -300,7 +300,11 @@ namespace Deploymentcenter.UpdateAgent
{
var forTarget = resolved
.Where(pair => BelongsToTarget(pair.Field, target, targets))
.Select(pair => new SetupValue(pair.Field.Key, pair.Value, pair.Field.Type))
// IsSecret ausdruecklich weiterreichen: ein per provision
// geholtes Token traegt oft den Typ "string", muss die
// Datei aber trotzdem auf den eigenen Benutzer beschraenken.
.Select(pair => new SetupValue(
pair.Field.Key, pair.Value, pair.Field.Type, pair.Field.IsSecret))
.ToList();
if (forTarget.Count == 0)
+48
View File
@@ -219,6 +219,54 @@ Platzhalter fällt auf, ein stillschweigend falscher Pfad nicht.
> deshalb den **aufgelösten Pfad** aus — prüfe ihn. Für Dienste ist ein
> absoluter Pfad oft die ehrlichere Angabe.
#### Wenn die Schreibweise plattformabhängig ist
Konfigurationsordner heißen nicht überall gleich: `%APPDATA%\MeineAnwendung`
ist unter Windows üblich, `$XDG_CONFIG_HOME/meineanwendung` unter Linux klein.
Meistens genügt es, die **kleingeschriebene Form** anzugeben. Sie trifft unter
Linux exakt und unter Windows ebenfalls, weil NTFS die Schreibweise ignoriert.
Andersherum funktioniert es nicht — unter Linux entstünde ein zweites, leeres
Verzeichnis neben dem, aus dem die Anwendung liest.
Dieser Kniff trägt aber nur, solange das Dateisystem wirklich unempfindlich
ist. Auf APFS mit Beachtung der Schreibweise, auf einem per `fsutil`
umgestellten NTFS-Ordner oder wenn sich die Namen um **mehr als die
Schreibweise** unterscheiden, braucht es eine eigene Angabe:
```json
{
"id": "user",
"file": "meineanwendung/Settings.json",
"fileWindows": "MeineAnwendung/Settings.json",
"location": "config"
}
```
`fileLinux` und `fileMacOS` gibt es ebenso. Ohne Angabe gilt `file`.
### Dateirechte
Enthält ein Ziel mindestens einen als geheim gekennzeichneten Wert, wird die
geschriebene Datei auf den eigenen Benutzer beschränkt (`600` unter Linux und
macOS). Ohne das schriebe .NET mit der Standardmaske, unter Linux üblicherweise
`644` — und in dieser Datei stehen Lizenzschlüssel und Anwendungstoken.
Als geheim gilt jedes Feld mit `type: "secret"` **und** jedes mit
`source: "provision"` — ein so geholtes Token trägt oft den Typ `string`, ist
aber genauso schutzbedürftig.
Unter Windows geschieht nichts: Dateien in `%APPDATA%` erben die
Zugriffsrechte des Profils.
> **Der Installer verschlüsselt nicht.** Er schreibt Klartext; ob und wie
> Geheimnisse anschließend geschützt werden, entscheidet die Anwendung — etwa
> über DPAPI beim ersten Speichern. Das setzt voraus, dass eure
> Entschlüsselung Klartext unverändert durchreicht, statt an ihm zu scheitern.
> Wer eine solche Weiche hat, sollte sie im Test als **aktiv genutzt**
> beschreiben und nicht als Altlast — sonst wird sie eines Tages als tot
> entfernt und sperrt jede frisch installierte Instanz aus.
`type` bestimmt auch, **wie** geschrieben wird: `int` und `bool` landen als
JSON-Zahl beziehungsweise -Wahrheitswert, nicht als Zeichenkette. Sonst
scheitert die Bindung in der Anwendung.
+8 -1
View File
@@ -6,9 +6,16 @@
"description": "Fuer den Betrieb werden eine Datenbank und ein Zugang zum Kursdatenanbieter benoetigt.",
"_targets_comment": "Wohin geschrieben wird. 'location' bestimmt den Bezugspunkt: install (Vorgabe, Installationsverzeichnis), config (%APPDATA% bzw. $XDG_CONFIG_HOME), data, home. In 'file' sind ${VAR} und %VAR% erlaubt, ein absoluter Pfad wird unveraendert benutzt. format ist 'json' oder 'env'. Ueber 'id' lassen sich einzelne Felder gezielt einer Datei zuordnen; ohne Angabe schreibt ein Feld in das erste Ziel.",
"_platform_comment": "Heisst der Ordner je nach Plattform anders, hilft meist die kleingeschriebene Form allein: sie trifft unter Linux exakt und unter Windows auch, weil NTFS die Schreibweise ignoriert. Auf case-sensitiven Dateisystemen oder bei groesseren Unterschieden gibt es fileWindows, fileLinux und fileMacOS.",
"targets": [
{ "id": "app", "file": "appsettings.json", "location": "install", "format": "json" },
{ "id": "user", "file": "beispielanwendung/Settings.json", "location": "config", "format": "json" },
{
"id": "user",
"file": "beispielanwendung/Settings.json",
"fileWindows": "Beispielanwendung/Settings.json",
"location": "config",
"format": "json"
},
{ "id": "env", "file": ".env", "location": "install", "format": "env" }
],