Skip to content

security: add opt-in HMAC-SHA256 integrity verification for bundle objects - #21917

Closed
theluckystrike wants to merge 3 commits into
PrefectHQ:mainfrom
theluckystrike:hardening/bundle-hmac-signing
Closed

security: add opt-in HMAC-SHA256 integrity verification for bundle objects#21917
theluckystrike wants to merge 3 commits into
PrefectHQ:mainfrom
theluckystrike:hardening/bundle-hmac-signing

Conversation

@theluckystrike

Copy link
Copy Markdown
Contributor

Summary

Adds opt-in HMAC-SHA256 signing and verification to _serialize_bundle_object() and _deserialize_bundle_object() via a new PREFECT_BUNDLE_SIGNING_KEY environment variable. When the key is set, serialized bundles are prepended with an HMAC-SHA256 hex digest; deserialization verifies that digest before calling cloudpickle.loads(). When the key is absent, behavior is identical to today — fully backwards compatible.

Motivation

This change was suggested by the Prefect security team as a hardening direction for deployments where the worker operator and the bundle author sit in different trust domains. Submitting at their invitation to give operators an explicit integrity-verification layer on top of cloudpickle deserialization.

Changes

src/prefect/bundles/__init__.py:

  1. New helper _get_bundle_signing_key() — reads PREFECT_BUNDLE_SIGNING_KEY from the environment. Returns empty bytes when unset (signing disabled).

  2. Modified _serialize_bundle_object() — when a signing key is configured, computes hmac.new(key, payload, sha256).hexdigest() and prepends it to the payload separated by :.

  3. Modified _deserialize_bundle_object() — when a signing key is configured:

    • Rejects unsigned bundles (no : separator) with a descriptive error
    • Splits signature from payload
    • Verifies using hmac.compare_digest() (timing-safe comparison)
    • Only calls cloudpickle.loads() after verification passes

Backwards Compatibility

Strictly additive:

  • PREFECT_BUNDLE_SIGNING_KEY is unset by default — all existing deployments continue to work without modification
  • The wire format change (<64-char hex sig>:<payload>) is only introduced when the key is explicitly configured
  • No changes to public API surface
  • No new dependencies (uses stdlib hmac and hashlib)

Testing Plan

  • No key set (default): round-trip produces identical output to current behavior; existing tests pass unchanged
  • Key set, valid round-trip: serialized bundle deserializes successfully with matching key
  • Key set, tampered payload: mutating a single byte raises ValueError before cloudpickle.loads()
  • Key set, missing signature: passing unsigned bundle while key is configured raises ValueError
  • Key mismatch: signing with key A, verifying with key B raises ValueError
  • Timing safety: hmac.compare_digest() is used (not ==)

References

  • Python docs: hmac.compare_digest
  • Cloudpickle deserialization executes arbitrary code; signature verification ensures the payload originates from a trusted serializer

…jects

Add cryptographic signing to the bundle serialization pipeline via a new
PREFECT_BUNDLE_SIGNING_KEY environment variable:

- When set, _serialize_bundle_object() prepends an HMAC-SHA256 hex digest
  to the serialized payload: "<hex_sig>:<base64_payload>"
- When set, _deserialize_bundle_object() verifies the signature using
  hmac.compare_digest() before calling cloudpickle.loads()
- When unset (default), behavior is identical to today — fully backwards
  compatible

This gives operators an explicit integrity-verification layer for
deployments where the worker operator and the bundle author sit in
different trust domains.

No new dependencies — uses stdlib hmac and hashlib.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@codspeed-hq

codspeed-hq Bot commented May 12, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 2 untouched benchmarks


Comparing theluckystrike:hardening/bundle-hmac-signing (a641be4) with main (7b3d832)

Open in CodSpeed

@desertaxle desertaxle left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR @theluckystrike! I think it'd be better to serialize the entire bundle and include the signature as an additional field in the bundle. That will extend the safety added by this fix to the dependencies also. Would you also be willing to add tests for this PR also?

Restructured HMAC-SHA256 signing per reviewer feedback:
- Sign the entire serialized bundle JSON instead of individual fields
- Include signature as additional 'signature' field in the bundle dict
- This extends integrity protection to dependencies and all other fields
- Reverted _serialize/_deserialize_bundle_object to original simple form
- Added comprehensive test suite for signing and verification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@theluckystrike

theluckystrike commented May 14, 2026 via email

Copy link
Copy Markdown
Contributor Author

Replace RST-style double backticks with plain text in docstrings.
Also fix "behaviour" → "behavior" (US spelling per codespell).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@theluckystrike

Copy link
Copy Markdown
Contributor Author

Pre-commit fix pushed (removed double backticks from docstrings, fixed UK→US spelling). All local checks passing — ruff, codespell, format. Ready for re-review when CI is green. Happy to make any further adjustments.

@theluckystrike

Copy link
Copy Markdown
Contributor Author

Hi @desertaxle, thanks for the feedback! Just wanted to clarify — the current implementation already does exactly what you described:

Signs the entire bundle, signature stored as a field:

def _sign_bundle(bundle: SerializedBundle) -> None:
    content = {k: v for k, v in bundle.items() if k != "signature"}
    payload = json.dumps(content, sort_keys=True).encode("utf-8")
    bundle["signature"] = hmac.new(signing_key, payload, hashlib.sha256).hexdigest()

The HMAC covers all fields (function, context, flow_run, dependencies, files_key) via deterministic JSON serialization. The signature field is added to the SerializedBundle TypedDict. Verification runs in extract_flow_from_bundle(), _extract_and_run_flow(), and execute_bundle_in_subprocess() — so the signature is checked before any deserialization or dependency installation happens.

Tests are included in tests/_experimental/bundles/test_bundle_signing.py (163 lines) covering:

  • Key retrieval from environment
  • Sign/verify round-trip
  • Tamper detection (modified function, context, dependencies, flow_run)
  • Unsigned bundle rejection when key is set
  • No-op behavior when key is unset (backwards-compatible)
  • Integration with create_bundle_for_flow_run

The single CI failure (Python 3.14 + postgres:14) is a pre-existing infrastructure issue — FATAL: role "root" does not exist — unrelated to these changes. All other 29 checks pass.

Happy to adjust the approach if you had a different architecture in mind. Would you like me to change anything?

@desertaxle

Copy link
Copy Markdown
Member

After digging further into the bundle integrity model, we think this needs a broader design, and iterating on a PR like this isn't the best place to do that.

In particular, the right solution needs to account for where bundle integrity metadata is stored, how workers retrieve trusted expected values, how to verify sidecar bundles, and how to leave room for future asymmetric signing. That likely needs changes across the bundle execution protocol and server/worker boundaries rather than a local HMAC check in bundle serialization.

I’m going to close this PR so the Prefect maintainers can take the design and implementation forward. We appreciate you raising this finding!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants