Quellcode durchsuchen

Sync files delete

sriram vor 2 Wochen
Ursprung
Commit
6c2ee11ccc
25 geänderte Dateien mit 1080 neuen und 0 gelöschten Zeilen
  1. 6
    0
      Installer/Installer.slnx
  2. 70
    0
      Installer/samples/manifest.example.json
  3. 56
    0
      Installer/src/Flo.Installer/ArtifactDownloader.cs
  4. 107
    0
      Installer/src/Flo.Installer/Commands/InstallCommand.cs
  5. 91
    0
      Installer/src/Flo.Installer/Commands/ProvisionCommand.cs
  6. 69
    0
      Installer/src/Flo.Installer/Commands/RunCommand.cs
  7. 13
    0
      Installer/src/Flo.Installer/Ed25519Verifier.cs
  8. 14
    0
      Installer/src/Flo.Installer/Flo.Installer.csproj
  9. 24
    0
      Installer/src/Flo.Installer/Manifest.cs
  10. 25
    0
      Installer/src/Flo.Installer/ManifestClient.cs
  11. 59
    0
      Installer/src/Flo.Installer/Program.cs
  12. 22
    0
      Installer/src/Flo.Installer/Provisioning/FerretDbProvisioner.cs
  13. 22
    0
      Installer/src/Flo.Installer/Provisioning/FloConfig.cs
  14. 34
    0
      Installer/src/Flo.Installer/Provisioning/HealthCheck.cs
  15. 23
    0
      Installer/src/Flo.Installer/Provisioning/PasswordGenerator.cs
  16. 26
    0
      Installer/src/Flo.Installer/Provisioning/Paths.cs
  17. 16
    0
      Installer/src/Flo.Installer/Provisioning/PortScanner.cs
  18. 164
    0
      Installer/src/Flo.Installer/Provisioning/PostgresProvisioner.cs
  19. 63
    0
      Installer/src/Flo.Installer/Provisioning/ProcessRunner.cs
  20. 26
    0
      Installer/src/Flo.Installer/Provisioning/SecretsStore.cs
  21. 13
    0
      Installer/src/Flo.Installer/TrustedPublicKey.cs
  22. 14
    0
      Installer/src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj
  23. 123
    0
      Installer/src/Flo.Manifest.Tool/Program.cs
  24. BIN
      docs/VAT findings.pdf
  25. BIN
      docs/VAT_Remediation_Plan.docx

+ 6
- 0
Installer/Installer.slnx Datei anzeigen

@@ -0,0 +1,6 @@
1
+<Solution>
2
+  <Folder Name="/src/">
3
+    <Project Path="src/Flo.Installer/Flo.Installer.csproj" />
4
+    <Project Path="src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj" />
5
+  </Folder>
6
+</Solution>

+ 70
- 0
Installer/samples/manifest.example.json Datei anzeigen

@@ -0,0 +1,70 @@
1
+{
2
+  "schemaVersion": 1,
3
+  "product": "Flo",
4
+  "version": "1.0.0",
5
+  "channel": "stable",
6
+  "releasedAt": "2026-04-20T00:00:00Z",
7
+  "minBootstrapperVersion": "1.0.0",
8
+  "artifacts": [
9
+    {
10
+      "id": "postgres",
11
+      "version": "16.4",
12
+      "platforms": {
13
+        "win-x64": {
14
+          "url": "https://updates.flo.example/stable/artifacts/postgres-16.4-win-x64.zip",
15
+          "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
16
+          "sizeBytes": 157286400,
17
+          "archive": "zip",
18
+          "installPath": "postgres"
19
+        },
20
+        "linux-x64": {
21
+          "url": "https://updates.flo.example/stable/artifacts/postgres-16.4-linux-x64.tar.gz",
22
+          "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
23
+          "sizeBytes": 142606336,
24
+          "archive": "tar.gz",
25
+          "installPath": "postgres"
26
+        }
27
+      }
28
+    },
29
+    {
30
+      "id": "ferretdb",
31
+      "version": "1.24.0",
32
+      "platforms": {
33
+        "win-x64": {
34
+          "url": "https://updates.flo.example/stable/artifacts/ferretdb-1.24.0-win-x64.zip",
35
+          "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
36
+          "sizeBytes": 31457280,
37
+          "archive": "zip",
38
+          "installPath": "ferretdb"
39
+        },
40
+        "linux-x64": {
41
+          "url": "https://updates.flo.example/stable/artifacts/ferretdb-1.24.0-linux-x64.tar.gz",
42
+          "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
43
+          "sizeBytes": 28311552,
44
+          "archive": "tar.gz",
45
+          "installPath": "ferretdb"
46
+        }
47
+      }
48
+    },
49
+    {
50
+      "id": "flo-service",
51
+      "version": "1.0.0",
52
+      "platforms": {
53
+        "win-x64": {
54
+          "url": "https://updates.flo.example/stable/artifacts/flo-service-1.0.0-win-x64.zip",
55
+          "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
56
+          "sizeBytes": 52428800,
57
+          "archive": "zip",
58
+          "installPath": "service"
59
+        },
60
+        "linux-x64": {
61
+          "url": "https://updates.flo.example/stable/artifacts/flo-service-1.0.0-linux-x64.tar.gz",
62
+          "sha256": "0000000000000000000000000000000000000000000000000000000000000000",
63
+          "sizeBytes": 50331648,
64
+          "archive": "tar.gz",
65
+          "installPath": "service"
66
+        }
67
+      }
68
+    }
69
+  ]
70
+}

+ 56
- 0
Installer/src/Flo.Installer/ArtifactDownloader.cs Datei anzeigen

