# Verify a signed attestation offline

> Check an exported agent bill of materials or action-receipt checkpoint yourself, with no call to Anectico.

Canonical page: https://anectico.com/docs/reference/verify-attestations/


Anectico signs two kinds of artifact with a dedicated Ed25519 key:

- an **agent bill of materials** — the components an agent asset depends on at a point in time, and
- an **action-receipt checkpoint** — an assertion about how long one action-receipt chain was, and
  what its last entry's digest was, at a point in time.

Both are **detached** signatures. You can hand either artifact to an auditor, a regulator, or your
own security team, and they can check it without an Anectico account, an API key, or a request to
us. This page is the complete contract they need.

## What a signature proves — and what it does not

A valid signature proves exactly one thing: **the holder of this Anectico private key asserted these
exact bytes.**

It does **not** prove:

- that the contents are accurate — signing attests to the bytes, not to the world they describe;
- that `generated_at` is an independently witnessed time — it is the time we recorded, not a time
  anyone else vouched for;
- that a compromised signer did not produce false or backdated artifacts.

Making the *set* of attestations append-only to a party we cannot influence is a different mechanism
(a transparency log with an external witness) and Anectico does not offer one. Hardware-backed key
custody is likewise not offered today. We would rather you know the shape of the claim than assume a
larger one.

What the signature *does* give you is independence: verification never requires a secret. That is
why the signature is asymmetric rather than a shared-secret MAC — a MAC can only be checked with the
key that could also forge it, so handing you verification would hand you forgery.

## Get the verification keys

```
GET https://<your Anectico host>/.well-known/anectico-signing-keys.json
```

No credential. The response is a JWK Set:

```json
{
  "keys": [
    {
      "kty": "OKP",
      "crv": "Ed25519",
      "alg": "EdDSA",
      "use": "sig",
      "kid": "anectico-abom-2026-08",
      "x": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg"
    }
  ]
}
```

`x` is the raw 32-byte public key as **unpadded base64url**.

**Retired keys stay in this document permanently.** An artifact signed in 2027 is still verifiable in
2030, because the key it names is still published. Take the `kid` from **inside** the artifact — not
from any wrapper around it — and look it up here.

## The byte contract

Every artifact carries a `canonical_form`: the exact bytes the signature covers. **Verify against
that string, byte for byte.** Do not re-serialize the artifact's other fields and hope to reproduce
it — the other fields are a convenience view over the canonical form, not a second copy of it.

The canonical form is [RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785) JSON encoded as
UTF-8: object members sorted by key, no insignificant whitespace, minimal string escaping, integers
in shortest decimal form, and non-ASCII characters emitted as literal UTF-8 rather than `\uXXXX`
escapes. Beyond JCS, five rules are fixed so nothing is left to guess:

1. **Timestamps** are UTC RFC 3339 with **exactly nine** fractional digits, trailing zeros kept:
   `2026-08-16T09:30:00.000000000Z`.
2. **Enumerated values enter as integers**, not display names. A bill-of-materials component's
   `kind` is the numeric value from the API's `AssetKind` enumeration.
3. **Every field is always present**, including empty optional identifiers, which appear as `""`.
   The key set never depends on the data.
4. **Components are sorted** by `(kind, ref, version, content_sha256, declared_by)` before encoding.
   Every other array's order is significant and is encoded as given.
5. **`release` is the one member whose value may be `null`.** The key is still always present — rule
   3 holds — but its value distinguishes two facts that must never collapse into one: `null` means
   the producer named no release, and `""` means the producer named the empty label. They are
   different bytes, a different digest and a different signature.

`signing_key_id` and `signature_alg` are **inside** the signed bytes, so neither can be swapped after
the fact. For a bill of materials, `authority` is inside them too — see below.

### An agent bill of materials

```json
{"asset_id":"7c9e6679-7425-40de-944b-e07fc1f90ae7","asset_version_id":"1b4e28ba-2fa1-11d2-883f-0016d3cca427","authority":"declared","components":[{"content_sha256":"bbbb…","declared_by":"telemetry","kind":1,"ref":"agent:triage","version":"4.0.1"}],"deployment_id":"","generated_at":"2026-08-16T09:30:00.000000000Z","manifest_id":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","release":"2026.08.16","schema":"anectico.abom.v1","scope":{"environment":"production","org_id":"…","project_id":"…"},"signature_alg":"EdDSA","signing_key_id":"anectico-abom-2026-08"}
```

**`release` says which release this bill of materials is for**, using the same label the agent puts
on its spans as `gen_ai.agent.version`. It is inside the signed body so that "this is the
composition for the release that ran" is something you can check yourself: two releases of one
unchanged composition are two artifacts with two digests and two signatures, and a manifest signed
for one cannot be presented as the manifest for the other without breaking the signature. `null`
means the producer declared no release at all — not the same thing as `""`.

