# Attestation (/docs/attestation)



Attestation extends the
[audit-ledger cluster](/docs/audit-ledger#the-audit-ledger-cluster):
it sits on top of the [hash chain](/docs/hash-chain). The chain
proves a slice of the event log hasn't been mutated after the
fact; attestation wraps a signed envelope around that slice so a
third party — a regulator, an external auditor, a downstream
replay — can verify it without needing read access to the
writer's database.

The substrate ships as `@pleach/core/attestation`. It's a substrate
subpath, not a SKU: signer, verifier, the key-store interface, two
stub production adapters (AWS KMS + Vault Transit), and a real
file-backed adapter on the `attestation/testing` subpath. Policy,
rotation cadence, framework-specific envelope shapes — those land
in `@pleach/compliance` or stay host-side.

> **Beta / `@experimental`.** While `@pleach/core` ships stable, the
> attestation surface (and `@pleach/compliance`) ship **beta** for the
> first release. Concretely: envelopes are **unsigned by default**
> (`signature: ""` + `compliance.signed: false`) unless you wire a
> real key-store — the AWS KMS + Vault Transit production adapters are
> stubs, only the file-backed adapter signs today; the `modelVersions`
> / `promptVersions` / `toolVersions` maps are emitted empty (Phase-A
> thin payload); and `ChainProofV1` has a pending type-unification
> across the `attestation` / `eventLog` subpaths. Treat "signed,
> third-party-verifiable" as the **wired-keystore** path, not the
> out-of-the-box default, until the 1.0 attestation cut.

<SourceMeta subpath="@pleach/core/attestation" source="{ label: &#x22;src/attestation/&#x22;, href: &#x22;https://github.com/pleachhq/core/tree/main/src/attestation&#x22; }" />

## What attestation adds [#what-attestation-adds]

The chain alone is enough to detect tampering on rows you already
control. It's not enough to prove anything to someone who doesn't.
A regulator asking "show me what this model decided on
2026-04-12T14:00Z, signed" wants:

* A fixed, canonical bytes representation of the slice.
* A signature over those bytes by a key whose public half they can
  resolve out-of-band.
* A chain proof showing how the attested HEAD sits inside the
  broader event log, so the slice can't be lifted from one tenant
  and replayed under another.

`AttestationV1` is the envelope shape that carries all three. The
chain provides the canonical hash of the covered slice
(`eventChainHash`) and a `ChainProofV1` locating the HEAD inside
the tenant's chain; the signer signs the canonical encoding of
that envelope; the verifier checks the signature and emits a
structured `VerifyResult`. Chain replay itself stays delegated to
`@pleach/core/eventLog`'s `verifyChain()` — the two surfaces are
independent on purpose.

## The four-piece surface [#the-four-piece-surface]

```typescript
import {
  signAttestation,
  verifyAttestation,
  canonicalizeAttestationPayload,
  computePayloadHash,
  type AttestationV1,
  type AttestationPayloadV1,
  type AttestationFieldVersions,
  type AttestationSignatureAlgorithm,
  type ChainProofV1,
  type VerificationError,
  type VerifyResult,
  type AttestationKeyStore,
  type AttestationSignResult,
  AwsKmsAttestationKeyStore,
  VaultTransitAttestationKeyStore,
  NotImplementedError,
} from "@pleach/core/attestation";
```

The `attestation/keyStores` subpath re-exports the same key-store
types and adapters for hosts that only need the key-store surface
(e.g. a pure verifier service that resolves public keys but never
signs).

```typescript
import {
  AwsKmsAttestationKeyStore,
  VaultTransitAttestationKeyStore,
  type AttestationKeyStore,
} from "@pleach/core/attestation/keyStores";
```

The `attestation/testing` subpath ships the real file-backed
adapter for unit tests and local development. Kept off the
production barrel so it can't be reached by typing
`@pleach/core/attestation` in app code.

```typescript
import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing";
```

Four surfaces, each with a single responsibility:

| Surface                                                 | Responsibility                                                                              |
| ------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `signAttestation` / `verifyAttestation`                 | The sign/verify pair. Pure functions over an `AttestationKeyStore`.                         |
| `canonicalizeAttestationPayload` + `computePayloadHash` | RFC 8785 JCS encoding + SHA-256. Exported so verifiers and signers share one canonicalizer. |
| `AttestationKeyStore` interface + production adapters   | Pluggable signing backend. AWS KMS + Vault Transit ship as Phase A scaffolds.               |
| `attestation/testing` subpath                           | Real `FileBackedAttestationKeyStore` for dev + unit tests.                                  |

## The envelope shape [#the-envelope-shape]

`AttestationV1` is the wire-format. Every field is `readonly`; the
signer freezes the returned object. The unsigned form
`AttestationPayloadV1` is `Omit<AttestationV1, "signature">` — the
exact shape that gets canonicalized and hashed.

```typescript
interface AttestationV1 {
  readonly version: "1";
  readonly sessionId: string;          // RFC 4122 v4
  readonly tenantId: string;
  readonly attestationId: string;      // RFC 4122 v4, generated at sign time
  readonly issuedAt: string;           // ISO 8601 UTC
  readonly modelVersions: { readonly [modelId: string]: string };
  readonly promptVersions: { readonly [promptId: string]: string };
  readonly toolVersions: { readonly [toolName: string]: string };
  readonly runtimeVersion: string;     // @pleach/core package version at attest time
  readonly eventChainHash: string;
  readonly firstEventId: string;       // event handle; don't assume UUID format
  readonly lastEventId: string;        // event handle; don't assume UUID format
  readonly eventCount: number;         // >= 1
  readonly signatureAlgorithm: AttestationSignatureAlgorithm; // "ed25519"
  readonly signaturePublicKeyId: string;
  readonly signature: string;          // base64(ed25519-sig(canonical(payload)))
  readonly redactionAllowlistHash: string;
  readonly chainProof: ChainProofV1;
  readonly firstChainedSequenceNumber: number;
}
```

A few of these earn explanation.

**`modelVersions` / `promptVersions` / `toolVersions`.** The
attested run's full version surface. A regulator who later wants
to replay the slice can pin every model, prompt contribution, and
tool version to the exact bytes that were active at sign time. An
empty `toolVersions` is legal — tool-free turns still attest.

**`eventChainHash` + `firstChainedSequenceNumber`.** Anchors the
envelope to the underlying [hash chain](/docs/hash-chain). When a
chain reader resolves and yields rows, `eventChainHash` is the
`merkleRoot` of the embedded `chainProof` — a real Merkle root
over the covered slice — and `eventCount` is the real number of
chained events. When no reader is available (or the chain yields
zero rows), the envelope falls back to a documented empty-chain
sentinel: an all-zero (32-byte) hash with a single-event
short-circuit proof (`proofPath: []`, `merkleRoot === leafHash ===
eventChainHash`). The all-zero shape is greppable on purpose, so a
consumer can tell a placeholder root from one computed against a
real event log. `firstChainedSequenceNumber` is the first row
inside the slice.

**`redactionAllowlistHash`.** The hash of the active
[scrubber](/docs/scrubbers) allowlist at attest time. Lets a
verifier confirm the slice was redacted under a specific policy
without needing the policy itself.

**`chainProof: ChainProofV1`.** A real Merkle inclusion proof
locating the attested leaf inside the tenant's broader chain.
`ChainProofV1` is one canonical shape, shared with
`@pleach/core/eventLog`:

```typescript
interface ChainProofV1 {
  readonly algorithm: "sha256";
  readonly version: "pleach.c9.v1";       // the canonicalization tag
  readonly tenantId: string;
  readonly chatId: string;
  readonly headSequenceNumber: number;
  readonly leafSequenceNumber: number;
  readonly leafHash: string;              // hex
  readonly merkleRoot: string;            // hex
  readonly proofPath: ReadonlyArray<{
    readonly hash: string;                // sibling hash, hex
    readonly side: "left" | "right";      // which side the sibling sits on
  }>;
}
```

`proofPath` carries sibling-with-side entries (not bare hex
strings), enabling real Merkle verification; `leafSequenceNumber`

* `headSequenceNumber` are the chain-native position handles
  (there is no `leafIndex`); and the C9 canonicalization tag is
  folded into `version` (`pleach.c9.v1`), not a separate field. An
  empty `proofPath` is legal for a single-event chain — the leaf is
  the root, so `merkleRoot === leafHash`.

## Canonicalization [#canonicalization]

`canonicalizeAttestationPayload(payload)` runs RFC 8785 JCS (JSON
Canonicalization Scheme) over the unsigned payload: keys sorted
lexicographically, no whitespace, UTF-8 bytes out.
`computePayloadHash(canonical)` takes those bytes and returns
SHA-256.

The two functions are exported separately for the same reason the
hash chain exports `canonicalizeRowForChain` and `computeRowHash`
separately: a verifier that wants to recompute the hash without
running the full `verifyAttestation` path can call them directly,
and a custom signing backend can sign the canonical hash without
re-deriving canonicalization rules from a guess.

Mismatched canonicalization between signer and verifier surfaces
at verify time as `kind: "signature-mismatch"`. The signer and
verifier import from one file specifically to make that drift
impossible.

## The `AttestationKeyStore` interface [#the-attestationkeystore-interface]

```typescript
interface AttestationSignResult {
  readonly signature: Uint8Array;   // ed25519 raw signature, 64 bytes
  readonly publicKeyId: string;     // opaque; matches AttestationV1.signaturePublicKeyId
}

interface AttestationKeyStore {
  sign(canonicalHash: Uint8Array): Promise<AttestationSignResult>;
  getPublicKey(publicKeyId: string): Promise<Uint8Array>;
}
```

Two paths through the interface:

* **Signer path.** `sign(canonicalHash)` returns the raw 64-byte
  ed25519 signature plus the keyId the verifier needs to resolve
  the public key. Caller embeds both into the `AttestationV1`.
* **Verifier path.** `getPublicKey(publicKeyId)` returns the
  ed25519 public key bytes (32 bytes), used by
  `verifyAttestation()` to re-check the signature.

The interface is intentionally minimal. No key-rotation surface,
no permission model, no audit hooks — adapters layer those on top
of their own infrastructure. AWS KMS has its own audit trail;
Vault Transit has its own access control; the substrate stays out
of their way.

## Production adapters (Phase A scaffolds) [#production-adapters-phase-a-scaffolds]

`AwsKmsAttestationKeyStore` and `VaultTransitAttestationKeyStore`
ship today as **constructor + interface-conformance scaffolds**.
Both adapters' `sign()` and `getPublicKey()` methods throw
`NotImplementedError`; the real bodies land in v1.0 alongside the
matching `@pleach/compliance` Phase B release. Phase A lets
consumers type-check their wiring against the final shape.

```typescript
import {
  AwsKmsAttestationKeyStore,
  VaultTransitAttestationKeyStore,
} from "@pleach/core/attestation";

const kms = new AwsKmsAttestationKeyStore({
  region: "us-east-1",
  keyArn: "arn:aws:kms:us-east-1:123456789012:key/abc-...",
});

const vault = new VaultTransitAttestationKeyStore({
  vaultAddr:    "https://vault.example.com:8200",
  transitMount: "transit",
  keyName:      "audit-attestation",
});
```

Both adapters are pinned to ed25519 at v1. KMS' `EDDSA` signing
algorithm is the only accepted shape; Vault Transit's key type
must be `ed25519`. ECC\_NIST\_P256 and other curves are rejected at
the type level — `AttestationSignatureAlgorithm` is the literal
`"ed25519"`. `publicKeyId` is `vault://<mount>/<keyName>` for
Vault and the full KMS ARN for AWS, disambiguating mixed-store
deployments.

## Signing is host/operator-provisioned [#signing-is-hostoperator-provisioned]

Attestation is an early-stage capability: the sign/verify functions
and the envelope shape are stable, but the signing key store is
host- or operator-provisioned, and the cloud-KMS path is not yet
available. The default and file-backed signers work for local and
dev use today.

## The file-backed adapter (dev + tests) [#the-file-backed-adapter-dev--tests]

`FileBackedAttestationKeyStore` is the **real** implementation
the production adapters' scaffolds reference. It lives on the
`attestation/testing` subpath so app code can't reach it through
the production barrel.

```typescript
import { FileBackedAttestationKeyStore } from "@pleach/core/attestation/testing";

const keyStore = new FileBackedAttestationKeyStore({
  privateKeyPath: "/run/secrets/attestation-key.bin",
  keyId:          "test-key-2026-06",
});
```

Key file format is 32 raw bytes — the ed25519 seed. No PEM, no
PKCS#8: that's a heavy parser surface for a dev/test adapter. The
private key is cached for the lifetime of the instance; callers
that need rotation should construct a fresh store. `sign()` uses
`@noble/curves/ed25519` directly; `getPublicKey()` derives the
public half from the cached seed.

## Worked example [#worked-example]

A host plugin that signs each completed `AuditableCall` and a
verifier service that checks an attestation envelope over the
wire.

```typescript
// Signer side — runs in the host plugin's recordCall path.
import {
  signAttestation,
  type AttestationPayloadV1,
  type AttestationKeyStore,
} from "@pleach/core/attestation";

async function attestCall(
  call: AuditableCall,
  keyStore: AttestationKeyStore,
  publicKeyId: string,
): Promise<AttestationV1> {
  const payload: AttestationPayloadV1 = {
    version: "1",
    sessionId:      call.sessionId,
    tenantId:       call.tenantId,
    attestationId:  crypto.randomUUID(),
    issuedAt:       new Date().toISOString(),
    modelVersions:  call.fingerprint.modelVersions,
    promptVersions: call.fingerprint.promptVersions,
    toolVersions:   call.fingerprint.toolVersions,
    runtimeVersion: PLEACH_CORE_VERSION,
    eventChainHash: call.chainHash,
    firstEventId:   call.firstEventId,
    lastEventId:    call.lastEventId,
    eventCount:     call.eventCount,
    signatureAlgorithm:   "ed25519",
    signaturePublicKeyId: publicKeyId,
    redactionAllowlistHash: call.redactionAllowlistHash,
    chainProof:             call.chainProof,
    firstChainedSequenceNumber: call.firstSeq,
  };
  return signAttestation(payload, keyStore);
}
```

The verifier resolves the public key out-of-band — typically
through whatever resolver the keyId scheme implies (KMS
`GetPublicKey`, Vault Transit `read key`, or a local cache for
the file-backed adapter) — and runs `verifyAttestation`.

```typescript
// Verifier side — runs in a service that receives signed envelopes.
import {
  verifyAttestation,
  type AttestationV1,
  type VerifyResult,
} from "@pleach/core/attestation";

async function checkEnvelope(
  attestation: AttestationV1,
  resolvePublicKey: (keyId: string) => Promise<Uint8Array>,
): Promise<VerifyResult> {
  const publicKey = await resolvePublicKey(attestation.signaturePublicKeyId);
  return verifyAttestation(attestation, publicKey);
}
```

`verifyAttestation` never throws on signature mismatch. The
returned `VerifyResult` is either `{ valid: true }` or
`{ valid: false, errors: VerificationError[] }`, with each error
tagged by `kind`:

| `kind`               | Meaning                                                                            |
| -------------------- | ---------------------------------------------------------------------------------- |
| `signature-mismatch` | The signature didn't verify against the resolved public key + canonical payload.   |
| `schema-invalid`     | `version`, `signatureAlgorithm`, or `signature` field failed the structural check. |
| `key-id-unknown`     | `publicKey` arg wasn't a 32-byte `Uint8Array`.                                     |

Schema errors short-circuit before the signature check, so a
malformed envelope surfaces `schema-invalid` rather than a
downstream cryptographic failure.

## Relationship to the audit-ledger cluster [#relationship-to-the-audit-ledger-cluster]

| Concept                                       | Where it lives                 | What it does                                                       |
| --------------------------------------------- | ------------------------------ | ------------------------------------------------------------------ |
| [AuditableCall row](/docs/auditable-call-row) | `@pleach/core/audit-ledger`    | Typed per-call record. The unit of evidence.                       |
| [Scrubbers](/docs/scrubbers)                  | `@pleach/core/scrubbers`       | Redact payload fields before the row persists.                     |
| [Hash chain](/docs/hash-chain)                | `@pleach/core/eventLog`        | `prev_hash` + `row_hash` columns; tamper-detection across rows.    |
| **Attestation**                               | **`@pleach/core/attestation`** | **Signed envelope over a chain slice; portable to third parties.** |
| [ProviderDecisionLedger](/docs/audit-ledger)  | `@pleach/core` (write path)    | The persistence surface every row flows through.                   |

Scrubbers shape what gets written; the chain proves it wasn't
mutated; attestation signs the proof. Each layer assumes the layer
below holds.

## What's not in scope today [#whats-not-in-scope-today]

`@pleach/core/attestation` is a substrate subpath, not a SKU. It
intentionally does not ship:

* **A CLI.** No `pleach attest` / `pleach verify-attestation`
  commands. Hosts wire the sign + verify functions into their own
  binaries.
* **Managed key rotation.** No rotation scheduler, no key-lifecycle
  policy. The interface accepts a keyId; the adapter decides what
  rotation means.
* **Framework-specific envelope shapes.** No FedRAMP-shape,
  HIPAA-shape, or SOC2-shape variants of `AttestationV1`. The
  envelope is one shape; framework-specific compliance bundles
  carry their own metadata alongside.
* **Compliance-mode policy enforcement.** Nothing in the substrate
  refuses to start because attestation isn't configured. That's a
  host-side or `@pleach/compliance` decision.

The production KMS + Vault adapters' bodies land in v1.0 alongside
the matching `@pleach/compliance` Phase B release. Until then,
Phase A consumers wire the scaffolds for type-checking and run
their dev paths through the file-backed adapter on
`@pleach/core/attestation/testing`.

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Hash chain" href="/docs/hash-chain" description="The tamper-evidence layer attestation signs over." />

  <Card title="AuditableCall row" href="/docs/auditable-call-row" description="The per-call evidence unit." />

  <Card title="Scrubbers" href="/docs/scrubbers" description="Payload redaction at write time; attestation carries the allowlist hash." />

  <Card title="Audit ledger" href="/docs/audit-ledger" description="The ProviderDecisionLedger write path and the three compliance plug-points." />

  <Card title="Compliance" href="/docs/compliance" description="The SKU that consumes attestation alongside framework-specific policy." />
</Cards>