@@ -0,0 +1,56 @@
1
+using System.Security.Cryptography;
2
+
3
+namespace Flo.Installer;
4
+
5
+internal sealed class ArtifactDownloader(HttpClient http)
6
+{
7
+    public async Task DownloadAsync(
8
+        Uri url,
9
+        string destinationPath,
10
+        string expectedSha256,
11
+        long expectedSize,
12
+        IProgress<DownloadProgress>? progress,
13
+        CancellationToken ct)
14
+    {
15
+        Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
16
+
17
+        using var response = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct);
18
+        response.EnsureSuccessStatusCode();
19
+
20
+        await using var source = await response.Content.ReadAsStreamAsync(ct);
21
+        await using var destination = File.Create(destinationPath);
22
+        using var sha = SHA256.Create();
23
+
24
+        var buffer = new byte[81920];
25
+        long totalRead = 0;
26
+        int read;
27
+        while ((read = await source.ReadAsync(buffer, ct)) > 0)
28
+        {
29
+            await destination.WriteAsync(buffer.AsMemory(0, read), ct);
30
+            sha.TransformBlock(buffer, 0, read, null, 0);
31
+            totalRead += read;
32
+            progress?.Report(new DownloadProgress(totalRead, expectedSize));
33
+        }
34
+        sha.TransformFinalBlock([], 0, 0);
35
+
36
+        string actualHex = Convert.ToHexString(sha.Hash!).ToLowerInvariant();
37
+        if (!string.Equals(actualHex, expectedSha256, StringComparison.OrdinalIgnoreCase))
38
+        {
39
+            File.Delete(destinationPath);
40
+            throw new InvalidOperationException(
41
+                $"SHA-256 mismatch for {url}. expected={expectedSha256} actual={actualHex}");
42
+        }
43
+
44
+        if (totalRead != expectedSize)
45
+        {
46
+            File.Delete(destinationPath);
47
+            throw new InvalidOperationException(
48
+                $"Size mismatch for {url}. expected={expectedSize} actual={totalRead}");
49
+        }
50
+    }
51
+}
52
+
53
+internal readonly record struct DownloadProgress(long BytesRead, long TotalBytes)
54
+{
55
+    public double PercentComplete => TotalBytes > 0 ? (double)BytesRead / TotalBytes * 100.0 : 0.0;
56
+}

+ 107
- 0
Installer/src/Flo.Installer/Commands/InstallCommand.cs Datei anzeigen

@@ -0,0 +1,107 @@
1
+using System.IO.Compression;
2
+using System.Runtime.InteropServices;
3
+
4
+namespace Flo.Installer.Commands;
5
+
6
+internal static class InstallCommand
7
+{
8
+    public static async Task<int> ExecuteAsync(string manifestUrl, string installPath, CancellationToken ct = default)
9
+    {
10
+        if (TrustedPublicKey.IsPlaceholder)
11
+        {
12
+            Console.Error.WriteLine(
13
+                "Trusted public key is unset. Bake the release public key into TrustedPublicKey.Bytes before running against a real manifest.");
14
+            return 2;
15
+        }
16
+
17
+        string rid = DetectRid();
18
+        Console.WriteLine($"Platform:     {rid}");
19
+        Console.WriteLine($"Manifest:     {manifestUrl}");
20
+        Console.WriteLine($"Install path: {installPath}");
21
+        Console.WriteLine();
22
+
23
+        using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
24
+        var manifestClient = new ManifestClient(http);
25
+        var downloader = new ArtifactDownloader(http);
26
+
27
+        Manifest manifest;
28
+        try
29
+        {
30
+            manifest = await manifestClient.DownloadAndVerifyAsync(new Uri(manifestUrl), TrustedPublicKey.Bytes, ct);
31
+        }
32
+        catch (Exception ex)
33
+        {
34
+            Console.Error.WriteLine($"Manifest load failed: {ex.Message}");
35
+            return 3;
36
+        }
37
+
38
+        Console.WriteLine($"Verified manifest: {manifest.Product} {manifest.Version} ({manifest.Channel})");
39
+        Console.WriteLine();
40
+
41
+        Directory.CreateDirectory(installPath);
42
+        string cacheDir = Path.Combine(installPath, ".cache");
43
+        Directory.CreateDirectory(cacheDir);
44
+
45
+        foreach (var artifact in manifest.Artifacts)
46
+        {
47
+            if (!artifact.Platforms.TryGetValue(rid, out var platform))
48
+            {
49
+                Console.WriteLine($"[skip] {artifact.Id}: no entry for {rid}.");
50
+                continue;
51
+            }
52
+
53
+            string cacheFile = Path.Combine(cacheDir, $"{artifact.Id}-{artifact.Version}-{rid}.bin");
54
+            var progress = new Progress<DownloadProgress>(p =>
55
+                Console.Write($"\r[get ] {artifact.Id} {artifact.Version}  {p.PercentComplete,6:0.0}%  "));
56
+
57
+            await downloader.DownloadAsync(
58
+                new Uri(platform.Url), cacheFile, platform.Sha256, platform.SizeBytes, progress, ct);
59
+            Console.WriteLine();
60
+
61
+            string target = Path.Combine(installPath, platform.InstallPath);
62
+            Console.WriteLine($"[inst] {artifact.Id} -> {target}");
63
+            Install(cacheFile, target, platform.Archive);
64
+        }
65
+
66
+        Console.WriteLine();
67
+        Console.WriteLine("Install complete.");
68
+        return 0;
69
+    }
70
+
71
+    private static void Install(string source, string target, string archive)
72
+    {
73
+        switch (archive)
74
+        {
75
+            case "zip":
76
+                Directory.CreateDirectory(target);
77
+                ZipFile.ExtractToDirectory(source, target, overwriteFiles: true);
78
+                break;
79
+            case "tar.gz":
80
+                Directory.CreateDirectory(target);
81
+                using (var fs = File.OpenRead(source))
82
+                using (var gz = new GZipStream(fs, CompressionMode.Decompress))
83
+                    System.Formats.Tar.TarFile.ExtractToDirectory(gz, target, overwriteFiles: true);
84
+                break;
85
+            case "none":
86
+                Directory.CreateDirectory(Path.GetDirectoryName(target)!);
87
+                File.Copy(source, target, overwrite: true);
88
+                break;
89
+            default:
90
+                throw new InvalidOperationException($"Unknown archive type: {archive}");
91
+        }
92
+    }
93
+
94
+    private static string DetectRid()
95
+    {
96
+        string os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win"
97
+                  : RuntimeInformation.IsOSPlatform(OSPlatform.Linux)   ? "linux"
98
+                  : throw new PlatformNotSupportedException("Only Windows and Linux are supported.");
99
+        string arch = RuntimeInformation.ProcessArchitecture switch
100
+        {
101
+            Architecture.X64   => "x64",
102
+            Architecture.Arm64 => "arm64",
103
+            var a => throw new PlatformNotSupportedException($"Unsupported architecture: {a}.")
104
+        };
105
+        return $"{os}-{arch}";
106
+    }
107
+}

+ 91
- 0
Installer/src/Flo.Installer/Commands/ProvisionCommand.cs Datei anzeigen

