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.

This commit is contained in:
Aere Network 2026-09-29 15:45:17 +03:00
parent 983f93aa66
commit 88ed2ac92c
23 changed files with 852 additions and 53 deletions

View File

@ -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 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 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
semantics, and none of them follows redirects, so your API key is never sent to a host you did not name. .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). 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.<your-0x-address>.<secret>`. A key looks like `ak2800.<your-0x-address>.<secret>`.
@ -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) // 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 }); 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) // transfer history for an address (token from genesis, native from launch)
const tx = await aere.transfers('0xYourAddress', 50); 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 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 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. JSON `.body` so you can act on the exact error.

2
csharp/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
bin/
obj/

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Aere.Cloud\Aere.Cloud.csproj" />
</ItemGroup>
</Project>

View File

@ -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<Task> 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<HttpRequestMessage, HttpResponseMessage>? 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<string, object> { ["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<string, object> { ["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<string, string> Antete, string Corp);
public List<Cerere> Cereri { get; } = new();
public Cerere Ultima => Cereri[^1];
public Func<HttpRequestMessage, HttpResponseMessage> Raspunde { get; set; } = _ => new HttpResponseMessage(HttpStatusCode.OK);
protected override async Task<HttpResponseMessage> 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);
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<RootNamespace>Aere.Cloud</RootNamespace>
<AssemblyName>Aere.Cloud</AssemblyName>
<PackageId>Aere.Cloud</PackageId>
<Version>1.6.0</Version>
<Authors>Aere Network</Authors>
<Description>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.</Description>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://aere.network</PackageProjectUrl>
<Deterministic>true</Deterministic>
</PropertyGroup>
</Project>

View File

@ -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;
/// <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.&lt;address&gt;.&lt;secret&gt;).</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.6.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>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=&amp;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);
}
}

49
csharp/README.md Normal file
View File

@ -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.

55
csharp/control-negativ.sh Normal file
View File

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

View File

@ -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 // 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) 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) // verify a webhook delivery (constant-time HMAC-SHA256 over the exact bytes received)
ok := aerecloud.VerifyWebhook(rawBody, r.Header.Get("x-aere-signature"), secret) ok := aerecloud.VerifyWebhook(rawBody, r.Header.Get("x-aere-signature"), secret)
``` ```

View File

@ -30,7 +30,7 @@ import (
) )
// Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route). // 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. // DefaultBaseURL is the production gateway.
const DefaultBaseURL = "https://cloud.aere.network/v1" 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) { func (c *Client) PQVerify(ctx context.Context, payload any) (json.RawMessage, error) {
return c.jsonReq(ctx, "POST", "/pq/verify", payload, true) 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) { func (c *Client) DataHead(ctx context.Context) (json.RawMessage, error) {
return c.jsonReq(ctx, "GET", "/data/head", nil, true) return c.jsonReq(ctx, "GET", "/data/head", nil, true)
} }

View File

@ -114,6 +114,14 @@ func TestPerimetruFinalitateDovada(t *testing.T) {
if v = s.ultima(); v.Path != "/v1/proof/0xab" || v.Key == "" { if v = s.ultima(); v.Path != "/v1/proof/0xab" || v.Key == "" {
t.Fatalf("dovada: %+v", v) 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") 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) { func TestVerifyWebhook(t *testing.T) {
secret := "AERE-SINTETIC-secret" secret := "AERE-SINTETIC-secret"
corp := []byte(`{"event":"pq-anchor","data":{"height":42}}`) corp := []byte(`{"event":"pq-anchor","data":{"height":42}}`)

View File

@ -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'], ['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)', ['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'], '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) { function ruleaza(src) {

View File

@ -65,6 +65,16 @@ export class AereCloud {
*/ */
pqVerify(p) { return this._req('POST', '/pq/verify', { body: p }); } 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 ---- // ---- chain data ----
dataHead() { return this._req('GET', '/data/head'); } dataHead() { return this._req('GET', '/data/head'); }
validators() { return this._req('GET', '/data/validators'); } validators() { return this._req('GET', '/data/validators'); }

View File

@ -2,8 +2,10 @@
The official Java client for the [Aere Cloud API](https://aere.network/cloud.html) — one keyed 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 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+): gas sponsorship on Aere Network (chain 2800). **Zero dependencies** beyond the standard library:
`java.net.http.HttpClient` and `javax.crypto` only. `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 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 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: // a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored:
String acct = aere.sponsorCreatePqAccount("0x09" + falconPkHex, 0); 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: // verify a webhook delivery (constant-time HMAC) — never trust one without this:
boolean ok = AereCloud.verifyWebhook(rawBodyBytes, request.header("x-aere-signature"), secret); 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". `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 ## 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 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 java -cp out network.aere.cloud.TestAereCloud # unit, offline
AERE_KEY=ak2800... java -cp out network.aere.cloud.TestAereCloud --live # + live probes 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 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). 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, 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 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. one's test to fail: 5/5 red, the untouched class green.

