Skip to content

feat: Add in-memory signing API returning the Sigstore bundle as bytes - #642

Open
DevamShah wants to merge 1 commit into
sigstore:mainfrom
DevamShah:sign-to-bytes-api
Open

feat: Add in-memory signing API returning the Sigstore bundle as bytes#642
DevamShah wants to merge 1 commit into
sigstore:mainfrom
DevamShah:sign-to-bytes-api

Conversation

@DevamShah

@DevamShah DevamShah commented Jun 23, 2026

Copy link
Copy Markdown

Summary

Adds Config.sign_to_bytes() (and a module-level signing.sign_to_bytes() helper) so callers can obtain the Sigstore bundle in memory as bytes instead of being forced to write it to disk, enabling serverless and pipeline signing flows. The existing disk-writing sign() remains the default and is unchanged for callers. This is the library half of #582; the --stdout CLI flag also requested in that issue is a natural follow-up that builds directly on this API, so this PR is tagged Part of: #582 rather than closing it.

Problem

Today the only way to capture a signature through the library API is Config.sign(model_path, signature_path), which serializes the bundle and writes it to a filesystem path. In serverless functions (read-only or ephemeral filesystems), CI/CD steps, and streaming pipelines, the caller wants the bundle bytes directly -- to push to an object store, attach to an HTTP response, or hand to another process -- without round-tripping through a temp file. The workaround (write to a temp file, read it back, delete it) adds filesystem dependencies, cleanup burden, and a small race/leak surface for what is sensitive signing output. This is the gap raised in #582 and requested by the maintainer.

Change

Following the existing API conventions:

  • model_signing/_signing/signing.py: added an abstract to_bytes(self) -> bytes method to the Signature base class, mirroring the existing write/read contract.
  • model_signing/_signing/sign_sigstore.py and sign_sigstore_pb.py: implemented to_bytes() as self.bundle.to_json().encode("utf-8"), and refactored write() to delegate to it (path.write_bytes(self.to_bytes())) so the on-disk and in-memory representations are guaranteed identical and produced by a single code path. These are the only two concrete Signature subclasses; the elliptic-key and PKCS#11 signers both return sign_sigstore_pb.Signature, so no subclass is left without an implementation.
  • model_signing/signing.py: extracted the hash -> payload -> sign sequence shared by both output modes into a private _sign() helper, then added Config.sign_to_bytes(model_path) -> bytes and a module-level sign_to_bytes(model_path) convenience function that mirrors the existing sign() function. sign() now reuses _sign() and is behaviorally unchanged.
  • README.md: added a usage snippet for sign_to_bytes() in the Model Signing API section, alongside the existing sign() examples.
  • tests/api_test.py: added TestKeySigning::test_sign_to_bytes. It asserts the returned value is bytes, that nothing is written to disk, that the bytes parse as a valid Sigstore bundle with the expected signed resources, that persisting those bytes unmodified and running them through the normal elliptic-key verification path succeeds, and that the in-memory DSSE payload is identical to what sign() writes to disk.
  • CHANGELOG.md: added an entry under Unreleased / Added.

The change is purely additive and backwards compatible -- no existing signature, default, or output is altered.

Security rationale

The bundle returned by to_bytes() is byte-for-byte the same Sigstore bundle that write() persists -- both now flow through the single to_bytes() serializer, so there is no divergence between the in-memory and on-disk signed artifact, and no second serialization path to audit. Keeping signing output in memory removes an unnecessary plaintext-on-disk step for sensitive provenance material, which is desirable on shared or ephemeral build hosts and reduces residual-file cleanup obligations (cf. CWE-459 Incomplete Cleanup, CWE-212 Improper Removal of Sensitive Information, CWE-200). This strengthens ML supply-chain integrity -- the project's core purpose, aligned with SLSA provenance and OWASP ML Security Top 10 ML06 (AI Supply Chain Attacks) -- by making it practical to sign in environments that previously could not write to disk, rather than encouraging the insecure write-read-delete workaround. No new key material handling, network behavior, or trust decision is introduced; the signer, payload, and bundle format are unchanged.

Testing

Rebased onto main at 6210542 and re-verified locally with the project's own hatch invocations (the ones unit_tests.yml and lint.yml use):

  • hatch test -c -py 3.12 -- 201 passed, 84% total coverage. Same suite on unmodified main is 200 passed, so this adds exactly one test and breaks none.
  • hatch test -c -py 3.10 and hatch test -c -py 3.14 -- 201 passed on each.
  • hatch fmt --check -- ruff check src/: All checks passed!; ruff format --check src/: 23 files already formatted.
  • I could not run hatch run type:check (pytype) locally -- it dies with /bin/sh: /Users/.../Library/Application: No such file or directory because my hatch env path contains a space. That is a local environment problem, not a code one, and CI covers it.

To confirm the round-trip assertion is not decorative, I mutated to_bytes() in sign_sigstore_pb.py to emit a bundle that is still valid JSON with an identical DSSE payload but a corrupted dsseEnvelope.signatures[0].sig. The strengthened test fails on it (cryptography.exceptions.InvalidSignature); without the verification step it passes. So the test now catches a to_bytes() that produces well-formed but cryptographically invalid output, not just a well-formed one.