@@ -0,0 +1,91 @@
1
+using Flo.Installer.Provisioning;
2
+
3
+namespace Flo.Installer.Commands;
4
+
5
+internal static class ProvisionCommand
6
+{
7
+    private const string DefaultSuperuser = "postgres";
8
+    private const string DefaultRole      = "flo_app";
9
+    private const string DefaultDatabase  = "flo";
10
+
11
+    public static async Task<int> ExecuteAsync(string installPath, CancellationToken ct = default)
12
+    {
13
+        var paths = new Paths(installPath);
14
+        Directory.CreateDirectory(paths.Config);
15
+        Directory.CreateDirectory(paths.Logs);
16
+
17
+        if (!File.Exists(paths.PostgresBin("initdb")))
18
+            throw new InvalidOperationException($"Postgres binaries not found at {paths.Postgres}. Run `install` first.");
19
+        if (!File.Exists(paths.FerretDbExe()))
20
+            throw new InvalidOperationException($"FerretDB binary not found at {paths.FerretDbExe()}. Run `install` first.");
21
+
22
+        var secretsStore = new SecretsStore(paths.SecretsFile);
23
+
24
+        FloConfig config;
25
+        Dictionary<string, string> secrets;
26
+
27
+        if (File.Exists(paths.ConfigFile) && secretsStore.Exists())
28
+        {
29
+            Console.WriteLine("Existing flo.json + secrets.json found — reusing ports and passwords.");
30
+            config = FloConfig.Load(paths.ConfigFile);
31
+            secrets = new Dictionary<string, string>(secretsStore.Load());
32
+        }
33
+        else
34
+        {
35
+            int pgPort = PortScanner.FindFreeLoopbackPort();
36
+            int fdPort = PortScanner.FindFreeLoopbackPort();
37
+            while (fdPort == pgPort) fdPort = PortScanner.FindFreeLoopbackPort();
38
+
39
+            config = new FloConfig(
40
+                PostgresPort:      pgPort,
41
+                FerretDbPort:      fdPort,
42
+                PostgresSuperuser: DefaultSuperuser,
43
+                ApplicationDbRole: DefaultRole,
44
+                ApplicationDbName: DefaultDatabase);
45
+
46
+            secrets = new Dictionary<string, string>
47
+            {
48
+                [$"pg.{config.PostgresSuperuser}"] = PasswordGenerator.Generate(),
49
+                [$"pg.{config.ApplicationDbRole}"] = PasswordGenerator.Generate(),
50
+            };
51
+
52
+            config.Save(paths.ConfigFile);
53
+            secretsStore.Save(secrets);
54
+            Console.WriteLine($"Allocated Postgres port {pgPort}, FerretDB port {fdPort}.");
55
+        }
56
+
57
+        var postgres = new PostgresProvisioner(paths);
58
+
59
+        if (!postgres.IsInitialized())
60
+        {
61
+            Console.WriteLine("Running initdb...");
62
+            postgres.Initialize(config.PostgresSuperuser, secrets[$"pg.{config.PostgresSuperuser}"]);
63
+            Console.WriteLine("initdb complete.");
64
+        }
65
+        else
66
+        {
67
+            Console.WriteLine("Postgres data dir already initialized; skipping initdb.");
68
+        }
69
+
70
+        postgres.ConfigurePort(config.PostgresPort);
71
+
72
+        Console.WriteLine("Starting Postgres temporarily to create role + database...");
73
+        await using (var pg = postgres.Start(config.PostgresPort))
74
+        {
75
+            await HealthCheck.WaitForPortAsync(
76
+                "127.0.0.1", config.PostgresPort, TimeSpan.FromSeconds(30), "Postgres", ct);
77
+
78
+            postgres.EnsureRoleAndDatabase(
79
+                config.PostgresPort,
80
+                config.PostgresSuperuser, secrets[$"pg.{config.PostgresSuperuser}"],
81
+                config.ApplicationDbRole, secrets[$"pg.{config.ApplicationDbRole}"],
82
+                config.ApplicationDbName);
83
+            Console.WriteLine($"Ensured role '{config.ApplicationDbRole}' and database '{config.ApplicationDbName}'.");
84
+
85
+            postgres.StopGracefully();
86
+        }
87
+
88
+        Console.WriteLine("Provisioning complete.");
89
+        return 0;
90
+    }
91
+}

+ 69
- 0
Installer/src/Flo.Installer/Commands/RunCommand.cs Datei anzeigen

@@ -0,0 +1,69 @@
1
+using System.Runtime.InteropServices;
2
+using Flo.Installer.Provisioning;
3
+
4
+namespace Flo.Installer.Commands;
5
+
6
+internal static class RunCommand
7
+{
8
+    public static async Task<int> ExecuteAsync(string installPath)
9
+    {
10
+        var paths = new Paths(installPath);
11
+        if (!File.Exists(paths.ConfigFile) || !File.Exists(paths.SecretsFile))
12
+        {
13
+            Console.Error.WriteLine("flo.json or secrets.json missing. Run `provision` first.");
14
+            return 1;
15
+        }
16
+
17
+        var config = FloConfig.Load(paths.ConfigFile);
18
+        var secrets = new SecretsStore(paths.SecretsFile).Load();
19
+
20
+        using var cts = new CancellationTokenSource();
21
+        Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
22
+        using var _sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, ctx =>
23
+        {
24
+            ctx.Cancel = true;
25
+            cts.Cancel();
26
+        });
27
+
28
+        var postgres = new PostgresProvisioner(paths);
29
+        var ferret = new FerretDbProvisioner(paths);
30
+
31
+        Console.WriteLine($"Starting Postgres on 127.0.0.1:{config.PostgresPort}...");
32
+        await using var pg = postgres.Start(config.PostgresPort);
33
+        await HealthCheck.WaitForPortAsync(
34
+            "127.0.0.1", config.PostgresPort, TimeSpan.FromSeconds(30), "Postgres", cts.Token);
35
+        Console.WriteLine("Postgres healthy.");
36
+
37
+        Console.WriteLine($"Starting FerretDB on 127.0.0.1:{config.FerretDbPort}...");
38
+        await using var fd = ferret.Start(
39
+            listenPort:   config.FerretDbPort,
40
+            postgresPort: config.PostgresPort,
41
+            pgUser:       config.ApplicationDbRole,
42
+            pgPassword:   secrets[$"pg.{config.ApplicationDbRole}"],
43
+            pgDatabase:   config.ApplicationDbName);
44
+        await HealthCheck.WaitForPortAsync(
45
+            "127.0.0.1", config.FerretDbPort, TimeSpan.FromSeconds(30), "FerretDB", cts.Token);
46
+        Console.WriteLine("FerretDB healthy.");
47
+
48
+        Console.WriteLine();
49
+        Console.WriteLine($"Postgres:  host=127.0.0.1 port={config.PostgresPort} user={config.ApplicationDbRole} db={config.ApplicationDbName}");
50
+        Console.WriteLine($"Mongo URL: mongodb://{config.ApplicationDbRole}:<password>@127.0.0.1:{config.FerretDbPort}/{config.ApplicationDbName}");
51
+        Console.WriteLine("(Password is in secrets.json.)");
52
+        Console.WriteLine();
53
+        Console.WriteLine("Press Ctrl-C to stop.");
54
+
55
+        try { await Task.Delay(Timeout.Infinite, cts.Token); }
56
+        catch (OperationCanceledException) { }
57
+
58
+        Console.WriteLine();
59
+        Console.WriteLine("Shutdown requested. Stopping FerretDB then Postgres...");
60
+
61
+        try { await fd.StopAsync(TimeSpan.FromSeconds(10)); }
62
+        catch (Exception ex) { Console.Error.WriteLine($"FerretDB stop error: {ex.Message}"); }
63
+
64
+        try { postgres.StopGracefully(); }
65
+        catch (Exception ex) { Console.Error.WriteLine($"Postgres graceful stop error: {ex.Message}"); }
66
+
67
+        return 0;
68
+    }
69
+}

