modelferry 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {modelferry-0.1.0 → modelferry-0.2.0}/.github/workflows/ci.yml +31 -1
- {modelferry-0.1.0 → modelferry-0.2.0}/.github/workflows/release.yml +10 -4
- {modelferry-0.1.0 → modelferry-0.2.0}/.gitignore +6 -0
- modelferry-0.2.0/BUILD_PLAN.md +254 -0
- modelferry-0.2.0/CHANGELOG.md +109 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/CLAUDE.md +1 -1
- {modelferry-0.1.0 → modelferry-0.2.0}/PKG-INFO +45 -18
- {modelferry-0.1.0 → modelferry-0.2.0}/README.md +43 -17
- modelferry-0.2.0/RESOURCES.md +133 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/SPEC.md +39 -8
- {modelferry-0.1.0 → modelferry-0.2.0}/pyproject.toml +5 -1
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/__init__.py +1 -1
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/cli.py +39 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/manifest.py +74 -3
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/offline.py +5 -4
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/pack.py +60 -4
- modelferry-0.2.0/src/modelferry/signing.py +159 -0
- modelferry-0.2.0/src/modelferry/verify_signature.py +176 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_cli_skeleton.py +2 -2
- modelferry-0.2.0/tests/test_manifest_v2.py +75 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_manifest.py +60 -0
- modelferry-0.2.0/tests/test_pack_signing.py +171 -0
- modelferry-0.2.0/tests/test_signing.py +117 -0
- modelferry-0.2.0/tests/test_verify_signature.py +201 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/uv.lock +158 -2
- modelferry-0.1.0/CHANGELOG.md +0 -53
- {modelferry-0.1.0 → modelferry-0.2.0}/.gitattributes +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/LICENSE +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/docs/Dockerfile.vhs +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/docs/demo.gif +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/docs/demo.md +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/docs/demo.tape +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/scripts/e2e_airgap.sh +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/errors.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/src/modelferry/hf.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/_bundle.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/conftest.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_corruption.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_pathsafety.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_roundtrip.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_stdlib_lint.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_unpack_hardening.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_offline_verify.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_pack_dest.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_pack_hardening.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_pack_integration.py +0 -0
- {modelferry-0.1.0 → modelferry-0.2.0}/tests/test_pack_writer.py +0 -0
|
@@ -2,7 +2,7 @@ name: ci
|
|
|
2
2
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
|
-
branches: [
|
|
5
|
+
branches: ['**']
|
|
6
6
|
pull_request:
|
|
7
7
|
|
|
8
8
|
concurrency:
|
|
@@ -119,3 +119,33 @@ jobs:
|
|
|
119
119
|
"$mf" unpack "$bundle" ./out
|
|
120
120
|
test -f ./out/model.safetensors
|
|
121
121
|
echo "wheel-install round trip OK"
|
|
122
|
+
- name: Signed pack + verify-signature round trip through the installed wheel (network)
|
|
123
|
+
env:
|
|
124
|
+
HF_HUB_DISABLE_PROGRESS_BARS: "1"
|
|
125
|
+
run: |
|
|
126
|
+
mf=/tmp/wenv/bin/modelferry
|
|
127
|
+
py=/tmp/wenv/bin/python
|
|
128
|
+
work="$(mktemp -d)"
|
|
129
|
+
cd "$work"
|
|
130
|
+
# Ephemeral keypair, job-only: never committed, never a repo secret. Uses
|
|
131
|
+
# the installed modelferry's own generate_keypair so producer and verifier
|
|
132
|
+
# agree on the key format.
|
|
133
|
+
"$py" -c "from modelferry.signing import Ed25519Signer; Ed25519Signer.generate_keypair('key.sec','key.pub')"
|
|
134
|
+
MODELFERRY_SIGNING_KEY="$PWD/key.sec" "$mf" pack hf-internal-testing/tiny-random-gpt2 --dest ./bs --chunk-size 200K --exclude "*.bin" --exclude "*.h5" --sign
|
|
135
|
+
bundle="$(echo ./bs/tiny-random-gpt2__*)"
|
|
136
|
+
echo "signed bundle: $bundle"
|
|
137
|
+
# The signed bundle carries a signing block and the detached signature.
|
|
138
|
+
test -f "$bundle/manifest.json.sig"
|
|
139
|
+
"$py" -c "import json,sys; s=json.loads(open(sys.argv[1]).read())['signing']; assert s['algorithm']=='ed25519' and s['signature_file']=='manifest.json.sig', s; print('signing block:', s)" "$bundle/manifest.json"
|
|
140
|
+
# Positive: the installed verifier reports VALID / exit 0.
|
|
141
|
+
out="$("$mf" verify-signature "$bundle" --public-key key.pub)"
|
|
142
|
+
echo "$out"
|
|
143
|
+
echo "$out" | grep -q '^VALID:'
|
|
144
|
+
# Negative: tamper a manifest value (still valid JSON) so the signature no
|
|
145
|
+
# longer covers these bytes; verify-signature must now exit non-zero. Proves
|
|
146
|
+
# the installed verifier actually verifies, not just prints VALID.
|
|
147
|
+
"$py" -c "import json,sys; from modelferry import manifest; p=sys.argv[1]; m=json.loads(open(p,'rb').read()); m['created_at']='1999-01-01T00:00:00Z'; open(p,'wb').write(manifest.serialize(m))" "$bundle/manifest.json"
|
|
148
|
+
if "$mf" verify-signature "$bundle" --public-key key.pub; then
|
|
149
|
+
echo "ERROR: verify-signature passed on a tampered manifest"; exit 1
|
|
150
|
+
fi
|
|
151
|
+
echo "signed wheel-install round trip OK (VALID, then tamper rejected)"
|
|
@@ -90,10 +90,16 @@ jobs:
|
|
|
90
90
|
GH_TOKEN: ${{ github.token }}
|
|
91
91
|
run: |
|
|
92
92
|
sha="$(sha256sum src/modelferry/offline.py | cut -d' ' -f1)"
|
|
93
|
-
# Lead with
|
|
94
|
-
# is, then append the verifier block.
|
|
95
|
-
#
|
|
96
|
-
|
|
93
|
+
# Lead with this tag's CHANGELOG section so the release page says what this
|
|
94
|
+
# is, then append the verifier block. Extraction is tag-driven: it prints
|
|
95
|
+
# from the "## [<version>]" header for this exact tag up to (not including)
|
|
96
|
+
# the next "## [" section header or the link-reference lines at the end.
|
|
97
|
+
# The second awk trims trailing blank lines from the section, so the
|
|
98
|
+
# heredoc's one leading blank line is the single separator before Verifier.
|
|
99
|
+
ver="${GITHUB_REF_NAME#v}"
|
|
100
|
+
awk -v hdr="## [$ver]" 'index($0, hdr)==1 {f=1; print; next} f && (/^## \[/ || /^\[.*\]: /) {exit} f {print}' CHANGELOG.md \
|
|
101
|
+
| awk 'NF {last=NR} {line[NR]=$0} END {for (i=1; i<=last; i++) print line[i]}' > notes.md
|
|
102
|
+
test -s notes.md || { echo "no CHANGELOG section for $ver"; exit 1; }
|
|
97
103
|
cat >> notes.md <<EOF
|
|
98
104
|
|
|
99
105
|
## Verifier
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# modelferry — Build Plan
|
|
2
|
+
|
|
3
|
+
**The signed, compliant supply chain for open AI models running inside air-gapped and regulated networks.**
|
|
4
|
+
|
|
5
|
+
This is the execution plan, handed to Claude Code one task at a time. Every phase lists numbered tasks, the files each touches, the key interfaces, named acceptance tests, and a definition of done. The acceptance tests are how "works offline" and "detects tampering" become things CI proves rather than things a README claims.
|
|
6
|
+
|
|
7
|
+
## The one strategic decision that shapes everything
|
|
8
|
+
|
|
9
|
+
We build the full platform, but we **ship each phase as a real release** instead of hiding five months of work on a long-lived branch. Same architecture, same destination. The difference is that every phase becomes a public milestone that ships, gets a launch post, and gathers feedback, rather than a held breath that decays.
|
|
10
|
+
|
|
11
|
+
Why: a solo, side-of-desk build does not die from bad code. It dies in month three when nothing has shipped, no user has reacted, and the reason to keep going has quietly evaporated. Phased releases refuel that reason from outside your own head, and each release is also free market validation. If a phase lands and people care, that is the signal to build the next. If it lands flat, you learned it before sinking two more months in.
|
|
12
|
+
|
|
13
|
+
Release map:
|
|
14
|
+
|
|
15
|
+
- **0.2.0 — Signing** (Phase 0 here). Closes the authenticity gap the launch post named. 2–3 weeks. Ships.
|
|
16
|
+
- **0.3.0 — Closure engine** (Phase 1). Bundles that actually run offline: wheels, image, model, one command. A large user-facing win and a second launch.
|
|
17
|
+
- **0.4.0 — Registry preview** (Phase 2). The appliance that admits, versions, serves, audits. The thing that turns a design-partner conversation into a pilot.
|
|
18
|
+
- **0.5.0 / 1.0 — Trust, compliance, delta, hardening** (Phases 3–5). SBOM, policy, CVE scanning, delta transport, clean-room install.
|
|
19
|
+
|
|
20
|
+
A parallel, non-code track runs alongside Phase 0–1 and is as important as any task in it: **turn one real disconnected environment into a named design partner.** See Section 12.
|
|
21
|
+
|
|
22
|
+
## How to drive Claude Code with this doc
|
|
23
|
+
|
|
24
|
+
- Keep `BUILD_PLAN.md`, `RESOURCES.md`, `SPEC.md`, and `CLAUDE.md` in the repo root so the plan is always in context.
|
|
25
|
+
- Give Claude Code one task at a time, addressed as "Phase N, Task M." Paste the task, its files, and its acceptance test, and ask for the test first, then the implementation.
|
|
26
|
+
- Definition of done for every task: the named test passes, it runs under the air-gap harness where the task is an inside-gap step, and the checkbox here is ticked.
|
|
27
|
+
- House rule, the most important constraint in this document: **the embedded offline verifier (`src/modelferry/offline.py`) stays standard-library only, forever, and verifies integrity only.** It never gains a crypto dependency, never verifies a signature itself, never grows toward hand-rolled cryptography. If a task would add a dependency to it, the task is wrong. A CI test enforces this mechanically (`test_offline_stdlib_lint`), proven to fail on a real non-stdlib import.
|
|
28
|
+
|
|
29
|
+
## Repository and git workflow
|
|
30
|
+
|
|
31
|
+
Work in the existing modelferry repo. The core primitives are extracted from code already validated on a real 65GB model, and the repo's history from packer to platform is part of the story a design partner and a YC reviewer read.
|
|
32
|
+
|
|
33
|
+
- `main` is the launched CLI, currently at v0.1.0. It advances only at each release, by merging `develop`.
|
|
34
|
+
- `develop` is the long-lived integration trunk, cut from `main` once. Every phase lands here.
|
|
35
|
+
- `phase-*` branches are cut from `develop` and merged back when that phase's acceptance tests pass.
|
|
36
|
+
- At each release milestone, `develop` merges to `main` and a version is tagged. Unlike the original "one merge at the end" plan, we merge and tag at every phase boundary, because shipping is the point.
|
|
37
|
+
|
|
38
|
+
CI (secret scanner, unit tests, the stdlib guard, the harness) runs on all branches. Branch protection required on `main` and `develop`.
|
|
39
|
+
|
|
40
|
+
The repo is public, so every branch is public the moment it is pushed. Anything that must stay private goes in a separate private repo, never a branch.
|
|
41
|
+
|
|
42
|
+
## Secret hygiene (non-negotiable, because the repo is public)
|
|
43
|
+
|
|
44
|
+
- Never commit a secret. Bots scrape public pushes within minutes, and git history keeps a secret even after a later commit deletes the file. Any secret that reaches a public branch is burned and must be rotated.
|
|
45
|
+
- All config reads from environment variables. Commit a `.env.example` with placeholders only. `.gitignore` covers `.env`, `*.key`, `sec.key`, `*.pem`, and credential files.
|
|
46
|
+
- A pre-commit secret scanner (gitleaks or trufflehog) blocks a commit containing a secret, and the same scan runs in CI.
|
|
47
|
+
- **The minisign signing key never touches the repo.** It is generated and stored outside version control. A leaked signing key lets anyone forge a bundle the appliance trusts, which breaks the entire trust model.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Phase 0 — Signing (ships as 0.2.0)
|
|
52
|
+
|
|
53
|
+
**Goal:** a bundle's manifest is cryptographically signed on the connected side, and the signature is verifiable wherever a trusted key and a crypto library exist (pack-time self-check, and later the appliance), **without adding any dependency or crypto to the frozen offline.py integrity verifier.**
|
|
54
|
+
|
|
55
|
+
**The load-bearing design decision, settled after reading the real offline.py:**
|
|
56
|
+
|
|
57
|
+
Integrity and authenticity are separate concerns handled by separate tools.
|
|
58
|
+
|
|
59
|
+
- `offline.py` answers "did these bytes arrive intact." That is **integrity**. It stays stdlib-only, runs on the bare air-gap host, and is unchanged except to accept schema 2 for integrity checking. No crypto ever enters it.
|
|
60
|
+
- A new, separate tool answers "was this manifest signed by a key I trust." That is **authenticity**. It is allowed dependencies (PyNaCl, with the `minisign` binary as documented interop), and it runs where a key and a crypto library live: pack-time, and the appliance at admission.
|
|
61
|
+
|
|
62
|
+
Why this is correct and not a dodge: the two questions have different trust models and different environments. The bare air-gap host verifies integrity against a manifest whose hash was approved out-of-band (exactly what MANIFEST.md already instructs). The signature protects that **approval chain** upstream; it is not part of the unpack step on the disconnected host. This is why offline.py barely moves.
|
|
63
|
+
|
|
64
|
+
Branch: `phase-signing` off `develop`.
|
|
65
|
+
|
|
66
|
+
- [x] **0.1** Branch setup and make the stdlib guard load-bearing. Cut `develop` from `main`, cut `phase-signing` from `develop`. Confirm `test_offline_stdlib_lint` asserts offline.py imports only stdlib, confirm CI runs on all branches, and prove the guard fails on a deliberate `import nacl` before reverting. *(Done: commit c122e20 broadened CI push trigger to all branches; guard proven to fail on real non-stdlib import.)*
|
|
67
|
+
|
|
68
|
+
- [x] **0.2** The `Signer` interface. New `src/modelferry/signing.py`: `class Signer(Protocol)` with `sign(manifest_bytes) -> bytes` and `key_id() -> str`, plus a `MinisignSigner` (or PyNaCl-backed) implementation. Connected side, so dependencies allowed. Design so a KMS/HSM signer drops in later. Key path read from env var, never a flag, never committed. *(Done: commit 2cec6ac. `Ed25519Signer` (PyNaCl) is the concrete impl; pynacl added as a runtime dep, SPEC §12 and CLAUDE.md updated to match; stdlib guard on offline.py still green on Linux CI.)*
|
|
69
|
+
|
|
70
|
+
**Decision — native key format (settled at 0.2):** the ed25519 signer uses a **raw hex-encoded 32-byte seed** as its on-disk key format (`generate_keypair` writes seed hex + public hex). This is deliberately **not** minisign's format. Rationale: 0.2.0 controls both ends (PyNaCl produces the signature and the pack-time self-check / `verify_signature.py` consume it); nothing this release hands a key or signature to the `minisign` binary, so matching minisign now buys zero working interop and pulls in its full envelope (base64 key blobs with algorithm tag + key id, scrypt-encrypted secret keys, the `.minisig` signature format with a signed trusted-comment). True minisign interop is deferred to a later `MinisignSigner` behind the same Protocol (see §8, "minisign as the documented interop path"). **Consequence for 0.3:** the manifest `algorithm` string must name what is actually produced (`"ed25519"`, not `"minisign-ed25519"`) until a real minisign signer lands; `minisign-ed25519` is reserved for that.
|
|
71
|
+
|
|
72
|
+
- [ ] **0.3** Manifest schema 2. Bump `MANIFEST_VERSION` to 2 in `manifest.py`. Add a `signing` block: `{"algorithm": "minisign-ed25519", "key_id": "<id>", "signature_file": "manifest.json.minisig"}`. Payload structure unchanged. The manifest never contains its own signature (circular); the signature is always the external `.minisig` sidecar, computed over the final serialized manifest bytes. Serialization stays deterministic.
|
|
73
|
+
|
|
74
|
+
- [ ] **0.4** offline.py accepts schema 2 for integrity — the one careful edit to the frozen file. Change `SUPPORTED_MANIFEST_VERSION` to accept `{1, 2}`. That is the only functional change: offline.py verifies integrity for both schemas, imports no crypto, does not verify signatures, stays under the line cap. Update the frozen-file rationale and SPEC to state the split explicitly. The stdlib guard from 0.1 must stay green.
|
|
75
|
+
|
|
76
|
+
- [ ] **0.5** The signature verifier — a separate tool, not offline.py. New `src/modelferry/verify_signature.py`: takes a bundle and a trusted public key, checks `manifest.json.minisig` against `manifest.json`. Dependencies allowed. This is what the appliance calls at admission and what a security officer runs connected-side. It is explicitly not copied into the bundle as a bare-host requirement.
|
|
77
|
+
|
|
78
|
+
- [ ] **0.6** Pack-time signing and self-check. In `pack.py`/`cli.py`: after writing the manifest, if a signing key is configured, sign it, write `manifest.json.minisig`, then run 0.5's verify as a pack-time self-check (mirrors the existing post-pack integrity self-verify). Gated by `--sign` or `MODELFERRY_SIGNING_KEY`. Unsigned packing still works; signing is additive.
|
|
79
|
+
|
|
80
|
+
- [ ] **0.7** MANIFEST.md and README reflect signing. MANIFEST.md gains a signing section when signed. The README's honest-limit paragraph flips: authenticity is covered when the bundle is signed and the verifier has the trusted key, with the boundary stated precisely (the bare-host integrity check still does not verify the signature; that is done at approval/admission where the key lives).
|
|
81
|
+
|
|
82
|
+
- [ ] **0.8** Release 0.2.0. Tag, publish to PyPI, GitHub release. The release notes publish the canonical `offline.py` **verifier hash** (as every release has since 0.1.0). They do NOT publish any signing key fingerprint: modelferry has no signing key of its own. `pack --sign` is a capability for users to sign THEIR bundles with THEIR keys, distributed out-of-band by whoever operates the approval authority for the receiving environment. There is no canonical modelferry signing key, so no release publishes one.
|
|
83
|
+
|
|
84
|
+
**Acceptance tests (Phase 0):**
|
|
85
|
+
- `test_offline_stdlib_lint` — offline.py imports only stdlib; fails on a real non-stdlib import. *(passing)*
|
|
86
|
+
- `test_signer_roundtrip` — sign a fixed byte string, verify against the public key in-process.
|
|
87
|
+
- `test_signing_key_never_in_repo` — no key material or key path literal committed.
|
|
88
|
+
- `test_manifest_v2_deterministic` — same inputs, byte-identical v2 manifest.
|
|
89
|
+
- `test_v2_bundle_verifies_integrity` — a v2 bundle passes `verify` on the unchanged integrity verifier.
|
|
90
|
+
- `test_v1_bundle_still_verifies` — no regression for existing bundles.
|
|
91
|
+
- `test_good_signature_verifies` / `test_tampered_manifest_fails_signature` / `test_wrong_key_fails` / `test_missing_signature_fails`.
|
|
92
|
+
- `test_pack_signs_when_key_present` / `test_pack_unsigned_without_key` / `test_packed_signature_self_verifies`.
|
|
93
|
+
|
|
94
|
+
**Done when:** a real model packs to a signed bundle, the signature verifies where the key lives, integrity still verifies on the bare stdlib host for both schemas, the guard holds, and 0.2.0 is published with the canonical offline.py verifier hash in the release notes (no signing key is published; there is no canonical modelferry signing key).
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Phase 1 — Closure engine (ships as 0.3.0)
|
|
99
|
+
|
|
100
|
+
**Goal:** a bundle carries everything to run the model offline, and a real model goes from Hugging Face to serving inside `--network none` using only the bundle.
|
|
101
|
+
|
|
102
|
+
- [ ] **1.1** `core/closure/resolve.py`: given a target (repo, revision, extras), produce a pinned `lock.txt` with `uv pip compile`. Record exact index URLs.
|
|
103
|
+
- [ ] **1.2** `core/closure/wheelhouse.py`: build the wheelhouse for the target platform. See Section 7 for flags and sdist handling.
|
|
104
|
+
- [ ] **1.3** `core/closure/image.py`: `docker save` the inference-server image into `payload/images/`. Record image digest in `compat.json`.
|
|
105
|
+
- [ ] **1.4** `core/closure/model.py`: fetch model artifacts from HF pinned to `revision`, honoring an HF token for gated repos. **The hash of record is computed post-fetch** — the manifest describes the bytes actually fetched, never claims byte-identity to HF (LFS re-uploads and mirror differences happen).
|
|
106
|
+
- [ ] **1.5** `core/compat.py`: emit `compat.json` (arch, minimum NVIDIA driver / compute capability, python version, footprint, image digest, torch build).
|
|
107
|
+
- [ ] **1.6** CLI: `modelferry pack <repo> --revision <sha> --server vllm --out bundle/`.
|
|
108
|
+
- [ ] **1.7** Offline reconstruction: verify, `pip install --no-index --find-links payload/wheelhouse -r payload/lock.txt`, `docker load`, load model, serve one completion.
|
|
109
|
+
- [ ] **1.8** Preflight: read `compat.json` and fail clearly if host driver or compute capability is below requirement, before any transfer or load.
|
|
110
|
+
- [ ] **1.9** Driver compatibility matrix (added, load-bearing). Ship a documented "these bundles run on driver X and up" table, and pack at least one bundle targeting an older CUDA (12.1, driver ~530) as a fallback. The first pilot's disconnected host often runs a frozen old driver IT will not touch; without a fallback, day one is a driver argument, not a demo.
|
|
111
|
+
|
|
112
|
+
**Acceptance tests:**
|
|
113
|
+
- `test_wheelhouse_offline_install` (GPU runner) — in `--network none`, install from wheelhouse succeeds and imports torch with CUDA.
|
|
114
|
+
- `test_model_serves_offline` (GPU runner) — vLLM loads the bundled model and returns a completion offline.
|
|
115
|
+
- `test_sdist_only_dependency_flagged` — a target pulling an sdist-only non-prebuilt dependency is caught at pack time, not load time.
|
|
116
|
+
- `test_compat_preflight_fails_on_mismatch` — a fabricated low-driver compat.json fails preflight.
|
|
117
|
+
- `test_older_cuda_bundle_serves` — the 12.1 fallback bundle serves on an older-driver host.
|
|
118
|
+
|
|
119
|
+
**Done when:** Qwen2.5-14B goes HF → served inside a network-isolated container using only the bundle, the driver fallback works, and the tests pass. Ships as 0.3.0.
|
|
120
|
+
|
|
121
|
+
**Honesty call to settle here:** is the wheelhouse a real independent install path, or a fallback with the image as primary runtime? flash-attn plus the torch/CUDA/Python matrix is the most common reason offline vLLM installs fail, and "prefer the image path" quietly means the image, not the wheelhouse, carries such models. Pick the honest story and make `test_wheelhouse_offline_install` match it, so it does not fail on exactly the models people want.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Phase 2 — Registry as the product (ships as 0.4.0 preview)
|
|
126
|
+
|
|
127
|
+
**Goal:** an appliance that admits, versions, serves three ways, and audits.
|
|
128
|
+
|
|
129
|
+
- [ ] **2.1** Ingest: chunked upload to `POST /bundles/`, store chunks in the CAS with refcounting, create a `pending` version. Reject on manifest hash mismatch.
|
|
130
|
+
- [ ] **2.2** Admission verification: check the minisign signature against the appliance's trusted key (this is where 0.2.0's `verify_signature` runs server-side) and validate the manifest before accepting. Failures never create an approved version.
|
|
131
|
+
- [ ] **2.3** RBAC and approval state machine: `pending → approved | rejected`, `approved → revoked`. Enforce separation of duties (creator cannot approve).
|
|
132
|
+
- [ ] **2.4** Serve models: materialize an approved version to a verified directory an inference host mounts.
|
|
133
|
+
- [ ] **2.5** Serve offline PyPI: static PEP 503 index from the CAS wheels.
|
|
134
|
+
- [ ] **2.6** Serve OCI: embed zot, load the bundled image, proxy `/v2/`.
|
|
135
|
+
- [ ] **2.7** Audit log — a **hash chain** (not a Merkle tree): `entry_hash = sha256(prev_hash || canonical(actor, action, target, detail, created_at))`, fixed genesis. Detects tampering of past rows. Known limitation stated in one sentence: a full-history rewrite by someone who controls the appliance is only caught if the genesis hash or a periodic anchor is recorded off-box. It is tamper-evident, not tamper-proof.
|
|
136
|
+
- [ ] **2.8** Revocation boundary (added, load-bearing). Revocation blocks future admission and serving from the appliance; it does not reach already-deployed inference hosts, which are re-verified on next materialize. Use the `deployments` table as the enforcement surface: a reconciliation check flags running deployments of a now-revoked version. Document this boundary explicitly — a CISO will find it otherwise.
|
|
137
|
+
- [ ] **2.9** Web UI (templates + HTMX): catalog, version detail, approval queue, audit view.
|
|
138
|
+
|
|
139
|
+
**Acceptance tests:**
|
|
140
|
+
- `test_ingest_rejects_bad_manifest` / `test_admission_rejects_bad_signature` / `test_separation_of_duties`.
|
|
141
|
+
- `test_pip_install_from_registry_offline` / `test_docker_pull_from_registry_offline`.
|
|
142
|
+
- `test_audit_chain_detects_tamper`.
|
|
143
|
+
- `test_revoked_deployment_flagged` — a running deployment of a revoked version is surfaced by reconciliation.
|
|
144
|
+
|
|
145
|
+
**Done when:** two versions ingested, approval enforced, an isolated host pulls wheels and image from the registry and serves, revocation reconciliation works, and the tests pass. Ships as 0.4.0 preview — the release you take to a design partner.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Phase 3 — Trust and compliance (folds into 0.5.0)
|
|
150
|
+
|
|
151
|
+
**Goal:** the appliance speaks auditor and CISO language and refuses non-compliant models.
|
|
152
|
+
|
|
153
|
+
- [ ] **3.1** Signing hardening: KMS/HSM-backed signer via the 0.2.0 `Signer` interface. cosign as the enterprise implementation.
|
|
154
|
+
- [ ] **3.2** in-toto: signed provenance statement (builder, modelferry source commit, model source and digest, scanner and policy results).
|
|
155
|
+
- [ ] **3.3** SBOM: `core/sbom.py` runs syft over the wheelhouse and image, injects model metadata, produces CycloneDX and SPDX. Verify current spec support for the ML fields when implementing — they are evolving.
|
|
156
|
+
- [ ] **3.4** License detection: read model-card metadata and LICENSE files, map to SPDX ids, classify permissive / copyleft / non-commercial / restrictive. Expect this to be heuristic and fiddly; model-card license metadata is wildly inconsistent.
|
|
157
|
+
- [ ] **3.5** CVE scanning: pip-audit or osv-scanner over `lock.txt`, trivy or grype over the image, aggregated into a signed `vuln-report.json`. Scanners run connected-side against a snapshotted vulnerability DB; only the signed report crosses the gap.
|
|
158
|
+
- [ ] **3.6** Policy engine: `core/policy.py` evaluates `policy.yaml` at two gates (pack and admission).
|
|
159
|
+
|
|
160
|
+
**Acceptance tests:** `test_policy_refuses_bad_license`, `test_policy_refuses_critical_cve`, `test_admission_reruns_policy`, `test_sbom_contains_model_and_deps`, `test_compliance_export_signed`.
|
|
161
|
+
|
|
162
|
+
**Sizing note:** this phase slips more than any other in a side-of-desk build. syft's CycloneDX ML extension is in flux and license classification is hand-tuned heuristics. Budget double the naive estimate.
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## Phase 4 — Delta and transport (folds into 0.5.0)
|
|
167
|
+
|
|
168
|
+
**Goal:** updates ship megabytes not gigabytes, and higher-security transports work. Respect the one-way constraint: only a small, **exact** (never probabilistic — a Bloom filter over a diode silently drops chunks) inventory crosses outward; the larger delta comes inward.
|
|
169
|
+
|
|
170
|
+
- [ ] **4.1** Exact inventory export: `GET /inventory/chunks`, sorted held-chunk hashes, varint-delta-encoded, zstd-compressed.
|
|
171
|
+
- [ ] **4.2** Delta pack: diff a new version's chunk set against an imported inventory, write a bundle of only missing chunks plus the full manifest.
|
|
172
|
+
- [ ] **4.3** Reconstruction: rebuild from held chunks plus delta, verify byte-identical against the manifest before `pending`.
|
|
173
|
+
- [ ] **4.4** Diode reconciliation manifest: completeness proof with no back-channel.
|
|
174
|
+
- [ ] **4.5** OCI export path for registry-to-registry ingest without USB.
|
|
175
|
+
- [ ] **4.6** Optional AES-256 media encryption (age or libsodium), documented key handling.
|
|
176
|
+
- [ ] **4.7** Resumable unpack via a receipt tracking applied chunks.
|
|
177
|
+
|
|
178
|
+
**Acceptance tests:** `test_delta_ships_only_missing_chunks`, `test_delta_reconstructs_byte_identical`, `test_delta_inventory_is_exact`, `test_resumable_unpack`. Record the size ratio — it is a YC application number.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Phase 5 — Appliance packaging and pilot hardening (ships as 1.0)
|
|
183
|
+
|
|
184
|
+
**Goal:** something a real security review passes, installed from media into a clean room.
|
|
185
|
+
|
|
186
|
+
- [ ] **5.1** Package the appliance itself as an offline-installable, signed modelferry bundle (dogfood).
|
|
187
|
+
- [ ] **5.2** Install, upgrade, backup, restore procedures with scripts.
|
|
188
|
+
- [ ] **5.3** Observability on the Celery workers (dogfood celerypeek).
|
|
189
|
+
- [ ] **5.4** Hardening: secrets, TLS, least-privilege, air-gap install verification.
|
|
190
|
+
- [ ] **5.5** Promote to 1.0.
|
|
191
|
+
|
|
192
|
+
**Acceptance tests:** `test_clean_room_offline_install`, `test_full_loop_e2e`.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Section 7 — Closure engine, in detail
|
|
197
|
+
|
|
198
|
+
The part that makes a bundle runnable and the part clones skip.
|
|
199
|
+
|
|
200
|
+
Wheelhouse build (connected side):
|
|
201
|
+
```bash
|
|
202
|
+
uv pip compile requirements.in -o lock.txt
|
|
203
|
+
pip download -r lock.txt -d payload/wheelhouse \
|
|
204
|
+
--only-binary=:all: --platform manylinux_2_28_x86_64 \
|
|
205
|
+
--python-version 311 --implementation cp
|
|
206
|
+
pip download torch --index-url https://download.pytorch.org/whl/cu124 \
|
|
207
|
+
--only-binary=:all: --platform manylinux_2_28_x86_64 \
|
|
208
|
+
--python-version 311 --implementation cp -d payload/wheelhouse
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Inside the gap:
|
|
212
|
+
```bash
|
|
213
|
+
pip install --no-index --find-links payload/wheelhouse -r payload/lock.txt
|
|
214
|
+
docker load -i payload/images/vllm.tar
|
|
215
|
+
vllm serve /path/to/materialized/model --host 0.0.0.0 --port 8000
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Gotchas, each with a test: sdist-only / build-required deps (flash-attn is the classic; prefer the image path and detect the gap at pack time), sharded safetensors (hash every shard and the index), custom-code models (`trust_remote_code`; fetch the `.py`, flag in policy), gated repos (HF token, restrictive license into classification), quantized/alternate formats (AWQ/GPTQ and GGUF, since buyers do not all run vLLM), FAT32 (chunk parts ≤ 4GiB — the 4MiB CAS chunk and the 4GiB FAT32 part-file cap are two different numbers; do not conflate them).
|
|
219
|
+
|
|
220
|
+
Documented limitation: modelferry bundles the CUDA userspace (torch wheels, server image), not the kernel driver. The gap host must have a compatible driver. `compat.json` plus preflight plus the 1.9 fallback bundle turn this into an early, clear failure and a documented workaround, not a runtime crash.
|
|
221
|
+
|
|
222
|
+
## Section 8 — Trust, signing, SBOM, policy
|
|
223
|
+
|
|
224
|
+
Keyless Sigstore needs a live CA and transparency log, so it cannot be the inside-gap verification path. Key-based signing, verified offline against a bundled trust root. minisign for v1, `Signer` interface designed for KMS/HSM cosign later. If a customer wants Sigstore bundles, produce them connected-side and verify offline with a bundled trust root; never depend on Rekor reachability inside the gap.
|
|
225
|
+
|
|
226
|
+
Policy schema (`policy.yaml`):
|
|
227
|
+
```yaml
|
|
228
|
+
version: 1
|
|
229
|
+
require_signature: true
|
|
230
|
+
allowed_licenses: [Apache-2.0, MIT, BSD-3-Clause, "class:permissive"]
|
|
231
|
+
denied_licenses: ["class:non-commercial"]
|
|
232
|
+
flag_remote_code: true
|
|
233
|
+
max_cve_severity: high
|
|
234
|
+
allowed_sources: [huggingface.co]
|
|
235
|
+
```
|
|
236
|
+
Evaluator returns `{passed, violations}`, runs at pack time (fail fast) and admission (enforcement of record).
|
|
237
|
+
|
|
238
|
+
## Section 10 — The air-gap test harness
|
|
239
|
+
|
|
240
|
+
Every inside-gap step runs inside `docker run --network none` with the bundle mounted read-only. Any accidental network call fails the test. Add an explicit egress probe the tests call to assert a socket is refused. Two modes: a CPU Python container for wheel reconstruction and a tiny model load, and the actual server image for the offline serve proof (GPU runner).
|
|
241
|
+
|
|
242
|
+
## Section 11 — End-to-end acceptance matrix
|
|
243
|
+
|
|
244
|
+
Positive: (1) pack Qwen2.5-14B, FAT32 transfer, admit, serve in `--network none`. (2) isolated client installs wheels via the registry index and pulls the image. (3) small-change delta transfers a fraction and reconstructs byte-identically. (4) compliance report verifies against the appliance key.
|
|
245
|
+
|
|
246
|
+
Negative, each failing in the right way: (5) flipped payload byte → integrity exit code. (6) tampered `manifest.json` → signature verification fails, admission refuses. (7) unsigned or wrong-key bundle → never approved. (8) missing wheel → flagged at pack time. (9) non-commercial model under permissive-only policy → refused at pack gate. (10) fabricated low-driver compat.json → preflight fails. (11) corrupt delta chunk → byte-identical check fails. (12) edited audit row → chain verify false. (13) any inside-gap network call → harness fails it.
|
|
247
|
+
|
|
248
|
+
## Section 12 — The design-partner track (as important as the code)
|
|
249
|
+
|
|
250
|
+
The plan's value as a YC application depends entirely on turning one real disconnected environment into a named design partner who will say "yes, we need this." The most valuable asset here is access to a real air-gapped government environment with a real model need. Have that conversation during Phase 0–1, not deferred to Phase 5. See the conversation script kept alongside this plan. Spend as much energy on that one conversation as on any single phase.
|
|
251
|
+
|
|
252
|
+
## Section 13 — What to cut for v1
|
|
253
|
+
|
|
254
|
+
No Kubernetes operator (docker-compose is enough). No multi-node HA or web-scale multi-tenancy. No React UI until a design partner needs it (templates, HTMX, Django admin). No OPA/Rego (the YAML evaluator suffices). No keyless Sigstore.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to modelferry are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.2.0] - 2026-07-22
|
|
10
|
+
|
|
11
|
+
Signing. A bundle's manifest can now be signed, and its authenticity checked
|
|
12
|
+
against a trusted key, without adding anything to the bare-host integrity
|
|
13
|
+
verifier. Signing is additive: unsigned bundles pack and verify exactly as before.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `modelferry pack --sign`: produce a signed bundle. The manifest gains a
|
|
18
|
+
`signing` block (algorithm, key id, signature filename), and a detached ed25519
|
|
19
|
+
signature over the exact `manifest.json` bytes is written to `manifest.json.sig`.
|
|
20
|
+
The signing key is read from the `MODELFERRY_SIGNING_KEY` environment variable
|
|
21
|
+
(a path), never a flag and never written into any bundle, manifest, sidecar, or
|
|
22
|
+
log. `--sign` without a key configured is a usage error, never a silent unsigned
|
|
23
|
+
pack, and pack self-verifies the signature it just wrote before the bundle ships.
|
|
24
|
+
- `modelferry verify-signature BUNDLE_DIR --public-key PATH`: check a bundle's
|
|
25
|
+
authenticity against a trusted public key. Reports VALID (exit 0), or UNSIGNED /
|
|
26
|
+
BAD_SIGNATURE / KEY_MISMATCH / MISSING_SIG (exit 1) or MALFORMED (exit 2). It is
|
|
27
|
+
connected-side / appliance-side, holds the trusted key, and is never copied into
|
|
28
|
+
a bundle. The trusted key comes from `--public-key` or `MODELFERRY_PUBLIC_KEY`.
|
|
29
|
+
- `manifest.json` schema 2: an optional top-level `signing` block. Version 2 is a
|
|
30
|
+
strict superset of version 1. An unsigned bundle is a version-2 manifest with no
|
|
31
|
+
signing block, byte-for-byte what version 1 was plus the bumped version number.
|
|
32
|
+
- `MANIFEST.md` gains a Signature section for signed bundles, telling the reviewer
|
|
33
|
+
how to check authenticity with `verify-signature` and stating that it augments,
|
|
34
|
+
not replaces, the approved-copy checksum comparison.
|
|
35
|
+
- `src/modelferry/signing.py` and `src/modelferry/verify_signature.py`: the
|
|
36
|
+
connected-side signer and signature verifier, ed25519 via PyNaCl.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `src/modelferry/offline.py` accepts manifest schema 1 and 2 for integrity. That
|
|
41
|
+
is its only change: it still imports no crypto, never verifies a signature, and
|
|
42
|
+
ignores the signing block (which is not under `payload.files`, so the integrity
|
|
43
|
+
hashing never touches it). The bundled verifier stays standard-library only and
|
|
44
|
+
runs on CPython 3.9. Its canonical sha256 therefore differs from 0.1.0; the new
|
|
45
|
+
hash is published in these release notes.
|
|
46
|
+
- Runtime dependency added: `pynacl`, for connected-side signing only. It never
|
|
47
|
+
enters `offline.py`.
|
|
48
|
+
|
|
49
|
+
### Security
|
|
50
|
+
|
|
51
|
+
- The trust model, stated in the README and SPEC section 9: integrity (the arrived
|
|
52
|
+
bytes match the manifest) is checked on the bare disconnected host by the bundled
|
|
53
|
+
`offline.py`, with no network and no key. Authenticity (the manifest was signed
|
|
54
|
+
by a trusted key) is checked by `verify-signature` on the connected / approval
|
|
55
|
+
side, where the key lives. A bundle an attacker rebuilt from scratch passes its
|
|
56
|
+
own integrity check but fails signature verification against the real key.
|
|
57
|
+
- modelferry has no signing key of its own. `pack --sign` signs a user's bundles
|
|
58
|
+
with the user's key; the trusted public key is distributed out-of-band by the
|
|
59
|
+
approval authority for the receiving environment, not by modelferry. The `key_id`
|
|
60
|
+
in the manifest tells a verifier which key a bundle claims; trust in that key
|
|
61
|
+
comes from the operator's out-of-band distribution. This release publishes the
|
|
62
|
+
canonical `offline.py` verifier hash, as every release does, and no signing key.
|
|
63
|
+
|
|
64
|
+
## [0.1.0] - 2026-07-16
|
|
65
|
+
|
|
66
|
+
First release.
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- `modelferry pack REPO_ID --dest DIR`: download a Hugging Face model repo pinned
|
|
71
|
+
to a commit, split oversized files into `.mfpartNNNN` parts, hash everything
|
|
72
|
+
with sha256, and write a bundle with `manifest.json`, `MANIFEST.md`, the
|
|
73
|
+
payload, and a copy of the offline verifier.
|
|
74
|
+
- `modelferry verify`, `unpack`, and `inspect`: thin wrappers over the exact
|
|
75
|
+
standard-library verifier that ships inside every bundle, so the installed CLI
|
|
76
|
+
and the bundled tool behave identically.
|
|
77
|
+
- `src/modelferry/offline.py`: the self-contained verifier and unpacker. Standard
|
|
78
|
+
library only, runs on CPython 3.9, no network, no third-party packages. Copied
|
|
79
|
+
verbatim into every bundle at `tools/modelferry_offline.py`.
|
|
80
|
+
- `manifest.json` (version 1): deterministic, `sort_keys` serialization with
|
|
81
|
+
whole-file and per-part sha256 hashes, source repo and resolved commit SHA,
|
|
82
|
+
license, gated flag, and the verifier hash.
|
|
83
|
+
- `MANIFEST.md`: human-readable approval document for security review.
|
|
84
|
+
- Chunking sized for FAT32 by default (`--chunk-size 3900M`), with `--include` /
|
|
85
|
+
`--exclude` fnmatch filtering and resumable downloads via `--staging`.
|
|
86
|
+
- Streamed IO with a fixed 8 MiB buffer throughout, so bundles larger than RAM
|
|
87
|
+
pack and verify with flat memory use.
|
|
88
|
+
- Zip-slip-safe unpack: absolute paths, `..` segments, and anything resolving
|
|
89
|
+
outside the destination are rejected, and symlinks are never created.
|
|
90
|
+
- Post-pack read-back self-verify, so disk-level write errors surface before the
|
|
91
|
+
bundle leaves the connected side.
|
|
92
|
+
- Exit codes per SPEC section 10 (0 success, 1 integrity, 2 usage, 3 source,
|
|
93
|
+
4 filesystem).
|
|
94
|
+
|
|
95
|
+
### Security
|
|
96
|
+
|
|
97
|
+
- `HF_TOKEN` is read from the environment only and is never written into a
|
|
98
|
+
bundle, manifest, receipt, or log. A test packs with a fake token and asserts
|
|
99
|
+
the token bytes appear nowhere in the output.
|
|
100
|
+
- The trust model is documented in the README. v1 covers accidental corruption,
|
|
101
|
+
incomplete transfers, media errors, and casual tampering. It does not cover a
|
|
102
|
+
coordinated adversary who rewrites payload, manifest, and verifier together;
|
|
103
|
+
that needs out-of-band signature verification, planned for v1.1. Each release
|
|
104
|
+
publishes the canonical `offline.py` sha256 so a receiving site can check the
|
|
105
|
+
bundled verifier out-of-band.
|
|
106
|
+
|
|
107
|
+
[Unreleased]: https://github.com/HamzaAhmedWajeeh/modelferry/compare/v0.2.0...HEAD
|
|
108
|
+
[0.2.0]: https://github.com/HamzaAhmedWajeeh/modelferry/releases/tag/v0.2.0
|
|
109
|
+
[0.1.0]: https://github.com/HamzaAhmedWajeeh/modelferry/releases/tag/v0.1.0
|
|
@@ -18,7 +18,7 @@ CLI that packs Hugging Face models into chunked, sha256-manifested bundles for t
|
|
|
18
18
|
- All payload IO is streamed with a fixed 8 MiB buffer. Never read a payload file fully into memory.
|
|
19
19
|
- Unpack rejects absolute paths, `..` segments, and anything resolving outside the destination. Never create symlinks.
|
|
20
20
|
- Exit codes exactly per SPEC.md §10. Do not invent new ones.
|
|
21
|
-
- Runtime dependencies are typer, rich, huggingface_hub. Do not add
|
|
21
|
+
- Runtime dependencies are typer, rich, huggingface_hub, and pynacl (added in 0.2.0 for connected-side signing in signing.py; it must never enter offline.py). Do not add another without asking.
|
|
22
22
|
- Never weaken a corruption, path-safety, or token-leak test to make it pass. Fix the code instead.
|
|
23
23
|
- Every bugfix ships with a regression test.
|
|
24
24
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: modelferry
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Pack Hugging Face models into chunked, hashed, self-verifying bundles for air-gapped transfer.
|
|
5
5
|
Project-URL: Homepage, https://github.com/HamzaAhmedWajeeh/modelferry
|
|
6
6
|
Project-URL: Repository, https://github.com/HamzaAhmedWajeeh/modelferry
|
|
@@ -21,6 +21,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
21
21
|
Classifier: Topic :: System :: Archiving :: Packaging
|
|
22
22
|
Requires-Python: >=3.10
|
|
23
23
|
Requires-Dist: huggingface-hub<2,>=1.0.0
|
|
24
|
+
Requires-Dist: pynacl>=1.5.0
|
|
24
25
|
Requires-Dist: rich>=13
|
|
25
26
|
Requires-Dist: typer>=0.24.0
|
|
26
27
|
Description-Content-Type: text/markdown
|
|
@@ -176,23 +177,49 @@ flagged at the top of the document.
|
|
|
176
177
|
|
|
177
178
|
## Trust model
|
|
178
179
|
|
|
179
|
-
Stated honestly, because this is the part a security review turns on.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
`manifest.json`
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
Stated honestly, because this is the part a security review turns on. modelferry
|
|
181
|
+
checks two different things with two different tools.
|
|
182
|
+
|
|
183
|
+
Integrity is whether the bytes that arrived match the manifest. It protects
|
|
184
|
+
against accidental corruption, incomplete transfers, media errors, and casual
|
|
185
|
+
tampering with payload files. Any of those shows up as a hash mismatch, a missing
|
|
186
|
+
part, or an extra file, and verify exits non-zero and names what failed. Integrity
|
|
187
|
+
is checked by the bundled `offline.py`, standard library only, on the bare
|
|
188
|
+
disconnected host with no network and no key.
|
|
189
|
+
|
|
190
|
+
Authenticity is whether the manifest was signed by a key you trust. When a bundle
|
|
191
|
+
is signed (`pack --sign`) and the verifier has the trusted public key, authenticity
|
|
192
|
+
is checkable: `modelferry verify-signature` checks the detached signature over
|
|
193
|
+
`manifest.json` against that key. A bundle an attacker rebuilt from scratch, with
|
|
194
|
+
its own internally consistent payload, manifest, and hashes, would pass its own
|
|
195
|
+
integrity check but fail signature verification against the real key, because the
|
|
196
|
+
attacker cannot produce that key's signature.
|
|
197
|
+
|
|
198
|
+
The boundary, stated precisely so it isn't oversold: the bare-host integrity
|
|
199
|
+
verifier does not check the signature. Signature verification is a separate
|
|
200
|
+
connected-side tool that holds the trusted key, and it is not copied into the
|
|
201
|
+
bundle. So on a truly bare air-gap host with only system Python and no trusted key
|
|
202
|
+
on hand, you get integrity, and authenticity is established upstream, at the
|
|
203
|
+
approval or admission step where the key lives. Signing protects that approval
|
|
204
|
+
chain. The trusted public key has to reach verifiers out-of-band, and it is the
|
|
205
|
+
approval authority for the receiving environment that distributes it, the same
|
|
206
|
+
people who already approve the manifest checksum. modelferry has no signing key of
|
|
207
|
+
its own and does not distribute keys: `pack --sign` signs your bundles with your
|
|
208
|
+
key, for your environment. The `key_id` in the manifest lets a verifier confirm
|
|
209
|
+
which key a bundle claims to be signed by, but trust in that key comes from the
|
|
210
|
+
operator's out-of-band distribution, not from modelferry. This is key-based
|
|
211
|
+
signing verified against a known key, not end-to-end authenticity on a bare host
|
|
212
|
+
that has no key.
|
|
213
|
+
|
|
214
|
+
Signing is additive. An unsigned bundle still packs and still verifies for
|
|
215
|
+
integrity; it just carries no authenticity claim, which verify-signature reports
|
|
216
|
+
as UNSIGNED rather than as valid.
|
|
217
|
+
|
|
218
|
+
The verifier itself is also checkable out-of-band. `manifest.json` records the
|
|
219
|
+
sha256 of the bundled `offline.py`, and every release publishes the canonical
|
|
220
|
+
`offline.py` hash in its release notes. A receiving site can check the bundled
|
|
221
|
+
verifier against that published hash before trusting it, or ignore the bundled
|
|
222
|
+
copy and bring its own known-good verifier.
|
|
196
223
|
|
|
197
224
|
## Exit codes
|
|
198
225
|
|
|
@@ -149,23 +149,49 @@ flagged at the top of the document.
|
|
|
149
149
|
|
|
150
150
|
## Trust model
|
|
151
151
|
|
|
152
|
-
Stated honestly, because this is the part a security review turns on.
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
`manifest.json`
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
152
|
+
Stated honestly, because this is the part a security review turns on. modelferry
|
|
153
|
+
checks two different things with two different tools.
|
|
154
|
+
|
|
155
|
+
Integrity is whether the bytes that arrived match the manifest. It protects
|
|
156
|
+
against accidental corruption, incomplete transfers, media errors, and casual
|
|
157
|
+
tampering with payload files. Any of those shows up as a hash mismatch, a missing
|
|
158
|
+
part, or an extra file, and verify exits non-zero and names what failed. Integrity
|
|
159
|
+
is checked by the bundled `offline.py`, standard library only, on the bare
|
|
160
|
+
disconnected host with no network and no key.
|
|
161
|
+
|
|
162
|
+
Authenticity is whether the manifest was signed by a key you trust. When a bundle
|
|
163
|
+
is signed (`pack --sign`) and the verifier has the trusted public key, authenticity
|
|
164
|
+
is checkable: `modelferry verify-signature` checks the detached signature over
|
|
165
|
+
`manifest.json` against that key. A bundle an attacker rebuilt from scratch, with
|
|
166
|
+
its own internally consistent payload, manifest, and hashes, would pass its own
|
|
167
|
+
integrity check but fail signature verification against the real key, because the
|
|
168
|
+
attacker cannot produce that key's signature.
|
|
169
|
+
|
|
170
|
+
The boundary, stated precisely so it isn't oversold: the bare-host integrity
|
|
171
|
+
verifier does not check the signature. Signature verification is a separate
|
|
172
|
+
connected-side tool that holds the trusted key, and it is not copied into the
|
|
173
|
+
bundle. So on a truly bare air-gap host with only system Python and no trusted key
|
|
174
|
+
on hand, you get integrity, and authenticity is established upstream, at the
|
|
175
|
+
approval or admission step where the key lives. Signing protects that approval
|
|
176
|
+
chain. The trusted public key has to reach verifiers out-of-band, and it is the
|
|
177
|
+
approval authority for the receiving environment that distributes it, the same
|
|
178
|
+
people who already approve the manifest checksum. modelferry has no signing key of
|
|
179
|
+
its own and does not distribute keys: `pack --sign` signs your bundles with your
|
|
180
|
+
key, for your environment. The `key_id` in the manifest lets a verifier confirm
|
|
181
|
+
which key a bundle claims to be signed by, but trust in that key comes from the
|
|
182
|
+
operator's out-of-band distribution, not from modelferry. This is key-based
|
|
183
|
+
signing verified against a known key, not end-to-end authenticity on a bare host
|
|
184
|
+
that has no key.
|
|
185
|
+
|
|
186
|
+
Signing is additive. An unsigned bundle still packs and still verifies for
|
|
187
|
+
integrity; it just carries no authenticity claim, which verify-signature reports
|
|
188
|
+
as UNSIGNED rather than as valid.
|
|
189
|
+
|
|
190
|
+
The verifier itself is also checkable out-of-band. `manifest.json` records the
|
|
191
|
+
sha256 of the bundled `offline.py`, and every release publishes the canonical
|
|
192
|
+
`offline.py` hash in its release notes. A receiving site can check the bundled
|
|
193
|
+
verifier against that published hash before trusting it, or ignore the bundled
|
|
194
|
+
copy and bring its own known-good verifier.
|
|
169
195
|
|
|
170
196
|
## Exit codes
|
|
171
197
|
|