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.
- detssh-0.6.0/.github/workflows/tests.yml +68 -0
- {detssh-0.1.0 → detssh-0.6.0}/.gitignore +3 -0
- detssh-0.6.0/CHANGELOG.md +126 -0
- detssh-0.6.0/PKG-INFO +154 -0
- detssh-0.6.0/README.md +133 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/__init__.py +1 -1
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/base.py +51 -5
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/__init__.py +10 -5
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/__init__.py +10 -5
- {detssh-0.1.0 → detssh-0.6.0}/detssh/cli.py +114 -9
- {detssh-0.1.0 → detssh-0.6.0}/detssh/keygen.py +11 -6
- detssh-0.6.0/detssh/registry.py +67 -0
- detssh-0.6.0/detssh/ssh_cli.py +119 -0
- detssh-0.6.0/detssh/ssh_config.py +68 -0
- {detssh-0.1.0 → detssh-0.6.0}/pyproject.toml +24 -2
- {detssh-0.1.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.6.0/tests/test_cli.py +735 -0
- detssh-0.6.0/tests/test_help_text.py +39 -0
- detssh-0.6.0/tests/test_properties.py +1347 -0
- detssh-0.6.0/tests/test_registry.py +80 -0
- detssh-0.6.0/tests/test_ssh_cli.py +254 -0
- detssh-0.6.0/tests/test_ssh_config.py +141 -0
- detssh-0.6.0/uv.lock +1004 -0
- detssh-0.1.0/.github/workflows/tests.yml +0 -36
- detssh-0.1.0/PKG-INFO +0 -80
- detssh-0.1.0/README.md +0 -59
- detssh-0.1.0/tests/test_cli.py +0 -403
- detssh-0.1.0/tests/test_properties.py +0 -614
- detssh-0.1.0/uv.lock +0 -519
- {detssh-0.1.0 → detssh-0.6.0}/.github/workflows/publish.yml +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/LICENSE +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/__init__.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/_argon2.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/argon2d.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/argon2i.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/argon2id.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/base.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/pbkdf2.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/kdf/scrypt.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/base.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/blake2b.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/blake2s.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha256.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha384.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha3_256.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha3_384.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha3_512.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/detssh/backends/salt/sha512.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/tests/golden/variations.csv +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/tests/golden/vectors.csv +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/tests/test_cli_combinations.py +0 -0
- {detssh-0.1.0 → detssh-0.6.0}/tests/test_determinism.py +0 -0
- {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."
|
|
@@ -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 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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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)
|