Skip to content

Export signed evidence

A deployment can sign three exports, for an auditor or for your own records:

  • an approval's audit bundle: who decided a human review, when, why, and the evidence it was shown;
  • a run's provenance: the graph of what ran, with each model call's usage;
  • compliance evidence: the audit events a filter matches (by run, agent, flow, kind or time).

Each is one JSON file. It holds the exact bytes that were signed (bundle, base64), a signature over them, the algorithm it was made with, and the public key that signed. Anyone can check it, with no access to your deployment.

The runtime signs with one key, its export signing key. Set exactly one of:

Setting What it holds
KINDGI_EXPORT_SIGNING_KEY_PATH The path of a private key file: Ed25519 or EC P-256 (PKCS#8 PEM, mode 0600).
KINDGI_EXPORT_SIGNING_KEY The same PEM file, base64: for a platform that gives secrets as environment variables, such as Cloud Run with Secret Manager.
KINDGI_EXPORT_SIGNING_KMS_KEY A KMS key version, so the private key never leaves the KMS: a Cloud KMS key version (EC_SIGN_ED25519 or EC_SIGN_P256_SHA256), or an Azure Key Vault key version (https://<vault>.vault.azure.net/keys/<name>/<version>, an EC P-256 key). Optional.

Make a key for this alone:

Terminal window
openssl genpkey -algorithm ed25519 -out export-signing.pem && chmod 600 export-signing.pem

The key's type sets the export's algorithm. An Ed25519 key signs ed25519, the default. An EC P-256 key signs ecdsa-p256-sha256, for a key store that has no Ed25519:

Terminal window
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out export-signing.pem && chmod 600 export-signing.pem

The verifiers below check either, and refuse an algorithm they don't know (see An algorithm the verifier doesn't know).

  • kindgi dev signs with a key the runtime makes at startup, so exports work with nothing set. That key is a throwaway, though. To keep one across restarts, set KINDGI_EXPORT_SIGNING_KEY_PATH to the absolute path of a key file on your machine, in the shell or the pack's .env. kindgi dev mounts that file into the runtime's container, read-only.

  • On Cloud Run, set export_signing = "secret" in the Terraform module, then add the key's version:

    Terminal window
    openssl genpkey -algorithm ed25519 | base64 | gcloud secrets versions add $N-export-signing-key --data-file=-

    For a P-256 key, put openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 in place of the first command; the rest is the same.

    Or set export_signing = "kms" and export_signing_kms_key to a key version: the module grants the server's service account roles/cloudkms.signerVerifier and roles/cloudkms.publicKeyViewer on its key.

  • On Azure, a Key Vault key: set KINDGI_EXPORT_SIGNING_KMS_KEY to the key version's URL, https://<vault>.vault.azure.net/keys/<name>/<version>. The version is required, so exports keep the key id of the version that signs them. The key must be EC P-256 (EC or EC-HSM) and allowed to sign, so exports are ecdsa-p256-sha256 (Key Vault has no Ed25519):

    Terminal window
    az keyvault key create --vault-name <vault> -n <name> --kty EC --curve P-256 --ops sign verify

    The runtime signs as its managed identity: KINDGI_AZURE_CLIENT_ID names a user-assigned one, else it's the system-assigned one. Give that identity Key Vault Crypto User on the key. The private key never leaves Key Vault.

  • Without a key (outside development), the exports answer 404 signing-not-configured.

The runtime publishes its key's public half. Pin it, so you can tell your deployment's exports from anyone else's:

Terminal window
curl "$KINDGI_API_URL/v1/export-signing-keys" -H "Authorization: Bearer $KINDGI_API_TOKEN"

Each key has an id derived from it (ex_…), so the same key keeps its id across restarts, and a fingerprint (sha256:…) to compare by eye.

Terminal window
kindgi approvals export 0b6e3c1e-2f4f-4a51-9d0e-7a1f2c3d4e5f > approval.json
kindgi provenance export 11a79b0a-ebdd-4d21-9e66-e0cacaea50e8 > provenance.json
  • An approval must be decided (approved, rejected, escalated, expired or withdrawn): an open one answers 409 approval-not-decided. --include-messages adds the conversation of the approval's run.

  • Compliance evidence is exported over HTTP. With no body, it covers every kind the classifier marks exportable:

    Terminal window
    curl -X POST "$KINDGI_API_URL/v1/compliance/evidence/export" \
    -H "Authorization: Bearer $KINDGI_API_TOKEN" \
    -H 'content-type: application/json' \
    -d '{ "filter": { "runId": "11a79b0a-ebdd-4d21-9e66-e0cacaea50e8" } }' > evidence.json

Every export is recorded as an export-signed audit event: who exported what, with which key, and the SHA-256 of the signed bytes. That ties a file someone hands you to the moment it was made. If the event can't be recorded, the export isn't handed out.

Terminal window
kindgi exports verify approval.json --from-runtime

--from-runtime trusts the keys the runtime lists. An auditor without access checks against the key you gave them: --trust=export-signing.pub.pem (repeat it for several). It exits 0 when the export checks out and 1 when it doesn't, saying why.

An export whose algorithm the verifier doesn't know is refused, never checked another way. Here, an export whose algorithm was edited to ecdsa-p384-sha384:

$ kindgi exports verify approval.json --trust=export-signing.pub.pem
{
"valid": false,
"kind": "audit-bundle",
"signingKeyId": "ex_sD1K1gmwO-S3BJxR",
"checkedAgainst": "trusted-keys",
"issues": [
"algorithm \"ecdsa-p384-sha384\" isn't one this verifier knows (ed25519, ecdsa-p256-sha256): a newer verifier may check it"
]
}

It exits 1. The TypeScript and Python verifiers give the same line in issues. A file signed with a newer algorithm needs a newer verifier; a file someone edited is refused either way.

Checked without trusted keys, a valid export only proves its bytes weren't changed: anyone could have signed it with a key of their own. Checking against your deployment's keys is what tells you it's yours.

An audit bundle made with @kindgi/api 0.1.4

Section titled “An audit bundle made with @kindgi/api 0.1.4”

The 0.1.4 runtime didn't sign exports (its three export routes answered 404 signing-not-configured), but an app that embedded @kindgi/api 0.1.4 with its own key could export audit bundles. Those verify. That version stamped a bundle's envelope exportedAt separately from the signed one, so the two can be a millisecond apart. The envelope's copy isn't signed, so the verifiers report the signed time in notes and don't compare the two:

$ kindgi exports verify approval.json --trust=export-signing.pub.pem
{
"valid": true,
"signingKeyId": "acme-export-key-1",
"checkedAgainst": "trusted-keys",
"notes": [
"made by Kindgi 0.1.4, which stamped the envelope's exportedAt separately: the signed export time is 2026-10-08T13:12:21.532Z (the envelope says 2026-10-08T13:12:21.533Z)"
]
}

It exits 0. The TypeScript and Python verifiers give the same line in notes. Only 0.1.4's audit bundle format (an envelope bundleSchemaVersion of 1) is checked this way; in every later export, an envelope exportedAt that isn't the signed one fails the check.

Replace the key (the file, the secret's version, or the KMS key version) and restart. New exports carry the new key's id.

An export signed before still checks out against the old public key wherever you pinned it. For --from-runtime to keep trusting it, list the old key as retired. Save its public half before you replace it:

Terminal window
curl -s "$KINDGI_API_URL/v1/export-signing-keys" -H "Authorization: Bearer $KINDGI_API_TOKEN" \
| jq -r '.data[] | select(.active) | .publicKeyPem' >> export-signing-retired.pem

Then give the runtime the file, in one of these:

Setting What it holds
KINDGI_EXPORT_SIGNING_RETIRED_PUBLIC_KEYS_PATH The path of a file of retired keys' public halves: PEM (-----BEGIN PUBLIC KEY-----), Ed25519 or EC P-256, concatenated.
KINDGI_EXPORT_SIGNING_RETIRED_PUBLIC_KEYS The same file, base64 (base64 < export-signing-retired.pem): for a platform that gives settings as environment variables.
  • What it lists: GET /v1/export-signing-keys lists the active key first and the retired ones after it, with active: false. So kindgi exports verify --from-runtime trusts what they signed.
  • They never sign: a request that asks for a retired key's id is 404 signing-key-not-found. A private key in the file stops the runtime at boot, and so does a block it can't read.
  • Any signing key: it works whatever signs now: a file, a secret or a KMS key version.

See Provenance and Compliance in the HTTP API.