ghostbit-cli 1.3.0__tar.gz → 1.5.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbit-cli
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/stackopshq/ghostbit
@@ -27,7 +27,9 @@ from ._completion import cmd_completion as _cmd_completion_raw
27
27
  from ._config import DEFAULT_SERVER, cmd_config, load_config
28
28
  from ._crypto import (
29
29
  decrypt,
30
+ decrypt_bytes,
30
31
  derive_key,
32
+ derive_key_for,
31
33
  encrypt,
32
34
  gen_key,
33
35
  gen_salt,
@@ -109,12 +111,20 @@ def cmd_paste(args) -> None:
109
111
 
110
112
  if password:
111
113
  kdf_salt = gen_salt()
112
- key = derive_key(password, kdf_salt)
114
+ kdf_choice = args.kdf
115
+ key = derive_key_for(kdf_choice, password, kdf_salt)
113
116
  else:
114
117
  key = gen_key()
115
118
  kdf_salt = None
119
+ kdf_choice = "pbkdf2-sha256" # ignored when has_password is false
116
120
 
117
- ciphertext, nonce = encrypt(content, key)
121
+ if args.compress:
122
+ import gzip
123
+
124
+ payload_input: str | bytes = gzip.compress(content.encode())
125
+ else:
126
+ payload_input = content
127
+ ciphertext, nonce = encrypt(payload_input, key)
118
128
 
119
129
  payload = {
120
130
  "content": ciphertext,
@@ -124,6 +134,8 @@ def cmd_paste(args) -> None:
124
134
  "expires_in": args.expires,
125
135
  "burn": args.burn,
126
136
  "max_views": args.max_views,
137
+ "compressed": args.compress,
138
+ "kdf": kdf_choice,
127
139
  }
128
140
 
129
141
  result = api_create(server, payload)
@@ -262,14 +274,22 @@ def cmd_view(args) -> None:
262
274
  if not kdf_salt:
263
275
  print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
264
276
  sys.exit(1)
265
- key = derive_key(password, kdf_salt)
277
+ # Server tells us which KDF was used; default to legacy PBKDF2 for
278
+ # pastes written before the field existed.
279
+ kdf_choice = data.get("kdf") or "pbkdf2-sha256"
280
+ key = derive_key_for(kdf_choice, password, kdf_salt)
266
281
  else:
267
282
  # Restore base64 padding stripped for URL safety.
268
283
  padded = key_b64url + "=" * (-len(key_b64url) % 4)
269
284
  key = base64.urlsafe_b64decode(padded)
270
285
 
271
286
  try:
272
- plaintext = decrypt(data["content"], data["nonce"], key)
287
+ if data.get("compressed"):
288
+ import gzip
289
+
290
+ plaintext = gzip.decompress(decrypt_bytes(data["content"], data["nonce"], key)).decode()
291
+ else:
292
+ plaintext = decrypt(data["content"], data["nonce"], key)
273
293
  except Exception: # noqa: BLE001
274
294
  print("Error: decryption failed — wrong key or corrupted paste.", file=sys.stderr)
275
295
  sys.exit(1)
@@ -501,6 +521,14 @@ current server: {current_server}
501
521
  "--burn", "-b", action="store_true",
502
522
  help="Delete after the first view.",
503
523
  ) # fmt: skip
