From b08f2c58e3e30274baa696454b276c402ca257e6 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 29 Sep 2026 10:34:38 +0200 Subject: [PATCH] Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log. --- README.md | 39 +- docs/crypto.md | 6 +- src/DodoSSH.Api/Features/Sync/ItemKinds.cs | 96 + .../Views/HostsScreen.axaml | 4 +- src/DodoSSH.Client.App/Views/HostDrawer.axaml | 16 +- .../Views/SettingsSecurityPage.axaml | 92 +- .../Views/SettingsSecurityPage.axaml.cs | 4 +- src/DodoSSH.Client.Domain/SpelunkKeySecret.cs | 92 + .../SpelunkKeySecretCodec.cs | 112 + .../SpelunkKeySecretMerge.cs | 89 + src/DodoSSH.Client.Domain/SpelunkRoute.cs | 24 +- src/DodoSSH.Client.Session/VaultSession.cs | 8 + .../ViewModels/VaultViewModel.Spelunk.cs | 77 +- .../ViewModels/VaultViewModel.SpelunkKeys.cs | 374 +++ .../ViewModels/VaultViewModel.cs | 8 + .../LocalCacheProtector.cs | 1 + src/DodoSSH.Client.Sync/ItemKinds.cs | 82 + src/DodoSSH.Client.Sync/SpelunkKeyCipher.cs | 125 ++ .../SpelunkKeyRepository.cs | 46 + src/DodoSSH.Contracts/PublicAPI.Unshipped.txt | 1 + src/DodoSSH.Contracts/SyncEntityType.cs | 10 + src/DodoSSH.Crypto/CryptoSpec.cs | 3 + src/DodoSSH.Domain/Hosts.cs | 56 + src/DodoSSH.Domain/SyncLog.cs | 3 + .../HostAndSyncConfigurations.cs | 35 + src/DodoSSH.Infrastructure/DodoDbContext.cs | 3 + ...260929073947_AddSpelunkKeyItem.Designer.cs | 2000 +++++++++++++++++ .../20260929073947_AddSpelunkKeyItem.cs | 76 + .../Migrations/DodoDbContextModelSnapshot.cs | 93 + tests/DodoSSH.Api.Tests/SyncEndpointTests.cs | 62 + .../SettingsPagesLayoutTests.cs | 35 + .../ShellFlowTests.Spelunk.cs | 152 ++ .../SpelunkKeySecretTests.cs | 131 ++ .../CacheStoreTests.cs | 1 + .../AadResourceTypeTests.cs | 13 + .../ItemKindsTests.cs | 1 + tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs | 1 + 37 files changed, 3907 insertions(+), 64 deletions(-) create mode 100644 src/DodoSSH.Client.Domain/SpelunkKeySecret.cs create mode 100644 src/DodoSSH.Client.Domain/SpelunkKeySecretCodec.cs create mode 100644 src/DodoSSH.Client.Domain/SpelunkKeySecretMerge.cs create mode 100644 src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs create mode 100644 src/DodoSSH.Client.Sync/SpelunkKeyCipher.cs create mode 100644 src/DodoSSH.Client.Sync/SpelunkKeyRepository.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.Designer.cs create mode 100644 src/DodoSSH.Infrastructure/Migrations/20260929073947_AddSpelunkKeyItem.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs 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); -- 2.54.0