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 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
+ ]
@@ -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()
@@ -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,6 @@
1
+ """File and directory hashing for modelsign."""
2
+
3
+ from modelsign.formats.single import hash_file
4
+ from modelsign.formats.directory import hash_directory
5
+
6
+ __all__ = ["hash_file", "hash_directory"]
@@ -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,6 @@
1
+ """Model identity card and canonical JSON."""
2
+
3
+ from modelsign.identity.canonical import canonical_json
4
+ from modelsign.identity.card import ModelCard, validate_card
5
+
6
+ __all__ = ["canonical_json", "ModelCard", "validate_card"]
@@ -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,5 @@
1
+ """Optional response signing middleware for API endpoints."""
2
+
3
+ from modelsign.middleware.response import ResponseSigner
4
+
5
+ __all__ = ["ResponseSigner"]
@@ -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
+ [![CI](https://github.com/QuantQJ/modelsign/actions/workflows/ci.yml/badge.svg)](https://github.com/QuantQJ/modelsign/actions/workflows/ci.yml)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
33
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ modelsign = modelsign.cli:main
@@ -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