186 lines
8.1 KiB
C#
186 lines
8.1 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ItWorx.KnowledgeHub.Application.Operations;
|
|
using ItWorx.KnowledgeHub.Infrastructure.Operations;
|
|
|
|
namespace ItWorx.KnowledgeHub.IntegrationTests.Operations;
|
|
|
|
public sealed class BackupEvidenceTests : IDisposable
|
|
{
|
|
private readonly string root = Path.Combine(Path.GetTempPath(), "kh-evidence-" + Guid.NewGuid().ToString("N"));
|
|
private string BackupsRoot => Path.Combine(root, "backups");
|
|
private string KeysRoot => Path.Combine(root, "keys");
|
|
|
|
[Fact]
|
|
public async Task Historical_manifest_has_unknown_integrity_and_restore_until_actual_evidence_is_recorded()
|
|
{
|
|
var store = CreateStore("backup-one");
|
|
var initial = await store.GetAsync("backup-one", CancellationToken.None);
|
|
Assert.NotNull(initial);
|
|
Assert.Null(initial.IntegrityVerifiedAt);
|
|
Assert.Null(initial.RestoreVerifiedAt);
|
|
Assert.False(initial.Protected);
|
|
|
|
await WriteValidIntegrityAsync("backup-one");
|
|
await store.RecordRestoreAsync("backup-one", new RestoreReport("backup-one", true,
|
|
[new RestoreTableReport("pages", 3, 3)], 1, 1, [], ManifestHash("backup-one")), CancellationToken.None);
|
|
var verified = await store.GetAsync("backup-one", CancellationToken.None);
|
|
Assert.NotNull(verified!.RestoreVerifiedAt);
|
|
// Changing the manifest invalidates previously verified evidence.
|
|
await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-one", "manifest.json"), " ");
|
|
Assert.Null((await store.GetAsync("backup-one", CancellationToken.None))!.RestoreVerifiedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Manifest_changed_during_restore_cannot_receive_successful_receipt()
|
|
{
|
|
var store = CreateStore("backup-changed");
|
|
await WriteValidIntegrityAsync("backup-changed");
|
|
var digest = ManifestHash("backup-changed");
|
|
await File.AppendAllTextAsync(Path.Combine(BackupsRoot, "backup-changed", "manifest.json"), " ");
|
|
await Assert.ThrowsAsync<IOException>(() => store.RecordRestoreAsync("backup-changed",
|
|
new RestoreReport("backup-changed", true, [], 0, 0, [], digest), CancellationToken.None));
|
|
Assert.Null((await store.GetAsync("backup-changed", CancellationToken.None))!.RestoreVerifiedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Integrity_evidence_is_bound_to_the_exact_manifest_hash_used_for_staging()
|
|
{
|
|
const string backupId = "backup-bound-manifest";
|
|
var store = CreateStore(backupId);
|
|
await WriteValidIntegrityAsync(backupId);
|
|
var manifestHash = ManifestHash(backupId);
|
|
|
|
var matching = await store.GetAsync(backupId, CancellationToken.None, manifestHash);
|
|
var mismatching = await store.GetAsync(backupId, CancellationToken.None, new string('0', 64));
|
|
|
|
Assert.NotNull(matching);
|
|
Assert.NotNull(matching.IntegrityVerifiedAt);
|
|
Assert.NotNull(mismatching);
|
|
Assert.Null(mismatching.IntegrityVerifiedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Forged_manifest_and_matching_unsigned_receipt_do_not_verify()
|
|
{
|
|
const string backupId = "backup-forged";
|
|
var store = CreateStore(backupId);
|
|
await WriteValidIntegrityAsync(backupId);
|
|
|
|
var manifestPath = Path.Combine(BackupsRoot, backupId, "manifest.json");
|
|
var manifest = JsonSerializer.Deserialize<BackupManifest>(await File.ReadAllTextAsync(manifestPath));
|
|
Assert.NotNull(manifest);
|
|
await File.WriteAllTextAsync(manifestPath, JsonSerializer.Serialize(manifest with { ProductVersion = "forged" }));
|
|
var changedHash = ManifestHash(backupId);
|
|
var forgedReceipt = new
|
|
{
|
|
Version = 1,
|
|
BackupId = backupId,
|
|
ManifestSha256 = changedHash,
|
|
CompletedAt = DateTimeOffset.UtcNow,
|
|
Success = true,
|
|
HmacSha256 = new string('0', 64)
|
|
};
|
|
await File.WriteAllTextAsync(Path.Combine(BackupsRoot, backupId, ".integrity.json"),
|
|
JsonSerializer.Serialize(forgedReceipt));
|
|
|
|
var evidence = await store.GetAsync(backupId, CancellationToken.None);
|
|
Assert.NotNull(evidence);
|
|
Assert.Null(evidence.IntegrityVerifiedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Missing_integrity_key_invalidates_a_previously_signed_receipt()
|
|
{
|
|
const string backupId = "backup-key-lost";
|
|
var store = CreateStore(backupId);
|
|
await WriteValidIntegrityAsync(backupId);
|
|
Assert.NotNull((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
|
|
|
|
File.Delete(Path.Combine(KeysRoot, "backup-integrity.key"));
|
|
|
|
Assert.Null((await store.GetAsync(backupId, CancellationToken.None))!.IntegrityVerifiedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Existing_unix_integrity_key_is_restricted_to_owner_read_write_before_use()
|
|
{
|
|
if (OperatingSystem.IsWindows()) return;
|
|
|
|
const string backupId = "backup-key-mode";
|
|
var store = CreateStore(backupId);
|
|
await WriteValidIntegrityAsync(backupId);
|
|
var keyPath = Path.Combine(KeysRoot, "backup-integrity.key");
|
|
File.SetUnixFileMode(keyPath,
|
|
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.GroupRead | UnixFileMode.OtherRead);
|
|
|
|
var evidence = await store.GetAsync(backupId, CancellationToken.None);
|
|
|
|
Assert.NotNull(evidence);
|
|
Assert.NotNull(evidence.IntegrityVerifiedAt);
|
|
Assert.Equal(UnixFileMode.UserRead | UnixFileMode.UserWrite, File.GetUnixFileMode(keyPath));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Protection_is_versioned_and_stale_write_cannot_remove_it()
|
|
{
|
|
var store = CreateStore("backup-two");
|
|
var protectedBackup = await store.SetProtectionAsync("backup-two", true, 0, Guid.NewGuid(), CancellationToken.None);
|
|
Assert.Equal(BackupProtectionResult.Updated, protectedBackup);
|
|
Assert.Equal(BackupProtectionResult.Conflict,
|
|
await store.SetProtectionAsync("backup-two", false, 0, Guid.NewGuid(), CancellationToken.None));
|
|
var state = await store.GetAsync("backup-two", CancellationToken.None);
|
|
Assert.True(state!.Protected);
|
|
Assert.Equal(1, state.ProtectionVersion);
|
|
Assert.Null(await store.GetAsync("../escape", CancellationToken.None));
|
|
}
|
|
|
|
private FileBackupEvidenceStore CreateStore(string id)
|
|
{
|
|
Directory.CreateDirectory(Path.Combine(BackupsRoot, id));
|
|
File.WriteAllText(Path.Combine(BackupsRoot, id, "manifest.json"), JsonSerializer.Serialize(
|
|
new BackupManifest(id, DateTimeOffset.UtcNow, "Hub", "1.0", "development", "schema", [], [], 0)));
|
|
return new FileBackupEvidenceStore(new BackupPaths(BackupsRoot, Path.Combine(root, "attachments"), KeysRoot), TimeProvider.System);
|
|
}
|
|
|
|
private string ManifestHash(string id) => Convert.ToHexStringLower(
|
|
SHA256.HashData(File.ReadAllBytes(Path.Combine(BackupsRoot, id, "manifest.json"))));
|
|
|
|
private async Task WriteValidIntegrityAsync(string id)
|
|
{
|
|
Directory.CreateDirectory(KeysRoot);
|
|
var keyPath = Path.Combine(KeysRoot, "backup-integrity.key");
|
|
if (!File.Exists(keyPath))
|
|
await File.WriteAllBytesAsync(keyPath, RandomNumberGenerator.GetBytes(32));
|
|
|
|
var key = await File.ReadAllBytesAsync(keyPath);
|
|
var completedAt = DateTimeOffset.UtcNow;
|
|
var manifestHash = ManifestHash(id);
|
|
var payload = string.Join('\n',
|
|
"itworx-knowledge-hub-backup-evidence-v1",
|
|
"integrity",
|
|
id,
|
|
manifestHash,
|
|
completedAt.UtcTicks.ToString(CultureInfo.InvariantCulture),
|
|
"1");
|
|
var record = new
|
|
{
|
|
Version = 1,
|
|
BackupId = id,
|
|
ManifestSha256 = manifestHash,
|
|
CompletedAt = completedAt,
|
|
Success = true,
|
|
HmacSha256 = Convert.ToHexStringLower(HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload)))
|
|
};
|
|
await File.WriteAllTextAsync(Path.Combine(BackupsRoot, id, ".integrity.json"),
|
|
JsonSerializer.Serialize(record));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
|
|
}
|
|
}
|