detssh 0.4.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 (53) hide show
  1. detssh-0.7.0/.github/workflows/tests.yml +68 -0
  2. {detssh-0.4.0 → detssh-0.7.0}/.gitignore +2 -0
  3. {detssh-0.4.0 → detssh-0.7.0}/CHANGELOG.md +63 -0
  4. {detssh-0.4.0 → detssh-0.7.0}/PKG-INFO +46 -1
  5. {detssh-0.4.0 → detssh-0.7.0}/README.md +45 -0
  6. {detssh-0.4.0 → detssh-0.7.0}/detssh/__init__.py +1 -1
  7. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/base.py +60 -8
  8. {detssh-0.4.0 → detssh-0.7.0}/detssh/cli.py +92 -11
  9. {detssh-0.4.0 → detssh-0.7.0}/detssh/keygen.py +40 -7
  10. {detssh-0.4.0 → detssh-0.7.0}/pyproject.toml +10 -1
  11. {detssh-0.4.0 → detssh-0.7.0}/tests/golden/README.md +7 -0
  12. detssh-0.7.0/tests/golden/help_default.txt +62 -0
  13. detssh-0.7.0/tests/golden/help_pbkdf2_sha256.txt +56 -0
  14. detssh-0.7.0/tests/mutmut_known_equivalents.txt +130 -0
  15. detssh-0.7.0/tests/test_authorize.py +394 -0
  16. {detssh-0.4.0 → detssh-0.7.0}/tests/test_cli.py +140 -8
  17. detssh-0.7.0/tests/test_help_text.py +39 -0
  18. {detssh-0.4.0 → detssh-0.7.0}/tests/test_properties.py +380 -21
  19. {detssh-0.4.0 → detssh-0.7.0}/tests/test_registry.py +10 -0
  20. {detssh-0.4.0 → detssh-0.7.0}/tests/test_ssh_cli.py +43 -1
  21. {detssh-0.4.0 → detssh-0.7.0}/tests/test_ssh_config.py +60 -1
  22. {detssh-0.4.0 → detssh-0.7.0}/uv.lock +328 -2
  23. detssh-0.4.0/.github/workflows/tests.yml +0 -36
  24. {detssh-0.4.0 → detssh-0.7.0}/.github/workflows/publish.yml +0 -0
  25. {detssh-0.4.0 → detssh-0.7.0}/LICENSE +0 -0
  26. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/__init__.py +0 -0
  27. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/__init__.py +0 -0
  28. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/_argon2.py +0 -0
  29. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/argon2d.py +0 -0
  30. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/argon2i.py +0 -0
  31. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/argon2id.py +0 -0
  32. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/base.py +0 -0
  33. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
  34. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/pbkdf2.py +0 -0
  35. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/scrypt.py +0 -0
  36. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/__init__.py +0 -0
  37. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/base.py +0 -0
  38. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/blake2b.py +0 -0
  39. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/blake2s.py +0 -0
  40. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha256.py +0 -0
  41. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha384.py +0 -0
  42. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha3_256.py +0 -0
  43. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha3_384.py +0 -0
  44. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha3_512.py +0 -0
  45. {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha512.py +0 -0
  46. {detssh-0.4.0 → detssh-0.7.0}/detssh/registry.py +0 -0
  47. {detssh-0.4.0 → detssh-0.7.0}/detssh/ssh_cli.py +0 -0
  48. {detssh-0.4.0 → detssh-0.7.0}/detssh/ssh_config.py +0 -0
  49. {detssh-0.4.0 → detssh-0.7.0}/tests/golden/variations.csv +0 -0
  50. {detssh-0.4.0 → detssh-0.7.0}/tests/golden/vectors.csv +0 -0
  51. {detssh-0.4.0 → detssh-0.7.0}/tests/test_cli_combinations.py +0 -0
  52. {detssh-0.4.0 → detssh-0.7.0}/tests/test_determinism.py +0 -0
  53. {detssh-0.4.0 → detssh-0.7.0}/tests/test_golden_vectors.py +0 -0
@@ -0,0 +1,68 @@
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+
11
+ jobs:
12
+ test:
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ['3.11', '3.12', '3.13', '3.14']
17
+ os-version: [ubuntu-22.04]
18
+
19
+ runs-on: ${{ matrix.os-version }}
20
+
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Install uv
25
+ uses: astral-sh/setup-uv@v3
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Install dependencies
30
+ run: uv sync
31
+
32
+ - name: Run tests (100% coverage required)
33
+ run: uv run pytest -q
34
+
35
+ - name: Run ruff
36
+ run: uv run ruff check .
37
+
38
+ - name: Check formatting
39
+ run: uv run ruff format --check .
40
+
41
+ mutation:
42
+ needs: test
43
+ runs-on: ubuntu-22.04
44
+
45
+ steps:
46
+ - uses: actions/checkout@v4
47
+
48
+ - name: Install uv
49
+ uses: astral-sh/setup-uv@v3
50
+ with:
51
+ python-version: '3.12'
52
+
53
+ - name: Install dependencies
54
+ run: uv sync
55
+
56
+ - name: Run mutation testing (no unlisted survivors allowed)
57
+ run: |
58
+ uv run mutmut run || true
59
+ uv run mutmut results
60
+ survivors=$(uv run mutmut results | grep 'survived$' | awk '{print $1}' | sed 's/:$//' | sort)
61
+ known=$(grep -v '^#' tests/mutmut_known_equivalents.txt | grep -v '^$' | sort)
62
+ new_survivors=$(comm -23 <(echo "$survivors") <(echo "$known"))
63
+ if [ -n "$new_survivors" ]; then
64
+ echo "New surviving mutant(s) not in tests/mutmut_known_equivalents.txt:"
65
+ echo "$new_survivors"
66
+ exit 1
67
+ fi
68
+ echo "No new survivors."
@@ -9,3 +9,5 @@ __pycache__/
9
9
  *.egg-info/
10
10
  dist/
11
11
  build/
12
+ mutants/
13
+ .mutmut-cache
@@ -7,6 +7,69 @@ 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
+
29
+ ## [0.6.0] - 2026-09-19
30
+
31
+ ### Added
32
+
33
+ - Mutation testing via `mutmut`, gating CI: it rewrites the source one small
34
+ change at a time and reruns the suite, catching cases where 100% line
35
+ coverage still let a real behavior change through untested. Started at
36
+ 300+ surviving mutants (from a from-scratch first run); closed all but
37
+ 40, each confirmed genuinely equivalent - the mutated code is provably
38
+ indistinguishable from the original for every input (e.g. `"utf-8"` vs
39
+ `"UTF-8"` as a codec name, which Python's codec lookup can't tell apart)
40
+ - and documented one by one in `tests/mutmut_known_equivalents.txt`,
41
+ which CI diffs new runs against.
42
+ - `ruff format --check` now runs in CI alongside `ruff check`.
43
+
44
+ No application behavior changed in this release - every one of the ~260
45
+ mutants closed was a test that was missing or checking too loosely (an
46
+ exact-message assertion where only a substring was checked before, an edge
47
+ case like a trailing `--kdf` flag with nothing after it, a permission check
48
+ on a directory that already existed rather than one the call under test
49
+ actually creates). The code they exercise was already correct.
50
+
51
+ ## [0.5.0] - 2026-09-19
52
+
53
+ ### Added
54
+
55
+ - Interactive mode now offers to create a missing output-path parent
56
+ directory itself, instead of requiring `--create-parent-dirs` up front.
57
+ It defaults to yes for a path under `~/.ssh` and to no (with a warning)
58
+ for one outside it - the same rule `--create-parent-dirs` already used.
59
+
60
+ ### Changed
61
+
62
+ - Flag mode now fails fast with a clear error when the output path's
63
+ parent is missing and `--create-parent-dirs` wasn't passed, instead of
64
+ running key derivation first and then failing on a raw `OSError`.
65
+
66
+ ### Fixed
67
+
68
+ - `--kdf`/`--salt-algo` backend detection (used to decide which options to
69
+ show before click's real parsing runs) could misread a flag's own value
70
+ as a second occurrence of the flag when the value happened to equal the
71
+ flag string itself (e.g. `--kdf --kdf`).
72
+
10
73
  ## [0.4.0] - 2026-09-09
11
74
 
12
75
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: detssh
3
- Version: 0.4.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`, ...)
@@ -122,8 +142,33 @@ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
122
142
  uv sync
123
143
  uv run pytest
124
144
  uv run ruff check .
145
+ uv run ruff format --check .
125
146
  ```
126
147
 
127
148
  `pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
128
149
  `pyproject.toml`) and fails under 100% line+branch coverage - same gate
129
150
  locally and in CI, nothing extra to remember to pass.
151
+
152
+ 100% coverage means every line runs under test, not that every behavior
153
+ change gets caught - a test can execute a line without asserting anything
154
+ meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
155
+ second thing: it rewrites the source one small change at a time (flips a
156
+ comparison, drops an argument, tweaks a string) and reruns the tests against
157
+ each mutant, one at a time. A mutant that still passes ("survived") is a
158
+ change the suite wouldn't notice.
159
+
160
+ ```
161
+ uv run mutmut run # takes a few minutes; ctrl-c-safe, resumes where it left off
162
+ uv run mutmut results # list survivors by name
163
+ uv run mutmut show <name> # see what a specific mutant changed
164
+ ```
165
+
166
+ A survivor is either a real test gap (add an assertion that would fail
167
+ against the mutant) or a genuinely equivalent mutant - the mutated code is
168
+ provably indistinguishable from the original for every input (e.g.
169
+ `"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
170
+ case-insensitive, so no test could tell them apart). CI
171
+ (`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
172
+ listed in `tests/mutmut_known_equivalents.txt`, which names each one and
173
+ says why - add a confirmed-equivalent mutant there (never to silence a
174
+ failure without doing that check) rather than chasing an unwinnable test.
@@ -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`, ...)
@@ -101,8 +121,33 @@ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
101
121
  uv sync
102
122
  uv run pytest
103
123
  uv run ruff check .
124
+ uv run ruff format --check .
104
125
  ```
105
126
 
106
127
  `pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
107
128
  `pyproject.toml`) and fails under 100% line+branch coverage - same gate
