Compare commits

..
Author SHA1 Message Date
jaap-jan b08f2c58e3 Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
ci / build and test (pull_request) Failing after 3m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting.

A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed.

A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for.

The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
2026-09-29 10:34:38 +02:00
jaap-jan ee423b25ce Merge claude/spelunk-sign-in: sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m36s
2026-09-29 09:16:12 +02:00
jaap-jan 38ab9b431b Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.

A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
2026-09-28 22:53:51 +02:00
jaap-jan 07bd5c9886 Merge claude/api-image-to-gitea-registry: push the API image to Gitea's own registry
ci / build and test (push) Successful in 4m2s
ci / android head (push) Successful in 5m27s
ci / api image (push) Successful in 18s
ci / desktop nightly (push) Successful in 1m50s
2026-09-28 22:07:41 +02:00
jaap-jan 31aca26e89 Push the API image to Gitea's own registry instead of registry-docker
git.dodotech.cloud/dodotech-public/dodossh-api, under the repository's owner, the way Spelunk publishes its images. REGISTRY_USERNAME and REGISTRY_PASSWORD now have to be a Gitea account and a package token for that owner.
2026-09-28 22:07:06 +02:00
jaap-jan 843713b118 Merge claude/api-image-sees-illink-pin: the API image restores with the trimmer pin
ci / build and test (push) Successful in 6m15s
ci / android head (push) Successful in 6m19s
ci / desktop nightly (push) Successful in 1m19s
ci / api image (push) Successful in 40s
2026-09-28 21:44:56 +02:00
jaap-jan 9c7be8ae9b Let the API image see the trimmer pin, so its restore stops breaking on each .NET servicing release
The API Dockerfile copied four of the five root build files before restoring and left out Directory.Build.targets, which pins Microsoft.NET.ILLink.Tasks. The image's locked-mode restore therefore asked for whatever ILLink its SDK bundled, and when sdk:10.0-noble moved to 10.0.401 (ILLink 10.0.12) it failed with NU1004 against lock files that correctly say 10.0.11 - while ci.yml, which sees the pin, stayed green.
2026-09-28 21:44:56 +02:00
jaap-jan 3d0a66f997 Merge claude/spelunk-tunnel: reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 6m46s
ci / build and test (push) Canceled after 6m48s
2026-09-28 21:38:09 +02:00
jaap-jan 689ba6c1bf Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
2026-09-28 21:38:09 +02:00
jaap-jan 6e39807cc0 Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the
SSH server on the machine's own loopback, so the server needs no open SSH
port. Shells and file transfers both go this way.

Client, in Client.Session:

- SpelunkTunnel implements Spelunk's contract: POST
  {api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
  and {"port":N}, then a WebSocket to the API's own origin at the path
  Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
  upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
  is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
  agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
  could not reach the port, or is too old: "Unknown stream kind") and 4503
  (agent disconnected). Only a path is taken from the ticket response, so it
  cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
  so the connect path has one shape for both.

Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.

The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.

Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.

UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.

dodossh://spelunk/connect?api=&server=&name=&host= links:

- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
  running passes it down the doorbell as "dodossh.open-link <link>", which
  the running instance parses again, bounded, and otherwise ignores whole.
  The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
  otherwise the editor opens on a new, unsaved Spelunk host, with the key
  pre-chosen only when another host already sends it to that Spelunk. A link
  that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
  hooks and removes it on uninstall only if it still points at this
  executable; the macOS plist template declares CFBundleURLTypes and the app
  takes the URL from Avalonia's activation event; the Linux desktop entry
  gets %u and MimeType=x-scheme-handler/dodossh.
