Key Takeaway
OpenSSF Model Signing (OMS) signs an ML model as one detached Sigstore bundle: a DSSE envelope holding an in-toto manifest of every file's SHA-256 hash. You sign with the model_signing CLI using a key, certificate or Sigstore keyless identity, then verify against an expected identity. Publishers sign today; enforcement before serving is still mostly manual.
The question that made me read the OpenSSF Model Signing spec end to end is a plain one: how do I know the model directory my deployment pulled at pod start is the one the release team signed off on? A container image has a digest the runtime can check. A folder of safetensors, a tokenizer and a config does not, and a download path serves whatever bytes the bucket returns.
This post is a close reading of the OMS v1.0 specification, the model_signing README, the Sigstore launch post, NVIDIA's NGC write-up and a July 2026 arXiv paper that measures what verification costs on a Kubernetes download path. I did not run the commands against a model for this post. Every flag below is quoted from a source I read, and where those sources disagree I say so.
What an OMS signature actually contains
An OMS signature is a detached file in the Sigstore Bundle Format, not a header glued onto your weights. Inside the bundle sits a DSSE envelope carrying an in-toto statement, the signature over it, and verification material such as certificate chains. The statement's predicate is a manifest listing every file with its hash, so one signature can cover weights, configs, tokenizers and datasets as a single verifiable unit. The spec repository ships no signing code: it holds the specification, schemas, test vectors and a conformance suite of 79 tests (31 roundtrip and 48 verify), and implementations live elsewhere.
The v1.0 spec pins the details. The predicate type must be exactly the URI below, the DSSE payload type is application/vnd.in-toto+json, and the predicate has two required keys, resources and serialization. Resources are sorted by name in Unicode code point order and directory entries are not allowed. The subject digest is a SHA-256 over the concatenated raw bytes of the resource digests, and it is always SHA-256 whatever hash_type the files use. SHA-256 is the default file hash; BLAKE2b is the alternative.
# Shape of an OMS v1.0 signature bundle (decoded DSSE payload), per the spec.
# Digests are placeholders; "..." marks fields omitted for brevity.
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "bert-base-uncased", # the model's basename
"digest": { "sha256": "<root digest>" } } # SHA-256 over the concatenated
], # resource digests, always sha256
"predicateType": "https://model_signing/signature/v1.0", # exact URI required
"predicate": {
"resources": [ # sorted by name, code point order
{ "name": "config.json", "digest": "<hex>", "algorithm": "sha256" },
{ "name": "model.safetensors", "digest": "<hex>", "algorithm": "sha256" },
{ "name": "tokenizer.json", "digest": "<hex>", "algorithm": "sha256" }
],
"serialization": {
"method": "files", # or "shards" (then shard_size is required)
"hash_type": "sha256", # BLAKE2b is the alternative
"allow_symlinks": false,
"ignore_paths": []
}
}
}
# payloadType of the DSSE envelope: application/vnd.in-toto+json
# bundle mediaType in the spec example: application/vnd.dev.sigstore.bundle.v0.3+jsonTwo consequences follow. Verification re-hashes the directory and compares it with the manifest, so the signature protects files by what they hash to, not by where they sit. And because the signature is a separate file, the spec says it should use a .sig extension, live in the model directory or its parent, and be excluded from the signed scope, otherwise the bundle would try to sign itself.
Signing and verifying with the model_signing CLI
The reference implementation is the model-signing Python package from the Sigstore project. It exposes sign, verify and digest subcommands, and you pick the method by subcommand: sigstore is the default and can be omitted, while key and certificate are explicit. Keyless signing opens an OIDC flow. The README lists Google, GitHub, GitHub Actions and Microsoft as identity providers, and GitHub Actions, GitLab CI, Google Cloud Platform and Buildkite CI as workload identity platforms.
# Commands as published in the sigstore/model-transparency README.
# Keyless (default method, opens an OIDC flow in the browser):
model_signing sign bert-base-uncased
# Verify against the identity and issuer you EXPECT, not whatever the bundle says:
model_signing verify bert-base-uncased --signature model.sig \
--identity "$identity" --identity-provider "$oidc_provider"
# Key-based: the method is a subcommand, not a flag.
model_signing sign key bert-base-uncased --private-key key.priv
model_signing verify key bert-base-uncased --signature resnet.sig --public-key key.pub
# Private Sigstore instance:
model_signing sign bert-base-uncased --trust-config client_trust_config.json
# Print the digest without signing (useful to compare two copies of a model):
model_signing digest bert-base-uncasedVerification asks for the identity you expect, not merely any valid signature. The Sigstore launch post frames it as checking the model against a claimed identity and OIDC provider, and says verification keeps working after the short-lived certificate expires, because the logged certificate and inclusion proof are what get checked.
The sources disagree on flag spelling. The current README writes --private-key and --identity-provider with hyphens, while the April 2025 launch post writes --identity_provider and NVIDIA's guide writes --certificate_chain with underscores. I did not run any of them, so treat model_signing verify --help on your installed version as the authority and pin the package version in CI.
Four ways to establish trust
OMS is deliberately PKI-agnostic: bare keys, self-signed certificates, private PKI and Sigstore all produce the same bundle shape. What changes is the trust anchor, and the spec spells out what a verifier must do for each.
| Method | Trust anchor | What the verifier checks (spec v1.0) | Where it fits |
|---|---|---|---|
| Bare key | A public key you already hold | Signature against the public key in the bundle; transparency log entries may be absent | Air-gapped clusters; the arXiv design uses key and certificate flows there |
| Certificate (self-signed or private PKI) | A root CA you trust | Chain validated to the trusted root, leaf validity period checked, then the signature; transparency log entries may be absent | NVIDIA's published verify step passes a downloaded certificate with a certificate chain flag |
| Sigstore, public keyless | An OIDC identity, certified through Fulcio and logged in Rekor | Sigstore Bundle rules including transparency log verification; log entries are required | Models built in CI under a workload identity; needs to reach the public transparency infrastructure |
| Sigstore, private instance | Your own Sigstore deployment | Same rules as public Sigstore, with a trust config file passed on the command line | Organisations that cannot use the public instance; the README shows a trust-config flag when signing |
I would default to Sigstore keyless where the build runs on one of those workload identity platforms, because no long-lived signing key exists to leak and the Sigstore authors recommend it as the default. I would reach for a key or certificate only when the verifier cannot reach the public transparency infrastructure, since keyless verification requires it.
What the signature covers, and what it ignores
Verification fails by default if the directory holds a file the manifest does not list. That is what makes the signature a statement about the whole model folder rather than a few files: someone who drops an extra file next to signed weights breaks verification. The opt-out is an explicit ignore-unsigned-files option, and even then every listed file must still verify.
More spec rules to know before you hit them. Top-level .git, .gitignore, .gitattributes and .github are excluded by default, and only at the model root. User exclusions are exact relative paths with no wildcards. Symlinks are not followed by default and should raise an error. Filenames must be valid UTF-8, paths compare byte for byte and case-sensitively, and a model with no regular files is rejected.
# Default: any file in the directory that the manifest does not list fails.
model_signing verify ./llama-finetune --signature ./llama-finetune.sig \
--identity "$identity" --identity-provider "$oidc_provider"
# Names from the spec (v1.0, sections on ignore rules and unsigned files);
# confirm the exact spelling with --help on your version.
# --ignore-paths exact relative paths, no * ? [ wildcards, "/" separator
# --ignore-unsigned-files tolerate unlisted files; listed ones must still verify
# Wrong: pointing the signature INSIDE the signed scope without excluding it.
# Right: the spec says the --signature file is excluded at sign AND verify time,
# and recommends a .sig name in the model directory or its parent.The spec also defines a shards method that splits each file into byte ranges, with a recommended default of 1,000,000,000 bytes and entries named path:start:end. A verifier that does not implement shards must reject the bundle with an error rather than guess, so confirm your verifier supports whichever method your signer emitted before rolling it across a fleet.
NVIDIA NGC: producers are signing already
Adoption on the publishing side is real. NVIDIA's developer blog says every NVIDIA-published model in the NGC Catalog has been signed with OMS since March 2025, and calls NGC the first major model hub to offer cryptographic signing for hosted models. The bundle verifies weights, configuration files, tokenizers and related assets together, the NGC UI shows a Signed badge, and the CLI listing has a Has Signed Version column.
# NVIDIA's published verification flow for a signed NGC model.
pip install model-signing # the post recommends pip hash-checking mode
ngc registry model download-version "nvidia/tao/dashcamnet:pruned_onnx_v1.0.5"
ngc registry model download-version-signature "nvidia/tao/dashcamnet:pruned_onnx_v1.0.5"
# The post's curl line combines -O and -o; I collapsed it to -o here.
curl -o cert.pem https://api.ngc.nvidia.com/v2/catalog/models/public-key
# The consumer verifies with NVIDIA's public certificate, not a Sigstore identity:
model_signing verify <MODEL_PATH> --signature <SIG_PATH> --certificate_chain cert.pem
# On success the post shows "Verification succeeded."That is a complete producer story and a manual consumer story. The post does not describe key rotation, certificate revocation or automatic verification on download, and it covers NVIDIA-published models only, so community models are outside it. I read that silence as a gap to plan around, not proof that nothing exists.
Where serving-side enforcement is still missing
The July 2026 arXiv paper by Georgii Kliukovkin states the gap plainly: producer-side adoption is real, but serving-side enforcement on the download path remains manual. Its table of KServe delivery paths shows why. Where a model arrives as an image reference in the pod spec, admission tools such as Kyverno and the sigstore policy-controller can verify it. Where the storage initializer fetches loose files, nothing at admission time observes the download.
| Path | Scheme | Pulled by | Admission-verifiable |
|---|---|---|---|
| Modelcar sidecar | oci:// | Container runtime | Yes |
| Image volume | oci+native:// | kubelet (KEP-4639) | Yes |
| Initializer OCI pull | oci+fetch:// | Storage initializer | No |
| Object storage | s3://, gs://, hf:// | Storage initializer | No |
The closest tool today is the sigstore model-validation-operator, which the repository describes as a proof of concept. A webhook injects an init container into pods that carry a label naming a ModelValidation resource, and the container checks the model against the signature. A mismatch fails with verification failed: the manifests do not match, and the workload does not run.
# From the sigstore/model-validation-operator README (proof of concept).
apiVersion: ml.sigstore.dev/v1alpha1
kind: ModelValidation
metadata:
name: demo
spec:
config:
sigstoreConfig:
certificateIdentity: "<URL of the GitHub workflow that signed the model>"
certificateOidcIssuer: "https://token.actions.githubusercontent.com"
model:
path: /data/tensorflow_saved_model
signaturePath: /data/tensorflow_saved_model/model.sig
# A pod in the same namespace opts in with this label; the webhook then injects
# the verifying init container. Pods labelled AFTER creation are not validated.
# validation.ml.sigstore.dev/ml: "demo"The paper's criticism is that this is platform-agnostic: it verifies volumes that already exist on pods it is pointed at, knows nothing of the serving platform's storage URIs or download code, and offers no digest pinning. The authors propose verification inside KServe's storage initializer (RFC 5789) and label it a design under review, not merged work. Treat it as direction, not a switch you can flip.
What verification costs, and how it fails
The paper measured the hashing half of verification on an AWS m6i.2xlarge with a local MinIO object store, reporting medians. Hashing while the bytes stream in is effectively free. Hashing after the download as a second pass is not.
| Artifact | Plain download | Streaming SHA-256 | Post-download SHA-256 |
|---|---|---|---|
| 14 GB | 190.3 s | 190.4 s (+0.05%) | 203.6 s (+7.0%) |
| 140 GB | 2,220.4 s | 2,219.8 s (under 0.1%) | 3,406.8 s (+53.4%) |
Two caveats stop me quoting this as the cost of OMS. These are SHA-256 hashing numbers: the signature half, and in keyless mode the Fulcio and Rekor round trips, the paper says are independent of artifact size and remain to be measured. And the setup is single-node and LAN-local. The paper also reports that at 140 GB the storage initializer was OOM-killed until its memory limit was raised fourfold, which matters if you bolt verification onto an initializer.
Failing open is the expensive mistake. The paper's design fails closed on every verifier error, not only on a mismatch, because an attacker who controls the storage can simply delete the signature file beside the artifact. Its warn mode starts the pod anyway and, in the authors' words, must not be mistaken for a security control.
Four more limits from the same threat model. A valid signature proves provenance, not intent: any artifact ever signed by the allowed identity passes, so an attacker with the storage can substitute an older signed revision or another model from the same release identity, which is why the paper's policy also binds the expected model name. Verification runs once at initialisation, after which a co-located sidecar or an operator with pods/exec could modify the bytes; the operator's optional sidecar mode re-validates on an interval such as 10m but only logs a failure and keeps running. Init containers do not re-run when only the main container restarts. And digest pinning does not yet handle mutable hf:// revisions well.
What to do on Monday
Start with the half you control, which is publishing. Then make verification a hard gate in whatever starts your model server, and only after that reach for admission machinery.
- Sign every model directory in CI with Sigstore keyless where your build platform is on the workload identity list, and write the signature beside the model with a .sig name.
- Record the identity, OIDC issuer and model name you expect in deployment config, and verify against those, never against whatever the bundle claims.
- Verify before the model server loads weights, and exit non-zero on any failure, including a missing signature file.
- If you hash in your own loader, hash while streaming: the paper's second pass over a 140 GB artifact cost 53.4% extra.
- Decide now what air-gapped or private clusters do: key or certificate verification with a trust config, since keyless verification needs the public transparency infrastructure.
The script below is my own sketch, not KServe's design and not tested here. It encodes only the fail-closed rule: no signature, no start.
#!/bin/sh
# Entrypoint wrapper: no verified signature, no model server. My own sketch.
set -eu
MODEL_DIR=/models/llama-finetune
SIG="$MODEL_DIR.sig" # kept OUTSIDE the signed scope, next to it
EXPECTED_IDENTITY="$SIGNER_IDENTITY" # pinned in deployment config
EXPECTED_ISSUER="https://token.actions.githubusercontent.com"
# A missing bundle is a failure, not a skip: whoever controls storage can delete it.
test -s "$SIG" || { echo "signature missing, refusing to start" >&2; exit 1; }
model_signing verify "$MODEL_DIR" --signature "$SIG" \
--identity "$EXPECTED_IDENTITY" --identity-provider "$EXPECTED_ISSUER" \
|| { echo "verification failed, refusing to start" >&2; exit 1; }
exec "$@" # only now start the model serverA signature on a model is only worth what the verifier does with it. OMS supplies the format and, with NGC, publishers who use it. The rule I take away is to verify where the bytes are loaded, against a pinned identity and model name, and to fail closed whenever any part of verification is missing.
Sources
- OpenSSF: model-signing-spec repository (README)
- OpenSSF: OMS specification v1.0
- Sigstore: model-transparency README (model_signing CLI)
- Sigstore blog: Practical Model Signing with Sigstore (v1.0)
- NVIDIA Developer Blog: model signing in NGC
- arXiv 2607.16596: Cold-Start Model Delivery in Kubernetes Inference Serving
- GitHub: sigstore/model-validation-operator

