detssh 0.7.0__tar.gz → 0.8.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 (68) hide show
  1. {detssh-0.7.0 → detssh-0.8.0}/CHANGELOG.md +11 -0
  2. detssh-0.8.0/PKG-INFO +40 -0
  3. detssh-0.8.0/README.md +18 -0
  4. {detssh-0.7.0 → detssh-0.8.0}/detssh/__init__.py +1 -1
  5. detssh-0.8.0/detssh/backends/base.py +122 -0
  6. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/_argon2.py +8 -12
  7. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/base.py +4 -2
  8. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/bcrypt_pbkdf.py +6 -8
  9. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/pbkdf2.py +7 -8
  10. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/scrypt.py +9 -12
  11. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/base.py +4 -2
  12. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/blake2b.py +13 -12
  13. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/blake2s.py +13 -12
  14. detssh-0.8.0/detssh/cli.py +285 -0
  15. detssh-0.8.0/docs/README.md +10 -0
  16. detssh-0.8.0/docs/authorize.md +19 -0
  17. detssh-0.8.0/docs/backends.md +37 -0
  18. detssh-0.8.0/docs/determinism.md +6 -0
  19. detssh-0.8.0/docs/development.md +36 -0
  20. detssh-0.8.0/docs/labels.md +33 -0
  21. detssh-0.8.0/docs/reference.md +34 -0
  22. detssh-0.8.0/docs/ssh.md +36 -0
  23. detssh-0.8.0/docs/usage.md +35 -0
  24. {detssh-0.7.0 → detssh-0.8.0}/pyproject.toml +2 -1
  25. {detssh-0.7.0 → detssh-0.8.0}/tests/golden/README.md +7 -0
  26. detssh-0.8.0/tests/golden/defaults.json +47 -0
  27. detssh-0.8.0/tests/mutmut_known_equivalents.txt +74 -0
  28. {detssh-0.7.0 → detssh-0.8.0}/tests/test_cli.py +141 -14
  29. detssh-0.8.0/tests/test_golden_defaults.py +47 -0
  30. {detssh-0.7.0 → detssh-0.8.0}/tests/test_properties.py +29 -467
  31. {detssh-0.7.0 → detssh-0.8.0}/uv.lock +19 -2
  32. detssh-0.7.0/PKG-INFO +0 -174
  33. detssh-0.7.0/README.md +0 -153
  34. detssh-0.7.0/detssh/backends/base.py +0 -252
  35. detssh-0.7.0/detssh/cli.py +0 -359
  36. detssh-0.7.0/tests/mutmut_known_equivalents.txt +0 -130
  37. {detssh-0.7.0 → detssh-0.8.0}/.github/workflows/publish.yml +0 -0
  38. {detssh-0.7.0 → detssh-0.8.0}/.github/workflows/tests.yml +0 -0
  39. {detssh-0.7.0 → detssh-0.8.0}/.gitignore +0 -0
  40. {detssh-0.7.0 → detssh-0.8.0}/LICENSE +0 -0
  41. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/__init__.py +0 -0
  42. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/__init__.py +0 -0
  43. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/argon2d.py +0 -0
  44. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/argon2i.py +0 -0
  45. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/kdf/argon2id.py +0 -0
  46. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/__init__.py +0 -0
  47. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/sha256.py +0 -0
  48. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/sha384.py +0 -0
  49. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/sha3_256.py +0 -0
  50. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/sha3_384.py +0 -0
  51. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/sha3_512.py +0 -0
  52. {detssh-0.7.0 → detssh-0.8.0}/detssh/backends/salt/sha512.py +0 -0
  53. {detssh-0.7.0 → detssh-0.8.0}/detssh/keygen.py +0 -0
  54. {detssh-0.7.0 → detssh-0.8.0}/detssh/registry.py +0 -0
  55. {detssh-0.7.0 → detssh-0.8.0}/detssh/ssh_cli.py +0 -0
  56. {detssh-0.7.0 → detssh-0.8.0}/detssh/ssh_config.py +0 -0
  57. {detssh-0.7.0 → detssh-0.8.0}/tests/golden/help_default.txt +0 -0
  58. {detssh-0.7.0 → detssh-0.8.0}/tests/golden/help_pbkdf2_sha256.txt +0 -0
  59. {detssh-0.7.0 → detssh-0.8.0}/tests/golden/variations.csv +0 -0
  60. {detssh-0.7.0 → detssh-0.8.0}/tests/golden/vectors.csv +0 -0
  61. {detssh-0.7.0 → detssh-0.8.0}/tests/test_authorize.py +0 -0
  62. {detssh-0.7.0 → detssh-0.8.0}/tests/test_cli_combinations.py +0 -0
  63. {detssh-0.7.0 → detssh-0.8.0}/tests/test_determinism.py +0 -0
  64. {detssh-0.7.0 → detssh-0.8.0}/tests/test_golden_vectors.py +0 -0
  65. {detssh-0.7.0 → detssh-0.8.0}/tests/test_help_text.py +0 -0
  66. {detssh-0.7.0 → detssh-0.8.0}/tests/test_registry.py +0 -0
  67. {detssh-0.7.0 → detssh-0.8.0}/tests/test_ssh_cli.py +0 -0
  68. {detssh-0.7.0 → detssh-0.8.0}/tests/test_ssh_config.py +0 -0
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.8.0] - 2026-10-25
11
+
12
+ ### Changed
13
+
14
+ - Options, questions and their checks now come from one declaration per
15
+ setting, built on `isik[cli]` (a new dependency). `--help`, every flag,
16
+ every question and every derived key are unchanged.
17
+ - In interactive mode, `--overwrite-files` with "Only add the public key" is
18
+ now refused, as it already was with `--authorize`. Before, it was quietly
19
+ ignored.
20
+
10
21
  ## [0.7.0] - 2026-10-05