524
+ parser.add_argument(
525
+ "--compress", "-z", action="store_true",
526
+ help="Gzip the plaintext locally BEFORE encryption (60–80%% smaller for text).",
527
+ ) # fmt: skip
528
+ parser.add_argument(
529
+ "--kdf", choices=("pbkdf2-sha256", "argon2id"), default="pbkdf2-sha256",
530
+ help="Key derivation function (used only with --password). Default: pbkdf2-sha256.",
531
+ ) # fmt: skip
504
532
  parser.add_argument(
505
533
  "--max-views", "-m", type=int, default=None, metavar="N",
506
534
  help="Delete after N views.",
@@ -0,0 +1,120 @@
1
+ """Client-side crypto — mirrors the browser's static/e2e.js behaviour.
2
+
3
+ Any parameter change here (PBKDF2 iterations, AES mode, nonce length,
4
+ salt length) must be reflected in static/e2e.js and in the app/api.py
5
+ validators, or pastes created by one side will be unreadable by the
6
+ other. See tests/test_cli_crypto.py for the contract.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import os
13
+ import sys
14
+
15
+ try:
16
+ from cryptography.hazmat.primitives import hashes as _hashes
17
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
+
20
+ _CRYPTO_OK = True
21
+ except ImportError:
22
+ _CRYPTO_OK = False
23
+
24
+ try:
25
+ from argon2.low_level import Type as _Argon2Type
26
+ from argon2.low_level import hash_secret_raw as _argon2_hash
27
+
28
+ _ARGON2_OK = True
29
+ except ImportError:
30
+ _ARGON2_OK = False
31
+
32
+ _PBKDF2_ITERATIONS = 600_000
33
+
34
+ # OWASP 2023+ minimum for Argon2id in interactive contexts (browser/CLI).
35
+ # m = memory in KiB; t = passes; p = parallelism; hash length in bytes.
36
+ # Anything stronger meaningfully impacts page-load time on a phone — bump
37
+ # these together with the browser WASM lib's defaults when it lands.
38
+ _ARGON2_MEMORY_KIB = 19_456 # 19 MiB
39
+ _ARGON2_TIME_COST = 2
40
+ _ARGON2_PARALLELISM = 1
41
+
42
+
43
+ def require_crypto() -> None:
44
+ """Abort with an actionable message if `cryptography` is missing."""
45
+ if not _CRYPTO_OK:
46
+ print(
47
+ "Error: 'cryptography' package required. Run: pip install cryptography",
48
+ file=sys.stderr,
49
+ )
50
+ sys.exit(1)
51
+
52
+
53
+ def gen_key() -> bytes:
54
+ return os.urandom(32)
55
+
56
+
57
+ def gen_salt() -> str:
58
+ return base64.b64encode(os.urandom(16)).decode()
59
+
60
+
61
+ def encrypt(plaintext: str | bytes, key: bytes) -> tuple[str, str]:
62
+ """Encrypt a string or raw bytes with AES-256-GCM. Returns (ct_b64, nonce_b64)."""
63
+ nonce = os.urandom(12)
64
+ data = plaintext.encode() if isinstance(plaintext, str) else plaintext
65
+ ct = AESGCM(key).encrypt(nonce, data, None)
66
+ return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
67
+
68
+
69
+ def decrypt_bytes(ciphertext_b64: str, nonce_b64: str, key: bytes) -> bytes:
70
+ """Decrypt returning raw bytes — used by the compressed-paste path."""
71
+ ct = base64.b64decode(ciphertext_b64)
72
+ nonce = base64.b64decode(nonce_b64)
73
+ return AESGCM(key).decrypt(nonce, ct, None)
74
+
75
+
76
+ def decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
77
+ return decrypt_bytes(ciphertext_b64, nonce_b64, key).decode()
78
+
79
+
80
+ def derive_key(password: str, salt_b64: str) -> bytes:
81
+ """PBKDF2-SHA256 at 600k iterations — the historical default."""
82
+ salt = base64.b64decode(salt_b64)
83
+ kdf = PBKDF2HMAC(
84
+ algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=_PBKDF2_ITERATIONS
85
+ )
86
+ return kdf.derive(password.encode())
87
+
88
+
89
+ def derive_key_argon2id(password: str, salt_b64: str) -> bytes:
90
+ """Argon2id with OWASP minimum params (m=19 MiB, t=2, p=1)."""
91
+ if not _ARGON2_OK:
92
+ print(
93
+ "Error: 'argon2-cffi' package required for --kdf argon2id. "
94
+ "Run: pip install argon2-cffi",
95
+ file=sys.stderr,
96
+ )
97
+ sys.exit(1)
98
+ salt = base64.b64decode(salt_b64)
99
+ return _argon2_hash(
100
+ secret=password.encode(),
101
+ salt=salt,
102
+ time_cost=_ARGON2_TIME_COST,
103
+ memory_cost=_ARGON2_MEMORY_KIB,
104
+ parallelism=_ARGON2_PARALLELISM,
105
+ hash_len=32,
106
+ type=_Argon2Type.ID,
107
+ )
108
+
109
+
110
+ def derive_key_for(kdf: str, password: str, salt_b64: str) -> bytes:
111
+ """Dispatch to the right KDF based on the protocol's kdf field."""
112
+ if kdf == "pbkdf2-sha256":
113
+ return derive_key(password, salt_b64)
114
+ if kdf == "argon2id":
115
+ return derive_key_argon2id(password, salt_b64)
116
+ raise ValueError(f"unsupported kdf {kdf!r}")
117
+
118
+
119
+ def key_to_fragment(key: bytes) -> str:
120
+ return base64.urlsafe_b64encode(key).rstrip(b"=").decode()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghostbit-cli
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: Ghostbit CLI — create end-to-end encrypted pastes from the terminal
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/stackopshq/ghostbit
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghostbit-cli"
7
- version = "1.3.0"
7
+ version = "1.5.0"
8
8
  description = "Ghostbit CLI — create end-to-end encrypted pastes from the terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,67 +0,0 @@
1
- """Client-side crypto — mirrors the browser's static/e2e.js behaviour.
2
-
3
- Any parameter change here (PBKDF2 iterations, AES mode, nonce length,
4
- salt length) must be reflected in static/e2e.js and in the app/api.py
5
- validators, or pastes created by one side will be unreadable by the
6
- other. See tests/test_cli_crypto.py for the contract.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- import base64
12
- import os
13
- import sys
14
-
15
- try:
16
- from cryptography.hazmat.primitives import hashes as _hashes
17
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
18
- from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
19
-
20
- _CRYPTO_OK = True
21
- except ImportError:
22
- _CRYPTO_OK = False
23
-
24
- _PBKDF2_ITERATIONS = 600_000
25
-
26
-
27
- def require_crypto() -> None:
28
- """Abort with an actionable message if `cryptography` is missing."""
29
- if not _CRYPTO_OK:
30
- print(
31
- "Error: 'cryptography' package required. Run: pip install cryptography",
32
- file=sys.stderr,
33
- )
34
- sys.exit(1)
35
-
36
-
37
- def gen_key() -> bytes:
38
- return os.urandom(32)
39
-
40
-
41
- def gen_salt() -> str:
42
- return base64.b64encode(os.urandom(16)).decode()
43
-
44
-
45
- def encrypt(plaintext: str, key: bytes) -> tuple[str, str]:
46
- nonce = os.urandom(12)
47
- ct = AESGCM(key).encrypt(nonce, plaintext.encode(), None)
48
- return base64.b64encode(ct).decode(), base64.b64encode(nonce).decode()
49
-
50
-
51
- def decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
52
- ct = base64.b64decode(ciphertext_b64)
53
- nonce = base64.b64decode(nonce_b64)
54
- pt = AESGCM(key).decrypt(nonce, ct, None)
55
- return pt.decode()
56
-
57
-
58
- def derive_key(password: str, salt_b64: str) -> bytes:
59
- salt = base64.b64decode(salt_b64)
60
- kdf = PBKDF2HMAC(
61
- algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=_PBKDF2_ITERATIONS
62
- )
63
- return kdf.derive(password.encode())
64
-
65
-
66
- def key_to_fragment(key: bytes) -> str:
67
- return base64.urlsafe_b64encode(key).rstrip(b"=").decode()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes