Metadata-Version: 2.5
Name: detssh
Version: 0.7.0
Summary: Deterministic Ed25519 SSH key generation from a seed passphrase, via configurable KDF and salt-hash backends.
Project-URL: Homepage, https://github.com/isik-kaplan/detssh
Project-URL: Repository, https://github.com/isik-kaplan/detssh
Author-email: Işık Kaplan <isik.kaplan@outlook.com>
License: MIT
License-File: LICENSE
Keywords: argon2,bcrypt,cryptography,ed25519,kdf,pbkdf2,scrypt,ssh
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.11
Requires-Dist: argon2-cffi>=21.3
Requires-Dist: bcrypt>=5.0.0
Requires-Dist: click>=8.1
Requires-Dist: cryptography>=41.0
Requires-Dist: questionary>=2.1.1
Description-Content-Type: text/markdown

# detssh

Deterministic Ed25519 SSH key generation from a seed passphrase. Same
passphrase + same parameters → same keypair, every time, on any machine -
nothing to back up but what's in your head.

```
detssh
```
Prompts for everything (passphrase hidden + confirmed) and writes to the
same place `ssh-keygen` would: `~/.ssh/id_ed25519` / `~/.ssh/id_ed25519.pub`
(mode 600/700, `~/.ssh` created if missing). Pass `--output` to write
somewhere else instead.

Non-interactively:
```
detssh --kdf argon2id --salt-algo blake2b --seed "..." --label github --overwrite-files
```
Any flag switches to non-interactive mode: missing optional fields fall back
to their defaults, `--seed` is the only required one. Passing `--seed` on the
command line puts it in your shell history and process list (`ps aux`) - fine
for local scripting, but prefer the interactive prompt when that matters.

By default the private key file is written unencrypted, like `ssh-keygen -N
""`. Pass `--key-passphrase` (or leave it blank at the interactive prompt to
skip) to encrypt it at rest instead - anyone who gets the file will still
need that passphrase to use it. This is unrelated to the seed passphrase: it
never touches key derivation, so it doesn't need to be remembered to
*recreate* the key, only to unlock the file you already have. Same caveat as
`--seed` applies if passed as a flag.

## Only installing the public key

Because the same seed and parameters always give the same key, a machine
you only want to log *into* doesn't need the private key at all. Run this
on that machine instead:
```
detssh --authorize
```
It prompts for the same seed, label and backend settings as a normal run,
then appends the public key to `~/.ssh/authorized_keys`, `ssh-copy-id`
style. No private key is written. If the key is already there, the file is
left untouched, so running it twice is harmless. The interactive wizard
asks about this up front too ("Only add the public key to an
authorized_keys file").

In flag mode, add `--authorize` to the usual flags, plus `--authorized-keys
FILE` for a file other than `~/.ssh/authorized_keys`. `--output`,
`--key-passphrase`, `--register` and `--overwrite-files` are rejected
alongside it, because they only apply when a private key is written.

## Using the key with `ssh`

`ssh` only auto-offers keys with default filenames (`id_rsa`, `id_ed25519`, ...)
sitting directly in `~/.ssh/` - a key written under `--output` or a per-label
subdirectory needs either `-i <path>`, a hand-written `Host` block in
`~/.ssh/config`, or `ssh-add`ing it to your agent.

The interactive run offers to do this for you at the end, right after it
writes the key:
```
Register this key so `ssh isik:personal:contabo` connects with it? [Y/n]: y
Destination (user@host): root@contabo.com
Non-default ssh port:

Registered isik:personal:contabo -> root@contabo.com
Run: ssh isik:personal:contabo
```
The label you derived the key with doubles as the `Host` name, so there's
nothing extra to type. In flag mode, `--register root@contabo.com` (plus
`--register-port` when it isn't 22) does the same thing, and needs `--label`
for that reason. Declining changes nothing about the key that was written.

For a key you already have, `detssh ssh register` reaches the same end state:
```
detssh ssh register isik:personal:contabo root@contabo.com --key ~/.ssh/isik:personal:contabo/id_ed25519
```
Either route records the label in `~/.config/detssh/config`, generates a `Host` block
for it in a detssh-managed file, and adds a single `Include` line to
`~/.ssh/config` (once, at the top) that pulls it in. After that,
`ssh isik:personal:contabo` just works - through real `ssh`, nothing routed
through detssh at connect time.

```
detssh ssh list              # show registered labels
detssh ssh forget <label>    # remove a label (never deletes the key file)
```

## Backends

- `--kdf`: `argon2id` (default), `argon2i`, `argon2d`, `scrypt`, `pbkdf2`, `bcrypt_pbkdf`
- `--salt-algo`: `blake2b` (default), `blake2s`, `sha256`, `sha384`, `sha512`, `sha3_256`, `sha3_384`, `sha3_512`

Each combination has its own options (cost knobs, salt digest size, etc.),
with their valid ranges shown in both `--help` and the interactive prompts.
See them with:
```
detssh --kdf <kdf> --salt-algo <salt-algo> --help
```

We recommend sticking with the defaults (`argon2id`, memory-hard with a high
cost) unless you have a specific reason not to - they make brute-forcing your
passphrase significantly more expensive than faster KDFs like `pbkdf2`.

Cost knobs (iterations, memory, cost, rounds, ...) also carry a soft safety
limit meant to catch typos before they cost you minutes of derivation time.
In interactive mode, going over one doesn't interrupt you - everything you
exceeded is summarized in one confirmation at the very end. In flag mode, it
fails immediately unless you pass `--force-allow-soft-constraints`.

## Determinism guarantee

Same explicit parameters → same key, across any release sharing the same
major version - only a major version bump may change key derivation. That
guarantee starts at 1.0; we're still on 0.x, so it doesn't apply yet (see
`tests/golden/README.md`).

## Development

```
uv sync
uv run pytest
uv run ruff check .
uv run ruff format --check .
```

`pytest` runs with coverage on by default (`[tool.pytest.ini_options]` in
`pyproject.toml`) and fails under 100% line+branch coverage - same gate
locally and in CI, nothing extra to remember to pass.

100% coverage means every line runs under test, not that every behavior
change gets caught - a test can execute a line without asserting anything
meaningful about it. [`mutmut`](https://mutmut.readthedocs.io/) checks the
second thing: it rewrites the source one small change at a time (flips a
comparison, drops an argument, tweaks a string) and reruns the tests against
each mutant, one at a time. A mutant that still passes ("survived") is a
change the suite wouldn't notice.

```
uv run mutmut run       # takes a few minutes; ctrl-c-safe, resumes where it left off
uv run mutmut results   # list survivors by name
uv run mutmut show <name>   # see what a specific mutant changed
```

A survivor is either a real test gap (add an assertion that would fail
against the mutant) or a genuinely equivalent mutant - the mutated code is
provably indistinguishable from the original for every input (e.g.
`"utf-8"` vs `"UTF-8"` as a codec name: Python's codec lookup is
case-insensitive, so no test could tell them apart). CI
(`.github/workflows/tests.yml`, job `mutation`) fails on any survivor not
listed in `tests/mutmut_known_equivalents.txt`, which names each one and
says why - add a confirmed-equivalent mutant there (never to silence a
failure without doing that check) rather than chasing an unwinnable test.
