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.
- detssh-0.7.0/.github/workflows/tests.yml +68 -0
- {detssh-0.4.0 → detssh-0.7.0}/.gitignore +2 -0
- {detssh-0.4.0 → detssh-0.7.0}/CHANGELOG.md +63 -0
- {detssh-0.4.0 → detssh-0.7.0}/PKG-INFO +46 -1
- {detssh-0.4.0 → detssh-0.7.0}/README.md +45 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/__init__.py +1 -1
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/base.py +60 -8
- {detssh-0.4.0 → detssh-0.7.0}/detssh/cli.py +92 -11
- {detssh-0.4.0 → detssh-0.7.0}/detssh/keygen.py +40 -7
- {detssh-0.4.0 → detssh-0.7.0}/pyproject.toml +10 -1
- {detssh-0.4.0 → detssh-0.7.0}/tests/golden/README.md +7 -0
- detssh-0.7.0/tests/golden/help_default.txt +62 -0
- detssh-0.7.0/tests/golden/help_pbkdf2_sha256.txt +56 -0
- detssh-0.7.0/tests/mutmut_known_equivalents.txt +130 -0
- detssh-0.7.0/tests/test_authorize.py +394 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_cli.py +140 -8
- detssh-0.7.0/tests/test_help_text.py +39 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_properties.py +380 -21
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_registry.py +10 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_ssh_cli.py +43 -1
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_ssh_config.py +60 -1
- {detssh-0.4.0 → detssh-0.7.0}/uv.lock +328 -2
- detssh-0.4.0/.github/workflows/tests.yml +0 -36
- {detssh-0.4.0 → detssh-0.7.0}/.github/workflows/publish.yml +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/LICENSE +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/__init__.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/__init__.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/_argon2.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/argon2d.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/argon2i.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/argon2id.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/base.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/pbkdf2.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/kdf/scrypt.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/__init__.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/base.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/blake2b.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/blake2s.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha256.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha384.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha3_256.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha3_384.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha3_512.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/backends/salt/sha512.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/registry.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/ssh_cli.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/detssh/ssh_config.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/golden/variations.csv +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/golden/vectors.csv +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_cli_combinations.py +0 -0
- {detssh-0.4.0 → detssh-0.7.0}/tests/test_determinism.py +0 -0
- {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."
|
|
@@ -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.
|
|
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,14 @@ from pathlib import Path
|
|
|
3
3
|
import click
|
|
4
4
|
import questionary
|
|
5
5
|
|
|
6
|
-
from detssh.keygen import
|
|
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
|
|
169
|
-
return
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
142
|
-
"Interactive mode
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
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
|
-
|
|
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
|