diff --git a/README.md b/README.md index f1417a21..400cc428 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,9 @@ repository can do the same using [Hatch](https://hatch.pypa.io/latest/) via For the remainder of the section, we would use `model_signing ` method. -The CLI has two subcommands: `sign` for signing and `verify` for verification. +The CLI has three subcommands: `sign` for signing, `verify` for verification, +and `trust-instance` for bootstrapping trust to a Sigstore instance (see +[Using Private Sigstore Instances](#using-private-sigstore-instances)). Each subcommand has another level of subcommands to select the signing method (`sigstore` -- the default, can be skipped --, `key`, `certificate`). Then, each of these subcommands has several flags to configure parameters for @@ -127,29 +129,57 @@ The digest subcommand follows the same ignore rules used when signing. ## Using Private Sigstore Instances -To use a private Sigstore setup (e.g. custom Rekor/Fulcio), use the `--trust-config` flag: +> **Note:** If you are signing and verifying with the default public +> [Sigstore](https://www.sigstore.dev/) instance, you do not need any of the +> options below — the CLI uses the public goods instance out of the box. This +> section is only relevant when operating your own private Sigstore deployment. + +The recommended way to use a private Sigstore instance is via `--instance`, +which resolves trust configuration automatically through +[TUF](https://theupdateframework.io/). + +**1. Bootstrap trust** (one-time setup): + +Fetch the instance's initial `root.json` and register it locally: ```bash -[...]$ model_signing sign bert-base-uncased --trust-config client_trust_config.json +[...]$ curl -o root.json https://tuf-repo-cdn.sigstore.dev/1.root.json +[...]$ model_signing trust-instance --instance https://tuf-repo-cdn.sigstore.dev root.json ``` -For verification: +**2. Sign and verify** using the instance URL: ```bash -[...]$ model_signing verify bert-base-uncased \ +[...]$ model_signing sign --instance https://tuf-repo-cdn.sigstore.dev bert-base-uncased +[...]$ model_signing verify --instance https://tuf-repo-cdn.sigstore.dev \ --signature model.sig \ - --trust-config client_trust_config.json - --identity "$identity" - --identity-provider "$oidc_provider" + --identity "$identity" \ + --identity-provider "$oidc_provider" \ + bert-base-uncased ``` -The `client_trust_config.json` file should include: +After bootstrapping, only the URL is needed — TUF handles metadata updates and +key rotation transparently. -- A signed target trust root -- A `signingConfig` section with your private Rekor, Fulcio, and CT log endpoints -- Public keys for verification (if applicable) +#### Using a manual ClientTrustConfig -You can find an example `client_trust_config.json` that references the public Sigstore production services in the Sigstore Python repository [here](https://github.com/sigstore/sigstore-python/blob/main/test/assets/trust_config/config.v1.json). +If you need full control over the trust root (e.g. pinning specific keys or +endpoints), you can provide a `ClientTrustConfig` JSON file directly via +`--trust-config`. This takes precedence over `--instance` when both are given. + +```bash +[...]$ model_signing sign --trust-config client_trust_config.json bert-base-uncased +[...]$ model_signing verify \ + --trust-config client_trust_config.json \ + --signature model.sig \ + --identity "$identity" \ + --identity-provider "$oidc_provider" \ + bert-base-uncased +``` + +An example `client_trust_config.json` referencing the public Sigstore production +services can be found in the sigstore-python repository +[here](https://github.com/sigstore/sigstore-python/blob/main/test/assets/trust_config/config.v1.json). As another example, here is how we can sign with private keys. First, we generate the key pair: @@ -382,6 +412,21 @@ model_signing.verifying.Config().use_sigstore_verifier( ).verify("finbert", "finbert.sig") ``` +To sign or verify against a specific Sigstore instance, pass its TUF URL: + +```python +import model_signing + +model_signing.signing.Config().use_sigstore_signer( + instance="https://tuf-repo-cdn.sigstore.dev" +).sign("finbert", "finbert.sig") + +model_signing.verifying.Config().use_sigstore_verifier( + identity=identity, oidc_issuer=oidc_provider, + instance="https://tuf-repo-cdn.sigstore.dev" +).verify("finbert", "finbert.sig") +``` + The same verification configuration can be used to verify multiple models: ```python diff --git a/pyproject.toml b/pyproject.toml index 04f913c9..5d63441f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "click", "cryptography", "in-toto-attestation", - "sigstore>=4.0", + "sigstore>=4.2", "sigstore-models>=0.0.5", "typing_extensions", ] diff --git a/src/model_signing/_cli.py b/src/model_signing/_cli.py index 94da05be..666772ff 100644 --- a/src/model_signing/_cli.py +++ b/src/model_signing/_cli.py @@ -75,6 +75,17 @@ def set_attribute(self, key, value): help="The client trust configuration to use", ) +# Decorator for the commonly used option to specify a Sigstore instance URL. +_instance_option = click.option( + "--instance", + type=str, + metavar="URL", + help=( + "Use the Sigstore instance at the given TUF repository URL. " + "Must be bootstrapped first via `trust-instance`." + ), +) + # Decorator for the commonly used option to ignore certain paths _ignore_paths_option = click.option( "--ignore-paths", @@ -311,6 +322,43 @@ def _digest( sys.exit(1) +@main.command(name="trust-instance") +@click.argument("root", type=pathlib.Path, metavar="ROOT_JSON") +@click.option( + "--instance", + type=str, + metavar="URL", + required=True, + help="The TUF repository URL of the Sigstore instance.", +) +def _trust_instance(root: pathlib.Path, instance: str) -> None: + r"""Bootstrap trust for a Sigstore instance. + + Seeds the local TUF metadata cache with ROOT_JSON so that + subsequent sign/verify commands can use --instance URL without + needing the root file again. + + \b + Example: + model_signing trust-instance \ + --instance https://tuf-repo-cdn.sigstore.dev \ + root.json + """ + from model_signing._signing.sign_sigstore import bootstrap_instance + + if not root.is_file(): + click.echo(f"Error: ROOT_JSON must be a file: {root}", err=True) + sys.exit(1) + + try: + bootstrap_instance(instance, root) + except Exception as err: + click.echo(f"Bootstrapping instance failed: {err}", err=True) + sys.exit(1) + + click.echo(f"Trust bootstrapped for {instance}") + + @main.group(name="sign", subcommand_metavar="PKI_METHOD", cls=_PKICmdGroup) def _sign() -> None: """Sign models. @@ -334,6 +382,7 @@ def _sign() -> None: @_write_signature_option @_sigstore_staging_option @_trust_config_option +@_instance_option @click.option( "--use-ambient-credentials", type=bool, @@ -383,6 +432,7 @@ def _sign_sigstore( client_id: str | None = None, client_secret: str | None = None, trust_config: pathlib.Path | None = None, + instance: str | None = None, ) -> None: """Sign using Sigstore (DEFAULT signing method). @@ -433,6 +483,7 @@ def _sign_sigstore( client_id=client_id, client_secret=client_secret, trust_config=trust_config, + instance=instance, ).set_hashing_config( model_signing.hashing.Config() .set_ignored_paths( @@ -688,6 +739,7 @@ def _verify() -> None: @_allow_symlinks_option @_sigstore_staging_option @_trust_config_option +@_instance_option @click.option( "--identity", type=str, @@ -714,6 +766,7 @@ def _verify_sigstore( use_staging: bool, ignore_unsigned_files: bool, trust_config: pathlib.Path | None = None, + instance: str | None = None, ) -> None: """Verify using Sigstore (DEFAULT verification method). @@ -741,6 +794,7 @@ def _verify_sigstore( oidc_issuer=identity_provider, use_staging=use_staging, trust_config=trust_config, + instance=instance, ).set_hashing_config( model_signing.hashing.Config() .set_ignored_paths( diff --git a/src/model_signing/_signing/sign_sigstore.py b/src/model_signing/_signing/sign_sigstore.py index a4e5bb42..3c62f3ce 100644 --- a/src/model_signing/_signing/sign_sigstore.py +++ b/src/model_signing/_signing/sign_sigstore.py @@ -38,6 +38,40 @@ _DEFAULT_CLIENT_SECRET = "" +def _resolve_trust_config( + *, + use_staging: bool = False, + trust_config: pathlib.Path | None = None, + instance: str | None = None, +) -> sigstore_models.ClientTrustConfig: + """Resolve trust configuration from the provided options. + + Precedence: trust_config file > instance URL > staging > production. + """ + if trust_config: + return sigstore_models.ClientTrustConfig.from_json( + trust_config.read_text() + ) + if instance: + return sigstore_models.ClientTrustConfig.from_tuf(instance) + if use_staging: + return sigstore_models.ClientTrustConfig.staging() + return sigstore_models.ClientTrustConfig.production() + + +def bootstrap_instance(instance: str, root: pathlib.Path) -> None: + """Bootstrap trust for a Sigstore instance. + + Seeds the local TUF cache with the provided root metadata so that + subsequent signing/verification can use ``--instance`` with just the URL. + + Args: + instance: The TUF repository URL of the Sigstore instance. + root: Path to the initial TUF ``root.json`` for the instance. + """ + sigstore_models.ClientTrustConfig.from_tuf(instance, bootstrap_root=root) + + class Signature(signing.Signature): """Sigstore signature support, wrapping around `sigstore_models.Bundle`.""" @@ -74,6 +108,7 @@ def __init__( client_id: str | None = None, client_secret: str | None = None, trust_config: pathlib.Path | None = None, + instance: str | None = None, ): """Initializes Sigstore signers. @@ -113,15 +148,16 @@ def __init__( supplied PKI and trust configurations, instead of the default Sigstore setup. If not specified, the default Sigstore configuration is used. + instance: A Sigstore TUF repository URL. When provided, trust + configuration is fetched via TUF from this URL instead of using + the default production instance or a manual config file. Must + have been bootstrapped first via `bootstrap_instance()`. """ - if use_staging: - trust_config = sigstore_models.ClientTrustConfig.staging() - elif trust_config: - trust_config = sigstore_models.ClientTrustConfig.from_json( - trust_config.read_text() - ) - else: - trust_config = sigstore_models.ClientTrustConfig.production() + trust_config = _resolve_trust_config( + use_staging=use_staging, + trust_config=trust_config, + instance=instance, + ) if not oidc_issuer: oidc_issuer = trust_config.signing_config.get_oidc_url() @@ -190,6 +226,7 @@ def __init__( oidc_issuer: str, use_staging: bool = False, trust_config: pathlib.Path | None = None, + instance: str | None = None, ): """Initializes Sigstore verifiers. @@ -208,15 +245,16 @@ def __init__( PKI and trust configurations, instead of the default Sigstore setup. If not specified, the default Sigstore configuration is used. + instance: A Sigstore TUF repository URL. When provided, trust + configuration is fetched via TUF from this URL instead of using + the default production instance or a manual config file. Must + have been bootstrapped first via `bootstrap_instance()`. """ - if trust_config: - trust_config = sigstore_models.ClientTrustConfig.from_json( - trust_config.read_text() - ) - elif use_staging: - trust_config = sigstore_models.ClientTrustConfig.staging() - else: - trust_config = sigstore_models.ClientTrustConfig.production() + trust_config = _resolve_trust_config( + use_staging=use_staging, + trust_config=trust_config, + instance=instance, + ) self._verifier = sigstore_verifier.Verifier( trusted_root=trust_config.trusted_root diff --git a/src/model_signing/signing.py b/src/model_signing/signing.py index 4de79be2..92c2638b 100644 --- a/src/model_signing/signing.py +++ b/src/model_signing/signing.py @@ -131,6 +131,7 @@ def use_sigstore_signer( client_id: str | None = None, client_secret: str | None = None, trust_config: pathlib.Path | None = None, + instance: str | None = None, ) -> Self: """Configures the signing to be performed with Sigstore. @@ -170,6 +171,8 @@ def use_sigstore_signer( PKI and trust configurations, instead of the default Sigstore setup. If not specified, the default Sigstore configuration is used. + instance: A Sigstore TUF repository URL. Trust configuration is + fetched via TUF from this URL. Must have been bootstrapped first. Return: The new signing configuration. @@ -183,6 +186,7 @@ def use_sigstore_signer( client_id=client_id, client_secret=client_secret, trust_config=trust_config, + instance=instance, ) return self diff --git a/src/model_signing/verifying.py b/src/model_signing/verifying.py index 87303490..bc6365ad 100644 --- a/src/model_signing/verifying.py +++ b/src/model_signing/verifying.py @@ -217,6 +217,7 @@ def use_sigstore_verifier( oidc_issuer: str, use_staging: bool = False, trust_config: pathlib.Path | None = None, + instance: str | None = None, ) -> Self: """Configures the verification of signatures produced by Sigstore. @@ -235,6 +236,8 @@ def use_sigstore_verifier( PKI and trust configurations, instead of the default Sigstore setup. If not specified, the default Sigstore configuration is used. + instance: A Sigstore TUF repository URL. Trust configuration is + fetched via TUF from this URL. Must have been bootstrapped first. Return: The new verification configuration. @@ -245,6 +248,7 @@ def use_sigstore_verifier( oidc_issuer=oidc_issuer, use_staging=use_staging, trust_config=trust_config, + instance=instance, ) return self diff --git a/tests/_signing/sigstore_test.py b/tests/_signing/sigstore_test.py index 40619bd4..4755037c 100644 --- a/tests/_signing/sigstore_test.py +++ b/tests/_signing/sigstore_test.py @@ -420,3 +420,117 @@ def test_verify_with_custom_trust_config( ) assert "signing_config" in trust_config_content assert "trustedRoot" in trust_config_content + + def test_sign_with_instance( + self, + sample_model_folder, + mocked_oidc_provider, + mocked_sigstore_signer, + mocked_sigstore_models, + tmp_path, + ): + mocked_client_trust_config = mocked_sigstore_models["ClientTrustConfig"] + mocked_custom_config = mock.MagicMock() + mocked_client_trust_config.from_tuf.return_value = mocked_custom_config + + serializer = file.Serializer( + self._file_hasher_factory, allow_symlinks=True + ) + manifest = serializer.serialize(sample_model_folder) + signature_path = tmp_path / "model.sig" + + signer = sigstore.Signer( + use_staging=False, + instance="https://tuf-repo-cdn.sigstore.dev", + ) + payload = signing.Payload(manifest) + signature = signer.sign(payload) + signature.write(signature_path) + + mocked_client_trust_config.from_tuf.assert_called_once_with( + "https://tuf-repo-cdn.sigstore.dev" + ) + assert not mocked_client_trust_config.production.called + assert not mocked_client_trust_config.staging.called + + def test_verify_with_instance( + self, + sample_model_folder, + mocked_oidc_provider, + mocked_sigstore_signer, + mocked_sigstore_models, + mocked_sigstore_verifier, + tmp_path, + ): + serializer = file.Serializer( + self._file_hasher_factory, allow_symlinks=True + ) + manifest = serializer.serialize(sample_model_folder) + signature_path = tmp_path / "model.sig" + self._sign_manifest(manifest, signature_path, sigstore.Signer) + + mocked_client_trust_config = mocked_sigstore_models["ClientTrustConfig"] + mocked_custom_config = mock.MagicMock() + mocked_client_trust_config.from_tuf.return_value = mocked_custom_config + + verifier = sigstore.Verifier( + identity="test", + oidc_issuer="test", + use_staging=False, + instance="https://tuf-repo-cdn.sigstore.dev", + ) + signature = sigstore.Signature.read(signature_path) + verifier.verify(signature) + + mocked_client_trust_config.from_tuf.assert_called_once_with( + "https://tuf-repo-cdn.sigstore.dev" + ) + assert not mocked_client_trust_config.production.called + + def test_trust_config_takes_precedence_over_instance( + self, + sample_model_folder, + mocked_oidc_provider, + mocked_sigstore_signer, + mocked_sigstore_models, + tmp_path, + ): + trust_config_path = ( + pathlib.Path(__file__).parent + / "testdata" + / "custom_trust_config.json" + ) + + mocked_client_trust_config = mocked_sigstore_models["ClientTrustConfig"] + mocked_custom_config = mock.MagicMock() + mocked_client_trust_config.from_json.return_value = mocked_custom_config + + signer = sigstore.Signer( + use_staging=False, + trust_config=trust_config_path, + instance="https://tuf-repo-cdn.sigstore.dev", + ) + serializer = file.Serializer( + self._file_hasher_factory, allow_symlinks=True + ) + manifest = serializer.serialize(sample_model_folder) + payload = signing.Payload(manifest) + signer.sign(payload) + + assert mocked_client_trust_config.from_json.called + assert not mocked_client_trust_config.from_tuf.called + + def test_bootstrap_instance(self, tmp_path): + root_json = tmp_path / "root.json" + root_json.write_text("{}") + + with mock.patch.object( + sigstore.sigstore_models.ClientTrustConfig, + "from_tuf", + ) as mocked_from_tuf: + sigstore.bootstrap_instance( + "https://tuf.example.com", root_json + ) + mocked_from_tuf.assert_called_once_with( + "https://tuf.example.com", bootstrap_root=root_json + )