Skip to content

Signed OCI pack distribution

Pack Contract v2 gives a pack a publisher identity, but declaring a publisher and proving it are different problems. This page covers the second one: how pnpm pack:publish and pnpm pack:install --source registry move a pack as a cryptographically signed OCI artifact, what gets verified before it is ever installed, and how to recover when something fails.

A published pack is one OCI 1.1 artifact manifest with artifactType: application/vnd.lumenflow.pack.v2+json:

  • Config blob (application/vnd.lumenflow.pack.config.v1+json) — the canonical-JSON encoding of exactly the fields install-time trust checks read: id, version, contract_version, publisher, compatibility.
  • Content layer (application/vnd.lumenflow.pack.layer.v1.tar+gzip) — a deterministic tar+gzip of the pack directory. “Deterministic” means publishing the same pack contents twice, at any time, from any machine, produces byte-identical layer bytes and therefore the same manifest digest — built from a confined, sorted distribution file list. Reviewed dist/ outputs are included; dependency trees, repository metadata, local caches, generated archives, and local secret files are excluded. The local source integrity hash intentionally keeps its existing dist/ exclusion, while install recomputes that source integrity after the signed distribution has passed its package contract. Tests are never archived: a pack’s own file selection (kernel’s listPackFiles, the same list computeDeterministicPackHash hashes and the release process’s pack-root guard verifies before a release) excludes a pack’s test directories (__tests__, at any nesting depth) and test-suffixed files (*.test.*, *.spec.*) by default — a pack’s tests are development-time content, never distributed content.

The manifest’s own SHA-256 digest (sha256:<hex>) is the one immutable identifier every later stage — signing, verification, caching, loading — resolves against. A version string (“1.0.0”) is a mutable tag a publisher can repoint; the digest cannot change without becoming a different artifact.

A signature, a CycloneDX SBOM, and a provenance statement are each pushed as a separate artifact manifest, linked back to the pack artifact via an OCI subject descriptor — never embedded inside the pack artifact itself. That is what lets a publisher rotate a signature or add a second provenance statement without changing the pack’s own digest.

The SBOM uses application/vnd.cyclonedx+json for both its referrer artifact type and content layer. Its exact blob digest is written into the SLSA provenance external parameters under lumenflow.sbom.digest. Install requires one and only one SBOM referrer and compares that binding exactly.

The registry speaks the real OCI Distribution Specification 1.1.1 — the same protocol Docker Hub, GHCR, and every other OCI-compliant registry speak, not a LumenFlow-invented wire format:

  • POST /v2/<name>/blobs/uploads/ + PUT <location>?digest=<digest> — blob push
  • GET /v2/<name>/blobs/<digest> — blob pull
  • PUT /v2/<name>/manifests/<digest> — manifest push, always by immutable digest
  • GET /v2/<name>/manifests/<reference> — manifest resolve, by digest or tag
  • GET /v2/<name>/referrers/<digest> — discover a subject’s signature, provenance, and SBOM referrers

Registries are not required to implement the native referrers API. When one returns 404 for it, the client falls back to the spec’s own documented referrers tag schema: a synthetic image.index.v1+json manifest at a computed fallback tag, maintained through a read-modify-write cycle with HTTP conditional requests (If-Match/If-None-Match) and bounded retry on 412 Precondition Failed. This is what keeps concurrent pushes — a signature, provenance statement, and SBOM racing for the same subject, or a duplicate re-publish — from ever silently dropping one referrer: the client re-reads, reconciles, and re-writes until its write lands cleanly.

Pulls (GET/HEAD) remain public. The production LumenFlow registry rejects every blob-upload and manifest write unless its bearer is a cryptographically verified GitHub Actions OIDC token with all of these exact properties:

  • issuer https://token.actions.githubusercontent.com;
  • audience sigstore;
  • repository hellmai/lumenflow-dev;
  • a push event on a SemVer refs/tags/v... ref; and
  • job_workflow_ref naming that same tag in .github/workflows/publish.yml.

