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

+82
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>
@@ -1058,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);
}