ltcai 4.3.3 → 4.4.0
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.
- package/README.md +21 -16
- package/docs/CHANGELOG.md +37 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- package/lattice_brain/__init__.py +38 -23
- package/lattice_brain/_kg_common.py +11 -1
- package/lattice_brain/context.py +212 -2
- package/lattice_brain/conversations.py +234 -1
- package/lattice_brain/discovery.py +11 -1
- package/lattice_brain/documents.py +11 -1
- package/lattice_brain/graph/__init__.py +28 -0
- package/lattice_brain/graph/_kg_common.py +1123 -0
- package/lattice_brain/graph/curator.py +473 -0
- package/lattice_brain/graph/discovery.py +1455 -0
- package/lattice_brain/graph/documents.py +218 -0
- package/lattice_brain/graph/identity.py +175 -0
- package/lattice_brain/graph/ingest.py +644 -0
- package/lattice_brain/graph/network.py +205 -0
- package/lattice_brain/graph/projection.py +571 -0
- package/lattice_brain/graph/provenance.py +401 -0
- package/lattice_brain/graph/retrieval.py +1341 -0
- package/lattice_brain/graph/schema.py +640 -0
- package/lattice_brain/graph/store.py +237 -0
- package/lattice_brain/graph/write_master.py +225 -0
- package/lattice_brain/identity.py +11 -13
- package/lattice_brain/ingest.py +11 -1
- package/lattice_brain/ingestion.py +318 -0
- package/lattice_brain/memory.py +100 -1
- package/lattice_brain/network.py +11 -1
- package/lattice_brain/portability.py +431 -0
- package/lattice_brain/projection.py +11 -1
- package/lattice_brain/provenance.py +11 -1
- package/lattice_brain/retrieval.py +11 -1
- package/lattice_brain/runtime/__init__.py +32 -0
- package/lattice_brain/runtime/agent_runtime.py +569 -0
- package/lattice_brain/runtime/hooks.py +754 -0
- package/lattice_brain/runtime/multi_agent.py +795 -0
- package/lattice_brain/schema.py +11 -1
- package/lattice_brain/store.py +10 -2
- package/lattice_brain/workflow.py +461 -0
- package/lattice_brain/write_master.py +11 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/agents.py +2 -2
- package/latticeai/api/browser.py +1 -1
- package/latticeai/api/chat.py +1 -1
- package/latticeai/api/computer_use.py +1 -1
- package/latticeai/api/hooks.py +2 -2
- package/latticeai/api/mcp.py +1 -1
- package/latticeai/api/tools.py +1 -1
- package/latticeai/api/workflow_designer.py +2 -2
- package/latticeai/app_factory.py +4 -4
- package/latticeai/brain/__init__.py +24 -6
- package/latticeai/brain/_kg_common.py +11 -1117
- package/latticeai/brain/context.py +12 -208
- package/latticeai/brain/conversations.py +12 -231
- package/latticeai/brain/discovery.py +13 -1451
- package/latticeai/brain/documents.py +13 -214
- package/latticeai/brain/identity.py +11 -169
- package/latticeai/brain/ingest.py +13 -640
- package/latticeai/brain/memory.py +12 -97
- package/latticeai/brain/network.py +12 -200
- package/latticeai/brain/projection.py +13 -567
- package/latticeai/brain/provenance.py +13 -397
- package/latticeai/brain/retrieval.py +13 -1337
- package/latticeai/brain/schema.py +12 -635
- package/latticeai/brain/store.py +13 -233
- package/latticeai/brain/write_master.py +13 -221
- package/latticeai/core/agent.py +1 -1
- package/latticeai/core/agent_registry.py +2 -2
- package/latticeai/core/builtin_hooks.py +2 -2
- package/latticeai/core/graph_curator.py +6 -468
- package/latticeai/core/hooks.py +6 -749
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/multi_agent.py +6 -790
- package/latticeai/core/workflow_engine.py +6 -456
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/services/agent_runtime.py +6 -564
- package/latticeai/services/ingestion.py +6 -313
- package/latticeai/services/kg_portability.py +6 -426
- package/latticeai/services/platform_runtime.py +3 -3
- package/latticeai/services/run_executor.py +1 -1
- package/latticeai/services/upload_service.py +1 -1
- package/p_reinforce.py +1 -1
- package/package.json +1 -1
- package/scripts/bump_version.py +1 -1
- package/scripts/wheel_smoke.py +7 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# ruff: noqa: F403,F405
|
|
4
|
+
|
|
5
|
+
from ._kg_common import * # noqa: F403,F401
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class KnowledgeGraphDocumentsMixin:
|
|
9
|
+
def _ingest_structure_nodes(
|
|
10
|
+
self,
|
|
11
|
+
conn: sqlite3.Connection,
|
|
12
|
+
file_id: str,
|
|
13
|
+
filename: str,
|
|
14
|
+
structure: Dict[str, Any],
|
|
15
|
+
) -> None:
|
|
16
|
+
for slide in structure.get("slides") or []:
|
|
17
|
+
index = slide.get("index")
|
|
18
|
+
slide_id = f"slide:{_sha256_text(f'{file_id}:slide:{index}')[:24]}"
|
|
19
|
+
title = f"{filename} slide {index}"
|
|
20
|
+
summary = "\n".join(slide.get("texts") or [])[:800]
|
|
21
|
+
self._upsert_node(
|
|
22
|
+
conn, slide_id, "Slide", title, summary=summary, metadata=slide
|
|
23
|
+
)
|
|
24
|
+
self._upsert_edge(conn, file_id, slide_id, "has_slide")
|
|
25
|
+
for text in slide.get("texts") or []:
|
|
26
|
+
for topic in _topic_candidates(text, limit=4):
|
|
27
|
+
topic_id = f"topic:{_slug(topic)}"
|
|
28
|
+
self._upsert_node(
|
|
29
|
+
conn,
|
|
30
|
+
topic_id,
|
|
31
|
+
"Topic",
|
|
32
|
+
topic,
|
|
33
|
+
metadata={"auto_extracted": True},
|
|
34
|
+
)
|
|
35
|
+
self._upsert_edge(conn, slide_id, topic_id, "discusses", weight=0.6)
|
|
36
|
+
|
|
37
|
+
for page in structure.get("pages") or []:
|
|
38
|
+
index = page.get("index")
|
|
39
|
+
page_id = f"page:{_sha256_text(f'{file_id}:page:{index}')[:24]}"
|
|
40
|
+
title = f"{filename} page {index}"
|
|
41
|
+
self._upsert_node(
|
|
42
|
+
conn,
|
|
43
|
+
page_id,
|
|
44
|
+
"Page",
|
|
45
|
+
title,
|
|
46
|
+
summary=page.get("preview") or "",
|
|
47
|
+
metadata=page,
|
|
48
|
+
)
|
|
49
|
+
self._upsert_edge(conn, file_id, page_id, "has_page")
|
|
50
|
+
for topic in _topic_candidates(page.get("preview") or "", limit=4):
|
|
51
|
+
topic_id = f"topic:{_slug(topic)}"
|
|
52
|
+
self._upsert_node(
|
|
53
|
+
conn, topic_id, "Topic", topic, metadata={"auto_extracted": True}
|
|
54
|
+
)
|
|
55
|
+
self._upsert_edge(conn, page_id, topic_id, "discusses", weight=0.6)
|
|
56
|
+
|
|
57
|
+
for sheet in structure.get("sheets") or []:
|
|
58
|
+
sheet_title = sheet.get("title")
|
|
59
|
+
sheet_id = f"sheet:{_sha256_text(f'{file_id}:sheet:{sheet_title}')[:24]}"
|
|
60
|
+
self._upsert_node(
|
|
61
|
+
conn, sheet_id, "Sheet", f"{filename} / {sheet_title}", metadata=sheet
|
|
62
|
+
)
|
|
63
|
+
self._upsert_edge(conn, file_id, sheet_id, "has_sheet")
|
|
64
|
+
|
|
65
|
+
for image in structure.get("images") or []:
|
|
66
|
+
image_key = image.get("sha256") or _sha256_text(
|
|
67
|
+
json.dumps(image, ensure_ascii=False, sort_keys=True)
|
|
68
|
+
)
|
|
69
|
+
image_id = f"image:{str(image_key)[:24]}"
|
|
70
|
+
title_parts = [filename, "image"]
|
|
71
|
+
if image.get("page"):
|
|
72
|
+
title_parts.append(f"page {image.get('page')}")
|
|
73
|
+
if image.get("name"):
|
|
74
|
+
title_parts.append(str(image.get("name")).split("/")[-1])
|
|
75
|
+
self._upsert_node(
|
|
76
|
+
conn, image_id, "Image", " / ".join(title_parts), metadata=image
|
|
77
|
+
)
|
|
78
|
+
self._upsert_edge(conn, file_id, image_id, "contains_image")
|
|
79
|
+
|
|
80
|
+
def _document_structure(self, path: Path, ext: str) -> Dict[str, Any]:
|
|
81
|
+
try:
|
|
82
|
+
if ext == ".pptx":
|
|
83
|
+
return self._pptx_structure(path)
|
|
84
|
+
if ext == ".pdf":
|
|
85
|
+
return self._pdf_structure(path)
|
|
86
|
+
if ext == ".docx":
|
|
87
|
+
return self._docx_structure(path)
|
|
88
|
+
if ext == ".xlsx":
|
|
89
|
+
return self._xlsx_structure(path)
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
return {"error": str(exc)}
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
def _pptx_structure(self, path: Path) -> Dict[str, Any]:
|
|
95
|
+
result: Dict[str, Any] = {"slides": [], "images": []}
|
|
96
|
+
try:
|
|
97
|
+
from PIL import Image
|
|
98
|
+
from pptx import Presentation
|
|
99
|
+
|
|
100
|
+
prs = Presentation(str(path))
|
|
101
|
+
for slide_index, slide in enumerate(prs.slides, start=1):
|
|
102
|
+
slide_info = {"index": slide_index, "shapes": [], "texts": []}
|
|
103
|
+
for shape_index, shape in enumerate(slide.shapes, start=1):
|
|
104
|
+
shape_info = {
|
|
105
|
+
"index": shape_index,
|
|
106
|
+
"name": getattr(shape, "name", ""),
|
|
107
|
+
"shape_type": str(getattr(shape, "shape_type", "")),
|
|
108
|
+
"bbox": {
|
|
109
|
+
"left": int(getattr(shape, "left", 0) or 0),
|
|
110
|
+
"top": int(getattr(shape, "top", 0) or 0),
|
|
111
|
+
"width": int(getattr(shape, "width", 0) or 0),
|
|
112
|
+
"height": int(getattr(shape, "height", 0) or 0),
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
if getattr(shape, "has_text_frame", False):
|
|
116
|
+
text = shape.text_frame.text.strip()
|
|
117
|
+
if text:
|
|
118
|
+
shape_info["text"] = text[:1000]
|
|
119
|
+
slide_info["texts"].append(text)
|
|
120
|
+
slide_info["shapes"].append(shape_info)
|
|
121
|
+
result["slides"].append(slide_info)
|
|
122
|
+
with zipfile.ZipFile(path) as zf:
|
|
123
|
+
for name in zf.namelist():
|
|
124
|
+
if not name.startswith("ppt/media/"):
|
|
125
|
+
continue
|
|
126
|
+
data = zf.read(name)
|
|
127
|
+
image_info: Dict[str, Any] = {
|
|
128
|
+
"name": name,
|
|
129
|
+
"bytes": len(data),
|
|
130
|
+
"sha256": _sha256_bytes(data),
|
|
131
|
+
}
|
|
132
|
+
try:
|
|
133
|
+
from io import BytesIO
|
|
134
|
+
|
|
135
|
+
with Image.open(BytesIO(data)) as img:
|
|
136
|
+
image_info.update(
|
|
137
|
+
{
|
|
138
|
+
"width": img.width,
|
|
139
|
+
"height": img.height,
|
|
140
|
+
"format": img.format,
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
result["images"].append(image_info)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
result["error"] = str(exc)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
def _pdf_structure(self, path: Path) -> Dict[str, Any]:
|
|
151
|
+
result: Dict[str, Any] = {"pages": [], "images": []}
|
|
152
|
+
try:
|
|
153
|
+
import pdfplumber
|
|
154
|
+
|
|
155
|
+
with pdfplumber.open(str(path)) as pdf:
|
|
156
|
+
metadata = dict(pdf.metadata or {})
|
|
157
|
+
result["metadata"] = {str(k): str(v) for k, v in metadata.items()}
|
|
158
|
+
for page_index, page in enumerate(pdf.pages, start=1):
|
|
159
|
+
text = page.extract_text() or ""
|
|
160
|
+
page_info = {
|
|
161
|
+
"index": page_index,
|
|
162
|
+
"width": float(page.width or 0),
|
|
163
|
+
"height": float(page.height or 0),
|
|
164
|
+
"chars": len(text),
|
|
165
|
+
"preview": _clean_text(text)[:500],
|
|
166
|
+
"image_count": len(page.images or []),
|
|
167
|
+
}
|
|
168
|
+
result["pages"].append(page_info)
|
|
169
|
+
for image_index, image in enumerate(page.images or [], start=1):
|
|
170
|
+
result["images"].append(
|
|
171
|
+
{
|
|
172
|
+
"page": page_index,
|
|
173
|
+
"index": image_index,
|
|
174
|
+
"name": image.get("name"),
|
|
175
|
+
"width": image.get("width"),
|
|
176
|
+
"height": image.get("height"),
|
|
177
|
+
"bbox": {
|
|
178
|
+
"x0": image.get("x0"),
|
|
179
|
+
"top": image.get("top"),
|
|
180
|
+
"x1": image.get("x1"),
|
|
181
|
+
"bottom": image.get("bottom"),
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
result["error"] = str(exc)
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def _docx_structure(self, path: Path) -> Dict[str, Any]:
|
|
190
|
+
from docx import Document
|
|
191
|
+
|
|
192
|
+
doc = Document(str(path))
|
|
193
|
+
headings = []
|
|
194
|
+
paragraphs = 0
|
|
195
|
+
for p in doc.paragraphs:
|
|
196
|
+
text = p.text.strip()
|
|
197
|
+
if not text:
|
|
198
|
+
continue
|
|
199
|
+
paragraphs += 1
|
|
200
|
+
style = getattr(p.style, "name", "")
|
|
201
|
+
if style.lower().startswith("heading"):
|
|
202
|
+
headings.append({"style": style, "text": text[:240]})
|
|
203
|
+
return {
|
|
204
|
+
"paragraphs": paragraphs,
|
|
205
|
+
"headings": headings[:80],
|
|
206
|
+
"tables": len(doc.tables),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
def _xlsx_structure(self, path: Path) -> Dict[str, Any]:
|
|
210
|
+
from openpyxl import load_workbook
|
|
211
|
+
|
|
212
|
+
wb = load_workbook(str(path), read_only=True, data_only=True)
|
|
213
|
+
sheets = []
|
|
214
|
+
for ws in wb.worksheets:
|
|
215
|
+
sheets.append(
|
|
216
|
+
{"title": ws.title, "max_row": ws.max_row, "max_column": ws.max_column}
|
|
217
|
+
)
|
|
218
|
+
return {"sheets": sheets}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Device identity — the sovereignty primitive.
|
|
2
|
+
|
|
3
|
+
Every Lattice installation owns an Ed25519 keypair. Exports are signed by
|
|
4
|
+
it, peers pair against its public key, and imported knowledge records which
|
|
5
|
+
device it came from. The private key never leaves the machine: it lives in
|
|
6
|
+
the OS keyring when one is available, otherwise in a 0600 file under the
|
|
7
|
+
data directory (the storage backend is reported honestly).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
from cryptography.hazmat.primitives import serialization
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
|
22
|
+
Ed25519PrivateKey,
|
|
23
|
+
Ed25519PublicKey,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_KEYRING_SERVICE = "lattice-ai-device-identity"
|
|
27
|
+
_KEYRING_ENTRY = "ed25519-private-key"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _b64(data: bytes) -> str:
|
|
31
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _unb64(text: str) -> bytes:
|
|
35
|
+
padded = text + "=" * (-len(text) % 4)
|
|
36
|
+
return base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _keyring_opt_in() -> bool:
|
|
40
|
+
"""Keyring storage is opt-in (LATTICEAI_DEVICE_KEY_KEYRING=1).
|
|
41
|
+
|
|
42
|
+
OS keychain access can block or prompt during startup/tests; the default
|
|
43
|
+
is a 0600 file under the data dir, and ``describe()`` reports which
|
|
44
|
+
backend holds the key — no silent security theater either way.
|
|
45
|
+
"""
|
|
46
|
+
return os.getenv("LATTICEAI_DEVICE_KEY_KEYRING", "").strip() in {"1", "true", "yes"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DeviceIdentity:
|
|
50
|
+
"""Loads-or-creates the installation's Ed25519 keypair."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, data_dir: Path, *, use_keyring: Optional[bool] = None):
|
|
53
|
+
if use_keyring is None:
|
|
54
|
+
use_keyring = _keyring_opt_in()
|
|
55
|
+
self._data_dir = Path(data_dir)
|
|
56
|
+
self._key_file = self._data_dir / "device_identity.key"
|
|
57
|
+
self._private: Ed25519PrivateKey
|
|
58
|
+
self.storage: str # "keyring" | "file"
|
|
59
|
+
self._load_or_create(use_keyring)
|
|
60
|
+
|
|
61
|
+
# ── key material ───────────────────────────────────────────────────────
|
|
62
|
+
def _load_or_create(self, use_keyring: bool) -> None:
|
|
63
|
+
raw: Optional[bytes] = None
|
|
64
|
+
backend = "file"
|
|
65
|
+
if use_keyring:
|
|
66
|
+
try:
|
|
67
|
+
import keyring
|
|
68
|
+
|
|
69
|
+
stored = keyring.get_password(_KEYRING_SERVICE, _KEYRING_ENTRY)
|
|
70
|
+
if stored:
|
|
71
|
+
raw = _unb64(stored)
|
|
72
|
+
backend = "keyring"
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logging.debug("device identity: keyring unavailable (%s)", exc)
|
|
75
|
+
if raw is None and self._key_file.exists():
|
|
76
|
+
raw = _unb64(self._key_file.read_text().strip())
|
|
77
|
+
backend = "file"
|
|
78
|
+
if raw is None:
|
|
79
|
+
key = Ed25519PrivateKey.generate()
|
|
80
|
+
raw = key.private_bytes(
|
|
81
|
+
serialization.Encoding.Raw,
|
|
82
|
+
serialization.PrivateFormat.Raw,
|
|
83
|
+
serialization.NoEncryption(),
|
|
84
|
+
)
|
|
85
|
+
backend = self._persist_new(raw, use_keyring)
|
|
86
|
+
self._private = Ed25519PrivateKey.from_private_bytes(raw)
|
|
87
|
+
self.storage = backend
|
|
88
|
+
|
|
89
|
+
def _persist_new(self, raw: bytes, use_keyring: bool) -> str:
|
|
90
|
+
if use_keyring:
|
|
91
|
+
try:
|
|
92
|
+
import keyring
|
|
93
|
+
|
|
94
|
+
keyring.set_password(_KEYRING_SERVICE, _KEYRING_ENTRY, _b64(raw))
|
|
95
|
+
return "keyring"
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
logging.debug("device identity: keyring store failed (%s); using file", exc)
|
|
98
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
self._key_file.write_text(_b64(raw))
|
|
100
|
+
os.chmod(self._key_file, 0o600)
|
|
101
|
+
return "file"
|
|
102
|
+
|
|
103
|
+
# ── public surface ─────────────────────────────────────────────────────
|
|
104
|
+
@property
|
|
105
|
+
def public_key_b64(self) -> str:
|
|
106
|
+
return _b64(
|
|
107
|
+
self._private.public_key().public_bytes(
|
|
108
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def fingerprint(self) -> str:
|
|
114
|
+
"""Short human-comparable id: sha256 of the raw public key."""
|
|
115
|
+
raw = self._private.public_key().public_bytes(
|
|
116
|
+
serialization.Encoding.Raw, serialization.PublicFormat.Raw
|
|
117
|
+
)
|
|
118
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
119
|
+
return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
|
|
120
|
+
|
|
121
|
+
def describe(self) -> Dict[str, Any]:
|
|
122
|
+
return {
|
|
123
|
+
"fingerprint": self.fingerprint,
|
|
124
|
+
"public_key": self.public_key_b64,
|
|
125
|
+
"algorithm": "ed25519",
|
|
126
|
+
"storage": self.storage,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# ── signing ────────────────────────────────────────────────────────────
|
|
130
|
+
def sign(self, payload: bytes) -> str:
|
|
131
|
+
return _b64(self._private.sign(payload))
|
|
132
|
+
|
|
133
|
+
def sign_manifest(self, manifest: Dict[str, Any]) -> Dict[str, Any]:
|
|
134
|
+
"""Detached signature over the canonical JSON of a manifest."""
|
|
135
|
+
canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
136
|
+
return {
|
|
137
|
+
"algorithm": "ed25519",
|
|
138
|
+
"public_key": self.public_key_b64,
|
|
139
|
+
"fingerprint": self.fingerprint,
|
|
140
|
+
"signature": self.sign(canonical),
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fingerprint_of(public_key_b64: str) -> str:
|
|
145
|
+
"""Human-comparable fingerprint of an Ed25519 public key.
|
|
146
|
+
|
|
147
|
+
Raises ValueError when the input is not a valid key — the pairing flow
|
|
148
|
+
uses this as its validation gate.
|
|
149
|
+
"""
|
|
150
|
+
raw = _unb64(public_key_b64)
|
|
151
|
+
Ed25519PublicKey.from_public_bytes(raw) # validates; raises on garbage
|
|
152
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
153
|
+
return ":".join(digest[i : i + 4] for i in range(0, 16, 4))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def verify_signature(public_key_b64: str, payload: bytes, signature_b64: str) -> bool:
|
|
157
|
+
"""True iff ``signature`` is valid for ``payload`` under the given key."""
|
|
158
|
+
try:
|
|
159
|
+
key = Ed25519PublicKey.from_public_bytes(_unb64(public_key_b64))
|
|
160
|
+
key.verify(_unb64(signature_b64), payload)
|
|
161
|
+
return True
|
|
162
|
+
except Exception:
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def verify_manifest(manifest: Dict[str, Any], signature_block: Dict[str, Any]) -> bool:
|
|
167
|
+
canonical = json.dumps(manifest, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
168
|
+
return verify_signature(
|
|
169
|
+
str(signature_block.get("public_key") or ""),
|
|
170
|
+
canonical,
|
|
171
|
+
str(signature_block.get("signature") or ""),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
__all__ = ["DeviceIdentity", "verify_signature", "verify_manifest"]
|