**Read `authority` before you read anything else in a manifest.** It has exactly two values and they
mean different things:

| `authority` | What the signature establishes |
|---|---|
| `declared` | The organization named in `scope` submitted this composition and Anectico notarized it. The signature proves what was declared and that it is unchanged since — **not** that the components are accurate. |
| `mediated` | Anectico itself supplied the composition. |

It is a member of the signed body, not a field beside it, precisely so that this distinction survives
export: a manifest handed to you as a file carries its own authority, and no column, wrapper or
covering email can change it without breaking the signature. A verifier that ignores it is reading a
customer's declaration as a platform finding.

### An action-receipt checkpoint

```json
{"chain_id":"…","entry_sha256":"9f9f…","generated_at":"2026-08-16T09:30:00.000000000Z","schema":"anectico.receipt-checkpoint.v1","scope":{"environment":"production","org_id":"…","project_id":"…"},"seq":41,"signature_alg":"EdDSA","signing_key_id":"anectico-abom-2026-08"}
```

`seq` is the sequence number of the **last** entry covered, so a chain of 42 entries checkpoints at
`seq` 41.

## What is actually signed

Not the canonical form directly, and not the bare digest. Compute the raw 32-byte SHA-256 of the
canonical form, then verify the Ed25519 signature over:

```
ASCII(<domain>) || 0x00 || <32-byte SHA-256 digest>
```

where `<domain>` is:

| Artifact | Domain string |
|---|---|
| Agent bill of materials | `anectico.abom.signature.v1` |
| Action-receipt checkpoint | `anectico.receipt-checkpoint.signature.v1` |

The trailing NUL byte is part of the input. The two domains are separate on purpose: **a signature
made for one artifact type will not verify as the other**, so a checkpoint can never be presented as
a bill of materials or the reverse.

The `signature` field is the Ed25519 signature as **unpadded base64url** (64 bytes decoded).

## Verify it

```python
import base64, hashlib, json
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

ABOM_DOMAIN       = b"anectico.abom.signature.v1\x00"
CHECKPOINT_DOMAIN = b"anectico.receipt-checkpoint.signature.v1\x00"

def b64url(s):                       # unpadded base64url -> bytes
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def verify(artifact, jwks, domain):
    canonical = artifact["canonical_form"].encode("utf-8")

    # The digest recorded beside the artifact must be the digest OF these bytes.
    recorded = artifact.get("manifest_sha256") or artifact["checkpoint_sha256"]
    if hashlib.sha256(canonical).hexdigest() != recorded:
        return False

    # The key id comes from INSIDE the signed bytes, never from a wrapper.
    kid = json.loads(canonical)["signing_key_id"]
    jwk = next((k for k in jwks["keys"] if k["kid"] == kid), None)
    if jwk is None:
        return False

    pub = Ed25519PublicKey.from_public_bytes(b64url(jwk["x"]))
    try:
        pub.verify(b64url(artifact["signature"]), domain + hashlib.sha256(canonical).digest())
        return True
    except Exception:
        return False
```

Test your implementation against these fixed vectors before trusting it. They are the same ones the
platform's own tests are pinned to.

| Value | |
|---|---|
| Key id | `anectico-abom-2026-08` |
| Public key (`x`) | `A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg` |
| Canonical-form SHA-256 | `55a4b06e4214bc956a8602d004c124d9f6132903af066cd33827aeee70125daa` |
| Signature (bill of materials domain) | `kryppUbmXxJMgwxs-ZBzeWwBxW6MZTLpxkdZz9hm63sMozjslk-LhZi7AXdKiYDIpMnceudaa2NG-NfxFVk1AQ` |

A correct implementation also **rejects** that same signature when checked in the checkpoint domain.
If yours accepts it, your domain separation is not wired up.

## What a checkpoint is for

Action receipts are chained: each entry's digest covers the previous entry's digest. That makes the
chain tamper-evident against **modification and reordering** — but not against **truncation**, since
deleting the end of a chain leaves a shorter chain that is perfectly self-consistent.

A checkpoint you have **retained yourself** closes that. Your copy says the chain held *n* entries
ending in a specific digest at a specific time, signed by a key whose private half never left
Anectico. Nothing we do later can reach your copy.

That is only true of a copy **you** hold. Ask for a checkpoint on a schedule that matches how much
history you need to be able to prove, store it outside Anectico, and keep the ones you have.

```http
POST /api/v1/receipt-chains/{chainId}/checkpoints?project_id=...
```

Requires the `agents:read` scope — not a write scope, because you supply nothing that goes into the
signed bytes: the sequence number and the digest are read from the chain itself. The chain is checked
first, and one that does not currently verify is **not** checkpointed; signing a state already known
to be faulty would make it look attested. Your project's chain id is `actions`.

## Ask the platform to check a chain

