Skip to content
Closed
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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ For verification:

```bash
[...]$ model_signing verify bert-base-uncased \
--signature model.sig \
--signature claims.jsonl \
--trust_config client_trust_config.json
--identity "$identity"
--identity_provider "$oidc_provider"
Expand Down Expand Up @@ -160,7 +160,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
Expand All @@ -171,7 +171,7 @@ model we use

```bash
[...]$ model_signing verify bert-base-uncased \
--signature model.sig \
--signature claims.jsonl \
--identity "$identity" \
--identity_provider "$oidc_provider"
```
Expand Down Expand Up @@ -234,15 +234,15 @@ 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
```

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
```

Expand Down Expand Up @@ -333,7 +333,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.
Expand Down
14 changes: 7 additions & 7 deletions docs/demo.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
]
},
{
Expand All @@ -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"
]
},
{
Expand Down Expand Up @@ -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\""
]
},
{
Expand Down Expand Up @@ -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\""
]
},
{
Expand Down Expand Up @@ -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\""
]
},
{
Expand Down Expand Up @@ -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\""
]
},
{
Expand Down
4 changes: 2 additions & 2 deletions docs/model_signing_format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": [
Expand Down
7 changes: 5 additions & 2 deletions src/model_signing/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."
),
)


Expand Down
15 changes: 13 additions & 2 deletions src/model_signing/_signing/sign_sigstore.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,24 @@ 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")
# Convert to compact JSON (single line) for JSONL format
# by removing newlines from the bundle's JSON output
bundle_json = self.bundle.to_json().replace("\n", "")

# Append to file if it exists (for accumulating attestations)
# Otherwise create new file
mode = "a" if path.exists() else "w"
with path.open(mode, encoding="utf-8") as f:
f.write(bundle_json + "\n")

@classmethod
@override
def read(cls, path: pathlib.Path) -> Self:
content = path.read_text(encoding="utf-8")
return cls(sigstore_models.Bundle.from_json(content))
# Handle JSONL format: read the last line (most recent attestation)
lines = content.strip().split("\n")
last_line = lines[-1]
return cls(sigstore_models.Bundle.from_json(last_line))


class Signer(signing.Signer):
Expand Down
15 changes: 13 additions & 2 deletions src/model_signing/_signing/sign_sigstore_pb.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,13 +105,24 @@ 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")
# Convert to compact JSON (single line) for JSONL format
# by removing newlines from the bundle's JSON output
bundle_json = self.bundle.to_json().replace("\n", "")

# Append to file if it exists (for accumulating attestations)
# Otherwise create new file
mode = "a" if path.exists() else "w"
with path.open(mode, encoding="utf-8") as f:
f.write(bundle_json + "\n")

@classmethod
@override
def read(cls, path: pathlib.Path) -> Self:
content = path.read_text(encoding="utf-8")
parsed_dict = json.loads(content)
# Handle JSONL format: read the last line (most recent attestation)
lines = content.strip().split("\n")
last_line = lines[-1]
parsed_dict = json.loads(last_line)

# adjust parsed_dict due to previous usage of protobufs
if "tlogEntries" not in parsed_dict["verificationMaterial"]:
Expand Down
2 changes: 1 addition & 1 deletion src/model_signing/_signing/signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ class Payload:
"hash_type": "sha256",
"allow_symlinks": true
"ignore_paths": [
"model.sig",
"claims.jsonl",
".git",
".gitattributes",
".github",
Expand Down
42 changes: 42 additions & 0 deletions tests/_signing/sigstore_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,45 @@ def test_verify_not_intoto_statement(

with pytest.raises(ValueError, match="Expected in-toto .* payload"):
self._verify_dsse_signature(signature_path)

def test_append_to_existing_claims_jsonl(
self, sample_model_folder, mocked_sigstore, tmp_path
):
"""Test that signing appends to existing claims.jsonl file.

This implements the unified bundle layout from issue #587, where
attestations accumulate in a single claims.jsonl file as the model
moves through its lifecycle.
"""
serializer = file.Serializer(
self._file_hasher_factory, allow_symlinks=True
)
manifest = serializer.serialize(sample_model_folder)
signature_path = tmp_path / "claims.jsonl"

# First signing - should create the file
self._sign_manifest(manifest, signature_path, sigstore.Signer)

# Verify file exists and has one line
assert signature_path.exists()
lines = signature_path.read_text(encoding="utf-8").strip().split("\n")
assert len(lines) == 1
# Verify it's valid JSON
first_bundle = json.loads(lines[0])
assert "_type" in first_bundle

# Second signing - should append to the file
self._sign_manifest(manifest, signature_path, sigstore.Signer)

# Verify file now has two lines
lines = signature_path.read_text(encoding="utf-8").strip().split("\n")
assert len(lines) == 2

# Verify both lines are valid JSON bundles
first_bundle = json.loads(lines[0])
second_bundle = json.loads(lines[1])
assert "_type" in first_bundle
assert "_type" in second_bundle

# Both bundles should be independently valid
# (We can't fully verify with mocked sigstore, but structure is valid)
15 changes: 12 additions & 3 deletions tests/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,20 @@ def populate_tmpdir(tmp_path: Path) -> Path:

def get_signed_files(modelsig: Path) -> list[str]:
with open(modelsig, "r") as file:
signature = json.load(file)
content = file.read().strip()
# Handle JSONL format: read last line (most recent attestation)
lines = content.split("\n")
signature = json.loads(lines[-1])
payload = json.loads(b64decode(signature["dsseEnvelope"]["payload"]))
return [entry["name"] for entry in payload["predicate"]["resources"]]


def get_ignore_paths(modelsig: Path) -> list[str]:
with open(modelsig, "r") as file:
signature = json.load(file)
content = file.read().strip()
# Handle JSONL format: read last line (most recent attestation)
lines = content.split("\n")
signature = json.loads(lines[-1])
payload = json.loads(b64decode(signature["dsseEnvelope"]["payload"]))
ignore_paths = payload["predicate"]["serialization"]["ignore_paths"]
ignore_paths.sort()
Expand All @@ -87,7 +93,10 @@ def check_ignore_paths(

def get_model_name(modelsig: Path) -> str:
with open(modelsig, "r") as file:
signature = json.load(file)
content = file.read().strip()
# Handle JSONL format: read last line (most recent attestation)
lines = content.split("\n")
signature = json.loads(lines[-1])
payload = json.loads(b64decode(signature["dsseEnvelope"]["payload"]))
return payload["subject"][0]["name"]

Expand Down
Loading