Add project files.

This commit is contained in:
micro
2026-06-20 09:31:59 -05:00
parent 9bb3f00d86
commit 4baa4ce8c0
346 changed files with 55751 additions and 0 deletions
@@ -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();
}
+48
View File
@@ -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);
}
}
+124
View File
@@ -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;
}
}
+79
View File
@@ -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;
}
}
+78
View File
@@ -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;
}
}