Skip to content

Commit 3f06adf

Browse files
committed
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, 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: #582 Signed-off-by: Devam Shah <devamshah91@gmail.com>
1 parent 8337cbc commit 3f06adf

7 files changed

Lines changed: 139 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ All versions prior to 1.0.0 are untracked.
1212
### Added
1313
- 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.
1414
- Added `--module-paths` option to PKCS #11 signing methods pkcs11-key and pkcs11-certificate.
15+
- 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))
1516

1617
### Changed
1718
- 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.

‎README.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,20 @@ for model in all_models:
372372
signing_config.sign(model, f"{model}_sharded.sig")
373373
```
374374

375+
To obtain the signature in memory instead of writing it to disk -- for example
376+
in a serverless function or a streaming pipeline that needs to push the bundle
377+
to an object store -- use `sign_to_bytes`, which returns the Sigstore bundle as
378+
bytes:
379+
380+
```python
381+
import model_signing
382+
383+
signature_bytes = model_signing.signing.sign_to_bytes("bert-base-uncased")
384+
```
385+
386+
The same is available on an explicit configuration via
387+
`Config().sign_to_bytes(model)`.
388+
375389
Verification needs a configuration. To verify using Sigstore:
376390

377391
```python

‎src/model_signing/_signing/sign_sigstore.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ def __init__(self, bundle: sigstore_models.Bundle):
5151

5252
@override
5353
def write(self, path: pathlib.Path) -> None:
54-
path.write_text(self.bundle.to_json(), encoding="utf-8")
54+
path.write_bytes(self.to_bytes())
55+
56+
@override
57+
def to_bytes(self) -> bytes:
58+
return self.bundle.to_json().encode("utf-8")
5559

5660
@classmethod
5761
@override

‎src/model_signing/_signing/sign_sigstore_pb.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,11 @@ def __init__(self, bundle: bundle_pb.Bundle):
105105

106106
@override
107107
def write(self, path: pathlib.Path) -> None:
108-
path.write_text(self.bundle.to_json(), encoding="utf-8")
108+
path.write_bytes(self.to_bytes())
109+
110+
@override
111+
def to_bytes(self) -> bytes:
112+
return self.bundle.to_json().encode("utf-8")
109113

110114
@classmethod
111115
@override

