detssh 0.1.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.
Files changed (40) hide show
  1. detssh-0.1.0/.github/workflows/publish.yml +47 -0
  2. detssh-0.1.0/.github/workflows/tests.yml +36 -0
  3. detssh-0.1.0/.gitignore +10 -0
  4. detssh-0.1.0/LICENSE +21 -0
  5. detssh-0.1.0/PKG-INFO +80 -0
  6. detssh-0.1.0/README.md +59 -0
  7. detssh-0.1.0/detssh/__init__.py +14 -0
  8. detssh-0.1.0/detssh/backends/__init__.py +0 -0
  9. detssh-0.1.0/detssh/backends/base.py +177 -0
  10. detssh-0.1.0/detssh/backends/kdf/__init__.py +22 -0
  11. detssh-0.1.0/detssh/backends/kdf/_argon2.py +44 -0
  12. detssh-0.1.0/detssh/backends/kdf/argon2d.py +8 -0
  13. detssh-0.1.0/detssh/backends/kdf/argon2i.py +8 -0
  14. detssh-0.1.0/detssh/backends/kdf/argon2id.py +8 -0
  15. detssh-0.1.0/detssh/backends/kdf/base.py +36 -0
  16. detssh-0.1.0/detssh/backends/kdf/bcrypt_pbkdf.py +32 -0
  17. detssh-0.1.0/detssh/backends/kdf/pbkdf2.py +36 -0
  18. detssh-0.1.0/detssh/backends/kdf/scrypt.py +45 -0
  19. detssh-0.1.0/detssh/backends/salt/__init__.py +26 -0
  20. detssh-0.1.0/detssh/backends/salt/base.py +25 -0
  21. detssh-0.1.0/detssh/backends/salt/blake2b.py +26 -0
  22. detssh-0.1.0/detssh/backends/salt/blake2s.py +26 -0
  23. detssh-0.1.0/detssh/backends/salt/sha256.py +15 -0
  24. detssh-0.1.0/detssh/backends/salt/sha384.py +15 -0
  25. detssh-0.1.0/detssh/backends/salt/sha3_256.py +15 -0
  26. detssh-0.1.0/detssh/backends/salt/sha3_384.py +15 -0
  27. detssh-0.1.0/detssh/backends/salt/sha3_512.py +15 -0
  28. detssh-0.1.0/detssh/backends/salt/sha512.py +15 -0
  29. detssh-0.1.0/detssh/cli.py +183 -0
  30. detssh-0.1.0/detssh/keygen.py +64 -0
  31. detssh-0.1.0/pyproject.toml +60 -0
  32. detssh-0.1.0/tests/golden/README.md +54 -0
  33. detssh-0.1.0/tests/golden/variations.csv +97 -0
  34. detssh-0.1.0/tests/golden/vectors.csv +49 -0
  35. detssh-0.1.0/tests/test_cli.py +403 -0
  36. detssh-0.1.0/tests/test_cli_combinations.py +91 -0
  37. detssh-0.1.0/tests/test_determinism.py +140 -0
  38. detssh-0.1.0/tests/test_golden_vectors.py +133 -0
  39. detssh-0.1.0/tests/test_properties.py +614 -0
  40. detssh-0.1.0/uv.lock +519 -0
@@ -0,0 +1,47 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ id-token: write
13
+
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: '3.12'
21
+
22
+ - name: Install build tools
23
+ run: |
24
+ python -m pip install --upgrade pip build
25
+
26
+ - name: Check if version changed
27
+ id: check_version
28
+ run: |
29
+ LOCAL_VERSION=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
30
+ PUBLISHED_VERSION=$(curl -s -f https://pypi.org/pypi/detssh/json | python -c "import json, sys; print(json.load(sys.stdin).get('info', {}).get('version', ''))" || echo "")
31
+ echo "Local version: $LOCAL_VERSION, published version: $PUBLISHED_VERSION"
32
+ if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then
33
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
34
+ else
35
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
36
+ fi
37
+
38
+ - name: Build package
39
+ if: steps.check_version.outputs.should_publish == 'true'
40
+ run: |
41
+ python -m build
42
+
43
+ - name: Publish to PyPI
44
+ if: steps.check_version.outputs.should_publish == 'true'
45
+ uses: pypa/gh-action-pypi-publish@release/v1
46
+ with:
47
+ packages-dir: dist
@@ -0,0 +1,36 @@
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
33
+ run: uv run pytest -q
34
+
35
+ - name: Run ruff
36
+ run: uv run ruff check .
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .hypothesis/
7
+ .idea/
8
+ *.egg-info/
9
+ dist/
10
+ build/
detssh-0.1.0/LICENSE ADDED
@@ -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.
detssh-0.1.0/PKG-INFO ADDED
@@ -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
+ ```
detssh-0.1.0/README.md ADDED
@@ -0,0 +1,59 @@
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
+ ## Backends
25
+
26
+ - `--kdf`: `argon2id` (default), `argon2i`, `argon2d`, `scrypt`, `pbkdf2`, `bcrypt_pbkdf`
27
+ - `--salt-algo`: `blake2b` (default), `blake2s`, `sha256`, `sha384`, `sha512`, `sha3_256`, `sha3_384`, `sha3_512`
28
+
29
+ Each combination has its own options (cost knobs, salt digest size, etc.),
30
+ with their valid ranges shown in both `--help` and the interactive prompts.
31
+ See them with:
32
+ ```
33
+ detssh --kdf <kdf> --salt-algo <salt-algo> --help
34
+ ```
35
+
36
+ We recommend sticking with the defaults (`argon2id`, memory-hard with a high
37
+ cost) unless you have a specific reason not to - they make brute-forcing your
38
+ passphrase significantly more expensive than faster KDFs like `pbkdf2`.
39
+
40
+ Cost knobs (iterations, memory, cost, rounds, ...) also carry a soft safety
41
+ limit meant to catch typos before they cost you minutes of derivation time.
42
+ In interactive mode, going over one doesn't interrupt you - everything you
43
+ exceeded is summarized in one confirmation at the very end. In flag mode, it
44
+ fails immediately unless you pass `--force-allow-soft-constraints`.
45
+
46
+ ## Determinism guarantee
47
+
48
+ Same explicit parameters → same key, across any release sharing the same
49
+ major version - only a major version bump may change key derivation. That
50
+ guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
51
+ `tests/golden/README.md`).
52
+
53
+ ## Development
54
+
55
+ ```
56
+ uv sync
57
+ uv run pytest
58
+ uv run ruff check .
59
+ ```
@@ -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
@@ -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,8 @@
1
+ from argon2.low_level import Type
2
+
3
+ from detssh.backends.kdf._argon2 import Argon2Base
4
+
5
+
6
+ class Argon2D(Argon2Base):
7
+ name = "argon2d"
8
+ type = Type.D
@@ -0,0 +1,8 @@
1
+ from argon2.low_level import Type
2
+
3
+ from detssh.backends.kdf._argon2 import Argon2Base
4
+
5
+
6
+ class Argon2I(Argon2Base):
7
+ name = "argon2i"
8
+ type = Type.I
@@ -0,0 +1,8 @@
1
+ from argon2.low_level import Type
2
+
3
+ from detssh.backends.kdf._argon2 import Argon2Base
4
+
5
+
6
+ class Argon2Id(Argon2Base):
7
+ name = "argon2id"
8
+ type = Type.ID
@@ -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
+ )