diff --git a/README.md b/README.md index 5697c6b..dccf9d1 100644 --- a/README.md +++ b/README.md @@ -456,15 +456,21 @@ phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in -Spelunk's API address, the server's id in Spelunk, and the credential that holds your Spelunk API key. A -connection then asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the SSH server on -the machine's own loopback — so the server needs no open SSH port at all. Shells and file transfers both go -this way; the relay and Spelunk are one choice, and ticking one unticks the other. +Spelunk's API address, the server's id in Spelunk, and your Spelunk API key. A connection then asks Spelunk +for a ticket, opens a WebSocket, and the agent connects to the SSH server on the machine's own loopback — so +the server needs no open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk +are one choice, and ticking one unticks the other. -- **The API key is an ordinary keychain credential** — paste the key (`spelunk_…`, with the Admin role) as a - credential's password. It is encrypted, synced and rotated like every other secret, one credential serves - every server in that Spelunk organisation, and it is sent to the host's API address and nowhere else. - The API address must be `https://`, except for a Spelunk on this machine. +- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste + the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault — + encrypted, synced to your other devices, never shown again — together with the address, and **it is only + ever sent to that address**: a host that names the key with any other address is refused at connect. One + key serves every server in that Spelunk organisation; saving a key for an address that already has one + replaces it, and every host using it connects with the new key. The host editor offers saved keys first + and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on + this machine. +- A key kept the older way, as a credential's password, still works and is still offered in the editor; + its password goes to whatever address the host names. - **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it is for every host, and the port is what the agent connects to on the server. - **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: a key Spelunk will @@ -487,14 +493,15 @@ link, or hands the link to the copy already running, and **connects straight awa carries a credential. The hosts links make live in your personal vault, tagged `spelunk`: - If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it. -- Otherwise, when a saved host already sends an API key to that same Spelunk, DodoSSH saves a new host in - your personal vault — named as the link names it, at the link's address (or `spelunk-`), port - 22, signing in through Spelunk with that same key credential, tagged `spelunk` (the tag is made if the - vault has none) — and connects to it. It syncs like any host you saved yourself. -- **The first link from a Spelunk** opens the host editor instead, filled in the same way with sign-in - ticked and the tag on, and you choose the credential holding that Spelunk's API key once and save. Every - later link from that Spelunk connects straight away. A link can be sent by anything on this machine, so - an API key is never sent to an address that no saved host already sends it to. +- Otherwise, when you have saved a Spelunk key for that Spelunk under Settings → Security — or a saved host + already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link + names it, at the link's address (or `spelunk-`), port 22, signing in through Spelunk with that + key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host + you saved yourself. +- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in + ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that + Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never + sent to an address you have not sent it to — saving the key under Security is how you say you have. A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of its own. A link that arrives while the vault is locked waits for the unlock. diff --git a/docs/crypto.md b/docs/crypto.md index 3ace35d..152316d 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order — `1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup, `8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag, -`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore. +`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore, +`17` SpelunkKey. > **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and > `HostCredential` as syncable since the contract was frozen, but this table had no value for @@ -318,6 +319,9 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order — > `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and > `AadResourceTypeTests` pins each pairing by name for exactly that reason. +> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is +> `14` on the wire, three behind, as `16` ObjectStore is `13`. + `0` means not applicable and is legal only where the table in §4.2 implies no resource. ### 4.4 What this prevents diff --git a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs index 867f83f..fe0ce67 100644 --- a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs +++ b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs @@ -73,6 +73,7 @@ internal static class ItemKinds (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(), new HostGroupKind(), new TagKind(), new SnippetKind(), new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(), + new SpelunkKeyKind(), }.ToDictionary(kind => kind.WireType); /// The kind for a wire type, or null when this server does not synchronise it yet. @@ -1093,3 +1094,98 @@ internal sealed class ObjectStoreKind : IItemKind /// public SyncPlaintextFields? Hydrate(IVaultItem item) => null; } + +/// Spelunk keys: an envelope and nothing else. +/// +/// 's rules, because it holds the same class of thing: an API key is a password, +/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk. +/// +internal sealed class SpelunkKeyKind : IItemKind +{ + /// + public SyncEntityType WireType => SyncEntityType.SpelunkKey; + + /// + public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey; + + /// + public async Task FindAsync( + DodoDbContext database, + Guid id, + CancellationToken cancellationToken) => + await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken) + .ConfigureAwait(false); + + /// + public async Task> LoadAsync( + DodoDbContext database, + Guid vaultId, + Guid[] ids, + CancellationToken cancellationToken) + { + var rows = await database.SpelunkKeys + .Where(s => s.VaultId == vaultId && ids.Contains(s.Id)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows.ToDictionary(row => row.Id, row => (IVaultItem)row); + } + + /// + public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId) + { + var key = new VaultSpelunkKey { Id = id, VaultId = vaultId }; + + database.SpelunkKeys.Add(key); + + return key; + } + + /// Refuses every plaintext field there is. + /// + /// The relay fields are refused although this type holds an address, as they are for a bucket: the + /// address belongs in the ciphertext, and the server has no business keeping a list of where its users + /// manage their servers from. + /// + /// + 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 Spelunk key is not something the server dials; its address stays encrypted."; + return false; + } + + if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null) + { + error = "A Spelunk key's contents are inside its payload."; + return false; + } + + if (fields.Kind is not null || fields.PublicKeyFingerprint is not null) + { + error = "A Spelunk key carries no plaintext fields at all."; + 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) + { + } + + /// + public SyncPlaintextFields? Hydrate(IVaultItem item) => null; +} diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index f657d4e..60afec2 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -1232,7 +1232,7 @@ PlaceholderText="Spelunk API address (https://api.…)" /> - + @@ -1250,7 +1250,7 @@ + Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Choose a Spelunk API key (Admin role) saved from the desktop's Settings → Security, or a credential whose password is the key; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." /> - + - + + + + @@ -753,7 +759,7 @@ only while Spelunk and its agent are. --> + Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." /> diff --git a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml index de290e1..7550f3d 100644 --- a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml +++ b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml @@ -1,8 +1,10 @@ + x:DataType="vm:MainWindowViewModel" + x:Name="Root"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs index c1b9af3..550a711 100644 --- a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs +++ b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs @@ -2,7 +2,9 @@ using Avalonia.Controls; namespace DodoSSH.Client.App.Views; -/// Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys. +/// +/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys. +/// internal sealed partial class SettingsSecurityPage : UserControl { public SettingsSecurityPage() => InitializeComponent(); diff --git a/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs b/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs new file mode 100644 index 0000000..cd28620 --- /dev/null +++ b/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs @@ -0,0 +1,92 @@ +using System.Diagnostics.CodeAnalysis; + +namespace DodoSSH.Client.Domain; + +/// +/// A Spelunk API key and the one Spelunk it may be sent to, decrypted. +/// +/// +/// +/// The address is what makes this an item of its own. A key kept as a credential's password can be +/// sent anywhere a host names, and a link from Spelunk can be sent by anything on the machine — so a link +/// that is not already backed by a saved host has to stop and ask. A key stored with its address says, once +/// and in the keychain, where the user agreed it may go: a link from that Spelunk connects straight away, and +/// a host naming this key with any other address is refused at connect rather than sending it there. See +/// VaultViewModel.OpenSpelunkLinkAsync. +/// +/// +/// is a password, and everything this codebase does about passwords applies to +/// it, as it does to : it is inside the encrypted payload, +/// the activity log records that it changed and never what it was, and the merge reports that it differed +/// rather than either value. +/// +/// +public sealed record SpelunkKeySecret : IVaultSecret +{ + /// The longest API key stored. + /// + /// Far beyond any real key, for the reason exists: an + /// unbounded string is the shape a corrupted payload takes, and this one goes into a request header. + /// + public const int MaxApiKeyLength = 4096; + + /// What the user calls this key. + public required string Label { get; init; } + + /// + /// The Spelunk API this key is for, as writes it, and the + /// only address it is ever sent to. + /// + /// + /// Normalised for the reason is: it is compared, byte for byte, with + /// the address a host or a link names. + /// + public required string ApiBase { get; init; } + + /// The API key itself, as Spelunk issued it. + public required string ApiKey { 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 Spelunk key needs a name."; + return false; + } + + if (string.IsNullOrWhiteSpace(ApiBase)) + { + reason = "A Spelunk key needs the address of the Spelunk it is for."; + return false; + } + + if (!SpelunkRoute.TryNormalizeApiBase(ApiBase, out var normalized, out reason)) + { + return false; + } + + if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal)) + { + reason = "The Spelunk API address is not in its normal form."; + return false; + } + + if (string.IsNullOrWhiteSpace(ApiKey)) + { + reason = "A Spelunk key needs the key."; + return false; + } + + // A header value cannot hold a line break, and a key with one would fail at the first connect with an + // HTTP error that names neither the key nor this item. + if (ApiKey.Length > MaxApiKeyLength || ApiKey.Any(char.IsControl)) + { + reason = "That does not look like a Spelunk API key."; + return false; + } + + reason = null; + return true; + } +} diff --git a/src/DodoSSH.Client.Domain/SpelunkKeySecretCodec.cs b/src/DodoSSH.Client.Domain/SpelunkKeySecretCodec.cs new file mode 100644 index 0000000..7999a0a --- /dev/null +++ b/src/DodoSSH.Client.Domain/SpelunkKeySecretCodec.cs @@ -0,0 +1,112 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace DodoSSH.Client.Domain; + +/// A decoded Spelunk key payload, together with the schema version it was written at. +/// The key. +/// The version the writing client used. +public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion) +{ + /// + public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion; +} + +/// +/// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload. +/// +/// +/// Mirrors , for the same reasons and with the same guarantees. +/// +public static class SpelunkKeySecretCodec +{ + /// The schema version this build writes. + public const int CurrentSchemaVersion = 1; + + /// Serialises a key to the bytes that get sealed. + /// The key is not valid for storage. + public static byte[] Encode(SpelunkKeySecret key) + { + ArgumentNullException.ThrowIfNull(key); + + if (!key.TryValidate(out var reason)) + { + throw new ArgumentException(reason, nameof(key)); + } + + var document = new SpelunkKeyPayloadDocument + { + SchemaVersion = CurrentSchemaVersion, + Label = key.Label, + ApiBase = key.ApiBase, + ApiKey = key.ApiKey, + }; + + return JsonSerializer.SerializeToUtf8Bytes( + document, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument); + } + + /// Parses a decrypted payload. + /// + public static bool TryDecode( + ReadOnlySpan payload, + [NotNullWhen(true)] out SpelunkKeySecretDocument? document) + { + document = null; + + SpelunkKeyPayloadDocument? parsed; + try + { + parsed = JsonSerializer.Deserialize( + payload, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument); + } + catch (JsonException) + { + return false; + } + + if (parsed is null || parsed.SchemaVersion < 1) + { + return false; + } + + var candidate = new SpelunkKeySecret + { + Label = parsed.Label ?? string.Empty, + ApiBase = parsed.ApiBase ?? string.Empty, + ApiKey = parsed.ApiKey ?? string.Empty, + }; + + // A key whose address is not in its normal form is refused rather than normalised here: the address + // is what decides where the key may go, and a payload that disagrees with how every client writes it + // is not one to guess about. + if (!candidate.TryValidate(out _)) + { + return false; + } + + document = new SpelunkKeySecretDocument(candidate, parsed.SchemaVersion); + return true; + } +} + +/// The serialised shape. Mutable and nullable because it models untrusted input. +/// +internal sealed class SpelunkKeyPayloadDocument +{ + public int SchemaVersion { get; set; } + + public string? Label { get; set; } + + public string? ApiBase { get; set; } + + public string? ApiKey { get; set; } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +[JsonSerializable(typeof(SpelunkKeyPayloadDocument))] +internal sealed partial class SpelunkKeyPayloadJsonContext : JsonSerializerContext; diff --git a/src/DodoSSH.Client.Domain/SpelunkKeySecretMerge.cs b/src/DodoSSH.Client.Domain/SpelunkKeySecretMerge.cs new file mode 100644 index 0000000..6fe8d40 --- /dev/null +++ b/src/DodoSSH.Client.Domain/SpelunkKeySecretMerge.cs @@ -0,0 +1,89 @@ +namespace DodoSSH.Client.Domain; + +/// The merged Spelunk key, and everything that had to be overridden to produce it. +/// The key to store and push. +/// Empty when the two sides were reconcilable field by field. +public sealed record SpelunkKeyMergeResult( + SpelunkKeySecret Merged, + IReadOnlyList Conflicts) +{ + /// Whether anything had to be overridden. + public bool HasConflicts => Conflicts.Count > 0; +} + +/// +/// Merges two divergent versions of a Spelunk key against the version they both started from. +/// +/// +/// +/// The address and the key merge as one value, not two. The pair is the whole meaning of this item — +/// "this key may go to this Spelunk" — and merging them apart could keep one device's new address beside the +/// other device's new key, sending a key to a Spelunk nobody ever paired it with. So when both sides changed +/// the pair, one side's pair is kept whole. +/// +/// +/// The key never reaches the conflict log, for the reason a secret access key does not: a discarded +/// one is very often still live. The addresses are shown, because which Spelunk the kept key goes to is the +/// whole content of the notice. +/// +/// +public static class SpelunkKeySecretMerge +{ + /// The name a conflict over the address and key is recorded under. + public const string PairField = "ApiBaseAndKey"; + + /// Produces the merged key. + /// The version both sides branched from. + /// The pending local version. + /// The server's current version. + public static SpelunkKeyMergeResult Merge( + SpelunkKeySecret ancestor, + SpelunkKeySecret local, + SpelunkKeySecret remote) + { + ArgumentNullException.ThrowIfNull(ancestor); + ArgumentNullException.ThrowIfNull(local); + ArgumentNullException.ThrowIfNull(remote); + + var conflicts = new List(); + + var label = ThreeWayMerge.Scalar(ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal); + + if (label.IsConflicted) + { + conflicts.Add(new HostFieldConflict( + nameof(SpelunkKeySecret.Label), + MergeSide.Local, + label.Value, + label.Discarded ?? "(none)", + DiscardedWasRemoval: false)); + } + + // A tuple of two strings compares both ordinally, which is the comparison each field needs. + var pair = ThreeWayMerge.Scalar( + (ancestor.ApiBase, ancestor.ApiKey), + (local.ApiBase, local.ApiKey), + (remote.ApiBase, remote.ApiKey)); + + if (pair.IsConflicted) + { + conflicts.Add(new HostFieldConflict( + PairField, + MergeSide.Local, + $"{pair.Value.ApiBase} (with its key)", + string.Equals(pair.Discarded.ApiBase, pair.Value.ApiBase, StringComparison.Ordinal) + ? "(a different key for the same address was discarded)" + : $"{pair.Discarded.ApiBase} (with its key)", + DiscardedWasRemoval: false)); + } + + var merged = new SpelunkKeySecret + { + Label = label.Value, + ApiBase = pair.Value.ApiBase, + ApiKey = pair.Value.ApiKey, + }; + + return new SpelunkKeyMergeResult(merged, conflicts); + } +} diff --git a/src/DodoSSH.Client.Domain/SpelunkRoute.cs b/src/DodoSSH.Client.Domain/SpelunkRoute.cs index a32810c..5b56e1a 100644 --- a/src/DodoSSH.Client.Domain/SpelunkRoute.cs +++ b/src/DodoSSH.Client.Domain/SpelunkRoute.cs @@ -19,11 +19,11 @@ namespace DodoSSH.Client.Domain; /// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this. /// /// -/// The API key is referenced, not embedded. names an ordinary -/// keychain credential whose password is the key, for the reason -/// gives: one Spelunk key is the key for every server in its organisation, and a copy per host is twenty -/// places to rotate it and one to forget. It also means the key is encrypted, synced, merged and shown in -/// the keychain exactly as a password is, with no item type of its own to build and keep in step. +/// The API key is referenced, not embedded. names the keychain item +/// holding it, for the reason gives: one Spelunk key is the key for +/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget. +/// That item is a , which also says the one address the key may go to, or — +/// for a host set up before those existed — an ordinary credential whose password is the key. /// /// public sealed record SpelunkRoute @@ -50,12 +50,22 @@ public sealed record SpelunkRoute /// The server's id in Spelunk. public required Guid ServerId { get; init; } - /// The keychain credential whose password is the Spelunk API key. + /// + /// The keychain item holding the Spelunk API key: a , or a credential whose + /// password is the key. + /// /// + /// + /// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so + /// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose + /// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage. + /// + /// /// Required rather than nullable. A route with no key can never be opened, and a host saved that way /// would refuse every connection with a sentence about something the editor let through. The reference /// can still dangle — the credential may be deleted on another machine — and that is refused where it /// is noticed, at connect, exactly as a missing SSH credential is. + /// /// public required Guid ApiKeyCredentialId { get; init; } @@ -113,7 +123,7 @@ public sealed record SpelunkRoute if (ApiKeyCredentialId == Guid.Empty) { - reason = "A host reached through Spelunk needs a credential holding its Spelunk API key."; + reason = "A host reached through Spelunk needs its Spelunk API key chosen."; return false; } diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs index c486394..142cbac 100644 --- a/src/DodoSSH.Client.Session/VaultSession.cs +++ b/src/DodoSSH.Client.Session/VaultSession.cs @@ -125,6 +125,7 @@ public sealed partial class VaultSession : IAsyncDisposable Tags = new TagRepository(Items, Outbox, keyring, activity); Snippets = new SnippetRepository(Items, Outbox, keyring, activity); ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity); + SpelunkKeys = new SpelunkKeyRepository(Items, Outbox, keyring, activity); } /// Who this session belongs to, and the material that unlocked it. @@ -200,6 +201,13 @@ public sealed partial class VaultSession : IAsyncDisposable /// public ObjectStoreRepository ObjectStores { get; } + /// Spelunk API keys and the addresses they may be sent to, decrypted. + /// + /// Read with the rest of the vault, and at connect from that list, as a credential's password is. The + /// address in each is what lets a link from Spelunk connect without asking. + /// + public SpelunkKeyRepository SpelunkKeys { get; } + /// The connections this vault has recorded, decrypted. /// /// Written through rather than directly by anything that connects. An diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs index 87dcc5b..47eca71 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs @@ -62,11 +62,11 @@ internal sealed partial class VaultViewModel [ObservableProperty] private string editorSpelunkServerId = string.Empty; - /// The credentials the editor offers as the Spelunk API key. + /// What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials. /// - /// Credentials only, and no "none" entry: a route with no key cannot be opened, and nothing selected is - /// how a host made from a link says it has not been chosen yet. A key the host names that has since been - /// deleted keeps a placeholder, for the reason keeps one. + /// No "none" entry: a route with no key cannot be opened, and nothing selected is how a host made from a + /// link says it has not been chosen yet. A key the host names that has since been deleted keeps a + /// placeholder, for the reason keeps one. /// internal ObservableCollection EditorSpelunkKeyChoices { get; } = []; @@ -170,13 +170,13 @@ internal sealed partial class VaultViewModel } /// - /// Opens a Spelunk tunnel to a host, with the key its credential holds. + /// Opens a Spelunk tunnel to a host, with the key it names. /// /// /// /// The key is read from the keychain at the moment of connecting, like every other secret the connect - /// path uses, and goes only to the API address the host names. A credential that has gone is refused in - /// words, exactly as a missing SSH credential is — see . + /// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when + /// that is the address it was saved for. See . /// /// /// The port is the host's resolved one, group and all: it is the port the agent connects to on the @@ -194,18 +194,13 @@ internal sealed partial class VaultViewModel SpelunkRoute route, CancellationToken cancellationToken) { - if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is not { } key) - { - throw new SpelunkTunnelException( - $"'{row.Label}' is reached through Spelunk with an API key credential that is not in this " - + "keychain any more. Edit the host to choose another one."); - } + var apiKey = SpelunkApiKeyFor(row, route); return await Spelunk .OpenAsync( route.ApiBase, route.ServerId, - key.Credential.Password, + apiKey, row.Resolved.Port.Value, route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null, cancellationToken) @@ -227,16 +222,17 @@ internal sealed partial class VaultViewModel /// /// Otherwise, when that Spelunk already gets an API key, the host is made and connected to. In the /// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no - /// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is - /// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already - /// goes, and the server is one that key's own organisation holds or Spelunk refuses it. + /// SSH key or password — with the key saved for that Spelunk under Settings → Security, or else the key + /// credential a saved host already sends there. It is saved the ordinary way and syncs like any host. + /// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that + /// key's own organisation holds or Spelunk refuses it. /// /// - /// The first link from a Spelunk opens the editor instead, and nothing more. Anything on this - /// machine can make a link, so it must never be able to send an API key to an address no saved host - /// already sends it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal - /// vault — and the user chooses the credential holding the key and saves; every later link from that - /// Spelunk connects straight away. + /// The first link from a Spelunk with no key opens the editor instead, and nothing more. Anything + /// on this machine can make a link, so it must never be able to send an API key to an address the user + /// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal + /// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight + /// away. /// /// internal async Task OpenSpelunkLinkAsync(SpelunkLink link) @@ -278,20 +274,29 @@ internal sealed partial class VaultViewModel string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase); /// - /// The credential a saved host already sends to this Spelunk as its API key, or null when none does. + /// The key the user has already agreed may go to this Spelunk, or null when there is none. /// /// - /// The personal vault's hosts first, so the answer does not depend on the order vaults happen to load in; - /// and only a credential still in the keychain, because a dangling one would make a host that cannot - /// connect. + /// + /// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and + /// otherwise the key a saved host already sends there, which is how the answer was reached before saved + /// keys existed and is still true of every host set up that way. + /// + /// + /// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and + /// only a key still in the keychain, because a dangling one would make a host that cannot connect. + /// /// private Guid? KnownSpelunkKey(string apiBase, Guid? personal) => - Hosts + SavedSpelunkKeyFor(apiBase, personal)?.EntityId + ?? Hosts .OrderByDescending(row => row.VaultId == personal) .Select(row => row.Host.Spelunk) .Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal)) .Select(route => (Guid?)route!.ApiKeyCredentialId) - .FirstOrDefault(id => Credentials.Any(credential => credential.EntityId == id)); + .FirstOrDefault(id => + Credentials.Any(credential => credential.EntityId == id) + || SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal))); /// The host a link makes: what the link says, signing in through Spelunk with a known key. private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new() @@ -390,9 +395,9 @@ internal sealed partial class VaultViewModel IsHostPaneOpen = true; var said = EditorSelectedSpelunkKey is null - ? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose the credential " - + "holding your Spelunk API key and save — Spelunk signs you in, so no SSH key is needed, and " - + "later links from this Spelunk connect straight away." + ? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose your Spelunk API " + + "key and save — Spelunk signs you in, so no SSH key is needed, and later links from this " + + "Spelunk connect straight away. Saving the key under Settings → Security does the same." : $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save."; Status = said; @@ -484,10 +489,20 @@ internal sealed partial class VaultViewModel } /// Fills the API key picker, keeping whatever the host names selectable. + /// + /// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made + /// for exactly this; then every credential, for a key kept as a password before saved keys existed. + /// private void BuildSpelunkKeyChoices(Guid? selectedId) { EditorSpelunkKeyChoices.Clear(); + foreach (var key in SpelunkKeys) + { + EditorSpelunkKeyChoices.Add(new AuthenticationChoice( + AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}")); + } + foreach (var credential in Credentials) { EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label)); diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs new file mode 100644 index 0000000..0bcd7b7 --- /dev/null +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs @@ -0,0 +1,374 @@ +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DodoSSH.Client.Domain; +using DodoSSH.Client.Session; +using DodoSSH.Client.Sync; + +namespace DodoSSH.Client.Shell.ViewModels; + +/// One saved Spelunk key, as a row on the Security page. +/// +/// Carries the decrypted key so the connect path needs no second decryption, and — as the credential row does +/// — exposes nothing of it to the view: the XAML binds the name, the address and a count. +/// +internal sealed class SpelunkKeyRowViewModel(VaultItem key, Guid vaultId, string vaultName) +{ + internal Guid EntityId => key.EntityId; + + internal Guid VaultId { get; } = vaultId; + + internal string VaultName { get; } = vaultName; + + internal SpelunkKeySecret Key => key.Secret; + + internal string Label => key.Secret.Label; + + /// The one address this key is ever sent to. + internal string ApiBase => key.Secret.ApiBase; + + /// The vault's name, when there is more than one to tell apart; empty otherwise. + internal string VaultBadge { get; init; } = string.Empty; + + internal bool HasVaultBadge => VaultBadge.Length > 0; + + /// How many hosts reach Spelunk with this key, in words. + internal string UsedBy { get; init; } = string.Empty; + + internal bool IsReadOnly => key.IsReadOnly; + + internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges); +} + +/// +/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do. +/// +/// +/// +/// A saved key says where it may go. It is an API key and the one Spelunk address it belongs to, kept +/// in the keychain and synced like any secret. A host can name it where it would name a credential, and it is +/// then sent to that address and never to another — see . A link from that +/// Spelunk connects straight away, because saving the key is the user saying so — see +/// . +/// +/// +/// The form saves into the personal vault, and a second key for an address the personal vault already has a +/// key for replaces that key rather than adding a rival: one Spelunk, one key, which is also what makes the +/// answer to "which key does a link use?" not depend on anything the user cannot see. +/// +/// +internal sealed partial class VaultViewModel +{ + /// Every Spelunk key this session can read, the personal vault's first. + internal ObservableCollection SpelunkKeys { get; } = []; + + /// Whether there is any saved key to list. + internal bool HasSpelunkKeys => SpelunkKeys.Count > 0; + + /// The Spelunk API address in the Security page's form, as typed. + [ObservableProperty] + private string spelunkKeyFormApi = string.Empty; + + /// The API key in the Security page's form, as pasted. Cleared the moment it is saved. + [ObservableProperty] + private string spelunkKeyFormKey = string.Empty; + + /// What the form has to say about the last save, or empty. + /// + /// Beside the form rather than only on the status line, because the Security page is a long way from the + /// status bar and a refusal nobody sees reads as a button that does nothing. + /// + [ObservableProperty] + private string spelunkKeyFormNote = string.Empty; + + /// Whether has anything in it. + internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0; + + partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote)); + + /// + /// Picks the saved key for the address being typed, when nothing is chosen yet. + /// + /// + /// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the + /// address would be choosing for them. + /// + partial void OnEditorSpelunkApiChanged(string value) + { + if (EditorSelectedSpelunkKey is not null + || !SpelunkRoute.TryNormalizeApiBase(value, out var apiBase, out _) + || SavedSpelunkKeyFor(apiBase, PersonalVaultId()) is not { } saved) + { + return; + } + + EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == saved.EntityId); + } + + /// Saves the key in the form for the address in the form. + [RelayCommand] + private async Task SaveSpelunkKeyAsync(CancellationToken cancellationToken) + { + if (!TryReadSpelunkKeyForm(out var vaultId, out var existing, out var key)) + { + return; + } + + var apiBase = key.ApiBase; + + SpelunkKeyFormNote = string.Empty; + + await RunAsync( + "Saving the Spelunk key…", + async () => + { + if (existing is not null) + { + await session.SpelunkKeys + .UpdateAsync(vaultId, existing.EntityId, key, cancellationToken) + .ConfigureAwait(true); + } + else + { + await session.SpelunkKeys.CreateAsync(vaultId, key, cancellationToken).ConfigureAwait(true); + } + + // The key leaves the form as soon as it is in the keychain; the address stays, as a reminder + // of what was just saved. + SpelunkKeyFormKey = string.Empty; + SpelunkKeyFormApi = apiBase; + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + + var said = existing is null + ? $"Saved the Spelunk key for {apiBase}. Links from that Spelunk now connect straight away." + : $"Replaced the Spelunk key for {apiBase}. Every host using it connects with the new one."; + + Status = connection() is null ? $"{said} It will sync when you are online." : said; + SpelunkKeyFormNote = said; + }).ConfigureAwait(true); + + await AutoSyncAsync(cancellationToken).ConfigureAwait(true); + } + + /// + /// Reads the form into the key it would save, and where; or says on the form why it cannot. + /// + /// The vault the key goes into: the personal one, when there is one. + /// The key already saved there for that address, which this one replaces. + /// The key to save, keeping the existing one's name. + private bool TryReadSpelunkKeyForm( + out Guid vaultId, + out SpelunkKeyRowViewModel? existing, + [NotNullWhen(true)] out SpelunkKeySecret? key) + { + vaultId = Guid.Empty; + existing = null; + key = null; + + if (!SpelunkRoute.TryNormalizeApiBase(SpelunkKeyFormApi, out var apiBase, out var reason)) + { + SpelunkKeyFormNote = reason; + return false; + } + + if ((PersonalVaultId() ?? WritableActiveVault()) is not { } target) + { + SpelunkKeyFormNote = "There is no vault here this account can write to, so the key cannot be saved."; + return false; + } + + vaultId = target; + existing = SpelunkKeys.FirstOrDefault(row => + row.VaultId == target && string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal)); + + var candidate = new SpelunkKeySecret + { + Label = existing?.Label ?? SpelunkKeyLabel(apiBase), + ApiBase = apiBase, + ApiKey = SpelunkKeyFormKey.Trim(), + }; + + if (!candidate.TryValidate(out reason)) + { + SpelunkKeyFormNote = reason; + return false; + } + + key = candidate; + return true; + } + + /// Asks whether a saved Spelunk key should go. + [RelayCommand] + private void DeleteSpelunkKeyRow(SpelunkKeyRowViewModel? row) + { + if (row is null) + { + return; + } + + PendingDeletion = new DeletionRequest( + DeletionTarget.SpelunkKey, + row.EntityId, + $"Delete the Spelunk key for {row.ApiBase}?", + HowFarADeletionGoes("The key"), + HostsReachingSpelunkWith(row.EntityId)); + } + + /// Queues a tombstone for the Spelunk key that was agreed to. + private async Task DeleteSpelunkKeyNowAsync(Guid entityId, CancellationToken cancellationToken) + { + if (SpelunkKeys.FirstOrDefault(row => row.EntityId == entityId) is not { } row) + { + Status = "That Spelunk key is no longer here, so nothing was deleted."; + return; + } + + await RunAsync( + "Deleting…", + async () => + { + await session.SpelunkKeys + .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) + .ConfigureAwait(true); + + await ReloadAsync(cancellationToken).ConfigureAwait(true); + Status = $"Deleted the Spelunk key for {row.ApiBase}."; + }).ConfigureAwait(true); + + await AutoSyncAsync(cancellationToken).ConfigureAwait(true); + } + + /// How many Spelunk keys would not decrypt. + /// + /// Read after the hosts, because each row says how many of them use it. + /// + private async Task ReloadSpelunkKeysAsync(CancellationToken cancellationToken) + { + var unreadable = 0; + var rows = new List(); + + var readableVaults = session.ReadableVaults.ToList(); + var several = readableVaults.Count > 1; + + foreach (var vault in readableVaults) + { + var listing = await session.SpelunkKeys + .ListAsync(vault.VaultId, cancellationToken) + .ConfigureAwait(true); + + unreadable += listing.Unreadable; + + rows.AddRange(listing.Items.Select(item => new SpelunkKeyRowViewModel(item, vault.VaultId, vault.Name) + { + VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty, + UsedBy = UsedBySummary(Hosts.Count(host => host.Host.Spelunk?.ApiKeyCredentialId == item.EntityId)), + })); + } + + var personal = PersonalVaultId(); + + SpelunkKeys.Clear(); + + foreach (var row in rows + .OrderByDescending(row => row.VaultId == personal) + .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) + .ThenBy(row => row.ApiBase, StringComparer.Ordinal)) + { + SpelunkKeys.Add(row); + } + + OnPropertyChanged(nameof(HasSpelunkKeys)); + + return unreadable; + } + + /// + /// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password. + /// + /// + /// + /// A saved Spelunk key goes to its own address or nowhere. A host naming one with a different + /// address — edited by hand, or merged from two devices — is refused in words rather than sending the key + /// to a Spelunk it was never saved for. A credential carries no address, so its password goes where the + /// host says, as it always has. + /// + /// + /// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential + /// is — see . + /// + /// + /// The key cannot be sent to this host's Spelunk. + private string SpelunkApiKeyFor(HostRowViewModel row, SpelunkRoute route) + { + if (SpelunkKeys.FirstOrDefault(key => key.EntityId == route.ApiKeyCredentialId) is { } saved) + { + if (!string.Equals(saved.ApiBase, route.ApiBase, StringComparison.Ordinal)) + { + throw new SpelunkTunnelException( + $"'{row.Label}' is reached through {route.ApiBase} with the Spelunk key saved for " + + $"{saved.ApiBase}, and that key is never sent anywhere else. Edit the host to choose " + + "the key for its own Spelunk."); + } + + return saved.Key.ApiKey; + } + + if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is { } credential) + { + return credential.Credential.Password; + } + + throw new SpelunkTunnelException( + $"'{row.Label}' is reached through Spelunk with an API key that is not in this keychain any more. " + + "Edit the host to choose another one, or save the key under Settings → Security."); + } + + /// The saved key for a Spelunk, the given vault's first, or null when none is saved. + private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) => + SpelunkKeys + .Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal)) + .OrderByDescending(row => row.VaultId == preferredVault) + .FirstOrDefault(); + + /// The active vault when this session may write to it, for a session with no personal vault. + private Guid? WritableActiveVault() => + session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite) + ?.VaultId; + + /// The name a key saved from the form is given: where it goes. + private static string SpelunkKeyLabel(string apiBase) => + Uri.TryCreate(apiBase, UriKind.Absolute, out var uri) ? $"Spelunk at {uri.Authority}" : "Spelunk"; + + private static string UsedBySummary(int hosts) => hosts switch + { + 0 => "No host uses it yet", + 1 => "1 host uses it", + _ => $"{hosts} hosts use it", + }; + + /// What the hosts that reach Spelunk with a key would be left with, or nothing when none do. + private string HostsReachingSpelunkWith(Guid entityId) + { + var named = Hosts + .Where(row => row.Host.Spelunk?.ApiKeyCredentialId == entityId) + .Select(row => row.Label) + .ToArray(); + + if (named.Length == 0) + { + return string.Empty; + } + + var list = named.Length <= 3 + ? string.Join(", ", named) + : $"{string.Join(", ", named.Take(3))} and {named.Length - 3} more"; + + return named.Length == 1 + ? $"{list} reaches Spelunk with it, and will refuse to connect until it names another key." + : $"{named.Length} hosts reach Spelunk with it — {list} — and will refuse to connect until they " + + "name another key."; + } +} diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index ccb4111..97f226e 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -1343,6 +1343,9 @@ internal enum DeletionTarget /// A tag, from the vault screen. Tag, + + /// A saved Spelunk key, from the Security page. + SpelunkKey, } /// @@ -4548,6 +4551,7 @@ internal sealed partial class VaultViewModel( unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true); + unreadable += await ReloadSpelunkKeysAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true); // Last, because it reads the host list to work out which pins nothing dials any more. @@ -11023,6 +11027,10 @@ internal sealed partial class VaultViewModel( await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); break; + case DeletionTarget.SpelunkKey: + await DeleteSpelunkKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); + break; + default: break; } diff --git a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs index d9352a0..c23f630 100644 --- a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs +++ b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs @@ -141,6 +141,7 @@ internal static class AadResourceTypes SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry, SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry, SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore, + SyncEntityType.SpelunkKey => CryptoSpec.AadResourceType.SpelunkKey, _ => throw new ArgumentOutOfRangeException( nameof(entityType), entityType, "No AAD resource type is defined for this entity type."), diff --git a/src/DodoSSH.Client.Sync/ItemKinds.cs b/src/DodoSSH.Client.Sync/ItemKinds.cs index 9400082..575c1b1 100644 --- a/src/DodoSSH.Client.Sync/ItemKinds.cs +++ b/src/DodoSSH.Client.Sync/ItemKinds.cs @@ -170,6 +170,9 @@ internal static class ItemKinds (SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) => new ItemReconciler(ObjectStoreKind.Instance, outbox, conflicts, keyring)), + + (SyncEntityType.SpelunkKey, static (outbox, conflicts, keyring) => + new ItemReconciler(SpelunkKeyKind.Instance, outbox, conflicts, keyring)), ]; /// The types to ask the server for, in a fixed order. @@ -1058,3 +1061,82 @@ internal sealed class ObjectStoreKind : IItemKind return secret with { Label = label }; } } + +/// Spelunk keys. +internal sealed class SpelunkKeyKind : IItemKind +{ + internal static SpelunkKeyKind Instance { get; } = new(); + + /// + public SyncEntityType EntityType => SyncEntityType.SpelunkKey; + + /// + public string Noun => "Spelunk key"; + + /// + public OpenedItem? TryOpen( + EncryptedPayload payload, + ReadOnlySpan vaultKey, + Guid entityId, + int itemVersion) + { + var document = SpelunkKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion); + + return document is null + ? null + : new OpenedItem(document.Key, document.IsReadOnly); + } + + /// + public EncryptedPayload Seal( + SpelunkKeySecret secret, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) => + SpelunkKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion); + + /// + /// Nothing. The address is the only candidate and must not go: nothing on the server calls Spelunk. + /// + /// + public SyncPlaintextFields? Fields(SpelunkKeySecret secret) => null; + + /// + public MergedItem Merge( + SpelunkKeySecret ancestor, + SpelunkKeySecret local, + SpelunkKeySecret remote) + { + var merged = SpelunkKeySecretMerge.Merge(ancestor, local, remote); + + return new MergedItem(merged.Merged, merged.Conflicts); + } + + /// + /// The key is compared and never reported by value, which is the rule every credential-like field in this + /// file follows. The address is shown: it is where the key goes, and a change to it is worth reading. + /// + /// + public IReadOnlyList Changes(SpelunkKeySecret before, SpelunkKeySecret after) + { + ArgumentNullException.ThrowIfNull(before); + ArgumentNullException.ThrowIfNull(after); + + var changed = new List(); + + Note(changed, "Name", before.Label, after.Label); + Note(changed, "Spelunk address", before.ApiBase, after.ApiBase); + Note(changed, "API key", before.ApiKey, after.ApiKey); + + return changed; + } + + /// + public SpelunkKeySecret Relabel(SpelunkKeySecret secret, string label) + { + ArgumentNullException.ThrowIfNull(secret); + + return secret with { Label = label }; + } +} diff --git a/src/DodoSSH.Client.Sync/SpelunkKeyCipher.cs b/src/DodoSSH.Client.Sync/SpelunkKeyCipher.cs new file mode 100644 index 0000000..7ce6ce5 --- /dev/null +++ b/src/DodoSSH.Client.Sync/SpelunkKeyCipher.cs @@ -0,0 +1,125 @@ +using System.Security.Cryptography; +using DodoSSH.Client.Domain; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Sync; + +/// +/// Turns a Spelunk key into an item payload and back. +/// +/// +/// +/// Mirrors exactly, including the version rule in . +/// +/// +/// The resource type is named, never cast. SyncEntityType.SpelunkKey is 14 and +/// CryptoSpec.AadResourceType.SpelunkKey is 17; 14 in the crypto enum is a connection log entry, and a +/// cast would seal an API key under that — which decrypts perfectly on the machine that wrote it and violates +/// docs/crypto.md everywhere else. +/// +/// +public static class SpelunkKeyCipher +{ + private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.SpelunkKey; + + /// Encrypts a Spelunk key. + /// The key. 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( + SpelunkKeySecret key, + ReadOnlySpan vaultKey, + Guid entityId, + uint keyGeneration, + int itemVersion) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1); + + var plaintext = SpelunkKeySecretCodec.Encode(key); + 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); + + // It holds an API key, which is live on the Spelunk it belongs to. + CryptographicOperations.ZeroMemory(plaintext); + } + } + + /// Decrypts a Spelunk key. + /// + public static SpelunkKeySecretDocument? 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 SpelunkKeySecretCodec.TryDecode(plaintext, out var document) ? document : null; + } + finally + { + CryptographicOperations.ZeroMemory(plaintext); + } + } + finally + { + CryptographicOperations.ZeroMemory(dataKey); + } + } +} diff --git a/src/DodoSSH.Client.Sync/SpelunkKeyRepository.cs b/src/DodoSSH.Client.Sync/SpelunkKeyRepository.cs new file mode 100644 index 0000000..32e83b9 --- /dev/null +++ b/src/DodoSSH.Client.Sync/SpelunkKeyRepository.cs @@ -0,0 +1,46 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; + +namespace DodoSSH.Client.Sync; + +/// +/// The Spelunk keys in this vault, decrypted, with unpushed local changes laid over them. +/// +/// +/// Another facade over the same generic repository. A key is read when the vault is listed and again at +/// connect, from the list, like a credential's password. +/// +public sealed class SpelunkKeyRepository( + ItemStore items, + OutboxStore outbox, + VaultKeyring keyring, + IActivityLogSink? activity = null) +{ + private readonly VaultItemRepository keys = + new(SpelunkKeyKind.Instance, items, outbox, keyring, activity); + + /// + public Task> ListAsync( + Guid vaultId, + CancellationToken cancellationToken) => + keys.ListAsync(vaultId, cancellationToken); + + /// + public Task CreateAsync( + Guid vaultId, + SpelunkKeySecret key, + CancellationToken cancellationToken) => + keys.CreateAsync(vaultId, key, cancellationToken); + + /// + public Task UpdateAsync( + Guid vaultId, + Guid entityId, + SpelunkKeySecret key, + CancellationToken cancellationToken) => + keys.UpdateAsync(vaultId, entityId, key, cancellationToken); + + /// + public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) => + keys.DeleteAsync(vaultId, entityId, cancellationToken); +} diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt index 3ce892e..41b4fe6 100644 --- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -469,6 +469,7 @@ DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEnti DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType +DodoSSH.Contracts.SyncEntityType.SpelunkKey = 14 -> DodoSSH.Contracts.SyncEntityType DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType DodoSSH.Contracts.SyncEntityType.Tag = 5 -> DodoSSH.Contracts.SyncEntityType DodoSSH.Contracts.SyncEntityType.Unspecified = 0 -> DodoSSH.Contracts.SyncEntityType diff --git a/src/DodoSSH.Contracts/SyncEntityType.cs b/src/DodoSSH.Contracts/SyncEntityType.cs index a1e4e3f..dde12cf 100644 --- a/src/DodoSSH.Contracts/SyncEntityType.cs +++ b/src/DodoSSH.Contracts/SyncEntityType.cs @@ -66,4 +66,14 @@ public enum SyncEntityType /// the vendor. The interface says S3, which is what people call the protocol. /// ObjectStore = 13, + + /// + /// A Spelunk API key, and the one Spelunk address it may be sent to. + /// + /// + /// An item of its own rather than a credential's password, because the address is the point: a link from + /// Spelunk can be sent by anything on the machine, so a key may only go where the user said it goes, and + /// only an item that carries both can say so. See SpelunkKeySecret. + /// + SpelunkKey = 14, } diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs index 42e3472..1f4452d 100644 --- a/src/DodoSSH.Crypto/CryptoSpec.cs +++ b/src/DodoSSH.Crypto/CryptoSpec.cs @@ -173,6 +173,9 @@ public static class CryptoSpec /// An S3-compatible bucket, and the credentials that reach it. ObjectStore = 16, + + /// A Spelunk API key, and the one Spelunk address it may be sent to. + SpelunkKey = 17, } /// HKDF info labels. Domain-separated so one subkey cannot stand in for another. diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs index 777bc88..0bef840 100644 --- a/src/DodoSSH.Domain/Hosts.cs +++ b/src/DodoSSH.Domain/Hosts.cs @@ -556,6 +556,62 @@ public sealed class VaultObjectStore : IVaultItem public Guid UpdatedByUserId { get; set; } } +/// +/// A Spelunk API key and the Spelunk address it may be sent to, as ciphertext. +/// +/// +/// 's shape, for the same reason: the key is a password, and the address beside +/// it is where the user's servers are managed. Nothing on the server calls Spelunk, so there is no plaintext +/// column and no argument for one. +/// +public sealed class VaultSpelunkKey : IVaultItem +{ + /// Primary key. UUIDv7, generated by the client so a key can be added offline. + public Guid Id { get; set; } + + /// Owning vault. + public Guid VaultId { get; set; } + + /// Owning vault. + public Vault? Vault { get; set; } + + /// The encrypted key and its address: 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 key went away. + public DateTimeOffset? DeletedAtUtc { get; set; } + + /// Who created it. + public Guid CreatedByUserId { get; set; } + + /// Who last modified it. + public Guid UpdatedByUserId { get; set; } +} + /// /// One connection that was made, as ciphertext. /// diff --git a/src/DodoSSH.Domain/SyncLog.cs b/src/DodoSSH.Domain/SyncLog.cs index 4bb9ef5..208fb7b 100644 --- a/src/DodoSSH.Domain/SyncLog.cs +++ b/src/DodoSSH.Domain/SyncLog.cs @@ -57,6 +57,9 @@ public enum ChangeEntityType /// An S3-compatible bucket, and the credentials that reach it. M2. ObjectStore = 13, + + /// A Spelunk API key, and the one Spelunk address it may be sent to. + SpelunkKey = 14, } /// diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs index df208d9..f50b253 100644 --- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs +++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs @@ -384,6 +384,41 @@ public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration +/// Maps . +/// +/// +/// again: a key and the address it goes to, neither of which belongs +/// in a column. +/// +public sealed class SpelunkKeyConfiguration : IEntityTypeConfiguration +{ + /// + public void Configure(EntityTypeBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + builder.ToTable("spelunk_key"); + builder.HasKey(s => s.Id); + + // Client-generated UUIDv7: a key must be addable offline, with its id. + builder.Property(s => s.Id).ValueGeneratedNever(); + builder.UseXminConcurrencyToken(); + + builder.Property(s => s.Payload).IsRequired(); + + builder.HasIndex(s => new { s.VaultId, s.ChangeSequence }); + + builder.HasIndex(s => s.VaultId) + .HasFilter("deleted_at_utc IS NULL") + .HasDatabaseName("ix_spelunk_key_vault_live"); + + builder.ToTable(t => t.HasCheckConstraint( + "ck_spelunk_key_version", + "version >= 1")); + } +} + /// Maps . public sealed class SyncChangeConfiguration : IEntityTypeConfiguration { diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs index 5be6bc0..5671e1d 100644 --- a/src/DodoSSH.Infrastructure/DodoDbContext.cs +++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs @@ -81,6 +81,9 @@ public class DodoDbContext(DbContextOptions options) : DbContext( /// S3-compatible buckets and their credentials, held as ciphertext. public DbSet ObjectStores => Set(); + /// Spelunk API keys and the addresses they may be sent to, held as ciphertext. + public DbSet SpelunkKeys => Set(); + /// The per-vault change log that delta sync reads. public DbSet VaultChanges => Set(); diff --git a/src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.Designer.cs new file mode 100644 index 0000000..3006d1d --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.Designer.cs @@ -0,0 +1,2000 @@ +// +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("20260929073947_AddSpelunkKeyItem")] + partial class AddSpelunkKeyItem + { + /// + 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.RelaySession", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("BytesClientToTarget") + .HasColumnType("bigint") + .HasColumnName("bytes_client_to_target"); + + b.Property("BytesTargetToClient") + .HasColumnType("bigint") + .HasColumnName("bytes_target_to_client"); + + b.Property("ClientAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("client_address"); + + b.Property("CloseReason") + .HasColumnType("integer") + .HasColumnName("close_reason"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("ended_at_utc"); + + b.Property("HostId") + .HasColumnType("uuid") + .HasColumnName("host_id"); + + b.Property("StartedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at_utc"); + + b.Property("TargetAddress") + .HasMaxLength(45) + .HasColumnType("character varying(45)") + .HasColumnName("target_address"); + + b.Property("TargetHost") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("target_host"); + + b.Property("TargetPort") + .HasColumnType("integer") + .HasColumnName("target_port"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("VaultId") + .HasColumnType("uuid") + .HasColumnName("vault_id"); + + b.HasKey("Id") + .HasName("pk_relay_session"); + + b.HasIndex("HostId", "StartedAtUtc") + .HasDatabaseName("ix_relay_session_host_id_started_at_utc"); + + b.HasIndex("UserId", "StartedAtUtc") + .HasDatabaseName("ix_relay_session_user_id_started_at_utc"); + + b.ToTable("relay_session", "dodo", t => + { + t.HasCheckConstraint("ck_relay_session_bytes", "bytes_client_to_target >= 0 AND bytes_target_to_client >= 0"); + + t.HasCheckConstraint("ck_relay_session_port_range", "target_port BETWEEN 1 AND 65535"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.RelayTicketUse", b => + { + b.Property("TicketId") + .HasColumnType("uuid") + .HasColumnName("ticket_id"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at_utc"); + + b.Property("UsedAtUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at_utc"); + + b.HasKey("TicketId") + .HasName("pk_relay_ticket_use"); + + b.HasIndex("ExpiresAtUtc") + .HasDatabaseName("ix_relay_ticket_use_expires_at_utc"); + + b.ToTable("relay_ticket_use", "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("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.VaultActivityLogEntry", 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_activity_log_entry"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_activity_log_entry_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_activity_log_entry_vault_id_change_sequence"); + + b.ToTable("activity_log_entry", "dodo", t => + { + t.HasCheckConstraint("ck_activity_log_entry_version", "version >= 1"); + }); + }); + + 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.VaultConnectionLogEntry", 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_connection_log_entry"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_connection_log_entry_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_connection_log_entry_vault_id_change_sequence"); + + b.ToTable("connection_log_entry", "dodo", t => + { + t.HasCheckConstraint("ck_connection_log_entry_version", "version >= 1"); + }); + }); + + 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.VaultHostGroup", 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_host_group"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_host_group_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_host_group_vault_id_change_sequence"); + + b.ToTable("host_group", "dodo", t => + { + t.HasCheckConstraint("ck_host_group_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.VaultKnownHostKey", 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_known_host_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_known_host_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_known_host_key_vault_id_change_sequence"); + + b.ToTable("known_host_key", "dodo", t => + { + t.HasCheckConstraint("ck_known_host_key_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultObjectStore", 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_object_store"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_object_store_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_object_store_vault_id_change_sequence"); + + b.ToTable("object_store", "dodo", t => + { + t.HasCheckConstraint("ck_object_store_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSnippet", 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_snippet"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_snippet_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_snippet_vault_id_change_sequence"); + + b.ToTable("snippet", "dodo", t => + { + t.HasCheckConstraint("ck_snippet_version", "version >= 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", 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_spelunk_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_spelunk_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_spelunk_key_vault_id_change_sequence"); + + b.ToTable("spelunk_key", "dodo", t => + { + t.HasCheckConstraint("ck_spelunk_key_version", "version >= 1"); + }); + }); + + 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.VaultTag", 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_tag"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_tag_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_tag_vault_id_change_sequence"); + + b.ToTable("tag", "dodo", t => + { + t.HasCheckConstraint("ck_tag_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.VaultActivityLogEntry", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_activity_log_entry_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultConnectionLogEntry", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_connection_log_entry_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + 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.VaultHostGroup", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_host_group_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.VaultKnownHostKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_known_host_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultObjectStore", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_object_store_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSnippet", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_snippet_vaults_vault_id"); + + b.Navigation("Vault"); + }); + + modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_spelunk_key_vaults_vault_id"); + + 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.VaultTag", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tag_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/20260929073947_AddSpelunkKeyItem.cs b/src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.cs new file mode 100644 index 0000000..56b7d34 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.cs @@ -0,0 +1,76 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + /// + /// Adds the table for Spelunk keys: an API key and the one Spelunk address it may be sent to. + /// + /// + /// New and empty, so this is additive and safe to roll back. The object-store table's shape: ciphertext + /// and bookkeeping, with no plaintext column, because nothing on the server calls Spelunk. + /// + public partial class AddSpelunkKeyItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "spelunk_key", + 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_spelunk_key", x => x.id); + table.CheckConstraint("ck_spelunk_key_version", "version >= 1"); + table.ForeignKey( + name: "fk_spelunk_key_vaults_vault_id", + column: x => x.vault_id, + principalSchema: "dodo", + principalTable: "vault", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_spelunk_key_vault_id_change_sequence", + schema: "dodo", + table: "spelunk_key", + columns: new[] { "vault_id", "change_sequence" }); + + migrationBuilder.CreateIndex( + name: "ix_spelunk_key_vault_live", + schema: "dodo", + table: "spelunk_key", + column: "vault_id", + filter: "deleted_at_utc IS NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "spelunk_key", + schema: "dodo"); + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index 50fb2a2..4e7f4c8 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -1487,6 +1487,87 @@ namespace DodoSSH.Infrastructure.Migrations }); }); + modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", 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_spelunk_key"); + + b.HasIndex("VaultId") + .HasDatabaseName("ix_spelunk_key_vault_live") + .HasFilter("deleted_at_utc IS NULL"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_spelunk_key_vault_id_change_sequence"); + + b.ToTable("spelunk_key", "dodo", t => + { + t.HasCheckConstraint("ck_spelunk_key_version", "version >= 1"); + }); + }); + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => { b.Property("Id") @@ -1854,6 +1935,18 @@ namespace DodoSSH.Infrastructure.Migrations b.Navigation("Vault"); }); + modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b => + { + b.HasOne("DodoSSH.Domain.Vault", "Vault") + .WithMany() + .HasForeignKey("VaultId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_spelunk_key_vaults_vault_id"); + + b.Navigation("Vault"); + }); + modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b => { b.HasOne("DodoSSH.Domain.Vault", "Vault") diff --git a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs index fedf1e7..aecc404 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -935,6 +935,55 @@ public sealed class SyncEndpointTests(ApiFixture fixture) change.PlaintextFields.ShouldBeNull("where a user keeps their data is not something this server keeps"); } + [Fact] + public async Task ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var keyId = Guid.CreateVersion7(); + + var pushed = await client.PostContractAsync( + PushUrl(vaultId), + new SyncPushRequest( + [SpelunkKeyOperation(keyId, expectedVersion: null, envelope: [4, 2])])); + + 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.SpelunkKey])); + + var page = await pulled.Content.ReadContractAsync(); + var change = page!.Changes.ShouldHaveSingleItem(); + + change.EntityType.ShouldBe(SyncEntityType.SpelunkKey); + change.EntityId.ShouldBe(keyId); + change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]); + + change.PlaintextFields.ShouldBeNull("where somebody manages their servers from is not this server's to keep"); + } + + [Fact] + public async Task ASpelunkKeyCarryingItsAddressInTheClear_IsRejected() + { + var (subject, vaultId) = await SeedUserWithVaultAsync(); + var client = fixture.CreateClientFor(subject); + + var operation = SpelunkKeyOperation(Guid.CreateVersion7(), null, [1]) + with + { PlaintextFields = new SyncPlaintextFields(RelayEnabled: false, Hostname: "api.spelunk.example.com", Port: 443) }; + + 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("stays encrypted"); + } + [Fact] public async Task ABucketCarryingItsEndpointInTheClear_IsRejected() { @@ -1425,6 +1474,19 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Payload(envelope), PlaintextFields: null); + private static SyncPushOperation SpelunkKeyOperation( + Guid entityId, + int? expectedVersion, + byte[] envelope) => + new( + Guid.CreateVersion7(), + SyncEntityType.SpelunkKey, + entityId, + SyncOperation.Upsert, + expectedVersion, + Payload(envelope), + PlaintextFields: null); + private static SyncPushRequest NewCreateBatch() => new([NewOperation(Guid.CreateVersion7(), expectedVersion: null, envelope: [1, 2, 3, 4])]); diff --git a/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs index 7c1af73..2a6aae8 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs @@ -232,6 +232,41 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime Token); } + /// + /// The SPELUNK section with a key saved: the form, its note, and the row listing the key all fit, and the + /// form's button is the real save. + /// + [Fact] + public async Task TheSecurityPageFitsWithASavedSpelunkKey() + { + vault.SpelunkKeyFormApi = "https://api.spelunk.example.com"; + vault.SpelunkKeyFormKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyz"; + await vault.SaveSpelunkKeyCommand.ExecuteAsync(null); + + vault.SpelunkKeys.ShouldHaveSingleItem(vault.SpelunkKeyFormNote); + + await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("with a Spelunk key saved")); + + await LayoutHarness.OnTheUiThreadAsync( + () => + { + var page = new SettingsSecurityPage { DataContext = shell }; + + var window = LayoutHarness.HostAtMinimumSize( + page, LayoutHarness.SettingsContentWidth, LayoutHarness.SettingsContentHeight); + + try + { + ButtonNamed(window, "SAVE KEY").Command.ShouldBeSameAs(vault.SaveSpelunkKeyCommand); + } + finally + { + window.Close(); + } + }, + Token); + } + // ---- v5c-2: Vaults, Groups, Tags ---- // // Vaults replaces the old full-bleed VaultsScreen, which ScreenLayoutTests used to measure at diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs index ccc44b0..f644bcc 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs @@ -507,6 +507,158 @@ public sealed partial class ShellFlowTests vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel"); } + // ---- A Spelunk key saved on the Security page ---- + + [Fact] + public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey); + + // Saved in the one form, in the personal vault, and gone from the form. + var saved = vault.SpelunkKeys.ShouldHaveSingleItem(); + saved.ApiBase.ShouldBe(SpelunkApi); + saved.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId); + vault.SpelunkKeyFormKey.ShouldBeEmpty(); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10")); + + vault.IsEditing.ShouldBeFalse("saving the key for this Spelunk is the say-so the first link used to ask for"); + + var added = vault.Hosts.ShouldHaveSingleItem(); + added.Host.Spelunk.ShouldNotBeNull().ApiKeyCredentialId.ShouldBe(saved.EntityId); + added.Host.SignsInThroughSpelunk.ShouldBeTrue(); + + spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22)); + + await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); + } + + [Fact] + public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key"); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.OpenSpelunkLinkAsync(Link()); + await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); + + await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); + + vault.SpelunkKeys.ShouldHaveSingleItem().UsedBy.ShouldBe("1 host uses it"); + vault.SpelunkKeyFormNote.ShouldContain("Replaced"); + + await vault.OpenSpelunkLinkAsync(Link()); + + spelunkTunnels.Opened.Select(opened => opened.ApiKey).ShouldBe(["spelunk_the_old_key", SpelunkApiKey]); + + await ssh.Requests[1].Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); + } + + [Fact] + public async Task ASavedKey_IsNeverSentToAnotherSpelunk() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); + var saved = vault.SpelunkKeys.ShouldHaveSingleItem(); + + // A host that names the key but another address — the one thing a key kept with its address refuses. + vault.NewHostCommand.Execute(null); + vault.EditorLabel = "web-01"; + vault.EditorHostname = "203.0.113.10"; + vault.EditorThroughSpelunk = true; + vault.EditorSpelunkSignIn = true; + vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example"; + vault.EditorSpelunkServerId = SpelunkServer.ToString(); + vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId); + await vault.SaveHostCommand.ExecuteAsync(null); + + vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + spelunkTunnels.Opened.ShouldBeEmpty(); + ssh.Requests.ShouldBeEmpty(); + vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else"); + } + + [Fact] + public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await AddCredentialAsync(vault, "an older key", password: "spelunk_older"); + await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); + + vault.NewHostCommand.Execute(null); + vault.EditorThroughSpelunk = true; + vault.EditorSpelunkApi = SpelunkApi + "/"; + + var chosen = vault.EditorSelectedSpelunkKey.ShouldNotBeNull(); + chosen.EntityId.ShouldBe(vault.SpelunkKeys.Single().EntityId); + chosen.Qualifier.ShouldBe($"Spelunk key · {SpelunkApi}"); + + // Saved keys come before credentials, which stay on offer for keys kept the older way. + vault.EditorSpelunkKeyChoices.Select(choice => choice.Label) + .ShouldBe(["Spelunk at api.spelunk.example.com", "an older key"]); + } + + [Fact] + public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await SaveSpelunkKeyAsync(vault, "http://api.spelunk.example.com", SpelunkApiKey); + + vault.SpelunkKeys.ShouldBeEmpty(); + vault.SpelunkKeyFormNote.ShouldContain("https"); + vault.SpelunkKeyFormKey.ShouldBe(SpelunkApiKey, "a refused key stays in the box to be saved once the address is fixed"); + } + + [Fact] + public async Task DeletingASavedKey_SaysWhichHostsWillStopConnecting() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.OpenSpelunkLinkAsync(Link()); + await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); + + vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single()); + + var request = vault.PendingDeletion.ShouldNotBeNull(); + request.Usage.ShouldContain("web 01 reaches Spelunk with it"); + + await vault.ConfirmDeleteCommand.ExecuteAsync(null); + + vault.SpelunkKeys.ShouldBeEmpty(); + + await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts.Single()); + vault.Status.ShouldNotBeNull().ShouldContain("not in this keychain any more"); + } + + private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey) + { + vault.SpelunkKeyFormApi = apiBase; + vault.SpelunkKeyFormKey = apiKey; + + await vault.SaveSpelunkKeyCommand.ExecuteAsync(null); + } + /// A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain. private async Task ReadyToConnectThroughSpelunkAsync() { diff --git a/tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs b/tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs new file mode 100644 index 0000000..c14a64c --- /dev/null +++ b/tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs @@ -0,0 +1,131 @@ +using System.Text; + +namespace DodoSSH.Client.Domain.Tests; + +/// +/// A Spelunk key: an API key and the one Spelunk it may be sent to. +/// +/// +/// What these pin is that the pair is stored as it will be compared — the address in its one normal form — +/// that an address the key could leak across a network from is refused, and that a merge never pairs one +/// device's key with another device's address. +/// +public sealed class SpelunkKeySecretTests +{ + private const string Api = "https://api.spelunk.example.com"; + + [Fact] + public void AKey_RoundTrips() + { + var key = Key(); + + SpelunkKeySecretCodec.TryDecode(SpelunkKeySecretCodec.Encode(key), out var document).ShouldBeTrue(); + + document.ShouldNotBeNull(); + document.Key.ShouldBe(key); + document.SchemaVersion.ShouldBe(SpelunkKeySecretCodec.CurrentSchemaVersion); + document.IsReadOnly.ShouldBeFalse(); + } + + [Fact] + public void AKey_EncodesAtTheBaseVersion() + { + Encoding.UTF8.GetString(SpelunkKeySecretCodec.Encode(Key())) + .ShouldBe( + """{"schemaVersion":1,"label":"Spelunk","apiBase":"https://api.spelunk.example.com","apiKey":"spelunk_one"}"""); + } + + [Theory] + [InlineData("https://api.spelunk.example.com/")] + [InlineData("HTTPS://API.SPELUNK.EXAMPLE.COM")] + public void AnAddressNotInItsNormalForm_IsRefused(string apiBase) + { + // Stored the one way or not at all: it is compared byte for byte with what a host or a link names. + (Key() with { ApiBase = apiBase }).TryValidate(out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull().ShouldContain("normal form"); + } + + [Fact] + public void PlainHttpAcrossANetwork_IsRefused() + { + (Key() with { ApiBase = "http://api.spelunk.example.com" }).TryValidate(out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull().ShouldContain("https"); + } + + [Fact] + public void PlainHttpToThisMachine_IsAllowed() + { + (Key() with { ApiBase = "http://localhost:5080" }).TryValidate(out _).ShouldBeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("spelunk_one\r\nX-Other: header")] + public void AKeyThatIsNotAKey_IsRefused(string apiKey) + { + (Key() with { ApiKey = apiKey }).TryValidate(out _).ShouldBeFalse(); + } + + [Fact] + public void APayloadWithAnAddressOutOfNormalForm_DoesNotDecode() + { + // The address decides where the key may go, so a payload that disagrees with how every client + // writes it is refused rather than normalised into something nobody chose. + var payload = Encoding.UTF8.GetBytes( + """{"schemaVersion":1,"label":"Spelunk","apiBase":"https://api.spelunk.example.com/","apiKey":"k"}"""); + + SpelunkKeySecretCodec.TryDecode(payload, out _).ShouldBeFalse(); + } + + [Fact] + public void AKeyChangedOnOneSide_AndAnAddressOnTheOther_IsKeptAsAPair() + { + // The case this merge exists for: merging field by field would send the new key to the new address, + // a pairing neither device made. + var ancestor = Key(); + var local = ancestor with { ApiKey = "spelunk_two" }; + var remote = ancestor with { ApiBase = "https://spelunk.other.example" }; + + var result = SpelunkKeySecretMerge.Merge(ancestor, local, remote); + + (result.Merged.ApiBase, result.Merged.ApiKey).ShouldBeOneOf( + (local.ApiBase, local.ApiKey), + (remote.ApiBase, remote.ApiKey)); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + conflict.Field.ShouldBe(SpelunkKeySecretMerge.PairField); + } + + [Fact] + public void AKeyReplacedOnBothSides_NeverReachesTheConflictLog() + { + var ancestor = Key(); + var local = ancestor with { ApiKey = "spelunk_local_secret" }; + var remote = ancestor with { ApiKey = "spelunk_remote_secret" }; + + var result = SpelunkKeySecretMerge.Merge(ancestor, local, remote); + + var conflict = result.Conflicts.ShouldHaveSingleItem(); + $"{conflict.Kept} {conflict.Discarded}".ShouldNotContain("secret"); + } + + [Fact] + public void AKeyChangedOnOneSideOnly_IsTakenWithoutAConflict() + { + var ancestor = Key(); + var local = ancestor with { ApiKey = "spelunk_two", Label = "renamed" }; + + var result = SpelunkKeySecretMerge.Merge(ancestor, local, ancestor); + + result.Merged.ShouldBe(local); + result.HasConflicts.ShouldBeFalse(); + } + + private static SpelunkKeySecret Key() => new() + { + Label = "Spelunk", + ApiBase = Api, + ApiKey = "spelunk_one", + }; +} diff --git a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs index 2360613..9333211 100644 --- a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs +++ b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs @@ -420,6 +420,7 @@ public sealed class CacheStoreTests : IAsyncLifetime [InlineData(SyncEntityType.ConnectionLogEntry)] [InlineData(SyncEntityType.ActivityLogEntry)] [InlineData(SyncEntityType.ObjectStore)] + [InlineData(SyncEntityType.SpelunkKey)] public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType) { var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}"""); diff --git a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs index a51df87..74b9767 100644 --- a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs @@ -43,6 +43,7 @@ public sealed class AadResourceTypeTests [InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)] [InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)] [InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)] + [InlineData(SyncEntityType.SpelunkKey, CryptoSpec.AadResourceType.SpelunkKey)] public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently( SyncEntityType wire, CryptoSpec.AadResourceType resource) @@ -77,6 +78,7 @@ public sealed class AadResourceTypeTests (SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry), (SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry), (SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore), + (SyncEntityType.SpelunkKey, CryptoSpec.AadResourceType.SpelunkKey), ]; public static TheoryData Pinned @@ -253,6 +255,10 @@ public sealed class AadResourceTypeTests SyncEntityType.ObjectStore => ObjectStoreCipher.Seal( NewBucket(), vaultKey, entityId, generation, version), + // 14 on the wire and 17 here, and 14 here is a connection log entry. + SyncEntityType.SpelunkKey => SpelunkKeyCipher.Seal( + NewSpelunkKey(), vaultKey, entityId, generation, version), + _ => throw new ArgumentOutOfRangeException( nameof(wire), wire, @@ -413,6 +419,13 @@ public sealed class AadResourceTypeTests Region = "eu-west-1", }; + private static SpelunkKeySecret NewSpelunkKey() => new() + { + Label = "Spelunk at api.spelunk.example.com", + ApiBase = "https://api.spelunk.example.com", + ApiKey = "spelunk_an_example_key", + }; + private static HostGroupSecret NewGroup() => new() { Label = "production" }; private static TagSecret NewTag() => new() { Label = "pci" }; diff --git a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs index 79acabd..8faba4f 100644 --- a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs @@ -30,6 +30,7 @@ public sealed class ItemKindsTests SyncEntityType.ConnectionLogEntry, SyncEntityType.ActivityLogEntry, SyncEntityType.ObjectStore, + SyncEntityType.SpelunkKey, ]); } diff --git a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs index a44407c..735abd8 100644 --- a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs +++ b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs @@ -96,6 +96,7 @@ public sealed class CryptoSpecTests [InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)] [InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)] [InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)] + [InlineData(CryptoSpec.AadResourceType.SpelunkKey, 17)] public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected) { ((int)type).ShouldBe(expected);