Skip to content

feat(desktop): frictionless SSH onboarding, no env vars required - #143

Open
muscariello wants to merge 14 commits into
mainfrom
feat/desktop-ssh-onboarding
Open

feat(desktop): frictionless SSH onboarding, no env vars required#143
muscariello wants to merge 14 commits into
mainfrom
feat/desktop-ssh-onboarding

Conversation

@muscariello

Copy link
Copy Markdown
Member

Closes #123.

A fresh install could not reach a room. DID auth read SHADI_SLIM_AUTH, SLIM_HUMAN_SEED and SLIM_MEMBER_DIDS from the app's own environment, and mTLS material had to already exist on disk — so Start node reported a missing certificate and the only way through was a shell script.

What

  • identity_bootstrap — one step from an SSH Ed25519 key to a usable app: human DID from the public half, one agent DID per name from the private half, the 32-byte root into the secret store (keychain / 1Password), mTLS generated, identity.json written. slim.rs prefers this over the environment, and still honours the env contract when it's set so the CLI demos keep working.
  • identity_discover_ssh_keys — lists the Ed25519 keys already in ~/.ssh with the DID each would produce, so setup is a choice from a list. Reads only .pub files, so nothing probes private keys speculatively.
  • GitHub verification — passing a handle cross-checks the key against github.com/<handle>.keys (no token) and refuses to claim a handle the key isn't published under.
  • identity_trust_github_handle — the invite side: name an account, get that person's human DID from their published key.
  • Identity tab, first in the tab order, since nothing else works until this has run.

Two things worth reviewing

member_dids() deliberately excludes trusted humans. SLIM admission verifies the agent DID on the wire, and someone's agent DIDs derive from their private key — so trusting a handle records who we accept but cannot admit their agents. Putting their human DID in the allow-list would look like it worked. That's #141.

mTLS uses rcgen, not openssl — a clean Windows install has no openssl. Existing files are left alone, so re-running onboarding can't invalidate certs a peer already trusts.

Test plan

  • 23 unit tests (cert generation, idempotence, adding an agent later, config round-trip excluding the seed, allow-list exclusion, GitHub handle resolution and its failure messages); clippy clean
  • Verified a live SLIM node accepts rcgen-issued certs — full client mTLS handshake and a group session
  • bash docs/content/demos/desktop-onboarding-e2e.sh passes: derives from an SSH key, generates mTLS via the app's own code, and creates a room with SHADI_SLIM_AUTH/SLIM_HUMAN_SEED/SLIM_MEMBER_DIDS unset
  • UI verified against a stubbed IPC bridge: key list, per-key DID preview, passphrase field only for encrypted keys, ready state showing "verified as @handle" and the local agent
  • Clicking through the real Tauri window on a genuinely fresh profile

A fresh install could not reach a room: DID auth read SHADI_SLIM_AUTH,
SLIM_HUMAN_SEED and SLIM_MEMBER_DIDS from the app's own environment, and
mTLS material had to exist on disk. Clicking Start node reported a
missing certificate, and the only way through was a shell script.

Onboarding now produces all of it. identity_bootstrap takes a local SSH
Ed25519 key and derives the human DID from its public half and one agent
DID per name from its private half, stores the 32-byte root in the secret
store (keychain or 1Password), generates mTLS material, and writes
identity.json. slim.rs prefers that over the environment, falling back to
the env contract when set so the CLI demos keep working.

identity_discover_ssh_keys lists the Ed25519 keys already in ~/.ssh with
their would-be DID, so setup is a choice from a list rather than a path
to type; it reads only .pub files, so nothing probes private keys
speculatively. Passing a GitHub handle cross-checks the key against
github.com/<handle>.keys and refuses to claim a handle the key is not
published under.

