Public Access
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>
149 lines
6.7 KiB
C#
149 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,
|
||
|
||
_ => throw new ArgumentOutOfRangeException(
|
||
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
||
};
|
||
}
|