mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: implement end-to-end encryption for room keys with secure storage and migration support
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
using System.Security.Cryptography;
|
||||
using EchoHub.Client.Services;
|
||||
using EchoHub.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace EchoHub.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// At-rest encryption of the cached room keys (RoomKeyProtector) and the store-level
|
||||
/// decisions built on it (RoomKeyStore): fresh envelopes overwrite stale cached keys,
|
||||
/// legacy plain-base64 entries are recognized for the one-way migration.
|
||||
/// </summary>
|
||||
public class RoomKeyProtectorTests : IDisposable
|
||||
{
|
||||
private readonly string _dir = Directory.CreateTempSubdirectory("echohub-keyprotector-").FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_dir, recursive: true); } catch { /* best-effort cleanup */ }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFile_Protect_RoundTrips()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var stored = protector.Protect(key);
|
||||
|
||||
Assert.StartsWith(RoomKeyProtector.KeyFilePrefix, stored);
|
||||
Assert.True(protector.TryUnprotect(stored, out var recovered, out var wasLegacy));
|
||||
Assert.Equal(key, recovered);
|
||||
Assert.False(wasLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dpapi_Protect_RoundTrips()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only
|
||||
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: true);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var stored = protector.Protect(key);
|
||||
|
||||
Assert.StartsWith(RoomKeyProtector.DpapiPrefix, stored);
|
||||
Assert.True(protector.TryUnprotect(stored, out var recovered, out var wasLegacy));
|
||||
Assert.Equal(key, recovered);
|
||||
Assert.False(wasLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Protect_DoesNotStoreThePlainKey()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
var stored = protector.Protect(key);
|
||||
|
||||
Assert.DoesNotContain(Convert.ToBase64String(key), stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_PlainBase64_IsAccepted_AndFlaggedForMigration()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var key = RoomCrypto.GenerateRoomKey();
|
||||
|
||||
Assert.True(protector.TryUnprotect(Convert.ToBase64String(key), out var recovered, out var wasLegacy));
|
||||
Assert.Equal(key, recovered);
|
||||
Assert.True(wasLegacy);
|
||||
|
||||
// The migration re-protects it; the upgraded value round-trips and is no longer legacy
|
||||
var upgraded = protector.Protect(recovered);
|
||||
Assert.True(protector.TryUnprotect(upgraded, out var recoveredAgain, out var stillLegacy));
|
||||
Assert.Equal(key, recoveredAgain);
|
||||
Assert.False(stillLegacy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Malformed_Values_AreRejected()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
|
||||
Assert.False(protector.TryUnprotect("not base64 at all!!", out _, out _));
|
||||
Assert.False(protector.TryUnprotect(RoomKeyProtector.KeyFilePrefix + "not base64!!", out _, out _));
|
||||
// Valid base64 but not a 32-byte room key → not a usable legacy entry
|
||||
Assert.False(protector.TryUnprotect(Convert.ToBase64String([1, 2, 3]), out _, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void KeyFile_Lost_MakesStoredValuesUnreadable_NotThrow()
|
||||
{
|
||||
var protector = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
var stored = protector.Protect(RoomCrypto.GenerateRoomKey());
|
||||
|
||||
File.Delete(Path.Combine(_dir, "roomkeys.key"));
|
||||
|
||||
// A fresh protector regenerates a different master key — the value must fail
|
||||
// cleanly (entry dropped, passphrase prompt recovers it), not throw
|
||||
var fresh = new RoomKeyProtector(_dir, useDpapi: false);
|
||||
Assert.False(fresh.TryUnprotect(stored, out _, out _));
|
||||
}
|
||||
}
|
||||
|
||||
public class RoomKeyStoreEnvelopeTests
|
||||
{
|
||||
// Note: without LoadForServer the store never touches the config file on disk —
|
||||
// these tests exercise the in-memory decision logic only.
|
||||
|
||||
private static RoomKeyStore NewStore() =>
|
||||
new(new RoomKeyProtector(Path.Combine(Path.GetTempPath(), "echohub-unused"), useDpapi: false));
|
||||
|
||||
[Fact]
|
||||
public void TryStoreFromEnvelope_FreshEnvelope_OverwritesStaleCachedKey()
|
||||
{
|
||||
var store = NewStore();
|
||||
var stale = RoomCrypto.GenerateRoomKey();
|
||||
store.StoreKey("vault", stale);
|
||||
|
||||
// Channel deleted and recreated under the same name → new room key, new envelope
|
||||
var fresh = RoomCrypto.GenerateRoomKey();
|
||||
var kek = RandomNumberGenerator.GetBytes(32);
|
||||
var wrapped = RoomCrypto.WrapRoomKey(fresh, kek);
|
||||
|
||||
Assert.True(store.TryStoreFromEnvelope("vault", wrapped, kek));
|
||||
Assert.True(store.TryGetKey("vault", out var current));
|
||||
Assert.Equal(fresh, current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryStoreFromEnvelope_WrongKek_KeepsCachedKey()
|
||||
{
|
||||
var store = NewStore();
|
||||
var cached = RoomCrypto.GenerateRoomKey();
|
||||
store.StoreKey("vault", cached);
|
||||
|
||||
var wrapped = RoomCrypto.WrapRoomKey(RoomCrypto.GenerateRoomKey(), RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
Assert.False(store.TryStoreFromEnvelope("vault", wrapped, RandomNumberGenerator.GetBytes(32)));
|
||||
Assert.True(store.TryGetKey("vault", out var current));
|
||||
Assert.Equal(cached, current);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkChannelEncrypted_TracksAndUntracks()
|
||||
{
|
||||
var store = NewStore();
|
||||
|
||||
Assert.False(store.IsChannelEncrypted("vault"));
|
||||
|
||||
store.MarkChannelEncrypted("vault", true);
|
||||
Assert.True(store.IsChannelEncrypted("vault"));
|
||||
Assert.True(store.IsChannelEncrypted("VAULT")); // channel names are case-insensitive
|
||||
|
||||
store.MarkChannelEncrypted("vault", false);
|
||||
Assert.False(store.IsChannelEncrypted("vault"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StoreKey_MarksChannelEncrypted()
|
||||
{
|
||||
var store = NewStore();
|
||||
store.StoreKey("vault", RoomCrypto.GenerateRoomKey());
|
||||
|
||||
Assert.True(store.IsChannelEncrypted("vault"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user