+ 13
- 0
Installer/src/Flo.Installer/Ed25519Verifier.cs Datei anzeigen

@@ -0,0 +1,13 @@
1
+using NSec.Cryptography;
2
+
3
+namespace Flo.Installer;
4
+
5
+internal static class Ed25519Verifier
6
+{
7
+    public static bool Verify(ReadOnlySpan<byte> publicKey, ReadOnlySpan<byte> data, ReadOnlySpan<byte> signature)
8
+    {
9
+        var algorithm = SignatureAlgorithm.Ed25519;
10
+        var key = NSec.Cryptography.PublicKey.Import(algorithm, publicKey, KeyBlobFormat.RawPublicKey);
11
+        return algorithm.Verify(key, data, signature);
12
+    }
13
+}

+ 14
- 0
Installer/src/Flo.Installer/Flo.Installer.csproj Datei anzeigen

@@ -0,0 +1,14 @@
1
+<Project Sdk="Microsoft.NET.Sdk">
2
+
3
+  <PropertyGroup>
4
+    <OutputType>Exe</OutputType>
5
+    <TargetFramework>net10.0</TargetFramework>
6
+    <ImplicitUsings>enable</ImplicitUsings>
7
+    <Nullable>enable</Nullable>
8
+  </PropertyGroup>
9
+
10
+  <ItemGroup>
11
+    <PackageReference Include="NSec.Cryptography" Version="25.4.0" />
12
+  </ItemGroup>
13
+
14
+</Project>

+ 24
- 0
Installer/src/Flo.Installer/Manifest.cs Datei anzeigen

@@ -0,0 +1,24 @@
1
+using System.Text.Json.Serialization;
2
+
3
+namespace Flo.Installer;
4
+
5
+public sealed record Manifest(
6
+    [property: JsonPropertyName("schemaVersion")] int SchemaVersion,
7
+    [property: JsonPropertyName("product")] string Product,
8
+    [property: JsonPropertyName("version")] string Version,
9
+    [property: JsonPropertyName("channel")] string Channel,
10
+    [property: JsonPropertyName("releasedAt")] DateTimeOffset ReleasedAt,
11
+    [property: JsonPropertyName("minBootstrapperVersion")] string MinBootstrapperVersion,
12
+    [property: JsonPropertyName("artifacts")] IReadOnlyList<Artifact> Artifacts);
13
+
14
+public sealed record Artifact(
15
+    [property: JsonPropertyName("id")] string Id,
16
+    [property: JsonPropertyName("version")] string Version,
17
+    [property: JsonPropertyName("platforms")] IReadOnlyDictionary<string, PlatformArtifact> Platforms);
18
+
19
+public sealed record PlatformArtifact(
20
+    [property: JsonPropertyName("url")] string Url,
21
+    [property: JsonPropertyName("sha256")] string Sha256,
22
+    [property: JsonPropertyName("sizeBytes")] long SizeBytes,
23
+    [property: JsonPropertyName("archive")] string Archive,
24
+    [property: JsonPropertyName("installPath")] string InstallPath);

+ 25
- 0
Installer/src/Flo.Installer/ManifestClient.cs Datei anzeigen

@@ -0,0 +1,25 @@
1
+using System.Text;
2
+using System.Text.Json;
3
+
4
+namespace Flo.Installer;
5
+
6
+internal sealed class ManifestClient(HttpClient http)
7
+{
8
+    public async Task<Manifest> DownloadAndVerifyAsync(
9
+        Uri manifestUrl,
10
+        ReadOnlyMemory<byte> trustedPublicKey,
11
+        CancellationToken ct)
12
+    {
13
+        var sigUrl = new Uri(manifestUrl + ".sig");
14
+
15
+        byte[] manifestBytes = await http.GetByteArrayAsync(manifestUrl, ct);
16
+        byte[] sigText = await http.GetByteArrayAsync(sigUrl, ct);
17
+        byte[] signature = Convert.FromBase64String(Encoding.UTF8.GetString(sigText).Trim());
18
+
19
+        if (!Ed25519Verifier.Verify(trustedPublicKey.Span, manifestBytes, signature))
20
+            throw new InvalidOperationException("Manifest signature verification failed.");
21
+
22
+        return JsonSerializer.Deserialize<Manifest>(manifestBytes)
23
+            ?? throw new InvalidOperationException("Manifest deserialized to null.");
24
+    }
25
+}

+ 59
- 0
Installer/src/Flo.Installer/Program.cs Datei anzeigen

@@ -0,0 +1,59 @@
1
+using Flo.Installer.Commands;
2
+
3
+if (args.Length == 0 || args[0] is "--help" or "-h")
4
+{
5
+    PrintUsage();
6
+    return 0;
7
+}
8
+
9
+try
10
+{
11
+    return args[0] switch
12
+    {
13
+        "install"   => await InstallCommand.ExecuteAsync(Required(args, "--manifest"), Required(args, "--install")),
14
+        "provision" => await ProvisionCommand.ExecuteAsync(Required(args, "--install")),
15
+        "setup"     => await Setup(args),
16
+        "run"       => await RunCommand.ExecuteAsync(Required(args, "--install")),
17
+        var cmd     => Unknown(cmd)
18
+    };
19
+}
20
+catch (Exception ex)
21
+{
22
+    Console.Error.WriteLine($"Fatal: {ex.Message}");
23
+    return 1;
24
+}
25
+
26
+static async Task<int> Setup(string[] args)
27
+{
28
+    string manifest = Required(args, "--manifest");
29
+    string install  = Required(args, "--install");
30
+    int rc = await InstallCommand.ExecuteAsync(manifest, install);
31
+    if (rc != 0) return rc;
32
+    return await ProvisionCommand.ExecuteAsync(install);
33
+}
34
+
35
+static int Unknown(string cmd)
36
+{
37
+    Console.Error.WriteLine($"Unknown command: {cmd}");
38
+    PrintUsage();
39
+    return 1;
40
+}
41
+
42
+static string Required(string[] args, string name)
43
+{
44
+    int i = Array.IndexOf(args, name);
45
+    if (i < 0 || i + 1 >= args.Length)
46
+        throw new ArgumentException($"{name} <value> is required.");
47
+    return args[i + 1];
48
+}
49
+
50
+static void PrintUsage()
51
+{
52
+    Console.WriteLine("Flo Installer");
53
+    Console.WriteLine();
54
+    Console.WriteLine("Commands:");
55
+    Console.WriteLine("  setup      --manifest <url> --install <path>   download + extract + provision DBs");
56
+    Console.WriteLine("  install    --manifest <url> --install <path>   download + extract only");
57
+    Console.WriteLine("  provision  --install <path>                    provision Postgres + FerretDB (idempotent)");
58
+    Console.WriteLine("  run        --install <path>                    start Postgres + FerretDB, block on Ctrl-C");
59
+}

