19 KiB
AERE Verification Layer (sidecar)
A sidecar that runs next to a deployment, on any cloud or on premises, and keeps an audit log of it. Every entry is an
AERE Proof Protocol envelope (AIP-23), built by the same proof-kinds builder and checked by the same AIP-23 reference
verifier as every other AERE proof, and every entry covers the hash of the one before it (an append-only hash chain,
hash = sha256(seq | prev | envelope), genesis 0x00..00). The head of the chain can be notarized on Aere Network, which
binds the history up to that head under a post-quantum validator certificate that anyone can check (what such a
certificate proves, and what it does not, is in the AIP-22 erratum of 2026-10-01).
Node.js 24, no dependencies; notarize-head alone needs ethers (npm install in this directory).
What it proves, and what it does not
-
The content of the entries is what the host says. The sidecar runs on the host and reads the export the operator makes. An entry proves that the host declared a container was running, with this image and this configuration, at the time it declared (
--atis also a declaration). It does not prove that the declaration is true. -
The chain has no key. Whoever can write the log file can rebuild the whole chain from genesis with any content, and
verify-logon that file alone reports it intact. A partial edit is caught (the chain breaks at the edited entry andverify-lognames it), a full rewrite is not. -
What is checkable without trusting the host is the history before a published head.
attest-headturns the current head into an envelope;notarize-headrecords its hash on the AereNotary contract; the AIP-23 reference verifier then shows it under a post-quantum certificate (the most recent certified anchor, the parent header bound by hash, its state root, and a Merkle proof offirstSeen[statementHash]).verify-log --attested <head.json>requires that today's log continues that head: the same firstcountentries, the entrycount - 1hashing to the attested head. A rewritten or shortened history fails. -
A head can be signed (2026-09-29).
keygenmakes an ML-DSA-65 key for the operator;attest-head --sign-keysigns the head statement in the form the AIP-23 reference verifier checks at itssignaturelevel;verify-log --attested head.json --signer head.pub.pemthen requires that the head was signed by that key. A signature says who vouches for the head, not when (the notarization says when) and not that the history is true: whoever holds the key can sign the head of a rewritten history, and without--signera head signed by anyone passes, which the output says.
So: publish heads often (every deployment, or on a timer), sign them with the operator's key, and judge a log against the latest head you hold from outside the host, with the signer you expect, never on its own.
Commands
node sidecar.mjs record --kind runtime --artifact <file> --attested 0x<sha256> [--host h] [--log p] [--at T]
node sidecar.mjs record --kind deployment --name <name> --version <v> --content-file <file> [--host h] [--log p] [--at T]
node sidecar.mjs record --kind proof --proof-file <envelope.json> # any AIP-23 envelope, after checking its form and hash
node sidecar.mjs scan --source docker|kubernetes --input <export.json> [--host h] [--log p]
node sidecar.mjs scan --source aws-ecs|aws-ec2|aws-lambda|azure-vm|azure-container|azure-containerapp|azure-webapp|gcp-compute|gcp-run --input <export.json> [--task-definitions <f>]
node sidecar.mjs verify-log --log p [--attested head.json [--signer head.pub.pem]] # 0 intact (and continues the head), 1 broken or not continued
node sidecar.mjs attest-head --log p [--host h] [--out head.json] [--sign-key head.key.pem] # the head as an aere-audit-head envelope
node sidecar.mjs keygen --out <dir> # the operator's ML-DSA-65 head key (head.key.pem 0600, head.pub.pem)
node sidecar.mjs notarize-head --head head.json --rpc <url> --key-file <file> [--notary 0x..] [--out notarized.json]
node sidecar.mjs bundle --log p [--out bundle.json] # {host, count, head, chainOk, entries[]} for a console
The default log is aere-audit.log in the current directory. Writers take a lock file beside the log (<log>.lock, holding
the writer's process id), so two writers on the same host do not break the chain; a lock left by a process that no longer
exists is removed after 10 seconds. A log that is already broken is never appended to.
record --kind runtime records that a file on the host (the binary that runs) has the digest you expected: matches says
whether it does. record --kind deployment records the digest of a deployed artifact, never its content.
The runtime adapter
scan reads an export the operator makes on the host, so the sidecar needs no access to the Docker daemon, the cluster, or
any cloud credential:
docker inspect $(docker ps -q) > export.json # then: scan --source docker --input export.json
kubectl get pods -A -o json > export.json # then: scan --source kubernetes --input export.json
For every container that is running, it writes a canonical descriptor (image and image id, name, namespace and pod on
Kubernetes, start time, restart count, the hash of the command line, the names of the environment variables, the
destinations of the mounts) and records the descriptor's digest as a deployment entry. No environment value, no argument in
clear and no host path of a mount is stored. The descriptors themselves are written beside the log
(<log>.descriptori.jsonl), so an auditor can re-hash each one and compare it with the chain. An export with no running
container, or one that is not in the expected shape, is refused rather than recorded as "zero deployments".
A hash does not hide a guessable secret: if a password is passed on a command line, anyone who knows the rest of the command
can test guesses against commandSha256. Pass secrets through files or the environment, not arguments.
Cloud exports (2026-10-18)
The same adapter, in the same honest form, for the workloads a cloud runs for you: the operator makes the export with the vendor's
own CLI and their own credentials, on their own machine; the sidecar reads the file. The sidecar holds no cloud credential and
calls no cloud API. These adapters are measured on the outputs as the vendors document them (fixturi-cloud/, every file an exact
copy of a documented example or, for GCP, a form built from the documented schema), not on a live account: Aere Network has no
AWS, Azure or GCP account. What a live export carries beyond the documented example is handled as documented and is NOT MEASURED;
a test account at each vendor is what it would take to measure it.
aws ecs describe-tasks --cluster C --tasks $(aws ecs list-tasks --cluster C --query 'taskArns' --output text) > tasks.json
aws ecs describe-task-definition --task-definition <family:revision> > def.json # one per task definition in use
node sidecar.mjs scan --source aws-ecs --input tasks.json --task-definitions def.json # def.json may hold one output or a JSON list of them
aws ec2 describe-instances > ec2.json; node sidecar.mjs scan --source aws-ec2 --input ec2.json
aws lambda list-functions > lambda.json; node sidecar.mjs scan --source aws-lambda --input lambda.json
az vm list -d > vm.json; node sidecar.mjs scan --source azure-vm --input vm.json
az container show -g G -n N > aci.json (collect several into a JSON array); node sidecar.mjs scan --source azure-container --input aci.json
az containerapp list > aca.json; node sidecar.mjs scan --source azure-containerapp --input aca.json
az webapp list --show-details > web.json; node sidecar.mjs scan --source azure-webapp --input web.json
gcloud compute instances list --format=json > gce.json; node sidecar.mjs scan --source gcp-compute --input gce.json
gcloud run services list --format=json > run.json; node sidecar.mjs scan --source gcp-run --input run.json
What each adapter reads, and what it refuses:
- Only what the export says is running. ECS: tasks and containers with
lastStatusRUNNING; EC2:State.Namerunning; Azure VM:powerStateVM running(the field-dadds; an export without it is refused, it does not say whether a machine runs); Container Instances:containers[].instanceView.currentState.stateRunning(az container listmay omitinstanceView; such an export is refused andaz container showis asked for); Container Apps:runningStatusRunning(Progressing,Stopped,SuspendedandReady, which the API enumerates without defining, do not count); App Service:stateRunning; Compute Engine:statusRUNNING; Cloud Run: theReadyconditionTrue. Lambda is the exception the vendor documents:list-functionsdoes not returnState, so every function listed is recorded as deployed withstate: not-in-export; when the export carriesState(fromget-function-configuration), onlyActivefunctions enter. - A file that is not the named CLI's output is refused by name (
the input is not the output of \aws ecs describe-tasks``), as is an export with no running workload: a zero is not recorded. - Identity. The image with its digest when the export has one (
imageIdentity: digest: an ECSimageDigest, an image given asname@sha256:...on Container Instances, Container Apps or Cloud Run); otherwise the id of the machine or service and the image or runtime the export declares, markedimageIdentity: declared(an AMI id, an AzureimageReference, a GCE boot disk and its licenses, aDOCKER|imageorNODE|20-ltsruntime); Lambda carriesCodeSha256, the digest of its package (codeSha256). An entry's name is the region or zone and the workload's own identifiers (ecs:cluster/task/container,ec2:region/instance,lambda:region/function,azvm:group/name,aci:group/name/container,aca:group/app/container,azwebapp:group/site,gce:zone/name,run:region/service/container), and its version the digest when there is one. - Names only, never values. Environment variables enter as their names (ECS:
environmentandsecretsof the task definition plusoverrides; Lambda: the keys ofEnvironment.Variables; Container Instances:environmentVariables, neithervaluenorsecureValue; Container Apps:env, withsecretRefnames, neverconfiguration.secrets; App Service:appSettingsandconnectionStringsnames; Cloud Run:env, nevervalueorvalueFrom). EC2 tags, Azure tags, GCE labels and GCE metadata enter as keys (startup-script,user-data,ssh-keyslive in metadata, with values the adapter never copies).customDataanduserDataof a VM, the admin user, SSH keys, registry credentials, IP addresses and host names are not copied at all; an ECS secret'svalueFromARN is not copied either. Where the export does not carry the names (ECS tasks without their task definition, a site listed without--show-details), the descriptor says so (envSource: not-in-export,siteConfigInExport: false) instead of pretending.
As with Docker and Kubernetes, an entry proves that the export declared the workload, at the time the operator says: the vendor's API answered the operator, and the operator handed the file to the sidecar. Nothing here checks the export against the vendor.
Notarization
notarize-head reads the chain id from the RPC endpoint (it does not trust an argument), refuses an attestation whose
statement does not match its hash before sending anything, and sends nothing if the head is already notarized. On chain 2800
(Aere Network mainnet) the notarization is a real transaction paid by the key's account, so it is sent only with
AERE_CONFIRM_MAINNET=yes. The key is read from a file (d=<hex> or PRIVATE_KEY=0x<hex>) and never printed; error
messages are cut before any long hex string.
dovezi-notarizare-testnet/ holds a real run on the public testnet (chain 28001): a log of three test deployments and its
head, notarized in block 3,699,939 on 2026-09-27. Anyone can check both halves:
node sidecar.mjs verify-log --log dovezi-notarizare-testnet/audit.log --attested dovezi-notarizare-testnet/cap-notarizat-28001.json
node verify-proof.mjs dovezi-notarizare-testnet/cap-notarizat-28001.json # the AIP-23 reference verifier (aere.network/tools)
The first says the log is the one that was notarized; the second, measured on 2026-09-29, reports finality: PASSED post-quantum under a certified anchor of the testnet, and VALID.
The log as a Merkle tree (RFC 9162)
The same entries, seen as a Merkle tree in the manner of Certificate Transparency 2.0 (RFC 9162, section 2.1): leaf i is the hash
the chain already gives entry i, with the RFC's separation between leaves (0x00) and nodes (0x01). With the chain alone, checking
that one entry is in the log, or that a new head extends an old one, means receiving and replaying the whole log; with the tree, a
proof of about log2(n) hashes is enough and shows no other entry.
node sidecar.mjs tree-head --log audit.log --sign-key head.key.pem --out tree-head.json # size and root, an AIP-23 envelope
node sidecar.mjs prove-inclusion --log audit.log --seq 7 --out inclusion.json
node sidecar.mjs verify-inclusion --proof inclusion.json --head tree-head.json --signer head.pub.pem # no log needed
node sidecar.mjs prove-consistency --log audit.log --from 12 --out consistency.json
node sidecar.mjs verify-consistency --proof consistency.json --old head-12.json --new head-20.json --signer head.pub.pem
The tree head is signed with the operator's key in the same form as the chain head (so the AIP-23 verifier checks its signature),
and can be notarized. An inclusion proof carries the entry itself: the verifier recomputes its hash from its content and checks its
envelope, so an internal node cannot be passed off as an entry. A proof names a tree by its size and root; bind it to a head you
received (--head), not to the numbers in the proof. Two heads of the same log with no consistency proof between them mean the
history was rewritten. What it does not prove, as for the chain: that the entries are true (the host writes them), or that no
other log with other heads was shown to someone else; whoever receives heads from both, or notarized heads, sees it. A head is
only as good as where you got it: without --signer, anyone can make two unsigned heads of an invented log that extend each
other, so both verify commands then say whether each head is signed and by which key, and that no expected signer was checked.
Tests
node proba-sidecar.mjs # 45 checks
node proba-adaptoare-cloud.mjs # 60: the nine cloud adapters on the documented exports (fixturi-cloud/)
node control-negativ-sidecar.mjs # puts each guard back to its absent form and requires the named check to fail (11 + 27 cloud plantings)
node proba-arbore.mjs # 20: the tree (the reference roots of Certificate Transparency for 1..8 leaves, every proof up to 64 leaves, each attack)
node control-negativ-arbore.mjs # 14 guards removed in a copy, each turns its named test red
proba-sidecar.mjs records runtime, deployment and envelope entries; checks that a modified entry breaks the chain at its
seq, a changed hash is caught, and nothing is appended to a broken chain; that a chain rebuilt from genesis passes
verify-log alone (the stated limit) but fails against the attested head, as do a shortened log and a modified attestation,
while a log that only grew passes; that a signed head passes the verifier's signature level and --signer, while a head
signed by another key, an unsigned head with --signer, a damaged signature, and a head signed with a classical key that
declares ml-dsa-65 are refused; that two writers appending 150 entries each at the same time leave an intact chain of 300;
that a line that is not JSON is reported as broken at its seq; that the Docker and Kubernetes adapters record only running
containers and no secret value; and that every envelope is VALID for the AIP-23 reference verifier. The verifier is looked
for at AERE_VERIFY_PROOF (verify-proof.mjs as published at aere.network/tools, with verify-anchor.mjs, mpt.mjs, falcon512.mjs, registry-v3.mjs and package.json beside it and npm install done there); without
it, the five checks that need it are reported as skipped and the exit code is 2, not 0.
control-negativ-sidecar.mjs measured 11 of 11 on 2026-10-05: the version before the review (taken from the development
history, skipped where that history is absent) and ten plantings, each disabling one guard (the requirement that the key of a
head signature be an ML-DSA-65 key, the writer lock, the head
comparison, the length check, the attestation hash check, the handling of an unreadable line, the digest check of --attested,
the head signature check, the comparison with --signer, the refusal of an unsigned head when --signer is given).
proba-notarizare-testnet.mjs --key-file <testnet key> repeats the on-chain run above (9 checks, it needs a funded testnet key).
proba-adaptoare-cloud.mjs (2026-10-18) runs every cloud adapter on the documented export, on the same export with one stopped
workload added, and on the same export with environment values planted (fixturi-cloud/, derived by deriva.mjs from the exact
copies in fixturi-cloud/surse/, and compared with them at every run): it requires exactly the running workloads with their
identities, no planted value anywhere in the log or the descriptors (and the names present, as the positive control), another CLI's
export refused by name, every descriptor re-hashing to the digest in its envelope, and every envelope VALID for the reference
verifier. control-negativ-sidecar.mjs adds three plantings per adapter (the running filter removed, the names replaced by the
name/value pairs, the shape check removed) and requires each to turn its named check red: measured 38 of 38 on 2026-10-18. The
adapters are producers, not verifiers, so they name no rule of the conformance suite (conformance/), whose rules are those of the
log and tree verifiers, unchanged.
Corrected on 2026-10-03. Until then the check of a head's signature required the DECLARED scheme to be ml-dsa-65 but not
the key to be an ML-DSA-65 key: a head signed with an Ed25519, RSA or P-256 key that declared ml-dsa-65 was reported as a head
signed with ML-DSA-65. Found while this verifier was enrolled in the conformance suite (conformance/), where it is now a vector.
The key must be an ML-DSA-65 key, or the head is refused by name. Every refusal of verify-log, of the inclusion check and of the
consistency check now also carries a rule id from a declared list (REGULI in sidecar.mjs and jurnal-arbore.mjs).
What it is not (yet)
It does not report to a live console over the network. Its per-cloud adapters (2026-10-18) read the operator's CLI export: no credentials, no live API calls, and they are measured on the vendors' documented outputs, not on a live account (none is held). It signs heads, not each entry, and the host name in an entry is still a declaration. No third party has reviewed it.