View File

@ -1,5 +1,5 @@
#!/usr/bin/env node #!/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 // 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. // controlului (STRICAT), nu un rosu. La urma, clasa neatinsa trebuie sa treaca intreaga.
// node aerenew/sdk/java/control-negativ.mjs // node aerenew/sdk/java/control-negativ.mjs
@ -25,7 +25,24 @@ const PLANTARI = [
'return req("POST", "/pq/readiness", json(m), true);', 'readiness fara attest'], '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);', ['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'], '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) { function ruleaza(sursa) {
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-java-cn-')); 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}')`); 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++; } 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); const martor = ruleaza(original);
if (martor.stricat || martor.esecuri !== 0) { console.log(' MARTORUL nu e verde: ' + JSON.stringify(martor)); rele++; } 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`); 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; process.exitCode = rele ? 1 : 0;

View File

@ -1,13 +1,15 @@
package network.aere.cloud; package network.aere.cloud;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URI; import java.net.URI;
import java.net.http.HttpClient; import java.net.URLEncoder;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.time.Duration; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import javax.crypto.Mac; 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 * 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 * 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. * 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. * Metodele primesc/intorc String-uri JSON; integratorul le parseaza cu ce foloseste deja.
*/ */
public final class AereCloud { 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* // 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 String BAZA_IMPLICITA = "https://cloud.aere.network/v1";
private static final int TIMP_MS = 30000;
private final String apiKey; private final String apiKey;
private final String baseUrl; private final String baseUrl;
private final HttpClient http;
/** Raspuns non-2xx de la Aere Cloud: poarta status, corpul serverului si calea. */ /** Raspuns non-2xx de la Aere Cloud: poarta status, corpul serverului si calea. */
public static final class AereCloudException extends RuntimeException { public static final class AereCloudException extends RuntimeException {
@ -53,30 +60,57 @@ public final class AereCloud {
throw new IllegalArgumentException("AereCloud: apiKey is required (ak2800.<address>.<secret>)"); throw new IllegalArgumentException("AereCloud: apiKey is required (ak2800.<address>.<secret>)");
this.apiKey = apiKey; this.apiKey = apiKey;
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; 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) { 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 { try {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(baseUrl + cale)) c = (HttpURLConnection) URI.create(baseUrl + cale).toURL().openConnection();
.timeout(Duration.ofSeconds(30)) c.setInstanceFollowRedirects(false);
// User-Agent explicit: marginea raspunde 403 la agentii generici de c.setConnectTimeout(TIMP_MS);
// biblioteca pe orice ruta. Lectie masurata la SDK-ul Python, 2026-08-27. c.setReadTimeout(TIMP_MS);
.header("user-agent", "aere-cloud-java/" + VERSIUNE) c.setRequestMethod(metoda);
.header("content-type", "application/json"); // User-Agent explicit: marginea raspunde 403 la agentii generici de
if (auth) b.header("x-api-key", apiKey); // biblioteca pe orice ruta. Lectie masurata la SDK-ul Python, 2026-08-27.
if (corpJson == null) b.method(metoda, HttpRequest.BodyPublishers.noBody()); c.setRequestProperty("user-agent", "aere-cloud-java/" + VERSIUNE);
else b.method(metoda, HttpRequest.BodyPublishers.ofString(corpJson, StandardCharsets.UTF_8)); if (json) c.setRequestProperty("content-type", "application/json");
HttpResponse<String> r = http.send(b.build(), HttpResponse.BodyHandlers.ofString()); if (auth) c.setRequestProperty("x-api-key", apiKey);
if (r.statusCode() < 200 || r.statusCode() >= 300) if (corp != null) {
throw new AereCloudException(r.statusCode(), r.body(), cale); c.setDoOutput(true);
return r.body(); c.setFixedLengthStreamingMode(corp.length);
} catch (IOException | InterruptedException e) { OutputStream o = c.getOutputStream();
if (e instanceof InterruptedException) Thread.currentThread().interrupt(); 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); 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) { private static String esc(String s) {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) { for (char c : s.toCharArray()) {
@ -126,6 +160,9 @@ public final class AereCloud {
// ---- post-quantum verification ---- // ---- post-quantum verification ----
/** payloadJson: {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."} */ /** payloadJson: {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."} */
public String pqVerify(String payloadJson) { return req("POST", "/pq/verify", payloadJson, true); } 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 ---- // ---- chain data ----
public String dataHead() { return req("GET", "/data/head", null, true); } 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 ---- // ---- cererea bruta: status, antete si octeti, pentru rutele care intorc fisiere ----
static final class Raspuns { static final class Raspuns {
final int status; final byte[] octeti; final java.net.http.HttpHeaders antete; final int status; final byte[] octeti; final Map<String, List<String>> antete;
Raspuns(int s, byte[] o, java.net.http.HttpHeaders a) { status = s; octeti = o; antete = a; } Raspuns(int s, byte[] o, Map<String, List<String>> a) { status = s; octeti = o; antete = a; }
String antet(String n) { return antete.firstValue(n).orElse(null); } /** 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<String, List<String>> 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); } String text() { return new String(octeti, StandardCharsets.UTF_8); }
} }
private Raspuns reqBrut(String metoda, String cale, boolean auth) { private Raspuns reqBrut(String metoda, String cale, boolean auth) { return trimite(metoda, cale, null, auth, false); }
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<byte[]> 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);
}
}
static String sha256Hex(byte[] b) { static String sha256Hex(byte[] b) {
try { try {
byte[] h = MessageDigest.getInstance("SHA-256").digest(b); byte[] h = MessageDigest.getInstance("SHA-256").digest(b);
@ -283,7 +313,10 @@ public final class AereCloud {
int j = json.indexOf('"', i + t.length()); int j = json.indexOf('"', i + t.length());
return j < 0 ? null : json.substring(i + t.length(), j); 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 ---- // ---- webhooks ----
public String listWebhooks() { return req("GET", "/webhooks", null, true); } public String listWebhooks() { return req("GET", "/webhooks", null, true); }

View File

@ -98,7 +98,7 @@ public final class TestAereCloud {
lc.readiness("example.com", false, false); lc.readiness("example.com", false, false);
String[] v = ultima.get(); 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[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); lc.readiness("example.com", true, true);
v = ultima.get(); v = ultima.get();
cere("ak2800.0xAB.SINTETIC".equals(v[2]) && v[3].equals("{\"domain\":\"example.com\",\"fresh\":true,\"attest\":true}"), String.join(" | ", v)); 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"); lc.proof("0xab");
cere(ultima.get()[1].equals("/v1/proof/0xab") && ultima.get()[2] != null, ultima.get()[1]); 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", () -> { 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"}}); 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"); 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]}); 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); } 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 { } finally {
srv.stop(0); srv.stop(0);
} }
@ -159,6 +180,20 @@ public final class TestAereCloud {
cere(e.body != null && e.body.contains("897"), "alt refuz: " + e.body); 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) { } else if (live) {
System.out.println(" (sar LIVE: AERE_KEY lipseste)"); System.out.println(" (sar LIVE: AERE_KEY lipseste)");
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "@aere/cloud", "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).", "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", "type": "module",
"main": "index.mjs", "main": "index.mjs",

View File

@ -38,6 +38,9 @@ out = aere.pq_verify({
# notarize a document hash — Aere pays the gas # notarize a document hash — Aere pays the gas
receipt = aere.notarize("0x" + sha256_hex) 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) proof = aere.proof_of("0x" + sha256_hex)
# a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored: # a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored:

View File

@ -14,7 +14,7 @@ import urllib.request
import urllib.error import urllib.error
from typing import Any, Optional from typing import Any, Optional
__version__ = "1.5.2" __version__ = "1.6.0"
__all__ = ["AereCloud", "AereCloudError", "verify_webhook"] __all__ = ["AereCloud", "AereCloudError", "verify_webhook"]
_BAZA = "https://cloud.aere.network/v1" _BAZA = "https://cloud.aere.network/v1"
@ -106,6 +106,11 @@ class AereCloud:
"""{scheme, publicKey, signature?, message?, signedMessage?} (0x-hex) -> {valid, ...}""" """{scheme, publicKey, signature?, message?, signedMessage?} (0x-hex) -> {valid, ...}"""
return self._req("POST", "/pq/verify", payload) 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 ---- # ---- chain data ----
def data_head(self) -> dict: def data_head(self) -> dict:
return self._req("GET", "/data/head") return self._req("GET", "/data/head")

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aere-cloud" 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." 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" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"

View File

@ -81,6 +81,16 @@ def t_query():
assert tr.cereri[2].full_url.endswith("/data/anchors/15000000") assert tr.cereri[2].full_url.endswith("/data/anchors/15000000")
assert tr.cereri[3].full_url.endswith("/data/finality") 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(): def t_pq_sponsor():
tr = Transport([(200, {"txHash": "0xab", "account": "0xcd", "alreadyDeployed": False})]) tr = Transport([(200, {"txHash": "0xab", "account": "0xcd", "alreadyDeployed": False})])
c = AereCloud("k", opener=tr) 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("rpc impacheteaza corect si ridica eroarea RPC", t_rpc)
proba("un status de eroare devine AereCloudError cu corpul serverului", t_eroare_http) proba("un status de eroare devine AereCloudError cu corpul serverului", t_eroare_http)
proba("rutele de date construiesc corect query-ul", t_query) 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("sponsor_create_pq_account: corp si cale corecte", t_pq_sponsor)
proba("verify_webhook: buna trece; stricata, prelungita, corp atins pica", t_webhook) 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: ancora cu >=6 sigilii", t_live_ancora)
proba("LIVE: sponsor PQ refuza JSON pe cheie scurta", lambda: _live_pq(c)) 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") print(f"\n{trecute} treceri, {picate} esecuri")
sys.exit(1 if picate else 0) sys.exit(1 if picate else 0)

View File

@ -65,11 +65,15 @@ await test('rutele de date construiesc corect query-ul', async () => {
const rute = { const rute = {
'GET /data/anchors?limit=3': { status: 200, corp: { anchors: [] } }, 'GET /data/anchors?limit=3': { status: 200, corp: { anchors: [] } },
'GET /data/finality': { status: 200, corp: { finalized: 1, anchor: 2 } }, '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: [] } }, '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) }); const c = new AereCloud({ apiKey: 'ak2800.0xAAA.AERE-SINTETIC-s', baseUrl: BAZA_TEST, fetch: fetchFals(rute) });
await c.anchors(3); await c.anchors(3);
await c.finality(); 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); await c.transfers('0xBeef', 25);
// daca reperele nu s-ar potrivi, fetchFals ar da 404 si _req ar arunca // 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(); const acc = await c.account();
assert.ok(acc.usageLast31Days); 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 { } else {
console.log(' (sar proba LIVE: AERE_CHEIE nu e in mediu)'); console.log(' (sar proba LIVE: AERE_CHEIE nu e in mediu)');
} }