feat: Add in-memory signing API returning the Sigstore bundle as bytes - #642
feat: Add in-memory signing API returning the Sigstore bundle as bytes#642DevamShah wants to merge 1 commit into
Conversation
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>
2655e1b to
fd14ff5
Compare
|
@mihaimaruseac — rebased and strengthened this. Summary of what changed since you last saw it. What it is, in one line: Rebased. Strengthened the test. The test previously asserted the returned bytes parse as a bundle and that the DSSE payload matches what To check that assertion actually bites, I mutated Verified locally, using the invocations from Baseline: the same suite on unmodified One thing I did not verify: What I did not change: Ask: the seven workflow runs on |
Summary
Adds
Config.sign_to_bytes()(and a module-levelsigning.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-writingsign()remains the default and is unchanged for callers. This is the library half of #582; the--stdoutCLI flag also requested in that issue is a natural follow-up that builds directly on this API, so this PR is taggedPart of: #582rather 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 abstractto_bytes(self) -> bytesmethod to theSignaturebase class, mirroring the existingwrite/readcontract.model_signing/_signing/sign_sigstore.pyandsign_sigstore_pb.py: implementedto_bytes()asself.bundle.to_json().encode("utf-8"), and refactoredwrite()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 concreteSignaturesubclasses; the elliptic-key and PKCS#11 signers both returnsign_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 addedConfig.sign_to_bytes(model_path) -> bytesand a module-levelsign_to_bytes(model_path)convenience function that mirrors the existingsign()function.sign()now reuses_sign()and is behaviorally unchanged.README.md: added a usage snippet forsign_to_bytes()in the Model Signing API section, alongside the existingsign()examples.tests/api_test.py: addedTestKeySigning::test_sign_to_bytes. It asserts the returned value isbytes, 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 whatsign()writes to disk.CHANGELOG.md: added an entry underUnreleased / 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 thatwrite()persists -- both now flow through the singleto_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
mainat 6210542 and re-verified locally with the project's own hatch invocations (the onesunit_tests.ymlandlint.ymluse):hatch test -c -py 3.12--201 passed, 84% total coverage. Same suite on unmodifiedmainis200 passed, so this adds exactly one test and breaks none.hatch test -c -py 3.10andhatch test -c -py 3.14--201 passedon each.hatch fmt --check--ruff check src/:All checks passed!;ruff format --check src/:23 files already formatted.hatch run type:check(pytype) locally -- it dies with/bin/sh: /Users/.../Library/Application: No such file or directorybecause 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()insign_sigstore_pb.pyto emit a bundle that is still valid JSON with an identical DSSE payload but a corrupteddsseEnvelope.signatures[0].sig. The strengthened test fails on it (cryptography.exceptions.InvalidSignature); without the verification step it passes. So the test now catches ato_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 newto_bytes()implementation directly.Part of: #582
Checklist