The sigstore audience is intentional: Fulcio requires it, so the release job can use one short-lived token for both authenticated registry writes and keyless signing. There is no long-lived registry credential, GitHub API-token fallback, or unauthenticated write path.

Publishing signs the pack artifact’s digest string (not its raw bytes) with Sigstore keyless signing: a short-lived certificate issued by Fulcio, bound to an OIDC identity, with the signature recorded in the Rekor transparency log. There is no long-lived private signing key to leak or rotate.

npx tsx tools/generate-sbom.ts packages/@lumenflow/packs/my-pack/package.json

pnpm pack:publish --id my-pack \
  --registry-url https://registry.example.com \
  --sbom packages/@lumenflow/packs/my-pack/sbom.cdx.json \
  --identity-token "$LUMENFLOW_SIGSTORE_IDENTITY_TOKEN"

--identity-token (or the LUMENFLOW_SIGSTORE_IDENTITY_TOKEN environment variable) and --sbom are required — there is no unsigned or SBOM-less publish fallback. A failed signing attempt may leave content-addressed blobs or an unreferenced pack manifest in the registry, but it never creates the complete signature/provenance/SBOM referrer set required for installation. Minting a real OIDC identity token is a release-time, CI-owned step (a GitHub Actions OIDC token, for example) — never a value a human types in by hand for a production publish.

For the first-party Reliability Operations pack, a v* tag runs this publication automatically after npm succeeds. The job refuses to start when the tag, package version, and Pack Contract manifest version differ; generates the CycloneDX SBOM; publishes the signed artifact; captures its immutable digest; then runs pack:install --digest in an isolated workspace. That final read-back exercises the normal signature, provenance, SBOM binding, quarantine, and atomic-promotion path against the production registry.

Alongside the signature, publish also attests a SLSA v1.2 provenance statement (an in-toto v1 statement) bound to the same digest: which build produced it (buildDefinition.buildType), and which builder identity ran it (runDetails.builder.id).

pack:install --source registry resolves a digest (either the exact --digest you pin, or the mutable --version tag), downloads to a quarantine location, and verifies — in order, every one required, no fifth path around any of them:

  1. Digest — the downloaded content actually hashes to what was resolved.
  2. Expected publisher — the artifact’s own publisher.id matches --expected-publisher-id (defaults to LumenFlow’s own publisher for first-party packs).
  3. Signature — the Sigstore bundle verifies cryptographically, AND the certificate’s OIDC issuer matches --expected-issuer, AND the certificate identity matches --expected-identity-pattern (a regex against the certificate’s URI SAN), AND either the transparency log proves inclusion (online) or a pinned offline trust root was actually consulted (see Air-gapped verification below). A signature that verifies cryptographically but comes from the wrong signer still fails closed here — cryptographic validity alone is never enough.
  4. SBOM — exactly one CycloneDX JSON referrer exists, its manifest names the pack digest as subject, its artifact and layer media types are exact, its bytes match the declared digest, and its component version matches the pack version.
  5. Provenance — the in-toto statement’s subject digest matches the artifact, its predicate type is the current SLSA provenance predicate, its builder identity is in --builder-id’s trusted set, and its build type is in --build-type’s allowed set. Its reserved lumenflow.sbom.digest external parameter must equal the selected SBOM content digest.
  6. Compatibility and contract — the same contract_version/ compatibility checks every Contract v2 pack goes through (see Pack Contract v2).
  7. Installed-trust-state transition — see Replay, rollback, and retargeting below.

Only after every check passes does the pipeline perform its one atomic cache promotion. There is no intermediate state where unverified content is reachable by a runtime load: content lands in a quarantine directory outside the runtime’s search path, gets re-verified there, and only then is renamed (never copy-then-delete) into the cache in one filesystem operation.

pnpm pack:install --id my-pack --source registry \
  --digest sha256:4c21ad... \
  --registry-url https://registry.example.com \
  --expected-publisher-id acme \
  --expected-issuer https://token.actions.githubusercontent.com \
  --expected-identity-pattern '^https://github\.com/acme/my-pack/'