+ 22
- 0
Installer/src/Flo.Installer/Provisioning/FerretDbProvisioner.cs Datei anzeigen

@@ -0,0 +1,22 @@
1
+namespace Flo.Installer.Provisioning;
2
+
3
+internal sealed class FerretDbProvisioner(Paths paths)
4
+{
5
+    public ProcessRunner Start(int listenPort, int postgresPort, string pgUser, string pgPassword, string pgDatabase)
6
+    {
7
+        Directory.CreateDirectory(paths.FerretDbData);
8
+
9
+        string pgUrl = $"postgres://{Uri.EscapeDataString(pgUser)}:{Uri.EscapeDataString(pgPassword)}@127.0.0.1:{postgresPort}/{pgDatabase}";
10
+
11
+        var env = new Dictionary<string, string>
12
+        {
13
+            ["FERRETDB_POSTGRESQL_URL"] = pgUrl,
14
+            ["FERRETDB_LISTEN_ADDR"]    = $"127.0.0.1:{listenPort}",
15
+            ["FERRETDB_STATE_DIR"]      = paths.FerretDbData,
16
+            ["FERRETDB_TELEMETRY"]      = "disable",
17
+            ["FERRETDB_LOG_LEVEL"]      = "info",
18
+        };
19
+
20
+        return ProcessRunner.Start(paths.FerretDbExe(), [], env, name: "ferretdb");
21
+    }
22
+}

+ 22
- 0
Installer/src/Flo.Installer/Provisioning/FloConfig.cs Datei anzeigen

@@ -0,0 +1,22 @@
1
+using System.Text.Json;
2
+using System.Text.Json.Serialization;
3
+
4
+namespace Flo.Installer.Provisioning;
5
+
6
+internal sealed record FloConfig(
7
+    [property: JsonPropertyName("postgresPort")] int PostgresPort,
8
+    [property: JsonPropertyName("ferretDbPort")] int FerretDbPort,
9
+    [property: JsonPropertyName("postgresSuperuser")] string PostgresSuperuser,
10
+    [property: JsonPropertyName("applicationDbRole")] string ApplicationDbRole,
11
+    [property: JsonPropertyName("applicationDbName")] string ApplicationDbName)
12
+{
13
+    public static FloConfig Load(string path)
14
+        => JsonSerializer.Deserialize<FloConfig>(File.ReadAllBytes(path))
15
+           ?? throw new InvalidOperationException($"Could not parse {path}");
16
+
17
+    public void Save(string path)
18
+    {
19
+        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
20
+        File.WriteAllText(path, JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true }));
21
+    }
22
+}

+ 34
- 0
Installer/src/Flo.Installer/Provisioning/HealthCheck.cs Datei anzeigen

@@ -0,0 +1,34 @@
1
+using System.Net.Sockets;
2
+
3
+namespace Flo.Installer.Provisioning;
4
+
5
+internal static class HealthCheck
6
+{
7
+    public static async Task WaitForPortAsync(
8
+        string host,
9
+        int port,
10
+        TimeSpan timeout,
11
+        string description,
12
+        CancellationToken ct)
13
+    {
14
+        var deadline = DateTime.UtcNow + timeout;
15
+        Exception? last = null;
16
+        while (DateTime.UtcNow < deadline)
17
+        {
18
+            ct.ThrowIfCancellationRequested();
19
+            try
20
+            {
21
+                using var client = new TcpClient();
22
+                await client.ConnectAsync(host, port, ct);
23
+                return;
24
+            }
25
+            catch (SocketException ex)
26
+            {
27
+                last = ex;
28
+                await Task.Delay(500, ct);
29
+            }
30
+        }
31
+        throw new TimeoutException(
32
+            $"{description} at {host}:{port} did not become healthy within {timeout.TotalSeconds:0}s. Last error: {last?.Message}");
33
+    }
34
+}

+ 23
- 0
Installer/src/Flo.Installer/Provisioning/PasswordGenerator.cs Datei anzeigen

@@ -0,0 +1,23 @@
1
+using System.Security.Cryptography;
2
+
3
+namespace Flo.Installer.Provisioning;
4
+
5
+internal static class PasswordGenerator
6
+{
7
+    private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
8
+    // Largest multiple of 62 that is <= 256; rejection sampling avoids modulo bias.
9
+    private const byte RejectAbove = 247;
10
+
11
+    public static string Generate(int length = 32)
12
+    {
13
+        var chars = new char[length];
14
+        Span<byte> b = stackalloc byte[1];
15
+        for (int i = 0; i < length;)
16
+        {
17
+            RandomNumberGenerator.Fill(b);
18
+            if (b[0] <= RejectAbove)
19
+                chars[i++] = Alphabet[b[0] % Alphabet.Length];
20
+        }
21
+        return new string(chars);
22
+    }
23
+}

+ 26
- 0
Installer/src/Flo.Installer/Provisioning/Paths.cs Datei anzeigen

@@ -0,0 +1,26 @@
1
+using System.Runtime.InteropServices;
2
+
3
+namespace Flo.Installer.Provisioning;
4
+
5
+internal sealed record Paths(string Install)
6
+{
7
+    public string Postgres      => Path.Combine(Install, "postgres");
8
+    public string FerretDb      => Path.Combine(Install, "ferretdb");
9
+    public string Service       => Path.Combine(Install, "service");
10
+    public string DataRoot      => Path.Combine(Install, "data");
11
+    public string PostgresData  => Path.Combine(DataRoot, "postgres");
12
+    public string FerretDbData  => Path.Combine(DataRoot, "ferretdb");
13
+    public string Logs          => Path.Combine(Install, "logs");
14
+    public string Config        => Path.Combine(Install, "config");
15
+    public string ConfigFile    => Path.Combine(Config, "flo.json");
16
+    public string SecretsFile   => Path.Combine(Install, "secrets", "secrets.json");
17
+
18
+    public string PostgresBin(string tool)
19
+    {
20
+        string name = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? $"{tool}.exe" : tool;
21
+        return Path.Combine(Postgres, "bin", name);
22
+    }
23
+
24
+    public string FerretDbExe()
25
+        => Path.Combine(FerretDb, RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "ferretdb.exe" : "ferretdb");
26
+}

