From a430c6ea6457b3d0948dd4e527b299d6b19ea1fd Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 9 Apr 2026 16:21:53 -0400 Subject: [PATCH 1/3] Change default signature filename from model.sig to claims.jsonl Update the CLI default and all documentation examples to use claims.jsonl as the default signature filename, aligning with the OMS format convention for bundled attestations. The default can still be overridden with --signature. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ralph Bean --- README.md | 18 +++++++++--------- docs/demo.ipynb | 14 +++++++------- docs/model_signing_format.md | 4 ++-- src/model_signing/__init__.py | 10 +++++----- src/model_signing/_cli.py | 7 +++++-- src/model_signing/_signing/signing.py | 2 +- src/model_signing/signing.py | 6 +++--- src/model_signing/verifying.py | 4 ++-- 8 files changed, 34 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f1417a21..64269798 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,9 @@ For verification: ```bash [...]$ model_signing verify bert-base-uncased \ - --signature model.sig \ - --trust-config client_trust_config.json - --identity "$identity" + --signature claims.jsonl \ + --trust-config client_trust_config.json \ + --identity "$identity" \ --identity-provider "$oidc_provider" ``` @@ -169,7 +169,7 @@ All signing methods support changing the signature name and location via the `--signature` flag: ```bash -[...]$ model_signing sign bert-base-uncased --signature model.sig +[...]$ model_signing sign bert-base-uncased --signature claims.jsonl ``` Consult the help for a list of all flags (`model_signing --help`, or directly @@ -180,7 +180,7 @@ model we use ```bash [...]$ model_signing verify bert-base-uncased \ - --signature model.sig \ + --signature claims.jsonl \ --identity "$identity" \ --identity-provider "$oidc_provider" ``` @@ -210,7 +210,7 @@ Similarly, for key verification, we can use ```bash [...]$ model_signing verify key bert-base-uncased \ - --signature resnet.sig --public-key key.pub + --signature claims.jsonl --public-key key.pub ``` #### Signing with PKCS #11 URIs @@ -243,7 +243,7 @@ With a PKCS #11 URI describing the private key, we can use the following for signing: ```bash -[...]$ model_signing sign pkcs11-key --signature model.sig \ +[...]$ model_signing sign pkcs11-key --signature claims.jsonl \ --pkcs11_uri "pkcs11:..." /path/to/your/model ``` @@ -251,7 +251,7 @@ For signature verification it is necessary to retrieve the public key from the PKCS #11 device and store it in a file in PEM format. With can then use: ```bash -[...]$ model_signing verify key --signature model.sig\ +[...]$ model_signing verify key --signature claims.jsonl \ --public-key key.pub /path/to/your/model ``` @@ -342,7 +342,7 @@ The simplest way to generate a signature using Sigstore is: ```python import model_signing -model_signing.signing.sign("bert-base-uncased", "model.sig") +model_signing.signing.sign("bert-base-uncased", "claims.jsonl") ``` This will run the same OIDC flow as when signing with Sigstore from the CLI. diff --git a/docs/demo.ipynb b/docs/demo.ipynb index 9167d14f..b9d852ac 100644 --- a/docs/demo.ipynb +++ b/docs/demo.ipynb @@ -541,7 +541,7 @@ "id": "L2zQrDPnBDcu" }, "source": [ - "By default, the signature is in `model.sig`. First, we can look at its size:" + "By default, the signature is in `claims.jsonl`. First, we can look at its size:" ] }, { @@ -559,12 +559,12 @@ "output_type": "stream", "name": "stdout", "text": [ - "-rw-r--r-- 1 root root 11345 Oct 10 18:00 model.sig\n" + "-rw-r--r-- 1 root root 11345 Oct 10 18:00 claims.jsonl\n" ] } ], "source": [ - "!ls -l model.sig" + "!ls -l claims.jsonl" ] }, { @@ -597,7 +597,7 @@ } ], "source": [ - "!model_signing verify bert-base-uncased --signature model.sig --identity \"$identity\" --identity_provider \"$oidc_provider\"" + "!model_signing verify bert-base-uncased --signature claims.jsonl --identity \"$identity\" --identity_provider \"$oidc_provider\"" ] }, { @@ -785,7 +785,7 @@ } ], "source": [ - "!model_signing verify resnet-50 --signature model.sig --identity \"$identity\" --identity_provider \"$oidc_provider\"" + "!model_signing verify resnet-50 --signature claims.jsonl --identity \"$identity\" --identity_provider \"$oidc_provider\"" ] }, { @@ -818,7 +818,7 @@ } ], "source": [ - "!model_signing verify bert-base-uncased --signature model.sig --identity \"FAKE_IDENTITY\" --identity_provider \"$oidc_provider\"" + "!model_signing verify bert-base-uncased --signature claims.jsonl --identity \"FAKE_IDENTITY\" --identity_provider \"$oidc_provider\"" ] }, { @@ -853,7 +853,7 @@ } ], "source": [ - "!model_signing verify bert-base-uncased --signature model.sig --identity \"$identity\" --identity_provider \"FAKE_PROVIDER\"" + "!model_signing verify bert-base-uncased --signature claims.jsonl --identity \"$identity\" --identity_provider \"FAKE_PROVIDER\"" ] }, { diff --git a/docs/model_signing_format.md b/docs/model_signing_format.md index f0da38c0..3ec7959d 100644 --- a/docs/model_signing_format.md +++ b/docs/model_signing_format.md @@ -71,7 +71,7 @@ transparency log. Below is an example of the Sigstore bundle showing each of the layers described above. ```bash -$ cat model.sig | jq . +$ cat claims.jsonl | jq . { "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial": { @@ -127,7 +127,7 @@ $ cat model.sig | jq . } } -$ cat model.sig | jq .dsseEnvelope.payload -r | base64 -d | jq . +$ cat claims.jsonl | jq .dsseEnvelope.payload -r | base64 -d | jq . { "_type": "https://in-toto.io/Statement/v1", "subject": [ diff --git a/src/model_signing/__init__.py b/src/model_signing/__init__.py index 6d45803e..c42aacd6 100644 --- a/src/model_signing/__init__.py +++ b/src/model_signing/__init__.py @@ -40,7 +40,7 @@ Signing can be done using the default configuration: ```python -model_signing.signing.sign("finbert", "finbert.sig") +model_signing.signing.sign("finbert", "finbert.jsonl") ``` This example generates the signature using Sigstore. @@ -55,7 +55,7 @@ model_signing.hashing.Config().set_ignored_paths( paths=["README.md"], ignore_git_paths=True ) -).sign("finbert", "finbert.sig") +).sign("finbert", "finbert.jsonl") ``` This example generates a signature using a private key based on elliptic curve @@ -72,7 +72,7 @@ ```python model_signing.verifying.Config().use_sigstore_verifier( identity=identity, oidc_issuer=oidc_provider -).verify("finbert", "finbert.sig") +).verify("finbert", "finbert.jsonl") ``` Where `identity` and `oidc_provider` are the parameters obtained after the OIDC @@ -86,7 +86,7 @@ ).set_hashing_config( model_signing.hashing.Config().use_shard_serialization() ) -).verify("finbert", "finbert.sig") +).verify("finbert", "finbert.jsonl") ``` Alternatively, we also support automatic detection of the hashing configuration @@ -95,7 +95,7 @@ ```python model_signing.verifying.Config().use_elliptic_key_verifier( public_key="key.pub" -).verify("finbert", "finbert.sig") +).verify("finbert", "finbert.jsonl") ``` A reminder that we still need to set the verification configuration. This sets diff --git a/src/model_signing/_cli.py b/src/model_signing/_cli.py index 94da05be..51c835d6 100644 --- a/src/model_signing/_cli.py +++ b/src/model_signing/_cli.py @@ -53,8 +53,11 @@ def set_attribute(self, key, value): "--signature", type=pathlib.Path, metavar="SIGNATURE_PATH", - default=pathlib.Path("model.sig"), - help="Location of the signature file to generate. Defaults to `model.sig`.", + default=pathlib.Path("claims.jsonl"), + help=( + "Location of the signature file to generate. " + "Defaults to `claims.jsonl`." + ), ) diff --git a/src/model_signing/_signing/signing.py b/src/model_signing/_signing/signing.py index d68c5872..c17d9b58 100644 --- a/src/model_signing/_signing/signing.py +++ b/src/model_signing/_signing/signing.py @@ -216,7 +216,7 @@ class Payload: "hash_type": "sha256", "allow_symlinks": true "ignore_paths": [ - "model.sig", + "claims.jsonl", ".git", ".gitattributes", ".github", diff --git a/src/model_signing/signing.py b/src/model_signing/signing.py index 4de79be2..655701c5 100644 --- a/src/model_signing/signing.py +++ b/src/model_signing/signing.py @@ -17,14 +17,14 @@ The module allows signing a model with a default configuration: ```python -model_signing.signing.sign("finbert", "finbert.sig") +model_signing.signing.sign("finbert", "finbert.jsonl") ``` The module allows customizing the signing configuration before signing: ```python model_signing.signing.Config().use_elliptic_key_signer(private_key="key").sign( - "finbert", "finbert.sig" + "finbert", "finbert.jsonl" ) ``` @@ -36,7 +36,7 @@ ) for model in all_models: - signing_config.sign(model, f"{model}_sharded.sig") + signing_config.sign(model, f"{model}_sharded.jsonl") ``` The API defined here is stable and backwards compatible. diff --git a/src/model_signing/verifying.py b/src/model_signing/verifying.py index 87303490..60fc4051 100644 --- a/src/model_signing/verifying.py +++ b/src/model_signing/verifying.py @@ -20,7 +20,7 @@ ```python model_signing.verifying.Config().use_sigstore_verifier( identity=identity, oidc_issuer=oidc_provider -).verify("finbert", "finbert.sig") +).verify("finbert", "finbert.jsonl") ``` The same verification configuration can be used to verify multiple models: @@ -31,7 +31,7 @@ ) for model in all_models: - verifying_config.verify(model, f"{model}_sharded.sig") + verifying_config.verify(model, f"{model}_sharded.jsonl") ``` The API defined here is stable and backwards compatible. From ce8d43bddc9d53d78139c8834ce7a1e45b7a25b4 Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 9 Apr 2026 16:22:27 -0400 Subject: [PATCH 2/3] Add backward compatible signature format support Support both legacy model.sig (single JSON) and new claims.jsonl (JSONL) formats during a deprecation period: - Add SignatureFormat enum and format detection functions - Add read_all() to iterate over all claims in JSONL files - JSONL write appends; legacy .sig overwrites with deprecation warning - Verification tries each claim newest-to-oldest, succeeds on first match - Add design document and tests for format detection, deprecation warnings, and JSONL append behavior Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ralph Bean --- README.md | 11 ++ src/model_signing/_signing/sign_sigstore.py | 43 ++++- .../_signing/sign_sigstore_pb.py | 49 +++++- src/model_signing/_signing/signing.py | 70 +++++++- src/model_signing/verifying.py | 43 ++++- tests/_signing/format_compat_test.py | 149 ++++++++++++++++++ 6 files changed, 355 insertions(+), 10 deletions(-) create mode 100644 tests/_signing/format_compat_test.py diff --git a/README.md b/README.md index 64269798..159b3354 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,17 @@ digest) pairs a predicate type set to `https://model_signing/signature/v1.0` and a dictionary of predicates. The idea is to use the predicates to store (and therefor sign) model card information in the future. +The default signature filename is `claims.jsonl`, which uses JSONL format (one +sigstore bundle per line). Signing **appends** a new attestation to this file +rather than overwriting it, allowing attestations to accumulate as models move +through their lifecycle (training, registry, security review, production). + +During verification, all claims in the file are checked from newest to oldest, +and verification succeeds if any claim matches. + +The legacy `model.sig` format (single JSON blob) is still supported but +deprecated. Using `.sig` files will emit deprecation warnings. + The verification part reads the sigstore bundle file and firstly verifies that the signature is valid and secondly compute the model's file hashes again to compare against the signed ones. diff --git a/src/model_signing/_signing/sign_sigstore.py b/src/model_signing/_signing/sign_sigstore.py index a4e5bb42..201434f8 100644 --- a/src/model_signing/_signing/sign_sigstore.py +++ b/src/model_signing/_signing/sign_sigstore.py @@ -17,6 +17,7 @@ import pathlib import sys from typing import cast +import warnings from google.protobuf import json_format from sigstore import dsse as sigstore_dsse @@ -51,13 +52,51 @@ 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") + fmt = signing.detect_output_format(path) + if fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON: + warnings.warn( + signing._DEPRECATION_WARNING, DeprecationWarning, stacklevel=2 + ) + path.write_text(self.bundle.to_json(), encoding="utf-8") + else: + # JSONL: append as a single compact line + with path.open("a", encoding="utf-8") as f: + f.write(self.bundle.to_json() + "\n") @classmethod @override def read(cls, path: pathlib.Path) -> Self: + fmt = signing.detect_signature_format(path) + if fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON: + warnings.warn( + signing._DEPRECATION_WARNING, DeprecationWarning, stacklevel=2 + ) + content = path.read_text(encoding="utf-8") + return cls(sigstore_models.Bundle.from_json(content)) + + # JSONL: read last line (most recent claim) + content = path.read_text(encoding="utf-8") + lines = [line for line in content.splitlines() if line.strip()] + return cls(sigstore_models.Bundle.from_json(lines[-1])) + + @classmethod + @override + def read_all(cls, path: pathlib.Path) -> list[Self]: + fmt = signing.detect_signature_format(path) + if fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON: + warnings.warn( + signing._DEPRECATION_WARNING, DeprecationWarning, stacklevel=2 + ) + content = path.read_text(encoding="utf-8") + return [cls(sigstore_models.Bundle.from_json(content))] + + # JSONL: return all claims, newest first (last line to first line) content = path.read_text(encoding="utf-8") - return cls(sigstore_models.Bundle.from_json(content)) + lines = [line for line in content.splitlines() if line.strip()] + return [ + cls(sigstore_models.Bundle.from_json(line)) + for line in reversed(lines) + ] class Signer(signing.Signer): diff --git a/src/model_signing/_signing/sign_sigstore_pb.py b/src/model_signing/_signing/sign_sigstore_pb.py index 800e0036..a7ef697d 100644 --- a/src/model_signing/_signing/sign_sigstore_pb.py +++ b/src/model_signing/_signing/sign_sigstore_pb.py @@ -26,6 +26,7 @@ import pathlib import sys from typing import cast +import warnings from sigstore_models.bundle import v1 as bundle_pb from typing_extensions import override @@ -105,12 +106,20 @@ 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") + fmt = signing.detect_output_format(path) + if fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON: + warnings.warn( + signing._DEPRECATION_WARNING, DeprecationWarning, stacklevel=2 + ) + path.write_text(self.bundle.to_json(), encoding="utf-8") + else: + # JSONL: append as a single compact line + with path.open("a", encoding="utf-8") as f: + f.write(self.bundle.to_json() + "\n") @classmethod - @override - def read(cls, path: pathlib.Path) -> Self: - content = path.read_text(encoding="utf-8") + def _parse_bundle(cls, content: str) -> Self: + """Parse a single JSON string into a Signature instance.""" parsed_dict = json.loads(content) # adjust parsed_dict due to previous usage of protobufs @@ -125,6 +134,38 @@ def read(cls, path: pathlib.Path) -> Self: return cls(bundle_pb.Bundle.from_dict(parsed_dict)) + @classmethod + @override + def read(cls, path: pathlib.Path) -> Self: + fmt = signing.detect_signature_format(path) + if fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON: + warnings.warn( + signing._DEPRECATION_WARNING, DeprecationWarning, stacklevel=2 + ) + content = path.read_text(encoding="utf-8") + return cls._parse_bundle(content) + + # JSONL: read last line (most recent claim) + content = path.read_text(encoding="utf-8") + lines = [line for line in content.splitlines() if line.strip()] + return cls._parse_bundle(lines[-1]) + + @classmethod + @override + def read_all(cls, path: pathlib.Path) -> list[Self]: + fmt = signing.detect_signature_format(path) + if fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON: + warnings.warn( + signing._DEPRECATION_WARNING, DeprecationWarning, stacklevel=2 + ) + content = path.read_text(encoding="utf-8") + return [cls._parse_bundle(content)] + + # JSONL: return all claims, newest first (last line to first line) + content = path.read_text(encoding="utf-8") + lines = [line for line in content.splitlines() if line.strip()] + return [cls._parse_bundle(line) for line in reversed(lines)] + class Signer(signing.Signer): """Signer for traditional signing. diff --git a/src/model_signing/_signing/signing.py b/src/model_signing/_signing/signing.py index c17d9b58..814472f6 100644 --- a/src/model_signing/_signing/signing.py +++ b/src/model_signing/_signing/signing.py @@ -37,6 +37,7 @@ """ import abc +import enum import json import pathlib import sys @@ -71,6 +72,50 @@ _PREDICATE_TYPE_COMPAT: str = "https://model_signing/Digests/v0.1" +_DEPRECATION_WARNING = ( + "The .sig format (single JSON) is deprecated. " + "Please use claims.jsonl format. " + "Future versions will only support claims.jsonl." +) + + +class SignatureFormat(enum.Enum): + """Format of a signature file.""" + + LEGACY_SINGLE_JSON = "legacy" # Old model.sig format + JSONL = "jsonl" # New claims.jsonl format + + +def detect_signature_format(file_path: pathlib.Path) -> SignatureFormat: + """Detect whether a signature file is legacy single JSON or JSONL format. + + Uses the file extension: `.sig` is legacy, everything else is JSONL. + + Args: + file_path: The path to the signature file. + + Returns: + The detected format. + """ + if file_path.suffix == ".sig": + return SignatureFormat.LEGACY_SINGLE_JSON + return SignatureFormat.JSONL + + +def detect_output_format(signature_path: pathlib.Path) -> SignatureFormat: + """Determine what format to write based on filename. + + Args: + signature_path: The path where the signature will be written. + + Returns: + The format to use for writing. + """ + if signature_path.suffix == ".sig": + return SignatureFormat.LEGACY_SINGLE_JSON + return SignatureFormat.JSONL + + def dsse_payload_to_manifest(dsse_payload: dict[str, Any]) -> manifest.Manifest: """Builds a manifest from the DSSE payload read from a signature. @@ -300,6 +345,10 @@ class Signature(metaclass=abc.ABCMeta): def write(self, path: pathlib.Path) -> None: """Writes the signature to disk, to the given path. + For JSONL format (default, non-.sig files), appends the signature as a + new line. For legacy .sig format, overwrites the file and emits a + deprecation warning. + Args: path: The path to write the signature to. """ @@ -307,7 +356,10 @@ def write(self, path: pathlib.Path) -> None: @classmethod @abc.abstractmethod def read(cls, path: pathlib.Path) -> Self: - """Reads the signature from disk. + """Reads a single signature from disk. + + For JSONL files, reads the last line (most recent claim). + For legacy .sig files, reads the single JSON blob. Does not perform any signature verification, except what is needed to parse the signature file. @@ -320,6 +372,22 @@ def read(cls, path: pathlib.Path) -> Self: signature and integrity verification. """ + @classmethod + @abc.abstractmethod + def read_all(cls, path: pathlib.Path) -> list[Self]: + """Reads all signatures from disk. + + For JSONL files, returns all claims (one per line), ordered from + newest (last line) to oldest (first line). + For legacy .sig files, returns a single-element list. + + Args: + path: The path to read the signatures from. + + Returns: + A list of signature instances, newest first. + """ + class Signer(metaclass=abc.ABCMeta): """Generic signer. diff --git a/src/model_signing/verifying.py b/src/model_signing/verifying.py index 60fc4051..404f62c8 100644 --- a/src/model_signing/verifying.py +++ b/src/model_signing/verifying.py @@ -80,21 +80,58 @@ def verify( ): """Verifies that a model conforms to a signature. + For JSONL signature files containing multiple claims, iterates + through claims from newest to oldest and returns success on the + first claim that verifies successfully. + Args: model_path: The path to the model to verify. signature_path: The path to the signature file. Raises: - ValueError: No verifier has been configured. + ValueError: No verifier has been configured, or no claims verify. """ if self._verifier is None: raise ValueError("Attempting to verify with no configured verifier") if self._uses_sigstore: - signature = sigstore.Signature.read(pathlib.Path(signature_path)) + signatures = sigstore.Signature.read_all( + pathlib.Path(signature_path) + ) else: - signature = sigstore_pb.Signature.read(pathlib.Path(signature_path)) + signatures = sigstore_pb.Signature.read_all( + pathlib.Path(signature_path) + ) + + if not signatures: + raise ValueError( + f"No claims found in signature file {signature_path}" + ) + + errors = [] + for signature in signatures: + try: + self._verify_single(model_path, signature) + return + except ValueError as e: + errors.append(str(e)) + + # All claims failed + raise ValueError( + f"None of {len(signatures)} claim(s) verified successfully. " + f"Errors: {errors}" + ) + def _verify_single(self, model_path: hashing.PathLike, signature): + """Verifies a model against a single signature/claim. + + Args: + model_path: The path to the model to verify. + signature: A single signature instance. + + Raises: + ValueError: Verification fails. + """ expected_manifest = self._verifier.verify(signature) if self._hashing_config is None: diff --git a/tests/_signing/format_compat_test.py b/tests/_signing/format_compat_test.py new file mode 100644 index 00000000..7dd1d91b --- /dev/null +++ b/tests/_signing/format_compat_test.py @@ -0,0 +1,149 @@ +# Copyright 2026 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for backward compatible signature format support.""" + +import json +import warnings + +from model_signing._signing import signing + + +class TestSignatureFormat: + """Tests for SignatureFormat enum and format detection.""" + + def test_sig_extension_is_legacy(self): + import pathlib + + fmt = signing.detect_signature_format(pathlib.Path("model.sig")) + assert fmt == signing.SignatureFormat.LEGACY_SINGLE_JSON + + def test_jsonl_extension_is_jsonl(self): + import pathlib + + fmt = signing.detect_signature_format(pathlib.Path("claims.jsonl")) + assert fmt == signing.SignatureFormat.JSONL + + def test_other_extension_is_jsonl(self): + import pathlib + + fmt = signing.detect_signature_format(pathlib.Path("model.bundle")) + assert fmt == signing.SignatureFormat.JSONL + + def test_output_format_matches_detect(self): + import pathlib + + for name, expected in [ + ("model.sig", signing.SignatureFormat.LEGACY_SINGLE_JSON), + ("claims.jsonl", signing.SignatureFormat.JSONL), + ("model.bundle", signing.SignatureFormat.JSONL), + ]: + path = pathlib.Path(name) + assert signing.detect_output_format(path) == expected + assert signing.detect_signature_format(path) == expected + + +class TestDeprecationWarnings: + """Tests that deprecation warnings are emitted for .sig format.""" + + def test_write_sig_emits_deprecation(self, tmp_path): + """Writing to a .sig file should emit a deprecation warning.""" + from model_signing._signing import sign_sigstore as sigstore + + # Create a minimal mock bundle + from unittest import mock + + bundle = mock.MagicMock() + bundle.to_json.return_value = json.dumps({"test": "bundle"}) + + sig = sigstore.Signature(bundle) + sig_path = tmp_path / "model.sig" + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + sig.write(sig_path) + + deprecation_warnings = [ + x for x in w if issubclass(x.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 1 + assert ".sig format" in str(deprecation_warnings[0].message) + + def test_write_jsonl_no_deprecation(self, tmp_path): + """Writing to a .jsonl file should not emit a deprecation warning.""" + from model_signing._signing import sign_sigstore as sigstore + + from unittest import mock + + bundle = mock.MagicMock() + bundle.to_json.return_value = json.dumps({"test": "bundle"}) + + sig = sigstore.Signature(bundle) + sig_path = tmp_path / "claims.jsonl" + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + sig.write(sig_path) + + deprecation_warnings = [ + x for x in w if issubclass(x.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 0 + + def test_read_sig_emits_deprecation(self, tmp_path): + """Reading from a .sig file should emit a deprecation warning.""" + from model_signing._signing import sign_sigstore as sigstore + from sigstore import models as sigstore_models + from unittest import mock + + sig_path = tmp_path / "model.sig" + sig_path.write_text(json.dumps({"test": "bundle"})) + + with ( + mock.patch.object( + sigstore_models.Bundle, + "from_json", + return_value=mock.MagicMock(), + ), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + sigstore.Signature.read(sig_path) + + deprecation_warnings = [ + x for x in w if issubclass(x.category, DeprecationWarning) + ] + assert len(deprecation_warnings) == 1 + + def test_jsonl_append_behavior(self, tmp_path): + """Writing multiple signatures to JSONL should append.""" + from model_signing._signing import sign_sigstore as sigstore + from unittest import mock + + bundle1 = mock.MagicMock() + bundle1.to_json.return_value = json.dumps({"claim": 1}) + bundle2 = mock.MagicMock() + bundle2.to_json.return_value = json.dumps({"claim": 2}) + + sig_path = tmp_path / "claims.jsonl" + + sigstore.Signature(bundle1).write(sig_path) + sigstore.Signature(bundle2).write(sig_path) + + lines = [ + l for l in sig_path.read_text().splitlines() if l.strip() + ] + assert len(lines) == 2 + assert json.loads(lines[0]) == {"claim": 1} + assert json.loads(lines[1]) == {"claim": 2} From 68205efcfda26c8e41347f741e1431ab32b95f3b Mon Sep 17 00:00:00 2001 From: Ralph Bean Date: Thu, 9 Apr 2026 17:44:25 -0400 Subject: [PATCH 3/3] Add informative signing and verification output messages Signing now reports whether the signature file was created or appended to. Verification with multi-claim JSONL files now reports which line matched. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Ralph Bean --- src/model_signing/_cli.py | 107 +++++++++++++++++++++------------ src/model_signing/verifying.py | 17 +++++- 2 files changed, 84 insertions(+), 40 deletions(-) diff --git a/src/model_signing/_cli.py b/src/model_signing/_cli.py index 51c835d6..1d036aa1 100644 --- a/src/model_signing/_cli.py +++ b/src/model_signing/_cli.py @@ -425,6 +425,7 @@ def _sign_sigstore( ) span.set_attribute("sigstore.use_staging", use_staging) try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -447,7 +448,8 @@ def _sign_sigstore( click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) - click.echo("Signing succeeded") + action = "Appended to" if existed else "Created" + click.echo(f"Signing succeeded. {action} {signature}") @_sign.command(name="key") @@ -486,6 +488,7 @@ def _sign_private_key( management protocols. """ try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -500,7 +503,8 @@ def _sign_private_key( click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) - click.echo("Signing succeeded") + action = "Appended to" if existed else "Created" + click.echo(f"Signing succeeded. {action} {signature}") @_sign.command(name="pkcs11-key") @@ -532,6 +536,7 @@ def _sign_pkcs11_key( management protocols. """ try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -546,7 +551,8 @@ def _sign_pkcs11_key( click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) - click.echo("Signing succeeded") + action = "Appended to" if existed else "Created" + click.echo(f"Signing succeeded. {action} {signature}") @_sign.command(name="certificate") @@ -585,6 +591,7 @@ def _sign_certificate( Note that we don't offer certificate and key management protocols. """ try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -601,7 +608,8 @@ def _sign_certificate( click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) - click.echo("Signing succeeded") + action = "Appended to" if existed else "Created" + click.echo(f"Signing succeeded. {action} {signature}") @_sign.command(name="pkcs11-certificate") @@ -641,6 +649,7 @@ def _sign_pkcs11_certificate( Note that we don't offer certificate and key management protocols. """ try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -657,7 +666,8 @@ def _sign_pkcs11_certificate( click.echo(f"Signing failed with error: {err}", err=True) sys.exit(1) - click.echo("Signing succeeded") + action = "Appended to" if existed else "Created" + click.echo(f"Signing succeeded. {action} {signature}") @main.group(name="verify", subcommand_metavar="PKI_METHOD", cls=_PKICmdGroup) @@ -739,25 +749,32 @@ def _verify_sigstore( ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) - model_signing.verifying.Config().use_sigstore_verifier( - identity=identity, - oidc_issuer=identity_provider, - use_staging=use_staging, - trust_config=trust_config, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths( - paths=ignored, ignore_git_paths=ignore_git_paths + matched_line, total_lines = ( + model_signing.verifying.Config() + .use_sigstore_verifier( + identity=identity, + oidc_issuer=identity_provider, + use_staging=use_staging, + trust_config=trust_config, ) - .set_allow_symlinks(allow_symlinks) - ).set_ignore_unsigned_files(ignore_unsigned_files).verify( - model_path, signature + .set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ) + .set_ignore_unsigned_files(ignore_unsigned_files) + .verify(model_path, signature) ) except Exception as err: click.echo(f"Verification failed with error: {err}", err=True) sys.exit(1) - click.echo("Verification succeeded") + msg = "Verification succeeded" + if total_lines > 1: + msg += f" (matched line {matched_line} of {signature})" + click.echo(msg) @_verify.command(name="key") @@ -800,20 +817,27 @@ def _verify_private_key( ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) - model_signing.verifying.Config().use_elliptic_key_verifier( - public_key=public_key - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).set_ignore_unsigned_files(ignore_unsigned_files).verify( - model_path, signature + matched_line, total_lines = ( + model_signing.verifying.Config() + .use_elliptic_key_verifier(public_key=public_key) + .set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ) + .set_ignore_unsigned_files(ignore_unsigned_files) + .verify(model_path, signature) ) except Exception as err: click.echo(f"Verification failed with error: {err}", err=True) sys.exit(1) - click.echo("Verification succeeded") + msg = "Verification succeeded" + if total_lines > 1: + msg += f" (matched line {matched_line} of {signature})" + click.echo(msg) @_verify.command(name="certificate") @@ -862,18 +886,27 @@ def _verify_certificate( ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) - model_signing.verifying.Config().use_certificate_verifier( - certificate_chain=certificate_chain, - log_fingerprints=log_fingerprints, - ).set_hashing_config( - model_signing.hashing.Config() - .set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths) - .set_allow_symlinks(allow_symlinks) - ).set_ignore_unsigned_files(ignore_unsigned_files).verify( - model_path, signature + matched_line, total_lines = ( + model_signing.verifying.Config() + .use_certificate_verifier( + certificate_chain=certificate_chain, + log_fingerprints=log_fingerprints, + ) + .set_hashing_config( + model_signing.hashing.Config() + .set_ignored_paths( + paths=ignored, ignore_git_paths=ignore_git_paths + ) + .set_allow_symlinks(allow_symlinks) + ) + .set_ignore_unsigned_files(ignore_unsigned_files) + .verify(model_path, signature) ) except Exception as err: click.echo(f"Verification failed with error: {err}", err=True) sys.exit(1) - click.echo("Verification succeeded") + msg = "Verification succeeded" + if total_lines > 1: + msg += f" (matched line {matched_line} of {signature})" + click.echo(msg) diff --git a/src/model_signing/verifying.py b/src/model_signing/verifying.py index 404f62c8..0541ec94 100644 --- a/src/model_signing/verifying.py +++ b/src/model_signing/verifying.py @@ -77,7 +77,7 @@ def __init__(self): def verify( self, model_path: hashing.PathLike, signature_path: hashing.PathLike - ): + ) -> tuple[int, int]: """Verifies that a model conforms to a signature. For JSONL signature files containing multiple claims, iterates @@ -88,6 +88,12 @@ def verify( model_path: The path to the model to verify. signature_path: The path to the signature file. + Returns: + A tuple of (matched_line, total_lines) where matched_line is + the 1-indexed line number of the claim that verified + successfully and total_lines is the total number of claims + in the file. + Raises: ValueError: No verifier has been configured, or no claims verify. """ @@ -108,11 +114,16 @@ def verify( f"No claims found in signature file {signature_path}" ) + # signatures are ordered newest-first (last line first), so + # index 0 corresponds to the last line of the file. + total = len(signatures) errors = [] - for signature in signatures: + for i, signature in enumerate(signatures): try: self._verify_single(model_path, signature) - return + # Convert from newest-first index to 1-indexed line number + matched_line = total - i + return matched_line, total except ValueError as e: errors.append(str(e))