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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ All versions prior to 1.0.0 are untracked.
## [Unreleased]

### 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 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 RFC 3161 Timestamp Authority support for PKI signing methods (`key`, `certificate`, `pkcs11-key`, `pkcs11-certificate`). Use the `--tsa-url` CLI flag or `tsa_url` API parameter to include a trusted timestamp in signatures, enabling long-term signature validity even after certificate expiration.

### 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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,38 @@ And then we use the private key to sign.
[...]$ model_signing sign key bert-base-uncased --private-key key.priv
```

#### RFC 3161 Timestamp Authority Support

When signing with private keys or certificates (non-Sigstore methods), you can
include an RFC 3161 timestamp from a trusted Timestamp Authority (TSA). This
provides cryptographic proof of when the signature was created, which is
important for:

- **Long-term signature validity**: Signatures remain verifiable even after the
signing certificate expires, as long as the signature was created while the
certificate was valid.
- **Audit trails**: Independent proof of signing time from a trusted third party.
- **Compliance**: Many security policies require trusted timestamps.

To include a TSA timestamp, use the `--tsa-url` flag with any PKI signing method:

```bash
# Sign with private key + TSA timestamp
[...]$ model_signing sign key bert-base-uncased \
--private-key key.priv \
--tsa-url https://timestamp.sigstore.dev/api/v1/timestamp

# Sign with certificate + TSA timestamp
[...]$ model_signing sign certificate bert-base-uncased \
--private-key key.priv \
--signing-certificate cert.pem \
--tsa-url https://timestamp.sigstore.dev/api/v1/timestamp
```

The timestamp is embedded in the signature bundle and automatically used during
verification. Sigstore provides a free public TSA at
`https://timestamp.sigstore.dev/api/v1/timestamp`.

All signing methods support changing the signature name and location via the
`--signature` flag:

Expand Down Expand Up @@ -359,6 +391,19 @@ model_signing.signing.Config().use_elliptic_key_signer(
)
```

To include an RFC 3161 timestamp for long-term signature validity:

```python
import model_signing

model_signing.signing.Config().use_elliptic_key_signer(
private_key="key.priv",
tsa_url="https://timestamp.sigstore.dev/api/v1/timestamp"
).sign(
"finbert", "finbert.sig"
)
```

The same signing configuration can be used to sign multiple models:

```python
Expand Down
22 changes: 20 additions & 2 deletions src/model_signing/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ def set_attribute(self, key, value):
help="Whether to allow following symlinks when signing or verifying files.",
)

# Decorator for the commonly used option to set a TSA URL for PKI signing
_tsa_url_option = click.option(
"--tsa-url",
type=str,
metavar="TSA_URL",
help="URL of an RFC 3161 Timestamp Authority for trusted timestamps.",
)


