From 88ed2ac92c883b059739f6af84866c56f383439a Mon Sep 17 00:00:00 2001 From: Aere Network Date: Tue, 29 Sep 2026 15:45:17 +0300 Subject: [PATCH] 1.6.0: the Trust API (verify: one AERE Proof Protocol envelope -> VALID / INVALID / PARTIAL, judged by the published reference verifier) in all clients; a .NET client (Aere.Cloud, .NET 8+); Java 1.6.1 on the Java 8 API (Android transport, redirects not followed). Go's Verify takes the envelope's bytes (json.RawMessage): a Go map would sort the keys and change the statement's hash. --- README.md | 11 +- csharp/.gitignore | 2 + .../Aere.Cloud.Tests/Aere.Cloud.Tests.csproj | 17 ++ csharp/Aere.Cloud.Tests/Program.cs | 182 +++++++++++++ csharp/Aere.Cloud/Aere.Cloud.csproj | 21 ++ csharp/Aere.Cloud/AereCloudClient.cs | 257 ++++++++++++++++++ csharp/README.md | 49 ++++ csharp/control-negativ.sh | 55 ++++ go/README.md | 4 + go/aerecloud.go | 14 +- go/aerecloud_test.go | 27 ++ go/control-negativ.mjs | 3 + index.mjs | 10 + java/README.md | 20 +- java/control-negativ.mjs | 29 +- .../java/network/aere/cloud/AereCloud.java | 115 +++++--- .../network/aere/cloud/TestAereCloud.java | 37 ++- package.json | 2 +- python/README.md | 3 + python/aere_cloud/__init__.py | 7 +- python/pyproject.toml | 2 +- python/test_aere_cloud.py | 23 ++ test/sdk.test.mjs | 15 + 23 files changed, 852 insertions(+), 53 deletions(-) create mode 100644 csharp/.gitignore create mode 100644 csharp/Aere.Cloud.Tests/Aere.Cloud.Tests.csproj create mode 100644 csharp/Aere.Cloud.Tests/Program.cs create mode 100644 csharp/Aere.Cloud/Aere.Cloud.csproj create mode 100644 csharp/Aere.Cloud/AereCloudClient.cs create mode 100644 csharp/README.md create mode 100644 csharp/control-negativ.sh diff --git a/README.md b/README.md index e9bb0f1..a4b761f 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,9 @@ pip install "git+https://git.aere.network/aere-network/aere-cloud-sdk.git#subdir go get git.aere.network/aere-network/aere-cloud-sdk/go@main # Go ``` -Java: build `java/` from source with the JDK alone (see `java/README.md`). All four clients have the same routes and -semantics, and none of them follows redirects, so your API key is never sent to a host you did not name. +Java: build `java/` from source with the JDK alone (see `java/README.md`); since 1.6.1 the same class is built against the Java 8 API, for Android too (not yet run on a device). .NET: build `csharp/Aere.Cloud` from source with the +.NET SDK (see `csharp/README.md`). All five clients have the same routes and semantics, and none of them follows redirects, so your +API key is never sent to a host you did not name. Get a key: the free trial is self-serve at [aere.network/cloud.html#subscribe](https://aere.network/cloud.html#subscribe). A key looks like `ak2800..`. @@ -65,6 +66,10 @@ const fin = await aere.pqFinality('latest', 'testnet', 'both'); // AIP-21: { fin // internal interface; the gateway builds M' = 0x00 || len(ctx) || ctx || message for you) const v = await aere.pqVerify({ scheme: 'ml-dsa-44', interface: 'external', publicKey, signature, message }); +// Trust API (1.6.0): any AERE Proof Protocol (AIP-23) envelope -> VALID / INVALID / PARTIAL, judged by the published reference +// verifier, with the levels it checked and the command that reproduces the verdict. Served by cloud.aere.network since 2026-09-29. +const r = await aere.verify(envelope, { chain: 2800 }); // { verdict, levels: [{ level, state, detail }], verifier, reproduce } + // transfer history for an address (token from genesis, native from launch) const tx = await aere.transfers('0xYourAddress', 50); @@ -74,7 +79,7 @@ const acct = await aere.sponsorCreatePqAccount('0x09' + falconPkHex, 0); ``` A Python mirror of this client lives in `python/` (installed from this repository, see Install): same -routes, same semantics, snake_case names. `go/` and `java/` carry the Go and Java clients. +routes, same semantics, snake_case names. `go/`, `java/` and `csharp/` carry the Go, Java and .NET clients. Every method throws `AereCloudError` on a non-2xx response, carrying `.status` and the server's JSON `.body` so you can act on the exact error. diff --git a/csharp/.gitignore b/csharp/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/csharp/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/csharp/Aere.Cloud.Tests/Aere.Cloud.Tests.csproj b/csharp/Aere.Cloud.Tests/Aere.Cloud.Tests.csproj new file mode 100644 index 0000000..dfb9707 --- /dev/null +++ b/csharp/Aere.Cloud.Tests/Aere.Cloud.Tests.csproj @@ -0,0 +1,17 @@ + + + + Exe + net10.0 + enable + enable + latest + true + false + + + + + + + diff --git a/csharp/Aere.Cloud.Tests/Program.cs b/csharp/Aere.Cloud.Tests/Program.cs new file mode 100644 index 0000000..1aa48ab --- /dev/null +++ b/csharp/Aere.Cloud.Tests/Program.cs @@ -0,0 +1,182 @@ +// Probele clientului .NET (Aere.Cloud 1.6.0), fara framework de probe: fiecare proba tipareste "--- PASS: Nume" sau "--- FAIL: Nume: motiv", +// iar codul de iesire e numarul de probe picate (0 = toate verzi). Aceleasi garzi ca la clientul Go: cheia obligatorie, cheia trimisa NUMAI +// unde trebuie, redirectarile NEurmate (dovedit pe un server local real, fiindca numai handlerul implicit al SDK-ului o poate arata), +// exportul jurnalului verificat pe octetii primiti, verificarea unei copii pastrate (tinuta / stearsa / eroare), webhook-ul in timp constant. +// Controlul negativ (control-negativ.sh) strica fiecare paznic intr-o COPIE a sursei si cere ca proba lui sa pice. +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Aere.Cloud; + +const string Cheie = "ak2800.0x1111111111111111111111111111111111111111.secretproba"; +int picate = 0, trecute = 0; + +async Task Proba(string nume, Func f) +{ + try { await f(); Console.WriteLine("--- PASS: " + nume); trecute++; } + catch (Exception e) { Console.WriteLine("--- FAIL: " + nume + ": " + e.Message.Split('\n')[0]); picate++; } +} +void Cer(bool c, string ce) { if (!c) throw new Exception("cerut: " + ce); } +string Sha(byte[] b) => "0x" + Convert.ToHexString(SHA256.HashData(b)).ToLowerInvariant(); + +(AereCloudClient, Ciot) Client(Func? r = null) +{ + var c = new Ciot { Raspunde = r ?? (_ => Json(200, "{}")) }; + return (new AereCloudClient(Cheie, "https://ciot.invalid/v1", c), c); +} +HttpResponseMessage Json(int s, string corp) => new((HttpStatusCode)s) { Content = new StringContent(corp, Encoding.UTF8, "application/json") }; + +await Proba("CheiaEObligatorie", () => +{ + bool a = false, b = false; + try { _ = new AereCloudClient(""); } catch (ArgumentException) { a = true; } + try { _ = new AereCloudClient(null!); } catch (ArgumentException) { b = true; } + Cer(a && b, "o cheie goala sau lipsa e refuzata la constructie"); + return Task.CompletedTask; +}); + +await Proba("ReadinessCheiaNumaiLaAttest", async () => +{ + var (c, ciot) = Client(); + await c.ReadinessAsync("exemplu.test"); + Cer(!ciot.Ultima.Antete.ContainsKey("x-api-key"), "fara attest cheia NU pleaca"); + await c.ReadinessAsync("exemplu.test", attest: true); + Cer(ciot.Ultima.Antete.GetValueOrDefault("x-api-key") == Cheie, "cu attest cheia pleaca"); + Cer(ciot.Ultima.Corp.Contains("\"attest\":true"), "corpul poarta attest"); + await c.ReadinessReportAsync("exemplu.test"); Cer(!ciot.Ultima.Antete.ContainsKey("x-api-key"), "raportul public fara cheie"); + await c.PqFinalityAsync(); Cer(!ciot.Ultima.Antete.ContainsKey("x-api-key"), "finalitatea AIP-21 fara cheie"); + await c.HealthAsync(); Cer(!ciot.Ultima.Antete.ContainsKey("x-api-key"), "health fara cheie"); + await c.AccountAsync(); Cer(ciot.Ultima.Antete.GetValueOrDefault("x-api-key") == Cheie, "account cu cheie"); + Cer(ciot.Ultima.Antete.GetValueOrDefault("user-agent", "").StartsWith("aere-cloud-csharp/1.6.0"), "user-agent-ul clientului"); +}); + +await Proba("PerimetruFinalitateDovada", async () => +{ + var (c, ciot) = Client(); + await c.ReadinessPerimeterAsync("exemplu.test", new PerimeterOptions(new[] { "www", "api" }, false, true)); + using (var d = JsonDocument.Parse(ciot.Ultima.Corp)) + { + var r = d.RootElement; + Cer(r.GetProperty("hosts").GetArrayLength() == 2 && !r.GetProperty("discover").GetBoolean() && r.GetProperty("attest").GetBoolean(), "corpul perimetrului"); + } + Cer(ciot.Ultima.Antete.ContainsKey("x-api-key"), "perimetrul cere cheia"); + await c.FinalityAsync(); Cer(ciot.Ultima.Url == "https://ciot.invalid/v1/data/finality" && ciot.Ultima.Metoda == "GET", "GET /data/finality"); + await c.ProofAsync("0xab/c"); Cer(ciot.Ultima.Url.EndsWith("/proof/0xab%2Fc"), "hash-ul scapat in cale: " + ciot.Ultima.Url); +}); + +await Proba("TrustApiVerify", async () => +{ + var (c, ciot) = Client(_ => Json(200, "{\"verdict\":\"VALID\"}")); + var r = await c.VerifyAsync(new Dictionary { ["kind"] = "data" }, 28001); + Cer(ciot.Ultima.Metoda == "POST" && ciot.Ultima.Url.EndsWith("/verify?chain=28001"), "POST /verify?chain=28001"); + Cer(ciot.Ultima.Corp == "{\"kind\":\"data\"}", "plicul trimis ca atare: " + ciot.Ultima.Corp); + Cer(r.GetProperty("verdict").GetString() == "VALID", "verdictul intors"); + await c.VerifyAsync(new Dictionary { ["kind"] = "data" }); + Cer(ciot.Ultima.Url.EndsWith("/verify"), "fara lant: /verify"); +}); + +await Proba("ExportulZilei", async () => +{ + var corp = Encoding.UTF8.GetBytes("{\"a\":1}\n{\"b\":2}\n"); + string? digest = Sha(corp); string format = "jsonl"; + var (c, _) = Client(_ => + { + var m = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(corp) }; + if (digest is not null) m.Headers.Add("x-aere-digest", digest); + m.Headers.Add("x-aere-day", "2026-09-27"); m.Headers.Add("x-aere-entries", "2"); + return m; + }); + var e = await c.AuditExportAsync("2026-09-27", format); + Cer(e.Verified == true && e.Entries == 2 && e.Day == "2026-09-27", "octetii se potrivesc cu amprenta declarata"); + digest = "0x" + new string('0', 64); + e = await c.AuditExportAsync("2026-09-27", format); Cer(e.Verified == false, "o amprenta care nu e a octetilor: NEverificat"); + digest = null; + e = await c.AuditExportAsync("2026-09-27", format); Cer(e.Verified == false, "fara amprenta: NEverificat"); + e = await c.AuditExportAsync("2026-09-27", "cef"); Cer(e.Verified is null, "cef: nimic de verificat aici"); +}); + +await Proba("ExportulPastrat", async () => +{ + var tinut = Encoding.UTF8.GetBytes("{\"x\":1}\n"); + int stare = 200; string piatra = ""; + var (c, _) = Client(_ => stare switch + { + 200 => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(tinut) }, + 410 => Json(410, piatra), + _ => Json(stare, "{\"error\":\"too many requests\"}"), + }); + var k = await c.AuditVerifyKeptAsync("2026-09-01", tinut); Cer(k.State == "held" && k.Matches, "copia pastrata = ziua tinuta"); + k = await c.AuditVerifyKeptAsync("2026-09-01", Encoding.UTF8.GetBytes("altceva")); Cer(k.State == "held" && !k.Matches, "o copie alterata NU se potriveste"); + stare = 410; piatra = "{\"expired\":{\"digest\":\"" + Sha(tinut) + "\",\"deletedAt\":\"2026-09-20T00:00:00Z\"}}"; + k = await c.AuditVerifyKeptAsync("2026-09-01", tinut); Cer(k.State == "expired" && k.Matches && k.DeletedAt == "2026-09-20T00:00:00Z", "ziua stearsa: piatra de mormant"); + k = await c.AuditVerifyKeptAsync("2026-09-01", Encoding.UTF8.GetBytes("altceva")); Cer(k.State == "expired" && !k.Matches, "piatra de mormant nu se potriveste cu o copie alterata"); + stare = 429; bool eroare = false; + try { await c.AuditVerifyKeptAsync("2026-09-01", tinut); } catch (AereCloudException x) { eroare = x.Status == 429; } + Cer(eroare, "un 429 e o EROARE, niciodata 'nu se potriveste'"); +}); + +await Proba("RedirectareaNuEUrmata", async () => +{ + int Port() { var l = new TcpListener(IPAddress.Loopback, 0); l.Start(); var p = ((IPEndPoint)l.LocalEndpoint).Port; l.Stop(); return p; } + int pa = Port(), pb = Port(); int loviturib = 0; string? cheiaLaB = null; + using var a = new HttpListener(); a.Prefixes.Add($"http://127.0.0.1:{pa}/"); a.Start(); + using var b = new HttpListener(); b.Prefixes.Add($"http://127.0.0.1:{pb}/"); b.Start(); + var ta = Task.Run(async () => { while (a.IsListening) { HttpListenerContext x; try { x = await a.GetContextAsync(); } catch { break; } x.Response.StatusCode = 302; x.Response.RedirectLocation = $"http://127.0.0.1:{pb}/fura"; x.Response.Close(); } }); + var tb = Task.Run(async () => { while (b.IsListening) { HttpListenerContext x; try { x = await b.GetContextAsync(); } catch { break; } loviturib++; cheiaLaB = x.Request.Headers["x-api-key"]; x.Response.StatusCode = 200; x.Response.Close(); } }); + using var c = new AereCloudClient(Cheie, $"http://127.0.0.1:{pa}/v1"); // handlerul IMPLICIT al SDK-ului + int status = -1; + try { await c.AccountAsync(); } catch (AereCloudException x) { status = x.Status; } + await Task.Delay(200); + a.Stop(); b.Stop(); + Cer(status == 302, "un 3xx e un raspuns (AereCloudException 302), nu un drum; primit " + status); + Cer(loviturib == 0 && cheiaLaB is null, "gazda din Location NU e contactata (lovituri " + loviturib + ")"); +}); + +await Proba("VerifyWebhook", () => +{ + var corp = Encoding.UTF8.GetBytes("{\"event\":\"anchor\",\"height\":1}"); + var sig = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes("secret"), corp)).ToLowerInvariant(); + Cer(AereCloudClient.VerifyWebhook(corp, sig, "secret"), "semnatura buna trece"); + var rea = sig[..^1] + (sig[^1] == '0' ? '1' : '0'); + Cer(!AereCloudClient.VerifyWebhook(corp, rea, "secret"), "o cifra schimbata e refuzata"); + Cer(!AereCloudClient.VerifyWebhook(corp, sig[..10], "secret"), "o semnatura scurta e refuzata"); + Cer(!AereCloudClient.VerifyWebhook(corp, null, "secret"), "fara antet: refuzat"); + Cer(!AereCloudClient.VerifyWebhook(corp, sig, "alt-secret"), "alt secret: refuzat"); + return Task.CompletedTask; +}); + +await Proba("EroriSiBlockNumber", async () => +{ + var (c, _) = Client(r => r.RequestUri!.AbsolutePath.EndsWith("/rpc") ? Json(200, "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":\"0x10\"}") : Json(500, "boom")); + Cer(await c.BlockNumberAsync() == 16, "eth_blockNumber 0x10 = 16"); + AereCloudException? e = null; + try { await c.DataHeadAsync(); } catch (AereCloudException x) { e = x; } + Cer(e is { Status: 500, Body: "boom", Path: "/data/head" }, "un 500 poarta statusul, corpul si calea"); + var (c2, _) = Client(_ => throw new HttpRequestException("retea cazuta")); + AereCloudException? e2 = null; + try { await c2.HealthAsync(); } catch (AereCloudException x) { e2 = x; } + Cer(e2 is { Status: 0 } && !e2.Message.Contains(Cheie), "fara raspuns: status 0, iar mesajul nu poarta cheia"); + var (c3, _) = Client(_ => Json(200, "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-1}}")); + bool refuz = false; try { await c3.BlockNumberAsync(); } catch (AereCloudException x) { refuz = x.Status == 200; } + Cer(refuz, "un rezultat fara forma 0x e o eroare, nu un zero"); +}); + +Console.WriteLine($"probe: {trecute} trecute, {picate} picate"); +return picate; + +sealed class Ciot : HttpMessageHandler +{ + public sealed record Cerere(string Metoda, string Url, Dictionary Antete, string Corp); + public List Cereri { get; } = new(); + public Cerere Ultima => Cereri[^1]; + public Func Raspunde { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.OK); + protected override async Task SendAsync(HttpRequestMessage r, CancellationToken ct) + { + var corp = r.Content is null ? "" : await r.Content.ReadAsStringAsync(ct); + var h = r.Headers.ToDictionary(x => x.Key.ToLowerInvariant(), x => string.Join(" ", x.Value)); + Cereri.Add(new Cerere(r.Method.Method, r.RequestUri!.ToString(), h, corp)); + return Raspunde(r); + } +} diff --git a/csharp/Aere.Cloud/Aere.Cloud.csproj b/csharp/Aere.Cloud/Aere.Cloud.csproj new file mode 100644 index 0000000..53529f4 --- /dev/null +++ b/csharp/Aere.Cloud/Aere.Cloud.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + latest + true + true + Aere.Cloud + Aere.Cloud + Aere.Cloud + 1.6.0 + Aere Network + Official .NET client for the Aere Cloud API (Aere Network, chain 2800): RPC, post-quantum verification, the Trust API (AERE Proof Protocol envelopes), chain data, notarization and proofs, quantum readiness, audit log, webhooks and gas sponsorship. + MIT + https://aere.network + true + + + diff --git a/csharp/Aere.Cloud/AereCloudClient.cs b/csharp/Aere.Cloud/AereCloudClient.cs new file mode 100644 index 0000000..7212a6e --- /dev/null +++ b/csharp/Aere.Cloud/AereCloudClient.cs @@ -0,0 +1,257 @@ +// 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.6.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; + +/// A non-2xx answer from Aere Cloud. 0 means the request never got an answer. +public sealed class AereCloudException : Exception +{ + /// HTTP status (0 when there was no answer). + public int Status { get; } + /// The server's body, as text. + public string Body { get; } + /// The API path that failed. + public string Path { get; } + + /// Creates the exception. + 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; + } +} + +/// Options for . +public sealed record PerimeterOptions(IReadOnlyList? Hosts = null, bool? Discover = null, bool Attest = false); + +/// An audit day as a file. For jsonl, 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. +public sealed record AuditExport(string? Day, string Format, string? Digest, byte[] Bytes, int Entries, bool? Verified) +{ + /// The bytes as UTF-8 text. + public string Text => Encoding.UTF8.GetString(Bytes); +} + +/// Does an export you KEPT still match the day we hold ("held"), or the tombstone a deleted day left ("expired")? +public sealed record KeptCheck(string Day, string State, string Digest, string Local, bool Matches, string? DeletedAt); + +/// One Aere Cloud gateway with one API key (ak2800.<address>.<secret>). +public sealed class AereCloudClient : IDisposable +{ + /// Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route). + public const string Version = "1.6.0"; + /// The production gateway. + public const string DefaultBaseUrl = "https://cloud.aere.network/v1"; + + private readonly string _apiKey; + private readonly string _baseUrl; + private readonly HttpClient _http; + + /// A client for (default: production). The API key is required. A test may pass its own + /// ; the default one does NOT follow redirects, so the key never travels to a host named in a Location. + public AereCloudClient(string apiKey, string? baseUrl = null, HttpMessageHandler? handler = null) + { + if (string.IsNullOrEmpty(apiKey)) throw new ArgumentException("Aere.Cloud: apiKey is required (ak2800.
.)", 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) }; + } + + /// + public void Dispose() => _http.Dispose(); + + private sealed record Raspuns(int Status, byte[] Body, Func Antet); + + private async Task 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 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 ---- + /// GET /health (no key). + public Task HealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/health", null, false, ct); + /// GET /account. + public Task AccountAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account", null, true, ct); + + /// One JSON-RPC call; returns the whole JSON-RPC envelope. + public Task RpcAsync(string method, IReadOnlyList? parameters = null, CancellationToken ct = default) => + JsonAsync(HttpMethod.Post, "/rpc", new Dictionary { ["jsonrpc"] = "2.0", ["id"] = 1, ["method"] = method, ["params"] = parameters ?? Array.Empty() }, true, ct); + + /// eth_blockNumber as an integer. + public async Task 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 ---- + /// POST /pq/verify, payload like {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."}. + public Task PqVerifyAsync(object payload, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/pq/verify", payload, true, ct); + + /// 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. : 2800 or 28001; 0 = the chain the envelope declares. + public Task VerifyAsync(object envelope, ulong chain = 0, CancellationToken ct = default) => + JsonAsync(HttpMethod.Post, "/verify" + (chain > 0 ? "?chain=" + chain : ""), envelope, true, ct); + + /// GET /data/head. + public Task DataHeadAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/head", null, true, ct); + /// GET /data/validators. + public Task ValidatorsAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/validators", null, true, ct); + /// GET /data/anchors?limit=. + public Task AnchorsAsync(int limit = 10, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/anchors?limit=" + limit, null, true, ct); + /// GET /data/anchors/{height}. + public Task AnchorAsync(ulong height, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/anchors/" + height, null, true, ct); + /// The post-quantum finality point (finalized/safe = the parent of the last certified anchor). + public Task FinalityAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/data/finality", null, true, ct); + /// GET /data/transfers?address=&limit=. + public Task TransfersAsync(string address, int limit = 50, CancellationToken ct = default) => + JsonAsync(HttpMethod.Get, "/data/transfers?address=" + Esc(address) + "&limit=" + limit, null, true, ct); + /// Notarizes a 32-byte digest (0x + 64 hex); the gas is ours. + public Task NotarizeAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/notarize", new Dictionary { ["hash"] = hash }, true, ct); + /// GET /notarize/{hash}. + public Task ProofOfAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/notarize/" + Esc(hash), null, true, ct); + /// The proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps). + public Task ProofAsync(string hash, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/proof/" + Esc(hash), null, true, ct); + + // ---- quantum readiness ---- + /// Scans a domain's public TLS edge. Free WITHOUT a key; notarizes the report digest and only then sends the key. + public Task ReadinessAsync(string domain, bool fresh = false, bool attest = false, CancellationToken ct = default) + { + var b = new Dictionary { ["domain"] = domain }; + if (fresh) b["fresh"] = true; + if (attest) b["attest"] = true; + return JsonAsync(HttpMethod.Post, "/pq/readiness", b, attest, ct); + } + /// GET /pq/readiness/{domain} (no key). + public Task ReadinessReportAsync(string domain, CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/pq/readiness/" + Esc(domain), null, false, ct); + + /// The whole public perimeter of a domain in one keyed report; call again while summary.complete is false. + public Task ReadinessPerimeterAsync(string domain, PerimeterOptions? o = null, CancellationToken ct = default) + { + o ??= new PerimeterOptions(); + var b = new Dictionary { ["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); + } + + /// AIP-21 per-block post-quantum finality. client: "besu", "nethermind" or "both" (with `agreement`). No key. + public Task 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 ---- + /// The paged JSON view of one UTC day (null = today), with the digest of the day file. + public Task 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); + + /// A day as a file: "jsonl" (the exact bytes of the day file, checked here against the stated digest) or "cef". + public async Task 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); + } + + /// 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. + public Task AuditRetentionAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/account/audit/retention", null, true, ct); + + /// 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". + public async Task 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 ---- + /// GET /webhooks. + public Task ListWebhooksAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/webhooks", null, true, ct); + /// POST /webhooks. + public Task CreateWebhookAsync(object spec, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/webhooks", spec, true, ct); + /// DELETE /webhooks/{id}. + public Task DeleteWebhookAsync(string id, CancellationToken ct = default) => JsonAsync(HttpMethod.Delete, "/webhooks/" + Esc(id), null, true, ct); + /// GET /sponsor/health. + public Task SponsorHealthAsync(CancellationToken ct = default) => JsonAsync(HttpMethod.Get, "/sponsor/health", null, true, ct); + /// POST /sponsor/createAccount. + public Task SponsorCreateAccountAsync(object body, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/createAccount", body, true, ct); + /// A smart account with a POST-QUANTUM owner (Falcon-512, 0x09-prefixed, 897 bytes), gas sponsored; idempotent on (key, salt). + public Task SponsorCreatePqAccountAsync(string falconPubKeyHex, ulong salt = 0, CancellationToken ct = default) => + JsonAsync(HttpMethod.Post, "/sponsor/createPqAccount", new Dictionary { ["falconPubKey"] = falconPubKeyHex, ["salt"] = salt }, true, ct); + /// POST /sponsor/execute. + public Task SponsorExecuteAsync(object body, CancellationToken ct = default) => JsonAsync(HttpMethod.Post, "/sponsor/execute", body, true, ct); + + /// Checks a webhook delivery's HMAC-SHA256 signature in constant time. must be the EXACT bytes + /// received, not re-serialized JSON. Never act on a webhook without this. + 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); + } +} diff --git a/csharp/README.md b/csharp/README.md new file mode 100644 index 0000000..b8f45ac --- /dev/null +++ b/csharp/README.md @@ -0,0 +1,49 @@ +# Aere.Cloud (.NET) + +The official .NET client for the [Aere Cloud API](https://aere.network/cloud.html) on Aere Network (chain 2800). +**No dependencies** beyond the .NET base library (`System.Net.Http`, `System.Text.Json`, `System.Security.Cryptography`). +Targets .NET 8 (LTS) and runs on later versions. Same routes and semantics as the JS, Python, Go and Java clients 1.6.0, +including `VerifyAsync`, the Trust API. + +JSON routes return the server's JSON as a `JsonElement`, so you bind it to the types you already use. The two routes +that return files (the audit export and the check of a kept export) return typed results, with the SHA-256 computed +locally from the bytes received. + +## Use + +```csharp +using Aere.Cloud; + +using var c = new AereCloudClient(Environment.GetEnvironmentVariable("AERE_KEY")!); +var head = await c.DataHeadAsync(); // {"block":...,"chainId":2800,...} +ulong n = await c.BlockNumberAsync(); + +// the Trust API: any AERE Proof Protocol (AIP-23) envelope -> VALID / INVALID / PARTIAL, with the levels checked +var verdict = await c.VerifyAsync(JsonDocument.Parse(File.ReadAllText("envelope.json")).RootElement); // chain: 2800 or 28001, default = the one the envelope declares +// pass the parsed JsonElement (key order kept): the verdict hashes the statement in its own key order + +// quantum-readiness scan of a domain's TLS edge: free, no key is sent unless you attest the report +var report = await c.ReadinessAsync("example.com"); + +// the audit log of a day as a file; Verified is true only if the sha256 of the bytes you received equals the digest +AuditExport day = await c.AuditExportAsync("2026-09-24"); + +// a kept export still matches the day we hold, or the tombstone a deleted day left behind +KeptCheck k = await c.AuditVerifyKeptAsync("2026-05-01", keptBytes); + +// verify a webhook delivery (constant-time HMAC-SHA256 over the exact bytes received) +bool ok = AereCloudClient.VerifyWebhook(rawBody, request.Headers["x-aere-signature"], secret); +``` + +Non-2xx answers are thrown as `AereCloudException` with `Status`, `Body` and `Path` (`Status` 0 = no answer; the +message never carries your key). The client does not follow redirects: a 3xx is returned as an answer, so your API key +is never sent to a host you did not name. + +## Tests + +``` +dotnet run --project Aere.Cloud.Tests -c Release # offline: a recording HTTP handler, plus a real local server for the redirect test +bash control-negativ.sh # breaks ten guards in a copy of the source; each one's test must fail +``` + +Measured 2026-09-28 (WSL, .NET SDK 10.0.302): 9 tests green; the negative control 10 of 10 red, the untouched source green. diff --git a/csharp/control-negativ.sh b/csharp/control-negativ.sh new file mode 100644 index 0000000..4e5edd1 --- /dev/null +++ b/csharp/control-negativ.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# control-negativ.sh - IN WSL: controlul negativ al probelor clientului .NET (Aere.Cloud). Fiecare paznic se strica intr-o COPIE a sursei +# (depozitul nu se atinge), probele ruleaza pe copie si proba tinta TREBUIE sa pice. O plantare a carei ancora nu apare exact o data, sau +# care nu compileaza (TreatWarningsAsErrors), e STRICAT, nu rosu. La urma, sursa neatinsa trebuie sa treaca intreaga. +# bash csharp/control-negativ.sh (Linux sau WSL, cu un SDK .NET 8+ in PATH; `dotnet` gasit altundeva: DOTNET_ROOT + PATH) +# 2026-09-29: caile sunt derivate (dosarul scriptului, un dosar temporar), nu scrise fix, ca scriptul sa mearga si din depozitul public. +set -u +export DOTNET_CLI_TELEMETRY_OPTOUT=1 DOTNET_NOLOGO=1 +command -v dotnet >/dev/null || { echo "STRICAT: dotnet nu e in PATH"; exit 2; } +S=$(cd "$(dirname "$0")" && pwd) +W=$(mktemp -d "${TMPDIR:-/tmp}/sdk-csharp-control-XXXX") +cp -r "$S/Aere.Cloud" "$S/Aere.Cloud.Tests" "$W/"; rm -rf "$W"/*/bin "$W"/*/obj +SRC="$W/Aere.Cloud/AereCloudClient.cs"; tr -d '\r' < "$S/Aere.Cloud/AereCloudClient.cs" > "$W/original.cs" +cat > "$W/plantari.py" <<'PY' +import sys +P = [ + ("exportul jsonl declarat verificat fara hash", 'format == "jsonl" ? !string.IsNullOrEmpty(digest) && digest == Sha256Hex(r.Body) : null', 'format == "jsonl" ? true : null', "ExportulZilei"), + ("ziua tinuta acceptata fara comparatie", 'new KeptCheck(day, "held", held, local, local == held, null)', 'new KeptCheck(day, "held", held, local, true, null)', "ExportulPastrat"), + ("piatra zilei sterse acceptata fara comparatie", 'new KeptCheck(day, "expired", digest, local, local == digest, del)', 'new KeptCheck(day, "expired", digest, local, true, del)', "ExportulPastrat"), + # inaintea throw-ului pentru non-2xx (prima forma a plantarii statea dupa el, deci nu se executa niciodata si "a ramas verde") + ("un 429 intors ca nepotrivire", ' if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), "/account/audit");\n var held', ' if (r.Status == 429) return new KeptCheck(day, "held", "", local, false, null);\n if (r.Status < 200 || r.Status >= 300) throw new AereCloudException(r.Status, Encoding.UTF8.GetString(r.Body), "/account/audit");\n var held', "ExportulPastrat"), + ("cheia trimisa si fara attest", 'return JsonAsync(HttpMethod.Post, "/pq/readiness", b, attest, ct);', 'return JsonAsync(HttpMethod.Post, "/pq/readiness", b, true, ct);', "ReadinessCheiaNumaiLaAttest"), + ("redirectarile urmate (cheia ar pleca spre alta gazda)", 'new HttpClientHandler { AllowAutoRedirect = false }', 'new HttpClientHandler { AllowAutoRedirect = true }', "RedirectareaNuEUrmata"), + ("webhook acceptat fara semnatura", 'return CryptographicOperations.FixedTimeEquals(want, got);', 'return got.Length >= 0 || CryptographicOperations.FixedTimeEquals(want, got);', "VerifyWebhook"), + ("cheia goala acceptata", 'if (string.IsNullOrEmpty(apiKey)) throw', 'if (apiKey == "niciodata-" + "asa") throw', "CheiaEObligatorie"), + ("lantul cerut scapat din Trust API", '(chain > 0 ? "?chain=" + chain : "")', '(chain > 0 ? "" : "")', "TrustApiVerify"), + ("un rezultat RPC fara forma citit ca zero", ' throw new AereCloudException(200, e.GetRawText(), "/rpc");', ' return 0;', "EroriSiBlockNumber"), +] +if sys.argv[1] == "n": print(len(P)); sys.exit(0) +i = int(sys.argv[1]); nume, vechi, nou, tinta = P[i] +t = open(sys.argv[2]).read() +if t.count(vechi) != 1: print("ANCORA %d" % t.count(vechi)); sys.exit(3) +open(sys.argv[3], "w").write(t.replace(vechi, nou)); print(nume + "|" + tinta) +PY +ruleaza() { # -> iesirea probelor in $W/iesire.txt; cod: 0 rulat, 2 nu compileaza + ( cd "$W" && dotnet build Aere.Cloud.Tests -c Release -p:NuGetAudit=false -nodeReuse:false -p:UseSharedCompilation=false > build.log 2>&1 < /dev/null ) || return 2 + # exact UN dll de probe in iesirea acestei constructii (un `| head -1` peste mai multe ar alege la intamplare) + local dll; dll=$(ls "$W"/Aere.Cloud.Tests/bin/Release/net*/Aere.Cloud.Tests.dll 2>/dev/null); [ "$(printf '%s\n' "$dll" | grep -c .)" = 1 ] || return 2 + dotnet "$dll" > "$W/iesire.txt" 2>&1 < /dev/null; return 0 +} +N=$(python3 "$W/plantari.py" n); RAU=0 +for i in $(seq 0 $((N - 1))); do + L=$(python3 "$W/plantari.py" "$i" "$W/original.cs" "$SRC"); c=$? + [ "$c" = 0 ] || { echo " STRICAT plantarea $i: $L"; RAU=$((RAU+1)); continue; } + nume=${L%|*}; tinta=${L#*|} + if ! ruleaza; then echo " STRICAT $nume: nu compileaza ($(grep -m1 -oE 'error [A-Z]+[0-9]+[^[]*' "$W/build.log"))"; RAU=$((RAU+1)); continue; fi + if grep -q "^--- FAIL: $tinta" "$W/iesire.txt"; then echo " ROSU cum trebuia $nume (pica $(grep -c '^--- FAIL' "$W/iesire.txt"): $(grep '^--- FAIL' "$W/iesire.txt" | cut -d: -f2 | tr '\n' ' '))" + else echo " CONTROL CAZUT $nume: $tinta a ramas verde"; RAU=$((RAU+1)); fi +done +cp "$W/original.cs" "$SRC" +if ruleaza && ! grep -q '^--- FAIL' "$W/iesire.txt" && grep -q '^--- PASS' "$W/iesire.txt"; then echo " martor: sursa neatinsa, $(grep -c '^--- PASS' "$W/iesire.txt") probe verzi" +else echo " MARTORUL nu e verde"; RAU=$((RAU+1)); fi +[ "$RAU" = 0 ] && echo "CONTROL NEGATIV TRECUT ($N din $N rosii, martorul verde)" || echo "CONTROL NEGATIV: $RAU probleme" +rm -rf "$W" +exit $([ "$RAU" = 0 ] && echo 0 || echo 1) diff --git a/go/README.md b/go/README.md index 8ec34cf..b2e957b 100644 --- a/go/README.md +++ b/go/README.md @@ -26,6 +26,10 @@ day, err := c.AuditExport(ctx, "2026-09-24", "jsonl") // a kept export still matches the day we hold, or the tombstone a deleted day left behind k, err := c.AuditVerifyKept(ctx, "2026-05-01", keptBytes) +// Trust API (1.6.0): any AERE Proof Protocol (AIP-23) envelope -> VALID / INVALID / PARTIAL; chain 0 = the one the envelope declares. +// Pass the envelope's bytes as received (os.ReadFile): a Go map would sort the keys and change the statement's hash. +verdict, err := c.Verify(ctx, json.RawMessage(envelopeBytes), 2800) + // verify a webhook delivery (constant-time HMAC-SHA256 over the exact bytes received) ok := aerecloud.VerifyWebhook(rawBody, r.Header.Get("x-aere-signature"), secret) ``` diff --git a/go/aerecloud.go b/go/aerecloud.go index 47c2917..8194c5a 100644 --- a/go/aerecloud.go +++ b/go/aerecloud.go @@ -30,7 +30,7 @@ import ( ) // Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route). -const Version = "1.5.2" +const Version = "1.6.0" // DefaultBaseURL is the production gateway. const DefaultBaseURL = "https://cloud.aere.network/v1" @@ -162,6 +162,18 @@ func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { func (c *Client) PQVerify(ctx context.Context, payload any) (json.RawMessage, error) { return c.jsonReq(ctx, "POST", "/pq/verify", payload, true) } + +// Verify is 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; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify. +// chain: 2800 or 28001; 0 = the chain the envelope declares. +// envelope: the envelope's JSON bytes as you received them. The verdict hashes the statement in its own key order, so the bytes are +// sent as they are; a Go map would sort the keys and turn a valid envelope into an INVALID one (measured 2026-09-29, before 1.6.0 shipped). +func (c *Client) Verify(ctx context.Context, envelope json.RawMessage, chain uint64) (json.RawMessage, error) { + p := "/verify" + if chain > 0 { + p += "?chain=" + strconv.FormatUint(chain, 10) + } + return c.jsonReq(ctx, "POST", p, envelope, true) +} func (c *Client) DataHead(ctx context.Context) (json.RawMessage, error) { return c.jsonReq(ctx, "GET", "/data/head", nil, true) } diff --git a/go/aerecloud_test.go b/go/aerecloud_test.go index c1fd3bf..47c0933 100644 --- a/go/aerecloud_test.go +++ b/go/aerecloud_test.go @@ -114,6 +114,14 @@ func TestPerimetruFinalitateDovada(t *testing.T) { if v = s.ultima(); v.Path != "/v1/proof/0xab" || v.Key == "" { t.Fatalf("dovada: %+v", v) } + c.Verify(ctx, json.RawMessage(`{"statementHash":"0x00"}`), 28001) + if v = s.ultima(); v.Method != "POST" || v.Path != "/v1/verify?chain=28001" || v.Key == "" || v.Body != `{"statementHash":"0x00"}` { + t.Fatalf("verify: %+v", v) + } + c.Verify(ctx, json.RawMessage(`{}`), 0) + if v = s.ultima(); v.Path != "/v1/verify" { + t.Fatalf("verify fara lant: %+v", v) + } } var zi = []byte("{\"t\":\"2026-05-01T10:00:00.000Z\",\"m\":\"GET\",\"p\":\"/v1/account\",\"s\":200}\n") @@ -190,6 +198,25 @@ func TestRedirectareaNuEUrmata(t *testing.T) { } } +// 2026-09-29: verdictul Trust API hashuieste statement-ul in ordinea LUI de chei; clientul trimite octetii plicului asa cum sunt. +// Perechea negativa: acelasi plic trecut printr-un map de Go iese cu cheile sortate, adica alt text canonic. +func TestVerifyPastreazaOrdinea(t *testing.T) { + s, c := porneste(t) + plic := `{"v":1,"statement":{"v":1,"kind":"aere-proof-of-data","subject":{"name":"x"},"createdAt":"2026-09-26T00:00:00Z"},"statementHash":"0x00"}` + if _, err := c.Verify(context.Background(), json.RawMessage(plic), 0); err != nil { + t.Fatal(err) + } + if v := s.ultima(); v.Body != plic { + t.Fatalf("ordinea cheilor schimbata: %s", v.Body) + } + var m map[string]any + _ = json.Unmarshal([]byte(plic), &m) + prin, _ := json.Marshal(m) + if string(prin) == plic { + t.Fatal("controlul probei: un map ar fi trebuit sa sorteze cheile") + } +} + func TestVerifyWebhook(t *testing.T) { secret := "AERE-SINTETIC-secret" corp := []byte(`{"event":"pq-anchor","data":{"height":42}}`) diff --git a/go/control-negativ.mjs b/go/control-negativ.mjs index fc216b6..5675a9e 100644 --- a/go/control-negativ.mjs +++ b/go/control-negativ.mjs @@ -21,6 +21,9 @@ const PLANTARI = [ ['redirectarile urmate (cheia ar pleca spre alta gazda)', 'CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }', 'CheckRedirect: nil', 'TestRedirectareaNuEUrmata'], ['un 429 intors ca nepotrivire', 'return nil, &Error{Status: r.status, Body: r.body, Path: "/account/audit"}\n\t}\n\td := sha256Hex(r.body)', 'return &KeptCheck{Day: day, State: "held", Local: local}, nil\n\t}\n\td := sha256Hex(r.body)', 'TestExportulPastrat'], + // 2026-09-29: plicul trecut printr-un map de Go (cheile sortate = alt text canonic, un plic valid ar iesi INVALID la Trust API) + ['Verify trece plicul printr-un map', 'return c.jsonReq(ctx, "POST", p, envelope, true)', + 'var m map[string]any\n\t_ = json.Unmarshal(envelope, &m)\n\treturn c.jsonReq(ctx, "POST", p, m, true)', 'TestVerifyPastreazaOrdinea'], ]; function ruleaza(src) { diff --git a/index.mjs b/index.mjs index 1068eb2..c421c4f 100644 --- a/index.mjs +++ b/index.mjs @@ -65,6 +65,16 @@ export class AereCloud { */ pqVerify(p) { return this._req('POST', '/pq/verify', { body: p }); } + /** + * 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; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify. + * @param {object} envelope the envelope as produced (statement, statementHash, signatures, notarization...) + * @param {{chain?: number}} [opts] 2800 or 28001; omitted = the chain the envelope declares + * @returns {Promise<{verdict:'VALID'|'INVALID'|'PARTIAL', levels:object[], verifier:object, reproduce:string}>} + */ + verify(envelope, { chain } = {}) { + return this._req('POST', '/verify' + (chain ? `?chain=${encodeURIComponent(chain)}` : ''), { body: envelope }); + } + // ---- chain data ---- dataHead() { return this._req('GET', '/data/head'); } validators() { return this._req('GET', '/data/validators'); } diff --git a/java/README.md b/java/README.md index d6c7b75..49d6131 100644 --- a/java/README.md +++ b/java/README.md @@ -2,8 +2,10 @@ The official Java client for the [Aere Cloud API](https://aere.network/cloud.html) — one keyed endpoint for RPC, post-quantum signature verification, chain data, notarization, webhooks and -gas sponsorship on Aere Network (chain 2800). **Zero dependencies** beyond the JDK (11+): -`java.net.http.HttpClient` and `javax.crypto` only. +gas sponsorship on Aere Network (chain 2800). **Zero dependencies** beyond the standard library: +`java.net.HttpURLConnection` and `javax.crypto` only. Since 1.6.1 the class uses only the Java 8 API (the tests enforce it with +`javac --release 8`), which is what Android provides; it has not yet been run on an Android device (see below). It does not follow redirects: a 3xx is raised as an +error, so your API key is never sent to a host you did not name. Mirror of `@aere/cloud` (JS) and `aere-cloud` (Python): same routes, same semantics. Bodies are raw JSON strings — the client does not force a JSON library on the integrator; parse responses @@ -23,6 +25,9 @@ long n = aere.blockNumber(); // a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored: String acct = aere.sponsorCreatePqAccount("0x09" + falconPkHex, 0); +// Trust API (1.6.0): any AERE Proof Protocol (AIP-23) envelope (JSON text) -> VALID / INVALID / PARTIAL with the levels checked +String verdict = aere.verify(envelopeJson, 2800); + // verify a webhook delivery (constant-time HMAC) — never trust one without this: boolean ok = AereCloud.verifyWebhook(rawBodyBytes, request.header("x-aere-signature"), secret); ``` @@ -48,9 +53,16 @@ AereCloud.KeptCheck k = aere.auditVerifyKept("2026-05-01", keptBytes); `auditVerifyKept` hashes YOUR bytes locally. An error (rate limit, network) is raised, never returned as "does not match". +## Android + +Copy `src/main/java/network/aere/cloud/AereCloud.java` into your app module (package `network.aere.cloud`) and call it off the main +thread, as with any network call on Android. What is measured: the class compiles against the Java 8 API (`javac --release 8`) and its +tests pass on the JDK. What is not: we have not run it on an Android device or emulator. + ## Tests ``` +javac --release 8 -d out8 src/main/java/network/aere/cloud/AereCloud.java # the Android gate: Java 8 API only javac -d out src/main/java/network/aere/cloud/AereCloud.java src/test/java/network/aere/cloud/TestAereCloud.java java -cp out network.aere.cloud.TestAereCloud # unit, offline AERE_KEY=ak2800... java -cp out network.aere.cloud.TestAereCloud --live # + live probes @@ -59,6 +71,10 @@ AERE_KEY=ak2800... java -cp out network.aere.cloud.TestAereCloud --live # + li Measured 2026-08-27: 7/7 green, including live probes against production (health, validator set, block number, and the free JSON refusal on the PQ sponsorship route). +Measured 2026-09-28 (1.6.1, JDK 21): 9/9 offline, including a 302 to a second local host that receives zero requests; the negative +control (`node control-negativ.mjs`) turns 8 of 8 planted faults red, among them a followed redirect and two post-Java-8 calls that +the JDK tests alone would not catch but `--release 8` refuses. + Measured 2026-09-25 (1.5.0): 7/7 offline, the new routes judged against a local JDK HTTP server (what the client sends, what it does with each answer). `node control-negativ.mjs` breaks five guards in a copy of the class and requires each one's test to fail: 5/5 red, the untouched class green. diff --git a/java/control-negativ.mjs b/java/control-negativ.mjs index 2565ad7..fa4d844 100644 --- a/java/control-negativ.mjs +++ b/java/control-negativ.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// Controlul negativ al probelor clientului Java 1.5.0: fiecare paznic se strica intr-o COPIE a clasei (sursa din depozit nu se +// Controlul negativ al probelor clientului Java (1.5.0; 1.6.1 adauga redirectarea si poarta --release 8): fiecare paznic se strica intr-o COPIE a clasei (sursa din depozit nu se // atinge), probele se ruleaza pe copie si proba tinta TREBUIE sa pice; o plantare care nu se aplica sau nu compileaza e un ESEC al // controlului (STRICAT), nu un rosu. La urma, clasa neatinsa trebuie sa treaca intreaga. // node aerenew/sdk/java/control-negativ.mjs @@ -25,7 +25,24 @@ const PLANTARI = [ 'return req("POST", "/pq/readiness", json(m), true);', 'readiness fara attest'], ['un 429 intors ca nepotrivire', 'if (r.status < 200 || r.status >= 300) throw new AereCloudException(r.status, r.text(), "/account/audit");\n String dig = sha256Hex(r.octeti);', 'if (r.status < 200 || r.status >= 300) return new KeptCheck(day, "held", null, local, false, null);\n String dig = sha256Hex(r.octeti);', 'auditVerifyKept'], + // 1.6.1: HttpURLConnection urmeaza redirectarile implicit; fara oprire, cheia pleaca spre gazda din Location + ['redirectarea urmata (cheia pleaca spre alta gazda)', 'c.setInstanceFollowRedirects(false);', 'c.setInstanceFollowRedirects(true);', '302'], ]; +// 1.6.1: poarta Android = clasa compileaza cu `javac --release 8`. Un API de dupa Java 8 trece probele pe JDK 21 si trebuie prins +// numai de poarta; plantarea NU e o proba, e o compilare care TREBUIE sa cada. +const PLANTARI_JAVA8 = [ + ['InputStream.readAllBytes (Java 9)', 'while ((n = in.read(buf)) != -1) b.write(buf, 0, n);\n return b.toByteArray();', 'return in.readAllBytes();'], + ['URLEncoder.encode cu Charset (Java 10)', 'try { return URLEncoder.encode(s, "UTF-8"); }', 'try { if (s == null) throw new java.io.UnsupportedEncodingException(); return URLEncoder.encode(s, StandardCharsets.UTF_8); }'], +]; +function compileazaJava8(sursa) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-java8-')); + try { + fs.writeFileSync(path.join(d, 'AereCloud.java'), sursa); + execFileSync('javac', ['--release', '8', '-Xlint:-options', '-d', path.join(d, 'out'), path.join(d, 'AereCloud.java')], { stdio: 'pipe' }); + return { ok: true }; + } catch (e) { return { ok: false, err: String(e.stderr || e.message).split('\n').find((l) => /error/.test(l)) || 'eroare' }; } + finally { fs.rmSync(d, { recursive: true, force: true }); } +} function ruleaza(sursa) { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-java-cn-')); @@ -50,8 +67,16 @@ for (const [nume, vechi, nou, tinta] of PLANTARI) { if (r.picate.some((l) => l.includes(tinta))) console.log(` ROSU cum trebuia ${nume} (${r.esecuri} esec, intre ele proba '${tinta}')`); else { console.log(` CONTROL CAZUT ${nume}: proba '${tinta}' a ramas verde`); rele++; } } +for (const [nume, vechi, nou] of PLANTARI_JAVA8) { + if (original.split(vechi).length !== 2) { console.log(` STRICAT ${nume}: ancora apare de ${original.split(vechi).length - 1} ori`); rele++; continue; } + const r = compileazaJava8(original.replace(vechi, nou)); + if (!r.ok) console.log(` ROSU cum trebuia ${nume}: --release 8 refuza (${r.err.trim().slice(0, 90)})`); + else { console.log(` CONTROL CAZUT ${nume}: --release 8 a compilat`); rele++; } +} +const j8 = compileazaJava8(original); +if (!j8.ok) { console.log(' MARTORUL nu compileaza cu --release 8: ' + j8.err); rele++; } else console.log(' martor: sursa neatinsa compileaza cu --release 8 (API Java 8, deci si Android)'); const martor = ruleaza(original); if (martor.stricat || martor.esecuri !== 0) { console.log(' MARTORUL nu e verde: ' + JSON.stringify(martor)); rele++; } else console.log(` martor: sursa neatinsa ${martor.treceri}/${martor.treceri} verde`); -console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `CONTROL NEGATIV TRECUT (${PLANTARI.length} din ${PLANTARI.length} rosii, martorul verde)`); +console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `CONTROL NEGATIV TRECUT (${PLANTARI.length + PLANTARI_JAVA8.length} din ${PLANTARI.length + PLANTARI_JAVA8.length} rosii, martorul verde si pe --release 8)`); process.exitCode = rele ? 1 : 0; diff --git a/java/src/main/java/network/aere/cloud/AereCloud.java b/java/src/main/java/network/aere/cloud/AereCloud.java index 6bce224..c7bb962 100644 --- a/java/src/main/java/network/aere/cloud/AereCloud.java +++ b/java/src/main/java/network/aere/cloud/AereCloud.java @@ -1,13 +1,15 @@ package network.aere.cloud; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.time.Duration; +import java.util.List; import java.util.Map; import java.util.LinkedHashMap; import javax.crypto.Mac; @@ -18,19 +20,24 @@ import javax.crypto.spec.SecretKeySpec; * * Oglinda fidela a @aere/cloud (sdk/index.mjs) si a aere-cloud (sdk/python): fiecare ruta a * gateway-ului e o metoda; erorile non-2xx se ridica drept {@link AereCloudException} cu codul - * HTTP si corpul serverului. Zero dependinte in afara JDK (11+): HttpClient, Mac/HMAC. + * HTTP si corpul serverului. Zero dependinte in afara bibliotecii standard: HttpURLConnection, Mac/HMAC. + * Din 1.6.1 foloseste numai API-ul Java 8 (poarta: `javac --release 8`), cel pe care il are Android; nerulat inca pe un dispozitiv. * * Corpurile sunt JSON brut (String): clientul NU impune o biblioteca JSON integratorului. * Metodele primesc/intorc String-uri JSON; integratorul le parseaza cu ce foloseste deja. */ public final class AereCloud { // 1.5.0 (2026-09-25): paritate cu @aere/cloud si aere-cloud (Python) 1.5.0: proof, readiness*, pqFinality, audit* - public static final String VERSIUNE = "1.5.0"; + // 1.6.0 (2026-09-28): verify() (Trust API, POST /v1/verify); VERSIUNE ramasese la 1.5.0 si in 1.5.1/1.5.2, adusa la zi cu celelalte trei + // 1.6.1 (2026-09-28): transportul pe HttpURLConnection in loc de java.net.http (care NU exista pe Android), fara redirectari + // urmate (ca inainte: HttpClient nu le urma implicit); URLEncoder cu numele setului de caractere (varianta cu Charset e + // Java 10+). Aceeasi suprafata publica. + public static final String VERSIUNE = "1.6.1"; private static final String BAZA_IMPLICITA = "https://cloud.aere.network/v1"; + private static final int TIMP_MS = 30000; private final String apiKey; private final String baseUrl; - private final HttpClient http; /** Raspuns non-2xx de la Aere Cloud: poarta status, corpul serverului si calea. */ public static final class AereCloudException extends RuntimeException { @@ -53,30 +60,57 @@ public final class AereCloud { throw new IllegalArgumentException("AereCloud: apiKey is required (ak2800.
.)"); this.apiKey = apiKey; this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; - this.http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); } private String req(String metoda, String cale, String corpJson, boolean auth) { + Raspuns r = trimite(metoda, cale, corpJson == null ? null : corpJson.getBytes(StandardCharsets.UTF_8), auth, true); + if (r.status < 200 || r.status >= 300) throw new AereCloudException(r.status, r.text(), cale); + return r.text(); + } + + /** + * Singurul drum spre retea. Redirectarile NU se urmeaza (un 3xx e un raspuns, deci o eroare pentru apelant): cheia API nu + * pleaca niciodata spre o gazda pe care integratorul nu a numit-o. Un raspuns de eroare isi citeste corpul din fluxul de + * eroare; un raspuns fara corp da zero octeti, nu null. + */ + private Raspuns trimite(String metoda, String cale, byte[] corp, boolean auth, boolean json) { + HttpURLConnection c = null; try { - HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + cale)) - .timeout(Duration.ofSeconds(30)) - // User-Agent explicit: marginea raspunde 403 la agentii generici de - // biblioteca pe orice ruta. Lectie masurata la SDK-ul Python, 2026-08-27. - .header("user-agent", "aere-cloud-java/" + VERSIUNE) - .header("content-type", "application/json"); - if (auth) b.header("x-api-key", apiKey); - if (corpJson == null) b.method(metoda, HttpRequest.BodyPublishers.noBody()); - else b.method(metoda, HttpRequest.BodyPublishers.ofString(corpJson, StandardCharsets.UTF_8)); - HttpResponse r = http.send(b.build(), HttpResponse.BodyHandlers.ofString()); - if (r.statusCode() < 200 || r.statusCode() >= 300) - throw new AereCloudException(r.statusCode(), r.body(), cale); - return r.body(); - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) Thread.currentThread().interrupt(); + c = (HttpURLConnection) URI.create(baseUrl + cale).toURL().openConnection(); + c.setInstanceFollowRedirects(false); + c.setConnectTimeout(TIMP_MS); + c.setReadTimeout(TIMP_MS); + c.setRequestMethod(metoda); + // User-Agent explicit: marginea raspunde 403 la agentii generici de + // biblioteca pe orice ruta. Lectie masurata la SDK-ul Python, 2026-08-27. + c.setRequestProperty("user-agent", "aere-cloud-java/" + VERSIUNE); + if (json) c.setRequestProperty("content-type", "application/json"); + if (auth) c.setRequestProperty("x-api-key", apiKey); + if (corp != null) { + c.setDoOutput(true); + c.setFixedLengthStreamingMode(corp.length); + OutputStream o = c.getOutputStream(); + try { o.write(corp); } finally { o.close(); } + } + int status = c.getResponseCode(); + InputStream in = status >= 400 ? c.getErrorStream() : c.getInputStream(); + return new Raspuns(status, citesteTot(in), c.getHeaderFields()); + } catch (IOException e) { throw new AereCloudException(0, e.getMessage(), cale); } } + private static byte[] citesteTot(InputStream in) throws IOException { + if (in == null) return new byte[0]; + try { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) != -1) b.write(buf, 0, n); + return b.toByteArray(); + } finally { in.close(); } + } + private static String esc(String s) { StringBuilder sb = new StringBuilder(); for (char c : s.toCharArray()) { @@ -126,6 +160,9 @@ public final class AereCloud { // ---- post-quantum verification ---- /** payloadJson: {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."} */ public String pqVerify(String payloadJson) { return req("POST", "/pq/verify", payloadJson, true); } + /** 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; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify. chain: 2800 or 28001; 0 = the chain the envelope declares. */ + public String verify(String envelopeJson, long chain) { return req("POST", "/verify" + (chain > 0 ? "?chain=" + chain : ""), envelopeJson, true); } + public String verify(String envelopeJson) { return verify(envelopeJson, 0); } // ---- chain data ---- public String dataHead() { return req("GET", "/data/head", null, true); } @@ -248,25 +285,18 @@ public final class AereCloud { // ---- cererea bruta: status, antete si octeti, pentru rutele care intorc fisiere ---- static final class Raspuns { - final int status; final byte[] octeti; final java.net.http.HttpHeaders antete; - Raspuns(int s, byte[] o, java.net.http.HttpHeaders a) { status = s; octeti = o; antete = a; } - String antet(String n) { return antete.firstValue(n).orElse(null); } + final int status; final byte[] octeti; final Map> antete; + Raspuns(int s, byte[] o, Map> a) { status = s; octeti = o; antete = a; } + /** Primul antet cu numele dat, fara sa conteze literele mari (HttpURLConnection pastreaza forma trimisa de server). */ + String antet(String n) { + if (antete == null) return null; + for (Map.Entry> e : antete.entrySet()) + if (e.getKey() != null && e.getKey().equalsIgnoreCase(n) && e.getValue() != null && !e.getValue().isEmpty()) return e.getValue().get(0); + return null; + } String text() { return new String(octeti, StandardCharsets.UTF_8); } } - private Raspuns reqBrut(String metoda, String cale, boolean auth) { - try { - HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + cale)) - .timeout(Duration.ofSeconds(30)) - .header("user-agent", "aere-cloud-java/" + VERSIUNE); - if (auth) b.header("x-api-key", apiKey); - b.method(metoda, HttpRequest.BodyPublishers.noBody()); - HttpResponse r = http.send(b.build(), HttpResponse.BodyHandlers.ofByteArray()); - return new Raspuns(r.statusCode(), r.body(), r.headers()); - } catch (IOException | InterruptedException e) { - if (e instanceof InterruptedException) Thread.currentThread().interrupt(); - throw new AereCloudException(0, e.getMessage(), cale); - } - } + private Raspuns reqBrut(String metoda, String cale, boolean auth) { return trimite(metoda, cale, null, auth, false); } static String sha256Hex(byte[] b) { try { byte[] h = MessageDigest.getInstance("SHA-256").digest(b); @@ -283,7 +313,10 @@ public final class AereCloud { int j = json.indexOf('"', i + t.length()); return j < 0 ? null : json.substring(i + t.length(), j); } - private static String enc(String s) { return java.net.URLEncoder.encode(s, StandardCharsets.UTF_8); } + private static String enc(String s) { + try { return URLEncoder.encode(s, "UTF-8"); } + catch (java.io.UnsupportedEncodingException e) { throw new IllegalStateException(e); } // UTF-8 exista pe orice JVM + } // ---- webhooks ---- public String listWebhooks() { return req("GET", "/webhooks", null, true); } diff --git a/java/src/test/java/network/aere/cloud/TestAereCloud.java b/java/src/test/java/network/aere/cloud/TestAereCloud.java index 68aaa83..0bdc4a1 100644 --- a/java/src/test/java/network/aere/cloud/TestAereCloud.java +++ b/java/src/test/java/network/aere/cloud/TestAereCloud.java @@ -98,7 +98,7 @@ public final class TestAereCloud { lc.readiness("example.com", false, false); String[] v = ultima.get(); cere(v[0].equals("POST") && v[1].equals("/v1/pq/readiness") && v[2] == null && v[3].equals("{\"domain\":\"example.com\"}"), String.join(" | ", v)); - cere(v[4].equals("aere-cloud-java/1.5.0"), v[4]); + cere(v[4].equals("aere-cloud-java/" + AereCloud.VERSIUNE), v[4]); // versiunea din cod, nu un literal (1.6.0 l-ar fi rupt) lc.readiness("example.com", true, true); v = ultima.get(); cere("ak2800.0xAB.SINTETIC".equals(v[2]) && v[3].equals("{\"domain\":\"example.com\",\"fresh\":true,\"attest\":true}"), String.join(" | ", v)); @@ -113,6 +113,13 @@ public final class TestAereCloud { lc.proof("0xab"); cere(ultima.get()[1].equals("/v1/proof/0xab") && ultima.get()[2] != null, ultima.get()[1]); }); + proba("1.6.0: verify (Trust API) trimite plicul neschimbat pe POST /v1/verify, cu cheie si ?chain; fara lant, fara parametru", () -> { + lc.verify("{\"statementHash\":\"0x00\"}", 28001); + String[] v = ultima.get(); + cere(v[0].equals("POST") && v[1].equals("/v1/verify?chain=28001") && v[2] != null && v[3].equals("{\"statementHash\":\"0x00\"}"), String.join(" | ", v)); + lc.verify("{}"); + cere(ultima.get()[1].equals("/v1/verify"), ultima.get()[1]); + }); proba("1.5.0: auditExport: jsonl verified=true pe octetii care dau digestul, FALSE pe alti octeti, null la cef; un 400 se ridica", () -> { raspunsuri.put("/v1/account/audit", new Object[]{200, zi, new String[]{"x-aere-digest", dZi, "x-aere-day", "2026-05-01", "x-aere-entries", "1"}}); AereCloud.AuditExport e = lc.auditExport("2026-05-01", "jsonl"); @@ -140,6 +147,20 @@ public final class TestAereCloud { raspunsuri.put("/v1/account/audit", new Object[]{429, "{\"error\":\"rate_limit\"}".getBytes(StandardCharsets.UTF_8), new String[0]}); try { lc.auditVerifyKept("2026-05-01", zi); throw new AssertionError("429 intors ca nepotrivire"); } catch (AereCloud.AereCloudException x) { cere(x.status == 429, "status " + x.status); } }); + // 1.6.1: transportul e HttpURLConnection, care URMEAZA redirectarile implicit; clientul trebuie sa le opreasca, altfel + // cheia API pleaca spre gazda din Location. A doua gazda locala numara cererile: trebuie sa primeasca ZERO. + final int[] strainVazute = {0}; + com.sun.net.httpserver.HttpServer strain = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + strain.createContext("/", ex -> { synchronized (strainVazute) { strainVazute[0]++; } ex.sendResponseHeaders(200, -1); ex.close(); }); + strain.start(); + try { + proba("1.6.1: un 302 spre alta gazda NU se urmeaza: clientul ridica 302, gazda straina primeste zero cereri (cheia nu pleaca)", () -> { + raspunsuri.put("/v1/data/head", new Object[]{302, new byte[0], new String[]{"location", "http://127.0.0.1:" + strain.getAddress().getPort() + "/fura"}}); + try { lc.dataHead(); throw new AssertionError("302 neridicat"); } + catch (AereCloud.AereCloudException x) { cere(x.status == 302, "status " + x.status); } + synchronized (strainVazute) { cere(strainVazute[0] == 0, "gazda straina a primit " + strainVazute[0] + " cereri"); } + }); + } finally { strain.stop(0); } } finally { srv.stop(0); } @@ -159,6 +180,20 @@ public final class TestAereCloud { cere(e.body != null && e.body.contains("897"), "alt refuz: " + e.body); } }); + // 2026-09-29: Trust API prin productie - plic minimal valid -> VALID, acelasi cu statement schimbat -> INVALID + proba("LIVE: verify - plic valid VALID, plic atins INVALID", () -> { + String st = "{\"v\":1,\"kind\":\"aere-proof-of-data\",\"subject\":{\"name\":\"example.csv\"},\"sha256\":\"0x" + "11".repeat(32) + "\",\"createdAt\":\"2026-09-26T00:00:00.000Z\"}"; + String h; + try { + byte[] d = java.security.MessageDigest.getInstance("SHA-256").digest(st.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder("0x"); for (byte b : d) sb.append(String.format("%02x", b)); h = sb.toString(); + } catch (Exception e) { throw new RuntimeException(e); } + String plic = "{\"v\":1,\"kind\":\"aere-proof-of-data-attestation\",\"statement\":" + st + ",\"statementHash\":\"" + h + "\"}"; + String bun = c.verify(plic); + cere(bun.contains("\"verdict\":\"VALID\""), bun); + String rau = c.verify(plic.replace("0x" + "11".repeat(32), "0x" + "22".repeat(32))); + cere(rau.contains("\"verdict\":\"INVALID\""), rau); + }); } else if (live) { System.out.println(" (sar LIVE: AERE_KEY lipseste)"); } diff --git a/package.json b/package.json index 5e01678..507d980 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aere/cloud", - "version": "1.5.2", + "version": "1.6.0", "description": "Official client for the Aere Cloud API: keyed RPC, post-quantum verification, chain data, notarization, webhooks and gas sponsorship on Aere Network (chain 2800).", "type": "module", "main": "index.mjs", diff --git a/python/README.md b/python/README.md index aa3d70d..52d5d8e 100644 --- a/python/README.md +++ b/python/README.md @@ -38,6 +38,9 @@ out = aere.pq_verify({ # notarize a document hash — Aere pays the gas receipt = aere.notarize("0x" + sha256_hex) + +# Trust API (1.6.0): any AERE Proof Protocol (AIP-23) envelope -> VALID / INVALID / PARTIAL, with the levels checked +r = aere.verify(envelope, chain=2800) # {"verdict", "levels", "verifier", "reproduce"} proof = aere.proof_of("0x" + sha256_hex) # a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored: diff --git a/python/aere_cloud/__init__.py b/python/aere_cloud/__init__.py index 8878ebb..2a18cae 100644 --- a/python/aere_cloud/__init__.py +++ b/python/aere_cloud/__init__.py @@ -14,7 +14,7 @@ import urllib.request import urllib.error from typing import Any, Optional -__version__ = "1.5.2" +__version__ = "1.6.0" __all__ = ["AereCloud", "AereCloudError", "verify_webhook"] _BAZA = "https://cloud.aere.network/v1" @@ -106,6 +106,11 @@ class AereCloud: """{scheme, publicKey, signature?, message?, signedMessage?} (0x-hex) -> {valid, ...}""" return self._req("POST", "/pq/verify", payload) + def verify(self, envelope: dict, chain: "int | None" = None) -> dict: + """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; the judgement is the published reference verifier. Needs a gateway that serves POST /v1/verify. + chain: 2800 or 28001; None = the chain the envelope declares.""" + return self._req("POST", "/verify" + (f"?chain={int(chain)}" if chain else ""), envelope) + # ---- chain data ---- def data_head(self) -> dict: return self._req("GET", "/data/head") diff --git a/python/pyproject.toml b/python/pyproject.toml index fe968ef..7faa2aa 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "aere-cloud" -version = "1.5.2" +version = "1.6.0" description = "Official Python client for the Aere Cloud API (Aere Network, chain 2800): RPC, post-quantum verification, chain data, notarization, webhooks, gas sponsorship." readme = "README.md" requires-python = ">=3.9" diff --git a/python/test_aere_cloud.py b/python/test_aere_cloud.py index cee1ce9..a570264 100644 --- a/python/test_aere_cloud.py +++ b/python/test_aere_cloud.py @@ -81,6 +81,16 @@ def t_query(): assert tr.cereri[2].full_url.endswith("/data/anchors/15000000") assert tr.cereri[3].full_url.endswith("/data/finality") +def t_verify(): + tr = Transport([(200, {"verdict": "VALID", "levels": []}), (200, {"verdict": "PARTIAL", "levels": []})]) + c = AereCloud("k", opener=tr) + assert c.verify({"statement": {}, "statementHash": "0x00"}, chain=28001)["verdict"] == "VALID" + assert c.verify({"statement": {}})["verdict"] == "PARTIAL" + assert tr.cereri[0].full_url.endswith("/verify?chain=28001") and tr.cereri[0].get_method() == "POST" + assert tr.cereri[1].full_url.endswith("/verify") + assert tr.cereri[0].get_header("X-api-key") == "k", "verify cere cheia" + assert json.loads(tr.cereri[0].data.decode()) == {"statement": {}, "statementHash": "0x00"} + def t_pq_sponsor(): tr = Transport([(200, {"txHash": "0xab", "account": "0xcd", "alreadyDeployed": False})]) c = AereCloud("k", opener=tr) @@ -103,6 +113,7 @@ proba("cheia merge in antet, health e fara cheie", t_antet) proba("rpc impacheteaza corect si ridica eroarea RPC", t_rpc) proba("un status de eroare devine AereCloudError cu corpul serverului", t_eroare_http) proba("rutele de date construiesc corect query-ul", t_query) +proba("verify (Trust API, 1.6.0): ruta, ?chain, cheia si plicul neschimbat", t_verify) proba("sponsor_create_pq_account: corp si cale corecte", t_pq_sponsor) proba("verify_webhook: buna trece; stricata, prelungita, corp atins pica", t_webhook) @@ -240,5 +251,17 @@ if "--live" in sys.argv: proba("LIVE: ancora cu >=6 sigilii", t_live_ancora) proba("LIVE: sponsor PQ refuza JSON pe cheie scurta", lambda: _live_pq(c)) + # 2026-09-29: Trust API prin productie - plic minimal valid -> VALID, acelasi cu statement schimbat -> INVALID + def t_live_verify(): + st = {"v": 1, "kind": "aere-proof-of-data", "subject": {"name": "example.csv"}, "sha256": "0x" + "11" * 32, "createdAt": "2026-09-26T00:00:00.000Z"} + text = json.dumps(st, separators=(",", ":"), ensure_ascii=False) + plic = {"v": 1, "kind": "aere-proof-of-data-attestation", "statement": st, "statementHash": "0x" + hashlib.sha256(text.encode("utf-8")).hexdigest()} + bun = c.verify(plic) + assert bun["verdict"] == "VALID", bun + rau = c.verify(dict(plic, statement=dict(st, sha256="0x" + "22" * 32))) + assert rau["verdict"] == "INVALID", rau + assert any(l["level"] == "integrity" and l["state"] == "FAILED" for l in rau["levels"]) + proba("LIVE: verify - plic valid VALID, plic atins INVALID", t_live_verify) + print(f"\n{trecute} treceri, {picate} esecuri") sys.exit(1 if picate else 0) diff --git a/test/sdk.test.mjs b/test/sdk.test.mjs index 3b4e118..0b00778 100644 --- a/test/sdk.test.mjs +++ b/test/sdk.test.mjs @@ -65,11 +65,15 @@ await test('rutele de date construiesc corect query-ul', async () => { const rute = { 'GET /data/anchors?limit=3': { status: 200, corp: { anchors: [] } }, 'GET /data/finality': { status: 200, corp: { finalized: 1, anchor: 2 } }, + 'POST /verify?chain=28001': { status: 200, corp: { verdict: 'VALID', levels: [] } }, + 'POST /verify': { status: 200, corp: { verdict: 'INVALID', levels: [] } }, 'GET /data/transfers?address=0xBeef&limit=25': { status: 200, corp: { tokenTransfers: [], nativeTransfers: [] } }, }; const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: fetchFals(rute) }); await c.anchors(3); await c.finality(); + assert.equal((await c.verify({ statement: {}, statementHash: '0x00' }, { chain: 28001 })).verdict, 'VALID'); + assert.equal((await c.verify({ statement: {}, statementHash: '0x00' })).verdict, 'INVALID'); await c.transfers('0xBeef', 25); // daca reperele nu s-ar potrivi, fetchFals ar da 404 si _req ar arunca }); @@ -194,6 +198,17 @@ if (process.env.AERE_CHEIE) { const acc = await c.account(); assert.ok(acc.usageLast31Days); }); + // 2026-09-29: Trust API prin productie - un plic minimal valid -> VALID, acelasi cu statement schimbat -> INVALID (integritatea) + await test('LIVE: verify() - plic valid VALID, plic atins INVALID', async () => { + const c = new AereCloud({ apiKey: process.env.AERE_CHEIE }); + const statement = { v: 1, kind: 'aere-proof-of-data', subject: { name: 'example.csv' }, sha256: '0x' + '11'.repeat(32), createdAt: '2026-09-26T00:00:00.000Z' }; + const plic = { v: 1, kind: 'aere-proof-of-data-attestation', statement, statementHash: '0x' + crypto.createHash('sha256').update(JSON.stringify(statement), 'utf8').digest('hex') }; + const bun = await c.verify(plic); + assert.equal(bun.verdict, 'VALID', JSON.stringify(bun).slice(0, 200)); + const rau = await c.verify({ ...plic, statement: { ...statement, sha256: '0x' + '22'.repeat(32) } }); + assert.equal(rau.verdict, 'INVALID'); + assert.ok(rau.levels.some((l) => l.level === 'integrity' && l.state === 'FAILED')); + }); } else { console.log(' (sar proba LIVE: AERE_CHEIE nu e in mediu)'); }