detssh 0.1.0__tar.gz → 0.6.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 (57) hide show
  1. detssh-0.6.0/.github/workflows/tests.yml +68 -0
  2. {detssh-0.1.0 → detssh-0.6.0}/.gitignore +3 -0
  3. detssh-0.6.0/CHANGELOG.md +126 -0
  4. detssh-0.6.0/PKG-INFO +154 -0
  5. detssh-0.6.0/README.md +133 -0
  6. {detssh-0.1.0 → detssh-0.6.0}/detssh/__init__.py +1 -1
  7. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/base.py +51 -5
  8. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/__init__.py +10 -5
  9. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/__init__.py +10 -5
  10. {detssh-0.1.0 → detssh-0.6.0}/detssh/cli.py +114 -9
  11. {detssh-0.1.0 → detssh-0.6.0}/detssh/keygen.py +11 -6
  12. detssh-0.6.0/detssh/registry.py +67 -0
  13. detssh-0.6.0/detssh/ssh_cli.py +119 -0
  14. detssh-0.6.0/detssh/ssh_config.py +68 -0
  15. {detssh-0.1.0 → detssh-0.6.0}/pyproject.toml +24 -2
  16. {detssh-0.1.0 → detssh-0.6.0}/tests/golden/README.md +7 -0
  17. detssh-0.6.0/tests/golden/help_default.txt +53 -0
  18. detssh-0.6.0/tests/golden/help_pbkdf2_sha256.txt +47 -0
  19. detssh-0.6.0/tests/mutmut_known_equivalents.txt +120 -0
  20. detssh-0.6.0/tests/test_cli.py +735 -0
  21. detssh-0.6.0/tests/test_help_text.py +39 -0
  22. detssh-0.6.0/tests/test_properties.py +1347 -0
  23. detssh-0.6.0/tests/test_registry.py +80 -0
  24. detssh-0.6.0/tests/test_ssh_cli.py +254 -0
  25. detssh-0.6.0/tests/test_ssh_config.py +141 -0
  26. detssh-0.6.0/uv.lock +1004 -0
  27. detssh-0.1.0/.github/workflows/tests.yml +0 -36
  28. detssh-0.1.0/PKG-INFO +0 -80
  29. detssh-0.1.0/README.md +0 -59
  30. detssh-0.1.0/tests/test_cli.py +0 -403
  31. detssh-0.1.0/tests/test_properties.py +0 -614
  32. detssh-0.1.0/uv.lock +0 -519
  33. {detssh-0.1.0 → detssh-0.6.0}/.github/workflows/publish.yml +0 -0
  34. {detssh-0.1.0 → detssh-0.6.0}/LICENSE +0 -0
  35. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/__init__.py +0 -0
  36. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/_argon2.py +0 -0
  37. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/argon2d.py +0 -0
  38. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/argon2i.py +0 -0
  39. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/argon2id.py +0 -0
  40. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/base.py +0 -0
  41. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
  42. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/pbkdf2.py +0 -0
  43. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/scrypt.py +0 -0
  44. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/base.py +0 -0
  45. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/blake2b.py +0 -0
  46. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/blake2s.py +0 -0
  47. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha256.py +0 -0
  48. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha384.py +0 -0
  49. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha3_256.py +0 -0
  50. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha3_384.py +0 -0
  51. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha3_512.py +0 -0
  52. {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha512.py +0 -0
  53. {detssh-0.1.0 → detssh-0.6.0}/tests/golden/variations.csv +0 -0
  54. {detssh-0.1.0 → detssh-0.6.0}/tests/golden/vectors.csv +0 -0
  55. {detssh-0.1.0 → detssh-0.6.0}/tests/test_cli_combinations.py +0 -0
  56. {detssh-0.1.0 → detssh-0.6.0}/tests/test_determinism.py +0 -0
  57. {detssh-0.1.0 → detssh-0.6.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."
@@ -4,7 +4,10 @@ __pycache__/
4
4
  .pytest_cache/
5
5
  .ruff_cache/
6
6
  .hypothesis/
7
+ .coverage
7
8
  .idea/
8
9
  *.egg-info/
9
10
  dist/
10
11
  build/
12
+ mutants/
13
+ .mutmut-cache
@@ -0,0 +1,126 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.6.0] - 2026-09-19
11
+
12
+ ### Added
13
+
14
+ - Mutation testing via `mutmut`, gating CI: it rewrites the source one small
15
+ change at a time and reruns the suite, catching cases where 100% line
16
+ coverage still let a real behavior change through untested. Started at
17
+ 300+ surviving mutants (from a from-scratch first run); closed all but
18
+ 40, each confirmed genuinely equivalent - the mutated code is provably
19
+ indistinguishable from the original for every input (e.g. `"utf-8"` vs
20
+ `"UTF-8"` as a codec name, which Python's codec lookup can't tell apart)
21
+ - and documented one by one in `tests/mutmut_known_equivalents.txt`,
22
+ which CI diffs new runs against.
23
+ - `ruff format --check` now runs in CI alongside `ruff check`.
24
+
25
+ No application behavior changed in this release - every one of the ~260
26
+ mutants closed was a test that was missing or checking too loosely (an
27
+ exact-message assertion where only a substring was checked before, an edge
28
+ case like a trailing `--kdf` flag with nothing after it, a permission check
29
+ on a directory that already existed rather than one the call under test
30
+ actually creates). The code they exercise was already correct.
31
+
32
+ ## [0.5.0] - 2026-09-19
33
+
34
+ ### Added
35
+
36
+ - Interactive mode now offers to create a missing output-path parent
37
+ directory itself, instead of requiring `--create-parent-dirs` up front.
38
+ It defaults to yes for a path under `~/.ssh` and to no (with a warning)
39
+ for one outside it - the same rule `--create-parent-dirs` already used.
40
+
41
+ ### Changed
42
+
43
+ - Flag mode now fails fast with a clear error when the output path's
44
+ parent is missing and `--create-parent-dirs` wasn't passed, instead of
45
+ running key derivation first and then failing on a raw `OSError`.
46
+
47
+ ### Fixed
48
+
49
+ - `--kdf`/`--salt-algo` backend detection (used to decide which options to
50
+ show before click's real parsing runs) could misread a flag's own value
51
+ as a second occurrence of the flag when the value happened to equal the
52
+ flag string itself (e.g. `--kdf --kdf`).
53
+
54
+ ## [0.4.0] - 2026-09-09
55
+
56
+ ### Added
57
+
58
+ - The interactive run now offers to register the key it just wrote, so
59
+ generating a key and getting `ssh <label>` to use it is one command
60
+ instead of two. The label doubles as the `Host` name; declining leaves
61
+ the key exactly as written.
62
+ - `--register <user>@<host>` and `--register-port` do the same in flag mode.
63
+ `--register` needs `--label`, since the label names the `Host` block. Both
64
+ are validated before key derivation starts, so a typo doesn't cost you a
65
+ minute of argon2 first.
66
+
67
+ ### Changed
68
+
69
+ - `detssh ssh register --port` now rejects non-numeric and out-of-range
70
+ ports up front instead of writing them into the generated `Host` block.
71
+ - Registered `IdentityFile` paths are stored absolute. A relative `--key`
72
+ used to be recorded as given, which `ssh` would then resolve against
73
+ whatever directory it happened to be run from.
74
+
75
+ ## [0.3.0] - 2026-08-23
76
+
77
+ ### Added
78
+
79
+ - `detssh ssh register <label> <user>@<host> --key <path>`: records the
80
+ label in `~/.config/detssh/config`, generates a `Host` block for it, and
81
+ wires it into `~/.ssh/config` via a single `Include` line - so plain
82
+ `ssh <label>` connects with the right key afterward, no `-i` or
83
+ hand-written config needed. Also `detssh ssh list` and
84
+ `detssh ssh forget <label>` (never deletes the key file itself).
85
+ - `--create-parent-dirs`: create the output path's missing parent
86
+ directories, including outside `~/.ssh` (with one interactive
87
+ confirmation first in that case). Previously only `~/.ssh` itself was
88
+ ever auto-created.
89
+ - The interactive output-path prompt now notes when the default location is
90
+ already occupied, before you type anything.
91
+
92
+ ## [0.2.0] - 2026-08-04
93
+
94
+ ### Added
95
+
96
+ - `--key-passphrase` option, with a matching interactive prompt (skippable
97
+ by leaving it blank), to encrypt the private key file at rest - like
98
+ `ssh-keygen -N`. Independent of the seed passphrase: it never touches key
99
+ derivation, so it doesn't change the key produced for a given seed, only
100
+ how the file sits on disk.
101
+ - 100% line+branch test coverage, enforced both locally and in CI via
102
+ `pytest-cov` - `uv run pytest` fails under 100%, no separate command to
103
+ remember.
104
+
105
+ ## [0.1.0] - 2026-07-19
106
+
107
+ ### Added
108
+
109
+ - Deterministic Ed25519 SSH key generation from a seed passphrase: same
110
+ passphrase and parameters produce the same keypair on any machine.
111
+ - KDF backends: `argon2id` (default), `argon2i`, `argon2d`, `scrypt`,
112
+ `pbkdf2`, `bcrypt_pbkdf`.
113
+ - Salt hash algorithm backends: `blake2b` (default), `blake2s`, `sha256`,
114
+ `sha384`, `sha512`, `sha3_256`, `sha3_384`, `sha3_512`.
115
+ - Interactive mode (prompts for everything, passphrase hidden and confirmed)
116
+ and non-interactive flag mode, selected automatically by whether any flag
117
+ is passed.
118
+ - `ssh-keygen`-style default output path (`~/.ssh/id_ed25519` /
119
+ `.pub`), created with the same file/directory permissions (600/700) and
120
+ the same overwrite confirmation.
121
+ - Soft safety limits on cost knobs (iterations, memory, cost, rounds, ...)
122
+ that catch typos before they cost minutes of derivation time - summarized
123
+ in one confirmation in interactive mode, or bypassable with
124
+ `--force-allow-soft-constraints` in flag mode.
125
+ - Per-backend `--help` showing the valid options and ranges for a given
126
+ `--kdf`/`--salt-algo` combination.
detssh-0.6.0/PKG-INFO ADDED
@@ -0,0 +1,154 @@
1
+ Metadata-Version: 2.5
2
+ Name: detssh
3
+ Version: 0.6.0
4
+ Summary: Deterministic Ed25519 SSH key generation from a seed passphrase, via configurable KDF and salt-hash backends.
5
+ Project-URL: Homepage, https://github.com/isik-kaplan/detssh
6
+ Project-URL: Repository, https://github.com/isik-kaplan/detssh
7
+ Author-email: Işık Kaplan <isik.kaplan@outlook.com>
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: argon2,bcrypt,cryptography,ed25519,kdf,pbkdf2,scrypt,ssh
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security :: Cryptography
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: argon2-cffi>=21.3
16
+ Requires-Dist: bcrypt>=5.0.0
17
+ Requires-Dist: click>=8.1
18
+ Requires-Dist: cryptography>=41.0
19
+ Requires-Dist: questionary>=2.1.1
20
+ Description-Content-Type: text/markdown
21
+
22
+ # detssh
23
+
24
+ Deterministic Ed25519 SSH key generation from a seed passphrase. Same
25
+ passphrase + same parameters → same keypair, every time, on any machine -
26
+ nothing to back up but what's in your head.
27
+
28
+ ```
29
+ detssh
30
+ ```
31
+ Prompts for everything (passphrase hidden + confirmed) and writes to the
32
+ same place `ssh-keygen` would: `~/.ssh/id_ed25519` / `~/.ssh/id_ed25519.pub`
33
+ (mode 600/700, `~/.ssh` created if missing). Pass `--output` to write
34
+ somewhere else instead.
35
+
36
+ Non-interactively:
37
+ ```
38
+ detssh --kdf argon2id --salt-algo blake2b --seed "..." --label github --overwrite-files
39
+ ```
40
+ Any flag switches to non-interactive mode: missing optional fields fall back
41
+ to their defaults, `--seed` is the only required one. Passing `--seed` on the
42
+ command line puts it in your shell history and process list (`ps aux`) - fine
43
+ for local scripting, but prefer the interactive prompt when that matters.
44
+
45
+ By default the private key file is written unencrypted, like `ssh-keygen -N
46
+ ""`. Pass `--key-passphrase` (or leave it blank at the interactive prompt to
47
+ skip) to encrypt it at rest instead - anyone who gets the file will still
48
+ need that passphrase to use it. This is unrelated to the seed passphrase: it
49
+ never touches key derivation, so it doesn't need to be remembered to
50
+ *recreate* the key, only to unlock the file you already have. Same caveat as
51
+ `--seed` applies if passed as a flag.
52
+
53
+ ## Using the key with `ssh`
54
+
55
+ `ssh` only auto-offers keys with default filenames (`id_rsa`, `id_ed25519`, ...)
56
+ sitting directly in `~/.ssh/` - a key written under `--output` or a per-label
57
+ subdirectory needs either `-i <path>`, a hand-written `Host` block in
58
+ `~/.ssh/config`, or `ssh-add`ing it to your agent.
59
+
60
+ The interactive run offers to do this for you at the end, right after it
61
+ writes the key:
62
+ ```
63
+ Register this key so `ssh isik:personal:contabo` connects with it? [Y/n]: y
64
+ Destination (user@host): root@contabo.com
65
+ Non-default ssh port:
66
+
67
+ Registered isik:personal:contabo -> root@contabo.com
68
+ Run: ssh isik:personal:contabo
69
+ ```
70
+ The label you derived the key with doubles as the `Host` name, so there's
71
+ nothing extra to type. In flag mode, `--register root@contabo.com` (plus
72
+ `--register-port` when it isn't 22) does the same thing, and needs `--label`
73
+ for that reason. Declining changes nothing about the key that was written.
74
+
75
+ For a key you already have, `detssh ssh register` reaches the same end state:
76
+ ```
77
+ detssh ssh register isik:personal:contabo root@contabo.com --key ~/.ssh/isik:personal:contabo/id_ed25519
78
+ ```
79
+ Either route records the label in `~/.config/detssh/config`, generates a `Host` block
80
+ for it in a detssh-managed file, and adds a single `Include` line to
81
+ `~/.ssh/config` (once, at the top) that pulls it in. After that,
82
+ `ssh isik:personal:contabo` just works - through real `ssh`, nothing routed
83
+ through detssh at connect time.
84
+
85
+ ```
86
+ detssh ssh list # show registered labels
87
+ detssh ssh forget <label> # remove a label (never deletes the key file)
88
+ ```
89
+
90
+ ## Backends
91
+
92
+ - `--kdf`: `argon2id` (default), `argon2i`, `argon2d`, `scrypt`, `pbkdf2`, `bcrypt_pbkdf`
93
+ - `--salt-algo`: `blake2b` (default), `blake2s`, `sha256`, `sha384`, `sha512`, `sha3_256`, `sha3_384`, `sha3_512`
94
+
95
+ Each combination has its own options (cost knobs, salt digest size, etc.),
96
+ with their valid ranges shown in both `--help` and the interactive prompts.
97
+ See them with:
98
+ ```
99
+ detssh --kdf <kdf> --salt-algo <salt-algo> --help
100
+ ```
101
+
102
+ We recommend sticking with the defaults (`argon2id`, memory-hard with a high
103
+ cost) unless you have a specific reason not to - they make brute-forcing your
104
+ passphrase significantly more expensive than faster KDFs like `pbkdf2`.
105
+
106
+ Cost knobs (iterations, memory, cost, rounds, ...) also carry a soft safety
107
+ limit meant to catch typos before they cost you minutes of derivation time.
108
+ In interactive mode, going over one doesn't interrupt you - everything you
109
+ exceeded is summarized in one confirmation at the very end. In flag mode, it
110
+ fails immediately unless you pass `--force-allow-soft-constraints`.
111
+
112
+ ## Determinism guarantee
113
+
114
+ Same explicit parameters → same key, across any release sharing the same
115
+ major version - only a major version bump may change key derivation. That
116
+ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
117
+ `tests/golden/README.md`).
118
+
119
+ ## Development
120
+
121
+ ```
122
+ uv sync
123
+ uv run pytest
124
+ uv run ruff check .
125
+ uv run ruff format --check .
126
+ ```
127
+
128
+ `pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
129
+ `pyproject.toml`) and fails under 100% line+branch coverage - same gate
130
+ locally and in CI, nothing extra to remember to pass.
131
+
132
+ 100% coverage means every line runs under test, not that every behavior
133
+ change gets caught - a test can execute a line without asserting anything
134
+ meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
135
+ second thing: it rewrites the source one small change at a time (flips a
136
+ comparison, drops an argument, tweaks a string) and reruns the tests against
137
+ each mutant, one at a time. A mutant that still passes ("survived") is a
138
+ change the suite wouldn't notice.
139
+
140
+ ```
141
+ uv run mutmut run # takes a few minutes; ctrl-c-safe, resumes where it left off
142
+ uv run mutmut results # list survivors by name
143
+ uv run mutmut show <name> # see what a specific mutant changed
144
+ ```
145
+
146
+ A survivor is either a real test gap (add an assertion that would fail
147
+ against the mutant) or a genuinely equivalent mutant - the mutated code is
148
+ provably indistinguishable from the original for every input (e.g.
149
+ `"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
150
+ case-insensitive, so no test could tell them apart). CI
151
+ (`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
152
+ listed in `tests/mutmut_known_equivalents.txt`, which names each one and
153
+ says why - add a confirmed-equivalent mutant there (never to silence a
154
+ failure without doing that check) rather than chasing an unwinnable test.
detssh-0.6.0/README.md ADDED
@@ -0,0 +1,133 @@
1
+ # detssh
2
+
3
+ Deterministic Ed25519 SSH key generation from a seed passphrase. Same
4
+ passphrase + same parameters → same keypair, every time, on any machine -
5
+ nothing to back up but what's in your head.
6
+
7
+ ```
8
+ detssh
9
+ ```
10
+ Prompts for everything (passphrase hidden + confirmed) and writes to the
11
+ same place `ssh-keygen` would: `~/.ssh/id_ed25519` / `~/.ssh/id_ed25519.pub`
12
+ (mode 600/700, `~/.ssh` created if missing). Pass `--output` to write
13
+ somewhere else instead.
14
+
15
+ Non-interactively:
16
+ ```
17
+ detssh --kdf argon2id --salt-algo blake2b --seed "..." --label github --overwrite-files
18
+ ```
19
+ Any flag switches to non-interactive mode: missing optional fields fall back
20
+ to their defaults, `--seed` is the only required one. Passing `--seed` on the
21
+ command line puts it in your shell history and process list (`ps aux`) - fine
22
+ for local scripting, but prefer the interactive prompt when that matters.
23
+
24
+ By default the private key file is written unencrypted, like `ssh-keygen -N
25
+ ""`. Pass `--key-passphrase` (or leave it blank at the interactive prompt to
26
+ skip) to encrypt it at rest instead - anyone who gets the file will still
27
+ need that passphrase to use it. This is unrelated to the seed passphrase: it
28
+ never touches key derivation, so it doesn't need to be remembered to
29
+ *recreate* the key, only to unlock the file you already have. Same caveat as
30
+ `--seed` applies if passed as a flag.
31
+
32
+ ## Using the key with `ssh`
33
+
34
+ `ssh` only auto-offers keys with default filenames (`id_rsa`, `id_ed25519`, ...)
35
+ sitting directly in `~/.ssh/` - a key written under `--output` or a per-label
36
+ subdirectory needs either `-i <path>`, a hand-written `Host` block in
37
+ `~/.ssh/config`, or `ssh-add`ing it to your agent.
38
+
39
+ The interactive run offers to do this for you at the end, right after it
40
+ writes the key:
41
+ ```
42
+ Register this key so `ssh isik:personal:contabo` connects with it? [Y/n]: y
43
+ Destination (user@host): root@contabo.com
44
+ Non-default ssh port:
45
+
46
+ Registered isik:personal:contabo -> root@contabo.com
47
+ Run: ssh isik:personal:contabo
48
+ ```
49
+ The label you derived the key with doubles as the `Host` name, so there's
50
+ nothing extra to type. In flag mode, `--register root@contabo.com` (plus
51
+ `--register-port` when it isn't 22) does the same thing, and needs `--label`
52
+ for that reason. Declining changes nothing about the key that was written.
53
+
54
+ For a key you already have, `detssh ssh register` reaches the same end state:
55
+ ```
56
+ detssh ssh register isik:personal:contabo root@contabo.com --key ~/.ssh/isik:personal:contabo/id_ed25519
57
+ ```
58
+ Either route records the label in `~/.config/detssh/config`, generates a `Host` block
59
+ for it in a detssh-managed file, and adds a single `Include` line to
60
+ `~/.ssh/config` (once, at the top) that pulls it in. After that,
61
+ `ssh isik:personal:contabo` just works - through real `ssh`, nothing routed
62
+ through detssh at connect time.
63
+
64
+ ```
65
+ detssh ssh list # show registered labels
66
+ detssh ssh forget <label> # remove a label (never deletes the key file)
67
+ ```
68
+
69
+ ## Backends
70
+
71
+ - `--kdf`: `argon2id` (default), `argon2i`, `argon2d`, `scrypt`, `pbkdf2`, `bcrypt_pbkdf`
72
+ - `--salt-algo`: `blake2b` (default), `blake2s`, `sha256`, `sha384`, `sha512`, `sha3_256`, `sha3_384`, `sha3_512`
73
+
74
+ Each combination has its own options (cost knobs, salt digest size, etc.),
75
+ with their valid ranges shown in both `--help` and the interactive prompts.
76
+ See them with:
77
+ ```
78
+ detssh --kdf <kdf> --salt-algo <salt-algo> --help
79
+ ```
80
+
81
+ We recommend sticking with the defaults (`argon2id`, memory-hard with a high
82
+ cost) unless you have a specific reason not to - they make brute-forcing your
83
+ passphrase significantly more expensive than faster KDFs like `pbkdf2`.
84
+
85
+ Cost knobs (iterations, memory, cost, rounds, ...) also carry a soft safety
86
+ limit meant to catch typos before they cost you minutes of derivation time.
87
+ In interactive mode, going over one doesn't interrupt you - everything you
88
+ exceeded is summarized in one confirmation at the very end. In flag mode, it
89
+ fails immediately unless you pass `--force-allow-soft-constraints`.
90
+
91
+ ## Determinism guarantee
92
+
93
+ Same explicit parameters → same key, across any release sharing the same
94
+ major version - only a major version bump may change key derivation. That
95
+ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
96
+ `tests/golden/README.md`).
97
+
98
+ ## Development
99
+
100
+ ```
101
+ uv sync
102
+ uv run pytest
103
+ uv run ruff check .
104
+ uv run ruff format --check .
105
+ ```
106
+
107
+ `pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
108
+ `pyproject.toml`) and fails under 100% line+branch coverage - same gate
109
+ locally and in CI, nothing extra to remember to pass.
110
+
111
+ 100% coverage means every line runs under test, not that every behavior
112
+ change gets caught - a test can execute a line without asserting anything
113
+ meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
114
+ second thing: it rewrites the source one small change at a time (flips a
115
+ comparison, drops an argument, tweaks a string) and reruns the tests against
116
+ each mutant, one at a time. A mutant that still passes ("survived") is a
117
+ change the suite wouldn't notice.
118
+
119
+ ```
120
+ uv run mutmut run # takes a few minutes; ctrl-c-safe, resumes where it left off
121
+ uv run mutmut results # list survivors by name
122
+ uv run mutmut show <name> # see what a specific mutant changed
123
+ ```
124
+
125
+ A survivor is either a real test gap (add an assertion that would fail
126
+ against the mutant) or a genuinely equivalent mutant - the mutated code is
127
+ provably indistinguishable from the original for every input (e.g.
128
+ `"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
129
+ case-insensitive, so no test could tell them apart). CI
130
+ (`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
131
+ listed in `tests/mutmut_known_equivalents.txt`, which names each one and
132
+ says why - add a confirmed-equivalent mutant there (never to silence a
133
+ 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.1.0"
6
+ __version__ = "0.6.0"
7
7
  __all__ = [
8
8
  "KDF_BACKENDS",
9
9
  "SALT_ALGOS",
@@ -3,7 +3,7 @@ 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, write_keypair
6
+ from detssh.keygen import keypair_from_seed, public_key_path, ssh_dir, write_keypair
7
7
 
8
8
 
9
9
  def _display_path(path):
@@ -55,11 +55,15 @@ class Field:
55
55
  def ask(self, default):
56
56
  message = self.message + self.hint()
57
57
  if self.kind == "secret":
58
- return click.prompt(message, hide_input=True, confirmation_prompt=True)
58
+ if self.required:
59
+ return click.prompt(message, hide_input=True, confirmation_prompt=True)
60
+ return click.prompt(message, hide_input=True, confirmation_prompt=True, default="", show_default=False)
59
61
  if self.kind == "int":
60
62
  return click.prompt(message, default=default, type=int)
61
63
  if self.kind == "path":
62
64
  if default is not None:
65
+ if Path(default).exists() or public_key_path(default).exists():
66
+ click.echo(f"Note: {_display_path(default)} already exists - enter a different path to avoid this.")
63
67
  message = f"{message} [{_display_path(default)}]"
64
68
  return click.prompt(
65
69
  message, default=default, type=click.Path(dir_okay=False, path_type=Path), show_default=False
@@ -159,19 +163,61 @@ def confirm_overwrite(output, overwrite_files):
159
163
  raise click.Abort()
160
164
 
161
165
 
162
- def write_keypair_and_recap(seed_bytes, output, comment, recap):
166
+ def confirm_create_parent_dirs(output, create_parent_dirs, interactive):
167
+ """Decide whether to create the output path's missing parent directories.
168
+
169
+ Returns the (possibly updated) flag for write_keypair_and_recap to act on. Flag
170
+ mode requires --create-parent-dirs up front and fails fast otherwise, so a typo
171
+ surfaces before key derivation rather than as a raw OSError after it. Interactive
172
+ mode prompts for the same decision instead, defaulting to yes under ~/.ssh and to
173
+ no (with a warning) elsewhere - matching what --create-parent-dirs would have done.
174
+ """
175
+ parent = Path(output).parent
176
+ if parent.exists() or parent == ssh_dir():
177
+ return create_parent_dirs
178
+
179
+ outside = not parent.is_relative_to(ssh_dir())
180
+
181
+ if not interactive:
182
+ if not create_parent_dirs:
183
+ raise click.UsageError(f"{_display_path(parent)} doesn't exist. Pass --create-parent-dirs to create it.")
184
+ return True
185
+
186
+ if not create_parent_dirs and not outside:
187
+ question, default = f"{_display_path(parent)} doesn't exist. Create it?", True
188
+ elif outside:
189
+ click.echo(f"Warning: {_display_path(parent)} is outside ~/.ssh.")
190
+ question, default = "Create it anyway?", False
191
+ else:
192
+ return True
193
+
194
+ if not click.confirm(question, default=default):
195
+ raise click.Abort()
196
+ return True
197
+
198
+
199
+ def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="", create_parent_dirs=False):
163
200
  private_key, public_key = keypair_from_seed(seed_bytes)
164
201
  try:
165
- priv_path, pub_path = write_keypair(private_key, public_key, output, comment=comment)
202
+ priv_path, pub_path = write_keypair(
203
+ private_key,
204
+ public_key,
205
+ output,
206
+ comment=comment,
207
+ key_passphrase=key_passphrase,
208
+ create_parent_dirs=create_parent_dirs,
209
+ )
166
210
  except OSError as e:
167
211
  raise click.ClickException(f"couldn't write {output}: {e.strerror or e}") from e
168
212
  except ValueError as e:
169
213
  raise click.UsageError(str(e)) from e
170
214
 
171
- click.echo(f"Wrote private key: {priv_path}")
215
+ click.echo(f"Wrote private key: {priv_path}{' (encrypted)' if key_passphrase else ''}")
172
216
  click.echo(f"Wrote public key: {pub_path}")
173
217
 
174
218
  click.echo()
175
219
  click.echo("To recreate this exact key, remember your passphrase (keep it secret) plus:")
176
220
  for name, value in (*recap, ("algorithm", "ed25519")):
177
221
  click.echo(f" {name:<18} {value}")
222
+
223
+ return priv_path, pub_path
@@ -15,8 +15,13 @@ BACKENDS = {
15
15
  "bcrypt_pbkdf": BcryptPbkdf,
16
16
  }
17
17
 
18
- for _name, _backend in BACKENDS.items():
19
- if _backend.name != _name:
20
- raise TypeError(f"{_backend.__name__}.name must equal its registry key {_name!r}")
21
- if _backend.salt_constraints is None:
22
- raise TypeError(f"{_backend.__name__} must set salt_constraints (use NO_SALT_CONSTRAINTS if none apply)")
18
+
19
+ def validate_backends(backends):
20
+ for name, backend in backends.items():
21
+ if backend.name != name:
22
+ raise TypeError(f"{backend.__name__}.name must equal its registry key {name!r}")
23
+ if backend.salt_constraints is None:
24
+ raise TypeError(f"{backend.__name__} must set salt_constraints (use NO_SALT_CONSTRAINTS if none apply)")
25
+
26
+
27
+ validate_backends(BACKENDS)
@@ -19,8 +19,13 @@ ALGOS = {
19
19
  "sha3_512": Sha3512,
20
20
  }
21
21
 
22
- for _name, _algo in ALGOS.items():
23
- if _algo.name != _name:
24
- raise TypeError(f"{_algo.__name__}.name must equal its registry key {_name!r}")
25
- if _algo.digest_size is None and (_algo.min_digest_size is None or _algo.max_digest_size is None):
26
- raise TypeError(f"{_algo.__name__} must set digest_size, or min/max_digest_size if variable")
22
+
23
+ def validate_algos(algos):
24
+ for name, algo in algos.items():
25
+ if algo.name != name:
26
+ raise TypeError(f"{algo.__name__}.name must equal its registry key {name!r}")
27
+ if algo.digest_size is None and (algo.min_digest_size is None or algo.max_digest_size is None):
28
+ raise TypeError(f"{algo.__name__} must set digest_size, or min/max_digest_size if variable")
29
+
30
+
31
+ validate_algos(ALGOS)