Skip to content

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

Merged
mihaimaruseac merged 1 commit into
sigstore:mainfrom
DevamShah:sign-to-bytes-api
Sep 19, 2026
Merged

mihaimaruseac merged 1 commit into
sigstore:mainfrom
DevamShah:sign-to-bytes-api

Conversation

@DevamShah

@DevamShah DevamShah commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

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
@DevamShah

Copy link
Copy Markdown
Contributor 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.

@DevamShah

Copy link
Copy Markdown
Contributor Author

The single red check here (model-signing-cli-test, run 33153620313) looks unrelated to this diff.

Signing succeeds all three ways; the job then fails in the legacy-version verify loop on the TUF root:

Testing 'verify sigstore' method
Error: 'verify sigstore' failed with v1.0.1
Key ... failed to verify root
Verification failed with error: root was signed by 0/3 keys

This PR is additive to signing (to_bytes() + sign_to_bytes()) and touches no verification, CLI or TUF code.

The same failure hit run 33358007154 on dependabot/github_actions/all-47aa82513f — a branch with no src/ changes — which suggests an external condition rather than anything on either branch.

It also appears to have since cleared: Run CLI tests passed on 2026-09-04 for both fix/reject-expired-certificates and fix/reject-empty-model-signing. The red X on this PR predates that and is stale.

Could a maintainer re-run the job when convenient? I don't have rerun rights here. Happy to rebase instead if you'd prefer a fresh run.

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
Contributor Author

Tracked down the red check and it isn't this branch. test-sign-verify-allversions.sh pins sigstore==v3.6.5 for the legacy verify loop but leaves securesystemslib unpinned, and sigstore 3.6.5 caps cryptography at >=42,<46. securesystemslib 1.5.0 needs cryptography 48+ but only declares that under its crypto extra, so pip happily installs 1.5.0 against cryptography 45, the root keys don't load, and you get root was signed by 0/3 keys. It's been yanked for exactly that (secure-systems-lab/securesystemslib#1203). Worth noting 1.5.1 fixes it with a runtime fallback rather than a metadata change, so the two releases look identical if you diff their requirements — it shipped 09-04, which is the day CLI runs here went green again.

Outside CI, model-signing 1.0.1 plus sigstore 3.6.5 verifying scripts/tests/v1.0.1-sigstore exits 1 under securesystemslib 1.5.0 and 0 under 1.5.1, same five key ids as the run log, nothing else changed. Happy to send a separate PR pinning securesystemslib next to the sigstore pin if you'd rather that loop not depend on whatever the resolver picks, though it's fixed upstream either way.

Rebased onto 8337cbc, diff unchanged, 202 tests against 201 on main. The runs on the new head are all sitting at action_required again — could you approve them?

@mihaimaruseac
mihaimaruseac merged commit ff3624e into sigstore:main Sep 19, 2026
52 checks passed
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.

3 participants