Attestation
Ed25519-signed envelopes over audit-ledger slices — a pluggable key-store substrate that sits on top of the canonical row hash and turns it into portable, third-party-verifiable evidence.
Attestation extends the audit-ledger cluster: it sits on top of the 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/coreships 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; themodelVersions/promptVersions/toolVersionsmaps are emitted empty (Phase-A thin payload); andChainProofV1has a pending type-unification across theattestation/eventLogsubpaths. Treat "signed, third-party-verifiable" as the wired-keystore path, not the out-of-the-box default, until the 1.0 attestation cut.
@pleach/core/attestationSourcesrc/attestation/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
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).
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.
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
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.
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. 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 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:
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
headSequenceNumberare the chain-native position handles (there is noleafIndex); and the C9 canonicalization tag is folded intoversion(pleach.c9.v1), not a separate field. An emptyproofPathis legal for a single-event chain — the leaf is the root, somerkleRoot === leafHash.
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
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 theAttestationV1. - Verifier path.
getPublicKey(publicKeyId)returns the ed25519 public key bytes (32 bytes), used byverifyAttestation()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)
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.
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
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)
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.
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
A host plugin that signs each completed AuditableCall and a
verifier service that checks an attestation envelope over the
wire.
// 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.
// 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
| Concept | Where it lives | What it does |
|---|---|---|
| AuditableCall row | @pleach/core/audit-ledger | Typed per-call record. The unit of evidence. |
| Scrubbers | @pleach/core/scrubbers | Redact payload fields before the row persists. |
| 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 | @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
@pleach/core/attestation is a substrate subpath, not a SKU. It
intentionally does not ship:
- A CLI. No
pleach attest/pleach verify-attestationcommands. 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/compliancedecision.
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
Hash chain
The tamper-evidence layer attestation signs over.
AuditableCall row
The per-call evidence unit.
Scrubbers
Payload redaction at write time; attestation carries the allowlist hash.
Audit ledger
The ProviderDecisionLedger write path and the three compliance plug-points.
Compliance
The SKU that consumes attestation alongside framework-specific policy.
Tamper-evident hash chain
prev_hash + row_hash columns on the audit-ledger event log — what the chain proves, how rows get verified, and how the default-on writer-side stamping ships in the @pleach/core schema bundle.
Migrate to Pleach
Migration guides for the four most common starting points — AI SDK, LangChain, Anthropic Enterprise, OpenAI Enterprise.