From af565d3aec9f322dc9def212e291c97b4f2df5b9 Mon Sep 17 00:00:00 2001 From: Liviu Date: Fri, 25 Sep 2026 11:02:13 +0300 Subject: [PATCH] Install from this repository (the clients are not on npm or PyPI yet); Python and Java clients 1.5.1/1.5.0 added The README told readers to run npm install @aere/cloud and pip install aere-cloud; neither name exists on those registries, so the command failed, and a name there that looks like ours is not ours. Install now points at this repository for JavaScript, Python (python/, subdirectory install) and Go (go/); java/ builds from source with the JDK alone. --- README.md | 14 +- java/.gitignore | 1 + java/README.md | 64 ++++ java/control-negativ.mjs | 57 ++++ .../java/network/aere/cloud/AereCloud.java | 323 ++++++++++++++++++ .../network/aere/cloud/TestAereCloud.java | 169 +++++++++ python/README.md | 79 +++++ python/aere_cloud/__init__.py | 256 ++++++++++++++ python/pyproject.toml | 25 ++ python/test_aere_cloud.py | 243 +++++++++++++ 10 files changed, 1228 insertions(+), 3 deletions(-) create mode 100644 java/.gitignore create mode 100644 java/README.md create mode 100644 java/control-negativ.mjs create mode 100644 java/src/main/java/network/aere/cloud/AereCloud.java create mode 100644 java/src/test/java/network/aere/cloud/TestAereCloud.java create mode 100644 python/README.md create mode 100644 python/aere_cloud/__init__.py create mode 100644 python/pyproject.toml create mode 100644 python/test_aere_cloud.py diff --git a/README.md b/README.md index 5707cba..e9bb0f1 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,18 @@ browsers. ## Install +The clients are installed from this repository; they are not on the npm or PyPI registries yet, and a name there that +looks like ours is not ours. + ``` -npm install @aere/cloud +npm install git+https://git.aere.network/aere-network/aere-cloud-sdk.git # JavaScript (@aere/cloud) +pip install "git+https://git.aere.network/aere-network/aere-cloud-sdk.git#subdirectory=python" # Python (aere-cloud) +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. + 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,8 +73,8 @@ const acct = await aere.sponsorCreatePqAccount('0x09' + falconPkHex, 0); // -> { txHash, account: '0x…', alreadyDeployed: false } ``` -A Python mirror of this client lives in `sdk/python/` (`pip install aere-cloud`): same routes, -same semantics, snake_case names. +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. 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/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000..89f9ac0 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/java/README.md b/java/README.md new file mode 100644 index 0000000..d6c7b75 --- /dev/null +++ b/java/README.md @@ -0,0 +1,64 @@ +# aere-cloud (Java) + +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. + +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 +with whatever you already use (Jackson, Gson, `org.json`, ...). + +## Use + +```java +import network.aere.cloud.AereCloud; + +var aere = new AereCloud(System.getenv("AERE_KEY")); + +String head = aere.dataHead(); // {"block":...,"chainId":2800,...} +String anchors = aere.anchors(5); // post-quantum anchor certificates +long n = aere.blockNumber(); + +// a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored: +String acct = aere.sponsorCreatePqAccount("0x09" + falconPkHex, 0); + +// verify a webhook delivery (constant-time HMAC) — never trust one without this: +boolean ok = AereCloud.verifyWebhook(rawBodyBytes, request.header("x-aere-signature"), secret); +``` + +Non-2xx responses raise `AereCloud.AereCloudException` with `.status`, `.body` (server JSON) +and `.path`. + +## Since 1.5.0 (same surface as the JS and Python SDKs 1.5.0) + +```java +aere.proof("0x" + sha256Hex); // a proof with its post-quantum finality +aere.readiness("example.com", false, false); // quantum-readiness scan, free, no key sent +aere.readinessPerimeter("example.com", new String[]{"api"}, null, false); // a domain's whole public perimeter +aere.pqFinality("latest", "testnet", "both"); // AIP-21 per-block PQ finality, both clients + +AereCloud.AuditExport day = aere.auditExport("2026-09-24", "jsonl"); +// day.bytes are the exact bytes of the day file; day.verified is true only if THEIR sha256 equals the stated digest + +aere.auditRetention(); // read-only with the API key; set it in the console +AereCloud.KeptCheck k = aere.auditVerifyKept("2026-05-01", keptBytes); +// k.matches: your kept export still matches the day we hold, or the tombstone (digest) a deleted day left behind +``` + +`auditVerifyKept` hashes YOUR bytes locally. An error (rate limit, network) is raised, never returned as "does not match". + +## Tests + +``` +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 +``` + +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-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 new file mode 100644 index 0000000..2565ad7 --- /dev/null +++ b/java/control-negativ.mjs @@ -0,0 +1,57 @@ +#!/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 +// 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 +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const SRC = path.join(AICI, 'src', 'main', 'java', 'network', 'aere', 'cloud', 'AereCloud.java'); +const TEST = path.join(AICI, 'src', 'test', 'java', 'network', 'aere', 'cloud', 'TestAereCloud.java'); +const original = fs.readFileSync(SRC, 'utf8'); + +const PLANTARI = [ + ['piatra zilei sterse acceptata fara comparatie', 'return new KeptCheck(day, "expired", dig, local, local.equals(dig), cand);', + 'return new KeptCheck(day, "expired", dig, local, true, cand);', 'auditVerifyKept'], + ['ziua tinuta acceptata fara comparatie', 'return new KeptCheck(day, "held", dig, local, local.equals(dig), null);', + 'return new KeptCheck(day, "held", dig, local, true, null);', 'auditVerifyKept'], + ['exportul jsonl declarat verificat fara hash', 'Boolean ok = "jsonl".equals(f) ? (digest != null && digest.equals(sha256Hex(r.octeti))) : null;', + 'Boolean ok = "jsonl".equals(f) ? Boolean.TRUE : null;', 'auditExport'], + ['cheia trimisa si fara attest', 'return req("POST", "/pq/readiness", json(m), 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);', + 'if (r.status < 200 || r.status >= 300) return new KeptCheck(day, "held", null, local, false, null);\n String dig = sha256Hex(r.octeti);', 'auditVerifyKept'], +]; + +function ruleaza(sursa) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-java-cn-')); + try { + fs.writeFileSync(path.join(d, 'AereCloud.java'), sursa); + try { execFileSync('javac', ['-d', path.join(d, 'out'), path.join(d, 'AereCloud.java'), TEST], { stdio: 'pipe' }); } + catch (e) { return { stricat: 'nu compileaza: ' + String(e.stderr || e.message).split('\n')[0] }; } + let out; + try { out = execFileSync('java', ['-cp', path.join(d, 'out'), 'network.aere.cloud.TestAereCloud'], { encoding: 'utf8' }); } + catch (e) { out = String(e.stdout || ''); } + const m = /(\d+) treceri, (\d+) esecuri/.exec(out); + if (!m) return { stricat: 'probele nu au terminat' }; + return { treceri: +m[1], esecuri: +m[2], picate: out.split('\n').filter((l) => l.startsWith(' PICA')) }; + } finally { fs.rmSync(d, { recursive: true, force: true }); } +} + +let rele = 0; +for (const [nume, vechi, nou, tinta] of PLANTARI) { + if (original.split(vechi).length !== 2) { console.log(` STRICAT ${nume}: ancora apare de ${original.split(vechi).length - 1} ori`); rele++; continue; } + const r = ruleaza(original.replace(vechi, nou)); + if (r.stricat) { console.log(` STRICAT ${nume}: ${r.stricat}`); rele++; continue; } + 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++; } +} +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)`); +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 new file mode 100644 index 0000000..1a8abff --- /dev/null +++ b/java/src/main/java/network/aere/cloud/AereCloud.java @@ -0,0 +1,323 @@ +package network.aere.cloud; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.Map; +import java.util.LinkedHashMap; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * aere-cloud (Java): clientul oficial al Aere Cloud API (Aere Network, chain 2800). + * + * 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. + * + * 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"; + private static final String BAZA_IMPLICITA = "https://cloud.aere.network/v1"; + + 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 { + public final int status; + public final String body; + public final String path; + + AereCloudException(int status, String body, String path) { + super("Aere Cloud " + status + " on " + path + ": " + (body == null ? "request failed" : body)); + this.status = status; + this.body = body; + this.path = path; + } + } + + public AereCloud(String apiKey) { this(apiKey, BAZA_IMPLICITA); } + + public AereCloud(String apiKey, String baseUrl) { + if (apiKey == null || apiKey.isEmpty()) + 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) { + 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(); + throw new AereCloudException(0, e.getMessage(), cale); + } + } + + private static String esc(String s) { + StringBuilder sb = new StringBuilder(); + for (char c : s.toCharArray()) { + switch (c) { + case '"': sb.append("\\\""); break; + case '\\': sb.append("\\\\"); break; + default: if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); else sb.append(c); + } + } + return sb.toString(); + } + + /** JSON minimal dintr-o mapa ordonata: String/Number/Boolean; suficient pentru rutele noastre. */ + static String json(Map m) { + StringBuilder sb = new StringBuilder("{"); + boolean primul = true; + for (Map.Entry e : m.entrySet()) { + if (!primul) sb.append(','); + primul = false; + sb.append('"').append(esc(e.getKey())).append("\":"); + Object v = e.getValue(); + if (v instanceof String) sb.append('"').append(esc((String) v)).append('"'); + else sb.append(v); + } + return sb.append('}').toString(); + } + + // ---- health & account ---- + public String health() { return req("GET", "/health", null, false); } + public String account() { return req("GET", "/account", null, true); } + + // ---- JSON-RPC ---- + /** Un apel JSON-RPC brut; params e un array JSON deja serializat, de ex. "[]" sau "[\"0x1\",true]". */ + public String rpc(String method, String paramsJson) { + String corp = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + esc(method) + "\",\"params\":" + (paramsJson == null ? "[]" : paramsJson) + "}"; + return req("POST", "/rpc", corp, true); + } + + public long blockNumber() { + String d = rpc("eth_blockNumber", "[]"); + int i = d.indexOf("\"result\":\"0x"); + if (i < 0) throw new AereCloudException(200, d, "/rpc"); + int j = d.indexOf('"', i + 11); + return Long.parseLong(d.substring(i + 12, j), 16); + } + + // ---- 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); } + + // ---- chain data ---- + public String dataHead() { return req("GET", "/data/head", null, true); } + public String validators() { return req("GET", "/data/validators", null, true); } + public String anchors(int limit) { return req("GET", "/data/anchors?limit=" + limit, null, true); } + public String anchor(long height) { return req("GET", "/data/anchors/" + height, null, true); } + public String transfers(String address, int limit) { + return req("GET", "/data/transfers?address=" + address + "&limit=" + limit, null, true); + } + + // ---- notarization ---- + public String notarize(String digestHex) { + Map m = new LinkedHashMap<>(); + m.put("hash", digestHex); + return req("POST", "/notarize", json(m), true); + } + public String proofOf(String digestHex) { return req("GET", "/notarize/" + digestHex, null, true); } + /** Dovada cu finalitatea ei post-cuantica: prima aparitie, ancora care o acopera, verdictul, pasii de verificare. */ + public String proof(String digestHex) { return req("GET", "/proof/" + enc(digestHex), null, true); } + + // ---- pregatirea post-cuantica a unui domeniu (1.2.0+) ---- + /** Scanarea marginii TLS a unui domeniu: gratuita fara cheie; attest=true notarizeaza digestul raportului si cere cheia. */ + public String readiness(String domain, boolean fresh, boolean attest) { + Map m = new LinkedHashMap<>(); + m.put("domain", domain); + if (fresh) m.put("fresh", true); + if (attest) m.put("attest", true); + return req("POST", "/pq/readiness", json(m), attest); + } + public String readinessReport(String domain) { return req("GET", "/pq/readiness/" + enc(domain), null, false); } + /** + * Perimetrul unui domeniu intr-un singur raport (cu cheie): gazdele numite plus prefixele gasite prin DNS. Cat timp + * summary.complete e false, se cheama din nou; attest=true notarizeaza numai un raport COMPLET (altfel 409). + * hosts poate fi null; discover=null lasa implicitul serverului. + */ + public String readinessPerimeter(String domain, String[] hosts, Boolean discover, boolean attest) { + StringBuilder sb = new StringBuilder("{\"domain\":\"").append(esc(domain)).append('"'); + if (hosts != null) { + sb.append(",\"hosts\":["); + for (int i = 0; i < hosts.length; i++) sb.append(i > 0 ? "," : "").append('"').append(esc(hosts[i])).append('"'); + sb.append(']'); + } + if (Boolean.FALSE.equals(discover)) sb.append(",\"discover\":false"); + if (attest) sb.append(",\"attest\":true"); + return req("POST", "/pq/readiness/perimeter", sb.append('}').toString(), true); + } + + // ---- AIP-21: finalitatea post-cuantica pe bloc (1.3.0+) ---- + /** block: "latest" sau un numar; network: "testnet"; client: "besu", "nethermind" sau "both" (cu `agreement`). Fara cheie. */ + public String pqFinality(String block, String network, String client) { + return req("GET", "/pq/finality/" + enc(block) + "?network=" + enc(network) + "&client=" + enc(client), null, false); + } + + // ---- jurnalul de audit al contului (1.4.0+) ---- + /** Pagina JSON a unei zile UTC (day null = azi), cu digestul fisierului zilei. */ + public String auditLog(String day, int offset, int limit) { + return req("GET", "/account/audit?offset=" + offset + "&limit=" + limit + (day == null ? "" : "&day=" + enc(day)), null, true); + } + + /** Ziua ca fisier: octetii exacti (jsonl) sau randuri CEF, digestul declarat si verificarea LOCALA a lui. */ + public static final class AuditExport { + public final String day, format, digest; + public final byte[] bytes; + public final int entries; + /** jsonl: sha256 al octetilor primiti == digestul declarat; cef: null (digestul e al fisierului-sursa). */ + public final Boolean verified; + AuditExport(String day, String format, byte[] bytes, int entries, String digest, Boolean verified) { + this.day = day; this.format = format; this.bytes = bytes; this.entries = entries; this.digest = digest; this.verified = verified; + } + public String text() { return new String(bytes, StandardCharsets.UTF_8); } + } + public AuditExport auditExport(String day, String format) { + String f = format == null ? "jsonl" : format; + String cale = "/account/audit?format=" + enc(f) + (day == null ? "" : "&day=" + enc(day)); + Raspuns r = reqBrut("GET", cale, true); + if (r.status < 200 || r.status >= 300) throw new AereCloudException(r.status, r.text(), "/account/audit"); + String digest = r.antet("x-aere-digest"); + int n = 0; + try { n = Integer.parseInt(r.antet("x-aere-entries")); } catch (Exception e) { /* antet lipsa */ } + Boolean ok = "jsonl".equals(f) ? (digest != null && digest.equals(sha256Hex(r.octeti))) : null; + return new AuditExport(r.antet("x-aere-day"), f, r.octeti, n, digest, ok); + } + + // ---- retentia jurnalului de audit (1.5.0+) ---- + /** + * DOAR-CITIRE cu cheia API: ce e in vigoare, ce asteapta si pana cand, ce tinem, registrul schimbarilor si al zilelor sterse. + * Retentia se SETEAZA numai cu semnatura portofelului, in consola: o cheie furata nu are voie sa-si scurteze urmele. + */ + public String auditRetention() { return req("GET", "/account/audit/retention", null, true); } + + /** Rezultatul verificarii unui export PASTRAT fata de ziua tinuta ("held") sau fata de piatra zilei sterse ("expired"). */ + public static final class KeptCheck { + public final String day, state, digest, local, deletedAt; + public final boolean matches; + KeptCheck(String day, String state, String digest, String local, boolean matches, String deletedAt) { + this.day = day; this.state = state; this.digest = digest; this.local = local; this.matches = matches; this.deletedAt = deletedAt; + } + } + /** + * Exportul pe care l-ai pastrat se mai potriveste cu ziua de la noi, chiar dupa ce am sters-o prin retentia ta? sha256 al + * octetilor TAI, calculat aici, fata de digestul zilei tinute sau al pietrei lasate de ziua stearsa (410). O eroare se + * ridica; nu se intoarce niciodata drept "nu se potriveste". + */ + public KeptCheck auditVerifyKept(String day, byte[] kept) { + String local = sha256Hex(kept); + Raspuns r = reqBrut("GET", "/account/audit?format=jsonl&day=" + enc(day), true); + if (r.status == 410) { + String corp = r.text(); + int i = corp.indexOf("\"expired\""); + String dig = i < 0 ? null : camp(corp.substring(i), "digest"); + String cand = i < 0 ? null : camp(corp.substring(i), "deletedAt"); + if (dig != null) return new KeptCheck(day, "expired", dig, local, local.equals(dig), cand); + } + if (r.status < 200 || r.status >= 300) throw new AereCloudException(r.status, r.text(), "/account/audit"); + String dig = sha256Hex(r.octeti); + return new KeptCheck(day, "held", dig, local, local.equals(dig), null); + } + + // ---- 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); } + 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); + } + } + static String sha256Hex(byte[] b) { + try { + byte[] h = MessageDigest.getInstance("SHA-256").digest(b); + StringBuilder sb = new StringBuilder("0x"); + for (byte x : h) sb.append(String.format("%02x", x)); + return sb.toString(); + } catch (Exception e) { throw new IllegalStateException(e); } + } + /** Valoarea sirului "cheie":"valoare" (prima aparitie); suficient pentru campurile noastre, fara o biblioteca JSON. */ + static String camp(String json, String cheie) { + String t = "\"" + cheie + "\":\""; + int i = json.indexOf(t); + if (i < 0) return null; + 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); } + + // ---- webhooks ---- + public String listWebhooks() { return req("GET", "/webhooks", null, true); } + public String createWebhook(String specJson) { return req("POST", "/webhooks", specJson, true); } + public String deleteWebhook(String id) { return req("DELETE", "/webhooks/" + id, null, true); } + + // ---- gas sponsorship ---- + public String sponsorHealth() { return req("GET", "/sponsor/health", null, true); } + public String sponsorCreateAccount(String bodyJson) { return req("POST", "/sponsor/createAccount", bodyJson, true); } + /** Cont inteligent cu proprietar POST-CUANTIC (Falcon-512), gazul platit de releu. + * falconPubKeyHex: 0x-hex, 897 octeti, antetul 0x09. Idempotent pe (cheie, salt). */ + public String sponsorCreatePqAccount(String falconPubKeyHex, long salt) { + Map m = new LinkedHashMap<>(); + m.put("falconPubKey", falconPubKeyHex); + m.put("salt", salt); + return req("POST", "/sponsor/createPqAccount", json(m), true); + } + public String sponsorExecute(String bodyJson) { return req("POST", "/sponsor/execute", bodyJson, true); } + + // ---- webhooks: verificarea semnaturii ---- + /** + * Verifica semnatura unei livrari de webhook, in timp constant. A NU crede un webhook + * fara asta. rawBody = octetii EXACTI ai cererii, nu re-serializati. + */ + public static boolean verifyWebhook(byte[] rawBody, String signatureHeader, String secret) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] h = mac.doFinal(rawBody); + StringBuilder sb = new StringBuilder(h.length * 2); + for (byte x : h) sb.append(String.format("%02x", x)); + byte[] asteptat = sb.toString().getBytes(StandardCharsets.UTF_8); + byte[] primit = (signatureHeader == null ? "" : signatureHeader).getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(asteptat, primit); + } catch (Exception e) { + return false; + } + } +} diff --git a/java/src/test/java/network/aere/cloud/TestAereCloud.java b/java/src/test/java/network/aere/cloud/TestAereCloud.java new file mode 100644 index 0000000..68aaa83 --- /dev/null +++ b/java/src/test/java/network/aere/cloud/TestAereCloud.java @@ -0,0 +1,169 @@ +package network.aere.cloud; + +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Bancul de proba al clientului Java, fara JUnit (zero dependinte, ca si clientul). + * + * 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 unitare, offline + * AERE_KEY=ak2800... java -cp out network.aere.cloud.TestAereCloud --live + probe vii + * + * Iese 0 numai daca TOATE probele trec. + */ +public final class TestAereCloud { + static int trecute = 0, picate = 0; + + static void proba(String nume, Runnable fn) { + try { + fn.run(); + trecute++; + System.out.println(" OK " + nume); + } catch (Throwable e) { + picate++; + System.out.println(" PICA " + nume + ": " + e); + } + } + + static void cere(boolean conditie, String mesaj) { + if (!conditie) throw new AssertionError(mesaj); + } + + public static void main(String[] args) throws Exception { + proba("constructorul cere cheia", () -> { + try { + new AereCloud(""); + throw new AssertionError("nu a ridicat pe cheie goala"); + } catch (IllegalArgumentException ok) { /* corect */ } + }); + + proba("json: scapa ghilimelele si pastreaza ordinea", () -> { + Map m = new LinkedHashMap<>(); + m.put("falconPubKey", "0x09\"aa"); + m.put("salt", 7L); + cere(AereCloud.json(m).equals("{\"falconPubKey\":\"0x09\\\"aa\",\"salt\":7}"), AereCloud.json(m)); + }); + + proba("verifyWebhook: buna trece; stricata, prelungita, corp atins pica", () -> { + try { + String secret = "sssh"; + byte[] corp = "{\"type\":\"pq-anchor\",\"x\":1}".getBytes(StandardCharsets.UTF_8); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] h = mac.doFinal(corp); + StringBuilder sb = new StringBuilder(); + for (byte x : h) sb.append(String.format("%02x", x)); + String semnatura = sb.toString(); + cere(AereCloud.verifyWebhook(corp, semnatura, secret), "buna nu a trecut"); + String stricata = semnatura.substring(0, semnatura.length() - 1) + + (semnatura.endsWith("0") ? "1" : "0"); + cere(!AereCloud.verifyWebhook(corp, stricata, secret), "stricata a trecut"); + cere(!AereCloud.verifyWebhook(corp, semnatura + "0", secret), "prelungita a trecut"); + byte[] corp2 = "{\"type\":\"pq-anchor\",\"x\":2}".getBytes(StandardCharsets.UTF_8); + cere(!AereCloud.verifyWebhook(corp2, semnatura, secret), "corp atins a trecut"); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + + // ---- 1.5.0: rutele noi, judecate pe un server HTTP LOCAL (JDK, 127.0.0.1): ce trimite clientul si ce face cu raspunsul + final java.util.List vazute = new java.util.ArrayList<>(); // {metoda, cale+query, x-api-key, corp, user-agent} + final java.util.Map raspunsuri = new java.util.HashMap<>(); // cale -> {status, byte[] corp, String[] antete} + com.sun.net.httpserver.HttpServer srv = com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1", 0), 0); + srv.createContext("/", ex -> { + byte[] corpCerere = ex.getRequestBody().readAllBytes(); + String cale = ex.getRequestURI().getRawPath() + (ex.getRequestURI().getRawQuery() == null ? "" : "?" + ex.getRequestURI().getRawQuery()); + synchronized (vazute) { vazute.add(new String[]{ex.getRequestMethod(), cale, ex.getRequestHeaders().getFirst("x-api-key"), + new String(corpCerere, StandardCharsets.UTF_8), ex.getRequestHeaders().getFirst("user-agent")}); } + Object[] r = raspunsuri.getOrDefault(ex.getRequestURI().getRawPath(), new Object[]{200, "{}".getBytes(StandardCharsets.UTF_8), new String[0]}); + String[] ant = (String[]) r[2]; + for (int i = 0; i + 1 < ant.length; i += 2) ex.getResponseHeaders().add(ant[i], ant[i + 1]); + byte[] b = (byte[]) r[1]; + ex.sendResponseHeaders((Integer) r[0], b.length == 0 ? -1 : b.length); + if (b.length > 0) ex.getResponseBody().write(b); + ex.close(); + }); + srv.start(); + final String baza = "http://127.0.0.1:" + srv.getAddress().getPort() + "/v1"; + final AereCloud lc = new AereCloud("ak2800.0xAB.SINTETIC", baza); + final java.util.function.Supplier ultima = () -> { synchronized (vazute) { return vazute.get(vazute.size() - 1); } }; + final byte[] zi = "{\"t\":\"2026-05-01T10:00:00.000Z\",\"m\":\"GET\",\"p\":\"/v1/account\",\"s\":200}\n".getBytes(StandardCharsets.UTF_8); + final String dZi = AereCloud.sha256Hex(zi); + try { + proba("1.5.0: readiness fara attest nu trimite cheia; cu attest o trimite; user-agent-ul poarta 1.5.0", () -> { + 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]); + 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)); + }); + proba("1.5.0: readinessPerimeter trimite gazdele, discover:false si attest, cu cheie; pqFinality fara cheie, cu parametrii scapati", () -> { + lc.readinessPerimeter("example.com", new String[]{"api", "www\"x"}, false, true); + String[] v = ultima.get(); + cere(v[3].equals("{\"domain\":\"example.com\",\"hosts\":[\"api\",\"www\\\"x\"],\"discover\":false,\"attest\":true}") && v[2] != null, v[3]); + lc.pqFinality("latest", "testnet", "both"); + v = ultima.get(); + cere(v[1].equals("/v1/pq/finality/latest?network=testnet&client=both") && v[2] == null, v[1]); + lc.proof("0xab"); + cere(ultima.get()[1].equals("/v1/proof/0xab") && ultima.get()[2] != null, 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"); + cere(Boolean.TRUE.equals(e.verified) && e.entries == 1 && "2026-05-01".equals(e.day) && e.text().equals(new String(zi, StandardCharsets.UTF_8)), "verified " + e.verified); + cere(ultima.get()[1].equals("/v1/account/audit?format=jsonl&day=2026-05-01"), ultima.get()[1]); + raspunsuri.put("/v1/account/audit", new Object[]{200, "alti octeti".getBytes(StandardCharsets.UTF_8), new String[]{"x-aere-digest", dZi}}); + cere(Boolean.FALSE.equals(lc.auditExport("2026-05-01", "jsonl").verified), "octeti straini nu au voie sa iasa verified"); + cere(lc.auditExport("2026-05-01", "cef").verified == null, "cef nu pretinde verificare"); + raspunsuri.put("/v1/account/audit", new Object[]{400, "{\"error\":\"bad_format\"}".getBytes(StandardCharsets.UTF_8), new String[0]}); + try { lc.auditExport(null, "xml"); throw new AssertionError("400 neridicat"); } catch (AereCloud.AereCloudException x) { cere(x.status == 400, "status " + x.status); } + }); + proba("1.5.0: auditRetention citeste cu cheia; auditVerifyKept: piatra unei zile sterse (410) verifica exportul pastrat, un octet schimbat NU, ziua tinuta la fel, o eroare se ridica", () -> { + raspunsuri.put("/v1/account/audit/retention", new Object[]{200, "{\"retentionDays\":90}".getBytes(StandardCharsets.UTF_8), new String[0]}); + cere(lc.auditRetention().contains("90") && ultima.get()[0].equals("GET") && ultima.get()[1].equals("/v1/account/audit/retention") && ultima.get()[2] != null, "retention"); + String piatra = "{\"error\":\"audit_day_expired\",\"expired\":{\"day\":\"2026-05-01\",\"digest\":\"" + dZi + "\",\"entries\":1,\"deletedAt\":\"2026-09-25T12:00:01.000Z\",\"retentionDays\":90}}"; + raspunsuri.put("/v1/account/audit", new Object[]{410, piatra.getBytes(StandardCharsets.UTF_8), new String[0]}); + AereCloud.KeptCheck k = lc.auditVerifyKept("2026-05-01", zi); + cere(k.state.equals("expired") && k.matches && k.digest.equals(dZi) && "2026-09-25T12:00:01.000Z".equals(k.deletedAt), k.state + " " + k.matches); + byte[] stricat = zi.clone(); stricat[5] ^= 1; + cere(!lc.auditVerifyKept("2026-05-01", stricat).matches, "un export modificat nu are voie sa se potriveasca cu piatra"); + raspunsuri.put("/v1/account/audit", new Object[]{200, zi, new String[]{"x-aere-digest", dZi}}); + AereCloud.KeptCheck h = lc.auditVerifyKept("2026-05-01", zi); + cere(h.state.equals("held") && h.matches, h.state); + cere(!lc.auditVerifyKept("2026-05-01", stricat).matches, "held: un export modificat nu se potriveste"); + 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); } + }); + } finally { + srv.stop(0); + } + + boolean live = args.length > 0 && args[0].equals("--live"); + String cheie = System.getenv("AERE_KEY"); + if (live && cheie != null && !cheie.isEmpty()) { + AereCloud c = new AereCloud(cheie); + proba("LIVE: health ok", () -> cere(c.health().contains("\"ok\":true"), c.health())); + proba("LIVE: validators = 10", () -> cere(c.validators().contains("\"count\":10"), "alt numar")); // al zecelea (Nethermind) din 2026-09-11 + proba("LIVE: blockNumber > 0", () -> cere(c.blockNumber() > 0, "bloc 0")); + proba("LIVE: sponsor PQ refuza JSON pe cheie scurta", () -> { + try { + c.sponsorCreatePqAccount("0x07", 0); + throw new AssertionError("nu a refuzat cheia scurta"); + } catch (AereCloud.AereCloudException e) { + cere(e.body != null && e.body.contains("897"), "alt refuz: " + e.body); + } + }); + } else if (live) { + System.out.println(" (sar LIVE: AERE_KEY lipseste)"); + } + + System.out.println("\n" + trecute + " treceri, " + picate + " esecuri"); + System.exit(picate == 0 ? 0 : 1); + } +} diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..aa3d70d --- /dev/null +++ b/python/README.md @@ -0,0 +1,79 @@ +# aere-cloud (Python) + +The official Python 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). Standard library only; Python 3.9+. + +Mirror of the JavaScript client `@aere/cloud`: same routes, same semantics, snake_case names. + +## Install + +``` +pip install "git+https://git.aere.network/aere-network/aere-cloud-sdk.git#subdirectory=python" # not on PyPI yet +``` + +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..`. + +## Use + +```python +import os +from aere_cloud import AereCloud, AereCloudError, verify_webhook + +aere = AereCloud(api_key=os.environ["AERE_KEY"]) + +# chain data +head = aere.data_head() +anchors = aere.anchors(5) # post-quantum anchor certificates +vals = aere.validators() # live QBFT validator set + +# JSON-RPC +n = aere.block_number() + +# post-quantum verification, computed by the chain's own precompiles +out = aere.pq_verify({ + "scheme": "ml-dsa-44", "publicKey": "0x...", "signature": "0x...", "message": "0x...", +}) + +# notarize a document hash — Aere pays the gas +receipt = aere.notarize("0x" + sha256_hex) +proof = aere.proof_of("0x" + sha256_hex) + +# a smart account with a POST-QUANTUM owner (Falcon-512), gas sponsored: +acct = aere.sponsor_create_pq_account("0x09" + falcon_pk_hex, salt=0) +# -> {"txHash": ..., "account": "0x...", "alreadyDeployed": False} +``` + +Every method raises `AereCloudError` on a non-2xx response, carrying `.status` and the server's +JSON `.body` so you can act on the exact error. + +## Proof with post-quantum finality, quantum readiness (1.2.0, 2026-09-17) + +```python +full = aere.proof(digest_hex) # first appearance, covering post-quantum anchor, finality, verification steps +scan = aere.readiness("example.com") # free, no key: score, verdict, measured TLS facts, findings +attested = aere.readiness("example.com", attest=True) # + attestation: report digest notarized on chain 2800 +perimeter = aere.readiness_perimeter("example.com", hosts=["api", "mail"]) # 1.3.0: every public host of the domain, one report (keyed) +# pq_verify: {"interface": "external"} verifies a standard FIPS 204/205 signature (the precompile implements the +# internal interface; the gateway builds M' = 0x00 || len(ctx) || ctx || message) +``` + +## Webhooks + +Never trust a webhook payload without checking its signature (constant-time, HMAC-SHA256): + +```python +from aere_cloud import verify_webhook + +# in your handler; raw_body must be the exact request bytes, not re-serialized JSON +if not verify_webhook(raw_body, request.headers["x-aere-signature"], stored_secret): + return 401 +``` + +## Tests + +``` +python3 test_aere_cloud.py # unit, offline, fake transport +AERE_KEY=ak2800... python3 test_aere_cloud.py --live # + live probes against production +``` diff --git a/python/aere_cloud/__init__.py b/python/aere_cloud/__init__.py new file mode 100644 index 0000000..393789d --- /dev/null +++ b/python/aere_cloud/__init__.py @@ -0,0 +1,256 @@ +"""aere-cloud: clientul Python oficial al Aere Cloud (chain 2800). + +Oglinda fidela a @aere/cloud (sdk/index.mjs): fiecare ruta a gateway-ului e o metoda, +erorile se ridica drept AereCloudError (cod HTTP + corpul serverului), si verify_webhook +e inclus ca primitorul de webhook-uri sa nu-si scrie singur verificarea HMAC. Zero +dependinte in afara bibliotecii standard (urllib, hmac, hashlib, json); Python 3.9+. +""" +from __future__ import annotations + +import hmac +import hashlib +import json as _json +import urllib.request +import urllib.error +from typing import Any, Optional + +__version__ = "1.5.1" +__all__ = ["AereCloud", "AereCloudError", "verify_webhook"] + +_BAZA = "https://cloud.aere.network/v1" + + +class _FaraRedirectari(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_FARA_REDIRECTARI = urllib.request.build_opener(_FaraRedirectari) + + +class AereCloudError(Exception): + """Raspuns non-2xx (sau eroare JSON-RPC) de la Aere Cloud. + + Poarta .status (int), .body (dict sau None, corpul serverului) si .path, ca + integratorul sa poata actiona pe eroarea exacta, nu pe un mesaj generic. + """ + + def __init__(self, status: int, body: Any, path: str): + mesaj = body.get("error") if isinstance(body, dict) else None + super().__init__(f"Aere Cloud {status} on {path}: {mesaj or 'request failed'}") + self.status = status + self.body = body + self.path = path + + +class AereCloud: + """Client pentru https://cloud.aere.network/v1. Cheia: ak2800...""" + + def __init__(self, api_key: str, base_url: str = _BAZA, timeout: float = 30.0, opener=None): + if not api_key: + raise ValueError("AereCloud: api_key is required (ak2800.
.)") + self.api_key = api_key + self.base_url = base_url.rstrip("/") + self.timeout = timeout + # opener injectabil pentru teste: primeste (urllib.request.Request, timeout) si + # intoarce (status:int, corp:bytes). Implicit, urllib. + self._opener = opener or self._urllib_opener + + @staticmethod + def _urllib_opener(cerere: urllib.request.Request, timeout: float): + try: + # 1.5.1: fara redirectari urmate (urllib le urmeaza implicit si copiaza x-api-key spre orice gazda din Location); + # un 3xx ajunge aici ca HTTPError si se intoarce drept raspuns, deci ca AereCloudError cu statusul lui + with _FARA_REDIRECTARI.open(cerere, timeout=timeout) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + + def _req(self, metoda: str, cale: str, body: Any = None, auth: bool = True) -> Any: + # User-Agent-ul explicit nu e cosmetic: marginea (Cloudflare) raspunde 403 la + # "Python-urllib/3.x" pentru ORICE ruta, inclusiv /health fara cheie. Masurat + # 2026-08-27 chiar la prima rulare vie a acestui SDK. + anteturi = {"content-type": "application/json", "user-agent": f"aere-cloud-python/{__version__}"} + if auth: + anteturi["x-api-key"] = self.api_key + date = None if body is None else _json.dumps(body).encode("utf-8") + cerere = urllib.request.Request(self.base_url + cale, data=date, headers=anteturi, method=metoda) + status, corp = self._opener(cerere, self.timeout) + try: + d = _json.loads(corp.decode("utf-8")) if corp else None + except ValueError: + d = None + if not (200 <= status < 300): + raise AereCloudError(status, d, cale) + return d + + # ---- health & account ---- + def health(self) -> dict: + return self._req("GET", "/health", auth=False) + + def account(self) -> dict: + return self._req("GET", "/account") + + # ---- JSON-RPC ---- + def rpc(self, method: str, params: Optional[list] = None) -> Any: + d = self._req("POST", "/rpc", {"jsonrpc": "2.0", "id": 1, "method": method, "params": params or []}) + if isinstance(d, dict) and d.get("error"): + raise AereCloudError(200, d, "/rpc") + return d.get("result") if isinstance(d, dict) else None + + def block_number(self) -> int: + return int(self.rpc("eth_blockNumber"), 16) + + # ---- post-quantum verification ---- + def pq_verify(self, payload: dict) -> dict: + """{scheme, publicKey, signature?, message?, signedMessage?} (0x-hex) -> {valid, ...}""" + return self._req("POST", "/pq/verify", payload) + + # ---- chain data ---- + def data_head(self) -> dict: + return self._req("GET", "/data/head") + + def validators(self) -> dict: + return self._req("GET", "/data/validators") + + def anchors(self, limit: int = 10) -> dict: + return self._req("GET", f"/data/anchors?limit={int(limit)}") + + def anchor(self, height: int) -> dict: + return self._req("GET", f"/data/anchors/{int(height)}") + + def transfers(self, address: str, limit: int = 50) -> dict: + return self._req("GET", f"/data/transfers?address={address}&limit={int(limit)}") + + # ---- notarization ---- + def notarize(self, digest_hex: str) -> dict: + """Notarizeaza un digest de 32 de octeti (0x + 64 hex); Aere plateste gazul.""" + return self._req("POST", "/notarize", {"hash": digest_hex}) + + def proof_of(self, digest_hex: str) -> dict: + return self._req("GET", f"/notarize/{digest_hex}") + + def proof(self, digest_hex: str) -> dict: + """2026-09-17: dovada cu finalitatea ei post-cuantica (prima aparitie, ancora care o acopera, verdict, pasii de verificare).""" + return self._req("GET", f"/proof/{digest_hex}") + + # ---- quantum readiness ---- + def readiness(self, domain: str, attest: bool = False, fresh: bool = False) -> dict: + """Scanarea marginii TLS publice a unui domeniu: libera, fara cheie; attest=True notarizeaza digestul raportului pe lant (cere cheia).""" + body: dict = {"domain": domain} + if fresh: + body["fresh"] = True + if attest: + body["attest"] = True + return self._req("POST", "/pq/readiness", body, auth=attest) + + def readiness_report(self, domain: str) -> dict: + return self._req("GET", f"/pq/readiness/{domain}", auth=False) + + def readiness_perimeter(self, domain: str, hosts: list | None = None, discover: bool = True, attest: bool = False) -> dict: + """Tot perimetrul public al unui domeniu intr-un raport (cu cheie): gazdele numite (etichete sau nume de sub domeniu) plus + prefixele obisnuite gasite prin DNS, fiecare masurata cu aceleasi strangeri de mana. Cat summary['complete'] e False se cere + din nou (scanarile terminate stau in cache); attest=True notarizeaza digestul unui raport COMPLET pe lantul 2800.""" + body: dict = {"domain": domain} + if hosts: + body["hosts"] = list(hosts) + if discover is False: + body["discover"] = False + if attest: + body["attest"] = True + return self._req("POST", "/pq/readiness/perimeter", body) + + def pq_finality(self, block: str = "latest", network: str = "testnet", client: str = "besu") -> dict: + """AIP-21: finalitatea post-cuantica a unui bloc (sigiliile auzite de un validator, re-verificate, verdict de cvorum). Libera. + client: "besu" (implicit), "nethermind" sau "both" (doua implementari independente, cu `agreement` calculat de gateway).""" + return self._req("GET", f"/pq/finality/{block}?network={network}&client={client}", auth=False) + + # ---- audit log ---- + def audit_log(self, day: Optional[str] = None, offset: int = 0, limit: int = 1000) -> dict: + """Jurnalul cererilor cu cheie ale contului pentru o zi UTC (paginat), cu sha256-ul fisierului zilei.""" + q = f"offset={int(offset)}&limit={int(limit)}" + (f"&day={day}" if day else "") + return self._req("GET", f"/account/audit?{q}") + + def audit_export(self, day: Optional[str] = None, fmt: str = "jsonl") -> dict: + """Ziua ca FISIER (1.4.0): fmt='jsonl' = octetii exacti ai fisierului zilei, al caror sha256 e digestul pe care il notarizezi + (calculat AICI, din ce ai primit, nu luat pe cuvant); fmt='cef' = un rand ArcSight CEF pe cerere, pentru un SIEM.""" + import hashlib + q = f"format={fmt}" + (f"&day={day}" if day else "") + anteturi = {"user-agent": f"aere-cloud-python/{__version__}", "x-api-key": self.api_key} + cerere = urllib.request.Request(self.base_url + f"/account/audit?{q}", headers=anteturi, method="GET") + status, corp = self._opener(cerere, self.timeout) + if not (200 <= status < 300): + try: + d = _json.loads(corp.decode("utf-8")) if corp else None + except ValueError: + d = None + raise AereCloudError(status, d, "/account/audit") + corp = corp or b"" + return {"format": fmt, "bytes": corp, "text": corp.decode("utf-8"), "entries": sum(1 for l in corp.split(b"\n") if l), + "sha256": "0x" + hashlib.sha256(corp).hexdigest()} + + def audit_retention(self) -> dict: + """Retentia jurnalului de audit (1.5.0), DOAR-CITIRE cu cheia API: ce e in vigoare, ce asteapta si pana cand, ce tinem, si + registrul fiecarei schimbari si al fiecarei zile sterse (cu digestul ei). Se SETEAZA cu semnatura portofelului, in consola.""" + return self._req("GET", "/account/audit/retention") + + def audit_verify_kept(self, day: str, data) -> dict: + """Exportul pe care l-ai PASTRAT se mai potriveste cu ziua de la noi, chiar dupa ce am sters-o prin retentia ta? sha256 al + octetilor TAI, calculat aici, fata de digestul zilei tinute ('held') sau de piatra lasata de ziua stearsa ('expired', 410).""" + import hashlib + octeti = data.encode("utf-8") if isinstance(data, str) else bytes(data) + local = "0x" + hashlib.sha256(octeti).hexdigest() + anteturi = {"user-agent": f"aere-cloud-python/{__version__}", "x-api-key": self.api_key} + cerere = urllib.request.Request(self.base_url + f"/account/audit?format=jsonl&day={day}", headers=anteturi, method="GET") + status, corp = self._opener(cerere, self.timeout) + corp = corp or b"" + if not (200 <= status < 300): + try: + d = _json.loads(corp.decode("utf-8")) if corp else None + except ValueError: + d = None + if status == 410 and isinstance(d, dict) and isinstance(d.get("expired"), dict): + e = d["expired"] + return {"day": day, "state": "expired", "digest": e.get("digest"), "local": local, "matches": local == e.get("digest"), "deleted_at": e.get("deletedAt")} + raise AereCloudError(status, d, "/account/audit") + digest = "0x" + hashlib.sha256(corp).hexdigest() + return {"day": day, "state": "held", "digest": digest, "local": local, "matches": local == digest, "deleted_at": None} + + # ---- webhooks ---- + def list_webhooks(self) -> dict: + return self._req("GET", "/webhooks") + + def create_webhook(self, spec: dict) -> dict: + return self._req("POST", "/webhooks", spec) + + def delete_webhook(self, webhook_id: str) -> dict: + return self._req("DELETE", f"/webhooks/{webhook_id}") + + # ---- gas sponsorship ---- + def sponsor_health(self) -> dict: + return self._req("GET", "/sponsor/health") + + def sponsor_create_account(self, body: dict) -> dict: + return self._req("POST", "/sponsor/createAccount", body) + + def sponsor_create_pq_account(self, falcon_pub_key: str, salt: int = 0) -> dict: + """Cont inteligent cu proprietar POST-CUANTIC (Falcon-512), gazul platit de releu. + + falcon_pub_key: 0x-hex, 897 octeti, antetul 0x09 (cheia publica Falcon-512). + Idempotent pe (cheie, salt): a doua chemare intoarce contul existent, fara tx. + """ + return self._req("POST", "/sponsor/createPqAccount", {"falconPubKey": falcon_pub_key, "salt": salt}) + + def sponsor_execute(self, body: dict) -> dict: + return self._req("POST", "/sponsor/execute", body) + + +def verify_webhook(raw_body, signature_header: str, secret: str) -> bool: + """Verifica semnatura unei livrari de webhook. A NU crede un webhook fara asta. + + raw_body: octetii EXACTI ai cererii (nu re-serializati). Comparatia e in timp constant. + """ + if isinstance(raw_body, str): + raw_body = raw_body.encode("utf-8") + asteptat = hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + return hmac.compare_digest(asteptat, str(signature_header or "")) diff --git a/python/pyproject.toml b/python/pyproject.toml new file mode 100644 index 0000000..4758a2a --- /dev/null +++ b/python/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "aere-cloud" +version = "1.5.1" +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" +license = { text = "MIT" } +authors = [{ name = "Aere Network Foundation" }] +keywords = ["aere", "blockchain", "post-quantum", "falcon", "ml-dsa", "web3"] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] + +[project.urls] +Homepage = "https://aere.network/cloud.html" +Source = "https://git.aere.network/aere-network/aerenew" + +[tool.setuptools.packages.find] +include = ["aere_cloud*"] diff --git a/python/test_aere_cloud.py b/python/test_aere_cloud.py new file mode 100644 index 0000000..1ee80ef --- /dev/null +++ b/python/test_aere_cloud.py @@ -0,0 +1,243 @@ +"""Testele aere-cloud (Python), oglinda celor din sdk/test/sdk.test.mjs. + + python3 test_aere_cloud.py unitare, cu transport fals, fara retea + AERE_KEY=ak2800... python3 test_aere_cloud.py --live + 3 probe prin productie +""" +import hashlib +import hmac as _hmac +import json +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from aere_cloud import AereCloud, AereCloudError, verify_webhook # noqa: E402 + +trecute, picate = 0, 0 +def proba(nume, fn): + global trecute, picate + try: + fn() + trecute += 1 + print(f" OK {nume}") + except Exception as e: + picate += 1 + print(f" PICA {nume}: {e!r}") + + +class Transport: + """Opener fals: inregistreaza cererile si raspunde din scenariu.""" + def __init__(self, raspunsuri): + self.cereri = [] + self.raspunsuri = list(raspunsuri) + + def __call__(self, cerere, timeout): + self.cereri.append(cerere) + status, corp = self.raspunsuri.pop(0) + return status, json.dumps(corp).encode("utf-8") + + +def t_constructor(): + try: + AereCloud("") + raise AssertionError("nu a ridicat pe cheie goala") + except ValueError: + pass + +def t_antet(): + tr = Transport([(200, {"ok": True}), (200, {"ok": True})]) + c = AereCloud("ak2800.0xAB.secret", opener=tr) + c.health() + assert "x-api-key" not in {k.lower() for k in tr.cereri[0].headers}, "health nu trimite cheia" + c.account() + assert tr.cereri[1].get_header("X-api-key") == "ak2800.0xAB.secret", "account trimite cheia" + +def t_rpc(): + tr = Transport([(200, {"jsonrpc": "2.0", "id": 1, "result": "0x10"})]) + c = AereCloud("k", opener=tr) + assert c.rpc("eth_blockNumber") == "0x10" + corp = json.loads(tr.cereri[0].data.decode()) + assert corp["method"] == "eth_blockNumber" and corp["jsonrpc"] == "2.0" + tr2 = Transport([(200, {"error": {"code": -32601, "message": "nope"}})]) + try: + AereCloud("k", opener=tr2).rpc("qbft_x") + raise AssertionError("eroarea RPC nu s-a ridicat") + except AereCloudError as e: + assert e.status == 200 + +def t_eroare_http(): + tr = Transport([(403, {"error": "invalid_or_expired_key"})]) + try: + AereCloud("k", opener=tr).account() + raise AssertionError("403 nu s-a ridicat") + except AereCloudError as e: + assert e.status == 403 and e.body["error"] == "invalid_or_expired_key" and e.path == "/account" + +def t_query(): + tr = Transport([(200, {}), (200, {}), (200, {})]) + c = AereCloud("k", opener=tr) + c.anchors(3); c.transfers("0xAB", 7); c.anchor(15000000) + assert tr.cereri[0].full_url.endswith("/data/anchors?limit=3") + assert tr.cereri[1].full_url.endswith("/data/transfers?address=0xAB&limit=7") + assert tr.cereri[2].full_url.endswith("/data/anchors/15000000") + +def t_pq_sponsor(): + tr = Transport([(200, {"txHash": "0xab", "account": "0xcd", "alreadyDeployed": False})]) + c = AereCloud("k", opener=tr) + d = c.sponsor_create_pq_account("0x09" + "00" * 896, salt=5) + corp = json.loads(tr.cereri[0].data.decode()) + assert corp["salt"] == 5 and corp["falconPubKey"].startswith("0x09") + assert tr.cereri[0].full_url.endswith("/sponsor/createPqAccount") + assert d["account"] == "0xcd" + +def t_webhook(): + secret, corp = "sssh", b'{"type":"pq-anchor","x":1}' + semnatura = _hmac.new(secret.encode(), corp, hashlib.sha256).hexdigest() + assert verify_webhook(corp, semnatura, secret) is True + assert verify_webhook(corp, semnatura[:-1] + ("0" if semnatura[-1] != "0" else "1"), secret) is False + assert verify_webhook(corp, semnatura + "0", secret) is False + assert verify_webhook(corp + b" ", semnatura, secret) is False + +proba("constructorul cere cheia", t_constructor) +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("sponsor_create_pq_account: corp si cale corecte", t_pq_sponsor) +proba("verify_webhook: buna trece; stricata, prelungita, corp atins pica", t_webhook) + +def t_proof_readiness(): + # 2026-09-17: proof(hash) cere cheia; readiness() e libera si trimite cheia doar cu attest=True; corpul e exact + h = "0x" + "ab" * 32 + tr = Transport([ + (200, {"hash": h, "notarized": True, "finality": "post-quantum"}), + (200, {"domain": "aere.network", "score": 75}), + (200, {"domain": "aere.network", "score": 75, "attestation": {"reportHash": h}}), + (200, {"domain": "aere.network", "score": 75, "cached": True}), + ]) + c = AereCloud("ak2800.0xAB.secret", opener=tr) + assert c.proof(h)["finality"] == "post-quantum" + assert tr.cereri[0].get_header("X-api-key") == "ak2800.0xAB.secret", "proof cere cheia" + assert tr.cereri[0].full_url.endswith("/proof/" + h) + c.readiness("aere.network") + assert "x-api-key" not in {k.lower() for k in tr.cereri[1].headers}, "scanarea libera nu trimite cheia" + assert json.loads(tr.cereri[1].data.decode()) == {"domain": "aere.network"} + assert c.readiness("aere.network", attest=True, fresh=True)["attestation"]["reportHash"] == h + assert tr.cereri[2].get_header("X-api-key") == "ak2800.0xAB.secret", "atestarea trimite cheia" + assert json.loads(tr.cereri[2].data.decode()) == {"domain": "aere.network", "fresh": True, "attest": True} + assert c.readiness_report("aere.network")["cached"] is True + assert "x-api-key" not in {k.lower() for k in tr.cereri[3].headers} +proba("proof + readiness: ruta, cheia doar cand se cere, corpul corect", t_proof_readiness) + +def t_perimetru(): + # 2026-09-18: readiness_perimeter() cere cheia; corpul poarta doar ce s-a cerut; discover=True e implicitul si nu se trimite + tr = Transport([(200, {"domain": "example.com", "summary": {"measured": 3, "complete": True}}), (200, {"domain": "example.com", "summary": {"complete": True}})]) + c = AereCloud("ak2800.0xAB.secret", opener=tr) + assert c.readiness_perimeter("example.com")["summary"]["measured"] == 3 + assert tr.cereri[0].get_header("X-api-key") == "ak2800.0xAB.secret", "perimetrul cere cheia" + assert tr.cereri[0].full_url.endswith("/pq/readiness/perimeter") + assert json.loads(tr.cereri[0].data.decode()) == {"domain": "example.com"} + c.readiness_perimeter("example.com", hosts=["api", "mail.example.com"], discover=False, attest=True) + assert json.loads(tr.cereri[1].data.decode()) == {"domain": "example.com", "hosts": ["api", "mail.example.com"], "discover": False, "attest": True} + from aere_cloud import __version__ + import re as _re, pathlib as _pl + proiect = (_pl.Path(__file__).parent / "pyproject.toml").read_text(encoding="utf-8") + assert _re.search(r'^version = "' + _re.escape(__version__) + '"', proiect, _re.M), "__version__ si pyproject.toml spun versiuni diferite (platit 2026-09-18: 1.1.0 fata de 1.2.1)" +proba("readiness_perimeter: ruta cu cheie, corpul exact; __version__ = versiunea din pyproject", t_perimetru) + +def t_export_audit(): + # 2026-09-18: audit_export() intoarce octetii asa cum au venit si sha256-ul LOR, calculat local; cu cheie; un 400 se arunca + import hashlib + corp = b'{"t":"2026-09-17T10:00:00.000Z","m":"GET","p":"/v1/account","s":200}\n' + class TransportBrut(Transport): + def __call__(self, cerere, timeout): + self.cereri.append(cerere); status, d = self.raspunsuri.pop(0) + return status, d + tr = TransportBrut([(200, corp), (400, b'{"error":"bad_format"}')]) + c = AereCloud("ak2800.0xAB.secret", opener=tr) + e = c.audit_export(day="2026-09-17") + assert e["bytes"] == corp and e["entries"] == 1 and e["sha256"] == "0x" + hashlib.sha256(corp).hexdigest() + assert tr.cereri[0].full_url.endswith("/account/audit?format=jsonl&day=2026-09-17"), tr.cereri[0].full_url + assert tr.cereri[0].get_header("X-api-key") == "ak2800.0xAB.secret" + try: + c.audit_export(fmt="xml"); raise AssertionError("un 400 trebuia aruncat") + except AereCloudError as x: + assert x.status == 400 +proba("audit_export: octetii zilei si sha256-ul lor calculat local; 400 aruncat", t_export_audit) + +def t_retentie_audit(): + # 2026-09-18: audit_retention() citeste cu cheia; audit_verify_kept(): exportul pastrat fata de ziua tinuta si fata de PIATRA unei zile sterse (410) + import hashlib, json as _j + corp = b'{"t":"2026-05-01T10:00:00.000Z","m":"GET","p":"/v1/account","s":200}\n' + d = "0x" + hashlib.sha256(corp).hexdigest() + piatra = _j.dumps({"error": "audit_day_expired", "expired": {"day": "2026-05-01", "digest": d, "entries": 1, "deletedAt": "2026-09-25T12:00:01.000Z"}}).encode() + class TransportBrut(Transport): + def __call__(self, cerere, timeout): + self.cereri.append(cerere); status, r = self.raspunsuri.pop(0) + return status, r + tr = TransportBrut([(200, b'{"retentionDays":90,"hold":false}'), (410, piatra), (410, piatra), (200, corp), (200, corp), (429, b'{"error":"rate_limit"}')]) + c = AereCloud("ak2800.0xAB.secret", opener=tr) + assert c.audit_retention()["retentionDays"] == 90 + assert tr.cereri[0].full_url.endswith("/account/audit/retention") and tr.cereri[0].get_method() == "GET" and tr.cereri[0].get_header("X-api-key") == "ak2800.0xAB.secret" + bun = c.audit_verify_kept("2026-05-01", corp) + assert (bun["state"], bun["matches"], bun["digest"], bun["deleted_at"]) == ("expired", True, d, "2026-09-25T12:00:01.000Z"), bun + stricat = bytes([corp[0] ^ 1]) + corp[1:] + assert c.audit_verify_kept("2026-05-01", stricat)["matches"] is False, "un export modificat nu are voie sa se potriveasca cu piatra" + tinut = c.audit_verify_kept("2026-05-01", corp.decode("utf-8")) + assert (tinut["state"], tinut["matches"]) == ("held", True), tinut + assert c.audit_verify_kept("2026-05-01", stricat)["matches"] is False + try: + c.audit_verify_kept("2026-05-01", corp); raise AssertionError("un 429 trebuia aruncat, nu intors ca nepotrivire") + except AereCloudError as x: + assert x.status == 429 +proba("audit_retention + audit_verify_kept: piatra unei zile sterse verifica exportul pastrat; o eroare nu e o nepotrivire", t_retentie_audit) + +def t_fara_redirectari(): + # 1.5.1: cu transportul IMPLICIT (urllib), pe un server local real: un 302 ajunge la apelant ca AereCloudError, iar tinta din + # Location nu primeste nimic (deci nici cheia). Transportul injectat al celorlalte probe nu vede redirectarile. + import http.server, threading + vazute = [] + class H(http.server.BaseHTTPRequestHandler): + def do_GET(self): + vazute.append((self.path, self.headers.get("x-api-key"))) + if self.path.startswith("/v1/"): + self.send_response(302); self.send_header("location", "/in-alta-parte"); self.end_headers() + else: + self.send_response(200); self.send_header("content-type", "application/json"); self.end_headers(); self.wfile.write(b"{}") + def log_message(self, *a): + pass + srv = http.server.HTTPServer(("127.0.0.1", 0), H) + fir = threading.Thread(target=srv.serve_forever, daemon=True); fir.start() + try: + c = AereCloud("ak2800.0xAB.secret", base_url=f"http://127.0.0.1:{srv.server_address[1]}/v1") + try: + c.account(); raise AssertionError("302 trebuia ridicat, nu urmat") + except AereCloudError as x: + assert x.status == 302, x.status + assert not [v for v in vazute if v[0] == "/in-alta-parte"], vazute + finally: + srv.shutdown(); srv.server_close() +proba("1.5.1: o redirectare nu e urmata cu transportul implicit; tinta din Location nu primeste cheia", t_fara_redirectari) + +def _live_pq(c): + try: + c.sponsor_create_pq_account("0x07", 0) + raise AssertionError("nu a refuzat cheia scurta") + except AereCloudError as e: + assert "897" in str(e.body.get("error", "")), e.body + +if "--live" in sys.argv: + cheie = os.environ.get("AERE_KEY") + if not cheie: + print(" (sar LIVE: AERE_KEY lipseste)") + else: + c = AereCloud(cheie) + def t_live_health(): assert c.health()["ok"] is True + def t_live_validatori(): assert c.validators()["count"] == 10 # al zecelea (Nethermind) din 2026-09-11 + def t_live_ancora(): assert c.anchors(1)["anchors"][0]["falconSeals"] >= 6 + proba("LIVE: health", t_live_health) + proba("LIVE: validators = 10", t_live_validatori) + proba("LIVE: ancora cu >=6 sigilii", t_live_ancora) + proba("LIVE: sponsor PQ refuza JSON pe cheie scurta", lambda: _live_pq(c)) + +print(f"\n{trecute} treceri, {picate} esecuri") +sys.exit(1 if picate else 0)