Add project files.
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
using System.Linq;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Signal protocol store for the linked account, seeded from the persisted <see cref="SignalAccount"/>
|
||||
/// (ACI identity + the signed/kyber prekeys registered at link time). Sessions and learned remote
|
||||
/// identities live in memory for the process lifetime (see SHORTCUTS.md: not yet persisted across runs).
|
||||
/// </summary>
|
||||
public sealed class AccountProtocolStore : ISignalProtocolStore
|
||||
{
|
||||
private readonly IdentityKeyPair _identityKeyPair;
|
||||
private readonly uint _registrationId;
|
||||
private readonly Dictionary<SignalProtocolAddress, IdentityKey> _identities = new();
|
||||
private readonly Dictionary<uint, PreKeyRecord> _preKeys = new();
|
||||
private readonly Dictionary<uint, SignedPreKeyRecord> _signedPreKeys = new();
|
||||
private readonly Dictionary<uint, KyberPreKeyRecord> _kyberPreKeys = new();
|
||||
private readonly Dictionary<SignalProtocolAddress, SessionRecord> _sessions = new();
|
||||
private readonly SignalAccount _account;
|
||||
private readonly Action? _onChanged;
|
||||
|
||||
/// <param name="onChanged">Invoked after a one-time prekey is consumed (removed from
|
||||
/// <see cref="SignalAccount.AciOneTimePreKeys"/>) so the caller can re-persist the account. Null =
|
||||
/// no persistence (tests / ephemeral use).</param>
|
||||
public AccountProtocolStore(SignalAccount account, Action? onChanged = null)
|
||||
{
|
||||
_account = account;
|
||||
_onChanged = onChanged;
|
||||
_identityKeyPair = account.AciIdentityKeyPair;
|
||||
_registrationId = account.AciRegistrationId;
|
||||
|
||||
RegisteredPreKeys pk = account.AciPreKeys;
|
||||
_signedPreKeys[pk.SignedPreKeyId] = new SignedPreKeyRecord(
|
||||
pk.SignedPreKeyId,
|
||||
new ECKeyPair(pk.SignedPreKeyPrivate, pk.SignedPreKeyPublic),
|
||||
pk.SignedPreKeySignature, timestamp: 0);
|
||||
_kyberPreKeys[pk.KyberPreKeyId] = new KyberPreKeyRecord(
|
||||
pk.KyberPreKeyId,
|
||||
new KyberKeyPair(pk.KyberPreKeyPublic, pk.KyberPreKeyPrivate),
|
||||
pk.KyberPreKeySignature, timestamp: 0);
|
||||
|
||||
foreach (OneTimePreKey otp in account.AciOneTimePreKeys)
|
||||
_preKeys[otp.Id] = new PreKeyRecord(otp.Id, new ECKeyPair(otp.Private, otp.Public));
|
||||
}
|
||||
|
||||
public IdentityKeyPair GetIdentityKeyPair() => _identityKeyPair;
|
||||
public uint GetLocalRegistrationId() => _registrationId;
|
||||
|
||||
public bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
bool changed = _identities.TryGetValue(address, out IdentityKey? existing)
|
||||
&& !existing!.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
_identities[address] = identity;
|
||||
return changed;
|
||||
}
|
||||
|
||||
public bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
if (!_identities.TryGetValue(address, out IdentityKey? existing)) return true; // first use
|
||||
return existing!.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
}
|
||||
|
||||
public IdentityKey? GetIdentity(SignalProtocolAddress address) =>
|
||||
_identities.TryGetValue(address, out IdentityKey? id) ? id : null;
|
||||
|
||||
public PreKeyRecord LoadPreKey(uint preKeyId) => _preKeys[preKeyId];
|
||||
public void StorePreKey(uint preKeyId, PreKeyRecord record) => _preKeys[preKeyId] = record;
|
||||
public bool ContainsPreKey(uint preKeyId) => _preKeys.ContainsKey(preKeyId);
|
||||
public void RemovePreKey(uint preKeyId)
|
||||
{
|
||||
_preKeys.Remove(preKeyId);
|
||||
// Persist consumption: a used one-time prekey must never be reused.
|
||||
int removed = _account.AciOneTimePreKeys.RemoveAll(k => k.Id == preKeyId);
|
||||
if (removed > 0) _onChanged?.Invoke();
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord LoadSignedPreKey(uint id) => _signedPreKeys[id];
|
||||
public void StoreSignedPreKey(uint id, SignedPreKeyRecord record) => _signedPreKeys[id] = record;
|
||||
public bool ContainsSignedPreKey(uint id) => _signedPreKeys.ContainsKey(id);
|
||||
|
||||
public KyberPreKeyRecord LoadKyberPreKey(uint id) => _kyberPreKeys[id];
|
||||
public void StoreKyberPreKey(uint id, KyberPreKeyRecord record) => _kyberPreKeys[id] = record;
|
||||
public bool ContainsKyberPreKey(uint id) => _kyberPreKeys.ContainsKey(id);
|
||||
public void MarkKyberPreKeyUsed(uint id) { /* last-resort key: kept */ }
|
||||
|
||||
public SessionRecord LoadSession(SignalProtocolAddress address) =>
|
||||
_sessions.TryGetValue(address, out SessionRecord? record) ? record : new SessionRecord();
|
||||
public bool ContainsSession(SignalProtocolAddress address) => _sessions.ContainsKey(address);
|
||||
public void StoreSession(SignalProtocolAddress address, SessionRecord record) => _sessions[address] = record;
|
||||
public void DeleteSession(SignalProtocolAddress address) => _sessions.Remove(address);
|
||||
|
||||
/// <summary>Device ids of <paramref name="name"/> that already have an established session — used to
|
||||
/// reuse active sessions on send (Sesame) instead of re-fetching a prekey bundle every time.</summary>
|
||||
public IReadOnlyList<uint> GetSubDeviceSessions(string name) =>
|
||||
_sessions.Keys.Where(a => a.Name == name).Select(a => a.DeviceId).ToList();
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Persists the linked <see cref="SignalAccount"/> to disk, encrypted at rest with Windows DPAPI
|
||||
/// (per-user). Stored under %LOCALAPPDATA%\Wingnal\account.bin.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class AccountStore
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.Account.v1"u8.ToArray();
|
||||
private readonly string _path;
|
||||
|
||||
public AccountStore(string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
_path = Path.Combine(directory, "account.bin");
|
||||
}
|
||||
|
||||
public bool Exists => File.Exists(_path);
|
||||
|
||||
public void Save(SignalAccount account)
|
||||
{
|
||||
byte[] json = JsonSerializer.SerializeToUtf8Bytes(account);
|
||||
byte[] encrypted = ProtectedData.Protect(json, Entropy, DataProtectionScope.CurrentUser);
|
||||
File.WriteAllBytes(_path, encrypted);
|
||||
}
|
||||
|
||||
public SignalAccount? Load()
|
||||
{
|
||||
if (!Exists)
|
||||
return null;
|
||||
byte[] encrypted = File.ReadAllBytes(_path);
|
||||
byte[] json = ProtectedData.Unprotect(encrypted, Entropy, DataProtectionScope.CurrentUser);
|
||||
return JsonSerializer.Deserialize<SignalAccount>(json);
|
||||
}
|
||||
|
||||
public void Delete()
|
||||
{
|
||||
if (Exists)
|
||||
File.Delete(_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>A synced contact: ACI + display info, used to name conversations.</summary>
|
||||
public sealed record Contact(string Aci, string? Number, string? Name, int InboxPosition);
|
||||
|
||||
/// <summary>
|
||||
/// SQLite store of contacts learned from a SyncMessage.Contacts blob, so the conversation list can show
|
||||
/// names instead of raw ACIs (%LOCALAPPDATA%\Wingnal\contacts.db). Keyed by ACI. Names + numbers are
|
||||
/// encrypted at rest with <see cref="LocalCipher"/> (so the file can't be read to map an ACI → a real
|
||||
/// person); legacy plaintext rows decrypt-through unchanged.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ContactsStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public ContactsStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "contacts.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
aci TEXT PRIMARY KEY,
|
||||
number TEXT,
|
||||
name TEXT,
|
||||
inboxPosition INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Upsert(Contact contact)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
INSERT INTO contacts (aci, number, name, inboxPosition) VALUES ($aci, $number, $name, $pos)
|
||||
ON CONFLICT(aci) DO UPDATE SET number = $number, name = $name, inboxPosition = $pos;
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("$aci", contact.Aci);
|
||||
cmd.Parameters.AddWithValue("$number", Enc(contact.Number));
|
||||
cmd.Parameters.AddWithValue("$name", Enc(contact.Name));
|
||||
cmd.Parameters.AddWithValue("$pos", contact.InboxPosition);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public string? NameFor(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT name FROM contacts WHERE aci = $aci;";
|
||||
cmd.Parameters.AddWithValue("$aci", aci);
|
||||
return cmd.ExecuteScalar() is string s ? _cipher.Unprotect(s) : null;
|
||||
}
|
||||
|
||||
/// <summary>Contacts whose name or number contains <paramref name="query"/> (empty = all named),
|
||||
/// ordered by inbox position then name. Filtered in memory because the columns are encrypted.</summary>
|
||||
public IReadOnlyList<Contact> Search(string? query, int limit = 25)
|
||||
{
|
||||
string q = (query ?? string.Empty).Trim();
|
||||
IEnumerable<Contact> contacts = All();
|
||||
contacts = q.Length == 0
|
||||
? contacts.Where(c => !string.IsNullOrEmpty(c.Name))
|
||||
: contacts.Where(c =>
|
||||
(c.Name?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
||||
(c.Number?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false));
|
||||
return contacts.OrderBy(c => c.InboxPosition).ThenBy(c => c.Name).Take(limit).ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<Contact> All()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT aci, number, name, inboxPosition FROM contacts ORDER BY inboxPosition;";
|
||||
var result = new List<Contact>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
result.Add(new Contact(
|
||||
reader.GetString(0),
|
||||
reader.IsDBNull(1) ? null : _cipher.Unprotect(reader.GetString(1)),
|
||||
reader.IsDBNull(2) ? null : _cipher.Unprotect(reader.GetString(2)),
|
||||
reader.GetInt32(3)));
|
||||
return result;
|
||||
}
|
||||
|
||||
private object Enc(string? value) => value is null ? DBNull.Value : _cipher.Protect(value);
|
||||
|
||||
/// <summary>Removes all synced contacts (used when unlinking).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM contacts;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Crypto;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Encrypts sensitive local-database fields at rest (message bodies, contact names) with AES-256-GCM
|
||||
/// under a per-install key. The key itself is a random 32 bytes wrapped with Windows DPAPI
|
||||
/// (CurrentUser) on disk, so the SQLite files no longer contain plaintext content. Also provides a
|
||||
/// deterministic keyed MAC for fields that must remain queryable/dedupable.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class LocalCipher
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.LocalCipher.v1"u8.ToArray();
|
||||
private readonly byte[] _key; // 32 bytes
|
||||
|
||||
public LocalCipher(byte[] key)
|
||||
{
|
||||
if (key.Length != 32) throw new ArgumentException("key must be 32 bytes", nameof(key));
|
||||
_key = key;
|
||||
}
|
||||
|
||||
/// <summary>Loads (or creates) the DPAPI-wrapped per-install key at %LOCALAPPDATA%\Wingnal\local.key.</summary>
|
||||
public static LocalCipher Default(string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
string keyPath = Path.Combine(directory, "local.key");
|
||||
|
||||
byte[] key;
|
||||
if (File.Exists(keyPath))
|
||||
{
|
||||
key = ProtectedData.Unprotect(File.ReadAllBytes(keyPath), Entropy, DataProtectionScope.CurrentUser);
|
||||
}
|
||||
else
|
||||
{
|
||||
key = RandomNumberGenerator.GetBytes(32);
|
||||
File.WriteAllBytes(keyPath, ProtectedData.Protect(key, Entropy, DataProtectionScope.CurrentUser));
|
||||
}
|
||||
return new LocalCipher(key);
|
||||
}
|
||||
|
||||
/// <summary>Encrypts a string to base64( nonce[12] ‖ ciphertext ‖ tag[16] ).</summary>
|
||||
public string Protect(string plaintext)
|
||||
{
|
||||
byte[] nonce = RandomNumberGenerator.GetBytes(12);
|
||||
byte[] ctAndTag = CryptoPrimitives.AesGcmEncrypt(_key, nonce, Encoding.UTF8.GetBytes(plaintext));
|
||||
var blob = new byte[nonce.Length + ctAndTag.Length];
|
||||
Buffer.BlockCopy(nonce, 0, blob, 0, nonce.Length);
|
||||
Buffer.BlockCopy(ctAndTag, 0, blob, nonce.Length, ctAndTag.Length);
|
||||
return Convert.ToBase64String(blob);
|
||||
}
|
||||
|
||||
/// <summary>Inverse of <see cref="Protect"/>. If <paramref name="stored"/> isn't a value we wrote
|
||||
/// (e.g. a legacy plaintext row from before encryption), it's returned unchanged.</summary>
|
||||
public string Unprotect(string stored)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] blob = Convert.FromBase64String(stored);
|
||||
if (blob.Length < 12 + 16) return stored;
|
||||
byte[] nonce = blob.AsSpan(0, 12).ToArray();
|
||||
byte[] ctAndTag = blob.AsSpan(12).ToArray();
|
||||
return Encoding.UTF8.GetString(CryptoPrimitives.AesGcmDecrypt(_key, nonce, ctAndTag));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return stored; // legacy plaintext or not ours — leave as-is
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic keyed identity of a value, for dedup/index columns (hex HMAC-SHA256).</summary>
|
||||
public string Mac(string value) =>
|
||||
Convert.ToHexString(CryptoPrimitives.HmacSha256(_key, Encoding.UTF8.GetBytes(value)));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using System.Runtime.Versioning;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Stores peers' profile keys (learned from the <c>profileKey</c> field of their inbound DataMessages),
|
||||
/// so we can derive their unidentified-access key and send them sealed-sender (metadata-minimized)
|
||||
/// messages. Profile keys are encrypted at rest with <see cref="LocalCipher"/>.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class ProfileKeyStore
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly LocalCipher _cipher;
|
||||
|
||||
public ProfileKeyStore(string? path = null, LocalCipher? cipher = null)
|
||||
{
|
||||
if (path is null)
|
||||
{
|
||||
string dir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(dir);
|
||||
path = Path.Combine(dir, "profilekeys.db");
|
||||
}
|
||||
_cipher = cipher ?? LocalCipher.Default();
|
||||
_connectionString = $"Data Source={path}";
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE TABLE IF NOT EXISTS profile_keys (aci TEXT PRIMARY KEY, key TEXT NOT NULL);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void Store(string aci, byte[] profileKey)
|
||||
{
|
||||
if (profileKey.Length != 32) return;
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT INTO profile_keys (aci, key) VALUES ($a, $k) ON CONFLICT(aci) DO UPDATE SET key = $k;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
cmd.Parameters.AddWithValue("$k", _cipher.Protect(Convert.ToBase64String(profileKey)));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public byte[]? Get(string aci)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT key FROM profile_keys WHERE aci = $a;";
|
||||
cmd.Parameters.AddWithValue("$a", aci);
|
||||
if (cmd.ExecuteScalar() is not string enc) return null;
|
||||
try { return Convert.FromBase64String(_cipher.Unprotect(enc)); } catch { return null; }
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM profile_keys;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Text;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Everything this linked secondary device needs to authenticate to Signal and run the protocol:
|
||||
/// account identifiers, the device id/password assigned at link time, registration ids, and the ACI
|
||||
/// and PNI identity key pairs (received from the primary during provisioning).
|
||||
/// </summary>
|
||||
public sealed class SignalAccount
|
||||
{
|
||||
public required string Aci { get; init; }
|
||||
public required string Pni { get; init; }
|
||||
public required string Number { get; init; }
|
||||
public required int DeviceId { get; set; }
|
||||
public required string Password { get; init; }
|
||||
public required uint AciRegistrationId { get; init; }
|
||||
public required uint PniRegistrationId { get; init; }
|
||||
|
||||
/// <summary>33-byte serialized DjbECPublicKey.</summary>
|
||||
public required byte[] AciIdentityPublic { get; init; }
|
||||
public required byte[] AciIdentityPrivate { get; init; }
|
||||
public required byte[] PniIdentityPublic { get; init; }
|
||||
public required byte[] PniIdentityPrivate { get; init; }
|
||||
public required byte[] ProfileKey { get; init; }
|
||||
|
||||
/// <summary>Signed + last-resort kyber prekeys registered at link time (ACI identity).</summary>
|
||||
public required RegisteredPreKeys AciPreKeys { get; init; }
|
||||
|
||||
/// <summary>Signed + last-resort kyber prekeys registered at link time (PNI identity).</summary>
|
||||
public required RegisteredPreKeys PniPreKeys { get; init; }
|
||||
|
||||
/// <summary>Unused one-time EC prekeys uploaded for the ACI identity (consumed as inbound sessions
|
||||
/// use them; removed on consumption and re-persisted). Not <c>required</c> so older account.bin
|
||||
/// files (without this field) still deserialize as empty.</summary>
|
||||
public List<OneTimePreKey> AciOneTimePreKeys { get; set; } = new();
|
||||
|
||||
/// <summary>The one-time 32-byte link'n'sync backup key the primary sent in the ProvisionMessage,
|
||||
/// used to decrypt the message-history transfer archive once. Cleared after a successful import (or
|
||||
/// null when the primary didn't offer link+sync). Not <c>required</c> for back-compat.</summary>
|
||||
public byte[]? EphemeralBackupKey { get; set; }
|
||||
|
||||
public IdentityKeyPair AciIdentityKeyPair =>
|
||||
new(IdentityKey.Decode(AciIdentityPublic), AciIdentityPrivate);
|
||||
|
||||
public IdentityKeyPair PniIdentityKeyPair =>
|
||||
new(IdentityKey.Decode(PniIdentityPublic), PniIdentityPrivate);
|
||||
|
||||
/// <summary>HTTP Basic auth value (without the "Basic " prefix) for the authenticated chat API.</summary>
|
||||
public string BasicAuthToken()
|
||||
{
|
||||
string user = DeviceId == 1 ? Aci : $"{Aci}.{DeviceId}";
|
||||
return Convert.ToBase64String(Encoding.UTF8.GetBytes($"{user}:{Password}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The durable private material for the prekeys this device registered for one identity.</summary>
|
||||
public sealed class RegisteredPreKeys
|
||||
{
|
||||
public required uint SignedPreKeyId { get; init; }
|
||||
public required byte[] SignedPreKeyPublic { get; init; } // raw 32
|
||||
public required byte[] SignedPreKeyPrivate { get; init; } // raw 32
|
||||
public required byte[] SignedPreKeySignature { get; init; }
|
||||
public required uint KyberPreKeyId { get; init; }
|
||||
public required byte[] KyberPreKeyPublic { get; init; } // ML-KEM encoded
|
||||
public required byte[] KyberPreKeyPrivate { get; init; }
|
||||
public required byte[] KyberPreKeySignature { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One unused one-time prekey's durable material (id + raw 32-byte EC key pair).</summary>
|
||||
public sealed class OneTimePreKey
|
||||
{
|
||||
public uint Id { get; set; }
|
||||
public byte[] Public { get; set; } = Array.Empty<byte>(); // raw 32
|
||||
public byte[] Private { get; set; } = Array.Empty<byte>(); // raw 32
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Protocol.Groups;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Durable <see cref="ISenderKeyStore"/>: persists group sender-key records to SQLite, keyed by
|
||||
/// (sender address, distribution id), so an established group chain survives an app restart — mirroring
|
||||
/// how <see cref="SqliteSignalProtocolStore"/> persists 1:1 sessions. Each record blob is DPAPI-protected
|
||||
/// at rest. State serialization is local-only (never sent to a peer).
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class SqliteSenderKeyStore : ISenderKeyStore
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.SenderKeyStore.v1"u8.ToArray();
|
||||
|
||||
private readonly string _connectionString;
|
||||
|
||||
public SqliteSenderKeyStore(string dbFileName = "senderkeys.db", string? directory = null)
|
||||
{
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
_connectionString = $"Data Source={Path.Combine(directory, dbFileName)}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sender_keys (
|
||||
name TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
distribution TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
PRIMARY KEY (name, device, distribution));
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public void StoreSenderKey(SignalProtocolAddress sender, Guid distributionId, SenderKeyRecord record)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"INSERT OR REPLACE INTO sender_keys (name, device, distribution, data) VALUES ($n, $d, $g, $data);";
|
||||
cmd.Parameters.AddWithValue("$n", sender.Name);
|
||||
cmd.Parameters.AddWithValue("$d", sender.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$g", distributionId.ToString("D"));
|
||||
cmd.Parameters.AddWithValue("$data", Protect(record.Serialize()));
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public SenderKeyRecord? LoadSenderKey(SignalProtocolAddress sender, Guid distributionId)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"SELECT data FROM sender_keys WHERE name = $n AND device = $d AND distribution = $g;";
|
||||
cmd.Parameters.AddWithValue("$n", sender.Name);
|
||||
cmd.Parameters.AddWithValue("$d", sender.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$g", distributionId.ToString("D"));
|
||||
return cmd.ExecuteScalar() is byte[] data ? SenderKeyRecord.Deserialize(Unprotect(data)) : null;
|
||||
}
|
||||
|
||||
/// <summary>Wipes all sender keys (used when unlinking, alongside the session/message wipe).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM sender_keys;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private static byte[] Protect(byte[] plain) => ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
|
||||
private static byte[] Unprotect(byte[] enc) => ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
using System.Linq;
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Wingnal.Protocol.Curve;
|
||||
using Wingnal.Protocol.State;
|
||||
|
||||
namespace Wingnal.Service.Account;
|
||||
|
||||
/// <summary>
|
||||
/// Durable Signal protocol store: identity + signed/kyber/one-time prekeys are seeded from the
|
||||
/// persisted <see cref="SignalAccount"/> (same as <see cref="AccountProtocolStore"/>), while sessions
|
||||
/// and learned remote identities persist to a SQLite DB so conversations survive app restarts. Each
|
||||
/// stored blob is DPAPI-protected at rest. One-time prekey consumption re-persists the account via the
|
||||
/// <c>onChanged</c> callback.
|
||||
/// </summary>
|
||||
[SupportedOSPlatform("windows")]
|
||||
public sealed class SqliteSignalProtocolStore : ISignalProtocolStore
|
||||
{
|
||||
private static readonly byte[] Entropy = "Wingnal.ProtocolStore.v1"u8.ToArray();
|
||||
|
||||
private readonly SignalAccount _account;
|
||||
private readonly Action? _onChanged;
|
||||
private readonly IdentityKeyPair _identityKeyPair;
|
||||
private readonly uint _registrationId;
|
||||
private readonly Dictionary<uint, PreKeyRecord> _preKeys = new();
|
||||
private readonly Dictionary<uint, SignedPreKeyRecord> _signedPreKeys = new();
|
||||
private readonly Dictionary<uint, KyberPreKeyRecord> _kyberPreKeys = new();
|
||||
private readonly string _connectionString;
|
||||
|
||||
/// <param name="dbFileName">Distinct file lets send/receive keep separate session spaces.</param>
|
||||
public SqliteSignalProtocolStore(SignalAccount account, string dbFileName = "protocol.db",
|
||||
Action? onChanged = null, string? directory = null)
|
||||
{
|
||||
_account = account;
|
||||
_onChanged = onChanged;
|
||||
_identityKeyPair = account.AciIdentityKeyPair;
|
||||
_registrationId = account.AciRegistrationId;
|
||||
|
||||
RegisteredPreKeys pk = account.AciPreKeys;
|
||||
_signedPreKeys[pk.SignedPreKeyId] = new SignedPreKeyRecord(
|
||||
pk.SignedPreKeyId, new ECKeyPair(pk.SignedPreKeyPrivate, pk.SignedPreKeyPublic), pk.SignedPreKeySignature, 0);
|
||||
_kyberPreKeys[pk.KyberPreKeyId] = new KyberPreKeyRecord(
|
||||
pk.KyberPreKeyId, new KyberKeyPair(pk.KyberPreKeyPublic, pk.KyberPreKeyPrivate), pk.KyberPreKeySignature, 0);
|
||||
foreach (OneTimePreKey otp in account.AciOneTimePreKeys)
|
||||
_preKeys[otp.Id] = new PreKeyRecord(otp.Id, new ECKeyPair(otp.Private, otp.Public));
|
||||
|
||||
directory ??= Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Wingnal");
|
||||
Directory.CreateDirectory(directory);
|
||||
_connectionString = $"Data Source={Path.Combine(directory, dbFileName)}";
|
||||
Initialize();
|
||||
}
|
||||
|
||||
private void Initialize()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText =
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS sessions (name TEXT NOT NULL, device INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (name, device));
|
||||
CREATE TABLE IF NOT EXISTS identities (name TEXT NOT NULL, device INTEGER NOT NULL, data BLOB NOT NULL, PRIMARY KEY (name, device));
|
||||
""";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── identities ──
|
||||
public IdentityKeyPair GetIdentityKeyPair() => _identityKeyPair;
|
||||
public uint GetLocalRegistrationId() => _registrationId;
|
||||
|
||||
public bool SaveIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
IdentityKey? existing = GetIdentity(address);
|
||||
bool changed = existing is not null && !existing.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
Upsert("identities", address, Protect(identity.PublicKey));
|
||||
return changed;
|
||||
}
|
||||
|
||||
public bool IsTrustedIdentity(SignalProtocolAddress address, IdentityKey identity)
|
||||
{
|
||||
IdentityKey? existing = GetIdentity(address);
|
||||
return existing is null || existing.PublicKey.AsSpan().SequenceEqual(identity.PublicKey);
|
||||
}
|
||||
|
||||
public IdentityKey? GetIdentity(SignalProtocolAddress address)
|
||||
{
|
||||
byte[]? data = Load("identities", address);
|
||||
return data is null ? null : new IdentityKey(Unprotect(data));
|
||||
}
|
||||
|
||||
// ── prekeys (from account; consumption re-persists the account) ──
|
||||
public PreKeyRecord LoadPreKey(uint preKeyId) => _preKeys[preKeyId];
|
||||
public void StorePreKey(uint preKeyId, PreKeyRecord record) => _preKeys[preKeyId] = record;
|
||||
public bool ContainsPreKey(uint preKeyId) => _preKeys.ContainsKey(preKeyId);
|
||||
public void RemovePreKey(uint preKeyId)
|
||||
{
|
||||
_preKeys.Remove(preKeyId);
|
||||
if (_account.AciOneTimePreKeys.RemoveAll(k => k.Id == preKeyId) > 0) _onChanged?.Invoke();
|
||||
}
|
||||
|
||||
public SignedPreKeyRecord LoadSignedPreKey(uint id) => _signedPreKeys[id];
|
||||
public void StoreSignedPreKey(uint id, SignedPreKeyRecord record) => _signedPreKeys[id] = record;
|
||||
public bool ContainsSignedPreKey(uint id) => _signedPreKeys.ContainsKey(id);
|
||||
|
||||
public KyberPreKeyRecord LoadKyberPreKey(uint id) => _kyberPreKeys[id];
|
||||
public void StoreKyberPreKey(uint id, KyberPreKeyRecord record) => _kyberPreKeys[id] = record;
|
||||
public bool ContainsKyberPreKey(uint id) => _kyberPreKeys.ContainsKey(id);
|
||||
public void MarkKyberPreKeyUsed(uint id) { /* last-resort key: kept */ }
|
||||
|
||||
// ── sessions (durable) ──
|
||||
public SessionRecord LoadSession(SignalProtocolAddress address)
|
||||
{
|
||||
byte[]? data = Load("sessions", address);
|
||||
return data is null ? new SessionRecord() : SessionRecord.Deserialize(Unprotect(data));
|
||||
}
|
||||
|
||||
public bool ContainsSession(SignalProtocolAddress address) => Load("sessions", address) is not null;
|
||||
public void StoreSession(SignalProtocolAddress address, SessionRecord record) =>
|
||||
Upsert("sessions", address, Protect(record.Serialize()));
|
||||
|
||||
public void DeleteSession(SignalProtocolAddress address)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM sessions WHERE name = $n AND device = $d;";
|
||||
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public IReadOnlyList<uint> GetSubDeviceSessions(string name)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT device FROM sessions WHERE name = $n;";
|
||||
cmd.Parameters.AddWithValue("$n", name);
|
||||
var result = new List<uint>();
|
||||
using SqliteDataReader reader = cmd.ExecuteReader();
|
||||
while (reader.Read()) result.Add((uint)reader.GetInt64(0));
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Forgets a peer's learned identities + sessions across all their devices. Used to APPROVE
|
||||
/// a changed identity: after this, the next session re-establishes trust-on-first-use with the new
|
||||
/// key (and the dead sessions tied to the old key are dropped).</summary>
|
||||
public void ResetPeer(string name)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM identities WHERE name = $n; DELETE FROM sessions WHERE name = $n;";
|
||||
cmd.Parameters.AddWithValue("$n", name);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
/// <summary>Wipes all sessions + learned identities (used when unlinking, so a re-link starts fresh
|
||||
/// and never reuses sessions tied to the old identity keys).</summary>
|
||||
public void Clear()
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM sessions; DELETE FROM identities;";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
private void Upsert(string table, SignalProtocolAddress address, byte[] data)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"INSERT OR REPLACE INTO {table} (name, device, data) VALUES ($n, $d, $data);";
|
||||
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||
cmd.Parameters.AddWithValue("$data", data);
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
private byte[]? Load(string table, SignalProtocolAddress address)
|
||||
{
|
||||
using SqliteConnection conn = Open();
|
||||
using SqliteCommand cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"SELECT data FROM {table} WHERE name = $n AND device = $d;";
|
||||
cmd.Parameters.AddWithValue("$n", address.Name);
|
||||
cmd.Parameters.AddWithValue("$d", address.DeviceId);
|
||||
return cmd.ExecuteScalar() as byte[];
|
||||
}
|
||||
|
||||
private static byte[] Protect(byte[] plain) => ProtectedData.Protect(plain, Entropy, DataProtectionScope.CurrentUser);
|
||||
private static byte[] Unprotect(byte[] enc) => ProtectedData.Unprotect(enc, Entropy, DataProtectionScope.CurrentUser);
|
||||
|
||||
private SqliteConnection Open()
|
||||
{
|
||||
var conn = new SqliteConnection(_connectionString);
|
||||
conn.Open();
|
||||
return conn;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user