11
22
 
12
23
  ### Added
detssh-0.8.0/PKG-INFO ADDED
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: detssh
3
+ Version: 0.8.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: isik[cli]>=0.34.0
20
+ Requires-Dist: questionary>=2.1.1
21
+ Description-Content-Type: text/markdown
22
+
23
+ # detssh
24
+
25
+ Deterministic Ed25519 SSH keys from a passphrase: same passphrase, same key,
26
+ on any machine. Nothing to back up.
27
+
28
+ ```
29
+ uv tool install detssh
30
+ detssh
31
+ ```
32
+
33
+ `detssh` asks for everything it needs.
34
+
35
+ I name labels `<owner>:<site>:<machine>`, e.g. `isik:home:laptop` or
36
+ `client:cloud:web1`
37
+ ([more](https://github.com/isik-kaplan/detssh/blob/master/docs/labels.md)).
38
+
39
+ See the [docs](https://github.com/isik-kaplan/detssh/tree/master/docs) for
40
+ flags and everything else.
detssh-0.8.0/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # detssh
2
+
3
+ Deterministic Ed25519 SSH keys from a passphrase: same passphrase, same key,
4
+ on any machine. Nothing to back up.
5
+
6
+ ```
7
+ uv tool install detssh
8
+ detssh
9
+ ```
10
+
11
+ `detssh` asks for everything it needs.
12
+
13
+ I name labels `<owner>:<site>:<machine>`, e.g. `isik:home:laptop` or
14
+ `client:cloud:web1`
15
+ ([more](https://github.com/isik-kaplan/detssh/blob/master/docs/labels.md)).
16
+
17
+ See the [docs](https://github.com/isik-kaplan/detssh/tree/master/docs) for
18
+ flags and everything else.
@@ -3,7 +3,7 @@ from detssh.backends.salt import ALGOS as SALT_ALGOS
3
3
  from detssh.keygen import default_output_path, keypair_from_seed, public_key_path, write_keypair
4
4
 
5
5
 
6
- __version__ = "0.7.0"
6
+ __version__ = "0.8.0"
7
7
  __all__ = [
8
8
  "KDF_BACKENDS",
9
9
  "SALT_ALGOS",
@@ -0,0 +1,122 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+ from isik.common.settings import Settings
5
+
6
+ from detssh.keygen import (
7
+ authorize_public_key,
8
+ keypair_from_seed,
9
+ public_key_line,
10
+ public_key_path,
11
+ ssh_dir,
12
+ write_keypair,
13
+ )
14
+
15
+
16
+ def _display_path(path):
17
+ try:
18
+ return f"~/{Path(path).relative_to(Path.home())}"
19
+ except (TypeError, ValueError):
20
+ return str(path)
21
+
22
+
23
+ class NoSettings(Settings):
24
+ """The settings of a backend that has none to give."""
25
+
26
+
27
+ def note_if_exists(default):
28
+ """Before asking for an output path: say so when the offered default would overwrite a key."""
29
+ if default is not None and (Path(default).exists() or public_key_path(default).exists()):
30
+ click.echo(f"Note: {_display_path(default)} already exists - enter a different path to avoid this.")
31
+
32
+
33
+ def confirm_overwrite(output, overwrite_files):
34
+ if overwrite_files:
35
+ return
36
+ output = Path(output)
37
+ if (output.exists() or public_key_path(output).exists()) and not click.confirm(
38
+ f"{output} already exists. Overwrite?"
39
+ ):
40
+ raise click.Abort()
41
+
42
+
43
+ def confirm_create_parent_dirs(output, create_parent_dirs, interactive):
44
+ """Decide whether to create the output path's missing parent directories.
45
+
46
+ Returns the (possibly updated) flag for write_keypair_and_recap to act on. Flag
47
+ mode requires --create-parent-dirs up front and fails fast otherwise, so a typo
48
+ surfaces before key derivation rather than as a raw OSError after it. Interactive
49
+ mode prompts for the same decision instead, defaulting to yes under ~/.ssh and to
50
+ no (with a warning) elsewhere - matching what --create-parent-dirs would have done.
51
+ """
52
+ parent = Path(output).parent
53
+ if parent.exists() or parent == ssh_dir():
54
+ return create_parent_dirs
55
+
56
+ outside = not parent.is_relative_to(ssh_dir())
57
+
58
+ if not interactive:
59
+ if not create_parent_dirs:
60
+ raise click.UsageError(f"{_display_path(parent)} doesn't exist. Pass --create-parent-dirs to create it.")
61
+ return True
62
+
63
+ if not create_parent_dirs and not outside:
64
+ question, default = f"{_display_path(parent)} doesn't exist. Create it?", True
65
+ elif outside:
66
+ click.echo(f"Warning: {_display_path(parent)} is outside ~/.ssh.")
67
+ question, default = "Create it anyway?", False
68
+ else:
69
+ return True
70
+
71
+ if not click.confirm(question, default=default):
72
+ raise click.Abort()
73
+ return True
74
+
75
+
76
+ def write_keypair_and_recap(seed_bytes, output, comment, recap, key_passphrase="", create_parent_dirs=False):
77
+ private_key, public_key = keypair_from_seed(seed_bytes)
78
+ try:
79
+ priv_path, pub_path = write_keypair(
80
+ private_key,
81
+ public_key,
82
+ output,
83
+ comment=comment,
84
+ key_passphrase=key_passphrase,
85
+ create_parent_dirs=create_parent_dirs,
86
+ )
87
+ except OSError as e:
88
+ raise click.ClickException(f"couldn't write {output}: {e.strerror or e}") from e
89
+ except ValueError as e:
90
+ raise click.UsageError(str(e)) from e
91
+
92
+ click.echo(f"Wrote private key: {priv_path}{' (encrypted)' if key_passphrase else ''}")
93
+ click.echo(f"Wrote public key: {pub_path}")
94
+
95
+ echo_recap(recap)
96
+
97
+ return priv_path, pub_path
98
+
99
+
100
+ def authorize_and_recap(seed_bytes, authorized_keys, comment, recap, create_parent_dirs=False):
101
+ _, public_key = keypair_from_seed(seed_bytes)
102
+ try:
103
+ added = authorize_public_key(
104
+ public_key, authorized_keys, comment=comment, create_parent_dirs=create_parent_dirs
105
+ )
106
+ except OSError as e:
107
+ raise click.ClickException(f"couldn't write {authorized_keys}: {e.strerror or e}") from e
108
+
109
+ if added:
110
+ click.echo(f"Added public key to {authorized_keys}:")
111
+ else:
112
+ click.echo(f"Public key already in {authorized_keys}, left it unchanged:")
113
+ click.echo(f" {public_key_line(public_key, comment).decode('utf-8').rstrip()}")
114
+
115
+ echo_recap(recap)
116
+
117
+
118
+ def echo_recap(recap):
119
+ click.echo()
120
+ click.echo("To recreate this exact key, remember your passphrase (keep it secret) plus:")
121
+ for name, value in (*recap, ("algorithm", "ed25519")):
122
+ click.echo(f" {name:<18} {value}")
@@ -1,24 +1,20 @@
1
1
  from argon2.exceptions import Argon2Error
2
2
  from argon2.low_level import hash_secret_raw
3
+ from isik.common.settings import Setting, Settings
3
4
 
4
- from detssh.backends.base import Field
5
5
  from detssh.backends.kdf.base import KDFBackend, KDFError, SaltConstraints
6
6
 
7
7
 
8
+ class Argon2Settings(Settings):
9
+ iterations = Setting(int, "Argon2 iterations", default=10, soft_max=100)
10
+ memory = Setting(int, "Argon2 memory, in MiB", default=1024, soft_max=2048)
11
+ parallelism = Setting(int, "Argon2 parallelism", default=4, soft_max=16)
12
+
13
+
8
14
  class Argon2Base(KDFBackend):
9
15
  salt_constraints = SaltConstraints(min_size=8)
10
16
  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
- }
17
+ settings = Argon2Settings
22
18
 
23
19
  @classmethod
24
20
  def run(cls, resolved, salt):
@@ -1,3 +1,6 @@
1
+ from detssh.backends.base import NoSettings
2
+
3
+
1
4
  class SaltConstraints:
2
5
  def __init__(self, min_size=None, max_size=None, exact_size=None):
3
6
  self.min_size = min_size
@@ -24,8 +27,7 @@ class KDFError(Exception):
24
27
  class KDFBackend:
25
28
  name = None
26
29
  salt_constraints = None
27
- fields = {}
28
- defaults = {}
30
+ settings = NoSettings
29
31
 
30
32
  @classmethod
31
33
  def run(cls, resolved, salt):
@@ -1,19 +1,17 @@
1
1
  import bcrypt
2
+ from isik.common.settings import Setting, Settings
2
3
 
3
- from detssh.backends.base import Field
4
4
  from detssh.backends.kdf.base import KDFBackend, KDFError, SaltConstraints
5
5
 
6
6
 
7
+ class BcryptPbkdfSettings(Settings):
8
+ rounds = Setting(int, "bcrypt_pbkdf rounds", default=16, soft_max=2000)
9
+
10
+
7
11
  class BcryptPbkdf(KDFBackend):
8
12
  name = "bcrypt_pbkdf"
9
13
  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
- }
14
+ settings = BcryptPbkdfSettings
17
15
 
18
16
  @classmethod
19
17
  def run(cls, resolved, salt):
@@ -1,19 +1,18 @@
1
1
  import hashlib
2
2
 
3
- from detssh.backends.base import Field
3
+ from isik.common.settings import Setting, Settings
4
+
4
5
  from detssh.backends.kdf.base import NO_SALT_CONSTRAINTS, KDFBackend, KDFError
5
6
 
6
7
 
8
+ class Pbkdf2Settings(Settings):
9
+ iterations = Setting(int, "PBKDF2 iterations", default=600_000, soft_max=50_000_000)
10
+
11
+
7
12
  class Pbkdf2(KDFBackend):
8
13
  name = "pbkdf2"
9
14
  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
- }
15
+ settings = Pbkdf2Settings
17
16
 
18
17
  @classmethod
19
18
  def run(cls, resolved, salt):
@@ -1,23 +1,20 @@
1
1
  import hashlib
2
2
 
3
- from detssh.backends.base import Field
3
+ from isik.common.settings import Setting, Settings
4
+
4
5
  from detssh.backends.kdf.base import NO_SALT_CONSTRAINTS, KDFBackend, KDFError
5
6
 
6
7
 
8
+ class ScryptSettings(Settings):
9
+ cost = Setting(int, "scrypt cost (N, power of two)", default=2**14, soft_max=2**18)
10
+ block_size = Setting(int, "scrypt block size (r)", default=8, soft_max=16)
11
+ parallelism = Setting(int, "scrypt parallelism (p)", default=1, soft_max=8)
12
+
13
+
7
14
  class Scrypt(KDFBackend):
8
15
  name = "scrypt"
9
16
  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
- }
17
+ settings = ScryptSettings
21
18
 
22
19
  @classmethod
23
20
  def run(cls, resolved, salt):
@@ -1,3 +1,6 @@
1
+ from detssh.backends.base import NoSettings
2
+
3
+
1
4
  class SaltError(Exception):
2
5
  pass
3
6
 
@@ -7,8 +10,7 @@ class SaltAlgo:
7
10
  digest_size = None
8
11
  min_digest_size = None
9
12
  max_digest_size = None
10
- fields = {}
11
- defaults = {}
13
+ settings = NoSettings
12
14
 
13
15
  @classmethod
14
16
  def is_variable(cls):
@@ -1,22 +1,23 @@
1
1
  import hashlib
2
2
 
3
- from detssh.backends.base import Field
3
+ from isik.common.settings import Setting, Settings
4
+
4
5
  from detssh.backends.salt.base import SaltAlgo, SaltError
5
6
 
6
7
 
8
+ MIN_DIGEST_SIZE = 1
9
+ MAX_DIGEST_SIZE = 64
10
+
11
+
12
+ class Blake2bSettings(Settings):
13
+ salt_digest_size = Setting(int, "Salt digest size, in bytes", default=16, min=MIN_DIGEST_SIZE, max=MAX_DIGEST_SIZE)
14
+
15
+
7
16
  class Blake2b(SaltAlgo):
8
17
  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
- }
18
+ min_digest_size = MIN_DIGEST_SIZE
19
+ max_digest_size = MAX_DIGEST_SIZE
20
+ settings = Blake2bSettings
20
21
 
21
22
  @classmethod
22
23
  def digest(cls, label, resolved):
@@ -1,22 +1,23 @@
1
1
  import hashlib
2
2
 
3
- from detssh.backends.base import Field
3
+ from isik.common.settings import Setting, Settings
4
+
4
5
  from detssh.backends.salt.base import SaltAlgo, SaltError
5
6
 
6
7
 
8
+ MIN_DIGEST_SIZE = 1
9
+ MAX_DIGEST_SIZE = 32
10
+
11
+
12
+ class Blake2sSettings(Settings):
13
+ salt_digest_size = Setting(int, "Salt digest size, in bytes", default=16, min=MIN_DIGEST_SIZE, max=MAX_DIGEST_SIZE)
14
+
15
+
7
16
  class Blake2s(SaltAlgo):
8
17
  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
- }
18
+ min_digest_size = MIN_DIGEST_SIZE
19
+ max_digest_size = MAX_DIGEST_SIZE
20
+ settings = Blake2sSettings
20
21
 
21
22
  @classmethod
22
23
  def digest(cls, label, resolved):