diff --git a/README.md b/README.md index f1417a21..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. @@ -137,9 +148,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 +180,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 +191,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 +221,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 +254,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 +262,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 +353,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..1d036aa1 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`." + ), ) @@ -422,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] ) @@ -444,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") @@ -483,6 +488,7 @@ def _sign_private_key( management protocols. """ try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -497,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") @@ -529,6 +536,7 @@ def _sign_pkcs11_key( management protocols. """ try: + existed = signature.exists() ignored = _resolve_ignore_paths( model_path, list(ignore_paths) + [signature] ) @@ -543,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") @@ -582,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] ) @@ -598,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") @@ -638,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] ) @@ -654,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) @@ -736,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") @@ -797,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") @@ -859,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/_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 d68c5872..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. @@ -216,7 +261,7 @@ class Payload: "hash_type": "sha256", "allow_symlinks": true "ignore_paths": [ - "model.sig", + "claims.jsonl", ".git", ".gitattributes", ".github", @@ -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/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..0541ec94 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. @@ -77,24 +77,72 @@ 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 + 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. + 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. + 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}" + ) + # signatures are ordered newest-first (last line first), so + # index 0 corresponds to the last line of the file. + total = len(signatures) + errors = [] + for i, signature in enumerate(signatures): + try: + self._verify_single(model_path, signature) + # 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)) + + # 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}