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.
This commit is contained in:
parent
ed88a38a4e
commit
af565d3aec
14
README.md
14
README.md
@ -7,10 +7,18 @@ browsers.
|
|||||||
|
|
||||||
## Install
|
## 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).
|
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,8 +73,8 @@ const acct = await aere.sponsorCreatePqAccount('0x09' + falconPkHex, 0);
|
|||||||
// -> { txHash, account: '0x…', alreadyDeployed: false }
|
// -> { txHash, account: '0x…', alreadyDeployed: false }
|
||||||
```
|
```
|
||||||
|
|
||||||
A Python mirror of this client lives in `sdk/python/` (`pip install aere-cloud`): same routes,
|
A Python mirror of this client lives in `python/` (installed from this repository, see Install): same
|
||||||
same semantics, snake_case names.
|
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
|
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.
|
||||||
|
|||||||
1
java/.gitignore
vendored
Normal file
1
java/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
out/
|
||||||
64
java/README.md
Normal file
64
java/README.md
Normal file
@ -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.
|
||||||
57
java/control-negativ.mjs
Normal file
57
java/control-negativ.mjs
Normal file
@ -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;
|
||||||
323
java/src/main/java/network/aere/cloud/AereCloud.java
Normal file
323
java/src/main/java/network/aere/cloud/AereCloud.java
Normal file
@ -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.<address>.<secret>)");
|
||||||
|
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<String> 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<String, Object> m) {
|
||||||
|
StringBuilder sb = new StringBuilder("{");
|
||||||
|
boolean primul = true;
|
||||||
|
for (Map.Entry<String, Object> 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<String, Object> 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<String, Object> 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<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) {
|
||||||
|
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<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
169
java/src/test/java/network/aere/cloud/TestAereCloud.java
Normal file
169
java/src/test/java/network/aere/cloud/TestAereCloud.java
Normal file
@ -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<String, Object> 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<String[]> vazute = new java.util.ArrayList<>(); // {metoda, cale+query, x-api-key, corp, user-agent}
|
||||||
|
final java.util.Map<String, Object[]> 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<String[]> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
python/README.md
Normal file
79
python/README.md
Normal file
@ -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.<your-0x-address>.<secret>`.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
256
python/aere_cloud/__init__.py
Normal file
256
python/aere_cloud/__init__.py
Normal file
@ -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.<adresa>.<secret>."""
|
||||||
|
|
||||||
|
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.<address>.<secret>)")
|
||||||
|
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 ""))
|
||||||
25
python/pyproject.toml
Normal file
25
python/pyproject.toml
Normal file
@ -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*"]
|
||||||
243
python/test_aere_cloud.py
Normal file
243
python/test_aere_cloud.py
Normal file
@ -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)
|
||||||
Loading…
Reference in New Issue
Block a user