ltcai 4.3.3 → 4.5.1
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 +53 -20
- package/docs/CHANGELOG.md +122 -0
- package/docs/V4_4_0_EXTRACTION_REPORT.md +239 -0
- package/docs/V4_5_0_GEMMA_RUNTIME_COMPATIBILITY_REPORT.md +49 -0
- package/docs/V4_5_0_GRAPH_UX_REPORT.md +34 -0
- package/docs/V4_5_0_MODEL_RUNTIME_UX_REPORT.md +40 -0
- package/docs/V4_5_0_ONBOARDING_REPORT.md +31 -0
- package/docs/V4_5_0_PRODUCT_EXPERIENCE_RECOVERY_REPORT.md +49 -0
- package/docs/V4_5_0_VALIDATION_REPORT.md +60 -0
- package/docs/V4_5_1_GRAPH_EXPERIENCE_REPORT.md +33 -0
- package/docs/V4_5_1_MODEL_EXPERIENCE_REPORT.md +37 -0
- package/docs/V4_5_1_NAVIGATION_REPORT.md +37 -0
- package/docs/V4_5_1_ONBOARDING_REPORT.md +29 -0
- package/docs/V4_5_1_PRODUCT_REIMAGINING_REPORT.md +61 -0
- package/docs/V4_5_1_RC_ARTIFACTS.md +44 -0
- package/docs/V4_5_1_UX_REPORT.md +45 -0
- package/docs/V4_5_1_VALIDATION_REPORT.md +54 -0
- package/docs/V4_5_1_VISUAL_DESIGN_REPORT.md +30 -0
- package/docs/V4_DIGITAL_BRAIN_RECOVERY.md +16 -16
- package/docs/architecture.md +8 -4
- package/frontend/src/App.tsx +152 -91
- package/frontend/src/api/client.ts +83 -1
- package/frontend/src/components/FirstRunGuide.tsx +99 -0
- package/frontend/src/components/primitives.tsx +131 -25
- package/frontend/src/components/ui/badge.tsx +2 -2
- package/frontend/src/components/ui/button.tsx +7 -7
- package/frontend/src/components/ui/card.tsx +5 -5
- package/frontend/src/components/ui/input.tsx +1 -1
- package/frontend/src/components/ui/textarea.tsx +1 -1
- package/frontend/src/pages/Act.tsx +58 -28
- package/frontend/src/pages/Ask.tsx +51 -19
- package/frontend/src/pages/Brain.tsx +60 -42
- package/frontend/src/pages/Capture.tsx +24 -24
- package/frontend/src/pages/Library.tsx +222 -32
- package/frontend/src/pages/System.tsx +56 -34
- package/frontend/src/routes.ts +15 -13
- package/frontend/src/store/appStore.ts +8 -1
- package/frontend/src/styles.css +666 -36
- 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/models.py +107 -18
- 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/model_compat.py +250 -0
- 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/models/router.py +136 -32
- 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/model_catalog.py +2 -2
- package/latticeai/services/model_recommendation.py +8 -1
- package/latticeai/services/model_runtime.py +18 -3
- 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/build_frontend_assets.mjs +12 -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 +5 -5
- package/static/app/assets/index-3G8qcrIS.js +336 -0
- package/static/app/assets/index-3G8qcrIS.js.map +1 -0
- package/static/app/assets/index-C0wYZp7k.css +2 -0
- package/static/app/index.html +2 -2
- package/static/app/assets/index-CHHal8Zl.css +0 -2
- package/static/app/assets/index-pdzil9ac.js +0 -333
- package/static/app/assets/index-pdzil9ac.js.map +0 -1
|
@@ -1,218 +1,17 @@
|
|
|
1
|
-
|
|
1
|
+
"""Deprecated shim: physically moved to lattice_brain.graph.documents.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Kept only for the compatibility window. The module aliases itself to the
|
|
4
|
+
physical module so identity, singletons, and monkeypatching are preserved.
|
|
5
|
+
"""
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
6
9
|
|
|
10
|
+
import lattice_brain.graph.documents as _impl
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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}
|
|
12
|
+
warnings.warn(
|
|
13
|
+
"latticeai.brain.documents is deprecated; import lattice_brain.graph.documents instead",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
16
|
+
)
|
|
17
|
+
sys.modules[__name__] = _impl
|
|
@@ -1,175 +1,17 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Deprecated shim: physically moved to lattice_brain.graph.identity.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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).
|
|
3
|
+
Kept only for the compatibility window. The module aliases itself to the
|
|
4
|
+
physical module so identity, singletons, and monkeypatching are preserved.
|
|
8
5
|
"""
|
|
9
6
|
|
|
10
|
-
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
11
9
|
|
|
12
|
-
import
|
|
13
|
-
import hashlib
|
|
14
|
-
import json
|
|
15
|
-
import logging
|
|
16
|
-
import os
|
|
17
|
-
from pathlib import Path
|
|
18
|
-
from typing import Any, Dict, Optional
|
|
10
|
+
import lattice_brain.graph.identity as _impl
|
|
19
11
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
warnings.warn(
|
|
13
|
+
"latticeai.brain.identity is deprecated; import lattice_brain.graph.identity instead",
|
|
14
|
+
DeprecationWarning,
|
|
15
|
+
stacklevel=2,
|
|
24
16
|
)
|
|
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"]
|
|
17
|
+
sys.modules[__name__] = _impl
|