detssh 0.6.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.6.0 → detssh-0.7.0}/CHANGELOG.md +19 -0
- {detssh-0.6.0 → detssh-0.7.0}/PKG-INFO +21 -1
- {detssh-0.6.0 → detssh-0.7.0}/README.md +20 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/__init__.py +1 -1
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/base.py +32 -3
- {detssh-0.6.0 → detssh-0.7.0}/detssh/cli.py +80 -9
- {detssh-0.6.0 → detssh-0.7.0}/detssh/keygen.py +40 -7
- {detssh-0.6.0 → detssh-0.7.0}/pyproject.toml +1 -1
- {detssh-0.6.0 → detssh-0.7.0}/tests/golden/help_default.txt +14 -5
- {detssh-0.6.0 → detssh-0.7.0}/tests/golden/help_pbkdf2_sha256.txt +14 -5
- {detssh-0.6.0 → detssh-0.7.0}/tests/mutmut_known_equivalents.txt +21 -11
- detssh-0.7.0/tests/test_authorize.py +394 -0
- {detssh-0.6.0 → detssh-0.7.0}/uv.lock +1 -1
- {detssh-0.6.0 → detssh-0.7.0}/.github/workflows/publish.yml +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/.github/workflows/tests.yml +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/.gitignore +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/LICENSE +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/__init__.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/__init__.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/_argon2.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/argon2d.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/argon2i.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/argon2id.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/base.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/bcrypt_pbkdf.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/pbkdf2.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/kdf/scrypt.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/__init__.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/base.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/blake2b.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/blake2s.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha256.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha384.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha3_256.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha3_384.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha3_512.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/backends/salt/sha512.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/registry.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/ssh_cli.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/detssh/ssh_config.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/golden/README.md +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/golden/variations.csv +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/golden/vectors.csv +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_cli.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_cli_combinations.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_determinism.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_golden_vectors.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_help_text.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_properties.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_registry.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_ssh_cli.py +0 -0
- {detssh-0.6.0 → detssh-0.7.0}/tests/test_ssh_config.py +0 -0
|
@@ -7,6 +7,25 @@ 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
|
+
|
|
10
29
|
## [0.6.0] - 2026-09-19
|
|
11
30
|
|
|
12
31
|
### 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`, ...)
|
|
@@ -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`, ...)
|
|
@@ -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):
|
|
@@ -215,9 +222,31 @@ def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="
|
|
|
215
222
|
click.echo(f"Wrote private key: {priv_path}{' (encrypted)' if key_passphrase else ''}")
|
|
216
223
|
click.echo(f"Wrote public key: {pub_path}")
|
|
217
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):
|
|
218
249
|
click.echo()
|
|
219
250
|
click.echo("To recreate this exact key, remember your passphrase (keep it secret) plus:")
|
|
220
251
|
for name, value in (*recap, ("algorithm", "ed25519")):
|
|
221
252
|
click.echo(f" {name:<18} {value}")
|
|
222
|
-
|
|
223
|
-
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)),
|
|
@@ -65,8 +79,8 @@ def _peek(args, flag, default):
|
|
|
65
79
|
return value
|
|
66
80
|
|
|
67
81
|
|
|
68
|
-
def _fields_for(kdf_cls, salt_cls):
|
|
69
|
-
|
|
82
|
+
def _fields_for(kdf_cls, salt_cls, authorize=False):
|
|
83
|
+
fields = {
|
|
70
84
|
"seed": COMMON["seed"],
|
|
71
85
|
"label": COMMON["label"],
|
|
72
86
|
**kdf_cls.fields,
|
|
@@ -75,6 +89,9 @@ def _fields_for(kdf_cls, salt_cls):
|
|
|
75
89
|
"comment": COMMON["comment"],
|
|
76
90
|
"key_passphrase": COMMON["key_passphrase"],
|
|
77
91
|
}
|
|
92
|
+
if authorize:
|
|
93
|
+
del fields["output"], fields["key_passphrase"]
|
|
94
|
+
return fields
|
|
78
95
|
|
|
79
96
|
|
|
80
97
|
def _help_note(kdf_cls, salt_cls):
|
|
@@ -110,6 +127,30 @@ def _registration_from_flags(label, values):
|
|
|
110
127
|
return label, user, host, port
|
|
111
128
|
|
|
112
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
|
+
|
|
113
154
|
def _register_interactively(label, key_path):
|
|
114
155
|
"""Offer to wire the key we just wrote into ~/.ssh/config. Declining leaves the key
|
|
115
156
|
exactly as written - `detssh ssh register` does the same thing later on."""
|
|
@@ -143,12 +184,26 @@ class KDFSaltCommand(click.Command):
|
|
|
143
184
|
fields = {**SELECTORS, **_fields_for(kdf_cls, salt_cls)}
|
|
144
185
|
defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
|
|
145
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
|
+
),
|
|
146
201
|
click.Option(["--overwrite-files"], is_flag=True, help="Overwrite existing output files without asking."),
|
|
147
202
|
click.Option(
|
|
148
203
|
["--create-parent-dirs"],
|
|
149
204
|
is_flag=True,
|
|
150
|
-
help="Create the output path's parent directories if missing,
|
|
151
|
-
"Interactive mode prompts for this itself if you don't pass the flag; "
|
|
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; "
|
|
152
207
|
"non-interactive mode requires it explicitly.",
|
|
153
208
|
),
|
|
154
209
|
click.Option(
|
|
@@ -199,10 +254,14 @@ class KDFSaltCommand(click.Command):
|
|
|
199
254
|
|
|
200
255
|
|
|
201
256
|
@click.command(cls=KDFSaltCommand, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120})
|
|
202
|
-
def main(overwrite_files, create_parent_dirs, **values):
|
|
257
|
+
def main(overwrite_files, create_parent_dirs, authorize, authorized_keys, **values):
|
|
203
258
|
interactive = is_interactive(values)
|
|
204
259
|
force_allow_soft_constraints = bool(values.get("force_allow_soft_constraints"))
|
|
205
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
|
+
|
|
206
265
|
kdf_name = values["kdf"]
|
|
207
266
|
if kdf_name is None:
|
|
208
267
|
kdf_name = SELECTORS["kdf"].ask(DEFAULTS["kdf"]) if interactive else DEFAULTS["kdf"]
|
|
@@ -213,7 +272,7 @@ def main(overwrite_files, create_parent_dirs, **values):
|
|
|
213
272
|
salt_name = SELECTORS["salt_algo"].ask(DEFAULTS["salt_algo"]) if interactive else DEFAULTS["salt_algo"]
|
|
214
273
|
salt_cls = salt.ALGOS[salt_name]
|
|
215
274
|
|
|
216
|
-
fields = _fields_for(kdf_cls, salt_cls)
|
|
275
|
+
fields = _fields_for(kdf_cls, salt_cls, authorize)
|
|
217
276
|
defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
|
|
218
277
|
|
|
219
278
|
resolved = {
|
|
@@ -226,6 +285,13 @@ def main(overwrite_files, create_parent_dirs, **values):
|
|
|
226
285
|
if any(char in resolved["comment"] for char in COMMENT_FORBIDDEN_CHARS):
|
|
227
286
|
raise click.UsageError("--comment must not contain newlines")
|
|
228
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
|
+
|
|
229
295
|
registration = None if interactive else _registration_from_flags(resolved["label"], values)
|
|
230
296
|
|
|
231
297
|
try:
|
|
@@ -236,8 +302,9 @@ def main(overwrite_files, create_parent_dirs, **values):
|
|
|
236
302
|
if salt_error:
|
|
237
303
|
raise click.UsageError(f"{kdf_name} {salt_error}")
|
|
238
304
|
|
|
239
|
-
|
|
240
|
-
|
|
305
|
+
if not authorize:
|
|
306
|
+
confirm_overwrite(target, overwrite_files)
|
|
307
|
+
create_parent_dirs = confirm_create_parent_dirs(target, create_parent_dirs, interactive)
|
|
241
308
|
|
|
242
309
|
click.echo("Deriving key...")
|
|
243
310
|
try:
|
|
@@ -255,6 +322,10 @@ def main(overwrite_files, create_parent_dirs, **values):
|
|
|
255
322
|
("hash-len", resolved["hash_len"]),
|
|
256
323
|
)
|
|
257
324
|
|
|
325
|
+
if authorize:
|
|
326
|
+
authorize_and_recap(seed_bytes, target, resolved["comment"], recap, create_parent_dirs=create_parent_dirs)
|
|
327
|
+
return
|
|
328
|
+
|
|
258
329
|
priv_path, _ = write_keypair_and_recap(
|
|
259
330
|
seed_bytes,
|
|
260
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
|
|
@@ -23,13 +23,22 @@ Options:
|
|
|
23
23
|
--comment TEXT Comment for the public key
|
|
24
24
|
--key-passphrase TEXT Passphrase to encrypt the private key file
|
|
25
25
|
with (leave empty for none)
|
|
26
|
+
--authorize Only add the public key to an authorized_keys
|
|
27
|
+
file (see --authorized-keys), so the machine
|
|
28
|
+
accepts logins from wherever you derived the
|
|
29
|
+
private key. Writes no private key. Doesn't
|
|
30
|
+
switch to flag mode on its own: `detssh
|
|
31
|
+
--authorize` still prompts for the rest.
|
|
32
|
+
--authorized-keys FILE authorized_keys file for --authorize to append
|
|
33
|
+
to. Skipped if the key is already in it.
|
|
34
|
+
[default: ~/.ssh/authorized_keys]
|
|
26
35
|
--overwrite-files Overwrite existing output files without
|
|
27
36
|
asking.
|
|
28
|
-
--create-parent-dirs Create the output path's
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
flag; non-
|
|
32
|
-
explicitly.
|
|
37
|
+
--create-parent-dirs Create the output path's (or --authorized-keys
|
|
38
|
+
file's) parent directories if missing, even
|
|
39
|
+
outside ~/.ssh. Interactive mode prompts for
|
|
40
|
+
this itself if you don't pass the flag; non-
|
|
41
|
+
interactive mode requires it explicitly.
|
|
33
42
|
--register USER@HOST Register the generated key so plain `ssh
|
|
34
43
|
<label>` connects to USER@HOST, as `detssh ssh
|
|
35
44
|
register` would. Needs --label, which names
|
|
@@ -17,13 +17,22 @@ Options:
|
|
|
17
17
|
--comment TEXT Comment for the public key
|
|
18
18
|
--key-passphrase TEXT Passphrase to encrypt the private key file
|
|
19
19
|
with (leave empty for none)
|
|
20
|
+
--authorize Only add the public key to an authorized_keys
|
|
21
|
+
file (see --authorized-keys), so the machine
|
|
22
|
+
accepts logins from wherever you derived the
|
|
23
|
+
private key. Writes no private key. Doesn't
|
|
24
|
+
switch to flag mode on its own: `detssh
|
|
25
|
+
--authorize` still prompts for the rest.
|
|
26
|
+
--authorized-keys FILE authorized_keys file for --authorize to append
|
|
27
|
+
to. Skipped if the key is already in it.
|
|
28
|
+
[default: ~/.ssh/authorized_keys]
|
|
20
29
|
--overwrite-files Overwrite existing output files without
|
|
21
30
|
asking.
|
|
22
|
-
--create-parent-dirs Create the output path's
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
flag; non-
|
|
26
|
-
explicitly.
|
|
31
|
+
--create-parent-dirs Create the output path's (or --authorized-keys
|
|
32
|
+
file's) parent directories if missing, even
|
|
33
|
+
outside ~/.ssh. Interactive mode prompts for
|
|
34
|
+
this itself if you don't pass the flag; non-
|
|
35
|
+
interactive mode requires it explicitly.
|
|
27
36
|
--register USER@HOST Register the generated key so plain `ssh
|
|
28
37
|
<label>` connects to USER@HOST, as `detssh ssh
|
|
29
38
|
register` would. Needs --label, which names
|
|
@@ -34,18 +34,16 @@ detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_13
|
|
|
34
34
|
|
|
35
35
|
# click.Option's own default for the `default=` kwarg is already None for a non-flag
|
|
36
36
|
# option, so passing default=None explicitly vs. omitting it are identical.
|
|
37
|
-
detssh.cli.xǁKDFSaltCommandǁ
|
|
38
|
-
detssh.cli.xǁKDFSaltCommandǁ
|
|
37
|
+
detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_64
|
|
38
|
+
detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_110
|
|
39
|
+
detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_129
|
|
39
40
|
|
|
40
|
-
# format_options'
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
# does, and test_format_options_sizes_the_first_column_from_option_names_not_help_text
|
|
41
|
+
# format_options' text_width floor: a 1-off change to the 10 (mutmut_22) only produces a
|
|
42
|
+
# different wrapped line on a terminal narrow enough to hit the floor at all, landing
|
|
43
|
+
# exactly on the changed boundary - the real --help output (checked byte-for-byte by
|
|
44
|
+
# test_help_text.py) never does, and test_format_options_sizes_the_first_column_from_option_names_not_help_text
|
|
45
45
|
# already pins the one-character-off-by-a-lot case (measure_table()[0] vs [1]) that
|
|
46
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
47
|
detssh.cli.xǁKDFSaltCommandǁformat_options__mutmut_22
|
|
50
48
|
# help_text.split(DEFAULT_TEXT_MARKER, 1) vs rsplit(..., 1) vs split(..., 2) vs
|
|
51
49
|
# split(..., ) (no limit): DEFAULT_TEXT_MARKER ("\x00") is inserted into a help string at
|
|
@@ -75,14 +73,15 @@ detssh.cli.x_run__mutmut_16
|
|
|
75
73
|
detssh.ssh_config.x_write_hosts_file__mutmut_16
|
|
76
74
|
# Same reasoning: write_keypair's os.open(..., PRIVATE_KEY_MODE) is followed by an
|
|
77
75
|
# unconditional os.fchmod(fd, PRIVATE_KEY_MODE).
|
|
78
|
-
detssh.keygen.
|
|
76
|
+
detssh.keygen.x_write_keypair__mutmut_43
|
|
79
77
|
|
|
80
78
|
# Python's codec lookup is case-insensitive ("utf-8" and "UTF-8" resolve to the same
|
|
81
79
|
# codec), and hashlib's hash-algorithm-name lookup is too ("sha256" and "SHA256" give the
|
|
82
80
|
# same digest) - every one of these mutates a hardcoded codec/hash name to a different
|
|
83
81
|
# case, which cannot change what the code does.
|
|
84
82
|
detssh.keygen.x_write_keypair__mutmut_29
|
|
85
|
-
detssh.keygen.
|
|
83
|
+
detssh.keygen.x_public_key_line__mutmut_20
|
|
84
|
+
detssh.backends.base.x_authorize_and_recap__mutmut_25
|
|
86
85
|
detssh.backends.kdf.scrypt.xǁScryptǁrun__mutmut_35
|
|
87
86
|
detssh.backends.kdf.bcrypt_pbkdf.xǁBcryptPbkdfǁrun__mutmut_13
|
|
88
87
|
detssh.backends.kdf.pbkdf2.xǁPbkdf2ǁrun__mutmut_12
|
|
@@ -108,6 +107,17 @@ detssh.backends.base.xǁFieldǁ__init____mutmut_2
|
|
|
108
107
|
# see the type= mutants on the same line (mutmut_34, 38), which ARE real and are killed
|
|
109
108
|
# by test_wizard_register_prompt_reasks_on_a_non_integer_port.
|
|
110
109
|
detssh.cli.x__register_interactively__mutmut_33
|
|
110
|
+
# Same for _ask_authorized_keys, whose default is a Path (truthy): click only shows the
|
|
111
|
+
# hint when show_default is truthy or a str, and None is neither, so it behaves exactly
|
|
112
|
+
# like False. The hint the prompt does show is written into its message by hand.
|
|
113
|
+
detssh.cli.x__ask_authorized_keys__mutmut_5
|
|
114
|
+
|
|
115
|
+
# click.Path only ever reads dir_okay through truthiness (`if not self.dir_okay and ...`),
|
|
116
|
+
# so dir_okay=None behaves exactly like dir_okay=False. The dir_okay=True and omitted
|
|
117
|
+
# mutants on the same lines are real and killed by the "rejects_a_directory" tests in
|
|
118
|
+
# test_authorize.py.
|
|
119
|
+
detssh.cli.x__ask_authorized_keys__mutmut_11
|
|
120
|
+
detssh.cli.xǁKDFSaltCommandǁparse_args__mutmut_68
|
|
111
121
|
|
|
112
122
|
# Two mutants turn a loop into an infinite one (an absolute assignment replacing a += in
|
|
113
123
|
# _peek's index-advance; `name = None` inside `_register_interactively`'s `while not
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import stat
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import pytest
|
|
5
|
+
import questionary
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
|
|
8
|
+
from detssh.backends.base import authorize_and_recap
|
|
9
|
+
from detssh.cli import AUTHORIZE_MODE, GENERATE_MODE, main
|
|
10
|
+
from detssh.keygen import (
|
|
11
|
+
authorize_public_key,
|
|
12
|
+
default_authorized_keys_path,
|
|
13
|
+
keypair_from_seed,
|
|
14
|
+
public_key_line,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
SEED = b"\x01" * 32
|
|
19
|
+
OTHER_SEED = b"\x02" * 32
|
|
20
|
+
FLAG_RUN = ["--kdf", "pbkdf2", "--iterations", "2", "--seed", "s", "--label", "work"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _public_key(seed=SEED):
|
|
24
|
+
return keypair_from_seed(seed)[1]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blob(seed=SEED):
|
|
28
|
+
return public_key_line(_public_key(seed)).rstrip(b"\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _fake_select(answer_for_mode):
|
|
32
|
+
"""Pick `answer_for_mode` at the mode question and pbkdf2 at the KDF picker, recording
|
|
33
|
+
every question asked so tests can check which ones came up."""
|
|
34
|
+
asked = []
|
|
35
|
+
|
|
36
|
+
def select(message, choices, default):
|
|
37
|
+
asked.append((message, tuple(choices), default))
|
|
38
|
+
if message == "What do you want to do?":
|
|
39
|
+
answer = answer_for_mode
|
|
40
|
+
else:
|
|
41
|
+
answer = "pbkdf2" if "pbkdf2" in choices else default
|
|
42
|
+
return type("Q", (), {"ask": lambda self: answer})()
|
|
43
|
+
|
|
44
|
+
return select, asked
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_default_authorized_keys_path_is_in_ssh_dir(monkeypatch, tmp_path):
|
|
48
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
49
|
+
assert default_authorized_keys_path() == tmp_path / ".ssh" / "authorized_keys"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_public_key_line_is_type_blob_comment_newline():
|
|
53
|
+
line = public_key_line(_public_key(), "me@work laptop")
|
|
54
|
+
key_type, blob, comment = line.decode("utf-8").rstrip("\n").split(" ", 2)
|
|
55
|
+
assert (key_type, comment) == ("ssh-ed25519", "me@work laptop")
|
|
56
|
+
assert line == f"ssh-ed25519 {blob} me@work laptop\n".encode()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_public_key_line_without_comment_has_no_trailing_space():
|
|
60
|
+
assert public_key_line(_public_key()) == _blob() + b"\n"
|
|
61
|
+
assert not _blob().endswith(b" ")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_public_key_line_rejects_a_comment_with_a_newline():
|
|
65
|
+
with pytest.raises(ValueError, match="^comment must not contain newlines$"):
|
|
66
|
+
public_key_line(_public_key(), "a\nb")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_authorize_creates_ssh_dir_and_file_with_private_modes(monkeypatch, tmp_path):
|
|
70
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
71
|
+
path = tmp_path / ".ssh" / "authorized_keys"
|
|
72
|
+
|
|
73
|
+
assert authorize_public_key(_public_key(), path, comment="c") is True
|
|
74
|
+
|
|
75
|
+
assert path.read_bytes() == _blob() + b" c\n"
|
|
76
|
+
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
|
77
|
+
assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_authorize_appends_after_existing_keys(tmp_path):
|
|
81
|
+
path = tmp_path / "authorized_keys"
|
|
82
|
+
path.write_bytes(_blob(OTHER_SEED) + b" other\n")
|
|
83
|
+
|
|
84
|
+
assert authorize_public_key(_public_key(), path) is True
|
|
85
|
+
|
|
86
|
+
assert path.read_bytes() == _blob(OTHER_SEED) + b" other\n" + _blob() + b"\n"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_authorize_adds_the_missing_newline_before_appending(tmp_path):
|
|
90
|
+
path = tmp_path / "authorized_keys"
|
|
91
|
+
path.write_bytes(b"ssh-rsa AAAA old")
|
|
92
|
+
|
|
93
|
+
authorize_public_key(_public_key(), path)
|
|
94
|
+
|
|
95
|
+
assert path.read_bytes() == b"ssh-rsa AAAA old\n" + _blob() + b"\n"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_authorize_into_an_empty_file_adds_no_leading_newline(tmp_path):
|
|
99
|
+
path = tmp_path / "authorized_keys"
|
|
100
|
+
path.write_bytes(b"")
|
|
101
|
+
|
|
102
|
+
authorize_public_key(_public_key(), path)
|
|
103
|
+
|
|
104
|
+
assert path.read_bytes() == _blob() + b"\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_authorize_skips_a_key_already_present_under_other_options_and_comment(tmp_path):
|
|
108
|
+
path = tmp_path / "authorized_keys"
|
|
109
|
+
original = b'from="10.0.0.1" ' + _blob() + b" old comment\n"
|
|
110
|
+
path.write_bytes(original)
|
|
111
|
+
|
|
112
|
+
assert authorize_public_key(_public_key(), path, comment="new comment") is False
|
|
113
|
+
|
|
114
|
+
assert path.read_bytes() == original
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_authorize_only_matches_the_whole_key_blob_not_a_substring(tmp_path):
|
|
118
|
+
path = tmp_path / "authorized_keys"
|
|
119
|
+
key_blob = _blob().split()[1]
|
|
120
|
+
path.write_bytes(b"ssh-ed25519 " + key_blob + b"XYZ\n")
|
|
121
|
+
|
|
122
|
+
assert authorize_public_key(_public_key(), path) is True
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_authorize_keeps_an_existing_files_mode(tmp_path):
|
|
126
|
+
path = tmp_path / "authorized_keys"
|
|
127
|
+
path.write_bytes(b"")
|
|
128
|
+
path.chmod(0o640)
|
|
129
|
+
|
|
130
|
+
authorize_public_key(_public_key(), path)
|
|
131
|
+
|
|
132
|
+
assert stat.S_IMODE(path.stat().st_mode) == 0o640
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_authorize_outside_ssh_dir_needs_create_parent_dirs(monkeypatch, tmp_path):
|
|
136
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
137
|
+
path = tmp_path / "elsewhere" / "authorized_keys"
|
|
138
|
+
|
|
139
|
+
with pytest.raises(FileNotFoundError):
|
|
140
|
+
authorize_public_key(_public_key(), path)
|
|
141
|
+
|
|
142
|
+
assert authorize_public_key(_public_key(), path, create_parent_dirs=True) is True
|
|
143
|
+
assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _invoke_recap(path):
|
|
147
|
+
@click.command()
|
|
148
|
+
def cmd():
|
|
149
|
+
authorize_and_recap(SEED, path, "c", recap=(("kdf", "pbkdf2"),))
|
|
150
|
+
|
|
151
|
+
return CliRunner().invoke(cmd)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_authorize_and_recap_reports_the_added_line_then_the_recap(tmp_path):
|
|
155
|
+
path = tmp_path / "authorized_keys"
|
|
156
|
+
|
|
157
|
+
result = _invoke_recap(path)
|
|
158
|
+
|
|
159
|
+
assert result.exit_code == 0, result.output
|
|
160
|
+
assert result.output == (
|
|
161
|
+
f"Added public key to {path}:\n"
|
|
162
|
+
f" {_blob().decode()} c\n"
|
|
163
|
+
"\n"
|
|
164
|
+
"To recreate this exact key, remember your passphrase (keep it secret) plus:\n"
|
|
165
|
+
" kdf pbkdf2\n"
|
|
166
|
+
" algorithm ed25519\n"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def test_authorize_and_recap_says_when_the_key_was_already_there(tmp_path):
|
|
171
|
+
path = tmp_path / "authorized_keys"
|
|
172
|
+
path.write_bytes(_blob() + b"\n")
|
|
173
|
+
|
|
174
|
+
result = _invoke_recap(path)
|
|
175
|
+
|
|
176
|
+
assert result.output.splitlines()[:2] == [
|
|
177
|
+
f"Public key already in {path}, left it unchanged:",
|
|
178
|
+
f" {_blob().decode()} c",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_authorize_and_recap_turns_os_errors_into_a_clean_message(tmp_path):
|
|
183
|
+
path = tmp_path / "authorized_keys"
|
|
184
|
+
path.mkdir()
|
|
185
|
+
|
|
186
|
+
result = _invoke_recap(path)
|
|
187
|
+
|
|
188
|
+
assert result.exit_code == 1
|
|
189
|
+
assert result.exc_info[0] is SystemExit
|
|
190
|
+
assert result.output == f"Error: couldn't write {path}: Is a directory\n"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_flag_mode_authorizes_the_same_public_key_a_full_run_writes(monkeypatch, tmp_path):
|
|
194
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
195
|
+
pair = tmp_path / "pair" / "id_ed25519"
|
|
196
|
+
|
|
197
|
+
full = CliRunner().invoke(main, [*FLAG_RUN, "--output", str(pair), "--create-parent-dirs"])
|
|
198
|
+
authorized = CliRunner().invoke(main, [*FLAG_RUN, "--authorize"])
|
|
199
|
+
|
|
200
|
+
assert full.exit_code == 0, full.output
|
|
201
|
+
assert authorized.exit_code == 0, authorized.output
|
|
202
|
+
ssh_dir = tmp_path / ".ssh"
|
|
203
|
+
assert (ssh_dir / "authorized_keys").read_bytes() == (tmp_path / "pair" / "id_ed25519.pub").read_bytes()
|
|
204
|
+
assert sorted(p.name for p in ssh_dir.iterdir()) == ["authorized_keys"]
|
|
205
|
+
assert f"Added public key to {ssh_dir / 'authorized_keys'}:" in authorized.output
|
|
206
|
+
assert "Wrote private key" not in authorized.output
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_flag_mode_appends_to_an_existing_authorized_keys_without_asking(monkeypatch, tmp_path):
|
|
210
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
211
|
+
path = tmp_path / ".ssh" / "authorized_keys"
|
|
212
|
+
path.parent.mkdir()
|
|
213
|
+
path.write_bytes(b"ssh-rsa AAAA old\n")
|
|
214
|
+
|
|
215
|
+
result = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", "--comment", "work"])
|
|
216
|
+
|
|
217
|
+
assert result.exit_code == 0, result.output
|
|
218
|
+
assert "Overwrite?" not in result.output
|
|
219
|
+
lines = path.read_bytes().splitlines()
|
|
220
|
+
assert lines[0] == b"ssh-rsa AAAA old"
|
|
221
|
+
assert lines[1].endswith(b" work")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_flag_mode_expands_tilde_in_authorized_keys(monkeypatch, tmp_path):
|
|
225
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
226
|
+
|
|
227
|
+
result = CliRunner().invoke(
|
|
228
|
+
main, [*FLAG_RUN, "--authorize", "--authorized-keys", "~/.ssh/other_keys", "--create-parent-dirs"]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
assert result.exit_code == 0, result.output
|
|
232
|
+
assert (tmp_path / ".ssh" / "other_keys").exists()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_flag_mode_requires_create_parent_dirs_outside_ssh_dir(monkeypatch, tmp_path):
|
|
236
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
237
|
+
path = tmp_path / "srv" / "authorized_keys"
|
|
238
|
+
|
|
239
|
+
refused = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", "--authorized-keys", str(path)])
|
|
240
|
+
allowed = CliRunner().invoke(
|
|
241
|
+
main, [*FLAG_RUN, "--authorize", "--authorized-keys", str(path), "--create-parent-dirs"]
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
assert refused.exit_code == 2
|
|
245
|
+
assert "~/srv doesn't exist. Pass --create-parent-dirs to create it." in refused.output
|
|
246
|
+
assert allowed.exit_code == 0, allowed.output
|
|
247
|
+
assert path.exists()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_authorized_keys_needs_authorize(tmp_path):
|
|
251
|
+
result = CliRunner().invoke(main, [*FLAG_RUN, "--authorized-keys", str(tmp_path / "k")])
|
|
252
|
+
|
|
253
|
+
assert result.exit_code == 2
|
|
254
|
+
assert "Error: --authorized-keys needs --authorize" in result.output
|
|
255
|
+
assert "Deriving key" not in result.output
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@pytest.mark.parametrize(
|
|
259
|
+
("extra", "message"),
|
|
260
|
+
[
|
|
261
|
+
(["--overwrite-files"], "--overwrite-files can't be used with --authorize: it only appends"),
|
|
262
|
+
(["--output", "k"], "--output can't be used with --authorize: no private key is written"),
|
|
263
|
+
(["--key-passphrase", "p"], "--key-passphrase can't be used with --authorize: no private key is written"),
|
|
264
|
+
(["--register", "a@b"], "--register can't be used with --authorize: no private key is written"),
|
|
265
|
+
(["--register-port", "22"], "--register-port can't be used with --authorize: no private key is written"),
|
|
266
|
+
],
|
|
267
|
+
)
|
|
268
|
+
def test_authorize_rejects_keypair_only_flags_before_deriving(extra, message):
|
|
269
|
+
result = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", *extra])
|
|
270
|
+
|
|
271
|
+
assert result.exit_code == 2
|
|
272
|
+
assert f"Error: {message}" in result.output
|
|
273
|
+
assert "Deriving key" not in result.output
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _interactive_answers(*after_comment):
|
|
277
|
+
return (
|
|
278
|
+
"\n".join(
|
|
279
|
+
[
|
|
280
|
+
"s", # seed passphrase
|
|
281
|
+
"s", # ... confirmed
|
|
282
|
+
"work", # label
|
|
283
|
+
"2", # pbkdf2 iterations
|
|
284
|
+
"", # salt digest size: default
|
|
285
|
+
"", # comment
|
|
286
|
+
*after_comment,
|
|
287
|
+
]
|
|
288
|
+
)
|
|
289
|
+
+ "\n"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def test_interactive_mode_question_defaults_to_generating_a_keypair(monkeypatch, tmp_path):
|
|
294
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
295
|
+
select, asked = _fake_select(GENERATE_MODE)
|
|
296
|
+
monkeypatch.setattr(questionary, "select", select)
|
|
297
|
+
|
|
298
|
+
CliRunner().invoke(main, [], input="\n")
|
|
299
|
+
|
|
300
|
+
assert asked[0] == ("What do you want to do?", (GENERATE_MODE, AUTHORIZE_MODE), GENERATE_MODE)
|
|
301
|
+
assert GENERATE_MODE == "Generate a keypair (private + public key)"
|
|
302
|
+
assert AUTHORIZE_MODE == "Only add the public key to an authorized_keys file"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_interactive_authorize_skips_private_key_prompts_and_registration(monkeypatch, tmp_path):
|
|
306
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
307
|
+
select, _ = _fake_select(AUTHORIZE_MODE)
|
|
308
|
+
monkeypatch.setattr(questionary, "select", select)
|
|
309
|
+
|
|
310
|
+
result = CliRunner().invoke(main, [], input=_interactive_answers(""))
|
|
311
|
+
|
|
312
|
+
assert result.exit_code == 0, result.output
|
|
313
|
+
assert "authorized_keys file to add the public key to [~/.ssh/authorized_keys]: " in result.output
|
|
314
|
+
assert "Output path" not in result.output
|
|
315
|
+
assert "Passphrase to encrypt" not in result.output
|
|
316
|
+
assert "Register this key" not in result.output
|
|
317
|
+
path = tmp_path / ".ssh" / "authorized_keys"
|
|
318
|
+
assert path.read_bytes().startswith(b"ssh-ed25519 ")
|
|
319
|
+
assert sorted(p.name for p in path.parent.iterdir()) == ["authorized_keys"]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_interactive_authorize_expands_tilde_and_asks_before_creating_parent_dirs(monkeypatch, tmp_path):
|
|
323
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
324
|
+
select, _ = _fake_select(AUTHORIZE_MODE)
|
|
325
|
+
monkeypatch.setattr(questionary, "select", select)
|
|
326
|
+
|
|
327
|
+
result = CliRunner().invoke(main, [], input=_interactive_answers("~/srv/authorized_keys", "y"))
|
|
328
|
+
|
|
329
|
+
assert result.exit_code == 0, result.output
|
|
330
|
+
assert "Warning: ~/srv is outside ~/.ssh." in result.output
|
|
331
|
+
assert (tmp_path / "srv" / "authorized_keys").exists()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def test_authorize_flag_alone_stays_interactive_without_the_mode_question(monkeypatch, tmp_path):
|
|
335
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
336
|
+
select, asked = _fake_select(GENERATE_MODE)
|
|
337
|
+
monkeypatch.setattr(questionary, "select", select)
|
|
338
|
+
|
|
339
|
+
result = CliRunner().invoke(main, ["--authorize"], input=_interactive_answers(""))
|
|
340
|
+
|
|
341
|
+
assert result.exit_code == 0, result.output
|
|
342
|
+
assert [message for message, _, _ in asked] == ["KDF backend", "Salt hash algorithm"]
|
|
343
|
+
assert (tmp_path / ".ssh" / "authorized_keys").exists()
|
|
344
|
+
assert not (tmp_path / ".ssh" / "id_ed25519").exists()
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def test_authorized_keys_flag_skips_its_prompt_in_interactive_mode(monkeypatch, tmp_path):
|
|
348
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
349
|
+
select, _ = _fake_select(GENERATE_MODE)
|
|
350
|
+
monkeypatch.setattr(questionary, "select", select)
|
|
351
|
+
path = tmp_path / "keys"
|
|
352
|
+
|
|
353
|
+
result = CliRunner().invoke(main, ["--authorize", "--authorized-keys", str(path)], input=_interactive_answers())
|
|
354
|
+
|
|
355
|
+
assert result.exit_code == 0, result.output
|
|
356
|
+
assert "authorized_keys file to add" not in result.output
|
|
357
|
+
assert path.exists()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_authorize_creates_every_missing_parent_level(tmp_path):
|
|
361
|
+
path = tmp_path / "a" / "b" / "authorized_keys"
|
|
362
|
+
|
|
363
|
+
assert authorize_public_key(_public_key(), path, create_parent_dirs=True) is True
|
|
364
|
+
assert path.exists()
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def test_authorize_and_recap_does_not_create_parent_dirs_unless_asked(monkeypatch, tmp_path):
|
|
368
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
369
|
+
path = tmp_path / "missing" / "authorized_keys"
|
|
370
|
+
|
|
371
|
+
result = _invoke_recap(path)
|
|
372
|
+
|
|
373
|
+
assert result.exit_code == 1
|
|
374
|
+
assert result.output == f"Error: couldn't write {path}: No such file or directory\n"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def test_authorized_keys_flag_rejects_a_directory(tmp_path):
|
|
378
|
+
result = CliRunner().invoke(main, [*FLAG_RUN, "--authorize", "--authorized-keys", str(tmp_path)])
|
|
379
|
+
|
|
380
|
+
assert result.exit_code == 2
|
|
381
|
+
assert "is a directory" in result.output
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def test_interactive_authorized_keys_prompt_rejects_a_directory(monkeypatch, tmp_path):
|
|
385
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
386
|
+
select, _ = _fake_select(AUTHORIZE_MODE)
|
|
387
|
+
monkeypatch.setattr(questionary, "select", select)
|
|
388
|
+
path = tmp_path / "keys"
|
|
389
|
+
|
|
390
|
+
result = CliRunner().invoke(main, [], input=_interactive_answers(str(tmp_path), str(path)))
|
|
391
|
+
|
|
392
|
+
assert result.exit_code == 0, result.output
|
|
393
|
+
assert "is a directory" in result.output
|
|
394
|
+
assert path.exists()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|