detssh 0.4.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.
- detssh-0.6.0/.github/workflows/tests.yml +68 -0
- {detssh-0.4.0 → detssh-0.6.0}/.gitignore +2 -0
- {detssh-0.4.0 → detssh-0.6.0}/CHANGELOG.md +44 -0
- {detssh-0.4.0 → detssh-0.6.0}/PKG-INFO +26 -1
- {detssh-0.4.0 → detssh-0.6.0}/README.md +25 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/__init__.py +1 -1
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/base.py +28 -5
- {detssh-0.4.0 → detssh-0.6.0}/detssh/cli.py +14 -4
- {detssh-0.4.0 → detssh-0.6.0}/pyproject.toml +10 -1
- {detssh-0.4.0 → detssh-0.6.0}/tests/golden/README.md +7 -0
- detssh-0.6.0/tests/golden/help_default.txt +53 -0
- detssh-0.6.0/tests/golden/help_pbkdf2_sha256.txt +47 -0
- detssh-0.6.0/tests/mutmut_known_equivalents.txt +120 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_cli.py +140 -8
- detssh-0.6.0/tests/test_help_text.py +39 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_properties.py +380 -21
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_registry.py +10 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_ssh_cli.py +43 -1
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_ssh_config.py +60 -1
- {detssh-0.4.0 → detssh-0.6.0}/uv.lock +328 -2
- detssh-0.4.0/.github/workflows/tests.yml +0 -36
- {detssh-0.4.0 → detssh-0.6.0}/.github/workflows/publish.yml +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/LICENSE +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/__init__.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/__init__.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/_argon2.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/argon2d.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/argon2i.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/argon2id.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/base.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/pbkdf2.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/kdf/scrypt.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/__init__.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/base.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/blake2b.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/blake2s.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha256.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha384.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha3_256.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha3_384.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha3_512.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/backends/salt/sha512.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/keygen.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/registry.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/ssh_cli.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/detssh/ssh_config.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/golden/variations.csv +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/golden/vectors.csv +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_cli_combinations.py +0 -0
- {detssh-0.4.0 → detssh-0.6.0}/tests/test_determinism.py +0 -0
- {detssh-0.4.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."
|
|
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
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
|
+
|
|
10
54
|
## [0.4.0] - 2026-09-09
|
|
11
55
|
|
|
12
56
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: detssh
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.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
|
|
@@ -122,8 +122,33 @@ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
|
|
|
122
122
|
uv sync
|
|
123
123
|
uv run pytest
|
|
124
124
|
uv run ruff check .
|
|
125
|
+
uv run ruff format --check .
|
|
125
126
|
```
|
|
126
127
|
|
|
127
128
|
`pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
|
|
128
129
|
`pyproject.toml`) and fails under 100% line+branch coverage - same gate
|
|
129
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.
|
|
@@ -101,8 +101,33 @@ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
|
|
|
101
101
|
uv sync
|
|
102
102
|
uv run pytest
|
|
103
103
|
uv run ruff check .
|
|
104
|
+
uv run ruff format --check .
|
|
104
105
|
```
|
|
105
106
|
|
|
106
107
|
`pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
|
|
107
108
|
`pyproject.toml`) and fails under 100% line+branch coverage - same gate
|
|
108
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.
|
|
@@ -164,13 +164,36 @@ def confirm_overwrite(output, overwrite_files):
|
|
|
164
164
|
|
|
165
165
|
|
|
166
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
|
+
"""
|
|
167
175
|
parent = Path(output).parent
|
|
168
|
-
if
|
|
169
|
-
return
|
|
170
|
-
|
|
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:
|
|
171
189
|
click.echo(f"Warning: {_display_path(parent)} is outside ~/.ssh.")
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
197
|
|
|
175
198
|
|
|
176
199
|
def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="", create_parent_dirs=False):
|
|
@@ -47,12 +47,21 @@ DEFAULTS = {
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
def _peek(args, flag, default):
|
|
50
|
+
"""Guess a flag's value before click does the real parsing, so we know which
|
|
51
|
+
backend's options to register. Skips past a consumed value instead of scanning
|
|
52
|
+
it as a token in its own right, so a value that happens to equal the flag itself
|
|
53
|
+
(e.g. `--kdf --kdf`) isn't misread as a second occurrence of the flag."""
|
|
50
54
|
value = default
|
|
51
|
-
|
|
55
|
+
i = 0
|
|
56
|
+
while i < len(args):
|
|
57
|
+
arg = args[i]
|
|
52
58
|
if arg == flag and i + 1 < len(args):
|
|
53
59
|
value = args[i + 1]
|
|
54
|
-
|
|
60
|
+
i += 2
|
|
61
|
+
continue
|
|
62
|
+
if arg.startswith(flag + "="):
|
|
55
63
|
value = arg.split("=", 1)[1]
|
|
64
|
+
i += 1
|
|
56
65
|
return value
|
|
57
66
|
|
|
58
67
|
|
|
@@ -139,7 +148,8 @@ class KDFSaltCommand(click.Command):
|
|
|
139
148
|
["--create-parent-dirs"],
|
|
140
149
|
is_flag=True,
|
|
141
150
|
help="Create the output path's parent directories if missing, even outside ~/.ssh. "
|
|
142
|
-
"Interactive mode
|
|
151
|
+
"Interactive mode prompts for this itself if you don't pass the flag; "
|
|
152
|
+
"non-interactive mode requires it explicitly.",
|
|
143
153
|
),
|
|
144
154
|
click.Option(
|
|
145
155
|
["--register"],
|
|
@@ -227,7 +237,7 @@ def main(overwrite_files, create_parent_dirs, **values):
|
|
|
227
237
|
raise click.UsageError(f"{kdf_name} {salt_error}")
|
|
228
238
|
|
|
229
239
|
confirm_overwrite(resolved["output"], overwrite_files)
|
|
230
|
-
confirm_create_parent_dirs(resolved["output"], create_parent_dirs, interactive)
|
|
240
|
+
create_parent_dirs = confirm_create_parent_dirs(resolved["output"], create_parent_dirs, interactive)
|
|
231
241
|
|
|
232
242
|
click.echo("Deriving key...")
|
|
233
243
|
try:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "detssh"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.6.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 = [
|
|
@@ -32,6 +32,7 @@ detssh = "detssh.cli:run"
|
|
|
32
32
|
[tool.uv]
|
|
33
33
|
dev-dependencies = [
|
|
34
34
|
"hypothesis>=6.156.9",
|
|
35
|
+
"mutmut>=3.8.0",
|
|
35
36
|
"pytest>=8.0.0",
|
|
36
37
|
"pytest-cov>=5.0.0",
|
|
37
38
|
"ruff>=0.9.0",
|
|
@@ -47,6 +48,7 @@ packages = ["detssh"]
|
|
|
47
48
|
[tool.ruff]
|
|
48
49
|
line-length = 120
|
|
49
50
|
fix = true
|
|
51
|
+
extend-exclude = ["mutants"]
|
|
50
52
|
|
|
51
53
|
[tool.ruff.lint]
|
|
52
54
|
select = ["E", "F", "I", "UP", "B"]
|
|
@@ -71,3 +73,10 @@ show_missing = true
|
|
|
71
73
|
exclude_lines = [
|
|
72
74
|
"if __name__ == .__main__.:",
|
|
73
75
|
]
|
|
76
|
+
|
|
77
|
+
[tool.mutmut]
|
|
78
|
+
source_paths = ["detssh"]
|
|
79
|
+
# --no-cov: mutmut only reruns the subset of tests covering a mutated line, so the
|
|
80
|
+
# --cov-fail-under=100 gate in addopts would fail every mutant run for the wrong
|
|
81
|
+
# reason. -q keeps per-mutant output down to a dot per test.
|
|
82
|
+
pytest_add_cli_args = ["--no-cov", "-q"]
|
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
seed/key output of every `--kdf` × `--salt-algo` combination at fixed
|
|
5
5
|
inputs. `../test_golden_vectors.py` loads and checks them.
|
|
6
6
|
|
|
7
|
+
`help_default.txt` and `help_pbkdf2_sha256.txt` are unrelated: exact
|
|
8
|
+
`detssh --help` snapshots for two `--kdf`/`--salt-algo` combinations, checked
|
|
9
|
+
by `../test_help_text.py`. Regenerate them (see that file's docstring) after
|
|
10
|
+
any deliberate change to an option's `help=` text, `_help_note`, or
|
|
11
|
+
`KDFSaltCommand.format_options`' wrapping - diff before committing, same as
|
|
12
|
+
the vectors below.
|
|
13
|
+
|
|
7
14
|
Not a correctness test (see `test_properties.py` / `test_determinism.py`) -
|
|
8
15
|
it catches any change that silently makes the same inputs produce a
|
|
9
16
|
*different* key.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
Usage: main [OPTIONS]
|
|
2
|
+
|
|
3
|
+
Options:
|
|
4
|
+
--kdf [argon2id|argon2i|argon2d|scrypt|pbkdf2|bcrypt_pbkdf]
|
|
5
|
+
KDF backend
|
|
6
|
+
[default: argon2id]
|
|
7
|
+
--salt-algo [blake2b|blake2s|sha256|sha384|sha512|sha3_256|sha3_384|sha3_512]
|
|
8
|
+
Salt hash algorithm
|
|
9
|
+
[default: blake2b]
|
|
10
|
+
--seed TEXT Seed passphrase
|
|
11
|
+
--label TEXT Label
|
|
12
|
+
--iterations INTEGER Argon2 iterations (soft max 100)
|
|
13
|
+
[default: 10]
|
|
14
|
+
--memory INTEGER Argon2 memory, in MiB (soft max 2048)
|
|
15
|
+
[default: 1024]
|
|
16
|
+
--parallelism INTEGER Argon2 parallelism (soft max 16)
|
|
17
|
+
[default: 4]
|
|
18
|
+
--salt-digest-size INTEGER Salt digest size, in bytes (1-64)
|
|
19
|
+
[default: 16]
|
|
20
|
+
--output FILE Output path for the private key (ssh-keygen
|
|
21
|
+
style, e.g. ~/.ssh/id_ed25519)
|
|
22
|
+
[default: ~/.ssh/id_ed25519]
|
|
23
|
+
--comment TEXT Comment for the public key
|
|
24
|
+
--key-passphrase TEXT Passphrase to encrypt the private key file
|
|
25
|
+
with (leave empty for none)
|
|
26
|
+
--overwrite-files Overwrite existing output files without
|
|
27
|
+
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.
|
|
33
|
+
--register USER@HOST Register the generated key so plain `ssh
|
|
34
|
+
<label>` connects to USER@HOST, as `detssh ssh
|
|
35
|
+
register` would. Needs --label, which names
|
|
36
|
+
the `Host` block. Interactive mode asks about
|
|
37
|
+
this at the end instead.
|
|
38
|
+
--register-port PORT Non-default ssh port to register with
|
|
39
|
+
--register.
|
|
40
|
+
--force-allow-soft-constraints Flag mode only: skip the 'this may take a long
|
|
41
|
+
time' confirmation for oversized cost knobs.
|
|
42
|
+
Interactive mode always asks.
|
|
43
|
+
-h, --help Show this message and exit.
|
|
44
|
+
|
|
45
|
+
Options below are for --kdf=argon2id --salt-algo=blake2b (defaults, unless you passed both already).
|
|
46
|
+
--kdf choices: argon2id, argon2i, argon2d, scrypt, pbkdf2, bcrypt_pbkdf
|
|
47
|
+
--salt-algo choices: blake2b, blake2s, sha256, sha384, sha512, sha3_256, sha3_384, sha3_512
|
|
48
|
+
|
|
49
|
+
Each combination has its own options. To see another one's:
|
|
50
|
+
detssh --kdf pbkdf2 --salt-algo sha256 --help
|
|
51
|
+
|
|
52
|
+
To use a generated key with plain `ssh <label>`, see:
|
|
53
|
+
detssh ssh --help
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
Usage: main [OPTIONS]
|
|
2
|
+
|
|
3
|
+
Options:
|
|
4
|
+
--kdf [argon2id|argon2i|argon2d|scrypt|pbkdf2|bcrypt_pbkdf]
|
|
5
|
+
KDF backend
|
|
6
|
+
[default: argon2id]
|
|
7
|
+
--salt-algo [blake2b|blake2s|sha256|sha384|sha512|sha3_256|sha3_384|sha3_512]
|
|
8
|
+
Salt hash algorithm
|
|
9
|
+
[default: blake2b]
|
|
10
|
+
--seed TEXT Seed passphrase
|
|
11
|
+
--label TEXT Label
|
|
12
|
+
--iterations INTEGER PBKDF2 iterations (soft max 50000000)
|
|
13
|
+
[default: 600000]
|
|
14
|
+
--output FILE Output path for the private key (ssh-keygen
|
|
15
|
+
style, e.g. ~/.ssh/id_ed25519)
|
|
16
|
+
[default: ~/.ssh/id_ed25519]
|
|
17
|
+
--comment TEXT Comment for the public key
|
|
18
|
+
--key-passphrase TEXT Passphrase to encrypt the private key file
|
|
19
|
+
with (leave empty for none)
|
|
20
|
+
--overwrite-files Overwrite existing output files without
|
|
21
|
+
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.
|
|
27
|
+
--register USER@HOST Register the generated key so plain `ssh
|
|
28
|
+
<label>` connects to USER@HOST, as `detssh ssh
|
|
29
|
+
register` would. Needs --label, which names
|
|
30
|
+
the `Host` block. Interactive mode asks about
|
|
31
|
+
this at the end instead.
|
|
32
|
+
--register-port PORT Non-default ssh port to register with
|
|
33
|
+
--register.
|
|
34
|
+
--force-allow-soft-constraints Flag mode only: skip the 'this may take a long
|
|
35
|
+
time' confirmation for oversized cost knobs.
|
|
36
|
+
Interactive mode always asks.
|
|
37
|
+
-h, --help Show this message and exit.
|
|
38
|
+
|
|
39
|
+
Options below are for --kdf=pbkdf2 --salt-algo=sha256 (defaults, unless you passed both already).
|
|
40
|
+
--kdf choices: argon2id, argon2i, argon2d, scrypt, pbkdf2, bcrypt_pbkdf
|
|
41
|
+
--salt-algo choices: blake2b, blake2s, sha256, sha384, sha512, sha3_256, sha3_384, sha3_512
|
|
42
|
+
|
|
43
|
+
Each combination has its own options. To see another one's:
|
|
44
|
+
detssh --kdf pbkdf2 --salt-algo sha256 --help
|
|
45
|
+
|
|
46
|
+
To use a generated key with plain `ssh <label>`, see:
|
|
47
|
+
detssh ssh --help
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Mutants mutmut generates that survive (or time out) for reasons that don't indicate
|
|
2
|
+
# a real test gap - each is a change the *code* cannot behave differently for, or (for
|
|
3
|
+
# the two "timeout" entries) a change that turns the line into an infinite loop, which
|
|
4
|
+
# mutmut correctly reports as non-equivalent but under a status other than "survived".
|
|
5
|
+
#
|
|
6
|
+
# CI (.github/workflows/tests.yml, job "mutation") fails only on a survivor NOT listed
|
|
7
|
+
# here, so this file is the thing to update - after re-verifying the reasoning below
|
|
8
|
+
# still holds - if `uv run mutmut show <name>` shows something new isn't actually
|
|
9
|
+
# equivalent. Never add an entry just to make CI pass without doing that check.
|
|
10
|
+
#
|
|
11
|
+
# Regenerate this exact set with:
|
|
12
|
+
# uv run mutmut run; uv run mutmut results | awk '{print $1}' | sed 's/:$//'
|
|
13
|
+
# then diff against this file before committing - the diff is your changelog.
|
|
14
|
+
|
|
15
|
+
# configparser's default optionxform lowercases option names on both write and read, so
|
|
16
|
+
# looking a key up (or storing it) under a different case than the source reads it back
|
|
17
|
+
# identically either way - "user"/"USER", "host"/"HOST", "port"/"PORT" are the same key.
|
|
18
|
+
detssh.registry.x_load_registry__mutmut_12
|
|
19
|
+
detssh.registry.x_load_registry__mutmut_14
|
|
20
|
+
detssh.registry.x_load_registry__mutmut_19
|
|
21
|
+
detssh.registry.x_save_registry__mutmut_4
|
|
22
|
+
detssh.registry.x_save_registry__mutmut_7
|
|
23
|
+
detssh.registry.x_save_registry__mutmut_9
|
|
24
|
+
detssh.registry.x_save_registry__mutmut_12
|
|
25
|
+
|
|
26
|
+
# _peek(args, "--kdf"/"--salt-algo", DEFAULT_*)'s default argument only ever feeds a
|
|
27
|
+
# dict.get(key, fallback) elsewhere - a dict miss returns the same fallback class no
|
|
28
|
+
# matter what the "default" string itself was, so DEFAULT_KDF and None are the same
|
|
29
|
+
# value for every input. (Not to be confused with the *.get()* call's own fallback
|
|
30
|
+
# argument, mutmut_21/23/26/28 in the same function - those ARE real and are tested via
|
|
31
|
+
# test_invalid_kdf_value_gives_a_clean_error_not_a_traceback and its salt-algo sibling.)
|
|
32
|
+
detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_4
|
|
33
|
+
detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_13
|
|
34
|
+
|
|
35
|
+
# click.Option's own default for the `default=` kwarg is already None for a non-flag
|
|
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
|
|
39
|
+
|
|
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
|
|
45
|
+
# already pins the one-character-off-by-a-lot case (measure_table()[0] vs [1]) that
|
|
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
|
+
detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_22
|
|
50
|
+
# help_text.split(DEFAULT_TEXT_MARKER, 1) vs rsplit(..., 1) vs split(..., 2) vs
|
|
51
|
+
# split(..., ) (no limit): DEFAULT_TEXT_MARKER ("\x00") is inserted into a help string at
|
|
52
|
+
# most once, by construction in build_options - with a single occurrence, every one of
|
|
53
|
+
# these splits the string identically.
|
|
54
|
+
detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_29
|
|
55
|
+
detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_30
|
|
56
|
+
detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_31
|
|
57
|
+
# `textwrap.wrap(description, text_width) or [""]` - the `or [...]` fallback only fires
|
|
58
|
+
# when wrap() returns an empty list, i.e. description is blank after stripping. No real
|
|
59
|
+
# option's message is ever blank, so this branch is unreachable for the actual option
|
|
60
|
+
# set; forcing it would mean asserting against a contrived, not-really-used input.
|
|
61
|
+
detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_38
|
|
62
|
+
|
|
63
|
+
# run()'s else-branch computes `argv = sys.argv[1:]` and passes `args=argv` unmodified -
|
|
64
|
+
# click.BaseCommand.main() already defaults args=None to sys.argv[1:] itself, so passing
|
|
65
|
+
# that exact value explicitly or omitting it (args=None) are the same call.
|
|
66
|
+
detssh.cli.x_run__mutmut_14
|
|
67
|
+
detssh.cli.x_run__mutmut_16
|
|
68
|
+
|
|
69
|
+
# write_hosts_file's os.open(..., mode=HOSTS_FILE_MODE) is immediately followed by an
|
|
70
|
+
# unconditional os.fchmod(fd, HOSTS_FILE_MODE) - whatever mode os.open used gets
|
|
71
|
+
# overwritten right after, so dropping the os.open mode argument is a no-op. (ensure_include
|
|
72
|
+
# has no such fchmod call, which is why its equivalent mutant - dropping SSH_CONFIG_FILE_MODE
|
|
73
|
+
# from its own os.open - is NOT in this list: it's real, and is tested by
|
|
74
|
+
# test_ensure_include_creates_a_missing_ssh_dir_with_correct_permissions.)
|
|
75
|
+
detssh.ssh_config.x_write_hosts_file__mutmut_16
|
|
76
|
+
# Same reasoning: write_keypair's os.open(..., PRIVATE_KEY_MODE) is followed by an
|
|
77
|
+
# unconditional os.fchmod(fd, PRIVATE_KEY_MODE).
|
|
78
|
+
detssh.keygen.x_write_keypair__mutmut_48
|
|
79
|
+
|
|
80
|
+
# Python's codec lookup is case-insensitive ("utf-8" and "UTF-8" resolve to the same
|
|
81
|
+
# codec), and hashlib's hash-algorithm-name lookup is too ("sha256" and "SHA256" give the
|
|
82
|
+
# same digest) - every one of these mutates a hardcoded codec/hash name to a different
|
|
83
|
+
# case, which cannot change what the code does.
|
|
84
|
+
detssh.keygen.x_write_keypair__mutmut_29
|
|
85
|
+
detssh.keygen.x_write_keypair__mutmut_71
|
|
86
|
+
detssh.backends.kdf.scrypt.xǁScryptǁrun__mutmut_35
|
|
87
|
+
detssh.backends.kdf.bcrypt_pbkdf.xǁBcryptPbkdfǁrun__mutmut_13
|
|
88
|
+
detssh.backends.kdf.pbkdf2.xǁPbkdf2ǁrun__mutmut_12
|
|
89
|
+
detssh.backends.kdf.pbkdf2.xǁPbkdf2ǁrun__mutmut_17
|
|
90
|
+
detssh.backends.kdf._argon2.xǁArgon2Baseǁrun__mutmut_19
|
|
91
|
+
detssh.backends.salt.sha512.xǁSha512ǁdigest__mutmut_4
|
|
92
|
+
detssh.backends.salt.sha384.xǁSha384ǁdigest__mutmut_4
|
|
93
|
+
detssh.backends.salt.sha256.xǁSha256ǁdigest__mutmut_4
|
|
94
|
+
detssh.backends.salt.blake2s.xǁBlake2sǁdigest__mutmut_7
|
|
95
|
+
detssh.backends.salt.sha3_512.xǁSha3512ǁdigest__mutmut_4
|
|
96
|
+
detssh.backends.salt.blake2b.xǁBlake2bǁdigest__mutmut_7
|
|
97
|
+
detssh.backends.salt.sha3_256.xǁSha3256ǁdigest__mutmut_4
|
|
98
|
+
detssh.backends.salt.sha3_384.xǁSha3384ǁdigest__mutmut_4
|
|
99
|
+
|
|
100
|
+
# Field.__init__'s default kind="text" is never explicitly checked anywhere (only
|
|
101
|
+
# "secret"/"int"/"path"/"select" are); "text" is just a sentinel meaning "none of those",
|
|
102
|
+
# so any other string in its place (kind="TEXT", kind="XXtextXX") behaves identically.
|
|
103
|
+
detssh.backends.base.xǁFieldǁ__init____mutmut_1
|
|
104
|
+
detssh.backends.base.xǁFieldǁ__init____mutmut_2
|
|
105
|
+
|
|
106
|
+
# click.prompt(..., show_default=None) vs show_default=False: with default="" (falsy),
|
|
107
|
+
# click treats both the same - neither shows a "[]" default hint. Confirmed empirically;
|
|
108
|
+
# see the type= mutants on the same line (mutmut_34, 38), which ARE real and are killed
|
|
109
|
+
# by test_wizard_register_prompt_reasks_on_a_non_integer_port.
|
|
110
|
+
detssh.cli.x__register_interactively__mutmut_33
|
|
111
|
+
|
|
112
|
+
# Two mutants turn a loop into an infinite one (an absolute assignment replacing a += in
|
|
113
|
+
# _peek's index-advance; `name = None` inside `_register_interactively`'s `while not
|
|
114
|
+
# name:` reprompt loop, which can never become truthy again). mutmut correctly reports
|
|
115
|
+
# these as "timeout", not "survived" - CI's grep only matches lines ending in "survived",
|
|
116
|
+
# so they don't need listing here, but are noted for anyone diffing this file against a
|
|
117
|
+
# fresh `mutmut results`.
|
|
118
|
+
# detssh.cli.x__peek__mutmut_14: timeout
|
|
119
|
+
# detssh.cli.x__peek__mutmut_30: timeout
|
|
120
|
+
# detssh.cli.x__register_interactively__mutmut_14: timeout
|