Pin to --digest for anything you actually depend on. --version still resolves a mutable tag — appropriate for “give me whatever acme currently publishes as 1.0.0,” not for a supply chain you are pinning against.

Passing the artifact’s own checks is necessary but not sufficient. A second, separate gate — the installed-trust-state transition — asks a different question: given what is already installed for this pack ID, is this transition allowed? All of the following fail closed by default:

  • Replay — the candidate digest already appears in this pack’s superseded-digest history.
  • Downgrade — the candidate’s version is older than what is installed.
  • Retarget — the candidate has the same version as what is installed but a different digest (a mutable-tag repoint).
  • Substitution — the candidate declares a different publisher under the same pack ID.
  • Freeze — the installed record has been explicitly frozen.

A validly signed artifact from the wrong publisher, or an older validly signed version of the right one, are exactly the cases this gate exists for — “signed correctly” and “the right transition” are independent questions. Every denial above, and only those, can be lifted by an explicit, audited AuditedTrustTransitionAuthorization (a reason, who authorized it, and when) — there is no environment flag or default that bypasses it silently.

One transition is always allowed with no authorization: re-installing the exact same digest that is already installed is idempotent, which is what keeps remove + reinstall of an unchanged pin working.

Air-gapped verification (offline trust material)

Section titled “Air-gapped verification (offline trust material)”

Sigstore verification normally fetches current Fulcio/Rekor/CT trust material from the public-good TUF mirror over the network. The verification clients underneath pack:install --source registry (createSigstoreSignatureVerificationClient/ createSigstoreProvenanceVerificationClient, @hellmai/lumenflow-host/pack) already support an offline mode instead: pin that trust material locally (tufRootPath/tufCachePath) and no live network fetch is attempted at all.

In offline mode, verification never falls back to “log unreachable, proceed anyway.” It records an explicit transparencyLog.offline.trustedRootPinned: true evidence value — the only accepted substitute for live transparency-log inclusion — and there is no path that produces a verified: true result with neither online inclusion nor an actually-consulted pinned root. Offline verification is strictly “verify against trust material you brought with you,” never “skip verification because you have no network.”

Registry-sourced packs materialize at <LUMENFLOW_HOME>/pack-cache/<packId>/<digest> (the pack cache) after every check above passes; a parallel <LUMENFLOW_HOME>/pack-oci-cache/ holds quarantine staging and the installed-trust-state ledger (pack-trust-state.json). PackLoader only ever reads from the pack cache for a source: registry pin — it performs no network I/O at load time. That split is deliberate: if nothing is at the expected cache path, that install simply never happened (or was removed), and the loader says so with an actionable error rather than silently reaching for the network the way an older, retired registry client used to.

Reinstalling a removed pack. Deleting the extracted cache directory and the workspace pin removes a pack. Because the installed-trust-state ledger still remembers the last-accepted digest for that pack ID, reinstalling the same digest afterward takes the idempotent-reinstall path above — it is not treated as a fresh, unauthorized install.

Recovering from a failed or interrupted install. Every stage — download, quarantine re-verification, cache promotion — is atomic or fails closed with nothing new reachable:

  • An interrupted download never reaches quarantine re-verification; no cache change occurs.
  • An interrupted promotion cannot leave a half-written directory at the final cache path: promotion is one same-filesystem rename, and a reinstall from an existing (non-empty) target path renames the old content aside first, then the new content into place — a crash between those two renames leaves nothing at the final path rather than a mixed one, so the next pack:install retry starts clean.
  • A corrupted cache entry (content that no longer matches its pin’s integrity hash) is caught by PackLoader’s own load-time integrity check — the fix is the same pack:install invocation that installed it; the atomic-promotion path above guarantees a completed reinstall replaces the corrupted directory wholesale, never patches it in place.

There is no scenario where install failure silently falls back to a git checkout, a bespoke tarball fetch, or a weaker verification path — every failure returns {success: false} with the specific stage and reason.