detssh 0.6.0__tar.gz → 0.7.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.
Files changed (52) hide show
  1. {detssh-0.6.0 → detssh-0.7.0}/CHANGELOG.md +19 -0
  2. {detssh-0.6.0 → detssh-0.7.0}/PKG-INFO +21 -1
  3. {detssh-0.6.0 → detssh-0.7.0}/README.md +20 -0
  4. {detssh-0.6.0 → detssh-0.7.0}/detssh/__init__.py +1 -1
  5. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/base.py +32 -3
  6. {detssh-0.6.0 → detssh-0.7.0}/detssh/cli.py +80 -9
  7. {detssh-0.6.0 → detssh-0.7.0}/detssh/keygen.py +40 -7
  8. {detssh-0.6.0 → detssh-0.7.0}/pyproject.toml +1 -1
  9. {detssh-0.6.0 → detssh-0.7.0}/tests/golden/help_default.txt +14 -5
  10. {detssh-0.6.0 → detssh-0.7.0}/tests/golden/help_pbkdf2_sha256.txt +14 -5
  11. {detssh-0.6.0 → detssh-0.7.0}/tests/mutmut_known_equivalents.txt +21 -11
  12. detssh-0.7.0/tests/test_authorize.py +394 -0
  13. {detssh-0.6.0 → detssh-0.7.0}/uv.lock +1 -1
  14. {detssh-0.6.0 → detssh-0.7.0}/.github/workflows/publish.yml +0 -0
  15. {detssh-0.6.0 → detssh-0.7.0}/.github/workflows/tests.yml +0 -0
  16. {detssh-0.6.0 → detssh-0.7.0}/.gitignore +0 -0
  17. {detssh-0.6.0 → detssh-0.7.0}/LICENSE +0 -0
  18. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/__init__.py +0 -0
  19. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/__init__.py +0 -0
  20. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/_argon2.py +0 -0
  21. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/argon2d.py +0 -0
  22. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/argon2i.py +0 -0
  23. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/argon2id.py +0 -0
  24. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/base.py +0 -0
  25. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
  26. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/pbkdf2.py +0 -0
  27. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/scrypt.py +0 -0
  28. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/__init__.py +0 -0
  29. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/base.py +0 -0
  30. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/blake2b.py +0 -0
  31. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/blake2s.py +0 -0
  32. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha256.py +0 -0
  33. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha384.py +0 -0
  34. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha3_256.py +0 -0
  35. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha3_384.py +0 -0
  36. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha3_512.py +0 -0
  37. {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha512.py +0 -0
  38. {detssh-0.6.0 → detssh-0.7.0}/detssh/registry.py +0 -0
  39. {detssh-0.6.0 → detssh-0.7.0}/detssh/ssh_cli.py +0 -0
  40. {detssh-0.6.0 → detssh-0.7.0}/detssh/ssh_config.py +0 -0
  41. {detssh-0.6.0 → detssh-0.7.0}/tests/golden/README.md +0 -0
  42. {detssh-0.6.0 → detssh-0.7.0}/tests/golden/variations.csv +0 -0
  43. {detssh-0.6.0 → detssh-0.7.0}/tests/golden/vectors.csv +0 -0
  44. {detssh-0.6.0 → detssh-0.7.0}/tests/test_cli.py +0 -0
  45. {detssh-0.6.0 → detssh-0.7.0}/tests/test_cli_combinations.py +0 -0
  46. {detssh-0.6.0 → detssh-0.7.0}/tests/test_determinism.py +0 -0
  47. {detssh-0.6.0 → detssh-0.7.0}/tests/test_golden_vectors.py +0 -0
  48. {detssh-0.6.0 → detssh-0.7.0}/tests/test_help_text.py +0 -0
  49. {detssh-0.6.0 → detssh-0.7.0}/tests/test_properties.py +0 -0
  50. {detssh-0.6.0 → detssh-0.7.0}/tests/test_registry.py +0 -0
  51. {detssh-0.6.0 → detssh-0.7.0}/tests/test_ssh_cli.py +0 -0
  52. {detssh-0.6.0 → detssh-0.7.0}/tests/test_ssh_config.py +0 -0
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - `--authorize`: derive the key, but only append its public half to
15
+ `~/.ssh/authorized_keys` (or `--authorized-keys FILE`), the way
16
+ `ssh-copy-id` would. No private key is written, so a machine you only want
17
+ to log *into* never holds one. A key that's already in the file (under any
18
+ comment or options) is left alone, and an existing file keeps its mode; a
19
+ new one is created 600 inside a 700 `~/.ssh`.
20
+ - Interactive mode now opens by asking whether to generate a keypair or only
21
+ add the public key. Generating stays the default. `detssh --authorize`
22
+ skips that question and still prompts for everything else, so the seed
23
+ passphrase never has to go on the command line.
24
+
25
+ ### Changed
26
+
27
+ - `--create-parent-dirs` applies to the `--authorized-keys` file's parent too.
28
+
10
29
  ## [0.6.0] - 2026-09-19
11
30
 
12
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: detssh
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Deterministic Ed25519 SSH key generation from a seed passphrase, via configurable KDF and salt-hash backends.
5
5
  Project-URL: Homepage, https://github.com/isik-kaplan/detssh
6
6
  Project-URL: Repository, https://github.com/isik-kaplan/detssh
@@ -50,6 +50,26 @@ never touches key derivation, so it doesn't need to be remembered to
50
50
  *recreate* the key, only to unlock the file you already have. Same caveat as
51
51
  `--seed` applies if passed as a flag.
52
52
 
