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);