+ 16
- 0
Installer/src/Flo.Installer/Provisioning/PortScanner.cs Datei anzeigen

@@ -0,0 +1,16 @@
1
+using System.Net;
2
+using System.Net.Sockets;
3
+
4
+namespace Flo.Installer.Provisioning;
5
+
6
+internal static class PortScanner
7
+{
8
+    public static int FindFreeLoopbackPort()
9
+    {
10
+        using var listener = new TcpListener(IPAddress.Loopback, 0);
11
+        listener.Start();
12
+        int port = ((IPEndPoint)listener.LocalEndpoint).Port;
13
+        listener.Stop();
14
+        return port;
15
+    }
16
+}

+ 164
- 0
Installer/src/Flo.Installer/Provisioning/PostgresProvisioner.cs Datei anzeigen

@@ -0,0 +1,164 @@
1
+using System.Diagnostics;
2
+
3
+namespace Flo.Installer.Provisioning;
4
+
5
+internal sealed class PostgresProvisioner(Paths paths)
6
+{
7
+    public bool IsInitialized() => File.Exists(Path.Combine(paths.PostgresData, "PG_VERSION"));
8
+
9
+    public void Initialize(string superuser, string superuserPassword)
10
+    {
11
+        Directory.CreateDirectory(paths.PostgresData);
12
+
13
+        string pwFile = Path.Combine(paths.DataRoot, ".pg-init-pwd.tmp");
14
+        File.WriteAllText(pwFile, superuserPassword);
15
+        try
16
+        {
17
+            RunToCompletion(paths.PostgresBin("initdb"),
18
+                ["--pgdata", paths.PostgresData,
19
+                 "--username", superuser,
20
+                 "--pwfile", pwFile,
21
+                 "--auth-local", "scram-sha-256",
22
+                 "--auth-host", "scram-sha-256",
23
+                 "--encoding", "UTF8"],
24
+                env: null);
25
+        }
26
+        finally
27
+        {
28
+            if (File.Exists(pwFile)) File.Delete(pwFile);
29
+        }
30
+    }
31
+
32
+    public void ConfigurePort(int port)
33
+    {
34
+        string confPath = Path.Combine(paths.PostgresData, "postgresql.conf");
35
+        var lines = File.ReadAllLines(confPath).ToList();
36
+        SetOrAppend(lines, "listen_addresses", "'127.0.0.1'");
37
+        SetOrAppend(lines, "port", port.ToString());
38
+        File.WriteAllLines(confPath, lines);
39
+    }
40
+
41
+    public ProcessRunner Start(int port) =>
42
+        ProcessRunner.Start(paths.PostgresBin("postgres"),
43
+            ["-D", paths.PostgresData, "-p", port.ToString()],
44
+            env: null,
45
+            name: "postgres");
46
+
47
+    // pg_ctl stop -m fast is the portable graceful shutdown — faster than smart, cleaner than SIGKILL.
48
+    public void StopGracefully()
49
+    {
50
+        RunToCompletion(paths.PostgresBin("pg_ctl"),
51
+            ["stop", "-D", paths.PostgresData, "-m", "fast", "-w"],
52
+            env: null);
53
+    }
54
+
55
+    public void EnsureRoleAndDatabase(
56
+        int port,
57
+        string superuser,
58
+        string superuserPassword,
59
+        string role,
60
+        string rolePassword,
61
+        string database)
62
+    {
63
+        if (!RoleExists(port, superuser, superuserPassword, role))
64
+        {
65
+            Psql(port, superuser, superuserPassword, "postgres",
66
+                $"CREATE ROLE \"{role}\" LOGIN PASSWORD '{EscapeLiteral(rolePassword)}';");
67
+        }
68
+
69
+        if (!DatabaseExists(port, superuser, superuserPassword, database))
70
+        {
71
+            Psql(port, superuser, superuserPassword, "postgres",
72
+                $"CREATE DATABASE \"{database}\" OWNER \"{role}\";");
73
+        }
74
+    }
75
+
76
+    private bool RoleExists(int port, string user, string password, string role)
77
+        => PsqlScalar(port, user, password, "postgres",
78
+            $"SELECT 1 FROM pg_roles WHERE rolname = '{EscapeLiteral(role)}';") == "1";
79
+
80
+    private bool DatabaseExists(int port, string user, string password, string database)
81
+        => PsqlScalar(port, user, password, "postgres",
82
+            $"SELECT 1 FROM pg_database WHERE datname = '{EscapeLiteral(database)}';") == "1";
83
+
84
+    private void Psql(int port, string user, string password, string db, string sql)
85
+    {
86
+        var (exit, stdout, stderr) = InvokePsql(port, user, password, db, sql, quiet: false);
87
+        if (exit != 0)
88
+            throw new InvalidOperationException($"psql failed (exit {exit})\nstdout: {stdout}\nstderr: {stderr}");
89
+    }
90
+
91
+    private string PsqlScalar(int port, string user, string password, string db, string sql)
92
+    {
93
+        var (exit, stdout, stderr) = InvokePsql(port, user, password, db, sql, quiet: true);
94
+        if (exit != 0)
95
+            throw new InvalidOperationException($"psql scalar failed (exit {exit})\nstdout: {stdout}\nstderr: {stderr}");
96
+        return stdout.Trim();
97
+    }
98
+
99
+    private (int exit, string stdout, string stderr) InvokePsql(
100
+        int port, string user, string password, string db, string sql, bool quiet)
101
+    {
102
+        var psi = new ProcessStartInfo(paths.PostgresBin("psql"))
103
+        {
104
+            UseShellExecute = false,
105
+            RedirectStandardOutput = true,
106
+            RedirectStandardError = true,
107
+            CreateNoWindow = true,
108
+        };
109
+        psi.ArgumentList.Add("-h"); psi.ArgumentList.Add("127.0.0.1");
110
+        psi.ArgumentList.Add("-p"); psi.ArgumentList.Add(port.ToString());
111
+        psi.ArgumentList.Add("-U"); psi.ArgumentList.Add(user);
112
+        psi.ArgumentList.Add("-d"); psi.ArgumentList.Add(db);
113
+        psi.ArgumentList.Add("-v"); psi.ArgumentList.Add("ON_ERROR_STOP=1");
114
+        if (quiet)
115
+        {
116
+            psi.ArgumentList.Add("-At"); // unaligned, tuples-only
117
+        }
118
+        psi.ArgumentList.Add("-c"); psi.ArgumentList.Add(sql);
119
+        psi.Environment["PGPASSWORD"] = password;
120
+
121
+        using var proc = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start psql");
122
+        string stdout = proc.StandardOutput.ReadToEnd();
123
+        string stderr = proc.StandardError.ReadToEnd();
124
+        proc.WaitForExit();
125
+        return (proc.ExitCode, stdout, stderr);
126
+    }
127
+
128
+    private static string EscapeLiteral(string s) => s.Replace("'", "''");
129
+
130
+    private static void SetOrAppend(List<string> lines, string key, string value)
131
+    {
132
+        string prefix = key;
133
+        int i = lines.FindIndex(l =>
134
+        {
135
+            string t = l.TrimStart();
136
+            return t.StartsWith(prefix + " ") || t.StartsWith(prefix + "\t") || t.StartsWith(prefix + "=");
137
+        });
138
+        string newLine = $"{key} = {value}";
139
+        if (i >= 0) lines[i] = newLine;
140
+        else lines.Add(newLine);
141
+    }
142
+
143
+    private static void RunToCompletion(string exe, IReadOnlyList<string> args, IReadOnlyDictionary<string, string>? env)
144
+    {
145
+        var psi = new ProcessStartInfo(exe)
146
+        {
147
+            UseShellExecute = false,
148
+            RedirectStandardOutput = true,
149
+            RedirectStandardError = true,
150
+            CreateNoWindow = true,
151
+        };
152
+        foreach (var a in args) psi.ArgumentList.Add(a);
153
+        if (env is not null)
154
+            foreach (var kv in env) psi.Environment[kv.Key] = kv.Value;
155
+
156
+        using var proc = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {exe}");
157
+        string stdout = proc.StandardOutput.ReadToEnd();
158
+        string stderr = proc.StandardError.ReadToEnd();
159
+        proc.WaitForExit();
160
+        if (proc.ExitCode != 0)
161
+            throw new InvalidOperationException(
162
+                $"{Path.GetFileName(exe)} failed (exit {proc.ExitCode})\nstdout: {stdout}\nstderr: {stderr}");
163
+    }
164
+}