The new test uses local elliptic-key signing, so it runs offline with no Sigstore/OIDC network dependency. Because the elliptic-key signer returns sign_sigstore_pb.Signature, this offline test also exercises the new to_bytes() implementation directly.

Part of: #582

Checklist
  • All commits are signed-off, using DCO
  • All new code has docstrings and type annotations
  • All new code is covered by tests. Aim for at least 90% coverage. CI is configured to highlight lines not covered by tests.
  • Public facing changes are paired with documentation changes
  • Release note has been added to CHANGELOG.md if needed

@DevamShah
DevamShah requested review from a team as code owners June 23, 2026 01:36
The library only exposed `Config.sign(model_path, signature_path)`, which
forces signing output to a filesystem path. Serverless functions, CI steps,
and streaming pipelines often run on read-only or ephemeral filesystems and
need the Sigstore bundle in memory, leaving a write-read-delete temp-file
workaround as the only option.

Add an abstract `Signature.to_bytes()` and implement it on both Sigstore
signature types as the UTF-8 encoded bundle JSON, then route `write()`
through it so the on-disk and in-memory artifacts share a single
serialization path. Expose `Config.sign_to_bytes()` and a module-level
`sign_to_bytes()` helper that mirror the existing `sign()` API. The hash,
payload, and sign sequence is factored into a private `_sign()` helper
reused by both output modes; `sign()` is behaviorally unchanged. Document
the new API in the README and add a CHANGELOG entry.

Add a key-based API test asserting the returned bytes are a valid Sigstore
bundle whose signed payload matches what `sign()` writes to disk, and that
the bytes survive the real verification path: the bundle is persisted
unmodified and verified with the elliptic-key verifier, so the test fails
if `to_bytes()` ever emits something that is well-formed but not
cryptographically valid.

Part of: sigstore#582

Signed-off-by: Devam Shah <devamshah91@gmail.com>
@DevamShah

Copy link
Copy Markdown
Author

@mihaimaruseac — rebased and strengthened this. Summary of what changed since you last saw it.

What it is, in one line: Config.sign_to_bytes() returns the Sigstore bundle in memory as bytes instead of forcing a filesystem path, so signing works inside an ephemeral build step, a read-only serverless filesystem, or a memory-only enclave without the write-read-delete temp-file workaround. It is the library half of #582 (@SequeI's issue); the --stdout CLI flag is a follow-up that sits directly on top of this.

Rebased. fd14ff5, now parented directly on main at 6210542. git diff --stat origin/main..HEAD is 7 files, +139/-4 and nothing else came along. I checked the interface drift explicitly, since main moved through #648/#649/#650 while this sat: those touched verifying.py, _cli.py, and sign_certificate.py, none of which this PR touches. grep -rn "class .*Signature" src/ still returns exactly two concrete subclasses (sign_sigstore.py:41, sign_sigstore_pb.py:95), both of which implement the new abstract to_bytes(), so adding the abstract method leaves no subclass unimplemented. sign_ec_key.py:103 and sign_pkcs11.py:159 both still return sign_sigstore_pb.Signature.

Strengthened the test. The test previously asserted the returned bytes parse as a bundle and that the DSSE payload matches what sign() writes. That checks well-formedness, not validity. tests/api_test.py::TestKeySigning::test_sign_to_bytes now also persists the returned bytes unmodified and runs them through the real verifying.Config().use_elliptic_key_verifier(...).verify() path.

To check that assertion actually bites, I mutated to_bytes() in sign_sigstore_pb.py to emit valid JSON with an identical DSSE payload but a corrupted dsseEnvelope.signatures[0].sig. Result: the old test passed on that mutation; the new test fails with cryptography.exceptions.InvalidSignature at sign_ec_key.py:201. So a to_bytes() that emits something well-formed but cryptographically invalid is now caught. Source restored after the experiment; git diff --stat HEAD -- src/ is empty.

Verified locally, using the invocations from unit_tests.yml and lint.yml:

hatch test -c -py 3.12   ->  201 passed, TOTAL 84% coverage
hatch test -c -py 3.10   ->  201 passed
hatch test -c -py 3.14   ->  201 passed
hatch fmt --check        ->  ruff check src/: All checks passed!
                             ruff format --check src/: 23 files already formatted

Baseline: the same suite on unmodified origin/main is 200 passed. So this is exactly +1 test and 0 regressions. The new test is offline — local elliptic-key signing, no Sigstore or OIDC dependency.

One thing I did not verify: hatch run type:check. pytype fails on my machine with /bin/sh: /Users/.../Library/Application: No such file or directory — my hatch env path contains a space. That is my environment, not the code, and I did not work around it. CI covers pytype.

What I did not change: sign() keeps its exact signature and behavior, and write() now just delegates to to_bytes() so the on-disk and in-memory artifacts cannot diverge. No new key handling, network behavior, or trust decision.

Ask: the seven workflow runs on fd14ff5 are all sitting at action_required — they need a maintainer to approve the run on a fork PR. They were all green on the previous head. Could you approve the runs? And if the abstract-method addition to Signature is the sticking point (it is the only part that touches an existing interface), I am happy to drop it and implement to_bytes() concretely on the two Sigstore classes without touching the base class — say the word and I will push that shape instead.

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.

1 participant