detssh 0.1.0__py3-none-any.whl
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/__init__.py +14 -0
- detssh/backends/__init__.py +0 -0
- detssh/backends/base.py +177 -0
- detssh/backends/kdf/__init__.py +22 -0
- detssh/backends/kdf/_argon2.py +44 -0
- detssh/backends/kdf/argon2d.py +8 -0
- detssh/backends/kdf/argon2i.py +8 -0
- detssh/backends/kdf/argon2id.py +8 -0
- detssh/backends/kdf/base.py +36 -0
- detssh/backends/kdf/bcrypt_pbkdf.py +32 -0
- detssh/backends/kdf/pbkdf2.py +36 -0
- detssh/backends/kdf/scrypt.py +45 -0
- detssh/backends/salt/__init__.py +26 -0
- detssh/backends/salt/base.py +25 -0
- detssh/backends/salt/blake2b.py +26 -0
- detssh/backends/salt/blake2s.py +26 -0
- detssh/backends/salt/sha256.py +15 -0
- detssh/backends/salt/sha384.py +15 -0
- detssh/backends/salt/sha3_256.py +15 -0
- detssh/backends/salt/sha3_384.py +15 -0
- detssh/backends/salt/sha3_512.py +15 -0
- detssh/backends/salt/sha512.py +15 -0
- detssh/cli.py +183 -0
- detssh/keygen.py +64 -0
- detssh-0.1.0.dist-info/METADATA +80 -0
- detssh-0.1.0.dist-info/RECORD +29 -0
- detssh-0.1.0.dist-info/WHEEL +4 -0
- detssh-0.1.0.dist-info/entry_points.txt +2 -0
- detssh-0.1.0.dist-info/licenses/LICENSE +21 -0
detssh/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from detssh.backends.kdf import BACKENDS as KDF_BACKENDS
|
|
2
|
+
from detssh.backends.salt import ALGOS as SALT_ALGOS
|
|
3
|
+
from detssh.keygen import default_output_path, keypair_from_seed, public_key_path, write_keypair
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"KDF_BACKENDS",
|
|
9
|
+
"SALT_ALGOS",
|
|
10
|
+
"default_output_path",
|
|
11
|
+
"keypair_from_seed",
|
|
12
|
+
"public_key_path",
|
|
13
|
+
"write_keypair",
|
|
14
|
+
]
|
|
File without changes
|
detssh/backends/base.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import questionary
|
|
5
|
+
|
|
6
|
+
from detssh.keygen import keypair_from_seed, public_key_path, write_keypair
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _display_path(path):
|
|
10
|
+
try:
|
|
11
|
+
return f"~/{Path(path).relative_to(Path.home())}"
|
|
12
|
+
except (TypeError, ValueError):
|
|
13
|
+
return str(path)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
DEFAULT_TEXT_MARKER = "\x00"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Field:
|
|
20
|
+
def __init__(self, message, kind="text", required=False, choices=(), min_value=None, max_value=None, soft_max=None):
|
|
21
|
+
self.message = message
|
|
22
|
+
self.kind = kind
|
|
23
|
+
self.required = required
|
|
24
|
+
self.choices = choices
|
|
25
|
+
self.min_value = min_value
|
|
26
|
+
self.max_value = max_value
|
|
27
|
+
self.soft_max = soft_max
|
|
28
|
+
|
|
29
|
+
def hint(self):
|
|
30
|
+
parts = []
|
|
31
|
+
if self.min_value is not None and self.max_value is not None:
|
|
32
|
+
parts.append(f"{self.min_value}-{self.max_value}")
|
|
33
|
+
elif self.min_value is not None:
|
|
34
|
+
parts.append(f">= {self.min_value}")
|
|
35
|
+
elif self.max_value is not None:
|
|
36
|
+
parts.append(f"<= {self.max_value}")
|
|
37
|
+
if self.soft_max is not None:
|
|
38
|
+
parts.append(f"soft max {self.soft_max}")
|
|
39
|
+
return f" ({', '.join(parts)})" if parts else ""
|
|
40
|
+
|
|
41
|
+
def hard_error_for(self, value):
|
|
42
|
+
if value is None:
|
|
43
|
+
return None
|
|
44
|
+
if self.min_value is not None and value < self.min_value:
|
|
45
|
+
return f"must be at least {self.min_value}"
|
|
46
|
+
if self.max_value is not None and value > self.max_value:
|
|
47
|
+
return f"must be at most {self.max_value}"
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def soft_error_for(self, value):
|
|
51
|
+
if value is None or self.soft_max is None or value <= self.soft_max:
|
|
52
|
+
return None
|
|
53
|
+
return f"is {value}, above the recommended safe limit of {self.soft_max} - this may take a long time"
|
|
54
|
+
|
|
55
|
+
def ask(self, default):
|
|
56
|
+
message = self.message + self.hint()
|
|
57
|
+
if self.kind == "secret":
|
|
58
|
+
return click.prompt(message, hide_input=True, confirmation_prompt=True)
|
|
59
|
+
if self.kind == "int":
|
|
60
|
+
return click.prompt(message, default=default, type=int)
|
|
61
|
+
if self.kind == "path":
|
|
62
|
+
if default is not None:
|
|
63
|
+
message = f"{message} [{_display_path(default)}]"
|
|
64
|
+
return click.prompt(
|
|
65
|
+
message, default=default, type=click.Path(dir_okay=False, path_type=Path), show_default=False
|
|
66
|
+
)
|
|
67
|
+
if self.kind == "select":
|
|
68
|
+
answer = questionary.select(message, choices=list(self.choices), default=default).ask()
|
|
69
|
+
if answer is None:
|
|
70
|
+
raise click.Abort()
|
|
71
|
+
return answer
|
|
72
|
+
return click.prompt(message, default=default, show_default=bool(default))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_options(fields, defaults=None):
|
|
76
|
+
defaults = defaults or {}
|
|
77
|
+
options = []
|
|
78
|
+
for name, field in fields.items():
|
|
79
|
+
kwargs = {"default": None, "help": field.message + field.hint()}
|
|
80
|
+
|
|
81
|
+
default_value = defaults.get(name)
|
|
82
|
+
if callable(default_value):
|
|
83
|
+
default_value = default_value()
|
|
84
|
+
if default_value not in (None, ""):
|
|
85
|
+
display_value = _display_path(default_value) if field.kind == "path" else default_value
|
|
86
|
+
kwargs["help"] = f"{field.message}{field.hint()}{DEFAULT_TEXT_MARKER}[default: {display_value}]"
|
|
87
|
+
|
|
88
|
+
if field.kind == "int":
|
|
89
|
+
kwargs["type"] = int
|
|
90
|
+
elif field.kind == "select":
|
|
91
|
+
kwargs["type"] = click.Choice(field.choices)
|
|
92
|
+
elif field.kind == "path":
|
|
93
|
+
kwargs["type"] = click.Path(dir_okay=False, path_type=Path)
|
|
94
|
+
options.append(click.Option([f"--{name.replace('_', '-')}"], **kwargs))
|
|
95
|
+
return options
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def is_interactive(values):
|
|
99
|
+
return all(value is None for value in values.values())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_fields(fields, values, defaults, interactive, allow_soft_constraints=False):
|
|
103
|
+
resolved = {}
|
|
104
|
+
soft_violations = []
|
|
105
|
+
for name, field in fields.items():
|
|
106
|
+
flag = f"--{name.replace('_', '-')}"
|
|
107
|
+
value = values.get(name)
|
|
108
|
+
|
|
109
|
+
if value is None and interactive:
|
|
110
|
+
default = defaults.get(name)
|
|
111
|
+
if callable(default):
|
|
112
|
+
default = default()
|
|
113
|
+
while True:
|
|
114
|
+
value = field.ask(default)
|
|
115
|
+
hard_error = field.hard_error_for(value)
|
|
116
|
+
if hard_error:
|
|
117
|
+
click.echo(f"Error: {flag} {hard_error}")
|
|
118
|
+
continue
|
|
119
|
+
break
|
|
120
|
+
soft_error = field.soft_error_for(value)
|
|
121
|
+
if soft_error:
|
|
122
|
+
soft_violations.append(f"{flag} {soft_error}")
|
|
123
|
+
elif value is None and field.required:
|
|
124
|
+
raise click.UsageError(f"{flag} is required")
|
|
125
|
+
elif value is None:
|
|
126
|
+
default = defaults.get(name)
|
|
127
|
+
value = default() if callable(default) else default
|
|
128
|
+
else:
|
|
129
|
+
hard_error = field.hard_error_for(value)
|
|
130
|
+
if hard_error:
|
|
131
|
+
raise click.UsageError(f"{flag} {hard_error}")
|
|
132
|
+
soft_error = field.soft_error_for(value)
|
|
133
|
+
if soft_error and not allow_soft_constraints:
|
|
134
|
+
raise click.UsageError(f"{flag} {soft_error} - pass --force-allow-soft-constraints to override")
|
|
135
|
+
|
|
136
|
+
if field.kind == "path" and value is not None:
|
|
137
|
+
value = Path(value).expanduser()
|
|
138
|
+
if field.required and not value:
|
|
139
|
+
raise click.UsageError(f"{flag} must not be empty")
|
|
140
|
+
resolved[name] = value
|
|
141
|
+
|
|
142
|
+
if soft_violations:
|
|
143
|
+
click.echo("These may take a long time:")
|
|
144
|
+
for violation in soft_violations:
|
|
145
|
+
click.echo(f" {violation}")
|
|
146
|
+
if not click.confirm("Continue anyway?"):
|
|
147
|
+
raise click.Abort()
|
|
148
|
+
|
|
149
|
+
return resolved
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def confirm_overwrite(output, overwrite_files):
|
|
153
|
+
if overwrite_files:
|
|
154
|
+
return
|
|
155
|
+
output = Path(output)
|
|
156
|
+
if (output.exists() or public_key_path(output).exists()) and not click.confirm(
|
|
157
|
+
f"{output} already exists. Overwrite?"
|
|
158
|
+
):
|
|
159
|
+
raise click.Abort()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def write_keypair_and_recap(seed_bytes, output, comment, recap):
|
|
163
|
+
private_key, public_key = keypair_from_seed(seed_bytes)
|
|
164
|
+
try:
|
|
165
|
+
priv_path, pub_path = write_keypair(private_key, public_key, output, comment=comment)
|
|
166
|
+
except OSError as e:
|
|
167
|
+
raise click.ClickException(f"couldn't write {output}: {e.strerror or e}") from e
|
|
168
|
+
except ValueError as e:
|
|
169
|
+
raise click.UsageError(str(e)) from e
|
|
170
|
+
|
|
171
|
+
click.echo(f"Wrote private key: {priv_path}")
|
|
172
|
+
click.echo(f"Wrote public key: {pub_path}")
|
|
173
|
+
|
|
174
|
+
click.echo()
|
|
175
|
+
click.echo("To recreate this exact key, remember your passphrase (keep it secret) plus:")
|
|
176
|
+
for name, value in (*recap, ("algorithm", "ed25519")):
|
|
177
|
+
click.echo(f" {name:<18} {value}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from detssh.backends.kdf.argon2d import Argon2D
|
|
2
|
+
from detssh.backends.kdf.argon2i import Argon2I
|
|
3
|
+
from detssh.backends.kdf.argon2id import Argon2Id
|
|
4
|
+
from detssh.backends.kdf.bcrypt_pbkdf import BcryptPbkdf
|
|
5
|
+
from detssh.backends.kdf.pbkdf2 import Pbkdf2
|
|
6
|
+
from detssh.backends.kdf.scrypt import Scrypt
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
BACKENDS = {
|
|
10
|
+
"argon2id": Argon2Id,
|
|
11
|
+
"argon2i": Argon2I,
|
|
12
|
+
"argon2d": Argon2D,
|
|
13
|
+
"scrypt": Scrypt,
|
|
14
|
+
"pbkdf2": Pbkdf2,
|
|
15
|
+
"bcrypt_pbkdf": BcryptPbkdf,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for _name, _backend in BACKENDS.items():
|
|
19
|
+
if _backend.name != _name:
|
|
20
|
+
raise TypeError(f"{_backend.__name__}.name must equal its registry key {_name!r}")
|
|
21
|
+
if _backend.salt_constraints is None:
|
|
22
|
+
raise TypeError(f"{_backend.__name__} must set salt_constraints (use NO_SALT_CONSTRAINTS if none apply)")
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from argon2.exceptions import Argon2Error
|
|
2
|
+
from argon2.low_level import hash_secret_raw
|
|
3
|
+
|
|
4
|
+
from detssh.backends.base import Field
|
|
5
|
+
from detssh.backends.kdf.base import KDFBackend, KDFError, SaltConstraints
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Argon2Base(KDFBackend):
|
|
9
|
+
salt_constraints = SaltConstraints(min_size=8)
|
|
10
|
+
type = None
|
|
11
|
+
|
|
12
|
+
fields = {
|
|
13
|
+
"iterations": Field("Argon2 iterations", kind="int", soft_max=100),
|
|
14
|
+
"memory": Field("Argon2 memory, in MiB", kind="int", soft_max=2048),
|
|
15
|
+
"parallelism": Field("Argon2 parallelism", kind="int", soft_max=16),
|
|
16
|
+
}
|
|
17
|
+
defaults = {
|
|
18
|
+
"iterations": 10,
|
|
19
|
+
"memory": 1024,
|
|
20
|
+
"parallelism": 4,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def run(cls, resolved, salt):
|
|
25
|
+
try:
|
|
26
|
+
return hash_secret_raw(
|
|
27
|
+
secret=resolved["seed"].encode("utf-8"),
|
|
28
|
+
salt=salt,
|
|
29
|
+
time_cost=resolved["iterations"],
|
|
30
|
+
memory_cost=resolved["memory"] * 1024,
|
|
31
|
+
parallelism=resolved["parallelism"],
|
|
32
|
+
hash_len=resolved["hash_len"],
|
|
33
|
+
type=cls.type,
|
|
34
|
+
)
|
|
35
|
+
except (Argon2Error, OverflowError, ValueError) as e:
|
|
36
|
+
raise KDFError(str(e)) from e
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def recap(cls, resolved):
|
|
40
|
+
return (
|
|
41
|
+
("iterations", resolved["iterations"]),
|
|
42
|
+
("memory", f"{resolved['memory']} MiB"),
|
|
43
|
+
("parallelism", resolved["parallelism"]),
|
|
44
|
+
)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class SaltConstraints:
|
|
2
|
+
def __init__(self, min_size=None, max_size=None, exact_size=None):
|
|
3
|
+
self.min_size = min_size
|
|
4
|
+
self.max_size = max_size
|
|
5
|
+
self.exact_size = exact_size
|
|
6
|
+
|
|
7
|
+
def error_for(self, size):
|
|
8
|
+
if self.exact_size is not None and size != self.exact_size:
|
|
9
|
+
return f"needs a salt of exactly {self.exact_size} bytes, got {size}"
|
|
10
|
+
if self.min_size is not None and size < self.min_size:
|
|
11
|
+
return f"needs a salt of at least {self.min_size} bytes, got {size}"
|
|
12
|
+
if self.max_size is not None and size > self.max_size:
|
|
13
|
+
return f"needs a salt of at most {self.max_size} bytes, got {size}"
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
NO_SALT_CONSTRAINTS = SaltConstraints()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KDFError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class KDFBackend:
|
|
25
|
+
name = None
|
|
26
|
+
salt_constraints = None
|
|
27
|
+
fields = {}
|
|
28
|
+
defaults = {}
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def run(cls, resolved, salt):
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def recap(cls, resolved):
|
|
36
|
+
return ()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import bcrypt
|
|
2
|
+
|
|
3
|
+
from detssh.backends.base import Field
|
|
4
|
+
from detssh.backends.kdf.base import KDFBackend, KDFError, SaltConstraints
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class BcryptPbkdf(KDFBackend):
|
|
8
|
+
name = "bcrypt_pbkdf"
|
|
9
|
+
salt_constraints = SaltConstraints(min_size=1)
|
|
10
|
+
|
|
11
|
+
fields = {
|
|
12
|
+
"rounds": Field("bcrypt_pbkdf rounds", kind="int", soft_max=2000),
|
|
13
|
+
}
|
|
14
|
+
defaults = {
|
|
15
|
+
"rounds": 16,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def run(cls, resolved, salt):
|
|
20
|
+
try:
|
|
21
|
+
return bcrypt.kdf(
|
|
22
|
+
password=resolved["seed"].encode("utf-8"),
|
|
23
|
+
salt=salt,
|
|
24
|
+
desired_key_bytes=resolved["hash_len"],
|
|
25
|
+
rounds=resolved["rounds"],
|
|
26
|
+
)
|
|
27
|
+
except (ValueError, OverflowError) as e:
|
|
28
|
+
raise KDFError(str(e)) from e
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def recap(cls, resolved):
|
|
32
|
+
return (("rounds", resolved["rounds"]),)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.base import Field
|
|
4
|
+
from detssh.backends.kdf.base import NO_SALT_CONSTRAINTS, KDFBackend, KDFError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Pbkdf2(KDFBackend):
|
|
8
|
+
name = "pbkdf2"
|
|
9
|
+
salt_constraints = NO_SALT_CONSTRAINTS
|
|
10
|
+
|
|
11
|
+
fields = {
|
|
12
|
+
"iterations": Field("PBKDF2 iterations", kind="int", soft_max=50_000_000),
|
|
13
|
+
}
|
|
14
|
+
defaults = {
|
|
15
|
+
"iterations": 600_000,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def run(cls, resolved, salt):
|
|
20
|
+
try:
|
|
21
|
+
return hashlib.pbkdf2_hmac(
|
|
22
|
+
"sha256",
|
|
23
|
+
resolved["seed"].encode("utf-8"),
|
|
24
|
+
salt,
|
|
25
|
+
resolved["iterations"],
|
|
26
|
+
dklen=resolved["hash_len"],
|
|
27
|
+
)
|
|
28
|
+
except (ValueError, OverflowError) as e:
|
|
29
|
+
raise KDFError(str(e)) from e
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def recap(cls, resolved):
|
|
33
|
+
return (
|
|
34
|
+
("iterations", resolved["iterations"]),
|
|
35
|
+
("prf", "sha256"),
|
|
36
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.base import Field
|
|
4
|
+
from detssh.backends.kdf.base import NO_SALT_CONSTRAINTS, KDFBackend, KDFError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Scrypt(KDFBackend):
|
|
8
|
+
name = "scrypt"
|
|
9
|
+
salt_constraints = NO_SALT_CONSTRAINTS
|
|
10
|
+
|
|
11
|
+
fields = {
|
|
12
|
+
"cost": Field("scrypt cost (N, power of two)", kind="int", soft_max=2**18),
|
|
13
|
+
"block_size": Field("scrypt block size (r)", kind="int", soft_max=16),
|
|
14
|
+
"parallelism": Field("scrypt parallelism (p)", kind="int", soft_max=8),
|
|
15
|
+
}
|
|
16
|
+
defaults = {
|
|
17
|
+
"cost": 2**14,
|
|
18
|
+
"block_size": 8,
|
|
19
|
+
"parallelism": 1,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def run(cls, resolved, salt):
|
|
24
|
+
cost, block_size, parallelism = resolved["cost"], resolved["block_size"], resolved["parallelism"]
|
|
25
|
+
maxmem = 128 * block_size * (cost + parallelism + 2) * 2
|
|
26
|
+
try:
|
|
27
|
+
return hashlib.scrypt(
|
|
28
|
+
resolved["seed"].encode("utf-8"),
|
|
29
|
+
salt=salt,
|
|
30
|
+
n=cost,
|
|
31
|
+
r=block_size,
|
|
32
|
+
p=parallelism,
|
|
33
|
+
maxmem=maxmem,
|
|
34
|
+
dklen=resolved["hash_len"],
|
|
35
|
+
)
|
|
36
|
+
except (ValueError, TypeError, OverflowError) as e:
|
|
37
|
+
raise KDFError(str(e)) from e
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def recap(cls, resolved):
|
|
41
|
+
return (
|
|
42
|
+
("cost (N)", resolved["cost"]),
|
|
43
|
+
("block-size (r)", resolved["block_size"]),
|
|
44
|
+
("parallelism (p)", resolved["parallelism"]),
|
|
45
|
+
)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from detssh.backends.salt.blake2b import Blake2b
|
|
2
|
+
from detssh.backends.salt.blake2s import Blake2s
|
|
3
|
+
from detssh.backends.salt.sha3_256 import Sha3256
|
|
4
|
+
from detssh.backends.salt.sha3_384 import Sha3384
|
|
5
|
+
from detssh.backends.salt.sha3_512 import Sha3512
|
|
6
|
+
from detssh.backends.salt.sha256 import Sha256
|
|
7
|
+
from detssh.backends.salt.sha384 import Sha384
|
|
8
|
+
from detssh.backends.salt.sha512 import Sha512
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ALGOS = {
|
|
12
|
+
"blake2b": Blake2b,
|
|
13
|
+
"blake2s": Blake2s,
|
|
14
|
+
"sha256": Sha256,
|
|
15
|
+
"sha384": Sha384,
|
|
16
|
+
"sha512": Sha512,
|
|
17
|
+
"sha3_256": Sha3256,
|
|
18
|
+
"sha3_384": Sha3384,
|
|
19
|
+
"sha3_512": Sha3512,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
for _name, _algo in ALGOS.items():
|
|
23
|
+
if _algo.name != _name:
|
|
24
|
+
raise TypeError(f"{_algo.__name__}.name must equal its registry key {_name!r}")
|
|
25
|
+
if _algo.digest_size is None and (_algo.min_digest_size is None or _algo.max_digest_size is None):
|
|
26
|
+
raise TypeError(f"{_algo.__name__} must set digest_size, or min/max_digest_size if variable")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class SaltError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SaltAlgo:
|
|
6
|
+
name = None
|
|
7
|
+
digest_size = None
|
|
8
|
+
min_digest_size = None
|
|
9
|
+
max_digest_size = None
|
|
10
|
+
fields = {}
|
|
11
|
+
defaults = {}
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def is_variable(cls):
|
|
15
|
+
return cls.digest_size is None
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def digest(cls, label, resolved):
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def recap(cls, resolved):
|
|
23
|
+
if cls.is_variable():
|
|
24
|
+
return (("salt-digest-size", resolved["salt_digest_size"]),)
|
|
25
|
+
return ()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.base import Field
|
|
4
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Blake2b(SaltAlgo):
|
|
8
|
+
name = "blake2b"
|
|
9
|
+
min_digest_size = 1
|
|
10
|
+
max_digest_size = 64
|
|
11
|
+
|
|
12
|
+
fields = {
|
|
13
|
+
"salt_digest_size": Field(
|
|
14
|
+
"Salt digest size, in bytes", kind="int", min_value=min_digest_size, max_value=max_digest_size
|
|
15
|
+
),
|
|
16
|
+
}
|
|
17
|
+
defaults = {
|
|
18
|
+
"salt_digest_size": 16,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def digest(cls, label, resolved):
|
|
23
|
+
try:
|
|
24
|
+
return hashlib.blake2b(label.encode("utf-8"), digest_size=resolved["salt_digest_size"]).digest()
|
|
25
|
+
except (ValueError, OverflowError) as e:
|
|
26
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.base import Field
|
|
4
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Blake2s(SaltAlgo):
|
|
8
|
+
name = "blake2s"
|
|
9
|
+
min_digest_size = 1
|
|
10
|
+
max_digest_size = 32
|
|
11
|
+
|
|
12
|
+
fields = {
|
|
13
|
+
"salt_digest_size": Field(
|
|
14
|
+
"Salt digest size, in bytes", kind="int", min_value=min_digest_size, max_value=max_digest_size
|
|
15
|
+
),
|
|
16
|
+
}
|
|
17
|
+
defaults = {
|
|
18
|
+
"salt_digest_size": 16,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def digest(cls, label, resolved):
|
|
23
|
+
try:
|
|
24
|
+
return hashlib.blake2s(label.encode("utf-8"), digest_size=resolved["salt_digest_size"]).digest()
|
|
25
|
+
except (ValueError, OverflowError) as e:
|
|
26
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sha256(SaltAlgo):
|
|
7
|
+
name = "sha256"
|
|
8
|
+
digest_size = 32
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def digest(cls, label, resolved):
|
|
12
|
+
try:
|
|
13
|
+
return hashlib.sha256(label.encode("utf-8")).digest()
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sha384(SaltAlgo):
|
|
7
|
+
name = "sha384"
|
|
8
|
+
digest_size = 48
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def digest(cls, label, resolved):
|
|
12
|
+
try:
|
|
13
|
+
return hashlib.sha384(label.encode("utf-8")).digest()
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sha3256(SaltAlgo):
|
|
7
|
+
name = "sha3_256"
|
|
8
|
+
digest_size = 32
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def digest(cls, label, resolved):
|
|
12
|
+
try:
|
|
13
|
+
return hashlib.sha3_256(label.encode("utf-8")).digest()
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sha3384(SaltAlgo):
|
|
7
|
+
name = "sha3_384"
|
|
8
|
+
digest_size = 48
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def digest(cls, label, resolved):
|
|
12
|
+
try:
|
|
13
|
+
return hashlib.sha3_384(label.encode("utf-8")).digest()
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sha3512(SaltAlgo):
|
|
7
|
+
name = "sha3_512"
|
|
8
|
+
digest_size = 64
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def digest(cls, label, resolved):
|
|
12
|
+
try:
|
|
13
|
+
return hashlib.sha3_512(label.encode("utf-8")).digest()
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise SaltError(str(e)) from e
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
|
|
3
|
+
from detssh.backends.salt.base import SaltAlgo, SaltError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Sha512(SaltAlgo):
|
|
7
|
+
name = "sha512"
|
|
8
|
+
digest_size = 64
|
|
9
|
+
|
|
10
|
+
@classmethod
|
|
11
|
+
def digest(cls, label, resolved):
|
|
12
|
+
try:
|
|
13
|
+
return hashlib.sha512(label.encode("utf-8")).digest()
|
|
14
|
+
except ValueError as e:
|
|
15
|
+
raise SaltError(str(e)) from e
|
detssh/cli.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import textwrap
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from click.formatting import measure_table
|
|
5
|
+
|
|
6
|
+
from detssh.backends import kdf, salt
|
|
7
|
+
from detssh.backends.base import (
|
|
8
|
+
DEFAULT_TEXT_MARKER,
|
|
9
|
+
Field,
|
|
10
|
+
build_options,
|
|
11
|
+
confirm_overwrite,
|
|
12
|
+
is_interactive,
|
|
13
|
+
resolve_fields,
|
|
14
|
+
write_keypair_and_recap,
|
|
15
|
+
)
|
|
16
|
+
from detssh.backends.kdf.base import KDFError
|
|
17
|
+
from detssh.backends.salt.base import SaltError
|
|
18
|
+
from detssh.keygen import COMMENT_FORBIDDEN_CHARS, ED25519_SEED_LENGTH, default_output_path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_KDF = "argon2id"
|
|
22
|
+
DEFAULT_SALT_ALGO = "blake2b"
|
|
23
|
+
HASH_LEN = ED25519_SEED_LENGTH
|
|
24
|
+
|
|
25
|
+
SELECTORS = {
|
|
26
|
+
"kdf": Field("KDF backend", kind="select", choices=tuple(kdf.BACKENDS)),
|
|
27
|
+
"salt_algo": Field("Salt hash algorithm", kind="select", choices=tuple(salt.ALGOS)),
|
|
28
|
+
}
|
|
29
|
+
COMMON = {
|
|
30
|
+
"seed": Field("Seed passphrase", kind="secret", required=True),
|
|
31
|
+
"label": Field("Label"),
|
|
32
|
+
"output": Field("Output path for the private key (ssh-keygen style, e.g. ~/.ssh/id_ed25519)", kind="path"),
|
|
33
|
+
"comment": Field("Comment for the public key"),
|
|
34
|
+
}
|
|
35
|
+
DEFAULTS = {
|
|
36
|
+
"kdf": DEFAULT_KDF,
|
|
37
|
+
"salt_algo": DEFAULT_SALT_ALGO,
|
|
38
|
+
"label": "",
|
|
39
|
+
"output": default_output_path,
|
|
40
|
+
"comment": "",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _peek(args, flag, default):
|
|
45
|
+
value = default
|
|
46
|
+
for i, arg in enumerate(args):
|
|
47
|
+
if arg == flag and i + 1 < len(args):
|
|
48
|
+
value = args[i + 1]
|
|
49
|
+
elif arg.startswith(flag + "="):
|
|
50
|
+
value = arg.split("=", 1)[1]
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fields_for(kdf_cls, salt_cls):
|
|
55
|
+
return {
|
|
56
|
+
"seed": COMMON["seed"],
|
|
57
|
+
"label": COMMON["label"],
|
|
58
|
+
**kdf_cls.fields,
|
|
59
|
+
**salt_cls.fields,
|
|
60
|
+
"output": COMMON["output"],
|
|
61
|
+
"comment": COMMON["comment"],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _help_note(kdf_cls, salt_cls):
|
|
66
|
+
return (
|
|
67
|
+
"\b\n"
|
|
68
|
+
f"Options below are for --kdf={kdf_cls.name} --salt-algo={salt_cls.name} "
|
|
69
|
+
"(defaults, unless you passed both already).\n"
|
|
70
|
+
f"--kdf choices: {', '.join(kdf.BACKENDS)}\n"
|
|
71
|
+
f"--salt-algo choices: {', '.join(salt.ALGOS)}\n"
|
|
72
|
+
"\b\n"
|
|
73
|
+
"Each combination has its own options. To see another one's:\n"
|
|
74
|
+
" detssh --kdf pbkdf2 --salt-algo sha256 --help"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class KDFSaltCommand(click.Command):
|
|
79
|
+
def parse_args(self, ctx, args):
|
|
80
|
+
kdf_name = _peek(args, "--kdf", DEFAULT_KDF)
|
|
81
|
+
salt_name = _peek(args, "--salt-algo", DEFAULT_SALT_ALGO)
|
|
82
|
+
|
|
83
|
+
kdf_cls = kdf.BACKENDS.get(kdf_name, kdf.BACKENDS[DEFAULT_KDF])
|
|
84
|
+
salt_cls = salt.ALGOS.get(salt_name, salt.ALGOS[DEFAULT_SALT_ALGO])
|
|
85
|
+
|
|
86
|
+
fields = {**SELECTORS, **_fields_for(kdf_cls, salt_cls)}
|
|
87
|
+
defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
|
|
88
|
+
self.params = build_options(fields, defaults) + [
|
|
89
|
+
click.Option(["--overwrite-files"], is_flag=True, help="Overwrite existing output files without asking."),
|
|
90
|
+
click.Option(
|
|
91
|
+
["--force-allow-soft-constraints"],
|
|
92
|
+
is_flag=True,
|
|
93
|
+
default=None,
|
|
94
|
+
help="Flag mode only: skip the 'this may take a long time' confirmation for oversized cost "
|
|
95
|
+
"knobs. Interactive mode always asks.",
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
self.epilog = _help_note(kdf_cls, salt_cls)
|
|
99
|
+
|
|
100
|
+
return super().parse_args(ctx, args)
|
|
101
|
+
|
|
102
|
+
def format_options(self, ctx, formatter):
|
|
103
|
+
opts = [record for param in self.get_params(ctx) if (record := param.get_help_record(ctx)) is not None]
|
|
104
|
+
if not opts:
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
first_col = min(measure_table(opts)[0], 30)
|
|
108
|
+
text_width = max(formatter.width - first_col - 4, 10)
|
|
109
|
+
|
|
110
|
+
rewrapped = []
|
|
111
|
+
for name, help_text in opts:
|
|
112
|
+
if DEFAULT_TEXT_MARKER in help_text:
|
|
113
|
+
description, default_line = help_text.split(DEFAULT_TEXT_MARKER, 1)
|
|
114
|
+
lines = textwrap.wrap(description, text_width) or [""]
|
|
115
|
+
lines.append(default_line)
|
|
116
|
+
help_text = "\b\n" + "\n".join(lines)
|
|
117
|
+
rewrapped.append((name, help_text))
|
|
118
|
+
|
|
119
|
+
with formatter.section("Options"):
|
|
120
|
+
formatter.write_dl(rewrapped)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@click.command(
|
|
124
|
+
cls=KDFSaltCommand, context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 120}
|
|
125
|
+
)
|
|
126
|
+
def main(overwrite_files, **values):
|
|
127
|
+
interactive = is_interactive(values)
|
|
128
|
+
force_allow_soft_constraints = bool(values.get("force_allow_soft_constraints"))
|
|
129
|
+
|
|
130
|
+
kdf_name = values["kdf"]
|
|
131
|
+
if kdf_name is None:
|
|
132
|
+
kdf_name = SELECTORS["kdf"].ask(DEFAULTS["kdf"]) if interactive else DEFAULTS["kdf"]
|
|
133
|
+
kdf_cls = kdf.BACKENDS[kdf_name]
|
|
134
|
+
|
|
135
|
+
salt_name = values["salt_algo"]
|
|
136
|
+
if salt_name is None:
|
|
137
|
+
salt_name = SELECTORS["salt_algo"].ask(DEFAULTS["salt_algo"]) if interactive else DEFAULTS["salt_algo"]
|
|
138
|
+
salt_cls = salt.ALGOS[salt_name]
|
|
139
|
+
|
|
140
|
+
fields = _fields_for(kdf_cls, salt_cls)
|
|
141
|
+
defaults = {**DEFAULTS, **kdf_cls.defaults, **salt_cls.defaults}
|
|
142
|
+
|
|
143
|
+
resolved = {
|
|
144
|
+
"kdf": kdf_name,
|
|
145
|
+
"salt_algo": salt_name,
|
|
146
|
+
"hash_len": HASH_LEN,
|
|
147
|
+
**resolve_fields(fields, values, defaults, interactive, force_allow_soft_constraints),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if any(char in resolved["comment"] for char in COMMENT_FORBIDDEN_CHARS):
|
|
151
|
+
raise click.UsageError("--comment must not contain newlines")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
salt_bytes = salt_cls.digest(resolved["label"], resolved)
|
|
155
|
+
except SaltError as e:
|
|
156
|
+
raise click.UsageError(f"{salt_name}: {e}") from e
|
|
157
|
+
salt_error = kdf_cls.salt_constraints.error_for(len(salt_bytes))
|
|
158
|
+
if salt_error:
|
|
159
|
+
raise click.UsageError(f"{kdf_name} {salt_error}")
|
|
160
|
+
|
|
161
|
+
confirm_overwrite(resolved["output"], overwrite_files)
|
|
162
|
+
|
|
163
|
+
click.echo("Deriving key...")
|
|
164
|
+
try:
|
|
165
|
+
seed_bytes = kdf_cls.run(resolved, salt_bytes)
|
|
166
|
+
except KDFError as e:
|
|
167
|
+
raise click.UsageError(f"{kdf_name}: {e}") from e
|
|
168
|
+
|
|
169
|
+
recap = (
|
|
170
|
+
("kdf", kdf_name),
|
|
171
|
+
("salt-algo", salt_name),
|
|
172
|
+
("label", repr(resolved["label"])),
|
|
173
|
+
("encoding", "utf-8"),
|
|
174
|
+
*salt_cls.recap(resolved),
|
|
175
|
+
*kdf_cls.recap(resolved),
|
|
176
|
+
("hash-len", resolved["hash_len"]),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
write_keypair_and_recap(seed_bytes, resolved["output"], resolved["comment"], recap=recap)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
detssh/keygen.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import stat
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from cryptography.hazmat.primitives import serialization
|
|
6
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
ED25519_SEED_LENGTH = 32
|
|
10
|
+
COMMENT_FORBIDDEN_CHARS = ("\n", "\r")
|
|
11
|
+
PRIVATE_KEY_MODE = stat.S_IRUSR | stat.S_IWUSR
|
|
12
|
+
SSH_DIR_MODE = stat.S_IRWXU
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ssh_dir():
|
|
16
|
+
return Path.home() / ".ssh"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def default_output_path():
|
|
20
|
+
return _ssh_dir() / "id_ed25519"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ed25519 is the only algorithm whose private key is literally its own seed, so it's the only one usable here.
|
|
24
|
+
def keypair_from_seed(seed):
|
|
25
|
+
if len(seed) != ED25519_SEED_LENGTH:
|
|
26
|
+
raise ValueError(f"Ed25519 seed must be {ED25519_SEED_LENGTH} bytes, got {len(seed)}")
|
|
27
|
+
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
|
28
|
+
return private_key, private_key.public_key()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def public_key_path(private_key_path):
|
|
32
|
+
private_key_path = Path(private_key_path)
|
|
33
|
+
return private_key_path.with_name(private_key_path.name + ".pub")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_keypair(private_key, public_key, output_path, comment=""):
|
|
37
|
+
if any(char in comment for char in COMMENT_FORBIDDEN_CHARS):
|
|
38
|
+
raise ValueError("comment must not contain newlines")
|
|
39
|
+
|
|
40
|
+
output_path = Path(output_path)
|
|
41
|
+
pub_path = public_key_path(output_path)
|
|
42
|
+
|
|
43
|
+
if output_path.parent == _ssh_dir():
|
|
44
|
+
output_path.parent.mkdir(mode=SSH_DIR_MODE, exist_ok=True)
|
|
45
|
+
|
|
46
|
+
private_bytes = private_key.private_bytes(
|
|
47
|
+
encoding=serialization.Encoding.PEM,
|
|
48
|
+
format=serialization.PrivateFormat.OpenSSH,
|
|
49
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
50
|
+
)
|
|
51
|
+
public_bytes = public_key.public_bytes(
|
|
52
|
+
encoding=serialization.Encoding.OpenSSH,
|
|
53
|
+
format=serialization.PublicFormat.OpenSSH,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, PRIVATE_KEY_MODE)
|
|
57
|
+
os.fchmod(fd, PRIVATE_KEY_MODE)
|
|
58
|
+
with os.fdopen(fd, "wb") as f:
|
|
59
|
+
f.write(private_bytes)
|
|
60
|
+
|
|
61
|
+
comment_suffix = f" {comment}" if comment else ""
|
|
62
|
+
pub_path.write_bytes(public_bytes + comment_suffix.encode("utf-8") + b"\n")
|
|
63
|
+
|
|
64
|
+
return output_path, pub_path
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: detssh
|
|
3
|
+
Version: 0.1.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
|
+
## Backends
|
|
46
|
+
|
|
47
|
+
- `--kdf`: `argon2id` (default), `argon2i`, `argon2d`, `scrypt`, `pbkdf2`, `bcrypt_pbkdf`
|
|
48
|
+
- `--salt-algo`: `blake2b` (default), `blake2s`, `sha256`, `sha384`, `sha512`, `sha3_256`, `sha3_384`, `sha3_512`
|
|
49
|
+
|
|
50
|
+
Each combination has its own options (cost knobs, salt digest size, etc.),
|
|
51
|
+
with their valid ranges shown in both `--help` and the interactive prompts.
|
|
52
|
+
See them with:
|
|
53
|
+
```
|
|
54
|
+
detssh --kdf <kdf> --salt-algo <salt-algo> --help
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
We recommend sticking with the defaults (`argon2id`, memory-hard with a high
|
|
58
|
+
cost) unless you have a specific reason not to - they make brute-forcing your
|
|
59
|
+
passphrase significantly more expensive than faster KDFs like `pbkdf2`.
|
|
60
|
+
|
|
61
|
+
Cost knobs (iterations, memory, cost, rounds, ...) also carry a soft safety
|
|
62
|
+
limit meant to catch typos before they cost you minutes of derivation time.
|
|
63
|
+
In interactive mode, going over one doesn't interrupt you - everything you
|
|
64
|
+
exceeded is summarized in one confirmation at the very end. In flag mode, it
|
|
65
|
+
fails immediately unless you pass `--force-allow-soft-constraints`.
|
|
66
|
+
|
|
67
|
+
## Determinism guarantee
|
|
68
|
+
|
|
69
|
+
Same explicit parameters → same key, across any release sharing the same
|
|
70
|
+
major version - only a major version bump may change key derivation. That
|
|
71
|
+
guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
|
|
72
|
+
`tests/golden/README.md`).
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
uv sync
|
|
78
|
+
uv run pytest
|
|
79
|
+
uv run ruff check .
|
|
80
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
detssh/__init__.py,sha256=jTiwAoFX3ZWkVL67_vHNbaAGQGanSWCLG-ocfs2Jy-I,379
|
|
2
|
+
detssh/cli.py,sha256=tD4ugI4knYm6LIUvfUMceWIkNQ7tU6926J8B7tE4b2g,6169
|
|
3
|
+
detssh/keygen.py,sha256=hSzcAWELziTBLq_f6m6zlQ3iYrro6dLOvw8tYMCW9ds,2094
|
|
4
|
+
detssh/backends/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
detssh/backends/base.py,sha256=AxcqHVxgpdKsmOYYUAdX6qIwZi8CXTG7FdsD29k_800,6655
|
|
6
|
+
detssh/backends/kdf/__init__.py,sha256=viL9hQdQ16khsB3q2JueUJJh7DBSuSd6K_JzjLmQDD0,783
|
|
7
|
+
detssh/backends/kdf/_argon2.py,sha256=VfWRuSSkSle2wgXdA0rGxUZtz9ERVdUn_dCbZcnuAT0,1425
|
|
8
|
+
detssh/backends/kdf/argon2d.py,sha256=rPeYgeCI0PTDLrNhxZKrwpxin4p2pZVUsQBP4dv9SBg,154
|
|
9
|
+
detssh/backends/kdf/argon2i.py,sha256=MOfmdxkDLG84Qlcp5JlD07dFV2MeLQiSZX0RAF9Snvo,154
|
|
10
|
+
detssh/backends/kdf/argon2id.py,sha256=s3W9oiU6aTtuk0Dqs5Qfducr49K0glP5zvNtcFYcFG0,157
|
|
11
|
+
detssh/backends/kdf/base.py,sha256=lb2zUu9ntjGMALciaRNuzCToQt9E-XIeYvUyZCa8atw,1016
|
|
12
|
+
detssh/backends/kdf/bcrypt_pbkdf.py,sha256=mcK378mH81YfFCLhZ68gVZAuwp0MPzcaZcmwbp1ix_U,864
|
|
13
|
+
detssh/backends/kdf/pbkdf2.py,sha256=TjrdkUMkzfOiiSDX03uaDRyy4H1_7VkylzVR_3xwCKc,934
|
|
14
|
+
detssh/backends/kdf/scrypt.py,sha256=OhFOVwtyTdTEL-r9VNKoGV57cAYN7HtAcNU1PTwE19c,1444
|
|
15
|
+
detssh/backends/salt/__init__.py,sha256=Sh29uTmSxgnHL2uC6RoGbZH1MM2MWGM0esl7gbKlCq4,949
|
|
16
|
+
detssh/backends/salt/base.py,sha256=BB39jTzxxglXknHIGp9lpfJhVU815pkwKwrUE7FqbnA,524
|
|
17
|
+
detssh/backends/salt/blake2b.py,sha256=4ov2CG3N2k_IrBc5_LiNdPgIQABGy8Kg_6cXI6KLzsg,710
|
|
18
|
+
detssh/backends/salt/blake2s.py,sha256=UR5uPruY6iVgbPuINnjUuQF1CfLDgMtVb-CEbY1ndx4,710
|
|
19
|
+
detssh/backends/salt/sha256.py,sha256=C0dhhLz5V-1PEHZkK-h77RBQUAsCnaOzQ4oPWHOWzGY,351
|
|
20
|
+
detssh/backends/salt/sha384.py,sha256=m5POotRzVCX66lwPq-gP68PTe3K4ZKP5Nh-CR-PCXxc,351
|
|
21
|
+
detssh/backends/salt/sha3_256.py,sha256=zCnfHxsQTsgxfXDlKxPjLcPI8JWFBpAJf2k0rMYkRIU,356
|
|
22
|
+
detssh/backends/salt/sha3_384.py,sha256=cAnU6PQDPxJXF22ryieQTmqDEdGvJ92TMqNpWmkEPQs,356
|
|
23
|
+
detssh/backends/salt/sha3_512.py,sha256=HPyZceWPXOWNXrC1Vz0j889PhPMRlpAZKlHXYooyJHs,356
|
|
24
|
+
detssh/backends/salt/sha512.py,sha256=_xxN62l8CtSkzeEi2ogjKHe4Y1qSMy1EeeQ9ueutndw,351
|
|
25
|
+
detssh-0.1.0.dist-info/METADATA,sha256=7Egt_kFXAU4KNv9OwdgXKU5bSb-0Vo6hqFyxgGD8cpQ,3086
|
|
26
|
+
detssh-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
27
|
+
detssh-0.1.0.dist-info/entry_points.txt,sha256=3B9mgrDZWnKGJYzE3QAWCk5BqZNVUHWH4RrSbuIEZEE,43
|
|
28
|
+
detssh-0.1.0.dist-info/licenses/LICENSE,sha256=FzSzVjj4SYgBjf1YNOfHhWqAJf14w_bcpbpvRMAetfQ,1070
|
|
29
|
+
detssh-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Işık Kaplan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|