modelsign 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modelsign/__init__.py +21 -0
- modelsign/cli.py +402 -0
- modelsign/crypto/__init__.py +23 -0
- modelsign/crypto/keys.py +111 -0
- modelsign/crypto/sign.py +28 -0
- modelsign/crypto/verify.py +13 -0
- modelsign/formats/__init__.py +6 -0
- modelsign/formats/directory.py +33 -0
- modelsign/formats/single.py +26 -0
- modelsign/identity/__init__.py +6 -0
- modelsign/identity/canonical.py +20 -0
- modelsign/identity/card.py +71 -0
- modelsign/middleware/__init__.py +5 -0
- modelsign/middleware/response.py +42 -0
- modelsign/sig.py +118 -0
- modelsign-1.0.0.dist-info/METADATA +147 -0
- modelsign-1.0.0.dist-info/RECORD +21 -0
- modelsign-1.0.0.dist-info/WHEEL +5 -0
- modelsign-1.0.0.dist-info/entry_points.txt +2 -0
- modelsign-1.0.0.dist-info/licenses/LICENSE +21 -0
- modelsign-1.0.0.dist-info/top_level.txt +1 -0
modelsign/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""modelsign — Sign AI models with identity. Verify anywhere."""
|
|
2
|
+
|
|
3
|
+
__version__ = "1.0.0"
|
|
4
|
+
|
|
5
|
+
from modelsign.identity.card import ModelCard, validate_card
|
|
6
|
+
from modelsign.identity.canonical import canonical_json
|
|
7
|
+
from modelsign.crypto.keys import generate_keypair, load_private_key, load_public_key, compute_fingerprint
|
|
8
|
+
from modelsign.crypto.sign import sign_bytes, build_file_message, build_dir_message
|
|
9
|
+
from modelsign.crypto.verify import verify_bytes
|
|
10
|
+
from modelsign.formats.single import hash_file
|
|
11
|
+
from modelsign.formats.directory import hash_directory
|
|
12
|
+
from modelsign.sig import SigFile, write_sig, read_sig
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"__version__",
|
|
16
|
+
"ModelCard", "validate_card", "canonical_json",
|
|
17
|
+
"generate_keypair", "load_private_key", "load_public_key", "compute_fingerprint",
|
|
18
|
+
"sign_bytes", "build_file_message", "build_dir_message", "verify_bytes",
|
|
19
|
+
"hash_file", "hash_directory",
|
|
20
|
+
"SigFile", "write_sig", "read_sig",
|
|
21
|
+
]
|
modelsign/cli.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Click-based CLI for modelsign — sign, verify, inspect AI models."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
import modelsign
|
|
12
|
+
from modelsign.crypto.keys import (
|
|
13
|
+
generate_keypair,
|
|
14
|
+
load_private_key,
|
|
15
|
+
load_public_key,
|
|
16
|
+
compute_fingerprint,
|
|
17
|
+
public_key_to_bytes,
|
|
18
|
+
keyring_add,
|
|
19
|
+
keyring_list,
|
|
20
|
+
keyring_remove,
|
|
21
|
+
)
|
|
22
|
+
from modelsign.crypto.sign import sign_bytes, build_file_message, build_dir_message
|
|
23
|
+
from modelsign.crypto.verify import verify_bytes
|
|
24
|
+
from modelsign.formats.single import hash_file
|
|
25
|
+
from modelsign.formats.directory import hash_directory
|
|
26
|
+
from modelsign.identity.card import ModelCard, validate_card
|
|
27
|
+
from modelsign.identity.canonical import canonical_json
|
|
28
|
+
from modelsign.sig import SigFile, write_sig, read_sig
|
|
29
|
+
|
|
30
|
+
DEFAULT_KEY_DIR = Path.home() / ".modelsign"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.group()
|
|
34
|
+
def main():
|
|
35
|
+
"""modelsign — Sign AI models with identity. Verify anywhere."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@main.command()
|
|
39
|
+
def version():
|
|
40
|
+
"""Print modelsign version."""
|
|
41
|
+
click.echo(modelsign.__version__)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@main.command()
|
|
45
|
+
@click.option("--out", "out_path", default=None, help="Path for private key PEM file.")
|
|
46
|
+
def keygen(out_path):
|
|
47
|
+
"""Generate an Ed25519 keypair."""
|
|
48
|
+
if out_path is not None:
|
|
49
|
+
priv_path = Path(out_path)
|
|
50
|
+
key_dir = priv_path.parent
|
|
51
|
+
# generate_keypair always creates private.pem + public.pem in the dir.
|
|
52
|
+
# We need to handle a custom private key name — generate into a temp subdir
|
|
53
|
+
# then move, OR: call generate_keypair on the parent dir and rename if needed.
|
|
54
|
+
# Simplest: generate into parent dir under standard names, then rename to desired.
|
|
55
|
+
tmp_priv = key_dir / "private.pem"
|
|
56
|
+
tmp_pub = key_dir / "public.pem"
|
|
57
|
+
|
|
58
|
+
# If already exists at out_path, just report fingerprint.
|
|
59
|
+
if priv_path.exists():
|
|
60
|
+
pub_path = _find_pubkey_for_private(priv_path)
|
|
61
|
+
priv_key = load_private_key(priv_path)
|
|
62
|
+
fp = compute_fingerprint(priv_key.public_key())
|
|
63
|
+
click.echo(f"Key already exists: {priv_path}")
|
|
64
|
+
click.echo(f"Fingerprint: {fp}")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
if priv_path.name != "private.pem":
|
|
70
|
+
# Generate with standard names then rename
|
|
71
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
72
|
+
from cryptography.hazmat.primitives import serialization
|
|
73
|
+
import os
|
|
74
|
+
|
|
75
|
+
private_key = Ed25519PrivateKey.generate()
|
|
76
|
+
priv_path.write_bytes(private_key.private_bytes(
|
|
77
|
+
encoding=serialization.Encoding.PEM,
|
|
78
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
79
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
80
|
+
))
|
|
81
|
+
os.chmod(priv_path, 0o600)
|
|
82
|
+
|
|
83
|
+
pub_path = priv_path.parent / (priv_path.stem + "_public.pem")
|
|
84
|
+
# Use sibling public.pem convention
|
|
85
|
+
pub_path = priv_path.with_name("public.pem")
|
|
86
|
+
pub_path.write_bytes(private_key.public_key().public_bytes(
|
|
87
|
+
encoding=serialization.Encoding.PEM,
|
|
88
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
89
|
+
))
|
|
90
|
+
fp = compute_fingerprint(private_key.public_key())
|
|
91
|
+
else:
|
|
92
|
+
actual_priv, actual_pub = generate_keypair(key_dir)
|
|
93
|
+
priv_key = load_private_key(actual_priv)
|
|
94
|
+
fp = compute_fingerprint(priv_key.public_key())
|
|
95
|
+
else:
|
|
96
|
+
priv_path, pub_path = generate_keypair(DEFAULT_KEY_DIR)
|
|
97
|
+
priv_key = load_private_key(priv_path)
|
|
98
|
+
fp = compute_fingerprint(priv_key.public_key())
|
|
99
|
+
|
|
100
|
+
click.echo(f"Private key: {priv_path}")
|
|
101
|
+
pub_path = _find_pubkey_for_private(priv_path)
|
|
102
|
+
click.echo(f"Public key: {pub_path}")
|
|
103
|
+
click.echo(f"Fingerprint: {fp}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _find_pubkey_for_private(priv_path: Path) -> Path:
|
|
107
|
+
"""Locate the public key alongside a private key."""
|
|
108
|
+
# Standard convention: public.pem lives next to private.pem
|
|
109
|
+
pub = priv_path.parent / "public.pem"
|
|
110
|
+
if pub.exists():
|
|
111
|
+
return pub
|
|
112
|
+
# Fallback: same stem with _public suffix
|
|
113
|
+
pub2 = priv_path.with_name(priv_path.stem + "_public.pem")
|
|
114
|
+
if pub2.exists():
|
|
115
|
+
return pub2
|
|
116
|
+
return pub # Return expected path even if missing (for display)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@main.command()
|
|
120
|
+
@click.argument("model_path")
|
|
121
|
+
@click.option("--name", default=None, help="Model name (required if --identity not given).")
|
|
122
|
+
@click.option("--identity", "identity_file", default=None, help="Path to JSON identity card file.")
|
|
123
|
+
@click.option("--key", "key_path", default=None, help="Path to private key PEM file.")
|
|
124
|
+
@click.option("--out", "out_path", default=None, help="Output .sig file path (default: MODEL.sig).")
|
|
125
|
+
def sign(model_path, name, identity_file, key_path, out_path):
|
|
126
|
+
"""Sign a model file or directory."""
|
|
127
|
+
model_path = Path(model_path)
|
|
128
|
+
if not model_path.exists():
|
|
129
|
+
click.echo(f"Error: model not found: {model_path}", err=True)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
|
|
132
|
+
# Resolve private key
|
|
133
|
+
if key_path is not None:
|
|
134
|
+
priv_key_path = Path(key_path)
|
|
135
|
+
else:
|
|
136
|
+
priv_key_path = DEFAULT_KEY_DIR / "private.pem"
|
|
137
|
+
if not priv_key_path.exists():
|
|
138
|
+
click.echo(
|
|
139
|
+
f"Error: no key found at {priv_key_path}. Run 'modelsign keygen' first.",
|
|
140
|
+
err=True,
|
|
141
|
+
)
|
|
142
|
+
sys.exit(1)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
private_key = load_private_key(priv_key_path)
|
|
146
|
+
except Exception as e:
|
|
147
|
+
click.echo(f"Error loading private key: {e}", err=True)
|
|
148
|
+
sys.exit(1)
|
|
149
|
+
|
|
150
|
+
pub_key = private_key.public_key()
|
|
151
|
+
fingerprint = compute_fingerprint(pub_key)
|
|
152
|
+
pub_key_b64 = base64.b64encode(public_key_to_bytes(pub_key)).decode("ascii")
|
|
153
|
+
|
|
154
|
+
# Build identity card
|
|
155
|
+
if identity_file is not None:
|
|
156
|
+
try:
|
|
157
|
+
card_data = json.loads(Path(identity_file).read_text())
|
|
158
|
+
card = ModelCard.from_dict(card_data)
|
|
159
|
+
except Exception as e:
|
|
160
|
+
click.echo(f"Error reading identity file: {e}", err=True)
|
|
161
|
+
sys.exit(1)
|
|
162
|
+
elif name is not None:
|
|
163
|
+
card = ModelCard(name=name)
|
|
164
|
+
else:
|
|
165
|
+
click.echo("Error: provide --name or --identity.", err=True)
|
|
166
|
+
sys.exit(1)
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
validate_card(card)
|
|
170
|
+
except ValueError as e:
|
|
171
|
+
click.echo(f"Error: invalid identity card: {e}", err=True)
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
identity_dict = card.to_dict()
|
|
175
|
+
identity_bytes = canonical_json(identity_dict)
|
|
176
|
+
|
|
177
|
+
# Hash model and build message
|
|
178
|
+
manifest = None
|
|
179
|
+
if model_path.is_dir():
|
|
180
|
+
manifest, model_hash = hash_directory(model_path)
|
|
181
|
+
message = build_dir_message(model_hash, identity_bytes)
|
|
182
|
+
file_label = str(model_path)
|
|
183
|
+
else:
|
|
184
|
+
model_hash = hash_file(model_path)
|
|
185
|
+
message = build_file_message(model_hash, identity_bytes)
|
|
186
|
+
file_label = model_path.name
|
|
187
|
+
|
|
188
|
+
# Sign
|
|
189
|
+
signature_bytes = sign_bytes(message, private_key)
|
|
190
|
+
signature_b64 = base64.b64encode(signature_bytes).decode("ascii")
|
|
191
|
+
|
|
192
|
+
signed_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
193
|
+
|
|
194
|
+
sig = SigFile(
|
|
195
|
+
modelsign_version="1.0",
|
|
196
|
+
file=file_label,
|
|
197
|
+
sha256=f"sha256:{model_hash}",
|
|
198
|
+
signature=signature_b64,
|
|
199
|
+
algorithm="ed25519",
|
|
200
|
+
signed_at=signed_at,
|
|
201
|
+
public_key=pub_key_b64,
|
|
202
|
+
key_fingerprint=fingerprint,
|
|
203
|
+
identity=identity_dict,
|
|
204
|
+
manifest=manifest,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
sig_path = Path(out_path) if out_path else Path(str(model_path) + ".sig")
|
|
208
|
+
write_sig(sig, sig_path)
|
|
209
|
+
|
|
210
|
+
click.echo(f"Signed: {model_path}")
|
|
211
|
+
click.echo(f"Sig file: {sig_path}")
|
|
212
|
+
click.echo(f"Fingerprint: {fingerprint}")
|
|
213
|
+
click.echo(f"SHA-256: sha256:{model_hash[:16]}...")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@main.command()
|
|
217
|
+
@click.argument("model_path")
|
|
218
|
+
@click.option("--sig", "sig_path", default=None, help="Path to .sig file (default: MODEL.sig).")
|
|
219
|
+
@click.option("--pubkey", "pubkey_path", default=None, help="Path to trusted public key PEM.")
|
|
220
|
+
@click.option("--json", "output_json", is_flag=True, default=False, help="Output JSON.")
|
|
221
|
+
@click.option("--quiet", is_flag=True, default=False, help="Suppress output; use exit code only.")
|
|
222
|
+
def verify(model_path, sig_path, pubkey_path, output_json, quiet):
|
|
223
|
+
"""Verify a signed model file or directory."""
|
|
224
|
+
model_path = Path(model_path)
|
|
225
|
+
|
|
226
|
+
# Locate sig file
|
|
227
|
+
if sig_path is not None:
|
|
228
|
+
sig_file_path = Path(sig_path)
|
|
229
|
+
else:
|
|
230
|
+
sig_file_path = Path(str(model_path) + ".sig")
|
|
231
|
+
|
|
232
|
+
if not sig_file_path.exists():
|
|
233
|
+
if not quiet:
|
|
234
|
+
click.echo(f"Error: sig file not found: {sig_file_path}", err=True)
|
|
235
|
+
sys.exit(2)
|
|
236
|
+
|
|
237
|
+
if not model_path.exists():
|
|
238
|
+
if not quiet:
|
|
239
|
+
click.echo(f"Error: model not found: {model_path}", err=True)
|
|
240
|
+
sys.exit(2)
|
|
241
|
+
|
|
242
|
+
# Read sig
|
|
243
|
+
try:
|
|
244
|
+
sig = read_sig(sig_file_path)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
if not quiet:
|
|
247
|
+
click.echo(f"Error reading sig file: {e}", err=True)
|
|
248
|
+
sys.exit(2)
|
|
249
|
+
|
|
250
|
+
# Reconstruct public key for verification
|
|
251
|
+
try:
|
|
252
|
+
if pubkey_path is not None:
|
|
253
|
+
pub_key = load_public_key(Path(pubkey_path))
|
|
254
|
+
else:
|
|
255
|
+
# Use embedded public key from sig file
|
|
256
|
+
raw_pub = base64.b64decode(sig.public_key)
|
|
257
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
258
|
+
from cryptography.hazmat.primitives import serialization
|
|
259
|
+
pub_key = Ed25519PublicKey.from_public_bytes(raw_pub)
|
|
260
|
+
except Exception as e:
|
|
261
|
+
if not quiet:
|
|
262
|
+
click.echo(f"Error loading public key: {e}", err=True)
|
|
263
|
+
sys.exit(2)
|
|
264
|
+
|
|
265
|
+
# Hash model
|
|
266
|
+
try:
|
|
267
|
+
if model_path.is_dir():
|
|
268
|
+
_, model_hash = hash_directory(model_path)
|
|
269
|
+
else:
|
|
270
|
+
model_hash = hash_file(model_path)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
if not quiet:
|
|
273
|
+
click.echo(f"Error hashing model: {e}", err=True)
|
|
274
|
+
sys.exit(2)
|
|
275
|
+
|
|
276
|
+
# Rebuild message and verify
|
|
277
|
+
identity_bytes = canonical_json(sig.identity)
|
|
278
|
+
if model_path.is_dir():
|
|
279
|
+
message = build_dir_message(model_hash, identity_bytes)
|
|
280
|
+
else:
|
|
281
|
+
message = build_file_message(model_hash, identity_bytes)
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
signature_bytes = base64.b64decode(sig.signature)
|
|
285
|
+
except Exception as e:
|
|
286
|
+
if not quiet:
|
|
287
|
+
click.echo(f"Error decoding signature: {e}", err=True)
|
|
288
|
+
sys.exit(2)
|
|
289
|
+
|
|
290
|
+
ok = verify_bytes(message, signature_bytes, pub_key)
|
|
291
|
+
|
|
292
|
+
if output_json:
|
|
293
|
+
result = {
|
|
294
|
+
"verified": ok,
|
|
295
|
+
"model": str(model_path),
|
|
296
|
+
"sha256": f"sha256:{model_hash}",
|
|
297
|
+
"fingerprint": sig.key_fingerprint,
|
|
298
|
+
"signed_at": sig.signed_at,
|
|
299
|
+
"identity": sig.identity,
|
|
300
|
+
}
|
|
301
|
+
click.echo(json.dumps(result))
|
|
302
|
+
if not ok:
|
|
303
|
+
sys.exit(1)
|
|
304
|
+
return
|
|
305
|
+
|
|
306
|
+
if quiet:
|
|
307
|
+
if not ok:
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
if ok:
|
|
312
|
+
click.echo(f"VERIFIED: {model_path}")
|
|
313
|
+
click.echo(f" Identity: {sig.identity.get('name', '(unnamed)')}")
|
|
314
|
+
click.echo(f" Fingerprint: {sig.key_fingerprint}")
|
|
315
|
+
click.echo(f" Signed at: {sig.signed_at} (unverified — no RFC 3161 timestamp)")
|
|
316
|
+
else:
|
|
317
|
+
click.echo(f"FAILED: signature verification failed for {model_path}")
|
|
318
|
+
sys.exit(1)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@main.command()
|
|
322
|
+
@click.argument("sig_file")
|
|
323
|
+
@click.option("--json", "output_json", is_flag=True, default=False, help="Output raw JSON.")
|
|
324
|
+
def inspect(sig_file, output_json):
|
|
325
|
+
"""Inspect a .sig file and display identity card."""
|
|
326
|
+
sig_path = Path(sig_file)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
sig = read_sig(sig_path)
|
|
330
|
+
except FileNotFoundError:
|
|
331
|
+
click.echo(f"Error: sig file not found: {sig_path}", err=True)
|
|
332
|
+
sys.exit(1)
|
|
333
|
+
except Exception as e:
|
|
334
|
+
click.echo(f"Error reading sig file: {e}", err=True)
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
|
|
337
|
+
if output_json:
|
|
338
|
+
from dataclasses import asdict
|
|
339
|
+
click.echo(json.dumps(asdict(sig), indent=2))
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
# Human-readable output
|
|
343
|
+
click.echo(f"File: {sig.file}")
|
|
344
|
+
click.echo(f"SHA-256: {sig.sha256}")
|
|
345
|
+
click.echo(f"Algorithm: {sig.algorithm}")
|
|
346
|
+
click.echo(f"Fingerprint: {sig.key_fingerprint}")
|
|
347
|
+
click.echo(f"Signed at: {sig.signed_at}")
|
|
348
|
+
click.echo("Identity:")
|
|
349
|
+
for key, val in sig.identity.items():
|
|
350
|
+
if isinstance(val, dict):
|
|
351
|
+
click.echo(f" {key}:")
|
|
352
|
+
for k2, v2 in val.items():
|
|
353
|
+
click.echo(f" {k2}: {v2}")
|
|
354
|
+
else:
|
|
355
|
+
click.echo(f" {key}: {val}")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@main.group()
|
|
359
|
+
def keyring():
|
|
360
|
+
"""Manage trusted public keys."""
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@keyring.command("add")
|
|
364
|
+
@click.argument("pubkey_path")
|
|
365
|
+
@click.argument("alias")
|
|
366
|
+
@click.option("--keyring-dir", default=None, help="Keyring directory (default: ~/.modelsign/keyring).")
|
|
367
|
+
def keyring_add_cmd(pubkey_path, alias, keyring_dir):
|
|
368
|
+
"""Add a public key to the trusted keyring."""
|
|
369
|
+
kr_dir = Path(keyring_dir) if keyring_dir else DEFAULT_KEY_DIR / "keyring"
|
|
370
|
+
try:
|
|
371
|
+
keyring_add(kr_dir, Path(pubkey_path), alias)
|
|
372
|
+
click.echo(f"Added key '{alias}' to keyring at {kr_dir}")
|
|
373
|
+
except Exception as e:
|
|
374
|
+
click.echo(f"Error: {e}", err=True)
|
|
375
|
+
sys.exit(1)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@keyring.command("list")
|
|
379
|
+
@click.option("--keyring-dir", default=None, help="Keyring directory (default: ~/.modelsign/keyring).")
|
|
380
|
+
def keyring_list_cmd(keyring_dir):
|
|
381
|
+
"""List all trusted public keys."""
|
|
382
|
+
kr_dir = Path(keyring_dir) if keyring_dir else DEFAULT_KEY_DIR / "keyring"
|
|
383
|
+
keys = keyring_list(kr_dir)
|
|
384
|
+
if not keys:
|
|
385
|
+
click.echo("No keys in keyring.")
|
|
386
|
+
return
|
|
387
|
+
for entry in keys:
|
|
388
|
+
click.echo(f"{entry['alias']:<20} {entry['fingerprint']} {entry['path']}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
@keyring.command("remove")
|
|
392
|
+
@click.argument("alias")
|
|
393
|
+
@click.option("--keyring-dir", default=None, help="Keyring directory (default: ~/.modelsign/keyring).")
|
|
394
|
+
def keyring_remove_cmd(alias, keyring_dir):
|
|
395
|
+
"""Remove a key from the trusted keyring."""
|
|
396
|
+
kr_dir = Path(keyring_dir) if keyring_dir else DEFAULT_KEY_DIR / "keyring"
|
|
397
|
+
try:
|
|
398
|
+
keyring_remove(kr_dir, alias)
|
|
399
|
+
click.echo(f"Removed key '{alias}' from keyring.")
|
|
400
|
+
except Exception as e:
|
|
401
|
+
click.echo(f"Error: {e}", err=True)
|
|
402
|
+
sys.exit(1)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Cryptographic signing and verification."""
|
|
2
|
+
|
|
3
|
+
from modelsign.crypto.sign import sign_bytes, build_file_message, build_dir_message
|
|
4
|
+
from modelsign.crypto.verify import verify_bytes
|
|
5
|
+
from modelsign.crypto.keys import (
|
|
6
|
+
generate_keypair,
|
|
7
|
+
load_private_key,
|
|
8
|
+
load_public_key,
|
|
9
|
+
compute_fingerprint,
|
|
10
|
+
public_key_to_bytes,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"sign_bytes",
|
|
15
|
+
"build_file_message",
|
|
16
|
+
"build_dir_message",
|
|
17
|
+
"verify_bytes",
|
|
18
|
+
"generate_keypair",
|
|
19
|
+
"load_private_key",
|
|
20
|
+
"load_public_key",
|
|
21
|
+
"compute_fingerprint",
|
|
22
|
+
"public_key_to_bytes",
|
|
23
|
+
]
|
modelsign/crypto/keys.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Ed25519 key generation, loading, fingerprints, and keyring management."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
9
|
+
Ed25519PrivateKey,
|
|
10
|
+
Ed25519PublicKey,
|
|
11
|
+
)
|
|
12
|
+
from cryptography.hazmat.primitives import serialization
|
|
13
|
+
|
|
14
|
+
# Display-only fingerprint length. 8 hex chars = 32 bits.
|
|
15
|
+
# NOT collision-resistant. Use full public key bytes for registry lookups (v2).
|
|
16
|
+
FINGERPRINT_HEX_CHARS = 8
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_keypair(key_dir: Path) -> tuple[Path, Path]:
|
|
20
|
+
"""Generate Ed25519 keypair. Returns (priv_path, pub_path).
|
|
21
|
+
|
|
22
|
+
If keys already exist, returns existing paths without overwriting.
|
|
23
|
+
"""
|
|
24
|
+
key_dir = Path(key_dir)
|
|
25
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
priv_path = key_dir / "private.pem"
|
|
27
|
+
pub_path = key_dir / "public.pem"
|
|
28
|
+
|
|
29
|
+
if priv_path.exists() and pub_path.exists():
|
|
30
|
+
return priv_path, pub_path
|
|
31
|
+
|
|
32
|
+
private_key = Ed25519PrivateKey.generate()
|
|
33
|
+
|
|
34
|
+
priv_path.write_bytes(private_key.private_bytes(
|
|
35
|
+
encoding=serialization.Encoding.PEM,
|
|
36
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
37
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
38
|
+
))
|
|
39
|
+
os.chmod(priv_path, 0o600)
|
|
40
|
+
|
|
41
|
+
pub_path.write_bytes(private_key.public_key().public_bytes(
|
|
42
|
+
encoding=serialization.Encoding.PEM,
|
|
43
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
44
|
+
))
|
|
45
|
+
|
|
46
|
+
return priv_path, pub_path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_private_key(path: Path) -> Ed25519PrivateKey:
|
|
50
|
+
"""Load Ed25519 private key from PEM file."""
|
|
51
|
+
key = serialization.load_pem_private_key(Path(path).read_bytes(), password=None)
|
|
52
|
+
if not isinstance(key, Ed25519PrivateKey):
|
|
53
|
+
raise ValueError(f"Expected Ed25519 private key, got {type(key).__name__}")
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_public_key(path: Path) -> Ed25519PublicKey:
|
|
58
|
+
"""Load Ed25519 public key from PEM file."""
|
|
59
|
+
key = serialization.load_pem_public_key(Path(path).read_bytes())
|
|
60
|
+
if not isinstance(key, Ed25519PublicKey):
|
|
61
|
+
raise ValueError(f"Expected Ed25519 public key, got {type(key).__name__}")
|
|
62
|
+
return key
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def public_key_to_bytes(key: Ed25519PublicKey) -> bytes:
|
|
66
|
+
"""Extract raw 32-byte public key."""
|
|
67
|
+
return key.public_bytes(
|
|
68
|
+
encoding=serialization.Encoding.Raw,
|
|
69
|
+
format=serialization.PublicFormat.Raw,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def compute_fingerprint(key: Ed25519PublicKey) -> str:
|
|
74
|
+
"""Compute display fingerprint: ed25519:<first 8 hex of SHA-256(raw_pubkey)>.
|
|
75
|
+
|
|
76
|
+
This is display-only (32 bits), NOT collision-resistant.
|
|
77
|
+
"""
|
|
78
|
+
raw = public_key_to_bytes(key)
|
|
79
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
80
|
+
return f"ed25519:{digest[:FINGERPRINT_HEX_CHARS]}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def keyring_add(keyring_dir: Path, pubkey_path: Path, alias: str) -> None:
|
|
84
|
+
"""Add a public key to the trusted keyring."""
|
|
85
|
+
keyring_dir = Path(keyring_dir)
|
|
86
|
+
keyring_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
dest = keyring_dir / f"{alias}.pem"
|
|
88
|
+
shutil.copy2(pubkey_path, dest)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def keyring_list(keyring_dir: Path) -> list[dict]:
|
|
92
|
+
"""List all trusted keys with alias and fingerprint."""
|
|
93
|
+
keyring_dir = Path(keyring_dir)
|
|
94
|
+
if not keyring_dir.exists():
|
|
95
|
+
return []
|
|
96
|
+
result = []
|
|
97
|
+
for pem_file in sorted(keyring_dir.glob("*.pem")):
|
|
98
|
+
key = load_public_key(pem_file)
|
|
99
|
+
result.append({
|
|
100
|
+
"alias": pem_file.stem,
|
|
101
|
+
"fingerprint": compute_fingerprint(key),
|
|
102
|
+
"path": str(pem_file),
|
|
103
|
+
})
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def keyring_remove(keyring_dir: Path, alias: str) -> None:
|
|
108
|
+
"""Remove a key from the trusted keyring by alias."""
|
|
109
|
+
target = Path(keyring_dir) / f"{alias}.pem"
|
|
110
|
+
if target.exists():
|
|
111
|
+
target.unlink()
|
modelsign/crypto/sign.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Ed25519 signing operations with domain-prefixed messages."""
|
|
2
|
+
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sign_bytes(message: bytes, private_key: Ed25519PrivateKey) -> bytes:
|
|
7
|
+
"""Sign a message with Ed25519. Returns 64-byte signature."""
|
|
8
|
+
return private_key.sign(message)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_file_message(file_hash: str, identity_bytes: bytes) -> bytes:
|
|
12
|
+
"""Build the message to sign for a single file.
|
|
13
|
+
|
|
14
|
+
Format: b"modelsign-v1:" + file_hash + b":" + identity_bytes
|
|
15
|
+
|
|
16
|
+
file_hash is always 64 hex chars (SHA-256), so the colon separator
|
|
17
|
+
is unambiguous. Do not change this invariant without updating the
|
|
18
|
+
domain prefix version.
|
|
19
|
+
"""
|
|
20
|
+
return b"modelsign-v1:" + file_hash.encode("utf-8") + b":" + identity_bytes
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_dir_message(manifest_hash: str, identity_bytes: bytes) -> bytes:
|
|
24
|
+
"""Build the message to sign for a directory.
|
|
25
|
+
|
|
26
|
+
Format: b"modelsign-v1:dir:" + manifest_hash + b":" + identity_bytes
|
|
27
|
+
"""
|
|
28
|
+
return b"modelsign-v1:dir:" + manifest_hash.encode("utf-8") + b":" + identity_bytes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Ed25519 verification operations."""
|
|
2
|
+
|
|
3
|
+
from cryptography.exceptions import InvalidSignature
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def verify_bytes(message: bytes, signature: bytes, public_key: Ed25519PublicKey) -> bool:
|
|
8
|
+
"""Verify an Ed25519 signature. Returns True if valid, False otherwise."""
|
|
9
|
+
try:
|
|
10
|
+
public_key.verify(signature, message)
|
|
11
|
+
return True
|
|
12
|
+
except InvalidSignature:
|
|
13
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Directory manifest hashing for multi-file models."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from modelsign.formats.single import hash_file
|
|
7
|
+
from modelsign.identity.canonical import canonical_json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def hash_directory(path: Path) -> tuple[dict, str]:
|
|
11
|
+
"""Hash all files in a directory, return (manifest_dict, manifest_hash).
|
|
12
|
+
|
|
13
|
+
Files are sorted by UTF-8 byte order (NOT locale-aware) for
|
|
14
|
+
deterministic results across Linux/macOS/Windows.
|
|
15
|
+
"""
|
|
16
|
+
path = Path(path)
|
|
17
|
+
if not path.exists():
|
|
18
|
+
raise FileNotFoundError(f"Directory not found: {path}")
|
|
19
|
+
|
|
20
|
+
all_files = [f for f in path.rglob("*") if f.is_file()]
|
|
21
|
+
all_files.sort(key=lambda f: str(f.relative_to(path)).encode("utf-8"))
|
|
22
|
+
|
|
23
|
+
files_dict = {}
|
|
24
|
+
for file_path in all_files:
|
|
25
|
+
rel_path = str(file_path.relative_to(path))
|
|
26
|
+
file_hash = hash_file(file_path)
|
|
27
|
+
files_dict[rel_path] = f"sha256:{file_hash}"
|
|
28
|
+
|
|
29
|
+
manifest = {"files": files_dict}
|
|
30
|
+
manifest_bytes = canonical_json(manifest)
|
|
31
|
+
manifest_hash = hashlib.sha256(manifest_bytes).hexdigest()
|
|
32
|
+
|
|
33
|
+
return manifest, manifest_hash
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Streaming SHA-256 hash for single model files."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
CHUNK_SIZE = 1024 * 1024 # 1 MB
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def hash_file(path: Path) -> str:
|
|
10
|
+
"""Compute SHA-256 hash of a file using streaming reads.
|
|
11
|
+
|
|
12
|
+
Returns hex digest string (64 chars).
|
|
13
|
+
Raises FileNotFoundError if path doesn't exist.
|
|
14
|
+
"""
|
|
15
|
+
path = Path(path)
|
|
16
|
+
if not path.exists():
|
|
17
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
18
|
+
|
|
19
|
+
h = hashlib.sha256()
|
|
20
|
+
with open(path, "rb") as f:
|
|
21
|
+
while True:
|
|
22
|
+
chunk = f.read(CHUNK_SIZE)
|
|
23
|
+
if not chunk:
|
|
24
|
+
break
|
|
25
|
+
h.update(chunk)
|
|
26
|
+
return h.hexdigest()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Deterministic JSON serialization using RFC 8785 (JCS).
|
|
2
|
+
|
|
3
|
+
This module is the SINGLE SOURCE OF TRUTH for JSON canonicalization
|
|
4
|
+
in modelsign. All signing and verification MUST use canonical_json()
|
|
5
|
+
from this module to produce deterministic bytes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import rfc8785
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def canonical_json(obj: dict) -> bytes:
|
|
12
|
+
"""Serialize a dict to canonical JSON bytes (RFC 8785 JCS).
|
|
13
|
+
|
|
14
|
+
Returns UTF-8 bytes with:
|
|
15
|
+
- Keys sorted lexicographically at all nesting levels
|
|
16
|
+
- No extra whitespace
|
|
17
|
+
- ECMAScript number serialization
|
|
18
|
+
- Minimal string escaping
|
|
19
|
+
"""
|
|
20
|
+
return rfc8785.dumps(obj)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Model Identity Card — structured, signed metadata about a model."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass, field, fields
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
_HASH_PATTERN = re.compile(r"^sha256:[0-9a-fA-F]+$")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class ModelCard:
|
|
12
|
+
"""Structured identity metadata for a signed model.
|
|
13
|
+
|
|
14
|
+
Only `name` is required. All other fields are optional.
|
|
15
|
+
Unknown fields from JSON are preserved in `extra` for forward compatibility.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
name: str = ""
|
|
19
|
+
architecture: Optional[str] = None
|
|
20
|
+
base_model: Optional[str] = None
|
|
21
|
+
parent_signature: Optional[str] = None
|
|
22
|
+
version: Optional[str] = None
|
|
23
|
+
creator: Optional[str] = None
|
|
24
|
+
contact: Optional[str] = None
|
|
25
|
+
license: Optional[str] = None
|
|
26
|
+
intended_use: Optional[str] = None
|
|
27
|
+
restrictions: Optional[str] = None
|
|
28
|
+
training: Optional[dict] = None
|
|
29
|
+
quantization: Optional[str] = None
|
|
30
|
+
eval_metrics: Optional[dict] = None
|
|
31
|
+
merge_details: Optional[str] = None
|
|
32
|
+
extra: dict = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
def to_dict(self) -> dict:
|
|
35
|
+
"""Convert to dict, excluding None values and empty extra."""
|
|
36
|
+
result = {}
|
|
37
|
+
for f in fields(self):
|
|
38
|
+
val = getattr(self, f.name)
|
|
39
|
+
if f.name == "extra":
|
|
40
|
+
if val:
|
|
41
|
+
result.update(val)
|
|
42
|
+
elif val is not None:
|
|
43
|
+
result[f.name] = val
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict) -> "ModelCard":
|
|
48
|
+
"""Create from dict, preserving unknown fields in extra."""
|
|
49
|
+
known = {f.name for f in fields(cls)} - {"extra"}
|
|
50
|
+
known_data = {k: v for k, v in data.items() if k in known}
|
|
51
|
+
unknown_data = {k: v for k, v in data.items() if k not in known}
|
|
52
|
+
return cls(**known_data, extra=unknown_data)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def validate_card(card: ModelCard) -> None:
|
|
56
|
+
"""Validate a ModelCard. Raises ValueError on invalid fields."""
|
|
57
|
+
if not isinstance(card.name, str) or not card.name.strip():
|
|
58
|
+
raise ValueError("name must be a non-empty string")
|
|
59
|
+
|
|
60
|
+
if card.parent_signature is not None:
|
|
61
|
+
if not _HASH_PATTERN.match(card.parent_signature):
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"parent_signature must match 'sha256:<hex>', got: {card.parent_signature}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if card.training and "dataset_hash" in card.training:
|
|
67
|
+
dh = card.training["dataset_hash"]
|
|
68
|
+
if not _HASH_PATTERN.match(dh):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"training.dataset_hash must match 'sha256:<hex>', got: {dh}"
|
|
71
|
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Ed25519 response signing for API authenticity."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
|
|
10
|
+
from modelsign.crypto.keys import load_private_key
|
|
11
|
+
from modelsign.identity.canonical import canonical_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ResponseSigner:
|
|
15
|
+
"""Signs API responses with Ed25519 for authenticity verification."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, key_path: str | Path):
|
|
18
|
+
self._private_key = load_private_key(Path(key_path).expanduser())
|
|
19
|
+
self._public_key = self._private_key.public_key()
|
|
20
|
+
|
|
21
|
+
def sign(self, data: dict) -> dict:
|
|
22
|
+
"""Sign response data. Returns envelope with data, signature, timestamp, fingerprint."""
|
|
23
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
24
|
+
canonical = canonical_json(data)
|
|
25
|
+
fingerprint = hashlib.sha256(canonical).hexdigest()[:16]
|
|
26
|
+
|
|
27
|
+
message = b"modelsign-v1:response:" + canonical + b":" + timestamp.encode("utf-8")
|
|
28
|
+
signature = self._private_key.sign(message)
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"data": data,
|
|
32
|
+
"signature": base64.b64encode(signature).decode(),
|
|
33
|
+
"timestamp": timestamp,
|
|
34
|
+
"fingerprint": fingerprint,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def get_public_key_pem(self) -> str:
|
|
38
|
+
"""Export public key PEM for verification by clients."""
|
|
39
|
+
return self._public_key.public_bytes(
|
|
40
|
+
encoding=serialization.Encoding.PEM,
|
|
41
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
42
|
+
).decode()
|
modelsign/sig.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Sig file I/O — reading, writing, and version management for .sig files."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, asdict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CURRENT_VERSION = "1.0"
|
|
11
|
+
SUPPORTED_MAJOR = 1
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SigVersionError(Exception):
|
|
15
|
+
"""Raised when .sig file version is incompatible."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SigFile:
|
|
21
|
+
"""In-memory representation of a .sig file."""
|
|
22
|
+
|
|
23
|
+
modelsign_version: str
|
|
24
|
+
file: str
|
|
25
|
+
sha256: str
|
|
26
|
+
signature: str
|
|
27
|
+
algorithm: str
|
|
28
|
+
signed_at: str
|
|
29
|
+
public_key: str
|
|
30
|
+
key_fingerprint: str
|
|
31
|
+
identity: dict
|
|
32
|
+
manifest: Optional[dict] = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def write_sig(sig: SigFile, path: Path) -> None:
|
|
36
|
+
"""Write a SigFile to disk as human-readable JSON."""
|
|
37
|
+
path = Path(path)
|
|
38
|
+
data = asdict(sig)
|
|
39
|
+
data = {k: v for k, v in data.items() if v is not None}
|
|
40
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def read_sig(path: Path, validate_fingerprint: bool = False) -> SigFile:
|
|
44
|
+
"""Read a .sig file and validate version.
|
|
45
|
+
|
|
46
|
+
Version policy:
|
|
47
|
+
- Known version (1.0): load normally
|
|
48
|
+
- Higher minor (1.x): warn to stderr, load
|
|
49
|
+
- Higher major (2+): raise SigVersionError
|
|
50
|
+
- Missing/malformed: raise SigVersionError
|
|
51
|
+
"""
|
|
52
|
+
path = Path(path)
|
|
53
|
+
if not path.exists():
|
|
54
|
+
raise FileNotFoundError(f"Signature file not found: {path}")
|
|
55
|
+
|
|
56
|
+
data = json.loads(path.read_text())
|
|
57
|
+
|
|
58
|
+
version = data.get("modelsign_version")
|
|
59
|
+
if not version or not isinstance(version, str):
|
|
60
|
+
raise SigVersionError("invalid sig file: missing or malformed modelsign_version")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
parts = version.split(".")
|
|
64
|
+
major = int(parts[0])
|
|
65
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
66
|
+
except (ValueError, IndexError):
|
|
67
|
+
raise SigVersionError(f"invalid sig file: cannot parse version '{version}'")
|
|
68
|
+
|
|
69
|
+
if major > SUPPORTED_MAJOR:
|
|
70
|
+
raise SigVersionError(
|
|
71
|
+
f"this sig requires modelsign v{major}+, please upgrade "
|
|
72
|
+
f"(current: v{CURRENT_VERSION})"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
if major == SUPPORTED_MAJOR and minor > 0:
|
|
76
|
+
print(
|
|
77
|
+
f"Warning: sig created with newer modelsign {version}, "
|
|
78
|
+
f"some fields may be unrecognized",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
sig = SigFile(
|
|
83
|
+
modelsign_version=data["modelsign_version"],
|
|
84
|
+
file=data["file"],
|
|
85
|
+
sha256=data["sha256"],
|
|
86
|
+
signature=data["signature"],
|
|
87
|
+
algorithm=data["algorithm"],
|
|
88
|
+
signed_at=data["signed_at"],
|
|
89
|
+
public_key=data["public_key"],
|
|
90
|
+
key_fingerprint=data["key_fingerprint"],
|
|
91
|
+
identity=data["identity"],
|
|
92
|
+
manifest=data.get("manifest"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if validate_fingerprint:
|
|
96
|
+
_check_fingerprint(sig)
|
|
97
|
+
|
|
98
|
+
return sig
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check_fingerprint(sig: SigFile) -> None:
|
|
102
|
+
"""Validate that key_fingerprint matches public_key."""
|
|
103
|
+
import base64
|
|
104
|
+
import hashlib
|
|
105
|
+
from modelsign.crypto.keys import FINGERPRINT_HEX_CHARS
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
raw_key = base64.b64decode(sig.public_key)
|
|
109
|
+
expected_fp = "ed25519:" + hashlib.sha256(raw_key).hexdigest()[:FINGERPRINT_HEX_CHARS]
|
|
110
|
+
if sig.key_fingerprint != expected_fp:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"fingerprint mismatch: sig says {sig.key_fingerprint}, "
|
|
113
|
+
f"computed {expected_fp} from embedded public key"
|
|
114
|
+
)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
if "fingerprint mismatch" in str(e):
|
|
117
|
+
raise
|
|
118
|
+
raise ValueError(f"fingerprint validation failed: {e}")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modelsign
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Sign AI models with identity. Verify anywhere.
|
|
5
|
+
Author-email: QJ <qj@constantone.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ConstantQJ/modelsign
|
|
8
|
+
Project-URL: Issues, https://github.com/ConstantQJ/modelsign/issues
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security :: Cryptography
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: cryptography>=41.0
|
|
19
|
+
Requires-Dist: click>=8.0
|
|
20
|
+
Requires-Dist: rfc8785>=0.1.2
|
|
21
|
+
Provides-Extra: middleware
|
|
22
|
+
Requires-Dist: fastapi>=0.100; extra == "middleware"
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
26
|
+
Requires-Dist: mypy; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# modelsign
|
|
30
|
+
|
|
31
|
+
[](https://github.com/QuantQJ/modelsign/actions/workflows/ci.yml)
|
|
32
|
+
[](https://opensource.org/licenses/MIT)
|
|
33
|
+
[](https://www.python.org/downloads/)
|
|
34
|
+
|
|
35
|
+
Sign AI models with identity. Verify anywhere.
|
|
36
|
+
|
|
37
|
+
`modelsign` cryptographically binds model files to a signed identity card -- who made this model, what it's based on, what it claims to be. Ed25519 signatures, zero ML dependencies, works with any model format.
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install modelsign
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Quick Start
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# Generate your signing key
|
|
49
|
+
modelsign keygen
|
|
50
|
+
|
|
51
|
+
# Sign a model with a name
|
|
52
|
+
modelsign sign model.safetensors --name "My-Llama-8B-v1"
|
|
53
|
+
|
|
54
|
+
# Verify it
|
|
55
|
+
modelsign verify model.safetensors
|
|
56
|
+
|
|
57
|
+
# Inspect the identity card
|
|
58
|
+
modelsign inspect model.safetensors.sig
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Rich Identity Cards
|
|
62
|
+
|
|
63
|
+
Sign with full provenance:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# Create an identity card
|
|
67
|
+
cat > card.json << 'EOF'
|
|
68
|
+
{
|
|
69
|
+
"name": "Llama-3.1-8B-Chat-QJ",
|
|
70
|
+
"architecture": "LlamaForCausalLM",
|
|
71
|
+
"base_model": "meta-llama/Llama-3.1-8B-Instruct",
|
|
72
|
+
"version": "1.0.0",
|
|
73
|
+
"creator": "ConstantQJ",
|
|
74
|
+
"license": "Llama 3.1 Community",
|
|
75
|
+
"intended_use": "Chat assistant",
|
|
76
|
+
"training": {
|
|
77
|
+
"dataset": "custom-chat-v2",
|
|
78
|
+
"epochs": 3,
|
|
79
|
+
"hardware": "DGX Spark GB10"
|
|
80
|
+
},
|
|
81
|
+
"eval_metrics": {
|
|
82
|
+
"mmlu": 0.68,
|
|
83
|
+
"humaneval": 0.53
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
EOF
|
|
87
|
+
|
|
88
|
+
modelsign sign model.safetensors --identity card.json
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Python SDK
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from modelsign import (
|
|
95
|
+
ModelCard, validate_card, canonical_json,
|
|
96
|
+
generate_keypair, load_private_key, load_public_key,
|
|
97
|
+
sign_bytes, build_file_message, verify_bytes,
|
|
98
|
+
hash_file, SigFile, write_sig, read_sig,
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## What It Protects Against
|
|
103
|
+
|
|
104
|
+
- Post-signing **tampering** of model weights
|
|
105
|
+
- **Substitution** of one model for another
|
|
106
|
+
- **Metadata swap** (changing identity claims invalidates signature)
|
|
107
|
+
|
|
108
|
+
## What It Does NOT Cover
|
|
109
|
+
|
|
110
|
+
- Key compromise (your key, your responsibility)
|
|
111
|
+
- Model safety, fairness, or legal compliance
|
|
112
|
+
- Cryptographic timestamping (timestamps are metadata, not proofs)
|
|
113
|
+
|
|
114
|
+
## How It Compares
|
|
115
|
+
|
|
116
|
+
| | modelsign | OpenSSF Model Signing (OMS) |
|
|
117
|
+
|---|---|---|
|
|
118
|
+
| **Focus** | Simple signing + rich identity | Supply-chain integrity via Sigstore |
|
|
119
|
+
| **Identity card** | Embedded (architecture, training, eval metrics) | Minimal (being expanded) |
|
|
120
|
+
| **Setup** | `pip install modelsign` | Sigstore toolchain + transparency log |
|
|
121
|
+
| **Signing** | Offline, Ed25519, one command | Keyless via OIDC + Rekor transparency |
|
|
122
|
+
| **Best for** | Individual fine-tunes, HF uploads, quick sharing | Enterprise supply-chain, NGC publishing |
|
|
123
|
+
| **Network required** | No | Yes (Sigstore/Rekor) |
|
|
124
|
+
|
|
125
|
+
modelsign and OMS are **complementary**. Use modelsign for fast, offline, identity-rich signing. Use OMS when you need transparency logs and keyless verification at enterprise scale.
|
|
126
|
+
|
|
127
|
+
## Identity Card Schema
|
|
128
|
+
|
|
129
|
+
| Field | Required | Description |
|
|
130
|
+
|---|---|---|
|
|
131
|
+
| `name` | Yes | Model name |
|
|
132
|
+
| `architecture` | No | Model class (e.g., `LlamaForCausalLM`) |
|
|
133
|
+
| `base_model` | No | Parent model name/path |
|
|
134
|
+
| `parent_signature` | No | Hash of parent's `.sig` (provenance chain) |
|
|
135
|
+
| `version` | No | Semantic version |
|
|
136
|
+
| `creator` | No | Person or organization |
|
|
137
|
+
| `license` | No | SPDX identifier or name |
|
|
138
|
+
| `intended_use` | No | What the model is for |
|
|
139
|
+
| `restrictions` | No | What it should NOT be used for |
|
|
140
|
+
| `training` | No | `{dataset, dataset_hash, epochs, hardware}` |
|
|
141
|
+
| `quantization` | No | Method (e.g., `GPTQ-4bit`) |
|
|
142
|
+
| `eval_metrics` | No | Benchmark results (`{mmlu: 0.68}`) |
|
|
143
|
+
| `extra` | No | Any additional metadata |
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT — QJ / ConstantOne (CIP1 LLC)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
modelsign/__init__.py,sha256=jSvBH6lBMV7k-hAXd29D-fpQWWdJKIuboHN6PW5GzKg,929
|
|
2
|
+
modelsign/cli.py,sha256=VlOMelyokIPABGS56mRqCsrZxdykn1QUGl_yLrHK9YA,14109
|
|
3
|
+
modelsign/sig.py,sha256=KZadRhHfZGPPwM8KOka3xkJVshYIBFCzWGw7DyRudT8,3502
|
|
4
|
+
modelsign/crypto/__init__.py,sha256=adsLupeY9RyFE1S4bP6G31YIibr6J_9HZGCOMh2V6yI,562
|
|
5
|
+
modelsign/crypto/keys.py,sha256=xCIGI7j0MRSkWPjNThdN1XJTMmwKJqkBS-kCnb9ZECM,3646
|
|
6
|
+
modelsign/crypto/sign.py,sha256=BAFLYGaes0r7tyxL7P4Gt98STiJ6xKTlT1t3SRfT4z8,1069
|
|
7
|
+
modelsign/crypto/verify.py,sha256=kED1WtLgmC9YYgexzx_Hvsiy0ELo7qL7RVV49ktG3P0,468
|
|
8
|
+
modelsign/formats/__init__.py,sha256=rFtQInPdEPs1cmga42PAPaeZMuxZgw09TteqWQgIuLU,194
|
|
9
|
+
modelsign/formats/directory.py,sha256=D0NEq9aYYPKZkLP7Hl00whprhYHHIEuiuPxRYFgAcyg,1089
|
|
10
|
+
modelsign/formats/single.py,sha256=oC5rbxkSQ_tfeoo4nZxajJjPPxK-2XYwC-HT17Hxj0U,653
|
|
11
|
+
modelsign/identity/__init__.py,sha256=c-4fAeDgkU6dJ5MttNbFNvTuvbP5EmJ1MZRJLV7mNc4,224
|
|
12
|
+
modelsign/identity/canonical.py,sha256=RmIHdMQvlRji4W3WPlUA54NUF4jZHSbVwYvwAfe2Giw,592
|
|
13
|
+
modelsign/identity/card.py,sha256=LaqhiFgjGlkk9_qxezP2AaRxhYtwmvQ-PYgpI81mBPg,2517
|
|
14
|
+
modelsign/middleware/__init__.py,sha256=XD3Byets2Vy9T-n7VMLmC5M8tZgviCMptlryy-qsS6g,150
|
|
15
|
+
modelsign/middleware/response.py,sha256=Qev4HfKschg3FUuP06TBAAQ6NOzcvoocxRmlN1Awt6I,1520
|
|
16
|
+
modelsign-1.0.0.dist-info/licenses/LICENSE,sha256=-PMOmGZ5tmf1McGSjuTS9uyjotvzAnYdFPpj4dJjUA0,1084
|
|
17
|
+
modelsign-1.0.0.dist-info/METADATA,sha256=5mG51Dl3xV39qbyDReRTH4juDpoopxLMMLDj59I5dvo,4762
|
|
18
|
+
modelsign-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
19
|
+
modelsign-1.0.0.dist-info/entry_points.txt,sha256=MJy_HDR6EQgT4zIwbeysQ74LoYeoHeX2WJYJRlPuhIU,49
|
|
20
|
+
modelsign-1.0.0.dist-info/top_level.txt,sha256=9QzBz3DtzpjLVlf4Af6izqC11Fgu-b5GCjvz5jrJrUQ,10
|
|
21
|
+
modelsign-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 QJ / ConstantOne (CIP1 LLC)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modelsign
|