53
+ ## Only installing the public key
54
+
55
+ Because the same seed and parameters always give the same key, a machine
56
+ you only want to log *into* doesn't need the private key at all. Run this
57
+ on that machine instead:
58
+ ```
59
+ detssh --authorize
60
+ ```
61
+ It prompts for the same seed, label and backend settings as a normal run,
62
+ then appends the public key to `~/.ssh/authorized_keys`, `ssh-copy-id`
63
+ style. No private key is written. If the key is already there, the file is
64
+ left untouched, so running it twice is harmless. The interactive wizard
65
+ asks about this up front too ("Only add the public key to an
66
+ authorized_keys file").
67
+
68
+ In flag mode, add `--authorize` to the usual flags, plus `--authorized-keys
69
+ FILE` for a file other than `~/.ssh/authorized_keys`. `--output`,
70
+ `--key-passphrase`, `--register` and `--overwrite-files` are rejected
71
+ alongside it, because they only apply when a private key is written.
72
+
53
73
  ## Using the key with `ssh`
54
74
 
55
75
  `ssh` only auto-offers keys with default filenames (`id_rsa`, `id_ed25519`, ...)
@@ -29,6 +29,26 @@ never touches key derivation, so it doesn't need to be remembered to
29
29
  *recreate* the key, only to unlock the file you already have. Same caveat as
30
30
  `--seed` applies if passed as a flag.
31
31
 
32
+ ## Only installing the public key
33
+
34
+ Because the same seed and parameters always give the same key, a machine
35
+ you only want to log *into* doesn't need the private key at all. Run this
36
+ on that machine instead:
37
+ ```
38
+ detssh --authorize
39
+ ```
40
+ It prompts for the same seed, label and backend settings as a normal run,
41
+ then appends the public key to `~/.ssh/authorized_keys`, `ssh-copy-id`
42
+ style. No private key is written. If the key is already there, the file is
43
+ left untouched, so running it twice is harmless. The interactive wizard
44
+ asks about this up front too ("Only add the public key to an
45
+ authorized_keys file").
46
+
47
+ In flag mode, add `--authorize` to the usual flags, plus `--authorized-keys
48
+ FILE` for a file other than `~/.ssh/authorized_keys`. `--output`,
49
+ `--key-passphrase`, `--register` and `--overwrite-files` are rejected
50
+ alongside it, because they only apply when a private key is written.
51
+
32
52
  ## Using the key with `ssh`
33
53
 
34
54
  `ssh` only auto-offers keys with default filenames (`id_rsa`, `id_ed25519`, ...)
@@ -3,7 +3,7 @@ from detssh.backends.salt import ALGOS as SALT_ALGOS
3
3
  from detssh.keygen import default_output_path, keypair_from_seed, public_key_path, write_keypair
4
4
 
5
5
 
6
- __version__ = "0.6.0"
6
+ __version__ = "0.7.0"
7
7
  __all__ = [
8
8
  "KDF_BACKENDS",
9
9
  "SALT_ALGOS",
@@ -3,7 +3,14 @@ from pathlib import Path
3
3
  import click
4
4
  import questionary
5
5
 
6
- from detssh.keygen import keypair_from_seed, public_key_path, ssh_dir, write_keypair
6
+ from detssh.keygen import (
7
+ authorize_public_key,
8
+ keypair_from_seed,
9
+ public_key_line,
10
+ public_key_path,
11
+ ssh_dir,
12
+ write_keypair,
13
+ )
7
14
 
8
15
 
9
16
  def _display_path(path):
@@ -215,9 +222,31 @@ def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="
215
222
  click.echo(f"Wrote private key: {priv_path}{' (encrypted)' if key_passphrase else ''}")
216
223
  click.echo(f"Wrote public key: {pub_path}")
217
224
 
225
+ echo_recap(recap)
226
+
227
+ return priv_path, pub_path
228
+
229
+
230
+ def authorize_and_recap(seed_bytes, authorized_keys, comment, recap, create_parent_dirs=False):
231
+ _, public_key = keypair_from_seed(seed_bytes)
232
+ try:
233
+ added = authorize_public_key(
234
+ public_key, authorized_keys, comment=comment, create_parent_dirs=create_parent_dirs
235
+ )
236
+ except OSError as e:
237
+ raise click.ClickException(f"couldn't write {authorized_keys}: {e.strerror or e}") from e
238
+
239
+ if added:
240
+ click.echo(f"Added public key to {authorized_keys}:")
241
+ else:
242
+ click.echo(f"Public key already in {authorized_keys}, left it unchanged:")
243
+ click.echo(f" {public_key_line(public_key, comment).decode('utf-8').rstrip()}")
244
+
245
+ echo_recap(recap)
246
+
247
+
248
+ def echo_recap(recap):
218
249
  click.echo()
219
250
  click.echo("To recreate this exact key, remember your passphrase (keep it secret) plus:")
220
251
  for name, value in (*recap, ("algorithm", "ed25519")):
221
252
  click.echo(f" {name:<18} {value}")
222
-
223
- return priv_path, pub_path
@@ -1,5 +1,6 @@
1
1
  import sys
2
2
  import textwrap
3
+ from pathlib import Path
3
4
 
4
5
  import click
5
6
  from click.formatting import measure_table
@@ -8,6 +9,8 @@ from detssh.backends import kdf, salt
8
9
  from detssh.backends.base import (
9
10
  DEFAULT_TEXT_MARKER,
10
11
  Field,
12
+ _display_path,
13
+ authorize_and_recap,
11
14
  build_options,
12
15
  confirm_create_parent_dirs,
13
16
  confirm_overwrite,
@@ -17,7 +20,12 @@ from detssh.backends.base import (
17
20
  )
18
21
  from detssh.backends.kdf.base import KDFError
19
22
  from detssh.backends.salt.base import SaltError
20
- from detssh.keygen import COMMENT_FORBIDDEN_CHARS, ED25519_SEED_LENGTH, default_output_path
23
+ from detssh.keygen import (
24
+ COMMENT_FORBIDDEN_CHARS,
25
+ ED25519_SEED_LENGTH,
26
+ default_authorized_keys_path,
27
+ default_output_path,
28
+ )
21
29
  from detssh.ssh_cli import OptionalPort, register_entry, split_destination
22
30
 
23
31
 
@@ -25,6 +33,12 @@ DEFAULT_KDF = "argon2id"
25
33
  DEFAULT_SALT_ALGO = "blake2b"
26
34
  HASH_LEN = ED25519_SEED_LENGTH
27
35
 
36
+ GENERATE_MODE = "Generate a keypair (private + public key)"
37
+ AUTHORIZE_MODE = "Only add the public key to an authorized_keys file"
38
+ MODE = Field("What do you want to do?", kind="select", choices=(GENERATE_MODE, AUTHORIZE_MODE))
39
+ # Flags that only make sense when a private key gets written.
40
+ KEYPAIR_ONLY_FLAGS = ("output", "key_passphrase", "register", "register_port")
41
+
28
42
  SELECTORS = {
29
43
  "kdf": Field("KDF backend", kind="select", choices=tuple(kdf.BACKENDS)),
30
44
  "salt_algo": Field("Salt hash algorithm", kind="select", choices=tuple(salt.ALGOS)),
@@ -65,8 +79,8 @@ def _peek(args, flag, default):
65
79
  return value
66
80
 
67
81
 
68
- def _fields_for(kdf_cls, salt_cls):
69
- return {
82
+ def _fields_for(kdf_cls, salt_cls, authorize=False):
83
+ fields = {
70
84
  "seed": COMMON["seed"],
71
85
  "label": COMMON["label"],
72
86
  **kdf_cls.fields,
@@ -75,6 +89,9 @@ def _fields_for(kdf_cls, salt_cls):
75
89
  "comment": COMMON["comment"],
76
90
  "key_passphrase": COMMON["key_passphrase"],
77
91
  }
92
+ if authorize:
93
+ del fields["output"], fields["key_passphrase"]
94
+ return fields
78
95
 
79
96
 
80
97
  def _help_note(kdf_cls, salt_cls):
@@ -110,6 +127,30 @@ def _registration_from_flags(label, values):
110
127
  return label, user, host, port
111
128
 
112
129
 
130
+ def _check_authorize_flags(authorize, authorized_keys, overwrite_files, values):
131
+ """Reject flag combinations --authorize can't honor, before any key derivation."""
132
+ if not authorize:
133
+ if authorized_keys is not None:
134
+ raise click.UsageError("--authorized-keys needs --authorize")
135
+ return
136
+ if overwrite_files:
137
+ raise click.UsageError("--overwrite-files can't be used with --authorize: it only appends")
138
+ for name in KEYPAIR_ONLY_FLAGS:
139
+ if values.get(name) is not None:
140
+ flag = f"--{name.replace('_', '-')}"
141
+ raise click.UsageError(f"{flag} can't be used with --authorize: no private key is written")
142
+
143
+
144
+ def _ask_authorized_keys():
145
+ default = default_authorized_keys_path()
146
+ return click.prompt(
147
+ f"authorized_keys file to add the public key to [{_display_path(default)}]",
148
+ default=default,
149
+ type=click.Path(dir_okay=False, path_type=Path),
150
+ show_default=False,
151
+ )
152
+
153
+
113
154
  def _register_interactively(label, key_path):
114
155
  """Offer to wire the key we just wrote into ~/.ssh/config. Declining leaves the key
115
156
  exactly as written - `detssh ssh register` does the same thing later on."""
@@ -143,12 +184,26 @@ class KDFSaltCommand(click.Command):
143
184
  fields = {**SELECTORS, **_fields_for(kdf_cls, salt_cls)}
144
185
  defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
145
186
  self.params = build_options(fields, defaults) + [
187
+ click.Option(
188
+ ["--authorize"],
189
+ is_flag=True,
190
+ help="Only add the public key to an authorized_keys file (see --authorized-keys), so the "
191
+ "machine accepts logins from wherever you derived the private key. Writes no private key. "
192
+ "Doesn't switch to flag mode on its own: `detssh --authorize` still prompts for the rest.",
193
+ ),
194
+ click.Option(
195
+ ["--authorized-keys"],
196
+ type=click.Path(dir_okay=False, path_type=Path),
197
+ default=None,
198
+ help="authorized_keys file for --authorize to append to. Skipped if the key is already "
199
+ f"in it.{DEFAULT_TEXT_MARKER}[default: ~/.ssh/authorized_keys]",
200
+ ),
146
201
  click.Option(["--overwrite-files"], is_flag=True, help="Overwrite existing output files without asking."),
147
202
  click.Option(
148
203
  ["--create-parent-dirs"],
149
204
  is_flag=True,
150
- help="Create the output path's parent directories if missing, even outside ~/.ssh. "
151
- "Interactive mode prompts for this itself if you don't pass the flag; "
205
+ help="Create the output path's (or --authorized-keys file's) parent directories if missing, "
206
+ "even outside ~/.ssh. Interactive mode prompts for this itself if you don't pass the flag; "
152
207
  "non-interactive mode requires it explicitly.",
153
208
  ),
154
209
  click.Option(
@@ -199,10 +254,14 @@ class KDFSaltCommand(click.Command):
199
254
 
200
255
 
201
256
  @click.command(cls=KDFSaltCommand, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120})
202
- def main(overwrite_files, create_parent_dirs, **values):
257
+ def main(overwrite_files, create_parent_dirs, authorize, authorized_keys, **values):
203
258
  interactive = is_interactive(values)
204
259
  force_allow_soft_constraints = bool(values.get("force_allow_soft_constraints"))
205
260
 
261
+ _check_authorize_flags(authorize, authorized_keys, overwrite_files, values)
262
+ if interactive and not authorize:
263
+ authorize = MODE.ask(GENERATE_MODE) == AUTHORIZE_MODE
264
+
206
265
  kdf_name = values["kdf"]
207
266
  if kdf_name is None:
208
267
  kdf_name = SELECTORS["kdf"].ask(DEFAULTS["kdf"]) if interactive else DEFAULTS["kdf"]
@@ -213,7 +272,7 @@ def main(overwrite_files, create_parent_dirs, **values):
213
272
  salt_name = SELECTORS["salt_algo"].ask(DEFAULTS["salt_algo"]) if interactive else DEFAULTS["salt_algo"]
214
273
  salt_cls = salt.ALGOS[salt_name]
215
274
 
216
- fields = _fields_for(kdf_cls, salt_cls)
275
+ fields = _fields_for(kdf_cls, salt_cls, authorize)
217
276
  defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
218
277
 
219
278
  resolved = {
@@ -226,6 +285,13 @@ def main(overwrite_files, create_parent_dirs, **values):
226
285
  if any(char in resolved["comment"] for char in COMMENT_FORBIDDEN_CHARS):
227
286
  raise click.UsageError("--comment must not contain newlines")
228
287
 
288
+ if authorize:
289
+ if authorized_keys is None:
290
+ authorized_keys = _ask_authorized_keys() if interactive else default_authorized_keys_path()
291
+ target = authorized_keys.expanduser()
292
+ else:
293
+ target = resolved["output"]
294
+
229
295
  registration = None if interactive else _registration_from_flags(resolved["label"], values)
230
296
 
231
297
  try:
@@ -236,8 +302,9 @@ def main(overwrite_files, create_parent_dirs, **values):
236
302
  if salt_error:
237
303
  raise click.UsageError(f"{kdf_name} {salt_error}")
238
304
 
239
- confirm_overwrite(resolved["output"], overwrite_files)
240
- create_parent_dirs = confirm_create_parent_dirs(resolved["output"], create_parent_dirs, interactive)
305
+ if not authorize:
306
+ confirm_overwrite(target, overwrite_files)
307
+ create_parent_dirs = confirm_create_parent_dirs(target, create_parent_dirs, interactive)
241
308
 
242
309
  click.echo("Deriving key...")
243
310
  try:
@@ -255,6 +322,10 @@ def main(overwrite_files, create_parent_dirs, **values):
255
322
  ("hash-len", resolved["hash_len"]),
256
323
  )
257
324
 
325
+ if authorize:
326
+ authorize_and_recap(seed_bytes, target, resolved["comment"], recap, create_parent_dirs=create_parent_dirs)
327
+ return
328
+
258
329
  priv_path, _ = write_keypair_and_recap(
259
330
  seed_bytes,
260
331
  resolved["output"],
@@ -20,6 +20,10 @@ def default_output_path():
20
20
  return ssh_dir() / "id_ed25519"
21
21
 
22
22
 
23
+ def default_authorized_keys_path():
24
+ return ssh_dir() / "authorized_keys"
25
+
26
+
23
27
  # ed25519 is the only algorithm whose private key is literally its own seed, so it's the only one usable here.
24
28
  def keypair_from_seed(seed):
25
29
  if len(seed) != ED25519_SEED_LENGTH:
@@ -33,9 +37,20 @@ def public_key_path(private_key_path):
33
37
  return private_key_path.with_name(private_key_path.name + ".pub")
34
38
 
35
39
 
36
- def write_keypair(private_key, public_key, output_path, comment="", key_passphrase="", create_parent_dirs=False):
40
+ def public_key_line(public_key, comment=""):
41
+ """The public key as one authorized_keys / .pub line, newline included."""
37
42
  if any(char in comment for char in COMMENT_FORBIDDEN_CHARS):
38
43
  raise ValueError("comment must not contain newlines")
44
+ public_bytes = public_key.public_bytes(
45
+ encoding=serialization.Encoding.OpenSSH,
46
+ format=serialization.PublicFormat.OpenSSH,
47
+ )
48
+ comment_suffix = f" {comment}" if comment else ""
49
+ return public_bytes + comment_suffix.encode("utf-8") + b"\n"
50
+
51
+
52
+ def write_keypair(private_key, public_key, output_path, comment="", key_passphrase="", create_parent_dirs=False):
53
+ pub_line = public_key_line(public_key, comment)
39
54
 
40
55
  output_path = Path(output_path)
41
56
  pub_path = public_key_path(output_path)
@@ -53,17 +68,35 @@ def write_keypair(private_key, public_key, output_path, comment="", key_passphra
53
68
  format=serialization.PrivateFormat.OpenSSH,
54
69
  encryption_algorithm=encryption_algorithm,
55
70
  )
56
- public_bytes = public_key.public_bytes(
57
- encoding=serialization.Encoding.OpenSSH,
58
- format=serialization.PublicFormat.OpenSSH,
59
- )
60
71
 
61
72
  fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_KEY_MODE)
62
73
  os.fchmod(fd, PRIVATE_KEY_MODE)
63
74
  with os.fdopen(fd, "wb") as f:
64
75
  f.write(private_bytes)
65
76
 
66
- comment_suffix = f" {comment}" if comment else ""
67
- pub_path.write_bytes(public_bytes + comment_suffix.encode("utf-8") + b"\n")
77
+ pub_path.write_bytes(pub_line)
68
78
 
69
79
  return output_path, pub_path
80
+
81
+
82
+ def authorize_public_key(public_key, authorized_keys_path, comment="", create_parent_dirs=False):
83
+ """Append the public key to an authorized_keys file, ssh-copy-id style. Returns False
84
+ without touching the file when the key is already in it (under any comment or options)."""
85
+ line = public_key_line(public_key, comment)
86
+ key_blob = line.split()[1]
87
+ authorized_keys_path = Path(authorized_keys_path)
88
+
89
+ existing = authorized_keys_path.read_bytes() if authorized_keys_path.exists() else b""
90
+ if any(key_blob in existing_line.split() for existing_line in existing.splitlines()):
91
+ return False
92
+
93
+ if create_parent_dirs or authorized_keys_path.parent == ssh_dir():
94
+ authorized_keys_path.parent.mkdir(mode=SSH_DIR_MODE, parents=True, exist_ok=True)
95
+
96
+ # An existing file keeps its own mode; sshd's StrictModes is what decides whether that's acceptable.
97
+ fd = os.open(authorized_keys_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, PRIVATE_KEY_MODE)
98
+ with os.fdopen(fd, "wb") as f:
99
+ if existing and not existing.endswith(b"\n"):
100
+ f.write(b"\n")
101
+ f.write(line)
102
+ return True
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "detssh"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Deterministic Ed25519 SSH key generation from a seed passphrase, via configurable KDF and salt-hash backends."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -23,13 +23,22 @@ Options:
23
23
  --comment TEXT Comment for the public key
24
24
  --key-passphrase TEXT Passphrase to encrypt the private key file
25
25
  with (leave empty for none)
26
+ --authorize Only add the public key to an authorized_keys
27
+ file (see --authorized-keys), so the machine
28
+ accepts logins from wherever you derived the
29
+ private key. Writes no private key. Doesn't
30
+ switch to flag mode on its own: `detssh
31
+ --authorize` still prompts for the rest.
32
+ --authorized-keys FILE authorized_keys file for --authorize to append
33
+ to. Skipped if the key is already in it.
34
+ [default: ~/.ssh/authorized_keys]
26
35
  --overwrite-files Overwrite existing output files without
27
36
  asking.
28
- --create-parent-dirs Create the output path's parent directories if
29
- missing, even outside ~/.ssh. Interactive mode
30
- prompts for this itself if you don't pass the
31
- flag; non-interactive mode requires it
32
- explicitly.
37
+ --create-parent-dirs Create the output path's (or --authorized-keys
38
+ file's) parent directories if missing, even
39
+ outside ~/.ssh. Interactive mode prompts for
40
+ this itself if you don't pass the flag; non-
41
+ interactive mode requires it explicitly.
33
42
  --register USER@HOST Register the generated key so plain `ssh
34
43
  <label>` connects to USER@HOST, as `detssh ssh
35
44
  register` would. Needs --label, which names
@@ -17,13 +17,22 @@ Options:
17
17
  --comment TEXT Comment for the public key
18
18
  --key-passphrase TEXT Passphrase to encrypt the private key file
19
19
  with (leave empty for none)
20
+ --authorize Only add the public key to an authorized_keys
21
+ file (see --authorized-keys), so the machine
22
+ accepts logins from wherever you derived the
23
+ private key. Writes no private key. Doesn't
24
+ switch to flag mode on its own: `detssh
25
+ --authorize` still prompts for the rest.
26
+ --authorized-keys FILE authorized_keys file for --authorize to append
27
+ to. Skipped if the key is already in it.
28
+ [default: ~/.ssh/authorized_keys]
20
29
  --overwrite-files Overwrite existing output files without
21
30
  asking.
22
- --create-parent-dirs Create the output path's parent directories if
23
- missing, even outside ~/.ssh. Interactive mode
24
- prompts for this itself if you don't pass the
25
- flag; non-interactive mode requires it
26
- explicitly.
31
+ --create-parent-dirs Create the output path's (or --authorized-keys
32
+ file's) parent directories if missing, even
33
+ outside ~/.ssh. Interactive mode prompts for
34
+ this itself if you don't pass the flag; non-
35
+ interactive mode requires it explicitly.
27
36
  --register USER@HOST Register the generated key so plain `ssh
28
37
  <label>` connects to USER@HOST, as `detssh ssh
29
38
  register` would. Needs --label, which names
@@ -34,18 +34,16 @@ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_13
34
34
 
35
35
  # click.Option's own default for the `default=` kwarg is already None for a non-flag
36
36
  # option, so passing default=None explicitly vs. omitting it are identical.
37
- detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_75
38
- detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_94
37
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_64
38
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_110
39
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_129
39
40
 
40
- # format_options' first_col/text_width arithmetic: the 30-char cap and 1-off changes to
41
- # it (mutmut_13) or to the -4/10 padding (21, 22) only produce a different wrapped line
42
- # for option-name/help-text/terminal-width combinations that land exactly on the changed
43
- # boundary - the real --help output (checked byte-for-byte by test_help_text.py) never
44
- # does, and test_format_options_sizes_the_first_column_from_option_names_not_help_text
41
+ # format_options' text_width floor: a 1-off change to the 10 (mutmut_22) only produces a
42
+ # different wrapped line on a terminal narrow enough to hit the floor at all, landing
43
+ # exactly on the changed boundary - the real --help output (checked byte-for-byte by
44
+ # test_help_text.py) never does, and test_format_options_sizes_the_first_column_from_option_names_not_help_text
45
45
  # already pins the one-character-off-by-a-lot case (measure_table()[0] vs [1]) that
46
46
  # *does* show up in real output.
47
- detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_13
48
- detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_21
49
47
  detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_22
50
48
  # help_text.split(DEFAULT_TEXT_MARKER, 1) vs rsplit(..., 1) vs split(..., 2) vs
51
49
  # split(..., ) (no limit): DEFAULT_TEXT_MARKER ("\x00") is inserted into a help string at
@@ -75,14 +73,15 @@ detssh.cli.x_run__mutmut_16
75
73
  detssh.ssh_config.x_write_hosts_file__mutmut_16
76
74
  # Same reasoning: write_keypair's os.open(..., PRIVATE_KEY_MODE) is followed by an
77
75
  # unconditional os.fchmod(fd, PRIVATE_KEY_MODE).
78
- detssh.keygen.x_write_keypair__mutmut_48
76
+ detssh.keygen.x_write_keypair__mutmut_43
79
77
 
80
78
  # Python's codec lookup is case-insensitive ("utf-8" and "UTF-8" resolve to the same
81
79
  # codec), and hashlib's hash-algorithm-name lookup is too ("sha256" and "SHA256" give the
82
80
  # same digest) - every one of these mutates a hardcoded codec/hash name to a different
83
81
  # case, which cannot change what the code does.
84
82
  detssh.keygen.x_write_keypair__mutmut_29
85
- detssh.keygen.x_write_keypair__mutmut_71
83
+ detssh.keygen.x_public_key_line__mutmut_20
84
+ detssh.backends.base.x_authorize_and_recap__mutmut_25
86
85
  detssh.backends.kdf.scrypt.xǁScryptǁrun__mutmut_35
87
86
  detssh.backends.kdf.bcrypt_pbkdf.xǁBcryptPbkdfǁrun__mutmut_13
88
87
  detssh.backends.kdf.pbkdf2.xǁPbkdf2ǁrun__mutmut_12
@@ -108,6 +107,17 @@ detssh.backends.base.xǁFieldǁ__init____mutmut_2
108
107
  # see the type= mutants on the same line (mutmut_34, 38), which ARE real and are killed
109
108
  # by test_wizard_register_prompt_reasks_on_a_non_integer_port.
110
109
  detssh.cli.x__register_interactively__mutmut_33
110
+ # Same for _ask_authorized_keys, whose default is a Path (truthy): click only shows the
111
+ # hint when show_default is truthy or a str, and None is neither, so it behaves exactly
112
+ # like False. The hint the prompt does show is written into its message by hand.
113
+ detssh.cli.x__ask_authorized_keys__mutmut_5
114
+
115
+ # click.Path only ever reads dir_okay through truthiness (`if not self.dir_okay and ...`),
116
+ # so dir_okay=None behaves exactly like dir_okay=False. The dir_okay=True and omitted
117
+ # mutants on the same lines are real and killed by the "rejects_a_directory" tests in
118
+ # test_authorize.py.
119
+ detssh.cli.x__ask_authorized_keys__mutmut_11
120
+ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_68
111
121
 
112
122
  # Two mutants turn a loop into an infinite one (an absolute assignment replacing a += in
113
123
  # _peek's index-advance; `name = None` inside `_register_interactively`'s `while not
@@ -0,0 +1,394 @@
1
+ import stat
2
+
3
+ import click
4
+ import pytest
5
+ import questionary
6
+ from click.testing import CliRunner
7
+
8
+ from detssh.backends.base import authorize_and_recap
9
+ from detssh.cli import AUTHORIZE_MODE, GENERATE_MODE, main
10
+ from detssh.keygen import (
11
+ authorize_public_key,
12
+ default_authorized_keys_path,
13
+ keypair_from_seed,
14
+ public_key_line,
15
+ )
16
+
17
+
18
+ SEED = b"\x01" * 32
19
+ OTHER_SEED = b"\x02" * 32
20
+ FLAG_RUN = ["--kdf", "pbkdf2", "--iterations", "2", "--seed", "s", "--label", "work"]
21
+
22
+
23
+ def _public_key(seed=SEED):
24
+ return keypair_from_seed(seed)[1]
25
+
26
+
27
+ def _blob(seed=SEED):
28
+ return public_key_line(_public_key(seed)).rstrip(b"\n")
29
+
30
+
31
+ def _fake_select(answer_for_mode):
32
+ """Pick `answer_for_mode` at the mode question and pbkdf2 at the KDF picker, recording
33
+ every question asked so tests can check which ones came up."""
34
+ asked = []
35
+
36
+ def select(message, choices, default):
37
+ asked.append((message, tuple(choices), default))
38
+ if message == "What do you want to do?":
39
+ answer = answer_for_mode
40
+ else:
41
+ answer = "pbkdf2" if "pbkdf2" in choices else default
42
+ return type("Q", (), {"ask": lambda self: answer})()
43
+
44
+ return select, asked
45
+
46
+
47
+ def test_default_authorized_keys_path_is_in_ssh_dir(monkeypatch, tmp_path):
48
+ monkeypatch.setenv("HOME", str(tmp_path))
49
+ assert default_authorized_keys_path() == tmp_path / ".ssh" / "authorized_keys"
50
+
51
+
52
+ def test_public_key_line_is_type_blob_comment_newline():
53
+ line = public_key_line(_public_key(), "me@work laptop")
54
+ key_type, blob, comment = line.decode("utf-8").rstrip("\n").split(" ", 2)
55
+ assert (key_type, comment) == ("ssh-ed25519", "me@work laptop")
56
+ assert line == f"ssh-ed25519 {blob} me@work laptop\n".encode()
57
+
58
+
59
+ def test_public_key_line_without_comment_has_no_trailing_space():
60
+ assert public_key_line(_public_key()) == _blob() + b"\n"
61
+ assert not _blob().endswith(b" ")
62
+
63
+
64
+ def test_public_key_line_rejects_a_comment_with_a_newline():
65
+ with pytest.raises(ValueError, match="^comment must not contain newlines$"):
66
+ public_key_line(_public_key(), "a\nb")
67
+
68
+
69
+ def test_authorize_creates_ssh_dir_and_file_with_private_modes(monkeypatch, tmp_path):
70
+ monkeypatch.setenv("HOME", str(tmp_path))
71
+ path = tmp_path / ".ssh" / "authorized_keys"
72
+
73
+ assert authorize_public_key(_public_key(), path, comment="c") is True
74
+
75
+ assert path.read_bytes() == _blob() + b" c\n"
76
+ assert stat.S_IMODE(path.stat().st_mode) == 0o600
77
+ assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
78
+
79
+
80
+ def test_authorize_appends_after_existing_keys(tmp_path):
81
+ path = tmp_path / "authorized_keys"
82
+ path.write_bytes(_blob(OTHER_SEED) + b" other\n")
83
+
84
+ assert authorize_public_key(_public_key(), path) is True
85
+
86
+ assert path.read_bytes() == _blob(OTHER_SEED) + b" other\n" + _blob() + b"\n"
87
+
88
+
89
+ def test_authorize_adds_the_missing_newline_before_appending(tmp_path):
90
+ path = tmp_path / "authorized_keys"
91
+ path.write_bytes(b"ssh-rsa AAAA old")
92
+
93
+ authorize_public_key(_public_key(), path)
94
+
95
+ assert path.read_bytes() == b"ssh-rsa AAAA old\n" + _blob() + b"\n"
96
+
97
+
98
+ def test_authorize_into_an_empty_file_adds_no_leading_newline(tmp_path):
99
+ path = tmp_path / "authorized_keys"
100
+ path.write_bytes(b"")
101
+
102
+ authorize_public_key(_public_key(), path)
103
+
104
+ assert path.read_bytes() == _blob() + b"\n"
105
+
106
+
107
+ def test_authorize_skips_a_key_already_present_under_other_options_and_comment(tmp_path):
108
+ path = tmp_path / "authorized_keys"
109
+ original = b'from="10.0.0.1" ' + _blob() + b" old comment\n"
110
+ path.write_bytes(original)
111
+
112
+ assert authorize_public_key(_public_key(), path, comment="new comment") is False
113
+
114
+ assert path.read_bytes() == original
115
+
116
+
117
+ def test_authorize_only_matches_the_whole_key_blob_not_a_substring(tmp_path):
118
+ path = tmp_path / "authorized_keys"
119
+ key_blob = _blob().split()[1]
120
+ path.write_bytes(b"ssh-ed25519 " + key_blob + b"XYZ\n")
121
+
122
+ assert authorize_public_key(_public_key(), path) is True
123
+
124
+
125
+ def test_authorize_keeps_an_existing_files_mode(tmp_path):
126
+ path = tmp_path / "authorized_keys"
127
+ path.write_bytes(b"")
128
+ path.chmod(0o640)
129
+
130
+ authorize_public_key(_public_key(), path)
131
+
132
+ assert stat.S_IMODE(path.stat().st_mode) == 0o640
133
+
134
+
135
+ def test_authorize_outside_ssh_dir_needs_create_parent_dirs(monkeypatch, tmp_path):
136
+ monkeypatch.setenv("HOME", str(tmp_path))
137
+ path = tmp_path / "elsewhere" / "authorized_keys"
138
+
139
+ with pytest.raises(FileNotFoundError):
140
+ authorize_public_key(_public_key(), path)
141
+
142
+ assert authorize_public_key(_public_key(), path, create_parent_dirs=True) is True
143
+ assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
144
+
145
+
146
+ def _invoke_recap(path):
147
+ @click.command()
148
+ def cmd():
149
+ authorize_and_recap(SEED, path, "c", recap=(("kdf", "pbkdf2"),))
150
+
151
+ return CliRunner().invoke(cmd)
152
+
153
+
154
+ def test_authorize_and_recap_reports_the_added_line_then_the_recap(tmp_path):
155
+ path = tmp_path / "authorized_keys"
156
+
157
+ result = _invoke_recap(path)
158
+
159
+ assert result.exit_code == 0, result.output
160
+ assert result.output == (
161
+ f"Added public key to {path}:\n"
162
+ f" {_blob().decode()} c\n"
163
+ "\n"
164
+ "To recreate this exact key, remember your passphrase (keep it secret) plus:\n"
165
+ " kdf pbkdf2\n"
166
+ " algorithm ed25519\n"
167
+ )
168
+
169
+
170
+ def test_authorize_and_recap_says_when_the_key_was_already_there(tmp_path):
171
+ path = tmp_path / "authorized_keys"
172
+ path.write_bytes(_blob() + b"\n")
173
+
174
+ result = _invoke_recap(path)
175
+
176
+ assert result.output.splitlines()[:2] == [
177
+ f"Public key already in {path}, left it unchanged:",
178
+ f" {_blob().decode()} c",
179
+ ]
180
+
181
+
182
+ def test_authorize_and_recap_turns_os_errors_into_a_clean_message(tmp_path):
183
+ path = tmp_path / "authorized_keys"
184
+ path.mkdir()
185
+
186
+ result = _invoke_recap(path)
187
+
188
+ assert result.exit_code == 1
189
+ assert result.exc_info[0] is SystemExit
190
+ assert result.output == f"Error: couldn't write {path}: Is a directory\n"
191
+
192
+
193
+ def test_flag_mode_authorizes_the_same_public_key_a_full_run_writes(monkeypatch, tmp_path):
194
+ monkeypatch.setenv("HOME", str(tmp_path))
195
+ pair = tmp_path / "pair" / "id_ed25519"
196
+
197
+ full = CliRunner().invoke(main, [*FLAG_RUN, "--output", str(pair), "--create-parent-dirs"])
198
+ authorized = CliRunner().invoke(main, [*FLAG_RUN, "--authorize"])
199
+
200
+ assert full.exit_code == 0, full.output
201
+ assert authorized.exit_code == 0, authorized.output
202
+ ssh_dir = tmp_path / ".ssh"
203
+ assert (ssh_dir / "authorized_keys").read_bytes() == (tmp_path / "pair" / "id_ed25519.pub").read_bytes()
204
+ assert sorted(p.name for p in ssh_dir.iterdir()) == ["authorized_keys"]
205
+ assert f"Added public key to {ssh_dir / 'authorized_keys'}:" in authorized.output
206
+ assert "Wrote private key" not in authorized.output
207
+
208
+
209
+ def test_flag_mode_appends_to_an_existing_authorized_keys_without_asking(monkeypatch, tmp_path):
210
+ monkeypatch.setenv("HOME", str(tmp_path))
211
+ path = tmp_path / ".ssh" / "authorized_keys"
212
+ path.parent.mkdir()
213
+ path.write_bytes(b"ssh-rsa AAAA old\n")
214
+
215
+ result = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", "--comment", "work"])
216
+
217
+ assert result.exit_code == 0, result.output
218
+ assert "Overwrite?" not in result.output
219
+ lines = path.read_bytes().splitlines()
220
+ assert lines[0] == b"ssh-rsa AAAA old"
221
+ assert lines[1].endswith(b" work")
222
+
223
+
224
+ def test_flag_mode_expands_tilde_in_authorized_keys(monkeypatch, tmp_path):
225
+ monkeypatch.setenv("HOME", str(tmp_path))
226
+
227
+ result = CliRunner().invoke(
228
+ main, [*FLAG_RUN, "--authorize", "--authorized-keys", "~/.ssh/other_keys", "--create-parent-dirs"]
229
+ )
230
+
231
+ assert result.exit_code == 0, result.output
232
+ assert (tmp_path / ".ssh" / "other_keys").exists()
233
+
234
+
235
+ def test_flag_mode_requires_create_parent_dirs_outside_ssh_dir(monkeypatch, tmp_path):
236
+ monkeypatch.setenv("HOME", str(tmp_path))
237
+ path = tmp_path / "srv" / "authorized_keys"
238
+
239
+ refused = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", "--authorized-keys", str(path)])
240
+ allowed = CliRunner().invoke(
241
+ main, [*FLAG_RUN, "--authorize", "--authorized-keys", str(path), "--create-parent-dirs"]
242
+ )
243
+
244
+ assert refused.exit_code == 2
245
+ assert "~/srv doesn't exist. Pass --create-parent-dirs to create it." in refused.output
246
+ assert allowed.exit_code == 0, allowed.output
247
+ assert path.exists()
248
+
249
+
250
+ def test_authorized_keys_needs_authorize(tmp_path):
251
+ result = CliRunner().invoke(main, [*FLAG_RUN, "--authorized-keys", str(tmp_path / "k")])
252
+
253
+ assert result.exit_code == 2
254
+ assert "Error: --authorized-keys needs --authorize" in result.output
255
+ assert "Deriving key" not in result.output
256
+
257
+
258
+ @pytest.mark.parametrize(
259
+ ("extra", "message"),
260
+ [
261
+ (["--overwrite-files"], "--overwrite-files can't be used with --authorize: it only appends"),
262
+ (["--output", "k"], "--output can't be used with --authorize: no private key is written"),
263
+ (["--key-passphrase", "p"], "--key-passphrase can't be used with --authorize: no private key is written"),
264
+ (["--register", "a@b"], "--register can't be used with --authorize: no private key is written"),
265
+ (["--register-port", "22"], "--register-port can't be used with --authorize: no private key is written"),
266
+ ],
267
+ )
268
+ def test_authorize_rejects_keypair_only_flags_before_deriving(extra, message):
269
+ result = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", *extra])
270
+
271
+ assert result.exit_code == 2
272
+ assert f"Error: {message}" in result.output
273
+ assert "Deriving key" not in result.output
274
+
275
+
276
+ def _interactive_answers(*after_comment):
277
+ return (
278
+ "\n".join(
279
+ [
280
+ "s", # seed passphrase
281
+ "s", # ... confirmed
282
+ "work", # label
283
+ "2", # pbkdf2 iterations
284
+ "", # salt digest size: default
285
+ "", # comment
286
+ *after_comment,
287
+ ]
288
+ )
289
+ + "\n"
290
+ )
291
+
292
+
293
+ def test_interactive_mode_question_defaults_to_generating_a_keypair(monkeypatch, tmp_path):
294
+ monkeypatch.setenv("HOME", str(tmp_path))
295
+ select, asked = _fake_select(GENERATE_MODE)
296
+ monkeypatch.setattr(questionary, "select", select)
297
+
298
+ CliRunner().invoke(main, [], input="\n")
299
+
300
+ assert asked[0] == ("What do you want to do?", (GENERATE_MODE, AUTHORIZE_MODE), GENERATE_MODE)
301
+ assert GENERATE_MODE == "Generate a keypair (private + public key)"
302
+ assert AUTHORIZE_MODE == "Only add the public key to an authorized_keys file"
303
+
304
+
305
+ def test_interactive_authorize_skips_private_key_prompts_and_registration(monkeypatch, tmp_path):
306
+ monkeypatch.setenv("HOME", str(tmp_path))
307
+ select, _ = _fake_select(AUTHORIZE_MODE)
308
+ monkeypatch.setattr(questionary, "select", select)
309
+
310
+ result = CliRunner().invoke(main, [], input=_interactive_answers(""))
311
+
312
+ assert result.exit_code == 0, result.output
313
+ assert "authorized_keys file to add the public key to [~/.ssh/authorized_keys]: " in result.output
314
+ assert "Output path" not in result.output
315
+ assert "Passphrase to encrypt" not in result.output
316
+ assert "Register this key" not in result.output
317
+ path = tmp_path / ".ssh" / "authorized_keys"
318
+ assert path.read_bytes().startswith(b"ssh-ed25519 ")
319
+ assert sorted(p.name for p in path.parent.iterdir()) == ["authorized_keys"]
320
+
321
+
322
+ def test_interactive_authorize_expands_tilde_and_asks_before_creating_parent_dirs(monkeypatch, tmp_path):
323
+ monkeypatch.setenv("HOME", str(tmp_path))
324
+ select, _ = _fake_select(AUTHORIZE_MODE)
325
+ monkeypatch.setattr(questionary, "select", select)
326
+
327
+ result = CliRunner().invoke(main, [], input=_interactive_answers("~/srv/authorized_keys", "y"))
328
+
329
+ assert result.exit_code == 0, result.output
330
+ assert "Warning: ~/srv is outside ~/.ssh." in result.output
331
+ assert (tmp_path / "srv" / "authorized_keys").exists()
332
+
333
+
334
+ def test_authorize_flag_alone_stays_interactive_without_the_mode_question(monkeypatch, tmp_path):
335
+ monkeypatch.setenv("HOME", str(tmp_path))
336
+ select, asked = _fake_select(GENERATE_MODE)
337
+ monkeypatch.setattr(questionary, "select", select)
338
+
339
+ result = CliRunner().invoke(main, ["--authorize"], input=_interactive_answers(""))
340
+
341
+ assert result.exit_code == 0, result.output
342
+ assert [message for message, _, _ in asked] == ["KDF backend", "Salt hash algorithm"]
343
+ assert (tmp_path / ".ssh" / "authorized_keys").exists()
344
+ assert not (tmp_path / ".ssh" / "id_ed25519").exists()
345
+
346
+
347
+ def test_authorized_keys_flag_skips_its_prompt_in_interactive_mode(monkeypatch, tmp_path):
348
+ monkeypatch.setenv("HOME", str(tmp_path))
349
+ select, _ = _fake_select(GENERATE_MODE)
350
+ monkeypatch.setattr(questionary, "select", select)
351
+ path = tmp_path / "keys"
352
+
353
+ result = CliRunner().invoke(main, ["--authorize", "--authorized-keys", str(path)], input=_interactive_answers())
354
+
355
+ assert result.exit_code == 0, result.output
356
+ assert "authorized_keys file to add" not in result.output
357
+ assert path.exists()
358
+
359
+
360
+ def test_authorize_creates_every_missing_parent_level(tmp_path):
361
+ path = tmp_path / "a" / "b" / "authorized_keys"
362
+
363
+ assert authorize_public_key(_public_key(), path, create_parent_dirs=True) is True
364
+ assert path.exists()
365
+
366
+
367
+ def test_authorize_and_recap_does_not_create_parent_dirs_unless_asked(monkeypatch, tmp_path):
368
+ monkeypatch.setenv("HOME", str(tmp_path))
369
+ path = tmp_path / "missing" / "authorized_keys"
370
+
371
+ result = _invoke_recap(path)
372
+
373
+ assert result.exit_code == 1
374
+ assert result.output == f"Error: couldn't write {path}: No such file or directory\n"
375
+
376
+
377
+ def test_authorized_keys_flag_rejects_a_directory(tmp_path):
378
+ result = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", "--authorized-keys", str(tmp_path)])
379
+
380
+ assert result.exit_code == 2
381
+ assert "is a directory" in result.output
382
+
383
+
384
+ def test_interactive_authorized_keys_prompt_rejects_a_directory(monkeypatch, tmp_path):
385
+ monkeypatch.setenv("HOME", str(tmp_path))
386
+ select, _ = _fake_select(AUTHORIZE_MODE)
387
+ monkeypatch.setattr(questionary, "select", select)
388
+ path = tmp_path / "keys"
389
+
390
+ result = CliRunner().invoke(main, [], input=_interactive_answers(str(tmp_path), str(path)))
391
+
392
+ assert result.exit_code == 0, result.output
393
+ assert "is a directory" in result.output
394
+ assert path.exists()
@@ -386,7 +386,7 @@ wheels = [
386
386
 
387
387
  [[package]]
388
388
  name = "detssh"
389
- version = "0.6.0"
389
+ version = "0.7.0"
390
390
  source = { editable = "." }
391
391
  dependencies = [
392
392
  { name = "argon2-cffi" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes