Model Signing and Verification
Defense-focused guide to implementing cryptographic model signing and verification, covering Sigstore for ML, certificate management, SBOM generation for AI systems, and deployment-time verification workflows.
In the current AI ecosystem, there is no standard mechanism for verifying that a model was produced by who it claims to be, has not been modified since creation, and was built from trusted inputs. This absence of provenance is the root cause of many supply chain attacks. Model signing provides the cryptographic foundation for trust: if a model is signed by a known entity and the signature verifies, you can be confident the model has not been tampered with since signing.
Why Model Signing Is Essential
Current State (No Signing):
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Model │ │ Download │ │ Deploy │
│ Repository │────>│ (no verify)│────>│ (no verify)│
│ (anyone can │ │ │ │ │
│ upload) │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘
Question: Who created this model? Was it modified? Is it safe?
Answer: Unknown. No way to verify.
Desired State (With Signing):
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Model │ │ Download + │ │ Verify + │
│ Repository │────>│ Verify Sig │────>│ Deploy │
│ (signed by │ │ (reject if │ │ (reject if │
│ publisher) │ │ invalid) │ │ unsigned) │
└─────────────┘ └─────────────┘ └─────────────┘
Question: Who created this model? Was it modified? Is it safe?
Answer: Signed by meta-llama, signature valid, checksum matches.
Implementing Model Signing with Sigstore
Sigstore provides free, open-source signing infrastructure that eliminates the need to manage your own PKI for model artifacts.
Signing Models
#!/bin/bash
# sign-model.sh
# Sign model artifacts using cosign (Sigstore)
set -euo pipefail
MODEL_DIR="${1:?Usage: sign-model.sh <model_directory>}"
OUTPUT_DIR="${MODEL_DIR}/.signatures"
echo "============================================"
echo " Model Artifact Signing"
echo " Model: $MODEL_DIR"
echo "============================================"
mkdir -p "$OUTPUT_DIR"
# Step 1: Generate manifest of all model files with checksums
echo "[1/4] Generating model manifest..."
MANIFEST_FILE="$OUTPUT_DIR/manifest.json"
python3 -c "
import json
import hashlib
from pathlib import Path
model_dir = Path('$MODEL_DIR')
manifest = {
'version': '1.0',
'model_directory': str(model_dir),
'files': {}
}
for f in sorted(model_dir.rglob('*')):
if f.is_dir() or '.signatures' in str(f):
continue
sha256 = hashlib.sha256(f.read_bytes()).hexdigest()
rel_path = str(f.relative_to(model_dir))
manifest['files'][rel_path] = {
'sha256': sha256,
'size': f.stat().st_size,
}
manifest['total_files'] = len(manifest['files'])
manifest['total_size'] = sum(f['size'] for f in manifest['files'].values())
with open('$MANIFEST_FILE', 'w') as out:
json.dump(manifest, out, indent=2)
print(f'Manifest: {len(manifest[\"files\"])} files, {manifest[\"total_size\"]:,} bytes')
"
# Step 2: Sign the manifest (which covers all files via checksums)
echo "[2/4] Signing manifest..."
# Option A: Keyless signing (uses OIDC identity, no key management needed)
if [ "${SIGSTORE_KEYLESS:-false}" = "true" ]; then
cosign sign-blob \
--output-signature "$OUTPUT_DIR/manifest.sig" \
--output-certificate "$OUTPUT_DIR/manifest.cert" \
"$MANIFEST_FILE"
echo "[*] Signed with keyless (OIDC identity)"
else
# Option B: Key-based signing (for CI/CD pipelines)
cosign sign-blob \
--key "${COSIGN_KEY:-cosign.key}" \
--output-signature "$OUTPUT_DIR/manifest.sig" \
"$MANIFEST_FILE"
echo "[*] Signed with key"
fi
# Step 3: Generate model card with provenance
echo "[3/4] Generating provenance record..."
cat > "$OUTPUT_DIR/provenance.json" << PROVEOF
{
"builder": {
"id": "$(whoami)@$(hostname)",
"version": "1.0"
},
"build_type": "model_training",
"invocation": {
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"environment": {
"os": "$(uname -s)",
"arch": "$(uname -m)",
"python": "$(python3 --version 2>&1 | awk '{print $2}')"
}
},
"materials": [],
"metadata": {
"git_commit": "$(git rev-parse HEAD 2>/dev/null || echo 'unknown')",
"pipeline_run": "${GITHUB_RUN_ID:-${CI_PIPELINE_ID:-local}}"
}
}
PROVEOF
# Sign provenance too
cosign sign-blob \
--key "${COSIGN_KEY:-cosign.key}" \
--output-signature "$OUTPUT_DIR/provenance.sig" \
"$OUTPUT_DIR/provenance.json" 2>/dev/null || true
# Step 4: Verify the signatures we just created
echo "[4/4] Verifying signatures..."
if [ "${SIGSTORE_KEYLESS:-false}" = "true" ]; then
cosign verify-blob \
--signature "$OUTPUT_DIR/manifest.sig" \
--certificate "$OUTPUT_DIR/manifest.cert" \
--certificate-identity-regexp '.*' \
--certificate-oidc-issuer-regexp '.*' \
"$MANIFEST_FILE"
else
cosign verify-blob \
--key "${COSIGN_PUB_KEY:-cosign.pub}" \
--signature "$OUTPUT_DIR/manifest.sig" \
"$MANIFEST_FILE"
fi
echo ""
echo "[PASS] Model signed and verified successfully"
echo " Manifest: $MANIFEST_FILE"
echo " Signature: $OUTPUT_DIR/manifest.sig"
echo " Provenance: $OUTPUT_DIR/provenance.json"Verifying Models at Deployment Time
"""
Model Signature Verifier
Verifies model artifact signatures at deployment time.
Blocks deployment of unsigned or tampered models.
"""
import json
import hashlib
import subprocess
import logging
from pathlib import Path
logger = logging.getLogger("model_verifier")
class ModelVerificationError(Exception):
"""Raised when model verification fails."""
pass
class ModelSignatureVerifier:
"""
Verifies model signatures before deployment.
Integrates with cosign for signature verification.
"""
def __init__(self, public_key_path: str = None, keyless: bool = False):
self.public_key = public_key_path
self.keyless = keyless
def verify_model(self, model_dir: str) -> dict:
"""
Full verification of a signed model directory.
Steps:
1. Check that signature files exist
2. Verify the manifest signature
3. Verify all file checksums match the manifest
4. Verify provenance if available
"""
model_path = Path(model_dir)
sig_dir = model_path / ".signatures"
result = {
"model_directory": model_dir,
"checks": {},
"verified": False,
}
# Step 1: Check signature files exist
manifest_file = sig_dir / "manifest.json"
signature_file = sig_dir / "manifest.sig"
if not manifest_file.exists():
result["checks"]["manifest_exists"] = {
"passed": False,
"error": "No manifest.json found -- model is unsigned",
}
raise ModelVerificationError(
f"Model {model_dir} is unsigned (no manifest.json). "
f"Deployment blocked by security policy."
)
if not signature_file.exists():
result["checks"]["signature_exists"] = {
"passed": False,
"error": "No manifest.sig found -- model signature missing",
}
raise ModelVerificationError(
f"Model {model_dir} has manifest but no signature. "
f"Deployment blocked."
)
result["checks"]["files_exist"] = {"passed": True}
# Step 2: Verify manifest signature
sig_valid = self._verify_cosign_signature(
str(manifest_file),
str(signature_file),
str(sig_dir / "manifest.cert") if self.keyless else None,
)
result["checks"]["signature_valid"] = {
"passed": sig_valid,
"method": "keyless" if self.keyless else "key-based",
}
if not sig_valid:
raise ModelVerificationError(
f"Model {model_dir} signature verification FAILED. "
f"The model may have been tampered with. Deployment blocked."
)
# Step 3: Verify file checksums
manifest = json.loads(manifest_file.read_text())
checksum_results = self._verify_checksums(model_path, manifest)
result["checks"]["checksums"] = checksum_results
if not checksum_results["all_valid"]:
raise ModelVerificationError(
f"Model {model_dir} checksum verification FAILED. "
f"Files have been modified since signing. "
f"Tampered files: {checksum_results['failed_files']}"
)
# Step 4: Verify provenance
provenance_file = sig_dir / "provenance.json"
if provenance_file.exists():
provenance = json.loads(provenance_file.read_text())
result["checks"]["provenance"] = {
"passed": True,
"builder": provenance.get("builder", {}).get("id", "unknown"),
"timestamp": provenance.get("invocation", {}).get("timestamp", "unknown"),
}
result["verified"] = True
logger.info(f"Model {model_dir} verification PASSED")
return result
def _verify_cosign_signature(
self,
file_path: str,
sig_path: str,
cert_path: str = None,
) -> bool:
"""Verify a cosign signature."""
try:
cmd = ["cosign", "verify-blob"]
if self.keyless and cert_path:
cmd.extend([
"--certificate", cert_path,
"--certificate-identity-regexp", ".*",
"--certificate-oidc-issuer-regexp", ".*",
])
elif self.public_key:
cmd.extend(["--key", self.public_key])
else:
logger.error("No verification method configured")
return False
cmd.extend(["--signature", sig_path, file_path])
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=30,
)
return result.returncode == 0
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
logger.error(f"Signature verification failed: {e}")
return False
def _verify_checksums(self, model_path: Path, manifest: dict) -> dict:
"""Verify all file checksums against the manifest."""
results = {
"total_files": len(manifest.get("files", {})),
"verified_files": [],
"failed_files": [],
"missing_files": [],
"extra_files": [],
}
manifest_files = set(manifest.get("files", {}).keys())
# Check files listed in manifest
for rel_path, file_info in manifest.get("files", {}).items():
file_path = model_path / rel_path
if not file_path.exists():
results["missing_files"].append(rel_path)
continue
actual_hash = hashlib.sha256(file_path.read_bytes()).hexdigest()
expected_hash = file_info["sha256"]
if actual_hash == expected_hash:
results["verified_files"].append(rel_path)
else:
results["failed_files"].append({
"file": rel_path,
"expected": expected_hash,
"actual": actual_hash,
})
# Check for extra files not in manifest
for f in model_path.rglob("*"):
if f.is_dir() or ".signatures" in str(f):
continue
rel = str(f.relative_to(model_path))
if rel not in manifest_files:
results["extra_files"].append(rel)
results["all_valid"] = (
len(results["failed_files"]) == 0
and len(results["missing_files"]) == 0
)
return results
def deployment_gate(model_dir: str, public_key: str) -> bool:
"""
Deployment gate function. Call this before deploying any model.
Returns True if model is verified, raises exception otherwise.
"""
verifier = ModelSignatureVerifier(public_key_path=public_key)
try:
result = verifier.verify_model(model_dir)
logger.info(
f"DEPLOYMENT APPROVED: {model_dir} "
f"(signature valid, {result['checks']['checksums']['total_files']} files verified)"
)
return True
except ModelVerificationError as e:
logger.critical(f"DEPLOYMENT BLOCKED: {e}")
raiseCertificate Management for Model Signing
# model-signing-certificate-policy.yaml
# Certificate management policy for model artifact signing
certificate_policy:
version: "1.0"
signing_authorities:
production:
type: "key-based"
key_storage: "HSM" # Hardware Security Module
key_algorithm: "ECDSA-P256"
key_rotation: "annual"
authorized_signers:
- "ml-platform-ci@company.com"
- "model-release-bot@company.com"
requires_approval: true
approval_count: 2
staging:
type: "key-based"
key_storage: "vault" # HashiCorp Vault
key_algorithm: "ECDSA-P256"
key_rotation: "quarterly"
authorized_signers:
- "ml-platform-ci@company.com"
- "ml-engineer-*@company.com"
requires_approval: false
development:
type: "keyless"
provider: "sigstore"
identity_provider: "company-oidc"
allowed_identities:
- "*@company.com"
requires_approval: false
verification_policy:
production_deployment:
require_signature: true
accepted_authorities: ["production"]
require_provenance: true
max_signature_age_days: 90
staging_deployment:
require_signature: true
accepted_authorities: ["production", "staging"]
require_provenance: true
max_signature_age_days: 180
development_deployment:
require_signature: false
accepted_authorities: ["production", "staging", "development"]
require_provenance: false
key_management:
generation:
- "Keys generated on HSM or Vault"
- "Private keys never exported"
- "Key ceremony requires 2 authorized personnel"
rotation:
- "New key generated before old key expires"
- "Transition period: both keys accepted for 30 days"
- "Old key revoked after transition"
- "All models re-signed with new key during transition"
revocation:
- "Compromised keys revoked immediately"
- "All models signed with compromised key quarantined"
- "Re-sign all affected models with new key"
- "Incident response triggered for key compromise"AI-Specific SBOM (Software Bill of Materials)
"""
AI SBOM Generator
Generates a Software Bill of Materials specifically designed
for AI systems, including model provenance, dataset provenance,
and framework dependencies.
Extends CycloneDX format with AI-specific components.
"""
import json
import hashlib
import subprocess
from datetime import datetime
from pathlib import Path
class AIBOMGenerator:
"""
Generates an AI Bill of Materials (AI-BOM) that extends
traditional SBOM with AI-specific components.
"""
def __init__(self, project_name: str, version: str):
self.project_name = project_name
self.version = version
self.components = []
self.dependencies = []
def add_model(
self,
name: str,
version: str,
source: str,
format: str,
checksum: str,
signed: bool = False,
signer: str = None,
training_data: str = None,
base_model: str = None,
):
"""Add a model component to the AI-BOM."""
component = {
"type": "machine-learning-model",
"name": name,
"version": version,
"supplier": {"name": source},
"hashes": [{"alg": "SHA-256", "content": checksum}],
"properties": [
{"name": "ml:model:format", "value": format},
{"name": "ml:model:signed", "value": str(signed)},
],
}
if signer:
component["properties"].append(
{"name": "ml:model:signer", "value": signer}
)
if training_data:
component["properties"].append(
{"name": "ml:model:training_data", "value": training_data}
)
self.dependencies.append({
"ref": name,
"dependsOn": [training_data],
})
if base_model:
component["properties"].append(
{"name": "ml:model:base_model", "value": base_model}
)
self.dependencies.append({
"ref": name,
"dependsOn": [base_model],
})
self.components.append(component)
def add_dataset(
self,
name: str,
version: str,
source: str,
checksum: str,
sample_count: int = None,
license: str = None,
):
"""Add a dataset component to the AI-BOM."""
component = {
"type": "data",
"name": name,
"version": version,
"supplier": {"name": source},
"hashes": [{"alg": "SHA-256", "content": checksum}],
"properties": [
{"name": "ml:data:type", "value": "training-dataset"},
],
}
if sample_count:
component["properties"].append(
{"name": "ml:data:sample_count", "value": str(sample_count)}
)
if license:
component["licenses"] = [{"license": {"id": license}}]
self.components.append(component)
def add_framework_dependencies(self):
"""Automatically detect and add ML framework dependencies."""
try:
result = subprocess.run(
["pip", "list", "--format=json"],
capture_output=True, text=True, check=True,
)
packages = json.loads(result.stdout)
ml_relevant = {
"torch", "tensorflow", "transformers", "safetensors",
"tokenizers", "accelerate", "peft", "datasets",
"numpy", "scipy", "scikit-learn", "onnxruntime",
"langchain", "openai", "anthropic",
}
for pkg in packages:
if pkg["name"].lower().replace("-", "_") in {
p.replace("-", "_") for p in ml_relevant
}:
self.components.append({
"type": "library",
"name": pkg["name"],
"version": pkg["version"],
"purl": f"pkg:pypi/{pkg['name']}@{pkg['version']}",
})
except (subprocess.CalledProcessError, json.JSONDecodeError):
pass
def generate(self) -> dict:
"""Generate the complete AI-BOM in CycloneDX format."""
return {
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": {
"timestamp": datetime.utcnow().isoformat() + "Z",
"component": {
"type": "application",
"name": self.project_name,
"version": self.version,
},
"properties": [
{"name": "ai-bom:version", "value": "1.0"},
{"name": "ai-bom:generator", "value": "ai-sbom-gen/1.0"},
],
},
"components": self.components,
"dependencies": self.dependencies,
}
def save(self, output_path: str):
"""Save AI-BOM to file."""
bom = self.generate()
Path(output_path).write_text(json.dumps(bom, indent=2))
return output_path
# Example: generate AI-BOM for a deployed model
if __name__ == "__main__":
bom = AIBOMGenerator("sentiment-analyzer", "2.1.0")
# Add the deployed model
bom.add_model(
name="sentiment-model-v2.1",
version="2.1.0",
source="internal-model-registry",
format="safetensors",
checksum="abc123def456...",
signed=True,
signer="ml-platform-ci@company.com",
training_data="imdb-sentiment-train-v3",
base_model="meta-llama/Llama-3-8B",
)
# Add the training dataset
bom.add_dataset(
name="imdb-sentiment-train-v3",
version="3.1.0",
source="internal-data-registry",
checksum="789abc...",
sample_count=47200,
license="MIT",
)
# Add the base model
bom.add_model(
name="meta-llama/Llama-3-8B",
version="main",
source="huggingface.co",
format="safetensors",
checksum="fedcba987...",
signed=False,
)
# Auto-detect framework dependencies
bom.add_framework_dependencies()
# Save
output = bom.save("ai-bom.json")
print(f"AI-BOM saved to {output}")
print(f"Total components: {len(bom.components)}")#!/bin/bash
# generate-ai-sbom.sh
# Generate AI-specific SBOM for a model deployment
set -euo pipefail
MODEL_DIR="${1:?Usage: generate-ai-sbom.sh <model_dir> <output_file>}"
OUTPUT_FILE="${2:-ai-sbom.json}"
echo "[*] Generating AI SBOM"
echo "[*] Model: $MODEL_DIR"
# Collect model file information
echo "[*] Scanning model artifacts..."
MODEL_FILES=$(find "$MODEL_DIR" -type f -not -path "*/.signatures/*" -exec sha256sum {} \;)
# Collect Python dependencies
echo "[*] Scanning Python dependencies..."
PIP_DEPS=$(pip list --format=json 2>/dev/null || echo "[]")
# Generate SBOM
python3 << PYEOF
import json
import os
from datetime import datetime
model_dir = "$MODEL_DIR"
output_file = "$OUTPUT_FILE"
# Parse model files
model_files = []
for line in """$MODEL_FILES""".strip().split("\n"):
if line.strip():
parts = line.strip().split(" ", 1)
if len(parts) == 2:
model_files.append({
"hash": parts[0],
"path": os.path.basename(parts[1]),
})
# Parse pip dependencies
pip_deps = json.loads('''$PIP_DEPS''')
# Build SBOM
sbom = {
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"metadata": {
"timestamp": datetime.utcnow().isoformat() + "Z",
"component": {
"type": "machine-learning-model",
"name": os.path.basename(model_dir),
},
},
"components": [],
}
# Add model files
for mf in model_files:
sbom["components"].append({
"type": "file",
"name": mf["path"],
"hashes": [{"alg": "SHA-256", "content": mf["hash"]}],
})
# Add dependencies
ml_packages = {
"torch", "tensorflow", "transformers", "safetensors",
"tokenizers", "numpy", "scipy", "onnxruntime",
}
for pkg in pip_deps:
if pkg["name"].lower() in ml_packages:
sbom["components"].append({
"type": "library",
"name": pkg["name"],
"version": pkg["version"],
"purl": f"pkg:pypi/{pkg['name']}@{pkg['version']}",
})
with open(output_file, "w") as f:
json.dump(sbom, f, indent=2)
print(f"SBOM generated: {output_file}")
print(f" Model files: {len(model_files)}")
print(f" ML dependencies: {len([c for c in sbom['components'] if c['type'] == 'library'])}")
PYEOF
echo "[*] Done"References
- Sigstore (2024). "Cosign: Container and Artifact Signing"
- NIST (2024). "AI Risk Management Framework (AI RMF 1.0)"
- CycloneDX (2024). "CycloneDX Machine Learning BOM (MLBOM)"
- SLSA (2024). "Supply-chain Levels for Software Artifacts"
- The Update Framework (2024). "TUF: A Framework for Securing Software Update Systems"
- OWASP (2025). "LLM03: Supply Chain Vulnerabilities"
What is the critical difference between verifying a model's SHA-256 checksum and verifying its cryptographic signature?