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.
- detssh-0.1.0/.github/workflows/publish.yml +47 -0
- detssh-0.1.0/.github/workflows/tests.yml +36 -0
- detssh-0.1.0/.gitignore +10 -0
- detssh-0.1.0/LICENSE +21 -0
- detssh-0.1.0/PKG-INFO +80 -0
- detssh-0.1.0/README.md +59 -0
- detssh-0.1.0/detssh/__init__.py +14 -0
- detssh-0.1.0/detssh/backends/__init__.py +0 -0
- detssh-0.1.0/detssh/backends/base.py +177 -0
- detssh-0.1.0/detssh/backends/kdf/__init__.py +22 -0
- detssh-0.1.0/detssh/backends/kdf/_argon2.py +44 -0
- detssh-0.1.0/detssh/backends/kdf/argon2d.py +8 -0
- detssh-0.1.0/detssh/backends/kdf/argon2i.py +8 -0
- detssh-0.1.0/detssh/backends/kdf/argon2id.py +8 -0
- detssh-0.1.0/detssh/backends/kdf/base.py +36 -0
- detssh-0.1.0/detssh/backends/kdf/bcrypt_pbkdf.py +32 -0
- detssh-0.1.0/detssh/backends/kdf/pbkdf2.py +36 -0
- detssh-0.1.0/detssh/backends/kdf/scrypt.py +45 -0
- detssh-0.1.0/detssh/backends/salt/__init__.py +26 -0
- detssh-0.1.0/detssh/backends/salt/base.py +25 -0
- detssh-0.1.0/detssh/backends/salt/blake2b.py +26 -0
- detssh-0.1.0/detssh/backends/salt/blake2s.py +26 -0
- detssh-0.1.0/detssh/backends/salt/sha256.py +15 -0
- detssh-0.1.0/detssh/backends/salt/sha384.py +15 -0
- detssh-0.1.0/detssh/backends/salt/sha3_256.py +15 -0
- detssh-0.1.0/detssh/backends/salt/sha3_384.py +15 -0
- detssh-0.1.0/detssh/backends/salt/sha3_512.py +15 -0
- detssh-0.1.0/detssh/backends/salt/sha512.py +15 -0
- detssh-0.1.0/detssh/cli.py +183 -0
- detssh-0.1.0/detssh/keygen.py +64 -0
- detssh-0.1.0/pyproject.toml +60 -0
- detssh-0.1.0/tests/golden/README.md +54 -0
- detssh-0.1.0/tests/golden/variations.csv +97 -0
- detssh-0.1.0/tests/golden/vectors.csv +49 -0
- detssh-0.1.0/tests/test_cli.py +403 -0
- detssh-0.1.0/tests/test_cli_combinations.py +91 -0
- detssh-0.1.0/tests/test_determinism.py +140 -0
- detssh-0.1.0/tests/test_golden_vectors.py +133 -0
- detssh-0.1.0/tests/test_properties.py +614 -0
- 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 .
|
detssh-0.1.0/.gitignore
ADDED
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,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
|
+
)
|