2026-09-28 21:25:04 +02:00
jaap-jan d7d002c972 Merge pull request 'Start port forwards on the phone, listed in a sheet off its terminal' (#20) from claude/dazzling-moser-641acd into main
ci / build and test (push) Successful in 3m57s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Failing after 13s
ci / android head (push) Successful in 5m32s
Reviewed-on: #20
2026-09-28 19:06:37 +00:00
jaap-jan a6a21decbd Report a host's pinned paths among the fields an edit changed
ci / build and test (push) Successful in 4m10s
ci / android head (push) Successful in 5m32s
ci / api image (push) Failing after 18s
ci / desktop nightly (push) Successful in 2m0s
2026-09-28 20:50:23 +02:00
65 changed files with 8481 additions and 133 deletions

No files matched your search

+15 -5
View File
@@ -1326,6 +1326,19 @@ jobs:
# inspects — it is the thing that gets deployed.
needs: [build]
runs-on: [linux]
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
# the repository - so the image is listed with the repository's packages rather than on a
# registry of its own. Lowercase, because an image reference may not contain capitals and
# the owner is DodoTech-Public.
#
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
# package read and write, allowed to publish for that owner. Not the job's built-in
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
# pushes the same way, with the same two secrets.
env:
REGISTRY: git.dodotech.cloud
IMAGE: dodotech-public/dodossh-api
steps:
# Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three.
@@ -1451,9 +1464,6 @@ jobs:
# changes, and where setup-buildx-action starts earning its place.
- name: work out the tags
id: tags
env:
REGISTRY: registry-docker.dodotech.cloud
IMAGE: dodotech/dodossh-api
run: |
set -euo pipefail
repo="$REGISTRY/$IMAGE"
@@ -1578,7 +1588,7 @@ jobs:
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
printf '%s' "$REGISTRY_PASSWORD" \
| docker login registry-docker.dodotech.cloud \
| docker login "$REGISTRY" \
--username "$REGISTRY_USERNAME" --password-stdin
- name: push
@@ -1596,7 +1606,7 @@ jobs:
# does not leave it behind.
- name: log out
if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud
run: docker logout "$REGISTRY"
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
+58
View File
@@ -452,6 +452,64 @@ says so, and the terminal is unaffected. Any row can be switched off and on whil
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
[The Android head](#the-android-head).
### Reaching a server through Spelunk
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
Spelunk's API address, the server's id in Spelunk, and your Spelunk API key. A connection then asks Spelunk
for a ticket, opens a WebSocket, and the agent connects to the SSH server on the machine's own loopback — so
the server needs no open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk
are one choice, and ticking one unticks the other.
- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste
the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault —
encrypted, synced to your other devices, never shown again — together with the address, and **it is only
ever sent to that address**: a host that names the key with any other address is refused at connect. One
key serves every server in that Spelunk organisation; saving a key for an address that already has one
replaces it, and every host using it connects with the new key. The host editor offers saved keys first
and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on
this machine.
- A key kept the older way, as a credential's password, still works and is still offered in the editor;
its password goes to whatever address the host names.
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
is for every host, and the port is what the agent connects to on the server.
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: a key Spelunk will
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
connected, an agent that could not reach the SSH port or is too old to open terminals.
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
so a DodoSSH older than this shows it read-only.
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
link, or hands the link to the copy already running, and **connects straight away.** The link never
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
- Otherwise, when you have saved a Spelunk key for that Spelunk under Settings → Security — or a saved host
already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link
names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through Spelunk with that
key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host
you saved yourself.
- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in
ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that
Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never
sent to an address you have not sent it to — saving the key under Security is how you say you have.
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
its own. A link that arrives while the vault is locked waits for the unlock.
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
The phone reaches Spelunk hosts the same way but does not answer links yet.
### Sharing a vault
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
+9 -1
View File
@@ -247,6 +247,13 @@ ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
# name deliberately and not the package's binary name — the window does not know which package
# started it.
#
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
# it, or passes it to the instance already running. update-desktop-database in the post-install
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
# installed answers, and with both installed the desktop's own default decides.
mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry]
@@ -255,13 +262,14 @@ Version=1.0
Name=%{dodo_title}
GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin}
Exec=%{dodo_bin} %%u
Icon=%{dodo_bin}
Terminal=false
StartupNotify=true
StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell;
MimeType=x-scheme-handler/dodossh;
DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different
+19
View File
@@ -119,6 +119,25 @@
<key>LSUIElement</key>
<false/>
<!--
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
for a Velopack install is on first launch from /Applications.
-->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.dodotech.dodossh.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>dodossh</string>
</array>
</dict>
</array>
<key>NSHumanReadableCopyright</key>
<string>© DodoTech. MIT licensed.</string>
</dict>
+5 -1
View File
@@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
`17` SpelunkKey.
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
@@ -318,6 +319,9 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
### 4.4 What this prevents
+13 -4
View File
@@ -2,9 +2,18 @@
#
# docker build -f src/DodoSSH.Api/Dockerfile .
#
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
# the root and all four are load-bearing here — central package management means a csproj
# alone does not name a single version, and a build that cannot see them resolves nothing.
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
# global.json all sit at the root and all five are load-bearing here — central package
# management means a csproj alone does not name a single version, and a build that cannot see
# them resolves nothing.
#
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
# ---------------------------------------------------------------------------------------
# Build
@@ -26,7 +35,7 @@ WORKDIR /src
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
# rather than skipping a node.
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./
COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
@@ -73,6 +73,7 @@ internal static class ItemKinds
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new TagKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
new SpelunkKeyKind(),
}.ToDictionary(kind => kind.WireType);
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
@@ -1093,3 +1094,98 @@ internal sealed class ObjectStoreKind : IItemKind
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
/// <remarks>
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
/// </remarks>
internal sealed class SpelunkKeyKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.SpelunkKeys
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
database.SpelunkKeys.Add(key);
return key;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <remarks>
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
/// manage their servers from.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A Spelunk key is not something the server dials; its address stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A Spelunk key's contents are inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A Spelunk key carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
@@ -1002,7 +1002,8 @@
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
PlaceholderText="{Binding EditorUsernamePlaceholder}"
IsEnabled="{Binding EditorAsksForLogin}" />
</Grid>
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
@@ -1016,6 +1017,7 @@
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
IsEnabled="{Binding EditorAsksForLogin}"
HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
@@ -1028,6 +1030,9 @@
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
<!--
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
@@ -1212,6 +1217,41 @@
<TextBlock Classes="body"
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
<!--
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
one unticks the other. The key is a keychain credential chosen here, never typed here. See
VaultViewModel.Spelunk.cs.
-->
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5"
Text="Reach this host through Spelunk"
TextWrapping="Wrap" />
</CheckBox>
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
PlaceholderText="Spelunk server id" />
<TextBlock Classes="detail" Text="Spelunk API key" />
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
SelectedItem="{Binding EditorSelectedSpelunkKey}"
HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5"
Text="Sign in through Spelunk (no SSH key needed)"
TextWrapping="Wrap" />
</CheckBox>
</StackPanel>
<TextBlock Classes="body"
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Choose a Spelunk API key (Admin role) saved from the desktop's Settings → Security, or a credential whose password is the key; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
<!--
This page covers the whole screen while it is open, which is why it needs a Status line of its
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
+73 -1
View File
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
/// </remarks>
internal sealed partial class DodoSshApp : Application
{
/// <summary>
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
/// </summary>
/// <remarks>
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
/// the UI thread only.
/// </remarks>
private static Action<SpelunkLink>? openLink;
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
private static SpelunkLink? waitingLink;
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -34,12 +48,59 @@ internal sealed partial class DodoSshApp : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Takes the links the platform hands a running application, which on macOS is every link.
/// </summary>
/// <remarks>
/// <para>
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
/// activation. The scheme is declared for it in build/macos's Info.plist template.
/// </para>
/// <para>
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
/// desktops that deliver links as arguments instead.
/// </para>
/// </remarks>
private void ListenForOpenedLinks()
{
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
{
return;
}
activatable.Activated += (_, e) =>
{
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
}
};
}
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
private static void Receive(SpelunkLink link)
{
if (openLink is { } open)
{
open(link);
}
else
{
waitingLink = link;
}
}
/// <summary>
/// Puts a window on screen first, and composes the application behind it.
/// </summary>
@@ -206,9 +267,20 @@ internal sealed partial class DodoSshApp : Application
// The other half of Program's claim on this profile: a second launch was refused, and this is what
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
var others = SingleInstance.Listen(
paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
openLink = viewModel.OpenLink;
if ((waitingLink ?? Program.LaunchLink) is { } first)
{
waitingLink = null;
viewModel.OpenLink(first);
}
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
+129 -10
View File
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
/// process asks the first to come forward and then leaves, which is what every desktop application does and
/// what makes the exclusion invisible rather than mysterious.
/// </para>
/// <para>
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
/// second launch like any other — so the doorbell has a second message, which carries the link to the
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
/// </para>
/// </remarks>
internal static class SingleInstance
{
@@ -46,6 +52,30 @@ internal static class SingleInstance
/// </remarks>
private const string ComeForward = "dodossh.come-forward";
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
/// <remarks>
/// <para>
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
/// passes is ignored whole — the window does not even come forward for it.
/// </para>
/// <para>
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
/// than half-obeying it.
/// </para>
/// </remarks>
private const string OpenLinkPrefix = "dodossh.open-link ";
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
/// <remarks>
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
/// process spends on it.
/// </remarks>
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
/// <remarks>
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
@@ -86,7 +116,11 @@ internal static class SingleInstance
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
/// <param name="link">
/// The link this launch was started with, if any — passed to the running instance instead of a bare
/// request to come forward.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
{
ArgumentNullException.ThrowIfNull(paths);
@@ -116,7 +150,7 @@ internal static class SingleInstance
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
// round trip on a local pipe rather than the whole window below.
if (AskTheRunningOneToComeForward(paths))
if (AskTheRunningOneToComeForward(paths, link))
{
return null;
}
@@ -142,25 +176,32 @@ internal static class SingleInstance
/// </remarks>
/// <param name="paths">The profile this instance claimed.</param>
/// <param name="comeForward">What to do when another launch asks for the window.</param>
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
/// <param name="openLink">
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
/// </param>
internal static IAsyncDisposable Listen(
ClientPaths paths,
Action comeForward,
Action<SpelunkLink>? openLink = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
listener.Start();
return listener;
}
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
/// <returns>Whether anything was listening.</returns>
/// <remarks>
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
/// and proves itself, and this is the part with a protocol in it.
/// </remarks>
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
{
ArgumentNullException.ThrowIfNull(paths);
@@ -175,7 +216,9 @@ internal static class SingleInstance
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
writer.WriteLine(ComeForward);
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
writer.WriteLine(Message(link));
return true;
}
@@ -188,6 +231,42 @@ internal static class SingleInstance
}
}
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
internal static string Message(SpelunkLink? link) =>
link is null ? ComeForward : OpenLinkPrefix + link;
/// <summary>
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
/// </summary>
/// <returns>
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
/// including a link that is almost right — the caller then does nothing at all.
/// </returns>
internal static bool TryRead(string? said, out SpelunkLink? link)
{
link = null;
if (said is null || said.Length > MaxMessageLength)
{
return false;
}
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
{
return true;
}
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
{
link = parsed;
return true;
}
return false;
}
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
/// <remarks>
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
@@ -278,14 +357,16 @@ internal static class SingleInstance
{
private readonly string name;
private readonly Action comeForward;
private readonly Action<SpelunkLink>? openLink;
private readonly CancellationTokenSource lifetime = new();
private Task? loop;
internal Doorbell(string name, Action comeForward)
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
{
this.name = name;
this.comeForward = comeForward;
this.openLink = openLink;
}
internal void Start() => loop = RunAsync(lifetime.Token);
@@ -324,11 +405,16 @@ internal static class SingleInstance
// leaveOpen, because this server outlives the conversation. See the remark above.
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
{
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
if (TryRead(said, out var link))
{
comeForward();
if (link is not null)
{
openLink?.Invoke(link);
}
}
}
@@ -348,6 +434,39 @@ internal static class SingleInstance
}
}
/// <summary>
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
/// </summary>
/// <remarks>
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
/// reader buffers underneath, and a real message is one short line.
/// </remarks>
private static async Task<string?> ReadBoundedLineAsync(
StreamReader reader,
CancellationToken cancellationToken)
{
var line = new StringBuilder();
var next = new char[1];
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
{
if (next[0] == '\n')
{
return line.ToString().TrimEnd('\r');
}
if (line.Length == MaxMessageLength)
{
return null;
}
line.Append(next[0]);
}
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
}
/// <summary>Ends one conversation without ending the doorbell.</summary>
/// <remarks>
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
@@ -0,0 +1,82 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session;
using Microsoft.Win32;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
/// </summary>
/// <remarks>
/// <para>
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
/// user, which is all a browser consults.
/// </para>
/// <para>
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
/// existing installation gets it at all, and because it costs nothing to say again.
/// </para>
/// <para>
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
/// take the handler away from the one that is still there.
/// </para>
/// </remarks>
[SupportedOSPlatform("windows")]
internal static class WindowsUrlScheme
{
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
private const string CommandPath = @"shell\open\command";
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
internal static void Register(string executable)
{
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
scheme.SetValue(string.Empty, "URL:DodoSSH");
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
// than a file type.
scheme.SetValue("URL Protocol", string.Empty);
using (var icon = scheme.CreateSubKey("DefaultIcon"))
{
icon.SetValue(string.Empty, $"\"{executable}\",0");
}
using var command = scheme.CreateSubKey(CommandPath);
command.SetValue(string.Empty, CommandFor(executable));
}
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
internal static void Unregister(string executable)
{
string? registered;
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
{
registered = command?.GetValue(string.Empty) as string;
}
if (IsOurs(registered, executable))
{
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
}
}
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
/// <remarks>
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
/// here. Program.Main then reads it back and checks it before anything acts on it.
/// </remarks>
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
internal static bool IsOurs(string? registered, string executable) =>
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
}
+101 -6
View File
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
internal static class Program
{
/// <summary>
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
/// ordinary launch.
/// </summary>
/// <remarks>
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
/// with no arguments to have.
/// </remarks>
internal static SpelunkLink? LaunchLink { get; private set; }
/// <summary>
/// Entry point.
/// </summary>
@@ -29,11 +39,12 @@ internal static class Program
// and a silent installer run that reached either would be a background process holding the cache
// file open during the very file operations the installer is performing.
//
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
// before unlock and touching no encrypted content.
VelopackApp.Build().Run();
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
// after an update — a schema migration — already runs on every ordinary launch from
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
// have touch only the registry; see RunVelopack.
RunVelopack();
KeepTheWebViewProfileOutOfTheInstallDirectory();
@@ -51,16 +62,100 @@ internal static class Program
*/
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
using var claim = SingleInstance.Claim(paths);
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
// passes the link on rather than merely asking for the window.
var link = LinkFrom(args);
using var claim = SingleInstance.Claim(paths, link: link);
if (claim is null)
{
return;
}
LaunchLink = link;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
/// <remarks>
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
/// <see cref="WindowsUrlScheme"/>.
/// </remarks>
private static void RunVelopack()
{
var velopack = VelopackApp.Build();
if (OperatingSystem.IsWindows())
{
velopack = velopack
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
}
velopack.Run();
}
/// <summary>
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
/// </summary>
/// <remarks>
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
/// </remarks>
private static SpelunkLink? LinkFrom(string[] args) =>
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
&& SpelunkLink.TryParse(candidate, out var link)
? link
: null;
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
/// <remarks>
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
/// convenience could not be set up would be the wrong trade — the application works without it, and the
/// only thing lost is Spelunk's button opening it.
/// </remarks>
private static void RegisterUrlScheme()
{
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
{
return;
}
try
{
WindowsUrlScheme.Register(executable);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// See the remarks.
}
}
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
private static void UnregisterUrlScheme()
{
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
{
return;
}
try
{
WindowsUrlScheme.Unregister(executable);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// See the remarks on RegisterUrlScheme.
}
}
/// <summary>
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
/// </summary>
+72 -1
View File
@@ -578,7 +578,8 @@
-->
<Grid ColumnDefinitions="*,8,78">
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40"
IsEnabled="{Binding EditorAsksForLogin}" />
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
@@ -599,6 +600,7 @@
-->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}"
IsEnabled="{Binding EditorAsksForLogin}"
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
@@ -612,6 +614,15 @@
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Why the two controls above are greyed out for a host Spelunk signs in to: disabled rather
than hidden, so a login somebody chose is still there to see and comes back if the box in
the Spelunk card is unticked before saving. See VaultViewModel.EditorAsksForLogin.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding !EditorAsksForLogin}"
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
<!--
Making a credential without leaving the host. The moment one is wanted is this one: somebody
is deciding how a host authenticates and finds the password is not in the keychain yet, and
@@ -692,6 +703,66 @@
</StackPanel>
</Border>
<!--
â—† THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
because a host has one route. A host ticked here is reached through the Spelunk agent already
on the server, so it needs no open SSH port at all. The fields appear only when the box is
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
and synced like every other secret. See VaultViewModel.Spelunk.cs.
-->
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
<StackPanel Spacing="6">
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
<TextBlock Text="Connect through Spelunk" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
</CheckBox>
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
PlaceholderText="Spelunk server id" />
<!--
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
the authentication picker's does, so an unrelated edit cannot drop it. Typing an address
a key is saved for picks that key when nothing is picked yet.
-->
<TextBlock Classes="hint" FontSize="10.5" Text="Spelunk API key" />
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
SelectedItem="{Binding EditorSelectedSpelunkKey}"
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Spelunk signs the user in: a key pair made in memory for each connection, its public
half sent with the ticket, and the agent's one-connection SSH server accepting only
that. Ticking it greys out the login above. See SpelunkRoute.SignInThroughSpelunk.
-->
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" VerticalContentAlignment="Center">
<TextBlock Text="Sign in through Spelunk (no SSH key needed)" FontSize="12.5"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
</CheckBox>
</StackPanel>
<!--
What the route asks for and what it costs, said where the decision is made: an Admin-role
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
only while Spelunk and its agent are.
-->
<TextBlock Classes="hint" FontSize="11.5"
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
</StackPanel>
</Border>
<!--
Withdrawing host key trust lives here, in the host's own settings, because a changed host
key is refused outright with no way to continue past it — so a legitimately rebuilt server
@@ -1,8 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
x:DataType="vm:MainWindowViewModel">
x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!--
v5c: Security, against Settings-Security.dc.html.
@@ -24,6 +26,14 @@
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
SPELUNK is where a Spelunk API key is filled in: an address and a key, saved as a Spelunk key item in the
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
because saving it is the user saying so. A second key for an address already saved replaces the first.
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
same ConfirmDeleteCard every other deletion does, with a count of the hosts that would stop connecting.
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
@@ -101,6 +111,86 @@
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="SPELUNK" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow">
<StackPanel Spacing="10">
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Hosts reached through Spelunk use this key, and links from that Spelunk connect straight away. It is kept encrypted in your personal vault, syncs to your other devices, and is only ever sent to the address below. Use a key with the Admin role." />
</StackPanel>
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
PlaceholderText="API key (spelunk_…)">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
Command="{Binding Vault.SaveSpelunkKeyCommand}"
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
</Grid>
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="{Binding Vault.SpelunkKeyFormNote}"
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
Text="Unlock your keychain to save a Spelunk key." />
</StackPanel>
</Border>
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
<Border Margin="24,12" Padding="20" CornerRadius="12"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1"
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<ItemsControl ItemsSource="{Binding Vault.SpelunkKeys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
<Border Classes="settingsrow" Padding="24,14">
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="{Binding Label}" />
<Border Classes="chip" IsVisible="{Binding HasVaultBadge}" VerticalAlignment="Center">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
<TextBlock Classes="hint" FontSize="11" Text="{Binding Badge}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding ApiBase}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
Text="{Binding UsedBy}" />
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
VerticalAlignment="Center"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
<Border Classes="settingscard" Margin="0,12,0,40">
@@ -2,7 +2,9 @@ using Avalonia.Controls;
namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.</summary>
/// <summary>
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys.
/// </summary>
internal sealed partial class SettingsSecurityPage : UserControl
{
public SettingsSecurityPage() => InitializeComponent();
+48 -1
View File
@@ -297,6 +297,34 @@ public sealed record HostSecret : IVaultSecret
/// </remarks>
public bool RelayEnabled { get; init; }
/// <summary>
/// How this host is reached through Spelunk, or null for a host that is dialled.
/// </summary>
/// <remarks>
/// <para>
/// A host carrying one is never dialled directly and never falls back to it, for the reason the relay
/// does not: the user chose this route because the direct one does not exist or does not work, and a
/// connection that quietly went another way would be the box saying one thing and the connection doing
/// another. See <see cref="SpelunkRoute"/> for what the route is.
/// </para>
/// <para>
/// <b><see cref="Hostname"/> and <see cref="Port"/> still matter, and not for dialling.</b> The host key
/// is pinned under them, as it is for every host, so they have to name this machine stably — a host made
/// from a Spelunk link takes the server's address, or a name made from its id when Spelunk gave none.
/// The port is also what the agent is asked to connect to on the server's own loopback.
/// </para>
/// <para>
/// <b>Mutually exclusive with <see cref="RelayEnabled"/>.</b> Two routes to one host is the same failure
/// as two bindings: the editor, the connect path and the user would each be free to guess which one is
/// meant. <see cref="TryValidate"/> refuses the pair.
/// </para>
/// </remarks>
public SpelunkRoute? Spelunk { get; init; }
/// <summary>Whether Spelunk signs the user in to this host, so it needs no login of its own.</summary>
/// <remarks>See <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</remarks>
public bool SignsInThroughSpelunk => Spelunk?.SignInThroughSpelunk is true;
/// <summary>
/// Checks the fields that must hold before this can be stored.
/// </summary>
@@ -332,7 +360,8 @@ public sealed record HostSecret : IVaultSecret
return PinnedPathsAreStorable(out reason)
&& PortForwardsAreStorable(out reason)
&& ReferencesAreStorable(out reason);
&& ReferencesAreStorable(out reason)
&& RouteIsStorable(out reason);
}
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
@@ -356,6 +385,24 @@ public sealed record HostSecret : IVaultSecret
return true;
}
/// <summary>Checks the Spelunk route, if there is one, and that it is the only route.</summary>
private bool RouteIsStorable([NotNullWhen(false)] out string? reason)
{
if (Spelunk is null)
{
reason = null;
return true;
}
if (RelayEnabled)
{
reason = "A host is reached through the server relay or through Spelunk, not both.";
return false;
}
return Spelunk.TryValidate(out reason);
}
/// <summary>
/// Checks the paths pinned on this host.
/// </summary>
+101 -1
View File
@@ -119,12 +119,44 @@ public static class HostSecretCodec
/// </remarks>
public const int PortForwardsSchemaVersion = 8;
/// <summary>The version that introduced <see cref="HostSecret.Spelunk"/>.</summary>
/// <remarks>
/// <para>
/// Its own version, by the rule this file follows, and it matters more here than for a tag. An older
/// build that decoded a Spelunk host as editable would drop the route on the next save — and the host
/// would then be dialled directly, at an address that exists only so its key has a name to be pinned
/// under. Written at this version, the older build shows the host, refuses to edit it, and says to update.
/// </para>
/// <para>
/// Additive, like every version but <see cref="PortInheritSchemaVersion"/>: the route is one more
/// property, and a build that does not know it skips it and still reads a valid host. It follows
/// <see cref="PortForwardsSchemaVersion"/>, whose forwards reached main first.
/// </para>
/// </remarks>
public const int SpelunkSchemaVersion = 9;
/// <summary>The version that introduced <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</summary>
/// <remarks>
/// <para>
/// Its own version, and nested in the existing <c>spelunk</c> object rather than beside it, because it
/// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host —
/// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped
/// and the host would start asking for a password it never had. Written at this version, that build
/// shows the host, refuses to edit it, and says to update.
/// </para>
/// <para>
/// Only a route that signs in is written here. Every other Spelunk host stays at
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
/// </para>
/// </remarks>
public const int SpelunkSignInSchemaVersion = 10;
/// <summary>The highest schema version this build can write.</summary>
/// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
/// </remarks>
public const int CurrentSchemaVersion = PortForwardsSchemaVersion;
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -175,6 +207,9 @@ public static class HostSecretCodec
PortForwards = host.PortForwards.Count == 0
? null
: [.. host.PortForwards.Select(PortForwardDocument.From)],
// Last of all, and absent for a host that is dialled, so every host without a route encodes
// exactly as it did before routes existed.
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -255,6 +290,16 @@ public static class HostSecretCodec
version = Math.Max(version, PortForwardsSchemaVersion);
}
if (host.Spelunk is not null)
{
version = Math.Max(version, SpelunkSchemaVersion);
}
if (host.SignsInThroughSpelunk)
{
version = Math.Max(version, SpelunkSignInSchemaVersion);
}
return version;
}
@@ -340,6 +385,7 @@ public static class HostSecretCodec
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
PortForwards = forwards,
Spelunk = RouteFrom(parsed.Spelunk),
};
if (!candidate.TryValidate(out _))
@@ -389,6 +435,26 @@ public static class HostSecretCodec
forwards = PortForwardList.Create(rules);
return true;
}
/// <summary>The route as stored, or null when there is none.</summary>
/// <remarks>
/// Missing parts come through as empty values rather than making the route vanish, so that
/// <see cref="HostSecret.TryValidate"/> refuses the host. Reading a half-written route as "no route"
/// would turn a Spelunk host into one that is dialled directly, which is the one reading of a damaged
/// payload that does something the user never asked for.
/// </remarks>
private static SpelunkRoute? RouteFrom(SpelunkPayloadDocument? parsed) =>
parsed is null
? null
: new SpelunkRoute
{
ApiBase = parsed.Api ?? string.Empty,
ServerId = parsed.ServerId ?? Guid.Empty,
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
// False folds to the default, as AsksForPassword's does: one state, one spelling.
SignInThroughSpelunk = parsed.SignIn is true,
};
}
/// <summary>
@@ -462,6 +528,40 @@ internal sealed class HostPayloadDocument
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
public PortForwardDocument?[]? PortForwards { get; set; }
/// <remarks>
/// Last, and null for a host that is dialled, for the reason <see cref="TagIds"/> gives. One nested object
/// rather than three loose properties, because the three are one decision — a server id without its
/// Spelunk means nothing — and an object makes "all or none" the shape of the JSON.
/// </remarks>
public SpelunkPayloadDocument? Spelunk { get; set; }
}
/// <summary>The serialised shape of a <see cref="SpelunkRoute"/>. Nullable because it models untrusted input.</summary>
internal sealed class SpelunkPayloadDocument
{
public string? Api { get; set; }
public Guid? ServerId { get; set; }
public Guid? ApiKeyCredentialId { get; set; }
/// <remarks>
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
/// </remarks>
public bool? SignIn { get; set; }
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
{
Api = route.ApiBase,
ServerId = route.ServerId,
ApiKeyCredentialId = route.ApiKeyCredentialId,
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
// field existed — the rule AsksForPassword follows.
SignIn = route.SignInThroughSpelunk ? true : null,
};
}
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
@@ -168,6 +168,18 @@ public static class HostSecretMerge
remote.AsksForPassword,
conflicts,
static asked => asked is true ? "a typed password" : "the group's binding"),
// Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to
// the credential holding its key — and it merges as one value for the reason the jump chain does:
// an API address from one side and a server id from the other would be a route neither user
// chose, to a server that may not exist on that Spelunk.
Spelunk = Field(
nameof(HostSecret.Spelunk),
ancestor.Spelunk,
local.Spelunk,
remote.Spelunk,
conflicts,
static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"),
};
private static string Text(
@@ -0,0 +1,92 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// A Spelunk API key and the one Spelunk it may be sent to, decrypted.
/// </summary>
/// <remarks>
/// <para>
/// <b>The address is what makes this an item of its own.</b> A key kept as a credential's password can be
/// sent anywhere a host names, and a link from Spelunk can be sent by anything on the machine — so a link
/// that is not already backed by a saved host has to stop and ask. A key stored with its address says, once
/// and in the keychain, where the user agreed it may go: a link from that Spelunk connects straight away, and
/// a host naming this key with any other address is refused at connect rather than sending it there. See
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
/// </para>
/// <para>
/// <b><see cref="ApiKey"/> is a password, and everything this codebase does about passwords applies to
/// it</b>, as it does to <see cref="ObjectStoreSecret.SecretAccessKey"/>: it is inside the encrypted payload,
/// the activity log records that it changed and never what it was, and the merge reports that it differed
/// rather than either value.
/// </para>
/// </remarks>
public sealed record SpelunkKeySecret : IVaultSecret
{
/// <summary>The longest API key stored.</summary>
/// <remarks>
/// Far beyond any real key, for the reason <see cref="SpelunkRoute.MaxApiBaseLength"/> exists: an
/// unbounded string is the shape a corrupted payload takes, and this one goes into a request header.
/// </remarks>
public const int MaxApiKeyLength = 4096;
/// <summary>What the user calls this key.</summary>
public required string Label { get; init; }
/// <summary>
/// The Spelunk API this key is for, as <see cref="SpelunkRoute.TryNormalizeApiBase"/> writes it, and the
/// only address it is ever sent to.
/// </summary>
/// <remarks>
/// Normalised for the reason <see cref="SpelunkRoute.ApiBase"/> is: it is compared, byte for byte, with
/// the address a host or a link names.
/// </remarks>
public required string ApiBase { get; init; }
/// <summary>The API key itself, as Spelunk issued it.</summary>
public required string ApiKey { get; init; }
/// <summary>Whether this is storable, and why not if it is not.</summary>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(Label))
{
reason = "A Spelunk key needs a name.";
return false;
}
if (string.IsNullOrWhiteSpace(ApiBase))
{
reason = "A Spelunk key needs the address of the Spelunk it is for.";
return false;
}
if (!SpelunkRoute.TryNormalizeApiBase(ApiBase, out var normalized, out reason))
{
return false;
}
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
{
reason = "The Spelunk API address is not in its normal form.";
return false;
}
if (string.IsNullOrWhiteSpace(ApiKey))
{
reason = "A Spelunk key needs the key.";
return false;
}
// A header value cannot hold a line break, and a key with one would fail at the first connect with an
// HTTP error that names neither the key nor this item.
if (ApiKey.Length > MaxApiKeyLength || ApiKey.Any(char.IsControl))
{
reason = "That does not look like a Spelunk API key.";
return false;
}
reason = null;
return true;
}
}
@@ -0,0 +1,112 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Domain;
/// <summary>A decoded Spelunk key payload, together with the schema version it was written at.</summary>
/// <param name="Key">The key.</param>
/// <param name="SchemaVersion">The version the writing client used.</param>
public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion)
{
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion;
}
/// <summary>
/// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload.
/// </summary>
/// <remarks>
/// Mirrors <see cref="ObjectStoreSecretCodec"/>, for the same reasons and with the same guarantees.
/// </remarks>
public static class SpelunkKeySecretCodec
{
/// <summary>The schema version this build writes.</summary>
public const int CurrentSchemaVersion = 1;
/// <summary>Serialises a key to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The key is not valid for storage.</exception>
public static byte[] Encode(SpelunkKeySecret key)
{
ArgumentNullException.ThrowIfNull(key);
if (!key.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(key));
}
var document = new SpelunkKeyPayloadDocument
{
SchemaVersion = CurrentSchemaVersion,
Label = key.Label,
ApiBase = key.ApiBase,
ApiKey = key.ApiKey,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
}
/// <summary>Parses a decrypted payload.</summary>
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
public static bool TryDecode(
ReadOnlySpan<byte> payload,
[NotNullWhen(true)] out SpelunkKeySecretDocument? document)
{
document = null;
SpelunkKeyPayloadDocument? parsed;
try
{
parsed = JsonSerializer.Deserialize(
payload, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
}
catch (JsonException)
{
return false;
}
if (parsed is null || parsed.SchemaVersion < 1)
{
return false;
}
var candidate = new SpelunkKeySecret
{
Label = parsed.Label ?? string.Empty,
ApiBase = parsed.ApiBase ?? string.Empty,
ApiKey = parsed.ApiKey ?? string.Empty,
};
// A key whose address is not in its normal form is refused rather than normalised here: the address
// is what decides where the key may go, and a payload that disagrees with how every client writes it
// is not one to guess about.
if (!candidate.TryValidate(out _))
{
return false;
}
document = new SpelunkKeySecretDocument(candidate, parsed.SchemaVersion);
return true;
}
}
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
internal sealed class SpelunkKeyPayloadDocument
{
public int SchemaVersion { get; set; }
public string? Label { get; set; }
public string? ApiBase { get; set; }
public string? ApiKey { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SpelunkKeyPayloadDocument))]
internal sealed partial class SpelunkKeyPayloadJsonContext : JsonSerializerContext;
@@ -0,0 +1,89 @@
namespace DodoSSH.Client.Domain;
/// <summary>The merged Spelunk key, and everything that had to be overridden to produce it.</summary>
/// <param name="Merged">The key to store and push.</param>
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
public sealed record SpelunkKeyMergeResult(
SpelunkKeySecret Merged,
IReadOnlyList<HostFieldConflict> Conflicts)
{
/// <summary>Whether anything had to be overridden.</summary>
public bool HasConflicts => Conflicts.Count > 0;
}
/// <summary>
/// Merges two divergent versions of a Spelunk key against the version they both started from.
/// </summary>
/// <remarks>
/// <para>
/// <b>The address and the key merge as one value, not two.</b> The pair is the whole meaning of this item —
/// "this key may go to this Spelunk" — and merging them apart could keep one device's new address beside the
/// other device's new key, sending a key to a Spelunk nobody ever paired it with. So when both sides changed
/// the pair, one side's pair is kept whole.
/// </para>
/// <para>
/// <b>The key never reaches the conflict log</b>, for the reason a secret access key does not: a discarded
/// one is very often still live. The addresses are shown, because which Spelunk the kept key goes to is the
/// whole content of the notice.
/// </para>
/// </remarks>
public static class SpelunkKeySecretMerge
{
/// <summary>The name a conflict over the address and key is recorded under.</summary>
public const string PairField = "ApiBaseAndKey";
/// <summary>Produces the merged key.</summary>
/// <param name="ancestor">The version both sides branched from.</param>
/// <param name="local">The pending local version.</param>
/// <param name="remote">The server's current version.</param>
public static SpelunkKeyMergeResult Merge(
SpelunkKeySecret ancestor,
SpelunkKeySecret local,
SpelunkKeySecret remote)
{
ArgumentNullException.ThrowIfNull(ancestor);
ArgumentNullException.ThrowIfNull(local);
ArgumentNullException.ThrowIfNull(remote);
var conflicts = new List<HostFieldConflict>();
var label = ThreeWayMerge.Scalar(ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
if (label.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
nameof(SpelunkKeySecret.Label),
MergeSide.Local,
label.Value,
label.Discarded ?? "(none)",
DiscardedWasRemoval: false));
}
// A tuple of two strings compares both ordinally, which is the comparison each field needs.
var pair = ThreeWayMerge.Scalar(
(ancestor.ApiBase, ancestor.ApiKey),
(local.ApiBase, local.ApiKey),
(remote.ApiBase, remote.ApiKey));
if (pair.IsConflicted)
{
conflicts.Add(new HostFieldConflict(
PairField,
MergeSide.Local,
$"{pair.Value.ApiBase} (with its key)",
string.Equals(pair.Discarded.ApiBase, pair.Value.ApiBase, StringComparison.Ordinal)
? "(a different key for the same address was discarded)"
: $"{pair.Discarded.ApiBase} (with its key)",
DiscardedWasRemoval: false));
}
var merged = new SpelunkKeySecret
{
Label = label.Value,
ApiBase = pair.Value.ApiBase,
ApiKey = pair.Value.ApiKey,
};
return new SpelunkKeyMergeResult(merged, conflicts);
}
}
+192
View File
@@ -0,0 +1,192 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way.
/// </summary>
/// <remarks>
/// <para>
/// Spelunk manages servers through an agent that runs on each of them and keeps a connection open to
/// Spelunk. A host carrying one of these is never dialled: a connection to it asks that Spelunk for a
/// ticket, opens a WebSocket, and Spelunk hands the bytes to the agent, which connects to the SSH server on
/// the machine's own loopback. That reaches a server with no public SSH port at all — which is the case
/// this exists for — and it is why nothing here names an address to dial.
/// </para>
/// <para>
/// <b>Inside the host's encrypted payload, and nowhere else.</b> Unlike the relay, nothing about this has to
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
/// </para>
/// <para>
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> gives: one Spelunk key is the key for
/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget.
/// That item is a <see cref="SpelunkKeySecret"/>, which also says the one address the key may go to, or —
/// for a host set up before those existed — an ordinary credential whose password is the key.
/// </para>
/// </remarks>
public sealed record SpelunkRoute
{
/// <summary>The longest API address stored.</summary>
/// <remarks>
/// Far beyond any real address, and there for the reason <see cref="HostSecret.MaxPinnedPathLength"/>
/// is: this is rendered by every device that decrypts the host, and an unbounded string is the shape a
/// corrupted payload takes.
/// </remarks>
public const int MaxApiBaseLength = 512;
/// <summary>
/// The Spelunk API's base address, as <see cref="TryNormalizeApiBase"/> writes it — for example
/// <c>https://api.spelunk.example.com</c>.
/// </summary>
/// <remarks>
/// A string in its normalised form rather than a <see cref="Uri"/>, because this is compared: two hosts
/// on the same Spelunk must say so in the same bytes, or a link from Spelunk would not find the host
/// already saved for it and the merge would see a change nobody made.
/// </remarks>
public required string ApiBase { get; init; }
/// <summary>The server's id in Spelunk.</summary>
public required Guid ServerId { get; init; }
/// <summary>
/// The keychain item holding the Spelunk API key: a <see cref="SpelunkKeySecret"/>, or a credential whose
/// password is the key.
/// </summary>
/// <remarks>
/// <para>
/// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so
/// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose
/// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage.
/// </para>
/// <para>
/// Required rather than nullable. A route with no key can never be opened, and a host saved that way
/// would refuse every connection with a sentence about something the editor let through. The reference
/// can still dangle — the credential may be deleted on another machine — and that is refused where it
/// is noticed, at connect, exactly as a missing SSH credential is.
/// </para>
/// </remarks>
public required Guid ApiKeyCredentialId { get; init; }
/// <summary>
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
/// </summary>
/// <remarks>
/// <para>
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
/// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on
/// the machine whose only authorised key is that one, and says which account it logs in as. It is real
/// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host,
/// and the key is gone when the connection is.
/// </para>
/// <para>
/// <b>What the host says about logging in is then not asked.</b> Its username, key, credential and
/// typed-password choice — its own or a group's — are left alone rather than refused, because they are
/// the host's answer for the day this is unticked, and refusing a host that carries them would make a
/// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path
/// never reads one for a host that has it. See <c>VaultViewModel.TryBuildAuthentication</c>.
/// </para>
/// <para>
/// Written from <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>, and only when true, so a route
/// without it encodes exactly as it did before it existed.
/// </para>
/// </remarks>
public bool SignInThroughSpelunk { get; init; }
/// <summary>Whether this is the route to the given server on the given Spelunk.</summary>
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
/// <param name="serverId">The server's id in Spelunk.</param>
public bool IsFor(string apiBase, Guid serverId) =>
ServerId == serverId && string.Equals(ApiBase, apiBase, StringComparison.Ordinal);
/// <summary>Checks that this route can be stored and opened.</summary>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (!TryNormalizeApiBase(ApiBase, out var normalized, out reason))
{
return false;
}
// Stored normalised or not at all, for the comparison the remarks on ApiBase depend on.
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
{
reason = "The Spelunk API address is not in its normal form.";
return false;
}
if (ServerId == Guid.Empty)
{
reason = "A host reached through Spelunk needs the id of its Spelunk server.";
return false;
}
if (ApiKeyCredentialId == Guid.Empty)
{
reason = "A host reached through Spelunk needs its Spelunk API key chosen.";
return false;
}
reason = null;
return true;
}
/// <summary>
/// Reads a Spelunk API address as a user or a link gives it, into the one form it is stored in.
/// </summary>
/// <remarks>
/// <para>
/// <b>HTTPS, or plain HTTP to this machine only.</b> The API key travels in a header on every tunnel, so
/// an address that sends it in clear across a network is refused rather than stored. A Spelunk running
/// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the
/// feature can be tried against one.
/// </para>
/// <para>
/// No user name, password, query or fragment: none of them means anything to the API, and the first two
/// are the classic way to make an address read as one host while naming another. What is left is the
/// scheme, the authority and any path prefix, lower-cased where URLs are case-insensitive and without a
/// trailing slash — so the same Spelunk typed twice is stored the same way twice.
/// </para>
/// </remarks>
public static bool TryNormalizeApiBase(
string? text,
[NotNullWhen(true)] out string? apiBase,
[NotNullWhen(false)] out string? reason)
{
apiBase = null;
var trimmed = text?.Trim() ?? string.Empty;
if (trimmed.Length == 0)
{
reason = "A host reached through Spelunk needs the address of the Spelunk API.";
return false;
}
if (trimmed.Length > MaxApiBaseLength
|| !Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|| uri.Scheme is not ("https" or "http")
|| uri.HostNameType is UriHostNameType.Unknown
|| string.IsNullOrEmpty(uri.Host))
{
reason = "The Spelunk API address must be a web address, like https://api.spelunk.example.com.";
return false;
}
if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback)
{
reason = "The Spelunk API address must use https, because the API key is sent with every connection.";
return false;
}
if (uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0)
{
reason = "The Spelunk API address cannot carry a user name, a query or a fragment.";
return false;
}
apiBase = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
reason = null;
return true;
}
}
@@ -0,0 +1,37 @@
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Session;
/// <summary>
/// A way to a host that is not a dial: one connection long, handed to SSH.NET as a loopback proxy.
/// </summary>
/// <remarks>
/// <para>
/// Two things answer to this — the server relay (<see cref="RelayRoute"/>) and a Spelunk tunnel
/// (<see cref="SpelunkTunnelRoute"/>) — and both are the same shape underneath: a WebSocket carrying the
/// SSH bytes, and a <see cref="RelayBridge"/> that turns it into an address SSH.NET can be given. What the
/// connect path needs from either is exactly that address, the reason the far end gave if it hung up, and a
/// way to let go of it when the connect never got as far as handing it over.
/// </para>
/// <para>
/// Handing <see cref="Proxy"/> to a connection hands the bridge over with it, so the connection's own
/// disposal is what closes the route. <see cref="IAsyncDisposable.DisposeAsync"/> is for a caller that ends
/// up not connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
/// </para>
/// </remarks>
public interface IConnectionRoute : IAsyncDisposable
{
/// <summary>The loopback proxy to connect through, which owns the route from then on.</summary>
SshLoopbackProxy Proxy { get; }
/// <summary>
/// What the far end said when it closed, in words a user can act on; null when it has not closed, or
/// closed with nothing to say.
/// </summary>
/// <remarks>
/// For a connect that failed. SSH.NET reports a route that hung up as a stream that ended, which reads
/// exactly like the direct connection the route was chosen to get around — so the route's own reason,
/// when it gave one, replaces it.
/// </remarks>
string? Failure { get; }
}
+1 -1
View File
@@ -40,7 +40,7 @@ public interface IServerRelay
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
/// </remarks>
public sealed class RelayRoute : IAsyncDisposable
public sealed class RelayRoute : IConnectionRoute
{
private readonly RelayBridge bridge;
+209
View File
@@ -0,0 +1,209 @@
using System.Diagnostics.CodeAnalysis;
using System.Text;
using DodoSSH.Client.Domain;
namespace DodoSSH.Client.Session;
/// <summary>
/// A <c>dodossh://spelunk/connect</c> link from Spelunk's web interface, read and checked.
/// </summary>
/// <remarks>
/// <para>
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
/// <c>dodossh://spelunk/connect?api=…&amp;server=…&amp;name=…&amp;host=…</c>. It says which Spelunk and which
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: the
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
/// supply somebody else's.
/// </para>
/// <para>
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
/// </para>
/// </remarks>
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
/// <param name="ServerId">The server's id in Spelunk.</param>
/// <param name="Name">What Spelunk calls the server, cleaned for display.</param>
/// <param name="Host">The server's public address as Spelunk knows it, or null when it gave none.</param>
public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host)
{
/// <summary>The URL scheme DodoSSH registers.</summary>
public const string Scheme = "dodossh";
/// <summary>The longest link read at all. Anything longer is refused before it is parsed.</summary>
/// <remarks>
/// A real link is a few hundred characters. The bound is what a second launch may put down the doorbell
/// pipe, and what the parser is asked to walk — neither of which should be up to the sender.
/// </remarks>
public const int MaxLength = 4096;
/// <summary>The longest name kept. Longer is cut, not refused: it is only a label.</summary>
public const int MaxNameLength = 100;
private const string Authority = "spelunk";
private const string Path = "/connect";
/// <summary>
/// The hostname a new host from this link is given: Spelunk's address for the server, or a name made
/// from its id when there is none.
/// </summary>
/// <remarks>
/// It is not dialled — the tunnel ignores it — but the host key is pinned under it, so it has to be
/// stable for this server and unlike any other. The id is both; the address is also what the user would
/// recognise, and a key already trusted for a direct connection to that address is found again.
/// </remarks>
public string Hostname => Host ?? $"spelunk-{ServerId}";
/// <summary>Whether a string looks like it is meant to be one of these, before checking it is one.</summary>
/// <remarks>
/// For the launcher, which is handed arguments of every kind and needs to tell a link that failed the
/// checks from something that was never a link.
/// </remarks>
public static bool LooksLikeOne(string? text) =>
text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase);
/// <summary>Reads a link, or refuses it.</summary>
/// <remarks>
/// <para>
/// Strict on everything that decides where anything goes: the scheme, <c>spelunk</c> as the authority
/// with no user, password or port, <c>/connect</c> as the path, no fragment, each known parameter at
/// most once, an API address <see cref="SpelunkRoute.TryNormalizeApiBase"/> accepts, and a server id
/// that is a real one. Unknown parameters are ignored rather than refused, so a newer Spelunk can add
/// one without every existing DodoSSH ignoring its links outright.
/// </para>
/// <para>
/// Lenient only on the name, which is display text: control characters become spaces and a long one is
/// cut. A missing name falls back to the host, then to a plain one.
/// </para>
/// </remarks>
public static bool TryParse(string? text, [NotNullWhen(true)] out SpelunkLink? link)
{
link = null;
if (text is null
|| text.Length > MaxLength
|| text.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|| !Uri.TryCreate(text, UriKind.Absolute, out var uri)
|| !string.Equals(uri.Scheme, Scheme, StringComparison.Ordinal)
|| !string.Equals(uri.Host, Authority, StringComparison.Ordinal)
|| uri.UserInfo.Length > 0
|| !uri.IsDefaultPort
|| !string.Equals(uri.AbsolutePath, Path, StringComparison.Ordinal)
|| uri.Fragment.Length > 0
|| !TryReadQuery(QueryOf(text), out var parameters)
|| !parameters.TryGetValue("api", out var api)
|| !SpelunkRoute.TryNormalizeApiBase(api, out var apiBase, out _)
|| !parameters.TryGetValue("server", out var server)
|| !Guid.TryParseExact(server, "D", out var serverId)
|| serverId == Guid.Empty)
{
return false;
}
string? host = null;
if (parameters.TryGetValue("host", out var given) && given.Trim() is { Length: > 0 } trimmed)
{
if (trimmed.Length > 253 || Uri.CheckHostName(trimmed) is UriHostNameType.Unknown)
{
return false;
}
host = trimmed;
}
var name = Clean(parameters.GetValueOrDefault("name")) ?? host ?? "Spelunk server";
link = new SpelunkLink(apiBase, serverId, name, host);
return true;
}
/// <summary>The link written out again, in the one form <see cref="TryParse"/> reads back to the same value.</summary>
/// <remarks>
/// What a second launch sends the running instance, so the instance parses a link this build wrote rather
/// than whatever the browser handed over.
/// </remarks>
public override string ToString()
{
var builder = new StringBuilder($"{Scheme}://{Authority}{Path}?api=")
.Append(Uri.EscapeDataString(ApiBase))
.Append("&server=")
.Append(ServerId.ToString("D"))
.Append("&name=")
.Append(Uri.EscapeDataString(Name));
if (Host is not null)
{
builder.Append("&host=").Append(Uri.EscapeDataString(Host));
}
return builder.ToString();
}
/// <summary>
/// Splits a query into its known parameters, refusing one that names any of them twice.
/// </summary>
/// <remarks>
/// By hand rather than through a web utility, because this project has none and because the rule that
/// matters — twice is refused, not "last wins" — is one no general-purpose parser applies. <c>+</c> is a
/// space, as a browser's form encoding writes it.
/// </remarks>
private static bool TryReadQuery(string query, out Dictionary<string, string> parameters)
{
parameters = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
{
var equals = pair.IndexOf('=', StringComparison.Ordinal);
var key = equals < 0 ? pair : pair[..equals];
var value = equals < 0 ? string.Empty : pair[(equals + 1)..];
if (key is not ("api" or "server" or "name" or "host"))
{
continue;
}
string decoded;
try
{
decoded = Uri.UnescapeDataString(value.Replace('+', ' '));
}
catch (UriFormatException)
{
return false;
}
if (!parameters.TryAdd(key, decoded))
{
return false;
}
}
return true;
}
/// <summary>The query exactly as it was written, rather than as <see cref="Uri"/> re-escapes it.</summary>
/// <remarks>
/// <see cref="Uri"/> has no rules for a scheme it does not know, and how much of the query it unescapes
/// for one is its business. Taking the text after the first <c>?</c> means every parameter is decoded
/// exactly once, here.
/// </remarks>
private static string QueryOf(string text) =>
text.IndexOf('?', StringComparison.Ordinal) is var at and >= 0 ? text[(at + 1)..] : string.Empty;
private static string? Clean(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
var cleaned = new string([.. name.Trim().Select(character => char.IsControl(character) ? ' ' : character)]);
return cleaned.Length > MaxNameLength ? cleaned[..MaxNameLength].TrimEnd() : cleaned;
}
}
@@ -0,0 +1,81 @@
using System.Text;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Session;
/// <summary>
/// The throwaway key one connection signs in to a Spelunk server with.
/// </summary>
/// <remarks>
/// <para>
/// For a host set to sign in through Spelunk — see <c>SpelunkRoute.SignInThroughSpelunk</c> — each connection
/// makes one of these, sends <see cref="PublicKeyLine"/> with its ticket request, and authenticates with
/// <see cref="Credential"/> once the tunnel is up. Spelunk's agent runs a one-connection SSH server whose only
/// authorised key is this one, so the key is worth nothing after the connection it was made for.
/// </para>
/// <para>
/// <b>Made in memory and never kept.</b> Nothing writes it to a disk or to the vault, and nothing logs it:
/// <see cref="ToString"/> says what it is and nothing of what it holds. Ed25519, which is what
/// <see cref="SshKeyGenerator"/> makes instantly and what every OpenSSH the agent could start accepts.
/// </para>
/// </remarks>
public sealed class SpelunkSignIn
{
/// <summary>The account Spelunk signs in as when its answer does not name one.</summary>
public const string DefaultUser = "root";
/// <summary>The comment the public key carries, which is what Spelunk and the server's logs see.</summary>
internal const string KeyComment = "dodossh-spelunk-sign-in";
private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential)
{
PublicKeyLine = publicKeyLine;
Credential = credential;
}
/// <summary>The public half, as one OpenSSH <c>authorized_keys</c> line: <c>ssh-ed25519 AAAA… comment</c>.</summary>
public string PublicKeyLine { get; }
/// <summary>The private half, in the form a connection authenticates with.</summary>
public SshPrivateKeyCredential Credential { get; }
/// <summary>Makes a fresh key pair for one connection.</summary>
public static SpelunkSignIn Create()
{
var key = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, KeyComment);
return new SpelunkSignIn(
key.PublicKeyLine.Trim(),
new SshPrivateKeyCredential(Encoding.UTF8.GetBytes(key.PrivateKeyArmour), Passphrase: null));
}
/// <inheritdoc />
/// <remarks>Says nothing of the key, so one that reaches a log by accident takes nothing with it.</remarks>
public override string ToString() => "Spelunk sign-in key";
}
/// <summary>
/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key.
/// </summary>
/// <remarks>
/// <para>
/// A host that signs in through Spelunk is resolved to a login before anything is opened — the same
/// resolution every host goes through — but its key does not exist until the tunnel is opened, and the
/// account is whatever Spelunk then says. This stands in for the key until the route replaces it, together
/// with the username, as the connection is made through it.
/// </para>
/// <para>
/// <b>It authenticates nothing.</b> The SSH layer knows only passwords and private keys, and refuses any other
/// credential before it dials — so a path that forgot to take the route's key fails closed rather than
/// sending something else in its place.
/// </para>
/// </remarks>
public sealed record SpelunkSignInPending : SshCredential
{
/// <summary>The one value; there is nothing in it to tell two apart.</summary>
public static SpelunkSignInPending Instance { get; } = new();
private SpelunkSignInPending()
{
}
}
+661
View File
@@ -0,0 +1,661 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Session;
/// <summary>
/// Spelunk's tunnel: the routes, the subprotocol and the close codes, as Spelunk defines them.
/// </summary>
/// <remarks>
/// <para>
/// The same two steps as the server relay's, and on purpose, because the bridge on this side is the relay's
/// and it only has to be told where to go. An authenticated POST decides everything — whose key, which
/// server, whether the key may — and answers with a ticket; the WebSocket that spends the ticket carries no
/// authority of its own, and after the upgrade every binary message is simply the next bytes of the SSH
/// stream, both ways, with nothing else in the socket at all.
/// </para>
/// <para>
/// This is Spelunk's contract, not DodoSSH's, which is why it lives here and not in
/// <c>DodoSSH.Contracts</c>: nothing on the DodoSSH server takes part in a Spelunk tunnel.
/// </para>
/// </remarks>
public static class SpelunkTunnelProtocol
{
/// <summary>The only subprotocol this version speaks.</summary>
public const string SubProtocol = "spelunk.tunnel.v1";
/// <summary>The prefix of the second subprotocol an upgrade offers, which carries the ticket.</summary>
/// <remarks>
/// A header rather than the query string, for the reason the relay gives: a URL is what proxies, access
/// logs and traces keep. Spelunk echoes <see cref="SubProtocol"/> alone.
/// </remarks>
public const string TicketProtocolPrefix = "ticket.";
/// <summary>
/// Close code for an agent that could not connect to the port on its own machine — or that is too old to
/// know what a tunnel is, in which case the reason says <see cref="UnknownStreamKind"/>.
/// </summary>
public const int TargetUnreachableCloseCode = 4502;
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
public const int AgentDisconnectedCloseCode = 4503;
/// <summary>
/// What an agent that predates tunnels — or, asked to sign in, predates that — says, inside a
/// <see cref="TargetUnreachableCloseCode"/>.
/// </summary>
internal const string UnknownStreamKind = "Unknown stream kind";
/// <summary>Where a ticket for one server is asked for.</summary>
internal static string TicketsPath(Guid serverId) =>
string.Create(CultureInfo.InvariantCulture, $"/api/servers/{serverId}/tunnel-tickets");
}
/// <summary>
/// Opens SSH connections to Spelunk-managed servers through Spelunk.
/// </summary>
public interface ISpelunkTunnel
{
/// <summary>
/// Opens a tunnel to one server, ready for a connection to be made through it.
/// </summary>
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
/// <param name="serverId">The server's id in Spelunk.</param>
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
/// <param name="port">
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
/// agent starts an SSH server of its own and Spelunk ignores it.
/// </param>
/// <param name="signIn">
/// The throwaway key to have Spelunk sign in with, or null for a plain tunnel to <paramref name="port"/>.
/// Its public half goes with the ticket request; the route hands the whole of it back, with the account
/// Spelunk named, for the connection made through it.
/// </param>
/// <param name="cancellationToken">Cancellation.</param>
/// <remarks>
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
/// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
/// use it and never ahead of one. The key is a parameter rather than a field of some request record
/// because a record prints its fields, and a key has no business in anything that might be logged.
/// </remarks>
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
Task<SpelunkTunnelRoute> OpenAsync(
string apiBase,
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken);
}
/// <summary>
/// An open Spelunk tunnel, one connection long.
/// </summary>
/// <remarks>
/// The same ownership as <see cref="RelayRoute"/>, because it is the same bridge: see
/// <see cref="IConnectionRoute"/>.
/// </remarks>
public sealed class SpelunkTunnelRoute : IConnectionRoute
{
/// <summary>The longest stretch of Spelunk's own close reason that is repeated to the user.</summary>
private const int MaxReasonLength = 200;
private readonly RelayBridge bridge;
private readonly int port;
internal SpelunkTunnelRoute(RelayBridge bridge, int port, SpelunkSignIn? signIn = null, string? user = null)
{
this.bridge = bridge;
this.port = port;
SignIn = signIn;
User = signIn is null ? null : user ?? SpelunkSignIn.DefaultUser;
}
/// <inheritdoc />
public SshLoopbackProxy Proxy => bridge.Proxy;
/// <summary>The key Spelunk was asked to sign in with, or null for a plain tunnel.</summary>
/// <remarks>
/// Handed back rather than kept by whoever asked, so the connection made through this route takes its
/// credential from the same place it takes its proxy, and cannot be given one without the other.
/// </remarks>
public SpelunkSignIn? SignIn { get; }
/// <summary>
/// The account Spelunk signs in as — its answer's <c>user</c>, or <see cref="SpelunkSignIn.DefaultUser"/>
/// when it named none — or null for a plain tunnel.
/// </summary>
public string? User { get; }
/// <inheritdoc />
/// <remarks>
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
/// account of that is the accurate one.
/// </remarks>
public string? Failure =>
bridge.CloseStatus is { } status
? Describe((int)status, bridge.CloseDescription, port, signingIn: SignIn is not null)
: null;
/// <inheritdoc />
public ValueTask DisposeAsync() => bridge.DisposeAsync();
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
internal static string? Describe(int code, string? reason, int port, bool signingIn = false) => code switch
{
SpelunkTunnelProtocol.TargetUnreachableCloseCode
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
signingIn
? "The Spelunk agent on this server is too old to sign you in. Update the agent, then try again."
: "The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
// Signing in, the agent connects to nothing: it starts an SSH server of its own, and what it could not
// do is that — most often because the server has no OpenSSH server to start, which Spelunk's reason
// says in its own words.
SpelunkTunnelProtocol.TargetUnreachableCloseCode when signingIn =>
"The Spelunk agent could not sign you in on this server"
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
+ ". Signing in through Spelunk needs OpenSSH's server (sshd) installed there.",
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
string.Create(
CultureInfo.InvariantCulture,
$"The Spelunk agent could not connect to port {port} on the server itself")
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
+ ". Check that the SSH server is running and listening on that port.",
SpelunkTunnelProtocol.AgentDisconnectedCloseCode =>
"The Spelunk agent on this server disconnected from Spelunk. It may be restarting; try again in a "
+ "moment.",
(int)WebSocketCloseStatus.EndpointUnavailable => "Spelunk is restarting. Try again in a moment.",
_ => null,
};
/// <summary>
/// Spelunk's own words, cut to one line of printable text, or null when there are none worth showing.
/// </summary>
/// <remarks>
/// Shown because they are specific — "connection refused" and "no route to host" are different problems
/// on the server — and trimmed because they are another program's output going onto a status line.
/// </remarks>
internal static string? Readable(string? text)
{
if (string.IsNullOrWhiteSpace(text))
{
return null;
}
var builder = new StringBuilder(Math.Min(text.Length, MaxReasonLength));
foreach (var character in text.Trim())
{
if (builder.Length == MaxReasonLength)
{
builder.Append('…');
break;
}
builder.Append(char.IsControl(character) ? ' ' : character);
}
return builder.ToString();
}
}
/// <summary>A Spelunk tunnel that could not be opened, with the reason in its message.</summary>
/// <remarks>
/// Its own type for the reason <see cref="ServerRelayException"/> is one: the connect path must be able to
/// tell a refusal, which already says exactly what happened, from a connection failure it might explain by
/// probing — and probing a Spelunk host would dial an address that exists only to name its key.
/// </remarks>
public sealed class SpelunkTunnelException : Exception
{
/// <summary>A refusal with nothing underneath it.</summary>
public SpelunkTunnelException(string message)
: base(message)
{
}
/// <summary>A refusal and what caused it.</summary>
public SpelunkTunnelException(string message, Exception innerException)
: base(message, innerException)
{
}
/// <summary>A refusal Spelunk answered with a status.</summary>
public SpelunkTunnelException(string message, HttpStatusCode statusCode)
: base(message) =>
StatusCode = statusCode;
/// <summary>What Spelunk answered with, or null where the refusal came from somewhere else.</summary>
/// <remarks>Kept for tests and logs; the screen reads the message.</remarks>
public HttpStatusCode? StatusCode { get; }
}
/// <summary>
/// The Spelunk tunnel: a ticket from the API, a socket, and the relay's bridge.
/// </summary>
/// <remarks>
/// <para>
/// Every way this can fail is turned into a sentence here, as <see cref="ServerRelay"/> does for the relay,
/// because the status codes are Spelunk's and the one place that should know what each means is the one
/// that asked.
/// </para>
/// <para>
/// <b>Nothing from Spelunk's answer is trusted to go anywhere else.</b> The socket is opened on the API's
/// own scheme, host and port, with only the path taken from the answer — and only a path that is one — so a
/// ticket response cannot send this client, or the ticket, to a different machine.
/// </para>
/// </remarks>
public sealed class SpelunkTunnel : ISpelunkTunnel
{
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
private const int MaxTicketLength = 2048;
/// <summary>The longest account name taken from Spelunk's answer — well past what a Unix system allows.</summary>
private const int MaxUserLength = 64;
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
private const int MaxProblemBytes = 16 * 1024;
/// <summary>
/// One transport for the process, as the platform advises for <see cref="HttpClient"/>.
/// </summary>
/// <remarks>
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody
/// watching "Connecting…" should hear that Spelunk is not answering while they still care.
/// </remarks>
private static readonly HttpClient SharedHttp = new(
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
{
Timeout = TimeSpan.FromSeconds(30),
};
private readonly HttpClient http;
private readonly Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect;
/// <summary>A tunnel over the process's shared transport, which is how the application builds one.</summary>
public SpelunkTunnel()
: this(SharedHttp, DialAsync)
{
}
/// <summary>A tunnel whose requests and socket are supplied, for tests.</summary>
internal SpelunkTunnel(
HttpClient http,
Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect)
{
this.http = http;
this.connect = connect;
}
/// <inheritdoc />
public async Task<SpelunkTunnelRoute> OpenAsync(
string apiBase,
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
{
throw new SpelunkTunnelException(reason);
}
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
.ConfigureAwait(false);
var socket = await connect(
SocketUrl(normalized, ticket.Path!),
[SpelunkTunnelProtocol.SubProtocol, SpelunkTunnelProtocol.TicketProtocolPrefix + ticket.Ticket],
cancellationToken)
.ConfigureAwait(false);
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port, signIn, ticket.User);
}
/// <summary>
/// Where the tunnel socket is: the API's own origin, on the WebSocket scheme, at the path Spelunk named.
/// </summary>
internal static Uri SocketUrl(string apiBase, string path)
{
var secure = apiBase.StartsWith("https://", StringComparison.Ordinal);
var rest = apiBase[(secure ? "https".Length : "http".Length)..];
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
}
/// <summary>
/// Trims the stored key and refuses one that could not be a header value.
/// </summary>
/// <remarks>
/// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and
/// one with a space in the middle would otherwise surface as a <see cref="FormatException"/> from inside
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
/// </remarks>
private static string Sanitise(string apiKey)
{
var trimmed = apiKey?.Trim() ?? string.Empty;
if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
{
throw new SpelunkTunnelException(
"The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one "
+ "unbroken run of characters starting with spelunk_; paste it as the credential's password.");
}
return trimmed;
}
private async Task<SpelunkTicketDocument> IssueTicketAsync(
string apiBase,
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(
HttpMethod.Post,
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
// do not need chunked encoding, and not every proxy in front of an API accepts it.
//
// Signing in sends the public key and no port: the agent starts an SSH server of its own rather than
// connecting to one, and Spelunk ignores the port beside a key anyway.
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
signIn is null
? new SpelunkTicketRequestDocument { Port = port }
: new SpelunkTicketRequestDocument
{
SignIn = new SpelunkSignInRequestDocument { PublicKey = signIn.PublicKeyLine },
},
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
};
HttpResponseMessage response;
try
{
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
catch (HttpRequestException exception)
{
throw new SpelunkTunnelException(
$"Spelunk could not be reached at {apiBase}: {exception.Message}",
exception);
}
catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested)
{
throw new SpelunkTunnelException(
$"Spelunk at {apiBase} did not answer in time. Try again in a moment.",
exception);
}
using (response)
{
if (!response.IsSuccessStatusCode)
{
var problem = await ReadProblemAsync(response, cancellationToken).ConfigureAwait(false);
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
}
return await ReadTicketAsync(response, signingIn: signIn is not null, cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
HttpResponseMessage response,
bool signingIn,
CancellationToken cancellationToken)
{
SpelunkTicketDocument? ticket;
try
{
ticket = await response.Content
.ReadFromJsonAsync(SpelunkTunnelJsonContext.Default.SpelunkTicketDocument, cancellationToken)
.ConfigureAwait(false);
}
catch (JsonException exception)
{
throw new SpelunkTunnelException(Unintelligible, exception);
}
// The ticket goes into a header as part of a subprotocol name, which may hold only token characters —
// base64url is all of those, and anything else would fail inside the WebSocket client instead. The
// path is appended to the API's own origin, so it has to be a path: "//elsewhere" or "@elsewhere"
// would turn it into a different host.
if (ticket is not { Ticket: { Length: > 0 and <= MaxTicketLength } value, Path: { } path }
|| !value.All(IsBase64UrlCharacter)
|| !path.StartsWith('/')
|| path.StartsWith("//", StringComparison.Ordinal)
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|| (ticket.Subprotocol is { } named
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|| (signingIn && ticket.User is { } user && !IsAccountName(user)))
{
throw new SpelunkTunnelException(Unintelligible);
}
return ticket;
}
private const string Unintelligible =
"Spelunk answered the tunnel request in a way this app does not understand. Update DodoSSH, or check "
+ "that the API address is Spelunk's.";
private static bool IsBase64UrlCharacter(char character) =>
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
/// <summary>
/// Whether the account Spelunk named is one an SSH login could be for: a short run of the characters
/// Unix account names are made of.
/// </summary>
/// <remarks>
/// Checked because it goes to the server as the login and onto the tab and the status line, and an
/// answer carrying anything else is not one this client understands.
/// </remarks>
private static bool IsAccountName(string user) =>
user.Length is > 0 and <= MaxUserLength
&& user.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.');
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
{
var detail = SpelunkTunnelRoute.Readable(problem?.Detail);
return status switch
{
// Spelunk's validation says which field and why; for a sign-in it is the public key.
HttpStatusCode.BadRequest =>
"Spelunk would not accept the request to open the tunnel"
+ ((SpelunkTunnelRoute.Readable(PublicKeyError(problem))
?? detail
?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said
? $": {said}"
: ". Update DodoSSH, or check that the API address is Spelunk's."),
HttpStatusCode.Unauthorized =>
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
+ "Spelunk and paste it into the credential this host uses for it.",
// The detail is the useful half here: it says which role the key has and which it needs.
HttpStatusCode.Forbidden =>
"The Spelunk API key is not allowed to open a terminal on this server"
+ (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"),
HttpStatusCode.NotFound =>
"Spelunk does not know this server in the API key's organisation. It may have been removed, or "
+ "the key belongs to a different organisation.",
HttpStatusCode.ServiceUnavailable =>
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
+ "agent is running on the server, then try again.",
_ => string.Create(
CultureInfo.InvariantCulture,
$"Spelunk would not open the tunnel (HTTP {(int)status})")
+ ((detail ?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said ? $": {said}" : "."),
};
}
/// <summary>What a validation problem says about the sign-in key, if it says anything.</summary>
/// <remarks>
/// Matched on the end of the name and without regard to case, because a validator may name the field
/// <c>publicKey</c>, <c>PublicKey</c> or <c>signIn.publicKey</c>, and all three mean this one.
/// </remarks>
private static string? PublicKeyError(SpelunkProblemDocument? problem) =>
problem?.Errors?
.FirstOrDefault(field => field.Key.EndsWith("publicKey", StringComparison.OrdinalIgnoreCase))
.Value?
.FirstOrDefault();
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
try
{
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return body.Length is 0 or > MaxProblemBytes
? null
: JsonSerializer.Deserialize(body, SpelunkTunnelJsonContext.Default.SpelunkProblemDocument);
}
catch (JsonException)
{
return null;
}
}
/// <summary>Opens the tunnel socket, and translates a refused upgrade into its reason.</summary>
/// <remarks>
/// The status of a refused upgrade is collected, as the relay's dialler does. A 401 is a ticket Spelunk
/// would not take — spent, or expired on the way — and asking again is the remedy; a 503 is the agent
/// going away in the moment between the ticket and the socket.
/// </remarks>
private static async Task<WebSocket> DialAsync(
Uri url,
IReadOnlyList<string> protocols,
CancellationToken cancellationToken)
{
var socket = new ClientWebSocket();
try
{
foreach (var protocol in protocols)
{
socket.Options.AddSubProtocol(protocol);
}
socket.Options.CollectHttpResponseDetails = true;
await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
}
catch (WebSocketException exception)
{
var status = socket.HttpStatusCode;
socket.Dispose();
throw new SpelunkTunnelException(
status switch
{
HttpStatusCode.Unauthorized => "Spelunk refused the tunnel ticket. Try connecting again.",
HttpStatusCode.ServiceUnavailable => Describe(status, problem: null),
_ => $"The Spelunk tunnel could not be opened: {exception.Message}",
},
exception);
}
catch
{
socket.Dispose();
throw;
}
if (!string.Equals(socket.SubProtocol, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
{
socket.Abort();
socket.Dispose();
throw new SpelunkTunnelException(
"Spelunk answered the tunnel in a protocol this app does not speak. Update DodoSSH or Spelunk.");
}
return socket;
}
}
/// <summary>The body of a ticket request.</summary>
/// <remarks>
/// Either a port or a sign-in, never both: the one not asked for is null and left out of the JSON, so a plain
/// tunnel still asks for exactly <c>{"port":22}</c>.
/// </remarks>
internal sealed class SpelunkTicketRequestDocument
{
public int? Port { get; set; }
public SpelunkSignInRequestDocument? SignIn { get; set; }
}
/// <summary>What a sign-in sends: the one-line OpenSSH public key the agent's SSH server is to accept.</summary>
internal sealed class SpelunkSignInRequestDocument
{
public string? PublicKey { get; set; }
}
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
internal sealed class SpelunkTicketDocument
{
public string? Ticket { get; set; }
public DateTimeOffset? ExpiresAt { get; set; }
public string? Path { get; set; }
public string? Subprotocol { get; set; }
/// <summary>The account a sign-in logs in as; absent or null for a plain tunnel.</summary>
public string? User { get; set; }
}
/// <summary>The fields of an RFC 7807 problem this client repeats.</summary>
internal sealed class SpelunkProblemDocument
{
public string? Title { get; set; }
public string? Detail { get; set; }
/// <summary>A validation problem's messages by field, as ASP.NET Core writes them.</summary>
public Dictionary<string, string[]>? Errors { get; set; }
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
[JsonSerializable(typeof(SpelunkTicketDocument))]
[JsonSerializable(typeof(SpelunkProblemDocument))]
internal sealed partial class SpelunkTunnelJsonContext : JsonSerializerContext;
@@ -125,6 +125,7 @@ public sealed partial class VaultSession : IAsyncDisposable
Tags = new TagRepository(Items, Outbox, keyring, activity);
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
SpelunkKeys = new SpelunkKeyRepository(Items, Outbox, keyring, activity);
}
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
@@ -200,6 +201,13 @@ public sealed partial class VaultSession : IAsyncDisposable
/// </remarks>
public ObjectStoreRepository ObjectStores { get; }
/// <summary>Spelunk API keys and the addresses they may be sent to, decrypted.</summary>
/// <remarks>
/// Read with the rest of the vault, and at connect from that list, as a credential's password is. The
/// address in each is what lets a link from Spelunk connect without asking.
/// </remarks>
public SpelunkKeyRepository SpelunkKeys { get; }
/// <summary>The connections this vault has recorded, decrypted.</summary>
/// <remarks>
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
@@ -0,0 +1,61 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// The shell's half of a link from Spelunk: holding it until there is a vault to act on it.
/// </summary>
internal sealed partial class MainWindowViewModel
{
/// <summary>
/// Acts on a <c>dodossh://spelunk/connect</c> link now, or as soon as a vault is open.
/// </summary>
/// <remarks>
/// <para>
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
/// passed down the doorbell, or one the platform delivered to the running application. The link has
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
/// host already sends it to.
/// </para>
/// <para>
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
/// asked for the passphrase is the ordinary first half of this; losing the click at the second half would
/// make the button look broken. It is not kept across a restart.
/// </para>
/// </remarks>
internal void OpenLink(SpelunkLink link)
{
ArgumentNullException.ThrowIfNull(link);
pendingLink = link;
if (State is ShellState.Unlocked && Vault is not null)
{
DeliverPendingLink();
return;
}
if (State is ShellState.Locked)
{
StatusMessage = $"Unlock to open {link.Name} from Spelunk.";
}
}
/// <summary>Hands a waiting link to the open vault, on the Hosts screen where its answer appears.</summary>
private void DeliverPendingLink()
{
if (pendingLink is not { } link || Vault is not { } vault)
{
return;
}
pendingLink = null;
ShowScreen(ShellScreen.Hosts);
// Not awaited: a connect reports its own progress and failures on the vault's status line and in its
// tab, as a tap on the row does, and this is called from places that must not wait on a handshake.
_ = vault.OpenSpelunkLinkAsync(link);
}
}
@@ -360,6 +360,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
private readonly ISshReachabilityProbe? reachability;
/// <summary>What opens a Spelunk tunnel, handed to each vault this shell opens.</summary>
/// <remarks>
/// Held for the reason <see cref="reachability"/> is: the vault is built on unlock, not here. Null means
/// the real one, which is what both heads use; a test passes one so a Spelunk host's connect never leaves
/// the process.
/// </remarks>
private readonly ISpelunkTunnel? spelunk;
/// <summary>
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
/// </summary>
/// <remarks>
/// One, and the latest: a link is somebody pressing a button in Spelunk, and a second press while this
/// machine is still locked means the second server, not both. Delivered as soon as a vault opens, and
/// dropped if the application closes first — it is a request to do something now, not something to
/// remember.
/// </remarks>
private SpelunkLink? pendingLink;
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
private readonly Action<Action> post;
@@ -569,9 +588,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Action<Action>? post = null,
Func<string, Task>? openInEditor = null,
Func<string, Action, IDisposable>? watchFile = null,
ISshReachabilityProbe? reachability = null)
ISshReachabilityProbe? reachability = null,
ISpelunkTunnel? spelunk = null)
{
this.reachability = reachability;
this.spelunk = spelunk;
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
this.paths = paths;
@@ -2938,7 +2959,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
copyToClipboard,
connectionLog,
visibility,
reachability);
reachability,
spelunk);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
@@ -2954,19 +2976,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the load, because the switches are built from the vaults the session admitted and the
// keyring is filled during it — before, and a machine with a team vault would come up with one
// switch until something else rebuilt them.
RebuildVaultToggles();
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
AfterTheFirstLoad(Vault);
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
@@ -2984,6 +2994,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Vault.StartAutoSync();
}
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
/// <remarks>
/// Split out of <see cref="AdoptAsync"/> for length rather than for reuse; the order is still the one that
/// method's remarks call load-bearing, and it is kept here as it was there.
/// </remarks>
private void AfterTheFirstLoad(VaultViewModel vault)
{
// After the load, because the switches are built from the vaults the session admitted and the
// keyring is filled during it — before, and a machine with a team vault would come up with one
// switch until something else rebuilt them.
RebuildVaultToggles();
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
// After the hosts are loaded, because what a link does depends on whether one of them is already
// the server it names.
DeliverPendingLink();
}
/// <summary>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
@@ -1158,7 +1158,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal bool SelectedHostAsksForAPassword =>
ShowsHostPicker
&& (SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|| (!SelectedHost.Host.SignsInThroughSpelunk
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword));
// ---- The remote pane ----
@@ -1796,14 +1797,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await CloseSessionAsync().ConfigureAwait(true);
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
is not { } opened)
is not ({ } opened, { } dialled))
{
return;
}
session = opened;
TypedPassword = string.Empty;
MarkHostConnected(opened, request, row, identityLabel);
MarkHostConnected(opened, dialled, row, identityLabel);
reconnectTo = (RemoteKind.Host, row.EntityId);
@@ -1816,34 +1817,36 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
}
/// <summary>
/// Opens the session on a host, through the relay when the host uses it, or says why not.
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
/// </summary>
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
private async Task<ISftpSession?> OpenHostSessionAsync(
/// <returns>
/// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account
/// Spelunk named rather than the one the host resolved to — or null with <see cref="Status"/> saying why
/// there is none.
/// </returns>
private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync(
VaultViewModel open,
HostRowViewModel row,
SshConnectionRequest request,
CancellationToken cancellationToken)
{
RelayRoute? relay = null;
IConnectionRoute? route = null;
try
{
// Through the relay for a host set to use it, exactly as a shell to it would be. The
// session owns the relay from here, on success and on failure alike.
relay = row.Host.RelayEnabled
? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
: null;
// Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be.
// The session owns the route from here, on success and on failure alike.
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
return await sftp
.OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
.ConfigureAwait(true);
var through = VaultViewModel.Through(request, route);
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
}
catch (Exception exception)
when (relay?.Failure is { } failure
when (route?.Failure is { } failure
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
{
// See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
// See VaultViewModel.OpenThroughAsync: the route's reason is the true one.
Status = failure;
return null;
}
@@ -0,0 +1,543 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// Reaching a host through Spelunk: the editor's half, the connect path's half, and a link from Spelunk.
/// </summary>
/// <remarks>
/// A file of its own because it is one feature and this class is long enough without it. Everything here
/// follows the relay, which is the other route a host can take: a tick box in the editor, a route opened by
/// the connect that will use it, no falling back to a direct dial, and a stated failure when the route
/// cannot be opened. See <see cref="HostSecret.Spelunk"/> and <see cref="SpelunkRoute"/>.
/// </remarks>
internal sealed partial class VaultViewModel
{
/// <summary>The tag every host made from a Spelunk link wears, and what a link looks for.</summary>
/// <remarks>
/// Matched without regard to case, as the editor matches every tag name — see <c>AddEditorTagAsync</c>.
/// </remarks>
internal const string SpelunkTag = "spelunk";
/// <summary>What the status bar and the log name as the identity of a Spelunk sign-in.</summary>
private const string SpelunkSignInIdentity = "Spelunk sign-in";
/// <summary>What opens a tunnel when no caller supplied something else.</summary>
/// <remarks>
/// Static and shared for the reason <see cref="DefaultReachability"/> is: it holds nothing but a
/// transport, and every tunnel is a fresh ticket and a fresh socket. Optional in the constructor so both
/// heads and every existing test go on meaning what they did, and a test that wants to see what was
/// opened passes a substitute.
/// </remarks>
private static readonly ISpelunkTunnel DefaultSpelunk = new SpelunkTunnel();
/// <summary>Whether the host being edited is reached through Spelunk.</summary>
/// <remarks>
/// Ticking it unticks the relay and the other way round, rather than letting both be ticked and refusing
/// the save: a host has one route, and <see cref="HostSecret.TryValidate"/> would only catch after the
/// fact what these two boxes can simply not express.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
private bool editorThroughSpelunk;
/// <summary>Whether Spelunk signs the user in to the host being edited, so it needs no login of its own.</summary>
/// <remarks>
/// Inside the Spelunk card, and meaningful only while that is ticked. See
/// <see cref="SpelunkRoute.SignInThroughSpelunk"/>.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
private bool editorSpelunkSignIn;
/// <summary>The Spelunk API address in the editor, as typed.</summary>
[ObservableProperty]
private string editorSpelunkApi = string.Empty;
/// <summary>The Spelunk server id in the editor, as typed.</summary>
[ObservableProperty]
private string editorSpelunkServerId = string.Empty;
/// <summary>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
/// <remarks>
/// No "none" entry: a route with no key cannot be opened, and nothing selected is how a host made from a
/// link says it has not been chosen yet. A key the host names that has since been deleted keeps a
/// placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
/// </remarks>
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
[ObservableProperty]
private AuthenticationChoice? editorSelectedSpelunkKey;
/// <summary>
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
/// </summary>
/// <remarks>
/// The two controls are disabled rather than hidden while it is false, with a sentence saying why, so a
/// login somebody had chosen is still there to see — and comes back as it was if the box is unticked
/// before saving.
/// </remarks>
internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn);
/// <inheritdoc cref="DefaultSpelunk" />
private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk;
partial void OnEditorThroughSpelunkChanged(bool value)
{
if (value)
{
EditorRelayEnabled = false;
}
}
partial void OnEditorRelayEnabledChanged(bool value)
{
if (value)
{
EditorThroughSpelunk = false;
}
}
/// <summary>
/// Opens the route a host is reached by, or null for a host that is dialled.
/// </summary>
/// <remarks>
/// <para>
/// The one place that decides, for both transports: a shell comes through here from
/// <see cref="OpenThroughAsync"/>, and file transfer opens its own connection and comes through here from
/// the transfers screen — or that screen would fail for exactly the hosts a route exists for.
/// </para>
/// <para>
/// A host naming a route that cannot be opened is a refusal, never a quiet direct dial. See
/// <see cref="OpenRelayAsync"/> for the relay's half of that argument; Spelunk's is stronger still,
/// because a Spelunk host's address may be nothing that answers at all.
/// </para>
/// <para>
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
/// key and account replace the ones the host resolved to.
/// </para>
/// </remarks>
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(row);
if (row.Host.Spelunk is { } route)
{
return await OpenSpelunkTunnelAsync(row, route, cancellationToken).ConfigureAwait(true);
}
return row.Host.RelayEnabled
? await OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
: null;
}
/// <summary>
/// The request to connect with once a route is open: through its proxy, and for a Spelunk sign-in, with
/// the key the tunnel was opened with and the account Spelunk named.
/// </summary>
/// <remarks>
/// <para>
/// Both transports make their request through this, so a sign-in cannot reach one of them with the
/// stand-in credential <see cref="TryBuildAuthentication"/> gives it — which the SSH layer would refuse
/// before dialling anyway. See <see cref="SpelunkSignInPending"/>.
/// </para>
/// <para>
/// The host and port are left alone. The host key is pinned under them as for every host, and Spelunk's
/// SSH server presents the server's real host key, so a sign-in and a plain tunnel to one server are
/// checked against the same pin.
/// </para>
/// </remarks>
internal static SshConnectionRequest Through(SshConnectionRequest request, IConnectionRoute? route)
{
ArgumentNullException.ThrowIfNull(request);
return route switch
{
null => request,
SpelunkTunnelRoute { SignIn: { } signIn, User: { } user } => request with
{
Proxy = route.Proxy,
Username = user,
Credential = signIn.Credential,
},
_ => request with { Proxy = route.Proxy },
};
}
/// <summary>
/// Opens a Spelunk tunnel to a host, with the key it names.
/// </summary>
/// <remarks>
/// <para>
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
/// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
/// </para>
/// <para>
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
/// </para>
/// <para>
/// <b>A host Spelunk signs in to gets a new key pair here, every time.</b> It is made in memory for this
/// one tunnel, its public half goes with the ticket request, and the route carries it to the connection —
/// see <see cref="SpelunkSignIn"/>. A retry after a host key question, or a reconnect, comes back through
/// here and makes another.
/// </para>
/// </remarks>
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
HostRowViewModel row,
SpelunkRoute route,
CancellationToken cancellationToken)
{
var apiKey = SpelunkApiKeyFor(row, route);
return await Spelunk
.OpenAsync(
route.ApiBase,
route.ServerId,
apiKey,
row.Resolved.Port.Value,
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
/// </summary>
/// <returns>
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
/// </returns>
/// <remarks>
/// <para>
/// <b>A host for that server in the personal vault, tagged <see cref="SpelunkTag"/>, is connected to</b>,
/// exactly as a tap on its row would. Those are the hosts links make, and the only ones a link looks for:
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
/// </para>
/// <para>
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
/// SSH key or password — with the key saved for that Spelunk under Settings → Security, or else the key
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
/// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that
/// key's own organisation holds or Spelunk refuses it.
/// </para>
/// <para>
/// <b>The first link from a Spelunk with no key opens the editor instead, and nothing more.</b> Anything
/// on this machine can make a link, so it must never be able to send an API key to an address the user
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
/// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight
/// away.
/// </para>
/// </remarks>
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
{
ArgumentNullException.ThrowIfNull(link);
var personal = PersonalVaultId();
if (personal is { } vaultId
&& Hosts.FirstOrDefault(row =>
row.VaultId == vaultId
&& row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true
&& WearsSpelunkTag(row.Host)) is { } saved)
{
await ConnectToRowCommand.ExecuteAsync(saved).ConfigureAwait(true);
return;
}
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
if (personal is { } into && knownKey is { } key)
{
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
return;
}
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
}
/// <summary>The personal vault, when this session can read and write it.</summary>
private Guid? PersonalVaultId() =>
session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId;
/// <summary>Whether a host wears a tag called <see cref="SpelunkTag"/>, from whichever vault.</summary>
private bool WearsSpelunkTag(HostSecret host) =>
host.TagIds.Any(id => tagsById.TryGetValue(id, out var tag) && IsSpelunkTag(tag));
private static bool IsSpelunkTag(TagSecret tag) =>
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
/// </summary>
/// <remarks>
/// <para>
/// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and
/// otherwise the key a saved host already sends there, which is how the answer was reached before saved
/// keys existed and is still true of every host set up that way.
/// </para>
/// <para>
/// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and
/// only a key still in the keychain, because a dangling one would make a host that cannot connect.
/// </para>
/// </remarks>
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
SavedSpelunkKeyFor(apiBase, personal)?.EntityId
?? Hosts
.OrderByDescending(row => row.VaultId == personal)
.Select(row => row.Host.Spelunk)
.Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal))
.Select(route => (Guid?)route!.ApiKeyCredentialId)
.FirstOrDefault(id =>
Credentials.Any(credential => credential.EntityId == id)
|| SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal)));
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
{
Label = link.Name,
Hostname = link.Hostname,
Port = HostSecret.DefaultPort,
TagIds = tags,
Spelunk = new SpelunkRoute
{
ApiBase = link.ApiBase,
ServerId = link.ServerId,
ApiKeyCredentialId = apiKeyCredentialId,
SignInThroughSpelunk = true,
},
};
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
{
if (IsBusy)
{
// RunAsync would drop it without a word, and the user pressed a button somewhere else to get here.
Status = $"Busy — open {link.Name} from Spelunk again in a moment.";
return;
}
Guid? hostId = null;
await RunAsync(
$"Adding {link.Name} from Spelunk…",
async () =>
{
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
hostId = await session.Hosts
.CreateAsync(
vaultId,
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
CancellationToken.None)
.ConfigureAwait(true);
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
}).ConfigureAwait(true);
// A failure has said so on the status line already.
if (hostId is not { } id || Hosts.FirstOrDefault(row => row.EntityId == id) is not { } row)
{
return;
}
Status = $"Added {link.Name} from Spelunk at {link.ApiBase}.";
await ConnectToRowCommand.ExecuteAsync(row).ConfigureAwait(true);
// After the connect rather than before it: the host is saved either way, and the terminal is what the
// user is waiting for. As SaveHostAsync does, it goes to the server now rather than at the next tick.
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
}
/// <summary>
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
/// </summary>
/// <remarks>
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
/// made or found afterwards and added to the host being edited; a tag made for a host that is then
/// cancelled stays, as the editor's own new-tag box leaves it.
/// </remarks>
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
{
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
// is not worth somebody's unsaved host.
if (AHostEditorIsInTheWay())
{
return;
}
NewHost();
if (personal is { } vaultId
&& EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice)
{
EditorSelectedVault = choice;
}
EditorLabel = link.Name;
EditorHostname = link.Hostname;
EditorPort = HostSecret.DefaultPort;
EditorThroughSpelunk = true;
EditorSpelunkSignIn = true;
EditorSpelunkApi = link.ApiBase;
EditorSpelunkServerId = link.ServerId.ToString();
BuildSpelunkKeyChoices(knownKey);
IsHostPaneOpen = true;
var said = EditorSelectedSpelunkKey is null
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose your Spelunk API "
+ "key and save — Spelunk signs you in, so no SSH key is needed, and later links from this "
+ "Spelunk connect straight away. Saving the key under Settings → Security does the same."
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
Status = said;
if (personal is { } into)
{
await TagHostFromLinkAsync(into, said).ConfigureAwait(true);
}
}
/// <summary>Adds the <see cref="SpelunkTag"/> tag to the host a link opened the editor on.</summary>
/// <param name="vaultId">The personal vault, where the host is to be saved and the tag is.</param>
/// <param name="said">What the status line says about the link, put back after the reload.</param>
private async Task TagHostFromLinkAsync(Guid vaultId, string said)
{
try
{
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
// Only onto the host this link opened: the user may have cancelled it, or moved on to another, in
// the moment the tag took.
if (!IsEditing || editingEntityId is not null || editingHostVaultId != vaultId)
{
return;
}
editorTagIds = TagSet.Create([.. editorTagIds, tagId]);
if (!tagsById.ContainsKey(tagId))
{
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
}
BuildTagChoices();
Status = said;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The host can still be saved; it is the tag that could not be made, and a later link would then
// not find it. Said, rather than failing the whole link over it.
Status = $"{said} (The '{SpelunkTag}' tag could not be added: {exception.Message})";
}
}
/// <summary>The <see cref="SpelunkTag"/> tag in a vault, made there if it has none.</summary>
private async Task<Guid> EnsureSpelunkTagAsync(Guid vaultId, CancellationToken cancellationToken)
{
var listing = await session.Tags.ListAsync(vaultId, cancellationToken).ConfigureAwait(true);
if (listing.Items
.Where(tag => IsSpelunkTag(tag.Secret))
.OrderBy(tag => tag.EntityId)
.FirstOrDefault() is { } existing)
{
return existing.EntityId;
}
return await session.Tags
.CreateAsync(vaultId, new TagSecret { Label = SpelunkTag }, cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
/// holding its Spelunk API key.
/// </summary>
/// <remarks>
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
/// </remarks>
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
(!row.Host.SignsInThroughSpelunk
&& row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == entityId)
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
private void LoadEditorSpelunk(SpelunkRoute? route)
{
EditorThroughSpelunk = route is not null;
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
}
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
/// <remarks>
/// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made
/// for exactly this; then every credential, for a key kept as a password before saved keys existed.
/// </remarks>
private void BuildSpelunkKeyChoices(Guid? selectedId)
{
EditorSpelunkKeyChoices.Clear();
foreach (var key in SpelunkKeys)
{
EditorSpelunkKeyChoices.Add(new AuthenticationChoice(
AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}"));
}
foreach (var credential in Credentials)
{
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
}
if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing))
{
EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing));
}
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId);
}
/// <summary>The editor's route, or null when the box is not ticked.</summary>
/// <remarks>
/// What cannot be read is written as empty rather than left out, so that
/// <see cref="HostSecret.TryValidate"/> says what is wrong with it — "needs the id of its Spelunk server"
/// — instead of the save quietly producing a host that is dialled directly. The address is normalised
/// here when it can be, so <c>https://api.example.com/</c> saves as the same Spelunk as the link's.
/// </remarks>
private SpelunkRoute? BuildSpelunkRoute()
{
if (!EditorThroughSpelunk)
{
return null;
}
return new SpelunkRoute
{
ApiBase = SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var normalized, out _)
? normalized
: EditorSpelunkApi.Trim(),
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
SignInThroughSpelunk = EditorSpelunkSignIn,
};
}
}
@@ -0,0 +1,374 @@
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Sync;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One saved Spelunk key, as a row on the Security page.</summary>
/// <remarks>
/// Carries the decrypted key so the connect path needs no second decryption, and — as the credential row does
/// — exposes nothing of it to the view: the XAML binds the name, the address and a count.
/// </remarks>
internal sealed class SpelunkKeyRowViewModel(VaultItem<SpelunkKeySecret> key, Guid vaultId, string vaultName)
{
internal Guid EntityId => key.EntityId;
internal Guid VaultId { get; } = vaultId;
internal string VaultName { get; } = vaultName;
internal SpelunkKeySecret Key => key.Secret;
internal string Label => key.Secret.Label;
/// <summary>The one address this key is ever sent to.</summary>
internal string ApiBase => key.Secret.ApiBase;
/// <summary>The vault's name, when there is more than one to tell apart; empty otherwise.</summary>
internal string VaultBadge { get; init; } = string.Empty;
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>How many hosts reach Spelunk with this key, in words.</summary>
internal string UsedBy { get; init; } = string.Empty;
internal bool IsReadOnly => key.IsReadOnly;
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
}
/// <summary>
/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do.
/// </summary>
/// <remarks>
/// <para>
/// <b>A saved key says where it may go.</b> It is an API key and the one Spelunk address it belongs to, kept
/// in the keychain and synced like any secret. A host can name it where it would name a credential, and it is
/// then sent to that address and never to another — see <see cref="SpelunkApiKeyFor"/>. A link from that
/// Spelunk connects straight away, because saving the key is the user saying so — see
/// <see cref="KnownSpelunkKey"/>.
/// </para>
/// <para>
/// The form saves into the personal vault, and a second key for an address the personal vault already has a
/// key for replaces that key rather than adding a rival: one Spelunk, one key, which is also what makes the
/// answer to "which key does a link use?" not depend on anything the user cannot see.
/// </para>
/// </remarks>
internal sealed partial class VaultViewModel
{
/// <summary>Every Spelunk key this session can read, the personal vault's first.</summary>
internal ObservableCollection<SpelunkKeyRowViewModel> SpelunkKeys { get; } = [];
/// <summary>Whether there is any saved key to list.</summary>
internal bool HasSpelunkKeys => SpelunkKeys.Count > 0;
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
[ObservableProperty]
private string spelunkKeyFormApi = string.Empty;
/// <summary>The API key in the Security page's form, as pasted. Cleared the moment it is saved.</summary>
[ObservableProperty]
private string spelunkKeyFormKey = string.Empty;
/// <summary>What the form has to say about the last save, or empty.</summary>
/// <remarks>
/// Beside the form rather than only on the status line, because the Security page is a long way from the
/// status bar and a refusal nobody sees reads as a button that does nothing.
/// </remarks>
[ObservableProperty]
private string spelunkKeyFormNote = string.Empty;
/// <summary>Whether <see cref="SpelunkKeyFormNote"/> has anything in it.</summary>
internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0;
partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote));
/// <summary>
/// Picks the saved key for the address being typed, when nothing is chosen yet.
/// </summary>
/// <remarks>
/// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the
/// address would be choosing for them.
/// </remarks>
partial void OnEditorSpelunkApiChanged(string value)
{
if (EditorSelectedSpelunkKey is not null
|| !SpelunkRoute.TryNormalizeApiBase(value, out var apiBase, out _)
|| SavedSpelunkKeyFor(apiBase, PersonalVaultId()) is not { } saved)
{
return;
}
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == saved.EntityId);
}
/// <summary>Saves the key in the form for the address in the form.</summary>
[RelayCommand]
private async Task SaveSpelunkKeyAsync(CancellationToken cancellationToken)
{
if (!TryReadSpelunkKeyForm(out var vaultId, out var existing, out var key))
{
return;
}
var apiBase = key.ApiBase;
SpelunkKeyFormNote = string.Empty;
await RunAsync(
"Saving the Spelunk key…",
async () =>
{
if (existing is not null)
{
await session.SpelunkKeys
.UpdateAsync(vaultId, existing.EntityId, key, cancellationToken)
.ConfigureAwait(true);
}
else
{
await session.SpelunkKeys.CreateAsync(vaultId, key, cancellationToken).ConfigureAwait(true);
}
// The key leaves the form as soon as it is in the keychain; the address stays, as a reminder
// of what was just saved.
SpelunkKeyFormKey = string.Empty;
SpelunkKeyFormApi = apiBase;
await ReloadAsync(cancellationToken).ConfigureAwait(true);
var said = existing is null
? $"Saved the Spelunk key for {apiBase}. Links from that Spelunk now connect straight away."
: $"Replaced the Spelunk key for {apiBase}. Every host using it connects with the new one.";
Status = connection() is null ? $"{said} It will sync when you are online." : said;
SpelunkKeyFormNote = said;
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Reads the form into the key it would save, and where; or says on the form why it cannot.
/// </summary>
/// <param name="vaultId">The vault the key goes into: the personal one, when there is one.</param>
/// <param name="existing">The key already saved there for that address, which this one replaces.</param>
/// <param name="key">The key to save, keeping the existing one's name.</param>
private bool TryReadSpelunkKeyForm(
out Guid vaultId,
out SpelunkKeyRowViewModel? existing,
[NotNullWhen(true)] out SpelunkKeySecret? key)
{
vaultId = Guid.Empty;
existing = null;
key = null;
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkKeyFormApi, out var apiBase, out var reason))
{
SpelunkKeyFormNote = reason;
return false;
}
if ((PersonalVaultId() ?? WritableActiveVault()) is not { } target)
{
SpelunkKeyFormNote = "There is no vault here this account can write to, so the key cannot be saved.";
return false;
}
vaultId = target;
existing = SpelunkKeys.FirstOrDefault(row =>
row.VaultId == target && string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
var candidate = new SpelunkKeySecret
{
Label = existing?.Label ?? SpelunkKeyLabel(apiBase),
ApiBase = apiBase,
ApiKey = SpelunkKeyFormKey.Trim(),
};
if (!candidate.TryValidate(out reason))
{
SpelunkKeyFormNote = reason;
return false;
}
key = candidate;
return true;
}
/// <summary>Asks whether a saved Spelunk key should go.</summary>
[RelayCommand]
private void DeleteSpelunkKeyRow(SpelunkKeyRowViewModel? row)
{
if (row is null)
{
return;
}
PendingDeletion = new DeletionRequest(
DeletionTarget.SpelunkKey,
row.EntityId,
$"Delete the Spelunk key for {row.ApiBase}?",
HowFarADeletionGoes("The key"),
HostsReachingSpelunkWith(row.EntityId));
}
/// <summary>Queues a tombstone for the Spelunk key that was agreed to.</summary>
private async Task DeleteSpelunkKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
{
if (SpelunkKeys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
{
Status = "That Spelunk key is no longer here, so nothing was deleted.";
return;
}
await RunAsync(
"Deleting…",
async () =>
{
await session.SpelunkKeys
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted the Spelunk key for {row.ApiBase}.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <returns>How many Spelunk keys would not decrypt.</returns>
/// <remarks>
/// Read after the hosts, because each row says how many of them use it.
/// </remarks>
private async Task<int> ReloadSpelunkKeysAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var rows = new List<SpelunkKeyRowViewModel>();
var readableVaults = session.ReadableVaults.ToList();
var several = readableVaults.Count > 1;
foreach (var vault in readableVaults)
{
var listing = await session.SpelunkKeys
.ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true);
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(item => new SpelunkKeyRowViewModel(item, vault.VaultId, vault.Name)
{
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
UsedBy = UsedBySummary(Hosts.Count(host => host.Host.Spelunk?.ApiKeyCredentialId == item.EntityId)),
}));
}
var personal = PersonalVaultId();
SpelunkKeys.Clear();
foreach (var row in rows
.OrderByDescending(row => row.VaultId == personal)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.ApiBase, StringComparer.Ordinal))
{
SpelunkKeys.Add(row);
}
OnPropertyChanged(nameof(HasSpelunkKeys));
return unreadable;
}
/// <summary>
/// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password.
/// </summary>
/// <remarks>
/// <para>
/// <b>A saved Spelunk key goes to its own address or nowhere.</b> A host naming one with a different
/// address — edited by hand, or merged from two devices — is refused in words rather than sending the key
/// to a Spelunk it was never saved for. A credential carries no address, so its password goes where the
/// host says, as it always has.
/// </para>
/// <para>
/// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential
/// is — see <see cref="TryBuildAuthentication"/>.
/// </para>
/// </remarks>
/// <exception cref="SpelunkTunnelException">The key cannot be sent to this host's Spelunk.</exception>
private string SpelunkApiKeyFor(HostRowViewModel row, SpelunkRoute route)
{
if (SpelunkKeys.FirstOrDefault(key => key.EntityId == route.ApiKeyCredentialId) is { } saved)
{
if (!string.Equals(saved.ApiBase, route.ApiBase, StringComparison.Ordinal))
{
throw new SpelunkTunnelException(
$"'{row.Label}' is reached through {route.ApiBase} with the Spelunk key saved for "
+ $"{saved.ApiBase}, and that key is never sent anywhere else. Edit the host to choose "
+ "the key for its own Spelunk.");
}
return saved.Key.ApiKey;
}
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is { } credential)
{
return credential.Credential.Password;
}
throw new SpelunkTunnelException(
$"'{row.Label}' is reached through Spelunk with an API key that is not in this keychain any more. "
+ "Edit the host to choose another one, or save the key under Settings → Security.");
}
/// <summary>The saved key for a Spelunk, the given vault's first, or null when none is saved.</summary>
private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) =>
SpelunkKeys
.Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal))
.OrderByDescending(row => row.VaultId == preferredVault)
.FirstOrDefault();
/// <summary>The active vault when this session may write to it, for a session with no personal vault.</summary>
private Guid? WritableActiveVault() =>
session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite)
?.VaultId;
/// <summary>The name a key saved from the form is given: where it goes.</summary>
private static string SpelunkKeyLabel(string apiBase) =>
Uri.TryCreate(apiBase, UriKind.Absolute, out var uri) ? $"Spelunk at {uri.Authority}" : "Spelunk";
private static string UsedBySummary(int hosts) => hosts switch
{
0 => "No host uses it yet",
1 => "1 host uses it",
_ => $"{hosts} hosts use it",
};
/// <summary>What the hosts that reach Spelunk with a key would be left with, or nothing when none do.</summary>
private string HostsReachingSpelunkWith(Guid entityId)
{
var named = Hosts
.Where(row => row.Host.Spelunk?.ApiKeyCredentialId == entityId)
.Select(row => row.Label)
.ToArray();
if (named.Length == 0)
{
return string.Empty;
}
var list = named.Length <= 3
? string.Join(", ", named)
: $"{string.Join(", ", named.Take(3))} and {named.Length - 3} more";
return named.Length == 1
? $"{list} reaches Spelunk with it, and will refuse to connect until it names another key."
: $"{named.Length} hosts reach Spelunk with it — {list} — and will refuse to connect until they "
+ "name another key.";
}
}
@@ -1343,6 +1343,9 @@ internal enum DeletionTarget
/// <summary>A tag, from the vault screen.</summary>
Tag,
/// <summary>A saved Spelunk key, from the Security page.</summary>
SpelunkKey,
}
/// <summary>
@@ -1482,7 +1485,8 @@ internal sealed partial class VaultViewModel(
Func<string, Task>? copyToClipboard = null,
ConnectionRecorder? connectionLog = null,
VaultVisibility? visibility = null,
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
ISshReachabilityProbe? reachability = null,
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
{
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
/// <remarks>
@@ -4307,10 +4311,15 @@ internal sealed partial class VaultViewModel(
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
/// every host under a group that binds a key, and the box would do nothing.
/// </para>
/// <para>
/// False for a host Spelunk signs in to, whatever it resolves to: there is nothing to type, and a box —
/// or on the phone, a sheet — asking for a password there would be a question with no use for its answer.
/// </para>
/// </remarks>
internal bool SelectedHostAsksForAPassword =>
SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|| (!SelectedHost.Host.SignsInThroughSpelunk
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
/// <summary>
/// What the terminal column says in place of the password box, or nothing when the box is showing.
@@ -4329,11 +4338,13 @@ internal sealed partial class VaultViewModel(
/// changed.
/// </para>
/// </remarks>
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
internal string SelectedHostAuthenticationNote => SelectedHost switch
{
{ Kind: ResolvedBindingKind.Credential } binding =>
{ Host.SignsInThroughSpelunk: true } =>
"Spelunk signs you in to this host with a one-time key; no SSH key or password is needed.",
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential } binding } =>
$"This host uses a password stored in your keychain{From(binding)}.",
{ Kind: ResolvedBindingKind.SshKey } binding =>
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } =>
$"This host authenticates with an SSH key{From(binding)}.",
_ => string.Empty,
};
@@ -4362,14 +4373,19 @@ internal sealed partial class VaultViewModel(
/// <remarks>
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
/// value that had not loaded.
/// value that had not loaded. A host Spelunk signs in to logs in as whatever Spelunk names, so it says
/// that rather than a username the connection will not use.
/// </remarks>
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
? user
: "no account set";
internal string SelectedHostUsernameLabel => SelectedHost switch
{
{ Host.SignsInThroughSpelunk: true } => "named by Spelunk",
{ Resolved.Username.Value: { Length: > 0 } user } => user,
_ => "no account set",
};
/// <inheritdoc cref="SelectedHostPortIsInherited" />
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
internal bool SelectedHostUsernameIsInherited =>
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
/// <summary>
/// What the pane names in the credentials row: the key or password this host authenticates with.
@@ -4385,11 +4401,12 @@ internal sealed partial class VaultViewModel(
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
/// </para>
/// </remarks>
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
internal string SelectedHostBindingLabel => SelectedHost switch
{
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
{ Host.SignsInThroughSpelunk: true } => SpelunkSignInIdentity,
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } } =>
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } =>
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
?? "(a password that is no longer here)",
_ => string.Empty,
@@ -4534,6 +4551,7 @@ internal sealed partial class VaultViewModel(
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadSpelunkKeysAsync(cancellationToken).ConfigureAwait(true);
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
// Last, because it reads the host list to work out which pins nothing dials any more.
@@ -4588,6 +4606,7 @@ internal sealed partial class VaultViewModel(
// longer say what the user chose.
var authentication = EditorSelectedAuthentication;
var groupId = EditorSelectedGroup?.EntityId;
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
BuildTagChoices();
@@ -4606,6 +4625,8 @@ internal sealed partial class VaultViewModel(
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
grouped: EditorSelectedGroup?.EntityId is not null);
BuildSpelunkKeyChoices(spelunkKeyId);
}
if (IsEditingGroup)
@@ -7745,6 +7766,7 @@ internal sealed partial class VaultViewModel(
EditorUsername = string.Empty;
EditorNotes = string.Empty;
EditorRelayEnabled = false;
LoadEditorSpelunk(route: null);
editorTagIds = TagSet.Empty;
EditorNewTag = string.Empty;
BuildTagChoices();
@@ -7836,6 +7858,7 @@ internal sealed partial class VaultViewModel(
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
LoadEditorSpelunk(row.Host.Spelunk);
editorTagIds = row.Host.TagIds;
EditorNewTag = string.Empty;
BuildTagChoices();
@@ -11004,6 +11027,10 @@ internal sealed partial class VaultViewModel(
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
case DeletionTarget.SpelunkKey:
await DeleteSpelunkKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
break;
default:
break;
}
@@ -11047,9 +11074,7 @@ internal sealed partial class VaultViewModel(
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
{
var bound = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind
&& binding.EntityId == entityId)
.Where(row => Uses(row, kind, entityId))
.Select(row => row.Label)
.ToArray();
@@ -11876,10 +11901,13 @@ internal sealed partial class VaultViewModel(
PhaseReporter reporter,
CancellationToken cancellationToken)
{
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
// probe does not take: it would dial the host directly — the route the user chose the relay because
// they cannot use — and report on that instead.
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
// A relay or Spelunk refusal already says exactly what happened, and a routed host is reached by a
// path the probe does not take: it would dial the host directly — the route the user chose the relay
// or Spelunk because they cannot use, or for a Spelunk host an address that exists only to name its
// key — and report on that instead.
if (reporter.Furthest != SshConnectionPhase.Reaching
|| exception is ServerRelayException or SpelunkTunnelException
|| target.Routed)
{
return exception.Message;
}
@@ -11984,7 +12012,11 @@ internal sealed partial class VaultViewModel(
authentication.Username,
authentication.Credential);
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
var (sessionId, dialled) = await OpenThroughAsync(target, request, reporter, cancellationToken)
.ConfigureAwait(true);
// The account the session actually logged in as, which a Spelunk sign-in learns only from the tunnel.
authentication = authentication with { Username = dialled.Username };
// The workspace has already opened a ticket for this session, with the address and the moment it
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
@@ -12046,11 +12078,11 @@ internal sealed partial class VaultViewModel(
/// this machine without touching the host.
/// </para>
/// <para>
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
/// Reached through <see cref="OpenRouteAsync"/>, which is what both transports call — file transfer
/// opens a connection of its own, and a relayed host needs the relay there too.
/// </para>
/// </remarks>
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
private async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
{
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
{
@@ -12063,22 +12095,23 @@ internal sealed partial class VaultViewModel(
}
/// <summary>
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
/// Opens the session, through the relay or Spelunk when the host uses one, and puts the route's reason on
/// a failure.
/// </summary>
/// <remarks>
/// <para>
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
/// host key refusals are left alone: a key was presented, so the relay did its job.
/// A route that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
/// every lost transport — a sentence that reads exactly like the direct failure the route was chosen to
/// get around. When the route said why it closed, that sentence is the true one and replaces it. The
/// host key refusals are left alone: a key was presented, so the route did its job.
/// </para>
/// <para>
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
/// The route is disposed on every failure. The connection owns it once it is handed over and disposes
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
/// bridge listening until its own timeout; disposing twice is safe.
/// </para>
/// </remarks>
private async Task<uint> OpenThroughAsync(
private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync(
ConnectionTarget target,
SshConnectionRequest request,
PhaseReporter reporter,
@@ -12086,28 +12119,30 @@ internal sealed partial class VaultViewModel(
{
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
// renderer's wait is the one step of a connect with no bound a user would recognise.
var relay = target.Relayed
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
var route = target.Row is { } row
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
: null;
var through = Through(request, route);
try
{
return await workspace
.OpenSessionAsync(
relay is null ? request : request with { Proxy = relay.Proxy },
TerminalSize.Default,
reporter,
cancellationToken)
var sessionId = await workspace
.OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken)
.ConfigureAwait(true);
}
catch (Exception exception) when (relay is not null)
{
await relay.DisposeAsync().ConfigureAwait(true);
if (relay.Failure is { } failure
return (sessionId, through);
}
catch (Exception exception) when (route is not null)
{
await route.DisposeAsync().ConfigureAwait(true);
if (route.Failure is { } failure
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
{
throw new ServerRelayException(failure, exception);
throw route is SpelunkTunnelRoute
? new SpelunkTunnelException(failure, exception)
: new ServerRelayException(failure, exception);
}
throw;
@@ -12334,13 +12369,13 @@ internal sealed partial class VaultViewModel(
internal Guid? HostId => Row?.EntityId;
/// <summary>
/// Whether this is reached through the server relay rather than dialled.
/// Whether this is reached through the server relay or through Spelunk rather than dialled.
/// </summary>
/// <remarks>
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
/// reached with a key the item names, and a machine typed into the manual box has neither.
/// </remarks>
internal bool Relayed => Row?.Host.RelayEnabled is true;
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
}
/// <summary>
@@ -12422,8 +12457,25 @@ internal sealed partial class VaultViewModel(
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
/// nothing about keys.
/// </para>
/// <para>
/// <b>A host Spelunk signs in to is answered first, whatever it or its group binds</b> — see
/// <see cref="SignedInBySpelunk"/>. It is the one host that connects with no binding, because it says so;
/// every other host goes through <see cref="TryBuildBoundAuthentication"/> exactly as before.
/// </para>
/// </remarks>
private bool TryBuildAuthentication(
HostSecret host,
ResolvedHost resolved,
string typedPassword,
[NotNullWhen(true)] out HostAuthentication? authentication,
[NotNullWhen(false)] out string? reason) =>
host.SignsInThroughSpelunk
? SignedInBySpelunk(out authentication, out reason)
: TryBuildBoundAuthentication(host, resolved, typedPassword, out authentication, out reason);
/// <summary>Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot.</summary>
/// <remarks>See <see cref="TryBuildAuthentication"/>.</remarks>
private bool TryBuildBoundAuthentication(
HostSecret host,
ResolvedHost resolved,
string typedPassword,
@@ -12488,6 +12540,27 @@ internal sealed partial class VaultViewModel(
out reason);
}
/// <summary>
/// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open.
/// </summary>
/// <remarks>
/// Spelunk signs the host in with a key the tunnel makes and an account Spelunk names, neither of which
/// exists yet. These stand-ins are replaced as the connection is made through the route — see
/// <see cref="Through"/> — and refused by the SSH layer if they ever reach it. It is the only way to
/// connect without a binding, and only for a host that says so; every other host still needs one.
/// </remarks>
private static bool SignedInBySpelunk(
[NotNullWhen(true)] out HostAuthentication? authentication,
out string? reason)
{
authentication = new HostAuthentication(
SpelunkSignIn.DefaultUser,
SpelunkSignInPending.Instance,
SpelunkSignInIdentity);
reason = null;
return true;
}
/// <summary>
/// The last thing every branch has to agree on: there is somebody to log in as.
/// </summary>
@@ -12549,6 +12622,10 @@ internal sealed partial class VaultViewModel(
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
RelayEnabled = EditorRelayEnabled,
// Null unless the box is ticked, whatever the fields below it still hold: unticking is how a host
// goes back to being dialled, and it must not keep a route the user just turned off.
Spelunk = BuildSpelunkRoute(),
// Only ever true or null, never false: the two would mean the same thing, and writing false would
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
//
@@ -12556,7 +12633,9 @@ internal sealed partial class VaultViewModel(
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
// host in the vault the first time it was edited.
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
// Never beside a Spelunk sign-in, nor are the bindings below: a host has one way to log in.
AsksForPassword = EditorAsksForLogin
&& EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
&& EditorSelectedGroup?.EntityId is not null
? true
: null,
@@ -12591,7 +12670,9 @@ internal sealed partial class VaultViewModel(
/// <summary>The picker's selection, if it names something of this kind.</summary>
private Guid? Bound(AuthenticationKind kind) =>
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind
? choice.EntityId
: null;
/// <summary>
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
@@ -13322,8 +13403,7 @@ internal sealed partial class VaultViewModel(
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
{
var names = Hosts
.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == kind && binding.EntityId == entityId)
.Where(row => Uses(row, kind, entityId))
.Select(row => row.Label)
.ToList();
@@ -13518,8 +13598,7 @@ internal sealed partial class VaultViewModel(
if (kind is { } resolvedKind && value is not null)
{
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
foreach (var row in Hosts.Where(row => Uses(row, resolvedKind, value.EntityId)))
{
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
}
@@ -141,6 +141,7 @@ internal static class AadResourceTypes
SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,
SyncEntityType.SpelunkKey => CryptoSpec.AadResourceType.SpelunkKey,
_ => throw new ArgumentOutOfRangeException(
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
+84
View File
@@ -170,6 +170,9 @@ internal static class ItemKinds
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
(SyncEntityType.SpelunkKey, static (outbox, conflicts, keyring) =>
new ItemReconciler<SpelunkKeySecret>(SpelunkKeyKind.Instance, outbox, conflicts, keyring)),
];
/// <summary>The types to ask the server for, in a fixed order.</summary>
@@ -268,12 +271,14 @@ internal sealed class HostKind : IItemKind<HostSecret>
Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
Note(changed, "Options", before.Options, after.Options);
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
Note(changed, "Spelunk", before.Spelunk, after.Spelunk);
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
Note(changed, "Credential", before.CredentialId, after.CredentialId);
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
Note(changed, "Group", before.GroupId, after.GroupId);
Note(changed, "Tags", before.TagIds, after.TagIds);
Note(changed, "Port forwards", before.PortForwards, after.PortForwards);
Note(changed, "Pinned paths", before.PinnedPaths, after.PinnedPaths);
return changed;
}
@@ -1056,3 +1061,82 @@ internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
return secret with { Label = label };
}
}
/// <summary>Spelunk keys.</summary>
internal sealed class SpelunkKeyKind : IItemKind<SpelunkKeySecret>
{
internal static SpelunkKeyKind Instance { get; } = new();
/// <inheritdoc />
public SyncEntityType EntityType => SyncEntityType.SpelunkKey;
/// <inheritdoc />
public string Noun => "Spelunk key";
/// <inheritdoc />
public OpenedItem<SpelunkKeySecret>? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
var document = SpelunkKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
return document is null
? null
: new OpenedItem<SpelunkKeySecret>(document.Key, document.IsReadOnly);
}
/// <inheritdoc />
public EncryptedPayload Seal(
SpelunkKeySecret secret,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion) =>
SpelunkKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
/// <summary>
/// Nothing. The address is the only candidate and must not go: nothing on the server calls Spelunk.
/// </summary>
/// <inheritdoc />
public SyncPlaintextFields? Fields(SpelunkKeySecret secret) => null;
/// <inheritdoc />
public MergedItem<SpelunkKeySecret> Merge(
SpelunkKeySecret ancestor,
SpelunkKeySecret local,
SpelunkKeySecret remote)
{
var merged = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
return new MergedItem<SpelunkKeySecret>(merged.Merged, merged.Conflicts);
}
/// <remarks>
/// The key is compared and never reported by value, which is the rule every credential-like field in this
/// file follows. The address is shown: it is where the key goes, and a change to it is worth reading.
/// </remarks>
/// <inheritdoc />
public IReadOnlyList<string> Changes(SpelunkKeySecret before, SpelunkKeySecret after)
{
ArgumentNullException.ThrowIfNull(before);
ArgumentNullException.ThrowIfNull(after);
var changed = new List<string>();
Note(changed, "Name", before.Label, after.Label);
Note(changed, "Spelunk address", before.ApiBase, after.ApiBase);
Note(changed, "API key", before.ApiKey, after.ApiKey);
return changed;
}
/// <inheritdoc />
public SpelunkKeySecret Relabel(SpelunkKeySecret secret, string label)
{
ArgumentNullException.ThrowIfNull(secret);
return secret with { Label = label };
}
}
+125
View File
@@ -0,0 +1,125 @@
using System.Security.Cryptography;
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Sync;
/// <summary>
/// Turns a Spelunk key into an item payload and back.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors <see cref="ObjectStoreCipher"/> exactly, including the version rule in <see cref="SyncVersions"/>.
/// </para>
/// <para>
/// <b>The resource type is named, never cast.</b> <c>SyncEntityType.SpelunkKey</c> is 14 and
/// <c>CryptoSpec.AadResourceType.SpelunkKey</c> is 17; 14 in the crypto enum is a connection log entry, and a
/// cast would seal an API key under that — which decrypts perfectly on the machine that wrote it and violates
/// docs/crypto.md everywhere else.
/// </para>
/// </remarks>
public static class SpelunkKeyCipher
{
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.SpelunkKey;
/// <summary>Encrypts a Spelunk key.</summary>
/// <param name="key">The key. Must be valid for storage.</param>
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
/// <param name="entityId">The item id, which the AAD binds.</param>
/// <param name="keyGeneration">The vault's current key generation.</param>
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
public static EncryptedPayload Seal(
SpelunkKeySecret key,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
uint keyGeneration,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(key);
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
var plaintext = SpelunkKeySecretCodec.Encode(key);
var dataKey = ItemKeys.CreateDataKey();
try
{
var dataKeyId = Guid.CreateVersion7();
var wrappedDataKey = ItemKeys.WrapDataKey(
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
var envelope = ItemKeys.SealPayload(
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
return new EncryptedPayload(
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
// It holds an API key, which is live on the Spelunk it belongs to.
CryptographicOperations.ZeroMemory(plaintext);
}
}
/// <summary>Decrypts a Spelunk key.</summary>
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
public static SpelunkKeySecretDocument? TryOpen(
EncryptedPayload payload,
ReadOnlySpan<byte> vaultKey,
Guid entityId,
int itemVersion)
{
ArgumentNullException.ThrowIfNull(payload);
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
{
return null;
}
var dataKey = ItemKeys.TryUnwrapDataKey(
vaultKey,
payload.WrappedDataKey,
Resource,
entityId,
payload.KeyGeneration,
(uint)itemVersion);
if (dataKey is null)
{
return null;
}
try
{
var plaintext = ItemKeys.TryOpenPayload(
dataKey,
payload.Envelope,
Resource,
entityId,
payload.DataKeyId,
payload.KeyGeneration,
(uint)itemVersion);
if (plaintext is null)
{
return null;
}
try
{
return SpelunkKeySecretCodec.TryDecode(plaintext, out var document) ? document : null;
}
finally
{
CryptographicOperations.ZeroMemory(plaintext);
}
}
finally
{
CryptographicOperations.ZeroMemory(dataKey);
}
}
}
@@ -0,0 +1,46 @@
using DodoSSH.Client.Domain;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.Sync;
/// <summary>
/// The Spelunk keys in this vault, decrypted, with unpushed local changes laid over them.
/// </summary>
/// <remarks>
/// Another facade over the same generic repository. A key is read when the vault is listed and again at
/// connect, from the list, like a credential's password.
/// </remarks>
public sealed class SpelunkKeyRepository(
ItemStore items,
OutboxStore outbox,
VaultKeyring keyring,
IActivityLogSink? activity = null)
{
private readonly VaultItemRepository<SpelunkKeySecret> keys =
new(SpelunkKeyKind.Instance, items, outbox, keyring, activity);
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
public Task<ItemListing<SpelunkKeySecret>> ListAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
keys.ListAsync(vaultId, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
public Task<Guid> CreateAsync(
Guid vaultId,
SpelunkKeySecret key,
CancellationToken cancellationToken) =>
keys.CreateAsync(vaultId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
public Task UpdateAsync(
Guid vaultId,
Guid entityId,
SpelunkKeySecret key,
CancellationToken cancellationToken) =>
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
keys.DeleteAsync(vaultId, entityId, cancellationToken);
}
@@ -469,6 +469,7 @@ DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEnti
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.SpelunkKey = 14 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Tag = 5 -> DodoSSH.Contracts.SyncEntityType
DodoSSH.Contracts.SyncEntityType.Unspecified = 0 -> DodoSSH.Contracts.SyncEntityType
+10
View File
@@ -66,4 +66,14 @@ public enum SyncEntityType
/// the vendor. The interface says S3, which is what people call the protocol.
/// </remarks>
ObjectStore = 13,
/// <summary>
/// A Spelunk API key, and the one Spelunk address it may be sent to.
/// </summary>
/// <remarks>
/// An item of its own rather than a credential's password, because the address is the point: a link from
/// Spelunk can be sent by anything on the machine, so a key may only go where the user said it goes, and
/// only an item that carries both can say so. See <c>SpelunkKeySecret</c>.
/// </remarks>
SpelunkKey = 14,
}
+3
View File
@@ -173,6 +173,9 @@ public static class CryptoSpec
/// <summary>An S3-compatible bucket, and the credentials that reach it.</summary>
ObjectStore = 16,
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
SpelunkKey = 17,
}
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
+56
View File
@@ -556,6 +556,62 @@ public sealed class VaultObjectStore : IVaultItem
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// A Spelunk API key and the Spelunk address it may be sent to, as ciphertext.
/// </summary>
/// <remarks>
/// <see cref="VaultObjectStore"/>'s shape, for the same reason: the key is a password, and the address beside
/// it is where the user's servers are managed. Nothing on the server calls Spelunk, so there is no plaintext
/// column and no argument for one.
/// </remarks>
public sealed class VaultSpelunkKey : IVaultItem
{
/// <summary>Primary key. UUIDv7, generated by the client so a key can be added offline.</summary>
public Guid Id { get; set; }
/// <summary>Owning vault.</summary>
public Guid VaultId { get; set; }
/// <summary>Owning vault.</summary>
public Vault? Vault { get; set; }
/// <summary>The encrypted key and its address: a DSH1 envelope. Opaque to the server.</summary>
public byte[] Payload { get; set; } = [];
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
public byte[]? DataKeyWrap { get; set; }
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
public Guid? ContentKeyId { get; set; }
/// <summary>Vault key generation this payload was encrypted under.</summary>
public int KeyGeneration { get; set; }
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
public short PayloadAadVersion { get; set; }
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
public int Version { get; set; }
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
public long ChangeSequence { get; set; }
/// <summary>Creation timestamp.</summary>
public DateTimeOffset CreatedAtUtc { get; set; }
/// <summary>Last modification timestamp.</summary>
public DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>Soft-delete marker; a tombstone, so an offline client learns the key went away.</summary>
public DateTimeOffset? DeletedAtUtc { get; set; }
/// <summary>Who created it.</summary>
public Guid CreatedByUserId { get; set; }
/// <summary>Who last modified it.</summary>
public Guid UpdatedByUserId { get; set; }
}
/// <summary>
/// One connection that was made, as ciphertext.
/// </summary>
+3
View File
@@ -57,6 +57,9 @@ public enum ChangeEntityType
/// <summary>An S3-compatible bucket, and the credentials that reach it. M2.</summary>
ObjectStore = 13,
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
SpelunkKey = 14,
}
/// <summary>
@@ -384,6 +384,41 @@ public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration<VaultObj
}
}
/// <summary>
/// Maps <see cref="VaultSpelunkKey"/>.
/// </summary>
/// <remarks>
/// <see cref="ObjectStoreConfiguration"/> again: a key and the address it goes to, neither of which belongs
/// in a column.
/// </remarks>
public sealed class SpelunkKeyConfiguration : IEntityTypeConfiguration<VaultSpelunkKey>
{
/// <inheritdoc />
public void Configure(EntityTypeBuilder<VaultSpelunkKey> builder)
{
ArgumentNullException.ThrowIfNull(builder);
builder.ToTable("spelunk_key");
builder.HasKey(s => s.Id);
// Client-generated UUIDv7: a key must be addable offline, with its id.
builder.Property(s => s.Id).ValueGeneratedNever();
builder.UseXminConcurrencyToken();
builder.Property(s => s.Payload).IsRequired();
builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
builder.HasIndex(s => s.VaultId)
.HasFilter("deleted_at_utc IS NULL")
.HasDatabaseName("ix_spelunk_key_vault_live");
builder.ToTable(t => t.HasCheckConstraint(
"ck_spelunk_key_version",
"version >= 1"));
}
}
/// <summary>Maps <see cref="VaultChange"/>.</summary>
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
{
@@ -81,6 +81,9 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
/// <summary>S3-compatible buckets and their credentials, held as ciphertext.</summary>
public DbSet<VaultObjectStore> ObjectStores => Set<VaultObjectStore>();
/// <summary>Spelunk API keys and the addresses they may be sent to, held as ciphertext.</summary>
public DbSet<VaultSpelunkKey> SpelunkKeys => Set<VaultSpelunkKey>();
/// <summary>The per-vault change log that delta sync reads.</summary>
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Infrastructure.Migrations
{
/// <summary>
/// Adds the table for Spelunk keys: an API key and the one Spelunk address it may be sent to.
/// </summary>
/// <remarks>
/// New and empty, so this is additive and safe to roll back. The object-store table's shape: ciphertext
/// and bookkeeping, with no plaintext column, because nothing on the server calls Spelunk.
/// </remarks>
public partial class AddSpelunkKeyItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "spelunk_key",
schema: "dodo",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
payload = table.Column<byte[]>(type: "bytea", nullable: false),
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
key_generation = table.Column<int>(type: "integer", nullable: false),
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
version = table.Column<int>(type: "integer", nullable: false),
change_sequence = table.Column<long>(type: "bigint", nullable: false),
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_spelunk_key", x => x.id);
table.CheckConstraint("ck_spelunk_key_version", "version >= 1");
table.ForeignKey(
name: "fk_spelunk_key_vaults_vault_id",
column: x => x.vault_id,
principalSchema: "dodo",
principalTable: "vault",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_spelunk_key_vault_id_change_sequence",
schema: "dodo",
table: "spelunk_key",
columns: new[] { "vault_id", "change_sequence" });
migrationBuilder.CreateIndex(
name: "ix_spelunk_key_vault_live",
schema: "dodo",
table: "spelunk_key",
column: "vault_id",
filter: "deleted_at_utc IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "spelunk_key",
schema: "dodo");
}
}
}
@@ -1487,6 +1487,87 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
{
b.Property<Guid>("Id")
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<long>("ChangeSequence")
.HasColumnType("bigint")
.HasColumnName("change_sequence");
b.Property<Guid?>("ContentKeyId")
.HasColumnType("uuid")
.HasColumnName("content_key_id");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at_utc");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("uuid")
.HasColumnName("created_by_user_id");
b.Property<byte[]>("DataKeyWrap")
.HasColumnType("bytea")
.HasColumnName("data_key_wrap");
b.Property<DateTimeOffset?>("DeletedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at_utc");
b.Property<int>("KeyGeneration")
.HasColumnType("integer")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("payload");
b.Property<short>("PayloadAadVersion")
.HasColumnType("smallint")
.HasColumnName("payload_aad_version");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UpdatedByUserId")
.HasColumnType("uuid")
.HasColumnName("updated_by_user_id");
b.Property<Guid>("VaultId")
.HasColumnType("uuid")
.HasColumnName("vault_id");
b.Property<int>("Version")
.HasColumnType("integer")
.HasColumnName("version");
b.Property<uint>("xmin")
.IsConcurrencyToken()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("xid")
.HasColumnName("xmin");
b.HasKey("Id")
.HasName("pk_spelunk_key");
b.HasIndex("VaultId")
.HasDatabaseName("ix_spelunk_key_vault_live")
.HasFilter("deleted_at_utc IS NULL");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_spelunk_key_vault_id_change_sequence");
b.ToTable("spelunk_key", "dodo", t =>
{
t.HasCheckConstraint("ck_spelunk_key_version", "version >= 1");
});
});
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
{
b.Property<Guid>("Id")
@@ -1854,6 +1935,18 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany()
.HasForeignKey("VaultId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_spelunk_key_vaults_vault_id");
b.Navigation("Vault");
});
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
@@ -935,6 +935,55 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
change.PlaintextFields.ShouldBeNull("where a user keeps their data is not something this server keeps");
}
[Fact]
public async Task ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var keyId = Guid.CreateVersion7();
var pushed = await client.PostContractAsync(
PushUrl(vaultId),
new SyncPushRequest(
[SpelunkKeyOperation(keyId, expectedVersion: null, envelope: [4, 2])]));
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
var pulled = await client.PostContractAsync(
PullUrl(vaultId),
new SyncPullRequest(null, null, [SyncEntityType.SpelunkKey]));
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
var change = page!.Changes.ShouldHaveSingleItem();
change.EntityType.ShouldBe(SyncEntityType.SpelunkKey);
change.EntityId.ShouldBe(keyId);
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]);
change.PlaintextFields.ShouldBeNull("where somebody manages their servers from is not this server's to keep");
}
[Fact]
public async Task ASpelunkKeyCarryingItsAddressInTheClear_IsRejected()
{
var (subject, vaultId) = await SeedUserWithVaultAsync();
var client = fixture.CreateClientFor(subject);
var operation = SpelunkKeyOperation(Guid.CreateVersion7(), null, [1])
with
{ PlaintextFields = new SyncPlaintextFields(RelayEnabled: false, Hostname: "api.spelunk.example.com", Port: 443) };
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
}
[Fact]
public async Task ABucketCarryingItsEndpointInTheClear_IsRejected()
{
@@ -1425,6 +1474,19 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
Payload(envelope),
PlaintextFields: null);
private static SyncPushOperation SpelunkKeyOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.SpelunkKey,
entityId,
SyncOperation.Upsert,
expectedVersion,
Payload(envelope),
PlaintextFields: null);
private static SyncPushRequest NewCreateBatch() =>
new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]);
@@ -1020,10 +1020,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The FIELDS column is the one that grows: it is a list of names, and a host has fourteen of them —
/// eleven until inheritance added "Password prompt", tags added "Tags" and forwards added "Port
/// forwards". Measured with an edit that touched several, because one field name fits anywhere. The
/// count is stated rather than derived, so
/// The FIELDS column is the one that grows: it is a list of names, and a host has fifteen of them —
/// eleven until inheritance added "Password prompt", tags added "Tags", forwards added "Port forwards"
/// and pinning added "Pinned paths". Measured with an edit that touched several, because one field
/// name fits anywhere. The count is stated rather than derived, so
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
/// </remarks>
@@ -2252,7 +2252,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
ItemId = Guid.CreateVersion7(),
ItemLabel = "customer-a-production-database",
Operation = ActivityOperation.Updated,
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags, Port forwards",
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags, Port forwards, Pinned paths",
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
DeviceName = "jaap-jan-workstation",
},
@@ -232,6 +232,41 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
Token);
}
/// <summary>
/// The SPELUNK section with a key saved: the form, its note, and the row listing the key all fit, and the
/// form's button is the real save.
/// </summary>
[Fact]
public async Task TheSecurityPageFitsWithASavedSpelunkKey()
{
vault.SpelunkKeyFormApi = "https://api.spelunk.example.com";
vault.SpelunkKeyFormKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyz";
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
vault.SpelunkKeys.ShouldHaveSingleItem(vault.SpelunkKeyFormNote);
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("with a Spelunk key saved"));
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var page = new SettingsSecurityPage { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight);
try
{
ButtonNamed(window, "SAVE KEY").Command.ShouldBeSameAs(vault.SaveSpelunkKeyCommand);
}
finally
{
window.Close();
}
},
Token);
}
// ---- v5c-2: Vaults, Groups, Tags ----
//
// Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at
@@ -0,0 +1,809 @@
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// A host reached through Spelunk, from the editor and from a link to the request that reaches SSH.
/// </summary>
/// <remarks>
/// <para>
/// The tunnel is stood in for at <see cref="ISpelunkTunnel"/>, and what it hands back is a real
/// <see cref="SpelunkTunnelRoute"/> over the relay's real bridge and an in-process WebSocket pair — so the
/// proxy SSH is given, and the close code a failed connect is explained by, are the real ones. The tunnel's
/// own HTTP and WebSocket half is <c>SpelunkTunnelTests</c>' job.
/// </para>
/// <para>
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects
/// straight away but never sends a key to a Spelunk no saved host already sends it to.
/// </para>
/// </remarks>
public sealed partial class ShellFlowTests
{
private const string SpelunkApi = "https://api.spelunk.example.com";
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
private readonly FakeSpelunkTunnel spelunkTunnels = new();
[Fact]
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
// The key from the credential, to the Spelunk and server the host names, for the host's own port.
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
var request = ssh.Requests.ShouldHaveSingleItem();
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
request.Host.ShouldBe("203.0.113.10");
shell.IsTerminalShowing.ShouldBeTrue();
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task ARefusedTunnel_IsExplained_AndNothingIsDialledOrProbed()
{
spelunkTunnels.Refusal = new SpelunkTunnelException(
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it.");
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
// No fallback to the address. For a Spelunk host it may be nothing that answers at all.
ssh.Requests.ShouldBeEmpty();
reachability.Probed.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("agent on this server is not connected");
}
[Fact]
public async Task ATunnelWhoseAgentCouldNotReachThePort_SaysSo_InsteadOfTheTransportError()
{
// What Spelunk does when the agent's own connect to 127.0.0.1:22 fails: accept, then close with 4502.
spelunkTunnels.CloseWith = (4502, "dial tcp 127.0.0.1:22: connection refused");
ssh.BeforeConnect = request => ThroughSocksToTheEndAsync(request.Proxy.ShouldNotBeNull().Port);
ssh.Failure = new InvalidOperationException("An established connection was aborted by the server.");
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("could not connect to port 22 on the server itself");
vault.Status.ShouldNotContain("aborted");
reachability.Probed.ShouldBeEmpty();
}
[Fact]
public async Task ASpelunkHostsFileTransfer_GoesThroughTheTunnelToo()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldHaveSingleItem();
var request = ssh.SftpRequests.ShouldHaveSingleItem();
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task TheRelayAndSpelunk_AreOneChoice_InTheEditor()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorRelayEnabled = true;
vault.EditorThroughSpelunk = true;
vault.EditorRelayEnabled.ShouldBeFalse("ticking Spelunk unticks the relay");
vault.EditorRelayEnabled = true;
vault.EditorThroughSpelunk.ShouldBeFalse("and the other way round");
}
[Fact]
public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
vault.EditorHostname = "203.0.113.10";
vault.EditorUsername = "deploy";
vault.EditorThroughSpelunk = true;
vault.EditorSpelunkApi = SpelunkApi;
vault.EditorSpelunkServerId = SpelunkServer.ToString();
await vault.SaveHostCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
vault.Hosts.ShouldBeEmpty();
}
[Fact]
public async Task ASavedSpelunkHost_OpensInTheEditorWithItsRoute()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorThroughSpelunk.ShouldBeTrue();
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
}
[Fact]
public async Task ACredentialHoldingASpelunkKey_IsInUse_WhereverThatIsDrawn()
{
// So deleting it is warned about like any other credential a host needs, rather than looking unused.
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.ShowSectionCommand.Execute(VaultSection.All);
var row = vault.VaultItems.Single(item => string.Equals(item.Name, "spelunk key", StringComparison.Ordinal));
row.UsedBySummary.ShouldBe("web-01");
vault.SelectedVaultItem = row;
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
}
// ---- Signing in through Spelunk ----
[Fact]
public async Task ASignInHost_ConnectsWithAFreshKeyAndSpelunksAccount_AndNoLoginOfItsOwn()
{
spelunkTunnels.User = "ops";
var vault = await ReadyToSignInThroughSpelunkAsync();
vault.SelectedHostAsksForAPassword.ShouldBeFalse("there is nothing to type");
vault.SelectedHostAuthenticationNote.ShouldContain("Spelunk signs you in");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
// The account Spelunk named and the key the tunnel was opened with, through the tunnel, as the host.
var request = ssh.Requests.ShouldHaveSingleItem();
request.Username.ShouldBe("ops");
request.Credential.ShouldBeSameAs(signIn.Credential);
request.Host.ShouldBe("203.0.113.10");
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
shell.IsTerminalShowing.ShouldBeTrue();
shell.Tabs.ShouldHaveSingleItem();
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task ASignInHostsForwards_StillStart_OverTheSignedInSession()
{
var vault = await ReadyToSignInThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewForwardKind = (int)Domain.PortForwardKind.Dynamic;
vault.EditorNewForwardBindPort = "1080";
vault.EditorNewForwardTarget = string.Empty;
vault.AddEditorForwardCommand.Execute(null);
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
var tab = shell.Tabs.ShouldHaveSingleItem();
await EventuallyAsync(() => tab.ListeningForwardCount == 1, "the forward should be listening");
ssh.Forwards.ShouldHaveSingleItem().Spec.ShouldBe(new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080));
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASignInHostsFileTransfer_SignsInThroughSpelunkToo()
{
var vault = await ReadyToSignInThroughSpelunkAsync();
shell.Transfers.Attach(vault, knownHosts);
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
shell.Transfers.SelectedHostAsksForAPassword.ShouldBeFalse();
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
var request = ssh.SftpRequests.ShouldHaveSingleItem();
request.Username.ShouldBe("root");
request.Credential.ShouldBeSameAs(signIn.Credential);
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
shell.Transfers.ConnectedTo.ShouldStartWith("root@203.0.113.10");
await request.Proxy.Lifetime.DisposeAsync();
}
[Fact]
public async Task ASpelunkHostThatDoesNotSignIn_StillNeedsALogin()
{
// The sign-in is what lets a host connect with no binding; without it, the old rule stands.
var vault = await ReadyToSignInThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSpelunkSignIn = false;
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single();
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("no username");
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
}
[Fact]
public async Task TickingSignIn_GreysOutTheLogin_AndTheSavedHostBindsNothing()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorAsksForLogin.ShouldBeTrue();
vault.EditorSpelunkSignIn = true;
vault.EditorAsksForLogin.ShouldBeFalse();
// The choice is still there to see, and to come back to, but it is not saved beside the sign-in.
vault.EditorSelectedAuthentication.ShouldNotBeNull().Label.ShouldBe("deploy password");
await vault.SaveHostCommand.ExecuteAsync(null);
var host = vault.Hosts.ShouldHaveSingleItem().Host;
host.SignsInThroughSpelunk.ShouldBeTrue();
host.CredentialId.ShouldBeNull();
host.SshKeyId.ShouldBeNull();
host.AsksForPassword.ShouldBeNull();
// Unticking the route altogether gives the login back.
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSpelunkSignIn.ShouldBeTrue();
vault.EditorThroughSpelunk = false;
vault.EditorAsksForLogin.ShouldBeTrue();
}
// ---- A link from Spelunk ----
[Fact]
public async Task ALinkForAServerWithNoHost_OpensTheEditor_AndConnectsNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
shell.OpenLink(Link(host: "203.0.113.10"));
vault.IsEditing.ShouldBeTrue();
vault.EditorLabel.ShouldBe("web 01");
vault.EditorHostname.ShouldBe("203.0.113.10");
vault.EditorPort.ShouldBe(22);
vault.EditorThroughSpelunk.ShouldBeTrue();
vault.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk");
vault.EditorAsksForLogin.ShouldBeFalse();
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
// for it would be offering to send the key wherever a link says.
vault.EditorSelectedSpelunkKey.ShouldBeNull();
vault.Status.ShouldNotBeNull().ShouldContain("first server");
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
}
[Fact]
public async Task ALinkWithNoAddress_NamesTheHostAfterTheServer()
{
await UnlockedAsync();
shell.OpenLink(Link(host: null));
shell.Vault!.EditorHostname.ShouldBe($"spelunk-{SpelunkServer}");
}
[Fact]
public async Task TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault()
{
await UnlockedAsync();
var vault = shell.Vault!;
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.IsEditing.ShouldBeTrue();
vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal.ShouldBeTrue();
vault.EditorTagChoices.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
chip => chip.IsWorn.ShouldBeTrue());
// So once it is saved, the next link for that server finds it rather than adding a second one.
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.Hosts.ShouldHaveSingleItem();
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk");
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the
// key that Spelunk already gets, and no login of its own.
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
added.Label.ShouldBe("web 01");
added.Host.Hostname.ShouldBe($"spelunk-{other}");
added.Host.Port.ShouldBe(22);
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue();
added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId);
added.Host.SshKeyId.ShouldBeNull();
added.Host.CredentialId.ShouldBeNull();
added.Host.Username.ShouldBeNull();
added.Host.TagIds.ShouldHaveSingleItem().ShouldBe(vault.Tags.Single(tag => tag.Label == VaultViewModel.SpelunkTag).EntityId);
// And connected, through Spelunk, signing in.
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
var request = ssh.Requests.ShouldHaveSingleItem();
request.Username.ShouldBe("root");
request.Credential.ShouldBeSameAs(signIn.Credential);
shell.IsTerminalShowing.ShouldBeTrue();
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASecondLinkForTheSameServer_ConnectsToTheHostTheFirstAdded()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(server: other));
await vault.OpenSpelunkLinkAsync(Link(server: other));
vault.Hosts.Count(row => row.Host.Spelunk?.ServerId == other).ShouldBe(1);
spelunkTunnels.Opened.Count.ShouldBe(2);
// The two SignIns are two keys: one per connection, never reused.
spelunkTunnels.SignIns[0].ShouldNotBeNull().PublicKeyLine
.ShouldNotBe(spelunkTunnels.SignIns[1].ShouldNotBeNull().PublicKeyLine);
foreach (var request in ssh.Requests)
{
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
}
[Fact]
public async Task ALinkForATaggedHostInThePersonalVault_ConnectsToItAsSaved()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorNewTag = VaultViewModel.SpelunkTag;
await vault.AddEditorTagCommand.ExecuteAsync(null);
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
vault.IsEditing.ShouldBeFalse();
vault.Hosts.ShouldHaveSingleItem();
spelunkTunnels.Opened.ShouldHaveSingleItem();
// The saved host, as saved — not the address the link happened to carry, and its own login, since
// this one does not sign in through Spelunk.
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldBeNull();
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("203.0.113.10");
request.Username.ShouldBe("deploy");
await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync();
}
[Fact]
public async Task ALinkForAHostSetUpByHand_AddsOneOfItsOwn()
{
// Untagged, so not one a link made: it is left as it is, and the link gets the host links make.
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.Hosts.Count.ShouldBe(2);
vault.Hosts.Single(row => row.Label == "web-01").Host.TagIds.ShouldBeEmpty();
vault.Hosts.Single(row => row.Label == "web 01").Host.SignsInThroughSpelunk.ShouldBeTrue();
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ALinkWhileLocked_WaitsForTheUnlock()
{
await EnrolledAndConfirmedAsync();
shell.OpenLink(Link(host: "203.0.113.10"));
shell.Vault.ShouldBeNull();
shell.StatusMessage.ShouldContain("Unlock to open web 01");
shell.Passphrase = Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
var vault = shell.Vault.ShouldNotBeNull();
vault.IsEditing.ShouldBeTrue();
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
vault.EditorSpelunkSignIn.ShouldBeTrue();
}
[Fact]
public async Task ALink_LeavesAHalfEditedHostAlone()
{
await UnlockedAsync();
var vault = shell.Vault!;
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "something I was typing";
shell.OpenLink(Link(host: "203.0.113.10"));
vault.EditorLabel.ShouldBe("something I was typing");
vault.EditorThroughSpelunk.ShouldBeFalse();
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
}
// ---- A Spelunk key saved on the Security page ----
[Fact]
public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey);
// Saved in the one form, in the personal vault, and gone from the form.
var saved = vault.SpelunkKeys.ShouldHaveSingleItem();
saved.ApiBase.ShouldBe(SpelunkApi);
saved.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
vault.SpelunkKeyFormKey.ShouldBeEmpty();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
vault.IsEditing.ShouldBeFalse("saving the key for this Spelunk is the say-so the first link used to ask for");
var added = vault.Hosts.ShouldHaveSingleItem();
added.Host.Spelunk.ShouldNotBeNull().ApiKeyCredentialId.ShouldBe(saved.EntityId);
added.Host.SignsInThroughSpelunk.ShouldBeTrue();
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link());
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
vault.SpelunkKeys.ShouldHaveSingleItem().UsedBy.ShouldBe("1 host uses it");
vault.SpelunkKeyFormNote.ShouldContain("Replaced");
await vault.OpenSpelunkLinkAsync(Link());
spelunkTunnels.Opened.Select(opened => opened.ApiKey).ShouldBe(["spelunk_the_old_key", SpelunkApiKey]);
await ssh.Requests[1].Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASavedKey_IsNeverSentToAnotherSpelunk()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
var saved = vault.SpelunkKeys.ShouldHaveSingleItem();
// A host that names the key but another address — the one thing a key kept with its address refuses.
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
vault.EditorHostname = "203.0.113.10";
vault.EditorThroughSpelunk = true;
vault.EditorSpelunkSignIn = true;
vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example";
vault.EditorSpelunkServerId = SpelunkServer.ToString();
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId);
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else");
}
[Fact]
public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "an older key", password: "spelunk_older");
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
vault.NewHostCommand.Execute(null);
vault.EditorThroughSpelunk = true;
vault.EditorSpelunkApi = SpelunkApi + "/";
var chosen = vault.EditorSelectedSpelunkKey.ShouldNotBeNull();
chosen.EntityId.ShouldBe(vault.SpelunkKeys.Single().EntityId);
chosen.Qualifier.ShouldBe($"Spelunk key · {SpelunkApi}");
// Saved keys come before credentials, which stay on offer for keys kept the older way.
vault.EditorSpelunkKeyChoices.Select(choice => choice.Label)
.ShouldBe(["Spelunk at api.spelunk.example.com", "an older key"]);
}
[Fact]
public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, "http://api.spelunk.example.com", SpelunkApiKey);
vault.SpelunkKeys.ShouldBeEmpty();
vault.SpelunkKeyFormNote.ShouldContain("https");
vault.SpelunkKeyFormKey.ShouldBe(SpelunkApiKey, "a refused key stays in the box to be saved once the address is fixed");
}
[Fact]
public async Task DeletingASavedKey_SaysWhichHostsWillStopConnecting()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link());
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single());
var request = vault.PendingDeletion.ShouldNotBeNull();
request.Usage.ShouldContain("web 01 reaches Spelunk with it");
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
vault.SpelunkKeys.ShouldBeEmpty();
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts.Single());
vault.Status.ShouldNotBeNull().ShouldContain("not in this keychain any more");
}
private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey)
{
vault.SpelunkKeyFormApi = apiBase;
vault.SpelunkKeyFormKey = apiKey;
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
}
/// <summary>A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.</summary>
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
vault.EditorHostname = "203.0.113.10";
vault.EditorUsername = "deploy";
vault.EditorThroughSpelunk = true;
vault.EditorSpelunkApi = SpelunkApi + "/";
vault.EditorSpelunkServerId = SpelunkServer.ToString();
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
.Single(choice => choice.Label == "deploy password");
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.Single(row => row.Host.Spelunk is not null);
// Saved in the one form, whatever was typed — which is what lets a link find it.
vault.SelectedHost.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
return vault;
}
/// <summary>A saved host through Spelunk that Spelunk signs in to: no username, no key, no password.</summary>
private async Task<VaultViewModel> ReadyToSignInThroughSpelunkAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
vault.EditorHostname = "203.0.113.10";
vault.EditorThroughSpelunk = true;
vault.EditorSpelunkSignIn = true;
vault.EditorSpelunkApi = SpelunkApi;
vault.EditorSpelunkServerId = SpelunkServer.ToString();
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
vault.SelectedHost.Host.SignsInThroughSpelunk.ShouldBeTrue();
return vault;
}
private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) =>
new(SpelunkApi, server ?? SpelunkServer, "web 01", host);
/// <summary>
/// Speaks SOCKS5 to the bridge as SSH.NET would, then reads until the bridge closes the stream — which it
/// does once the tunnel has closed, so its reason is recorded by the time this returns.
/// </summary>
private static async Task ThroughSocksToTheEndAsync(int port)
{
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port, Token);
var stream = client.GetStream();
var name = Encoding.ASCII.GetBytes("203.0.113.10");
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
await stream.ReadExactlyAsync(new byte[2], Token);
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
await stream.ReadExactlyAsync(new byte[10], Token);
var rest = new byte[256];
while (await stream.ReadAsync(rest, Token) > 0)
{
}
}
/// <summary>
/// Spelunk as far as the shell can see it: a route over a real bridge, or a refusal, as a test arranges.
/// </summary>
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
{
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
internal List<SpelunkSignIn?> SignIns { get; } = [];
/// <summary>The account a sign-in answer names.</summary>
internal string User { get; set; } = "root";
internal SpelunkTunnelException? Refusal { get; set; }
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
internal (int Code, string Reason)? CloseWith { get; set; }
public async Task<SpelunkTunnelRoute> OpenAsync(
string apiBase,
Guid serverId,
string apiKey,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
Opened.Add((apiBase, serverId, apiKey, port));
SignIns.Add(signIn);
if (Refusal is { } refusal)
{
throw refusal;
}
var (client, far) = await PairAsync(cancellationToken);
if (CloseWith is { } close)
{
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
}
return new SpelunkTunnelRoute(RelayBridge.Start(client), port, signIn, signIn is null ? null : User);
}
private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken)
{
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var dialling = new TcpClient();
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, cancellationToken);
var accepted = await listener.AcceptTcpClientAsync(cancellationToken);
await connecting;
return (
WebSocket.CreateFromStream(
dialling.GetStream(),
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero }),
WebSocket.CreateFromStream(
accepted.GetStream(),
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
}
}
}
@@ -226,7 +226,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
// Scripted rather than real, so that a failed connection in any test in this file does not go
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
// the wall clock.
reachability: reachability);
reachability: reachability,
// Scripted for the same reason: a Spelunk host's connect never leaves the process. See
// ShellFlowTests.Spelunk.cs.
spelunk: spelunkTunnels);
return ValueTask.CompletedTask;
}
@@ -1,3 +1,6 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
@@ -198,4 +201,149 @@ public sealed class SingleInstanceTests : IDisposable
}
}
}
// ---- A launch that carries a link ----
/// <remarks>
/// Clicking "Open in DodoSSH" in Spelunk while DodoSSH is running is a second launch like any other, and
/// the link has to reach the instance that can act on it — along with the window coming forward.
/// </remarks>
[Fact]
public async Task ARefusedLaunchWithALink_PassesTheLinkOn()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var cameForward = new TaskCompletionSource();
var opened = new TaskCompletionSource<SpelunkLink>();
await using var doorbell = SingleInstance.Listen(
paths,
() => cameForward.TrySetResult(),
link => opened.TrySetResult(link));
var link = Link();
SingleInstance.Claim(paths, Impatient, link).ShouldBeNull("the profile is taken");
await cameForward.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
(await opened.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken)).ShouldBe(link);
}
[Fact]
public void ThePlainRing_IsStillThePlainRing()
{
// An older running build compares the whole line against this, so a launch with no link must go on
// saying exactly it.
SingleInstance.Message(link: null).ShouldBe("dodossh.come-forward");
SingleInstance.TryRead("dodossh.come-forward", out var link).ShouldBeTrue();
link.ShouldBeNull();
}
[Fact]
public void ALinkMessage_ReadsBackToTheSameLink()
{
var link = Link();
SingleInstance.TryRead(SingleInstance.Message(link), out var read).ShouldBeTrue();
read.ShouldBe(link);
}
/// <remarks>
/// Anything on this machine can write to the pipe, so a message that is not exactly one of the two is
/// ignored whole — the window does not even come forward for it.
/// </remarks>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("dodossh.come-forward ")]
[InlineData("DODOSSH.COME-FORWARD")]
[InlineData("dodossh.open-link")]
[InlineData("dodossh.open-link https://evil.example")]
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=http%3A%2F%2Fevil.example&server=0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b")]
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=not-a-server")]
[InlineData("dodossh.run calc.exe")]
public void AnythingElse_IsNotARing(string? said)
{
SingleInstance.TryRead(said, out var link).ShouldBeFalse();
link.ShouldBeNull();
}
[Fact]
public void AnOverlongMessage_IsNotARing()
{
SingleInstance.TryRead("dodossh.open-link " + new string('a', 64 * 1024), out _).ShouldBeFalse();
}
[Fact]
public async Task AGarbledRing_IsIgnored_AndTheDoorbellKeepsAnswering()
{
using var first = SingleInstance.Claim(paths, Impatient);
first.ShouldNotBeNull();
var rings = 0;
var answered = new TaskCompletionSource();
await using var doorbell = SingleInstance.Listen(
paths,
() =>
{
Interlocked.Increment(ref rings);
answered.TrySetResult();
});
// Somebody else's program, writing something that is not a ring and going on far past a line. The
// doorbell stops reading and hangs up part way, which the writer sees as a broken pipe.
using (var pipe = new NamedPipeClientStream(".", PipeNameOf(paths), PipeDirection.Out))
{
await pipe.ConnectAsync(TestContext.Current.CancellationToken);
try
{
await pipe.WriteAsync(
Encoding.UTF8.GetBytes(new string('x', 20_000) + "\n"),
TestContext.Current.CancellationToken);
}
catch (IOException)
{
// Hung up on, which is the point.
}
}
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
await answered.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
rings.ShouldBe(1, "the garbage rang nothing, and the real ring after it was still answered");
}
[Fact]
public void TheWindowsHandler_RunsThisExecutableWithTheLinkAsOneArgument()
{
Assert.SkipUnless(OperatingSystem.IsWindows(), "The registry handler exists only on Windows.");
const string executable = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe";
const string nightly = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop.Nightly\current\DodoSSH.exe";
WindowsUrlScheme.CommandFor(executable)
.ShouldBe(@"""C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe"" ""%1""");
// Uninstalling one channel must not take the handler away from the other if it registered since.
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(executable), executable).ShouldBeTrue();
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(nightly), executable).ShouldBeFalse();
WindowsUrlScheme.IsOurs(null, executable).ShouldBeFalse();
}
private static SpelunkLink Link() =>
new("https://api.spelunk.example.com", Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), "web 01", "203.0.113.10");
/// <summary>The pipe name <c>SingleInstance</c> derives, so a test can write to it as a stranger would.</summary>
private static string PipeNameOf(ClientPaths profile)
{
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(profile.DataDirectory));
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
}
}
@@ -39,6 +39,19 @@ internal static class HostFactory
BindPort = 1080,
};
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
/// <summary>A server's id in Spelunk.</summary>
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
/// <summary>
/// A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/> — signing in through Spelunk
/// when <paramref name="signIn"/> says so.
/// </summary>
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn };
/// <remarks>
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
@@ -59,7 +72,8 @@ internal static class HostFactory
Guid? groupId = null,
Guid[]? tags = null,
string[]? pinnedPaths = null,
PortForwardRule[]? portForwards = null) =>
PortForwardRule[]? portForwards = null,
SpelunkRoute? spelunk = null) =>
new()
{
Label = label,
@@ -79,6 +93,7 @@ internal static class HostFactory
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
Spelunk = spelunk,
};
/// <summary>
@@ -18,8 +18,11 @@ public sealed class HostSecretCodecTests
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
/// pair of pinned paths and a pair of forwards — which are orthogonal to the binding and are what make
/// this host reach the highest schema version a valid host can.
/// pair of pinned paths, a pair of forwards and a Spelunk route that signs in — which are orthogonal to the
/// binding (a sign-in leaves a stored binding alone rather than refusing it) and
/// are what make this host reach the highest schema version a valid host can. The route and the relay are
/// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's
/// flag has its own tests.
/// </remarks>
[Fact]
public void AFullHost_RoundTrips()
@@ -34,12 +37,12 @@ public sealed class HostSecretCodecTests
notes: "primary replica",
jumps: [Bastion, Relay],
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
relayEnabled: true,
credentialId: credentialId,
groupId: Production,
tags: [Pci, EuWest],
pinnedPaths: ["/var/www/app", "/var/log"],
portForwards: [Postgres, Socks]);
portForwards: [Postgres, Socks],
spelunk: ThroughSpelunk(signIn: true));
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
@@ -50,6 +53,21 @@ public sealed class HostSecretCodecTests
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ARelayedHost_RoundTrips_AtTheVersionItsOtherFieldsNeed()
{
// The relay flag predates schema versions, so it costs none; it is here because the full host above
// now takes the Spelunk route, which the relay excludes.
var host = Host(relayEnabled: true);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.Host.RelayEnabled.ShouldBeTrue();
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
}
// ---- The schema version is content-dependent ----
[Fact]
@@ -0,0 +1,131 @@
using System.Text;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// A Spelunk key: an API key and the one Spelunk it may be sent to.
/// </summary>
/// <remarks>
/// What these pin is that the pair is stored as it will be compared — the address in its one normal form —
/// that an address the key could leak across a network from is refused, and that a merge never pairs one
/// device's key with another device's address.
/// </remarks>
public sealed class SpelunkKeySecretTests
{
private const string Api = "https://api.spelunk.example.com";
[Fact]
public void AKey_RoundTrips()
{
var key = Key();
SpelunkKeySecretCodec.TryDecode(SpelunkKeySecretCodec.Encode(key), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Key.ShouldBe(key);
document.SchemaVersion.ShouldBe(SpelunkKeySecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void AKey_EncodesAtTheBaseVersion()
{
Encoding.UTF8.GetString(SpelunkKeySecretCodec.Encode(Key()))
.ShouldBe(
"""{"schemaVersion":1,"label":"Spelunk","apiBase":"https://api.spelunk.example.com","apiKey":"spelunk_one"}""");
}
[Theory]
[InlineData("https://api.spelunk.example.com/")]
[InlineData("HTTPS://API.SPELUNK.EXAMPLE.COM")]
public void AnAddressNotInItsNormalForm_IsRefused(string apiBase)
{
// Stored the one way or not at all: it is compared byte for byte with what a host or a link names.
(Key() with { ApiBase = apiBase }).TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("normal form");
}
[Fact]
public void PlainHttpAcrossANetwork_IsRefused()
{
(Key() with { ApiBase = "http://api.spelunk.example.com" }).TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("https");
}
[Fact]
public void PlainHttpToThisMachine_IsAllowed()
{
(Key() with { ApiBase = "http://localhost:5080" }).TryValidate(out _).ShouldBeTrue();
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("spelunk_one\r\nX-Other: header")]
public void AKeyThatIsNotAKey_IsRefused(string apiKey)
{
(Key() with { ApiKey = apiKey }).TryValidate(out _).ShouldBeFalse();
}
[Fact]
public void APayloadWithAnAddressOutOfNormalForm_DoesNotDecode()
{
// The address decides where the key may go, so a payload that disagrees with how every client
// writes it is refused rather than normalised into something nobody chose.
var payload = Encoding.UTF8.GetBytes(
"""{"schemaVersion":1,"label":"Spelunk","apiBase":"https://api.spelunk.example.com/","apiKey":"k"}""");
SpelunkKeySecretCodec.TryDecode(payload, out _).ShouldBeFalse();
}
[Fact]
public void AKeyChangedOnOneSide_AndAnAddressOnTheOther_IsKeptAsAPair()
{
// The case this merge exists for: merging field by field would send the new key to the new address,
// a pairing neither device made.
var ancestor = Key();
var local = ancestor with { ApiKey = "spelunk_two" };
var remote = ancestor with { ApiBase = "https://spelunk.other.example" };
var result = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
(result.Merged.ApiBase, result.Merged.ApiKey).ShouldBeOneOf(
(local.ApiBase, local.ApiKey),
(remote.ApiBase, remote.ApiKey));
var conflict = result.Conflicts.ShouldHaveSingleItem();
conflict.Field.ShouldBe(SpelunkKeySecretMerge.PairField);
}
[Fact]
public void AKeyReplacedOnBothSides_NeverReachesTheConflictLog()
{
var ancestor = Key();
var local = ancestor with { ApiKey = "spelunk_local_secret" };
var remote = ancestor with { ApiKey = "spelunk_remote_secret" };
var result = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
var conflict = result.Conflicts.ShouldHaveSingleItem();
$"{conflict.Kept} {conflict.Discarded}".ShouldNotContain("secret");
}
[Fact]
public void AKeyChangedOnOneSideOnly_IsTakenWithoutAConflict()
{
var ancestor = Key();
var local = ancestor with { ApiKey = "spelunk_two", Label = "renamed" };
var result = SpelunkKeySecretMerge.Merge(ancestor, local, ancestor);
result.Merged.ShouldBe(local);
result.HasConflicts.ShouldBeFalse();
}
private static SpelunkKeySecret Key() => new()
{
Label = "Spelunk",
ApiBase = Api,
ApiKey = "spelunk_one",
};
}
@@ -0,0 +1,301 @@
using System.Text;
using static DodoSSH.Client.Domain.Tests.HostFactory;
namespace DodoSSH.Client.Domain.Tests;
/// <summary>
/// A host reached through Spelunk: the route's own rules, how it is stored, and how it merges.
/// </summary>
/// <remarks>
/// The weight is on the compatibility rules, because a Spelunk host misread by some build is a host dialled
/// directly at an address that exists only to name its key — the one misreading that does something the
/// user never asked for.
/// </remarks>
public sealed class SpelunkRouteTests
{
// ---- The API address ----
[Theory]
[InlineData("https://api.spelunk.example.com", "https://api.spelunk.example.com")]
[InlineData(" https://API.Spelunk.Example.com/ ", "https://api.spelunk.example.com")]
[InlineData("https://api.spelunk.example.com:8443/", "https://api.spelunk.example.com:8443")]
[InlineData("https://example.com/spelunk/", "https://example.com/spelunk")]
[InlineData("http://localhost:5080", "http://localhost:5080")]
[InlineData("http://127.0.0.1:5080/", "http://127.0.0.1:5080")]
public void AnApiAddress_IsStoredInOneForm(string typed, string stored)
{
// One form, so the host a link finds is the host that was saved for it however either was typed.
SpelunkRoute.TryNormalizeApiBase(typed, out var normalized, out _).ShouldBeTrue();
normalized.ShouldBe(stored);
}
[Theory]
[InlineData("")]
[InlineData("api.spelunk.example.com")]
[InlineData("ftp://api.spelunk.example.com")]
[InlineData("javascript:alert(1)")]
[InlineData("https://user:pass@api.spelunk.example.com")]
[InlineData("https://api.spelunk.example.com/?x=1")]
[InlineData("https://api.spelunk.example.com/#top")]
public void AnAddressThatIsNotASpelunkApi_IsRefused(string typed)
{
SpelunkRoute.TryNormalizeApiBase(typed, out _, out var reason).ShouldBeFalse();
reason.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public void PlainHttp_IsRefusedAnywhereButThisMachine()
{
// The key is a bearer token sent with every tunnel; in clear across a network it is somebody else's.
SpelunkRoute.TryNormalizeApiBase("http://api.spelunk.example.com", out _, out var reason)
.ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("https");
}
// ---- The host ----
[Fact]
public void AHostReachedThroughSpelunk_IsValid()
{
Host(spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeTrue(reason);
}
[Fact]
public void AHostCannotTakeTheRelayAndSpelunkBoth()
{
Host(relayEnabled: true, spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("not both");
}
[Theory]
[InlineData("server")]
[InlineData("key")]
[InlineData("api")]
public void ARouteMissingAPart_IsRefused(string missing)
{
var route = missing switch
{
"server" => ThroughSpelunk() with { ServerId = Guid.Empty },
"key" => ThroughSpelunk() with { ApiKeyCredentialId = Guid.Empty },
_ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" },
};
Host(spelunk: route).TryValidate(out _).ShouldBeFalse();
}
// ---- The payload ----
[Fact]
public void AHostReachedThroughSpelunk_RoundTrips_AtTheVersionThatIntroducedIt()
{
var host = Host(spelunk: ThroughSpelunk(), credentialId: Guid.CreateVersion7());
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void AHostWithoutARoute_EncodesExactlyAsBefore()
{
// Not using the feature is not using the feature: no key in the JSON, and no version bump, so every
// existing host keeps its bytes and the first sync after the upgrade pushes nothing.
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
""");
}
[Fact]
public void TheRoute_IsWrittenLastAndWhole()
{
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk()));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
""");
}
[Fact]
public void AnOlderPayload_StillDecodes_AsAHostThatIsDialled()
{
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/log"]}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.Spelunk.ShouldBeNull();
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void AHalfWrittenRoute_MakesTheHostUnreadable_RatherThanDialled()
{
// The other reading — no route — would turn a damaged Spelunk host into one dialled directly.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
""");
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
}
[Fact]
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
{
// The rule every version before this one relies on, checked from the far side: a build that knows
// version 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue();
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
}
// ---- Signing in through Spelunk ----
[Fact]
public void AHostSpelunkSignsInTo_IsValid_WithNoLoginOfItsOwn()
{
// No username, no key, no credential, no typed password: Spelunk names the account and makes the key.
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
host.TryValidate(out var reason).ShouldBeTrue(reason);
host.SignsInThroughSpelunk.ShouldBeTrue();
}
[Fact]
public void AHostSpelunkSignsInTo_KeepsALoginItStillCarries()
{
// Left alone rather than refused: it is the host's answer for the day the box is unticked, and a
// merge of one machine ticking it with another binding a key must still decode.
Host(sshKeyId: Guid.CreateVersion7(), spelunk: ThroughSpelunk(signIn: true))
.TryValidate(out var reason)
.ShouldBeTrue(reason);
}
[Fact]
public void AHostReachedThroughSpelunkWithoutSigningIn_DoesNotSignIn()
{
Host(spelunk: ThroughSpelunk()).SignsInThroughSpelunk.ShouldBeFalse();
Host().SignsInThroughSpelunk.ShouldBeFalse();
}
[Fact]
public void ASignIn_RoundTrips_AtTheVersionThatIntroducedIt()
{
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ASignIn_IsWrittenInsideTheRoute_AndOnlyWhenOn()
{
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk(signIn: true)));
Encoding.UTF8.GetString(bytes).ShouldBe(
"""
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":true}}
""");
}
[Fact]
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
{
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
document.Host.SignsInThroughSpelunk.ShouldBeFalse();
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ASignInWrittenAsFalse_ReadsAsTheRouteWithout()
{
// One state, one spelling — the merge would otherwise see a change nobody made.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":false}}
""");
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
}
[Fact]
public void TickingSignIn_MergesAsPartOfTheOneRoute()
{
var ancestor = Host(spelunk: ThroughSpelunk());
var local = ancestor with { Spelunk = ThroughSpelunk(signIn: true) };
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
// Alone, the tick arrives.
HostSecretMerge.Merge(ancestor, local, ancestor).Merged.ShouldBe(local);
// Beside a different edit to the route, the route is still one value: one side wins whole, with the
// other reported, rather than a sign-in to a server nobody chose it for.
var result = HostSecretMerge.Merge(ancestor, local, remote);
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
}
// ---- The merge ----
[Fact]
public void TheRoute_IsRoutedThroughTheMerge()
{
var ancestor = Host();
var local = ancestor with { Spelunk = ThroughSpelunk() };
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
result.Merged.ShouldBe(local);
result.HasConflicts.ShouldBeFalse();
}
[Fact]
public void TwoDifferentRoutes_MergeAsOneValue_AndTheDiscardedOneIsReported()
{
// Whole, not per part: one side's API address with the other side's server id is a route nobody
// chose, to a server that may not exist on that Spelunk.
var ancestor = Host();
var local = ancestor with { Spelunk = ThroughSpelunk("https://spelunk.local.example") };
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
var result = HostSecretMerge.Merge(ancestor, local, remote);
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
}
}
@@ -0,0 +1,224 @@
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// Spelunk's two tunnel endpoints, as far as the wire is concerned: HTTP/1.1 over a real loopback socket.
/// </summary>
/// <remarks>
/// <para>
/// Real sockets rather than a stubbed handler, because what is being tested is the client's own HTTP and
/// WebSocket stack talking to something: the header the key goes in, the subprotocols the upgrade offers,
/// a refused upgrade's status read back through <c>CollectHttpResponseDetails</c>, and a close code
/// arriving through <see cref="Client.Ssh.RelayBridge"/>. A handler stub would skip exactly those.
/// </para>
/// <para>
/// Hand-rolled rather than Kestrel, so this client suite takes no dependency on the ASP.NET Core framework
/// for one fixture. It speaks only what the client sends: one request per connection, answered with
/// <c>Connection: close</c>, and the one upgrade.
/// </para>
/// </remarks>
internal sealed class FakeSpelunk : IAsyncDisposable
{
internal const string Ticket = "dGlja2V0LWZvci1hLXR1bm5lbA";
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource stopping = new();
private readonly Task accepting;
internal FakeSpelunk()
{
listener.Start();
accepting = AcceptAsync();
}
/// <summary>The API base a client is given.</summary>
internal string ApiBase => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
/// <summary>Every request that arrived, in order.</summary>
internal List<Request> Requests { get; } = [];
/// <summary>How the ticket endpoint answers. By default, with a ticket.</summary>
internal Func<Request, Answer> OnTicket { get; set; } = request => Answer.Json(
HttpStatusCode.OK,
$$"""
{"ticket":"{{Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1"}
""");
/// <summary>How the upgrade is answered: null accepts it, anything else refuses it with that status.</summary>
internal Func<Request, HttpStatusCode?> OnUpgrade { get; set; } = _ => null;
/// <summary>What Spelunk does with an accepted tunnel — by default nothing, until the client closes.</summary>
internal Func<WebSocket, Task> OnTunnel { get; set; } = DrainAsync;
public async ValueTask DisposeAsync()
{
await stopping.CancelAsync();
listener.Stop();
try
{
await accepting;
}
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
{
// Stopped.
}
stopping.Dispose();
}
/// <summary>Reads until the client closes, and answers the close.</summary>
internal static async Task DrainAsync(WebSocket socket)
{
var buffer = new byte[4096];
while ((await socket.ReceiveAsync(buffer, CancellationToken.None)).MessageType != WebSocketMessageType.Close)
{
}
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
private async Task AcceptAsync()
{
while (!stopping.IsCancellationRequested)
{
var client = await listener.AcceptTcpClientAsync(stopping.Token);
_ = ServeAsync(client);
}
}
private async Task ServeAsync(TcpClient client)
{
using (client)
{
var stream = client.GetStream();
var request = await ReadRequestAsync(stream);
lock (Requests)
{
Requests.Add(request);
}
if (request.Headers.TryGetValue("Upgrade", out var upgrade)
&& upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase))
{
await UpgradeAsync(stream, request);
return;
}
var answer = OnTicket(request);
var body = Encoding.UTF8.GetBytes(answer.Body);
await WriteAsync(
stream,
$"HTTP/1.1 {(int)answer.Status} {answer.Status}\r\nContent-Type: {answer.ContentType}\r\n"
+ $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(body);
}
}
private async Task UpgradeAsync(NetworkStream stream, Request request)
{
if (OnUpgrade(request) is { } refusal)
{
await WriteAsync(
stream,
$"HTTP/1.1 {(int)refusal} {refusal}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
return;
}
await WriteAsync(
stream,
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ $"Sec-WebSocket-Accept: {AcceptFor(request.Headers["Sec-WebSocket-Key"])}\r\n"
+ "Sec-WebSocket-Protocol: spelunk.tunnel.v1\r\n\r\n");
using var socket = WebSocket.CreateFromStream(
stream,
new WebSocketCreationOptions
{
IsServer = true,
SubProtocol = "spelunk.tunnel.v1",
KeepAliveInterval = TimeSpan.Zero,
});
try
{
await OnTunnel(socket);
}
catch (Exception exception) when (exception is WebSocketException or IOException)
{
// The client went away; nothing here is under test.
}
}
/// <summary>The handshake's accept value, as RFC 6455 section 4.2.2 defines it.</summary>
private static string AcceptFor(string key)
{
#pragma warning disable RS0030, CA5350 // RFC 6455 fixes SHA-1 for this value. It authenticates nothing; it proves the peer read the key.
return Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
#pragma warning restore RS0030, CA5350
}
private static async Task<Request> ReadRequestAsync(NetworkStream stream)
{
var head = new List<byte>();
var one = new byte[1];
while (!(head.Count >= 4 && head[^4] == '\r' && head[^3] == '\n' && head[^2] == '\r' && head[^1] == '\n'))
{
if (await stream.ReadAsync(one) == 0)
{
break;
}
head.Add(one[0]);
}
var lines = Encoding.ASCII.GetString([.. head]).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
var requestLine = lines[0].Split(' ');
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines.Skip(1))
{
var colon = line.IndexOf(':', StringComparison.Ordinal);
headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
}
var body = string.Empty;
if (headers.TryGetValue("Content-Length", out var length) && int.Parse(length, System.Globalization.CultureInfo.InvariantCulture) is > 0 and var count)
{
var bytes = new byte[count];
await stream.ReadExactlyAsync(bytes);
body = Encoding.UTF8.GetString(bytes);
}
return new Request(requestLine[0], requestLine[1], headers, body);
}
private static Task WriteAsync(NetworkStream stream, string text) =>
stream.WriteAsync(Encoding.ASCII.GetBytes(text)).AsTask();
/// <summary>One request as it arrived.</summary>
internal sealed record Request(string Method, string Path, Dictionary<string, string> Headers, string Body);
/// <summary>What the ticket endpoint says back.</summary>
internal sealed record Answer(HttpStatusCode Status, string ContentType, string Body)
{
internal static Answer Json(HttpStatusCode status, string body) => new(status, "application/json", body);
internal static Answer Problem(HttpStatusCode status, string title, string? detail = null) => new(
status,
"application/problem+json",
detail is null
? $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}}}"""
: $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}},"detail":"{{detail}}"}""");
}
}
@@ -0,0 +1,115 @@
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// Reading a <c>dodossh://spelunk/connect</c> link, which anything on the machine can hand the application.
/// </summary>
/// <remarks>
/// A link is data to check, never an instruction. The refusals below are the part that matters: each is a
/// link that is nearly right, and "nearly right" is the shape a crafted one takes.
/// </remarks>
public sealed class SpelunkLinkTests
{
private const string Server = "0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b";
private const string Link =
"dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=" + Server
+ "&name=web%2001&host=203.0.113.10";
[Fact]
public void ALinkFromSpelunk_IsRead()
{
SpelunkLink.TryParse(Link, out var link).ShouldBeTrue();
link.ShouldBe(new SpelunkLink("https://api.spelunk.example.com", Guid.Parse(Server), "web 01", "203.0.113.10"));
link.Hostname.ShouldBe("203.0.113.10");
}
[Fact]
public void ALinkWithNoHost_NamesTheHostAfterTheServer()
{
// The name the host key is pinned under, so it has to be stable and this server's alone.
SpelunkLink.TryParse(Link.Replace("&host=203.0.113.10", string.Empty, StringComparison.Ordinal), out var link)
.ShouldBeTrue();
link.Host.ShouldBeNull();
link.Hostname.ShouldBe($"spelunk-{Server}");
}
[Fact]
public void ALink_WritesOutAsItReadsBack()
{
// What a second launch sends the running instance: the one spelling this build writes.
SpelunkLink.TryParse(Link, out var link).ShouldBeTrue();
SpelunkLink.TryParse(link.ToString(), out var again).ShouldBeTrue();
again.ShouldBe(link);
}
[Fact]
public void ANameFormEncoded_AndOneWithControlCharacters_AreCleanedForDisplay()
{
SpelunkLink.TryParse(Link.Replace("web%2001", "web+01%0A%1B%5B31m", StringComparison.Ordinal), out var link)
.ShouldBeTrue();
link.Name.ShouldNotContain('\n');
link.Name.ShouldNotContain('\u001b');
link.Name.ShouldStartWith("web 01");
}
[Fact]
public void AnUnknownParameter_IsIgnored()
{
// So a newer Spelunk can add one without every DodoSSH already installed refusing its links.
SpelunkLink.TryParse(Link + "&port=22", out _).ShouldBeTrue();
}
[Theory]
[InlineData("https://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
[InlineData("dodossh://elsewhere/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
[InlineData("dodossh://spelunk/disconnect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
[InlineData("dodossh://user@spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
[InlineData("dodossh://spelunk:8080/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "#x")]
[InlineData("dodossh://spelunk/connect?server=" + Server)]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com")]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=not-a-guid")]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=00000000-0000-0000-0000-000000000000")]
[InlineData("dodossh://spelunk/connect?api=http%3A%2F%2Fapi.example.com&server=" + Server)]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fu%3Ap%40api.example.com&server=" + Server)]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&api=https%3A%2F%2Fevil.example&server=" + Server)]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "&host=bad%20host")]
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "&name=a b")]
[InlineData("")]
public void ALinkThatIsNotExactlyThis_IsRefused(string text)
{
SpelunkLink.TryParse(text, out var link).ShouldBeFalse();
link.ShouldBeNull();
}
[Fact]
public void AnOverlongLink_IsRefusedBeforeItIsParsed()
{
SpelunkLink.TryParse(Link + "&name=" + new string('a', SpelunkLink.MaxLength), out _).ShouldBeFalse();
}
[Fact]
public void ANameThatRunsOn_IsCut()
{
SpelunkLink.TryParse(Link.Replace("web%2001", new string('a', 500), StringComparison.Ordinal), out var link)
.ShouldBeTrue();
link.Name.Length.ShouldBe(SpelunkLink.MaxNameLength);
}
[Theory]
[InlineData("dodossh://spelunk/connect", true)]
[InlineData("DODOSSH://anything", true)]
[InlineData("--veloapp-install", false)]
[InlineData(null, false)]
public void TheLauncher_TellsALinkFromAnotherArgument(string? argument, bool looksLikeOne)
{
SpelunkLink.LooksLikeOne(argument).ShouldBe(looksLikeOne);
}
}
@@ -0,0 +1,403 @@
using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// The Spelunk tunnel client against Spelunk's contract, over real sockets.
/// </summary>
/// <remarks>
/// <para>
/// Every test here uses the application's own <see cref="SpelunkTunnel"/> — its shared transport and its
/// real WebSocket dialler — against <see cref="FakeSpelunk"/>. What is asserted is what the contract fixes:
/// the ticket request, the two subprotocols, bytes both ways once the tunnel is up, and a sentence for every
/// way Spelunk says no.
/// </para>
/// <para>
/// The bytes are not SSH here. The bridge is the relay's, and a real SSH connection through it is
/// <c>RelayBridgeTests</c>' job; this suite holds the half that is Spelunk's.
/// </para>
/// </remarks>
public sealed class SpelunkTunnelTests : IAsyncLifetime
{
private const string ApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
private static readonly TimeSpan Settles = TimeSpan.FromSeconds(20);
private readonly FakeSpelunk spelunk = new();
private static CancellationToken Token => TestContext.Current.CancellationToken;
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
public ValueTask DisposeAsync() => spelunk.DisposeAsync();
[Fact]
public async Task ATicket_IsAskedForWithTheKey_AndSpentAsTheSecondSubprotocol()
{
await using var route = await OpenAsync();
var ticket = spelunk.Requests[0];
ticket.Method.ShouldBe("POST");
ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets");
ticket.Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
ticket.Body.ShouldBe("""{"port":22}""");
var upgrade = spelunk.Requests[1];
upgrade.Path.ShouldBe($"/api/servers/{ServerId}/tunnel");
upgrade.Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
// The key opened the ticket and went nowhere else: the socket carries the ticket alone.
upgrade.Headers.ContainsKey("Authorization").ShouldBeFalse();
upgrade.Path.ShouldNotContain(FakeSpelunk.Ticket);
}
[Fact]
public async Task BytesCross_BothWays_ThroughTheBridge()
{
var received = new TaskCompletionSource<string>();
spelunk.OnTunnel = async socket =>
{
// What the agent's end does: read what the client sent, answer with the server's own bytes.
var buffer = new byte[64];
var read = await socket.ReceiveAsync(buffer, CancellationToken.None);
received.TrySetResult(Encoding.ASCII.GetString(buffer, 0, read.Count));
await socket.SendAsync(
Encoding.ASCII.GetBytes("SSH-2.0-OpenSSH_9.9\r\n"),
WebSocketMessageType.Binary,
endOfMessage: true,
CancellationToken.None);
await FakeSpelunk.DrainAsync(socket);
};
await using var route = await OpenAsync();
using var client = await ThroughSocksAsync(route.Proxy.Port);
var stream = client.GetStream();
await stream.WriteAsync(Encoding.ASCII.GetBytes("SSH-2.0-DodoSSH\r\n"), Token);
(await received.Task.WaitAsync(Settles, Token)).ShouldBe("SSH-2.0-DodoSSH\r\n");
var answer = new byte["SSH-2.0-OpenSSH_9.9\r\n".Length];
await stream.ReadExactlyAsync(answer, Token);
Encoding.ASCII.GetString(answer).ShouldBe("SSH-2.0-OpenSSH_9.9\r\n");
}
[Fact]
public async Task ThePortAsked_IsTheHostsPort()
{
await using var route = await spelunk.ApiBase.OpenAsync(port: 2222);
spelunk.Requests[0].Body.ShouldBe("""{"port":2222}""");
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, "did not accept the API key")]
[InlineData(HttpStatusCode.NotFound, "does not know this server")]
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
public async Task ARefusedTicket_IsExplained_AndNoSocketIsOpened(HttpStatusCode status, string explanation)
{
spelunk.OnTicket = _ => FakeSpelunk.Answer.Problem(status, "refused");
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
refusal.Message.ShouldContain(explanation);
refusal.StatusCode.ShouldBe(status);
spelunk.Requests.ShouldHaveSingleItem();
}
[Fact]
public async Task AKeyWithTooLowARole_IsToldWhatSpelunkSaid()
{
// Spelunk's detail says which role the key has and which it needs, and it is the useful half.
spelunk.OnTicket = _ => FakeSpelunk.Answer.Problem(
HttpStatusCode.Forbidden,
"Forbidden",
"This API key has the Viewer role; opening a tunnel needs Admin.");
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
refusal.Message.ShouldContain("not allowed to open a terminal");
refusal.Message.ShouldContain("needs Admin");
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, "refused the tunnel ticket")]
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
public async Task ARefusedUpgrade_IsExplained(HttpStatusCode status, string explanation)
{
spelunk.OnUpgrade = _ => status;
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
refusal.Message.ShouldContain(explanation);
}
[Theory]
[InlineData("//evil.example/api/servers/x/tunnel")]
[InlineData("@evil.example/api/servers/x/tunnel")]
[InlineData("api/servers/x/tunnel")]
[InlineData("/api/servers/x/tunnel?elsewhere=1")]
public async Task ATicketNamingAnythingButAPath_IsRefused_AndNothingIsDialled(string path)
{
// The path is appended to the API's own origin. Anything that would move it to another host — and
// take the ticket with it — is not a path.
spelunk.OnTicket = _ => FakeSpelunk.Answer.Json(
HttpStatusCode.OK,
$$"""{"ticket":"{{FakeSpelunk.Ticket}}","path":"{{path}}","subprotocol":"spelunk.tunnel.v1"}""");
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
refusal.Message.ShouldContain("does not understand");
spelunk.Requests.ShouldHaveSingleItem();
}
[Theory]
[InlineData("connection refused", "could not connect to port 22 on the server itself (connection refused)")]
[InlineData("Unknown stream kind: tunnel", "too old to open a terminal")]
public async Task AnAgentThatCouldNotReachThePort_SaysSo(string reason, string explanation)
{
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
var route = await OpenAsync();
await EndOfStreamAsync(route);
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
}
[Fact]
public async Task AnAgentThatDisconnected_SaysSo()
{
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync((WebSocketCloseStatus)4503, "agent gone", CancellationToken.None);
var route = await OpenAsync();
await EndOfStreamAsync(route);
route.Failure.ShouldNotBeNull().ShouldContain("disconnected from Spelunk");
}
[Fact]
public async Task ANormalClose_IsNotReworded()
{
// 1000 is the SSH server hanging up, and SSH.NET's own account of that is the accurate one.
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
var route = await OpenAsync();
await EndOfStreamAsync(route);
route.Failure.ShouldBeNull();
}
[Fact]
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
{
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
refusal.Message.ShouldContain("does not hold one");
spelunk.Requests.ShouldBeEmpty();
}
[Fact]
public async Task AKeyPastedWithANewline_IsTrimmed()
{
await using var route = await new SpelunkTunnel()
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, signIn: null, Token);
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
}
[Fact]
public async Task AnUnreachableSpelunk_SaysWhere()
{
// A port that was listening a moment ago and is not any more.
var closed = new TcpListener(IPAddress.Loopback, 0);
closed.Start();
var port = ((IPEndPoint)closed.LocalEndpoint).Port;
closed.Stop();
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
}
// ---- Signing in through Spelunk ----
[Fact]
public async Task ASignIn_SendsThePublicKeyAndNoPort_AndLogsInAsTheAccountSpelunkNames()
{
spelunk.OnTicket = SignedInAs("root");
var signIn = SpelunkSignIn.Create();
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, signIn);
// The one-line OpenSSH public key and nothing else: the port means nothing beside a key.
// Read as JSON rather than compared as text: the serialiser escapes base64's '+' as +, which is
// the same string to anything that parses it.
using (var body = System.Text.Json.JsonDocument.Parse(spelunk.Requests[0].Body))
{
body.RootElement.EnumerateObject().Select(property => property.Name).ShouldBe(["signIn"]);
var sent = body.RootElement.GetProperty("signIn");
sent.EnumerateObject().Select(property => property.Name).ShouldBe(["publicKey"]);
sent.GetProperty("publicKey").GetString().ShouldBe(signIn.PublicKeyLine);
}
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 AAAA");
// The same key comes back with the route, for the connection made through it, under Spelunk's account.
route.SignIn.ShouldBeSameAs(signIn);
route.User.ShouldBe("root");
// And the socket is spent exactly as a plain tunnel's is.
spelunk.Requests[1].Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
}
[Fact]
public async Task ASignInAnswerNamingNoAccount_LogsInAsRoot()
{
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
route.User.ShouldBe(SpelunkSignIn.DefaultUser);
}
[Fact]
public async Task APlainTunnel_HasNoSignIn_WhateverTheAnswerSays()
{
spelunk.OnTicket = SignedInAs("root");
await using var route = await OpenAsync();
route.SignIn.ShouldBeNull();
route.User.ShouldBeNull();
}
[Theory]
[InlineData("root; rm -rf /")]
[InlineData("root\\nsomeone")]
[InlineData("")]
public async Task ASignInAnswerNamingSomethingThatIsNotAnAccount_IsRefused(string user)
{
spelunk.OnTicket = SignedInAs(user);
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
refusal.Message.ShouldContain("does not understand");
spelunk.Requests.ShouldHaveSingleItem();
}
[Fact]
public async Task AKeySpelunkWillNotTake_IsExplainedInItsWords()
{
spelunk.OnTicket = _ => new FakeSpelunk.Answer(
HttpStatusCode.BadRequest,
"application/problem+json",
"""{"title":"One or more validation errors occurred.","status":400,"errors":{"publicKey":["The public key must be an ssh-ed25519 key."]}}""");
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
refusal.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
refusal.Message.ShouldContain("would not accept");
refusal.Message.ShouldContain("must be an ssh-ed25519 key");
spelunk.Requests.ShouldHaveSingleItem();
}
[Theory]
[InlineData("no OpenSSH server on this machine", "could not sign you in on this server (no OpenSSH server on this machine)")]
[InlineData("Unknown stream kind: signIn", "too old to sign you in")]
public async Task AnAgentThatCouldNotSignIn_SaysSo(string reason, string explanation)
{
spelunk.OnTunnel = socket =>
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
await EndOfStreamAsync(route);
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
}
[Fact]
public void ASignInKey_IsNewEachTime_AndNeverPrinted()
{
var first = SpelunkSignIn.Create();
var second = SpelunkSignIn.Create();
first.PublicKeyLine.ShouldNotBe(second.PublicKeyLine);
first.PublicKeyLine.Split(' ').Length.ShouldBe(3);
first.Credential.Passphrase.ShouldBeNull();
Encoding.UTF8.GetString(first.Credential.PrivateKeyPem).ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
first.ToString().ShouldNotContain("AAAA");
first.ToString().ShouldNotContain("PRIVATE");
}
/// <summary>A ticket answer for a sign-in, naming the account it logs in as.</summary>
private static Func<FakeSpelunk.Request, FakeSpelunk.Answer> SignedInAs(string user) => request => FakeSpelunk.Answer.Json(
HttpStatusCode.OK,
$$"""
{"ticket":"{{FakeSpelunk.Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1","user":"{{user}}"}
""");
private Task<SpelunkTunnelRoute> OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
/// <summary>Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.</summary>
private static async Task<TcpClient> ThroughSocksAsync(int port)
{
var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, port, Token);
var stream = client.GetStream();
var name = Encoding.ASCII.GetBytes("203.0.113.10");
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
await stream.ReadExactlyAsync(new byte[2], Token);
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
await stream.ReadExactlyAsync(new byte[10], Token);
return client;
}
/// <summary>Arrives at the bridge and reads until it closes the stream, which it does once Spelunk has.</summary>
private static async Task EndOfStreamAsync(SpelunkTunnelRoute route)
{
using var client = await ThroughSocksAsync(route.Proxy.Port);
var stream = client.GetStream();
var rest = new byte[256];
while (await stream.ReadAsync(rest, Token).AsTask().WaitAsync(Settles, Token) > 0)
{
}
await route.DisposeAsync();
}
}
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
file static class Opening
{
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
new SpelunkTunnel().OpenAsync(
apiBase,
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
port,
signIn,
TestContext.Current.CancellationToken);
}
@@ -420,6 +420,7 @@ public sealed class CacheStoreTests : IAsyncLifetime
[InlineData(SyncEntityType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry)]
[InlineData(SyncEntityType.ObjectStore)]
[InlineData(SyncEntityType.SpelunkKey)]
public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
{
var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
@@ -43,6 +43,7 @@ public sealed class AadResourceTypeTests
[InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
[InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
[InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
[InlineData(SyncEntityType.SpelunkKey, CryptoSpec.AadResourceType.SpelunkKey)]
public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
SyncEntityType wire,
CryptoSpec.AadResourceType resource)
@@ -77,6 +78,7 @@ public sealed class AadResourceTypeTests
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
(SyncEntityType.SpelunkKey, CryptoSpec.AadResourceType.SpelunkKey),
];
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
@@ -253,6 +255,10 @@ public sealed class AadResourceTypeTests
SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
NewBucket(), vaultKey, entityId, generation, version),
// 14 on the wire and 17 here, and 14 here is a connection log entry.
SyncEntityType.SpelunkKey => SpelunkKeyCipher.Seal(
NewSpelunkKey(), vaultKey, entityId, generation, version),
_ => throw new ArgumentOutOfRangeException(
nameof(wire),
wire,
@@ -413,6 +419,13 @@ public sealed class AadResourceTypeTests
Region = "eu-west-1",
};
private static SpelunkKeySecret NewSpelunkKey() => new()
{
Label = "Spelunk at api.spelunk.example.com",
ApiBase = "https://api.spelunk.example.com",
ApiKey = "spelunk_an_example_key",
};
private static HostGroupSecret NewGroup() => new() { Label = "production" };
private static TagSecret NewTag() => new() { Label = "pci" };
@@ -1,3 +1,4 @@
using DodoSSH.Client.Domain;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Sync.Tests;
@@ -29,6 +30,7 @@ public sealed class ItemKindsTests
SyncEntityType.ConnectionLogEntry,
SyncEntityType.ActivityLogEntry,
SyncEntityType.ObjectStore,
SyncEntityType.SpelunkKey,
]);
}
@@ -53,4 +55,23 @@ public sealed class ItemKindsTests
reconcilers.Keys.Order().ShouldBe(ItemKinds.SyncedTypes.Order());
}
[Fact]
public void AHostEditThatOnlyChangedItsPins_IsReportedAsChangingPinnedPaths()
{
// Pinned paths were the one host field Changes did not look at, so an edit that only pinned or
// unpinned a directory reached the keychain log as an update that changed nothing.
var before = new HostSecret
{
Label = "db-01",
Hostname = "db-01.internal",
PinnedPaths = PinnedPathList.Create(["/var/log"]),
};
var after = before with { PinnedPaths = PinnedPathList.Create(["/var/log", "/etc/nginx"]) };
HostKind.Instance.Changes(before, after).ShouldBe(["Pinned paths"]);
HostKind.Instance.Changes(before, before with { PinnedPaths = PinnedPathList.Empty })
.ShouldBe(["Pinned paths"]);
HostKind.Instance.Changes(before, before with { }).ShouldBeEmpty();
}
}
@@ -96,6 +96,7 @@ public sealed class CryptoSpecTests
[InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)]
[InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)]
[InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)]
[InlineData(CryptoSpec.AadResourceType.SpelunkKey, 17)]
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
{
((int)type).ShouldBe(expected);