diff --git a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs index 7385339..b25f2b6 100644 --- a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs +++ b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs @@ -68,7 +68,7 @@ internal interface IItemKind internal static class ItemKinds { private static readonly Dictionary Supported = - new[] { (IItemKind)new HostKind(), new SshKeyKind() } + new[] { (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind() } .ToDictionary(kind => kind.WireType); /// The kind for a wire type, or null when this server does not synchronise it yet. @@ -292,3 +292,98 @@ internal sealed class SshKeyKind : IItemKind ? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint) : null; } + +/// Credentials: an envelope and nothing else. +/// +/// The strictest of the three kinds about plaintext, and the reason is not symmetry. A key at least has a +/// fingerprint that is public by nature; a password has no part that is safe to expose, so this kind accepts +/// no plaintext fields at all and hydrates none. +/// +internal sealed class CredentialKind : IItemKind +{ + /// + public SyncEntityType WireType => SyncEntityType.Credential; + + /// + public ChangeEntityType ChangeType => ChangeEntityType.Credential; + + /// + public async Task FindAsync( + DodoDbContext database, + Guid id, + CancellationToken cancellationToken) => + await database.Credentials.SingleOrDefaultAsync(c => c.Id == id, cancellationToken) + .ConfigureAwait(false); + + /// + public async Task> LoadAsync( + DodoDbContext database, + Guid vaultId, + Guid[] ids, + CancellationToken cancellationToken) + { + var rows = await database.Credentials + .Where(c => c.VaultId == vaultId && ids.Contains(c.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); + } + + /// + public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) + { + var credential = new VaultCredential { Id = id, VaultId = vaultId }; + + database.Credentials.Add(credential); + + return credential; + } + + /// + /// Refuses every plaintext field there is. + /// + /// + /// Refused with a reason rather than silently dropped, so a client that believes it is storing something + /// finds out now rather than when the field turns out to be missing. + /// + /// + public bool ValidateFields(SyncPlaintextFields fields, out string error) + { + ArgumentNullException.ThrowIfNull(fields); + + error = string.Empty; + + if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) + { + error = "A credential has no relay target; relay fields may only be set on a host."; + return false; + } + + if (fields.PublicKeyFingerprint is not null) + { + error = "A credential has no public key."; + return false; + } + + return true; + } + + /// Nothing to copy: this type has no plaintext columns to copy anything into. + /// + public void ApplyFields(IVaultItem item, SyncPlaintextFields fields) + { + } + + /// + public void ClearFieldsOnDelete(IVaultItem item) + { + } + + /// + /// Always null, which is a stronger statement than an empty record: this type has no plaintext columns, + /// so there is nothing a pull could hydrate even in principle. + /// + /// + public SyncPlaintextFields? Hydrate(IVaultItem item) => null; +} diff --git a/src/DodoSSH.Client.Domain/CredentialSecret.cs b/src/DodoSSH.Client.Domain/CredentialSecret.cs new file mode 100644 index 0000000..d1d6d26 --- /dev/null +++ b/src/DodoSSH.Client.Domain/CredentialSecret.cs @@ -0,0 +1,76 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DodoSSH.Client.Domain; + +/// +/// A username and password as the user sees it, decrypted. +/// +/// +/// +/// The same bargain documents applies here and is worth not repeating in full: +/// the password is an ordinary managed string, it cannot be wiped, and a process dump taken while the vault +/// is unlocked contains it. What that buys is that it never reaches the disk or the server in a form either +/// can read. +/// +/// +/// is optional and overrides the host's when set, which is the reason this is a +/// separate item rather than two more fields on a host: one credential is very often the same account on +/// twenty machines, and duplicating it per host means rotating it in twenty places and missing one. +/// +/// +public sealed record CredentialSecret : IVaultSecret +{ + private readonly string? username; + + /// What the user calls this credential. + public required string Label { get; init; } + + /// + /// The password. + /// + /// + /// Required, and an empty one is not valid — see . A credential with no password + /// is not a credential, and storing one would produce an item that looks usable and fails at the + /// handshake with an error about authentication rather than about the vault. + /// + public required string Password { get; init; } + + /// + /// The account this credential is for, when it is not the host's own username. + /// + /// + /// Empty is normalised to null, as is and for the same kind of + /// reason: blank and absent mean one thing here — "use the host's username" — and two spellings of one + /// state would give two clients different payload bytes for an identical credential, and make + /// Username is not null an unreliable answer to "does this override the host?". + /// + public string? Username + { + get => username; + init => username = string.IsNullOrEmpty(value) ? null : value; + } + + /// Free text. + public string? Notes { get; init; } + + /// Whether this is storable, and why not if it is not. + public bool TryValidate([NotNullWhen(false)] out string? reason) + { + if (string.IsNullOrWhiteSpace(Label)) + { + reason = "A credential needs a name."; + return false; + } + + if (Password.Length == 0) + { + // Length rather than IsNullOrWhiteSpace: a password of spaces is a password, and refusing it + // would lock someone out of a host over a validation opinion. + reason = "A credential needs a password."; + return false; + } + + reason = null; + return true; + } +} diff --git a/src/DodoSSH.Client.Domain/CredentialSecretCodec.cs b/src/DodoSSH.Client.Domain/CredentialSecretCodec.cs new file mode 100644 index 0000000..c4b82fe --- /dev/null +++ b/src/DodoSSH.Client.Domain/CredentialSecretCodec.cs @@ -0,0 +1,116 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DodoSSH.Client.Domain; + +/// A decoded credential payload, together with the schema version it was written at. +/// The credential. +/// The version the writing client used. +public sealed record CredentialSecretDocument(CredentialSecret Credential, int SchemaVersion) +{ + /// + public bool IsReadOnly => SchemaVersion > CredentialSecretCodec.CurrentSchemaVersion; +} + +/// +/// Encodes and decodes the plaintext inside a credential item's encrypted payload. +/// +/// +/// Mirrors , for the same reasons and with the same guarantees: JSON so a +/// field can be added without a migration, deterministic property order so an unchanged credential does not +/// look like a change to the sync engine, and a separate mutable document type so a decode failure cannot +/// produce a half-built credential that looks valid downstream. +/// +public static class CredentialSecretCodec +{ + /// The schema version this build writes. + public const int CurrentSchemaVersion = 1; + + /// Serialises a credential to the bytes that get sealed. + /// The credential is not valid for storage. + public static byte[] Encode(CredentialSecret credential) + { + ArgumentNullException.ThrowIfNull(credential); + + if (!credential.TryValidate(out var reason)) + { + throw new ArgumentException(reason, nameof(credential)); + } + + var document = new CredentialPayloadDocument + { + SchemaVersion = CurrentSchemaVersion, + Label = credential.Label, + Password = credential.Password, + Username = credential.Username, + Notes = credential.Notes, + }; + + return JsonSerializer.SerializeToUtf8Bytes( + document, CredentialPayloadJsonContext.Default.CredentialPayloadDocument); + } + + /// Parses a decrypted payload. + /// + public static bool TryDecode( + ReadOnlySpan payload, + [NotNullWhen(true)] out CredentialSecretDocument? document) + { + document = null; + + CredentialPayloadDocument? parsed; + try + { + parsed = JsonSerializer.Deserialize( + payload, CredentialPayloadJsonContext.Default.CredentialPayloadDocument); + } + catch (JsonException) + { + return false; + } + + if (parsed is null || parsed.SchemaVersion < 1) + { + return false; + } + + var candidate = new CredentialSecret + { + Label = parsed.Label ?? string.Empty, + Password = parsed.Password ?? string.Empty, + Username = parsed.Username, + Notes = parsed.Notes, + }; + + if (!candidate.TryValidate(out _)) + { + return false; + } + + document = new CredentialSecretDocument(candidate, parsed.SchemaVersion); + return true; + } +} + +/// The serialised shape. Mutable and nullable because it models untrusted input. +/// +internal sealed class CredentialPayloadDocument +{ + public int SchemaVersion { get; set; } + + public string? Label { get; set; } + + public string? Password { get; set; } + + public string? Username { get; set; } + + public string? Notes { get; set; } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +[JsonSerializable(typeof(CredentialPayloadDocument))] +internal sealed partial class CredentialPayloadJsonContext : JsonSerializerContext; diff --git a/src/DodoSSH.Client.Domain/CredentialSecretMerge.cs b/src/DodoSSH.Client.Domain/CredentialSecretMerge.cs new file mode 100644 index 0000000..c327762 --- /dev/null +++ b/src/DodoSSH.Client.Domain/CredentialSecretMerge.cs @@ -0,0 +1,111 @@ +namespace DodoSSH.Client.Domain; + +/// The merged credential, and everything that had to be overridden to produce it. +/// The credential to store and push. +/// Empty when the two sides were reconcilable field by field. +public sealed record CredentialMergeResult( + CredentialSecret Merged, + IReadOnlyList Conflicts) +{ + /// Whether anything had to be overridden. + public bool HasConflicts => Conflicts.Count > 0; +} + +/// +/// Merges two divergent versions of a credential against the version they both started from. +/// +/// +/// +/// Every field is a scalar, so this is the same shape as and reuses +/// for the same reason: the conflict log, the storage behind it and the +/// interface that shows it are shared, and a parallel record with identical members would have to be mapped +/// at every boundary. +/// +/// +/// The password never reaches the conflict log. Reported as having differed and nothing more, exactly +/// as SshKeySecretMerge does for key material — and here the case for it is if anything plainer, since +/// a discarded password is very often still the live password on some other system. The user loses nothing +/// they could act on: nobody reconciles two passwords by reading them side by side. +/// +/// +/// The username is shown, because it is not a secret and knowing which of two accounts the merge dropped is +/// exactly what makes the notice useful. +/// +/// +public static class CredentialSecretMerge +{ + /// Produces the merged credential. + /// The version both sides branched from. + /// The pending local version. + /// The server's current version. + public static CredentialMergeResult Merge( + CredentialSecret ancestor, + CredentialSecret local, + CredentialSecret remote) + { + ArgumentNullException.ThrowIfNull(ancestor); + ArgumentNullException.ThrowIfNull(local); + ArgumentNullException.ThrowIfNull(remote); + + var conflicts = new List(); + + var merged = new CredentialSecret + { + // Null-forgiving on the two required fields, as the host and key merges do for the same reason: + // the merge returns one of its three inputs, and all three are non-null by construction. + Label = Resolve( + nameof(CredentialSecret.Label), + ancestor.Label, + local.Label, + remote.Label, + conflicts, + redact: false)!, + Password = Resolve( + nameof(CredentialSecret.Password), + ancestor.Password, + local.Password, + remote.Password, + conflicts, + redact: true)!, + Username = Resolve( + nameof(CredentialSecret.Username), + ancestor.Username, + local.Username, + remote.Username, + conflicts, + redact: false), + Notes = Resolve( + nameof(CredentialSecret.Notes), + ancestor.Notes, + local.Notes, + remote.Notes, + conflicts, + redact: false), + }; + + return new CredentialMergeResult(merged, conflicts); + } + + private static string? Resolve( + string name, + string? ancestor, + string? local, + string? remote, + List conflicts, + bool redact) + { + var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal); + + if (merge.IsConflicted) + { + conflicts.Add(new HostFieldConflict( + name, + MergeSide.Local, + redact ? "(kept the server's value)" : merge.Value ?? "(none)", + redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)", + DiscardedWasRemoval: false)); + } + + return merge.Value; + } +} diff --git a/src/DodoSSH.Client.Domain/HostSecret.cs b/src/DodoSSH.Client.Domain/HostSecret.cs index 3c7e388..d8becd1 100644 --- a/src/DodoSSH.Client.Domain/HostSecret.cs +++ b/src/DodoSSH.Client.Domain/HostSecret.cs @@ -82,6 +82,25 @@ public sealed record HostSecret : IVaultSecret /// public Guid? SshKeyId { get; init; } + /// + /// The vault credential to authenticate with, or null to be asked for a password. + /// + /// + /// + /// The password counterpart of , with the same reasoning about ids rather than + /// copies, the same dangling-reference handling, and the same refusal to fall back when the reference + /// cannot be resolved. One credential is very often the same account on twenty hosts, which is exactly + /// why it is referenced and not embedded — a copy per host is twenty places to rotate and one to forget. + /// + /// + /// Mutually exclusive with . SSH itself would happily try a key and fall + /// back to a password, but a host that names both leaves "how does this authenticate?" without a single + /// answer — and the interface, the connect path and the user would each be free to guess differently. + /// One host, one method; enforces it. + /// + /// + public Guid? CredentialId { get; init; } + /// /// Whether this host may be dialled through the server relay. /// @@ -143,6 +162,18 @@ public sealed record HostSecret : IVaultSecret return false; } + if (CredentialId == Guid.Empty) + { + reason = "A credential reference cannot be an empty id; use no credential instead."; + return false; + } + + if (SshKeyId is not null && CredentialId is not null) + { + reason = "A host authenticates with a key or with a credential, not both."; + return false; + } + reason = null; return true; } diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs index 84f3a69..0fc9627 100644 --- a/src/DodoSSH.Client.Domain/HostSecretCodec.cs +++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs @@ -59,8 +59,11 @@ public static class HostSecretCodec /// The version that introduced . public const int SshKeyIdSchemaVersion = 2; + /// The version that introduced . + public const int CredentialIdSchemaVersion = 3; + /// The highest schema version this build can write. - public const int CurrentSchemaVersion = SshKeyIdSchemaVersion; + public const int CurrentSchemaVersion = CredentialIdSchemaVersion; /// Serialises a host to the bytes that get sealed. /// The host is not valid for storage. @@ -91,6 +94,7 @@ public static class HostSecretCodec Options = options, RelayEnabled = host.RelayEnabled, SshKeyId = host.SshKeyId, + CredentialId = host.CredentialId, }; return JsonSerializer.SerializeToUtf8Bytes( @@ -110,14 +114,24 @@ public static class HostSecretCodec /// use the newer field. /// /// - /// The rule generalises, and the next field added should follow it: a host is written at the version - /// that introduced the newest field it actually carries. It also means the bytes for a host with no key - /// are identical to what this codec produced before existed, so - /// adding the field did not make every host in every vault look like a change to the sync engine. + /// The rule generalises, and every field added since has followed it: a host is written at the version + /// that introduced the newest field it actually carries. It also means the bytes for a host that binds + /// nothing are identical to what this codec produced before either binding existed, so adding the fields + /// did not make every host in every vault look like a change to the sync engine. + /// + /// + /// The two bindings are mutually exclusive — see — so this reads as + /// a ladder rather than a maximum. If a future field is not exclusive with an older one, this + /// becomes the maximum over the versions of the fields present, which is the same rule stated more + /// generally. /// /// - private static int SchemaVersionFor(HostSecret host) => - host.SshKeyId is null ? BaseSchemaVersion : SshKeyIdSchemaVersion; + private static int SchemaVersionFor(HostSecret host) => host switch + { + { CredentialId: not null } => CredentialIdSchemaVersion, + { SshKeyId: not null } => SshKeyIdSchemaVersion, + _ => BaseSchemaVersion, + }; /// /// Parses a decrypted payload. @@ -184,6 +198,7 @@ public static class HostSecretCodec Options = options, RelayEnabled = parsed.RelayEnabled, SshKeyId = parsed.SshKeyId, + CredentialId = parsed.CredentialId, }; if (!candidate.TryValidate(out _)) @@ -236,6 +251,9 @@ internal sealed class HostPayloadDocument /// makes a host with no key encode exactly as it did before the field existed. /// public Guid? SshKeyId { get; set; } + + /// + public Guid? CredentialId { get; set; } } [JsonSourceGenerationOptions( diff --git a/src/DodoSSH.Client.Domain/HostSecretMerge.cs b/src/DodoSSH.Client.Domain/HostSecretMerge.cs index af57a2d..b689a44 100644 --- a/src/DodoSSH.Client.Domain/HostSecretMerge.cs +++ b/src/DodoSSH.Client.Domain/HostSecretMerge.cs @@ -114,6 +114,14 @@ public static class HostSecretMerge remote.SshKeyId, conflicts, static id => id?.ToString() ?? "no key"), + + CredentialId = Field( + nameof(HostSecret.CredentialId), + ancestor.CredentialId, + local.CredentialId, + remote.CredentialId, + conflicts, + static id => id?.ToString() ?? "no credential"), }; return new HostMergeResult(merged, conflicts); diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs index 2286176..0a3fa0d 100644 --- a/src/DodoSSH.Client.Session/VaultSession.cs +++ b/src/DodoSSH.Client.Session/VaultSession.cs @@ -76,6 +76,7 @@ public sealed class VaultSession : IAsyncDisposable Vault = new VaultStore(caches, clock); Hosts = new HostRepository(Items, Outbox, keyring); SshKeys = new SshKeyRepository(Items, Outbox, keyring); + Credentials = new CredentialRepository(Items, Outbox, keyring); } /// Who this session belongs to, and the material that unlocked it. @@ -97,6 +98,9 @@ public sealed class VaultSession : IAsyncDisposable /// public SshKeyRepository SshKeys { get; } + /// Usernames and passwords, decrypted, with unpushed local changes laid over them. + public CredentialRepository Credentials { get; } + /// Vaults whose grant could not be opened, so their items cannot be read. public IReadOnlyList UnreadableVaults => keyring.Unopened; diff --git a/src/DodoSSH.Client.Sync/CredentialCipher.cs b/src/DodoSSH.Client.Sync/CredentialCipher.cs new file mode 100644 index 0000000..feeae1d --- /dev/null +++ b/src/DodoSSH.Client.Sync/CredentialCipher.cs @@ -0,0 +1,129 @@ +using System.Security.Cryptography; +using DodoSSH.Client.Domain; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync; + +/// +/// Turns a credential into an item payload and back. +/// +/// +/// +/// Mirrors exactly, including the rule that a payload is sealed at the version the +/// server will assign rather than the one it replaces — see . +/// +/// +/// The resource type is the one thing not to copy. As with , the AAD binds +/// it and the two enums that name item types do not agree: SyncEntityType.Credential is 2 while +/// CryptoSpec.AadResourceType.Credential is 5, because the crypto enum carries None, User, Device and +/// Vault ahead of the item types. Casting one to the other would seal a password under the resource type for +/// a user — which encrypts perfectly, decrypts perfectly on the machine that wrote it, and is a +/// specification violation nothing would notice until an interoperating client refused the item. +/// +/// +public static class CredentialCipher +{ + private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Credential; + + /// Encrypts a credential. + /// The credential. 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. + public static EncryptedPayload Seal( + CredentialSecret credential, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) + { + ArgumentNullException.ThrowIfNull(credential); + ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1); + + var plaintext = CredentialSecretCodec.Encode(credential); + 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); + + // The encoded password, wiped. As with a key, this is the one buffer holding the secret that can + // actually be cleared — the strings the codec read it from cannot be. + CryptographicOperations.ZeroMemory(plaintext); + } + } + + /// Decrypts a credential. + /// + public static CredentialSecretDocument? 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 CredentialSecretCodec.TryDecode(plaintext, out var document) ? document : null; + } + finally + { + CryptographicOperations.ZeroMemory(plaintext); + } + } + finally + { + CryptographicOperations.ZeroMemory(dataKey); + } + } +} diff --git a/src/DodoSSH.Client.Sync/CredentialRepository.cs b/src/DodoSSH.Client.Sync/CredentialRepository.cs new file mode 100644 index 0000000..837add0 --- /dev/null +++ b/src/DodoSSH.Client.Sync/CredentialRepository.cs @@ -0,0 +1,50 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; + +namespace DodoSSH.Client.Sync; + +/// +/// The credentials in a vault, decrypted, with unpushed local changes laid over them. +/// +/// +/// +/// The third facade over the same generic repository, and by now that is the point: adding an item type to +/// this client is a kind, a facade and a view, with no new reconciliation logic and no new sync path. +/// +/// +/// A credential listed here has its password in memory. Listing decrypts every credential in the +/// vault, so the caller holds them all for as long as it holds the listing — the same bargain +/// makes for key material, and worth restating because it is the reason the +/// interface reads a listing once per reload rather than holding one open. +/// +/// +public sealed class CredentialRepository(ItemStore items, OutboxStore outbox, VaultKeyring keyring) +{ + private readonly VaultItemRepository credentials = + new(CredentialKind.Instance, items, outbox, keyring); + + /// + public Task> ListAsync( + Guid vaultId, + CancellationToken cancellationToken) => + credentials.ListAsync(vaultId, cancellationToken); + + /// + public Task CreateAsync( + Guid vaultId, + CredentialSecret credential, + CancellationToken cancellationToken) => + credentials.CreateAsync(vaultId, credential, cancellationToken); + + /// + public Task UpdateAsync( + Guid vaultId, + Guid entityId, + CredentialSecret credential, + CancellationToken cancellationToken) => + credentials.UpdateAsync(vaultId, entityId, credential, cancellationToken); + + /// + public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => + credentials.DeleteAsync(vaultId, entityId, cancellationToken); +} diff --git a/src/DodoSSH.Client.Sync/ItemKinds.cs b/src/DodoSSH.Client.Sync/ItemKinds.cs index 57f788a..ac2b95e 100644 --- a/src/DodoSSH.Client.Sync/ItemKinds.cs +++ b/src/DodoSSH.Client.Sync/ItemKinds.cs @@ -27,8 +27,8 @@ internal sealed record MergedItem( /// /// /// The reconciler holds the six answers a collision can have — merge, adopt, resurrect, abandon, park, -/// refuse — and every one of them is identical for a host and for an SSH key. Only the encoding, the -/// merge and the plaintext columns differ, and those arrive through here. A second copy of the +/// refuse — and every one of them is identical for a host, an SSH key and a credential. Only the encoding, +/// the merge and the plaintext columns differ, and those arrive through here. A second copy of the /// reconciler per item type is the alternative, and it is not a real one: the file's whole premise is /// that the pull and push paths must answer the same situation the same way, and two copies would drift /// the moment one of them was fixed. @@ -113,6 +113,9 @@ internal static class ItemKinds (SyncEntityType.SshKey, static (outbox, conflicts, keyring) => new ItemReconciler(SshKeyKind.Instance, outbox, conflicts, keyring)), + + (SyncEntityType.Credential, static (outbox, conflicts, keyring) => + new ItemReconciler(CredentialKind.Instance, outbox, conflicts, keyring)), ]; /// The types to ask the server for, in a fixed order. @@ -248,3 +251,68 @@ internal sealed class SshKeyKind : IItemKind return secret with { Label = label }; } } + +/// Credentials. +internal sealed class CredentialKind : IItemKind +{ + internal static CredentialKind Instance { get; } = new(); + + /// + public SyncEntityType EntityType => SyncEntityType.Credential; + + /// + public string Noun => "credential"; + + /// + public OpenedItem? TryOpen( + EncryptedPayload payload, + ReadOnlySpan vaultKey, + Guid entityId, + int itemVersion) + { + var document = CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion); + + return document is null + ? null + : new OpenedItem(document.Credential, document.IsReadOnly); + } + + /// + public EncryptedPayload Seal( + CredentialSecret secret, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) => + CredentialCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); + + /// + /// Nothing, and for this type there was never a candidate. + /// + /// + /// A key at least has a fingerprint, which is public by nature and which this client still declines to + /// send. A password has no part that is safe to expose — not its length, not a hash, not a hint — so + /// there is no decision to make here. The server refuses plaintext fields on this type outright. + /// + /// + public SyncPlaintextFields? Fields(CredentialSecret secret) => null; + + /// + public MergedItem Merge( + CredentialSecret ancestor, + CredentialSecret local, + CredentialSecret remote) + { + var merged = CredentialSecretMerge.Merge(ancestor, local, remote); + + return new MergedItem(merged.Merged, merged.Conflicts); + } + + /// + public CredentialSecret Relabel(CredentialSecret secret, string label) + { + ArgumentNullException.ThrowIfNull(secret); + + return secret with { Label = label }; + } +} diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs index 2f479dd..f48fe7c 100644 --- a/src/DodoSSH.Domain/Hosts.cs +++ b/src/DodoSSH.Domain/Hosts.cs @@ -168,3 +168,68 @@ public sealed class VaultSshKey : IVaultItem /// Who last modified it. public Guid UpdatedByUserId { get; set; } } + +/// +/// A stored username and password, as ciphertext. +/// +/// +/// +/// Its own table for the same reason is: the columns a host needs are columns a +/// credential must never have. There is no relay trio here, and unlike a key there is not even a fingerprint +/// — nothing about a password is safe to hold in the clear, not its length, not a hash, not a hint. So this +/// row is an opaque envelope and its bookkeeping, and that is the whole design. +/// +/// +/// The server cannot enforce anything about the contents, and should not pretend to. Whether a +/// credential has a username, whether its password is empty, whether it is still valid — all of that is +/// inside the payload and belongs to the client. The one thing this row asserts is that the ciphertext +/// belongs to a vault and carries a version, which is what the write path needs to order changes. +/// +/// +public sealed class VaultCredential : IVaultItem +{ + /// Primary key. UUIDv7, generated by the client so credentials can be created offline. + public Guid Id { get; set; } + + /// Owning vault. + public Guid VaultId { get; set; } + + /// Owning vault. + public Vault? Vault { get; set; } + + /// The encrypted credential: a DSH1 envelope. Opaque to the server. + public byte[] Payload { get; set; } = []; + + /// The item's data key, wrapped under the vault key. Opaque. + public byte[]? DataKeyWrap { get; set; } + + /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3. + public Guid? ContentKeyId { get; set; } + + /// Vault key generation this payload was encrypted under. + public int KeyGeneration { get; set; } + + /// AAD rule version, enabling a lazy re-encrypt-on-write migration later. + public short PayloadAadVersion { get; set; } + + /// Client-visible, monotonic item version, used for expectedVersion checks. + public int Version { get; set; } + + /// Latest change-log sequence touching this row, so a delta pull can join directly. + public long ChangeSequence { get; set; } + + /// Creation timestamp. + public DateTimeOffset CreatedAtUtc { get; set; } + + /// Last modification timestamp. + public DateTimeOffset UpdatedAtUtc { get; set; } + + /// Soft-delete marker; a tombstone, so an offline client learns the credential went away. + public DateTimeOffset? DeletedAtUtc { get; set; } + + /// Who created it. + public Guid CreatedByUserId { get; set; } + + /// Who last modified it. + public Guid UpdatedByUserId { get; set; } +} diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs index 10e73a2..a2eebc3 100644 --- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs +++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs @@ -86,6 +86,42 @@ public sealed class SshKeyConfiguration : IEntityTypeConfiguration } } +/// +/// Maps . +/// +/// +/// The narrowest of the three item tables, and deliberately so: no relay CHECK, and unlike +/// not even a fingerprint column. There is nothing about a password that +/// is safe to hold in the clear, so there is nothing here but the envelope and its bookkeeping. +/// +public sealed class CredentialConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("credential"); + builder.HasKey(c => c.Id); + + // Client-generated UUIDv7: credentials must be creatable offline, with their ids. + builder.Property(c => c.Id).ValueGeneratedNever(); + builder.UseXminConcurrencyToken(); + + builder.Property(c => c.Payload).IsRequired(); + + builder.HasIndex(c => new { c.VaultId, c.ChangeSequence }); + + builder.HasIndex(c => c.VaultId) + .HasFilter("deleted_at_utc IS NULL") + .HasDatabaseName("ix_credential_vault_live"); + + builder.ToTable(t => t.HasCheckConstraint( + "ck_credential_version", + "version >= 1")); + } +} + /// Maps . public sealed class SyncChangeConfiguration : IEntityTypeConfiguration { diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs index ef18aa3..11024f2 100644 --- a/src/DodoSSH.Infrastructure/DodoDbContext.cs +++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs @@ -57,6 +57,9 @@ public class DodoDbContext(DbContextOptions options) : DbContext( /// SSH key pairs, held as ciphertext. public DbSet SshKeys => Set(); + /// Usernames and passwords, held as ciphertext. + public DbSet Credentials => Set(); + /// The per-vault change log that delta sync reads. public DbSet VaultChanges => Set(); diff --git a/src/DodoSSH.Infrastructure/Migrations/20260729185638_AddCredentialItem.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260729185638_AddCredentialItem.Designer.cs new file mode 100644 index 0000000..362eaf1 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260729185638_AddCredentialItem.Designer.cs @@ -0,0 +1,1162 @@ +// +using System; +using DodoSSH.Infrastructure; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + [DbContext(typeof(DodoDbContext))] + [Migration("20260729185638_AddCredentialItem")] + partial class AddCredentialItem + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dodo") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("DodoSSH.Domain.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("EnrolledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at_utc"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Platform") + .HasColumnType("integer") + .HasColumnName("platform"); + + b.Property("PublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("public_key"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_device"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_user_id"); + + b.ToTable("device", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("EncryptionPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("encryption_public_key"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("PreviousHash") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("previous_hash"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Sequence") + .HasName("pk_key_log"); + + b.HasIndex("Hash") + .IsUnique() + .HasDatabaseName("ix_key_log_hash"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_key_log_user_id"); + + b.ToTable("key_log", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.SshHost", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("GroupId") + .HasColumnType("uuid") + .HasColumnName("group_id"); + + b.Property("Hostname") + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("hostname"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("Port") + .HasColumnType("integer") + .HasColumnName("port"); + + b.Property("RelayEnabled") + .HasColumnType("boolean") + .HasColumnName("relay_enabled"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_host"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_host_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_host_vault_id_change_sequence"); + + b.ToTable("host", "dodo", t => + { + t.HasCheckConstraint("ck_host_port_range", "port IS NULL OR (port BETWEEN 1 AND 65535)"); + + t.HasCheckConstraint("ck_host_relay_target", "(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)\nOR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)"); + + t.HasCheckConstraint("ck_host_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b => + { + b.Property("OperationId") + .HasColumnType("uuid") + .HasColumnName("operation_id"); + + b.Property("AppliedChangeSequence") + .HasColumnType("bigint") + .HasColumnName("applied_change_sequence"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("ResultVersion") + .HasColumnType("integer") + .HasColumnName("result_version"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("OperationId") + .HasName("pk_sync_operation_receipt"); + + b.HasIndex("VaultId", "CreatedAtUtc") + .HasDatabaseName("ix_sync_operation_receipt_vault_id_created_at_utc"); + + b.ToTable("sync_operation_receipt", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Team", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)") + .HasColumnName("description"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("citext") + .HasColumnName("slug"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_team"); + + b.HasIndex("Slug") + .IsUnique() + .HasDatabaseName("ix_team_slug") + .HasFilter("deleted_at_utc IS NULL"); + + b.ToTable("team", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("InvitedByUserId") + .HasColumnType("uuid") + .HasColumnName("invited_by_user_id"); + + b.Property("JoinedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("joined_at_utc"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_team_membership"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_team_membership_user_id"); + + b.HasIndex("TeamId", "UserId") + .IsUnique() + .HasDatabaseName("ix_team_membership_team_id_user_id") + .HasFilter("deleted_at_utc IS NULL"); + + b.ToTable("team_membership", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserAccount", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("DisplayName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("display_name"); + + b.Property("Email") + .HasMaxLength(320) + .HasColumnType("citext") + .HasColumnName("email"); + + b.Property("EnrolledAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("enrolled_at_utc"); + + b.Property("Issuer") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("issuer"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_seen_at_utc"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("status"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("subject"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_user_account"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_user_account_email") + .HasFilter("email IS NOT NULL AND deleted_at_utc IS NULL"); + + b.HasIndex("Issuer", "Subject") + .IsUnique() + .HasDatabaseName("ix_user_account_issuer_subject"); + + b.ToTable("user_account", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("EncryptionPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("encryption_public_key"); + + b.Property("FingerprintSha256") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("fingerprint_sha256"); + + b.Property("Generation") + .HasColumnType("integer") + .HasColumnName("generation"); + + b.Property("IdentityProviderBinding") + .HasColumnType("jsonb") + .HasColumnName("identity_provider_binding"); + + b.Property("IsCurrent") + .HasColumnType("boolean") + .HasColumnName("is_current"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("SigningPublicKey") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("signing_public_key"); + + b.Property("Statement") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("statement"); + + b.Property("StatementSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("statement_signature"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_key"); + + b.HasIndex("FingerprintSha256") + .IsUnique() + .HasDatabaseName("ix_user_key_fingerprint_sha256"); + + b.HasIndex("UserId") + .IsUnique() + .HasDatabaseName("ix_user_key_current") + .HasFilter("is_current"); + + b.HasIndex("UserId", "Generation") + .IsUnique() + .HasDatabaseName("ix_user_key_user_id_generation"); + + b.ToTable("user_key", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("KdfAlgorithm") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("kdf_algorithm"); + + b.Property("KdfMemoryKibibytes") + .HasColumnType("integer") + .HasColumnName("kdf_memory_kibibytes"); + + b.Property("KdfParallelism") + .HasColumnType("integer") + .HasColumnName("kdf_parallelism"); + + b.Property("KdfPasses") + .HasColumnType("integer") + .HasColumnName("kdf_passes"); + + b.Property("KdfSalt") + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("kdf_salt"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("LastUsedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used_at_utc"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Wrap") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("wrap"); + + b.Property("WrapVersion") + .HasColumnType("integer") + .HasColumnName("wrap_version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_user_key_wrap"); + + b.HasIndex("DeviceId") + .HasDatabaseName("ix_user_key_wrap_device_id"); + + b.HasIndex("UserId", "DeviceId") + .IsUnique() + .HasDatabaseName("ix_user_key_wrap_user_device") + .HasFilter("device_id IS NOT NULL"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasDatabaseName("ix_user_key_wrap_user_kind") + .HasFilter("device_id IS NULL"); + + b.ToTable("user_key_wrap", "dodo", t => + { + t.HasCheckConstraint("ck_user_key_wrap_device", "(kind = 2 AND device_id IS NOT NULL) OR (kind <> 2 AND device_id IS NULL)"); + + t.HasCheckConstraint("ck_user_key_wrap_kdf", "(kind IN (1, 3) AND kdf_algorithm IS NOT NULL AND kdf_salt IS NOT NULL\n AND kdf_memory_kibibytes IS NOT NULL AND kdf_passes IS NOT NULL\n AND kdf_parallelism IS NOT NULL)\nOR (kind IN (2, 4) AND kdf_algorithm IS NULL AND kdf_salt IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("OwnerKind") + .HasColumnType("integer") + .HasColumnName("owner_kind"); + + b.Property("OwnerUserId") + .HasColumnType("uuid") + .HasColumnName("owner_user_id"); + + b.Property("RekeyReason") + .HasColumnType("integer") + .HasColumnName("rekey_reason"); + + b.Property("RekeyRequired") + .HasColumnType("boolean") + .HasColumnName("rekey_required"); + + b.Property("TeamId") + .HasColumnType("uuid") + .HasColumnName("team_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_vault"); + + b.HasIndex("OwnerUserId") + .HasDatabaseName("ix_vault_owner_user_id"); + + b.HasIndex("TeamId") + .HasDatabaseName("ix_vault_team_id"); + + b.ToTable("vault", "dodo", t => + { + t.HasCheckConstraint("ck_vault_key_generation", "key_generation >= 1"); + + t.HasCheckConstraint("ck_vault_owner", "(owner_kind = 1 AND owner_user_id IS NOT NULL AND team_id IS NULL)\nOR (owner_kind = 2 AND team_id IS NOT NULL AND owner_user_id IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultChange", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("sequence"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence")); + + b.Property("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property("EntityId") + .HasColumnType("uuid") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("integer") + .HasColumnName("entity_type"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("occurred_at_utc"); + + b.Property("Operation") + .HasColumnType("integer") + .HasColumnName("operation"); + + b.Property("Revision") + .HasColumnType("integer") + .HasColumnName("revision"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("Sequence") + .HasName("pk_sync_change"); + + b.HasIndex("VaultId", "Sequence") + .HasDatabaseName("ix_sync_change_vault_id_sequence"); + + b.HasIndex("VaultId", "EntityId", "Sequence") + .IsDescending(false, false, true) + .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence"); + + b.ToTable("sync_change", "dodo"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_credential"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_credential_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_credential_vault_id_change_sequence"); + + b.ToTable("credential", "dodo", t => + { + t.HasCheckConstraint("ck_credential_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("GranterKeyFingerprint") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("granter_key_fingerprint"); + + b.Property("GranterUserId") + .HasColumnType("uuid") + .HasColumnName("granter_user_id"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("KeyLogHead") + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("key_log_head"); + + b.Property("Kind") + .HasColumnType("integer") + .HasColumnName("kind"); + + b.Property("RecipientKeyFingerprint") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("bytea") + .HasColumnName("recipient_key_fingerprint"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("recipient_user_id"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("revoked_at_utc"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("bytea") + .HasColumnName("signature"); + + b.Property("State") + .HasColumnType("integer") + .HasColumnName("state"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("WrappedKey") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("wrapped_key"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_vault_key_grant"); + + b.HasIndex("RecipientUserId") + .HasDatabaseName("ix_vault_key_grant_recipient_user_id"); + + b.HasIndex("VaultId", "KeyGeneration", "RecipientUserId") + .IsUnique() + .HasDatabaseName("ix_vault_key_grant_vault_id_key_generation_recipient_user_id") + .HasFilter("revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL"); + + b.ToTable("vault_key_grant", "dodo", t => + { + t.HasCheckConstraint("ck_vault_key_grant_recipient", "(kind = 1 AND recipient_user_id IS NOT NULL) OR (kind <> 1 AND recipient_user_id IS NULL)"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("PublicKeyFingerprint") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("public_key_fingerprint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_ssh_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_ssh_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_ssh_key_vault_id_change_sequence"); + + b.ToTable("ssh_key", "dodo", t => + { + t.HasCheckConstraint("ck_ssh_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.Device", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("Devices") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_users_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.SshHost", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany("Hosts") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_host_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b => + { + b.HasOne("DodoSSH.Domain.Team", "Team") + .WithMany("Memberships") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_team_membership_team_team_id"); + + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_team_membership_users_user_id"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKey", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("Keys") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_key_user_account_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b => + { + b.HasOne("DodoSSH.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_key_wrap_device_device_id"); + + b.HasOne("DodoSSH.Domain.UserAccount", "User") + .WithMany("KeyWraps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_key_wrap_user_account_user_id"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "OwnerUser") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vault_user_account_owner_user_id"); + + b.HasOne("DodoSSH.Domain.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_vault_team_team_id"); + + b.Navigation("OwnerUser"); + + b.Navigation("Team"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_credential_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => + { + b.HasOne("DodoSSH.Domain.UserAccount", "RecipientUser") + .WithMany() + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_vault_key_grant_user_account_recipient_user_id"); + + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany("KeyGrants") + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_vault_key_grant_vault_vault_id"); + + b.Navigation("RecipientUser"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_ssh_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Team", b => + { + b.Navigation("Memberships"); + }); + + modelBuilder.Entity("DodoSSH.Domain.UserAccount", b => + { + b.Navigation("Devices"); + + b.Navigation("KeyWraps"); + + b.Navigation("Keys"); + }); + + modelBuilder.Entity("DodoSSH.Domain.Vault", b => + { + b.Navigation("Hosts"); + + b.Navigation("KeyGrants"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/20260729185638_AddCredentialItem.cs b/src/DodoSSH.Infrastructure/Migrations/20260729185638_AddCredentialItem.cs new file mode 100644 index 0000000..7659202 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260729185638_AddCredentialItem.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + /// + public partial class AddCredentialItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "credential", + schema: "dodo", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + vault_id = table.Column(type: "uuid", nullable: false), + payload = table.Column(type: "bytea", nullable: false), + data_key_wrap = table.Column(type: "bytea", nullable: true), + content_key_id = table.Column(type: "uuid", nullable: true), + key_generation = table.Column(type: "integer", nullable: false), + payload_aad_version = table.Column(type: "smallint", nullable: false), + version = table.Column(type: "integer", nullable: false), + change_sequence = table.Column(type: "bigint", nullable: false), + created_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + updated_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + deleted_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + created_by_user_id = table.Column(type: "uuid", nullable: false), + updated_by_user_id = table.Column(type: "uuid", nullable: false), + xmin = table.Column(type: "xid", rowVersion: true, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_credential", x => x.id); + table.CheckConstraint("ck_credential_version", "version >= 1"); + table.ForeignKey( + name: "fk_credential_vaults_vault_id", + column: x => x.vault_id, + principalSchema: "dodo", + principalTable: "vault", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_credential_vault_id_change_sequence", + schema: "dodo", + table: "credential", + columns: new[] { "vault_id", "change_sequence" }); + + migrationBuilder.CreateIndex( + name: "ix_credential_vault_live", + schema: "dodo", + table: "credential", + column: "vault_id", + filter: "deleted_at_utc IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "credential", + schema: "dodo"); + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index dd1a92f..8fe24ca 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -737,6 +737,87 @@ namespace DodoSSH.Infrastructure.Migrations b.ToTable("sync_change", "dodo"); }); + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ChangeSequence") + .HasColumnType("bigint") + .HasColumnName("change_sequence"); + + b.Property("ContentKeyId") + .HasColumnType("uuid") + .HasColumnName("content_key_id"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at_utc"); + + b.Property("CreatedByUserId") + .HasColumnType("uuid") + .HasColumnName("created_by_user_id"); + + b.Property("DataKeyWrap") + .HasColumnType("bytea") + .HasColumnName("data_key_wrap"); + + b.Property("DeletedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at_utc"); + + b.Property("KeyGeneration") + .HasColumnType("integer") + .HasColumnName("key_generation"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("payload"); + + b.Property("PayloadAadVersion") + .HasColumnType("smallint") + .HasColumnName("payload_aad_version"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at_utc"); + + b.Property("UpdatedByUserId") + .HasColumnType("uuid") + .HasColumnName("updated_by_user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.Property("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.Property("xmin") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("Id") + .HasName("pk_credential"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_credential_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_credential_vault_id_change_sequence"); + + b.ToTable("credential", "dodo", t => + { + t.HasCheckConstraint("ck_credential_version", "version >= 1"); + }); + }); + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => { b.Property("Id") @@ -1008,6 +1089,18 @@ namespace DodoSSH.Infrastructure.Migrations b.Navigation("Team"); }); + modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_credential_vaults_vault_id"); + + b.Navigation("Vault"); + }); + modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b => { b.HasOne("DodoSSH.Domain.UserAccount", "RecipientUser") diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index 746d6ec..4a2404a 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -584,6 +584,19 @@ public sealed class SyncEndpointTests(ApiFixture fixture) { // A newer client asking for something this server does not do yet gets a precise // per-operation answer rather than a whole-batch rejection. + // + // The type is taken from the server's own registry rather than named, and that is not fussiness. This + // test used to name Credential, and implementing credentials turned it into a test asserting the + // opposite of the truth — it failed loudly, but a differently-shaped test would have gone quiet + // instead. Asking the registry what is still missing keeps it aimed at the branch it was written for. + var unsupported = Enum.GetValues() + .Where(type => type != SyncEntityType.Unspecified) + .FirstOrDefault(type => Features.Sync.ItemKinds.For(type) is null); + + Assert.SkipWhen( + unsupported == SyncEntityType.Unspecified, + "Every entity type in the contract is implemented, so this branch is no longer reachable."); + var (subject, vaultId) = await SeedUserWithVaultAsync(); var client = fixture.CreateClientFor(subject); @@ -591,7 +604,7 @@ public sealed class SyncEndpointTests(ApiFixture fixture) [ new SyncPushOperation( Guid.CreateVersion7(), - SyncEntityType.Credential, + unsupported, Guid.CreateVersion7(), SyncOperation.Upsert, null, @@ -665,6 +678,125 @@ public sealed class SyncEndpointTests(ApiFixture fixture) /// separately and then put them back in the log's order, because a client's cursor cannot resume from a /// sequence that was regrouped. /// + [Fact] + public async Task ACredential_RoundTripsAsCiphertextWithNoPlaintextAtAll() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var credentialId = Guid.CreateVersion7(); + + var pushed = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest( + [CredentialOperation(credentialId, expectedVersion: null, envelope: [7, 7, 7])])); + + var results = await pushed.Content.ReadContractAsync(); + results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied); + + var pulled = await client.PostContractAsync( + PullUrl(vaultId), + new SyncPullRequest(null, null, [SyncEntityType.Credential])); + + var page = await pulled.Content.ReadContractAsync(); + var change = page!.Changes.ShouldHaveSingleItem(); + + change.EntityType.ShouldBe(SyncEntityType.Credential); + change.EntityId.ShouldBe(credentialId); + change.Payload.ShouldNotBeNull().Envelope.ShouldBe([7, 7, 7]); + + change.PlaintextFields.ShouldBeNull( + "a credential has no plaintext columns, so a pull has nothing to hydrate"); + } + + [Fact] + public async Task ACredentialCarryingPlaintextFields_IsRejected() + { + // Refused rather than dropped. A client that thinks it is storing something and is not will be + // surprised later, and for this type "something" would be a detail about a password. + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var operation = new SyncPushOperation( + Guid.CreateVersion7(), + SyncEntityType.Credential, + Guid.CreateVersion7(), + SyncOperation.Upsert, + null, + Payload([1, 2, 3]), + new SyncPlaintextFields(RelayEnabled: true, Hostname: "db.internal", Port: 22)); + + var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation])); + + var result = (await pushed.Content.ReadContractAsync())!.Results + .ShouldHaveSingleItem(); + + result.Status.ShouldBe(SyncOperationStatus.Invalid); + result.Detail.ShouldNotBeNull().ShouldContain("no relay target"); + } + + [Fact] + public async Task ACredentialWithAFingerprint_IsRejected() + { + // The one plaintext field a key may carry, refused here. A password has no public half, so a client + // sending one is confused about what it is storing. + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var operation = new SyncPushOperation( + Guid.CreateVersion7(), + SyncEntityType.Credential, + Guid.CreateVersion7(), + SyncOperation.Upsert, + null, + Payload([1, 2, 3]), + new SyncPlaintextFields(PublicKeyFingerprint: "SHA256:whatever")); + + var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation])); + + var result = (await pushed.Content.ReadContractAsync())!.Results + .ShouldHaveSingleItem(); + + result.Status.ShouldBe(SyncOperationStatus.Invalid); + result.Detail.ShouldNotBeNull().ShouldContain("no public key"); + } + + [Fact] + public async Task ThreeItemTypesWithOneId_AreThreeSeparateItems() + { + // The tables are separate, so one id may name a host, a key and a credential at once. Not something a + // client would do — ids are UUIDv7 — but if the write path ever confused two types, this is the test + // that says so rather than a mystery about a missing item. + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var sharedId = Guid.CreateVersion7(); + + var pushed = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest( + [ + NewOperation(sharedId, expectedVersion: null, envelope: [1, 1]), + KeyOperation(sharedId, expectedVersion: null, envelope: [2, 2]), + CredentialOperation(sharedId, expectedVersion: null, envelope: [3, 3]), + ])); + + (await pushed.Content.ReadContractAsync())!.Results + .ShouldAllBe(result => result.Status == SyncOperationStatus.Applied); + + var pulled = await client.PostContractAsync( + PullUrl(vaultId), + new SyncPullRequest(null, null, null)); + + var page = await pulled.Content.ReadContractAsync(); + + page!.Changes.Count.ShouldBe(3); + page.Changes.ShouldAllBe(change => change.EntityId == sharedId); + + page.Changes.Select(change => change.EntityType).Order().ShouldBe( + [SyncEntityType.Host, SyncEntityType.Credential, SyncEntityType.SshKey]); + } + [Fact] public async Task APullMixingHostsAndKeys_ReturnsBothInLogOrder() { @@ -776,6 +908,23 @@ public sealed class SyncEndpointTests(ApiFixture fixture) /// PlaintextFields: null rather than an empty instance, which is what a real client sends for a /// type with no plaintext columns — and what the server must accept without inventing defaults. /// + /// + /// Like with even less: a credential has no plaintext column at all, not even + /// the fingerprint a key may carry, so this is the narrowest an operation gets. + /// + private static SyncPushOperation CredentialOperation( + Guid entityId, + int? expectedVersion, + byte[] envelope) => + new( + Guid.CreateVersion7(), + SyncEntityType.Credential, + entityId, + SyncOperation.Upsert, + expectedVersion, + Payload(envelope), + PlaintextFields: null); + private static SyncPushOperation KeyOperation(Guid entityId, int? expectedVersion, byte[] envelope) => new( Guid.CreateVersion7(), diff --git a/tests/DodoSSH.Client.Domain.Tests/CredentialSecretTests.cs b/tests/DodoSSH.Client.Domain.Tests/CredentialSecretTests.cs new file mode 100644 index 0000000..8fda1c9 --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/CredentialSecretTests.cs @@ -0,0 +1,180 @@ +namespace DodoSSH.Client.Domain.Tests; + +/// +/// The credential record, its codec and its merge. +/// +/// +/// Deliberately shorter than . The two types have the same shape and the same +/// merge strategy, so what is covered here is what is specific to a credential: that an empty password is +/// refused while a password of spaces is not, that "no username" has one spelling, and that the password +/// never appears in a conflict log. +/// +public sealed class CredentialSecretTests +{ + [Fact] + public void AnEmptyUsername_IsTheSameAsNone() + { + // One spelling of "use the host's username", for the same reasons SshKeySecret.Passphrase normalises: + // identical credentials encode identically, and "does this override the host?" has one answer. + Credential(username: string.Empty).Username.ShouldBeNull(); + Credential(username: null).Username.ShouldBeNull(); + Credential(username: "postgres").Username.ShouldBe("postgres"); + + Credential(username: string.Empty).ShouldBe(Credential(username: null)); + } + + [Fact] + public void APasswordOfSpaces_IsAPassword() + { + // IsNullOrWhiteSpace would refuse this, and refusing it would lock someone out of a host over a + // validation opinion. Only genuinely empty is refused. + Credential(password: " ").TryValidate(out var reason).ShouldBeTrue(reason); + Credential(password: string.Empty).TryValidate(out _).ShouldBeFalse(); + } + + [Theory] + [InlineData("", "hunter2", "needs a name")] + [InlineData(" ", "hunter2", "needs a name")] + [InlineData("db", "", "needs a password")] + public void AnInvalidCredential_SaysWhatIsWrongWithIt(string label, string password, string expected) + { + var credential = new CredentialSecret { Label = label, Password = password }; + + credential.TryValidate(out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull().ShouldContain(expected); + } + + [Fact] + public void ACredential_SurvivesARoundTrip() + { + var credential = Credential(username: "postgres") with { Notes = "rotate in June" }; + + var encoded = CredentialSecretCodec.Encode(credential); + + CredentialSecretCodec.TryDecode(encoded, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Credential.ShouldBe(credential); + document.SchemaVersion.ShouldBe(CredentialSecretCodec.CurrentSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void EncodingIsDeterministic() + { + CredentialSecretCodec.Encode(Credential()) + .ShouldBe(CredentialSecretCodec.Encode(Credential())); + } + + [Fact] + public void AnEmptyUsernameIsNotWrittenAtAll() + { + CredentialSecretCodec.Encode(Credential(username: string.Empty)) + .ShouldBe(CredentialSecretCodec.Encode(Credential(username: null))); + } + + [Theory] + [InlineData("not json")] + [InlineData("{}")] + [InlineData("""{"schemaVersion":1,"label":"db"}""")] + [InlineData("""{"schemaVersion":1,"password":"hunter2"}""")] + [InlineData("""{"schemaVersion":0,"label":"db","password":"hunter2"}""")] + public void APayloadThatIsNotACredential_DoesNotDecode(string json) + { + CredentialSecretCodec + .TryDecode(System.Text.Encoding.UTF8.GetBytes(json), out var document) + .ShouldBeFalse(); + + document.ShouldBeNull(); + } + + [Fact] + public void ACredentialFromANewerClient_IsReadableButNotWritable() + { + var payload = System.Text.Encoding.UTF8.GetBytes( + """ + {"schemaVersion":99,"label":"db","password":"hunter2","totpSeed":"something new"} + """); + + CredentialSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); + + document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue(); + } + + [Fact] + public void EachSideEditingADifferentField_KeepsBoth() + { + var ancestor = Credential(); + var local = ancestor with { Label = "db-primary" }; + var remote = ancestor with { Notes = "from the desktop" }; + + var merged = CredentialSecretMerge.Merge(ancestor, local, remote); + + merged.HasConflicts.ShouldBeFalse(); + merged.Merged.Label.ShouldBe("db-primary"); + merged.Merged.Notes.ShouldBe("from the desktop"); + merged.Merged.Password.ShouldBe(ancestor.Password); + } + + [Fact] + public void BothSidesChangingThePassword_NeverPutsEitherInTheConflictLog() + { + // The reason CredentialSecretMerge redacts, and the case for it is if anything plainer than for a + // key: a discarded password is very often still the live password on some other system. + var ancestor = Credential(); + var local = ancestor with { Password = "LAPTOP-SECRET" }; + var remote = ancestor with { Password = "DESKTOP-SECRET" }; + + var merged = CredentialSecretMerge.Merge(ancestor, local, remote); + + var conflict = merged.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe(nameof(CredentialSecret.Password)); + + var kept = conflict.Kept.ShouldNotBeNull(); + var discarded = conflict.Discarded.ShouldNotBeNull(); + + foreach (var reported in new[] { kept, discarded }) + { + reported.ShouldNotContain("LAPTOP-SECRET"); + reported.ShouldNotContain("DESKTOP-SECRET"); + } + + // Redacting the report must not redact the value. + merged.Merged.Password.ShouldBeOneOf("LAPTOP-SECRET", "DESKTOP-SECRET"); + } + + [Fact] + public void AUsernameClash_IsShownInFull() + { + // Not a secret, and knowing which account the merge dropped is the whole use of the notice. + var ancestor = Credential(); + var local = ancestor with { Username = "postgres" }; + var remote = ancestor with { Username = "deploy" }; + + var merged = CredentialSecretMerge.Merge(ancestor, local, remote); + + var conflict = merged.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe(nameof(CredentialSecret.Username)); + + new[] { conflict.Kept, conflict.Discarded }.ShouldBe(["deploy", "postgres"], ignoreOrder: true); + } + + [Fact] + public void AUsernameClashingWithItsRemoval_SaysWhichSideHadNone() + { + var ancestor = Credential(username: "root"); + var local = ancestor with { Username = null }; + var remote = ancestor with { Username = "deploy" }; + + var merged = CredentialSecretMerge.Merge(ancestor, local, remote); + + var conflict = merged.Conflicts.ShouldHaveSingleItem(); + conflict.Kept.ShouldBe("deploy"); + conflict.Discarded.ShouldBe("(none)"); + } + + private static CredentialSecret Credential( + string password = "hunter2", + string? username = null) => + new() { Label = "db", Password = password, Username = username }; +} diff --git a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs index c7e8eef..4f1fc80 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs @@ -19,7 +19,8 @@ internal static class HostFactory Guid[]? jumps = null, (string Name, string Value)[]? options = null, bool relayEnabled = false, - Guid? sshKeyId = null) => + Guid? sshKeyId = null, + Guid? credentialId = null) => new() { Label = label, @@ -33,5 +34,6 @@ internal static class HostFactory : HostOptions.Create(options.Select(o => new HostOption(o.Name, o.Value))), RelayEnabled = relayEnabled, SshKeyId = sshKeyId, + CredentialId = credentialId, }; } diff --git a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs index 0c8b8b7..014c657 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostSecretCodecTests.cs @@ -14,11 +14,17 @@ namespace DodoSSH.Client.Domain.Tests; /// public sealed class HostSecretCodecTests { + /// + /// "Full" cannot mean every field any more: the two bindings are mutually exclusive, so a host may carry + /// a key or a credential and never both. This one carries the credential because that is the newer of + /// the two and therefore the highest schema version a valid host can reach; the key-bound case has its + /// own version test below. + /// [Fact] public void AFullHost_RoundTrips() { - // Every field, which is what makes the version assertion below meaningful: a host carrying the - // newest field is the only kind written at the newest version. + var credentialId = Guid.Parse("0192f0c8-5555-7c3d-8e4f-5a6b7c8d9e05"); + var host = Host( label: "prod-db", hostname: "db.internal", @@ -28,12 +34,13 @@ public sealed class HostSecretCodecTests jumps: [Bastion, Relay], options: [("ServerAliveInterval", "30"), ("Compression", "yes")], relayEnabled: true, - sshKeyId: DeployKey); + credentialId: credentialId); HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue(); document.ShouldNotBeNull(); document.Host.ShouldBe(host); + document.Host.CredentialId.ShouldBe(credentialId); document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } @@ -65,6 +72,37 @@ public sealed class HostSecretCodecTests document.Host.SshKeyId.ShouldBe(DeployKey); } + [Fact] + public void AHostThatBindsACredential_IsWrittenAtTheVersionThatIntroducedIt() + { + // Each binding earns its own version, so a host using only the older one is not dragged forward onto + // a version older clients refuse to edit. + var credentialId = Guid.CreateVersion7(); + + HostSecretCodec + .TryDecode(HostSecretCodec.Encode(Host(credentialId: credentialId)), out var document) + .ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.SchemaVersion.ShouldBe(HostSecretCodec.CredentialIdSchemaVersion); + document.Host.CredentialId.ShouldBe(credentialId); + } + + [Fact] + public void AKeyBoundHost_IsNotDraggedOntoTheCredentialVersion() + { + // The point of the ladder. Adding credentials must not make every key-bound host in every vault + // read-only on a client that understands keys perfectly well. + HostSecretCodec + .TryDecode(HostSecretCodec.Encode(Host(sshKeyId: DeployKey)), out var document) + .ShouldBeTrue(); + + var version = document.ShouldNotBeNull().SchemaVersion; + + version.ShouldBe(HostSecretCodec.SshKeyIdSchemaVersion); + version.ShouldBeLessThan(HostSecretCodec.CredentialIdSchemaVersion); + } + [Fact] public void AddingTheKeyField_DidNotChangeTheBytesOfAHostWithoutOne() { diff --git a/tests/DodoSSH.Client.Domain.Tests/SshKeySecretTests.cs b/tests/DodoSSH.Client.Domain.Tests/SshKeySecretTests.cs index b7ec642..d5b8e4e 100644 --- a/tests/DodoSSH.Client.Domain.Tests/SshKeySecretTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/SshKeySecretTests.cs @@ -198,10 +198,14 @@ public sealed class SshKeySecretTests // Named, so the user knows what clashed. Not quoted, because the conflict log is stored to be read // and is deliberately kept after acknowledgement. - conflict.Kept.ShouldNotContain("LAPTOP-SECRET"); - conflict.Kept.ShouldNotContain("DESKTOP-SECRET"); - conflict.Discarded.ShouldNotBeNull().ShouldNotContain("LAPTOP-SECRET"); - conflict.Discarded.ShouldNotContain("DESKTOP-SECRET"); + var kept = conflict.Kept.ShouldNotBeNull(); + var discarded = conflict.Discarded.ShouldNotBeNull(); + + foreach (var reported in new[] { kept, discarded }) + { + reported.ShouldNotContain("LAPTOP-SECRET"); + reported.ShouldNotContain("DESKTOP-SECRET"); + } // And the surviving key is a real one — redacting the report must not redact the value. merged.Merged.PrivateKeyPem.ShouldBeOneOf(local.PrivateKeyPem, remote.PrivateKeyPem); @@ -218,8 +222,11 @@ public sealed class SshKeySecretTests var conflict = merged.Conflicts.ShouldHaveSingleItem(); conflict.Field.ShouldBe(nameof(SshKeySecret.Passphrase)); - conflict.Kept.ShouldNotContain("passphrase-"); - conflict.Kept.ShouldNotContain("laptop-passphrase"); + + var kept = conflict.Kept.ShouldNotBeNull(); + kept.ShouldNotContain("passphrase-"); + kept.ShouldNotContain("laptop-passphrase"); + conflict.Discarded.ShouldNotBeNull().ShouldNotContain("desktop-passphrase"); } diff --git a/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs b/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs index 83880e0..7e96bc9 100644 --- a/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/ValueSemanticsTests.cs @@ -156,6 +156,23 @@ public sealed class ValueSemanticsTests Host(hostname: " ").TryValidate(out _).ShouldBeFalse(); Host(port: 65536).TryValidate(out _).ShouldBeFalse(); Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); + Host(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); + Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse(); Host().TryValidate(out _).ShouldBeTrue(); } + + [Fact] + public void AHostAuthenticatesOneWay_NotTwo() + { + // SSH would happily try a key and fall back to a password, and a host that named both would leave + // "how does this authenticate?" without a single answer — so the interface, the connect path and the + // user would each be free to guess differently. Refused at the type instead. + var both = Host(sshKeyId: DeployKey, credentialId: Guid.CreateVersion7()); + + both.TryValidate(out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull().ShouldContain("not both"); + + Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue(); + Host(credentialId: Guid.CreateVersion7()).TryValidate(out _).ShouldBeTrue(); + } } diff --git a/tests/DodoSSH.Client.Sync.Tests/CredentialSyncTests.cs b/tests/DodoSSH.Client.Sync.Tests/CredentialSyncTests.cs new file mode 100644 index 0000000..a470fcb --- /dev/null +++ b/tests/DodoSSH.Client.Sync.Tests/CredentialSyncTests.cs @@ -0,0 +1,211 @@ +using DodoSSH.Client.Storage; +using DodoSSH.Contracts; +using static DodoSSH.Client.Sync.Tests.SyncHarness; + +namespace DodoSSH.Client.Sync.Tests; + +/// +/// Credentials through the two-machine harness. +/// +/// +/// Shorter still than , and that is the payoff of the shared reconciler: the six +/// collision outcomes are one implementation and are already exercised. What is left to check per type is its +/// cipher, what it tells the server, that its items cannot be confused with another type's, and that the one +/// thing which must never be logged is not logged. +/// +public sealed class CredentialSyncTests : IAsyncLifetime +{ + private SyncHarness harness = null!; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + /// + public async ValueTask InitializeAsync() => harness = await CreateAsync(); + + /// + public ValueTask DisposeAsync() + { + harness.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task ACredentialCreatedOnOneMachine_ReachesTheOther() + { + var entityId = await harness.First.CreateCredentialAsync( + Credential("prod-db", password: "hunter2", username: "postgres", notes: "rotate in June")); + + await harness.SettleAsync(); + + var seen = await harness.Second.FindCredentialAsync(entityId); + + seen.Secret.Label.ShouldBe("prod-db"); + seen.Secret.Password.ShouldBe("hunter2"); + seen.Secret.Username.ShouldBe("postgres"); + seen.Secret.Notes.ShouldBe("rotate in June"); + seen.HasUnsyncedChanges.ShouldBeFalse(); + } + + [Fact] + public async Task ThePull_AsksForAllThreeTypes() + { + await harness.First.SyncAsync(); + + var asked = harness.Server.LastPullTypes.ShouldNotBeNull(); + + asked.ShouldContain(SyncEntityType.Host); + asked.ShouldContain(SyncEntityType.SshKey); + asked.ShouldContain(SyncEntityType.Credential); + } + + [Fact] + public async Task ACredentialHandsTheServerNothingInPlaintext() + { + var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db")); + + var queued = await harness.First.Outbox + .FindAsync(VaultId, SyncEntityType.Credential, entityId, Token); + + queued.ShouldNotBeNull(); + queued.Fields.ShouldBeNull("nothing about a password is safe to hold in the clear"); + + await harness.SettleAsync(); + + var row = harness.Server.Find(entityId, SyncEntityType.Credential).ShouldNotBeNull(); + + row.Fields.RelayEnabled.ShouldBeFalse(); + row.Fields.Hostname.ShouldBeNull(); + row.Fields.PublicKeyFingerprint.ShouldBeNull(); + } + + [Fact] + public async Task ThreeTypesSharingOneId_AreThreeItems() + { + // The cache keys on the type as well as the id, and each payload's AAD binds a different resource + // type — two defences, independently. Arranged on the server because the repositories mint UUIDv7s + // and would never collide. + var sharedId = Guid.CreateVersion7(); + + harness.First.Keyring.TryGet(VaultId, out var vaultKey, out var generation).ShouldBeTrue(); + + harness.Server.ExternalUpsert( + sharedId, + HostCipher.Seal(Host("prod-db"), vaultKey.Span, sharedId, generation, itemVersion: 1), + new SyncPlaintextFields(), + SyncEntityType.Host); + + harness.Server.ExternalUpsert( + sharedId, + SshKeyCipher.Seal(Key("deploy"), vaultKey.Span, sharedId, generation, itemVersion: 1), + null, + SyncEntityType.SshKey); + + harness.Server.ExternalUpsert( + sharedId, + CredentialCipher.Seal( + Credential("db-login"), vaultKey.Span, sharedId, generation, itemVersion: 1), + null, + SyncEntityType.Credential); + + await harness.Second.SyncAsync(); + + (await harness.Second.ListAsync()).Items.ShouldHaveSingleItem() + .Secret.Label.ShouldBe("prod-db"); + (await harness.Second.ListKeysAsync()).Items.ShouldHaveSingleItem() + .Secret.Label.ShouldBe("deploy"); + + var credentials = await harness.Second.ListCredentialsAsync(); + + credentials.Items.ShouldHaveSingleItem().Secret.Label.ShouldBe("db-login"); + credentials.Unreadable.ShouldBe(0); + } + + [Fact] + public async Task TwoMachinesEditingDifferentFields_BothSurvive() + { + var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db-primary")); + await harness.Second.UpdateCredentialAsync( + entityId, Credential("prod-db", notes: "from the desktop")); + + await harness.SettleAsync(); + + var first = (await harness.First.FindCredentialAsync(entityId)).Secret; + + first.ShouldBe((await harness.Second.FindCredentialAsync(entityId)).Secret); + first.Label.ShouldBe("prod-db-primary"); + first.Notes.ShouldBe("from the desktop"); + + (await ConflictKindsAsync()).ShouldBeEmpty(); + } + + [Fact] + public async Task BothChangedThePassword_NeitherReachesTheConflictLog() + { + var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db", "LAPTOP-SECRET")); + await harness.Second.UpdateCredentialAsync(entityId, Credential("prod-db", "DESKTOP-SECRET")); + + await harness.SettleAsync(); + + (await ConflictKindsAsync()).ShouldContain(kind => kind == ConflictKind.FieldOverridden); + + var details = await ConflictDetailsAsync(); + + details.ShouldContain( + detail => detail.Contains("Password", StringComparison.Ordinal), + "the user still has to be told which field clashed"); + + foreach (var detail in details) + { + detail.ShouldNotContain("LAPTOP-SECRET"); + detail.ShouldNotContain("DESKTOP-SECRET"); + } + } + + [Fact] + public async Task ACredentialEditedElsewhereAfterBeingDeletedHere_IsCalledACredential() + { + var entityId = await harness.First.CreateCredentialAsync(Credential("prod-db")); + await harness.SettleAsync(); + + await harness.First.UpdateCredentialAsync(entityId, Credential("prod-db", notes: "still in use")); + await harness.Second.Credentials.DeleteAsync(VaultId, entityId, Token); + + await harness.SettleAsync(); + + (await harness.First.FindCredentialAsync(entityId)).Secret.Notes.ShouldBe("still in use"); + + var details = await ConflictDetailsAsync(); + + details.ShouldContain( + detail => detail.Contains("This credential was edited", StringComparison.Ordinal)); + + details.ShouldNotContain( + detail => detail.Contains("This host was edited", StringComparison.Ordinal)); + } + + private async Task> ConflictKindsAsync() + { + var first = await harness.First.ConflictsAsync(); + var second = await harness.Second.ConflictsAsync(); + + return [.. first.Concat(second).Select(conflict => conflict.Kind)]; + } + + private async Task> ConflictDetailsAsync() + { + var first = await harness.First.ConflictsAsync(); + var second = await harness.Second.ConflictsAsync(); + + return + [ + .. first.Concat(second) + .Select(conflict => System.Text.Encoding.UTF8.GetString(conflict.Detail)), + ]; + } +} diff --git a/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs index c1d1ef6..d04b087 100644 --- a/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs +++ b/tests/DodoSSH.Client.Sync.Tests/FakeVaultServer.cs @@ -30,7 +30,8 @@ namespace DodoSSH.Client.Sync.Tests; internal sealed class FakeVaultServer : ISyncApi { /// The item types this fake knows, mirroring the server's own registry. - private static readonly SyncEntityType[] Supported = [SyncEntityType.Host, SyncEntityType.SshKey]; + private static readonly SyncEntityType[] Supported = + [SyncEntityType.Host, SyncEntityType.SshKey, SyncEntityType.Credential]; private readonly Dictionary<(SyncEntityType Type, Guid EntityId), Row> rows = []; private readonly List log = []; @@ -278,6 +279,23 @@ internal sealed class FakeVaultServer : ISyncApi return true; } + if (entityType == SyncEntityType.Credential) + { + if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null) + { + error = "A credential has no relay target; relay fields may only be set on a host."; + return false; + } + + if (fields.PublicKeyFingerprint is not null) + { + error = "A credential has no public key."; + return false; + } + + return true; + } + if (!fields.RelayEnabled && (fields.Hostname is not null || fields.Port is not null)) { error = "An address may only be supplied when relay is enabled."; diff --git a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs index 7df4b87..fc68c59 100644 --- a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs @@ -17,7 +17,8 @@ public sealed class ItemKindsTests [Fact] public void ThePullFilterNamesEveryTypeThisBuildSynchronises() { - ItemKinds.SyncedTypes.ShouldBe([SyncEntityType.Host, SyncEntityType.SshKey]); + ItemKinds.SyncedTypes.ShouldBe( + [SyncEntityType.Host, SyncEntityType.SshKey, SyncEntityType.Credential]); } [Fact] diff --git a/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs b/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs index 4f86781..ef033b9 100644 --- a/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs +++ b/tests/DodoSSH.Client.Sync.Tests/SyncHarness.cs @@ -38,6 +38,7 @@ internal sealed class SyncDevice : IDisposable Conflicts = new ConflictStore(factory, protector, TimeProvider.System); Hosts = new HostRepository(Items, Outbox, keyring); SshKeys = new SshKeyRepository(Items, Outbox, keyring); + Credentials = new CredentialRepository(Items, Outbox, keyring); Engine = new SyncEngine( server, Items, Outbox, SyncState, Conflicts, keyring, TimeProvider.System, options); @@ -59,6 +60,8 @@ internal sealed class SyncDevice : IDisposable internal SshKeyRepository SshKeys { get; } + internal CredentialRepository Credentials { get; } + internal SyncEngine Engine { get; } internal static async Task CreateAsync( @@ -142,6 +145,26 @@ internal sealed class SyncDevice : IDisposable internal Task DeleteKeyAsync(Guid entityId) => SshKeys.DeleteAsync(SyncHarness.VaultId, entityId, TestContext.Current.CancellationToken); + // ---- And again on credentials ---- + + internal Task> ListCredentialsAsync() => + Credentials.ListAsync(SyncHarness.VaultId, TestContext.Current.CancellationToken); + + internal async Task> FindCredentialAsync(Guid entityId) + { + var listing = await ListCredentialsAsync(); + + return listing.Items.SingleOrDefault(credential => credential.EntityId == entityId) + ?? throw new InvalidOperationException($"{Name} cannot see credential {entityId}."); + } + + internal Task CreateCredentialAsync(CredentialSecret credential) => + Credentials.CreateAsync(SyncHarness.VaultId, credential, TestContext.Current.CancellationToken); + + internal Task UpdateCredentialAsync(Guid entityId, CredentialSecret credential) => + Credentials.UpdateAsync( + SyncHarness.VaultId, entityId, credential, TestContext.Current.CancellationToken); + internal Task> ConflictsAsync() => Conflicts.ListAsync(SyncHarness.VaultId, false, TestContext.Current.CancellationToken); @@ -284,6 +307,14 @@ internal sealed class SyncHarness : IDisposable /// what it was used for. SshKeySecret.TryValidate only requires the armour, and the tests that /// need a key SSH.NET can actually load live in DodoSSH.Client.Ssh.Tests where one is generated. /// + /// A credential for the suites, varying only what a test is about. + internal static CredentialSecret Credential( + string label, + string password = "hunter2", + string? username = null, + string? notes = null) => + new() { Label = label, Password = password, Username = username, Notes = notes }; + internal static SshKeySecret Key( string label, string material = "deploy-key-material",