From 2655e1be71740328d92895a950732047598dc7e9 Mon Sep 17 00:00:00 2001 From: Devam Shah Date: Mon, 22 Jun 2026 19:40:07 +0530 Subject: [PATCH] feat: Add in-memory signing API returning the Sigstore bundle as bytes 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. Part of: #582 Signed-off-by: Devam Shah --- CHANGELOG.md | 1 + README.md | 14 +++++ src/model_signing/_signing/sign_sigstore.py | 6 ++- .../_signing/sign_sigstore_pb.py | 6 ++- src/model_signing/_signing/signing.py | 13 +++++ src/model_signing/signing.py | 53 ++++++++++++++++++- tests/api_test.py | 41 ++++++++++++++ 7 files changed, 130 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b391983..d20cc8e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All versions prior to 1.0.0 are untracked. ### Added - Added the `digest` subcommand to compute and print a model's digest. This enables other tools to easily pair the attestations with a model directory. - Added `--module-paths` option to PKCS #11 signing methods pkcs11-key and pkcs11-certificate. +- Added `model_signing.signing.Config.sign_to_bytes()` (and a module-level `model_signing.signing.sign_to_bytes()` helper) that return the Sigstore bundle in memory as bytes instead of writing it to disk. This supports serverless and pipeline callers that need to stream or store the signature without filesystem access. ([#582](https://github.com/sigstore/model-transparency/issues/582)) ### Changed - Standardized CLI flags to use hyphens (e.g., `--trust-config` instead of `--trust_config`). Underscore variants are still accepted for backwards compatibility via token normalization. diff --git a/README.md b/README.md index f1417a21..c5e147f6 100644 --- a/README.md +++ b/README.md @@ -372,6 +372,20 @@ for model in all_models: signing_config.sign(model, f"{model}_sharded.sig") ``` +To obtain the signature in memory instead of writing it to disk -- for example +in a serverless function or a streaming pipeline that needs to push the bundle +to an object store -- use `sign_to_bytes`, which returns the Sigstore bundle as +bytes: + +```python +import model_signing + +signature_bytes = model_signing.signing.sign_to_bytes("bert-base-uncased") +``` + +The same is available on an explicit configuration via +`Config().sign_to_bytes(model)`. + Verification needs a configuration. To verify using Sigstore: ```python diff --git a/src/model_signing/_signing/sign_sigstore.py b/src/model_signing/_signing/sign_sigstore.py index a4e5bb42..26e399b6 100644 --- a/src/model_signing/_signing/sign_sigstore.py +++ b/src/model_signing/_signing/sign_sigstore.py @@ -51,7 +51,11 @@ def __init__(self, bundle: sigstore_models.Bundle): @override def write(self, path: pathlib.Path) -> None: - path.write_text(self.bundle.to_json(), encoding="utf-8") + path.write_bytes(self.to_bytes()) + + @override + def to_bytes(self) -> bytes: + return self.bundle.to_json().encode("utf-8") @classmethod @override diff --git a/src/model_signing/_signing/sign_sigstore_pb.py b/src/model_signing/_signing/sign_sigstore_pb.py index 800e0036..70ad9751 100644 --- a/src/model_signing/_signing/sign_sigstore_pb.py +++ b/src/model_signing/_signing/sign_sigstore_pb.py @@ -105,7 +105,11 @@ def __init__(self, bundle: bundle_pb.Bundle): @override def write(self, path: pathlib.Path) -> None: - path.write_text(self.bundle.to_json(), encoding="utf-8") + path.write_bytes(self.to_bytes()) + + @override + def to_bytes(self) -> bytes: + return self.bundle.to_json().encode("utf-8") @classmethod @override diff --git a/src/model_signing/_signing/signing.py b/src/model_signing/_signing/signing.py index d68c5872..1faafbb9 100644 --- a/src/model_signing/_signing/signing.py +++ b/src/model_signing/_signing/signing.py @@ -304,6 +304,19 @@ def write(self, path: pathlib.Path) -> None: path: The path to write the signature to. """ + @abc.abstractmethod + def to_bytes(self) -> bytes: + """Serializes the signature to bytes. + + Returns the same Sigstore bundle content that `write` persists to + disk, encoded as UTF-8 JSON. This lets callers in serverless or + pipeline contexts obtain the signature in memory without touching the + filesystem. + + Returns: + The serialized signature, as UTF-8 encoded bytes. + """ + @classmethod @abc.abstractmethod def read(cls, path: pathlib.Path) -> Self: diff --git a/src/model_signing/signing.py b/src/model_signing/signing.py index 4de79be2..14022ef0 100644 --- a/src/model_signing/signing.py +++ b/src/model_signing/signing.py @@ -74,6 +74,24 @@ def sign(model_path: hashing.PathLike, signature_path: hashing.PathLike): Config().sign(model_path, signature_path) +def sign_to_bytes(model_path: hashing.PathLike) -> bytes: + """Signs a model using the default configuration and returns the signature. + + In this default configuration we sign using Sigstore and the default hashing + configuration from `model_signing.hashing`. + + The resulting signature is the Sigstore bundle, returned in memory as UTF-8 + encoded JSON bytes instead of being written to disk. + + Args: + model_path: the path to the model to sign. + + Returns: + The signature, as a Sigstore bundle encoded in UTF-8 JSON bytes. + """ + return Config().sign_to_bytes(model_path) + + class Config: """Configuration to use when signing models. @@ -101,12 +119,43 @@ def sign( model_path: The path to the model to sign. signature_path: The path of the resulting signature. """ + signature = self._sign(model_path) + signature.write(pathlib.Path(signature_path)) + + def sign_to_bytes(self, model_path: hashing.PathLike) -> bytes: + """Signs a model and returns the signature as bytes. + + This mirrors `sign`, but instead of writing the signature to disk it + returns the Sigstore bundle in memory. This is useful in serverless or + pipeline contexts where writing to the filesystem is undesirable or + impossible, and the bundle needs to be streamed, persisted to an object + store, or passed directly to another process. + + The returned bytes are the UTF-8 encoded Sigstore bundle, identical to + what `sign` would have written to the signature path. + + Args: + model_path: The path to the model to sign. + + Returns: + The signature, as a Sigstore bundle encoded in UTF-8 JSON bytes. + """ + return self._sign(model_path).to_bytes() + + def _sign(self, model_path: hashing.PathLike) -> signing.Signature: + """Hashes and signs a model, returning the in-memory signature. + + Args: + model_path: The path to the model to sign. + + Returns: + The `Signature` produced by the configured signer. + """ if self._signer is None: self.use_sigstore_signer() manifest = self._hashing_config.hash(model_path) payload = signing.Payload(manifest) - signature = self._signer.sign(payload) - signature.write(pathlib.Path(signature_path)) + return self._signer.sign(payload) def set_hashing_config(self, hashing_config: hashing.Config) -> Self: """Sets the new configuration for hashing models. diff --git a/tests/api_test.py b/tests/api_test.py index c65e7322..fc4c71d3 100644 --- a/tests/api_test.py +++ b/tests/api_test.py @@ -218,6 +218,47 @@ def test_sign_and_verify_with_custom_trust_config( class TestKeySigning: + def test_sign_to_bytes(self, base_path, populate_tmpdir): + os.chdir(base_path) + + model_path = populate_tmpdir + signature = Path(model_path / "model.sig") + private_key = Path(TESTDATA / "keys/certificate/signing-key.pem") + + config = ( + signing.Config() + .use_elliptic_key_signer(private_key=private_key) + .set_hashing_config( + hashing.Config().set_ignored_paths( + paths=[signature], ignore_git_paths=False + ) + ) + ) + + # In-memory signing returns the Sigstore bundle as bytes, without + # writing anything to disk. + signature_bytes = config.sign_to_bytes(model_path) + assert isinstance(signature_bytes, bytes) + assert not signature.exists() + + # The bytes are a valid Sigstore bundle carrying the expected payload. + bundle = json.loads(signature_bytes) + payload = json.loads(b64decode(bundle["dsseEnvelope"]["payload"])) + signed_files = [ + entry["name"] for entry in payload["predicate"]["resources"] + ] + assert signed_files == [".gitignore", "signme-1", "signme-2"] + + # The in-memory payload matches what writing to disk produces. The + # bundle signature itself is non-deterministic (ECDSA), so we compare + # the signed DSSE payload rather than the raw bytes. + config.sign(model_path, signature) + disk_bundle = json.loads(signature.read_text()) + disk_payload = json.loads( + b64decode(disk_bundle["dsseEnvelope"]["payload"]) + ) + assert payload == disk_payload + def test_sign_and_verify(self, base_path, populate_tmpdir): os.chdir(base_path)