+ 63
- 0
Installer/src/Flo.Installer/Provisioning/ProcessRunner.cs Datei anzeigen

@@ -0,0 +1,63 @@
1
+using System.Diagnostics;
2
+
3
+namespace Flo.Installer.Provisioning;
4
+
5
+internal sealed class ProcessRunner : IAsyncDisposable
6
+{
7
+    private readonly Process _process;
8
+    private readonly string _name;
9
+
10
+    private ProcessRunner(Process process, string name)
11
+    {
12
+        _process = process;
13
+        _name = name;
14
+    }
15
+
16
+    public static ProcessRunner Start(
17
+        string executable,
18
+        IReadOnlyList<string> args,
19
+        IReadOnlyDictionary<string, string>? env,
20
+        string name)
21
+    {
22
+        var psi = new ProcessStartInfo(executable)
23
+        {
24
+            UseShellExecute = false,
25
+            RedirectStandardOutput = true,
26
+            RedirectStandardError = true,
27
+            CreateNoWindow = true,
28
+        };
29
+        foreach (var a in args) psi.ArgumentList.Add(a);
30
+        if (env is not null)
31
+            foreach (var kv in env) psi.Environment[kv.Key] = kv.Value;
32
+
33
+        var proc = Process.Start(psi) ?? throw new InvalidOperationException($"Failed to start {executable}");
34
+
35
+        proc.OutputDataReceived += (_, e) => { if (e.Data != null) Console.WriteLine($"[{name}] {e.Data}"); };
36
+        proc.ErrorDataReceived  += (_, e) => { if (e.Data != null) Console.Error.WriteLine($"[{name}] {e.Data}"); };
37
+        proc.BeginOutputReadLine();
38
+        proc.BeginErrorReadLine();
39
+
40
+        return new ProcessRunner(proc, name);
41
+    }
42
+
43
+    public bool HasExited => _process.HasExited;
44
+    public int ExitCode => _process.ExitCode;
45
+    public Process Process => _process;
46
+
47
+    public async Task StopAsync(TimeSpan timeout)
48
+    {
49
+        if (_process.HasExited) return;
50
+        _process.Kill(entireProcessTree: true);
51
+        await _process.WaitForExitAsync().WaitAsync(timeout);
52
+    }
53
+
54
+    public async ValueTask DisposeAsync()
55
+    {
56
+        if (!_process.HasExited)
57
+        {
58
+            try { await StopAsync(TimeSpan.FromSeconds(10)); }
59
+            catch (Exception ex) { Console.Error.WriteLine($"[{_name}] stop error: {ex.Message}"); }
60
+        }
61
+        _process.Dispose();
62
+    }
63
+}

+ 26
- 0
Installer/src/Flo.Installer/Provisioning/SecretsStore.cs Datei anzeigen

@@ -0,0 +1,26 @@
1
+using System.Text.Json;
2
+
3
+namespace Flo.Installer.Provisioning;
4
+
5
+internal sealed class SecretsStore(string path)
6
+{
7
+    public bool Exists() => File.Exists(path);
8
+
9
+    public IReadOnlyDictionary<string, string> Load()
10
+        => JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllBytes(path))
11
+           ?? throw new InvalidOperationException($"Malformed secrets file: {path}");
12
+
13
+    public void Save(IReadOnlyDictionary<string, string> secrets)
14
+    {
15
+        Directory.CreateDirectory(Path.GetDirectoryName(path)!);
16
+        File.WriteAllText(path, JsonSerializer.Serialize(secrets, new JsonSerializerOptions { WriteIndented = true }));
17
+        RestrictPermissions(path);
18
+    }
19
+
20
+    // Phase 5 will swap this for DPAPI on Windows and real ACLs; Linux keeps the file-mode approach.
21
+    private static void RestrictPermissions(string path)
22
+    {
23
+        if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
24
+            File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
25
+    }
26
+}

+ 13
- 0
Installer/src/Flo.Installer/TrustedPublicKey.cs Datei anzeigen

@@ -0,0 +1,13 @@
1
+namespace Flo.Installer;
2
+
3
+internal static class TrustedPublicKey
4
+{
5
+    // Placeholder. Replace before any real release.
6
+    // 1. Generate keypair:  dotnet run --project src/Flo.Manifest.Tool -- generate-key --out release-keypair.key
7
+    // 2. Export public key: dotnet run --project src/Flo.Manifest.Tool -- export-pubkey --key release-keypair.key
8
+    // 3. Paste the printed array literal over the line below and rebuild.
9
+    // 4. Move release-keypair.key to offline storage. Never commit it.
10
+    public static readonly byte[] Bytes = new byte[32];
11
+
12
+    public static bool IsPlaceholder => Bytes.All(b => b == 0);
13
+}

