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 ADDED
@@ -0,0 +1,3 @@
1
+ """Elpis Protocol CLI -- Agent Identity & Interaction."""
2
+
3
+ __version__ = "0.1.0"
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
+ }