using System.Net.Http.Json; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace Keyguard.Client; /// Minimal secure client — HMAC on every call after init. public sealed class KeyguardClient { private readonly HttpClient _http; private readonly string _baseUrl; private string? _sessionId; private byte[]? _signingKey; private byte[]? _encryptionKey; public KeyguardClient(string baseUrl, HttpClient? http = null) { _baseUrl = baseUrl.TrimEnd('/'); _http = http ?? new HttpClient(); } public async Task InitAsync(string appName, string ownerId, string? version = null, string? hash = null, CancellationToken ct = default) { var res = await _http.PostAsJsonAsync($"{_baseUrl}/api/client/v1/init", new { name = appName, ownerId, ver = version, hash }, ct); res.EnsureSuccessStatusCode(); using var doc = await JsonDocument.ParseAsync(await res.Content.ReadAsStreamAsync(ct), cancellationToken: ct); var root = doc.RootElement; if (!root.GetProperty("success").GetBoolean()) throw new InvalidOperationException(root.GetProperty("message").GetString()); _sessionId = root.GetProperty("sessionid").GetString(); var encB64 = root.GetProperty("enckey").GetString()!; _encryptionKey = Convert.FromBase64String(encB64); _signingKey = _encryptionKey; } public async Task LoginAsync(string username, string password, string hwid, CancellationToken ct = default) => await DispatchAsync("login", new { username, password, hwid }, ct); public async Task LicenseAsync(string key, string hwid, CancellationToken ct = default) => await DispatchAsync("license", new { key, hwid }, ct); public async Task CheckAsync(CancellationToken ct = default) => await DispatchAsync("check", new { }, ct); public async Task RegisterAsync(string username, string password, string licenseKey, string hwid, CancellationToken ct = default) => await DispatchAsync("register", new { username, password, key = licenseKey, hwid }, ct); public async Task LogoutAsync(CancellationToken ct = default) => await DispatchAsync("logout", new { }, ct); public async Task ChangePasswordAsync(string currentPassword, string newPassword, bool invalidateOtherSessions = true, CancellationToken ct = default) => await DispatchAsync("changepassword", new { current_password = currentPassword, new_password = newPassword, invalidate_other_sessions = invalidateOtherSessions }, ct); public async Task RenewAsync(string username, string password, string licenseKey, CancellationToken ct = default) => await DispatchAsync("renew", new { username, password, key = licenseKey }, ct); public async Task CheckUpdatesAsync(CancellationToken ct = default) => await DispatchAsync("updates", new { }, ct); /// /// Download a stored file. Pass the SHA-256 from the Files tab so a swapped payload is rejected. /// public async Task FileAsync(string fileId, string? expectedSha256 = null, CancellationToken ct = default) { using var doc = await DispatchAsync("file", new { fileid = fileId }, ct); var root = doc.RootElement; if (!root.GetProperty("success").GetBoolean()) throw new InvalidOperationException( root.TryGetProperty("message", out var msg) ? msg.GetString() : "file failed"); var sha = root.GetProperty("sha256").GetString() ?? ""; var contents = root.GetProperty("contents").GetString() ?? ""; var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? fileId : fileId; if (!string.IsNullOrEmpty(expectedSha256) && !string.Equals(sha, expectedSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SHA-256 mismatch — refuse this file."); return new KeyguardFile(name, sha, Convert.FromBase64String(contents)); } private async Task DispatchAsync(string type, object payload, CancellationToken ct) { EnsureSession(); var payloadJson = JsonSerializer.Serialize(payload); var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant(); var signature = Sign(type, timestamp, nonce, _sessionId!, payloadJson); var envelope = new { type, sessionid = _sessionId, timestamp, nonce, signature, payload = JsonSerializer.Deserialize(payloadJson) }; var res = await _http.PostAsJsonAsync($"{_baseUrl}/api/client/v1/dispatch", envelope, ct); return await JsonDocument.ParseAsync(await res.Content.ReadAsStreamAsync(ct), cancellationToken: ct); } private string Sign(string type, long timestamp, string nonce, string sessionId, string payloadJson) { var canonical = $"{type}|{timestamp}|{nonce}|{sessionId}|{payloadJson}"; var hash = HMACSHA256.HashData(_signingKey!, Encoding.UTF8.GetBytes(canonical)); return Convert.ToHexString(hash).ToLowerInvariant(); } private void EnsureSession() { if (_sessionId is null || _signingKey is null) throw new InvalidOperationException("Call InitAsync first."); } } public sealed record KeyguardFile(string Name, string Sha256, byte[] Bytes);