ai-trust, proof-of-ai, verifiable-data: the trust chain of an AI result, the AIP-23 envelope of an agent action, and a dataset whose answers are checkable without the data
ai-trust/ links six existing proofs (agent identity, model artifact attestation, host runtime record, policy and signed ledger,
Proof of AI envelope, on-chain witness) into one bundle judged by one verifier that checks only the links; 13/13 in the lab,
16/16 live on testnet 28001 (ledger head notarized). proof-of-ai/ is the AIP-23 envelope builder it uses. verifiable-data/
publishes append-only versions with an RFC 9162 root and a sorted index per field, range and equality queries proven correct
and complete, selective disclosure per field, availability by sampling, and the AIP-23 data envelope notarized (version 2 on
testnet 28001). agents/agent-ancora.mjs gains notarizeEnvelope({kind}), the general form of notarizeHead. Each README says what
is not built: zero-knowledge queries need a prover and are not here. No third party has reviewed any of this.
This commit is contained in:
parent
f84a5e8c18
commit
cf8c56792f
@ -15,6 +15,9 @@ notarization command of the verification layer, and the agents' x402 wallet (EIP
|
|||||||
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
|
| [`readiness/`](readiness/) | the post-quantum readiness scanner of a public hostname: real TLS handshakes (hybrid only, hybrid preferred, TLS 1.2), HSTS, the certificate; no connection to an address not proven public; a rate limit per client that X-Forwarded-For cannot bypass |
|
||||||
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
|
| [`control-plane/`](control-plane/) | from findings to a finished migration: a prioritized plan from the inventory and the scanner, its execution through the gateway, KMS and PKI with consent per action and a measured proof afterwards, recipes for the servers the products do not touch and a rescan that judges them, a compliance report against NIST IR 8547, the EU roadmap and optionally CNSA 2.0, and a console that checks it all again |
|
||||||
| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; and the chain as the witness of a ledger: a notarized head, read through the AIP-23 verifier under a post-quantum certified anchor, bounds entry times from below (a backdated entry is caught) |
|
| [`agents/`](agents/) | limits an AI agent cannot break unseen: a post-quantum identity (ML-DSA-65), a policy (spending per time window, allowed tools and recipients, which actions need human approval), a signed ledger of every action judged against the policy, approvals and revocation signed by people, and a verifier that re-runs the policy over the whole ledger without trusting the agent; two branches of one ledger are a proof of equivocation anyone can check; and a wallet that pays over x402 only what the agent's ledger records and its policy allows, either holding the payment key for the owner or co-signing from a 2-of-2 contract wallet that neither the agent nor the owner can spend alone; both run on the public testnet with their evidence; and the chain as the witness of a ledger: a notarized head, read through the AIP-23 verifier under a post-quantum certified anchor, bounds entry times from below (a backdated entry is caught) |
|
||||||
|
| [`proof-of-ai/`](proof-of-ai/) | the AIP-23 envelope of an AI agent's action: model, version, weights digest, prompt, input, output, tools, agent, requester, as digests; verified by the same AIP-23 verifier as every other proof |
|
||||||
|
| [`verifiable-data/`](verifiable-data/) | a dataset whose every answer is checkable against a short commitment without the data: RFC 9162 commitments per version (append-only, consistency proofs), equality and range queries proven correct AND complete (contiguous run of a sorted index plus its two neighbours), hash-based selective disclosure of fields, availability sampling against the serving endpoint, and the version's AIP-23 envelope notarized and read back under a post-quantum certified anchor; not zero knowledge, and says so |
|
||||||
|
| [`ai-trust/`](ai-trust/) | the trust chain of an AI result: the agent's identity (agents/), the model artifact's attestation, the host's runtime record (verify-layer/), the policy and signed ledger, the Proof of AI envelope and the chain as witness, joined in one bundle whose verifier checks only the links between them |
|
||||||
| [`identity/`](identity/) | post-quantum credentials: an issuer signs claims (hybrid Ed25519 + ML-DSA-65, both required), the holder shows only the claims it picks (selective disclosure in the manner of SD-JWT, RFC 9901, in a format of its own), bound to the verifier's audience and nonce; delegation to a phone, an agent or a ten-minute session key that can only narrow; revocation and an issuer status list in the manner of W3C Bitstring Status List; all checked offline from the files; and compliance without surveillance: a verifier's policy (issuers, required claims) judged on a presentation, recorded as an AIP-23 envelope that carries no personal data; and the Travel Rule between VASPs, the IVMS101 data sealed for the receiving VASP with a hybrid X25519 + ML-KEM-768 key encapsulation, signed, bound to the transfer and acknowledged; and the same credentials as standard SD-JWT (IETF RFC 9901): issued with the issuer's Ed25519 key (EdDSA) or its ML-DSA-65 key, presented with a key binding JWT, and verified against the RFC's own example vectors (`identity-cli.mjs verify-sdjwt`, behind Aere Cloud's POST /v1/identity/sd-jwt/verify) |
|
| [`identity/`](identity/) | post-quantum credentials: an issuer signs claims (hybrid Ed25519 + ML-DSA-65, both required), the holder shows only the claims it picks (selective disclosure in the manner of SD-JWT, RFC 9901, in a format of its own), bound to the verifier's audience and nonce; delegation to a phone, an agent or a ten-minute session key that can only narrow; revocation and an issuer status list in the manner of W3C Bitstring Status List; all checked offline from the files; and compliance without surveillance: a verifier's policy (issuers, required claims) judged on a presentation, recorded as an AIP-23 envelope that carries no personal data; and the Travel Rule between VASPs, the IVMS101 data sealed for the receiving VASP with a hybrid X25519 + ML-KEM-768 key encapsulation, signed, bound to the transfer and acknowledged; and the same credentials as standard SD-JWT (IETF RFC 9901): issued with the issuer's Ed25519 key (EdDSA) or its ML-DSA-65 key, presented with a key binding JWT, and verified against the RFC's own example vectors (`identity-cli.mjs verify-sdjwt`, behind Aere Cloud's POST /v1/identity/sd-jwt/verify) |
|
||||||
|
|
||||||
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
|
Each component's README says what it is **not** and what is **not measured**. No third party has reviewed any of them.
|
||||||
@ -37,6 +40,9 @@ test, counts as a failure of the control. Results measured on 2026-09-29 (Node.j
|
|||||||
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; the console viewer in a real Chromium, phone and desktop, 26/26 (`node proba-consola-web.mjs`, measured 2026-09-30; needs `playwright-core` and a Chromium, otherwise it exits 2) | remediation 7/7, compliance report 3/3 in this repository; viewer 8/8 (`node control-negativ-consola-web.mjs`) |
|
| control-plane | planner 30/30, command line 9/9, execution 30/30 on real products started locally, remediation 33/33 on real TLS servers, compliance report 27/27 (with the AIP-23 verifier), console 8/8; the console viewer in a real Chromium, phone and desktop, 26/26 (`node proba-consola-web.mjs`, measured 2026-09-30; needs `playwright-core` and a Chromium, otherwise it exits 2) | remediation 7/7, compliance report 3/3 in this repository; viewer 8/8 (`node control-negativ-consola-web.mjs`) |
|
||||||
| identity | 44/44 (`node proba-identity.mjs`, with the AIP-23 verifier for its envelope test; without it that test is reported as skipped and the exit code is 2), compliance 15/15 (`node proba-conformitate.mjs`), Travel Rule 19/19 (`node proba-travel-rule.mjs`), SD-JWT 23/23 (`node proba-sdjwt.mjs`, the RFC 9901 vectors included), measured 2026-09-30 | 49/49 (`node control-negativ-identity.mjs`); compliance 14/14 (`node control-negativ-conformitate.mjs`); Travel Rule 19/19 (`node control-negativ-travel-rule.mjs`); SD-JWT 28/28 (`node control-negativ-sdjwt.mjs`) |
|
| identity | 44/44 (`node proba-identity.mjs`, with the AIP-23 verifier for its envelope test; without it that test is reported as skipped and the exit code is 2), compliance 15/15 (`node proba-conformitate.mjs`), Travel Rule 19/19 (`node proba-travel-rule.mjs`), SD-JWT 23/23 (`node proba-sdjwt.mjs`, the RFC 9901 vectors included), measured 2026-09-30 | 49/49 (`node control-negativ-identity.mjs`); compliance 14/14 (`node control-negativ-conformitate.mjs`); Travel Rule 19/19 (`node control-negativ-travel-rule.mjs`); SD-JWT 28/28 (`node control-negativ-sdjwt.mjs`) |
|
||||||
| agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc <solc>`) | 26/26 (`node control-negativ-aprobare.mjs`); the chain as witness 11/11 (`node control-negativ-ancora.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository |
|
| agents | policy 27/27 with the AIP-23 verifier (without it 25 run, 2 are reported as skipped and the exit code is 2), ledger 51/51, approval and revocation 39/39, the chain as witness 26/26 without a network and 7/7 on testnet 28001 on 2026-09-30 (`proba-agent-ancora-testnet.mjs`, needs a funded testnet key and the AIP-23 verifier), command line 23/23 through files and processes only (on Linux and macOS one more test checks the key file mode; not measured here); x402 wallet 25/25, 2-of-2 co-signing 16/16 and payment verifier 14/14 without a network; on the public testnet 28001, 9/9 with the wallet key and 13/13 with the 2-of-2 contract wallet (`x402/proba-x402-testnet.mjs`, `x402/proba-cosign-testnet.mjs`, each needs a funded testnet key); the contract's artifact recompiles byte for byte with solc 0.8.23 (`node x402/recompileaza-contract.mjs --solc <solc>`) | 26/26 (`node control-negativ-aprobare.mjs`); the chain as witness 11/11 (`node control-negativ-ancora.mjs`); x402 30/30 (`node x402/control-negativ-wallet.mjs`); the contract's own tests (7) and their negative control (4/4) run in the Aere Network contracts project, not in this repository |
|
||||||
|
| proof-of-ai | 6/6 with the AIP-23 verifier (`AERE_VERIFY_PROOF=... node proba-proof-of-ai.mjs`); without it the probe reports NEMASURAT and exits 2 | one negative control inside the test (an envelope touched after its hash: INVALID) |
|
||||||
|
| verifiable-data | 22/22 with the AIP-23 verifier (`AERE_VERIFY_PROOF=... node proba-verifiable-data.mjs`: honest answers VALID, nine forged ones INVALID for their named reason, availability sampling on a local server; the envelope row is NEMASURAT without the verifier, exit 2); 24/24 with `--viu` on testnet 28001 on 2026-10-02 (a version notarized with a test key and read back under a post-quantum certified anchor; evidence in `dovezi/`) | 5/5 (`node proba-verifiable-data.mjs --control-negativ`: field commitment, index order, completeness, consistency, sampling, each planted out on a copy and each forged fixture then accepted) |
|
||||||
|
| ai-trust | 13/13 with the AIP-23 verifier (`AERE_VERIFY_PROOF=... node proba-lant-de-incredere.mjs`: the whole chain on a committed fixture, then ten links broken on purpose, each refused for its named reason); without the verifier it exits 2 | 8/8 (`node proba-lant-de-incredere.mjs --control-negativ`) |
|
||||||
|
|
||||||
Code comments, most function and variable names (also many exported between the files of a component), test names and control
|
Code comments, most function and variable names (also many exported between the files of a component), test names and control
|
||||||
messages are in Romanian, and so are the two command words of the KMS HSM tool (explained in its README). Error codes, error
|
messages are in Romanian, and so are the two command words of the KMS HSM tool (explained in its README). Error codes, error
|
||||||
@ -45,4 +51,4 @@ interface, command line and data (`definePolicy`, `verifyLedger`, `approve`, ...
|
|||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
MIT, see [LICENSE](LICENSE). Files: 158 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 15, readiness 4).
|
MIT, see [LICENSE](LICENSE). Files: 174 (pq-gateway 6, pq-kms 10, pq-pki 6, crypto-inventory 42, verify-layer 12, proof-kinds 3, control-plane 19, agents 39, identity 15, proof-of-ai 3, ai-trust 6, verifiable-data 7, readiness 4).
|
||||||
|
|||||||
@ -67,10 +67,16 @@ export function cheiaDinFisier(f) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Pune statementHash-ul capului pe AereNotary. Lantul se citeste de pe RPC, nu se crede din argument; cheia din fisier, netiparita. */
|
/** Pune statementHash-ul capului pe AereNotary. Lantul se citeste de pe RPC, nu se crede din argument; cheia din fisier, netiparita. */
|
||||||
export async function notarizeHead({ envelope, rpc, keyFile, ethers, notary = null, confirmMainnet = process.env.AERE_CONFIRM_MAINNET === 'yes' }) {
|
export function notarizeHead(o) { return notarizeEnvelope({ ...o, kind: HEAD_KIND }); }
|
||||||
|
/**
|
||||||
|
* Pune statementHash-ul unui plic AIP-23 de felul `kind` pe AereNotary (2026-10-02: forma generala a lui notarizeHead, ca datele
|
||||||
|
* verificabile sa notarizeze acelasi fel, din acelasi loc; fara `kind`, numai capul de registru). Aceleasi garzi: lantul de pe RPC,
|
||||||
|
* refuz pe 2800 fara AERE_CONFIRM_MAINNET, cheia din fisier si niciodata tiparita.
|
||||||
|
*/
|
||||||
|
export async function notarizeEnvelope({ envelope, kind = HEAD_KIND, rpc, keyFile, ethers, notary = null, confirmMainnet = process.env.AERE_CONFIRM_MAINNET === 'yes' }) {
|
||||||
const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 300);
|
const taie = (s) => String(s ?? '').replace(/(0x)?[0-9a-fA-F]{40,}/g, '<hex>').slice(0, 300);
|
||||||
const st = envelope && envelope.statement;
|
const st = envelope && envelope.statement;
|
||||||
if (!st || envelope.kind !== HEAD_KIND + '-attestation' || st.kind !== HEAD_KIND || !H32.test(String(envelope.statementHash))) return { ok: false, cod: 2, motiv: `REFUSED: not an ${HEAD_KIND} attestation with a 32-byte statementHash` };
|
if (!st || envelope.kind !== kind + '-attestation' || st.kind !== kind || !H32.test(String(envelope.statementHash))) return { ok: false, cod: 2, motiv: `REFUSED: not an ${kind} attestation with a 32-byte statementHash` };
|
||||||
if (sha(JSON.stringify(st)) !== envelope.statementHash) return { ok: false, cod: 1, motiv: 'REFUSED: statementHash does not match the statement (modified envelope)' };
|
if (sha(JSON.stringify(st)) !== envelope.statementHash) return { ok: false, cod: 1, motiv: 'REFUSED: statementHash does not match the statement (modified envelope)' };
|
||||||
try {
|
try {
|
||||||
const provider = new ethers.JsonRpcProvider(rpc);
|
const provider = new ethers.JsonRpcProvider(rpc);
|
||||||
|
|||||||
49
ai-trust/README.md
Normal file
49
ai-trust/README.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# AERE AI Trust Chain
|
||||||
|
|
||||||
|
Trust infrastructure for an AI result, built only from pieces that already exist and are verified on their own: the agent's
|
||||||
|
post-quantum identity, the attestation of the model artifact, the host's runtime record, the agent's policy and signed ledger, the
|
||||||
|
Proof of AI envelope of the output, and the chain as the witness of time. This component adds **no new proof**: it adds the
|
||||||
|
**links** between those proofs, in one bundle, judged by one verifier that calls the existing verifiers and checks only that the
|
||||||
|
digests, the identities and the times agree.
|
||||||
|
|
||||||
|
| facet | what proves it | the link checked here |
|
||||||
|
|---|---|---|
|
||||||
|
| identity | `agents/` (agentId derived from an ML-DSA-65 key) | the ledger, the bundle and the output name the id the key derives |
|
||||||
|
| version | Proof of Software attestation of the model artifact (hybrid signature) | the output's `model.weightsSha256` is an artifact of the attestation; the version labels agree |
|
||||||
|
| runtime | the verify-layer sidecar's `runtime` record in its audit log (chain, head) | the host ran that same artifact (`matches`), before the output; the record is an entry of an intact log that continues the attested head |
|
||||||
|
| permissions | the agent's policy and ledger (`agents/`) | the ledger verifies; an **allowed** entry links the output by its provenance hash; the entry's time matches the output's |
|
||||||
|
| provenance | the Proof of AI envelope (AIP-23; digests only) | the envelope verifies with the AIP-23 reference verifier; notarized, its finality is read under a post-quantum certified anchor |
|
||||||
|
| witness | the ledger head notarized on Aere (`agents/agent-ancora.mjs`) | the heads read back through the reference verifier become the ledger's time anchors; without them every time is the key holder's statement |
|
||||||
|
|
||||||
|
Verdict: `VALID` when every present level PASSED **and** the witness PASSED; `INVALID` when a level FAILED; `PARTIAL` otherwise
|
||||||
|
(an UNMEASURED level, or no witness). Every level reports what it rests on. A verdict says that the bytes link and who vouches for
|
||||||
|
each link. It does not say the model is good, the host is honest, or the output is true.
|
||||||
|
|
||||||
|
```
|
||||||
|
import { construieste, verifica } from './lant-de-incredere.mjs';
|
||||||
|
const bundle = construieste({ ledger, policy: { policy, policyHash }, proofOfAi, software: { attestation }, runtime: { record, auditLog, head }, witness: { heads: [notarizedHead], chainId: 28001 } });
|
||||||
|
const v = await verifica(bundle, { rpc: 'https://testnet-rpc.aere.network', chain: 28001 }); // rpc: only for notarized pieces
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it is not
|
||||||
|
|
||||||
|
- Not an attestation of execution in a TEE: the runtime record is the **host's declaration**, as the sidecar says; what is checkable
|
||||||
|
without trusting the host is that the declaration was made before the output and sits in a history published outside the host.
|
||||||
|
- Not a judgement of the signer of the model attestation: who holds the signing keys is `pos.mjs verify --signer`'s question.
|
||||||
|
- Not a judgement of the content: prompts, inputs and outputs are digests; the verifier proves that bytes with these hashes were
|
||||||
|
named, not what they say.
|
||||||
|
|
||||||
|
## Probes
|
||||||
|
|
||||||
|
```
|
||||||
|
AERE_VERIFY_PROOF=<verify-proof.mjs from aere-node> node proba-lant-de-incredere.mjs # no network: 13 rows (the chain, then every link broken on purpose)
|
||||||
|
AERE_VERIFY_PROOF=... node proba-lant-de-incredere.mjs --control-negativ # 8 plantings in a copy of the verifier; each must turn its fixture red
|
||||||
|
AERE_VERIFY_PROOF=... AERE_NOTARY_KEY_FILE=<key> AERE_ETHERS_DIR=<dir with node_modules/ethers> node proba-lant-de-incredere.mjs --viu
|
||||||
|
# plus the witness on testnet 28001 (a notarization paid with a test key)
|
||||||
|
```
|
||||||
|
|
||||||
|
The probes use the real children beside this component: the verify-layer sidecar (record, attest-head), the agents (ledger, policy,
|
||||||
|
anchor), proof-of-ai, and the AIP-23 reference verifier named by `AERE_VERIFY_PROOF` (without it the probe exits 2, NEMASURAT). The
|
||||||
|
model artifact and its Proof of Software attestation are a committed fixture (`fixturi/`: 4 KB of random bytes attested with
|
||||||
|
laboratory keys by `pos.mjs`, which is not part of this component; the digest is recomputed from the bytes, never read from the
|
||||||
|
attestation). `--viu` writes its evidence in `dovezi/`.
|
||||||
269
ai-trust/dovezi/lant-28001-2026-10-02T0519Z.json
Normal file
269
ai-trust/dovezi/lant-28001-2026-10-02T0519Z.json
Normal file
File diff suppressed because one or more lines are too long
45
ai-trust/fixturi/atestare-model.json
Normal file
45
ai-trust/fixturi/atestare-model.json
Normal file
File diff suppressed because one or more lines are too long
BIN
ai-trust/fixturi/model-weights.bin
Normal file
BIN
ai-trust/fixturi/model-weights.bin
Normal file
Binary file not shown.
213
ai-trust/lant-de-incredere.mjs
Normal file
213
ai-trust/lant-de-incredere.mjs
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// lant-de-incredere.mjs - AERE AI Trust Chain (roadmap master point 21, track B, 2026-10-02): the LINKS between the six facets of
|
||||||
|
// trust in an AI result, each of which already exists as its own proof, joined into one bundle that one verifier judges:
|
||||||
|
// identity the agent's post-quantum identity (agents/: agentId derived from its ML-DSA-65 key)
|
||||||
|
// version what model ran: a Proof of Software attestation of the model artifact (weights or runtime), hybrid-signed
|
||||||
|
// runtime that it ran: the host's runtime record (verify-layer sidecar, kind runtime) in its audit log, before the output
|
||||||
|
// permissions that it was allowed: the agent's policy and signed ledger (agents/), the entry that links the output
|
||||||
|
// provenance what came out: the Proof of AI envelope (model, prompt, input, output, tools, agent, requester: digests only)
|
||||||
|
// witness when: the ledger head notarized on Aere, read under a post-quantum certified anchor (agents/agent-ancora.mjs)
|
||||||
|
// Nothing here re-implements a child verifier: every envelope is judged by the AIP-23 reference verifier (verify-proof.mjs), the
|
||||||
|
// ledger by verifyLedger, the audit log by the sidecar's own chain check. This module checks only the LINKS between them (equal
|
||||||
|
// digests, equal identities, the order of times) and reports each level in four states (PASSED, FAILED, UNMEASURED, ABSENT) with
|
||||||
|
// what it rests on. A verdict says that the bytes link and who vouches for each; it does not say the model is good, the host is
|
||||||
|
// honest, or the output is true.
|
||||||
|
// VALID every present level PASSED and the witness PASSED (a time somebody other than the agent fixed)
|
||||||
|
// INVALID a level FAILED
|
||||||
|
// PARTIAL otherwise (a level UNMEASURED, or no witness)
|
||||||
|
// Node.js 24 only; the children are in this repository (agents, verify-layer, proof-of-software, proof-of-ai, verify-proof.mjs).
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { agentIdFromKey, verifyLedger, canonical } from '../agents/agent-ledger.mjs';
|
||||||
|
import { hashPolicy } from '../agents/agent-policy.mjs';
|
||||||
|
import { verificaJurnal, verificaFataDeCap } from '../verify-layer/sidecar.mjs';
|
||||||
|
import { anchorsFromNotarizedHeads } from '../agents/agent-ancora.mjs';
|
||||||
|
|
||||||
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
export const FORMAT = 'aere-ai-trust-chain-1';
|
||||||
|
export const LEVELS = ['form', 'identity', 'version', 'runtime', 'permissions', 'provenance', 'witness'];
|
||||||
|
export const TOLERANCE_S = 300;
|
||||||
|
export const VERIFY_PROOF_DEFAULT = process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', '..', 'verificator-falcon', 'verify-proof.mjs');
|
||||||
|
const H32 = /^0x[0-9a-f]{64}$/;
|
||||||
|
const sha = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
||||||
|
const lower = (x) => (typeof x === 'string' ? x.toLowerCase() : x);
|
||||||
|
const lvl = (state, extra = {}) => ({ state, ...extra });
|
||||||
|
|
||||||
|
/** the hash a ledger entry stores for its provenance: the ledger's sha over the canonical JSON (with or without 0x, both forms matched) */
|
||||||
|
export const provenanceHash = (envelope) => sha(Buffer.from(canonical(envelope), 'utf8'));
|
||||||
|
const sameHash = (a, b) => String(a || '').replace(/^0x/, '').toLowerCase() === String(b || '').replace(/^0x/, '').toLowerCase() && String(a || '').length > 0;
|
||||||
|
|
||||||
|
/** build the bundle from the pieces (files already parsed); the links are not computed here, the verifier recomputes them */
|
||||||
|
export function construieste({ ledger, policy, proofOfAi, software = null, runtime = null, witness = null, createdAt = new Date().toISOString() }) {
|
||||||
|
if (!ledger || !Array.isArray(ledger.entries) || !ledger.publicKeyPem || !ledger.agentId) throw new Error('a ledger (agent-ledger export) is required');
|
||||||
|
if (!policy) throw new Error('the policy is required');
|
||||||
|
if (!proofOfAi || !proofOfAi.statement || !H32.test(String(proofOfAi.statementHash || ''))) throw new Error('a Proof of AI envelope is required');
|
||||||
|
const b = {
|
||||||
|
format: FORMAT, createdAt,
|
||||||
|
identity: { agentId: ledger.agentId, publicKeyPem: ledger.publicKeyPem },
|
||||||
|
permissions: { policy: policy.policy || policy, policyHash: policy.policyHash || null, ledger, revocations: [] },
|
||||||
|
provenance: { proofOfAi },
|
||||||
|
version: software ? { attestation: software.attestation || software, artifactName: software.artifactName || null } : null,
|
||||||
|
runtime: runtime ? { record: runtime.record, auditLog: runtime.auditLog || null, head: runtime.head || null } : null,
|
||||||
|
witness: witness ? { heads: witness.heads || [], chainId: witness.chainId || null } : null,
|
||||||
|
};
|
||||||
|
if (policy.revocations) b.permissions.revocations = policy.revocations;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** run the AIP-23 reference verifier on an envelope; returns its JSON (levels as a map) or null */
|
||||||
|
export function judecatorAip23(verifyProof, envelope, { rpc = null, chain = null } = {}) {
|
||||||
|
return new Promise((ok) => {
|
||||||
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-ai-trust-')); const f = path.join(d, 'plic.json'); fs.writeFileSync(f, JSON.stringify(envelope));
|
||||||
|
const args = [verifyProof, f, '--json', ...(rpc ? ['--rpc', rpc] : []), ...(chain ? ['--chain', String(chain)] : [])];
|
||||||
|
const c = spawn(process.execPath, args); let out = '';
|
||||||
|
c.stdout.on('data', (x) => { out += x; }); c.stderr.on('data', () => {});
|
||||||
|
const t = setTimeout(() => c.kill(), 300000);
|
||||||
|
const gata = (j) => { clearTimeout(t); fs.rmSync(d, { recursive: true, force: true }); ok(j); };
|
||||||
|
c.on('error', () => gata(null));
|
||||||
|
c.on('close', () => { try { const j = JSON.parse(out); ok(gata({ verdict: j.verdict, levels: Object.fromEntries((j.levels || []).map((l) => [l.level, l])) })); } catch { gata(null); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const parsed = (j, name) => (j && j.levels && j.levels[name]) ? j.levels[name] : { state: 'UNMEASURED', detail: 'the reference verifier gave no ' + name + ' level' };
|
||||||
|
const envelopeOk = (j) => j && ['form', 'integrity'].every((n) => parsed(j, n).state === 'PASSED') && (parsed(j, 'signature').state !== 'FAILED');
|
||||||
|
|
||||||
|
export async function verifica(bundle, { verifyProof = VERIFY_PROOF_DEFAULT, rpc = null, chain = null, now = Math.floor(Date.now() / 1000) } = {}) {
|
||||||
|
const levels = Object.fromEntries(LEVELS.map((l) => [l, lvl('ABSENT')]));
|
||||||
|
const assumptions = {};
|
||||||
|
const fail = (l, reason, detail) => { levels[l] = lvl('FAILED', { reason, detail }); };
|
||||||
|
const unm = (l, reason, detail) => { levels[l] = lvl('UNMEASURED', { reason, detail }); };
|
||||||
|
// 1. form
|
||||||
|
const isObj = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||||
|
if (!isObj(bundle) || bundle.format !== FORMAT || !isObj(bundle.identity) || !isObj(bundle.permissions) || !isObj(bundle.provenance) || !isObj(bundle.permissions.ledger) || !isObj(bundle.provenance.proofOfAi)) {
|
||||||
|
fail('form', 'form', 'not an ' + FORMAT + ' bundle with identity, permissions.ledger and provenance.proofOfAi');
|
||||||
|
return verdict(levels, assumptions);
|
||||||
|
}
|
||||||
|
levels.form = lvl('PASSED');
|
||||||
|
const poa = bundle.provenance.proofOfAi, st = poa.statement || {}, ledger = bundle.permissions.ledger;
|
||||||
|
const outputAt = Math.floor(Date.parse(st.createdAt || '') / 1000);
|
||||||
|
// 2. identity: the id is derived from the key, and the ledger, the bundle and the output name the same agent
|
||||||
|
try {
|
||||||
|
const derived = agentIdFromKey(crypto.createPublicKey(bundle.identity.publicKeyPem));
|
||||||
|
const same = derived === bundle.identity.agentId && derived === ledger.agentId && derived === st.agentId /*PAZA:identity*/;
|
||||||
|
if (!same) fail('identity', 'identity-mismatch', `key derives ${derived}; bundle says ${bundle.identity.agentId}, ledger ${ledger.agentId}, output ${st.agentId}`);
|
||||||
|
else levels.identity = lvl('PASSED', { detail: `${derived} (ML-DSA-65 key; the id is derived from the key, nobody can claim it without the key)` });
|
||||||
|
} catch (e) { fail('identity', 'identity-key', 'the public key cannot be read: ' + String(e.message).slice(0, 80)); }
|
||||||
|
assumptions.identity = ['ML-DSA-65 unbroken; the agent key holder is the agent'];
|
||||||
|
// 3. version: the model artifact attested (Proof of Software), and the output names that very artifact
|
||||||
|
let attestedSha = null;
|
||||||
|
if (bundle.version && bundle.version.attestation) {
|
||||||
|
const att = bundle.version.attestation;
|
||||||
|
const j = await judecatorAip23(verifyProof, att, { rpc: att.notarization ? rpc : null, chain });
|
||||||
|
if (!j) unm('version', 'version-verifier', 'the AIP-23 reference verifier did not answer for the attestation');
|
||||||
|
else if (!envelopeOk(j)) fail('version', 'version-attestation', 'the Proof of Software attestation does not verify: ' + ['form', 'integrity', 'signature'].map((n) => n + ' ' + parsed(j, n).state).join(', '));
|
||||||
|
else {
|
||||||
|
const arts = (att.statement && att.statement.artifacts) || [];
|
||||||
|
const w = lower(st.model && st.model.weightsSha256);
|
||||||
|
const hit = arts.find((a) => lower(a.sha256) === w);
|
||||||
|
const ok = !!(w && hit) /*PAZA:version*/;
|
||||||
|
if (!w) unm('version', 'model-weights-not-named', 'the Proof of AI names no model.weightsSha256, so no artifact can be matched');
|
||||||
|
else if (!ok) fail('version', 'version-artifact-mismatch', `the output names model weights ${w.slice(0, 14)}.., the attestation covers ${arts.map((a) => a.name + ' ' + String(a.sha256).slice(0, 14) + '..').join(', ') || 'no artifact'}`);
|
||||||
|
else {
|
||||||
|
const v = att.statement.subject && att.statement.subject.version; const mv = st.model && st.model.version;
|
||||||
|
if (v && mv && String(v) !== String(mv)) fail('version', 'version-label-mismatch', `the output says model version ${mv}, the attestation ${v}`);
|
||||||
|
else { const h = hit || arts[0] || { name: '?', sha256: w }; attestedSha = lower(h.sha256); levels.version = lvl('PASSED', { detail: `${h.name} ${String(h.sha256).slice(0, 18)}.. attested as ${att.statement.subject ? att.statement.subject.name + '@' + att.statement.subject.version : '?'}; signature ${parsed(j, 'signature').state}${att.notarization ? ', finality ' + parsed(j, 'finality').state : ''}` }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assumptions.version = ['the attestation proves what its signing keys attested (who holds them is a separate judgement: pos.mjs verify --signer); the artifact digest is what links it to the output'];
|
||||||
|
}
|
||||||
|
// 4. runtime: the host declared that THIS artifact ran, in its audit log, before the output
|
||||||
|
if (bundle.runtime && bundle.runtime.record) {
|
||||||
|
const rec = bundle.runtime.record, rs = rec.statement || {};
|
||||||
|
const j = await judecatorAip23(verifyProof, rec, { rpc: rec.notarization ? rpc : null, chain });
|
||||||
|
if (!j) unm('runtime', 'runtime-verifier', 'the AIP-23 reference verifier did not answer for the runtime record');
|
||||||
|
else if (!envelopeOk(j) || rec.kind !== 'aere-proof-of-runtime-attestation') fail('runtime', 'runtime-record', 'the runtime record is not a verified aere-proof-of-runtime-attestation');
|
||||||
|
else {
|
||||||
|
const target = attestedSha || lower(st.model && st.model.weightsSha256);
|
||||||
|
const ok = !!(target && lower(rs.artifactSha256) === target && rs.matches === true) /*PAZA:runtime*/;
|
||||||
|
if (!target) unm('runtime', 'runtime-no-artifact', 'no attested artifact to match the runtime record against');
|
||||||
|
else if (!ok) fail('runtime', 'runtime-artifact-mismatch', `the host ran ${String(rs.artifactSha256).slice(0, 14)}.. (matches ${rs.matches}), the output names ${target.slice(0, 14)}..`);
|
||||||
|
else {
|
||||||
|
const runAt = Math.floor(Date.parse(rs.createdAt || '') / 1000);
|
||||||
|
const ordered = Number.isFinite(runAt) && Number.isFinite(outputAt) && runAt <= outputAt + TOLERANCE_S /*PAZA:order*/;
|
||||||
|
if (!ordered) fail('runtime', 'runtime-after-output', `the runtime record is dated ${rs.createdAt}, the output ${st.createdAt}`);
|
||||||
|
else {
|
||||||
|
let note = `host ${rs.host} declared the artifact running at ${rs.createdAt}`;
|
||||||
|
const log = bundle.runtime.auditLog;
|
||||||
|
if (Array.isArray(log)) {
|
||||||
|
const v = verificaJurnal(log);
|
||||||
|
if (!v.ok) { fail('runtime', 'audit-log-broken', `the audit log chain breaks at seq ${v.rupt}: ${v.motiv}`); }
|
||||||
|
else if (!log.some((e) => e.proof && lower(e.proof.statementHash) === lower(rec.statementHash)) /*PAZA:audit*/) fail('runtime', 'runtime-not-in-audit-log', 'the runtime record is not an entry of the audit log given');
|
||||||
|
else {
|
||||||
|
note += `; in the host's audit log (${log.length} entries, chain intact)`;
|
||||||
|
if (bundle.runtime.head) {
|
||||||
|
const c = verificaFataDeCap(log, bundle.runtime.head);
|
||||||
|
if (!c.ok) fail('runtime', 'audit-head', 'the log does not continue the attested head: ' + (c.motiv || ''));
|
||||||
|
else { note += `; continues the attested head (count ${bundle.runtime.head.statement && bundle.runtime.head.statement.count})`; if (bundle.runtime.head.notarization && rpc) { const hj = await judecatorAip23(verifyProof, bundle.runtime.head, { rpc, chain }); note += `; head finality ${hj ? parsed(hj, 'finality').state : 'UNMEASURED'}`; } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else note += '; no audit log given (the record stands alone)';
|
||||||
|
if (levels.runtime.state !== 'FAILED') levels.runtime = lvl('PASSED', { detail: note });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assumptions.runtime = ['the runtime record is the HOST\'s declaration (the sidecar reads the export the operator makes); it proves what the host said ran, not that it did; the audit log has no key and is judged against a head published outside the host'];
|
||||||
|
}
|
||||||
|
// 5. permissions: the ledger verifies under its policy, the output is an entry of it, allowed, at a matching time
|
||||||
|
let anchors = [], witnessNote = null;
|
||||||
|
if (bundle.witness && Array.isArray(bundle.witness.heads) && bundle.witness.heads.length) {
|
||||||
|
if (!rpc) { unm('witness', 'witness-needs-rpc', 'the notarized heads can be read only through an endpoint of the chain (--rpc)'); }
|
||||||
|
else {
|
||||||
|
const a = await anchorsFromNotarizedHeads(ledger, bundle.witness.heads, { verifyProof, rpc, chain: chain || bundle.witness.chainId || null });
|
||||||
|
if (a.stare === 'OK' && a.anchors.length) { anchors = a.anchors; witnessNote = a.randuri.map((r) => r.detail).join(' | '); }
|
||||||
|
else if (a.stare === 'FAILED') fail('witness', 'witness-head', a.randuri.filter((r) => !r.pass).map((r) => r.detail).join(' | '));
|
||||||
|
else unm('witness', 'witness-unmeasured', a.randuri.map((r) => r.detail).join(' | ') || 'no anchor could be read');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { policy, policyHash } = hashPolicy(bundle.permissions.policy);
|
||||||
|
if (bundle.permissions.policyHash && lower(bundle.permissions.policyHash) !== policyHash) fail('permissions', 'policy-hash', 'the bundle pins another policy hash than the policy given');
|
||||||
|
else {
|
||||||
|
const v = verifyLedger(ledger, { policy, policyHash, revocations: bundle.permissions.revocations || [], anchors, maxTime: now + TOLERANCE_S });
|
||||||
|
if (!v.ok) fail('permissions', 'ledger-invalid', String(v.error || '').slice(0, 200));
|
||||||
|
else {
|
||||||
|
const ph = provenanceHash(poa);
|
||||||
|
const entry = ledger.entries.find((e) => e.body && sameHash(e.body.provenance, ph)) /*PAZA:output-in-ledger*/;
|
||||||
|
if (!entry) fail('permissions', 'output-not-in-ledger', 'no ledger entry links this Proof of AI envelope (provenance hash ' + ph.slice(0, 14) + '..)');
|
||||||
|
else if (!(entry.body.decision && entry.body.decision.allowed === true) /*PAZA:allowed*/) fail('permissions', 'action-refused', `the ledger entry seq ${entry.seq} was refused: ${entry.body.decision && entry.body.decision.reason}`);
|
||||||
|
else if (!(Number.isFinite(outputAt) && Math.abs(Number(entry.body.at) - outputAt) <= TOLERANCE_S)) fail('permissions', 'ledger-time-mismatch', `the ledger entry is at ${entry.body.at}, the output at ${outputAt} (${st.createdAt})`);
|
||||||
|
else levels.permissions = lvl('PASSED', { detail: `entry seq ${entry.seq} (${entry.body.action && entry.body.action.kind}${entry.body.action && entry.body.action.tool ? ' ' + entry.body.action.tool : ''}) allowed under policy ${policyHash.slice(0, 14)}..; ${ledger.entries.length} entries verified, revocations ${v.revocations ? v.revocations.given : 0}, time ${v.time ? v.time.lowerBound : '?'}` });
|
||||||
|
if (anchors.length && levels.witness.state === 'ABSENT') levels.witness = lvl('PASSED', { detail: `${anchors.length} anchor(s) from notarized heads under a post-quantum certified state: ${witnessNote}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { fail('permissions', 'policy', String(e.message).slice(0, 160)); }
|
||||||
|
assumptions.permissions = ['the policy given is the one pinned (its hash is recomputed); revocations come from the owner, outside the ledger, and only those given are judged'];
|
||||||
|
// 6. provenance: the Proof of AI envelope itself
|
||||||
|
{
|
||||||
|
const j = await judecatorAip23(verifyProof, poa, { rpc: poa.notarization ? rpc : null, chain });
|
||||||
|
if (!j) unm('provenance', 'provenance-verifier', 'the AIP-23 reference verifier did not answer');
|
||||||
|
else if (poa.kind !== 'aere-proof-of-ai-attestation' || !envelopeOk(j)) fail('provenance', 'provenance-envelope', 'the Proof of AI envelope does not verify: ' + ['form', 'integrity', 'signature'].map((n) => n + ' ' + parsed(j, n).state).join(', '));
|
||||||
|
else if (poa.notarization && !rpc) unm('provenance', 'provenance-finality-needs-rpc', 'the envelope claims a notarization that only an endpoint can confirm');
|
||||||
|
else if (poa.notarization && parsed(j, 'finality').state !== 'PASSED') { const f = parsed(j, 'finality'); (f.state === 'FAILED' ? fail : unm)('provenance', 'provenance-finality', String(f.detail).slice(0, 200)); }
|
||||||
|
else levels.provenance = lvl('PASSED', { detail: `model ${st.model && st.model.name}@${st.model && st.model.version}, prompt ${String(st.promptHash).slice(0, 12)}.., output ${String(st.outputHash).slice(0, 12)}.., tools [${(st.tools || []).join(', ')}], requester ${String(st.requesterHash).slice(0, 12)}..; signature ${parsed(j, 'signature').state}${poa.notarization ? ', finality ' + parsed(j, 'finality').state : ''}` });
|
||||||
|
assumptions.provenance = ['digests only: the prompt, input, output and requester are proven to exist as the bytes whose hashes these are, not shown; the envelope\'s time is its producer\'s statement unless notarized'];
|
||||||
|
}
|
||||||
|
if (levels.witness.state !== 'ABSENT') assumptions.witness = ['the time is the chain\'s (the notary records the block time; on 28001 and 2800 every validator is run by the Foundation): whoever notarizes can only notarize late'];
|
||||||
|
else assumptions.witness = ['no witness: every time in this bundle is the statement of whoever holds the keys'];
|
||||||
|
return verdict(levels, assumptions);
|
||||||
|
}
|
||||||
|
function verdict(levels, assumptions) {
|
||||||
|
const failed = LEVELS.find((l) => levels[l].state === 'FAILED');
|
||||||
|
let v, reason = null;
|
||||||
|
if (failed) { v = 'INVALID'; reason = levels[failed].reason; }
|
||||||
|
else {
|
||||||
|
const unmL = LEVELS.find((l) => levels[l].state === 'UNMEASURED');
|
||||||
|
if (unmL) { v = 'PARTIAL'; reason = levels[unmL].reason; }
|
||||||
|
else if (levels.witness.state !== 'PASSED') { v = 'PARTIAL'; reason = 'unwitnessed'; }
|
||||||
|
else v = 'VALID';
|
||||||
|
}
|
||||||
|
return { verdict: v, reason, levels: Object.fromEntries(LEVELS.map((l) => [l, levels[l].state])), details: levels, assumptions,
|
||||||
|
notice: 'A verdict says that the bytes link and who vouches for each link. It does not say the model is good, the host is honest, or the output is true.' };
|
||||||
|
}
|
||||||
162
ai-trust/proba-lant-de-incredere.mjs
Normal file
162
ai-trust/proba-lant-de-incredere.mjs
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// proba-lant-de-incredere.mjs: the AI trust chain end to end in the LABORATORY, with the real children (Proof of Software attest,
|
||||||
|
// the verify-layer sidecar, the agent ledger, Proof of AI, the AIP-23 reference verifier), then the fixtures (each link broken on
|
||||||
|
// purpose, refused for its named reason), then the plantings (--control-negativ: a copy of the verifier with one check removed must
|
||||||
|
// turn exactly its fixture ROSU). --viu adds the witness: the ledger head notarized on testnet 28001 with the L1 test key, read back
|
||||||
|
// under a post-quantum certified anchor, and the bundle judged VALID with --rpc.
|
||||||
|
// node proba-lant-de-incredere.mjs [--viu] [--control-negativ]
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { newAgentIdentity, openLedger } from '../agents/agent-ledger.mjs';
|
||||||
|
import { definePolicy } from '../agents/agent-policy.mjs';
|
||||||
|
import { buildProofOfAi } from '../proof-of-ai/proof-of-ai.mjs';
|
||||||
|
import { ledgerHeadEnvelope, notarizeHead, cheiaDinFisier } from '../agents/agent-ancora.mjs';
|
||||||
|
|
||||||
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const RAD = path.resolve(AICI, '..', '..');
|
||||||
|
// the children: the sidecar lives beside this component (public copy: ../verify-layer/, rewritten at staging); the AIP-23 reference
|
||||||
|
// verifier comes from AERE_VERIFY_PROOF (public copy, as for agents/) or from the development repository; without it: NEMASURAT (2)
|
||||||
|
const SIDECAR = fileURLToPath(new URL('../verify-layer/sidecar.mjs', import.meta.url));
|
||||||
|
const VERIFY_PROOF = process.env.AERE_VERIFY_PROOF || path.join(RAD, 'verificator-falcon', 'verify-proof.mjs');
|
||||||
|
const RPC_28001 = process.env.AERE_TESTNET_RPC || 'https://testnet-rpc.aere.network';
|
||||||
|
// --viu: the notarization key (gas only, a test key) and the folder whose node_modules hold ethers
|
||||||
|
const CHEIE = process.env.AERE_NOTARY_KEY_FILE || path.join(RAD, 'tools', 'aere-quantum-shield', 'dovezi-l1', '.chei', 'cheie-de-proba-28001.key');
|
||||||
|
const ETHERS_DIR = process.env.AERE_ETHERS_DIR || path.join(RAD, 'contracts');
|
||||||
|
// the model artifact and its Proof of Software attestation are a COMMITTED fixture (fixturi/: 4 KB of random bytes attested with
|
||||||
|
// laboratory keys by tools/proof-of-software/pos.mjs, which is not part of this component); regenerate with
|
||||||
|
// node ../proof-of-software/pos.mjs keygen --out k.json && node ../proof-of-software/pos.mjs attest --out fixturi/atestare-model.json --name aere-model-proba --version 1.0.0 --keys k.json fixturi/model-weights.bin
|
||||||
|
const FIXTURI = path.join(AICI, 'fixturi');
|
||||||
|
if (!fs.existsSync(VERIFY_PROOF)) { console.log(`NEMASURAT: verificatorul AIP-23 nu e la ${VERIFY_PROOF}; AERE_VERIFY_PROOF=<verify-proof.mjs>`); process.exit(2); }
|
||||||
|
const sha = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
||||||
|
const run = (args, cwd) => { const r = spawnSync(process.execPath, args, { cwd, encoding: 'utf8', timeout: 180000 }); if (r.status !== 0) throw new Error(`${path.basename(args[0])} ${args[1]} exited ${r.status}: ${(r.stdout + r.stderr).slice(-300)}`); return r.stdout; };
|
||||||
|
const rehash = (env) => ({ ...env, statementHash: sha(Buffer.from(JSON.stringify(env.statement), 'utf8')) });
|
||||||
|
const clone = (x) => JSON.parse(JSON.stringify(x));
|
||||||
|
|
||||||
|
/** the laboratory pieces: a model artifact attested, a host that ran it, an agent allowed to use it, an output */
|
||||||
|
export function laborator({ toolAllowed = true } = {}) {
|
||||||
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-ai-trust-lab-'));
|
||||||
|
// version: the committed fixture (the artifact and its hybrid-signed Proof of Software attestation); the digest is recomputed here,
|
||||||
|
// never read from the attestation, so a fixture whose artifact and attestation drifted apart is caught at P1
|
||||||
|
const art = path.join(T, 'model-weights.bin'); fs.copyFileSync(path.join(FIXTURI, 'model-weights.bin'), art); const artSha = sha(fs.readFileSync(art));
|
||||||
|
const attestation = JSON.parse(fs.readFileSync(path.join(FIXTURI, 'atestare-model.json'), 'utf8'));
|
||||||
|
// runtime: the host declares the artifact running, in its audit log, 60 s before the output
|
||||||
|
const t0 = Math.floor(Date.now() / 1000) - 120, t1 = t0 + 60;
|
||||||
|
const iso = (t) => new Date(t * 1000).toISOString();
|
||||||
|
run([SIDECAR, 'record', '--kind', 'runtime', '--artifact', art, '--attested', artSha, '--host', 'lab-probe', '--log', path.join(T, 'audit.log'), '--at', iso(t0)], T);
|
||||||
|
run([SIDECAR, 'attest-head', '--log', path.join(T, 'audit.log'), '--host', 'lab-probe', '--out', path.join(T, 'head.json')], T);
|
||||||
|
const auditLog = fs.readFileSync(path.join(T, 'audit.log'), 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
|
||||||
|
const record = auditLog[0].proof; const head = JSON.parse(fs.readFileSync(path.join(T, 'head.json'), 'utf8'));
|
||||||
|
// permissions + identity: an agent allowed (or not) to call the model tool
|
||||||
|
const identity = newAgentIdentity();
|
||||||
|
const { policy, policyHash } = definePolicy({ agentId: identity.agentId, tools: toolAllowed ? ['model:aere-model-proba'] : ['model:other'] });
|
||||||
|
const ledger = openLedger({ identity, policy, policyHash, now: () => t1 });
|
||||||
|
// provenance: the output, naming the attested artifact and the agent
|
||||||
|
const proofOfAi = buildProofOfAi({ model: { name: 'aere-model-proba', version: '1.0.0', weightsSha256: artSha }, prompt: 'summarise document X', input: 'document X, 12 kB', output: 'a summary of document X', tools: ['model:aere-model-proba'], agentId: identity.agentId, requester: 'tester', createdAt: iso(t1) });
|
||||||
|
const r = ledger.record({ kind: 'tool', tool: 'model:aere-model-proba', args: { promptHash: proofOfAi.statement.promptHash } }, { provenance: proofOfAi, at: t1 });
|
||||||
|
return { T, art, artSha, attestation, record, auditLog, head, identity, policy, policyHash, ledger, proofOfAi, allowed: r.allowed, t0, t1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function proba(mod, cer, { viu = false } = {}) {
|
||||||
|
const L = laborator();
|
||||||
|
const bundle = mod.construieste({ ledger: L.ledger.export(), policy: { policy: L.policy, policyHash: L.policyHash }, proofOfAi: L.proofOfAi, software: { attestation: L.attestation }, runtime: { record: L.record, auditLog: L.auditLog, head: L.head } });
|
||||||
|
const v0 = await mod.verifica(bundle, { verifyProof: VERIFY_PROOF });
|
||||||
|
const toate = ['identity', 'version', 'runtime', 'permissions', 'provenance'];
|
||||||
|
cer('P1', 'laborator: toate cele cinci fatete PASSED, martorul ABSENT, verdict PARTIAL (unwitnessed)', toate.every((l) => v0.levels[l] === 'PASSED') && v0.levels.witness === 'ABSENT' && v0.verdict === 'PARTIAL' && v0.reason === 'unwitnessed' ? 'VERDE' : 'ROSU', `${v0.verdict} (${v0.reason}) ${JSON.stringify(v0.levels)}`);
|
||||||
|
cer('P2', 'intrarea registrului a fost PERMISA de politica (unealta model:aere-model-proba in lista)', L.allowed ? 'VERDE' : 'ROSU');
|
||||||
|
const v1 = await mod.verifica(mod.construieste({ ledger: L.ledger.export(), policy: { policy: L.policy, policyHash: L.policyHash }, proofOfAi: L.proofOfAi }), { verifyProof: VERIFY_PROOF });
|
||||||
|
cer('P3', 'fara versiune si fara runtime: nivelurile ABSENT, restul PASSED, tot PARTIAL unwitnessed (ce lipseste e spus, nu inventat)', v1.levels.version === 'ABSENT' && v1.levels.runtime === 'ABSENT' && v1.levels.permissions === 'PASSED' && v1.verdict === 'PARTIAL' ? 'VERDE' : 'ROSU', JSON.stringify(v1.levels));
|
||||||
|
// fixtures: each link broken on purpose
|
||||||
|
const fix = [
|
||||||
|
['F1', 'iesirea schimbata in Proof of AI (hash refacut): nu mai e intrarea din registru -> permissions FAILED output-not-in-ledger', (b) => { b.provenance.proofOfAi.statement.outputHash = sha(Buffer.from('another output')); b.provenance.proofOfAi = rehash(b.provenance.proofOfAi); }, 'permissions', 'output-not-in-ledger'],
|
||||||
|
['F2', 'digestul ponderilor schimbat in Proof of AI: nu e artefactul atestat -> version FAILED version-artifact-mismatch', (b) => { b.provenance.proofOfAi.statement.model.weightsSha256 = '0x' + 'ab'.repeat(32); b.provenance.proofOfAi = rehash(b.provenance.proofOfAi); }, 'version', 'version-artifact-mismatch'],
|
||||||
|
['F3', 'alt agentId in Proof of AI: identity FAILED identity-mismatch', (b) => { b.provenance.proofOfAi.statement.agentId = 'aere-agent:' + '11'.repeat(20); b.provenance.proofOfAi = rehash(b.provenance.proofOfAi); }, 'identity', 'identity-mismatch'],
|
||||||
|
['F4', 'inregistrarea de runtime numeste alt artefact: runtime FAILED runtime-artifact-mismatch', (b) => { b.runtime.record.statement.artifactSha256 = '0x' + 'cd'.repeat(32); b.runtime.record = rehash(b.runtime.record); }, 'runtime', 'runtime-artifact-mismatch'],
|
||||||
|
['F6', 'inregistrarea de runtime datata DUPA iesire: runtime FAILED runtime-after-output', (b) => { b.runtime.record.statement.createdAt = new Date((L.t1 + 3600) * 1000).toISOString(); b.runtime.record = rehash(b.runtime.record); }, 'runtime', 'runtime-after-output'],
|
||||||
|
['F7', 'o intrare a registrului modificata (argumentele): permissions FAILED ledger-invalid', (b) => { b.permissions.ledger.entries[0].body.action.args = { promptHash: '0x' + '00'.repeat(32) }; }, 'permissions', 'ledger-invalid'],
|
||||||
|
['F8', 'jurnalul de audit fara inregistrarea de runtime: runtime FAILED runtime-not-in-audit-log', (b) => { b.runtime.auditLog = []; b.runtime.head = null; }, 'runtime', 'runtime-not-in-audit-log'],
|
||||||
|
['F9', 'atestarea Proof of Software modificata dupa hash: version FAILED version-attestation', (b) => { b.version.attestation.statement.artifacts[0].sha256 = '0x' + 'ef'.repeat(32); }, 'version', 'version-attestation'],
|
||||||
|
['F10', 'jurnalul de audit cu lantul rupt (hash-ul intrarii schimbat): runtime FAILED audit-log-broken', (b) => { b.runtime.auditLog[0].hash = '0x' + '77'.repeat(32); }, 'runtime', 'audit-log-broken'],
|
||||||
|
];
|
||||||
|
for (const [id, eticheta, muta, nivel, motiv] of fix) {
|
||||||
|
const b = clone(bundle); muta(b);
|
||||||
|
const v = await mod.verifica(b, { verifyProof: VERIFY_PROOF });
|
||||||
|
const ok = v.verdict === 'INVALID' && v.levels[nivel] === 'FAILED' && v.details[nivel].reason === motiv;
|
||||||
|
cer(id, eticheta, ok ? 'VERDE' : 'ROSU', `${v.verdict} (${v.reason}); ${nivel} ${v.levels[nivel]} ${v.details[nivel].reason || ''}`);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const L2 = laborator({ toolAllowed: false });
|
||||||
|
const b = mod.construieste({ ledger: L2.ledger.export(), policy: { policy: L2.policy, policyHash: L2.policyHash }, proofOfAi: L2.proofOfAi, software: { attestation: L2.attestation }, runtime: { record: L2.record, auditLog: L2.auditLog } });
|
||||||
|
const v = await mod.verifica(b, { verifyProof: VERIFY_PROOF });
|
||||||
|
cer('F5', 'unealta nepermisa de politica: intrarea REFUZATA exista, iesirea nu poate fi "permisa" -> permissions FAILED action-refused', !L2.allowed && v.levels.permissions === 'FAILED' && v.details.permissions.reason === 'action-refused' ? 'VERDE' : 'ROSU', `${v.verdict} ${v.details.permissions.reason || ''}`);
|
||||||
|
}
|
||||||
|
if (viu) {
|
||||||
|
// the witness: the ledger head notarized on 28001 with the laboratory test key (gas only), read back under a certified anchor
|
||||||
|
const require = createRequire(path.join(ETHERS_DIR, 'package.json'));
|
||||||
|
const { ethers } = require('ethers');
|
||||||
|
const headEnv = ledgerHeadEnvelope(L.ledger.export());
|
||||||
|
let notarized;
|
||||||
|
// notarizeHead returns { ok, envelope }: the ENVELOPE (with its notarization) is the head the witness level reads
|
||||||
|
try { const r = await notarizeHead({ envelope: headEnv, rpc: RPC_28001, keyFile: CHEIE, ethers }); if (!r || !r.ok) throw new Error('notarizeHead: ' + JSON.stringify(r).slice(0, 160)); notarized = r.envelope; } catch (e) { cer('V1', 'capul registrului notarizat pe 28001', 'STRICAT', String(e.message).slice(0, 160)); return; }
|
||||||
|
cer('V1', 'capul registrului notarizat pe 28001 (cheia de proba L1, numai gaz)', notarized && notarized.notarization ? 'VERDE' : 'ROSU', notarized && notarized.notarization ? `tx ${String(notarized.notarization.txHash).slice(0, 14)}.. block ${notarized.notarization.block}` : JSON.stringify(notarized).slice(0, 120));
|
||||||
|
const bw = mod.construieste({ ledger: L.ledger.export(), policy: { policy: L.policy, policyHash: L.policyHash }, proofOfAi: L.proofOfAi, software: { attestation: L.attestation }, runtime: { record: L.record, auditLog: L.auditLog, head: L.head }, witness: { heads: [notarized], chainId: 28001 } });
|
||||||
|
let vw = null;
|
||||||
|
for (let i = 0; i < 20; i++) { vw = await mod.verifica(bw, { verifyProof: VERIFY_PROOF, rpc: RPC_28001, chain: 28001 }); if (vw.levels.witness !== 'UNMEASURED') break; await new Promise((ok) => setTimeout(ok, 8000)); }
|
||||||
|
cer('V2', 'cu martorul citit sub o ancora certificata (--rpc 28001): witness PASSED, verdict VALID', vw && vw.verdict === 'VALID' && vw.levels.witness === 'PASSED' ? 'VERDE' : (vw && vw.levels.witness === 'UNMEASURED' ? 'STRICAT' : 'ROSU'), vw ? `${vw.verdict} (${vw.reason}); witness ${vw.levels.witness}: ${String(vw.details.witness.detail || vw.details.witness.reason || '').slice(0, 160)}` : 'fara verdict');
|
||||||
|
const vb = await mod.verifica(bw, { verifyProof: VERIFY_PROOF });
|
||||||
|
cer('V3', 'acelasi pachet FARA --rpc: martorul UNMEASURED (witness-needs-rpc), nu PASSED', vb.levels.witness === 'UNMEASURED' && vb.verdict === 'PARTIAL' ? 'VERDE' : 'ROSU', `${vb.verdict} (${vb.reason})`);
|
||||||
|
const d = path.join(RAD, 'tools', 'aere-ai-trust', 'dovezi'); fs.mkdirSync(d, { recursive: true });
|
||||||
|
const f = path.join(d, `lant-28001-${new Date().toISOString().replace(/[:.]/g, '').slice(0, 15)}Z.json`);
|
||||||
|
fs.writeFileSync(f, JSON.stringify({ bundle: bw, verdict: vw }, null, 1)); console.log(' dovada scrisa: ' + f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.includes('--control-negativ')) {
|
||||||
|
const SURSA = fs.readFileSync(path.join(AICI, 'lant-de-incredere.mjs'), 'utf8');
|
||||||
|
const PLANTARI = [
|
||||||
|
{ nume: '(a) identitatea necomparata', de: '/*PAZA:identity*/', cu: "|| String(1) === '1' /*PLANTAT*/", rosii: ['F3'] },
|
||||||
|
{ nume: '(b) artefactul atestat necomparat cu ponderile', de: '/*PAZA:version*/', cu: "|| String(1) === '1' /*PLANTAT*/", rosii: ['F2'] },
|
||||||
|
{ nume: '(c) artefactul rulat necomparat', de: '/*PAZA:runtime*/', cu: "|| String(1) === '1' /*PLANTAT*/", rosii: ['F4'] },
|
||||||
|
{ nume: '(d) ordinea timpilor nejudecata', de: '/*PAZA:order*/', cu: "|| String(1) === '1' /*PLANTAT*/", rosii: ['F6'] },
|
||||||
|
{ nume: '(e) iesirea necautata in registru (prima intrare luata oricum)', de: '/*PAZA:output-in-ledger*/', cu: '|| ledger.entries[0] /*PLANTAT*/', rosii: ['F1'] },
|
||||||
|
// (f) and (g) sit inside a NEGATED condition (`if (!(x) /*PAZA*/)`): the planting that silences the check is `&& false`, not `|| true`
|
||||||
|
// (measured 2026-10-02: `|| true` made the honest rows fail instead of letting the fixture pass)
|
||||||
|
{ nume: '(f) decizia refuzata luata drept permisa', de: '/*PAZA:allowed*/', cu: "&& String(1) === '2' /*PLANTAT*/", rosii: ['F5'] },
|
||||||
|
{ nume: '(g) inregistrarea necautata in jurnalul de audit', de: '/*PAZA:audit*/', cu: "&& String(1) === '2' /*PLANTAT*/", rosii: ['F8'] },
|
||||||
|
{ nume: '(h) controlul controlului: o ancora care nu exista', de: '/*PAZA:nu-exista*/', cu: 'x', rosii: [], asteapta: 'STRICAT' },
|
||||||
|
];
|
||||||
|
let bune = 0;
|
||||||
|
for (const [k, p] of PLANTARI.entries()) {
|
||||||
|
const n = SURSA.split(p.de).length - 1;
|
||||||
|
const copie = path.join(AICI, `_plantat-lant-${process.pid}-${k}.mjs`);
|
||||||
|
let stare, detaliu = '';
|
||||||
|
try {
|
||||||
|
if (n !== 1) { stare = 'STRICAT'; detaliu = `ancora apare de ${n} ori (cerut 1)`; }
|
||||||
|
else {
|
||||||
|
fs.writeFileSync(copie, SURSA.replace(p.de, p.cu));
|
||||||
|
const mod = await import(pathToFileURL(copie).href);
|
||||||
|
const randuri = [];
|
||||||
|
// a probe that THROWS on the planted copy is STRICAT for that planting, not the end of the control
|
||||||
|
try { await proba(mod, (id, eticheta, st, det) => randuri.push({ id, stare: st, det })); } catch (e) { randuri.push({ id: 'proba', stare: 'STRICAT', det: String(e.message).slice(0, 160) }); }
|
||||||
|
const stricate = randuri.filter((x) => x.stare === 'STRICAT'); const rosii = randuri.filter((x) => x.stare === 'ROSU').map((x) => x.id);
|
||||||
|
if (stricate.length) { stare = 'STRICAT'; detaliu = 'proba a cazut: ' + stricate.map((x) => x.id + ' ' + x.det).join(' | ').slice(0, 200); }
|
||||||
|
else { const prezise = p.rosii.every((id) => rosii.includes(id)); const numai = rosii.every((id) => p.rosii.includes(id)); stare = prezise && numai ? 'VERDE' : 'ROSU'; detaliu = `rosii: ${rosii.join(',') || '-'} (prezis ${p.rosii.join(',') || '-'})`; }
|
||||||
|
}
|
||||||
|
} finally { fs.rmSync(copie, { force: true }); }
|
||||||
|
const ok = stare === (p.asteapta || 'VERDE'); if (ok) bune++;
|
||||||
|
console.log(` ${ok ? 'BINE' : 'ESEC'} ${p.nume}: ${stare}${p.asteapta ? ' (prezis ' + p.asteapta + ')' : ''} ${detaliu}`);
|
||||||
|
}
|
||||||
|
const ramase = fs.readdirSync(AICI).filter((f) => f.startsWith('_plantat-lant-'));
|
||||||
|
console.log(`CONTROL: ${bune} din ${PLANTARI.length} cum s-a prezis${ramase.length ? '; copii ramase: ' + ramase.join(',') : ''}`);
|
||||||
|
process.exitCode = bune === PLANTARI.length && !ramase.length ? 0 : 1;
|
||||||
|
} else {
|
||||||
|
const mod = await import('./lant-de-incredere.mjs');
|
||||||
|
let verzi = 0, rosii = 0, stricate = 0;
|
||||||
|
await proba(mod, (id, eticheta, stare, det) => { if (stare === 'VERDE') verzi++; else if (stare === 'ROSU') rosii++; else stricate++; console.log(` [${stare.padEnd(7)}] ${id.padEnd(4)} ${eticheta}${det ? ' ' + det : ''}`); }, { viu: process.argv.includes('--viu') });
|
||||||
|
console.log(`\nlantul de incredere pentru AI: ${verzi} VERDE, ${rosii} ROSU, ${stricate} STRICAT`);
|
||||||
|
process.exitCode = rosii || stricate ? 1 : 0;
|
||||||
|
}
|
||||||
18
proof-of-ai/README.md
Normal file
18
proof-of-ai/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# AERE Proof of AI (roadmap B2 / #38)
|
||||||
|
|
||||||
|
Un plic AERE Proof Protocol (AIP-23) care dovedeste provenienta unei actiuni de agent AI - ce model, ce versiune, ce prompt, ce
|
||||||
|
unelte, ce iesire, ce agent, cine a cerut - verificat de ACELASI verificator ca orice alt proof AERE, fara niciun cod nou de
|
||||||
|
verificare. Notarizarea si finalitatea post-cuantica vin din AIP-23 (statementHash notarizat pe AereNotary, acoperit de o ancora PQ).
|
||||||
|
|
||||||
|
```
|
||||||
|
import { buildProofOfAi } from './proof-of-ai.mjs';
|
||||||
|
const plic = buildProofOfAi({ model:{name,version,weightsSha256}, prompt, input, output, tools:[...], agentId, requester, createdAt });
|
||||||
|
// -> plic AIP-23; se verifica cu tools/aere-proof-protocol/verify.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Plicul tine numai DIGESTURI (promptul, intrarea, iesirea, cine a cerut se stocheaza ca SHA-256, nu brut). Se aliniaza cu contractul
|
||||||
|
de pe lant `contracts/contracts/compliance/AereAIProof.sol` (modelId, inputHash, outputHash, requesterHash), dar e PORTABIL: exista
|
||||||
|
fara lant, il cere doar pentru finalitate.
|
||||||
|
|
||||||
|
Proba: `node proba-proof-of-ai.mjs` (6/6, cu control negativ: iesire schimbata dupa hash -> INVALID). Teza demonstrata: un standard,
|
||||||
|
multe feluri de dovada - acelasi verificator, un fel de proof nou, zero cod nou.
|
||||||
61
proof-of-ai/proba-proof-of-ai.mjs
Normal file
61
proof-of-ai/proba-proof-of-ai.mjs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
// Proba Proof-of-AI (B2): construieste un plic, il verifica cu ACELASI verificator AIP-23 (fara cod nou de verificare), si un
|
||||||
|
// CONTROL NEGATIV: un plic caruia i se atinge statement-ul dupa hash pica la integritate. Dovedeste teza "un standard, multe feluri".
|
||||||
|
// node proba-proof-of-ai.mjs -> 0 toate cum trebuia, 1 altfel
|
||||||
|
|
||||||
|
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';
|
||||||
|
import { buildProofOfAi, KIND } from './proof-of-ai.mjs';
|
||||||
|
|
||||||
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
// Verificatorul AIP-23: AERE_VERIFY_PROOF=<verify-proof.mjs> (copia publica, ca la agents/) sau, in depozitul de dezvoltare,
|
||||||
|
// ../aere-proof-protocol/verify.mjs; fara el proba nu poate masura nimic si iese 2 (NEMASURAT), nu verde
|
||||||
|
const VERIFY = process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', 'aere-proof-protocol', 'verify.mjs');
|
||||||
|
if (!fs.existsSync(VERIFY)) { console.log(`NEMASURAT: verificatorul AIP-23 nu e la ${VERIFY}; AERE_VERIFY_PROOF=<verify-proof.mjs>`); process.exit(2); }
|
||||||
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'poai-'));
|
||||||
|
let rele = 0;
|
||||||
|
const cer = (n, c) => { console.log(` [${c ? 'OK ' : 'RAU '}] ${n}`); if (!c) rele++; };
|
||||||
|
function verif(plicPath) {
|
||||||
|
try { return { cod: 0, out: execFileSync(process.execPath, [VERIFY, plicPath, '--json'], { encoding: 'utf8' }) }; }
|
||||||
|
catch (e) { return { cod: e.status ?? 2, out: (e.stdout || '') + (e.stderr || '') }; }
|
||||||
|
}
|
||||||
|
const verdict = (o) => { try { return JSON.parse(o).verdict; } catch { return '?'; } };
|
||||||
|
// verificatorul AIP-23 e in ENGLEZA (public-ready, D-402/B4): niveluri->levels, nivel->level, stare->state, TRECUT->PASSED, PICAT->FAILED
|
||||||
|
const nivel = (o, n) => { try { return JSON.parse(o).levels.find((x) => x.level === n).state; } catch { return '?'; } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const plic = buildProofOfAi({
|
||||||
|
model: { name: 'aere-fable', version: '5.1', weightsSha256: '0x' + '22'.repeat(32) },
|
||||||
|
prompt: 'rezuma documentul X',
|
||||||
|
input: 'documentul X, 12kb',
|
||||||
|
output: 'rezumatul: ...',
|
||||||
|
tools: ['retrieval', 'calculator'],
|
||||||
|
agentId: '0xa11ce00000000000000000000000000000000001',
|
||||||
|
requester: 'client-42',
|
||||||
|
createdAt: '2026-09-26T09:00:00.000Z',
|
||||||
|
});
|
||||||
|
cer('plicul are kind aere-proof-of-ai-attestation', plic.kind === KIND + '-attestation');
|
||||||
|
cer('statement-ul are model, promptHash, inputHash, outputHash, tools', !!(plic.statement.model && plic.statement.promptHash && plic.statement.inputHash && plic.statement.outputHash && Array.isArray(plic.statement.tools)));
|
||||||
|
cer('nu contine continut brut (doar digesturi)', !JSON.stringify(plic).includes('rezuma documentul X') && !JSON.stringify(plic).includes('rezumatul: ...'));
|
||||||
|
|
||||||
|
const f = path.join(T, 'ai.json'); fs.writeFileSync(f, JSON.stringify(plic, null, 1));
|
||||||
|
const r = verif(f);
|
||||||
|
cer('verificatorul AIP-23 il valideaza (fara cod nou)', verdict(r.out) === 'VALID' && nivel(r.out, 'integrity') === 'PASSED' && r.cod === 0);
|
||||||
|
|
||||||
|
// CONTROL NEGATIV: schimb outputHash dupa ce statementHash e fixat -> integritate PICAT
|
||||||
|
const rau = JSON.parse(JSON.stringify(plic)); rau.statement.outputHash = '0x' + '33'.repeat(32);
|
||||||
|
const fr = path.join(T, 'ai-rau.json'); fs.writeFileSync(fr, JSON.stringify(rau, null, 1));
|
||||||
|
const rr = verif(fr);
|
||||||
|
cer('CONTROL: iesire schimbata dupa hash -> INVALID', verdict(rr.out) === 'INVALID' && nivel(rr.out, 'integrity') === 'FAILED' && rr.cod === 1);
|
||||||
|
|
||||||
|
// acelasi statement -> acelasi hash (determinist)
|
||||||
|
const plic2 = buildProofOfAi({ model: { name: 'aere-fable', version: '5.1', weightsSha256: '0x' + '22'.repeat(32) }, prompt: 'rezuma documentul X', input: 'documentul X, 12kb', output: 'rezumatul: ...', tools: ['retrieval', 'calculator'], agentId: '0xa11ce00000000000000000000000000000000001', requester: 'client-42', createdAt: '2026-09-26T09:00:00.000Z' });
|
||||||
|
cer('determinist: acelasi continut -> acelasi statementHash', plic2.statementHash === plic.statementHash);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(T, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
console.log(`\nB2 proof-of-ai: ${6 - rele}/6 cum trebuia`);
|
||||||
|
process.exit(rele ? 1 : 0);
|
||||||
52
proof-of-ai/proof-of-ai.mjs
Normal file
52
proof-of-ai/proof-of-ai.mjs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
'use strict';
|
||||||
|
// AERE Proof of AI (roadmap B2 / #38): un plic AERE Proof Protocol (AIP-23) care dovedeste provenienta unei actiuni de agent AI -
|
||||||
|
// ce model, ce versiune, ce prompt, ce unelte, ce iesire, ce agent, cine a cerut - astfel incat un tert sa verifice fara sa aiba
|
||||||
|
// incredere in producator, cu ACELASI verificator ca orice alt proof AERE (tools/aere-proof-protocol/verify.mjs). Nu adauga niciun
|
||||||
|
// cod nou de verificare: asta e chiar teza standardului ("un plic, multe feluri de dovada"). Notarizarea + finalitatea post-cuantica
|
||||||
|
// vin gratis din AIP-23 (statementHash notarizat pe AereNotary, acoperit de o ancora PQ). Numai Node 24, node:crypto.
|
||||||
|
//
|
||||||
|
// Se aliniaza cu contractul de pe lant AereAIProof.sol (modelId, inputHash, outputHash, requesterHash): campurile de mai jos se pot
|
||||||
|
// mapa la o atestare on-chain, dar plicul e PORTABIL (nu cere lantul ca sa existe; il cere doar ca sa capete finalitate).
|
||||||
|
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export const KIND = 'aere-proof-of-ai';
|
||||||
|
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
||||||
|
const hexOf = (v) => {
|
||||||
|
if (v == null) return null;
|
||||||
|
if (typeof v === 'string' && /^0x[0-9a-fA-F]{64}$/.test(v)) return v.toLowerCase(); // deja un digest
|
||||||
|
return sha256(Buffer.from(typeof v === 'string' ? v : JSON.stringify(v), 'utf8'));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construieste un plic Proof-of-AI (AIP-23). Nu tine niciun continut sensibil: doar digesturi.
|
||||||
|
* @param {object} p
|
||||||
|
* @param {{name:string, version?:string, weightsSha256?:string}} p.model
|
||||||
|
* @param {string} [p.prompt] promptul brut (se stocheaza doar digestul) SAU
|
||||||
|
* @param {string} [p.promptHash] digestul promptului (0x+64) daca nu vrei sa-l dai brut
|
||||||
|
* @param {string} [p.input] intrarea (digest stocat) SAU p.inputHash
|
||||||
|
* @param {string} [p.output] iesirea (digest stocat) SAU p.outputHash
|
||||||
|
* @param {string[]} [p.tools] uneltele pe care agentul avea voie sa le foloseasca / le-a folosit
|
||||||
|
* @param {string} [p.agentId] identitatea agentului (adresa / id)
|
||||||
|
* @param {string} [p.requester] cine a cerut (digest stocat) SAU p.requesterHash
|
||||||
|
* @param {string} p.createdAt RFC3339
|
||||||
|
* @returns {object} plicul AIP-23 { v, kind, statement, statementHash }
|
||||||
|
*/
|
||||||
|
export function buildProofOfAi(p) {
|
||||||
|
if (!p || !p.model || !p.model.name) throw new Error('proof-of-ai: model.name e obligatoriu');
|
||||||
|
if (!p.createdAt) throw new Error('proof-of-ai: createdAt e obligatoriu (RFC3339)');
|
||||||
|
const statement = {
|
||||||
|
v: 1,
|
||||||
|
kind: KIND,
|
||||||
|
model: { name: p.model.name, version: p.model.version || null, weightsSha256: p.model.weightsSha256 || null },
|
||||||
|
promptHash: p.promptHash ? p.promptHash.toLowerCase() : hexOf(p.prompt),
|
||||||
|
inputHash: p.inputHash ? p.inputHash.toLowerCase() : hexOf(p.input),
|
||||||
|
outputHash: p.outputHash ? p.outputHash.toLowerCase() : hexOf(p.output),
|
||||||
|
tools: Array.isArray(p.tools) ? [...p.tools] : [],
|
||||||
|
agentId: p.agentId || null,
|
||||||
|
requesterHash: p.requesterHash ? p.requesterHash.toLowerCase() : hexOf(p.requester),
|
||||||
|
createdAt: p.createdAt,
|
||||||
|
};
|
||||||
|
const text = JSON.stringify(statement); // canonic AIP-23: ordinea de inserare, SHA-256
|
||||||
|
return { v: 1, kind: KIND + '-attestation', statement, statementHash: sha256(Buffer.from(text, 'utf8')) };
|
||||||
|
}
|
||||||
63
verifiable-data/README.md
Normal file
63
verifiable-data/README.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# AERE Verifiable Data
|
||||||
|
|
||||||
|
A dataset whose every answer can be checked against a short commitment, by anyone, without the data and without trusting the server.
|
||||||
|
Built from pieces that already exist beside it: the RFC 9162 Merkle tree of the verification layer (`verify-layer/arbore-merkle.mjs`),
|
||||||
|
the AIP-23 `data` envelope (`proof-kinds/`), the notary call of the agents (`agents/agent-ancora.mjs`) and the AIP-23 reference
|
||||||
|
verifier for the time of a version under a post-quantum certified anchor.
|
||||||
|
|
||||||
|
| what | how it is proven | with |
|
||||||
|
|---|---|---|
|
||||||
|
| a record is in the dataset at a version | inclusion proof in the version's log (RFC 9162, log2(n) hashes) | `verifica.mjs inregistrare` |
|
||||||
|
| a newer version only appended | consistency proof between the two log roots; the newer commitment names the older as `previous` | `verifica.mjs consistenta` |
|
||||||
|
| a query answer is correct AND complete | every row proves itself in the log and at its position in the SORTED index of the queried field; the rows are one contiguous run; the neighbour before the run is below the range and the one after is above it, each with its proof | `verifica.mjs raspuns` |
|
||||||
|
| a row reveals only what was asked | every field is committed as SHA-256(salt \| field \| value) in the record leaf; undisclosed fields stay hidden, disclosed ones cannot change | the same |
|
||||||
|
| the publisher serves what it committed to | availability sampling: random positions fetched and verified against the commitment; a withheld record fails the sample that lands on it | `verifica.mjs disponibilitate` |
|
||||||
|
| when a version existed | the commitment's AIP-23 envelope notarized on AereNotary and read back under a post-quantum certified anchor (`finality` of the reference verifier) | `verifica.mjs angajament --rpc` |
|
||||||
|
|
||||||
|
```
|
||||||
|
node vd.mjs creeaza ./set --nume users --index country,age
|
||||||
|
node vd.mjs adauga ./set records.ndjson # {"id":"u1","country":"RO","age":31,"email":"..."} per line
|
||||||
|
node vd.mjs angajeaza ./set --out c1.json # version 1: commitment + plicuri/1.json (its AIP-23 envelope)
|
||||||
|
node vd.mjs interogheaza ./set 1 --camp country --eq '"RO"' --campuri age --out a.json
|
||||||
|
node verifica.mjs raspuns a.json --angajament c1.json # VALID (n rows) | INVALID + reasons
|
||||||
|
node vd.mjs serveste ./set --port 8797 # GET /v1/vd/users/{commitments|record/1/7|query/1?field=age&from=30&to=40|consistency/1/2}
|
||||||
|
node verifica.mjs disponibilitate --url http://127.0.0.1:8797/v1/vd/users --angajament c1.json --esantioane 32
|
||||||
|
```
|
||||||
|
|
||||||
|
A commitment is `{ name, version, size, logRoot, indexes: { field: { root, size } }, createdAt, previous }`; its hash is the SHA-256 of its
|
||||||
|
canonical JSON, and that hash is the `sha256` of the version's `aere-proof-of-data` envelope. Records are flat JSON objects with a
|
||||||
|
string `id`; values are strings, finite numbers, booleans or null (an index needs a total order: by type, then numerically or by code
|
||||||
|
unit, in one function both sides use). A dataset appends; it never rewrites.
|
||||||
|
|
||||||
|
## What it is not, and what is not measured
|
||||||
|
|
||||||
|
- **Not zero knowledge.** Selective disclosure here is hash-based: the reader learns the disclosed values and the commitments of the
|
||||||
|
rest. A proof ABOUT rows without revealing them (a count, a sum, membership in a set, "some row satisfies P") needs a proving system;
|
||||||
|
the one this network verifies on chain (SP1, Groth16 over BN254) needs about 24 GiB to prove, measured 2026-09-26, and no such
|
||||||
|
circuit is built. Until it is, a "ZK query" is not offered, and this README says so instead of calling disclosure zero knowledge.
|
||||||
|
- **Completeness rests on the index being sorted.** A single answer proves its rows and its two neighbours against the committed index
|
||||||
|
root; that no matching row was omitted follows only if the index is in order, which the reader checks locally on the rows returned
|
||||||
|
(an unsorted pair is refused) and which anyone holding the data checks in full with `vd.mjs auditeaza`. A publisher that commits an
|
||||||
|
unsorted index is caught by the first answer that shows two rows out of order, or by an audit, not by a one-row answer.
|
||||||
|
- **Availability is retrievability under sampling, not a data-availability scheme.** k verified samples make a publisher that hides a
|
||||||
|
share s of its records survive with probability (1 - s)^k; there is no erasure coding and no network of samplers.
|
||||||
|
- **The time of a version is the chain's only when notarized**; an unnotarized commitment carries the publisher's `createdAt`.
|
||||||
|
- **Not storage.** The records live where the publisher keeps them (a folder here); this component commits, proves and serves, it does
|
||||||
|
not replicate, encrypt or back up.
|
||||||
|
- Index trees are rebuilt per version (sorted); consistency proofs cover the LOG only. Queries on fields that are not indexed are not
|
||||||
|
offered. Field values above 4096 characters, arrays and nested objects are refused.
|
||||||
|
|
||||||
|
## Probes
|
||||||
|
|
||||||
|
```
|
||||||
|
AERE_VERIFY_PROOF=<verify-proof.mjs from aere-node> node proba-verifiable-data.mjs # 22 rows: honest answers VALID, nine forged ones INVALID
|
||||||
|
# for their named reason, sampling on a local server
|
||||||
|
AERE_VERIFY_PROOF=... node proba-verifiable-data.mjs --control-negativ # 5 plantings on a copy of baza.mjs; each must make its
|
||||||
|
# forged fixture pass, which proves the check is load-bearing
|
||||||
|
AERE_VERIFY_PROOF=... AERE_NOTARY_KEY_FILE=<key> AERE_ETHERS_DIR=<dir with node_modules/ethers> node proba-verifiable-data.mjs --viu
|
||||||
|
# plus a notarization on testnet 28001 (a test key) read back
|
||||||
|
# under a post-quantum certified anchor; evidence in dovezi/
|
||||||
|
```
|
||||||
|
|
||||||
|
Without the AIP-23 verifier the envelope row is reported NEMASURAT and the exit code is 2. The probe never touches chain 2800; the notary
|
||||||
|
call refuses 2800 unless `AERE_CONFIRM_MAINNET=yes`.
|
||||||
272
verifiable-data/baza.mjs
Normal file
272
verifiable-data/baza.mjs
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
// baza.mjs: the verifiable dataset (roadmap master point 20, pista B, 2026-10-02). A dataset is an append-only list of records; a
|
||||||
|
// VERSION is a commitment to it: the RFC 9162 Merkle root of the record leaves (the log, `../verify-layer/arbore-merkle.mjs`), its size,
|
||||||
|
// and for each indexed field the root of a SORTED index tree over (value, record). From a commitment anyone can verify, without the data:
|
||||||
|
// - a record is in the dataset at a version (inclusion proof in the log, log2(n) hashes);
|
||||||
|
// - a newer version only APPENDED (consistency proof between the two log roots): a version that is not a prefix of the next is a
|
||||||
|
// rewritten history;
|
||||||
|
// - a query answer (equality or range on an indexed field) is COMPLETE and CORRECT: every returned row proves itself in the log and in
|
||||||
|
// the index, the rows are the contiguous run of the sorted index, and the two neighbours outside the range are shown with their
|
||||||
|
// proofs, so no matching row could have been left out; the completeness rests on the index being sorted, which anyone holding the
|
||||||
|
// data audits in full (`auditeaza`) and no single query can;
|
||||||
|
// - a row reveals only the fields the query asked for (selective disclosure): every field is committed as
|
||||||
|
// SHA-256(salt | field | value) in the record leaf, so an undisclosed field stays hidden and a disclosed one cannot be changed. This
|
||||||
|
// is hash-based disclosure, NOT zero knowledge: the verifier learns the disclosed values; a proof about rows without revealing them
|
||||||
|
// (counts, sums, membership in a set) needs a proving system and is not built here (see README).
|
||||||
|
// The commitment itself is an AIP-23 `data` envelope (proof-kinds), notarizable on AereNotary and read back under a post-quantum
|
||||||
|
// certified anchor by the reference verifier: the TIME of a version is then the chain's, not the publisher's.
|
||||||
|
// Only node:crypto and the Merkle tree of the verification layer. Records: JSON objects with a string `id` (unique) and flat fields
|
||||||
|
// whose values are strings, finite numbers, booleans or null; arrays and nested objects are refused (an index needs a total order).
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { leafHash, merkleTree, verifyInclusion, verifyConsistency } from '../verify-layer/arbore-merkle.mjs';
|
||||||
|
import { buildProof } from '../proof-kinds/proof-kinds.mjs';
|
||||||
|
|
||||||
|
export const FORMAT = 'aere-verifiable-dataset-1';
|
||||||
|
export const KIND_RASPUNS = 'aere-verifiable-query-answer';
|
||||||
|
export const KIND_INREGISTRARE = 'aere-verifiable-record';
|
||||||
|
export const KIND_CONSISTENTA = 'aere-verifiable-consistency';
|
||||||
|
const H = /^0x[0-9a-f]{64}$/;
|
||||||
|
const sha256 = (b) => '0x' + crypto.createHash('sha256').update(b).digest('hex');
|
||||||
|
const utf8 = (s) => Buffer.from(s, 'utf8');
|
||||||
|
|
||||||
|
/** Canonical JSON: keys sorted at every depth, no whitespace; refuses what JSON cannot carry faithfully. */
|
||||||
|
export function canonical(x) {
|
||||||
|
if (x === null) return 'null';
|
||||||
|
if (typeof x === 'string') return JSON.stringify(x);
|
||||||
|
if (typeof x === 'boolean') return x ? 'true' : 'false';
|
||||||
|
if (typeof x === 'number') { if (!Number.isFinite(x)) throw new Error('canonical: a number must be finite'); return JSON.stringify(x); }
|
||||||
|
if (Array.isArray(x)) return '[' + x.map(canonical).join(',') + ']';
|
||||||
|
if (typeof x === 'object') return '{' + Object.keys(x).sort().map((k) => { if (x[k] === undefined) throw new Error('canonical: undefined under ' + k); return JSON.stringify(k) + ':' + canonical(x[k]); }).join(',') + '}';
|
||||||
|
throw new Error('canonical: unsupported value of type ' + typeof x);
|
||||||
|
}
|
||||||
|
const tip = (v) => v === null ? 'null' : typeof v;
|
||||||
|
const TIPURI = ['boolean', 'null', 'number', 'string'];
|
||||||
|
/** Total order on field values: by type name, then by value (numbers numerically, strings by code unit). One place, used by both sides. */
|
||||||
|
export function comparaValori(a, b) {
|
||||||
|
const ta = tip(a), tb = tip(b);
|
||||||
|
if (!TIPURI.includes(ta) || !TIPURI.includes(tb)) throw new Error('compare: a field value must be a string, a finite number, a boolean or null');
|
||||||
|
if (ta !== tb) return ta < tb ? -1 : 1;
|
||||||
|
if (ta === 'number') return a < b ? -1 : a > b ? 1 : 0;
|
||||||
|
if (ta === 'boolean') return a === b ? 0 : a ? 1 : -1;
|
||||||
|
const sa = String(a), sb = String(b); return sa < sb ? -1 : sa > sb ? 1 : 0;
|
||||||
|
}
|
||||||
|
export const angajamentCamp = (field, value, salt) => sha256(utf8(String(salt) + '|' + field + '|' + canonical(value)));
|
||||||
|
export const frunzaInregistrare = (id, c) => leafHash(utf8(canonical({ id, c })));
|
||||||
|
export const frunzaIndex = (field, value, i, recordLeaf) => leafHash(utf8(canonical({ field, value, i, recordLeaf })));
|
||||||
|
export const hashAngajament = (commitment) => sha256(utf8(canonical(commitment)));
|
||||||
|
|
||||||
|
function valideazaInregistrare(r) {
|
||||||
|
if (!r || typeof r !== 'object' || Array.isArray(r)) throw new Error('a record is a JSON object');
|
||||||
|
if (typeof r.id !== 'string' || !r.id.length || r.id.length > 256) throw new Error('a record needs a string id (1..256 chars)');
|
||||||
|
const fields = r.fields && typeof r.fields === 'object' ? r.fields : Object.fromEntries(Object.entries(r).filter(([k]) => k !== 'id'));
|
||||||
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
|
if (!/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(k)) throw new Error('field name "' + k + '" is not [A-Za-z_][A-Za-z0-9_]{0,63}');
|
||||||
|
if (!TIPURI.includes(tip(v)) || (typeof v === 'number' && !Number.isFinite(v))) throw new Error('field "' + k + '" must be a string, a finite number, a boolean or null');
|
||||||
|
if (typeof v === 'string' && v.length > 4096) throw new Error('field "' + k + '" is longer than 4096 chars');
|
||||||
|
}
|
||||||
|
return { id: r.id, fields };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a dataset folder: set.json (name, indexed fields), an empty record file, no versions. */
|
||||||
|
export function creeaza(dir, { name, indexes = [] }) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(String(name))) throw new Error('dataset name: [a-z0-9][a-z0-9-]{0,63}');
|
||||||
|
if (!Array.isArray(indexes) || indexes.some((f) => !/^[A-Za-z_][A-Za-z0-9_]{0,63}$/.test(f))) throw new Error('indexes: a list of field names');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
if (fs.existsSync(path.join(dir, 'set.json'))) throw new Error('the dataset already exists at ' + dir);
|
||||||
|
fs.writeFileSync(path.join(dir, 'set.json'), JSON.stringify({ format: FORMAT, name, indexes: [...new Set(indexes)].sort() }, null, 1));
|
||||||
|
fs.writeFileSync(path.join(dir, 'inregistrari.ndjson'), '');
|
||||||
|
fs.writeFileSync(path.join(dir, 'versiuni.json'), '[]');
|
||||||
|
return deschide(dir);
|
||||||
|
}
|
||||||
|
export function deschide(dir) {
|
||||||
|
const set = JSON.parse(fs.readFileSync(path.join(dir, 'set.json'), 'utf8'));
|
||||||
|
if (set.format !== FORMAT) throw new Error('not a ' + FORMAT + ' dataset: ' + dir);
|
||||||
|
const text = fs.readFileSync(path.join(dir, 'inregistrari.ndjson'), 'utf8');
|
||||||
|
const inregistrari = text.split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||||
|
const versiuni = JSON.parse(fs.readFileSync(path.join(dir, 'versiuni.json'), 'utf8'));
|
||||||
|
return { dir, set, inregistrari, versiuni, _cache: new Map() };
|
||||||
|
}
|
||||||
|
/** Appends records (never rewrites): each gets a fresh 16-byte salt per field; ids must be new. */
|
||||||
|
export function adauga(db, records) {
|
||||||
|
const ids = new Set(db.inregistrari.map((r) => r.id)); const noi = [];
|
||||||
|
for (const r of records) {
|
||||||
|
const { id, fields } = valideazaInregistrare(r);
|
||||||
|
if (ids.has(id)) throw new Error('duplicate id "' + id + '" (a dataset appends; it never rewrites a record)');
|
||||||
|
ids.add(id);
|
||||||
|
const salts = Object.fromEntries(Object.keys(fields).map((k) => [k, crypto.randomBytes(16).toString('hex')]));
|
||||||
|
noi.push({ id, fields, salts });
|
||||||
|
}
|
||||||
|
fs.appendFileSync(path.join(db.dir, 'inregistrari.ndjson'), noi.map((r) => JSON.stringify(r) + '\n').join(''));
|
||||||
|
db.inregistrari.push(...noi); db._cache.clear();
|
||||||
|
return noi.length;
|
||||||
|
}
|
||||||
|
const angajamente = (r) => Object.fromEntries(Object.keys(r.fields).sort().map((k) => [k, angajamentCamp(k, r.fields[k], r.salts[k])]));
|
||||||
|
/** The trees of a version of `size` records: the log and one sorted index per indexed field (memoized per size). */
|
||||||
|
function arbori(db, size) {
|
||||||
|
if (db._cache.has(size)) return db._cache.get(size);
|
||||||
|
if (!Number.isInteger(size) || size < 1 || size > db.inregistrari.length) throw new Error('version size ' + size + ' outside 1..' + db.inregistrari.length);
|
||||||
|
const recs = db.inregistrari.slice(0, size).map((r, i) => { const c = angajamente(r); return { i, id: r.id, c, leaf: frunzaInregistrare(r.id, c) }; });
|
||||||
|
const log = merkleTree(recs.map((r) => r.leaf));
|
||||||
|
const index = {};
|
||||||
|
for (const f of db.set.indexes) {
|
||||||
|
const intrari = recs.filter((r) => f in db.inregistrari[r.i].fields).map((r) => ({ value: db.inregistrari[r.i].fields[f], i: r.i, recordLeaf: r.leaf }));
|
||||||
|
intrari.sort((a, b) => comparaValori(a.value, b.value) || a.i - b.i);
|
||||||
|
index[f] = { intrari, tree: merkleTree(intrari.map((e) => frunzaIndex(f, e.value, e.i, e.recordLeaf))) };
|
||||||
|
}
|
||||||
|
const t = { recs, log, index }; db._cache.set(size, t); return t;
|
||||||
|
}
|
||||||
|
/** Commits the current records as a new version; returns the commitment (also appended to versiuni.json). */
|
||||||
|
export function angajeaza(db, { createdAt = new Date().toISOString() } = {}) {
|
||||||
|
const size = db.inregistrari.length; if (!size) throw new Error('nothing to commit: the dataset is empty');
|
||||||
|
const prev = db.versiuni[db.versiuni.length - 1] || null;
|
||||||
|
if (prev && prev.size === size) throw new Error('nothing new since version ' + prev.version + ' (' + size + ' records)');
|
||||||
|
const t = arbori(db, size);
|
||||||
|
const commitment = { v: 1, format: FORMAT, name: db.set.name, version: db.versiuni.length + 1, size, logRoot: t.log.root(), createdAt,
|
||||||
|
indexes: Object.fromEntries(db.set.indexes.map((f) => [f, { root: t.index[f].tree.root(), size: t.index[f].intrari.length }])),
|
||||||
|
previous: prev ? hashAngajament(prev) : null };
|
||||||
|
db.versiuni.push(commitment);
|
||||||
|
fs.writeFileSync(path.join(db.dir, 'versiuni.json'), JSON.stringify(db.versiuni, null, 1));
|
||||||
|
return commitment;
|
||||||
|
}
|
||||||
|
export function versiune(db, n) { const c = db.versiuni.find((x) => x.version === Number(n)); if (!c) throw new Error('no version ' + n); return c; }
|
||||||
|
const fara = (fields, care) => care === '*' ? { ...fields } : Object.fromEntries(Object.entries(fields).filter(([k]) => care.includes(k)));
|
||||||
|
const dezvaluie = (r, care) => Object.fromEntries(Object.keys(fara(r.fields, care)).map((k) => [k, { value: r.fields[k], salt: r.salts[k] }]));
|
||||||
|
const dovada = (p) => ({ index: p.index, treeSize: p.treeSize, path: p.path });
|
||||||
|
/** One record at a version, with its log inclusion proof; `fields` = '*' (all) or a list of field names to disclose. */
|
||||||
|
export function dovadaInregistrare(db, n, i, { fields = '*' } = {}) {
|
||||||
|
const c = versiune(db, n); const t = arbori(db, c.size); const r = t.recs[i]; if (!r) throw new Error('record ' + i + ' outside version ' + n + ' (' + c.size + ' records)');
|
||||||
|
return { v: 1, kind: KIND_INREGISTRARE, name: c.name, version: c.version, commitmentHash: hashAngajament(c), i, id: r.id, c: r.c, disclosed: dezvaluie(db.inregistrari[i], fields), logProof: dovada(t.log.inclusionProof(i, c.size)) };
|
||||||
|
}
|
||||||
|
/** Equality (eq) or range (from..to, inclusive, either side open) on an indexed field; `fields` = the fields to disclose besides it. */
|
||||||
|
export function interogheaza(db, n, { field, eq, from, to, fields = [], limit = 1000 } = {}) {
|
||||||
|
const c = versiune(db, n); if (!c.indexes[field]) throw new Error('field "' + field + '" is not indexed in ' + c.name);
|
||||||
|
if (eq !== undefined) { from = eq; to = eq; }
|
||||||
|
if (from !== undefined && to !== undefined && comparaValori(from, to) > 0) throw new Error('from is above to');
|
||||||
|
const t = arbori(db, c.size); const ix = t.index[field]; const e = ix.intrari;
|
||||||
|
let start = 0; while (start < e.length && from !== undefined && comparaValori(e[start].value, from) < 0) start++;
|
||||||
|
let end = start; while (end < e.length && (to === undefined || comparaValori(e[end].value, to) <= 0)) end++;
|
||||||
|
if (end - start > limit) throw new Error('the answer would have ' + (end - start) + ' rows, above the limit ' + limit);
|
||||||
|
const care = fields === '*' ? '*' : [...new Set([field, ...fields])];
|
||||||
|
const rows = e.slice(start, end).map((x, k) => ({ pos: start + k, i: x.i, id: t.recs[x.i].id, c: t.recs[x.i].c, disclosed: dezvaluie(db.inregistrari[x.i], care),
|
||||||
|
logProof: dovada(t.log.inclusionProof(x.i, c.size)), indexProof: dovada(ix.tree.inclusionProof(start + k, e.length)) }));
|
||||||
|
const vecin = (pos) => pos < 0 || pos >= e.length ? null : ({ pos, value: e[pos].value, i: e[pos].i, recordLeaf: e[pos].recordLeaf, indexProof: dovada(ix.tree.inclusionProof(pos, e.length)) });
|
||||||
|
return { v: 1, kind: KIND_RASPUNS, name: c.name, version: c.version, commitmentHash: hashAngajament(c), field, from: from === undefined ? null : from, to: to === undefined ? null : to,
|
||||||
|
indexSize: e.length, rows, before: vecin(start - 1), after: vecin(end) };
|
||||||
|
}
|
||||||
|
/** The log of version a is a prefix of the log of version b (RFC 9162 consistency). */
|
||||||
|
export function dovadaConsistenta(db, a, b) {
|
||||||
|
const ca = versiune(db, a), cb = versiune(db, b); if (ca.size > cb.size) throw new Error('version ' + a + ' is larger than version ' + b);
|
||||||
|
const t = arbori(db, cb.size);
|
||||||
|
return { v: 1, kind: KIND_CONSISTENTA, name: cb.name, from: { version: ca.version, commitmentHash: hashAngajament(ca) }, to: { version: cb.version, commitmentHash: hashAngajament(cb) }, proof: t.log.consistencyProof(ca.size, cb.size) };
|
||||||
|
}
|
||||||
|
/** Rebuilds every version from the records and compares the roots: the full audit anyone holding the data runs. */
|
||||||
|
export function auditeaza(db) {
|
||||||
|
const probleme = [];
|
||||||
|
for (const c of db.versiuni) {
|
||||||
|
try { const t = arbori(db, c.size); if (t.log.root() !== c.logRoot) probleme.push('version ' + c.version + ': log root differs');
|
||||||
|
for (const f of Object.keys(c.indexes)) { if (!t.index[f] || t.index[f].tree.root() !== c.indexes[f].root || t.index[f].intrari.length !== c.indexes[f].size) probleme.push('version ' + c.version + ': index ' + f + ' differs'); }
|
||||||
|
} catch (e) { probleme.push('version ' + c.version + ': ' + e.message); }
|
||||||
|
}
|
||||||
|
for (let k = 1; k < db.versiuni.length; k++) if (db.versiuni[k].previous !== hashAngajament(db.versiuni[k - 1])) probleme.push('version ' + db.versiuni[k].version + ' does not name version ' + db.versiuni[k - 1].version + ' as previous');
|
||||||
|
return { ok: probleme.length === 0, probleme };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- verification, without the data (what a reader runs on a commitment and an answer) -----
|
||||||
|
const ok = (motive, cond, m) => { if (!cond) motive.push(m); return cond; };
|
||||||
|
function verificaRand(r, commitment, field, motive, eticheta) {
|
||||||
|
if (!r || typeof r !== 'object' || !r.c || typeof r.c !== 'object' || !r.disclosed) return ok(motive, false, eticheta + ': malformed row');
|
||||||
|
for (const [k, d] of Object.entries(r.disclosed)) {
|
||||||
|
if (!d || typeof d.salt !== 'string') return ok(motive, false, eticheta + ': disclosed field ' + k + ' without a salt');
|
||||||
|
const okCamp = r.c[k] === angajamentCamp(k, d.value, d.salt) /*PAZA:camp*/;
|
||||||
|
if (!ok(motive, okCamp, eticheta + ': the disclosed value of "' + k + '" does not match its commitment')) return false;
|
||||||
|
}
|
||||||
|
const leaf = frunzaInregistrare(r.id, r.c);
|
||||||
|
if (!ok(motive, r.logProof && verifyInclusion({ ...r.logProof, leafHash: leaf, treeSize: commitment.size, rootHash: commitment.logRoot }) && r.logProof.index === r.i, eticheta + ': the record is not in the log of version ' + commitment.version)) return false;
|
||||||
|
if (field !== null) {
|
||||||
|
if (!ok(motive, r.disclosed[field] !== undefined, eticheta + ': the queried field "' + field + '" is not disclosed')) return false;
|
||||||
|
const ix = commitment.indexes[field];
|
||||||
|
const okIndex = r.indexProof && verifyInclusion({ ...r.indexProof, leafHash: frunzaIndex(field, r.disclosed[field].value, r.i, leaf), treeSize: ix.size, rootHash: ix.root }) && r.indexProof.index === r.pos;
|
||||||
|
if (!ok(motive, okIndex, eticheta + ': the row is not at position ' + r.pos + ' of the index "' + field + '"')) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/** A query answer against a commitment: every row proves itself, the rows are the whole contiguous run, the neighbours are outside. */
|
||||||
|
export function verificaRaspuns(answer, commitment) {
|
||||||
|
const motive = [];
|
||||||
|
if (!answer || answer.kind !== KIND_RASPUNS || !commitment) return { verdict: 'INVALID', motive: ['not a ' + KIND_RASPUNS], rows: 0 };
|
||||||
|
ok(motive, answer.commitmentHash === hashAngajament(commitment) && answer.version === commitment.version && answer.name === commitment.name, 'the answer names another commitment');
|
||||||
|
const f = answer.field; const ix = commitment.indexes && commitment.indexes[f];
|
||||||
|
if (!ok(motive, !!ix, 'the field "' + f + '" is not indexed in the commitment')) return { verdict: 'INVALID', motive, rows: 0 };
|
||||||
|
ok(motive, answer.indexSize === ix.size, 'the answer\'s index size differs from the commitment\'s');
|
||||||
|
const from = answer.from, to = answer.to; const rows = Array.isArray(answer.rows) ? answer.rows : [];
|
||||||
|
if (from !== null && to !== null) ok(motive, comparaValori(from, to) <= 0, 'from is above to');
|
||||||
|
const inRange = (v) => (from === null || comparaValori(v, from) >= 0) && (to === null || comparaValori(v, to) <= 0);
|
||||||
|
let start = rows.length ? rows[0].pos : (answer.before ? answer.before.pos + 1 : 0);
|
||||||
|
for (const [k, r] of rows.entries()) {
|
||||||
|
if (!verificaRand(r, commitment, f, motive, 'row ' + k)) continue;
|
||||||
|
ok(motive, r.pos === start + k, 'row ' + k + ' breaks the contiguous run of the index');
|
||||||
|
ok(motive, inRange(r.disclosed[f].value), 'row ' + k + ' is outside the queried range');
|
||||||
|
if (k) { const okOrdine = comparaValori(rows[k - 1].disclosed[f].value, r.disclosed[f].value) < 0 || (comparaValori(rows[k - 1].disclosed[f].value, r.disclosed[f].value) === 0 && rows[k - 1].i < r.i) /*PAZA:ordine*/; ok(motive, okOrdine, 'rows ' + (k - 1) + ' and ' + k + ' are not in index order'); }
|
||||||
|
}
|
||||||
|
const end = start + rows.length;
|
||||||
|
const vecin = (n, pos, cond, m) => {
|
||||||
|
if (!n) return ok(motive, cond, m);
|
||||||
|
const okVecin = n.pos === pos && n.indexProof && verifyInclusion({ ...n.indexProof, leafHash: frunzaIndex(f, n.value, n.i, n.recordLeaf), treeSize: ix.size, rootHash: ix.root }) && n.indexProof.index === pos;
|
||||||
|
return ok(motive, okVecin, 'the neighbour at position ' + pos + ' does not prove itself in the index');
|
||||||
|
};
|
||||||
|
// completeness: the row before the run is below the range (or the run starts at 0), the row after is above it (or the run ends the index)
|
||||||
|
const okInainte = vecin(answer.before, start - 1, start === 0, 'the run does not start at 0 and no neighbour before it is shown') && (!answer.before || (from !== null && comparaValori(answer.before.value, from) < 0)) /*PAZA:completitudine*/;
|
||||||
|
ok(motive, okInainte, 'a row before the run could still be in the range (the answer may be incomplete)');
|
||||||
|
const okDupa = vecin(answer.after, end, end === ix.size, 'the run does not end the index and no neighbour after it is shown') && (!answer.after || (to !== null && comparaValori(answer.after.value, to) > 0)) /*PAZA:completitudine*/;
|
||||||
|
ok(motive, okDupa, 'a row after the run could still be in the range (the answer may be incomplete)');
|
||||||
|
return { verdict: motive.length ? 'INVALID' : 'VALID', motive, rows: rows.length };
|
||||||
|
}
|
||||||
|
/** One record (from `dovadaInregistrare` or the serving endpoint) against a commitment. */
|
||||||
|
export function verificaInregistrare(rec, commitment) {
|
||||||
|
const motive = [];
|
||||||
|
if (!rec || rec.kind !== KIND_INREGISTRARE) return { ok: false, motive: ['not a ' + KIND_INREGISTRARE] };
|
||||||
|
ok(motive, rec.commitmentHash === hashAngajament(commitment) && rec.version === commitment.version, 'the record names another commitment');
|
||||||
|
verificaRand(rec, commitment, null, motive, 'record ' + rec.i);
|
||||||
|
return { ok: motive.length === 0, motive };
|
||||||
|
}
|
||||||
|
/** A consistency proof between two commitments: the older log is a prefix of the newer (nothing rewritten, nothing removed). */
|
||||||
|
export function verificaConsistentaVersiuni(d, older, newer) {
|
||||||
|
const motive = [];
|
||||||
|
if (!d || d.kind !== KIND_CONSISTENTA || !d.proof) return { ok: false, motive: ['not a ' + KIND_CONSISTENTA] };
|
||||||
|
ok(motive, d.from.commitmentHash === hashAngajament(older) && d.to.commitmentHash === hashAngajament(newer), 'the proof names other commitments');
|
||||||
|
const okRadacini = d.proof.firstRoot === older.logRoot && d.proof.secondRoot === newer.logRoot && d.proof.firstSize === older.size && d.proof.secondSize === newer.size /*PAZA:consistenta*/;
|
||||||
|
ok(motive, okRadacini, 'the proof\'s roots or sizes are not the commitments\'');
|
||||||
|
ok(motive, verifyConsistency(d.proof), 'the older log is not a prefix of the newer (rewritten history)');
|
||||||
|
ok(motive, newer.previous === hashAngajament(older) || newer.version !== older.version + 1, 'the newer version does not name the older as previous');
|
||||||
|
return { ok: motive.length === 0, motive };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Availability sampling: k record positions drawn from a seed (random when none; the seed is printed so the draw can be repeated), each
|
||||||
|
* fetched from `url` + /record/<version>/<i> through `ia(url) -> JSON | null` and verified against the commitment. A record that is not
|
||||||
|
* served, or does not verify, fails the sample. What this measures: the publisher serves what it committed to, NOW, at the sampled
|
||||||
|
* positions; k samples bound the share of unserved records a publisher can hide only probabilistically ((1 - share)^k survive).
|
||||||
|
*/
|
||||||
|
export async function esantioneaza({ url, commitment, k = 16, seed = null, ia }) {
|
||||||
|
const n = commitment.size; const cerute = Math.min(Math.max(Number(k) || 1, 1), n);
|
||||||
|
const s = seed || crypto.randomBytes(16).toString('hex'); const pozitii = new Set(); let ctr = 0;
|
||||||
|
while (pozitii.size < cerute) { const h = crypto.createHash('sha256').update(s + ':' + ctr++).digest(); pozitii.add(Number(h.readBigUInt64BE(0) % BigInt(n))); }
|
||||||
|
const lipsa = [], motive = []; let verificate = 0;
|
||||||
|
for (const i of [...pozitii].sort((x, y) => x - y)) {
|
||||||
|
let rec = null; try { rec = await ia(url.replace(/\/$/, '') + '/record/' + commitment.version + '/' + i); } catch (e) { motive.push('record ' + i + ': ' + String(e.message).slice(0, 80)); }
|
||||||
|
if (!rec) { lipsa.push(i); continue; }
|
||||||
|
const r = verificaInregistrare(rec, commitment);
|
||||||
|
if (r.ok && rec.i === i) verificate++; else motive.push('record ' + i + ': ' + (rec.i !== i ? 'another record was served' : r.motive.join('; ')));
|
||||||
|
}
|
||||||
|
const okEsantion = lipsa.length === 0 && verificate === cerute /*PAZA:esantion*/;
|
||||||
|
if (lipsa.length) motive.push('not served: ' + lipsa.join(', '));
|
||||||
|
return { ok: okEsantion, seed: s, cerute, verificate, lipsa, motive };
|
||||||
|
}
|
||||||
|
/** The AIP-23 `data` envelope of a commitment: sha256 = SHA-256 of the canonical commitment, name = dataset@version. */
|
||||||
|
export function plicAngajament(commitment, { createdAt = commitment.createdAt } = {}) {
|
||||||
|
return buildProof('data', { content: utf8(canonical(commitment)), name: commitment.name + '@' + commitment.version, createdAt });
|
||||||
|
}
|
||||||
|
export function verificaPlicAngajament(env, commitment) {
|
||||||
|
const st = env && env.statement;
|
||||||
|
return !!st && env.kind === 'aere-proof-of-data-attestation' && st.sha256 === hashAngajament(commitment) && st.name === commitment.name + '@' + commitment.version && H.test(String(env.statementHash));
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"commitment": {
|
||||||
|
"v": 1,
|
||||||
|
"format": "aere-verifiable-dataset-1",
|
||||||
|
"name": "proba",
|
||||||
|
"version": 2,
|
||||||
|
"size": 400,
|
||||||
|
"logRoot": "0x373a9e95032d46ea613cfedb887c7e79b11b2e9c211dc8ce4f8051c7d6534bb1",
|
||||||
|
"createdAt": "2026-10-02T08:01:00.000Z",
|
||||||
|
"indexes": {
|
||||||
|
"tara": {
|
||||||
|
"root": "0x9df01d08ddc09b3bf6a966f2446f0345148031100b5ca7d2d1cc27a6e4753c15",
|
||||||
|
"size": 400
|
||||||
|
},
|
||||||
|
"varsta": {
|
||||||
|
"root": "0xe5ca27f5a9e5308f719c74fc2b42c80b554e4321d512e785ce951da03f09d4d1",
|
||||||
|
"size": 400
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"previous": "0x60000ce734e7479355b910c41cf1be305cd0242d16fd1d4a65ebcf43fcbc68f3"
|
||||||
|
},
|
||||||
|
"envelope": {
|
||||||
|
"v": 1,
|
||||||
|
"kind": "aere-proof-of-data-attestation",
|
||||||
|
"statement": {
|
||||||
|
"v": 1,
|
||||||
|
"kind": "aere-proof-of-data",
|
||||||
|
"sha256": "0xea7441856ff78c64bc549b763816a76fbe40b049c024843427d42761981741cb",
|
||||||
|
"name": "proba@2",
|
||||||
|
"createdAt": "2026-10-02T08:01:00.000Z"
|
||||||
|
},
|
||||||
|
"statementHash": "0xeb177d94c7bfa1de42c35a7096b454bda04c9456dad0ca6e5922f78b92192260",
|
||||||
|
"notarization": {
|
||||||
|
"chainId": 28001,
|
||||||
|
"notary": "0x70099E62735500AA2F85B60C518551a57B202d54",
|
||||||
|
"firstSeen": 1790929167,
|
||||||
|
"txHash": "0x39938e8ef28f62f60ad3d5f6a205d248e499fe1eda0e1e1851b1c43cc8f08c62",
|
||||||
|
"block": 4400052
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verdict": {
|
||||||
|
"verdict": "VALID",
|
||||||
|
"statementHash": "0xeb177d94c7bfa1de42c35a7096b454bda04c9456dad0ca6e5922f78b92192260",
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"level": "form",
|
||||||
|
"state": "PASSED",
|
||||||
|
"detail": "kind=aere-proof-of-data-attestation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "integrity",
|
||||||
|
"state": "PASSED",
|
||||||
|
"detail": "sha256(statement) == statementHash (0xeb177d94c7bfa1de..)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "signature",
|
||||||
|
"state": "ABSENT",
|
||||||
|
"detail": "the envelope carries no signature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "finality",
|
||||||
|
"state": "PASSED",
|
||||||
|
"detail": "post-quantum: the state of block 4400063, the parent of anchor 4400064 whose post-quantum certificate verified here records this statementHash in AereNotary 0x70099e62735500aa2f85b60c518551a57b202d54 as first seen at 2026-10-02T08:19:27.000Z (1790929167); first seen in block 4400052, transaction 0x39938e8ef28f62f6.. (located through the endpoint's logs, not part of what is certified)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
171
verifiable-data/proba-verifiable-data.mjs
Normal file
171
verifiable-data/proba-verifiable-data.mjs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// proba-verifiable-data.mjs: the laboratory of the verifiable dataset (roadmap point 20), three states per row: VERDE (the named
|
||||||
|
// verdict), ROSU (another verdict), STRICAT (the probe itself broke). No network, except --viu.
|
||||||
|
// node proba-verifiable-data.mjs the laboratory: 24 rows (honest answers VALID; nine forged ones INVALID, each
|
||||||
|
// refused for its named reason; availability sampling on a local server)
|
||||||
|
// node proba-verifiable-data.mjs --control-negativ plantings on a COPY of baza.mjs (PAZA markers): each must turn its forged
|
||||||
|
// fixture green-by-mistake, which proves the check is load-bearing
|
||||||
|
// node proba-verifiable-data.mjs --viu plus: the newest commitment notarized on testnet 28001 with a test key and read
|
||||||
|
// back under a post-quantum certified anchor (AERE_NOTARY_KEY_FILE, AERE_ETHERS_DIR)
|
||||||
|
// Env: AERE_VERIFY_PROOF (the AIP-23 verifier; without it the envelope rows are NEMASURAT and the exit code is 2), AERE_VD_MODUL (the
|
||||||
|
// module under test; the control sets it to its planted copy).
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import { leafHash, merkleTree } from '../verify-layer/arbore-merkle.mjs';
|
||||||
|
import { porneste } from './serveste.mjs';
|
||||||
|
|
||||||
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const RAD = path.resolve(AICI, '..', '..');
|
||||||
|
const MODUL = process.env.AERE_VD_MODUL || path.join(AICI, 'baza.mjs');
|
||||||
|
const VERIFY_PROOF = process.env.AERE_VERIFY_PROOF || path.join(RAD, 'verificator-falcon', 'verify-proof.mjs');
|
||||||
|
const RPC_28001 = process.env.AERE_TESTNET_RPC || 'https://testnet-rpc.aere.network';
|
||||||
|
const CHEIE = process.env.AERE_NOTARY_KEY_FILE || path.join(RAD, 'tools', 'aere-quantum-shield', 'dovezi-l1', '.chei', 'cheie-de-proba-28001.key');
|
||||||
|
const ETHERS_DIR = process.env.AERE_ETHERS_DIR || path.join(RAD, 'contracts');
|
||||||
|
const a = process.argv.slice(2); const viu = a.includes('--viu'); const control = a.includes('--control-negativ');
|
||||||
|
const B = await import(pathToFileURL(MODUL).href + '?t=' + Date.now());
|
||||||
|
|
||||||
|
const TARI = ['RO', 'DE', 'FR', 'ES', 'IT'];
|
||||||
|
function inregistrari(n, de = 0) { const r = []; for (let k = de; k < de + n; k++) r.push({ id: 'r' + k, tara: TARI[k % 5], varsta: 18 + ((k * 37) % 63), email: 'u' + k + '@example.test', privat: 'nu-se-dezvaluie-' + k }); return r; }
|
||||||
|
const clona = (x) => JSON.parse(JSON.stringify(x));
|
||||||
|
|
||||||
|
/** The fixtures: each returns { verdict, detail }; `asteptat` is the verdict the honest module must give. */
|
||||||
|
export async function fixturi(B, { dir, server }) {
|
||||||
|
const F = [];
|
||||||
|
const db = B.deschide(dir); const c1 = B.versiune(db, 1), c2 = B.versiune(db, 2);
|
||||||
|
const brut = (f) => db.inregistrari.slice(0, c2.size).filter((r) => f(r.fields)).length; // the count straight from the records, to compare with the answer
|
||||||
|
F.push({ nume: 'L1 a record of version 1 proves itself (log inclusion, field commitments)', asteptat: 'VALID', r: () => { const x = B.verificaInregistrare(B.dovadaInregistrare(db, 1, 7), c1); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'L2 version 1 is a prefix of version 2 (consistency)', asteptat: 'VALID', r: () => { const x = B.verificaConsistentaVersiuni(B.dovadaConsistenta(db, 1, 2), c1, c2); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'L3 equality query tara=RO at version 2: VALID, as many rows as the data has, only tara disclosed', asteptat: 'VALID', r: () => {
|
||||||
|
const q = B.interogheaza(db, 2, { field: 'tara', eq: 'RO' }); const x = B.verificaRaspuns(q, c2);
|
||||||
|
const n = brut((r) => r.tara === 'RO'); const doarTara = q.rows.every((r) => Object.keys(r.disclosed).join() === 'tara' && !JSON.stringify(r).includes('nu-se-dezvaluie'));
|
||||||
|
return { verdict: x.verdict === 'VALID' && x.rows === n && doarTara ? 'VALID' : 'INVALID', detail: `${x.rows} rows (data: ${n}); only tara: ${doarTara}; ${x.motive.join('; ')}` }; } });
|
||||||
|
F.push({ nume: 'L4 range query varsta 30..40 with email disclosed too: VALID, count as the data', asteptat: 'VALID', r: () => {
|
||||||
|
const q = B.interogheaza(db, 2, { field: 'varsta', from: 30, to: 40, fields: ['email'] }); const x = B.verificaRaspuns(q, c2);
|
||||||
|
const n = brut((r) => r.varsta >= 30 && r.varsta <= 40); const cuEmail = q.rows.every((r) => r.disclosed.email && r.disclosed.varsta && !r.disclosed.privat);
|
||||||
|
return { verdict: x.verdict === 'VALID' && x.rows === n && cuEmail ? 'VALID' : 'INVALID', detail: `${x.rows} rows (data: ${n}); varsta+email only: ${cuEmail}; ${x.motive.join('; ')}` }; } });
|
||||||
|
F.push({ nume: 'L5 open range varsta <= 20 (run starts at index 0, no neighbour before) and varsta >= 79 (ends the index)', asteptat: 'VALID', r: () => {
|
||||||
|
const q1 = B.interogheaza(db, 2, { field: 'varsta', to: 20 }); const q2 = B.interogheaza(db, 2, { field: 'varsta', from: 79 });
|
||||||
|
const x1 = B.verificaRaspuns(q1, c2), x2 = B.verificaRaspuns(q2, c2);
|
||||||
|
return { verdict: x1.verdict === 'VALID' && x2.verdict === 'VALID' && q1.before === null && q2.after === null && x1.rows === brut((r) => r.varsta <= 20) && x2.rows === brut((r) => r.varsta >= 79) ? 'VALID' : 'INVALID', detail: `${x1.rows} + ${x2.rows} rows; ${x1.motive.concat(x2.motive).join('; ')}` }; } });
|
||||||
|
F.push({ nume: 'L6 a query with no match: zero rows, the two neighbours prove the gap', asteptat: 'VALID', r: () => { const q = B.interogheaza(db, 2, { field: 'tara', eq: 'GB' }); const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: `${x.rows} rows, before=${q.before && q.before.value}, after=${q.after && q.after.value}; ${x.motive.join('; ')}` }; } });
|
||||||
|
F.push({ nume: 'L7 the audit rebuilds both versions from the records: every root matches', asteptat: 'VALID', r: () => { const x = B.auditeaza(db); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.probleme.join('; ') }; } });
|
||||||
|
// forged answers: each must be INVALID for its named reason
|
||||||
|
F.push({ nume: 'N1 a disclosed value changed after commitment (varsta 31 -> 32)', asteptat: 'INVALID', paza: 'camp', r: () => { const q = B.interogheaza(db, 2, { field: 'tara', eq: 'DE', fields: ['varsta'] }); q.rows[0].disclosed.varsta.value += 1; const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N2 a row dropped from the middle of the run', asteptat: 'INVALID', r: () => { const q = B.interogheaza(db, 2, { field: 'tara', eq: 'FR' }); q.rows.splice(3, 1); const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N3 the last matching row dropped and shown as the neighbour after (its value is still in range)', asteptat: 'INVALID', paza: 'completitudine', r: () => {
|
||||||
|
const q = B.interogheaza(db, 2, { field: 'tara', eq: 'IT' }); const ultima = q.rows.pop();
|
||||||
|
q.after = { pos: ultima.pos, value: ultima.disclosed.tara.value, i: ultima.i, recordLeaf: B.frunzaInregistrare(ultima.id, ultima.c), indexProof: ultima.indexProof };
|
||||||
|
const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N4 the first matching row dropped and shown as the neighbour before', asteptat: 'INVALID', paza: 'completitudine', r: () => {
|
||||||
|
const q = B.interogheaza(db, 2, { field: 'varsta', from: 50, to: 60 }); const prima = q.rows.shift();
|
||||||
|
q.before = { pos: prima.pos, value: prima.disclosed.varsta.value, i: prima.i, recordLeaf: B.frunzaInregistrare(prima.id, prima.c), indexProof: prima.indexProof };
|
||||||
|
const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N5 an answer of version 1 presented against the commitment of version 2', asteptat: 'INVALID', r: () => { const q = B.interogheaza(db, 1, { field: 'tara', eq: 'RO' }); const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N6 a rewritten history: a "version 2" built from the records with one changed, with the honest consistency proof', asteptat: 'INVALID', paza: 'consistenta', r: () => {
|
||||||
|
const fals = clona(c2); fals.logRoot = '0x' + crypto.createHash('sha256').update('alt arbore').digest('hex');
|
||||||
|
const d = B.dovadaConsistenta(db, 1, 2); d.to.commitmentHash = B.hashAngajament(fals);
|
||||||
|
const x = B.verificaConsistentaVersiuni(d, c1, fals); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N7 a consistency proof with one hash of the path changed', asteptat: 'INVALID', r: () => { const d = B.dovadaConsistenta(db, 1, 2); d.proof.path[0] = '0x' + 'ab'.repeat(32); const x = B.verificaConsistentaVersiuni(d, c1, c2); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N8 a publisher whose index is NOT sorted (two rows with valid proofs, out of order)', asteptat: 'INVALID', paza: 'ordine', r: () => {
|
||||||
|
// two records, the index built in the wrong order (b before a), everything else honest: the reader sees it on the first answer with two rows
|
||||||
|
const recs = [{ id: 'x', fields: { k: 'b' }, salts: { k: '00'.repeat(16) } }, { id: 'y', fields: { k: 'a' }, salts: { k: '11'.repeat(16) } }];
|
||||||
|
const rc = recs.map((r) => { const c = { k: B.angajamentCamp('k', r.fields.k, r.salts.k) }; return { ...r, c, leaf: B.frunzaInregistrare(r.id, c) }; });
|
||||||
|
const log = merkleTree(rc.map((r) => r.leaf)); const idx = merkleTree(rc.map((r, i) => B.frunzaIndex('k', r.fields.k, i, r.leaf))); // unsorted: 'b' at 0, 'a' at 1
|
||||||
|
const c = { v: 1, format: B.FORMAT, name: 'nesortat', version: 1, size: 2, logRoot: log.root(), createdAt: '2026-10-02T00:00:00.000Z', indexes: { k: { root: idx.root(), size: 2 } }, previous: null };
|
||||||
|
const rand = (i) => ({ pos: i, i, id: rc[i].id, c: rc[i].c, disclosed: { k: { value: rc[i].fields.k, salt: rc[i].salts.k } }, logProof: log.inclusionProof(i, 2), indexProof: idx.inclusionProof(i, 2) });
|
||||||
|
const q = { v: 1, kind: B.KIND_RASPUNS, name: 'nesortat', version: 1, commitmentHash: B.hashAngajament(c), field: 'k', from: 'a', to: 'b', indexSize: 2, rows: [rand(0), rand(1)], before: null, after: null };
|
||||||
|
const x = B.verificaRaspuns(q, c); return { verdict: x.verdict, detail: x.motive.join('; ') }; } });
|
||||||
|
F.push({ nume: 'N9 a record whose log proof points at another position', asteptat: 'INVALID', r: () => { const d = B.dovadaInregistrare(db, 2, 5); d.i = 6; d.logProof.index = 6; const x = B.verificaInregistrare(d, c2); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.motive.join('; ') }; } });
|
||||||
|
// availability sampling against the local server
|
||||||
|
const ia = async (u) => { const r = await fetch(u, { signal: AbortSignal.timeout(10000) }); if (!r.ok) return null; return r.json(); };
|
||||||
|
F.push({ nume: 'A1 availability sampling, 32 records drawn from a seed, all served and verified', asteptat: 'VALID', r: async () => { const x = await B.esantioneaza({ url: server.url, commitment: c2, k: 32, seed: 'proba-a1', ia }); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: `${x.verificate}/${x.cerute}; ${x.motive.join('; ')}` }; } });
|
||||||
|
F.push({ nume: 'A2 a publisher that withholds record 123: the full sample finds it', asteptat: 'INVALID', paza: 'esantion', r: async () => { const x = await B.esantioneaza({ url: server.ascuns, commitment: c2, k: c2.size, seed: 'proba-a2', ia }); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: `${x.verificate}/${x.cerute}; not served: ${x.lipsa.join(',')}` }; } });
|
||||||
|
F.push({ nume: 'A3 the served query answer (HTTP) verifies like the local one', asteptat: 'VALID', r: async () => { const q = await ia(server.url + '/query/2?field=tara&eq=%22ES%22&fields=varsta'); const x = B.verificaRaspuns(q, c2); return { verdict: x.verdict, detail: `${x.rows} rows; ${x.motive.join('; ')}` }; } });
|
||||||
|
F.push({ nume: 'A4 the served consistency proof 1 -> 2 verifies', asteptat: 'VALID', r: async () => { const d = await ia(server.url + '/consistency/1/2'); const x = B.verificaConsistentaVersiuni(d, c1, c2); return { verdict: x.ok ? 'VALID' : 'INVALID', detail: x.motive.join('; ') }; } });
|
||||||
|
// the commitment as an AIP-23 envelope
|
||||||
|
F.push({ nume: 'E1 the envelope of version 2 names this commitment (sha256 of its canonical bytes); a changed commitment does not', asteptat: 'VALID', r: () => { const env = B.plicAngajament(c2); const alt = clona(c2); alt.size += 1; return { verdict: B.verificaPlicAngajament(env, c2) && !B.verificaPlicAngajament(env, alt) ? 'VALID' : 'INVALID', detail: env.statementHash.slice(0, 18) + '..' }; } });
|
||||||
|
F.push({ nume: 'E2 the AIP-23 reference verifier accepts the envelope (no new verification code)', asteptat: 'VALID', nemasurat: !fs.existsSync(VERIFY_PROOF), r: () => {
|
||||||
|
const f = path.join(os.tmpdir(), 'vd-plic-' + process.pid + '.json'); fs.writeFileSync(f, JSON.stringify(B.plicAngajament(c2)));
|
||||||
|
try { const r = spawnSync(process.execPath, [VERIFY_PROOF, f, '--json'], { encoding: 'utf8', timeout: 120000 }); let v; try { v = JSON.parse(r.stdout); } catch { return { verdict: 'STRICAT', detail: 'verifier gave no JSON' }; } return { verdict: v.verdict, detail: (v.levels || []).map((l) => l.level + ':' + l.state).join(' ') }; } finally { fs.rmSync(f, { force: true }); } } });
|
||||||
|
return F;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds the dataset of the laboratory: 300 records committed as version 1, 100 more as version 2. */
|
||||||
|
export function laborator(dir) {
|
||||||
|
const db = B.creeaza(path.join(dir, 'set'), { name: 'proba', indexes: ['tara', 'varsta'] });
|
||||||
|
B.adauga(db, inregistrari(300)); B.angajeaza(db, { createdAt: '2026-10-02T08:00:00.000Z' });
|
||||||
|
B.adauga(db, inregistrari(100, 300)); B.angajeaza(db, { createdAt: '2026-10-02T08:01:00.000Z' });
|
||||||
|
return path.join(dir, 'set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const T = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-vd-'));
|
||||||
|
let codIesire = 0;
|
||||||
|
try {
|
||||||
|
const dir = laborator(T);
|
||||||
|
const s1 = await porneste({ dir }); const s2 = await porneste({ dir, ascunde: [123] });
|
||||||
|
const server = { url: s1.url, ascuns: s2.url };
|
||||||
|
try {
|
||||||
|
if (!control) {
|
||||||
|
let verzi = 0, rosii = 0, stricate = 0, nem = 0;
|
||||||
|
for (const f of await fixturi(B, { dir, server })) {
|
||||||
|
if (f.nemasurat) { console.log(` [NEMASURAT] ${f.nume}: the AIP-23 verifier is not at ${VERIFY_PROOF} (AERE_VERIFY_PROOF)`); nem++; continue; }
|
||||||
|
let r; try { r = await f.r(); } catch (e) { r = { verdict: 'STRICAT', detail: String(e.stack || e.message).split('\n').slice(0, 2).join(' ').slice(0, 200) }; }
|
||||||
|
const stare = r.verdict === 'STRICAT' ? 'STRICAT' : r.verdict === f.asteptat ? 'VERDE' : 'ROSU';
|
||||||
|
if (stare === 'VERDE') verzi++; else if (stare === 'ROSU') rosii++; else stricate++;
|
||||||
|
console.log(` [${stare.padEnd(7)}] ${f.nume}: ${r.verdict}${r.detail ? ' (' + r.detail.slice(0, 160) + ')' : ''}`);
|
||||||
|
}
|
||||||
|
if (viu) {
|
||||||
|
const db = B.deschide(dir); const c2 = B.versiune(db, 2); const env = B.plicAngajament(c2);
|
||||||
|
const { notarizeEnvelope } = await import('../agents/agent-ancora.mjs');
|
||||||
|
const { ethers } = createRequire(path.join(ETHERS_DIR, 'package.json'))('ethers');
|
||||||
|
const n = await notarizeEnvelope({ envelope: env, kind: 'aere-proof-of-data', rpc: RPC_28001, keyFile: CHEIE, ethers });
|
||||||
|
if (!n.ok) { console.log(` [STRICAT] V1 notarization on 28001: ${n.motiv}`); stricate++; }
|
||||||
|
else {
|
||||||
|
const f = path.join(T, 'plic-notarizat.json'); fs.writeFileSync(f, JSON.stringify(n.envelope, null, 1));
|
||||||
|
console.log(` [VERDE ] V1 version 2 notarized on 28001: firstSeen ${n.envelope.notarization.firstSeen}${n.envelope.notarization.txHash ? ', tx ' + n.envelope.notarization.txHash + ' block ' + n.envelope.notarization.block : ' (already)'}`); verzi++;
|
||||||
|
let v = null;
|
||||||
|
for (let k = 0; k < 24; k++) { const r = spawnSync(process.execPath, [VERIFY_PROOF, f, '--json', '--rpc', RPC_28001, '--chain', '28001'], { encoding: 'utf8', timeout: 300000 }); try { v = JSON.parse(r.stdout); } catch { v = null; } const fin = v && (v.levels || []).find((l) => l.level === 'finality'); if (fin && fin.state !== 'PENDING') break; await new Promise((ok) => setTimeout(ok, 5000)); }
|
||||||
|
const fin = v && (v.levels || []).find((l) => l.level === 'finality');
|
||||||
|
const okFin = v && v.verdict === 'VALID' && fin && fin.state === 'PASSED';
|
||||||
|
console.log(` [${okFin ? 'VERDE ' : 'ROSU '}] V2 read back under a post-quantum certified anchor: ${v ? v.verdict : '?'}; finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 140) : '?'}`); okFin ? verzi++ : rosii++;
|
||||||
|
const dov = path.join(AICI, 'dovezi'); fs.mkdirSync(dov, { recursive: true });
|
||||||
|
const out = path.join(dov, 'notarizare-28001-' + new Date().toISOString().replace(/[:.]/g, '').slice(0, 15) + 'Z.json');
|
||||||
|
fs.writeFileSync(out, JSON.stringify({ commitment: c2, envelope: n.envelope, verdict: v }, null, 1)); console.log(' evidence: ' + path.relative(RAD, out));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\nverifiable data: ${verzi} VERDE, ${rosii} ROSU, ${stricate} STRICAT${nem ? ', ' + nem + ' NEMASURAT' : ''}`);
|
||||||
|
codIesire = stricate || rosii ? 1 : nem ? 2 : 0;
|
||||||
|
} else {
|
||||||
|
// the control: a planting per PAZA marker on a copy of baza.mjs; the fixture that the marker guards must turn green-by-mistake
|
||||||
|
const SURSA = fs.readFileSync(MODUL, 'utf8'); let bune = 0; const marcaje = ['camp', 'ordine', 'completitudine', 'consistenta', 'esantion'];
|
||||||
|
const toate = await fixturi(B, { dir, server });
|
||||||
|
for (const m of marcaje) {
|
||||||
|
const copie = path.join(AICI, `_plantat-baza-${process.pid}-${m}.mjs`); const n = SURSA.split(`/*PAZA:${m}*/`).length - 1;
|
||||||
|
const tinte = toate.filter((f) => f.paza === m);
|
||||||
|
let stare, detaliu;
|
||||||
|
try {
|
||||||
|
if (n < 1 || !tinte.length) { stare = 'STRICAT'; detaliu = `marker ${n} times, ${tinte.length} fixtures`; }
|
||||||
|
else {
|
||||||
|
fs.writeFileSync(copie, SURSA.split(`/*PAZA:${m}*/`).join("|| String(1) === '1' /*PLANTAT*/"));
|
||||||
|
const P = await import(pathToFileURL(copie).href + '?t=' + Date.now());
|
||||||
|
const fx = await fixturi(P, { dir, server }); const rez = [];
|
||||||
|
for (const t of tinte) { const f = fx.find((x) => x.nume === t.nume); const r = await f.r(); rez.push(`${t.nume.split(' ')[0]}:${r.verdict}`); }
|
||||||
|
const verzi = rez.every((x) => x.endsWith(':VALID'));
|
||||||
|
stare = verzi ? 'VERDE' : 'ROSU'; detaliu = (verzi ? 'the forged fixture is accepted with the check planted out: ' : 'the planting did not change the verdict: ') + rez.join(' ');
|
||||||
|
}
|
||||||
|
} catch (e) { stare = 'STRICAT'; detaliu = String(e.message).slice(0, 160); } finally { fs.rmSync(copie, { force: true }); }
|
||||||
|
if (stare === 'VERDE') bune++;
|
||||||
|
console.log(` [${stare.padEnd(7)}] PAZA:${m} (${n} marker${n === 1 ? '' : 's'}) ${detaliu}`);
|
||||||
|
}
|
||||||
|
const ramase = fs.readdirSync(AICI).filter((f) => f.startsWith('_plantat-baza-'));
|
||||||
|
console.log(`CONTROL: ${bune} of ${marcaje.length} checks proven load-bearing${ramase.length ? '; copies left: ' + ramase.join(',') : ''}`);
|
||||||
|
codIesire = bune === marcaje.length && !ramase.length ? 0 : 1;
|
||||||
|
}
|
||||||
|
} finally { await s1.inchide(); await s2.inchide(); }
|
||||||
|
} finally { fs.rmSync(T, { recursive: true, force: true }); }
|
||||||
|
process.exitCode = codIesire;
|
||||||
52
verifiable-data/serveste.mjs
Normal file
52
verifiable-data/serveste.mjs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// serveste.mjs: the read-only HTTP face of a verifiable dataset (roadmap point 20, "availability"): commitments, records with their
|
||||||
|
// inclusion proofs, query answers with their completeness proofs, consistency proofs between versions. Every answer is checkable
|
||||||
|
// against a commitment with verifica.mjs, without trusting this server. What this server proves is only that the bytes were served
|
||||||
|
// when asked: availability sampling (verifica.mjs disponibilitate) draws random record positions and verifies each against the
|
||||||
|
// commitment; a publisher that withholds a record fails the sample that lands on it. This is retrievability under sampling, not an
|
||||||
|
// erasure-coded data-availability scheme.
|
||||||
|
// GET /v1/vd/<name>/commitments every version's commitment (+ its AIP-23 envelope, notarized or not)
|
||||||
|
// GET /v1/vd/<name>/commitment/<n>
|
||||||
|
// GET /v1/vd/<name>/record/<n>/<i> one record at version n with its log proof (all fields, or none with --privat)
|
||||||
|
// GET /v1/vd/<name>/query/<n>?field=f&eq=J | &from=J&to=J [&fields=a,b|*] [&limit=N] J = a JSON value ("x", 42, true, null)
|
||||||
|
// GET /v1/vd/<name>/consistency/<a>/<b>
|
||||||
|
// Only GET; JSON; the dataset is re-read when versiuni.json changes; bodies are bounded by the query limit (default 1000 rows).
|
||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { deschide, versiune, dovadaInregistrare, interogheaza, dovadaConsistenta } from './baza.mjs';
|
||||||
|
|
||||||
|
const json = (res, cod, o) => { const b = Buffer.from(JSON.stringify(o)); res.writeHead(cod, { 'content-type': 'application/json', 'content-length': b.length, 'cache-control': 'no-store' }); res.end(b); };
|
||||||
|
const dinJson = (s) => { if (s === null || s === undefined) return undefined; try { return JSON.parse(s); } catch { throw new Error('a query value is JSON: "x", 42, true or null (got ' + String(s).slice(0, 40) + ')'); } };
|
||||||
|
|
||||||
|
/** Starts the server; resolves with { server, url, inchide }. `ascunde`: record positions the server withholds (a test hook, for the
|
||||||
|
* availability control: a publisher that does not serve a record). */
|
||||||
|
export function porneste({ dir, port = 0, host = '127.0.0.1', privat = false, limit = 1000, ascunde = [] } = {}) {
|
||||||
|
let db = deschide(dir); let mtime = fs.statSync(path.join(dir, 'versiuni.json')).mtimeMs;
|
||||||
|
const plic = (n) => { const f = path.join(dir, 'plicuri', n + '.json'); return fs.existsSync(f) ? JSON.parse(fs.readFileSync(f, 'utf8')) : null; };
|
||||||
|
const recarca = () => { const m = fs.statSync(path.join(dir, 'versiuni.json')).mtimeMs; if (m !== mtime) { db = deschide(dir); mtime = m; } };
|
||||||
|
const ascunse = new Set(ascunde.map(Number));
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method !== 'GET') return json(res, 405, { error: 'method_not_allowed' });
|
||||||
|
const url = new URL(req.url, 'http://x'); const p = url.pathname.split('/').filter(Boolean);
|
||||||
|
if (p[0] !== 'v1' || p[1] !== 'vd' || p.length < 4) return json(res, 404, { error: 'not_found' });
|
||||||
|
recarca();
|
||||||
|
if (p[2] !== db.set.name) return json(res, 404, { error: 'no_such_dataset' });
|
||||||
|
const n = (s) => { const x = Number(s); if (!Number.isInteger(x) || x < 0) throw new Error('not a non-negative integer: ' + s); return x; };
|
||||||
|
if (p[3] === 'health') return json(res, 200, { ok: true, name: db.set.name, versions: db.versiuni.length, records: db.inregistrari.length });
|
||||||
|
if (p[3] === 'commitments' && p.length === 4) return json(res, 200, { name: db.set.name, commitments: db.versiuni.map((c) => ({ commitment: c, envelope: plic(c.version) })) });
|
||||||
|
if (p[3] === 'commitment' && p.length === 5) { const c = versiune(db, n(p[4])); return json(res, 200, { commitment: c, envelope: plic(c.version) }); }
|
||||||
|
if (p[3] === 'record' && p.length === 6) { const i = n(p[5]); if (ascunse.has(i)) return json(res, 404, { error: 'record_not_served' }); return json(res, 200, dovadaInregistrare(db, n(p[4]), i, { fields: privat ? [] : '*' })); }
|
||||||
|
if (p[3] === 'query' && p.length === 5) {
|
||||||
|
const q = url.searchParams; const fields = q.get('fields'); const lim = Math.min(Number(q.get('limit') || limit) || limit, limit);
|
||||||
|
return json(res, 200, interogheaza(db, n(p[4]), { field: q.get('field'), eq: dinJson(q.get('eq')), from: dinJson(q.get('from')), to: dinJson(q.get('to')), fields: fields === '*' ? '*' : (fields ? fields.split(',').filter(Boolean) : []), limit: lim }));
|
||||||
|
}
|
||||||
|
if (p[3] === 'consistency' && p.length === 6) return json(res, 200, dovadaConsistenta(db, n(p[4]), n(p[5])));
|
||||||
|
return json(res, 404, { error: 'not_found' });
|
||||||
|
} catch (e) { return json(res, 400, { error: 'bad_request', detail: String(e.message).slice(0, 200) }); }
|
||||||
|
});
|
||||||
|
return new Promise((ok, nu) => {
|
||||||
|
server.on('error', nu);
|
||||||
|
server.listen(port, host, () => { const a = server.address(); ok({ server, url: `http://${host}:${a.port}/v1/vd/${db.set.name}`, inchide: () => new Promise((r) => server.close(r)) }); });
|
||||||
|
});
|
||||||
|
}
|
||||||
61
verifiable-data/vd.mjs
Normal file
61
verifiable-data/vd.mjs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// vd.mjs: the publisher's command line of a verifiable dataset (roadmap point 20). Values on the command line are JSON ("x", 42).
|
||||||
|
// node vd.mjs creeaza <dir> --nume <name> [--index f1,f2] a new dataset folder
|
||||||
|
// node vd.mjs adauga <dir> <records.ndjson|records.json> append records ({id, ...fields}); never rewrites
|
||||||
|
// node vd.mjs angajeaza <dir> [--out c.json] a new version: commitment + its AIP-23 envelope (plicuri/<n>.json)
|
||||||
|
// node vd.mjs notarizeaza <dir> <n> --rpc URL --key-file f --ethers-dir d [--notary 0x..]
|
||||||
|
// the envelope's statementHash on AereNotary (refuses 2800 unless AERE_CONFIRM_MAINNET=yes)
|
||||||
|
// node vd.mjs interogheaza <dir> <n> --camp f (--eq J | [--de-la J] [--pana-la J]) [--campuri a,b|*] [--out answer.json]
|
||||||
|
// node vd.mjs inregistrare <dir> <n> <i> [--campuri a,b|*] [--out rec.json]
|
||||||
|
// node vd.mjs consistenta <dir> <a> <b> [--out proof.json]
|
||||||
|
// node vd.mjs auditeaza <dir> rebuild every version from the records and compare the roots
|
||||||
|
// node vd.mjs serveste <dir> [--port 8797] [--privat] the read-only HTTP face (serveste.mjs)
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { creeaza, deschide, adauga, angajeaza, versiune, dovadaInregistrare, interogheaza, dovadaConsistenta, auditeaza, plicAngajament, hashAngajament } from './baza.mjs';
|
||||||
|
import { notarizeEnvelope } from '../agents/agent-ancora.mjs';
|
||||||
|
import { porneste } from './serveste.mjs';
|
||||||
|
|
||||||
|
const a = process.argv.slice(2); const cmd = a[0]; const dir = a[1];
|
||||||
|
const opt = (k, d = null) => { const i = a.indexOf(k); return i >= 0 && i + 1 < a.length ? a[i + 1] : d; };
|
||||||
|
const are = (k) => a.includes(k);
|
||||||
|
const J = (s) => (s === null ? undefined : JSON.parse(s));
|
||||||
|
const scrie = (o) => { const f = opt('--out'); const t = JSON.stringify(o, null, 1); if (f) { fs.writeFileSync(f, t); console.log('written ' + f); } else console.log(t); };
|
||||||
|
const plicuri = (d) => { const p = path.join(d, 'plicuri'); fs.mkdirSync(p, { recursive: true }); return p; };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (cmd === 'creeaza') { const db = creeaza(dir, { name: opt('--nume'), indexes: (opt('--index', '') || '').split(',').filter(Boolean) }); console.log('created ' + db.set.name + ' at ' + dir + ' (indexes: ' + (db.set.indexes.join(', ') || 'none') + ')'); }
|
||||||
|
else if (cmd === 'adauga') {
|
||||||
|
const db = deschide(dir); const t = fs.readFileSync(a[2], 'utf8');
|
||||||
|
const recs = a[2].endsWith('.ndjson') ? t.split('\n').filter(Boolean).map((l) => JSON.parse(l)) : JSON.parse(t);
|
||||||
|
console.log('appended ' + adauga(db, Array.isArray(recs) ? recs : [recs]) + ' records (' + db.inregistrari.length + ' in total)');
|
||||||
|
}
|
||||||
|
else if (cmd === 'angajeaza') {
|
||||||
|
const db = deschide(dir); const c = angajeaza(db); const env = plicAngajament(c);
|
||||||
|
fs.writeFileSync(path.join(plicuri(dir), c.version + '.json'), JSON.stringify(env, null, 1));
|
||||||
|
console.log(`version ${c.version}: ${c.size} records, log root ${c.logRoot.slice(0, 18)}.., commitment ${hashAngajament(c).slice(0, 18)}.., envelope ${env.statementHash.slice(0, 18)}..`);
|
||||||
|
scrie(c);
|
||||||
|
}
|
||||||
|
else if (cmd === 'notarizeaza') {
|
||||||
|
const db = deschide(dir); const c = versiune(db, a[2]); const f = path.join(dir, 'plicuri', c.version + '.json');
|
||||||
|
if (!fs.existsSync(f)) throw new Error('no envelope for version ' + c.version + ' (run angajeaza first)');
|
||||||
|
const env = JSON.parse(fs.readFileSync(f, 'utf8')); if (env.notarization) { console.log('already notarized: firstSeen ' + env.notarization.firstSeen); process.exitCode = 0; }
|
||||||
|
else {
|
||||||
|
const ethersDir = opt('--ethers-dir'); if (!ethersDir || !opt('--rpc') || !opt('--key-file')) throw new Error('--rpc, --key-file and --ethers-dir are required');
|
||||||
|
const { ethers } = createRequire(path.join(path.resolve(ethersDir), 'package.json'))('ethers');
|
||||||
|
const r = await notarizeEnvelope({ envelope: env, kind: 'aere-proof-of-data', rpc: opt('--rpc'), keyFile: opt('--key-file'), ethers, notary: opt('--notary') });
|
||||||
|
if (!r.ok) { console.log(r.motiv); process.exitCode = r.cod || 1; }
|
||||||
|
else { fs.writeFileSync(f, JSON.stringify(r.envelope, null, 1)); const n = r.envelope.notarization; console.log(`notarized on chain ${n.chainId}: firstSeen ${n.firstSeen}${n.txHash ? ', tx ' + n.txHash + ' block ' + n.block : ' (already)'}`); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (cmd === 'interogheaza') {
|
||||||
|
const db = deschide(dir); const campuri = opt('--campuri', '');
|
||||||
|
scrie(interogheaza(db, a[2], { field: opt('--camp'), eq: J(opt('--eq')), from: J(opt('--de-la')), to: J(opt('--pana-la')), fields: campuri === '*' ? '*' : campuri.split(',').filter(Boolean), limit: Number(opt('--limita', 1000)) }));
|
||||||
|
}
|
||||||
|
else if (cmd === 'inregistrare') { const db = deschide(dir); const campuri = opt('--campuri', '*'); scrie(dovadaInregistrare(db, a[2], Number(a[3]), { fields: campuri === '*' ? '*' : campuri.split(',').filter(Boolean) })); }
|
||||||
|
else if (cmd === 'consistenta') { const db = deschide(dir); scrie(dovadaConsistenta(db, a[2], a[3])); }
|
||||||
|
else if (cmd === 'auditeaza') { const db = deschide(dir); const r = auditeaza(db); console.log(r.ok ? `AUDIT OK: ${db.versiuni.length} versions rebuilt from ${db.inregistrari.length} records, every root matches` : 'AUDIT FAILED:\n ' + r.probleme.join('\n ')); process.exitCode = r.ok ? 0 : 1; }
|
||||||
|
else if (cmd === 'serveste') { const s = await porneste({ dir, port: Number(opt('--port', 8797)), privat: are('--privat') }); console.log('serving ' + s.url + (are('--privat') ? ' (records without field values)' : '')); }
|
||||||
|
else { console.log('usage: vd.mjs creeaza|adauga|angajeaza|notarizeaza|interogheaza|inregistrare|consistenta|auditeaza|serveste ... (see the header)'); process.exitCode = 2; }
|
||||||
|
} catch (e) { console.log('ERROR: ' + String(e.message).slice(0, 300)); process.exitCode = 1; }
|
||||||
54
verifiable-data/verifica.mjs
Normal file
54
verifiable-data/verifica.mjs
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// verifica.mjs: the reader's side of a verifiable dataset, without the data (roadmap point 20). Everything is judged against a
|
||||||
|
// COMMITMENT (a version's JSON) and prints VALID / INVALID with the reasons; exit 0 VALID, 1 INVALID, 2 could not measure.
|
||||||
|
// node verifica.mjs raspuns <answer.json> --angajament <c.json> a query answer: correct, complete, in order
|
||||||
|
// node verifica.mjs inregistrare <record.json> --angajament <c.json> one record with its proof
|
||||||
|
// node verifica.mjs consistenta <proof.json> --vechi <c1.json> --nou <c2.json> the newer version only appended
|
||||||
|
// node verifica.mjs disponibilitate --url <http://host:port/v1/vd/name> --angajament <c.json> [--esantioane 16] [--seed <hex>]
|
||||||
|
// random records fetched and verified (retrievability)
|
||||||
|
// node verifica.mjs angajament <c.json> --plic <env.json> [--verify-proof <verify-proof.mjs>] [--rpc URL --chain 28001]
|
||||||
|
// the AIP-23 envelope is this commitment's; with --rpc,
|
||||||
|
// its notarization under a post-quantum certified anchor
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { verificaRaspuns, verificaInregistrare, verificaConsistentaVersiuni, verificaPlicAngajament, esantioneaza, hashAngajament } from './baza.mjs';
|
||||||
|
|
||||||
|
const AICI = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const a = process.argv.slice(2); const cmd = a[0];
|
||||||
|
const opt = (k, d = null) => { const i = a.indexOf(k); return i >= 0 && i + 1 < a.length ? a[i + 1] : d; };
|
||||||
|
const citeste = (f) => JSON.parse(fs.readFileSync(f, 'utf8'));
|
||||||
|
const iesi = (verdict, motive = [], extra = '') => { console.log(`${verdict}${extra ? ' ' + extra : ''}${motive.length ? '\n ' + motive.join('\n ') : ''}`); process.exitCode = verdict === 'VALID' ? 0 : verdict === 'INVALID' ? 1 : 2; };
|
||||||
|
const ruleaza = (verifyProof, envFile, { rpc, chain }) => new Promise((ok) => {
|
||||||
|
const args = [verifyProof, envFile, '--json', ...(rpc ? ['--rpc', rpc] : []), ...(chain ? ['--chain', String(chain)] : [])];
|
||||||
|
const c = spawn(process.execPath, args); let out = ''; c.stdout.on('data', (d) => { out += d; }); c.stderr.on('data', () => {});
|
||||||
|
const ceas = setTimeout(() => c.kill(), 300000); c.on('error', () => { clearTimeout(ceas); ok(null); }); c.on('close', () => { clearTimeout(ceas); try { ok(JSON.parse(out)); } catch { ok(null); } });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (cmd === 'raspuns') { const r = verificaRaspuns(citeste(a[1]), citeste(opt('--angajament'))); return iesi(r.verdict, r.motive, `(${r.rows} rows)`); }
|
||||||
|
if (cmd === 'inregistrare') { const r = verificaInregistrare(citeste(a[1]), citeste(opt('--angajament'))); return iesi(r.ok ? 'VALID' : 'INVALID', r.motive); }
|
||||||
|
if (cmd === 'consistenta') { const r = verificaConsistentaVersiuni(citeste(a[1]), citeste(opt('--vechi')), citeste(opt('--nou'))); return iesi(r.ok ? 'VALID' : 'INVALID', r.motive); }
|
||||||
|
if (cmd === 'disponibilitate') {
|
||||||
|
const url = opt('--url'); const c = citeste(opt('--angajament')); if (!url) throw new Error('--url is required');
|
||||||
|
const ia = async (u) => { const r = await fetch(u, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20000) }); if (!r.ok) return null; return r.json(); };
|
||||||
|
const r = await esantioneaza({ url, commitment: c, k: Number(opt('--esantioane', 16)), seed: opt('--seed', null), ia });
|
||||||
|
return iesi(r.ok ? 'VALID' : 'INVALID', r.motive, `(${r.verificate} of ${r.cerute} sampled records verified, seed ${r.seed}${r.lipsa.length ? '; not served: ' + r.lipsa.join(',') : ''})`);
|
||||||
|
}
|
||||||
|
if (cmd === 'angajament') {
|
||||||
|
const c = citeste(a[1]); const env = citeste(opt('--plic'));
|
||||||
|
if (!verificaPlicAngajament(env, c)) return iesi('INVALID', ['the envelope is not this commitment\'s (sha256/name differ, or not an aere-proof-of-data attestation)']);
|
||||||
|
const vp = opt('--verify-proof', process.env.AERE_VERIFY_PROOF || path.resolve(AICI, '..', '..', 'verificator-falcon', 'verify-proof.mjs'));
|
||||||
|
if (!fs.existsSync(vp)) return iesi('UNMEASURED', ['the AIP-23 verifier is not at ' + vp + ' (AERE_VERIFY_PROOF or --verify-proof)'], `commitment ${hashAngajament(c)} is the envelope's`);
|
||||||
|
const v = await ruleaza(vp, opt('--plic'), { rpc: opt('--rpc'), chain: opt('--chain') });
|
||||||
|
if (!v) return iesi('UNMEASURED', ['the verifier gave no JSON']);
|
||||||
|
const fin = (v.levels || []).find((l) => l.level === 'finality');
|
||||||
|
const extra = `envelope ${v.verdict}; finality ${fin ? fin.state + ': ' + String(fin.detail).slice(0, 160) : 'absent'}`;
|
||||||
|
if (v.verdict !== 'VALID') return iesi('INVALID', [], extra);
|
||||||
|
if (opt('--rpc') && !(fin && fin.state === 'PASSED')) return iesi(fin && fin.state === 'FAILED' ? 'INVALID' : 'UNMEASURED', [], extra);
|
||||||
|
return iesi('VALID', [], extra);
|
||||||
|
}
|
||||||
|
console.log('usage: verifica.mjs raspuns|inregistrare|consistenta|disponibilitate|angajament ... (see the header)'); process.exitCode = 2;
|
||||||
|
}
|
||||||
|
try { await main(); } catch (e) { iesi('UNMEASURED', [String(e.message).slice(0, 200)]); }
|
||||||
Loading…
Reference in New Issue
Block a user