Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ All versions prior to 1.0.0 are untracked.
### Added
- Added the `digest` subcommand to compute and print a model's digest. This enables other tools to easily pair the attestations with a model directory.
- Added `--module-paths` option to PKCS #11 signing methods pkcs11-key and pkcs11-certificate.
- Added streaming hashing, signing, and verification for models stored at GCS and S3 URIs, with provider-specific optional dependencies. ([#148](https://github.com/sigstore/model-transparency/issues/148))

### Changed
- Standardized CLI flags to use hyphens (e.g., `--trust-config` instead of `--trust_config`). Underscore variants are still accepted for backwards compatibility via token normalization.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,38 @@ subcommand, pointing it to the model directory:

The digest subcommand follows the same ignore rules used when signing.

### Cloud-backed models

Model paths can be GCS or S3 URIs. Install the extra for the provider that
stores the model:

```bash
pip install "model-signing[gcs]" # gs://
pip install "model-signing[s3]" # s3://
```

The model URI can then be used anywhere a local model path is accepted:

```bash
model_signing digest gs://my-bucket/models/bert
model_signing sign key gs://my-bucket/models/bert \
--private-key key.priv --signature model.sig
model_signing verify key gs://my-bucket/models/bert \
--public-key key.pub --signature model.sig
```

Model files are opened through the cloud filesystem backend and read in
bounded chunks. Shard serialization uses seek/range reads, so
`model-signing` does not stage the complete model on local disk. Any caching
performed by the installed provider is controlled by that provider. In this
scope, signature bundles, keys, certificates, and trust configuration files
remain local paths.

Provider credentials use the standard `gcsfs` or `s3fs` configuration. The
test suite validates the URI, traversal, streaming, signing, and verification
paths with an in-memory `fsspec` backend; it does not exercise live cloud
credentials.

## Using Private Sigstore Instances

To use a private Sigstore setup (e.g. custom Rekor/Fulcio), use the `--trust-config` flag:
Expand Down
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ dependencies = [
"blake3",
"click",
"cryptography",
"etils[epath]>=1.13",
"in-toto-attestation",
"sigstore>=4.0",
"sigstore-models>=0.0.5",
Expand All @@ -49,6 +50,12 @@ keywords = [
]

[project.optional-dependencies]
gcs = [
"gcsfs",
]
s3 = [
"s3fs",
]
pkcs11 = [
"PyKCS11",
]
Expand Down
77 changes: 52 additions & 25 deletions src/model_signing/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import click

import model_signing
from model_signing import _filesystem


class NoOpTracer:
Expand All @@ -44,7 +45,7 @@ def set_attribute(self, key, value):

# Decorator for the commonly used argument for the model path.
_model_path_argument = click.argument(
"model_path", type=pathlib.Path, metavar="MODEL_PATH"
"model_path", type=str, metavar="MODEL_PATH"
)


Expand Down Expand Up @@ -78,7 +79,7 @@ def set_attribute(self, key, value):
# Decorator for the commonly used option to ignore certain paths
_ignore_paths_option = click.option(
"--ignore-paths",
type=pathlib.Path,
type=str,
metavar="IGNORE_PATHS",
multiple=True,
help="File paths to ignore when signing or verifying.",
Expand Down Expand Up @@ -166,13 +167,39 @@ def set_attribute(self, key, value):


def _resolve_ignore_paths(
model_path: pathlib.Path, paths: Iterable[pathlib.Path]
) -> list[pathlib.Path]:
model_root = model_path.resolve()
model_path: model_signing.hashing.PathLike,
paths: Iterable[str | pathlib.Path],
) -> list[_filesystem.Path]:
model_root = _filesystem.as_path(model_path)

if _filesystem.is_remote(model_root):
remote_paths = []
for p in paths:
# Signature paths are local pathlib values. Explicit ignore paths
# arrive as strings and are resolved against the remote model.
if not isinstance(p, str):
continue
candidate = _filesystem.as_path(p)
if _filesystem.is_remote(candidate):
full = candidate
elif candidate.is_absolute():
continue
else:
full = model_root / candidate
try:
remote_paths.append(full.relative_to(model_root))
except ValueError:
continue
return remote_paths

model_root = pathlib.Path(model_path).resolve()
cwd = pathlib.Path.cwd()
resolved_paths = []
resolved_paths: list[_filesystem.Path] = []
for p in paths:
candidate = (p if p.is_absolute() else (cwd / p)).resolve()
local_path = pathlib.Path(p)
candidate = (
local_path if local_path.is_absolute() else (cwd / local_path)
).resolve()
try:
resolved_paths.append(candidate.relative_to(model_root))
except ValueError:
Expand Down Expand Up @@ -281,8 +308,8 @@ def main(log_level: str) -> None:
@_ignore_git_paths_option
@_allow_symlinks_option
def _digest(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
model_path: str,
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
) -> None:
Expand Down Expand Up @@ -380,8 +407,8 @@ def _sign() -> None:
help="The custom OpenID Connect client secret to use during OAuth2",
)
def _sign_sigstore(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
model_path: str,
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
signature: pathlib.Path,
Expand Down Expand Up @@ -470,8 +497,8 @@ def _sign_sigstore(
help="Password for the key encryption, if any",
)
def _sign_private_key(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
model_path: str,
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
signature: pathlib.Path,
Expand Down Expand Up @@ -518,8 +545,8 @@ def _sign_private_key(
@_pkcs11_uri_option
@_module_paths_option
def _sign_pkcs11_key(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
model_path: str,
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
signature: pathlib.Path,
Expand Down Expand Up @@ -570,8 +597,8 @@ def _sign_pkcs11_key(
@_signing_certificate_option
@_certificate_root_of_trust_option
def _sign_certificate(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
model_path: str,
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
signature: pathlib.Path,
Expand Down Expand Up @@ -626,8 +653,8 @@ def _sign_certificate(
@_certificate_root_of_trust_option
@_module_paths_option
def _sign_pkcs11_certificate(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
model_path: str,
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
signature: pathlib.Path,
Expand Down Expand Up @@ -724,9 +751,9 @@ def _verify() -> None:
)
@_ignore_unsigned_files_option
def _verify_sigstore(
model_path: pathlib.Path,
model_path: str,
signature: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
identity: str,
Expand Down Expand Up @@ -792,9 +819,9 @@ def _verify_sigstore(
)
@_ignore_unsigned_files_option
def _verify_private_key(
model_path: pathlib.Path,
model_path: str,
signature: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
public_key: pathlib.Path,
Expand Down Expand Up @@ -865,9 +892,9 @@ def _verify_private_key(
)
@_ignore_unsigned_files_option
def _verify_certificate(
model_path: pathlib.Path,
model_path: str,
signature: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
ignore_paths: Iterable[str],
ignore_git_paths: bool,
allow_symlinks: bool,
certificate_chain: Iterable[pathlib.Path],
Expand Down
83 changes: 83 additions & 0 deletions src/model_signing/_filesystem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# 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.

"""Filesystem path helpers shared by model serialization code."""

from collections.abc import Iterator
import os
import pathlib
from typing import TypeAlias

from etils import epath


PathLike: TypeAlias = str | bytes | os.PathLike
Path: TypeAlias = pathlib.Path | epath.Path


def as_path(path: PathLike) -> Path:
"""Builds a local pathlib path or a URI-aware epath path."""
if isinstance(path, (pathlib.Path, epath.Path)):
return path

raw_path = os.fspath(path)
if isinstance(raw_path, bytes):
return pathlib.Path(os.fsdecode(raw_path))
if "://" in raw_path:
return epath.Path(raw_path)
return pathlib.Path(raw_path)


def is_remote(path: Path) -> bool:
"""Returns whether a path uses a non-local URI scheme."""
return not isinstance(path, pathlib.Path) and "://" in os.fspath(path)


def is_symlink(path: Path) -> bool:
"""Checks local symlinks; object-store paths cannot be symlinks."""
if isinstance(path, pathlib.Path):
return path.is_symlink()
return False


def file_size(path: Path) -> int:
"""Returns a file size for pathlib and epath stat result types."""
result = path.stat()
if isinstance(result, os.stat_result):
return result.st_size
return result.length


def walk_paths(model_path: Path) -> Iterator[Path]:
"""Yields a model and all of its descendants.

pathlib's existing recursive glob behavior is retained for local paths.
etils intentionally rejects recursive glob patterns for cloud paths, so
remote directories are traversed explicitly through their path interface.
"""
yield model_path
if isinstance(model_path, pathlib.Path):
yield from model_path.glob("**/*")
return

if not model_path.is_dir():
return

directories = [model_path]
while directories:
directory = directories.pop()
for child in directory.iterdir():
yield child
if child.is_dir():
directories.append(child)
Loading