legion-lock 0.1.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.
- legion_lock-0.1.0/LICENSE +19 -0
- legion_lock-0.1.0/PKG-INFO +84 -0
- legion_lock-0.1.0/README.md +67 -0
- legion_lock-0.1.0/legion_lock/__init__.py +284 -0
- legion_lock-0.1.0/legion_lock/vendor/legion_tlog_verify.py +1151 -0
- legion_lock-0.1.0/legion_lock/vendor/legion_verify_mirror.py +717 -0
- legion_lock-0.1.0/legion_lock/verify_server.py +295 -0
- legion_lock-0.1.0/legion_lock.egg-info/PKG-INFO +84 -0
- legion_lock-0.1.0/legion_lock.egg-info/SOURCES.txt +12 -0
- legion_lock-0.1.0/legion_lock.egg-info/dependency_links.txt +1 -0
- legion_lock-0.1.0/legion_lock.egg-info/entry_points.txt +3 -0
- legion_lock-0.1.0/legion_lock.egg-info/top_level.txt +1 -0
- legion_lock-0.1.0/pyproject.toml +28 -0
- legion_lock-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2026 Legion33shadow
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
14
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
15
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
|
16
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
17
|
+
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
18
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
|
19
|
+
OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: legion-lock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pin MCP server tool lists in a lockfile; fail CI on tool drift, with signed evidence from the LEGION transparency log
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Source, https://github.com/Legion33shadow/legion-lock
|
|
7
|
+
Project-URL: Lockfile format, https://github.com/Legion33shadow/legion-lock/blob/main/FORMAT.md
|
|
8
|
+
Keywords: mcp,model-context-protocol,lockfile,tool-pinning,tool-drift,transparency-log,supply-chain,ed25519,rfc9162
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Topic :: Security
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Requires-Python: >=3.8
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Dynamic: license-file
|
|
17
|
+
|
|
18
|
+
# legion-lock — pin MCP server tools in a lockfile, fail CI on tool drift
|
|
19
|
+
|
|
20
|
+
An MCP server can change a tool's description or input schema after you approved it, and a model reads those
|
|
21
|
+
descriptions as instructions (tool drift, "rug pull"). `legion-lock` pins the approved tool list of each MCP server in
|
|
22
|
+
`.mcp-tools.lock.json`. `check` fails as soon as a server changed, and names the change: tool added, removed,
|
|
23
|
+
description changed, schema paths changed.
|
|
24
|
+
|
|
25
|
+
Fingerprints come from the public LEGION transparency log of MCP tool lists (`https://tlog.legion-api.com/tlog/v1`).
|
|
26
|
+
Each one is signed (Ed25519), included in an append-only Merkle tree (RFC 9162) and checked against a root key
|
|
27
|
+
fingerprint you pin. The lockfile keeps the proofs: `verify` re-checks them offline.
|
|
28
|
+
|
|
29
|
+
[Version française](README.fr.md)
|
|
30
|
+
|
|
31
|
+
## Pin in 3 commands
|
|
32
|
+
```bash
|
|
33
|
+
python3 legion_lock.py add io.guestgraph/mental-model # pin the current state (verified proof) in .mcp-tools.lock.json
|
|
34
|
+
python3 legion_lock.py check # 0 = matches · 1 = DRIFT (per-tool diff) · 2 = invalid proof
|
|
35
|
+
python3 legion_lock.py verify # offline: re-check the proofs stored in the lockfile
|
|
36
|
+
```
|
|
37
|
+
The id is the one of the official MCP registry. To approve an earlier version: `add ID --at-leaf N`. To prove it
|
|
38
|
+
against a specific signed head: `--at-head S`.
|
|
39
|
+
|
|
40
|
+
Requirements: Python 3.8+, no dependency (`cryptography` is used if installed, otherwise pure-Python Ed25519).
|
|
41
|
+
Files: `legion_lock.py`, plus `vendor/legion_tlog_verify.py` and `vendor/legion_verify_mirror.py`, LEGION's two public
|
|
42
|
+
verifiers.
|
|
43
|
+
|
|
44
|
+
## Check one live server in one command: `legion_verify_server.py`
|
|
45
|
+
```bash
|
|
46
|
+
python3 legion_verify_server.py https://mcp.example.com/mcp --save proof.json # live tools/list vs the signed history
|
|
47
|
+
python3 legion_verify_server.py --offline proof.json # re-check the saved proof, no network
|
|
48
|
+
```
|
|
49
|
+
Verdict: `UNCHANGED` (live list = latest signed version) · `CHANGED` (per-tool diff) · `UNKNOWN` (never signed by the
|
|
50
|
+
log) · `AUTH` (server requires auth). It only sends `initialize` and `tools/list`, never calls a tool. No score.
|
|
51
|
+
|
|
52
|
+
## Demo (real drift, public log)
|
|
53
|
+
`demo/demo_drift.sh` pins two servers to a state BEFORE a change they really made, then runs `check`:
|
|
54
|
+
`io.guestgraph/mental-model` (tool `diagram`: description and 2 schema paths changed, leaf 16044 → 17659) and
|
|
55
|
+
`ai.daystruct.api/daystruct` (tool `plan_project_development` added, leaf 121 → 14516). Expected: exit 1 with the drift
|
|
56
|
+
named; offline `verify` = 0; hand-edited lockfile = 2. Transcript: `demo/transcript_drift_*.txt`.
|
|
57
|
+
Adversarial tests (lying log, forged archive, rewritten history, forged lockfile): `tests/test_adversarial.py`, 15/15.
|
|
58
|
+
|
|
59
|
+
## What `check` verifies, in order
|
|
60
|
+
1. The log keyring starts from the pinned root fingerprint `a7c6a35b…`.
|
|
61
|
+
2. Your pin and the current state are both signed records, included in signed heads (RFC 9162).
|
|
62
|
+
3. The current head extends the head of your pin (consistency proof). Otherwise: rewritten history, fork or rollback,
|
|
63
|
+
exit 2.
|
|
64
|
+
4. Is the current fingerprint the approved one? If not, `check` fetches the archived definitions of both versions,
|
|
65
|
+
accepts them only if they hash back to the signed fingerprints, then names the drift field by field.
|
|
66
|
+
|
|
67
|
+
## Limits (read them)
|
|
68
|
+
- This is **the signed observation of a single observer** (LEGION). It does not show what the server serves to other
|
|
69
|
+
clients.
|
|
70
|
+
- Only `name`, `description` and `inputSchema` are fingerprinted. A change limited to `annotations`, `outputSchema` or
|
|
71
|
+
`title` is not seen.
|
|
72
|
+
- Delay: a change appears only after LEGION's next observation, then the next signed head (one to three signed cycles a
|
|
73
|
+
day, measured 9–11 Oct 2026). `check --strict` also fails when a newer observation is not signed yet.
|
|
74
|
+
- Servers that require authentication are not observed.
|
|
75
|
+
- The log's Bitcoin anchors are `pending` (no `ots verify` on a full node). `legion-lock` does not rely on them.
|
|
76
|
+
|
|
77
|
+
## Integrations (`integrations/`)
|
|
78
|
+
- `github/mcp-tools-lock.yml`: on every pull request and daily, `verify` then `check`. The job fails on drift.
|
|
79
|
+
- `pre-commit/pre-commit.sh` (raw git hook).
|
|
80
|
+
- `claude-code/settings.json` + `legion_lock_hook.sh`: `PreToolUse` hook on `mcp__.*`. Claude Code blocks the MCP tool
|
|
81
|
+
call if a pinned server drifted. Result cached 10 min.
|
|
82
|
+
|
|
83
|
+
Lockfile format: [FORMAT.md](FORMAT.md). Read-only MCP server on the same log:
|
|
84
|
+
[legion-mcp](https://github.com/Legion33shadow/legion-mcp). License: MIT.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# legion-lock — pin MCP server tools in a lockfile, fail CI on tool drift
|
|
2
|
+
|
|
3
|
+
An MCP server can change a tool's description or input schema after you approved it, and a model reads those
|
|
4
|
+
descriptions as instructions (tool drift, "rug pull"). `legion-lock` pins the approved tool list of each MCP server in
|
|
5
|
+
`.mcp-tools.lock.json`. `check` fails as soon as a server changed, and names the change: tool added, removed,
|
|
6
|
+
description changed, schema paths changed.
|
|
7
|
+
|
|
8
|
+
Fingerprints come from the public LEGION transparency log of MCP tool lists (`https://tlog.legion-api.com/tlog/v1`).
|
|
9
|
+
Each one is signed (Ed25519), included in an append-only Merkle tree (RFC 9162) and checked against a root key
|
|
10
|
+
fingerprint you pin. The lockfile keeps the proofs: `verify` re-checks them offline.
|
|
11
|
+
|
|
12
|
+
[Version française](README.fr.md)
|
|
13
|
+
|
|
14
|
+
## Pin in 3 commands
|
|
15
|
+
```bash
|
|
16
|
+
python3 legion_lock.py add io.guestgraph/mental-model # pin the current state (verified proof) in .mcp-tools.lock.json
|
|
17
|
+
python3 legion_lock.py check # 0 = matches · 1 = DRIFT (per-tool diff) · 2 = invalid proof
|
|
18
|
+
python3 legion_lock.py verify # offline: re-check the proofs stored in the lockfile
|
|
19
|
+
```
|
|
20
|
+
The id is the one of the official MCP registry. To approve an earlier version: `add ID --at-leaf N`. To prove it
|
|
21
|
+
against a specific signed head: `--at-head S`.
|
|
22
|
+
|
|
23
|
+
Requirements: Python 3.8+, no dependency (`cryptography` is used if installed, otherwise pure-Python Ed25519).
|
|
24
|
+
Files: `legion_lock.py`, plus `vendor/legion_tlog_verify.py` and `vendor/legion_verify_mirror.py`, LEGION's two public
|
|
25
|
+
verifiers.
|
|
26
|
+
|
|
27
|
+
## Check one live server in one command: `legion_verify_server.py`
|
|
28
|
+
```bash
|
|
29
|
+
python3 legion_verify_server.py https://mcp.example.com/mcp --save proof.json # live tools/list vs the signed history
|
|
30
|
+
python3 legion_verify_server.py --offline proof.json # re-check the saved proof, no network
|
|
31
|
+
```
|
|
32
|
+
Verdict: `UNCHANGED` (live list = latest signed version) · `CHANGED` (per-tool diff) · `UNKNOWN` (never signed by the
|
|
33
|
+
log) · `AUTH` (server requires auth). It only sends `initialize` and `tools/list`, never calls a tool. No score.
|
|
34
|
+
|
|
35
|
+
## Demo (real drift, public log)
|
|
36
|
+
`demo/demo_drift.sh` pins two servers to a state BEFORE a change they really made, then runs `check`:
|
|
37
|
+
`io.guestgraph/mental-model` (tool `diagram`: description and 2 schema paths changed, leaf 16044 → 17659) and
|
|
38
|
+
`ai.daystruct.api/daystruct` (tool `plan_project_development` added, leaf 121 → 14516). Expected: exit 1 with the drift
|
|
39
|
+
named; offline `verify` = 0; hand-edited lockfile = 2. Transcript: `demo/transcript_drift_*.txt`.
|
|
40
|
+
Adversarial tests (lying log, forged archive, rewritten history, forged lockfile): `tests/test_adversarial.py`, 15/15.
|
|
41
|
+
|
|
42
|
+
## What `check` verifies, in order
|
|
43
|
+
1. The log keyring starts from the pinned root fingerprint `a7c6a35b…`.
|
|
44
|
+
2. Your pin and the current state are both signed records, included in signed heads (RFC 9162).
|
|
45
|
+
3. The current head extends the head of your pin (consistency proof). Otherwise: rewritten history, fork or rollback,
|
|
46
|
+
exit 2.
|
|
47
|
+
4. Is the current fingerprint the approved one? If not, `check` fetches the archived definitions of both versions,
|
|
48
|
+
accepts them only if they hash back to the signed fingerprints, then names the drift field by field.
|
|
49
|
+
|
|
50
|
+
## Limits (read them)
|
|
51
|
+
- This is **the signed observation of a single observer** (LEGION). It does not show what the server serves to other
|
|
52
|
+
clients.
|
|
53
|
+
- Only `name`, `description` and `inputSchema` are fingerprinted. A change limited to `annotations`, `outputSchema` or
|
|
54
|
+
`title` is not seen.
|
|
55
|
+
- Delay: a change appears only after LEGION's next observation, then the next signed head (one to three signed cycles a
|
|
56
|
+
day, measured 9–11 Oct 2026). `check --strict` also fails when a newer observation is not signed yet.
|
|
57
|
+
- Servers that require authentication are not observed.
|
|
58
|
+
- The log's Bitcoin anchors are `pending` (no `ots verify` on a full node). `legion-lock` does not rely on them.
|
|
59
|
+
|
|
60
|
+
## Integrations (`integrations/`)
|
|
61
|
+
- `github/mcp-tools-lock.yml`: on every pull request and daily, `verify` then `check`. The job fails on drift.
|
|
62
|
+
- `pre-commit/pre-commit.sh` (raw git hook).
|
|
63
|
+
- `claude-code/settings.json` + `legion_lock_hook.sh`: `PreToolUse` hook on `mcp__.*`. Claude Code blocks the MCP tool
|
|
64
|
+
call if a pinned server drifted. Result cached 10 min.
|
|
65
|
+
|
|
66
|
+
Lockfile format: [FORMAT.md](FORMAT.md). Read-only MCP server on the same log:
|
|
67
|
+
[legion-mcp](https://github.com/Legion33shadow/legion-mcp). License: MIT.
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""legion-lock — épingle les outils APPROUVÉS de serveurs MCP et détecte toute dérive, preuves signées à l'appui.
|
|
3
|
+
|
|
4
|
+
legion_lock.py add SERVER_ID [--at-leaf N] [--at-head S] épingle l'état observé (le plus récent, ou la feuille N),
|
|
5
|
+
preuve contre la tête signée courante (ou celle de taille S)
|
|
6
|
+
legion_lock.py check [--strict] [--json] compare au journal public ; code 1 si dérive, avec le diff par outil
|
|
7
|
+
legion_lock.py verify HORS LIGNE : revérifie les preuves stockées dans le lockfile
|
|
8
|
+
legion_lock.py show affiche ce qui est épinglé
|
|
9
|
+
|
|
10
|
+
Source : journal de transparence LEGION (https://tlog.legion-api.com/tlog/v1). Ancre de confiance : empreinte de la clé
|
|
11
|
+
racine, épinglée ci-dessous (à recouper hors bande). Python 3.8+, bibliothèque standard ; `cryptography` utilisé s'il est
|
|
12
|
+
présent, sinon Ed25519 en Python pur (vendor/legion_verify_mirror.py, vecteurs RFC 8032 testés au chargement).
|
|
13
|
+
Codes de sortie : 0 = conforme ; 1 = dérive ; 2 = preuve invalide, réseau ou usage.
|
|
14
|
+
Ce que ça prouve : ce que LEGION a observé et signé pour ce serveur (l'observation d'UN observateur), et que l'historique
|
|
15
|
+
n'a pas été réécrit depuis votre épinglage. Ce que ça ne prouve pas : ce que le serveur montre à d'autres clients."""
|
|
16
|
+
import argparse, base64, datetime, difflib, hashlib, json, os, sys, urllib.error, urllib.parse, urllib.request
|
|
17
|
+
|
|
18
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
19
|
+
sys.path.insert(0, os.path.join(HERE, "vendor"))
|
|
20
|
+
import legion_tlog_verify as T # noqa: E402 (vérifieur public, 369/369 vecteurs officiels)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import cryptography # noqa: F401
|
|
24
|
+
except ImportError: # pas de dépendance obligatoire : Ed25519 pur Python
|
|
25
|
+
import legion_verify_mirror as _V # noqa: E402
|
|
26
|
+
assert _V.ed_selftest() is not False
|
|
27
|
+
T.ed25519_ok = lambda pub, sig, msg: _V.ed_verify_pure(pub, sig, msg)
|
|
28
|
+
|
|
29
|
+
FORMAT = "mcp-tools-lock/0.1"
|
|
30
|
+
DEFAULT_LOCK = ".mcp-tools.lock.json"
|
|
31
|
+
BASE = os.environ.get("LEGION_LOCK_BASE", "https://tlog.legion-api.com/tlog/v1").rstrip("/")
|
|
32
|
+
ROOT_PIN = "a7c6a35b1c2c81d589a30c2ad628679bdea9da4139d49aa3c42cdde6e1c65036"
|
|
33
|
+
FIELDS = ("name", "description", "inputSchema")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Fail(Exception):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get(path, raw=False):
|
|
41
|
+
url = BASE + path
|
|
42
|
+
for attempt in range(3):
|
|
43
|
+
try:
|
|
44
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "legion-lock/0.1"}), timeout=30) as r:
|
|
45
|
+
b = r.read(8 << 20)
|
|
46
|
+
return b.decode("utf-8") if raw else json.loads(b)
|
|
47
|
+
except urllib.error.HTTPError as e:
|
|
48
|
+
if e.code == 429 and attempt < 2:
|
|
49
|
+
import time; time.sleep(min(int(e.headers.get("Retry-After") or 5), 30)); continue
|
|
50
|
+
raise Fail("journal : HTTP %d sur %s" % (e.code, path))
|
|
51
|
+
except (urllib.error.URLError, OSError) as e:
|
|
52
|
+
if attempt < 2:
|
|
53
|
+
import time; time.sleep(3); continue
|
|
54
|
+
raise Fail("journal injoignable (%s)" % type(e).__name__)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def keys_from(keyring_text):
|
|
58
|
+
first = json.loads(next(l for l in keyring_text.splitlines() if l.strip()))
|
|
59
|
+
if T.key_pin(first.get("public_key_b64", "")) != ROOT_PIN:
|
|
60
|
+
raise Fail("trousseau : la 1re entrée n'a pas l'empreinte racine épinglée %s…" % ROOT_PIN[:16])
|
|
61
|
+
return T.load_keyring(keyring_text, first["public_key_b64"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_evidence(ev, keys, server_id):
|
|
65
|
+
"""Preuve = enregistrement signé + inclusion RFC 9162 + tête signée, clés issues de la racine épinglée."""
|
|
66
|
+
res = T.verify_bundle_offline({"record": ev["record"], "inclusion": ev["inclusion"], "sth": ev["sth"]}, keys)
|
|
67
|
+
if not res["ok"]:
|
|
68
|
+
bad = [k for k in ("record_integrity", "record_signature", "head_signature", "inclusion") if not res.get(k)]
|
|
69
|
+
raise Fail("%s : preuve REJETÉE (%s)" % (server_id, ", ".join(bad)))
|
|
70
|
+
if ev["record"].get("server_id") != server_id:
|
|
71
|
+
raise Fail("%s : la preuve concerne un autre serveur (%s)" % (server_id, ev["record"].get("server_id")))
|
|
72
|
+
return res
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canon_for(algo):
|
|
76
|
+
return T.canon_v1 if algo.endswith("/v1") else T.canon
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def tools_from_archive(fp):
|
|
80
|
+
"""Définitions archivées, ACCEPTÉES seulement si elles redonnent l'empreinte signée."""
|
|
81
|
+
try:
|
|
82
|
+
tools = get("/tools/" + fp["sha256"])["tools"]
|
|
83
|
+
except Fail:
|
|
84
|
+
return None
|
|
85
|
+
lst = sorted(({k: t[k] for k in FIELDS if k in t} for t in tools), key=lambda t: t["name"])
|
|
86
|
+
if hashlib.sha256(canon_for(fp["algo"])(lst)).hexdigest() != fp["sha256"]:
|
|
87
|
+
raise Fail("archive /tools/%s… : le contenu ne redonne pas l'empreinte signée" % fp["sha256"][:12])
|
|
88
|
+
return {t["name"]: t for t in lst}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def approved_view(rec):
|
|
92
|
+
fp = rec["tools_fingerprint"]
|
|
93
|
+
return {"tools_sha256": fp["sha256"], "algo": fp["algo"], "tool_count": len(fp["per_tool_sha256"]),
|
|
94
|
+
"tools": {n: h for n, h in fp["per_tool_sha256"]}}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_lock(p):
|
|
98
|
+
if not os.path.exists(p):
|
|
99
|
+
return {"format": FORMAT, "log": {"base": BASE, "origin": "tlog.legion-api.com/mcp/v0", "root_pin_sha256": ROOT_PIN},
|
|
100
|
+
"keyring": None, "servers": {}}
|
|
101
|
+
d = json.load(open(p, encoding="utf-8"))
|
|
102
|
+
if d.get("format") != FORMAT:
|
|
103
|
+
raise Fail("%s : format inconnu %r" % (p, d.get("format")))
|
|
104
|
+
if d["log"]["root_pin_sha256"] != ROOT_PIN:
|
|
105
|
+
raise Fail("%s : empreinte racine différente de celle de cet outil" % p)
|
|
106
|
+
return d
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def save_lock(p, d):
|
|
110
|
+
tmp = p + ".tmp"
|
|
111
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
112
|
+
json.dump(d, f, indent=1, ensure_ascii=False, sort_keys=True); f.write("\n")
|
|
113
|
+
os.replace(tmp, p)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def now():
|
|
117
|
+
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------- commandes
|
|
121
|
+
def cmd_add(a):
|
|
122
|
+
lock = load_lock(a.lock)
|
|
123
|
+
kr = get("/keyring", raw=True); keys = keys_from(kr)
|
|
124
|
+
sid = a.server_id
|
|
125
|
+
if a.at_leaf is None and a.at_head is None:
|
|
126
|
+
b = get("/servers/%s/latest" % urllib.parse.quote(sid, safe=""))
|
|
127
|
+
ev = {"record": b["record"], "inclusion": b["inclusion"], "sth": b["sth"]}
|
|
128
|
+
else:
|
|
129
|
+
if a.at_leaf is None:
|
|
130
|
+
rec = get("/servers/%s/latest" % urllib.parse.quote(sid, safe=""))["record"]
|
|
131
|
+
else:
|
|
132
|
+
rec = get("/records/%d" % a.at_leaf); rec = rec.get("record", rec)
|
|
133
|
+
h = get("/head/%d" % a.at_head) if a.at_head is not None else get("/head"); sth = h.get("sth", h)
|
|
134
|
+
n = sth["tree_head"]["tree_size"]
|
|
135
|
+
ev = {"record": rec, "inclusion": get("/proof/inclusion?leaf_index=%d&tree_size=%d" % (rec["leaf_index"], n)), "sth": sth}
|
|
136
|
+
check_evidence(ev, keys, sid)
|
|
137
|
+
ap = approved_view(ev["record"])
|
|
138
|
+
lock["keyring"] = kr
|
|
139
|
+
lock["servers"][sid] = {"approved": ap, "evidence": ev, "pinned_at": now(),
|
|
140
|
+
"pinned_from": {"leaf_index": ev["record"]["leaf_index"], "observed_at": ev["record"]["observed_at"],
|
|
141
|
+
"tree_size": ev["sth"]["tree_head"]["tree_size"]}}
|
|
142
|
+
save_lock(a.lock, lock)
|
|
143
|
+
print("ÉPINGLÉ %s : %d outils, empreinte %s… (feuille %d, observée %s), preuve vérifiée contre la racine %s…"
|
|
144
|
+
% (sid, ap["tool_count"], ap["tools_sha256"][:16], ev["record"]["leaf_index"], ev["record"]["observed_at"], ROOT_PIN[:16]))
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def schema_paths(a, b, path=""):
|
|
149
|
+
"""Chemins JSON qui diffèrent entre deux schémas (ajout, retrait, valeur)."""
|
|
150
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
151
|
+
out = []
|
|
152
|
+
for k in sorted(set(a) | set(b)):
|
|
153
|
+
p = "%s.%s" % (path, k) if path else k
|
|
154
|
+
if k not in a: out.append("+ " + p)
|
|
155
|
+
elif k not in b: out.append("- " + p)
|
|
156
|
+
else: out += schema_paths(a[k], b[k], p)
|
|
157
|
+
return out
|
|
158
|
+
if isinstance(a, list) and isinstance(b, list) and len(a) == len(b):
|
|
159
|
+
return [x for i, (u, v) in enumerate(zip(a, b)) for x in schema_paths(u, v, "%s[%d]" % (path, i))]
|
|
160
|
+
return [] if a == b else ["~ " + (path or "(racine)")]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def describe_drift(ap, rec):
|
|
164
|
+
cur = approved_view(rec)
|
|
165
|
+
old_t, new_t = ap["tools"], cur["tools"]
|
|
166
|
+
added = sorted(set(new_t) - set(old_t)); removed = sorted(set(old_t) - set(new_t))
|
|
167
|
+
modified = sorted(n for n in set(old_t) & set(new_t) if old_t[n] != new_t[n])
|
|
168
|
+
lines, real_mod = [], []
|
|
169
|
+
olda = newa = None
|
|
170
|
+
if modified or ap["algo"] != cur["algo"]:
|
|
171
|
+
olda = tools_from_archive({"sha256": ap["tools_sha256"], "algo": ap["algo"]})
|
|
172
|
+
newa = tools_from_archive({"sha256": cur["tools_sha256"], "algo": cur["algo"]})
|
|
173
|
+
for n in added:
|
|
174
|
+
d = (newa or {}).get(n, {}).get("description", "")
|
|
175
|
+
lines.append("outil %s : AJOUTÉ%s" % (n, (" — « %s »" % d[:120]) if d else ""))
|
|
176
|
+
for n in removed:
|
|
177
|
+
lines.append("outil %s : RETIRÉ" % n)
|
|
178
|
+
for n in modified:
|
|
179
|
+
if olda is None or newa is None:
|
|
180
|
+
lines.append("outil %s : définition changée (archive indisponible : champ non déterminé)" % n); real_mod.append(n); continue
|
|
181
|
+
x, y = olda[n], newa[n]
|
|
182
|
+
if x == y: # même contenu, empreinte recalculée autrement (changement d'algo)
|
|
183
|
+
lines.append("outil %s : contenu IDENTIQUE (empreinte différente seulement par l'algorithme %s -> %s)" % (n, ap["algo"][-3:], cur["algo"][-3:]))
|
|
184
|
+
continue
|
|
185
|
+
real_mod.append(n)
|
|
186
|
+
if x.get("description") != y.get("description"):
|
|
187
|
+
lines.append("outil %s : description changée" % n)
|
|
188
|
+
for l in difflib.unified_diff((x.get("description") or "").split(". "), (y.get("description") or "").split(". "), "approuvée", "actuelle", n=0, lineterm=""):
|
|
189
|
+
if not l.startswith(("---", "+++", "@@")):
|
|
190
|
+
lines.append(" " + l[:160])
|
|
191
|
+
if x.get("inputSchema") != y.get("inputSchema"):
|
|
192
|
+
ps = schema_paths(x.get("inputSchema"), y.get("inputSchema"))
|
|
193
|
+
lines.append("outil %s : inputSchema changé (%d chemin(s)) : %s" % (n, len(ps), ", ".join(ps[:8]) + (" …" if len(ps) > 8 else "")))
|
|
194
|
+
return {"added": added, "removed": removed, "modified": real_mod, "lines": lines,
|
|
195
|
+
"approved_sha256": ap["tools_sha256"], "current_sha256": cur["tools_sha256"]}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def cmd_check(a):
|
|
199
|
+
lock = load_lock(a.lock)
|
|
200
|
+
if not lock["servers"]:
|
|
201
|
+
raise Fail("%s : aucun serveur épinglé (legion_lock.py add SERVER_ID)" % a.lock)
|
|
202
|
+
keys = keys_from(get("/keyring", raw=True))
|
|
203
|
+
report, drift, warn = [], 0, 0
|
|
204
|
+
for sid, ent in sorted(lock["servers"].items()):
|
|
205
|
+
check_evidence(ent["evidence"], keys, sid) # l'épinglage lui-même reste prouvé
|
|
206
|
+
b = get("/servers/%s/latest" % urllib.parse.quote(sid, safe=""))
|
|
207
|
+
cur = {"record": b["record"], "inclusion": b["inclusion"], "sth": b["sth"]}
|
|
208
|
+
check_evidence(cur, keys, sid)
|
|
209
|
+
m, n = ent["evidence"]["sth"]["tree_head"]["tree_size"], cur["sth"]["tree_head"]["tree_size"]
|
|
210
|
+
rm, rn = ent["evidence"]["sth"]["tree_head"]["root_sha256"], cur["sth"]["tree_head"]["root_sha256"]
|
|
211
|
+
if m < n:
|
|
212
|
+
pr = get("/proof/consistency?old_size=%d&new_size=%d" % (m, n))["proof"]
|
|
213
|
+
if not T._verify_consistency(m, n, bytes.fromhex(rm), bytes.fromhex(rn), [bytes.fromhex(h) for h in pr]):
|
|
214
|
+
raise Fail("%s : HISTORIQUE RÉÉCRIT — la tête %d n'étend pas la tête %d de votre épinglage" % (sid, n, m))
|
|
215
|
+
elif m == n and rm != rn:
|
|
216
|
+
raise Fail("%s : FOURCHE — deux racines signées pour la taille %d" % (sid, m))
|
|
217
|
+
elif m > n:
|
|
218
|
+
raise Fail("%s : RETOUR EN ARRIÈRE — le journal sert la taille %d < %d déjà vue" % (sid, n, m))
|
|
219
|
+
ent_r = {"server_id": sid, "pinned_leaf": ent["pinned_from"]["leaf_index"], "current_leaf": cur["record"]["leaf_index"],
|
|
220
|
+
"observed_at": cur["record"]["observed_at"], "head": n, "append_only_since_pin": True}
|
|
221
|
+
if cur["record"]["tools_fingerprint"]["sha256"] == ent["approved"]["tools_sha256"]:
|
|
222
|
+
ent_r["status"] = "CONFORME"
|
|
223
|
+
else:
|
|
224
|
+
d = describe_drift(ent["approved"], cur["record"])
|
|
225
|
+
if d["added"] or d["removed"] or d["modified"]:
|
|
226
|
+
ent_r.update(status="DÉRIVE", drift=d); drift += 1
|
|
227
|
+
else:
|
|
228
|
+
ent_r.update(status="CONFORME", note="empreinte recalculée autrement, contenu identique", drift=d)
|
|
229
|
+
if b.get("newer_uncovered_records"):
|
|
230
|
+
ent_r["warning"] = "%d observation(s) plus récente(s) pas encore dans une tête signée" % b["newer_uncovered_records"]; warn += 1
|
|
231
|
+
report.append(ent_r)
|
|
232
|
+
if a.json:
|
|
233
|
+
print(json.dumps({"ok": drift == 0 and not (a.strict and warn), "servers": report}, indent=1, ensure_ascii=False))
|
|
234
|
+
else:
|
|
235
|
+
for r in report:
|
|
236
|
+
print("%-9s %s (épinglé feuille %d ; actuel feuille %d, observé %s ; tête signée %d, append-only vérifié)"
|
|
237
|
+
% (r["status"], r["server_id"], r["pinned_leaf"], r["current_leaf"], r["observed_at"], r["head"]))
|
|
238
|
+
for l in (r.get("drift") or {}).get("lines", []):
|
|
239
|
+
print(" " + l)
|
|
240
|
+
if r.get("warning"):
|
|
241
|
+
print(" attention : " + r["warning"])
|
|
242
|
+
print("RÉSULTAT : %s" % ("DÉRIVE sur %d serveur(s)" % drift if drift else "aucune dérive"))
|
|
243
|
+
return 1 if drift or (a.strict and warn) else 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def cmd_verify(a):
|
|
247
|
+
lock = load_lock(a.lock)
|
|
248
|
+
keys = keys_from(lock["keyring"] or "")
|
|
249
|
+
for sid, ent in sorted(lock["servers"].items()):
|
|
250
|
+
res = check_evidence(ent["evidence"], keys, sid)
|
|
251
|
+
if approved_view(ent["evidence"]["record"]) != ent["approved"]:
|
|
252
|
+
raise Fail("%s : l'état « approuvé » du lockfile ne correspond pas à l'enregistrement signé" % sid)
|
|
253
|
+
print("OK %s : feuille %d incluse dans la tête signée %d ; %d outils, empreinte %s… (hors ligne)"
|
|
254
|
+
% (sid, res["leaf_index"], res["tree_size"], ent["approved"]["tool_count"], ent["approved"]["tools_sha256"][:16]))
|
|
255
|
+
print("RÉSULTAT : %d épinglage(s) prouvé(s) hors ligne contre la racine %s…" % (len(lock["servers"]), ROOT_PIN[:16]))
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_show(a):
|
|
260
|
+
lock = load_lock(a.lock)
|
|
261
|
+
for sid, ent in sorted(lock["servers"].items()):
|
|
262
|
+
print("%s : %d outils, %s…, feuille %d, épinglé %s" % (sid, ent["approved"]["tool_count"], ent["approved"]["tools_sha256"][:16],
|
|
263
|
+
ent["pinned_from"]["leaf_index"], ent["pinned_at"]))
|
|
264
|
+
return 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def main(argv=None):
|
|
268
|
+
ap = argparse.ArgumentParser(prog="legion-lock", description=__doc__.split("\n")[0])
|
|
269
|
+
ap.add_argument("--lock", default=DEFAULT_LOCK)
|
|
270
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
271
|
+
s = sub.add_parser("add"); s.add_argument("server_id"); s.add_argument("--at-leaf", type=int); s.add_argument("--at-head", type=int, help="prouver contre la tête signée de cette taille")
|
|
272
|
+
s = sub.add_parser("check"); s.add_argument("--strict", action="store_true"); s.add_argument("--json", action="store_true")
|
|
273
|
+
sub.add_parser("verify"); sub.add_parser("show")
|
|
274
|
+
a = ap.parse_args(argv)
|
|
275
|
+
try:
|
|
276
|
+
return {"add": cmd_add, "check": cmd_check, "verify": cmd_verify, "show": cmd_show}[a.cmd](a)
|
|
277
|
+
except Fail as e:
|
|
278
|
+
print("ÉCHEC : %s" % e, file=sys.stderr); return 2
|
|
279
|
+
except (ValueError, KeyError, TypeError, json.JSONDecodeError) as e:
|
|
280
|
+
print("ÉCHEC : donnée invalide (%s: %s)" % (type(e).__name__, e), file=sys.stderr); return 2
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
sys.exit(main())
|