108
129
  locally and in CI, nothing extra to remember to pass.
130
+
131
+ 100% coverage means every line runs under test, not that every behavior
132
+ change gets caught - a test can execute a line without asserting anything
133
+ meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
134
+ second thing: it rewrites the source one small change at a time (flips a
135
+ comparison, drops an argument, tweaks a string) and reruns the tests against
136
+ each mutant, one at a time. A mutant that still passes ("survived") is a
137
+ change the suite wouldn't notice.
138
+
139
+ ```
140
+ uv run mutmut run # takes a few minutes; ctrl-c-safe, resumes where it left off
141
+ uv run mutmut results # list survivors by name
142
+ uv run mutmut show <name> # see what a specific mutant changed
143
+ ```
144
+
145
+ A survivor is either a real test gap (add an assertion that would fail
146
+ against the mutant) or a genuinely equivalent mutant - the mutated code is
147
+ provably indistinguishable from the original for every input (e.g.
148
+ `"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
149
+ case-insensitive, so no test could tell them apart). CI
150
+ (`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
151
+ listed in `tests/mutmut_known_equivalents.txt`, which names each one and
152
+ says why - add a confirmed-equivalent mutant there (never to silence a
153
+ failure without doing that check) rather than chasing an unwinnable test.
@@ -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.4.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):
@@ -164,13 +171,36 @@ def confirm_overwrite(output, overwrite_files):
164
171
 