def _resolve_ignore_paths(
model_path: pathlib.Path, paths: Iterable[pathlib.Path]
Expand Down Expand Up @@ -454,6 +462,7 @@ def _sign_sigstore(
@_allow_symlinks_option
@_write_signature_option
@_private_key_option
@_tsa_url_option
@click.option(
"--password",
type=str,
Expand All @@ -468,6 +477,7 @@ def _sign_private_key(
signature: pathlib.Path,
private_key: pathlib.Path,
password: str | None = None,
tsa_url: str | None = None,
) -> None:
"""Sign using a private key (paired with a public one).

Expand All @@ -487,7 +497,7 @@ def _sign_private_key(
model_path, list(ignore_paths) + [signature]
)
model_signing.signing.Config().use_elliptic_key_signer(
private_key=private_key, password=password
private_key=private_key, password=password, tsa_url=tsa_url
).set_hashing_config(
model_signing.hashing.Config()
.set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths)
Expand All @@ -507,13 +517,15 @@ def _sign_private_key(
@_allow_symlinks_option
@_write_signature_option
@_pkcs11_uri_option
@_tsa_url_option
def _sign_pkcs11_key(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
ignore_git_paths: bool,
allow_symlinks: bool,
signature: pathlib.Path,
pkcs11_uri: str,
tsa_url: str | None = None,
) -> None:
"""Sign using a private key using a PKCS #11 URI.

Expand All @@ -533,7 +545,7 @@ def _sign_pkcs11_key(
model_path, list(ignore_paths) + [signature]
)
model_signing.signing.Config().use_pkcs11_signer(
pkcs11_uri=pkcs11_uri
pkcs11_uri=pkcs11_uri, tsa_url=tsa_url
).set_hashing_config(
model_signing.hashing.Config()
.set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths)
Expand All @@ -555,6 +567,7 @@ def _sign_pkcs11_key(
@_private_key_option
@_signing_certificate_option
@_certificate_root_of_trust_option
@_tsa_url_option
def _sign_certificate(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
Expand All @@ -564,6 +577,7 @@ def _sign_certificate(
private_key: pathlib.Path,
signing_certificate: pathlib.Path,
certificate_chain: Iterable[pathlib.Path],
tsa_url: str | None = None,
) -> None:
"""Sign using a certificate.

Expand All @@ -589,6 +603,7 @@ def _sign_certificate(
private_key=private_key,
signing_certificate=signing_certificate,
certificate_chain=certificate_chain,
tsa_url=tsa_url,
).set_hashing_config(
model_signing.hashing.Config()
.set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths)
Expand All @@ -610,6 +625,7 @@ def _sign_certificate(
@_pkcs11_uri_option
@_signing_certificate_option
@_certificate_root_of_trust_option
@_tsa_url_option
def _sign_pkcs11_certificate(
model_path: pathlib.Path,
ignore_paths: Iterable[pathlib.Path],
Expand All @@ -619,6 +635,7 @@ def _sign_pkcs11_certificate(
pkcs11_uri: str,
signing_certificate: pathlib.Path,
certificate_chain: Iterable[pathlib.Path],
tsa_url: str | None = None,
) -> None:
"""Sign using a certificate.

Expand All @@ -645,6 +662,7 @@ def _sign_pkcs11_certificate(
pkcs11_uri=pkcs11_uri,
signing_certificate=signing_certificate,
certificate_chain=certificate_chain,
tsa_url=tsa_url,
).set_hashing_config(
model_signing.hashing.Config()
.set_ignored_paths(paths=ignored, ignore_git_paths=ignore_git_paths)
Expand Down
19 changes: 16 additions & 3 deletions src/model_signing/_signing/sign_certificate.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ def __init__(
private_key_path: pathlib.Path,
signing_certificate_path: pathlib.Path,
certificate_chain_paths: Iterable[pathlib.Path],
tsa_url: str | None = None,
):
"""Initializes the signer with the key, certificate and trust chain.

Expand All @@ -54,12 +55,13 @@ def __init__(
signing_certificate_path: The path to the signing certificate.
certificate_chain_paths: Paths to other certificates used to
establish chain of trust.
tsa_url: Optional URL of an RFC 3161 Timestamp Authority.

Raises:
ValueError: Signing certificate's public key does not match the
private key's public pair.
"""
super().__init__(private_key_path)
super().__init__(private_key_path, tsa_url=tsa_url)
self._signing_certificate = x509.load_pem_x509_certificate(
signing_certificate_path.read_bytes()
)
Expand Down Expand Up @@ -196,6 +198,10 @@ def _verify_certificates(
The public key is extracted from the signing certificate from the chain
of trust, after the chain is validated. It must match the public key
from the key used during signing.

If a TSA timestamp is present in the verification material, it will be
used as the verification time, allowing verification of signatures made
with certificates that have since expired.
"""

def _to_openssl_certificate(certificate_bytes, log_fingerprints):
Expand All @@ -209,8 +215,15 @@ def _to_openssl_certificate(certificate_bytes, log_fingerprints):
signing_chain.certificates[0].raw_bytes
)

max_signing_time = signing_certificate.not_valid_before_utc
self._store.set_time(max_signing_time)
tsa_timestamp = sigstore_pb.get_timestamp_from_bundle(
verification_material
)
if tsa_timestamp is not None:
verification_time = tsa_timestamp
else:
verification_time = signing_certificate.not_valid_before_utc

self._store.set_time(verification_time)

trust_chain_ssl = [
_to_openssl_certificate(
Expand Down
29 changes: 20 additions & 9 deletions src/model_signing/_signing/sign_ec_key.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,33 +86,37 @@ class Signer(sigstore_pb.Signer):
"""Signer using an elliptic curve private key."""

def __init__(
self, private_key_path: pathlib.Path, password: str | None = None
self,
private_key_path: pathlib.Path,
password: str | None = None,
tsa_url: str | None = None,
):
"""Initializes the signer with the private key and optional password.

Args:
private_key_path: The path to the PEM encoded private key.
password: Optional password for the private key.
tsa_url: Optional URL of an RFC 3161 Timestamp Authority.
"""
self._private_key = serialization.load_pem_private_key(
private_key_path.read_bytes(), password
)
_check_supported_ec_key(self._private_key.public_key())
self._tsa_url = tsa_url

@override
def sign(self, payload: signing.Payload) -> signing.Signature:
raw_payload = json_format.MessageToJson(payload.statement.pb).encode(
"utf-8"
)

signature_bytes = self._private_key.sign(
sigstore_pb.pae(raw_payload),
ec.ECDSA(get_ec_key_hash(self._private_key.public_key())),
)

raw_signature = intoto_pb.Signature(
sig=base64.b64encode(
self._private_key.sign(
sigstore_pb.pae(raw_payload),
ec.ECDSA(get_ec_key_hash(self._private_key.public_key())),
)
),
keyid="",
sig=base64.b64encode(signature_bytes), keyid=""
)

envelope = intoto_pb.Envelope(
Expand All @@ -121,10 +125,17 @@ def sign(self, payload: signing.Payload) -> signing.Signature:
signatures=[raw_signature],
)

verification_material = self._get_verification_material()

if self._tsa_url:
verification_material.timestamp_verification_data = (
sigstore_pb.request_timestamp(signature_bytes, self._tsa_url)
)

return sigstore_pb.Signature(
bundle_pb.Bundle(
media_type=sigstore_pb._BUNDLE_MEDIA_TYPE,
verification_material=self._get_verification_material(),
verification_material=verification_material,
dsse_envelope=envelope,
)
)
Expand Down
19 changes: 16 additions & 3 deletions src/model_signing/_signing/sign_pkcs11.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,13 @@ class Signer(sigstore_pb.Signer):
"""Signer using PKCS #11 URIs with elliptic curves keys."""

def __init__(
self, pkcs11_uri: str, module_paths: Iterable[str] = frozenset()
self,
pkcs11_uri: str,
module_paths: Iterable[str] = frozenset(),
tsa_url: str | None = None,
):
self.session = None
self._tsa_url = tsa_url

self.pkcs11_uri = Pkcs11URI()
self.pkcs11_uri.parse(pkcs11_uri)
Expand Down Expand Up @@ -183,10 +187,17 @@ def sign(self, payload: signing.Payload) -> signing.Signature:
signatures=[raw_signature],
)

verification_material = self._get_verification_material()

if self._tsa_url:
verification_material.timestamp_verification_data = (
sigstore_pb.request_timestamp(sig, self._tsa_url)
)

return sigstore_pb.Signature(
bundle_pb.Bundle(
media_type=sigstore_pb._BUNDLE_MEDIA_TYPE,
verification_material=self._get_verification_material(),
verification_material=verification_material,
dsse_envelope=envelope,
)
)
Expand Down Expand Up @@ -217,6 +228,7 @@ def __init__(
signing_certificate_path: pathlib.Path,
certificate_chain_paths: Iterable[pathlib.Path],
module_paths: Iterable[str] = frozenset(),
tsa_url: str | None = None,
):
"""Initializes the signer with the key, certificate and trust chain.

Expand All @@ -226,12 +238,13 @@ def __init__(
certificate_chain_paths: Paths to other certificates used to
establish chain of trust.
module_paths: Paths to PKCS #11 modules to load.
tsa_url: Optional URL of an RFC 3161 Timestamp Authority.

Raises:
ValueError: Signing certificate's public key does not match the
private key's public pair.
"""
super().__init__(pkcs11_uri, module_paths=module_paths)
super().__init__(pkcs11_uri, module_paths=module_paths, tsa_url=tsa_url)
self._signing_certificate = x509.load_pem_x509_certificate(
signing_certificate_path.read_bytes()
)
Expand Down
Loading
Loading