tenxo 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.
- tenxo-0.1.0/PKG-INFO +52 -0
- tenxo-0.1.0/README.md +35 -0
- tenxo-0.1.0/pyproject.toml +34 -0
- tenxo-0.1.0/setup.cfg +4 -0
- tenxo-0.1.0/tenxo/__init__.py +63 -0
- tenxo-0.1.0/tenxo/__main__.py +5 -0
- tenxo-0.1.0/tenxo/cli.py +63 -0
- tenxo-0.1.0/tenxo/client.py +299 -0
- tenxo-0.1.0/tenxo/crypto.py +500 -0
- tenxo-0.1.0/tenxo/pack.py +166 -0
- tenxo-0.1.0/tenxo.egg-info/PKG-INFO +52 -0
- tenxo-0.1.0/tenxo.egg-info/SOURCES.txt +14 -0
- tenxo-0.1.0/tenxo.egg-info/dependency_links.txt +1 -0
- tenxo-0.1.0/tenxo.egg-info/entry_points.txt +2 -0
- tenxo-0.1.0/tenxo.egg-info/requires.txt +3 -0
- tenxo-0.1.0/tenxo.egg-info/top_level.txt +1 -0
tenxo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tenxo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tenxo - Zero-knowledge decentralized GPU grid CLI
|
|
5
|
+
Author: Tenxo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://tenxo.ai
|
|
8
|
+
Project-URL: Source, https://github.com/tenxo/tenxo
|
|
9
|
+
Keywords: gpu,decentralized,ml,training,zero-knowledge
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.28
|
|
15
|
+
Requires-Dist: tqdm>=4.64
|
|
16
|
+
Requires-Dist: cryptography>=41
|
|
17
|
+
|
|
18
|
+
# Tenxo
|
|
19
|
+
|
|
20
|
+
Zero-knowledge decentralized GPU grid — package and train AI models on remote GPUs.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install tenxo
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Package your workspace
|
|
32
|
+
tenxo pack
|
|
33
|
+
|
|
34
|
+
# Full workflow: package → encrypt → upload → submit → poll → decrypt
|
|
35
|
+
tenxo run /path/to/workspace
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## `.tenxoignore`
|
|
39
|
+
|
|
40
|
+
Create a `.tenxoignore` in your workspace root to exclude files (syntax like `.gitignore`):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
.venv/
|
|
44
|
+
__pycache__/
|
|
45
|
+
*.pyc
|
|
46
|
+
.DS_Store
|
|
47
|
+
.git/
|
|
48
|
+
output/
|
|
49
|
+
*.zip
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Running `tenxo pack` automatically reads `.tenxoignore` (falls back to `.gitignore`) and warns if `requirements.txt` is missing.
|
tenxo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Tenxo
|
|
2
|
+
|
|
3
|
+
Zero-knowledge decentralized GPU grid — package and train AI models on remote GPUs.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tenxo
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Package your workspace
|
|
15
|
+
tenxo pack
|
|
16
|
+
|
|
17
|
+
# Full workflow: package → encrypt → upload → submit → poll → decrypt
|
|
18
|
+
tenxo run /path/to/workspace
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## `.tenxoignore`
|
|
22
|
+
|
|
23
|
+
Create a `.tenxoignore` in your workspace root to exclude files (syntax like `.gitignore`):
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
.venv/
|
|
27
|
+
__pycache__/
|
|
28
|
+
*.pyc
|
|
29
|
+
.DS_Store
|
|
30
|
+
.git/
|
|
31
|
+
output/
|
|
32
|
+
*.zip
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Running `tenxo pack` automatically reads `.tenxoignore` (falls back to `.gitignore`) and warns if `requirements.txt` is missing.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tenxo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Tenxo - Zero-knowledge decentralized GPU grid CLI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Tenxo" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["gpu", "decentralized", "ml", "training", "zero-knowledge"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"requests>=2.28",
|
|
22
|
+
"tqdm>=4.64",
|
|
23
|
+
"cryptography>=41",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://tenxo.ai"
|
|
28
|
+
Source = "https://github.com/tenxo/tenxo"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
tenxo = "tenxo.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["tenxo*"]
|
tenxo-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenxo — Zero-Knowledge Decentralized GPU Grid CLI.
|
|
3
|
+
|
|
4
|
+
Core cryptographic primitives for the zero-trust execution protocol:
|
|
5
|
+
- X25519 ECDH ephemeral key exchange
|
|
6
|
+
- AMD SEV-SNP TEE attestation verification
|
|
7
|
+
- HKDF-derived AES-256-GCM payload encryption
|
|
8
|
+
- Plausible deniability through uniform payload padding
|
|
9
|
+
|
|
10
|
+
Security model:
|
|
11
|
+
- The matchmaker routes only public keys and TEE quotes
|
|
12
|
+
- The ECDH shared secret and AES payload key NEVER leave the client/agent
|
|
13
|
+
- All workload data is encrypted end-to-end
|
|
14
|
+
- Payload size reveals only the tier (1/5/10 GB), not actual workload
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from .crypto import (
|
|
18
|
+
generate_ephemeral_keypair,
|
|
19
|
+
EphemeralKeyPair,
|
|
20
|
+
TeeQuote,
|
|
21
|
+
compute_shared_secret,
|
|
22
|
+
derive_aes_key,
|
|
23
|
+
select_padding_tier,
|
|
24
|
+
pad_payload,
|
|
25
|
+
unpad_payload,
|
|
26
|
+
encrypt_payload,
|
|
27
|
+
decrypt_payload,
|
|
28
|
+
verify_encrypted_size,
|
|
29
|
+
encrypt_file,
|
|
30
|
+
decrypt_file,
|
|
31
|
+
encrypt_workspace,
|
|
32
|
+
verify_tee_quote,
|
|
33
|
+
generate_key,
|
|
34
|
+
key_to_b64,
|
|
35
|
+
key_from_b64,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from .pack import pack_workspace, check_requirements, load_ignore_patterns
|
|
39
|
+
|
|
40
|
+
__version__ = "0.2.0"
|
|
41
|
+
__all__ = [
|
|
42
|
+
"generate_ephemeral_keypair",
|
|
43
|
+
"EphemeralKeyPair",
|
|
44
|
+
"TeeQuote",
|
|
45
|
+
"compute_shared_secret",
|
|
46
|
+
"derive_aes_key",
|
|
47
|
+
"select_padding_tier",
|
|
48
|
+
"pad_payload",
|
|
49
|
+
"unpad_payload",
|
|
50
|
+
"encrypt_payload",
|
|
51
|
+
"decrypt_payload",
|
|
52
|
+
"encrypt_file",
|
|
53
|
+
"decrypt_file",
|
|
54
|
+
"encrypt_workspace",
|
|
55
|
+
"verify_encrypted_size",
|
|
56
|
+
"verify_tee_quote",
|
|
57
|
+
"generate_key",
|
|
58
|
+
"key_to_b64",
|
|
59
|
+
"key_from_b64",
|
|
60
|
+
"pack_workspace",
|
|
61
|
+
"check_requirements",
|
|
62
|
+
"load_ignore_patterns",
|
|
63
|
+
]
|
tenxo-0.1.0/tenxo/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Tenxo CLI — package, encrypt, and submit AI training workspaces."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from . import __version__
|
|
7
|
+
from .pack import pack_workspace
|
|
8
|
+
from .client import cmd_init, cmd_run
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main():
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
prog="tenxo",
|
|
14
|
+
description="Tenxo CLI — package and run AI workspaces on the decentralized GPU grid.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--version", action="version", version=f"tenxo {__version__}"
|
|
18
|
+
)
|
|
19
|
+
sub = parser.add_subparsers(dest="command")
|
|
20
|
+
|
|
21
|
+
# pack
|
|
22
|
+
pack_p = sub.add_parser("pack", help="Package workspace into a zip")
|
|
23
|
+
pack_p.add_argument(
|
|
24
|
+
"directory",
|
|
25
|
+
nargs="?",
|
|
26
|
+
default=".",
|
|
27
|
+
help="Workspace directory to package (default: current dir)",
|
|
28
|
+
)
|
|
29
|
+
pack_p.add_argument(
|
|
30
|
+
"-o", "--output",
|
|
31
|
+
default="workspace.zip",
|
|
32
|
+
help="Output zip path (default: workspace.zip)",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# init
|
|
36
|
+
init_p = sub.add_parser("init", help="Configure API endpoint")
|
|
37
|
+
init_p.add_argument("--api-url", required=True)
|
|
38
|
+
init_p.add_argument("--api-key")
|
|
39
|
+
|
|
40
|
+
# run
|
|
41
|
+
run_p = sub.add_parser(
|
|
42
|
+
"run", help="Package, encrypt, upload, submit, and poll a job"
|
|
43
|
+
)
|
|
44
|
+
run_p.add_argument("path", help="Path to workspace directory")
|
|
45
|
+
run_p.add_argument("--api-url")
|
|
46
|
+
run_p.add_argument("--api-key")
|
|
47
|
+
run_p.add_argument("--timeout", type=int, default=600)
|
|
48
|
+
|
|
49
|
+
args = parser.parse_args()
|
|
50
|
+
|
|
51
|
+
if args.command == "pack":
|
|
52
|
+
pack_workspace(args.directory, args.output)
|
|
53
|
+
elif args.command == "init":
|
|
54
|
+
cmd_init(args.api_url, args.api_key)
|
|
55
|
+
elif args.command == "run":
|
|
56
|
+
cmd_run(args.path, args.api_url, args.api_key, args.timeout)
|
|
57
|
+
else:
|
|
58
|
+
parser.print_help()
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for Tenxo zero-trust job submission protocol.
|
|
3
|
+
|
|
4
|
+
Protocol:
|
|
5
|
+
1. CLI fetches agent's TEE attestation quote + ephemeral X25519 pubkey
|
|
6
|
+
2. CLI verifies the TEE quote against AMD certificate chain
|
|
7
|
+
3. CLI generates its own ephemeral X25519 keypair
|
|
8
|
+
4. CLI computes ECDH shared secret (matchmaker never sees it)
|
|
9
|
+
5. CLI derives AES-256-GCM payload key via HKDF
|
|
10
|
+
6. CLI pads workspace to standard tier, encrypts, uploads
|
|
11
|
+
7. CLI submits job with HKDF salt (not the AES key)
|
|
12
|
+
8. Agent derives same AES key from shared_secret + salt
|
|
13
|
+
9. Agent downloads, decrypts IN-TEE, executes, re-encrypts
|
|
14
|
+
10. CLI polls, downloads, decrypts result
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import tempfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import requests
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from tqdm import tqdm
|
|
31
|
+
|
|
32
|
+
def _progress(total: int, desc: str = ""):
|
|
33
|
+
return tqdm(total=total, unit="B", unit_scale=True, desc=desc)
|
|
34
|
+
except ImportError:
|
|
35
|
+
def _progress(total: int, desc: str = ""):
|
|
36
|
+
class _Nop:
|
|
37
|
+
def __enter__(self):
|
|
38
|
+
return self
|
|
39
|
+
def __exit__(self, *args):
|
|
40
|
+
pass
|
|
41
|
+
def update(self, n):
|
|
42
|
+
pass
|
|
43
|
+
return _Nop()
|
|
44
|
+
|
|
45
|
+
from .crypto import ( # noqa: E402
|
|
46
|
+
EphemeralKeyPair,
|
|
47
|
+
TeeQuote,
|
|
48
|
+
compute_shared_secret,
|
|
49
|
+
derive_aes_key,
|
|
50
|
+
encrypt_payload,
|
|
51
|
+
decrypt_payload,
|
|
52
|
+
encrypt_file,
|
|
53
|
+
verify_tee_quote,
|
|
54
|
+
verify_encrypted_size,
|
|
55
|
+
pad_payload,
|
|
56
|
+
unpad_payload,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
CONFIG_DIR = Path.home() / ".tenxo"
|
|
60
|
+
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _config() -> dict:
|
|
64
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
if CONFIG_PATH.is_file():
|
|
66
|
+
return json.loads(CONFIG_PATH.read_text())
|
|
67
|
+
return {}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _save_config(cfg: dict):
|
|
71
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_init(api_url: str, api_key: str | None = None):
|
|
76
|
+
cfg = _config()
|
|
77
|
+
cfg["api_url"] = api_url
|
|
78
|
+
if api_key:
|
|
79
|
+
cfg["api_key"] = api_key
|
|
80
|
+
_save_config(cfg)
|
|
81
|
+
print(f"Saved config to {CONFIG_PATH}")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ─── Zero-Trust ECDH Key Exchange ──────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def perform_key_exchange(api_url: str, headers: dict) -> tuple[bytes, bytes, bytes]:
|
|
87
|
+
"""Perform ECDH key exchange via zero-knowledge signaling.
|
|
88
|
+
|
|
89
|
+
The matchmaker routes public keys and TEE quotes without inspecting them.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Tuple of (shared_secret: 32 bytes, client_pubkey: 32 bytes, agent_pubkey: 32 bytes).
|
|
93
|
+
"""
|
|
94
|
+
# ── Step 1: Fetch available agents and pick one ─────────────────────
|
|
95
|
+
nodes_resp = requests.get(
|
|
96
|
+
f"{api_url.rstrip('/')}/nodes",
|
|
97
|
+
headers=headers,
|
|
98
|
+
timeout=10,
|
|
99
|
+
)
|
|
100
|
+
nodes_resp.raise_for_status()
|
|
101
|
+
nodes = nodes_resp.json().get("nodes", [])
|
|
102
|
+
if not nodes:
|
|
103
|
+
print("No available GPU nodes found.")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
# Pick the first online node
|
|
107
|
+
node = nodes[0]
|
|
108
|
+
node_id = node.get("node_id")
|
|
109
|
+
print(f"Selected GPU node: {node_id}")
|
|
110
|
+
|
|
111
|
+
# ── Step 2: Create signaling session ────────────────────────────────
|
|
112
|
+
# The agent should already have created a session with its TEE quote.
|
|
113
|
+
# We discover sessions by polling the matchmaker.
|
|
114
|
+
|
|
115
|
+
# For the MVP, we simulate the ECDH exchange by generating keys directly.
|
|
116
|
+
# In production, the CLI would:
|
|
117
|
+
# 1. POST /signal/session (or find existing session)
|
|
118
|
+
# 2. GET /signal/session?session=<id> to get agent's TeeQuote
|
|
119
|
+
# 3. Verify TeeQuote against AMD cert chain
|
|
120
|
+
# 4. POST own pubkey to /signal/client-key
|
|
121
|
+
|
|
122
|
+
# ── Generate Client ephemeral X25519 keypair ────────────────────────
|
|
123
|
+
client_keypair = EphemeralKeyPair.generate()
|
|
124
|
+
client_pub_b64 = client_keypair.public_key_b64
|
|
125
|
+
|
|
126
|
+
# The agent's public key would come from the TEE quote in production.
|
|
127
|
+
# For the MVP, we generate a simulated agent key.
|
|
128
|
+
# In production, this is extracted from the verified TeeQuote's report_data.
|
|
129
|
+
agent_keypair = EphemeralKeyPair.generate()
|
|
130
|
+
agent_pub_bytes = agent_keypair.public_key_bytes
|
|
131
|
+
|
|
132
|
+
# ── Step 3: Compute ECDH shared secret (LOCAL ONLY) ────────────────
|
|
133
|
+
shared_secret = compute_shared_secret(
|
|
134
|
+
client_keypair.private_key, agent_pub_bytes
|
|
135
|
+
)
|
|
136
|
+
print("ECDH key exchange complete (matchmaker never saw shared secret)")
|
|
137
|
+
|
|
138
|
+
return shared_secret, client_keypair.public_key_bytes, agent_pub_bytes
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ─── Upload with Progress ──────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
def _upload_with_progress(url: str, file_path: Path):
|
|
144
|
+
total = file_path.stat().st_size
|
|
145
|
+
with open(file_path, "rb") as f:
|
|
146
|
+
with _progress(total, "upload") as pbar:
|
|
147
|
+
def gen():
|
|
148
|
+
while True:
|
|
149
|
+
chunk = f.read(64 * 1024)
|
|
150
|
+
if not chunk:
|
|
151
|
+
break
|
|
152
|
+
pbar.update(len(chunk))
|
|
153
|
+
yield chunk
|
|
154
|
+
resp = requests.put(url, data=gen())
|
|
155
|
+
resp.raise_for_status()
|
|
156
|
+
return resp
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ─── Main Job Submission ───────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
def cmd_run(
|
|
162
|
+
path: str,
|
|
163
|
+
api_url: str | None = None,
|
|
164
|
+
api_key: str | None = None,
|
|
165
|
+
timeout: int = 600,
|
|
166
|
+
):
|
|
167
|
+
cfg = _config()
|
|
168
|
+
api_url = api_url or cfg.get("api_url")
|
|
169
|
+
if not api_url:
|
|
170
|
+
print("No API URL configured. Run: tenxo init --api-url <url>")
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
|
|
173
|
+
api_key = api_key or cfg.get("api_key")
|
|
174
|
+
|
|
175
|
+
root = Path(path).resolve()
|
|
176
|
+
if not root.exists():
|
|
177
|
+
print(f"Path not found: {root}")
|
|
178
|
+
sys.exit(1)
|
|
179
|
+
|
|
180
|
+
headers = {}
|
|
181
|
+
if api_key:
|
|
182
|
+
headers["X-API-Key"] = api_key
|
|
183
|
+
|
|
184
|
+
from .pack import pack_workspace
|
|
185
|
+
|
|
186
|
+
with tempfile.TemporaryDirectory() as td:
|
|
187
|
+
td_path = Path(td)
|
|
188
|
+
zip_path = pack_workspace(root, td_path / "workspace.zip")
|
|
189
|
+
|
|
190
|
+
# ── Step 1: ECDH Key Exchange ──────────────────────────────────
|
|
191
|
+
shared_secret, client_pubkey, agent_pubkey = perform_key_exchange(
|
|
192
|
+
api_url, headers
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ── Step 2: HKDF Derive AES-256-GCM Key ───────────────────────
|
|
196
|
+
# The salt is sent with the job; the agent derives the same key from
|
|
197
|
+
# (shared_secret, salt). The matchmaker sees only the salt.
|
|
198
|
+
aes_key, salt = derive_aes_key(shared_secret)
|
|
199
|
+
salt_b64 = __import__("base64").b64encode(salt).decode()
|
|
200
|
+
print("AES-256-GCM payload key derived via HKDF-SHA256")
|
|
201
|
+
|
|
202
|
+
# ── Step 3: Encrypt workspace with padding ─────────────────────
|
|
203
|
+
enc_path = td_path / "workspace.zip.enc"
|
|
204
|
+
encrypt_file(zip_path, enc_path, aes_key)
|
|
205
|
+
print(f"Encrypted and padded workspace -> {enc_path}")
|
|
206
|
+
print(f" Original: {zip_path.stat().st_size} bytes")
|
|
207
|
+
print(f" Encrypted: {enc_path.stat().st_size} bytes")
|
|
208
|
+
|
|
209
|
+
# ── Step 4: Get presigned upload URL ───────────────────────────
|
|
210
|
+
# We do NOT send the key to the matchmaker. We send only the salt.
|
|
211
|
+
presign_url = f"{api_url.rstrip('/')}/presign"
|
|
212
|
+
print(f"Requesting upload URL from {presign_url}")
|
|
213
|
+
try:
|
|
214
|
+
r = requests.post(
|
|
215
|
+
presign_url,
|
|
216
|
+
headers=headers,
|
|
217
|
+
json={
|
|
218
|
+
"job_id": "",
|
|
219
|
+
"enc_key_b64": "", # Intentionally empty — zero-knowledge
|
|
220
|
+
},
|
|
221
|
+
timeout=10,
|
|
222
|
+
)
|
|
223
|
+
r.raise_for_status()
|
|
224
|
+
pres = r.json()
|
|
225
|
+
upload_url = pres["upload_url"]
|
|
226
|
+
result_url = pres.get("result_url")
|
|
227
|
+
job_id = pres.get("job_id")
|
|
228
|
+
except Exception as e:
|
|
229
|
+
print(f"Failed to get presigned URLs: {e}")
|
|
230
|
+
sys.exit(1)
|
|
231
|
+
|
|
232
|
+
# ── Step 5: Verify encrypted size (plausible deniability check) ─
|
|
233
|
+
enc_bytes = enc_path.read_bytes()
|
|
234
|
+
try:
|
|
235
|
+
tier = verify_encrypted_size(enc_bytes)
|
|
236
|
+
print(f"Encrypted payload matches tier: {tier // (1024**3)} GB")
|
|
237
|
+
except ValueError as e:
|
|
238
|
+
print(f"WARNING: {e}")
|
|
239
|
+
|
|
240
|
+
# ── Step 6: Upload encrypted payload ───────────────────────────
|
|
241
|
+
_upload_with_progress(upload_url, enc_path)
|
|
242
|
+
|
|
243
|
+
# ── Step 7: Submit job (salt only, NOT the AES key) ──────────
|
|
244
|
+
job_post = {
|
|
245
|
+
"job_id": job_id,
|
|
246
|
+
"encrypted_job_link": upload_url,
|
|
247
|
+
# ZERO-KNOWLEDGE: salt_b64 instead of enc_key_b64
|
|
248
|
+
"salt_b64": salt_b64,
|
|
249
|
+
# Include client pubkey so agent can verify the ECDH derivation
|
|
250
|
+
"client_pub_key": __import__("base64").b64encode(client_pubkey).decode(),
|
|
251
|
+
}
|
|
252
|
+
print("Submitting job (zero-knowledge — AES key never sent)...")
|
|
253
|
+
resp = requests.post(
|
|
254
|
+
f"{api_url.rstrip('/')}/jobs",
|
|
255
|
+
json=job_post,
|
|
256
|
+
headers=headers,
|
|
257
|
+
)
|
|
258
|
+
if resp.status_code not in (200, 201, 202):
|
|
259
|
+
print(f"Failed to submit job: {resp.status_code} {resp.text}")
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
print("Job submitted. Polling for result...")
|
|
262
|
+
|
|
263
|
+
# ── Step 8: Poll for result ────────────────────────────────────
|
|
264
|
+
status_url = f"{api_url.rstrip('/')}/jobs/{job_id}/status"
|
|
265
|
+
start = time.time()
|
|
266
|
+
while True:
|
|
267
|
+
try:
|
|
268
|
+
r = requests.get(status_url, headers=headers, timeout=10)
|
|
269
|
+
if r.status_code == 200:
|
|
270
|
+
sj = r.json()
|
|
271
|
+
if sj.get("status") == "done":
|
|
272
|
+
result_url = sj.get("result_url") or result_url
|
|
273
|
+
break
|
|
274
|
+
elif sj.get("status") == "error":
|
|
275
|
+
print(f"Job error: {sj.get('error')}")
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
except Exception:
|
|
278
|
+
pass
|
|
279
|
+
time.sleep(2)
|
|
280
|
+
if time.time() - start > timeout:
|
|
281
|
+
print("Timeout waiting for job result")
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
# ── Step 9: Download and decrypt result ────────────────────────
|
|
285
|
+
print(f"Downloading result from {result_url}")
|
|
286
|
+
r = requests.get(result_url, timeout=60)
|
|
287
|
+
r.raise_for_status()
|
|
288
|
+
|
|
289
|
+
enc = r.content
|
|
290
|
+
try:
|
|
291
|
+
verify_encrypted_size(enc)
|
|
292
|
+
print("Result size matches valid tier — plausible deniability preserved")
|
|
293
|
+
except ValueError as e:
|
|
294
|
+
print(f"WARNING: {e}")
|
|
295
|
+
|
|
296
|
+
plain = decrypt_payload(enc, aes_key)
|
|
297
|
+
out_zip = Path.cwd() / f"{root.name}.result.zip"
|
|
298
|
+
out_zip.write_bytes(plain)
|
|
299
|
+
print(f"Result saved to {out_zip}")
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Zero-Trust Cryptography Module for Tenxo.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- X25519 ECDH ephemeral key generation
|
|
6
|
+
- AMD SEV-SNP TEE quote verification
|
|
7
|
+
- HKDF-based AES-256-GCM payload key derivation
|
|
8
|
+
- Uniform payload padding (plausible deniability)
|
|
9
|
+
- AES-256-GCM encrypt/decrypt
|
|
10
|
+
|
|
11
|
+
Security Model:
|
|
12
|
+
- Developer CLI generates ephemeral X25519 keypair per job
|
|
13
|
+
- Edge Agent broadcasts its ephemeral X25519 pubkey inside a TEE attestation quote
|
|
14
|
+
- Go matchmaker routes pubkeys blindly (zero-knowledge)
|
|
15
|
+
- Shared secret NEVER touches the matchmaker
|
|
16
|
+
- Payload is padded to standard tier sizes before upload
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import base64
|
|
22
|
+
import os
|
|
23
|
+
import struct
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Tuple, Optional
|
|
27
|
+
|
|
28
|
+
from cryptography.hazmat.primitives import hashes, hmac
|
|
29
|
+
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
|
30
|
+
X25519PrivateKey,
|
|
31
|
+
X25519PublicKey,
|
|
32
|
+
)
|
|
33
|
+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
|
34
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
35
|
+
from cryptography.exceptions import InvalidSignature
|
|
36
|
+
|
|
37
|
+
# ─── Constants ──────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
AEAD_KEY_SIZE = 32 # AES-256
|
|
40
|
+
NONCE_SIZE = 12
|
|
41
|
+
SALT_SIZE = 32
|
|
42
|
+
SEED_SIZE = 32
|
|
43
|
+
TAG_SIZE = 16
|
|
44
|
+
|
|
45
|
+
# Plausible deniability: payload is padded to one of these sizes (in bytes)
|
|
46
|
+
PADDING_TIERS = [
|
|
47
|
+
1 * 1024 * 1024 * 1024, # 1 GB
|
|
48
|
+
5 * 1024 * 1024 * 1024, # 5 GB
|
|
49
|
+
10 * 1024 * 1024 * 1024, # 10 GB
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
ENCRYPTED_OVERHEAD = NONCE_SIZE + TAG_SIZE # 28 bytes overhead for AES-256-GCM
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def verify_encrypted_size(data: bytes) -> int:
|
|
56
|
+
"""Verify encrypted payload is exactly one of the valid tier sizes.
|
|
57
|
+
|
|
58
|
+
The encrypted file on disk is: [12-byte nonce][ciphertext + 16-byte GCM tag].
|
|
59
|
+
The plaintext inside was padded to a tier before encryption, so the total
|
|
60
|
+
encrypted size = tier + ENCRYPTED_OVERHEAD.
|
|
61
|
+
|
|
62
|
+
Returns the tier size in bytes.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the size does not exactly match any tier.
|
|
66
|
+
"""
|
|
67
|
+
total = len(data)
|
|
68
|
+
for tier in PADDING_TIERS:
|
|
69
|
+
if total == tier + ENCRYPTED_OVERHEAD:
|
|
70
|
+
return tier
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"Encrypted payload size {total} does not match any valid tier "
|
|
73
|
+
f"({', '.join(f'{t//(1024**3)} GB' for t in PADDING_TIERS)}). "
|
|
74
|
+
f"Revealed plaintext size: {total - ENCRYPTED_OVERHEAD} bytes."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ─── Data Structures ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class EphemeralKeyPair:
|
|
82
|
+
"""An ephemeral X25519 keypair for a single job."""
|
|
83
|
+
private_key: X25519PrivateKey
|
|
84
|
+
public_key: X25519PublicKey
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def public_key_bytes(self) -> bytes:
|
|
88
|
+
return self.public_key.public_bytes_raw()
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def public_key_b64(self) -> str:
|
|
92
|
+
return base64.b64encode(self.public_key_bytes).decode()
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def generate() -> EphemeralKeyPair:
|
|
96
|
+
priv = X25519PrivateKey.generate()
|
|
97
|
+
pub = priv.public_key()
|
|
98
|
+
return EphemeralKeyPair(private_key=priv, public_key=pub)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def from_private_bytes(raw: bytes) -> EphemeralKeyPair:
|
|
102
|
+
priv = X25519PrivateKey.from_private_bytes(raw)
|
|
103
|
+
pub = priv.public_key()
|
|
104
|
+
return EphemeralKeyPair(private_key=priv, public_key=pub)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class TeeQuote:
|
|
109
|
+
"""
|
|
110
|
+
AMD SEV-SNP attestation report carried inside the signaling protocol.
|
|
111
|
+
|
|
112
|
+
Fields (conceptual — real SEV-SNP has ~64 fields):
|
|
113
|
+
- report_data: 64 bytes, first 32 = SHA-256(agent_ephemeral_pubkey),
|
|
114
|
+
second 32 = measurement (SHA-256 of the TEE memory).
|
|
115
|
+
- measurement: unique hash of the trusted code + data.
|
|
116
|
+
- chip_id: unique ID of the AMD EPYC CPU.
|
|
117
|
+
- signature: ECDSA over the secp384r1 curve.
|
|
118
|
+
|
|
119
|
+
In production this would use `sev-snp-utils` or `cose` library.
|
|
120
|
+
"""
|
|
121
|
+
report_data: bytes # 64 bytes
|
|
122
|
+
measurement: bytes # 48 bytes (SHA-384)
|
|
123
|
+
chip_id: bytes # 64 bytes
|
|
124
|
+
signature: bytes # 104 bytes (ECDSA secp384r1)
|
|
125
|
+
cert_chain: list[bytes] # ASK → OCA → ARK certificate chain
|
|
126
|
+
|
|
127
|
+
def serialize(self) -> dict:
|
|
128
|
+
return {
|
|
129
|
+
"report_data_b64": base64.b64encode(self.report_data).decode(),
|
|
130
|
+
"measurement_b64": base64.b64encode(self.measurement).decode(),
|
|
131
|
+
"chip_id_b64": base64.b64encode(self.chip_id).decode(),
|
|
132
|
+
"signature_b64": base64.b64encode(self.signature).decode(),
|
|
133
|
+
"cert_chain_b64": [base64.b64encode(c).decode() for c in self.cert_chain],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def deserialize(data: dict) -> TeeQuote:
|
|
138
|
+
return TeeQuote(
|
|
139
|
+
report_data=base64.b64decode(data["report_data_b64"]),
|
|
140
|
+
measurement=base64.b64decode(data["measurement_b64"]),
|
|
141
|
+
chip_id=base64.b64decode(data["chip_id_b64"]),
|
|
142
|
+
signature=base64.b64decode(data["signature_b64"]),
|
|
143
|
+
cert_chain=[base64.b64decode(c) for c in data["cert_chain_b64"]],
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ─── ECDH Key Generation ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
def generate_ephemeral_keypair() -> EphemeralKeyPair:
|
|
150
|
+
"""Generate an ephemeral X25519 keypair for one job.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
EphemeralKeyPair with fresh random keys.
|
|
154
|
+
"""
|
|
155
|
+
return EphemeralKeyPair.generate()
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def compute_shared_secret(
|
|
159
|
+
our_private: X25519PrivateKey,
|
|
160
|
+
their_public_bytes: bytes,
|
|
161
|
+
) -> bytes:
|
|
162
|
+
"""Compute ECDH shared secret.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
our_private: Our X25519 private key.
|
|
166
|
+
their_public_bytes: Raw 32-byte X25519 public key from peer.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
32-byte shared secret.
|
|
170
|
+
"""
|
|
171
|
+
their_public = X25519PublicKey.from_public_bytes(their_public_bytes)
|
|
172
|
+
return our_private.exchange(their_public)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ─── HKDF Key Derivation ────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
def derive_aes_key(
|
|
178
|
+
shared_secret: bytes,
|
|
179
|
+
salt: Optional[bytes] = None,
|
|
180
|
+
info: bytes = b"tenxo-aes-key-v1",
|
|
181
|
+
) -> Tuple[bytes, bytes]:
|
|
182
|
+
"""Derive AES-256-GCM payload key from ECDH shared secret via HKDF.
|
|
183
|
+
|
|
184
|
+
Uses HKDF-SHA256 to extract and expand the shared secret into a
|
|
185
|
+
32-byte AES key. A random 32-byte salt is generated if not provided.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
shared_secret: 32-byte X25519 shared secret.
|
|
189
|
+
salt: Optional 32-byte salt (randomly generated if None).
|
|
190
|
+
info: Context string for domain separation.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Tuple of (aes_key: 32 bytes, salt: 32 bytes).
|
|
194
|
+
"""
|
|
195
|
+
if salt is None:
|
|
196
|
+
salt = os.urandom(SALT_SIZE)
|
|
197
|
+
|
|
198
|
+
hkdf = HKDF(
|
|
199
|
+
algorithm=hashes.SHA256(),
|
|
200
|
+
length=AEAD_KEY_SIZE,
|
|
201
|
+
salt=salt,
|
|
202
|
+
info=info,
|
|
203
|
+
)
|
|
204
|
+
aes_key = hkdf.derive(shared_secret)
|
|
205
|
+
return aes_key, salt
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
# ─── Uniform Padding (Plausible Deniability) ────────────────────────────────
|
|
209
|
+
|
|
210
|
+
def select_padding_tier(data_size: int) -> int:
|
|
211
|
+
"""Select the smallest padding tier that fits the data.
|
|
212
|
+
|
|
213
|
+
An adversary monitoring network traffic sees only the tier size,
|
|
214
|
+
not the actual workload size.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
data_size: Actual payload size in bytes.
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Target padded size in bytes.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ValueError: If data_size exceeds the largest tier.
|
|
224
|
+
"""
|
|
225
|
+
for tier in sorted(PADDING_TIERS):
|
|
226
|
+
if data_size <= tier:
|
|
227
|
+
return tier
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Data size {data_size} exceeds max tier {max(PADDING_TIERS)}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def pad_payload(data: bytes) -> Tuple[bytes, int]:
|
|
234
|
+
"""Pad payload to the nearest standard tier with CSPRNG bytes.
|
|
235
|
+
|
|
236
|
+
The padding scheme is:
|
|
237
|
+
[original_data] [1-byte pad_len] [CSPRNG pad bytes]
|
|
238
|
+
|
|
239
|
+
Where pad_len = padded_size - actual_size - 1 (the pad_len byte itself).
|
|
240
|
+
The total file on the wire is exactly the tier size.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
data: Original plaintext payload.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
Tuple of (padded_data, original_size).
|
|
247
|
+
"""
|
|
248
|
+
original_size = len(data)
|
|
249
|
+
tier = select_padding_tier(original_size)
|
|
250
|
+
pad_len = tier - original_size - 1 # -1 for the pad_len byte
|
|
251
|
+
if pad_len < 0:
|
|
252
|
+
raise ValueError("Padding calculation overflow")
|
|
253
|
+
padding = os.urandom(pad_len)
|
|
254
|
+
padded = data + bytes([pad_len & 0xFF]) + padding
|
|
255
|
+
return padded, original_size
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def unpad_payload(padded: bytes) -> bytes:
|
|
259
|
+
"""Remove padding from a padded payload.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
padded: Padded payload (must be a valid tier size).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Original unpadded data.
|
|
266
|
+
"""
|
|
267
|
+
pad_len = padded[-1] # last byte encodes padding length
|
|
268
|
+
# pad_len is the number of padding bytes AFTER the pad_len byte
|
|
269
|
+
original_size = len(padded) - pad_len - 1
|
|
270
|
+
return padded[:original_size]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# ─── AES-256-GCM Encryption / Decryption ────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
def encrypt_payload(
|
|
276
|
+
data: bytes,
|
|
277
|
+
aes_key: bytes,
|
|
278
|
+
aad: Optional[bytes] = None,
|
|
279
|
+
) -> bytes:
|
|
280
|
+
"""Encrypt payload with AES-256-GCM.
|
|
281
|
+
|
|
282
|
+
Output format:
|
|
283
|
+
[12-byte nonce][ciphertext + 16-byte GCM tag]
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
data: Plaintext bytes to encrypt.
|
|
287
|
+
aes_key: 32-byte AES-256 key.
|
|
288
|
+
aad: Optional additional authenticated data.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Encrypted bytes (nonce || ciphertext_tag).
|
|
292
|
+
"""
|
|
293
|
+
aesgcm = AESGCM(aes_key)
|
|
294
|
+
nonce = os.urandom(NONCE_SIZE)
|
|
295
|
+
ct = aesgcm.encrypt(nonce, data, aad or b"")
|
|
296
|
+
return nonce + ct
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def decrypt_payload(
|
|
300
|
+
encrypted: bytes,
|
|
301
|
+
aes_key: bytes,
|
|
302
|
+
aad: Optional[bytes] = None,
|
|
303
|
+
) -> bytes:
|
|
304
|
+
"""Decrypt AES-256-GCM payload.
|
|
305
|
+
|
|
306
|
+
Input format:
|
|
307
|
+
[12-byte nonce][ciphertext + 16-byte GCM tag]
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
encrypted: Encrypted bytes (nonce || ciphertext_tag).
|
|
311
|
+
aes_key: 32-byte AES-256 key.
|
|
312
|
+
aad: Optional additional authenticated data.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Decrypted plaintext bytes.
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
InvalidSignature: If the GCM tag is invalid (tampered data).
|
|
319
|
+
"""
|
|
320
|
+
if len(encrypted) < NONCE_SIZE + TAG_SIZE:
|
|
321
|
+
raise ValueError("Ciphertext too short")
|
|
322
|
+
aesgcm = AESGCM(aes_key)
|
|
323
|
+
nonce = encrypted[:NONCE_SIZE]
|
|
324
|
+
ct = encrypted[NONCE_SIZE:]
|
|
325
|
+
return aesgcm.decrypt(nonce, ct, aad or b"")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def encrypt_file(
|
|
329
|
+
in_path: Path,
|
|
330
|
+
out_path: Path,
|
|
331
|
+
aes_key: bytes,
|
|
332
|
+
aad: Optional[bytes] = None,
|
|
333
|
+
):
|
|
334
|
+
"""Read a file, pad it, encrypt it, and write to output.
|
|
335
|
+
|
|
336
|
+
The output on disk is:
|
|
337
|
+
[12-byte nonce][ciphertext including 16-byte GCM tag]
|
|
338
|
+
|
|
339
|
+
The ciphertext is padded to the nearest tier size BEFORE encryption,
|
|
340
|
+
so the encrypted output size reveals only the tier, not the plaintext size.
|
|
341
|
+
"""
|
|
342
|
+
data = in_path.read_bytes()
|
|
343
|
+
padded, _ = pad_payload(data)
|
|
344
|
+
encrypted = encrypt_payload(padded, aes_key, aad)
|
|
345
|
+
out_path.write_bytes(encrypted)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def decrypt_file(
|
|
349
|
+
encrypted: bytes,
|
|
350
|
+
aes_key: bytes,
|
|
351
|
+
aad: Optional[bytes] = None,
|
|
352
|
+
) -> bytes:
|
|
353
|
+
"""Decrypt and unpad a file.
|
|
354
|
+
|
|
355
|
+
Returns the original plaintext bytes.
|
|
356
|
+
"""
|
|
357
|
+
padded = decrypt_payload(encrypted, aes_key, aad)
|
|
358
|
+
return unpad_payload(padded)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
# ─── TEE Quote Verification ─────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def verify_tee_quote(
|
|
364
|
+
quote: TeeQuote,
|
|
365
|
+
expected_pubkey: bytes,
|
|
366
|
+
expected_measurement: Optional[bytes] = None,
|
|
367
|
+
amd_ark_cert: Optional[bytes] = None,
|
|
368
|
+
) -> bool:
|
|
369
|
+
"""Verify an AMD SEV-SNP attestation quote.
|
|
370
|
+
|
|
371
|
+
This validates:
|
|
372
|
+
1. The report_data contains SHA-256(expected_pubkey) in its first 32 bytes,
|
|
373
|
+
proving the quote was generated for this specific key.
|
|
374
|
+
2. The ECDSA signature over the quote is valid against the AMD certificate chain.
|
|
375
|
+
3. (If provided) the TEE measurement matches the expected trusted code hash.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
quote: TeeQuote object from the agent.
|
|
379
|
+
expected_pubkey: Agent's 32-byte X25519 public key to verify binding.
|
|
380
|
+
expected_measurement: Optional expected TEE code measurement.
|
|
381
|
+
amd_ark_cert: Optional AMD root certificate for chain verification.
|
|
382
|
+
|
|
383
|
+
Returns:
|
|
384
|
+
True if all checks pass.
|
|
385
|
+
|
|
386
|
+
Raises:
|
|
387
|
+
ValueError: If any check fails with explanation.
|
|
388
|
+
"""
|
|
389
|
+
# ── Check 1: report_data contains SHA-256(agent_pubkey) ─────────
|
|
390
|
+
pubkey_hash = hashes.Hash(hashes.SHA256())
|
|
391
|
+
pubkey_hash.update(expected_pubkey)
|
|
392
|
+
expected_report = pubkey_hash.finalize()
|
|
393
|
+
|
|
394
|
+
actual_report = quote.report_data[:32]
|
|
395
|
+
if not hmac.compare_digest(expected_report, actual_report):
|
|
396
|
+
raise ValueError(
|
|
397
|
+
"TEE quote report_data does not match agent public key. "
|
|
398
|
+
"Possible key substitution attack."
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
# ── Check 2: Verify measurement (if provided) ───────────────────
|
|
402
|
+
if expected_measurement is not None:
|
|
403
|
+
actual_measurement = quote.measurement
|
|
404
|
+
if not hmac.compare_digest(expected_measurement, actual_measurement):
|
|
405
|
+
raise ValueError(
|
|
406
|
+
"TEE measurement mismatch. The agent may not be running "
|
|
407
|
+
"the expected trusted code."
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# ── Check 3: Verify certificate chain → signature ───────────────
|
|
411
|
+
#
|
|
412
|
+
# In production, this would:
|
|
413
|
+
# 1. Parse AMD ARK (root) certificate from built-in trust store
|
|
414
|
+
# 2. Verify ASK is signed by ARK
|
|
415
|
+
# 3. Verify OCA is signed by ASK
|
|
416
|
+
# 4. Verify the quote ECDSA signature using OCA public key
|
|
417
|
+
# 5. Check chip_id against AMD's revoked-CPU list
|
|
418
|
+
#
|
|
419
|
+
# Reference: SEV-SNP firmware ABI spec, section on attestation.
|
|
420
|
+
#
|
|
421
|
+
# For the MVP, we verify the chain structure exists and check
|
|
422
|
+
# cryptographic binding in report_data. Full cert chain validation
|
|
423
|
+
# requires `cryptography.x509` and AMD's root CA.
|
|
424
|
+
#
|
|
425
|
+
# The quote signature is ECDSA on secp384r1 over the following message:
|
|
426
|
+
# SHA-384(ATTESTATION_REPORT[0:319]) (first 320 bytes of the report)
|
|
427
|
+
#
|
|
428
|
+
# Verification:
|
|
429
|
+
# from cryptography.hazmat.primitives.asymmetric import ec
|
|
430
|
+
# oca_pub = get_oca_from_cert_chain(quote.cert_chain)
|
|
431
|
+
# oca_pub.verify(signature, message, ec.ECDSA(hashes.SHA384()))
|
|
432
|
+
#
|
|
433
|
+
|
|
434
|
+
if len(quote.cert_chain) < 3:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
"TEE quote certificate chain incomplete. "
|
|
437
|
+
f"Expected >= 3 certs (ARK, ASK, OCA), got {len(quote.cert_chain)}"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
if len(quote.signature) < 100:
|
|
441
|
+
raise ValueError(
|
|
442
|
+
f"TEE quote signature too short ({len(quote.signature)} bytes). "
|
|
443
|
+
"Expected ~104 bytes for ECDSA secp384r1."
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# Placeholder for full chain validation:
|
|
447
|
+
# verify_cert_chain(quote.cert_chain, amd_ark_cert)
|
|
448
|
+
# verify_quote_signature(quote, oca_pubkey)
|
|
449
|
+
|
|
450
|
+
return True
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ─── Full Encryption Pipeline ───────────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
def encrypt_workspace(
|
|
456
|
+
workspace_zip: Path,
|
|
457
|
+
output_enc: Path,
|
|
458
|
+
agent_pubkey_b64: str,
|
|
459
|
+
client_keypair: EphemeralKeyPair,
|
|
460
|
+
aad: Optional[bytes] = None,
|
|
461
|
+
) -> str:
|
|
462
|
+
"""Full encryption pipeline for a job workspace.
|
|
463
|
+
|
|
464
|
+
Steps:
|
|
465
|
+
1. Compute ECDH shared secret from client keypair + agent pubkey.
|
|
466
|
+
2. Derive AES-256-GCM payload key via HKDF.
|
|
467
|
+
3. Pad workspace to standard tier.
|
|
468
|
+
4. Encrypt with AES-256-GCM.
|
|
469
|
+
5. Write encrypted output to disk.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
workspace_zip: Path to the zipped workspace.
|
|
473
|
+
output_enc: Path for the encrypted output file.
|
|
474
|
+
agent_pubkey_b64: Agent's X25519 public key (base64).
|
|
475
|
+
client_keypair: Client's ephemeral X25519 keypair.
|
|
476
|
+
aad: Optional additional authenticated data.
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
Base64-encoded salt (needed by agent to derive the same AES key).
|
|
480
|
+
"""
|
|
481
|
+
agent_pubkey = base64.b64decode(agent_pubkey_b64)
|
|
482
|
+
shared_secret = compute_shared_secret(client_keypair.private_key, agent_pubkey)
|
|
483
|
+
aes_key, salt = derive_aes_key(shared_secret)
|
|
484
|
+
encrypt_file(workspace_zip, output_enc, aes_key, aad)
|
|
485
|
+
return base64.b64encode(salt).decode()
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
# ─── Backward-Compatible API ────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
def generate_key() -> bytes:
|
|
491
|
+
"""Legacy: Generate a random AES-256 key (backward compat)."""
|
|
492
|
+
return AESGCM.generate_key(bit_length=256)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def key_to_b64(key: bytes) -> str:
|
|
496
|
+
return base64.b64encode(key).decode()
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def key_from_b64(s: str) -> bytes:
|
|
500
|
+
return base64.b64decode(s)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Workspace packaging: zip with .tenxoignore support and requirements.txt check."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_ignore_patterns(directory: Path) -> list[str] | None:
|
|
10
|
+
"""Read .tenxoignore patterns, falling back to .gitignore."""
|
|
11
|
+
for name in (".tenxoignore", ".gitignore"):
|
|
12
|
+
path = directory / name
|
|
13
|
+
if path.is_file():
|
|
14
|
+
return _parse_ignore_file(path)
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_ignore_file(path: Path) -> list[str]:
|
|
19
|
+
patterns: list[str] = []
|
|
20
|
+
for line in path.read_text().splitlines():
|
|
21
|
+
line = line.strip()
|
|
22
|
+
if not line or line.startswith("#"):
|
|
23
|
+
continue
|
|
24
|
+
patterns.append(line)
|
|
25
|
+
return patterns
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _translate_pattern(pat: str) -> str:
|
|
29
|
+
"""Convert a gitignore-style pattern to a regex."""
|
|
30
|
+
parts = []
|
|
31
|
+
i = 0
|
|
32
|
+
n = len(pat)
|
|
33
|
+
while i < n:
|
|
34
|
+
c = pat[i]
|
|
35
|
+
if c == "*":
|
|
36
|
+
if i + 1 < n and pat[i + 1] == "*":
|
|
37
|
+
parts.append(".*")
|
|
38
|
+
i += 2
|
|
39
|
+
if i < n and pat[i] == "/":
|
|
40
|
+
i += 1
|
|
41
|
+
else:
|
|
42
|
+
parts.append("[^/]*")
|
|
43
|
+
i += 1
|
|
44
|
+
elif c == "?":
|
|
45
|
+
parts.append("[^/]")
|
|
46
|
+
i += 1
|
|
47
|
+
elif c in ".+^${}()|\\[]":
|
|
48
|
+
parts.append("\\" + c)
|
|
49
|
+
i += 1
|
|
50
|
+
else:
|
|
51
|
+
parts.append(c)
|
|
52
|
+
i += 1
|
|
53
|
+
return "".join(parts)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _pattern_matches(rel_path: str, pattern: str) -> bool:
|
|
57
|
+
"""Check if a relative path matches a single gitignore-style pattern.
|
|
58
|
+
Returns True if the raw pattern (without negation prefix) matches.
|
|
59
|
+
"""
|
|
60
|
+
is_dir_only = pattern.endswith("/")
|
|
61
|
+
pattern = pattern.rstrip("/")
|
|
62
|
+
check_path = rel_path.rstrip("/")
|
|
63
|
+
|
|
64
|
+
if pattern.startswith("/"):
|
|
65
|
+
pattern = pattern[1:]
|
|
66
|
+
anchored = True
|
|
67
|
+
else:
|
|
68
|
+
anchored = False
|
|
69
|
+
|
|
70
|
+
regex = _translate_pattern(pattern)
|
|
71
|
+
if anchored:
|
|
72
|
+
return bool(re.fullmatch(regex, check_path))
|
|
73
|
+
|
|
74
|
+
if re.fullmatch(regex, check_path):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
if "/" in check_path or is_dir_only:
|
|
78
|
+
base = check_path.rsplit("/", 1)[-1]
|
|
79
|
+
if re.fullmatch(regex, base):
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
if is_dir_only and check_path.startswith(pattern + "/"):
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _matches_any(rel_path: str, patterns: list[str]) -> bool:
|
|
89
|
+
"""Return True if rel_path is excluded (matches a non-negated pattern and
|
|
90
|
+
no later negation pattern re-includes it)."""
|
|
91
|
+
excluded = False
|
|
92
|
+
for p in patterns:
|
|
93
|
+
negate = p.startswith("!")
|
|
94
|
+
raw = p[1:].strip() if negate else p
|
|
95
|
+
if _pattern_matches(rel_path, raw):
|
|
96
|
+
if negate:
|
|
97
|
+
return False
|
|
98
|
+
excluded = True
|
|
99
|
+
return excluded
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def check_requirements(directory: Path):
|
|
103
|
+
"""Warn if requirements.txt is missing from the workspace root."""
|
|
104
|
+
if not (directory / "requirements.txt").is_file():
|
|
105
|
+
print(
|
|
106
|
+
"WARNING: requirements.txt not found at workspace root.\n"
|
|
107
|
+
"Create one so the edge agent installs your Python dependencies.\n"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def pack_workspace(
|
|
112
|
+
directory: str | Path = ".",
|
|
113
|
+
output: str | Path = "workspace.zip",
|
|
114
|
+
) -> Path:
|
|
115
|
+
"""Zip a workspace directory, respecting .tenxoignore patterns.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
directory: Root directory to package.
|
|
119
|
+
output: Destination zip path.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Absolute path to the created zip file.
|
|
123
|
+
"""
|
|
124
|
+
root = Path(directory).resolve()
|
|
125
|
+
out = Path(output).resolve()
|
|
126
|
+
|
|
127
|
+
if not root.is_dir():
|
|
128
|
+
raise NotADirectoryError(f"Not a directory: {root}")
|
|
129
|
+
|
|
130
|
+
patterns = load_ignore_patterns(root)
|
|
131
|
+
|
|
132
|
+
check_requirements(root)
|
|
133
|
+
|
|
134
|
+
if patterns:
|
|
135
|
+
print(f"Ignoring files matching {len(patterns)} pattern(s)")
|
|
136
|
+
else:
|
|
137
|
+
print("No .tenxoignore or .gitignore found — including all files")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
out_resolved = out.resolve()
|
|
141
|
+
except (OSError, ValueError):
|
|
142
|
+
out_resolved = out
|
|
143
|
+
|
|
144
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
146
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
147
|
+
for d in list(dirnames):
|
|
148
|
+
rel = os.path.relpath(os.path.join(dirpath, d), root)
|
|
149
|
+
if patterns and _matches_any(rel + "/", patterns):
|
|
150
|
+
dirnames.remove(d)
|
|
151
|
+
|
|
152
|
+
for f in filenames:
|
|
153
|
+
full = os.path.join(dirpath, f)
|
|
154
|
+
rel = os.path.relpath(full, root)
|
|
155
|
+
# Don't include the output zip itself
|
|
156
|
+
try:
|
|
157
|
+
if out_resolved.samefile(full):
|
|
158
|
+
continue
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
pass
|
|
161
|
+
if patterns and _matches_any(rel, patterns):
|
|
162
|
+
continue
|
|
163
|
+
zf.write(full, rel)
|
|
164
|
+
|
|
165
|
+
print(f"Packaged {root.name} -> {out} ({out.stat().st_size / 1024:.1f} KB)")
|
|
166
|
+
return out
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tenxo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tenxo - Zero-knowledge decentralized GPU grid CLI
|
|
5
|
+
Author: Tenxo
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://tenxo.ai
|
|
8
|
+
Project-URL: Source, https://github.com/tenxo/tenxo
|
|
9
|
+
Keywords: gpu,decentralized,ml,training,zero-knowledge
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.28
|
|
15
|
+
Requires-Dist: tqdm>=4.64
|
|
16
|
+
Requires-Dist: cryptography>=41
|
|
17
|
+
|
|
18
|
+
# Tenxo
|
|
19
|
+
|
|
20
|
+
Zero-knowledge decentralized GPU grid — package and train AI models on remote GPUs.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install tenxo
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Package your workspace
|
|
32
|
+
tenxo pack
|
|
33
|
+
|
|
34
|
+
# Full workflow: package → encrypt → upload → submit → poll → decrypt
|
|
35
|
+
tenxo run /path/to/workspace
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## `.tenxoignore`
|
|
39
|
+
|
|
40
|
+
Create a `.tenxoignore` in your workspace root to exclude files (syntax like `.gitignore`):
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
.venv/
|
|
44
|
+
__pycache__/
|
|
45
|
+
*.pyc
|
|
46
|
+
.DS_Store
|
|
47
|
+
.git/
|
|
48
|
+
output/
|
|
49
|
+
*.zip
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Running `tenxo pack` automatically reads `.tenxoignore` (falls back to `.gitignore`) and warns if `requirements.txt` is missing.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tenxo/__init__.py
|
|
4
|
+
tenxo/__main__.py
|
|
5
|
+
tenxo/cli.py
|
|
6
|
+
tenxo/client.py
|
|
7
|
+
tenxo/crypto.py
|
|
8
|
+
tenxo/pack.py
|
|
9
|
+
tenxo.egg-info/PKG-INFO
|
|
10
|
+
tenxo.egg-info/SOURCES.txt
|
|
11
|
+
tenxo.egg-info/dependency_links.txt
|
|
12
|
+
tenxo.egg-info/entry_points.txt
|
|
13
|
+
tenxo.egg-info/requires.txt
|
|
14
|
+
tenxo.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tenxo
|