165
172
 
166
173
  def confirm_create_parent_dirs(output, create_parent_dirs, interactive):
174
+ """Decide whether to create the output path's missing parent directories.
175
+
176
+ Returns the (possibly updated) flag for write_keypair_and_recap to act on. Flag
177
+ mode requires --create-parent-dirs up front and fails fast otherwise, so a typo
178
+ surfaces before key derivation rather than as a raw OSError after it. Interactive
179
+ mode prompts for the same decision instead, defaulting to yes under ~/.ssh and to
180
+ no (with a warning) elsewhere - matching what --create-parent-dirs would have done.
181
+ """
167
182
  parent = Path(output).parent
168
- if not create_parent_dirs or parent.exists() or parent == ssh_dir():
169
- return
170
- if interactive and not parent.is_relative_to(ssh_dir()):
183
+ if parent.exists() or parent == ssh_dir():
184
+ return create_parent_dirs
185
+
186
+ outside = not parent.is_relative_to(ssh_dir())
187
+
188
+ if not interactive:
189
+ if not create_parent_dirs:
190
+ raise click.UsageError(f"{_display_path(parent)} doesn't exist. Pass --create-parent-dirs to create it.")
191
+ return True
192
+
193
+ if not create_parent_dirs and not outside:
194
+ question, default = f"{_display_path(parent)} doesn't exist. Create it?", True
195
+ elif outside:
171
196
  click.echo(f"Warning: {_display_path(parent)} is outside ~/.ssh.")
