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)