```http
GET /api/v1/receipt-chains/{chainId}/verify?project_id=...
```

Requires the `agents:read` scope. The response reports whether the stored chain is internally
consistent:

| Field | Meaning |
|---|---|
| `valid` | Every entry's digest recomputes, every link matches its predecessor, and the sequence is gapless. |
| `entry_count` | How many entries were read. Reported even when `valid` is `false`. |
| `mediated_entry_count` | How many of them Anectico performed or gated. |
| `reported_entry_count` | How many of them your agents declared. Adds to `mediated_entry_count` to give `entry_count`. |
| `first_invalid_seq` | The lowest sequence number involved in a fault. Meaningful only when `valid` is `false`. |
| `last_entry_sha256` | The digest of the last entry read. |
| `detail` | A short lowercase code naming the kind of fault. Never free text and never your data. |

A chain that has never been opened — no action has been recorded in that project, by us or by you —
is a `404`, not `valid: false`. Having done nothing is not evidence of tampering.

**Read the two counts, not just `valid`.** One chain holds both kinds of entry, and the split is what
tells you how much of a verified ledger is Anectico's own attestation and how much is your agents'
own account of themselves. `MEDIATED` means the action ran through this platform. `REPORTED` means an
agent told us it acted, and the record proves what we received and when — not that it happened. The
word is inside the bytes each entry's digest covers, so it survives export: an entry cannot be
relabelled without breaking its digest, which is exactly what the recipe above detects.

**This check does not replace a checkpoint you hold.** It reads the same stored chain it is
reporting on, so it can tell you that a chain is internally inconsistent, but it cannot tell you that
entries were removed from the end. Compare `last_entry_sha256` and `entry_count` against a checkpoint
you retained to answer that. The two together are the complete answer; either alone is not.

The same check is available where you are already working, and both default to your project's one
action chain:

```bash
anectico receipt-chains verify            # or: anectico receipt-chains verify <chain-id>
```

From an AI agent connected over MCP, ask it to run the `verify_receipt_chain` action — it is reached
through `execute_read_action` and needs the same `agents:read` scope. All three surfaces return the
same fields and the same verdict; a project that has recorded no action reports that it has no chain
rather than reporting a failure.

## Fetch a bill of materials

```http
GET /api/v1/abom-manifests/{manifestId}?project_id=...
```

Requires the `agents:read` scope. The response carries the manifest, its canonical form, the
canonical-form SHA-256 and the signature — everything the verification recipe above needs. Re-derive
the digest from the canonical bytes yourself rather than trusting the one in the response; a manifest
is immutable once signed, so the same id returns the same bytes and the same digest every time.

Read `authority` and `release` from the **canonical form**, not from the JSON fields beside it. They
agree today, and only one copy of each is covered by the signature.

The response also carries a verdict on the stored artifact: `valid`, and `defect` naming the reason
when `valid` is false. This is **not** a claim about the signature — checking that is your job, with
the published key and the recipe above, and an attestation whose verification depended on us would
be worth less. What it reports is the one thing you cannot determine alone: whether the byte contract
has moved underneath the artifact since it was signed.

| `defect` | What it means |
|---|---|
| *(empty)* | The stored body matches the contract on this page. |
| `canonical_form_superseded` | The manifest was signed under an **earlier** version of this contract. Its signature is still a true statement about the bytes it covers; those bytes are simply no longer a body we would produce for that manifest. Sign a fresh manifest for the asset version and reference that one going forward. |
| `canonical_form_unreadable` | The stored body is not JSON. This should never happen; tell us. |
| `digest_mismatch` | The recorded `manifest_sha256` is not the digest of the recorded `canonical_form`. This should never happen; tell us. |

`valid: false` is reported with a normal `200` and the manifest is returned as stored. It is your
artifact, it is the only copy of it you can reach here, and withholding it would destroy a
historical attestation rather than protect anyone.

## Declare a bill of materials

```http
POST /api/v1/abom-manifests
```

Requires the `agents:write` scope, which is owner/admin-only to mint. Send the scope, the asset (and
optionally the asset version and deployment) the manifest describes, `generated_at`, and the
component list — identifiers, versions and content hashes only. Send `release` as well, using the
same label your agents put on their spans as `gen_ai.agent.version`; it goes inside the signed bytes
so a holder can check that this is the composition for the release that actually ran. Omit it
entirely if you do not tag releases — the signed body then says `"release": null`, which is a
different statement from `"release": ""`, and sending an empty string to mean "none" makes the
artifact say something you did not intend. The five derived fields
(`canonical_form`, `manifest_sha256`, `signature`, `signing_key_id`, `authority`) must be absent; a
supplied value is rejected rather than ignored, so nothing you sent can be silently discarded and
nothing you sent can choose what the signature means. The response is the complete signed artifact,
with `authority: DECLARED`.
