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.
The artifact
Section titled “The artifact”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. Revieweddist/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 existingdist/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’slistPackFiles, the same listcomputeDeterministicPackHashhashes 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.
Registry protocol
Section titled “Registry protocol”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 pushGET /v2/<name>/blobs/<digest>— blob pullPUT /v2/<name>/manifests/<digest>— manifest push, always by immutable digestGET /v2/<name>/manifests/<reference>— manifest resolve, by digest or tagGET /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
pushevent on a SemVerrefs/tags/v...ref; and job_workflow_refnaming 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.
Signing and identity
Section titled “Signing and identity”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.
--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).
What install checks, and in what order
Section titled “What install checks, and in what order”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:
- Digest — the downloaded content actually hashes to what was resolved.
- Expected publisher — the artifact’s own
publisher.idmatches--expected-publisher-id(defaults to LumenFlow’s own publisher for first-party packs). - 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. - 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.
- 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 reservedlumenflow.sbom.digestexternal parameter must equal the selected SBOM content digest. - Compatibility and contract — the same
contract_version/compatibilitychecks every Contract v2 pack goes through (see Pack Contract v2). - 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.
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.
Replay, rollback, and retargeting
Section titled “Replay, rollback, and retargeting”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.”
Cache and recovery
Section titled “Cache and recovery”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 nextpack:installretry 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 samepack:installinvocation 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.