identity_trust_github_handle is the invite side: name an account and its
published ssh-ed25519 key gives that person's human DID. That records who
we accept — it cannot admit their agents, whose DIDs derive from their
private key, so member_dids() deliberately excludes trusted humans
(#141).

mTLS uses rcgen rather than shelling to openssl, which a clean Windows
install lacks; existing files are left alone so re-running cannot
invalidate certs a peer already trusts. Verified a live SLIM node accepts
the rcgen-issued certs.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
@muscariello
muscariello requested a review from a team as a code owner August 5, 2026 11:43
@muscariello
muscariello requested a review from micpapal August 5, 2026 11:43
@codecov

codecov Bot commented Aug 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Onboarding only looked in ~/.ssh, which is a dead end for anyone who
keeps keys in 1Password and lets its agent serve them — a real case, and
the first one tried: no files there at all.

The key source is now the user's choice of three: a key discovered in
~/.ssh, any file via the native picker (tauri-plugin-dialog), or a
1Password SSH Key item. BootstrapRequest takes a tagged KeySource instead
of a bare path.

1Password items are read with `op item get --fields 'private key'` rather
than through SHADI's own 1Password SecretStore, which cannot serve this:
it expects base64 in an item's notesPlain, its own storage format, not
how a native SSH Key item holds a key. Listing uses the public key field
only, so browsing never reveals a private one, and it still yields the
DID preview. Not being signed in is reported with `op signin` rather than
a raw CLI error.

`op` returns multi-line fields quoted and newline-escaped; two tests pin
that a field mangled that way still parses and derives the same seed as
the plain PEM.

The passphrase field is always shown now. It was conditional on the
~/.ssh scan detecting encryption, which neither of the other sources can
determine before reading the key.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
The note above the 1Password source read like a failure and led with an
implementation detail — that SHADI's own 1Password SecretStore expects a
different storage format — which tells the user nothing they can act on.
It was reported as an error message.

Removed. The vault-policy caveat it also carried already appears in the
read failure itself, where it is actionable, and the format detail belongs
in the code that has to know it.

The item list now loads when the source is chosen instead of behind a
button, since `op` prompting for Touch ID is the expected 1Password
flow. It still never runs at startup.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Registering tauri-plugin-dialog is not enough: Tauri v2 gates each plugin
command behind a capability, so Browse… failed with "dialog.open not
allowed".

Grants dialog:allow-open only, not dialog:default — the default bundle
also permits save dialogs, message boxes and confirm prompts, none of
which this app uses.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
`op` was invoked without --account, so it used whichever account is
default. This machine has three configured, and the default is not the
one holding the SSH key — the item list came back from the wrong vault
with no indication why.

Adds identity_list_1password_accounts and threads an account UUID through
every `op` call, including the public-key read behind the DID preview.
KeySource::OnePassword carries it.

The picker deliberately has no default selection: `op` silently choosing
one is what caused this. Accounts are listed as soon as the source is
chosen, since that needs no unlock; reading a vault's items waits for an
explicit account pick, because that can prompt for Touch ID. A sign-in
error now names the account it applies to.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
The frontend sent kind "onepassword" but rename_all = "snake_case" turns
OnePassword into one_password, so identity_bootstrap rejected every
1Password attempt with `unknown variant`.

Aligns the frontend and adds a test that deserializes the exact JSON it
sends, including nulls for the optional fields, and asserts the old
misspelling still fails. Nothing checked the two sides agreed, which is
why a one-word mismatch reached the user.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Picking the obviously-named 1Password item ("SSH Key - Github") and being
told it is not the published key left nothing to act on: the app knew both
DIDs and made the user guess which of their keys was the right one.

Entering a handle now resolves the DID it publishes and marks each
candidate — in both the ~/.ssh and 1Password lists — with ✓ or ✗, using
the public key the listing already reads. The line under the picker says
what the handle publishes and offers the other way out: clear the handle
and set up without the GitHub binding.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Trusting @msardara failed with "no ssh-ed25519 key found among 1 published
SSH key(s) ... add one with: ssh-keygen -t ed25519". Both halves were
unhelpful: it never said the published key is RSA, and it told the
operator to generate a key when the account is someone else's.

The error now names the algorithms found (found: ssh-rsa) and carries no
remedy, since only the caller knows whose account it is. Trusting a handle
appends "Ask @<handle> to add an Ed25519 key to their GitHub account";
verifying your own during bootstrap appends the self-directed version plus
the option of clearing the handle.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Starting the desktop app printed "keychain get failed: The specified item
could not be found in the keychain." three times before onboarding had
run. Asking whether a secret is set yet is ordinary control flow, and the
store was both logging it and reporting it as StorageFailure — so a
genuinely broken keychain looked identical to an unset key.

get and delete now map errSecItemNotFound to InvalidInput, matching what
the 1Password backend already does for not-found, and only log real
failures. The desktop's load_seed uses the distinction: a miss says run
onboarding, anything else reports the underlying error instead of blaming
setup.

Two tests pinned the old conflation and now assert the split, plus one
that a real backend failure is still StorageFailure.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Signed-off-by: Luca Muscariello <muscariello@ieee.org>
The desktop app is its own workspace, so the release bumps to
shadi_identity, agentbridge, shadi_mas and agent_transport_slim left its
Cargo.lock behind and 'cargo check --locked' refused to proceed.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Onboarding showed five inputs at once. The passphrase box appeared for every
key, though discovery already reports which keys are encrypted, and the agent
list, GitHub handle and endpoint all have working defaults.

The default path is now pick a key and press the button; the rest sits behind
Options, with a one-line summary of what it will use.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Onboarding assumed a key already existed, so a fresh machine had to leave the
app for ssh-keygen before anything worked. identity_generate_ssh_key writes
~/.ssh/id_ed25519 at 0600 and its .pub, and the panel offers it when discovery
finds nothing.

It refuses to touch an existing key: overwriting one would orphan every agent
DID already derived from it. The passphrase is optional, matching ssh-keygen
with the prompt left blank.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Reading only HOME made discovery report an empty ~/.ssh on Windows even when
the machine had keys, and key generation fail with 'cannot locate a home
directory'. The whole onboarding path was Unix-only.

The 0600 mode stays Unix-only; on Windows the key inherits the user profile's
ACLs, which is what ssh-keygen there relies on too.

Signed-off-by: Luca Muscariello <muscariello@ieee.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop: first-run onboarding and identity bootstrap flow

1 participant