212 lines
19 KiB
Markdown
212 lines
19 KiB
Markdown
# 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 (`--at` is 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-log` on that file alone reports it intact. A partial edit is caught (the chain breaks at the edited entry and
|
|
`verify-log` names it), a full rewrite is not.
|
|
- **What is checkable without trusting the host is the history before a published head.** `attest-head` turns the current
|
|
head into an envelope; `notarize-head` records 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 of `firstSeen[statementHash]`). `verify-log --attested <head.json>` requires that today's log **continues** that head:
|
|
the same first `count` entries, the entry `count - 1` hashing to the attested head. A rewritten or shortened history fails.
|
|
|
|
- **A head can be signed (2026-09-29).** `keygen` makes an ML-DSA-65 key for the operator; `attest-head --sign-key` signs the
|
|
head statement in the form the AIP-23 reference verifier checks at its `signature` level; `verify-log --attested head.json
|
|
--signer head.pub.pem` then 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 `--signer` a 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 `lastStatus` `RUNNING`; EC2: `State.Name` `running`;
|
|
Azure VM: `powerState` `VM running` (the field `-d` adds; an export without it is refused, it does not say whether a machine runs);
|
|
Container Instances: `containers[].instanceView.currentState.state` `Running` (`az container list` may omit `instanceView`; such an
|
|
export is refused and `az container show` is asked for); Container Apps: `runningStatus` `Running` (`Progressing`, `Stopped`,
|
|
`Suspended` and `Ready`, which the API enumerates without defining, do not count); App Service: `state` `Running`; Compute
|
|
Engine: `status` `RUNNING`; Cloud Run: the `Ready` condition `True`. Lambda is the exception the vendor documents: `list-functions`
|
|
does not return `State`, so every function listed is recorded as deployed with `state: not-in-export`; when the export carries
|
|
`State` (from `get-function-configuration`), only `Active` functions 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 ECS `imageDigest`, an image given as
|
|
`name@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, marked `imageIdentity: declared` (an AMI id, an Azure `imageReference`, a GCE boot disk and its
|
|
licenses, a `DOCKER|image` or `NODE|20-lts` runtime); Lambda carries `CodeSha256`, 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: `environment` and `secrets` of the task definition
|
|
plus `overrides`; Lambda: the keys of `Environment.Variables`; Container Instances: `environmentVariables`, neither `value` nor
|
|
`secureValue`; Container Apps: `env`, with `secretRef` names, never `configuration.secrets`; App Service: `appSettings` and
|
|
`connectionStrings` names; Cloud Run: `env`, never `value` or `valueFrom`). EC2 tags, Azure tags, GCE labels and GCE metadata enter
|
|
as keys (`startup-script`, `user-data`, `ssh-keys` live in metadata, with values the adapter never copies). `customData` and
|
|
`userData` of a VM, the admin user, SSH keys, registry credentials, IP addresses and host names are **not copied at all**; an
|
|
ECS secret's `valueFrom` ARN 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.
|