Build provenance signing
KiCI build provenance produces a signed, offline-verifiable statement of what produced an artifact. This page covers how that signature is constructed across the three tiers, what roots the trust, and how a verifier re-establishes the whole chain. The workflow-author view is in the build provenance guide.
Signing flow
Section titled “Signing flow”When a step calls ctx.attestProvenance, the agent obtains an
orchestrator-issued identity token, builds and signs the statement locally, and
persists the resulting bundle. The orchestrator is the root of trust: it mints and
signs the token itself, with no hosted-platform dependency in the build hot path:
The identity claims — repository, ref, sha, run and job identifiers — are
derived by the orchestrator from its own record of the run and job, not from
anything the agent or step asserts. The orchestrator resolves the request against
a job the requesting agent actually owns; it never lets a step name its own
repository or ref. This is what makes the identity claims trustworthy: the build
cannot lie about where it came from.
Trust root
Section titled “Trust root”The orchestrator owns a long-lived ECDSA P-256 (ES256) signing key. Custody is pluggable: by default the private key is encrypted at rest in the orchestrator’s database, wrapped with the orchestrator’s master key (the master key lives in the environment or a KMS, never in the database, so a database backup alone cannot sign); it can also be held in AWS KMS or any external KMS/HSM via a signing command, where the private key never exists inside KiCI at all. The private key is non-exportable by design.
The key’s public half is published as a JSON Web Key Set (JWKS) at the orchestrator’s well-known OIDC discovery endpoint. Verifiers trust the published key set, not the signing provider. Swapping the underlying signing technology is therefore a transparent key rotation — the only durable external contract is the JWKS. For provisioning, custody, and rotation, see provenance signing keys.
Bundle construction
Section titled “Bundle construction”The agent assembles a self-contained bundle so verification needs nothing but the trusted key set:
- Build an in-toto SLSA v1.0 statement — the subject (artifact name + digest) plus a provenance predicate populated from the identity token’s server-truth claims.
- Generate an ephemeral ES256 key in-process and DSSE-sign the statement with it.
- Package the DSSE envelope, the ephemeral public key, and the identity token into one bundle.
The identity token stands in for a signing certificate: it binds the ephemeral key’s signature to the build identity, the same role a short-lived certificate plays in certificate-based signing systems. The bundle is then persisted so the dashboard can list it and the verification CLI can retrieve it.
Verification chain
Section titled “Verification chain”A verifier re-establishes trust from the published key set inward:
- JWKS → identity token. Verify the identity token against the trusted JWKS, with its issuer pinned to the configured trust root (never read from the token itself), and check the audience.
- Token → ephemeral key. The bundle’s ephemeral public key is the one the DSSE signature must verify against; its key id must match the key’s own thumbprint.
- Ephemeral key → DSSE signature. Verify the DSSE signature over the statement with that key.
- Build-context cross-check. The statement’s build context must match the
identity token’s claims. A mismatch is a hard failure — this is the check
that makes the model sound, because the build identity is server-truth while
the statement is assembled on the agent. It runs for every attestation,
deferred ones included: a deferred token carries the same
repository/ref/sha/workflow_refclaims a live one does, because deferral marks the mint timing and does not change what the claims say. A deferred attestation must additionally satisfy the statement-hash binding below — both, never either. - Subject digest (optional). When a verifier supplies the artifact, its SHA-256 digest is matched against the subject. This is the only check that binds the attestation to specific bytes; the build identity is independent of it.
The build identity is server-truth throughout; the artifact digest is the only build-supplied input.
How it works today
Section titled “How it works today”- Revocation is all-or-nothing. A signing key’s public half stays in the JWKS after it is rotated out, so historical attestations remain verifiable. Revoking a compromised key removes it from the JWKS and distrusts every attestation it ever signed — there is no per-attestation trusted timestamp to scope revocation to “before time T”. Time-scoped revocation requires a transparency log and is a future capability.
- The trust root is pinned out-of-band. Verifiers fetch the published key set
from the orchestrator’s provenance issuer and pin the token’s issuer to it,
rather than following the issuer named in the token.
kici verify-attestationdefaults its trust root to the configured orchestrator; it can also verify offline against an exported{ issuer, jwks }file (air-gap) or against the orchestrator’s nativePOST /v1/verify-attestationendpoint. Bundles signed by the hosted platform before the orchestrator owned signing keep verifying against the platform’s still-published JWKS. - The bundle format is forward-compatible. Verification dispatches on the bundle’s media type, so additional bundle formats can be added without changing the verifier’s existing path.
Verify-at-ingest and stored verdicts
Section titled “Verify-at-ingest and stored verdicts”Beyond on-demand verification (CLI or in-browser), the orchestrator verifies each provenance bundle at ingest — when it records the attestation — and stores the verdict alongside the row. This is what makes the org-wide attestations browser trustworthy at any scale: the list shows a real badge per row with no per-row bundle fetch or re-verification.
The stored verdict is one of:
- verified — the bundle’s signature, build identity, and build context all checked out against the provenance trust root.
- failed — verification ran and the bundle did not pass (bad signature, mismatched identity, or unsupported bundle mode). A provenance-integrity signal; the first failure code is stored.
- unverifiable — no verdict could be computed: the trust root is not configured, or its key set / the bundle could not be read. This is not a forgery signal — it means “we could not check”, distinct from “we checked and it failed”.
- pending — the verdict has not been computed yet (a row recorded before verification, awaiting backfill).
The verdict is a point-in-time record over an immutable bundle. The
attestation-detail page offers a live re-verification against the current
signing keys, and operators backfill or refresh stored verdicts with
kici-admin attestations reverify.
Trust root at ingest. When orchestrator-owned signing is configured, the
orchestrator verifies at ingest against its own key set — read directly from
its signing-keys store, so fresh rotations and revocations are reflected
immediately. Bundles signed by the hosted platform before the orchestrator owned
signing are verified against the platform’s provenance issuer instead. When no
provenance trust root is configured, every verdict is recorded as unverifiable
rather than silently verified. Verification consumes only the public issuer +
key set; the private signing key never takes part in checking a bundle.
Deferred attestations (attest-later)
Section titled “Deferred attestations (attest-later)”Minting the identity token needs the orchestrator’s signing key. That key can be unresolvable at the moment of a build’s mint: it is still being reconciled after a cold start or a leader election, or the mint fails transiently. In that case the attestation is deferred rather than lost, and the job stays green.
An orchestrator with no provenance issuer configured
(KICI_ORCHESTRATOR_PROVENANCE_ISSUER unset) has no signing key at all, so it
cannot complete a deferred attestation. The rows stay queued, and the retrier
logs one warning per drain naming the variable to set.
The lifecycle:
-
Freeze at build time. The agent builds the statement from the build context the orchestrator sent with the job — the same run-row facts a live mint reads — and DSSE-signs it with its ephemeral key immediately. The attested facts are sealed live; only the identity token is deferred.
Sending that context is what makes the statement checkable. Built from the agent’s own view instead, it would disagree with the claims by construction. A job’s checkout ref is a pull request’s HEAD branch, where the claim is the BASE branch. A global workflow’s clone ref is not the
<name>@<sha>workflow reference. An agent that receives no context falls back to that local view, and its capture is refused at step 2. -
Cross-check, then capture to a durable outbox. On a transient mint failure the agent reports the frozen envelope, ephemeral public key, and a
statement_hashto the orchestrator. The orchestrator then checks that statement against its own run row: it recomputes the hash, compares the build context against the claims the later mint will carry, and confirms the subject digest. Only then does it record a row in the cluster-sharedpending_attestationsoutbox, storing its OWN recomputed hash. A statement that fails any check is dropped. A permanent rejection (a genuinely bad request) still fails the step — only transient failures defer. -
Fulfil later. A Raft-leader-only retrier mints each pending attestation exactly once — on a periodic sweep and immediately when the orchestrator’s Platform connection re-authenticates. It mints the identity token with the orchestrator’s own key, bound to the frozen
statement_hash, attaches it to the already-frozen envelope, uploads the bundle, records the attestation with a verify-at-ingest verdict, and drains the outbox row. Operators can trigger a drain on demand withkici-admin attestations retry. -
Run-sync backfill. A run ingested while the Platform was fully down has no Platform run/job records, so the retrier first replays the run and job status the Platform mirror missed (the same org-asserted data the live path sends), then mints. The mint reads the orchestrator’s own rows, so the replay serves the dashboard, not the mint.
Truth contract
Section titled “Truth contract”Deferral preserves the attestation’s truth:
- No tamper window. The statement is frozen and DSSE-signed at build time; the digest is the artifact.
- Statement-hash binding. The deferred identity token commits to the frozen statement by hash, so the identity cannot be re-bound to a different artifact at retry time. A verifier recomputes the hash and hard-fails on a mismatch. This is a binding in addition to the build-context cross-check, not instead of it. The hash proves the statement was not swapped; the cross-check proves the statement agrees with the run the token names. A hash-only rule accepted a statement naming a release commit the build never touched, because nothing compared the two.
- Checked before it is stored. The orchestrator cross-checks a captured
statement against its own run row BEFORE writing the outbox row. It recomputes
the statement hash from the envelope bytes rather than trusting the reported
one. It compares the statement’s build context against the claims the later
mint will carry. It confirms the reported subject digest appears in the
statement. A capture that fails any of these is dropped, and the job stays
green with no attestation. The orchestrator therefore never signs a statement
it has not checked.
kici_orch_deferred_attestation_rejected_total{reason}counts the refusals. - The mint-timing marker is read from the signed claim only. The frozen
statement also records an
attestationOriginin its internal parameters, but that field is unsigned and producer-written, so a verifier ignores it. A token carrying no marker is treated aslive, which is the stricter reading. - Temporal honesty. The predicate keeps the true build timestamps; the token
is minted later against a knowingly-completed job (the mint relaxes its
live-job check only for an explicitly-flagged deferred mint). The bundle
carries a mint-timing marker —
deferred, oroffline-backfillfor a fully-offline-ingested run — so the gap is disclosed, never hidden. - Anchor preserved. The organization id remains the only un-forgeable anchor,
exactly as in the live path;
repo/ref/shaare organization-asserted in both paths, so backfill concedes no independence the live path did not.
kici verify-attestation surfaces the deferred / offline-backfill marker on
a PASS; the orchestrator exposes kici_orch_pending_attestations_current and
kici_orch_pending_attestation_oldest_age_seconds gauges for the outbox depth.