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.
This commit is contained in:
jaap-jan committed 2026-09-29 10:34:38 +02:00
1 parent ee423b25ce
commit b08f2c58e3
37 files changed
+3907 -64

No files matched your search

@@ -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])]);
@@ -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
@@ -507,6 +507,158 @@ public sealed partial class ShellFlowTests
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()
{
@@ -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",
};
}
@@ -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" };
@@ -30,6 +30,7 @@ public sealed class ItemKindsTests
SyncEntityType.ConnectionLogEntry,
SyncEntityType.ActivityLogEntry,
SyncEntityType.ObjectStore,
SyncEntityType.SpelunkKey,
]);
}
@@ -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);