ghostbit-cli 1.4.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.4.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
@@ -29,6 +29,7 @@ from ._crypto import (
29
29
  decrypt,
30
30
  decrypt_bytes,
31
31
  derive_key,
32
+ derive_key_for,
32
33
  encrypt,
33
34
  gen_key,
34
35
  gen_salt,
@@ -110,10 +111,12 @@ def cmd_paste(args) -> None:
110
111
 
111
112
  if password:
112
113
  kdf_salt = gen_salt()
113
- key = derive_key(password, kdf_salt)
114
+ kdf_choice = args.kdf
115
+ key = derive_key_for(kdf_choice, password, kdf_salt)
114
116
  else:
115
117
  key = gen_key()
116
118
  kdf_salt = None
119
+ kdf_choice = "pbkdf2-sha256" # ignored when has_password is false
117
120
 
118
121
  if args.compress:
119
122
  import gzip
@@ -132,6 +135,7 @@ def cmd_paste(args) -> None:
132
135
  "burn": args.burn,
133
136
  "max_views": args.max_views,
134
137
  "compressed": args.compress,
138
+ "kdf": kdf_choice,
135
139
  }
136
140
 
137
141
  result = api_create(server, payload)
@@ -270,7 +274,10 @@ def cmd_view(args) -> None:
270
274
  if not kdf_salt:
271
275
  print("Error: no KDF salt — paste is not password-protected.", file=sys.stderr)
272
276
  sys.exit(1)
273
- 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)
274
281
  else:
275
282
  # Restore base64 padding stripped for URL safety.
276
283
  padded = key_b64url + "=" * (-len(key_b64url) % 4)
@@ -518,6 +525,10 @@ current server: {current_server}
518
525
  "--compress", "-z", action="store_true",
519
526
  help="Gzip the plaintext locally BEFORE encryption (60–80%% smaller for text).",
520
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
521
532
  parser.add_argument(
522
533
  "--max-views", "-m", type=int, default=None, metavar="N",
523
534
  help="Delete after N views.",
@@ -21,8 +21,24 @@ try:
21
21
  except ImportError:
22
22
  _CRYPTO_OK = False
23
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
+
24
32
  _PBKDF2_ITERATIONS = 600_000
25
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
+
26
42
 
27
43
  def require_crypto() -> None:
28
44
  """Abort with an actionable message if `cryptography` is missing."""
@@ -62,6 +78,7 @@ def decrypt(ciphertext_b64: str, nonce_b64: str, key: bytes) -> str:
62
78
 
63
79
 
64
80
  def derive_key(password: str, salt_b64: str) -> bytes:
81
+ """PBKDF2-SHA256 at 600k iterations — the historical default."""
65
82
  salt = base64.b64decode(salt_b64)
66
83
  kdf = PBKDF2HMAC(
67
84
  algorithm=_hashes.SHA256(), length=32, salt=salt, iterations=_PBKDF2_ITERATIONS
@@ -69,5 +86,35 @@ def derive_key(password: str, salt_b64: str) -> bytes:
69
86
  return kdf.derive(password.encode())
70
87
 
71
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
+
72
119
  def key_to_fragment(key: bytes) -> str:
73
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.4.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.4.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"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes