Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault

A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was
refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings ->
Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a
native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's
/api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs
in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at
/api/app-sign-in/token for an app session token. That token opens terminals in every organisation its
person is in, with the role they have on each server at the time; tunnel tickets send no organisation.

The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type
18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came
from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of
DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in
still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of
DodoSSH and of every Spelunk on the device.

A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows
read-only and asks to be updated for, and a payload that still names a key credential decodes with the
id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17
are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its
cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a
Spelunk this device is not signed in to it opens the editor, which offers to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
laurensandClaude Opus 5.5 committed 2026-10-20 16:26:25 +02:00
1 parent 902761d37b
commit ef365f462e
62 files changed
+4327 -1945

No files matched your search

+9 -43
View File
@@ -1186,52 +1186,21 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
}
[Fact]
public async Task ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
public async Task ASpelunkKeyFromAnOlderClient_IsRefused_AsATypeThisServerDoesNotSync()
{
// 14 was SpelunkKey, removed when DodoSSH started signing in to Spelunk. An older client may still push
// one; it is told so for that item, and nothing of it is kept.
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])]));
new SyncPushRequest([RetiredSpelunkKeyOperation(Guid.CreateVersion7(), [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();
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results.ShouldHaveSingleItem();
result.Status.ShouldBe(SyncOperationStatus.Invalid);
result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
result.Detail.ShouldNotBeNull().ShouldContain("not yet supported");
}
[Fact]
@@ -1724,16 +1693,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
Payload(envelope),
PlaintextFields: null);
private static SyncPushOperation SpelunkKeyOperation(
Guid entityId,
int? expectedVersion,
byte[] envelope) =>
private static SyncPushOperation RetiredSpelunkKeyOperation(Guid entityId, byte[] envelope) =>
new(
Guid.CreateVersion7(),
SyncEntityType.SpelunkKey,
(SyncEntityType)14,
entityId,
SyncOperation.Upsert,
expectedVersion,
ExpectedVersion: null,
Payload(envelope),
PlaintextFields: null);
@@ -233,19 +233,26 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
}
/// <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.
/// The SPELUNK section with this device signed in: the form, and the row listing the sign-in, all fit, and
/// the form's button is the real sign-in.
/// </summary>
[Fact]
public async Task TheSecurityPageFitsWithASavedSpelunkKey()
public async Task TheSecurityPageFitsWhenSignedInToSpelunk()
{
vault.SpelunkKeyFormApi = "https://api.spelunk.example.com";
vault.SpelunkKeyFormKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyz";
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
await vault.Session.SpelunkSignIns.SaveAsync(
new SpelunkSignInRecord(
"https://api.spelunk.example.com",
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
Guid.CreateVersion7(),
"laurens@example.com",
"Laurens Example",
DateTimeOffset.UtcNow),
Token);
await vault.ReloadSpelunkSignInsAsync(Token);
vault.SpelunkKeys.ShouldHaveSingleItem(vault.SpelunkKeyFormNote);
vault.SpelunkSignIns.ShouldHaveSingleItem();
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("with a Spelunk key saved"));
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("signed in to Spelunk"));
await LayoutHarness.OnTheUiThreadAsync(
() =>
@@ -257,7 +264,7 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
try
{
ButtonNamed(window, "SAVE KEY").Command.ShouldBeSameAs(vault.SaveSpelunkKeyCommand);
ButtonNamed(window, "SIGN IN").Command.ShouldBeSameAs(vault.SignInToSpelunkCommand);
}
finally
{
@@ -122,6 +122,20 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
/// <inheritdoc />
public IVaultEventStream Events => Notices;
/// <summary>What the identity provider answers when the vault checks the sign-in, or null for yes.</summary>
/// <remarks>An <c>OidcException</c> with <c>invalid_grant</c> is an account the provider no longer signs in.</remarks>
public Exception? SignInRefusal { get; set; }
/// <summary>How many times the vault checked the sign-in.</summary>
public int SignInChecks { get; private set; }
/// <inheritdoc />
public Task VerifySignInAsync(CancellationToken cancellationToken)
{
SignInChecks++;
return SignInRefusal is { } refusal ? Task.FromException(refusal) : Task.CompletedTask;
}
/// <summary>
/// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a
/// server that does not advertise the feature supplies.
@@ -2,7 +2,9 @@ using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Session;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
@@ -19,22 +21,25 @@ namespace DodoSSH.Client.App.Tests;
/// 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.
/// What these hold is what the tick box, the sign-in and the link promise: a Spelunk host is never dialled
/// directly and never probed, a refusal is said in Spelunk's terms, this device's sign-in to a Spelunk goes
/// only to that Spelunk and only once DodoSSH's own account has been checked, 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
/// starts a sign-in by itself.
/// </para>
/// </remarks>
public sealed partial class ShellFlowTests
{
private const string SpelunkApi = "https://api.spelunk.example.com";
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private const string SpelunkToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
private readonly FakeSpelunkTunnel spelunkTunnels = new();
private readonly FakeSpelunkAccounts spelunkAccounts = new();
[Fact]
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
{
@@ -43,9 +48,11 @@ public sealed partial class ShellFlowTests
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.
// This device's sign-in, to the Spelunk and server the host names, for the host's own port — and only
// once DodoSSH's own account has been checked.
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
server.SignInChecks.ShouldBeGreaterThan(0);
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
var request = ssh.Requests.ShouldHaveSingleItem();
@@ -127,23 +134,22 @@ public sealed partial class ShellFlowTests
}
[Fact]
public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved()
public async Task AHostOnASpelunkThisDeviceIsNotSignedInTo_IsRefusedAtConnect_AndOffersTheSignIn()
{
await UnlockedAsync();
var vault = shell.Vault!;
var vault = await ReadyToConnectThroughSpelunkAsync();
await vault.Session.SpelunkSignIns.ForgetAllAsync(Token);
await vault.ReloadSpelunkSignInsAsync(Token);
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 using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
await vault.SaveHostCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
vault.Hosts.ShouldBeEmpty();
vault.EditSelectedHostCommand.Execute(null);
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
vault.EditorSpelunkSignInNote.ShouldContain("not signed in to Spelunk");
}
[Fact]
@@ -156,22 +162,8 @@ public sealed partial class ShellFlowTests
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");
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
vault.EditorSpelunkSignInNote.ShouldContain("ops@example.com");
}
// ---- Signing in through Spelunk ----
@@ -188,7 +180,7 @@ public sealed partial class ShellFlowTests
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
@@ -201,7 +193,7 @@ public sealed partial class ShellFlowTests
shell.IsTerminalShowing.ShouldBeTrue();
shell.Tabs.ShouldHaveSingleItem();
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
vault.Credentials.ShouldBeEmpty("nothing is remembered for a sign-in");
await request.Proxy.Lifetime.DisposeAsync();
}
@@ -319,11 +311,11 @@ public sealed partial class ShellFlowTests
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");
// Not signed in to that Spelunk, and a link never starts a sign-in by itself: the editor offers it.
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
vault.Status.ShouldNotBeNull().ShouldContain("Sign in to that Spelunk");
spelunkAccounts.SignedIn.ShouldBeEmpty();
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
@@ -353,9 +345,11 @@ public sealed partial class ShellFlowTests
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");
// Signed in from the editor and saved, the next link for that server finds it rather than adding a
// second one.
await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null);
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
await vault.SaveHostCommand.ExecuteAsync(null);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
@@ -376,10 +370,10 @@ public sealed partial class ShellFlowTests
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");
vault.IsEditing.ShouldBeFalse("nothing to choose: this device is signed in 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.
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk, 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");
@@ -387,7 +381,6 @@ public sealed partial class ShellFlowTests
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();
@@ -395,7 +388,7 @@ public sealed partial class ShellFlowTests
// And connected, through Spelunk, signing in.
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
opened.ShouldBe((SpelunkApi, other, SpelunkToken, 22));
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
var request = ssh.Requests.ShouldHaveSingleItem();
@@ -507,70 +500,45 @@ public sealed partial class ShellFlowTests
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
}
// ---- A Spelunk key saved on the Security page ----
// ---- Signing this device in to Spelunk ----
[Fact]
public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
public async Task ASignInOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey);
await SignInToSpelunkAsync(vault, SpelunkApi + "/");
// 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();
// Signed in through the browser, for the address in its one form, and kept on this device.
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
var signedIn = vault.SpelunkSignIns.ShouldHaveSingleItem();
signedIn.ApiBase.ShouldBe(SpelunkApi);
signedIn.Who.ShouldContain("ops@example.com");
vault.SpelunkSignInFormNote.ShouldContain("Signed in");
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");
vault.IsEditing.ShouldBeFalse("signing in to this Spelunk is the say-so a link needs");
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));
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
}
[Fact]
public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne()
public async Task ASignIn_IsNeverSentToAnotherSpelunk()
{
await UnlockedAsync();
var vault = shell.Vault!;
await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key");
await SignInToSpelunkAsync(vault, SpelunkApi);
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.
// A host on a Spelunk this device did not sign in to: the sign-in it has is for somewhere else.
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
vault.EditorHostname = "203.0.113.10";
@@ -578,7 +546,7 @@ public sealed partial class ShellFlowTests
vault.EditorSpelunkSignIn = true;
vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example";
vault.EditorSpelunkServerId = SpelunkServer.ToString();
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId);
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
await vault.SaveHostCommand.ExecuteAsync(null);
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
@@ -588,85 +556,171 @@ public sealed partial class ShellFlowTests
spelunkTunnels.Opened.ShouldBeEmpty();
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else");
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
}
[Fact]
public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor()
public async Task AnAddressThatWouldSendTheSignInInClear_IsRefusedOnTheForm()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "an older key", password: "spelunk_older");
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
await SignInToSpelunkAsync(vault, "http://api.spelunk.example.com");
spelunkAccounts.SignedIn.ShouldBeEmpty("no browser is opened for an address that would be refused");
vault.SpelunkSignIns.ShouldBeEmpty();
vault.SpelunkSignInFormNote.ShouldContain("https");
}
[Fact]
public async Task ASignInCancelledInTheBrowser_IsSaid_AndNothingIsKept()
{
await UnlockedAsync();
var vault = shell.Vault!;
spelunkAccounts.Refusal = new SpelunkSignInException("Signing in to Spelunk was cancelled in the browser.");
await SignInToSpelunkAsync(vault, SpelunkApi);
vault.SpelunkSignIns.ShouldBeEmpty();
vault.SpelunkSignInFormNote.ShouldContain("cancelled in the browser");
vault.IsSigningInToSpelunk.ShouldBeFalse();
}
[Fact]
public async Task TheEditor_SaysWhoItReachesSpelunkAs_AndSignsInFromThere()
{
await UnlockedAsync();
var vault = shell.Vault!;
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}");
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
// 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"]);
await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null);
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
vault.SpelunkSignIns.ShouldHaveSingleItem();
}
[Fact]
public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm()
public async Task SpelunkSigningThisDeviceOut_ForgetsTheSignIn_SoTheNextConnectAsksForANewOne()
{
await UnlockedAsync();
var vault = shell.Vault!;
spelunkTunnels.Refusal = new SpelunkTunnelException(
"Spelunk signed this device out: your password changed.", HttpStatusCode.Unauthorized);
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);
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.OpenSpelunkLinkAsync(Link());
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
await vault.ConnectCommand.ExecuteAsync(null);
vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single());
vault.Status.ShouldNotBeNull().ShouldContain("signed this device out");
vault.SpelunkSignIns.ShouldBeEmpty();
(await vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty();
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");
// The next attempt does not repeat the refusal: it asks to sign in.
spelunkTunnels.Refusal = null;
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.Count.ShouldBe(1);
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
}
private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey)
[Fact]
public async Task AServerOrOrganisationSpelunkRefuses_KeepsTheSignIn()
{
vault.SpelunkKeyFormApi = apiBase;
vault.SpelunkKeyFormKey = apiKey;
// A 403 is about this server, not about who is signed in: every other server stays reachable.
spelunkTunnels.Refusal = new SpelunkTunnelException(
"You are not allowed to open a terminal on this server.", HttpStatusCode.Forbidden);
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
var vault = await ReadyToConnectThroughSpelunkAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
vault.Status.ShouldNotBeNull().ShouldContain("not allowed");
vault.SpelunkSignIns.ShouldHaveSingleItem();
}
/// <summary>A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.</summary>
[Fact]
public async Task ADodoSshAccountTheProviderRefuses_ReachesNoSpelunk_AndSignsThisDeviceOut()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
server.SignInRefusal = new OidcException("The account is disabled.", "invalid_grant");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldBeEmpty("Spelunk is not reached for an account DodoSSH no longer has");
ssh.Requests.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("no longer signs in");
// Signed out of every Spelunk, there and here, and of DodoSSH: offline, and saying why.
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
vault.SpelunkSignIns.ShouldBeEmpty();
shell.IsOnline.ShouldBeFalse();
vault.OfflineReason.ShouldNotBeNull().ShouldContain("refused");
}
[Fact]
public async Task ADodoSshSignInThatCannotBeChecked_ReachesNoSpelunk_ButIsKept()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
server.SignInRefusal = new HttpRequestException("The identity provider could not be reached.");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldBeEmpty();
vault.Status.ShouldNotBeNull().ShouldContain("could not check");
vault.SpelunkSignIns.ShouldHaveSingleItem("a provider that is down is not an account that is gone");
shell.IsOnline.ShouldBeTrue();
}
[Fact]
public async Task SigningOutOfSpelunk_OnTheSecurityPage_EndsItThereAndHere()
{
var vault = await ReadyToConnectThroughSpelunkAsync();
await vault.SignOutOfSpelunkCommand.ExecuteAsync(vault.SpelunkSignIns.Single());
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
vault.SpelunkSignIns.ShouldBeEmpty();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectCommand.ExecuteAsync(null);
spelunkTunnels.Opened.ShouldBeEmpty();
}
[Fact]
public async Task SigningOutOfDodoSsh_SignsOutOfEverySpelunk()
{
await ReadyToConnectThroughSpelunkAsync();
shell.SignOutCommand.Execute(null);
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
}
private static async Task SignInToSpelunkAsync(VaultViewModel vault, string apiBase)
{
vault.SpelunkSignInFormApi = apiBase;
await vault.SignInToSpelunkCommand.ExecuteAsync(null);
}
/// <summary>A saved host through Spelunk, bound to a stored SSH password, on a Spelunk this device is signed in to.</summary>
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
await SignInToSpelunkAsync(vault, SpelunkApi);
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
vault.NewHostCommand.Execute(null);
@@ -676,7 +730,6 @@ public sealed partial class ShellFlowTests
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");
@@ -697,7 +750,7 @@ public sealed partial class ShellFlowTests
var vault = shell.Vault!;
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
await SignInToSpelunkAsync(vault, SpelunkApi);
vault.NewHostCommand.Execute(null);
vault.EditorLabel = "web-01";
@@ -706,7 +759,6 @@ public sealed partial class ShellFlowTests
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);
@@ -748,7 +800,7 @@ public sealed partial class ShellFlowTests
/// </summary>
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
{
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
internal List<(string ApiBase, Guid ServerId, string Token, 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; } = [];
@@ -764,12 +816,12 @@ public sealed partial class ShellFlowTests
public async Task<SpelunkTunnelRoute> OpenAsync(
string apiBase,
Guid serverId,
string apiKey,
string token,
int port,
SpelunkSignIn? signIn,
CancellationToken cancellationToken)
{
Opened.Add((apiBase, serverId, apiKey, port));
Opened.Add((apiBase, serverId, token, port));
SignIns.Add(signIn);
if (Refusal is { } refusal)
@@ -806,4 +858,32 @@ public sealed partial class ShellFlowTests
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
}
}
/// <summary>Signing in to Spelunk as far as the shell can see it: no browser, a sign-in or a refusal.</summary>
private sealed class FakeSpelunkAccounts : ISpelunkAccounts
{
/// <summary>The Spelunks a sign-in was asked for, in order.</summary>
internal List<string> SignedIn { get; } = [];
/// <summary>The tokens that were signed out, in order.</summary>
internal List<string> SignedOut { get; } = [];
internal SpelunkSignInException? Refusal { get; set; }
public Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken)
{
SignedIn.Add(apiBase);
return Refusal is { } refusal
? Task.FromException<SpelunkSignInRecord>(refusal)
: Task.FromResult(new SpelunkSignInRecord(
apiBase, SpelunkToken, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.UtcNow));
}
public Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
{
SignedOut.Add(signIn.Token);
return Task.CompletedTask;
}
}
}
@@ -228,9 +228,10 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
// the wall clock.
reachability: reachability,
// Scripted for the same reason: a Spelunk host's connect never leaves the process. See
// ShellFlowTests.Spelunk.cs.
spelunk: spelunkTunnels);
// Scripted for the same reason: a Spelunk host's connect never leaves the process, and signing in
// to Spelunk opens no browser. See ShellFlowTests.Spelunk.cs.
spelunk: spelunkTunnels,
spelunkAccounts: spelunkAccounts);
return ValueTask.CompletedTask;
}
@@ -39,18 +39,14 @@ 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.
/// A route to <see cref="SpelunkServer"/> — 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 };
new() { ApiBase = apiBase, ServerId = SpelunkServer, SignInThroughSpelunk = signIn };
/// <remarks>
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
@@ -1,131 +0,0 @@
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",
};
}
@@ -73,14 +73,12 @@ public sealed class SpelunkRouteTests
[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/" },
};
@@ -98,7 +96,7 @@ public sealed class SpelunkRouteTests
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignedInDeviceSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
@@ -122,7 +120,7 @@ public sealed class SpelunkRouteTests
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"}}
{"schemaVersion":11,"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"}}
""");
}
@@ -156,10 +154,10 @@ public sealed class SpelunkRouteTests
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.
// version 11 reads a version 12 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}
{"schemaVersion":12,"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();
@@ -207,7 +205,7 @@ public sealed class SpelunkRouteTests
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignedInDeviceSchemaVersion);
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
document.IsReadOnly.ShouldBeFalse();
}
@@ -219,13 +217,15 @@ public sealed class SpelunkRouteTests
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}}
{"schemaVersion":11,"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","signIn":true}}
""");
}
[Fact]
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
public void ARouteWrittenWithAnApiKey_DecodesWithoutIt_AsNotSigningIn_AndStaysEditable()
{
// The API key a route used to name is an unknown member now, skipped: the host is reached as whoever
// this device signed in to Spelunk as, and saving it writes it at version 11 without the key.
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"}}
@@ -0,0 +1,216 @@
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using DodoSSH.Client.Auth;
namespace DodoSSH.Client.Session.Tests;
/// <summary>
/// Signing this device in to Spelunk, with the browser and the redirect stood in for and Spelunk's token
/// endpoint answered in-process: what is opened, what is checked when the browser comes back, what is sent for
/// the token, and what is refused.
/// </summary>
public sealed class SpelunkAccountsTests
{
private const string ApiBase = "https://api.spelunk.example.com";
private const string IssuedToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid SessionId = Guid.Parse("0192f0c8-eeee-7c3d-8e4f-5a6b7c8d9e0e");
private readonly ScriptedBrowser browser = new();
private readonly TokenEndpoint spelunk = new();
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode()
{
var signIn = await Accounts().SignInAsync(ApiBase + "/", Token);
var opened = browser.Opened.ShouldHaveSingleItem();
opened.GetLeftPart(UriPartial.Path).ShouldBe($"{ApiBase}/api/app-sign-in/authorize");
var query = Query(opened);
query["client_id"].ShouldBe("dodossh");
query["response_type"].ShouldBe("code");
query["code_challenge_method"].ShouldBe("S256");
query["device_name"].ShouldBe("work laptop");
query["redirect_uri"].ShouldBe(ScriptedCallback.Redirect);
// The verifier sent for the token is the one the challenge was made from.
using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem());
var verifier = sent.RootElement.GetProperty("codeVerifier").GetString()!;
Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))).ShouldBe(query["code_challenge"]);
sent.RootElement.GetProperty("code").GetString().ShouldBe("the-code");
sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe(ScriptedCallback.Redirect);
sent.RootElement.GetProperty("clientId").GetString().ShouldBe("dodossh");
signIn.ApiBase.ShouldBe(ApiBase);
signIn.Token.ShouldBe(IssuedToken);
signIn.SessionId.ShouldBe(SessionId);
signIn.Email.ShouldBe("ops@example.com");
signIn.ToString().ShouldNotContain(IssuedToken);
}
[Fact]
public async Task AnAnswerToSomeOtherSignIn_IsIgnored()
{
browser.Answer = (_, _) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = "not-this-one" };
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("different sign-in");
spelunk.Bodies.ShouldBeEmpty("a code from another sign-in is never redeemed");
}
[Fact]
public async Task SayingNoInTheBrowser_IsSaidAsACancel()
{
browser.Answer = (_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["error"] = "access_denied", ["state"] = state };
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("cancelled in the browser");
spelunk.Bodies.ShouldBeEmpty();
}
[Fact]
public async Task ARefusedCode_IsSaid()
{
spelunk.Status = HttpStatusCode.BadRequest;
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("did not accept the sign-in");
}
[Fact]
public async Task ATokenThatCouldNotBeAHeader_IsRefused()
{
spelunk.IssuedToken = "spelunkapp_with a space";
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
refusal.Message.ShouldContain("does not understand");
}
[Fact]
public async Task AnAddressThatWouldSendTheSignInInClear_OpensNoBrowser()
{
var refusal = await Should.ThrowAsync<SpelunkSignInException>(
() => Accounts().SignInAsync("http://api.spelunk.example.com", Token));
refusal.Message.ShouldContain("https");
browser.Opened.ShouldBeEmpty();
}
[Fact]
public async Task SigningOut_TellsSpelunk_WithTheToken_AndNeverThrows()
{
var accounts = Accounts();
var signIn = await accounts.SignInAsync(ApiBase, Token);
await accounts.SignOutAsync(signIn, Token);
var (method, uri, authorization) = spelunk.Others.ShouldHaveSingleItem();
method.ShouldBe(HttpMethod.Delete);
uri.ShouldBe(new Uri($"{ApiBase}/api/app-sign-in/session"));
authorization.ShouldBe($"Bearer {IssuedToken}");
spelunk.Unreachable = true;
await accounts.SignOutAsync(signIn, Token);
}
private SpelunkAccounts Accounts() =>
new(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop");
private static Dictionary<string, string> Query(Uri uri) =>
uri.Query.TrimStart('?').Split('&')
.Select(pair => pair.Split('=', 2))
.ToDictionary(pair => pair[0], pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
/// <summary>A browser that, opened at the authorize URL, comes straight back with what the test scripts.</summary>
private sealed class ScriptedBrowser : IBrowserLauncher
{
internal List<Uri> Opened { get; } = [];
internal ScriptedCallback Callback { get; } = new();
/// <summary>The answer, from the authorize URL and the state it carried. A code and that state by default.</summary>
internal Func<Uri, string, Dictionary<string, string>> Answer { get; set; } =
(_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = state };
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
{
Opened.Add(url);
Callback.Result = new CallbackResult(Answer(url, Query(url)["state"]));
return Task.CompletedTask;
}
}
private sealed class ScriptedCallback : IAuthorizationCallback
{
internal const string Redirect = "http://127.0.0.1:53682/callback";
internal CallbackResult? Result { get; set; }
public Uri RedirectUri { get; } = new(Redirect);
public Task<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) =>
Task.FromResult(Result ?? throw new InvalidOperationException("The browser was never opened."));
public void Dispose()
{
}
}
/// <summary>Spelunk's token and sign-out endpoints.</summary>
private sealed class TokenEndpoint : HttpMessageHandler
{
internal List<string> Bodies { get; } = [];
internal List<(HttpMethod Method, Uri Uri, string? Authorization)> Others { get; } = [];
internal HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
internal string IssuedToken { get; set; } = SpelunkAccountsTests.IssuedToken;
internal bool Unreachable { get; set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (Unreachable)
{
throw new HttpRequestException("Connection refused.");
}
if (request.Method != HttpMethod.Post)
{
Others.Add((request.Method, request.RequestUri!, request.Headers.Authorization?.ToString()));
return new HttpResponseMessage(HttpStatusCode.NoContent);
}
Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
return new HttpResponseMessage(Status)
{
Content = new StringContent(
Status == HttpStatusCode.OK
? JsonSerializer.Serialize(new
{
token = IssuedToken,
sessionId = SessionId,
user = new { id = Guid.CreateVersion7(), email = "ops@example.com", displayName = "Ops" },
})
: """{"title":"Sign-in not accepted"}""",
Encoding.UTF8,
"application/json"),
};
}
}
}
@@ -22,7 +22,7 @@ namespace DodoSSH.Client.Session.Tests;
/// </remarks>
public sealed class SpelunkTunnelTests : IAsyncLifetime
{
private const string ApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private const string SignInToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
@@ -44,7 +44,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
var ticket = spelunk.Requests[0];
ticket.Method.ShouldBe("POST");
ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets");
ticket.Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
ticket.Headers["Authorization"].ShouldBe($"Bearer {SignInToken}");
ticket.Body.ShouldBe("""{"port":22}""");
var upgrade = spelunk.Requests[1];
@@ -98,7 +98,8 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
}
[Theory]
[InlineData(HttpStatusCode.Unauthorized, "did not accept the API key")]
[InlineData(HttpStatusCode.Unauthorized, "signed this device out")]
[InlineData(HttpStatusCode.Forbidden, "not allowed to open a terminal")]
[InlineData(HttpStatusCode.NotFound, "does not know this server")]
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
@@ -202,24 +203,18 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
}
[Fact]
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
public async Task ADamagedSignIn_IsRefusedBeforeAnythingIsSent_AsASignedOutDevice()
{
// It came from Spelunk and was checked then, so a token that is not a header value is a damaged cache,
// and the remedy is the one a 401 gets: sign in again.
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunkapp_abc\ndef", 22, signIn: null, Token));
refusal.Message.ShouldContain("does not hold one");
refusal.Message.ShouldContain("Sign in to Spelunk again");
refusal.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
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()
{
@@ -230,7 +225,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
closed.Stop();
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, SignInToken, 22, signIn: null, Token));
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
}
@@ -389,14 +384,14 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
}
}
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
/// <summary>Opens a tunnel to the test server with the one sign-in 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",
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
port,
signIn,
TestContext.Current.CancellationToken);
@@ -456,7 +456,6 @@ 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}}"}""");
@@ -0,0 +1,87 @@
namespace DodoSSH.Client.Storage.Tests;
/// <summary>
/// The Spelunks this device is signed in to: one sign-in per Spelunk, sealed whole, and gone with the cache.
/// </summary>
public sealed class SpelunkSignInStoreTests
{
private const string Spelunk = "https://api.spelunk.example.com";
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task ASignInRoundTrips_AndIsFoundByItsSpelunk()
{
using var harness = await CacheHarness.CreateAsync();
var store = Store(harness);
(await store.FindAsync(Spelunk, Token)).ShouldBeNull("nothing is signed in yet");
var signIn = SignIn(Spelunk, "spelunkapp_one");
await store.SaveAsync(signIn, Token);
(await store.FindAsync(Spelunk, Token)).ShouldBe(signIn);
(await store.FindAsync("https://spelunk.elsewhere.example", Token)).ShouldBeNull();
}
[Fact]
public async Task SigningInAgainToTheSameSpelunk_Replaces_AndOthersStay()
{
using var harness = await CacheHarness.CreateAsync();
var store = Store(harness);
await store.SaveAsync(SignIn(Spelunk, "spelunkapp_old"), Token);
await store.SaveAsync(SignIn("https://spelunk.elsewhere.example", "spelunkapp_other"), Token);
await store.SaveAsync(SignIn(Spelunk, "spelunkapp_new"), Token);
var all = await store.ListAsync(Token);
all.Count.ShouldBe(2);
(await store.FindAsync(Spelunk, Token)).ShouldNotBeNull().Token.ShouldBe("spelunkapp_new");
}
[Fact]
public async Task ForgettingOne_LeavesTheOthers_AndForgettingAll_LeavesNone()
{
using var harness = await CacheHarness.CreateAsync();
var store = Store(harness);
await store.SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token);
await store.SaveAsync(SignIn("https://spelunk.elsewhere.example", "spelunkapp_two"), Token);
await store.ForgetAsync(Spelunk, Token);
(await store.ListAsync(Token)).ShouldHaveSingleItem().ApiBase.ShouldBe("https://spelunk.elsewhere.example");
await store.ForgetAllAsync(Token);
(await store.ListAsync(Token)).ShouldBeEmpty();
}
[Fact]
public async Task AnotherUsersCacheKey_OpensNothing()
{
using var owner = await CacheHarness.CreateAsync();
using var stranger = await CacheHarness.CreateAsync();
await Store(owner).SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token);
(await new SpelunkSignInStore(owner.Factory, stranger.Protector, TimeProvider.System).ListAsync(Token))
.ShouldBeEmpty();
}
[Fact]
public async Task ResettingTheCache_SignsThisDeviceOutOfEverySpelunk()
{
// What signing out of DodoSSH does on disk, whatever reached Spelunk before it.
using var harness = await CacheHarness.CreateAsync();
await Store(harness).SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token);
await harness.Factory.ResetAsync(Token);
(await Store(harness).ListAsync(Token)).ShouldBeEmpty();
}
private static SpelunkSignInStore Store(CacheHarness harness) =>
new(harness.Factory, harness.Protector, TimeProvider.System);
private static SpelunkSignInRecord SignIn(string apiBase, string token) =>
new(apiBase, token, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.FromUnixTimeSeconds(1_790_000_000));
}
@@ -43,7 +43,6 @@ 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)
@@ -78,7 +77,6 @@ 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
@@ -255,10 +253,6 @@ 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,
@@ -419,13 +413,6 @@ 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" };
@@ -30,7 +30,6 @@ public sealed class ItemKindsTests
SyncEntityType.ConnectionLogEntry,
SyncEntityType.ActivityLogEntry,
SyncEntityType.ObjectStore,
SyncEntityType.SpelunkKey,
]);
}
@@ -271,7 +271,7 @@ public sealed class SyncEngineTests
/// </summary>
/// <remarks>
/// The cursor a pull returns is past every change it read, including the changes of types it did not ask
/// for. A build that adds a type to the list — SpelunkKey did, ObjectStore before it — and carried on from
/// for. A build that adds a type to the list — ObjectStore did — and carried on from
/// that cursor would never receive the items of the new type made before it arrived.
/// </remarks>
[Fact]
@@ -96,7 +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)]
[InlineData(CryptoSpec.AadResourceType.SpelunkSignIn, 18)]
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
{
((int)type).ShouldBe(expected);