Everything crypto.md section 3 describes below the identity key, which is what the desktop client needs before it can enroll or store anything. DshAad gives every descriptor in the specification a named constructor. The AAD binding is the most valuable structural property in the design -- it is what stops a server holding every ciphertext from pasting one row's bytes onto another, rolling a row back to a superseded generation, or replaying a revoked grant -- and all of it depends on callers getting purpose, resource type and ids right at every single call site. Hand-constructing descriptors makes that a matter of care; picking a method name makes it a matter of spelling. UserSecretBundle holds private keys in libsodium's guarded, mlocked allocations rather than a byte[], so they are not paged out and do not land in a core dump. They are created exportable, deliberately: re-wrapping the same bundle for a passphrase change or a new device needs to re-encode it, and the alternative -- a long-lived managed array so the keys need not be exportable -- keeps the identical secret in strictly worse memory. Every export is into a buffer zeroed before the method returns. Two spec changes, both found by implementing it, which is the argument for writing code before calling a spec frozen: - MK is 64 bytes, not 32. Skipping HKDF-Extract is correct for an Argon2id output (RFC 5869 3.3), but it means MK *is* the PRK, and .NET's HKDF.Expand rejects a PRK shorter than the hash output -- so a 32-byte MK cannot be expanded with SHA-512 at all. Widening it keeps the specified primitive; the alternatives were dropping to SHA-256 or adding an Extract step that conditions nothing. - The bundle encoding is a fixed 92-byte layout rather than canonical CBOR. Canonicality is not load-bearing here -- unlike a key statement the bundle is never hashed or signed, only encrypted -- so CBOR's one advantage does not apply, while its canonicalisation rules are a real source of cross-implementation disagreement. It also costs a dependency System.Formats.Cbor is not in the shared framework. Safe to change now and not later: no bundle has ever been stored. 53 new tests. The encoding is checked against an independent codec written in the test rather than by round-tripping production code against itself -- a round trip passes just as happily when both directions are wrong the same way, and this format cannot change after one bundle is stored. The pinned 92-byte hex constant is the golden vector for the layout. Most of the rest are negative, because a binding is only demonstrated by the substitutions that fail: a wrap for another user, a grant from a superseded generation, a payload pasted onto another item, a metadata blob offered as a payload, a version rolled back.
DodoSSH
A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.
Manage hosts, credentials and keys in a desktop app; sync them across your devices and share them with teammates through a server you run yourself. The server stores ciphertext and never holds a key — the operator cannot read the credentials it stores.
Status: early development. See the milestone plan for what exists today.
Why
Teams either scatter SSH credentials across individual ~/.ssh directories with no sharing
story, or pay per-seat for a hosted product that holds their infrastructure credentials.
DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and
zero-knowledge.
Architecture
| Component | Choice |
|---|---|
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
| Client | Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js |
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
Two consequences worth knowing before you read further:
- Revocation is not retroactive. A removed member keeps what they already downloaded. The real remediation is rotating the SSH credential, so offboarding is built around a rotation checklist rather than a button that implies more than it delivers.
- No session recording in relay mode. The relay forwards SSH ciphertext, so it cannot see commands. That is the cost of the relay not being able to read your traffic.
The reasoning behind each major decision is recorded in docs/adr/, starting with
the E2EE trust model.
Repository layout
src/
DodoSSH.Contracts DTOs shared with the client — the real API contract
DodoSSH.Crypto DSH1 envelope, AAD derivation, key wrapping
DodoSSH.Domain entities and invariants, no EF
DodoSSH.Infrastructure DbContext, configurations, migrations
DodoSSH.Api the host
tests/ one test project per source project
docs/adr/ architecture decision records
Building
Requires the .NET SDK pinned in global.json (10.0.x).
dotnet build DodoSSH.slnx
dotnet test DodoSSH.slnx
Run the API locally:
dotnet run --project src/DodoSSH.Api
It listens on http://localhost:5233, serving /healthz/live, /healthz/ready and — in
Development — /openapi/v1.json.
Development and testing are currently Windows-only. Anything known or suspected to differ on
Linux and macOS is tracked in docs/platform-flags.md, along with the
deployment gotchas that have already cost time once. Read it before assuming something works
off-Windows.
Conventions the build enforces
- Warnings are errors.
dotnet format --verify-no-changesgates CI. - Package versions are centralised in
Directory.Packages.props;packages.lock.jsonis committed and CI restores in locked mode. BannedSymbols.txtbansDateTime.UtcNow(useTimeProvider),Guid.NewGuid(useCreateVersion7), sync-over-async, MD5/SHA1 and PBKDF2.- Public members of
DodoSSH.Contractsmust be declared inPublicAPI.Unshipped.txt, so a contract change is a build error rather than a client-side surprise.
Milestones
- M0 — foundation. Repo structure, build conventions, CI, ADRs. Done.
- M1 — vertical slice. OIDC login → enroll → create a host → open a shell.
Backend done: the DSH1 crypto core, the data model, sync push/pull for hosts,
/me, and enrollment with the identity-provider key binding. Remaining: the desktop client, and the two spikes that gate it — the Linux WebView and SSH.NET'swindow-change. Both need a Linux and a macOS machine, so neither has run yet. - M2 — full personal vault, robust sync, relay.
- M3 — teams, sharing, ACLs.
- M4 — hardening and ops, packaging, self-hosting guide.
- M5 — multi-provider OIDC, key rotation, per-item content keys.
Licence
MIT.