pnasyscnct 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.
- pnasyscnct-0.2.0/PKG-INFO +60 -0
- pnasyscnct-0.2.0/README.md +47 -0
- pnasyscnct-0.2.0/pyproject.toml +23 -0
- pnasyscnct-0.2.0/setup.cfg +4 -0
- pnasyscnct-0.2.0/src/pnasyscnct/__init__.py +2 -0
- pnasyscnct-0.2.0/src/pnasyscnct/cli.py +307 -0
- pnasyscnct-0.2.0/src/pnasyscnct/common.py +151 -0
- pnasyscnct-0.2.0/src/pnasyscnct/link.py +171 -0
- pnasyscnct-0.2.0/src/pnasyscnct/mcp_server.py +264 -0
- pnasyscnct-0.2.0/src/pnasyscnct/pi_cli.py +266 -0
- pnasyscnct-0.2.0/src/pnasyscnct/pi_daemon.py +347 -0
- pnasyscnct-0.2.0/src/pnasyscnct/pi_daemon_run.py +18 -0
- pnasyscnct-0.2.0/src/pnasyscnct.egg-info/PKG-INFO +60 -0
- pnasyscnct-0.2.0/src/pnasyscnct.egg-info/SOURCES.txt +16 -0
- pnasyscnct-0.2.0/src/pnasyscnct.egg-info/dependency_links.txt +1 -0
- pnasyscnct-0.2.0/src/pnasyscnct.egg-info/entry_points.txt +3 -0
- pnasyscnct-0.2.0/src/pnasyscnct.egg-info/requires.txt +3 -0
- pnasyscnct-0.2.0/src/pnasyscnct.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pnasyscnct
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: PNASystems Connect — tailscale-style Pi remote: computer + Pi, SSH over queue, MCP
|
|
5
|
+
Author: PNASystems
|
|
6
|
+
Project-URL: Homepage, https://github.com/Powerentity303/PNASystems_CRP
|
|
7
|
+
Project-URL: Repository, https://github.com/Powerentity303/PNASystems_CRP
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: cryptography>=41
|
|
11
|
+
Requires-Dist: pnasys-encryption-service>=1.12
|
|
12
|
+
Requires-Dist: pnasys-ses>=0.1.0
|
|
13
|
+
|
|
14
|
+
# pnasyscnct — tailscale-style Pi remote (computer + Pi)
|
|
15
|
+
|
|
16
|
+
One package, both ends. Install on Windows 11 (TPM 2.0) and on Pi 4/5
|
|
17
|
+
(Raspberry Pi OS, headless or desktop) — via pip or via GitHub, identical:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install pnasyscnct
|
|
21
|
+
# or:
|
|
22
|
+
pip install git+https://github.com/Powerentity303/PNASystems_CRP.git
|
|
23
|
+
# or the one-liner (venv-safe on Pi OS):
|
|
24
|
+
curl -fsSL https://raw.githubusercontent.com/Powerentity303/PNASystems_CRP/main/installer.sh | bash
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This gives you **both** commands: `pnasyscnct` (computer) and `pnasyscrp` (Pi).
|
|
28
|
+
|
|
29
|
+
## Computer
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pnasyscnct setup # same questions + TPM-sealed vault, prints computer ID
|
|
33
|
+
pnasyscnct setup --ssh # scan for the Pi's pairing request, verify, rotate link key
|
|
34
|
+
pnasyscnct ssh # remote shell (reconnects on loss)
|
|
35
|
+
pnasyscnct mcp --enckey K # MCP server (OpenCode), SSH always active
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
OpenCode (`opencode.json`):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"mcp": {"pnasys-ssh": {"type": "local",
|
|
42
|
+
"command": ["pnasyscnct", "mcp", "--enckey", "LINK_KEY"],
|
|
43
|
+
"enabled": true}}}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Pi
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pnasyscrp setup # identity + AES vault, prints access key
|
|
50
|
+
pnasyscrp enable # listener + fresh session key (10-min idle auto-off)
|
|
51
|
+
pnasyscrp ssh setup # pairing: key first, code issued, enter computer ID
|
|
52
|
+
pnasyscrp ssh enable # presence + listener (root-aware exec)
|
|
53
|
+
pnasyscrp update # reinstall latest (cache-busted)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Linking in short: computer `setup --ssh` scans → Pi `ssh setup` sends an
|
|
57
|
+
encrypted pairing code to that computer ID → computer verifies with the
|
|
58
|
+
pairing key, both adopt a fresh link key → Pi verifies the answer the same
|
|
59
|
+
way. Vercel only relays opaque blobs. Pi exec runs root-aware (`sudo -n`
|
|
60
|
+
when not uid 0).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# pnasyscnct — tailscale-style Pi remote (computer + Pi)
|
|
2
|
+
|
|
3
|
+
One package, both ends. Install on Windows 11 (TPM 2.0) and on Pi 4/5
|
|
4
|
+
(Raspberry Pi OS, headless or desktop) — via pip or via GitHub, identical:
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install pnasyscnct
|
|
8
|
+
# or:
|
|
9
|
+
pip install git+https://github.com/Powerentity303/PNASystems_CRP.git
|
|
10
|
+
# or the one-liner (venv-safe on Pi OS):
|
|
11
|
+
curl -fsSL https://raw.githubusercontent.com/Powerentity303/PNASystems_CRP/main/installer.sh | bash
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This gives you **both** commands: `pnasyscnct` (computer) and `pnasyscrp` (Pi).
|
|
15
|
+
|
|
16
|
+
## Computer
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnasyscnct setup # same questions + TPM-sealed vault, prints computer ID
|
|
20
|
+
pnasyscnct setup --ssh # scan for the Pi's pairing request, verify, rotate link key
|
|
21
|
+
pnasyscnct ssh # remote shell (reconnects on loss)
|
|
22
|
+
pnasyscnct mcp --enckey K # MCP server (OpenCode), SSH always active
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
OpenCode (`opencode.json`):
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{"mcp": {"pnasys-ssh": {"type": "local",
|
|
29
|
+
"command": ["pnasyscnct", "mcp", "--enckey", "LINK_KEY"],
|
|
30
|
+
"enabled": true}}}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Pi
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnasyscrp setup # identity + AES vault, prints access key
|
|
37
|
+
pnasyscrp enable # listener + fresh session key (10-min idle auto-off)
|
|
38
|
+
pnasyscrp ssh setup # pairing: key first, code issued, enter computer ID
|
|
39
|
+
pnasyscrp ssh enable # presence + listener (root-aware exec)
|
|
40
|
+
pnasyscrp update # reinstall latest (cache-busted)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Linking in short: computer `setup --ssh` scans → Pi `ssh setup` sends an
|
|
44
|
+
encrypted pairing code to that computer ID → computer verifies with the
|
|
45
|
+
pairing key, both adopt a fresh link key → Pi verifies the answer the same
|
|
46
|
+
way. Vercel only relays opaque blobs. Pi exec runs root-aware (`sudo -n`
|
|
47
|
+
when not uid 0).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pnasyscnct"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "PNASystems Connect — tailscale-style Pi remote: computer + Pi, SSH over queue, MCP"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = ["cryptography>=41", "pnasys-encryption-service>=1.12", "pnasys-ses>=0.1.0"]
|
|
12
|
+
authors = [{name="PNASystems"}]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/Powerentity303/PNASystems_CRP"
|
|
16
|
+
Repository = "https://github.com/Powerentity303/PNASystems_CRP"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
pnasyscnct = "pnasyscnct.cli:main"
|
|
20
|
+
pnasyscrp = "pnasyscnct.pi_cli:main"
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.packages.find]
|
|
23
|
+
where = ["src"]
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""pnasyscnct — computer side (Windows 11, TPM 2.0).
|
|
2
|
+
|
|
3
|
+
pnasyscnct setup fav restaurant/animal/color + pc user/pass + local pw
|
|
4
|
+
pnasyscnct setup --ssh link a Pi (scan for its pairing request)
|
|
5
|
+
pnasyscnct ssh remote shell into the linked Pi (reconnects)
|
|
6
|
+
pnasyscnct mcp --enckey K stdio MCP server, SSH always active
|
|
7
|
+
pnasyscnct selftest offline crypto check
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import getpass
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from pnasyscnct import link as L
|
|
21
|
+
from pnasyscnct.common import (decrypt_local, derive_key_iv, encrypt_local,
|
|
22
|
+
generate_session_key, interleave3, make_access_sha1024,
|
|
23
|
+
make_access_variant, make_pi_blob, secure_pack,
|
|
24
|
+
secure_unpack, sha256_hex)
|
|
25
|
+
|
|
26
|
+
HOME = Path.home()
|
|
27
|
+
SAFE = HOME / ".pnasys_crp"
|
|
28
|
+
VAULT = SAFE / ".vault"
|
|
29
|
+
CREDS = VAULT / "creds.txt"
|
|
30
|
+
ACCESS = VAULT / "access.enc.json"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _ensure() -> None:
|
|
34
|
+
SAFE.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
35
|
+
VAULT.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _save_state(s: dict) -> None:
|
|
39
|
+
_ensure()
|
|
40
|
+
cur = L.load_state()
|
|
41
|
+
cur.update(s)
|
|
42
|
+
(SAFE / "state.json").write_text(json.dumps(cur, indent=2))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_setup() -> int:
|
|
46
|
+
_ensure()
|
|
47
|
+
print("pnasyscnct setup (computer)")
|
|
48
|
+
fav_rest = input("Favorite restaurant: ").strip()
|
|
49
|
+
fav_animal = input("Favorite animal: ").strip()
|
|
50
|
+
fav_color = input("Favorite color: ").strip()
|
|
51
|
+
pc_user = input("Computer username: ").strip()
|
|
52
|
+
pc_pass = getpass.getpass("Computer password: ")
|
|
53
|
+
local_pw = getpass.getpass("Local encryption password (anything): ")
|
|
54
|
+
if not all([fav_rest, fav_animal, fav_color, pc_user, pc_pass, local_pw]):
|
|
55
|
+
print("All fields required.", file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
_ = derive_key_iv(local_pw)
|
|
58
|
+
CREDS.write_text(json.dumps(encrypt_local(f"{pc_user}\n{pc_pass}\n".encode(), local_pw)))
|
|
59
|
+
computer_id = uuid.uuid4().hex
|
|
60
|
+
uuid1 = str(uuid.uuid4())
|
|
61
|
+
blob = make_pi_blob(uuid1, fav_color, fav_rest)
|
|
62
|
+
_, _u2, access = make_access_variant(uuid1, fav_color, fav_rest)
|
|
63
|
+
_ = fav_animal, make_access_sha1024(blob, uuid1, fav_color, fav_rest)
|
|
64
|
+
ACCESS.write_text(json.dumps(encrypt_local(access.encode(), local_pw)))
|
|
65
|
+
from pnasys_ses import SecureEncryptionService as SES
|
|
66
|
+
|
|
67
|
+
SES.CreateEncryptedFile(json.dumps({"computer_id": computer_id}), "cnct_self", local_pw)
|
|
68
|
+
_save_state({"setup_done": True, "computer_id": computer_id})
|
|
69
|
+
for f in (CREDS, ACCESS):
|
|
70
|
+
try:
|
|
71
|
+
os.chmod(f, 0o600)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
print(f"Computer ID: {computer_id}")
|
|
75
|
+
print("Setup complete. Vault sealed with TPM (pnasys-ses).")
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _unlock() -> tuple[str, dict]:
|
|
80
|
+
"""Prompt local pw; return (pw, {"computer_id": ...}). Never prints secrets."""
|
|
81
|
+
from pnasys_ses import SecureEncryptionService as SES
|
|
82
|
+
|
|
83
|
+
pw = getpass.getpass("Local encryption password: ")
|
|
84
|
+
try:
|
|
85
|
+
link = json.loads(SES.DecryptEncryptedFile("cnct_link", pw))
|
|
86
|
+
print("Vault unlocked (linked).")
|
|
87
|
+
return pw, link
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
try:
|
|
91
|
+
me = json.loads(SES.DecryptEncryptedFile("cnct_self", pw))
|
|
92
|
+
print("Vault unlocked.")
|
|
93
|
+
return pw, me
|
|
94
|
+
except Exception:
|
|
95
|
+
print("Wrong password or no setup. Run: pnasyscnct setup", file=sys.stderr)
|
|
96
|
+
sys.exit(2)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cmd_setup_ssh() -> int:
|
|
100
|
+
"""Scan for a Pi pairing request, verify, rotate to a fresh link key."""
|
|
101
|
+
pw, me = _unlock()
|
|
102
|
+
computer_id = me.get("computer_id") or L.load_state().get("computer_id", "")
|
|
103
|
+
if not computer_id:
|
|
104
|
+
print("No computer ID. Run: pnasyscnct setup", file=sys.stderr)
|
|
105
|
+
return 2
|
|
106
|
+
print(f"Scanning for Pi pairing requests (computer {computer_id[:8]}...). Ctrl+C to stop.")
|
|
107
|
+
req = None
|
|
108
|
+
while req is None:
|
|
109
|
+
try:
|
|
110
|
+
r = L.link_poll(computer_id)
|
|
111
|
+
except KeyboardInterrupt:
|
|
112
|
+
print("\nStopped.")
|
|
113
|
+
return 130
|
|
114
|
+
except Exception as e:
|
|
115
|
+
print(f"scan error, retrying: {e}")
|
|
116
|
+
time.sleep(3)
|
|
117
|
+
continue
|
|
118
|
+
if r.get("empty"):
|
|
119
|
+
continue
|
|
120
|
+
req = r
|
|
121
|
+
print("Pairing request received.")
|
|
122
|
+
pair_key = getpass.getpass("Pairing encryption key (the one entered on the Pi): ")
|
|
123
|
+
new_key = getpass.getpass("NEW link encryption key (choose now, Pi will adopt it): ")
|
|
124
|
+
if not new_key:
|
|
125
|
+
print("A new link key is required.", file=sys.stderr)
|
|
126
|
+
return 2
|
|
127
|
+
try:
|
|
128
|
+
inner = secure_unpack(pair_key, req["blob"])
|
|
129
|
+
pi_ident = inner["pi_ident"]
|
|
130
|
+
except Exception:
|
|
131
|
+
print("Decrypt failed — wrong pairing key?", file=sys.stderr)
|
|
132
|
+
return 1
|
|
133
|
+
ans = secure_pack(pair_key, {"computer_id": computer_id, "link_key": new_key,
|
|
134
|
+
"ok": True, "ts": int(time.time())})
|
|
135
|
+
r = L.link_answer(computer_id, ans, for_pi=pi_ident)
|
|
136
|
+
if not r.get("ok"):
|
|
137
|
+
print(f"Answer failed: {r}", file=sys.stderr)
|
|
138
|
+
return 1
|
|
139
|
+
L.pc_save_link(computer_id, pi_ident, new_key, pw)
|
|
140
|
+
print("Linked. Pi verified and both sides hold the fresh link key.")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _shell(pi_ident: str, link_key: str) -> int:
|
|
145
|
+
print(f"Remote shell (link {pi_ident[:8]}...). :put local remote | :get remote local | :exit")
|
|
146
|
+
while True:
|
|
147
|
+
try:
|
|
148
|
+
line = input("pi# ").strip()
|
|
149
|
+
except (EOFError, KeyboardInterrupt):
|
|
150
|
+
print()
|
|
151
|
+
return 0
|
|
152
|
+
if not line:
|
|
153
|
+
continue
|
|
154
|
+
if line in (":exit", ":quit"):
|
|
155
|
+
return 0
|
|
156
|
+
try:
|
|
157
|
+
if line.startswith(":put "):
|
|
158
|
+
_, local, remote = line.split(None, 2)
|
|
159
|
+
raw = Path(local).expanduser().read_bytes()
|
|
160
|
+
import base64 as _b64
|
|
161
|
+
|
|
162
|
+
op = {"kind": "write", "path": remote, "data_b64": _b64.b64encode(raw).decode()}
|
|
163
|
+
if len(raw) > 4_000_000:
|
|
164
|
+
print("Large file: streaming in encrypted chunks...")
|
|
165
|
+
_stream_write(pi_ident, link_key, remote, raw)
|
|
166
|
+
continue
|
|
167
|
+
elif line.startswith(":get "):
|
|
168
|
+
_, remote, local = line.split(None, 2)
|
|
169
|
+
op = {"kind": "read", "path": remote}
|
|
170
|
+
else:
|
|
171
|
+
op = {"kind": "exec", "cmd": line[:4000]}
|
|
172
|
+
rid = f"sh{int(time.time() * 1000)}"
|
|
173
|
+
blob = secure_pack(link_key, op)
|
|
174
|
+
r = L.enqueue(_access_for(link_key, pi_ident), "secure", {"blob": blob}, rid)
|
|
175
|
+
if not r.get("ok"):
|
|
176
|
+
print(f"send failed: {r.get('error', r)} — retrying...")
|
|
177
|
+
time.sleep(3)
|
|
178
|
+
continue
|
|
179
|
+
res = L.wait_result(pi_ident, rid, 180)
|
|
180
|
+
if res.get("pending"):
|
|
181
|
+
print("(no reply yet — Pi may be offline; result kept, retry :status)")
|
|
182
|
+
continue
|
|
183
|
+
inner = secure_unpack(link_key, res["enc"])
|
|
184
|
+
if inner.get("out"):
|
|
185
|
+
print(inner["out"], end="" if inner["out"].endswith("\n") else "\n")
|
|
186
|
+
if inner.get("data_b64"):
|
|
187
|
+
out = input("save output to local path: ").strip()
|
|
188
|
+
if out:
|
|
189
|
+
import base64 as _b64
|
|
190
|
+
|
|
191
|
+
Path(out).expanduser().write_bytes(_b64.b64decode(inner["data_b64"]))
|
|
192
|
+
print(f"saved {out}")
|
|
193
|
+
if inner.get("rc") not in (None, 0):
|
|
194
|
+
print(f"[rc={inner.get('rc')}] {inner.get('err', '')}")
|
|
195
|
+
elif inner.get("error"):
|
|
196
|
+
print(f"Pi error: {inner['error']}")
|
|
197
|
+
except (EOFError, KeyboardInterrupt):
|
|
198
|
+
print()
|
|
199
|
+
return 0
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"link error ({e}) — reconnecting...")
|
|
202
|
+
time.sleep(3)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _access_for(link_key: str, pi_ident: str) -> str:
|
|
206
|
+
"""Registration check needs the access key — but SSH uses link identity.
|
|
207
|
+
|
|
208
|
+
The Pi registered under its access key; pairing stored pi_ident. For
|
|
209
|
+
enqueue auth we send pi_ident as the selector: the server accepts either
|
|
210
|
+
a registered access key or a paired pi_ident for kind=secure.
|
|
211
|
+
"""
|
|
212
|
+
return f"ident:{pi_ident}"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _stream_write(pi_ident: str, link_key: str, remote: str, raw: bytes) -> None:
|
|
216
|
+
from pnasyscnct.common import secure_pack as _sp
|
|
217
|
+
|
|
218
|
+
import base64 as _b64
|
|
219
|
+
|
|
220
|
+
rid = f"sw{int(time.time() * 1000)}"
|
|
221
|
+
n = 500_000
|
|
222
|
+
parts = [_sp(link_key, {"chunk": _b64.b64encode(raw[i:i + n]).decode()})
|
|
223
|
+
for i in range(0, len(raw), n)] or [_sp(link_key, {"chunk": ""})]
|
|
224
|
+
r = L.enqueue(f"ident:{pi_ident}", "secure",
|
|
225
|
+
{"blob": _sp(link_key, {"kind": "write-parts", "path": remote,
|
|
226
|
+
"parts": parts})}, rid)
|
|
227
|
+
if not r.get("ok"):
|
|
228
|
+
print(f"stream failed: {r.get('error', r)}")
|
|
229
|
+
return
|
|
230
|
+
res = L.wait_result(pi_ident, rid, 300)
|
|
231
|
+
ok = res.get("ok") if isinstance(res, dict) else False
|
|
232
|
+
print(f"streamed {len(raw)} bytes in {len(parts)} encrypted chunks (ok={ok})")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_ssh() -> int:
|
|
236
|
+
pw, link = _unlock()
|
|
237
|
+
if "link_key" not in link:
|
|
238
|
+
print("Not linked yet. Run: pnasyscnct setup --ssh", file=sys.stderr)
|
|
239
|
+
return 2
|
|
240
|
+
L.ssh_join(link["computer_id"], link["pi_ident"],
|
|
241
|
+
secure_pack(link["link_key"], {"hello": "pc", "ts": int(time.time())}))
|
|
242
|
+
print("Session open. Reconnects automatically on failure.")
|
|
243
|
+
while True:
|
|
244
|
+
rc = _shell(link["pi_ident"], link["link_key"])
|
|
245
|
+
if rc == 0:
|
|
246
|
+
return 0
|
|
247
|
+
print("Shell exited abnormally — rejoining in 5s (Ctrl+C to quit)...")
|
|
248
|
+
try:
|
|
249
|
+
time.sleep(5)
|
|
250
|
+
except KeyboardInterrupt:
|
|
251
|
+
return 130
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def cmd_mcp(enckey: str) -> int:
|
|
255
|
+
from pnasyscnct.mcp_server import serve
|
|
256
|
+
|
|
257
|
+
st = L.load_state()
|
|
258
|
+
if not st.get("pi_ident") or not st.get("computer_id"):
|
|
259
|
+
print("Not linked yet. Run: pnasyscnct setup --ssh", file=sys.stderr)
|
|
260
|
+
return 2
|
|
261
|
+
if not enckey:
|
|
262
|
+
print("--enckey (the link encryption key) is required.", file=sys.stderr)
|
|
263
|
+
return 2
|
|
264
|
+
print("MCP server starting (SSH session active).", file=sys.stderr)
|
|
265
|
+
serve(link_key=enckey, pi_ident=st["pi_ident"], computer_id=st["computer_id"])
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_selftest() -> int:
|
|
270
|
+
pw = "selftest-pw"
|
|
271
|
+
key, iv = derive_key_iv(pw)
|
|
272
|
+
assert len(key) == 32 and len(iv) == 12
|
|
273
|
+
b = encrypt_local(b"hello-pc", pw)
|
|
274
|
+
assert decrypt_local(b, pw) == b"hello-pc"
|
|
275
|
+
sk = generate_session_key()
|
|
276
|
+
assert len(sk.split("=")) == 6
|
|
277
|
+
blob = secure_pack("k", {"kind": "exec", "cmd": "x"})
|
|
278
|
+
assert secure_unpack("k", blob)["cmd"] == "x"
|
|
279
|
+
print("selftest OK: AES vault, session keygen, secure pack/unpack")
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main(argv: list[str] | None = None) -> int:
|
|
284
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
285
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
286
|
+
print("Usage: pnasyscnct {setup [--ssh]|ssh|mcp --enckey KEY|selftest}")
|
|
287
|
+
return 0
|
|
288
|
+
if argv[0] == "setup" and "--ssh" in argv[1:]:
|
|
289
|
+
return cmd_setup_ssh()
|
|
290
|
+
if argv[0] == "setup":
|
|
291
|
+
return cmd_setup()
|
|
292
|
+
if argv[0] == "ssh":
|
|
293
|
+
return cmd_ssh()
|
|
294
|
+
if argv[0] == "mcp":
|
|
295
|
+
key = ""
|
|
296
|
+
for i, a in enumerate(argv[1:]):
|
|
297
|
+
if a == "--enckey" and i + 1 < len(argv[1:]):
|
|
298
|
+
key = argv[1:][i + 1]
|
|
299
|
+
return cmd_mcp(key)
|
|
300
|
+
if argv[0] == "selftest":
|
|
301
|
+
return cmd_selftest()
|
|
302
|
+
print(f"Unknown command: {argv[0]}", file=sys.stderr)
|
|
303
|
+
return 2
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
if __name__ == "__main__":
|
|
307
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Shared crypto: AES-256-GCM at-rest, pnasys transport, keygen, identity math.
|
|
2
|
+
|
|
3
|
+
- AES key = SHA256(password).digest() (32B)
|
|
4
|
+
- AES iv = SHA256(reverse(b64(reverse(password)))).digest()[:12]
|
|
5
|
+
- Transport (pnasys-encryption-service): session/link keys, exact ops.
|
|
6
|
+
- Session/link code generator: exact PNASystemsKeyGenerator.py logic.
|
|
7
|
+
- Pi identity: interleave blob + SHA1024 + uuid2 variant (unchanged spec).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import datetime
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def sha256_hex(s: str) -> str:
|
|
20
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def derive_key_iv(password: str) -> tuple[bytes, bytes]:
|
|
24
|
+
key = hashlib.sha256(password.encode("utf-8")).digest()
|
|
25
|
+
rev_b64 = base64.b64encode(password[::-1].encode("utf-8")).decode("ascii")[::-1]
|
|
26
|
+
return key, hashlib.sha256(rev_b64.encode("utf-8")).digest()[:12]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encrypt_local(plaintext: bytes, password: str) -> dict:
|
|
30
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
31
|
+
|
|
32
|
+
key, _ = derive_key_iv(password)
|
|
33
|
+
nonce = os.urandom(12)
|
|
34
|
+
return {"nonce_b64": base64.b64encode(nonce).decode("ascii"),
|
|
35
|
+
"ct_b64": base64.b64encode(AESGCM(key).encrypt(nonce, plaintext, None)).decode("ascii")}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def decrypt_local(bundle: dict, password: str) -> bytes:
|
|
39
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
40
|
+
|
|
41
|
+
key, _ = derive_key_iv(password)
|
|
42
|
+
return AESGCM(key).decrypt(base64.b64decode(bundle["nonce_b64"]),
|
|
43
|
+
base64.b64decode(bundle["ct_b64"]), None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def secure_pack(key: str, op: dict) -> str:
|
|
47
|
+
import json as _json
|
|
48
|
+
|
|
49
|
+
from pnasys_encryption_service import EncryptString
|
|
50
|
+
|
|
51
|
+
return EncryptString(_json.dumps(op, separators=(",", ":")), key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def secure_unpack(key: str, token: str) -> dict:
|
|
55
|
+
import json as _json
|
|
56
|
+
|
|
57
|
+
from pnasys_encryption_service import DecryptString
|
|
58
|
+
|
|
59
|
+
return _json.loads(DecryptString(token, key))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- session/link code generator (exact PNASystemsKeyGenerator.py) ---
|
|
63
|
+
|
|
64
|
+
def RandomService(Minimum, Maximum) -> int:
|
|
65
|
+
return Minimum + secrets.randbelow(Maximum - Minimum + 1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ToHexSHA256(Input: str) -> str:
|
|
69
|
+
return hashlib.sha256(Input.encode("utf-8")).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def GetRandomNumber() -> int:
|
|
73
|
+
A = 0
|
|
74
|
+
B = RandomService(123821, 98169809)
|
|
75
|
+
C = RandomService(838390, 67867189)
|
|
76
|
+
if B == C:
|
|
77
|
+
B += RandomService(1, 100)
|
|
78
|
+
if B > C:
|
|
79
|
+
A = round(B / C)
|
|
80
|
+
else:
|
|
81
|
+
A = round(C / B)
|
|
82
|
+
return A
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def ReverseString(String: str) -> str:
|
|
86
|
+
return String[::-1]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def generate_session_key() -> str:
|
|
90
|
+
from pnasys_encryption_service import GenerateEncryptionKey
|
|
91
|
+
|
|
92
|
+
EncryptionKeyBase = GenerateEncryptionKey()
|
|
93
|
+
try:
|
|
94
|
+
EncryptionKeyPart1 = str(uuid.uuid8()) # exact original call
|
|
95
|
+
except AttributeError:
|
|
96
|
+
EncryptionKeyPart1 = str(uuid.uuid4()) # stdlib has no uuid8
|
|
97
|
+
EncryptionKeyPart2 = (EncryptionKeyPart1
|
|
98
|
+
+ datetime.datetime.now().strftime("%d/%m/%Y")
|
|
99
|
+
+ os.getcwd() + str(GetRandomNumber()))
|
|
100
|
+
EncryptionKeyPart3 = ReverseString(EncryptionKeyPart2)
|
|
101
|
+
EncryptionKeyPart4 = (EncryptionKeyPart2 + "-" + EncryptionKeyBase + "-"
|
|
102
|
+
+ ReverseString(EncryptionKeyBase) + "-" + EncryptionKeyPart3)
|
|
103
|
+
Hash1 = ToHexSHA256(EncryptionKeyPart4)
|
|
104
|
+
Hash2 = ToHexSHA256(EncryptionKeyBase)
|
|
105
|
+
Hash3 = ToHexSHA256(Hash1 + EncryptionKeyBase + ReverseString(EncryptionKeyBase)
|
|
106
|
+
+ ReverseString(EncryptionKeyBase) + ReverseString(Hash1))
|
|
107
|
+
return (Hash1 + "=" + Hash2 + "=" + Hash3 + "=" + ReverseString(Hash3)
|
|
108
|
+
+ "=" + ReverseString(Hash2) + "=" + ReverseString(Hash1))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# --- identity math (unchanged spec) ---
|
|
112
|
+
|
|
113
|
+
def interleave3(a: str, b: str, c: str) -> str:
|
|
114
|
+
out: list[str] = []
|
|
115
|
+
for i in range(max(len(a), len(b), len(c))):
|
|
116
|
+
if i < len(a):
|
|
117
|
+
out.append(a[i])
|
|
118
|
+
if i < len(b):
|
|
119
|
+
out.append(b[i])
|
|
120
|
+
if i < len(c):
|
|
121
|
+
out.append(c[i])
|
|
122
|
+
return "".join(out)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def make_pi_blob(uuid4str: str, favcolor: str, favrest: str) -> str:
|
|
126
|
+
return interleave3(sha256_hex(uuid4str), sha256_hex(favcolor), sha256_hex(favrest))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def make_access_sha1024(combined: str, uuid4str: str, favcolor: str, favrest: str) -> str:
|
|
130
|
+
p1 = sha256_hex(combined)
|
|
131
|
+
p2 = sha256_hex(f"{uuid4str}-{p1}-{p1[::-1]}-{uuid4str[::-1]}")
|
|
132
|
+
p3 = sha256_hex(f"{favcolor}-{p2}-{p2[::-1]}-{favcolor[::-1]}")
|
|
133
|
+
p4 = sha256_hex(f"{favrest}-{p3}-{p3[::-1]}-{favrest[::-1]}")
|
|
134
|
+
return f"{p1}-{p2}-{p3}-{p4}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def make_access_variant(uuid1: str, favcolor: str, favrest: str, uuid2: str | None = None):
|
|
138
|
+
uuid2 = uuid2 or str(uuid.uuid4())
|
|
139
|
+
combined2 = interleave3(sha256_hex(f"{uuid1}-{uuid2}"),
|
|
140
|
+
sha256_hex(f"{favcolor}-{uuid2}"),
|
|
141
|
+
sha256_hex(f"{favrest}-{uuid2}"))
|
|
142
|
+
return combined2, uuid2, make_access_sha1024(combined2, uuid2, favcolor, favrest)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def derive_pi_encryption_key(accesskey: str) -> str:
|
|
146
|
+
akh = sha256_hex(accesskey)
|
|
147
|
+
h1 = sha256_hex(accesskey + akh)
|
|
148
|
+
h2 = sha256_hex(akh + h1 + accesskey)
|
|
149
|
+
h3 = sha256_hex(h2 + accesskey + h1)
|
|
150
|
+
h4 = sha256_hex(h1 + akh + h2)
|
|
151
|
+
return f"{h1}-{h3}-{h4}-{h2}"
|