@aurite-ai/attestation-verifier
v0.8.0
Published
Standalone offline verifier for Kahuna attestation evidence packages. Verifies ed25519 signatures and chain linkage using public keys embedded in the package — no network access to Kahuna required.
Downloads
805
Readme
@aurite-ai/attestation-verifier
External verifier package for Kahuna attestation events.
What this is
This package lets any party — Kahuna customers, third-party auditors, compliance scanners — independently verify the integrity of attestation chains produced by Kahuna agents. It wraps the verify operations from @kahuna/shared-attestation with a public API designed for standalone consumption.
The verifier is pure — given the same inputs (an event and a public key bundle), it returns the same outputs. No network calls during verification. No environment dependencies. No side effects.
Two questions, both answered offline
| Question | Command | Needs |
|---|---|---|
| Are these events authentic and linked? | verify evidence.json | the package |
| Is this all of them? | verify evidence.json --countersigned-head head.json | the package + a file from the customer |
Neither touches the network. The signing keys travel inside the package, and a countersigned head is a local file. Adding the scope check does not make verification less offline — it adds a second local input.
The two questions are genuinely different, and only the first is answerable from the package alone. A curated subset answers "yes" to the first and hides the second.
Why open verification matters
Per the architectural decision baked into the v1 schema (see docs/attestation/event-schema.md §11), Kahuna attestation chains are designed for independent verification. The cryptographic guarantee — "events were produced by the named signing key" — only matters if parties other than Kahuna can verify it.
For evidence packages, the keys travel inside the package, so verification needs no network access at all and no cooperation from whoever operates the chain. That is the point: the party you are auditing is not in the loop when you check their work.
Correction (2026-08-27). This section previously said "Public key bundles are openly accessible (no API key required). Anyone can fetch any organization's bundle." That was not true and has never been true. The
/.well-known/kahuna/public-keys/<org>route exists and is unauthenticated on the server, but in a deployed cluster it sits behind an authenticating load balancer, so an unauthenticated request is redirected to a login page and never reaches it. Making it publicly reachable is tracked as G51(a) and is not in this release.Nothing about offline verification depended on it: the keys are in the package. What it affects is only the optional
--againstcross-check below.
Checking the scope claim — read this before trusting "COMPLETE chain"
Everything an evidence package says about its own extent is written by the party being audited. A genuine 10-event prefix of a 60-event chain can declare itself the complete chain and pass every offline check — real signatures, linked hashes, self-consistent metadata. This was demonstrated, not theorised.
Offline verification answers "are these events authentic and linked?" It does not answer "is this all of them?" Those are different questions and only the first is answerable from the file alone.
The countersigned chain head
The second question needs one number the package cannot contain: a chain head observed by someone other than the operator. A countersigned head is a signed statement by the customer about what they saw:
{
"assertion": {
"kind": "kahuna.chain-head-attestation/v1",
"organization_id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"chain_head_position": 59,
"head_event_hash": null,
"observed_at": "2026-08-20T10:00:00.000Z",
"observer_id": "spiffe://customer.example/agent/attestation_consumer"
},
"signature": "<base64 Ed25519 over the JCS serialization of `assertion`>",
"signer_public_key": "<base64 raw Ed25519 public key>"
}It is not a public key, though it carries one so the signature is checkable. It is a dated, signed claim that the chain had reached position 59.
head_event_hash may be null. The observer records a hash only if it saw the
head event; observing only a position is honest and still sufficient for the
check below. It is never invented.
Why it works
The chain is append-only, so head position only increases. If the customer observed position 59 at time T, then a package assembled at or after T claiming the complete chain ends at position 9 is self-contradictory. That contradiction is what the check reports.
Producing one (customer side)
Runs as a workload in the customer's own environment, signing with a customer-held Ed25519 key — not a Kahuna key, and not the workload's SVID key, which is short-lived by design.
import { startHeadObserver } from "@aurite-ai/attestation-verifier";
startHeadObserver({
serverBase: "http://kahuna-server:9100", // in-cluster; no ingress needed
organizationId: "<org-uuid>",
signingKey: customerEd25519PrivateKey, // 32 bytes, customer-held
observerId: "spiffe://customer.example/agent/attestation_consumer",
intervalMs: 60 * 60 * 1000,
onAttestation: (a) => persistSomewhereTheCustomerControls(a),
});Retention is the whole mechanism. An attestation stored only where the operator can reach it buys nothing — the point is a reference they cannot revise. Frequency sets resolution: hourly bounds any curated package to within an hour of the truth. Infrequent is fine, because the chain only grows, so an old attestation stays valid evidence of a position already reached.
Using one (auditor side)
npx @aurite-ai/attestation-verifier verify evidence.json \
--countersigned-head head.jsonObtain head.json from the customer, not from the package. A countersigned
head that arrived inside the evidence bundle proves nothing — the operator
assembled both.
Outcomes:
| Result | Meaning | |---|---| | exit 0, "a reference this platform does not control" | The package's scope claim is consistent with what the customer observed | | exit 2, "claims the COMPLETE chain ends at N" | Contradicted. The customer had already observed a later position | | exit 2, "No verdict was reached" | The package claims to predate the observation, so nothing follows. Get an attestation observed before the package was assembled | | exit 2, "countersignature did not verify" | The file is not usable as evidence |
Note the third row: a check that cannot reach a verdict fails. Silence must not read as agreement.
What this still does not close
- Whose key is it? The verifier checks that the signature matches the embedded key. It cannot know the key is the customer's. That is the auditor's job, and it is why the file must come from the customer through a channel the operator does not control.
assembled_atis operator-supplied. An operator can claim a package predates the attestation and escape the comparison — which reportsstale_attestationrather than passing. Closing it needs a timestamp the operator does not control (a transparency log). Tracked as G51(c).
--against <server> — deferred
An earlier design fetched the live head from the server. It is weaker (the same party serves the package and the head, so it defeats a curated file but not a determined operator) and it is not reachable in a deployed cluster — see the correction above. Deferred to a future release; the countersigned head is the stronger check and needs no ingress change.
Setting this up (customer, once)
Three steps. After this, every audit is a file handover.
1. Generate a signing key you control
npx @aurite-ai/attestation-verifier keygen --out observer-key.jsonWrites a 0600 file and prints the public key:
Wrote observer-key.json (mode 0600).
PRIVATE half — never send this to Kahuna or anyone else.
PUBLIC key, base64 — give this to your auditors:
iptR2KuIx9Nye3VbFUqM34dwUBbuQRZCAW1WzPrgaoM=It runs entirely on your machine. Kahuna never sees the private half — that is the property the whole mechanism rests on, which is also why there is no "generate a key" button in the Kahuna console: a console page would put our code in your key path, and an auditor could not rule out that we kept a copy.
Load observer-key.json into your observer workload the way you load any other
secret — a Kubernetes Secret, your secret manager, a mounted file.
Losing this key is cheap. Everything already signed stays valid, and old attestations remain good evidence of positions the chain had already reached. Generate a new one and carry on. That is also why we do not derive it from a passphrase: the key would be only as strong as the phrase, a guessed phrase forges countersignatures, and there is no recovery need to justify the risk.
1b. Declare the public half to Kahuna (optional, and not a trust root)
Recording your public key with Kahuna lets you check that the key we show alongside an evidence package is the one you actually hold. Declaring requires a possession proof:
npx @aurite-ai/attestation-verifier prove \
--key observer-key.json \
--org <your-organization-id> \
--observer-id <your-reference>Send the printed JSON as possession_proof to
POST /management/v1/observer-keys. The proof contains your public key and
a signature; your private key is used to sign and never leaves the process. It
is valid for five minutes.
Why a proof is required. An Ed25519 private key and a public key are both
32 base64 bytes, so nothing about the value itself distinguishes them. Pasting
the private_key line where public_key belongs would pass every structural
check and disclose the half that makes your countersignature mean anything —
and you would not find out until verification failed later, looking like a
broken key rather than a leaked one. The proof catches it immediately.
This does not make our copy of your key authoritative. We are the party being audited; a key an auditor learns from us is a key we could have substituted. The sound path is still the one in step 3 — you hand the key to your auditor directly. Declaring it here buys you the ability to notice a substitution, and buys an auditor nothing on its own.
2. Run the observer in your cluster
Any long-running workload will do — a small Deployment of its own is the usual
shape. It needs network access to kahuna-server and read access to the key.
import { loadObserverKey, startHeadObserver } from "@aurite-ai/attestation-verifier";
const key = loadObserverKey("/etc/kahuna/observer-key.json");
startHeadObserver({
serverBase: "http://kahuna-server:9100", // in-cluster; no ingress needed
organizationId: process.env.KAHUNA_ORG_ID!,
signingKey: key.privateKey,
observerId: "spiffe://your-org.example/agent/attestation-observer",
intervalMs: 60 * 60 * 1000, // hourly is plenty — see below
onAttestation: async (a) => {
// `a` serialized IS the head.json an auditor is later handed.
await appendToYourOwnStorage(`${JSON.stringify(a)}\n`);
},
onError: (e) => log.warn({ e }, "head observation failed; will retry"),
});Each attestation looks like this — and this object, written to a file, is
exactly what --countersigned-head consumes:
{
"assertion": {
"kind": "kahuna.chain-head-attestation/v1",
"organization_id": "…",
"chain_head_position": 59,
"head_event_hash": null,
"observed_at": "2026-08-20T10:00:00.000Z",
"observer_id": "spiffe://your-org.example/agent/attestation-observer"
},
"signature": "…",
"signer_public_key": "iptR2KuIx9Nye3VbFUqM34dwUBbuQRZCAW1WzPrgaoM="
}Hourly is generous. The chain only grows, so an old attestation stays valid evidence of a position already reached; frequency sets how tightly a curated package can be bounded, not whether the check works.
3. Retain the output somewhere Kahuna cannot reach
This is the mechanism, not an implementation detail. An attestation stored where the operator could revise it proves nothing. Object storage in your account, your log pipeline, anywhere under your control.
You do not need all of them. One attestation older than the package under audit is enough.
Receiving an audit (auditor)
You should be handed three things. Ask for the third if it is missing.
| | What | Why |
|---|---|---|
| 1 | evidence.json | the events and the keys that signed them |
| 2 | head.json | a countersigned chain head, from the customer |
| 3 | the customer's public key, through a channel the operator does not control | so #2 means something |
Then:
npx @aurite-ai/attestation-verifier verify evidence.json \
--countersigned-head head.jsonCheck the key yourself — the file carries signer_public_key, and it should
equal what the customer gave you separately:
jq -r .signer_public_key head.json # must match the key you were givenIf those differ, stop. A countersigned head whose key you learned only from the file proves nothing about who observed the chain.
Why the separate channel matters. The verifier checks that the signature matches the embedded key. It cannot know whose key that is. If the operator supplied the package, the head file and the key, they supplied the entire chain of custody and the check is theatre.
Installation
npm install @aurite-ai/attestation-verifierCLI
# Verify an exported evidence package — fully offline
npx @aurite-ai/attestation-verifier verify evidence.json
# Export a package from a running Kahuna server
npx @aurite-ai/attestation-verifier export --org <uuid> --server <url> --out evidence.jsonExit codes: 0 passed · 1 usage / unreadable file / malformed JSON ·
2 verification failed (signature, chain position, or hash linkage).
Both attestation-verifier and kahuna-verify are installed as binaries; they are the same tool.
What a pass proves — and what it does not
A pass proves every event in the file is authentically signed and that the events form an unbroken run.
It does not, on its own, prove the file contains the whole chain. A window is verified against its own first event, so a partial export passes exactly like a complete one. Compare metadata.chain_position_start and metadata.chain_position_end against the range you expected. Do not rely on metadata.window_complete — see tracker G31; it is not currently trustworthy.
Library usage
Verify a single event
import { verifyEvent, type PublicKeyBundle } from "@aurite-ai/attestation-verifier";
const bundle: PublicKeyBundle = await fetchYourOrgBundle();
const event = await fetchEventFromKahuna();
const result = await verifyEvent(event, bundle);
if (!result.valid) {
console.error("Verification failed:", result.failure.reason);
console.error("Message:", result.failure.message);
}Verify a chain segment
import { verifyChainSegment } from "@aurite-ai/attestation-verifier";
const events = await fetchChainSegment(); // events in chain_position order
const result = await verifyChainSegment(events, bundle);
if (result.valid) {
console.log("Chain head:", result.chainHead);
} else {
console.error(
`Verification failed at segment index ${result.failure.failed_at_segment_index}:`,
result.failure.reason,
);
}Note that verifyChainSegment accepts an optional third argument, startingState. Without it, the segment is verified against its own first event — which is why a partial window passes like a complete one. Pass a ChainStartingState when you know where the segment is supposed to attach.
Verify an evidence package
import { verifyEvidencePackage, type EvidencePackage } from "@aurite-ai/attestation-verifier";
const summary = await verifyEvidencePackage(pkg);
if (!summary.valid) throw new Error(summary.failure?.message);Failure reasons
The verifier returns structured failures per schema §9.3. The full set:
schema_invalid— event is malformed (missing fields, wrong types, etc.)signature_invalid— Ed25519 signature didn't verifykey_unknown— the named signing key isn't in the bundlechain_position_gap— adjacent events have non-consecutive positionschain_position_duplicate— two events share a positionprevious_hash_mismatch— a chain link is brokenorganization_mismatch— events span multiple organizationsinternal_inconsistency— an internal-consistency check failed (e.g.,recorded_at < occurred_at)bounds_exceeded— an event exceeds a size or cardinality bound
Status
Current: v0.3.0. The verify operations are real and tested. fetchPublicKeyBundle() is implemented against GET <server>/.well-known/kahuna/public-keys/<organizationId>.
Not yet done: the package is not published to any registry. Until it is, npx @aurite-ai/attestation-verifier cannot work for anyone outside this repository — see tracker G30, which is what that row exists to close.
Building and publishing (maintainers)
pnpm build runs scripts/build.mjs, which bundles the library and CLI with esbuild and emits declarations with tsc. The two @kahuna/* workspace packages are inlined into the bundle — they do not exist on npm, so a published package that imported them would fail on the customer's first run. The build asserts this rather than assuming it, and also asserts that every remaining bare import is a declared runtime dependency.
Publish with pnpm publish, never npm publish. The manifest points main/types/bin at src/ for in-repo consumers and overrides them to dist/ under publishConfig. pnpm applies those overrides; npm does not, so npm pack produces a tarball whose entry points reference TypeScript sources that are not shipped.
To check the artifact the way a customer receives it:
pnpm pack --pack-destination /tmp
mkdir /tmp/clean && cd /tmp/clean && npm init -y
npm install /tmp/aurite-ai-attestation-verifier-0.3.0.tgz
npx @aurite-ai/attestation-verifier verify <some-package.json>Run that from outside this repository. Every defect tracked in G30 was invisible from inside it.
License
MIT — see LICENSE. This package is MIT even though the wider Kahuna repository is not: a verifier that third parties are told to run has to be one they are actually permitted to run.
