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."), }; }