using System.Security.Cryptography; using DodoSSH.Client.Domain; using DodoSSH.Contracts; using DodoSSH.Crypto; namespace DodoSSH.Client.Sync; /// /// Turns a host into an item payload and back. /// /// /// /// Every operation binds to the item's identity, its key generation and its version, because /// that is what DshAad.ItemPayload requires. The version part has a consequence worth stating /// plainly: a payload must be sealed at the version the server will assign, not the version it is /// replacing. See . /// /// /// The data key is fresh per call and is zeroed before returning, as is the encoded plaintext. Neither /// is ever handed to a caller: a data key that escaped this class would be a data key some other layer /// could forget to clear. /// /// public static class HostCipher { private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Host; /// /// Encrypts a host. /// /// The host. Must be valid for storage. /// The vault key, which the data key is wrapped under. /// The item id, which the AAD binds. /// The vault's current key generation. /// /// The version this payload will hold once the server accepts it — one more than the version being /// replaced. Sealing at the version being replaced would produce a payload that authenticates /// against a row that no longer exists, and the item would read as corrupt from then on. /// public static EncryptedPayload Seal( HostSecret host, ReadOnlySpan vaultKey, Guid entityId, uint keyGeneration, int itemVersion) { ArgumentNullException.ThrowIfNull(host); ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1); var plaintext = HostSecretCodec.Encode(host); 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); CryptographicOperations.ZeroMemory(plaintext); } } /// /// Decrypts a host. /// /// /// The host and the schema version it was written at, or if the payload does /// not belong to this item, version or generation, or does not parse. /// /// A null is a meaningful outcome, not an error to be thrown past. It is what a server relocating /// ciphertext between rows looks like from here, and it is also what an ordinary rekey looks like /// before new grants arrive. The caller distinguishes them by comparing generations; either way one /// unreadable item must not abort a sync pass and strand every change behind it. /// /// public static HostSecretDocument? TryOpen( EncryptedPayload payload, ReadOnlySpan 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 HostSecretCodec.TryDecode(plaintext, out var document) ? document : null; } finally { CryptographicOperations.ZeroMemory(plaintext); } } finally { CryptographicOperations.ZeroMemory(dataKey); } } } /// /// The one place that decides which item version a payload is sealed at. /// /// /// The payload's AAD binds the item version, so the sealing side has to predict what the server will /// assign. That prediction is safe because it is checked: the server applies an upsert only when /// expectedVersion matches, and then increments by exactly one. A mismatch is a conflict, not a /// silently mis-sealed row. Both the sealing and the opening sides go through here, so they cannot /// drift — the failure if they did would be an item that encrypts fine and never decrypts again. /// internal static class SyncVersions { /// The version an accepted upsert will produce. /// The version being replaced, or null for a create. internal static int NextVersion(int? expectedVersion) => (expectedVersion ?? 0) + 1; } /// /// Derives the plaintext columns the server needs from a host. /// /// /// /// The single point at which a hostname can leave the encrypted payload, which is the whole reason it /// is a function rather than something each call site assembles. The address is emitted only when the /// user has turned the relay on for that host; with relay off, the server learns nothing but that an /// item exists. See ADR 0004 for why the relay cannot work any other way. /// /// /// The port is the host's own, and it is allowed to be, because a relay host may not inherit one. /// This runs from inside the generic write path — see IItemKind{TSecret}.Fields — which holds one /// secret and has no group list to walk, and threading one in would put the group chain inside the sync /// engine for the sake of a single column. It does not have to: /// refuses a relay host with no port of its own, so the branch below cannot be reached by a host that /// inherits. That refusal exists for a stronger reason than this convenience — a plaintext column derived /// from another item goes stale when that item is edited, and nothing re-pushes the hosts beneath it. /// /// internal static class HostFields { internal static SyncPlaintextFields From(HostSecret host) => host.RelayEnabled ? new SyncPlaintextFields(RelayEnabled: true, Hostname: host.Hostname, Port: host.Port) : new SyncPlaintextFields(); }