+ 14
- 0
Installer/src/Flo.Manifest.Tool/Flo.Manifest.Tool.csproj Datei anzeigen

@@ -0,0 +1,14 @@
1
+<Project Sdk="Microsoft.NET.Sdk">
2
+
3
+  <PropertyGroup>
4
+    <OutputType>Exe</OutputType>
5
+    <TargetFramework>net10.0</TargetFramework>
6
+    <ImplicitUsings>enable</ImplicitUsings>
7
+    <Nullable>enable</Nullable>
8
+  </PropertyGroup>
9
+
10
+  <ItemGroup>
11
+    <PackageReference Include="NSec.Cryptography" Version="25.4.0" />
12
+  </ItemGroup>
13
+
14
+</Project>

+ 123
- 0
Installer/src/Flo.Manifest.Tool/Program.cs Datei anzeigen

@@ -0,0 +1,123 @@
1
+using NSec.Cryptography;
2
+
3
+if (args.Length == 0)
4
+{
5
+    PrintUsage();
6
+    return 0;
7
+}
8
+
9
+try
10
+{
11
+    return args[0] switch
12
+    {
13
+        "generate-key"  => GenerateKey(args),
14
+        "export-pubkey" => ExportPubkey(args),
15
+        "sign"          => Sign(args),
16
+        "verify"        => Verify(args),
17
+        _               => UnknownCommand()
18
+    };
19
+}
20
+catch (Exception ex)
21
+{
22
+    Console.Error.WriteLine($"Error: {ex.Message}");
23
+    return 1;
24
+}
25
+
26
+static int UnknownCommand()
27
+{
28
+    Console.Error.WriteLine("Unknown command.");
29
+    PrintUsage();
30
+    return 1;
31
+}
32
+
33
+static int GenerateKey(string[] args)
34
+{
35
+    string outPath = RequireArg(args, "--out");
36
+    if (File.Exists(outPath))
37
+        throw new InvalidOperationException($"{outPath} already exists. Refusing to overwrite a keypair.");
38
+
39
+    var algorithm = SignatureAlgorithm.Ed25519;
40
+    using var key = Key.Create(algorithm, new KeyCreationParameters
41
+    {
42
+        ExportPolicy = KeyExportPolicies.AllowPlaintextExport
43
+    });
44
+    byte[] priv = key.Export(KeyBlobFormat.RawPrivateKey);
45
+    byte[] pub  = key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
46
+
47
+    File.WriteAllText(outPath, $"{Convert.ToBase64String(priv)}\n{Convert.ToBase64String(pub)}\n");
48
+    Console.WriteLine($"Wrote keypair to {outPath}");
49
+    Console.WriteLine("Protect this file. Losing it means you cannot ship updates; leaking it means anyone can.");
50
+    return 0;
51
+}
52
+
53
+static int ExportPubkey(string[] args)
54
+{
55
+    string keyPath = RequireArg(args, "--key");
56
+    (_, byte[] pub) = LoadKeypair(keyPath);
57
+
58
+    string literal = string.Join(", ", pub.Select(b => $"0x{b:x2}"));
59
+    Console.WriteLine("// Paste into src/Flo.Installer/TrustedPublicKey.cs");
60
+    Console.WriteLine($"public static readonly byte[] Bytes = new byte[] {{ {literal} }};");
61
+    return 0;
62
+}
63
+
64
+static int Sign(string[] args)
65
+{
66
+    string manifestPath = RequireArg(args, "--manifest");
67
+    string keyPath = RequireArg(args, "--key");
68
+    string outPath = RequireArg(args, "--out");
69
+
70
+    (byte[] priv, _) = LoadKeypair(keyPath);
71
+    var algorithm = SignatureAlgorithm.Ed25519;
72
+    using var signingKey = Key.Import(algorithm, priv, KeyBlobFormat.RawPrivateKey);
73
+    byte[] manifestBytes = File.ReadAllBytes(manifestPath);
74
+    byte[] signature = algorithm.Sign(signingKey, manifestBytes);
75
+
76
+    File.WriteAllText(outPath, Convert.ToBase64String(signature));
77
+    Console.WriteLine($"Signed: {manifestPath} -> {outPath}");
78
+    return 0;
79
+}
80
+
81
+static int Verify(string[] args)
82
+{
83
+    string manifestPath = RequireArg(args, "--manifest");
84
+    string sigPath = RequireArg(args, "--sig");
85
+    string keyPath = RequireArg(args, "--key");
86
+
87
+    (_, byte[] pub) = LoadKeypair(keyPath);
88
+    var algorithm = SignatureAlgorithm.Ed25519;
89
+    var pubKey = NSec.Cryptography.PublicKey.Import(algorithm, pub, KeyBlobFormat.RawPublicKey);
90
+    byte[] manifestBytes = File.ReadAllBytes(manifestPath);
91
+    byte[] signature = Convert.FromBase64String(File.ReadAllText(sigPath).Trim());
92
+
93
+    bool ok = algorithm.Verify(pubKey, manifestBytes, signature);
94
+    Console.WriteLine(ok ? "OK: signature valid." : "FAIL: signature INVALID.");
95
+    return ok ? 0 : 2;
96
+}
97
+
98
+static (byte[] priv, byte[] pub) LoadKeypair(string path)
99
+{
100
+    string[] lines = File.ReadAllLines(path);
101
+    if (lines.Length < 2)
102
+        throw new InvalidOperationException($"Malformed keypair file: {path}");
103
+    return (Convert.FromBase64String(lines[0]), Convert.FromBase64String(lines[1]));
104
+}
105
+
106
+static string RequireArg(string[] args, string name)
107
+{
108
+    int i = Array.IndexOf(args, name);
109
+    if (i < 0 || i + 1 >= args.Length)
110
+        throw new ArgumentException($"{name} <value> is required.");
111
+    return args[i + 1];
112
+}
113
+
114
+static void PrintUsage()
115
+{
116
+    Console.WriteLine("Flo.Manifest.Tool  (release-side signing helper)");
117
+    Console.WriteLine();
118
+    Console.WriteLine("Commands:");
119
+    Console.WriteLine("  generate-key   --out <keypair-file>");
120
+    Console.WriteLine("  export-pubkey  --key <keypair-file>");
121
+    Console.WriteLine("  sign           --manifest <path> --key <keypair-file> --out <sig-file>");
122
+    Console.WriteLine("  verify         --manifest <path> --sig <sig-file> --key <keypair-file>");
123
+}

BIN
docs/VAT findings.pdf Datei anzeigen


BIN
docs/VAT_Remediation_Plan.docx Datei anzeigen


Laden…
Abbrechen
Speichern