elpis-cli 0.1.0__py3-none-any.whl
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.
- elpis_cli/__init__.py +3 -0
- elpis_cli/cli.py +358 -0
- elpis_cli/config.py +67 -0
- elpis_cli/identity.py +127 -0
- elpis_cli/signer.py +58 -0
- elpis_cli/xrpl_client.py +494 -0
- elpis_cli-0.1.0.dist-info/METADATA +188 -0
- elpis_cli-0.1.0.dist-info/RECORD +11 -0
- elpis_cli-0.1.0.dist-info/WHEEL +4 -0
- elpis_cli-0.1.0.dist-info/entry_points.txt +2 -0
- elpis_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
elpis_cli/__init__.py
ADDED
elpis_cli/cli.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Elpis CLI entry point."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .config import (
|
|
11
|
+
ensure_dir,
|
|
12
|
+
identity_exists,
|
|
13
|
+
load_identity,
|
|
14
|
+
load_private_key,
|
|
15
|
+
save_identity,
|
|
16
|
+
save_private_key,
|
|
17
|
+
)
|
|
18
|
+
from .identity import build_identity, generate_keypair, load_keypair_from_file
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.version_option(__version__)
|
|
23
|
+
def cli():
|
|
24
|
+
"""Elpis Protocol CLI -- Agent Identity & Interaction."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@cli.command()
|
|
29
|
+
@click.option("--name", prompt="Agent name", help="Display name for this identity.")
|
|
30
|
+
@click.option("--provider", default="", help="Provider name (e.g. efiniti).")
|
|
31
|
+
@click.option("--role", default="", help="Agent role description.")
|
|
32
|
+
@click.option(
|
|
33
|
+
"--network",
|
|
34
|
+
default="testnet",
|
|
35
|
+
type=click.Choice(["testnet", "mainnet"]),
|
|
36
|
+
help="XRPL network for DID registration.",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--key",
|
|
40
|
+
"key_path",
|
|
41
|
+
default=None,
|
|
42
|
+
type=click.Path(exists=True),
|
|
43
|
+
help="Path to existing Ed25519 private key file.",
|
|
44
|
+
)
|
|
45
|
+
@click.option("--force", is_flag=True, help="Overwrite existing identity.")
|
|
46
|
+
@click.option(
|
|
47
|
+
"--register/--no-register",
|
|
48
|
+
default=True,
|
|
49
|
+
help="Register DID on XRPL (requires network access).",
|
|
50
|
+
)
|
|
51
|
+
@click.option("--wallet-address", default=None, help="XRPL wallet r-address (mainnet only).")
|
|
52
|
+
@click.option("--wallet-secret", default=None, help="XRPL wallet secret (mainnet only).")
|
|
53
|
+
def init(name, provider, role, network, key_path, force, register, wallet_address, wallet_secret):
|
|
54
|
+
"""Initialize a new Elpis identity.
|
|
55
|
+
|
|
56
|
+
Generates an Ed25519 keypair, creates a DID, and optionally
|
|
57
|
+
registers it on the XRPL Testnet.
|
|
58
|
+
"""
|
|
59
|
+
# Check existing identity
|
|
60
|
+
if identity_exists() and not force:
|
|
61
|
+
existing = load_identity()
|
|
62
|
+
if existing:
|
|
63
|
+
click.echo(f"Identity already exists: {existing.get('did', '?')}")
|
|
64
|
+
click.echo("Use --force to overwrite.")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
# Generate or load keypair
|
|
68
|
+
if key_path:
|
|
69
|
+
click.echo(f"Loading key from {key_path}...")
|
|
70
|
+
try:
|
|
71
|
+
private_seed, public_key = load_keypair_from_file(key_path)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
click.echo(f"Error loading key: {exc}", err=True)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
click.echo("Key loaded successfully.")
|
|
76
|
+
else:
|
|
77
|
+
click.echo("Generating Ed25519 keypair...")
|
|
78
|
+
private_seed, public_key = generate_keypair()
|
|
79
|
+
click.echo("Keypair generated.")
|
|
80
|
+
|
|
81
|
+
# Build identity
|
|
82
|
+
identity = build_identity(
|
|
83
|
+
name=name,
|
|
84
|
+
provider=provider,
|
|
85
|
+
role=role,
|
|
86
|
+
network=network,
|
|
87
|
+
private_seed=private_seed,
|
|
88
|
+
public_key=public_key,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Save to ~/.elpis/
|
|
92
|
+
ensure_dir()
|
|
93
|
+
save_private_key(private_seed.hex())
|
|
94
|
+
save_identity(identity)
|
|
95
|
+
click.echo("Identity saved to ~/.elpis/")
|
|
96
|
+
|
|
97
|
+
# XRPL registration: full chain (DIDSet -> MPT -> Credential)
|
|
98
|
+
if register:
|
|
99
|
+
if network == "testnet":
|
|
100
|
+
click.echo("Registering on XRPL Testnet (Faucet -> DIDSet -> MPT -> Credential)...")
|
|
101
|
+
from .xrpl_client import register_did_testnet
|
|
102
|
+
|
|
103
|
+
result = asyncio.run(
|
|
104
|
+
register_did_testnet(
|
|
105
|
+
did=identity["did"],
|
|
106
|
+
public_key_hex=identity["public_key"],
|
|
107
|
+
name=name,
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
elif network == "mainnet":
|
|
111
|
+
if not wallet_address or not wallet_secret:
|
|
112
|
+
wallet_address = wallet_address or click.prompt("XRPL wallet address (r...)")
|
|
113
|
+
wallet_secret = wallet_secret or click.prompt("XRPL wallet secret", hide_input=True)
|
|
114
|
+
|
|
115
|
+
click.echo("Registering on XRPL Mainnet (DIDSet -> MPT -> Credential)...")
|
|
116
|
+
click.echo("Each transaction requires your confirmation.")
|
|
117
|
+
from .xrpl_client import register_did_mainnet, TX_DESCRIPTIONS
|
|
118
|
+
|
|
119
|
+
def _confirm(step_name, tx_type, fee_xrp, reserve_xrp):
|
|
120
|
+
desc = TX_DESCRIPTIONS.get(tx_type, tx_type)
|
|
121
|
+
click.echo(f"\n TX: {desc}")
|
|
122
|
+
click.echo(f" Fee: {fee_xrp:.6f} XRP | Reserve: {reserve_xrp:.1f} XRP")
|
|
123
|
+
return click.confirm(" Submit?", default=True)
|
|
124
|
+
|
|
125
|
+
result = asyncio.run(
|
|
126
|
+
register_did_mainnet(
|
|
127
|
+
did=identity["did"],
|
|
128
|
+
public_key_hex=identity["public_key"],
|
|
129
|
+
name=name,
|
|
130
|
+
wallet_address=wallet_address,
|
|
131
|
+
wallet_secret=wallet_secret,
|
|
132
|
+
confirm_fn=_confirm,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
if result and result.get("balance_xrp") is not None:
|
|
136
|
+
click.echo(f" Balance: {result['balance_xrp']:.2f} XRP")
|
|
137
|
+
if result and result.get("estimated_cost"):
|
|
138
|
+
cost = result["estimated_cost"]
|
|
139
|
+
click.echo(f" Est. cost: {cost['total_xrp']:.6f} XRP (fees + reserve)")
|
|
140
|
+
else:
|
|
141
|
+
result = None
|
|
142
|
+
|
|
143
|
+
if result:
|
|
144
|
+
if result.get("address"):
|
|
145
|
+
identity["xrpl_address"] = result["address"]
|
|
146
|
+
click.echo(f" Wallet: {result['address']}")
|
|
147
|
+
|
|
148
|
+
steps = result.get("steps", {})
|
|
149
|
+
for step_name, step_data in steps.items():
|
|
150
|
+
if step_data.get("skipped"):
|
|
151
|
+
status = "SKIPPED"
|
|
152
|
+
elif step_data.get("success"):
|
|
153
|
+
status = "OK"
|
|
154
|
+
else:
|
|
155
|
+
status = "FAILED"
|
|
156
|
+
tx = step_data.get("tx_hash", "")[:12]
|
|
157
|
+
click.echo(f" {step_name:12s} {status}" + (f" tx={tx}..." if tx else ""))
|
|
158
|
+
|
|
159
|
+
if result.get("tx_hash"):
|
|
160
|
+
identity["xrpl_tx_hash"] = result["tx_hash"]
|
|
161
|
+
if result.get("mpt_tx_hash"):
|
|
162
|
+
identity["xrpl_mpt_hash"] = result["mpt_tx_hash"]
|
|
163
|
+
if result.get("credential_tx_hash"):
|
|
164
|
+
identity["xrpl_credential_hash"] = result["credential_tx_hash"]
|
|
165
|
+
|
|
166
|
+
save_identity(identity)
|
|
167
|
+
|
|
168
|
+
if not result.get("registered"):
|
|
169
|
+
click.echo(f" Warning: {result.get('error', 'partial failure')}")
|
|
170
|
+
else:
|
|
171
|
+
click.echo("XRPL registration skipped (network unreachable).")
|
|
172
|
+
|
|
173
|
+
# Summary
|
|
174
|
+
click.echo("")
|
|
175
|
+
click.echo(f" DID: {identity['did']}")
|
|
176
|
+
click.echo(f" Name: {identity['name']}")
|
|
177
|
+
click.echo(f" Network: {identity['network']}")
|
|
178
|
+
click.echo(f" Public Key: {identity['public_key'][:16]}...")
|
|
179
|
+
click.echo(f" Cert Hash: {identity['cert_hash']}")
|
|
180
|
+
if identity.get("xrpl_address"):
|
|
181
|
+
click.echo(f" XRPL Addr: {identity['xrpl_address']}")
|
|
182
|
+
if identity.get("xrpl_tx_hash"):
|
|
183
|
+
click.echo(f" TX Hash: {identity['xrpl_tx_hash'][:16]}...")
|
|
184
|
+
click.echo("")
|
|
185
|
+
click.echo("Ready. Run 'elpis whoami' to verify your identity.")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@cli.command()
|
|
189
|
+
@click.option(
|
|
190
|
+
"--resolver",
|
|
191
|
+
default="https://elpis.efiniti.ai/whoami",
|
|
192
|
+
help="Elpis resolver URL for identity verification.",
|
|
193
|
+
)
|
|
194
|
+
@click.option("--offline", is_flag=True, help="Show local identity without contacting resolver.")
|
|
195
|
+
def whoami(resolver, offline):
|
|
196
|
+
"""Verify identity via signed request to Elpis resolver.
|
|
197
|
+
|
|
198
|
+
Sends a signed GET request to the resolver and displays the
|
|
199
|
+
verified identity response. Use --offline to show local identity only.
|
|
200
|
+
"""
|
|
201
|
+
identity = load_identity()
|
|
202
|
+
key_hex = load_private_key()
|
|
203
|
+
if not identity or not key_hex:
|
|
204
|
+
click.echo("No identity found. Run 'elpis init' first.")
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
|
|
207
|
+
if offline:
|
|
208
|
+
click.echo(f" DID: {identity['did']}")
|
|
209
|
+
click.echo(f" Name: {identity['name']}")
|
|
210
|
+
click.echo(f" Network: {identity['network']}")
|
|
211
|
+
click.echo(f" Public Key: {identity['public_key'][:16]}...")
|
|
212
|
+
click.echo(f" Created: {identity.get('created_at', '?')}")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# Sign and send whoami request
|
|
216
|
+
from .signer import sign_request
|
|
217
|
+
import httpx
|
|
218
|
+
|
|
219
|
+
private_seed = bytes.fromhex(key_hex)
|
|
220
|
+
sig_headers = sign_request(private_seed, "GET", resolver)
|
|
221
|
+
headers = {
|
|
222
|
+
**sig_headers,
|
|
223
|
+
"X-Elpis-DID": identity["did"],
|
|
224
|
+
"X-Elpis-Domain": identity.get("provider", ""),
|
|
225
|
+
}
|
|
226
|
+
if identity.get("xrpl_address"):
|
|
227
|
+
headers["X-Elpis-Account"] = identity["xrpl_address"]
|
|
228
|
+
|
|
229
|
+
click.echo(f"Verifying identity: {identity['did']}")
|
|
230
|
+
try:
|
|
231
|
+
resp = httpx.get(resolver, headers=headers, timeout=10.0)
|
|
232
|
+
if resp.status_code == 200:
|
|
233
|
+
data = resp.json()
|
|
234
|
+
click.echo(f" Verified: {data.get('verified', False)}")
|
|
235
|
+
click.echo(f" DID: {data.get('did', identity['did'])}")
|
|
236
|
+
click.echo(f" Name: {data.get('name', identity['name'])}")
|
|
237
|
+
if data.get("message"):
|
|
238
|
+
click.echo(f" Message: {data['message']}")
|
|
239
|
+
else:
|
|
240
|
+
click.echo(f" Resolver returned {resp.status_code}")
|
|
241
|
+
click.echo(f" Falling back to local identity:")
|
|
242
|
+
click.echo(f" DID: {identity['did']}")
|
|
243
|
+
click.echo(f" Name: {identity['name']}")
|
|
244
|
+
click.echo(f" Signature: valid (local)")
|
|
245
|
+
except httpx.ConnectError:
|
|
246
|
+
click.echo(" Resolver unreachable. Local identity:")
|
|
247
|
+
click.echo(f" DID: {identity['did']}")
|
|
248
|
+
click.echo(f" Name: {identity['name']}")
|
|
249
|
+
click.echo(f" Signature: valid (local, unverified)")
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
click.echo(f" Error: {exc}")
|
|
252
|
+
click.echo(f" DID: {identity['did']}")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@cli.command("request")
|
|
256
|
+
@click.argument("url")
|
|
257
|
+
@click.option("-X", "--method", default="GET", help="HTTP method.")
|
|
258
|
+
@click.option("-d", "--data", "body", default=None, help="Request body (string).")
|
|
259
|
+
@click.option("-H", "--header", "extra_headers", multiple=True,
|
|
260
|
+
help="Extra header (key: value). Repeatable.")
|
|
261
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show request headers.")
|
|
262
|
+
@click.option("-o", "--output", "output_file", default=None, type=click.Path(),
|
|
263
|
+
help="Write response body to file.")
|
|
264
|
+
def request_cmd(url, method, body, extra_headers, verbose, output_file):
|
|
265
|
+
"""Send a signed HTTP request (curl replacement).
|
|
266
|
+
|
|
267
|
+
Signs the request with your Elpis identity and injects X-Elpis-*
|
|
268
|
+
headers. The response is printed to stdout.
|
|
269
|
+
|
|
270
|
+
Examples:
|
|
271
|
+
|
|
272
|
+
elpis request https://efiniti.de/api/eacd/home
|
|
273
|
+
|
|
274
|
+
elpis request -X POST -d '{"key": "value"}' https://api.example.com/data
|
|
275
|
+
"""
|
|
276
|
+
identity = load_identity()
|
|
277
|
+
key_hex = load_private_key()
|
|
278
|
+
if not identity or not key_hex:
|
|
279
|
+
click.echo("No identity found. Run 'elpis init' first.", err=True)
|
|
280
|
+
sys.exit(1)
|
|
281
|
+
|
|
282
|
+
from .signer import sign_request
|
|
283
|
+
import httpx
|
|
284
|
+
|
|
285
|
+
private_seed = bytes.fromhex(key_hex)
|
|
286
|
+
body_bytes = body.encode() if body else b""
|
|
287
|
+
|
|
288
|
+
# Sign the request
|
|
289
|
+
sig_headers = sign_request(private_seed, method.upper(), url, body_bytes)
|
|
290
|
+
|
|
291
|
+
# Build headers
|
|
292
|
+
headers = {
|
|
293
|
+
**sig_headers,
|
|
294
|
+
"X-Elpis-DID": identity["did"],
|
|
295
|
+
"X-Elpis-Domain": identity.get("provider", ""),
|
|
296
|
+
}
|
|
297
|
+
if identity.get("xrpl_address"):
|
|
298
|
+
headers["X-Elpis-Account"] = identity["xrpl_address"]
|
|
299
|
+
if identity.get("cert_hash"):
|
|
300
|
+
headers["X-Elpis-Cert-Hash"] = identity["cert_hash"]
|
|
301
|
+
if identity.get("name"):
|
|
302
|
+
headers["X-Elpis-Display-Name"] = identity["name"]
|
|
303
|
+
|
|
304
|
+
# Add extra headers
|
|
305
|
+
for h in extra_headers:
|
|
306
|
+
if ":" in h:
|
|
307
|
+
k, v = h.split(":", 1)
|
|
308
|
+
headers[k.strip()] = v.strip()
|
|
309
|
+
|
|
310
|
+
if verbose:
|
|
311
|
+
click.echo(f"> {method.upper()} {url}", err=True)
|
|
312
|
+
for k, v in headers.items():
|
|
313
|
+
click.echo(f"> {k}: {v}", err=True)
|
|
314
|
+
click.echo(">", err=True)
|
|
315
|
+
|
|
316
|
+
# Send request
|
|
317
|
+
try:
|
|
318
|
+
resp = httpx.request(
|
|
319
|
+
method=method.upper(),
|
|
320
|
+
url=url,
|
|
321
|
+
headers=headers,
|
|
322
|
+
content=body_bytes if body_bytes else None,
|
|
323
|
+
timeout=30.0,
|
|
324
|
+
follow_redirects=True,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if verbose:
|
|
328
|
+
click.echo(f"< HTTP {resp.status_code}", err=True)
|
|
329
|
+
for k, v in resp.headers.items():
|
|
330
|
+
click.echo(f"< {k}: {v}", err=True)
|
|
331
|
+
click.echo("<", err=True)
|
|
332
|
+
|
|
333
|
+
if output_file:
|
|
334
|
+
with open(output_file, "wb") as f:
|
|
335
|
+
f.write(resp.content)
|
|
336
|
+
click.echo(f"Response written to {output_file}", err=True)
|
|
337
|
+
else:
|
|
338
|
+
click.echo(resp.text)
|
|
339
|
+
|
|
340
|
+
sys.exit(0 if resp.status_code < 400 else 1)
|
|
341
|
+
|
|
342
|
+
except httpx.ConnectError as exc:
|
|
343
|
+
click.echo(f"Connection error: {exc}", err=True)
|
|
344
|
+
sys.exit(1)
|
|
345
|
+
except Exception as exc:
|
|
346
|
+
click.echo(f"Request failed: {exc}", err=True)
|
|
347
|
+
sys.exit(1)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@cli.command()
|
|
351
|
+
def status():
|
|
352
|
+
"""Show current identity status."""
|
|
353
|
+
identity = load_identity()
|
|
354
|
+
if not identity:
|
|
355
|
+
click.echo("No identity found. Run 'elpis init' first.")
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
click.echo(json.dumps(identity, indent=2))
|
elpis_cli/config.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Elpis CLI configuration and identity file management."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ELPIS_DIR = Path.home() / ".elpis"
|
|
10
|
+
IDENTITY_FILE = ELPIS_DIR / "identity.json"
|
|
11
|
+
KEY_FILE = ELPIS_DIR / "private.key"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ensure_dir() -> Path:
|
|
15
|
+
"""Create ~/.elpis/ with restricted permissions if it doesn't exist."""
|
|
16
|
+
ELPIS_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
17
|
+
return ELPIS_DIR
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def save_identity(identity: Dict[str, Any]) -> Path:
|
|
21
|
+
"""Save identity to ~/.elpis/identity.json."""
|
|
22
|
+
ensure_dir()
|
|
23
|
+
IDENTITY_FILE.write_text(json.dumps(identity, indent=2))
|
|
24
|
+
IDENTITY_FILE.chmod(0o600)
|
|
25
|
+
return IDENTITY_FILE
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_identity() -> Optional[Dict[str, Any]]:
|
|
29
|
+
"""Load identity from ~/.elpis/identity.json. Returns None if not found."""
|
|
30
|
+
if not IDENTITY_FILE.exists():
|
|
31
|
+
return None
|
|
32
|
+
try:
|
|
33
|
+
return json.loads(IDENTITY_FILE.read_text())
|
|
34
|
+
except (json.JSONDecodeError, OSError):
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def save_private_key(key_hex: str) -> Path:
|
|
39
|
+
"""Save private key (hex-encoded) to ~/.elpis/private.key.
|
|
40
|
+
|
|
41
|
+
Uses os.open() with explicit permissions to avoid race condition
|
|
42
|
+
where the key file is briefly world-readable with default umask.
|
|
43
|
+
|
|
44
|
+
Note:
|
|
45
|
+
The private key is stored in plaintext (hex-encoded).
|
|
46
|
+
TODO: Implement encryption-at-rest (e.g. age/AEAD) before v1.0.
|
|
47
|
+
"""
|
|
48
|
+
ensure_dir()
|
|
49
|
+
fd = os.open(str(KEY_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
50
|
+
with os.fdopen(fd, 'w') as f:
|
|
51
|
+
f.write(key_hex)
|
|
52
|
+
return KEY_FILE
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_private_key() -> Optional[str]:
|
|
56
|
+
"""Load private key hex from ~/.elpis/private.key."""
|
|
57
|
+
if not KEY_FILE.exists():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
return KEY_FILE.read_text().strip()
|
|
61
|
+
except OSError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def identity_exists() -> bool:
|
|
66
|
+
"""Check if an identity has been initialized."""
|
|
67
|
+
return IDENTITY_FILE.exists() and KEY_FILE.exists()
|
elpis_cli/identity.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Ed25519 identity management for Elpis Protocol."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Dict, Tuple
|
|
6
|
+
|
|
7
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
8
|
+
from cryptography.hazmat.primitives import serialization
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def generate_keypair() -> Tuple[bytes, bytes]:
|
|
12
|
+
"""Generate an Ed25519 keypair.
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
(private_seed_32bytes, public_key_32bytes)
|
|
16
|
+
"""
|
|
17
|
+
private_key = Ed25519PrivateKey.generate()
|
|
18
|
+
private_seed = private_key.private_bytes(
|
|
19
|
+
encoding=serialization.Encoding.Raw,
|
|
20
|
+
format=serialization.PrivateFormat.Raw,
|
|
21
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
22
|
+
)
|
|
23
|
+
public_key = private_key.public_key().public_bytes(
|
|
24
|
+
encoding=serialization.Encoding.Raw,
|
|
25
|
+
format=serialization.PublicFormat.Raw,
|
|
26
|
+
)
|
|
27
|
+
return private_seed, public_key
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_keypair_from_file(key_path: str) -> Tuple[bytes, bytes]:
|
|
31
|
+
"""Load an existing Ed25519 private key from a file.
|
|
32
|
+
|
|
33
|
+
Supports hex-encoded raw seed (64 hex chars) or PEM format.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
key_path: Path to the key file.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
(private_seed_32bytes, public_key_32bytes)
|
|
40
|
+
"""
|
|
41
|
+
with open(key_path, "r") as f:
|
|
42
|
+
content = f.read().strip()
|
|
43
|
+
|
|
44
|
+
if content.startswith("-----BEGIN"):
|
|
45
|
+
# PEM format
|
|
46
|
+
private_key = serialization.load_pem_private_key(
|
|
47
|
+
content.encode(), password=None
|
|
48
|
+
)
|
|
49
|
+
if not isinstance(private_key, Ed25519PrivateKey):
|
|
50
|
+
raise ValueError("Key file is not an Ed25519 key")
|
|
51
|
+
else:
|
|
52
|
+
# Hex-encoded raw seed
|
|
53
|
+
seed = bytes.fromhex(content)
|
|
54
|
+
if len(seed) != 32:
|
|
55
|
+
raise ValueError(f"Expected 32-byte seed, got {len(seed)} bytes")
|
|
56
|
+
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
|
57
|
+
|
|
58
|
+
private_seed = private_key.private_bytes(
|
|
59
|
+
encoding=serialization.Encoding.Raw,
|
|
60
|
+
format=serialization.PrivateFormat.Raw,
|
|
61
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
62
|
+
)
|
|
63
|
+
public_key = private_key.public_key().public_bytes(
|
|
64
|
+
encoding=serialization.Encoding.Raw,
|
|
65
|
+
format=serialization.PublicFormat.Raw,
|
|
66
|
+
)
|
|
67
|
+
return private_seed, public_key
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def create_did(network: str = "testnet", public_key: bytes = b"") -> str:
|
|
71
|
+
"""Create a DID from a public key.
|
|
72
|
+
|
|
73
|
+
Format: did:xrpl:{nanoid}#{fragment}
|
|
74
|
+
- nanoid: 8-char hex derived from public key hash
|
|
75
|
+
- fragment: opaque UUID (Section A.3 Privacy by Default)
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
network: XRPL network (testnet, mainnet).
|
|
79
|
+
public_key: 32-byte Ed25519 public key.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
DID string.
|
|
83
|
+
"""
|
|
84
|
+
# Derive nanoid from public key hash (first 8 hex chars)
|
|
85
|
+
key_hash = hashlib.sha256(public_key).hexdigest()[:8]
|
|
86
|
+
# Fragment derived deterministically from public key for reproducibility
|
|
87
|
+
fragment = hashlib.sha256(public_key + b"fragment").hexdigest()[:8]
|
|
88
|
+
return f"did:xrpl:{key_hash}#{fragment}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def build_identity(
|
|
92
|
+
name: str,
|
|
93
|
+
provider: str = "",
|
|
94
|
+
role: str = "",
|
|
95
|
+
network: str = "testnet",
|
|
96
|
+
private_seed: bytes = b"",
|
|
97
|
+
public_key: bytes = b"",
|
|
98
|
+
did: str = "",
|
|
99
|
+
) -> Dict:
|
|
100
|
+
"""Build an identity document.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
name: Agent/user display name.
|
|
104
|
+
provider: Provider name (e.g. "efiniti").
|
|
105
|
+
role: Role description.
|
|
106
|
+
network: XRPL network.
|
|
107
|
+
private_seed: Raw 32-byte private key seed.
|
|
108
|
+
public_key: Raw 32-byte public key.
|
|
109
|
+
did: Pre-generated DID (or auto-generated from public_key).
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Identity dict suitable for JSON serialization.
|
|
113
|
+
"""
|
|
114
|
+
if not did:
|
|
115
|
+
did = create_did(network, public_key)
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"version": "1.0",
|
|
119
|
+
"did": did,
|
|
120
|
+
"name": name,
|
|
121
|
+
"provider": provider,
|
|
122
|
+
"role": role,
|
|
123
|
+
"network": network,
|
|
124
|
+
"public_key": public_key.hex(),
|
|
125
|
+
"cert_hash": hashlib.sha256(public_key).hexdigest()[:16],
|
|
126
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
127
|
+
}
|
elpis_cli/signer.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Ed25519 request signing for Elpis Protocol.
|
|
2
|
+
|
|
3
|
+
Produces X-Elpis-* headers compatible with elpis-proxy, elpis-signer,
|
|
4
|
+
and the Pandora Agent SDK.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import re
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Dict
|
|
13
|
+
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
15
|
+
|
|
16
|
+
# ARGUS H1 alignment: reject control characters in canonical fields
|
|
17
|
+
_SAFE_FIELD_RE = re.compile(r"^[^\n\r\x00-\x1f]+$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def sign_request(
|
|
21
|
+
private_seed: bytes,
|
|
22
|
+
method: str,
|
|
23
|
+
url: str,
|
|
24
|
+
body: bytes = b"",
|
|
25
|
+
) -> Dict[str, str]:
|
|
26
|
+
"""Sign an HTTP request with Ed25519.
|
|
27
|
+
|
|
28
|
+
Canonical format (identical to elpis-proxy/elpis-signer):
|
|
29
|
+
{method}\\n{url}\\n{body_hash}\\n{timestamp}\\n{nonce}
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
private_seed: 32-byte Ed25519 private key seed.
|
|
33
|
+
method: HTTP method (GET, POST, etc.).
|
|
34
|
+
url: Full target URL.
|
|
35
|
+
body: Request body bytes.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dict of X-Elpis-* headers (Timestamp, Nonce, Signature).
|
|
39
|
+
"""
|
|
40
|
+
for name, val in [("method", method), ("url", url)]:
|
|
41
|
+
if not val or not _SAFE_FIELD_RE.match(val):
|
|
42
|
+
raise ValueError(f"sign_request: {name} contains invalid characters")
|
|
43
|
+
|
|
44
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
45
|
+
nonce = str(uuid.uuid4())
|
|
46
|
+
body_hash = hashlib.sha256(body).hexdigest()
|
|
47
|
+
|
|
48
|
+
canonical = f"{method}\n{url}\n{body_hash}\n{timestamp}\n{nonce}"
|
|
49
|
+
|
|
50
|
+
key = Ed25519PrivateKey.from_private_bytes(private_seed)
|
|
51
|
+
signature = key.sign(canonical.encode())
|
|
52
|
+
signature_b64 = base64.b64encode(signature).decode()
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
"X-Elpis-Timestamp": timestamp,
|
|
56
|
+
"X-Elpis-Nonce": nonce,
|
|
57
|
+
"X-Elpis-Signature": signature_b64,
|
|
58
|
+
}
|