Public Access
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.
150 lines
6.7 KiB
C#
150 lines
6.7 KiB
C#
using System.Security.Cryptography;
|
||
using DodoSSH.Contracts;
|
||
using DodoSSH.Crypto;
|
||
|
||
namespace DodoSSH.Client.Storage;
|
||
|
||
/// <summary>
|
||
/// Seals the few things the local cache holds that are not already ciphertext.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The cache stores item payloads exactly as the server sent them, so they need no further
|
||
/// protection. Two things do: the plaintext columns the server needs — a relay-enabled host's
|
||
/// address, chiefly — and the values a merge overrode, which are decrypted vault content by
|
||
/// definition. Both go through here.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>What this is and is not worth.</b> The key derives from the identity bundle, so it exists only
|
||
/// while the vault is unlocked and is never written anywhere. That makes a stolen laptop, a stray backup or
|
||
/// a synced-to-cloud application folder yield nothing — which is the threat this addresses. It does
|
||
/// <em>not</em> defend against a process running as the same user: that process can read this
|
||
/// process's memory, and no on-disk measure changes it. docs/crypto.md §10 says the same about a
|
||
/// compromised endpoint, and this layer does not pretend otherwise.
|
||
/// </para>
|
||
/// <para>
|
||
/// Every record is bound to its own row, so a record cannot be moved to a different row of the same
|
||
/// cache. For a relay address that is not academic: two swapped rows would aim one host's connection
|
||
/// at another host's address.
|
||
/// </para>
|
||
/// </remarks>
|
||
public sealed class LocalCacheProtector : IDisposable
|
||
{
|
||
private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
|
||
private bool disposed;
|
||
|
||
private LocalCacheProtector(UserSecretBundle bundle) => bundle.DeriveLocalCacheKey(key);
|
||
|
||
/// <summary>
|
||
/// Derives the cache key from an opened identity bundle.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// From the bundle rather than the passphrase master key, so that every way of opening the vault
|
||
/// reaches the same cache — see <see cref="UserSecretBundle.DeriveLocalCacheKey"/>. The bundle is not
|
||
/// retained. Only the subkey is, and it is domain-separated by its HKDF label from the key that wraps
|
||
/// the bundle: the two live in very different threat models and must not be the same bytes.
|
||
/// </remarks>
|
||
public static LocalCacheProtector From(UserSecretBundle bundle)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(bundle);
|
||
|
||
return new LocalCacheProtector(bundle);
|
||
}
|
||
|
||
/// <summary>Seals a cache record, binding it to the row that will hold it.</summary>
|
||
public byte[] Protect(
|
||
CryptoSpec.AadResourceType resourceType,
|
||
Guid recordId,
|
||
ReadOnlySpan<byte> plaintext)
|
||
{
|
||
ObjectDisposedException.ThrowIf(disposed, this);
|
||
|
||
return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens a sealed cache record.
|
||
/// </summary>
|
||
/// <returns>
|
||
/// The plaintext, or <see langword="null"/> if the record does not belong to this row or this
|
||
/// user. Null rather than an exception because a stale cache file is an ordinary situation — a
|
||
/// changed passphrase re-derives a different key — and the caller's answer is to discard the row
|
||
/// and re-pull, not to fail.
|
||
/// </returns>
|
||
public byte[]? TryUnprotect(
|
||
CryptoSpec.AadResourceType resourceType,
|
||
Guid recordId,
|
||
ReadOnlySpan<byte> envelope)
|
||
{
|
||
ObjectDisposedException.ThrowIf(disposed, this);
|
||
|
||
return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public void Dispose()
|
||
{
|
||
if (disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
disposed = true;
|
||
CryptographicOperations.ZeroMemory(key);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Maps a syncable entity type onto the resource type its AAD binds.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
|
||
/// same list: <see cref="CryptoSpec.AadResourceType"/> also covers users, devices and vaults, so the
|
||
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
|
||
/// without failing anywhere a test would notice.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>It has to name every syncable type, and the throw is not a safety net.</b>
|
||
/// <c>ConflictStore.RecordAsync</c> calls this unconditionally, so a type with no arm here is a type
|
||
/// whose <em>conflicts</em> cannot be recorded — and the conflict log is the only reason the merge is
|
||
/// allowed to pick a winner at all. Three types went two phases without one: the two logs and the
|
||
/// buckets were added to both enums and to the reconciler registry, and this switch was not touched,
|
||
/// so a merge over any of them turned a recorded conflict into an
|
||
/// <see cref="ArgumentOutOfRangeException"/>. The two other callers hid it —
|
||
/// <c>ItemStore</c> and <c>OutboxStore</c> only reach this when an item carries plaintext fields, and
|
||
/// none of those three does.
|
||
/// </para>
|
||
/// <para>
|
||
/// <c>AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping</c> is what says so now, and it
|
||
/// is a name comparison rather than a list to keep by hand.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal static class AadResourceTypes
|
||
{
|
||
internal static CryptoSpec.AadResourceType For(SyncEntityType entityType) => entityType switch
|
||
{
|
||
SyncEntityType.Host => CryptoSpec.AadResourceType.Host,
|
||
SyncEntityType.Credential => CryptoSpec.AadResourceType.Credential,
|
||
SyncEntityType.SshKey => CryptoSpec.AadResourceType.SshKey,
|
||
SyncEntityType.HostGroup => CryptoSpec.AadResourceType.HostGroup,
|
||
SyncEntityType.Tag => CryptoSpec.AadResourceType.Tag,
|
||
SyncEntityType.HostTag => CryptoSpec.AadResourceType.HostTag,
|
||
SyncEntityType.HostCredential => CryptoSpec.AadResourceType.HostCredential,
|
||
SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
|
||
SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
|
||
SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,
|
||
|
||
// Appended in the order the enums grew, which is why these three are not beside their
|
||
// neighbours by number. See docs/crypto.md §4.3 on why 14–16 are not one-behind their wire
|
||
// counterparts the way the arms above are.
|
||
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."),
|
||
};
|
||
}
|