POST /v1/identity/verify: an AERE Identity presentation judged by the published command line -> VALID / PARTIAL / INVALID with every row, the claims shown and the subject when no row failed, and the command that reproduces the verdict. POST /v1/compliance/check: your policy judged on a presentation -> COMPLIANT / NOT_COMPLIANT with the reasons and, with record, the decision as an AIP-23 envelope without personal data. In Go the presentation, keys, lists and policy travel as json.RawMessage: a map or float64 would change numbers above 2^53 and the canonical text the signatures cover. The .NET negative control now builds clean for each planted defect (an incremental build could run the tests against the previous planted library). A negative control for the JavaScript and Python routes is added. Tests: JavaScript 13, Python 14, Go ok, Java 10/10 (and the Java 8 API gate); negative controls Go 9/9, Java 10/10, .NET 12/12, JavaScript and Python 4/4.
266 lines
18 KiB
C#
266 lines
18 KiB
C#
// Aere.Cloud - the official .NET client for the Aere Cloud API (Aere Network, chain 2800).
|
|
//
|
|
// Same routes and semantics as @aere/cloud (JS), aere-cloud (Python), aere-cloud (Java) and the Go client, 1.7.0. No dependency beyond
|
|
// the .NET base library. JSON routes return the server's JSON as a JsonElement, so the integrator binds it to whatever types they
|
|
// already use; the two routes that return FILES (the audit export and the check of a kept export) return typed results with the
|
|
// digest computed locally from the bytes received. Non-2xx answers are thrown as AereCloudException (HTTP status, server body, path).
|
|
//
|
|
// Nota interna: comentariile de implementare sunt in romana fara diacritice, ca in celelalte SDK-uri ale casei; documentatia publica
|
|
// (comentariile XML ale membrilor publici) e in engleza.
|
|
|
|
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
namespace Aere.Cloud;
|
|
|
|
/// <summary>A non-2xx answer from Aere Cloud. <see cref="Status"/> 0 means the request never got an answer.</summary>
|
|
public sealed class AereCloudException : Exception
|
|
{
|
|
/// <summary>HTTP status (0 when there was no answer).</summary>
|
|
public int Status { get; }
|
|
/// <summary>The server's body, as text.</summary>
|
|
public string Body { get; }
|
|
/// <summary>The API path that failed.</summary>
|
|
public string Path { get; }
|
|
|
|
/// <summary>Creates the exception.</summary>
|
|
public AereCloudException(int status, string body, string path)
|
|
: base($"Aere Cloud {status} on {path}: {(string.IsNullOrEmpty(body) ? "request failed" : body)}")
|
|
{
|
|
Status = status; Body = body; Path = path;
|
|
}
|
|
}
|
|
|
|
/// <summary>Options for <see cref="AereCloudClient.ReadinessPerimeterAsync"/>.</summary>
|
|
public sealed record PerimeterOptions(IReadOnlyList<string>? Hosts = null, bool? Discover = null, bool Attest = false);
|
|
|
|
/// <summary>An audit day as a file. For jsonl, <see cref="Verified"/> says whether the sha256 of the bytes RECEIVED equals the stated
|
|
/// digest (computed here, not taken on trust); for cef it is null, because the digest is the one of the source file.</summary>
|
|
public sealed record AuditExport(string? Day, string Format, string? Digest, byte[] Bytes, int Entries, bool? Verified)
|
|
{
|
|
/// <summary>The bytes as UTF-8 text.</summary>
|
|
public string Text => Encoding.UTF8.GetString(Bytes);
|
|
}
|
|
|
|
/// <summary>Does an export you KEPT still match the day we hold ("held"), or the tombstone a deleted day left ("expired")?</summary>
|
|
public sealed record KeptCheck(string Day, string State, string Digest, string Local, bool Matches, string? DeletedAt);
|
|
|
|
/// <summary>One Aere Cloud gateway with one API key (ak2800.<address>.<secret>).</summary>
|
|
public sealed class AereCloudClient : IDisposable
|
|
{
|
|
/// <summary>Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route).</summary>
|
|
public const string Version = "1.7.0";
|
|
/// <summary>The production gateway.</summary>
|
|
public const string DefaultBaseUrl = "https://cloud.aere.network/v1";
|
|
|
|
private readonly string _apiKey;
|
|
private readonly string _baseUrl;
|
|
private readonly HttpClient _http;
|
|
|
|
/// <summary>A client for <paramref name="baseUrl"/> (default: production). The API key is required. A test may pass its own
|
|
/// <paramref name="handler"/>; the default one does NOT follow redirects, so the key never travels to a host named in a Location.</summary>
|
|
public AereCloudClient(string apiKey, string? baseUrl = null, HttpMessageHandler? handler = null)
|
|
{
|
|
if (string.IsNullOrEmpty(apiKey)) throw new ArgumentException("Aere.Cloud: apiKey is required (ak2800.<address>.<secret>)", nameof(apiKey));
|
|
_apiKey = apiKey;
|
|
_baseUrl = (baseUrl ?? DefaultBaseUrl).TrimEnd('/');
|
|
// fara redirectari urmate: un 3xx e un raspuns (AereCloudException cu statusul lui), nu un drum spre alta gazda care ar primi cheia
|
|
_http = new HttpClient(handler ?? new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30) };
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public void Dispose() => _http.Dispose();
|
|
|
|
private sealed record Raspuns(int Status, byte[] Body, Func<string, string?> Antet);
|
|
|
|
private async Task<Raspuns> DoAsync(HttpMethod method, string path, object? body, bool auth, CancellationToken ct)
|
|
{
|
|
using var req = new HttpRequestMessage(method, _baseUrl + path);
|
|
req.Headers.UserAgent.ParseAdd("aere-cloud-csharp/" + Version);
|
|
if (auth) req.Headers.Add("x-api-key", _apiKey);
|
|
if (body is not null)
|
|
{
|
|
req.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body));
|
|
req.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
|
|
}
|
|
HttpResponseMessage r;
|
|
try { r = await _http.SendAsync(req, ct).ConfigureAwait(false); }
|
|
catch (HttpRequestException e) { throw new AereCloudException(0, e.Message, path); } // mesajul de retea nu poarta cheia (e in antet)
|
|
using (r)
|
|
{
|
|
var b = await r.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
|
var h = r.Headers; var hc = r.Content.Headers;
|
|
string? Antet(string n) => h.TryGetValues(n, out var v) ? v.FirstOrDefault() : hc.TryGetValues(n, out var w) ? w.FirstOrDefault() : null;
|
|
return new Raspuns((int)r.StatusCode, b, Antet);
|
|
}
|
|
}
|
|
|
|
private async Task<JsonElement> JsonAsync(HttpMethod method, string path, object? body, bool auth, CancellationToken ct)
|
|
{
|
|
var r = await DoAsync(method, path, body, auth, ct).ConfigureAwait(false);
|
|
if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), path);
|
|
using var d = JsonDocument.Parse(r.Body.Length == 0 ? "null"u8.ToArray() : r.Body);
|
|
return d.RootElement.Clone();
|
|
}
|
|
|
|
private static string Esc(string s) => Uri.EscapeDataString(s);
|
|
private static string Sha256Hex(byte[] b) => "0x" + Convert.ToHexString(SHA256.HashData(b)).ToLowerInvariant();
|
|
|
|
// ---- health, account, JSON-RPC ----
|
|
/// <summary>GET /health (no key).</summary>
|
|
public Task<JsonElement> HealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/health", null, false, ct);
|
|
/// <summary>GET /account.</summary>
|
|
public Task<JsonElement> AccountAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account", null, true, ct);
|
|
|
|
/// <summary>One JSON-RPC call; returns the whole JSON-RPC envelope.</summary>
|
|
public Task<JsonElement> RpcAsync(string method, IReadOnlyList<object?>? parameters = null, CancellationToken ct = default) =>
|
|
JsonAsync(HttpMethod.Post, "/rpc", new Dictionary<string, object?> { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = method, ["params"] = parameters ?? Array.Empty<object?>() }, true, ct);
|
|
|
|
/// <summary>eth_blockNumber as an integer.</summary>
|
|
public async Task<ulong> BlockNumberAsync(CancellationToken ct = default)
|
|
{
|
|
var e = await RpcAsync("eth_blockNumber", null, ct).ConfigureAwait(false);
|
|
if (e.ValueKind != JsonValueKind.Object || !e.TryGetProperty("result", out var r) || r.GetString() is not { } s || !s.StartsWith("0x", StringComparison.Ordinal))
|
|
throw new AereCloudException(200, e.GetRawText(), "/rpc");
|
|
return Convert.ToUInt64(s[2..], 16);
|
|
}
|
|
|
|
// ---- post-quantum verification, the Trust API, chain data, notarization, proofs ----
|
|
/// <summary>POST /pq/verify, payload like {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."}.</summary>
|
|
public Task<JsonElement> PqVerifyAsync(object payload, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/pq/verify", payload, true, ct);
|
|
|
|
/// <summary>The Trust API (1.6.0): one AERE Proof Protocol (AIP-23) envelope of any kind -> VALID / INVALID / PARTIAL with the levels
|
|
/// checked and the command that reproduces the verdict. <paramref name="chain"/>: 2800 or 28001; 0 = the chain the envelope declares.</summary>
|
|
public Task<JsonElement> VerifyAsync(object envelope, ulong chain = 0, CancellationToken ct = default) =>
|
|
JsonAsync(HttpMethod.Post, "/verify" + (chain > 0 ? "?chain=" + chain : ""), envelope, true, ct);
|
|
|
|
/// <summary>Identity verification (1.7.0): {presentation, audience, nonce, trustedIssuers?, statusLists?, revocations?, maxAgeSeconds?} ->
|
|
/// VALID / PARTIAL / INVALID with every row, judged by the published AERE Identity command line. Read "verdict" (PARTIAL: some rows not judged).</summary>
|
|
public Task<JsonElement> IdentityVerifyAsync(object request, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/identity/verify", request, true, ct);
|
|
|
|
/// <summary>Compliance check (1.7.0): {presentation, policy, audience, nonce, statusLists?, revocations?, record?} -> COMPLIANT / NOT_COMPLIANT
|
|
/// with the reasons and, with record, an AIP-23 record without personal data.</summary>
|
|
public Task<JsonElement> ComplianceCheckAsync(object request, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/compliance/check", request, true, ct);
|
|
|
|
/// <summary>GET /data/head.</summary>
|
|
public Task<JsonElement> DataHeadAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/head", null, true, ct);
|
|
/// <summary>GET /data/validators.</summary>
|
|
public Task<JsonElement> ValidatorsAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/validators", null, true, ct);
|
|
/// <summary>GET /data/anchors?limit=.</summary>
|
|
public Task<JsonElement> AnchorsAsync(int limit = 10, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/anchors?limit=" + limit, null, true, ct);
|
|
/// <summary>GET /data/anchors/{height}.</summary>
|
|
public Task<JsonElement> AnchorAsync(ulong height, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/anchors/" + height, null, true, ct);
|
|
/// <summary>The post-quantum finality point (finalized/safe = the parent of the last certified anchor).</summary>
|
|
public Task<JsonElement> FinalityAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/finality", null, true, ct);
|
|
/// <summary>GET /data/transfers?address=&limit=.</summary>
|
|
public Task<JsonElement> TransfersAsync(string address, int limit = 50, CancellationToken ct = default) =>
|
|
JsonAsync(HttpMethod.Get, "/data/transfers?address=" + Esc(address) + "&limit=" + limit, null, true, ct);
|
|
/// <summary>Notarizes a 32-byte digest (0x + 64 hex); the gas is ours.</summary>
|
|
public Task<JsonElement> NotarizeAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/notarize", new Dictionary<string, string> { ["hash"] = hash }, true, ct);
|
|
/// <summary>GET /notarize/{hash}.</summary>
|
|
public Task<JsonElement> ProofOfAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/notarize/" + Esc(hash), null, true, ct);
|
|
/// <summary>The proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps).</summary>
|
|
public Task<JsonElement> ProofAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/proof/" + Esc(hash), null, true, ct);
|
|
|
|
// ---- quantum readiness ----
|
|
/// <summary>Scans a domain's public TLS edge. Free WITHOUT a key; <paramref name="attest"/> notarizes the report digest and only then sends the key.</summary>
|
|
public Task<JsonElement> ReadinessAsync(string domain, bool fresh = false, bool attest = false, CancellationToken ct = default)
|
|
{
|
|
var b = new Dictionary<string, object> { ["domain"] = domain };
|
|
if (fresh) b["fresh"] = true;
|
|
if (attest) b["attest"] = true;
|
|
return JsonAsync(HttpMethod.Post, "/pq/readiness", b, attest, ct);
|
|
}
|
|
/// <summary>GET /pq/readiness/{domain} (no key).</summary>
|
|
public Task<JsonElement> ReadinessReportAsync(string domain, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/pq/readiness/" + Esc(domain), null, false, ct);
|
|
|
|
/// <summary>The whole public perimeter of a domain in one keyed report; call again while summary.complete is false.</summary>
|
|
public Task<JsonElement> ReadinessPerimeterAsync(string domain, PerimeterOptions? o = null, CancellationToken ct = default)
|
|
{
|
|
o ??= new PerimeterOptions();
|
|
var b = new Dictionary<string, object> { ["domain"] = domain };
|
|
if (o.Hosts is not null) b["hosts"] = o.Hosts;
|
|
if (o.Discover == false) b["discover"] = false;
|
|
if (o.Attest) b["attest"] = true;
|
|
return JsonAsync(HttpMethod.Post, "/pq/readiness/perimeter", b, true, ct);
|
|
}
|
|
|
|
/// <summary>AIP-21 per-block post-quantum finality. client: "besu", "nethermind" or "both" (with `agreement`). No key.</summary>
|
|
public Task<JsonElement> PqFinalityAsync(string block = "latest", string network = "testnet", string client = "besu", CancellationToken ct = default) =>
|
|
JsonAsync(HttpMethod.Get, "/pq/finality/" + Esc(block) + "?network=" + Esc(network) + "&client=" + Esc(client), null, false, ct);
|
|
|
|
// ---- audit log ----
|
|
/// <summary>The paged JSON view of one UTC day (null = today), with the digest of the day file.</summary>
|
|
public Task<JsonElement> AuditLogAsync(string? day = null, int offset = 0, int limit = 1000, CancellationToken ct = default) =>
|
|
JsonAsync(HttpMethod.Get, "/account/audit?offset=" + offset + "&limit=" + limit + (day is null ? "" : "&day=" + Esc(day)), null, true, ct);
|
|
|
|
/// <summary>A day as a file: "jsonl" (the exact bytes of the day file, checked here against the stated digest) or "cef".</summary>
|
|
public async Task<AuditExport> AuditExportAsync(string? day = null, string format = "jsonl", CancellationToken ct = default)
|
|
{
|
|
var r = await DoAsync(HttpMethod.Get, "/account/audit?format=" + Esc(format) + (day is null ? "" : "&day=" + Esc(day)), null, true, ct).ConfigureAwait(false);
|
|
if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), "/account/audit");
|
|
var digest = r.Antet("x-aere-digest");
|
|
bool? verified = format == "jsonl" ? !string.IsNullOrEmpty(digest) && digest == Sha256Hex(r.Body) : null;
|
|
return new AuditExport(r.Antet("x-aere-day"), format, digest, r.Body, int.TryParse(r.Antet("x-aere-entries"), out var n) ? n : 0, verified);
|
|
}
|
|
|
|
/// <summary>The retention of the audit log, READ-ONLY with the key: retention is SET with a wallet signature in the console, never
|
|
/// with the key, so a stolen key cannot shorten the trail of its own use.</summary>
|
|
public Task<JsonElement> AuditRetentionAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account/audit/retention", null, true, ct);
|
|
|
|
/// <summary>Hashes YOUR bytes locally and compares them with the day we hold, or with the tombstone of a deleted day (410). Any other
|
|
/// failure is thrown, never reported as "does not match".</summary>
|
|
public async Task<KeptCheck> AuditVerifyKeptAsync(string day, byte[] kept, CancellationToken ct = default)
|
|
{
|
|
var local = Sha256Hex(kept);
|
|
var r = await DoAsync(HttpMethod.Get, "/account/audit?format=jsonl&day=" + Esc(day), null, true, ct).ConfigureAwait(false);
|
|
if (r.Status == 410)
|
|
{
|
|
try
|
|
{
|
|
using var d = JsonDocument.Parse(r.Body);
|
|
if (d.RootElement.TryGetProperty("expired", out var t) && t.ValueKind == JsonValueKind.Object
|
|
&& t.TryGetProperty("digest", out var dg) && dg.GetString() is { Length: > 0 } digest)
|
|
{
|
|
var del = t.TryGetProperty("deletedAt", out var da) ? da.GetString() : null;
|
|
return new KeptCheck(day, "expired", digest, local, local == digest, del);
|
|
}
|
|
}
|
|
catch (JsonException) { /* un 410 fara piatra de mormant cade mai jos, ca eroare */ }
|
|
}
|
|
if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), "/account/audit");
|
|
var held = Sha256Hex(r.Body);
|
|
return new KeptCheck(day, "held", held, local, local == held, null);
|
|
}
|
|
|
|
// ---- webhooks and gas sponsorship ----
|
|
/// <summary>GET /webhooks.</summary>
|
|
public Task<JsonElement> ListWebhooksAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/webhooks", null, true, ct);
|
|
/// <summary>POST /webhooks.</summary>
|
|
public Task<JsonElement> CreateWebhookAsync(object spec, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/webhooks", spec, true, ct);
|
|
/// <summary>DELETE /webhooks/{id}.</summary>
|
|
public Task<JsonElement> DeleteWebhookAsync(string id, CancellationToken ct = default) => JsonAsync(HttpMethod.Delete, "/webhooks/" + Esc(id), null, true, ct);
|
|
/// <summary>GET /sponsor/health.</summary>
|
|
public Task<JsonElement> SponsorHealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/sponsor/health", null, true, ct);
|
|
/// <summary>POST /sponsor/createAccount.</summary>
|
|
public Task<JsonElement> SponsorCreateAccountAsync(object body, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/createAccount", body, true, ct);
|
|
/// <summary>A smart account with a POST-QUANTUM owner (Falcon-512, 0x09-prefixed, 897 bytes), gas sponsored; idempotent on (key, salt).</summary>
|
|
public Task<JsonElement> SponsorCreatePqAccountAsync(string falconPubKeyHex, ulong salt = 0, CancellationToken ct = default) =>
|
|
JsonAsync(HttpMethod.Post, "/sponsor/createPqAccount", new Dictionary<string, object> { ["falconPubKey"] = falconPubKeyHex, ["salt"] = salt }, true, ct);
|
|
/// <summary>POST /sponsor/execute.</summary>
|
|
public Task<JsonElement> SponsorExecuteAsync(object body, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/execute", body, true, ct);
|
|
|
|
/// <summary>Checks a webhook delivery's HMAC-SHA256 signature in constant time. <paramref name="rawBody"/> must be the EXACT bytes
|
|
/// received, not re-serialized JSON. Never act on a webhook without this.</summary>
|
|
public static bool VerifyWebhook(byte[] rawBody, string? signatureHeader, string secret)
|
|
{
|
|
var want = Encoding.ASCII.GetBytes(Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), rawBody)).ToLowerInvariant());
|
|
var got = Encoding.ASCII.GetBytes(signatureHeader ?? "");
|
|
// FixedTimeEquals intoarce fals pe lungimi diferite fara sa scurteze comparatia pe continut
|
|
return CryptographicOperations.FixedTimeEquals(want, got);
|
|
}
|
|
}
|