‎src/model_signing/_signing/signing.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,19 @@ def write(self, path: pathlib.Path) -> None:
308308
path: The path to write the signature to.
309309
"""
310310

311+
@abc.abstractmethod
312+
def to_bytes(self) -> bytes:
313+
"""Serializes the signature to bytes.
314+
315+
Returns the same Sigstore bundle content that `write` persists to
316+
disk, encoded as UTF-8 JSON. This lets callers in serverless or
317+
pipeline contexts obtain the signature in memory without touching the
318+
filesystem.
319+
320+
Returns:
321+
The serialized signature, as UTF-8 encoded bytes.
322+
"""
323+
311324
@classmethod
312325
@abc.abstractmethod
313326
def read(cls, path: pathlib.Path) -> Self:

‎src/model_signing/signing.py‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,24 @@ def sign(model_path: hashing.PathLike, signature_path: hashing.PathLike):
7474
Config().sign(model_path, signature_path)
7575

7676

77+
def sign_to_bytes(model_path: hashing.PathLike) -> bytes:
78+
"""Signs a model using the default configuration and returns the signature.
79+
80+
In this default configuration we sign using Sigstore and the default hashing
81+
configuration from `model_signing.hashing`.
82+
83+
The resulting signature is the Sigstore bundle, returned in memory as UTF-8
84+
encoded JSON bytes instead of being written to disk.
85+
86+
Args:
87+
model_path: the path to the model to sign.
88+
89+
Returns:
90+
The signature, as a Sigstore bundle encoded in UTF-8 JSON bytes.
91+
"""
92+
return Config().sign_to_bytes(model_path)
93+
94+
7795
class Config:
7896
"""Configuration to use when signing models.
7997
@@ -101,12 +119,43 @@ def sign(
101119
model_path: The path to the model to sign.
102120
signature_path: The path of the resulting signature.
103121
"""
122+
signature = self._sign(model_path)
123+
signature.write(pathlib.Path(signature_path))
124+
125+
def sign_to_bytes(self, model_path: hashing.PathLike) -> bytes:
126+
"""Signs a model and returns the signature as bytes.
127+
128+
This mirrors `sign`, but instead of writing the signature to disk it
129+
returns the Sigstore bundle in memory. This is useful in serverless or
130+
pipeline contexts where writing to the filesystem is undesirable or
131+
impossible, and the bundle needs to be streamed, persisted to an object
132+
store, or passed directly to another process.
133+
134+
The returned bytes are the UTF-8 encoded Sigstore bundle, identical to
135+
what `sign` would have written to the signature path.
136+
137+
Args:
138+
model_path: The path to the model to sign.
139+
140+
Returns:
141+
The signature, as a Sigstore bundle encoded in UTF-8 JSON bytes.
142+
"""
143+
return self._sign(model_path).to_bytes()
144+
145+
def _sign(self, model_path: hashing.PathLike) -> signing.Signature:
146+
"""Hashes and signs a model, returning the in-memory signature.
147+
148+
Args:
149+
model_path: The path to the model to sign.
150+
151+
Returns:
152+
The `Signature` produced by the configured signer.
153+
"""
104154
if self._signer is None:
105155
self.use_sigstore_signer()
106156
manifest = self._hashing_config.hash(model_path)
107157
payload = signing.Payload(manifest)
108-
signature = self._signer.sign(payload)
109-
signature.write(pathlib.Path(signature_path))
158+
return self._signer.sign(payload)
110159

111160
def set_hashing_config(self, hashing_config: hashing.Config) -> Self:
112161
"""Sets the new configuration for hashing models.

‎tests/api_test.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,56 @@ def test_sign_and_verify_with_custom_trust_config(
218218

219219

220220
class TestKeySigning:
221+
def test_sign_to_bytes(self, base_path, populate_tmpdir):
222+
os.chdir(base_path)
223+
224+
model_path = populate_tmpdir
225+
signature = Path(model_path / "model.sig")
226+
private_key = Path(TESTDATA / "keys/certificate/signing-key.pem")
227+
public_key = Path(TESTDATA / "keys/certificate/signing-key-pub.pem")
228+
hashing_config = hashing.Config().set_ignored_paths(
229+
paths=[signature], ignore_git_paths=False
230+
)
231+
232+
config = (
233+
signing.Config()
234+
.use_elliptic_key_signer(private_key=private_key)
235+
.set_hashing_config(hashing_config)
236+
)
237+
238+
# In-memory signing returns the Sigstore bundle as bytes, without
239+
# writing anything to disk.
240+
signature_bytes = config.sign_to_bytes(model_path)
241+
assert isinstance(signature_bytes, bytes)
242+
assert not signature.exists()
243+
244+
# The bytes are a valid Sigstore bundle carrying the expected payload.
245+
bundle = json.loads(signature_bytes)
246+
payload = json.loads(b64decode(bundle["dsseEnvelope"]["payload"]))
247+
signed_files = [
248+
entry["name"] for entry in payload["predicate"]["resources"]
249+
]
250+
assert signed_files == [".gitignore", "signme-1", "signme-2"]
251+
252+
# The in-memory bundle is not just well-formed, it verifies: persist
253+
# the bytes unmodified and run the normal verification path over them.
254+
# `verify` raises on any failure, so reaching the next line is the
255+
# assertion.
256+
signature.write_bytes(signature_bytes)
257+
verifying.Config().use_elliptic_key_verifier(
258+
public_key=public_key
259+
).set_hashing_config(hashing_config).verify(model_path, signature)
260+
261+
# The in-memory payload matches what writing to disk produces. The
262+
# bundle signature itself is non-deterministic (ECDSA), so we compare
263+
# the signed DSSE payload rather than the raw bytes.
264+
config.sign(model_path, signature)
265+
disk_bundle = json.loads(signature.read_text())
266+
disk_payload = json.loads(
267+
b64decode(disk_bundle["dsseEnvelope"]["payload"])
268+
)
269+
assert payload == disk_payload
270+
221271
def test_sign_and_verify(self, base_path, populate_tmpdir):
222272
os.chdir(base_path)
223273

0 commit comments

Comments
 (0)