172
- if not click.confirm("Create it anyway?"):
173
- raise click.Abort()
197
+ question, default = "Create it anyway?", False
198
+ else:
199
+ return True
200
+
201
+ if not click.confirm(question, default=default):
202
+ raise click.Abort()
203
+ return True
174
204
 
175
205
 
176
206
  def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="", create_parent_dirs=False):
@@ -192,9 +222,31 @@ def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="
192
222
  click.echo(f"Wrote private key: {priv_path}{' (encrypted)' if key_passphrase else ''}")
193
223
  click.echo(f"Wrote public key: {pub_path}")
194
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):
195
249
  click.echo()
196
250
  click.echo("To recreate this exact key, remember your passphrase (keep it secret) plus:")
197
251
  for name, value in (*recap, ("algorithm", "ed25519")):
198
252
  click.echo(f" {name:<18} {value}")
199
-
200
- 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)),
@@ -47,17 +61,26 @@ DEFAULTS = {
47
61
 
48
62
 
49
63
  def _peek(args, flag, default):
64
+ """Guess a flag's value before click does the real parsing, so we know which
65
+ backend's options to register. Skips past a consumed value instead of scanning
66
+ it as a token in its own right, so a value that happens to equal the flag itself
67
+ (e.g. `--kdf --kdf`) isn't misread as a second occurrence of the flag."""
50
68
  value = default
51
- for i, arg in enumerate(args):
69
+ i = 0
70
+ while i < len(args):
71
+ arg = args[i]
52
72
  if arg == flag and i + 1 < len(args):
53
73
  value = args[i + 1]
54
- elif arg.startswith(flag + "="):
74
+ i += 2
75
+ continue
76
+ if arg.startswith(flag + "="):
55
77
  value = arg.split("=", 1)[1]
78
+ i += 1
56
79
  return value
57
80
 
58
81
 
59
- def _fields_for(kdf_cls, salt_cls):
60
- return {
82
+ def _fields_for(kdf_cls, salt_cls, authorize=False):
83
+ fields = {
61
84
  "seed": COMMON["seed"],
62
85
  "label": COMMON["label"],
63
86
  **kdf_cls.fields,
@@ -66,6 +89,9 @@ def _fields_for(kdf_cls, salt_cls):
66
89
  "comment": COMMON["comment"],
67
90
  "key_passphrase": COMMON["key_passphrase"],
68
91
  }
92
+ if authorize:
93
+ del fields["output"], fields["key_passphrase"]
94
+ return fields
69
95
 
70
96
 
71
97
  def _help_note(kdf_cls, salt_cls):
@@ -101,6 +127,30 @@ def _registration_from_flags(label, values):
101
127
  return label, user, host, port
102
128
 
103
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
+
104
154
  def _register_interactively(label, key_path):
105
155
  """Offer to wire the key we just wrote into ~/.ssh/config. Declining leaves the key
106
156
  exactly as written - `detssh ssh register` does the same thing later on."""
@@ -134,12 +184,27 @@ class KDFSaltCommand(click.Command):
134
184
  fields = {**SELECTORS, **_fields_for(kdf_cls, salt_cls)}
135
185
  defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
136
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
+ ),
137
201
  click.Option(["--overwrite-files"], is_flag=True, help="Overwrite existing output files without asking."),
138
202
  click.Option(
139
203
  ["--create-parent-dirs"],
140
204
  is_flag=True,
141
- help="Create the output path's parent directories if missing, even outside ~/.ssh. "
142
- "Interactive mode still confirms before creating one outside ~/.ssh.",
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; "
207
+ "non-interactive mode requires it explicitly.",
143
208
  ),
144
209
  click.Option(
145
210
  ["--register"],
@@ -189,10 +254,14 @@ class KDFSaltCommand(click.Command):
189
254
 
190
255
 
191
256
  @click.command(cls=KDFSaltCommand, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120})
192
- def main(overwrite_files, create_parent_dirs, **values):
257
+ def main(overwrite_files, create_parent_dirs, authorize, authorized_keys, **values):
193
258
  interactive = is_interactive(values)
194
259
  force_allow_soft_constraints = bool(values.get("force_allow_soft_constraints"))
195
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
+
196
265
  kdf_name = values["kdf"]
197
266
  if kdf_name is None:
198
267
  kdf_name = SELECTORS["kdf"].ask(DEFAULTS["kdf"]) if interactive else DEFAULTS["kdf"]
@@ -203,7 +272,7 @@ def main(overwrite_files, create_parent_dirs, **values):
203
272
  salt_name = SELECTORS["salt_algo"].ask(DEFAULTS["salt_algo"]) if interactive else DEFAULTS["salt_algo"]
204
273
  salt_cls = salt.ALGOS[salt_name]
205
274
 
206
- fields = _fields_for(kdf_cls, salt_cls)
275
+ fields = _fields_for(kdf_cls, salt_cls, authorize)
207
276
  defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
208
277
 
209
278
  resolved = {
@@ -216,6 +285,13 @@ def main(overwrite_files, create_parent_dirs, **values):
216
285
  if any(char in resolved["comment"] for char in COMMENT_FORBIDDEN_CHARS):
217
286
  raise click.UsageError("--comment must not contain newlines")
218
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
+
219
295
  registration = None if interactive else _registration_from_flags(resolved["label"], values)
220
296
 
221
297
  try:
@@ -226,8 +302,9 @@ def main(overwrite_files, create_parent_dirs, **values):
226
302
  if salt_error:
227
303
  raise click.UsageError(f"{kdf_name} {salt_error}")
228
304
 
229
- confirm_overwrite(resolved["output"], overwrite_files)
230
- 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)
231
308
 
232
309
  click.echo("Deriving key...")
233
310
  try:
@@ -245,6 +322,10 @@ def main(overwrite_files, create_parent_dirs, **values):
245
322
  ("hash-len", resolved["hash_len"]),
246
323
  )
247
324
 
325
+ if authorize:
326
+ authorize_and_recap(seed_bytes, target, resolved["comment"], recap, create_parent_dirs=create_parent_dirs)
327
+ return
328
+
248
329
  priv_path, _ = write_keypair_and_recap(
249
330
  seed_bytes,
250
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