using System.Security.Cryptography;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Storage;
///
/// Seals the few things the local cache holds that are not already ciphertext.
///
///
///
/// 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.
///
///
/// What this is and is not worth. 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
/// not 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.
///
///
/// 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.
///
///
public sealed class LocalCacheProtector : IDisposable
{
private readonly byte[] key = new byte[CryptoSpec.SymmetricKeySize];
private bool disposed;
private LocalCacheProtector(UserSecretBundle bundle) => bundle.DeriveLocalCacheKey(key);
///
/// Derives the cache key from an opened identity bundle.
///
///
/// From the bundle rather than the passphrase master key, so that every way of opening the vault
/// reaches the same cache — see . 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.
///
public static LocalCacheProtector From(UserSecretBundle bundle)
{
ArgumentNullException.ThrowIfNull(bundle);
return new LocalCacheProtector(bundle);
}
/// Seals a cache record, binding it to the row that will hold it.
public byte[] Protect(
CryptoSpec.AadResourceType resourceType,
Guid recordId,
ReadOnlySpan plaintext)
{
ObjectDisposedException.ThrowIf(disposed, this);
return DshCrypto.Seal(key, plaintext, DshAad.LocalCache(resourceType, recordId));
}
///
/// Opens a sealed cache record.
///
///
/// The plaintext, or 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.
///
public byte[]? TryUnprotect(
CryptoSpec.AadResourceType resourceType,
Guid recordId,
ReadOnlySpan envelope)
{
ObjectDisposedException.ThrowIf(disposed, this);
return DshCrypto.Open(key, envelope, DshAad.LocalCache(resourceType, recordId));
}
///
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
CryptographicOperations.ZeroMemory(key);
}
}
///
/// Maps a syncable entity type onto the resource type its AAD binds.
///
///
///
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
/// same list: 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.
///
///
/// It has to name every syncable type, and the throw is not a safety net.
/// ConflictStore.RecordAsync calls this unconditionally, so a type with no arm here is a type
/// whose conflicts 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
/// . The two other callers hid it —
/// ItemStore and OutboxStore only reach this when an item carries plaintext fields, and
/// none of those three does.
///
///
/// AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping is what says so now, and it
/// is a name comparison rather than a list to keep by hand.
///
///
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."),
};
}