ghostbit-cli 1.0.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.
cli.py ADDED
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Ghostbit CLI — create and view pastes from the terminal.
4
+
5
+ Usage:
6
+ cat file.py | gb
7
+ gb file.py
8
+ gb file.py --lang python --burn
9
+ gb view https://paste.example.com/abc123#KEY~TOKEN
10
+ gb config set server https://paste.example.com
11
+ gb config show
12
+ """
13
+
14
+ import argparse
15
+ import base64
16
+ import json
17
+ import os
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from pathlib import Path
23
+
24
+ __version__ = "1.0.0"
25
+ _USER_AGENT = f"Ghostbit-CLI/{__version__}"
26
+
27
+ try:
28
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
29
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
30
+ from cryptography.hazmat.primitives import hashes as _hashes
31
+ _CRYPTO_OK = True
32
+ except ImportError:
33
+ _CRYPTO_OK = False
34
+
35
+
36
+ # ── Crypto (mirrors e2e.js) ───────────────────────────────────────────────────
37
+
38
+ def _require_crypto():
39
+ if not _CRYPTO_OK:
40
+ print("Error: 'cryptography' package required. Run: pip install cryptography", file=sys.stderr)
41
+ sys.exit(1)
42
+
43
+ def _gen_key() -> bytes:
44
+ return os.urandom(32)
45
+
46
+ def _gen_salt() -> str:
47
+ return base64.b64encode(os.urandom(16)).decode()
48
+
49
+ def _encrypt(plaintext: str, key: bytes) -> tuple[str, str]:
50
+ nonce = os.urandom(12)
51
+ ct = AESGCM(key).encrypt(nonce, plaintext.encode(), None)
52
+ return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
53
+
54
+ def _decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
55
+ ct = base64.b64decode(ciphertext_b64)
56
+ nonce = base64.b64decode(nonce_b64)
57
+ pt = AESGCM(key).decrypt(nonce, ct, None)
58
+ return pt.decode()
59
+
60
+ def _derive_key(password: str, salt_b64: str) -> bytes:
61
+ salt = base64.b64decode(salt_b64)
62
+ kdf = PBKDF2HMAC(algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=600_000)
63
+ return kdf.derive(password.encode())
64
+
65
+ def _key_to_fragment(key: bytes) -> str:
66
+ return base64.urlsafe_b64encode(key).rstrip(b'=').decode()
67
+
68
+ # ── Config ────────────────────────────────────────────────────────────────────
69
+
70
+ CONFIG_PATH = Path.home() / ".config" / "ghostbit.toml"
71
+ DEFAULT_SERVER = "http://localhost:8000"
72
+
73
+ VALID_KEYS = {"server"}
74
+
75
+
76
+ def _load_config() -> dict:
77
+ if not CONFIG_PATH.exists():
78
+ return {}
79
+ cfg = {}
80
+ for line in CONFIG_PATH.read_text().splitlines():
81
+ line = line.strip()
82
+ if not line or line.startswith("#"):
83
+ continue
84
+ if "=" in line:
85
+ key, _, val = line.partition("=")
86
+ cfg[key.strip()] = val.strip().strip('"').strip("'")
87
+ return cfg
88
+
89
+
90
+ def _write_config(cfg: dict) -> None:
91
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
92
+ lines = [f'{k} = "{v}"' for k, v in cfg.items()]
93
+ CONFIG_PATH.write_text("\n".join(lines) + "\n")
94
+
95
+
96
+ # ── Subcommands ───────────────────────────────────────────────────────────────
97
+
98
+ def _run_config(argv):
99
+ parser = argparse.ArgumentParser(prog="gb config", description="Manage Ghostbit CLI configuration.")
100
+ sub = parser.add_subparsers(dest="action")
101
+
102
+ sub.add_parser("show", help="Show current configuration.")
103
+
104
+ p_set = sub.add_parser("set", help="Set a config value.")
105
+ p_set.add_argument("key", help="Config key (e.g. server)")
106
+ p_set.add_argument("value", help="Value to set")
107
+
108
+ p_unset = sub.add_parser("unset", help="Remove a config key.")
109
+ p_unset.add_argument("key", help="Config key to remove")
110
+
111
+ args = parser.parse_args(argv)
112
+ if not args.action:
113
+ parser.print_help()
114
+ sys.exit(0)
115
+ cmd_config(args.action, getattr(args, "key", None), getattr(args, "value", None))
116
+
117
+
118
+ def cmd_config(action, key=None, value=None):
119
+ cfg = _load_config()
120
+
121
+ if action == "show":
122
+ if not cfg:
123
+ print(f"No config yet. File would be: {CONFIG_PATH}", file=sys.stderr)
124
+ print(f"server = {DEFAULT_SERVER!r} (default)")
125
+ else:
126
+ print(f"# {CONFIG_PATH}")
127
+ for k, v in cfg.items():
128
+ print(f"{k} = {v!r}")
129
+ return
130
+
131
+ if action == "set":
132
+ k = key.lower()
133
+ if k not in VALID_KEYS:
134
+ print(f"Unknown config key {k!r}. Valid keys: {', '.join(VALID_KEYS)}", file=sys.stderr)
135
+ sys.exit(1)
136
+ cfg[k] = value
137
+ _write_config(cfg)
138
+ print(f"Set {k} = {value!r}")
139
+ print(f"Config saved to {CONFIG_PATH}")
140
+ return
141
+
142
+ if action == "unset":
143
+ k = key.lower()
144
+ if k in cfg:
145
+ del cfg[k]
146
+ _write_config(cfg)
147
+ print(f"Removed {k!r} from config.")
148
+ else:
149
+ print(f"{k!r} is not set.", file=sys.stderr)
150
+ return
151
+
152
+
153
+ def cmd_paste(args):
154
+ cfg = _load_config()
155
+ server = args.server or cfg.get("server", DEFAULT_SERVER)
156
+
157
+ # Read content
158
+ if args.file:
159
+ path = Path(args.file)
160
+ if not path.exists():
161
+ print(f"File not found: {args.file}", file=sys.stderr)
162
+ sys.exit(1)
163
+ content = path.read_text(errors="replace")
164
+ # Infer language from extension if not specified
165
+ if args.lang is None:
166
+ ext_map = {
167
+ ".py": "python", ".js": "javascript", ".ts": "typescript",
168
+ ".go": "go", ".rs": "rust", ".rb": "ruby", ".php": "php",
169
+ ".java": "java", ".c": "c", ".cpp": "cpp", ".cs": "csharp",
170
+ ".sh": "bash", ".bash": "bash", ".zsh": "bash",
171
+ ".html": "html", ".css": "css", ".sql": "sql",
172
+ ".json": "json", ".yaml": "yaml", ".yml": "yaml",
173
+ ".toml": "toml", ".xml": "xml", ".md": "markdown",
174
+ ".dockerfile": "dockerfile", ".kt": "kotlin", ".swift": "swift",
175
+ ".lua": "lua", ".r": "r", ".diff": "diff", ".patch": "diff",
176
+ }
177
+ args.lang = ext_map.get(path.suffix.lower())
178
+ else:
179
+ if sys.stdin.isatty():
180
+ print("Reading from stdin… (Ctrl+D to finish)", file=sys.stderr)
181
+ content = sys.stdin.read()
182
+
183
+ if not content.strip():
184
+ print("Error: content is empty.", file=sys.stderr)
185
+ sys.exit(1)
186
+
187
+ _require_crypto()
188
+
189
+ # ── Encrypt client-side (mirrors browser e2e.js) ──
190
+ if args.password:
191
+ kdf_salt = _gen_salt()
192
+ key = _derive_key(args.password, kdf_salt)
193
+ else:
194
+ key = _gen_key()
195
+ kdf_salt = None
196
+
197
+ ciphertext, nonce = _encrypt(content, key)
198
+
199
+ payload = {
200
+ "content": ciphertext,
201
+ "nonce": nonce,
202
+ "kdf_salt": kdf_salt,
203
+ "language": args.lang,
204
+ "expires_in": args.expires,
205
+ "burn": args.burn,
206
+ "max_views": args.max_views,
207
+ }
208
+
209
+ result = _api_create(server, payload)
210
+
211
+ # Build full URL with fragment (key + delete token)
212
+ if args.password:
213
+ fragment = f"~{result['delete_token']}"
214
+ else:
215
+ fragment = f"{_key_to_fragment(key)}~{result['delete_token']}"
216
+
217
+ full_url = f"{result['url']}#{fragment}"
218
+
219
+ if args.json:
220
+ result["full_url"] = full_url
221
+ print(json.dumps(result, indent=2))
222
+ elif args.quiet:
223
+ print(full_url)
224
+ else:
225
+ print(full_url)
226
+ if sys.stdout.isatty():
227
+ parts = []
228
+ if result.get("expires_at"):
229
+ delta = result["expires_at"] - int(time.time())
230
+ if delta < 3600:
231
+ parts.append(f"expires in {delta // 60}m")
232
+ elif delta < 86400:
233
+ parts.append(f"expires in {delta // 3600}h")
234
+ else:
235
+ parts.append(f"expires in {delta // 86400}d")
236
+ if result.get("burn"):
237
+ parts.append("burn after read")
238
+ if result.get("max_views"):
239
+ parts.append(f"max {result['max_views']} views")
240
+ if args.password:
241
+ parts.append("password protected")
242
+ if parts:
243
+ print(" " + " · ".join(parts), file=sys.stderr)
244
+ if not args.password:
245
+ print(" Share the full URL — the decryption key is in the #fragment.", file=sys.stderr)
246
+
247
+
248
+ def _print_highlighted(content: str, language: str | None) -> None:
249
+ """Print content with terminal syntax highlighting.
250
+
251
+ Markdown: rendered via rich (titles, bold, lists, code blocks…) if available.
252
+ Other languages: syntax-highlighted via Pygments if available.
253
+ Fallback: plain text.
254
+ """
255
+ if language == "markdown":
256
+ try:
257
+ from rich.console import Console
258
+ from rich.markdown import Markdown
259
+ Console().print(Markdown(content))
260
+ return
261
+ except ImportError:
262
+ pass
263
+
264
+ try:
265
+ from pygments import highlight
266
+ from pygments.formatters import TerminalTrueColorFormatter
267
+ from pygments.lexers import TextLexer, get_lexer_by_name
268
+ from pygments.util import ClassNotFound
269
+
270
+ try:
271
+ lexer = get_lexer_by_name(language) if language else TextLexer()
272
+ except ClassNotFound:
273
+ lexer = TextLexer()
274
+
275
+ print(highlight(content, lexer, TerminalTrueColorFormatter(style="monokai")), end="")
276
+ return
277
+ except ImportError:
278
+ pass
279
+
280
+ print(content)
281
+
282
+
283
+ def cmd_view(args):
284
+ import getpass
285
+ from urllib.parse import urldefrag, urlparse
286
+
287
+ url_no_frag, fragment = urldefrag(args.url)
288
+ parsed = urlparse(url_no_frag)
289
+ server = f"{parsed.scheme}://{parsed.netloc}"
290
+ paste_id = parsed.path.strip("/")
291
+
292
+ if not paste_id or not parsed.scheme:
293
+ print("Error: invalid URL.", file=sys.stderr)
294
+ sys.exit(1)
295
+
296
+ # Fragment format: KEY_B64URL~DELETE_TOKEN (no password)
297
+ # ~DELETE_TOKEN (password-protected)
298
+ key_b64url = fragment.partition("~")[0]
299
+ is_password = not key_b64url
300
+
301
+ # Fetch paste metadata + ciphertext
302
+ api_url = f"{server}/api/v1/pastes/{paste_id}"
303
+ req = urllib.request.Request(api_url, headers={"User-Agent": _USER_AGENT})
304
+ try:
305
+ with urllib.request.urlopen(req, timeout=15) as resp:
306
+ data = json.loads(resp.read())
307
+ except urllib.error.HTTPError as e:
308
+ body = e.read().decode(errors="replace")
309
+ try:
310
+ detail = json.loads(body).get("detail", body)
311
+ except Exception:
312
+ detail = body
313
+ print(f"Error {e.code}: {detail}", file=sys.stderr)
314
+ sys.exit(1)
315
+ except urllib.error.URLError as e:
316
+ print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
317
+ sys.exit(1)
318
+
319
+ _require_crypto()
320
+
321
+ # Derive or import decryption key
322
+ if is_password:
323
+ password = getpass.getpass("Password: ")
324
+ kdf_salt = data.get("kdf_salt")
325
+ if not kdf_salt:
326
+ print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
327
+ sys.exit(1)
328
+ key = _derive_key(password, kdf_salt)
329
+ else:
330
+ # Restore base64 padding stripped for URL safety
331
+ padded = key_b64url + "=" * (-len(key_b64url) % 4)
332
+ key = base64.urlsafe_b64decode(padded)
333
+
334
+ try:
335
+ plaintext = _decrypt(data["content"], data["nonce"], key)
336
+ except Exception:
337
+ print("Error: decryption failed — wrong key or corrupted paste.", file=sys.stderr)
338
+ sys.exit(1)
339
+
340
+ # Warn if this view just burned the paste
341
+ burned = data.get("burn") or (
342
+ data.get("max_views") and data.get("view_count", 0) >= data["max_views"]
343
+ )
344
+ if burned and sys.stderr.isatty():
345
+ print("⚠️ This paste has been burned and is no longer available on the server.", file=sys.stderr)
346
+
347
+ language = data.get("language")
348
+ if sys.stdout.isatty():
349
+ _print_highlighted(plaintext, language)
350
+ else:
351
+ print(plaintext, end="")
352
+
353
+
354
+ # ── API ───────────────────────────────────────────────────────────────────────
355
+
356
+ def _api_create(server: str, payload: dict) -> dict:
357
+ url = server.rstrip("/") + "/api/v1/pastes"
358
+ data = json.dumps(payload).encode()
359
+ req = urllib.request.Request(
360
+ url,
361
+ data=data,
362
+ headers={"Content-Type": "application/json", "User-Agent": _USER_AGENT},
363
+ method="POST",
364
+ )
365
+ try:
366
+ with urllib.request.urlopen(req, timeout=15) as resp:
367
+ return json.loads(resp.read())
368
+ except urllib.error.HTTPError as e:
369
+ body = e.read().decode(errors="replace")
370
+ try:
371
+ detail = json.loads(body).get("detail", body)
372
+ except Exception:
373
+ detail = body
374
+ print(f"Error {e.code}: {detail}", file=sys.stderr)
375
+ sys.exit(1)
376
+ except urllib.error.URLError as e:
377
+ print(f"Could not connect to {server}: {e.reason}", file=sys.stderr)
378
+ print(f" Tip: run `gb config set server <URL>` to set your server.", file=sys.stderr)
379
+ sys.exit(1)
380
+
381
+
382
+ # ── Entry point ───────────────────────────────────────────────────────────────
383
+
384
+ def main():
385
+ # Route to config subcommand early so file paths aren't mistaken for subcommands
386
+ if len(sys.argv) > 1 and sys.argv[1] == "config":
387
+ _run_config(sys.argv[2:])
388
+ return
389
+
390
+ # Route to view subcommand
391
+ if len(sys.argv) > 1 and sys.argv[1] == "view":
392
+ if len(sys.argv) < 3:
393
+ print("Usage: gb view <url>", file=sys.stderr)
394
+ sys.exit(1)
395
+ view_parser = argparse.ArgumentParser(prog="gb view")
396
+ view_parser.add_argument("url", help="Full paste URL (including #fragment).")
397
+ cmd_view(view_parser.parse_args(sys.argv[2:]))
398
+ return
399
+
400
+ cfg = _load_config()
401
+ current_server = cfg.get("server", DEFAULT_SERVER)
402
+
403
+ parser = argparse.ArgumentParser(
404
+ prog="gb",
405
+ description="Ghostbit CLI — create encrypted pastes from the terminal.",
406
+ formatter_class=argparse.RawDescriptionHelpFormatter,
407
+ epilog=f"""
408
+ examples:
409
+ cat main.py | gb
410
+ gb main.py
411
+ gb main.py --lang python --burn
412
+ gb main.py --expires 3600 --password secret
413
+ gb view https://paste.example.com/abc123#KEY~TOKEN
414
+ gb config set server https://paste.example.com
415
+ gb config show
416
+
417
+ current server: {current_server}
418
+ """,
419
+ )
420
+
421
+ # ── gb [file] ──
422
+ parser.add_argument(
423
+ "file",
424
+ nargs="?",
425
+ help="File to paste. Reads from stdin if omitted.",
426
+ )
427
+ parser.add_argument(
428
+ "--server", "-s",
429
+ default=None,
430
+ metavar="URL",
431
+ help=f"Server URL for this invocation only (current: {current_server})",
432
+ )
433
+ parser.add_argument(
434
+ "--lang", "-l",
435
+ default=None,
436
+ help="Language (e.g. python, javascript, go). Auto-detected if omitted.",
437
+ )
438
+ parser.add_argument(
439
+ "--expires", "-e",
440
+ type=int,
441
+ default=None,
442
+ metavar="SECONDS",
443
+ help="Expiry TTL in seconds (3600 = 1 h, 86400 = 1 d). Default: never.",
444
+ )
445
+ parser.add_argument(
446
+ "--burn", "-b",
447
+ action="store_true",
448
+ help="Delete after the first view.",
449
+ )
450
+ parser.add_argument(
451
+ "--max-views", "-m",
452
+ type=int,
453
+ default=None,
454
+ metavar="N",
455
+ help="Delete after N views.",
456
+ )
457
+ parser.add_argument(
458
+ "--password", "-p",
459
+ default=None,
460
+ metavar="PASS",
461
+ help="Encrypt with a password.",
462
+ )
463
+ parser.add_argument(
464
+ "--quiet", "-q",
465
+ action="store_true",
466
+ help="Print only the URL.",
467
+ )
468
+ parser.add_argument(
469
+ "--json",
470
+ action="store_true",
471
+ help="Print the full JSON response.",
472
+ )
473
+
474
+ args = parser.parse_args()
475
+ cmd_paste(args)
476
+
477
+
478
+ if __name__ == "__main__":
479
+ main()
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghostbit-cli
3
+ Version: 1.0.0
4
+ Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/stackopshq/ghostbit
7
+ Project-URL: Repository, https://github.com/stackopshq/ghostbit
8
+ Project-URL: Bug Tracker, https://github.com/stackopshq/ghostbit/issues
9
+ Keywords: paste,cli,encryption,privacy,ghostbit
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Internet
21
+ Classifier: Topic :: Security :: Cryptography
22
+ Classifier: Topic :: Utilities
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ Requires-Dist: cryptography>=42.0.0
26
+ Provides-Extra: color
27
+ Requires-Dist: pygments>=2.17.0; extra == "color"
28
+ Provides-Extra: markdown
29
+ Requires-Dist: rich>=13.0.0; extra == "markdown"
30
+ Provides-Extra: all
31
+ Requires-Dist: pygments>=2.17.0; extra == "all"
32
+ Requires-Dist: rich>=13.0.0; extra == "all"
33
+
34
+ # Ghostbit CLI
35
+
36
+ Command-line tool for [Ghostbit](https://github.com/stackopshq/ghostbit) — a self-hosted, end-to-end encrypted paste service.
37
+
38
+ All content is encrypted **in the client** before being sent to the server. The server never sees your plaintext.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install ghostbit-cli
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ # Paste from stdin
50
+ cat file.py | gb
51
+
52
+ # Paste a file (language auto-detected from extension)
53
+ gb file.py
54
+
55
+ # With options
56
+ gb file.py --lang python --burn --expires 3600
57
+
58
+ # Password-protected paste
59
+ echo "secret" | gb --password mysecret
60
+
61
+ # Output JSON (includes full URL with decryption key)
62
+ cat data.json | gb --json
63
+
64
+ # Point to your self-hosted instance
65
+ gb config set server https://paste.example.com
66
+ ```
67
+
68
+ ## Options
69
+
70
+ | Flag | Short | Description |
71
+ |------|-------|-------------|
72
+ | `--lang` | `-l` | Language hint (python, go, rust, …) |
73
+ | `--expires` | `-e` | TTL in seconds (3600 = 1h, 86400 = 1d) |
74
+ | `--burn` | `-b` | Delete after the first view |
75
+ | `--max-views` | `-m` | Delete after N views |
76
+ | `--password` | `-p` | Encrypt with a password |
77
+ | `--server` | `-s` | Override server URL for this call |
78
+ | `--quiet` | `-q` | Print URL only |
79
+ | `--json` | | Print full JSON response |
80
+
81
+ ## Configuration
82
+
83
+ ```bash
84
+ gb config set server https://paste.example.com
85
+ gb config show
86
+ gb config unset server
87
+ ```
88
+
89
+ Config is stored at `~/.config/ghostbit.toml`.
90
+
91
+ ## Self-hosting
92
+
93
+ See the [Ghostbit server repository](https://github.com/stackopshq/ghostbit) for Docker and manual setup instructions.
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,6 @@
1
+ cli.py,sha256=gh_EMW2iG-CcxTXgWz_B-a1zR5cl33f8uYhdZcVJfnM,16117
2
+ ghostbit_cli-1.0.0.dist-info/METADATA,sha256=6w0J6Y27NchEzoSZ4eMO7au0B31I4GCMvjOc_PMnVmQ,2885
3
+ ghostbit_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
4
+ ghostbit_cli-1.0.0.dist-info/entry_points.txt,sha256=nfNW9iDaLizsPkidZBG4jz-jn7iZBdaToJv2claY1XU,52
5
+ ghostbit_cli-1.0.0.dist-info/top_level.txt,sha256=2ImG917oaVHlm0nP9oJE-Qrgs-fq_fGWgba2H1f8fpE,4
6
+ ghostbit_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ gb = cli:main
3
+ ghostbit = cli:main
@@ -0,0 +1 @@
1
+ cli