Mit Roslyn umbenannt (Verweise folgen, Zeichenketten bleiben unberuehrt). Nicht umbenannt, weil gespeichert oder ueber die API gebunden (Stufe 4e): Properties, Record-Parameter, Enum-Member sowie die Parameter der Endpunkt-Handler (Routen-/Query-Bindung ueber den Namen). Die JSON-Felder anonymer Objekte bleiben ausdruecklich benannt (JsonFilter: pfad). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
8.8 KiB
C#
255 lines
8.8 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Backup;
|
|
using ClawdDotNet.Core.Security;
|
|
using ClawdDotNet.Core.Storage;
|
|
using ClawdDotNet.Core.Tests.Infrastructure;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Core.Tests.Backup;
|
|
|
|
/// <summary>
|
|
/// The backup, encrypted with the license key.
|
|
///
|
|
/// <para><b>What this is about.</b> A backup should survive a change of machine
|
|
/// without a passphrase having to be stored or typed in. The license key
|
|
/// is there anyway. And an archive on an FTP server or a USB stick should be of no use
|
|
/// to anyone who merely finds it.</para>
|
|
///
|
|
/// <para>Runs in <see cref="SecretKeyCollection"/>: the change of machine is reproduced via the
|
|
/// static key store.</para>
|
|
/// </summary>
|
|
[Collection(SecretKeyCollection.Name)]
|
|
public sealed class LicenseBackupTests : IDisposable
|
|
{
|
|
private const string Lizenz = "CLAWD-ABCD-1234-EFGH-5678";
|
|
private const string ApiKey = "sk-or-v1-streng-geheim-98765";
|
|
|
|
private readonly string _root = Path.Combine(
|
|
Path.GetTempPath(), "clawd-tests", "licbak-" + Guid.NewGuid().ToString("N")[..8]);
|
|
|
|
private string Datei(string name) => Path.Combine(_root, name);
|
|
|
|
public LicenseBackupTests()
|
|
{
|
|
Directory.CreateDirectory(_root);
|
|
SecretKeyStore.UseDirectory(Datei("maschine-a"));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
SecretKeyStore.UseDirectory(null);
|
|
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
|
|
try { Directory.Delete(_root, recursive: true); }
|
|
catch { /* Cleaning up is secondary */ }
|
|
}
|
|
|
|
private string Source(byte[] content)
|
|
{
|
|
var path = Datei("quelle.bin");
|
|
File.WriteAllBytes(path, content);
|
|
return path;
|
|
}
|
|
|
|
// ─── The container ───
|
|
|
|
/// <summary>
|
|
/// More than one chunk: the chunking is the part where an error swallows data
|
|
/// without it being noticed.
|
|
/// </summary>
|
|
[Fact]
|
|
public void An_archive_spanning_several_chunks_comes_back_unchanged()
|
|
{
|
|
var content = RandomNumberGenerator.GetBytes(2_600_000);
|
|
|
|
LicenseBackupCipher.Encrypt(Source(content), "{}", Datei("a.clawdbak"), Lizenz);
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz);
|
|
|
|
File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe(content);
|
|
}
|
|
|
|
[Fact]
|
|
public void An_empty_archive_comes_back_too()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Source([]), "{}", Datei("a.clawdbak"), Lizenz);
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz);
|
|
|
|
File.ReadAllBytes(Datei("zurueck.bin")).ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// A different key is recognized at the header — before anything is decrypted and
|
|
/// above all before an instance is stopped for it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_foreign_key_is_recognised_before_anything_is_written()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Source([1, 2, 3]), "{}", Datei("a.clawdbak"), Lizenz);
|
|
|
|
LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), "ANDERE-LIZENZ").ShouldBeFalse();
|
|
LicenseBackupCipher.MatchesKey(Datei("a.clawdbak"), Lizenz).ShouldBeTrue();
|
|
|
|
Should.Throw<BackupKeyMismatchException>(() =>
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), "ANDERE-LIZENZ"));
|
|
|
|
File.Exists(Datei("zurueck.bin")).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// In the restore dialog a human types in a foreign key. A
|
|
/// space or a lowercase letter must not decide about "lost" there.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Spelling_and_spaces_of_the_key_do_not_matter()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Source([4, 5, 6]), "{}", Datei("a.clawdbak"), " clawd-abcd-1234 -efgh-5678 ");
|
|
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz);
|
|
File.ReadAllBytes(Datei("zurueck.bin")).ShouldBe([4, 5, 6]);
|
|
}
|
|
|
|
[Fact]
|
|
public void The_manifest_is_readable_without_decrypting_the_archive()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Source(RandomNumberGenerator.GetBytes(3_000_000)),
|
|
"""{"instanceName":"Alpha"}""", Datei("a.clawdbak"), Lizenz);
|
|
|
|
LicenseBackupCipher.ReadManifest(Datei("a.clawdbak"), Lizenz).ShouldContain("Alpha");
|
|
}
|
|
|
|
/// <summary>
|
|
/// If a whole chunk is missing at the end, that must be noticed. Without the end marker you would consider the rest
|
|
/// complete and get a ZIP that aborts somewhere when unpacking.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_missing_last_chunk_is_noticed()
|
|
{
|
|
var content = RandomNumberGenerator.GetBytes(1024 * 1024 + 500);
|
|
const string manifest = "{}";
|
|
|
|
LicenseBackupCipher.Encrypt(Source(content), manifest, Datei("a.clawdbak"), Lizenz);
|
|
|
|
// Header 8+1+16, manifest 12+4+16+len, first chunk 12+4+16+1 MiB - cut after that.
|
|
var upToFirstChunk = 25 + (32 + Encoding.UTF8.GetByteCount(manifest)) + (32 + 1024 * 1024);
|
|
var full = File.ReadAllBytes(Datei("a.clawdbak"));
|
|
File.WriteAllBytes(Datei("a.clawdbak"), full[..upToFirstChunk]);
|
|
|
|
Should.Throw<BackupException>(() =>
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz));
|
|
}
|
|
|
|
[Fact]
|
|
public void A_change_in_the_middle_of_the_archive_is_noticed()
|
|
{
|
|
LicenseBackupCipher.Encrypt(Source(RandomNumberGenerator.GetBytes(200_000)), "{}",
|
|
Datei("a.clawdbak"), Lizenz);
|
|
|
|
var bytes = File.ReadAllBytes(Datei("a.clawdbak"));
|
|
bytes[bytes.Length / 2] ^= 0x01;
|
|
File.WriteAllBytes(Datei("a.clawdbak"), bytes);
|
|
|
|
Should.Throw<BackupException>(() =>
|
|
LicenseBackupCipher.DecryptTo(Datei("a.clawdbak"), Datei("zurueck.bin"), Lizenz));
|
|
}
|
|
|
|
// ─── The backup as a whole ───
|
|
|
|
private string Instance()
|
|
{
|
|
var path = Datei("Instance-Alpha");
|
|
Directory.CreateDirectory(path);
|
|
|
|
AtomicFile.WriteAllText(Path.Combine(path, "InstanceSettings.json"), $$"""
|
|
{
|
|
"instanceId": "alpha-1",
|
|
"instanceName": "Alpha",
|
|
"openRouterApiKey": "{{SecretProtector.Protect(ApiKey)}}"
|
|
}
|
|
""");
|
|
|
|
return path;
|
|
}
|
|
|
|
private async Task<string> BackupAsync()
|
|
{
|
|
var ziel = Datei("backup_Alpha.clawdbak");
|
|
|
|
await new BackupService().CreateAsync(Instance(), ziel, new BackupOptions
|
|
{
|
|
Secrets = SecretMode.License,
|
|
LicenseKey = Lizenz
|
|
});
|
|
|
|
return ziel;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The case this is about: back up on machine A, restore on machine B, and
|
|
/// the API key is readable afterwards — without a passphrase, solely with the
|
|
/// license key.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_backup_survives_the_machine_change()
|
|
{
|
|
var backup = await BackupAsync();
|
|
|
|
SecretKeyStore.UseDirectory(Datei("maschine-b"));
|
|
|
|
var ziel = Datei("Wiederhergestellt");
|
|
await new BackupService().RestoreAsync(backup, ziel,
|
|
new RestoreOptions { LicenseKey = Lizenz, Overwrite = true });
|
|
|
|
using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(ziel, "InstanceSettings.json")));
|
|
var value = doc.RootElement.GetProperty("openRouterApiKey").GetString();
|
|
|
|
SecretProtector.Unprotect(value).ShouldBe(ApiKey);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Nothing is in plain text in the file — neither the key nor that there is
|
|
/// one at all.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task The_backup_reveals_no_plaintext()
|
|
{
|
|
var backup = await BackupAsync();
|
|
|
|
LicenseBackupCipher.IsEncrypted(backup).ShouldBeTrue();
|
|
|
|
var raw = Encoding.Latin1.GetString(File.ReadAllBytes(backup));
|
|
raw.ShouldNotContain(ApiKey);
|
|
raw.ShouldNotContain("openRouterApiKey");
|
|
raw.ShouldNotContain("InstanceSettings");
|
|
}
|
|
|
|
/// <summary>
|
|
/// With a foreign key nothing is written — not half, not partially.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task With_a_foreign_key_the_target_stays_untouched()
|
|
{
|
|
var backup = await BackupAsync();
|
|
var ziel = Datei("Ziel");
|
|
Directory.CreateDirectory(ziel);
|
|
|
|
await Should.ThrowAsync<BackupKeyMismatchException>(() =>
|
|
new BackupService().RestoreAsync(backup, ziel,
|
|
new RestoreOptions { LicenseKey = "ANDERE-LIZENZ", Overwrite = true }));
|
|
|
|
Directory.EnumerateFileSystemEntries(ziel).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_overview_needs_the_matching_key()
|
|
{
|
|
var backup = await BackupAsync();
|
|
|
|
(await new BackupService().InspectAsync(backup, licenseKey: Lizenz))
|
|
.InstanceName.ShouldBe("Alpha");
|
|
|
|
await Should.ThrowAsync<BackupKeyMismatchException>(() =>
|
|
new BackupService().InspectAsync(backup, licenseKey: "ANDERE-LIZENZ"));
|
|
}
|
|
}
|