mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 07:36:03 +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,120 @@
|
||||
using System.Security.Cryptography;
|
||||
using EchoHub.Core.Security;
|
||||
using Serilog;
|
||||
|
||||
namespace EchoHub.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts cached room content keys at rest so the client config never holds them as
|
||||
/// plain base64. Windows uses DPAPI (current-user scope, format prefix "dp1:"). On other
|
||||
/// platforms the keys are AES-GCM encrypted with a per-user master key file stored next
|
||||
/// to the config with 0600 permissions (prefix "k1:") — without an OS keychain that is
|
||||
/// file-permission-level protection, not zero-knowledge: anyone who can read both the
|
||||
/// config and the key file can recover the room keys. Values with no recognized prefix
|
||||
/// are legacy plain-base64 keys from older clients; they load once and are re-encrypted.
|
||||
/// The room passphrase itself is never stored in any form.
|
||||
/// </summary>
|
||||
public sealed class RoomKeyProtector
|
||||
{
|
||||
public const string DpapiPrefix = "dp1:";
|
||||
public const string KeyFilePrefix = "k1:";
|
||||
|
||||
private const string KeyFileName = "roomkeys.key";
|
||||
private const int MasterKeySizeBytes = 32;
|
||||
private const int RoomKeySizeBytes = 32;
|
||||
|
||||
private readonly string _keyFilePath;
|
||||
private readonly bool _useDpapi;
|
||||
private readonly Lock _lock = new();
|
||||
private byte[]? _masterKey;
|
||||
|
||||
/// <param name="keyDirectory">Directory holding the master key file (the client config dir).</param>
|
||||
/// <param name="useDpapi">Overrides the platform default (DPAPI on Windows) — for tests.</param>
|
||||
public RoomKeyProtector(string keyDirectory, bool? useDpapi = null)
|
||||
{
|
||||
_keyFilePath = Path.Combine(keyDirectory, KeyFileName);
|
||||
_useDpapi = useDpapi ?? OperatingSystem.IsWindows();
|
||||
}
|
||||
|
||||
/// <summary>Encrypts a room key for storage in the config file.</summary>
|
||||
public string Protect(byte[] roomKey)
|
||||
{
|
||||
if (_useDpapi && OperatingSystem.IsWindows())
|
||||
return DpapiPrefix + Convert.ToBase64String(
|
||||
ProtectedData.Protect(roomKey, null, DataProtectionScope.CurrentUser));
|
||||
|
||||
return KeyFilePrefix + Convert.ToBase64String(RoomCrypto.EncryptBytes(roomKey, GetMasterKey()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts a stored value back into a room key. <paramref name="wasLegacy"/> is true when
|
||||
/// the value was an unencrypted legacy entry that should be re-persisted via
|
||||
/// <see cref="Protect"/>. Returns false for unreadable values (wrong user/machine, missing
|
||||
/// or regenerated key file, malformed data) — the caller drops the entry and the user can
|
||||
/// recover it by re-entering the passphrase.
|
||||
/// </summary>
|
||||
public bool TryUnprotect(string stored, out byte[] roomKey, out bool wasLegacy)
|
||||
{
|
||||
roomKey = [];
|
||||
wasLegacy = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (stored.StartsWith(DpapiPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
return false; // config copied from a Windows machine
|
||||
|
||||
roomKey = ProtectedData.Unprotect(
|
||||
Convert.FromBase64String(stored[DpapiPrefix.Length..]), null, DataProtectionScope.CurrentUser);
|
||||
return roomKey.Length > 0;
|
||||
}
|
||||
|
||||
if (stored.StartsWith(KeyFilePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
if (!File.Exists(_keyFilePath))
|
||||
return false;
|
||||
|
||||
roomKey = RoomCrypto.DecryptBytes(
|
||||
Convert.FromBase64String(stored[KeyFilePrefix.Length..]), GetMasterKey());
|
||||
return roomKey.Length > 0;
|
||||
}
|
||||
|
||||
// No recognized prefix — legacy plain-base64 room key from a pre-encryption client
|
||||
roomKey = Convert.FromBase64String(stored);
|
||||
wasLegacy = true;
|
||||
return roomKey.Length == RoomKeySizeBytes;
|
||||
}
|
||||
catch (Exception ex) when (ex is FormatException or CryptographicException
|
||||
or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private byte[] GetMasterKey()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_masterKey is not null)
|
||||
return _masterKey;
|
||||
|
||||
if (File.Exists(_keyFilePath))
|
||||
{
|
||||
var existing = File.ReadAllBytes(_keyFilePath);
|
||||
if (existing.Length == MasterKeySizeBytes)
|
||||
return _masterKey = existing;
|
||||
|
||||
Log.Warning("Room-key master key file has unexpected size — regenerating (previously cached keys become unreadable)");
|
||||
}
|
||||
|
||||
var key = RandomNumberGenerator.GetBytes(MasterKeySizeBytes);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_keyFilePath)!);
|
||||
File.WriteAllBytes(_keyFilePath, key);
|
||||
if (!OperatingSystem.IsWindows())
|
||||
File.SetUnixFileMode(_keyFilePath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
|
||||
return _masterKey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user