diff --git a/README.md b/README.md index dccf9d1..eb4c601 100644 --- a/README.md +++ b/README.md @@ -456,30 +456,40 @@ 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 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 and the server's id in Spelunk. 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. -- **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. +- **Sign this device in to Spelunk once, under Settings → Security → Spelunk** (or with the button in a + Spelunk host's editor). Give Spelunk's API address and press SIGN IN: your browser opens at Spelunk, you + sign in there however you normally do — password, passkey, your company's single sign-on, second factor — + and allow *DodoSSH on ""*. No key is pasted anywhere, and DodoSSH never sees your Spelunk + password. +- **One sign-in reaches every organisation you are in** on that Spelunk, as you, with the role you have on + each server at the time: Admin opens a terminal, anything less is refused for that server alone. DodoSSH + sends no organisation; Spelunk takes it from the server. +- **It stays on this device.** Nothing about it is synced: each device signs in on its own, and the host + itself carries only the address and the server id, so the same host opens on every device signed in to its + Spelunk. The sign-in is sealed in the local cache like the remembered DodoSSH sign-in, and only ever sent to + the Spelunk it came from. The API address must be `https://`, except for a Spelunk on this machine. +- **It has no expiry, and ends with you.** Spelunk refuses it once you are removed, change your password or + second factors, or sign it out under *Your account → Signed-in apps* there; DodoSSH then forgets it and asks + you to sign in again. SIGN OUT beside it under Settings → Security ends it in both places, and so does + signing out of DodoSSH. +- **DodoSSH has to be online and signed in.** Before every connection through Spelunk it asks its identity + provider whether your DodoSSH sign-in still stands (at most once a minute). An account the provider no longer + signs in is signed out of DodoSSH and of every Spelunk on this device; a provider that cannot be reached + holds the connection until it can. - **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 - not take, a key whose role is too low, a server outside the key's organisation, an agent that is not - connected, an agent that could not reach the SSH port or is too old to open terminals. +- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: signed out, not + allowed on that server, a server Spelunk does not know, an agent that is not connected, an agent that could + not reach the SSH port or is too old to open terminals. -**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log +**Sign in through Spelunk (no SSH key needed)**, the last box in the same card, lets Spelunk log you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway -Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises; +Ed25519 key pair in memory and sends the public half with the ticket request, which your sign-in authorises; the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file transfers and port forwards all work, and the key is gone with the connection — it is never stored, @@ -493,15 +503,14 @@ 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 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. +- Otherwise, when this device is signed in to that 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, 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 this device is not signed in to** opens the host editor instead, filled in the same + way with sign-in ticked and the tag on, and offers to sign in to that Spelunk. Sign in, save, and every later + link from that Spelunk connects straight away. A link can be sent by anything on this machine, so it never + starts a sign-in by itself. 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 152316d..c2f478f 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -300,7 +300,7 @@ 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, -`17` SpelunkKey. +`18` SpelunkSignIn. `17` is retired and never reused. > **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 @@ -319,8 +319,12 @@ 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`. +> **Added 2026-09-29, removed 2026-10-07:** `17` SpelunkKey, a Spelunk API key and the address it +> may be sent to (`14` on the wire). Removed in beta when DodoSSH started signing in to Spelunk instead; +> both numbers stay retired. + +> **Added 2026-10-07:** `18` SpelunkSignIn, a device's sign-in to one Spelunk, sealed in the local cache +> (§4.2's LocalCache AAD) and bound to its row. It is never synced and has no `SyncEntityType`. `0` means not applicable and is legal only where the table in §4.2 implies no resource. diff --git a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs index fe0ce67..867f83f 100644 --- a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs +++ b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs @@ -73,7 +73,6 @@ 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. @@ -1094,98 +1093,3 @@ 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.Api/Features/Sync/TombstoneCollector.cs b/src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs index 4ba840d..abbd89e 100644 --- a/src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs +++ b/src/DodoSSH.Api/Features/Sync/TombstoneCollector.cs @@ -56,7 +56,6 @@ internal sealed class TombstoneCollector( collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false); collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false); collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false); - collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false); collected += await CompactAsync(cancellationToken).ConfigureAwait(false); diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index 8d0fdb8..38a93f1 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -271,7 +271,16 @@ public sealed partial class DodoSshApp : Avalonia.Application copyToClipboard: copyToClipboard, readClipboard: readClipboard, - updates: updates); + updates: updates, + + // Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same + // registered scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as + // the account's device list does, for difference 5's reason. + spelunkAccounts: new SpelunkAccounts( + browser, + path => new AndroidRedirectCallback(path), + TimeProvider.System, + PhoneEnvironment.DeviceName)); // Started rather than awaited: framework initialisation must not block on a schema migration. The // view model shows its own progress and handles its own failures. diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index d5b0a72..46652fe 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -1326,8 +1326,8 @@ - - - - - - - - + + + ToolTip.Tip="Sign this device out of this Spelunk. Hosts reached through it stop connecting until you sign in again." /> diff --git a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs index 550a711..519e93e 100644 --- a/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs +++ b/src/DodoSSH.Client.App/Views/SettingsSecurityPage.axaml.cs @@ -3,7 +3,7 @@ using Avalonia.Controls; namespace DodoSSH.Client.App.Views; /// -/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys. +/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and signing in to Spelunk. /// internal sealed partial class SettingsSecurityPage : UserControl { diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs index 24097ad..60aa3a3 100644 --- a/src/DodoSSH.Client.Domain/HostSecretCodec.cs +++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs @@ -145,18 +145,26 @@ public static class HostSecretCodec /// shows the host, refuses to edit it, and says to update. /// /// - /// Only a route that signs in is written here. Every other Spelunk host stays at - /// , byte for byte what it was. + /// No longer written: every Spelunk host is written at . /// /// public const int SpelunkSignInSchemaVersion = 10; + /// The version from which a names no API key. + /// + /// Every Spelunk host is written here: the device's own sign-in to Spelunk replaced the key a route used + /// to name. A build that knows 10 requires that key and would read the host as damaged; written at this + /// version, it shows the host, refuses to edit it, and says to update. A payload written before still + /// decodes — its key id is an unknown member, skipped like any other. + /// + public const int SpelunkSignedInDeviceSchemaVersion = 11; + /// The highest schema version this build can write. /// /// Names the highest constant above, which assumes when it takes a /// maximum. A new field added below this line has to be named here too. /// - public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion; + public const int CurrentSchemaVersion = SpelunkSignedInDeviceSchemaVersion; /// Serialises a host to the bytes that get sealed. /// The host is not valid for storage. @@ -292,12 +300,7 @@ public static class HostSecretCodec if (host.Spelunk is not null) { - version = Math.Max(version, SpelunkSchemaVersion); - } - - if (host.SignsInThroughSpelunk) - { - version = Math.Max(version, SpelunkSignInSchemaVersion); + version = Math.Max(version, SpelunkSignedInDeviceSchemaVersion); } return version; @@ -450,7 +453,6 @@ public static class HostSecretCodec { ApiBase = parsed.Api ?? string.Empty, ServerId = parsed.ServerId ?? Guid.Empty, - ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty, // False folds to the default, as AsksForPassword's does: one state, one spelling. SignInThroughSpelunk = parsed.SignIn is true, @@ -544,19 +546,13 @@ internal sealed class SpelunkPayloadDocument public Guid? ServerId { get; set; } - public Guid? ApiKeyCredentialId { get; set; } - - /// - /// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9. - /// See . - /// + /// Last, and null unless true. See . public bool? SignIn { get; set; } public static SpelunkPayloadDocument From(SpelunkRoute route) => new() { Api = route.ApiBase, ServerId = route.ServerId, - ApiKeyCredentialId = route.ApiKeyCredentialId, // Null rather than false, so a route that does not sign in encodes exactly as it did before the // field existed — the rule AsksForPassword follows. diff --git a/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs b/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs deleted file mode 100644 index cd28620..0000000 --- a/src/DodoSSH.Client.Domain/SpelunkKeySecret.cs +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 7999a0a..0000000 --- a/src/DodoSSH.Client.Domain/SpelunkKeySecretCodec.cs +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 6fe8d40..0000000 --- a/src/DodoSSH.Client.Domain/SpelunkKeySecretMerge.cs +++ /dev/null @@ -1,89 +0,0 @@ -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 5b56e1a..7cd45b9 100644 --- a/src/DodoSSH.Client.Domain/SpelunkRoute.cs +++ b/src/DodoSSH.Client.Domain/SpelunkRoute.cs @@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis; namespace DodoSSH.Client.Domain; /// -/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way. +/// How a host is reached through Spelunk: which Spelunk, and which of its servers. /// /// /// @@ -15,15 +15,15 @@ namespace DodoSSH.Client.Domain; /// /// /// Inside the host's encrypted payload, and nowhere else. Unlike the relay, nothing about this has to -/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only -/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this. +/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, as whoever is +/// signed in to that Spelunk on this device. So the concession ADR 0004 argues for the relay's address is +/// not made for this. /// /// -/// 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. +/// No credential travels with the host. Each device signs in to a Spelunk once, through the browser, +/// and keeps what that gives it to itself (see SpelunkSignIns): the same host opens on every device +/// whose person is signed in to its Spelunk, as that person, in whichever of their organisations the server +/// is in. Nothing about who may connect is synced. /// /// public sealed record SpelunkRoute @@ -50,32 +50,13 @@ public sealed record SpelunkRoute /// The server's id in Spelunk. public required Guid ServerId { get; init; } - /// - /// 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; } - /// /// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own. /// /// /// /// For each connection this client makes a throwaway key pair in memory and sends the public half with - /// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on + /// the ticket request, which this device's sign-in to Spelunk authorises. The agent then runs a one-connection SSH server on /// the machine whose only authorised key is that one, and says which account it logs in as. It is real /// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host, /// and the key is gone when the connection is. @@ -121,12 +102,6 @@ public sealed record SpelunkRoute return false; } - if (ApiKeyCredentialId == Guid.Empty) - { - reason = "A host reached through Spelunk needs its Spelunk API key chosen."; - return false; - } - reason = null; return true; } @@ -136,8 +111,8 @@ public sealed record SpelunkRoute /// /// /// - /// HTTPS, or plain HTTP to this machine only. The API key travels in a header on every tunnel, so - /// an address that sends it in clear across a network is refused rather than stored. A Spelunk running + /// HTTPS, or plain HTTP to this machine only. The sign-in's token travels in a header on every + /// tunnel, so an address that sends it in clear across a network is refused rather than stored. A Spelunk running /// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the /// feature can be tried against one. /// @@ -175,7 +150,7 @@ public sealed record SpelunkRoute if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback) { - reason = "The Spelunk API address must use https, because the API key is sent with every connection."; + reason = "The Spelunk API address must use https, because your sign-in is sent with every connection."; return false; } diff --git a/src/DodoSSH.Client.Session/ServerConnection.cs b/src/DodoSSH.Client.Session/ServerConnection.cs index c222b06..740f4ce 100644 --- a/src/DodoSSH.Client.Session/ServerConnection.cs +++ b/src/DodoSSH.Client.Session/ServerConnection.cs @@ -40,6 +40,7 @@ internal sealed class RefreshingAccessTokenProvider( { private readonly SemaphoreSlim gate = new(1, 1); private TokenSet tokens = initial; + private DateTimeOffset lastRefreshedAt = DateTimeOffset.MinValue; /// /// The refresh token this provider currently holds, or null when none was granted. @@ -82,6 +83,7 @@ internal sealed class RefreshingAccessTokenProvider( .ConfigureAwait(false); Volatile.Write(ref tokens, refreshed); + lastRefreshedAt = clock.GetUtcNow(); return refreshed.AccessToken; } @@ -91,6 +93,44 @@ internal sealed class RefreshingAccessTokenProvider( } } + /// + /// Refreshes now, whatever the expiry says, unless that was done within . + /// + /// + /// The refresh grant is the one question the identity provider answers about whether a sign-in still + /// stands: a disabled account, a revoked session or a removed user gets invalid_grant here, where + /// an access token already issued would go on being accepted until it expired. Under the same lock as the + /// ordinary refresh, so the two cannot spend one rotating refresh token twice. + /// + /// The provider refused, or could not be asked. + internal async Task RefreshNowAsync(TimeSpan within, CancellationToken cancellationToken) + { + await gate.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + var now = clock.GetUtcNow(); + if (now - lastRefreshedAt < within) + { + return; + } + + var current = Volatile.Read(ref tokens); + if (current.RefreshToken is null) + { + throw new OidcException("This sign-in has no refresh token, so it cannot be checked. Sign in again.", "invalid_grant"); + } + + var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref tokens, refreshed); + lastRefreshedAt = now; + } + finally + { + gate.Release(); + } + } + public void Dispose() => gate.Dispose(); } @@ -161,6 +201,19 @@ public interface IVaultServer : IDisposable /// IVaultEventStream Events { get; } + /// + /// Asks the identity provider, now, whether this sign-in still stands — at most once a minute. + /// + /// + /// For what must not go on working for a person the account has lost: connecting through Spelunk asks + /// before every tunnel, so a removed or disabled account stops it within a minute rather than when an + /// access token happens to expire. A stand-in server answers yes, which is why this has a body. + /// + /// + /// The provider refused (invalid_grant), or could not be asked. + /// + Task VerifySignInAsync(CancellationToken cancellationToken) => Task.CompletedTask; + /// /// Reaches a host through the server, for a host set to connect that way. /// @@ -261,6 +314,10 @@ public sealed class ServerConnection : IVaultServer /// public IAccountApi Account => Api; + /// + public Task VerifySignInAsync(CancellationToken cancellationToken) => + tokens.RefreshNowAsync(TimeSpan.FromMinutes(1), cancellationToken); + /// public ISyncApi Sync => Api; diff --git a/src/DodoSSH.Client.Session/SpelunkAccounts.cs b/src/DodoSSH.Client.Session/SpelunkAccounts.cs new file mode 100644 index 0000000..90217e5 --- /dev/null +++ b/src/DodoSSH.Client.Session/SpelunkAccounts.cs @@ -0,0 +1,447 @@ +using System.Buffers.Text; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using DodoSSH.Client.Auth; +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; + +namespace DodoSSH.Client.Session; + +/// A sign-in to Spelunk that did not happen; the message says why, in words for the user. +public sealed class SpelunkSignInException : Exception +{ + public SpelunkSignInException() + { + } + + public SpelunkSignInException(string message) + : base(message) + { + } + + public SpelunkSignInException(string message, Exception innerException) + : base(message, innerException) + { + } +} + +/// Signs this device in to a Spelunk, and out again. +public interface ISpelunkAccounts +{ + /// + /// Signs this device in to the Spelunk at through the browser, and returns what + /// to keep. Nothing is kept here; the caller stores the result. + /// + /// The Spelunk API's base address, normalised by . + /// Abandons the sign-in — the user closing the dialog that waits for it. + /// It did not happen; the message says why. + Task SignInAsync(string apiBase, CancellationToken cancellationToken); + + /// Tells Spelunk this device is signing out, so its sign-in ends there too. Best effort: never throws. + Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken); +} + +/// +/// The OAuth authorization code flow with PKCE for a native app (RFC 8252), against Spelunk as the +/// authorization server. +/// +/// +/// +/// The browser opens Spelunk's /api/app-sign-in/authorize, which sends it on to Spelunk's own +/// consent page. There the person signs in however they sign in to Spelunk — a password, a passkey, their +/// company's single sign-on, a second factor — and allows this device. Spelunk sends the browser back to the +/// same redirect this app's own sign-in uses (a loopback address on a desktop, the app's scheme on a phone) +/// with a code, which is redeemed here with the PKCE verifier for a token. +/// +/// +/// Not . Spelunk is no OpenID provider: it has no discovery document, and +/// what it returns is a token for its tunnel route, not an identity. The pieces that matter for security — +/// the callback, PKCE, the state check — are the same ones, from DodoSSH.Client.Auth. +/// +/// +/// The token does not expire. Spelunk ends it when its person signs it out there, changes their password +/// or second factors, has their sessions ended, or is removed; a ticket request then answers 401 and the +/// sign-in is forgotten (see VaultViewModel). +/// +/// +public sealed class SpelunkAccounts : ISpelunkAccounts +{ + /// What Spelunk knows this app as. + public const string ClientId = "dodossh"; + + /// The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache. + private const int MaxTokenLength = 512; + + /// What every token Spelunk issues a signed-in app begins with, and how Spelunk tells it from an API key. + /// + /// Checked rather than assumed: a token without it is one Spelunk would read as something else, so an answer + /// carrying one is not Spelunk's app sign-in, whatever else it looks like. + /// + internal const string TokenPrefix = "spelunkapp_"; + + /// The longest error body read, so a misbehaving proxy cannot hand this a gigabyte. + private const int MaxProblemBytes = 16 * 1024; + + /// How long signing out waits for Spelunk before forgetting the sign-in here regardless. + private static readonly TimeSpan SignOutTimeout = TimeSpan.FromSeconds(10); + + /// The page the loopback listener shows once Spelunk has sent the browser back. + private const string CompletionHtml = + """ + DodoSSH + +

Done

You can close this tab and return to DodoSSH.

+ + """; + + /// + /// Redirects are not followed. The code, its verifier and the token go to the address the user signed in + /// to and nowhere else, and a Spelunk that answered with a redirect would be asking for them to be sent on. + /// + private static readonly HttpClient SharedHttp = new( + new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false }) + { + Timeout = TimeSpan.FromSeconds(30), + }; + + private readonly HttpClient http; + private readonly IBrowserLauncher browser; + private readonly Func callbackFactory; + private readonly TimeProvider clock; + private readonly string deviceName; + + /// Opens Spelunk's sign-in. + /// + /// What waits for Spelunk's answer: the same seam as , so a + /// phone answers through its own scheme. + /// + /// For the wait's timeout. + /// + /// What Spelunk is to call this device, on its consent page and under Your account; the machine's name when + /// not given. A phone passes its model, since its machine name says nothing. + /// + public SpelunkAccounts( + IBrowserLauncher browser, + Func? callbackFactory = null, + TimeProvider? clock = null, + string? deviceName = null) + : this(SharedHttp, browser, callbackFactory, clock, deviceName) + { + } + + /// Sign-ins whose requests are supplied, for tests. + internal SpelunkAccounts( + HttpClient http, + IBrowserLauncher browser, + Func? callbackFactory = null, + TimeProvider? clock = null, + string? deviceName = null) + { + this.http = http; + this.browser = browser; + this.callbackFactory = callbackFactory ?? (path => new LoopbackCallbackListener(path)); + this.clock = clock ?? TimeProvider.System; + this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName.Trim(); + } + + /// How long the browser has to come back. + public TimeSpan BrowserTimeout { get; init; } = TimeSpan.FromMinutes(10); + + /// + public async Task SignInAsync(string apiBase, CancellationToken cancellationToken) + { + if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason)) + { + throw new SpelunkSignInException(reason); + } + + var pkce = PkcePair.Create(); + var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32)); + + // Disposed on the way out whatever happens, so the loopback port is closed once it has had its one + // answer - or none. + using var callback = callbackFactory("/callback"); + + // Read once and sent twice, byte for byte: Spelunk compares the token request's against the authorize one. + var redirectUri = callback.RedirectUri.ToString(); + + await OpenBrowserAsync(AuthorizeUrl(normalized, redirectUri, pkce.CodeChallenge, state), cancellationToken) + .ConfigureAwait(false); + + var answer = await WaitForAnswerAsync(callback, cancellationToken).ConfigureAwait(false); + + // Checked before anything else is read: an answer to some other sign-in is no answer to this one. + if (!string.Equals(answer.State, state, StringComparison.Ordinal)) + { + throw new SpelunkSignInException("The browser came back from a different sign-in, so it was ignored. Try again."); + } + + if (answer.Error is { } error) + { + throw new SpelunkSignInException(string.Equals(error, "access_denied", StringComparison.Ordinal) + ? "Signing in to Spelunk was cancelled in the browser." + : $"Spelunk refused the sign-in ({SpelunkTunnelRoute.Readable(answer.ErrorDescription) ?? SpelunkTunnelRoute.Readable(error)})."); + } + + if (answer.Code is not { Length: > 0 } code) + { + throw new SpelunkSignInException("Spelunk sent the browser back without a sign-in. Try again."); + } + + return await RedeemAsync(normalized, code, pkce.CodeVerifier, redirectUri, cancellationToken).ConfigureAwait(false); + } + + /// Spelunk's authorize address for this sign-in: OAuth's own parameter names, and this device's. + private Uri AuthorizeUrl(string apiBase, string redirectUri, string codeChallenge, string state) + { + var query = string.Join('&', new Dictionary(StringComparer.Ordinal) + { + ["client_id"] = ClientId, + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = PkcePair.Method, + ["state"] = state, + ["device_name"] = deviceName, + }.Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value)}")); + + return new Uri($"{apiBase}/api/app-sign-in/authorize?{query}", UriKind.Absolute); + } + + private async Task OpenBrowserAsync(Uri authorize, CancellationToken cancellationToken) + { + try + { + await browser.OpenAsync(authorize, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException)) + { + throw new SpelunkSignInException($"The browser could not be opened to sign in to Spelunk: {exception.Message}", exception); + } + } + + /// Waits for the browser to come back, and turns every way it does not into a sentence. + /// The user abandoned the sign-in. + private async Task WaitForAnswerAsync(IAuthorizationCallback callback, CancellationToken cancellationToken) + { + using var timeout = new CancellationTokenSource(BrowserTimeout, clock); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + + try + { + return await callback.WaitForCallbackAsync(CompletionHtml, linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + throw new SpelunkSignInException("Spelunk did not send the browser back in time. Try signing in again."); + } + catch (OperationCanceledException exception) + { + // Neither the user nor the clock: a phone's redirect is handed to one sign-in at a time, and another + // one - to DodoSSH, or to Spelunk again - took it over. + throw new SpelunkSignInException("Another sign-in started before this one finished. Try signing in again.", exception); + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + // A phone's browser closing without an answer arrives as an OidcException with the reason in it; a + // desktop's listener failing to read the browser's request arrives as whatever the socket threw. + throw new SpelunkSignInException( + exception is OidcException ? exception.Message : $"The browser's answer from Spelunk could not be read: {exception.Message}", + exception); + } + } + + private async Task RedeemAsync( + string apiBase, string code, string verifier, string redirectUri, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri($"{apiBase}/api/app-sign-in/token", UriKind.Absolute)) + { + Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes( + new SpelunkTokenRequestDocument { ClientId = ClientId, Code = code, CodeVerifier = verifier, RedirectUri = redirectUri }, + SpelunkAccountsJsonContext.Default.SpelunkTokenRequestDocument)) + { + Headers = { ContentType = new MediaTypeHeaderValue("application/json") }, + }, + }; + + HttpResponseMessage response; + try + { + response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + throw new SpelunkSignInException($"Spelunk could not be reached at {apiBase}: {exception.Message}", exception); + } + catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new SpelunkSignInException($"Spelunk at {apiBase} did not answer in time. Try again in a moment.", exception); + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + throw new SpelunkSignInException(await DescribeRefusalAsync(response, cancellationToken).ConfigureAwait(false)); + } + + return await ReadSignInAsync(apiBase, response, cancellationToken).ConfigureAwait(false); + } + } + + /// Why the token endpoint said no, in Spelunk's own words where it gave some. + private static async Task DescribeRefusalAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var detail = await ReadProblemDetailAsync(response, cancellationToken).ConfigureAwait(false); + + return response.StatusCode switch + { + // Spelunk says which: a code that was wrong, used or expired, or sessions ended meanwhile. + HttpStatusCode.BadRequest => "Spelunk did not accept the sign-in" + + (detail is null ? ". It may have taken too long; try again." : $": {detail}"), + + HttpStatusCode.TooManyRequests => + "Spelunk is refusing sign-ins from this address for a moment. Wait a minute, then try again.", + + _ => string.Create(CultureInfo.InvariantCulture, $"Spelunk would not finish the sign-in (HTTP {(int)response.StatusCode})") + + (detail is null ? "." : $": {detail}"), + }; + } + + /// Reads the token endpoint's answer, and refuses one that is not an app sign-in's. + private async Task ReadSignInAsync(string apiBase, HttpResponseMessage response, CancellationToken cancellationToken) + { + SpelunkTokenDocument? token; + try + { + token = await response.Content + .ReadFromJsonAsync(SpelunkAccountsJsonContext.Default.SpelunkTokenDocument, cancellationToken) + .ConfigureAwait(false); + } + catch (JsonException exception) + { + throw new SpelunkSignInException(Unintelligible, exception); + } + + // The token goes into a header on every ticket request, so it has to be one - and one Spelunk reads as + // an app's. + if (token is not { Token: { Length: > 0 and <= MaxTokenLength } value, SessionId: { } sessionId, User: { Email: { } email } user } + || !value.StartsWith(TokenPrefix, StringComparison.Ordinal) + || value.Length == TokenPrefix.Length + || !value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_')) + { + throw new SpelunkSignInException(Unintelligible); + } + + return new SpelunkSignInRecord(apiBase, value, sessionId, email, user.DisplayName ?? email, clock.GetUtcNow()); + } + + /// + public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(signIn); + + // Only to an address a sign-in could have come from: the cache is this device's own, but a record that + // no longer normalises to itself is one to forget rather than to send a token on behalf of. + if (!SpelunkRoute.TryNormalizeApiBase(signIn.ApiBase, out var apiBase, out _) + || !string.Equals(apiBase, signIn.ApiBase, StringComparison.Ordinal)) + { + return; + } + + // Bounded on its own: signing out of DodoSSH waits for this, and a Spelunk that does not answer must not + // hold that up for the transport's whole timeout. + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(SignOutTimeout); + + try + { + using var request = new HttpRequestMessage(HttpMethod.Delete, new Uri($"{apiBase}/api/app-sign-in/session", UriKind.Absolute)); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", signIn.Token); + using var response = await http.SendAsync(request, timeout.Token).ConfigureAwait(false); + } + catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or InvalidOperationException or FormatException) + { + // Unreachable, already signed out, or slow: either way this device forgets it, which is what signing + // out here means. Spelunk's list under Your account still offers to end it there. + } + } + + /// Spelunk's own words about a refusal, cut to one line, or null when it gave none. + private static async Task ReadProblemDetailAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + try + { + var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); + if (body.Length is 0 or > MaxProblemBytes) + { + return null; + } + + var problem = JsonSerializer.Deserialize(body, SpelunkAccountsJsonContext.Default.SpelunkSignInProblemDocument); + return SpelunkTunnelRoute.Readable(problem?.Detail); + } + catch (Exception exception) when (exception is JsonException or HttpRequestException) + { + return null; + } + } + + private const string Unintelligible = + "Spelunk answered the sign-in in a way this app does not understand. Update DodoSSH, or check that the API " + + "address is Spelunk's."; +} + +/// What the app sends to redeem its code. +internal sealed class SpelunkTokenRequestDocument +{ + public string? ClientId { get; set; } + + public string? Code { get; set; } + + public string? CodeVerifier { get; set; } + + public string? RedirectUri { get; set; } +} + +/// A signed-in app's token, as Spelunk returns it. Nullable because it models untrusted input. +internal sealed class SpelunkTokenDocument +{ + public string? Token { get; set; } + + public Guid? SessionId { get; set; } + + public SpelunkTokenUserDocument? User { get; set; } +} + +internal sealed class SpelunkTokenUserDocument +{ + public string? Email { get; set; } + + public string? DisplayName { get; set; } +} + +/// The part of an RFC 7807 refusal from Spelunk's token endpoint that is shown. +internal sealed class SpelunkSignInProblemDocument +{ + public string? Detail { get; set; } +} + +[JsonSourceGenerationOptions( + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)] +[JsonSerializable(typeof(SpelunkTokenRequestDocument))] +[JsonSerializable(typeof(SpelunkTokenDocument))] +[JsonSerializable(typeof(SpelunkSignInProblemDocument))] +internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext; diff --git a/src/DodoSSH.Client.Session/SpelunkLink.cs b/src/DodoSSH.Client.Session/SpelunkLink.cs index 1abb5d3..645c734 100644 --- a/src/DodoSSH.Client.Session/SpelunkLink.cs +++ b/src/DodoSSH.Client.Session/SpelunkLink.cs @@ -11,17 +11,17 @@ namespace DodoSSH.Client.Session; /// /// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page: /// dodossh://spelunk/connect?api=…&server=…&name=…&host=…. It says which Spelunk and which -/// server, and what to call it. It never carries a credential, and nothing reads one out of it: the -/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could -/// supply somebody else's. +/// server, and what to call it. It never carries a credential, and nothing reads one out of it: who +/// connects is whoever this device signed in to that Spelunk as, and a link that could supply a credential +/// would be a link that could supply somebody else's. /// /// /// Anything on the machine can hand this application a link — a web page, a mail, another program — /// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape, /// and the most it can ever do is connect to a host a link made before, add one for another server on a -/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new -/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does -/// not already send it. See VaultViewModel.OpenSpelunkLinkAsync. +/// Spelunk this device is already signed in to, or open the editor on a new one that the user then has to +/// sign in for and save. It never sends a sign-in anywhere the user did not sign in to themselves. See +/// VaultViewModel.OpenSpelunkLinkAsync. /// /// /// The Spelunk API's base address, normalised as stores it. diff --git a/src/DodoSSH.Client.Session/SpelunkTunnel.cs b/src/DodoSSH.Client.Session/SpelunkTunnel.cs index 7def527..13635cc 100644 --- a/src/DodoSSH.Client.Session/SpelunkTunnel.cs +++ b/src/DodoSSH.Client.Session/SpelunkTunnel.cs @@ -69,7 +69,7 @@ public interface ISpelunkTunnel ///
/// The Spelunk API's base address, normalised by . /// The server's id in Spelunk. - /// The Spelunk API key, which authorises the ticket and is sent nowhere else. + /// This device's sign-in to that Spelunk, which authorises the ticket and is sent nowhere else. /// /// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the /// agent starts an SSH server of its own and Spelunk ignores it. @@ -83,14 +83,14 @@ public interface ISpelunkTunnel /// /// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is /// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will - /// use it and never ahead of one. The key is a parameter rather than a field of some request record - /// because a record prints its fields, and a key has no business in anything that might be logged. + /// use it and never ahead of one. The token is a parameter rather than a field of some request record + /// because a record prints its fields, and a token has no business in anything that might be logged. /// /// The tunnel could not be opened; the message says why. Task OpenAsync( string apiBase, Guid serverId, - string apiKey, + string token, int port, SpelunkSignIn? signIn, CancellationToken cancellationToken); @@ -273,10 +273,12 @@ public sealed class SpelunkTunnel : ISpelunkTunnel /// /// /// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody - /// watching "Connecting…" should hear that Spelunk is not answering while they still care. + /// watching "Connecting…" should hear that Spelunk is not answering while they still care. Redirects are + /// not followed: the sign-in's token goes to the Spelunk it came from, and a redirect is a request to send + /// the ticket request somewhere else. /// private static readonly HttpClient SharedHttp = new( - new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false }) { Timeout = TimeSpan.FromSeconds(30), }; @@ -303,7 +305,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel public async Task OpenAsync( string apiBase, Guid serverId, - string apiKey, + string token, int port, SpelunkSignIn? signIn, CancellationToken cancellationToken) @@ -313,7 +315,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel throw new SpelunkTunnelException(reason); } - var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken) + var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(token), port, signIn, cancellationToken) .ConfigureAwait(false); var socket = await connect( @@ -336,32 +338,27 @@ public sealed class SpelunkTunnel : ISpelunkTunnel return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute); } - /// - /// Trims the stored key and refuses one that could not be a header value. - /// + /// Refuses a stored token that could not be a header value. /// - /// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and - /// one with a space in the middle would otherwise surface as a from inside - /// rather than as a sentence about the credential. + /// One that would otherwise surface as a from inside + /// rather than as a sentence. It came from Spelunk and was checked then, so this is a damaged cache. /// - private static string Sanitise(string apiKey) + private static string Sanitise(string token) { - var trimmed = apiKey?.Trim() ?? string.Empty; - - if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))) + if (string.IsNullOrEmpty(token) || token.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))) { throw new SpelunkTunnelException( - "The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one " - + "unbroken run of characters starting with spelunk_; paste it as the credential's password."); + "This device's sign-in to Spelunk is damaged. Sign in to Spelunk again.", + HttpStatusCode.Unauthorized); } - return trimmed; + return token; } private async Task IssueTicketAsync( string apiBase, Guid serverId, - string apiKey, + string token, int port, SpelunkSignIn? signIn, CancellationToken cancellationToken) @@ -370,7 +367,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel HttpMethod.Post, new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute)); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); // Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes // do not need chunked encoding, and not every proxy in front of an API accepts it. @@ -495,18 +492,19 @@ public sealed class SpelunkTunnel : ISpelunkTunnel ? $": {said}" : ". Update DodoSSH, or check that the API address is Spelunk's."), + // Spelunk says why when it knows: signed out there, a changed password, sessions ended. HttpStatusCode.Unauthorized => - "Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in " - + "Spelunk and paste it into the credential this host uses for it.", + "Spelunk signed this device out" + + (detail is null ? ". Sign in to Spelunk again." : $": {detail}"), - // The detail is the useful half here: it says which role the key has and which it needs. HttpStatusCode.Forbidden => - "The Spelunk API key is not allowed to open a terminal on this server" - + (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"), + "You are not allowed to open a terminal on this server" + + (detail is null + ? ". It is in an organisation you are not a member of, or your role on it is below Admin." + : $": {detail}"), HttpStatusCode.NotFound => - "Spelunk does not know this server in the API key's organisation. It may have been removed, or " - + "the key belongs to a different organisation.", + "Spelunk does not know this server, or you are not allowed to see it. It may have been removed.", HttpStatusCode.ServiceUnavailable => "The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the " diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs index 142cbac..eddd95d 100644 --- a/src/DodoSSH.Client.Session/VaultSession.cs +++ b/src/DodoSSH.Client.Session/VaultSession.cs @@ -107,6 +107,7 @@ public sealed partial class VaultSession : IAsyncDisposable Vault = new VaultStore(caches, clock); Unlock = new UnlockStore(caches, clock); SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock); + SpelunkSignIns = new SpelunkSignInStore(caches, protector, clock); // The two log repositories first, and unaudited: the recorder writes through one of them, so a log // that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what // actually stops it; building them first is what lets the recorder exist before the kinds that use @@ -125,7 +126,6 @@ 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. @@ -201,13 +201,6 @@ 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 @@ -247,6 +240,13 @@ public sealed partial class VaultSession : IAsyncDisposable /// internal RememberedSignInStore SignIn { get; } + /// The Spelunks this device is signed in to, and what each sign-in gave it. + /// + /// Kept on this device only, sealed like the remembered sign-in above and wiped with the cache on sign-out: + /// each device signs in to Spelunk through its own browser. See SpelunkAccounts. + /// + public SpelunkSignInStore SpelunkSignIns { get; } + /// /// Remembers the sign-in this machine currently holds, so a later launch can resume it. /// diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs index 181af00..e39f2a9 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs @@ -15,8 +15,8 @@ internal sealed partial class MainWindowViewModel /// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch /// passed down the doorbell, or one the platform delivered to the running application. The link has /// already been checked by ; what it may do is decided by - /// , which never sends an API key to a Spelunk no saved - /// host already sends it to. + /// , which never starts a sign-in by itself, and only + /// connects through a Spelunk this device is already signed in to. ///
/// /// A locked application keeps it rather than dropping it. Clicking a server in Spelunk and being diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs index 0a7ff5c..a5d9aa2 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs @@ -373,6 +373,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp ///
private readonly ISpelunkTunnel? spelunk; + /// What signs this device in to Spelunk, handed to each vault this shell opens. + /// + /// Null means the system browser and a loopback listener, which is what the desktop uses; the phone passes + /// its own browser and redirect, and a test one that never opens a browser. + /// + private readonly ISpelunkAccounts? spelunkAccounts; + /// /// A dodossh://spelunk/connect link that arrived while there was no open vault to act on it. /// @@ -594,12 +601,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp Func? openInEditor = null, Func? watchFile = null, ISshReachabilityProbe? reachability = null, - ISpelunkTunnel? spelunk = null) + ISpelunkTunnel? spelunk = null, + ISpelunkAccounts? spelunkAccounts = null) { this.reachability = reachability; this.spelunk = spelunk; + this.spelunkAccounts = spelunkAccounts; this.post = post ?? (action => Dispatcher.UIThread.Post(action)); - this.paths = paths; this.caches = caches; this.workspace = workspace; @@ -3620,7 +3628,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp connectionLog, visibility, reachability, - spelunk); + spelunk, + spelunkAccounts, + OnAccountRefusedAsync); State = ShellState.Unlocked; // Offered only where it can actually be honoured: a machine that can keep a key, and a profile that @@ -3834,16 +3844,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp } catch (OidcException exception) when (IsRefusedGrant(exception)) { - // The provider answered and said no: the session was revoked, or the token was rotated and - // this machine kept the old one. Retrying costs a round trip a minute and can only ever get - // the same answer, so the token goes and the user is told the one thing that fixes it. - await ForgetSignInAsync(cancellationToken).ConfigureAwait(true); - - var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} " - + "Sign in again from Preferences to start syncing."; - - vault.OfflineReason = message; - Announce(message); + await OnResumeRefusedAsync(vault, exception, cancellationToken).ConfigureAwait(true); return null; } @@ -3862,6 +3863,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp } } + /// The provider answered a resume and said no. + /// + /// The session was revoked, or the token was rotated and this machine kept the old one. Retrying costs a + /// round trip a minute and can only ever get the same answer, so the token goes and the user is told the one + /// thing that fixes it. + /// + private async Task OnResumeRefusedAsync(VaultViewModel vault, OidcException exception, CancellationToken cancellationToken) + { + await ForgetSignInAsync(cancellationToken).ConfigureAwait(true); + + // And with it every Spelunk this device is signed in to, as when the vault finds the account refused + // before a Spelunk connection: a sign-in there is only ever used on behalf of a DodoSSH account that + // still stands, and this one, for now, does not. + var spelunks = await SignOutOfEverySpelunkQuietlyAsync(vault).ConfigureAwait(true); + + var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} " + + (spelunks ? "This device has been signed out of Spelunk too. " : string.Empty) + + "Sign in again from Preferences to start syncing."; + + vault.OfflineReason = message; + Announce(message); + } + + /// Signs out of every Spelunk, for a DodoSSH sign-in the provider refused; whether there were any. + /// Never throws: it runs where the refusal is already being reported, and a cache that will not take it is wiped on sign-out anyway. + private static async Task SignOutOfEverySpelunkQuietlyAsync(VaultViewModel vault) + { + try + { + var any = vault.HasSpelunkSignIns; + await vault.SignOutOfEverySpelunkAsync(CancellationToken.None).ConfigureAwait(true); + return any; + } + catch (Exception exception) when (exception is not OutOfMemoryException) + { + return false; + } + } + /// /// Whether a failed resume was the provider refusing the token, rather than failing to answer. /// @@ -3903,6 +3943,33 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp } } + /// + /// The identity provider refused this account when the vault checked it — before a Spelunk connection. + /// + /// + /// The same answer a refused resume gets, from the other side: the sign-in is forgotten and the machine goes + /// offline, saying why. The vault has already ended every Spelunk sign-in on this device by then. + /// + private async Task OnAccountRefusedAsync(string reason, CancellationToken cancellationToken) + { + await ForgetSignInAsync(cancellationToken).ConfigureAwait(true); + + connection?.Dispose(); + connection = null; + + var message = $"Your DodoSSH sign-in was refused, so this machine is offline and signed out of Spelunk: " + + $"{reason} Sign in again from Preferences."; + + if (Vault is { } vault) + { + vault.OfflineReason = message; + } + + Announce(message); + OnPropertyChanged(nameof(IsOnline)); + RaiseSyncState(); + } + /// Drops the remembered sign-in, so nothing tries to resume it again. private async Task ForgetSignInAsync(CancellationToken cancellationToken) { @@ -4198,6 +4265,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp { if (Vault is { } vault) { + // Spelunk first, while the sign-ins can still be read: each one ends there too, not only here. + await vault.SignOutOfEverySpelunkAsync(cancellationToken).ConfigureAwait(true); + await vault.Session .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken) .ConfigureAwait(true); diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs index 47eca71..8bd956f 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs @@ -1,4 +1,3 @@ -using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using DodoSSH.Client.Domain; using DodoSSH.Client.Session; @@ -62,18 +61,6 @@ internal sealed partial class VaultViewModel [ObservableProperty] private string editorSpelunkServerId = string.Empty; - /// What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials. - /// - /// 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; } = []; - - /// The credential chosen to hold the Spelunk API key. - [ObservableProperty] - private AuthenticationChoice? editorSelectedSpelunkKey; - /// /// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to. /// @@ -119,7 +106,7 @@ internal sealed partial class VaultViewModel /// /// /// The request a route is used for is made with , which is where a Spelunk sign-in's - /// key and account replace the ones the host resolved to. + /// throwaway key and account replace the ones the host resolved to. /// /// internal async Task OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken) @@ -170,13 +157,13 @@ internal sealed partial class VaultViewModel } /// - /// Opens a Spelunk tunnel to a host, with the key it names. + /// Opens a Spelunk tunnel to a host, as whoever this device is signed in to that Spelunk as. /// /// /// - /// 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 — and for a saved Spelunk key, only when - /// that is the address it was saved for. See . + /// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see + /// . The sign-in only ever goes to the Spelunk it came from, since it is + /// found by that address. One Spelunk refuses (a 401) is forgotten, so the next attempt asks to sign in. /// /// /// The port is the host's resolved one, group and all: it is the port the agent connects to on the @@ -194,17 +181,27 @@ internal sealed partial class VaultViewModel SpelunkRoute route, CancellationToken cancellationToken) { - var apiKey = SpelunkApiKeyFor(row, route); + var signIn = await SpelunkSignInForAsync(row, route, cancellationToken).ConfigureAwait(true); - return await Spelunk - .OpenAsync( - route.ApiBase, - route.ServerId, - apiKey, - row.Resolved.Port.Value, - route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null, - cancellationToken) - .ConfigureAwait(true); + try + { + // The sign-in's own address rather than the route's, though they are one string: the token goes to + // the Spelunk that issued it, and that is said here rather than left to the lookup that found it. + return await Spelunk + .OpenAsync( + signIn.ApiBase, + route.ServerId, + signIn.Token, + row.Resolved.Port.Value, + route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null, + cancellationToken) + .ConfigureAwait(true); + } + catch (SpelunkTunnelException refusal) + { + await ForgetRefusedSpelunkSignInAsync(route.ApiBase, refusal).ConfigureAwait(true); + throw; + } } /// @@ -220,19 +217,17 @@ internal sealed partial class VaultViewModel /// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's. /// /// - /// 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 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. + /// Otherwise, when this device is signed in to that Spelunk, 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. It is saved the ordinary way and syncs like any host. Nothing leaks by this: the + /// sign-in goes back to the Spelunk the user signed in to, and the server is one they may reach or Spelunk + /// refuses it. /// /// - /// 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. + /// A link from a Spelunk this device is not signed in to opens the editor instead, and nothing + /// more. Anything on this machine can make a link, so it must never be able to start a sign-in by + /// itself. The host is pre-filled — signing in through Spelunk, tagged, in the personal vault — and the + /// editor offers to sign in; every later link from that Spelunk connects straight away. /// /// internal async Task OpenSpelunkLinkAsync(SpelunkLink link) @@ -251,15 +246,15 @@ internal sealed partial class VaultViewModel return; } - var knownKey = KnownSpelunkKey(link.ApiBase, personal); + var signedIn = await session.SpelunkSignIns.FindAsync(link.ApiBase, CancellationToken.None).ConfigureAwait(true) is not null; - if (personal is { } into && knownKey is { } key) + if (personal is { } into && signedIn) { - await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true); + await AddFromLinkAndConnectAsync(link, into).ConfigureAwait(true); return; } - await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true); + await OpenLinkInEditorAsync(link, personal).ConfigureAwait(true); } /// The personal vault, when this session can read and write it. @@ -273,33 +268,8 @@ internal sealed partial class VaultViewModel private static bool IsSpelunkTag(TagSecret tag) => string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase); - /// - /// The key the user has already agreed may go to this Spelunk, or null when there is none. - /// - /// - /// - /// 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) => - 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) - || 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() + /// The host a link makes: what the link says, signing in through Spelunk. + private static HostSecret HostFromLink(SpelunkLink link, TagSet tags) => new() { Label = link.Name, Hostname = link.Hostname, @@ -309,13 +279,12 @@ internal sealed partial class VaultViewModel { ApiBase = link.ApiBase, ServerId = link.ServerId, - ApiKeyCredentialId = apiKeyCredentialId, SignInThroughSpelunk = true, }, }; /// Saves the host a link describes into the personal vault, tagged, and connects to it. - private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId) + private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId) { if (IsBusy) { @@ -335,7 +304,7 @@ internal sealed partial class VaultViewModel hostId = await session.Hosts .CreateAsync( vaultId, - HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])), + HostFromLink(link, TagSet.Create([tagId])), CancellationToken.None) .ConfigureAwait(true); @@ -358,14 +327,14 @@ internal sealed partial class VaultViewModel } /// - /// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to. + /// Opens the editor on a new host from a link, for a link from a Spelunk this device is not signed in to. /// /// /// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is /// made or found afterwards and added to the host being edited; a tag made for a host that is then /// cancelled stays, as the editor's own new-tag box leaves it. /// - private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey) + private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal) { // Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link // is not worth somebody's unsaved host. @@ -390,14 +359,12 @@ internal sealed partial class VaultViewModel EditorSpelunkApi = link.ApiBase; EditorSpelunkServerId = link.ServerId.ToString(); - BuildSpelunkKeyChoices(knownKey); - IsHostPaneOpen = true; - var said = EditorSelectedSpelunkKey is null - ? $"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." + var said = EditorSpelunkNeedsSignIn + ? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Sign in to that Spelunk from the editor and " + + "save — Spelunk signs you in to the server, so no SSH key is needed, and later links from this " + + "Spelunk connect straight away." : $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save."; Status = said; @@ -460,22 +427,16 @@ internal sealed partial class VaultViewModel .ConfigureAwait(true); } - /// - /// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential - /// holding its Spelunk API key. - /// + /// Whether a host needs this key or credential to connect, as its resolved binding. /// - /// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask, - /// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk - /// keys are named by the host itself and never inherited, so the stored route is the whole answer there. + /// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask. /// A host Spelunk signs in to uses no binding at all, whatever its group lends. /// private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) => - (!row.Host.SignsInThroughSpelunk - && row.Resolved.Binding is { } binding - && binding.Kind == kind - && binding.EntityId == entityId) - || (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId); + !row.Host.SignsInThroughSpelunk + && row.Resolved.Binding is { } binding + && binding.Kind == kind + && binding.EntityId == entityId; /// Puts a host's route into the editor, or clears it for a host that has none. private void LoadEditorSpelunk(SpelunkRoute? route) @@ -484,36 +445,9 @@ internal sealed partial class VaultViewModel EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false; EditorSpelunkApi = route?.ApiBase ?? string.Empty; EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty; + EditorSpelunkSignInNote = string.Empty; - BuildSpelunkKeyChoices(route?.ApiKeyCredentialId); - } - - /// 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)); - } - - if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing)) - { - EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing)); - } - - EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId); + UpdateEditorSpelunkSignIn(); } /// The editor's route, or null when the box is not ticked. @@ -536,7 +470,6 @@ internal sealed partial class VaultViewModel ? normalized : EditorSpelunkApi.Trim(), ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty, - ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty, SignInThroughSpelunk = EditorSpelunkSignIn, }; } diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs deleted file mode 100644 index 0bcd7b7..0000000 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkKeys.cs +++ /dev/null @@ -1,374 +0,0 @@ -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.SpelunkSignIns.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkSignIns.cs new file mode 100644 index 0000000..6598716 --- /dev/null +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.SpelunkSignIns.cs @@ -0,0 +1,320 @@ +using System.Collections.ObjectModel; +using System.Net; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DodoSSH.Client.Auth; +using DodoSSH.Client.Domain; +using DodoSSH.Client.Session; +using DodoSSH.Client.Storage; + +namespace DodoSSH.Client.Shell.ViewModels; + +/// One Spelunk this device is signed in to, as a row on the Security page. +/// Carries the token for the sign-out that needs it, and shows nothing of it: the XAML binds the address and who. +internal sealed class SpelunkSignInRowViewModel(SpelunkSignInRecord record, int hosts) +{ + internal SpelunkSignInRecord Record => record; + + internal string ApiBase => record.ApiBase; + + /// Who this device is signed in as there. + internal string Who => string.Equals(record.DisplayName, record.Email, StringComparison.Ordinal) + ? record.Email + : $"{record.DisplayName} · {record.Email}"; + + /// How many hosts reach Spelunk through it, in words. + internal string UsedBy { get; } = hosts switch + { + 0 => "No host uses it yet", + 1 => "1 host uses it", + _ => $"{hosts} hosts use it", + }; +} + +/// +/// Signing this device in to Spelunk, and what a sign-in lets a host and a link do. +/// +/// +/// +/// Per device, through the browser. Each device signs in to a Spelunk once — Settings → Security, or the +/// button in a Spelunk host's editor — and Spelunk asks its person, on its own page, whether to let this +/// device in. What comes back is kept on this device only () and +/// opens terminals on that Spelunk's servers in every organisation its person is in, as them. +/// +/// +/// It ends with the person. Spelunk refuses it once they are removed, sign it out, or change their +/// password — a 401, after which it is forgotten here and the user is asked to sign in again. And DodoSSH +/// checks its own account before every Spelunk connection (), so an +/// account removed from DodoSSH stops reaching Spelunk too. +/// +/// +internal sealed partial class VaultViewModel +{ + /// What signs in to Spelunk when no caller supplied something else: the system browser and a loopback listener. + private static readonly ISpelunkAccounts DefaultSpelunkAccounts = new SpelunkAccounts(new SystemBrowserLauncher()); + + /// The Spelunks this device is signed in to. + internal ObservableCollection SpelunkSignIns { get; } = []; + + /// Whether there is any sign-in to list. + internal bool HasSpelunkSignIns => SpelunkSignIns.Count > 0; + + /// The Spelunk API address in the Security page's form, as typed. + [ObservableProperty] + private string spelunkSignInFormApi = string.Empty; + + /// What the Security page's form has to say about the last sign-in, 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] + [NotifyPropertyChangedFor(nameof(HasSpelunkSignInFormNote))] + private string spelunkSignInFormNote = string.Empty; + + /// Whether a sign-in is waiting for the browser. + [ObservableProperty] + private bool isSigningInToSpelunk; + + /// Who the editor's Spelunk would be reached as from this device, or why it would not be. + [ObservableProperty] + private string editorSpelunkSignInNote = string.Empty; + + /// Whether this device still has to sign in to the Spelunk the editor names. + [ObservableProperty] + private bool editorSpelunkNeedsSignIn; + + internal bool HasSpelunkSignInFormNote => SpelunkSignInFormNote.Length > 0; + + /// + private ISpelunkAccounts SpelunkAccounts => spelunkAccounts ?? DefaultSpelunkAccounts; + + partial void OnEditorSpelunkApiChanged(string value) => UpdateEditorSpelunkSignIn(); + + /// Signs this device in to the Spelunk in the Security page's form. + [RelayCommand(IncludeCancelCommand = true)] + private async Task SignInToSpelunkAsync(CancellationToken cancellationToken) + { + SpelunkSignInFormNote = string.Empty; + + if (!SpelunkRoute.TryNormalizeApiBase(SpelunkSignInFormApi, out var apiBase, out var reason)) + { + SpelunkSignInFormNote = reason; + return; + } + + SpelunkSignInFormApi = apiBase; + SpelunkSignInFormNote = await SignInToSpelunkCoreAsync(apiBase, cancellationToken).ConfigureAwait(true); + } + + /// Signs this device in to the Spelunk the host editor names. + [RelayCommand(IncludeCancelCommand = true)] + private async Task SignInToEditorSpelunkAsync(CancellationToken cancellationToken) + { + if (!SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var apiBase, out var reason)) + { + EditorSpelunkSignInNote = reason; + return; + } + + EditorSpelunkSignInNote = await SignInToSpelunkCoreAsync(apiBase, cancellationToken).ConfigureAwait(true); + UpdateEditorSpelunkSignIn(keepNote: true); + } + + /// Signs this device out of one Spelunk: there, when it can be reached, and here regardless. + [RelayCommand] + private async Task SignOutOfSpelunkAsync(SpelunkSignInRowViewModel? row) + { + if (row is null) + { + return; + } + + await SpelunkAccounts.SignOutAsync(row.Record, CancellationToken.None).ConfigureAwait(true); + await session.SpelunkSignIns.ForgetAsync(row.ApiBase, CancellationToken.None).ConfigureAwait(true); + await ReloadSpelunkSignInsAsync(CancellationToken.None).ConfigureAwait(true); + + Status = $"Signed out of Spelunk at {row.ApiBase}."; + SpelunkSignInFormNote = Status; + } + + /// + /// Signs out of every Spelunk this device is signed in to — on signing out of DodoSSH, and when DodoSSH + /// finds its own account refused. Best effort there, certain here. + /// + internal async Task SignOutOfEverySpelunkAsync(CancellationToken cancellationToken) + { + foreach (var signIn in await session.SpelunkSignIns.ListAsync(cancellationToken).ConfigureAwait(true)) + { + await SpelunkAccounts.SignOutAsync(signIn, cancellationToken).ConfigureAwait(true); + } + + await session.SpelunkSignIns.ForgetAllAsync(cancellationToken).ConfigureAwait(true); + await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true); + } + + /// The whole of a sign-in, from either place it starts; returns what to say about it. + /// + /// DodoSSH has to be online and signed in to start one, for the reason it has to be to use one: + /// Spelunk is reached as somebody this DodoSSH account vouches for on this device. + /// + private async Task SignInToSpelunkCoreAsync(string apiBase, CancellationToken cancellationToken) + { + if (IsSigningInToSpelunk) + { + return "A sign-in to Spelunk is already waiting for the browser."; + } + + IsSigningInToSpelunk = true; + + try + { + await VerifyDodoSshSignInAsync(cancellationToken).ConfigureAwait(true); + + Status = $"Finish signing in to Spelunk at {apiBase} in your browser…"; + + var signIn = await SpelunkAccounts.SignInAsync(apiBase, cancellationToken).ConfigureAwait(true); + await session.SpelunkSignIns.SaveAsync(signIn, cancellationToken).ConfigureAwait(true); + await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true); + + Status = $"Signed in to Spelunk at {apiBase} as {signIn.Email}. Links from that Spelunk now connect straight away."; + return Status; + } + catch (OperationCanceledException) + { + Status = "Signing in to Spelunk was cancelled."; + return Status; + } + catch (Exception exception) when (exception is SpelunkSignInException or SpelunkTunnelException) + { + Status = exception.Message; + return Status; + } + finally + { + IsSigningInToSpelunk = false; + } + } + + /// + /// Refuses unless DodoSSH is online and its account still signed in — asked of the identity provider + /// itself, at most once a minute (see ). + /// + /// + /// An account the provider refuses is treated as signed out everywhere it reaches: every Spelunk sign-in + /// on this device is ended, and the shell forgets the DodoSSH sign-in and goes offline (see + /// accountRefused). Anything short of a refusal is a check that could not be made, and a Spelunk + /// connection waits for one that can rather than going ahead unchecked. + /// + /// Not online, not signed in, or refused; the message says which. + private async Task VerifyDodoSshSignInAsync(CancellationToken cancellationToken) + { + if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server) + { + throw new SpelunkTunnelException( + "Connecting through Spelunk needs DodoSSH online and signed in, so that your account can be " + + "checked first. " + (OfflineReason ?? "This machine is offline.")); + } + + try + { + await server.VerifySignInAsync(cancellationToken).ConfigureAwait(true); + + // Asked again only for what asking does to a connection already held: the shell writes down the + // refresh token the check may just have rotated, which is the only one the next launch can use. + // Left for the next sync pass, a crash in between would cost a browser sign-in. + await ResolveServerAsync(cancellationToken).ConfigureAwait(true); + } + catch (OidcException exception) when (string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal)) + { + await SignOutOfEverySpelunkAsync(CancellationToken.None).ConfigureAwait(true); + + if (accountRefused is { } refused) + { + await refused(exception.Message, CancellationToken.None).ConfigureAwait(true); + } + + throw new SpelunkTunnelException( + "Your DodoSSH account no longer signs in, so this device has been signed out of DodoSSH and of " + + "Spelunk. Sign in again from Preferences."); + } + catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or SpelunkTunnelException)) + { + throw new SpelunkTunnelException( + $"DodoSSH could not check that you are still signed in ({exception.Message}). Connecting through " + + "Spelunk needs that check; try again once the server can be reached.", + exception); + } + } + + /// + /// What to reach a host's Spelunk with: this device's sign-in there, once DodoSSH's own has been checked. + /// + /// There is none, or DodoSSH's own was refused. + private async Task SpelunkSignInForAsync( + HostRowViewModel row, + SpelunkRoute route, + CancellationToken cancellationToken) + { + await VerifyDodoSshSignInAsync(cancellationToken).ConfigureAwait(true); + + return await session.SpelunkSignIns.FindAsync(route.ApiBase, cancellationToken).ConfigureAwait(true) + ?? throw new SpelunkTunnelException( + $"'{row.Label}' is reached through Spelunk at {route.ApiBase}, and this device is not signed in " + + "there. Sign in under Settings → Security → Spelunk, or from the host's editor."); + } + + /// Forgets a sign-in Spelunk refused, so the next attempt asks for a new one instead of repeating the refusal. + private async Task ForgetRefusedSpelunkSignInAsync(string apiBase, SpelunkTunnelException refusal) + { + if (refusal.StatusCode != HttpStatusCode.Unauthorized) + { + return; + } + + await session.SpelunkSignIns.ForgetAsync(apiBase, CancellationToken.None).ConfigureAwait(true); + await ReloadSpelunkSignInsAsync(CancellationToken.None).ConfigureAwait(true); + } + + /// Read after the hosts, because each row says how many of them reach Spelunk through it. + internal async Task ReloadSpelunkSignInsAsync(CancellationToken cancellationToken) + { + var signIns = await session.SpelunkSignIns.ListAsync(cancellationToken).ConfigureAwait(true); + + SpelunkSignIns.Clear(); + + foreach (var signIn in signIns) + { + SpelunkSignIns.Add(new SpelunkSignInRowViewModel( + signIn, + Hosts.Count(row => string.Equals(row.Host.Spelunk?.ApiBase, signIn.ApiBase, StringComparison.Ordinal)))); + } + + OnPropertyChanged(nameof(HasSpelunkSignIns)); + UpdateEditorSpelunkSignIn(keepNote: true); + } + + /// Says, in the host editor, whether this device can reach the Spelunk it names. + /// Whether to leave a sign-in's own outcome in place rather than restating the state. + private void UpdateEditorSpelunkSignIn(bool keepNote = false) + { + if (!SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var apiBase, out _)) + { + EditorSpelunkNeedsSignIn = false; + EditorSpelunkSignInNote = string.Empty; + return; + } + + var signIn = SpelunkSignIns.FirstOrDefault(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal)); + EditorSpelunkNeedsSignIn = signIn is null; + + if (keepNote && EditorSpelunkSignInNote.Length > 0 && signIn is null) + { + return; + } + + EditorSpelunkSignInNote = signIn is null + ? $"This device is not signed in to Spelunk at {apiBase}. Sign in to connect — Spelunk asks in your browser." + : $"This device reaches it as {signIn.Who}."; + } +} diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs index 06a4436..1270605 100644 --- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs +++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs @@ -1343,9 +1343,6 @@ internal enum DeletionTarget /// A tag, from the vault screen. Tag, - - /// A saved Spelunk key, from the Security page. - SpelunkKey, } /// @@ -1486,7 +1483,9 @@ internal sealed partial class VaultViewModel( ConnectionRecorder? connectionLog = null, VaultVisibility? visibility = null, ISshReachabilityProbe? reachability = null, - ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable + ISpelunkTunnel? spelunk = null, + ISpelunkAccounts? spelunkAccounts = null, + Func? accountRefused = null) : ObservableObject, IAsyncDisposable { /// What explains a failed connection when no caller supplied something else. /// @@ -4596,7 +4595,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); + await ReloadSpelunkSignInsAsync(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. @@ -4651,7 +4650,6 @@ internal sealed partial class VaultViewModel( // longer say what the user chose. var authentication = EditorSelectedAuthentication; var groupId = EditorSelectedGroup?.EntityId; - var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId; BuildTagChoices(); @@ -4670,8 +4668,6 @@ internal sealed partial class VaultViewModel( authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null, asksForPassword: authentication?.Kind == AuthenticationKind.Typed, grouped: EditorSelectedGroup?.EntityId is not null); - - BuildSpelunkKeyChoices(spelunkKeyId); } if (IsEditingGroup) @@ -11147,10 +11143,6 @@ 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/CacheRows.cs b/src/DodoSSH.Client.Storage/CacheRows.cs index cfca77b..1cc19bf 100644 --- a/src/DodoSSH.Client.Storage/CacheRows.cs +++ b/src/DodoSSH.Client.Storage/CacheRows.cs @@ -109,6 +109,29 @@ internal sealed class RememberedSignInRow public DateTimeOffset UpdatedAtUtc { get; set; } } +/// This device's sign-in to one Spelunk. +/// +/// +/// Everything sealed, the Spelunk's address included, in one record bound to its row: which Spelunk somebody +/// manages their servers with is not for a stolen laptop to say. There are a handful at most, so finding the +/// one for an address opens them all. +/// +/// +/// Per device, never synced: each device signs in through its own browser, and Spelunk lists and signs out +/// each one on its own. Like , sign-out deletes every row with the rest of +/// the cache. +/// +/// +internal sealed class SpelunkSignInRow +{ + public Guid Id { get; set; } + + /// The sign-in, sealed under the LocalCacheKey. Ciphertext. + public byte[] Sealed { get; set; } = []; + + public DateTimeOffset UpdatedAtUtc { get; set; } +} + /// A vault the user can reach, with the grant that opens it. /// /// Cached so the vault list and the key needed to decrypt it are both available offline. The name is diff --git a/src/DodoSSH.Client.Storage/ClientCacheContext.cs b/src/DodoSSH.Client.Storage/ClientCacheContext.cs index a8c1272..5c3a51b 100644 --- a/src/DodoSSH.Client.Storage/ClientCacheContext.cs +++ b/src/DodoSSH.Client.Storage/ClientCacheContext.cs @@ -74,6 +74,7 @@ public sealed class ClientCacheContext(DbContextOptions opti ConfigureUnlockMaterial(modelBuilder); ConfigureRememberedSignIn(modelBuilder); + ConfigureSpelunkSignIns(modelBuilder); ConfigureVaults(modelBuilder); ConfigureItems(modelBuilder); ConfigureOutbox(modelBuilder); @@ -123,6 +124,15 @@ public sealed class ClientCacheContext(DbContextOptions opti entity.Property(row => row.SealedRefreshToken).IsRequired(); }); + private static void ConfigureSpelunkSignIns(ModelBuilder modelBuilder) => + modelBuilder.Entity(entity => + { + entity.ToTable("spelunk_sign_in"); + entity.HasKey(row => row.Id); + entity.Property(row => row.Id).ValueGeneratedNever(); + entity.Property(row => row.Sealed).IsRequired(); + }); + private static void ConfigureVaults(ModelBuilder modelBuilder) { modelBuilder.Entity(entity => diff --git a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs index c23f630..d9352a0 100644 --- a/src/DodoSSH.Client.Storage/LocalCacheProtector.cs +++ b/src/DodoSSH.Client.Storage/LocalCacheProtector.cs @@ -141,7 +141,6 @@ 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.Storage/Migrations/20261007152750_AddSpelunkSignIns.Designer.cs b/src/DodoSSH.Client.Storage/Migrations/20261007152750_AddSpelunkSignIns.Designer.cs new file mode 100644 index 0000000..b1f1316 --- /dev/null +++ b/src/DodoSSH.Client.Storage/Migrations/20261007152750_AddSpelunkSignIns.Designer.cs @@ -0,0 +1,502 @@ +// +using System; +using DodoSSH.Client.Storage; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DodoSSH.Client.Storage.Migrations +{ + [DbContext(typeof(ClientCacheContext))] + [Migration("20261007152750_AddSpelunkSignIns")] + partial class AddSpelunkSignIns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("EntityType") + .HasColumnType("INTEGER") + .HasColumnName("entity_type"); + + b.Property("EntityId") + .HasColumnType("TEXT") + .HasColumnName("entity_id"); + + b.Property("AadVersion") + .HasColumnType("INTEGER") + .HasColumnName("aad_version"); + + b.Property("ChangeSequence") + .HasColumnType("INTEGER") + .HasColumnName("change_sequence"); + + b.Property("DataKeyId") + .HasColumnType("TEXT") + .HasColumnName("data_key_id"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER") + .HasColumnName("is_deleted"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("Payload") + .HasColumnType("BLOB") + .HasColumnName("payload"); + + b.Property("ProtectedFields") + .HasColumnType("BLOB") + .HasColumnName("protected_fields"); + + b.Property("SweepMark") + .HasColumnType("INTEGER") + .HasColumnName("sweep_mark"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.Property("Version") + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WrappedDataKey") + .HasColumnType("BLOB") + .HasColumnName("wrapped_data_key"); + + b.HasKey("VaultId", "EntityType", "EntityId") + .HasName("pk_item"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_item_vault_id_change_sequence"); + + b.HasIndex("VaultId", "EntityType") + .HasDatabaseName("ix_item_vault_id_entity_type"); + + b.ToTable("item", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("WrappedKey") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("wrapped_key"); + + b.HasKey("VaultId", "KeyGeneration") + .HasName("pk_vault_key_wrap"); + + b.ToTable("vault_key_wrap", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("Hidden") + .HasColumnType("INTEGER") + .HasColumnName("hidden"); + + b.Property("IsPersonal") + .HasColumnType("INTEGER") + .HasColumnName("is_personal"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("INTEGER") + .HasColumnName("permissions"); + + b.Property("RekeyRequired") + .HasColumnType("INTEGER") + .HasColumnName("rekey_required"); + + b.Property("TeamId") + .HasColumnType("TEXT") + .HasColumnName("team_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.Property("WrappedVaultKey") + .HasColumnType("BLOB") + .HasColumnName("wrapped_vault_key"); + + b.HasKey("VaultId") + .HasName("pk_vault"); + + b.ToTable("vault", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Acknowledged") + .HasColumnType("INTEGER") + .HasColumnName("acknowledged"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("detail"); + + b.Property("DetectedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("detected_at_utc"); + + b.Property("EntityId") + .HasColumnType("TEXT") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("INTEGER") + .HasColumnName("entity_type"); + + b.Property("Kind") + .HasColumnType("INTEGER") + .HasColumnName("kind"); + + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.HasKey("Id") + .HasName("pk_conflict"); + + b.HasIndex("VaultId", "Acknowledged") + .HasDatabaseName("ix_conflict_vault_id_acknowledged"); + + b.HasIndex("VaultId", "EntityType", "EntityId") + .HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id"); + + b.ToTable("conflict", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("sequence"); + + b.Property("AadVersion") + .HasColumnType("INTEGER") + .HasColumnName("aad_version"); + + b.Property("AncestorAadVersion") + .HasColumnType("INTEGER") + .HasColumnName("ancestor_aad_version"); + + b.Property("AncestorDataKeyId") + .HasColumnType("TEXT") + .HasColumnName("ancestor_data_key_id"); + + b.Property("AncestorKeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("ancestor_key_generation"); + + b.Property("AncestorPayload") + .HasColumnType("BLOB") + .HasColumnName("ancestor_payload"); + + b.Property("AncestorProtectedFields") + .HasColumnType("BLOB") + .HasColumnName("ancestor_protected_fields"); + + b.Property("AncestorVersion") + .HasColumnType("INTEGER") + .HasColumnName("ancestor_version"); + + b.Property("AncestorWrappedDataKey") + .HasColumnType("BLOB") + .HasColumnName("ancestor_wrapped_data_key"); + + b.Property("Attempts") + .HasColumnType("INTEGER") + .HasColumnName("attempts"); + + b.Property("DataKeyId") + .HasColumnType("TEXT") + .HasColumnName("data_key_id"); + + b.Property("EntityId") + .HasColumnType("TEXT") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("INTEGER") + .HasColumnName("entity_type"); + + b.Property("ExpectedVersion") + .HasColumnType("INTEGER") + .HasColumnName("expected_version"); + + b.Property("IsParked") + .HasColumnType("INTEGER") + .HasColumnName("is_parked"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("LastError") + .HasColumnType("TEXT") + .HasColumnName("last_error"); + + b.Property("Operation") + .HasColumnType("INTEGER") + .HasColumnName("operation"); + + b.Property("OperationId") + .HasColumnType("TEXT") + .HasColumnName("operation_id"); + + b.Property("Payload") + .HasColumnType("BLOB") + .HasColumnName("payload"); + + b.Property("ProtectedFields") + .HasColumnType("BLOB") + .HasColumnName("protected_fields"); + + b.Property("QueuedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("queued_at_utc"); + + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("WrappedDataKey") + .HasColumnType("BLOB") + .HasColumnName("wrapped_data_key"); + + b.HasKey("Sequence") + .HasName("pk_outbox"); + + b.HasIndex("OperationId") + .IsUnique() + .HasDatabaseName("ix_outbox_operation_id"); + + b.HasIndex("VaultId", "EntityType", "EntityId") + .IsUnique() + .HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id"); + + b.HasIndex("VaultId", "IsParked", "Sequence") + .HasDatabaseName("ix_outbox_vault_id_is_parked_sequence"); + + b.ToTable("outbox", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("SealedRefreshToken") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("sealed_refresh_token"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.HasKey("Id") + .HasName("pk_remembered_sign_in"); + + b.ToTable("remembered_sign_in", null, t => + { + t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1"); + }); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Sealed") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("sealed"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.HasKey("Id") + .HasName("pk_spelunk_sign_in"); + + b.ToTable("spelunk_sign_in", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("Cursor") + .HasColumnType("TEXT") + .HasColumnName("cursor"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("LastPulledAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("last_pulled_at_utc"); + + b.Property("LastPushedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("last_pushed_at_utc"); + + b.Property("PulledTypes") + .HasColumnType("TEXT") + .HasColumnName("pulled_types"); + + b.Property("ServerTimeSkewMs") + .HasColumnType("INTEGER") + .HasColumnName("server_time_skew_ms"); + + b.Property("SweepMark") + .HasColumnType("INTEGER") + .HasColumnName("sweep_mark"); + + b.Property("SweepSeenUpTo") + .HasColumnType("INTEGER") + .HasColumnName("sweep_seen_up_to"); + + b.HasKey("VaultId") + .HasName("pk_sync_state"); + + b.ToTable("sync_state", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DeviceId") + .HasColumnType("TEXT") + .HasColumnName("device_id"); + + b.Property("DeviceWrappedPrivateKey") + .HasColumnType("BLOB") + .HasColumnName("device_wrapped_private_key"); + + b.Property("DisplayName") + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("issuer"); + + b.Property("KdfAlgorithm") + .IsRequired() + .HasColumnType("TEXT") + .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") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("kdf_salt"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("ServerUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subject"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WrappedPrivateKey") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("wrapped_private_key"); + + b.HasKey("Id") + .HasName("pk_unlock_material"); + + b.ToTable("unlock_material", null, t => + { + t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DodoSSH.Client.Storage/Migrations/20261007152750_AddSpelunkSignIns.cs b/src/DodoSSH.Client.Storage/Migrations/20261007152750_AddSpelunkSignIns.cs new file mode 100644 index 0000000..e0df561 --- /dev/null +++ b/src/DodoSSH.Client.Storage/Migrations/20261007152750_AddSpelunkSignIns.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Client.Storage.Migrations +{ + /// + /// Adds this device's Spelunk sign-ins, and drops what is left of Spelunk keys (entity type 14), which + /// they replaced: their cached items, their outbox entries and their conflicts. + /// + public partial class AddSpelunkSignIns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM item WHERE entity_type = 14;"); + migrationBuilder.Sql("DELETE FROM outbox WHERE entity_type = 14;"); + migrationBuilder.Sql("DELETE FROM conflict WHERE entity_type = 14;"); + + migrationBuilder.CreateTable( + name: "spelunk_sign_in", + columns: table => new + { + id = table.Column(type: "TEXT", nullable: false), + @sealed = table.Column(name: "sealed", type: "BLOB", nullable: false), + updated_at_utc = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_spelunk_sign_in", x => x.id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "spelunk_sign_in"); + } + } +} diff --git a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs index a0b2dd7..f12e545 100644 --- a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs +++ b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs @@ -344,6 +344,27 @@ namespace DodoSSH.Client.Storage.Migrations }); }); + modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Sealed") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("sealed"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.HasKey("Id") + .HasName("pk_spelunk_sign_in"); + + b.ToTable("spelunk_sign_in", (string)null); + }); + modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b => { b.Property("VaultId") diff --git a/src/DodoSSH.Client.Storage/SpelunkSignInStore.cs b/src/DodoSSH.Client.Storage/SpelunkSignInStore.cs new file mode 100644 index 0000000..d10345b --- /dev/null +++ b/src/DodoSSH.Client.Storage/SpelunkSignInStore.cs @@ -0,0 +1,166 @@ +using System.Security.Cryptography; +using System.Text.Json; +using DodoSSH.Crypto; +using Microsoft.EntityFrameworkCore; + +namespace DodoSSH.Client.Storage; + +/// This device's sign-in to one Spelunk, as keeps it. +/// The Spelunk API's base address, normalised as a host's route stores it. +/// What Spelunk issued this device: sent as the bearer token on every ticket request. +/// Spelunk's id for this sign-in, which its person sees under Your account. +/// Who it is signed in as, for saying so. +/// The same, as Spelunk names them. +/// When this device signed in. +public sealed record SpelunkSignInRecord( + string ApiBase, + string Token, + Guid SessionId, + string Email, + string DisplayName, + DateTimeOffset SignedInAtUtc) +{ + /// Keeps the token out of logs and debugger views. + public override string ToString() => $"{Email} at {ApiBase}"; +} + +/// +/// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see +/// for why per device and why sealed whole. +/// +public sealed class SpelunkSignInStore( + IDbContextFactory contexts, + LocalCacheProtector protector, + TimeProvider clock) +{ + /// Every sign-in this key can open. One that will not open is skipped, as a stale cache record is. + public async Task> ListAsync(CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set() + .AsNoTracking() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return rows + .Select(Open) + .OfType() + .OrderBy(record => record.ApiBase, StringComparer.Ordinal) + .ToList(); + } + + /// The sign-in for a Spelunk, or null when this device is not signed in to it. + public async Task FindAsync(string apiBase, CancellationToken cancellationToken) => + (await ListAsync(cancellationToken).ConfigureAwait(false)) + .FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal)); + + /// Keeps a sign-in, replacing any this device had for the same Spelunk. + public async Task SaveAsync(SpelunkSignInRecord record, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(record); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set().ToListAsync(cancellationToken).ConfigureAwait(false); + context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, record.ApiBase, StringComparison.Ordinal))); + + var id = Guid.CreateVersion7(); + var bytes = Serialize(record); + try + { + context.Add(new SpelunkSignInRow + { + Id = id, + Sealed = protector.Protect(CryptoSpec.AadResourceType.SpelunkSignIn, id, bytes), + UpdatedAtUtc = clock.GetUtcNow(), + }); + } + finally + { + CryptographicOperations.ZeroMemory(bytes); + } + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// Forgets the sign-in for a Spelunk, if this device had one. + public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var rows = await context.Set().ToListAsync(cancellationToken).ConfigureAwait(false); + context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, apiBase, StringComparison.Ordinal))); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// Forgets every Spelunk sign-in on this device. + public async Task ForgetAllAsync(CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + await context.Set().ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + private SpelunkSignInRecord? Open(SpelunkSignInRow row) + { + var opened = protector.TryUnprotect(CryptoSpec.AadResourceType.SpelunkSignIn, row.Id, row.Sealed); + if (opened is null) + { + return null; + } + + try + { + return Deserialize(opened); + } + finally + { + CryptographicOperations.ZeroMemory(opened); + } + } + + // Written by hand rather than through a serializer context: six fields, and nothing here should depend + // on reflection the Android build trims away. + private static byte[] Serialize(SpelunkSignInRecord record) + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteString("api", record.ApiBase); + writer.WriteString("token", record.Token); + writer.WriteString("session", record.SessionId); + writer.WriteString("email", record.Email); + writer.WriteString("name", record.DisplayName); + writer.WriteString("at", record.SignedInAtUtc); + writer.WriteEndObject(); + } + + return buffer.ToArray(); + } + + private static SpelunkSignInRecord? Deserialize(byte[] bytes) + { + try + { + using var document = JsonDocument.Parse(bytes); + var root = document.RootElement; + return new SpelunkSignInRecord( + root.GetProperty("api").GetString()!, + root.GetProperty("token").GetString()!, + root.GetProperty("session").GetGuid(), + root.GetProperty("email").GetString()!, + root.GetProperty("name").GetString()!, + root.GetProperty("at").GetDateTimeOffset()); + } + catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or FormatException) + { + return null; + } + } +} diff --git a/src/DodoSSH.Client.Sync/ItemKinds.cs b/src/DodoSSH.Client.Sync/ItemKinds.cs index 575c1b1..9400082 100644 --- a/src/DodoSSH.Client.Sync/ItemKinds.cs +++ b/src/DodoSSH.Client.Sync/ItemKinds.cs @@ -170,9 +170,6 @@ 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. @@ -1061,82 +1058,3 @@ 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 deleted file mode 100644 index 7ce6ce5..0000000 --- a/src/DodoSSH.Client.Sync/SpelunkKeyCipher.cs +++ /dev/null @@ -1,125 +0,0 @@ -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 deleted file mode 100644 index 32e83b9..0000000 --- a/src/DodoSSH.Client.Sync/SpelunkKeyRepository.cs +++ /dev/null @@ -1,46 +0,0 @@ -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.Client.Sync/SyncEngine.cs b/src/DodoSSH.Client.Sync/SyncEngine.cs index cbefd64..8b970bc 100644 --- a/src/DodoSSH.Client.Sync/SyncEngine.cs +++ b/src/DodoSSH.Client.Sync/SyncEngine.cs @@ -219,7 +219,7 @@ public sealed class SyncEngine /// Where the pull should carry on from, which is the start when the types it reads have changed. /// /// A cursor read under a different list of types is past changes of the types it did not ask for, so an - /// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made + /// upgrade that adds one — ObjectStore, say — would never receive the items of that type made /// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes /// the restart for a refused cursor safe as well. A state from before the list was kept has none and /// starts over once for the same reason: it may already have missed some. diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt index 2320a5f..0693bc8 100644 --- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -471,7 +471,6 @@ 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 dde12cf..3feb411 100644 --- a/src/DodoSSH.Contracts/SyncEntityType.cs +++ b/src/DodoSSH.Contracts/SyncEntityType.cs @@ -67,13 +67,6 @@ public enum SyncEntityType /// 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, + // 14 was SpelunkKey, a Spelunk API key and its address, removed while still in beta when signing in to + // Spelunk replaced it. Not to be reused: an old client may still send it, and must be refused as unknown. } diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs index 1f4452d..a41fed9 100644 --- a/src/DodoSSH.Crypto/CryptoSpec.cs +++ b/src/DodoSSH.Crypto/CryptoSpec.cs @@ -174,8 +174,11 @@ 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, + // 17 was SpelunkKey, removed with its item type (SyncEntityType 14). Not to be reused, so no payload + // sealed under it can ever open as something else. + + /// This device's sign-in to one Spelunk. Local cache only; never synced. + SpelunkSignIn = 18, } /// 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 0bef840..777bc88 100644 --- a/src/DodoSSH.Domain/Hosts.cs +++ b/src/DodoSSH.Domain/Hosts.cs @@ -556,62 +556,6 @@ 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 208fb7b..76d197a 100644 --- a/src/DodoSSH.Domain/SyncLog.cs +++ b/src/DodoSSH.Domain/SyncLog.cs @@ -58,8 +58,7 @@ 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, + // 14 was SpelunkKey, removed with its table; see the RemoveSpelunkKeyItem migration. Not to be reused. } /// diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs index f50b253..df208d9 100644 --- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs +++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs @@ -384,41 +384,6 @@ 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 5671e1d..5be6bc0 100644 --- a/src/DodoSSH.Infrastructure/DodoDbContext.cs +++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs @@ -81,9 +81,6 @@ 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/20261007152724_RemoveSpelunkKeyItem.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20261007152724_RemoveSpelunkKeyItem.Designer.cs new file mode 100644 index 0000000..9087ccc --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20261007152724_RemoveSpelunkKeyItem.Designer.cs @@ -0,0 +1,1911 @@ +// +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("20261007152724_RemoveSpelunkKeyItem")] + partial class RemoveSpelunkKeyItem + { + /// + 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("SyncHorizon") + .HasColumnType("bigint") + .HasColumnName("sync_horizon"); + + 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.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.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/20261007152724_RemoveSpelunkKeyItem.cs b/src/DodoSSH.Infrastructure/Migrations/20261007152724_RemoveSpelunkKeyItem.cs new file mode 100644 index 0000000..578f1f1 --- /dev/null +++ b/src/DodoSSH.Infrastructure/Migrations/20261007152724_RemoveSpelunkKeyItem.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Infrastructure.Migrations +{ + /// + /// Removes Spelunk keys, the API keys DodoSSH sent to Spelunk: each device now signs in to Spelunk and + /// keeps that sign-in to itself, so there is nothing of Spelunk's left to sync. + /// + /// + /// Destructive, deliberately: still in beta, and a key has no use without the code that sent it. Their + /// change-log entries go too (entity type 14), so no client is ever handed a change of a type it no longer + /// knows. Down recreates the empty table and nothing in it. + /// + public partial class RemoveSpelunkKeyItem : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM dodo.sync_change WHERE entity_type = 14;"); + + migrationBuilder.DropTable( + name: "spelunk_key", + schema: "dodo"); + } + + /// + protected override void Down(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), + change_sequence = table.Column(type: "bigint", nullable: false), + content_key_id = table.Column(type: "uuid", nullable: true), + created_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + created_by_user_id = table.Column(type: "uuid", nullable: false), + data_key_wrap = table.Column(type: "bytea", nullable: true), + deleted_at_utc = table.Column(type: "timestamp with time zone", nullable: true), + key_generation = table.Column(type: "integer", nullable: false), + payload = table.Column(type: "bytea", nullable: false), + payload_aad_version = table.Column(type: "smallint", nullable: false), + updated_at_utc = table.Column(type: "timestamp with time zone", nullable: false), + updated_by_user_id = table.Column(type: "uuid", nullable: false), + version = table.Column(type: "integer", 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"); + } + } +} diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs index ad73147..c556372 100644 --- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs +++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs @@ -1491,87 +1491,6 @@ 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") @@ -1939,18 +1858,6 @@ 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 b144bcc..ad04271 100644 --- a/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs +++ b/tests/DodoSSH.Api.Tests/SyncEndpointTests.cs @@ -1186,52 +1186,21 @@ public sealed class SyncEndpointTests(ApiFixture fixture) } [Fact] - public async Task ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll() + public async Task ASpelunkKeyFromAnOlderClient_IsRefused_AsATypeThisServerDoesNotSync() { + // 14 was SpelunkKey, removed when DodoSSH started signing in to Spelunk. An older client may still push + // one; it is told so for that item, and nothing of it is kept. 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])])); + new SyncPushRequest([RetiredSpelunkKeyOperation(Guid.CreateVersion7(), [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(); + var result = (await pushed.Content.ReadContractAsync())!.Results.ShouldHaveSingleItem(); result.Status.ShouldBe(SyncOperationStatus.Invalid); - result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted"); + result.Detail.ShouldNotBeNull().ShouldContain("not yet supported"); } [Fact] @@ -1724,16 +1693,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture) Payload(envelope), PlaintextFields: null); - private static SyncPushOperation SpelunkKeyOperation( - Guid entityId, - int? expectedVersion, - byte[] envelope) => + private static SyncPushOperation RetiredSpelunkKeyOperation(Guid entityId, byte[] envelope) => new( Guid.CreateVersion7(), - SyncEntityType.SpelunkKey, + (SyncEntityType)14, entityId, SyncOperation.Upsert, - expectedVersion, + ExpectedVersion: null, Payload(envelope), PlaintextFields: null); diff --git a/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs index 2a6aae8..62ddb9f 100644 --- a/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs +++ b/tests/DodoSSH.Client.App.Layout.Tests/SettingsPagesLayoutTests.cs @@ -233,19 +233,26 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime } /// - /// 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. + /// The SPELUNK section with this device signed in: the form, and the row listing the sign-in, all fit, and + /// the form's button is the real sign-in. /// [Fact] - public async Task TheSecurityPageFitsWithASavedSpelunkKey() + public async Task TheSecurityPageFitsWhenSignedInToSpelunk() { - vault.SpelunkKeyFormApi = "https://api.spelunk.example.com"; - vault.SpelunkKeyFormKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyz"; - await vault.SaveSpelunkKeyCommand.ExecuteAsync(null); + await vault.Session.SpelunkSignIns.SaveAsync( + new SpelunkSignInRecord( + "https://api.spelunk.example.com", + "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG", + Guid.CreateVersion7(), + "laurens@example.com", + "Laurens Example", + DateTimeOffset.UtcNow), + Token); + await vault.ReloadSpelunkSignInsAsync(Token); - vault.SpelunkKeys.ShouldHaveSingleItem(vault.SpelunkKeyFormNote); + vault.SpelunkSignIns.ShouldHaveSingleItem(); - await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("with a Spelunk key saved")); + await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("signed in to Spelunk")); await LayoutHarness.OnTheUiThreadAsync( () => @@ -257,7 +264,7 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime try { - ButtonNamed(window, "SAVE KEY").Command.ShouldBeSameAs(vault.SaveSpelunkKeyCommand); + ButtonNamed(window, "SIGN IN").Command.ShouldBeSameAs(vault.SignInToSpelunkCommand); } finally { diff --git a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs index 1ed10e0..679b762 100644 --- a/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs +++ b/tests/DodoSSH.Client.App.Tests/FakeVaultServer.cs @@ -122,6 +122,20 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync /// public IVaultEventStream Events => Notices; + /// What the identity provider answers when the vault checks the sign-in, or null for yes. + /// An OidcException with invalid_grant is an account the provider no longer signs in. + public Exception? SignInRefusal { get; set; } + + /// How many times the vault checked the sign-in. + public int SignInChecks { get; private set; } + + /// + public Task VerifySignInAsync(CancellationToken cancellationToken) + { + SignInChecks++; + return SignInRefusal is { } refusal ? Task.FromException(refusal) : Task.CompletedTask; + } + /// /// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a /// server that does not advertise the feature supplies. diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs index f644bcc..1e5905b 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs @@ -2,7 +2,9 @@ using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using System.Text; +using DodoSSH.Client.Auth; using DodoSSH.Client.Session; +using DodoSSH.Client.Storage; using DodoSSH.Client.Shell.ViewModels; using DodoSSH.Client.Ssh; @@ -19,22 +21,25 @@ namespace DodoSSH.Client.App.Tests; /// own HTTP and WebSocket half is SpelunkTunnelTests' job. /// /// -/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and -/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in -/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects -/// straight away but never sends a key to a Spelunk no saved host already sends it to. +/// What these hold is what the tick box, the sign-in and the link promise: a Spelunk host is never dialled +/// directly and never probed, a refusal is said in Spelunk's terms, this device's sign-in to a Spelunk goes +/// only to that Spelunk and only once DodoSSH's own account has been checked, a sign-in connects with the +/// tunnel's key and Spelunk's account and nothing of the host's, and a link connects straight away but never +/// starts a sign-in by itself. /// /// public sealed partial class ShellFlowTests { private const string SpelunkApi = "https://api.spelunk.example.com"; - private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; + private const string SpelunkToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"); private readonly FakeSpelunkTunnel spelunkTunnels = new(); + private readonly FakeSpelunkAccounts spelunkAccounts = new(); + [Fact] public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself() { @@ -43,9 +48,11 @@ public sealed partial class ShellFlowTests await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); - // The key from the credential, to the Spelunk and server the host names, for the host's own port. + // This device's sign-in, to the Spelunk and server the host names, for the host's own port — and only + // once DodoSSH's own account has been checked. var opened = spelunkTunnels.Opened.ShouldHaveSingleItem(); - opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22)); + opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22)); + server.SignInChecks.ShouldBeGreaterThan(0); // Through the loopback proxy, and named as the host, because the name is what its key is pinned under. var request = ssh.Requests.ShouldHaveSingleItem(); @@ -127,23 +134,22 @@ public sealed partial class ShellFlowTests } [Fact] - public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved() + public async Task AHostOnASpelunkThisDeviceIsNotSignedInTo_IsRefusedAtConnect_AndOffersTheSignIn() { - await UnlockedAsync(); - var vault = shell.Vault!; + var vault = await ReadyToConnectThroughSpelunkAsync(); + await vault.Session.SpelunkSignIns.ForgetAllAsync(Token); + await vault.ReloadSpelunkSignInsAsync(Token); - vault.NewHostCommand.Execute(null); - vault.EditorLabel = "web-01"; - vault.EditorHostname = "203.0.113.10"; - vault.EditorUsername = "deploy"; - vault.EditorThroughSpelunk = true; - vault.EditorSpelunkApi = SpelunkApi; - vault.EditorSpelunkServerId = SpelunkServer.ToString(); + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); - await vault.SaveHostCommand.ExecuteAsync(null); + spelunkTunnels.Opened.ShouldBeEmpty(); + ssh.Requests.ShouldBeEmpty(); + vault.Status.ShouldNotBeNull().ShouldContain("not signed in there"); - vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key"); - vault.Hosts.ShouldBeEmpty(); + vault.EditSelectedHostCommand.Execute(null); + vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); + vault.EditorSpelunkSignInNote.ShouldContain("not signed in to Spelunk"); } [Fact] @@ -156,22 +162,8 @@ public sealed partial class ShellFlowTests vault.EditorThroughSpelunk.ShouldBeTrue(); vault.EditorSpelunkApi.ShouldBe(SpelunkApi); vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString()); - vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key"); - } - - [Fact] - public async Task ACredentialHoldingASpelunkKey_IsInUse_WhereverThatIsDrawn() - { - // So deleting it is warned about like any other credential a host needs, rather than looking unused. - var vault = await ReadyToConnectThroughSpelunkAsync(); - - vault.ShowSectionCommand.Execute(VaultSection.All); - var row = vault.VaultItems.Single(item => string.Equals(item.Name, "spelunk key", StringComparison.Ordinal)); - - row.UsedBySummary.ShouldBe("web-01"); - - vault.SelectedVaultItem = row; - vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01"); + vault.EditorSpelunkNeedsSignIn.ShouldBeFalse(); + vault.EditorSpelunkSignInNote.ShouldContain("ops@example.com"); } // ---- Signing in through Spelunk ---- @@ -188,7 +180,7 @@ public sealed partial class ShellFlowTests await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.ConnectCommand.ExecuteAsync(null); - spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22)); + spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22)); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 "); @@ -201,7 +193,7 @@ public sealed partial class ShellFlowTests shell.IsTerminalShowing.ShouldBeTrue(); shell.Tabs.ShouldHaveSingleItem(); - vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in"); + vault.Credentials.ShouldBeEmpty("nothing is remembered for a sign-in"); await request.Proxy.Lifetime.DisposeAsync(); } @@ -319,11 +311,11 @@ public sealed partial class ShellFlowTests vault.EditorSpelunkApi.ShouldBe(SpelunkApi); vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString()); - // No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key - // for it would be offering to send the key wherever a link says. - vault.EditorSelectedSpelunkKey.ShouldBeNull(); - vault.Status.ShouldNotBeNull().ShouldContain("first server"); + // Not signed in to that Spelunk, and a link never starts a sign-in by itself: the editor offers it. + vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); + vault.Status.ShouldNotBeNull().ShouldContain("Sign in to that Spelunk"); + spelunkAccounts.SignedIn.ShouldBeEmpty(); spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it"); @@ -353,9 +345,11 @@ public sealed partial class ShellFlowTests chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag), chip => chip.IsWorn.ShouldBeTrue()); - // So once it is saved, the next link for that server finds it rather than adding a second one. - await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey); - vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key"); + // Signed in from the editor and saved, the next link for that server finds it rather than adding a + // second one. + await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null); + spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]); + vault.EditorSpelunkNeedsSignIn.ShouldBeFalse(); await vault.SaveHostCommand.ExecuteAsync(null); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); @@ -376,10 +370,10 @@ public sealed partial class ShellFlowTests await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); await vault.OpenSpelunkLinkAsync(Link(host: null, server: other)); - vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk"); + vault.IsEditing.ShouldBeFalse("nothing to choose: this device is signed in to this Spelunk"); - // Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the - // key that Spelunk already gets, and no login of its own. + // Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk, and no + // login of its own. var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other); added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId); added.Label.ShouldBe("web 01"); @@ -387,7 +381,6 @@ public sealed partial class ShellFlowTests added.Host.Port.ShouldBe(22); added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi); added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue(); - added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId); added.Host.SshKeyId.ShouldBeNull(); added.Host.CredentialId.ShouldBeNull(); added.Host.Username.ShouldBeNull(); @@ -395,7 +388,7 @@ public sealed partial class ShellFlowTests // And connected, through Spelunk, signing in. var opened = spelunkTunnels.Opened.ShouldHaveSingleItem(); - opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22)); + opened.ShouldBe((SpelunkApi, other, SpelunkToken, 22)); var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull(); var request = ssh.Requests.ShouldHaveSingleItem(); @@ -507,70 +500,45 @@ public sealed partial class ShellFlowTests vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel"); } - // ---- A Spelunk key saved on the Security page ---- + // ---- Signing this device in to Spelunk ---- [Fact] - public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway() + public async Task ASignInOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway() { await UnlockedAsync(); var vault = shell.Vault!; - await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey); + await SignInToSpelunkAsync(vault, SpelunkApi + "/"); - // 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(); + // Signed in through the browser, for the address in its one form, and kept on this device. + spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]); + var signedIn = vault.SpelunkSignIns.ShouldHaveSingleItem(); + signedIn.ApiBase.ShouldBe(SpelunkApi); + signedIn.Who.ShouldContain("ops@example.com"); + vault.SpelunkSignInFormNote.ShouldContain("Signed in"); 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"); + vault.IsEditing.ShouldBeFalse("signing in to this Spelunk is the say-so a link needs"); 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)); + spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22)); await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); } [Fact] - public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne() + public async Task ASignIn_IsNeverSentToAnotherSpelunk() { await UnlockedAsync(); var vault = shell.Vault!; - await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key"); + await SignInToSpelunkAsync(vault, SpelunkApi); - 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. + // A host on a Spelunk this device did not sign in to: the sign-in it has is for somewhere else. vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; vault.EditorHostname = "203.0.113.10"; @@ -578,7 +546,7 @@ public sealed partial class ShellFlowTests vault.EditorSpelunkSignIn = true; vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example"; vault.EditorSpelunkServerId = SpelunkServer.ToString(); - vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId); + vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); await vault.SaveHostCommand.ExecuteAsync(null); vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status); @@ -588,85 +556,198 @@ public sealed partial class ShellFlowTests spelunkTunnels.Opened.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty(); - vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else"); + vault.Status.ShouldNotBeNull().ShouldContain("not signed in there"); } [Fact] - public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor() + public async Task AnAddressThatWouldSendTheSignInInClear_IsRefusedOnTheForm() { await UnlockedAsync(); var vault = shell.Vault!; - await AddCredentialAsync(vault, "an older key", password: "spelunk_older"); - await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey); + await SignInToSpelunkAsync(vault, "http://api.spelunk.example.com"); + + spelunkAccounts.SignedIn.ShouldBeEmpty("no browser is opened for an address that would be refused"); + vault.SpelunkSignIns.ShouldBeEmpty(); + vault.SpelunkSignInFormNote.ShouldContain("https"); + } + + [Fact] + public async Task ASignInCancelledInTheBrowser_IsSaid_AndNothingIsKept() + { + await UnlockedAsync(); + var vault = shell.Vault!; + + spelunkAccounts.Refusal = new SpelunkSignInException("Signing in to Spelunk was cancelled in the browser."); + await SignInToSpelunkAsync(vault, SpelunkApi); + + vault.SpelunkSignIns.ShouldBeEmpty(); + vault.SpelunkSignInFormNote.ShouldContain("cancelled in the browser"); + vault.IsSigningInToSpelunk.ShouldBeFalse(); + } + + [Fact] + public async Task TheEditor_SaysWhoItReachesSpelunkAs_AndSignsInFromThere() + { + await UnlockedAsync(); + var vault = shell.Vault!; 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}"); + vault.EditorSpelunkNeedsSignIn.ShouldBeTrue(); - // 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"]); + await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null); + + spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]); + vault.EditorSpelunkNeedsSignIn.ShouldBeFalse(); + vault.SpelunkSignIns.ShouldHaveSingleItem(); } [Fact] - public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm() + public async Task SpelunkSigningThisDeviceOut_ForgetsTheSignIn_SoTheNextConnectAsksForANewOne() { - await UnlockedAsync(); - var vault = shell.Vault!; + spelunkTunnels.Refusal = new SpelunkTunnelException( + "Spelunk signed this device out: your password changed.", HttpStatusCode.Unauthorized); - 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); + var vault = await ReadyToConnectThroughSpelunkAsync(); await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); - await vault.OpenSpelunkLinkAsync(Link()); - await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync(); + await vault.ConnectCommand.ExecuteAsync(null); - vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single()); + vault.Status.ShouldNotBeNull().ShouldContain("signed this device out"); + vault.SpelunkSignIns.ShouldBeEmpty(); + (await vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty(); - 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"); + // The next attempt does not repeat the refusal: it asks to sign in. + spelunkTunnels.Refusal = null; + await vault.ConnectCommand.ExecuteAsync(null); + spelunkTunnels.Opened.Count.ShouldBe(1); + vault.Status.ShouldNotBeNull().ShouldContain("not signed in there"); } - private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey) + [Fact] + public async Task AServerOrOrganisationSpelunkRefuses_KeepsTheSignIn() { - vault.SpelunkKeyFormApi = apiBase; - vault.SpelunkKeyFormKey = apiKey; + // A 403 is about this server, not about who is signed in: every other server stays reachable. + spelunkTunnels.Refusal = new SpelunkTunnelException( + "You are not allowed to open a terminal on this server.", HttpStatusCode.Forbidden); - await vault.SaveSpelunkKeyCommand.ExecuteAsync(null); + var vault = await ReadyToConnectThroughSpelunkAsync(); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + vault.Status.ShouldNotBeNull().ShouldContain("not allowed"); + vault.SpelunkSignIns.ShouldHaveSingleItem(); } - /// A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain. + [Fact] + public async Task ADodoSshAccountTheProviderRefuses_ReachesNoSpelunk_AndSignsThisDeviceOut() + { + var vault = await ReadyToConnectThroughSpelunkAsync(); + + server.SignInRefusal = new OidcException("The account is disabled.", "invalid_grant"); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + spelunkTunnels.Opened.ShouldBeEmpty("Spelunk is not reached for an account DodoSSH no longer has"); + ssh.Requests.ShouldBeEmpty(); + vault.Status.ShouldNotBeNull().ShouldContain("no longer signs in"); + + // Signed out of every Spelunk, there and here, and of DodoSSH: offline, and saying why. + spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); + vault.SpelunkSignIns.ShouldBeEmpty(); + shell.IsOnline.ShouldBeFalse(); + vault.OfflineReason.ShouldNotBeNull().ShouldContain("refused"); + } + + [Fact] + public async Task ADodoSshSignInThatCannotBeChecked_ReachesNoSpelunk_ButIsKept() + { + var vault = await ReadyToConnectThroughSpelunkAsync(); + + server.SignInRefusal = new HttpRequestException("The identity provider could not be reached."); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + + spelunkTunnels.Opened.ShouldBeEmpty(); + vault.Status.ShouldNotBeNull().ShouldContain("could not check"); + vault.SpelunkSignIns.ShouldHaveSingleItem("a provider that is down is not an account that is gone"); + shell.IsOnline.ShouldBeTrue(); + } + + [Fact] + public async Task ADodoSshSignInRefusedOnResume_SignsThisDeviceOutOfEverySpelunkToo() + { + await ReadyToConnectThroughSpelunkAsync(); + await shell.Vault!.SyncOnOpenAsync(Token); + await shell.LockCommand.ExecuteAsync(null); + spelunkAccounts.SignedOut.ShouldBeEmpty(); + + resumeFailure = new OidcException("The token endpoint returned 400: Account disabled.", "invalid_grant"); + + var relaunch = Relaunch(resume: ResumeAsync); + await using var _ = relaunch.ConfigureAwait(false); + + await relaunch.StartAsync(Token); + relaunch.Passphrase = Passphrase; + await relaunch.UnlockCommand.ExecuteAsync(null); + await relaunch.Vault.ShouldNotBeNull().SyncOnOpenAsync(Token); + + // The token Spelunk is told to end is the one the first launch kept: the sign-in outlived the lock and + // the relaunch, and went only when DodoSSH's own did. + relaunch.IsOnline.ShouldBeFalse(); + spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); + relaunch.Vault.SpelunkSignIns.ShouldBeEmpty(); + (await relaunch.Vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty(); + relaunch.Vault.Status.ShouldNotBeNull().ShouldContain("signed out of Spelunk"); + } + + [Fact] + public async Task SigningOutOfSpelunk_OnTheSecurityPage_EndsItThereAndHere() + { + var vault = await ReadyToConnectThroughSpelunkAsync(); + + await vault.SignOutOfSpelunkCommand.ExecuteAsync(vault.SpelunkSignIns.Single()); + + spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); + vault.SpelunkSignIns.ShouldBeEmpty(); + + await using var renderer = await FakeRenderer.AttachAsync(workspace, Token); + await vault.ConnectCommand.ExecuteAsync(null); + spelunkTunnels.Opened.ShouldBeEmpty(); + } + + [Fact] + public async Task SigningOutOfDodoSsh_SignsOutOfEverySpelunk() + { + await ReadyToConnectThroughSpelunkAsync(); + + shell.SignOutCommand.Execute(null); + await shell.ConfirmSignOutCommand.ExecuteAsync(null); + + spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]); + } + + private static async Task SignInToSpelunkAsync(VaultViewModel vault, string apiBase) + { + vault.SpelunkSignInFormApi = apiBase; + + await vault.SignInToSpelunkCommand.ExecuteAsync(null); + } + + /// A saved host through Spelunk, bound to a stored SSH password, on a Spelunk this device is signed in to. private async Task ReadyToConnectThroughSpelunkAsync() { await UnlockedAsync(); var vault = shell.Vault!; - await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey); + await SignInToSpelunkAsync(vault, SpelunkApi); await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy"); vault.NewHostCommand.Execute(null); @@ -676,7 +757,6 @@ public sealed partial class ShellFlowTests vault.EditorThroughSpelunk = true; vault.EditorSpelunkApi = SpelunkApi + "/"; vault.EditorSpelunkServerId = SpelunkServer.ToString(); - vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key"); vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices .Single(choice => choice.Label == "deploy password"); @@ -697,7 +777,7 @@ public sealed partial class ShellFlowTests var vault = shell.Vault!; - await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey); + await SignInToSpelunkAsync(vault, SpelunkApi); vault.NewHostCommand.Execute(null); vault.EditorLabel = "web-01"; @@ -706,7 +786,6 @@ public sealed partial class ShellFlowTests vault.EditorSpelunkSignIn = true; vault.EditorSpelunkApi = SpelunkApi; vault.EditorSpelunkServerId = SpelunkServer.ToString(); - vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key"); await vault.SaveHostCommand.ExecuteAsync(null); @@ -748,7 +827,7 @@ public sealed partial class ShellFlowTests /// private sealed class FakeSpelunkTunnel : ISpelunkTunnel { - internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = []; + internal List<(string ApiBase, Guid ServerId, string Token, int Port)> Opened { get; } = []; /// The sign-in key each tunnel was asked for, in order; null for a plain tunnel. internal List SignIns { get; } = []; @@ -764,12 +843,12 @@ public sealed partial class ShellFlowTests public async Task OpenAsync( string apiBase, Guid serverId, - string apiKey, + string token, int port, SpelunkSignIn? signIn, CancellationToken cancellationToken) { - Opened.Add((apiBase, serverId, apiKey, port)); + Opened.Add((apiBase, serverId, token, port)); SignIns.Add(signIn); if (Refusal is { } refusal) @@ -806,4 +885,32 @@ public sealed partial class ShellFlowTests new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero })); } } + + /// Signing in to Spelunk as far as the shell can see it: no browser, a sign-in or a refusal. + private sealed class FakeSpelunkAccounts : ISpelunkAccounts + { + /// The Spelunks a sign-in was asked for, in order. + internal List SignedIn { get; } = []; + + /// The tokens that were signed out, in order. + internal List SignedOut { get; } = []; + + internal SpelunkSignInException? Refusal { get; set; } + + public Task SignInAsync(string apiBase, CancellationToken cancellationToken) + { + SignedIn.Add(apiBase); + + return Refusal is { } refusal + ? Task.FromException(refusal) + : Task.FromResult(new SpelunkSignInRecord( + apiBase, SpelunkToken, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.UtcNow)); + } + + public Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken) + { + SignedOut.Add(signIn.Token); + return Task.CompletedTask; + } + } } diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs index 3a32bbf..1de2636 100644 --- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs +++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs @@ -228,9 +228,10 @@ public sealed partial class ShellFlowTests : IAsyncLifetime // the wall clock. reachability: reachability, - // Scripted for the same reason: a Spelunk host's connect never leaves the process. See - // ShellFlowTests.Spelunk.cs. - spelunk: spelunkTunnels); + // Scripted for the same reason: a Spelunk host's connect never leaves the process, and signing in + // to Spelunk opens no browser. See ShellFlowTests.Spelunk.cs. + spelunk: spelunkTunnels, + spelunkAccounts: spelunkAccounts); return ValueTask.CompletedTask; } @@ -9665,7 +9666,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime TimeProvider.System, ssh, CheapProfile, - resume); + resume, + + // The same stand-in as the first launch's, so a relaunch neither opens a browser at Spelunk nor + // sends a sign-out anywhere real. + spelunkAccounts: spelunkAccounts); private async Task SignedInAsync() { diff --git a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs index 6079859..bd71bf7 100644 --- a/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs +++ b/tests/DodoSSH.Client.Domain.Tests/HostFactory.cs @@ -39,18 +39,14 @@ internal static class HostFactory BindPort = 1080, }; - /// A credential holding a Spelunk API key, for the hosts reached through Spelunk. - internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"); - /// A server's id in Spelunk. internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d"); /// - /// A route to , with — signing in through Spelunk - /// when says so. + /// A route to — signing in through Spelunk when says so. /// internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) => - new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn }; + new() { ApiBase = apiBase, ServerId = SpelunkServer, SignInThroughSpelunk = signIn }; /// /// defaults to an explicit 22 rather than to null, so a test that says nothing diff --git a/tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs b/tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs deleted file mode 100644 index c14a64c..0000000 --- a/tests/DodoSSH.Client.Domain.Tests/SpelunkKeySecretTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -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.Domain.Tests/SpelunkRouteTests.cs b/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs index 8910ef7..e7b2a09 100644 --- a/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs +++ b/tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs @@ -73,14 +73,12 @@ public sealed class SpelunkRouteTests [Theory] [InlineData("server")] - [InlineData("key")] [InlineData("api")] public void ARouteMissingAPart_IsRefused(string missing) { var route = missing switch { "server" => ThroughSpelunk() with { ServerId = Guid.Empty }, - "key" => ThroughSpelunk() with { ApiKeyCredentialId = Guid.Empty }, _ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" }, }; @@ -98,7 +96,7 @@ public sealed class SpelunkRouteTests document.ShouldNotBeNull(); document.Host.ShouldBe(host); - document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion); + document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignedInDeviceSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } @@ -122,7 +120,7 @@ public sealed class SpelunkRouteTests Encoding.UTF8.GetString(bytes).ShouldBe( """ - {"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}} + {"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d"}} """); } @@ -156,10 +154,10 @@ public sealed class SpelunkRouteTests public void ARouteWrittenByANewerClient_IsReadOnlyHere() { // The rule every version before this one relies on, checked from the far side: a build that knows - // version 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent. + // version 11 reads a version 12 host, shows it, and refuses to re-encode what it cannot represent. var payload = Encoding.UTF8.GetBytes( """ - {"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true} + {"schemaVersion":12,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true} """); HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue(); @@ -207,7 +205,7 @@ public sealed class SpelunkRouteTests document.ShouldNotBeNull(); document.Host.ShouldBe(host); document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue(); - document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion); + document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignedInDeviceSchemaVersion); document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion); document.IsReadOnly.ShouldBeFalse(); } @@ -219,13 +217,15 @@ public sealed class SpelunkRouteTests Encoding.UTF8.GetString(bytes).ShouldBe( """ - {"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":true}} + {"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","signIn":true}} """); } [Fact] - public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable() + public void ARouteWrittenWithAnApiKey_DecodesWithoutIt_AsNotSigningIn_AndStaysEditable() { + // The API key a route used to name is an unknown member now, skipped: the host is reached as whoever + // this device signed in to Spelunk as, and saving it writes it at version 11 without the key. var payload = Encoding.UTF8.GetBytes( """ {"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}} diff --git a/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs b/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs new file mode 100644 index 0000000..56fd0fe --- /dev/null +++ b/tests/DodoSSH.Client.Session.Tests/SpelunkAccountsTests.cs @@ -0,0 +1,388 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using DodoSSH.Client.Auth; + +namespace DodoSSH.Client.Session.Tests; + +/// +/// Signing this device in to Spelunk, with the browser and the redirect stood in for and Spelunk's token +/// endpoint answered in-process: what is opened, what is checked when the browser comes back, what is sent for +/// the token, and what is refused. +/// +public sealed class SpelunkAccountsTests : IDisposable +{ + private const string ApiBase = "https://api.spelunk.example.com"; + + private const string IssuedToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; + + private static readonly Guid SessionId = Guid.Parse("0192f0c8-eeee-7c3d-8e4f-5a6b7c8d9e0e"); + + private readonly ScriptedBrowser browser = new(); + + private readonly TokenEndpoint spelunk = new(); + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + public void Dispose() => spelunk.Dispose(); + + [Fact] + public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode() + { + var signIn = await Accounts().SignInAsync(ApiBase + "/", Token); + + var opened = browser.Opened.ShouldHaveSingleItem(); + opened.GetLeftPart(UriPartial.Path).ShouldBe($"{ApiBase}/api/app-sign-in/authorize"); + var query = Query(opened); + query["client_id"].ShouldBe("dodossh"); + query["response_type"].ShouldBe("code"); + query["code_challenge_method"].ShouldBe("S256"); + query["device_name"].ShouldBe("work laptop"); + query["redirect_uri"].ShouldBe(ScriptedCallback.Redirect); + + // The verifier sent for the token is the one the challenge was made from. + using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem()); + var verifier = sent.RootElement.GetProperty("codeVerifier").GetString()!; + Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))).ShouldBe(query["code_challenge"]); + sent.RootElement.GetProperty("code").GetString().ShouldBe("the-code"); + sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe(ScriptedCallback.Redirect); + sent.RootElement.GetProperty("clientId").GetString().ShouldBe("dodossh"); + + signIn.ApiBase.ShouldBe(ApiBase); + signIn.Token.ShouldBe(IssuedToken); + signIn.SessionId.ShouldBe(SessionId); + signIn.Email.ShouldBe("ops@example.com"); + signIn.ToString().ShouldNotContain(IssuedToken); + } + + [Fact] + public async Task AnAnswerToSomeOtherSignIn_IsIgnored() + { + browser.Answer = (_, _) => new Dictionary(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = "not-this-one" }; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("different sign-in"); + spelunk.Bodies.ShouldBeEmpty("a code from another sign-in is never redeemed"); + } + + [Fact] + public async Task SayingNoInTheBrowser_IsSaidAsACancel() + { + browser.Answer = (_, state) => new Dictionary(StringComparer.Ordinal) { ["error"] = "access_denied", ["state"] = state }; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("cancelled in the browser"); + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task ARefusedCode_IsSaid() + { + spelunk.Status = HttpStatusCode.BadRequest; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("did not accept the sign-in"); + } + + [Fact] + public async Task ARefusedCode_IsSaidInSpelunksOwnWords() + { + spelunk.Status = HttpStatusCode.BadRequest; + spelunk.ProblemDetail = "The code is wrong, used or expired. Sign in again from the app."; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("used or expired"); + } + + [Fact] + public async Task ATokenThatIsNotAnAppSignIns_IsRefused() + { + // Spelunk reads a bearer token by its prefix: without this one it is an API key or a JWT to it, and an + // answer carrying one is not Spelunk's app sign-in. + spelunk.IssuedToken = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("does not understand"); + } + + [Fact] + public async Task ABrowserThatWillNotOpen_IsSaid() + { + browser.Failure = new InvalidOperationException("No browser is installed."); + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("No browser is installed."); + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task APhoneBrowserClosedWithoutAnAnswer_IsSaid_AsTheReasonThePhoneGave() + { + // What AndroidRedirectCallback.Abandon fails the wait with when the app comes back with no redirect. + browser.Callback.Failure = new OidcException("Sign-in was cancelled: the browser closed without signing you in."); + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("browser closed without signing you in"); + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task ABrowserThatNeverComesBack_TimesOut_AndSaysSo() + { + browser.Callback.Never = true; + + var accounts = new SpelunkAccounts(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop") + { + BrowserTimeout = TimeSpan.FromMilliseconds(50), + }; + var refusal = await Should.ThrowAsync(() => accounts.SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("in time"); + } + + [Fact] + public async Task ClosingTheDialog_CancelsTheSignIn_AndRedeemsNothing() + { + browser.Callback.Never = true; + using var closed = CancellationTokenSource.CreateLinkedTokenSource(Token); + closed.CancelAfter(TimeSpan.FromMilliseconds(50)); + + await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, closed.Token)); + + spelunk.Bodies.ShouldBeEmpty(); + } + + [Fact] + public async Task APhonesRedirect_IsSentExactlyAsSpelunkAllowsIt_BothTimes() + { + // Spelunk matches an app-scheme redirect byte for byte, at authorize and again at the token request. + browser.Callback.RedirectUri = new Uri("dev.dodotech.dodossh:/callback"); + + await Accounts().SignInAsync(ApiBase, Token); + + Query(browser.Opened.ShouldHaveSingleItem())["redirect_uri"].ShouldBe("dev.dodotech.dodossh:/callback"); + using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem()); + sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe("dev.dodotech.dodossh:/callback"); + } + + [Fact] + public async Task OnADesktop_TheBrowserComesBackToALoopbackLiteral_AndTheListenerTakesOneAnswer() + { + // The real listener, and a "browser" that follows Spelunk's redirect the way a browser would. Spelunk + // allows plain HTTP to a loopback IP literal only - never localhost - on any port. + var following = new FollowingBrowser(); + var accounts = new SpelunkAccounts(new HttpClient(spelunk), following, deviceName: "work laptop"); + + var signIn = await accounts.SignInAsync(ApiBase, Token); + + var redirect = new Uri(Query(following.Opened.ShouldHaveSingleItem())["redirect_uri"]); + redirect.Scheme.ShouldBe("http"); + redirect.Host.ShouldBe("127.0.0.1"); + redirect.AbsolutePath.ShouldBe("/callback"); + (await following.Page).ShouldContain("return to DodoSSH"); + signIn.Token.ShouldBe(IssuedToken); + + // Closed once it has its answer: nothing is listening there any more. + await Should.ThrowAsync(() => following.Http.GetAsync(redirect, Token)); + } + + [Fact] + public async Task ATokenThatCouldNotBeAHeader_IsRefused() + { + spelunk.IssuedToken = "spelunkapp_with a space"; + + var refusal = await Should.ThrowAsync(() => Accounts().SignInAsync(ApiBase, Token)); + + refusal.Message.ShouldContain("does not understand"); + } + + [Fact] + public async Task AnAddressThatWouldSendTheSignInInClear_OpensNoBrowser() + { + var refusal = await Should.ThrowAsync( + () => Accounts().SignInAsync("http://api.spelunk.example.com", Token)); + + refusal.Message.ShouldContain("https"); + browser.Opened.ShouldBeEmpty(); + } + + [Fact] + public async Task SigningOut_TellsSpelunk_WithTheToken_AndNeverThrows() + { + var accounts = Accounts(); + var signIn = await accounts.SignInAsync(ApiBase, Token); + + await accounts.SignOutAsync(signIn, Token); + + var (method, uri, authorization) = spelunk.Others.ShouldHaveSingleItem(); + method.ShouldBe(HttpMethod.Delete); + uri.ShouldBe(new Uri($"{ApiBase}/api/app-sign-in/session")); + authorization.ShouldBe($"Bearer {IssuedToken}"); + + spelunk.Unreachable = true; + await accounts.SignOutAsync(signIn, Token); + } + + [Fact] + public async Task SigningOut_SendsTheTokenOnlyToTheAddressItCameFrom() + { + var accounts = Accounts(); + var signIn = await accounts.SignInAsync(ApiBase, Token); + + // A damaged record whose address is no longer one a sign-in could have come from is forgotten unsent. + await accounts.SignOutAsync(signIn with { ApiBase = "http://spelunk.example.com" }, Token); + + spelunk.Others.ShouldBeEmpty(); + } + + private SpelunkAccounts Accounts() => + new(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop"); + + private static Dictionary Query(Uri uri) => + uri.Query.TrimStart('?').Split('&') + .Select(pair => pair.Split('=', 2)) + .ToDictionary(pair => pair[0], pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal); + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + /// A browser that, opened at the authorize URL, comes straight back with what the test scripts. + private sealed class ScriptedBrowser : IBrowserLauncher + { + internal List Opened { get; } = []; + + internal ScriptedCallback Callback { get; } = new(); + + /// The answer, from the authorize URL and the state it carried. A code and that state by default. + internal Func> Answer { get; set; } = + (_, state) => new Dictionary(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = state }; + + /// What opening the browser throws, or null when it opens. + internal Exception? Failure { get; set; } + + public Task OpenAsync(Uri url, CancellationToken cancellationToken) + { + if (Failure is { } failure) + { + throw failure; + } + + Opened.Add(url); + Callback.Result = new CallbackResult(Answer(url, Query(url)["state"])); + return Task.CompletedTask; + } + } + + private sealed class ScriptedCallback : IAuthorizationCallback + { + internal const string Redirect = "http://127.0.0.1:53682/callback"; + + internal CallbackResult? Result { get; set; } + + /// What the wait fails with, or null when it answers. + internal Exception? Failure { get; set; } + + /// Whether the browser never comes back, so only a cancellation ends the wait. + internal bool Never { get; set; } + + public Uri RedirectUri { get; set; } = new(Redirect); + + public async Task WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken) + { + if (Never) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + + return Failure is { } failure + ? throw failure + : Result ?? throw new InvalidOperationException("The browser was never opened."); + } + + public void Dispose() + { + } + } + + /// + /// A browser that does what Spelunk's consent page ends with: goes to the redirect URI with a code and the + /// state it was opened with. + /// + private sealed class FollowingBrowser : IBrowserLauncher + { + internal HttpClient Http { get; } = new() { Timeout = TimeSpan.FromSeconds(5) }; + + internal List Opened { get; } = []; + + /// The page the listener showed once the browser came back. + internal Task Page { get; private set; } = Task.FromResult(string.Empty); + + public async Task OpenAsync(Uri url, CancellationToken cancellationToken) + { + Opened.Add(url); + var query = Query(url); + + // Not awaited by the sign-in, as a real browser is not: it is answered once the listener is waiting. + var back = new Uri($"{query["redirect_uri"]}?code=the-code&state={Uri.EscapeDataString(query["state"])}"); + Page = Task.Run(() => Http.GetStringAsync(back, cancellationToken), cancellationToken); + + await Task.CompletedTask; + } + } + + /// Spelunk's token and sign-out endpoints. + private sealed class TokenEndpoint : HttpMessageHandler + { + internal List Bodies { get; } = []; + + internal List<(HttpMethod Method, Uri Uri, string? Authorization)> Others { get; } = []; + + internal HttpStatusCode Status { get; set; } = HttpStatusCode.OK; + + internal string IssuedToken { get; set; } = SpelunkAccountsTests.IssuedToken; + + /// The detail of a refusal, as Spelunk's problem details carry it. + internal string? ProblemDetail { get; set; } + + internal bool Unreachable { get; set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (Unreachable) + { + throw new HttpRequestException("Connection refused."); + } + + if (request.Method != HttpMethod.Post) + { + Others.Add((request.Method, request.RequestUri!, request.Headers.Authorization?.ToString())); + return new HttpResponseMessage(HttpStatusCode.NoContent); + } + + Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken)); + + return new HttpResponseMessage(Status) + { + Content = new StringContent( + Status == HttpStatusCode.OK + ? JsonSerializer.Serialize(new + { + token = IssuedToken, + sessionId = SessionId, + user = new { id = Guid.CreateVersion7(), email = "ops@example.com", displayName = "Ops" }, + }) + : JsonSerializer.Serialize(new { title = "Sign-in not accepted", detail = ProblemDetail }), + Encoding.UTF8, + "application/json"), + }; + } + } +} diff --git a/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs b/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs index 4b24b43..bd88a1a 100644 --- a/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs +++ b/tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs @@ -22,7 +22,7 @@ namespace DodoSSH.Client.Session.Tests; /// public sealed class SpelunkTunnelTests : IAsyncLifetime { - private const string ApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; + private const string SignInToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG"; private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"); @@ -44,7 +44,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime var ticket = spelunk.Requests[0]; ticket.Method.ShouldBe("POST"); ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets"); - ticket.Headers["Authorization"].ShouldBe($"Bearer {ApiKey}"); + ticket.Headers["Authorization"].ShouldBe($"Bearer {SignInToken}"); ticket.Body.ShouldBe("""{"port":22}"""); var upgrade = spelunk.Requests[1]; @@ -98,7 +98,8 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime } [Theory] - [InlineData(HttpStatusCode.Unauthorized, "did not accept the API key")] + [InlineData(HttpStatusCode.Unauthorized, "signed this device out")] + [InlineData(HttpStatusCode.Forbidden, "not allowed to open a terminal")] [InlineData(HttpStatusCode.NotFound, "does not know this server")] [InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")] [InlineData(HttpStatusCode.InternalServerError, "HTTP 500")] @@ -202,24 +203,18 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime } [Fact] - public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent() + public async Task ADamagedSignIn_IsRefusedBeforeAnythingIsSent_AsASignedOutDevice() { + // It came from Spelunk and was checked then, so a token that is not a header value is a damaged cache, + // and the remedy is the one a 401 gets: sign in again. var refusal = await Should.ThrowAsync( - new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token)); + new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunkapp_abc\ndef", 22, signIn: null, Token)); - refusal.Message.ShouldContain("does not hold one"); + refusal.Message.ShouldContain("Sign in to Spelunk again"); + refusal.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); spelunk.Requests.ShouldBeEmpty(); } - [Fact] - public async Task AKeyPastedWithANewline_IsTrimmed() - { - await using var route = await new SpelunkTunnel() - .OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, signIn: null, Token); - - spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}"); - } - [Fact] public async Task AnUnreachableSpelunk_SaysWhere() { @@ -230,7 +225,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime closed.Stop(); var refusal = await Should.ThrowAsync( - new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token)); + new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, SignInToken, 22, signIn: null, Token)); refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}"); } @@ -389,14 +384,14 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime } } -/// Opens a tunnel to the test server with the one key and server the suite uses. +/// Opens a tunnel to the test server with the one sign-in and server the suite uses. file static class Opening { internal static Task OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) => new SpelunkTunnel().OpenAsync( apiBase, Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), - "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG", + "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG", port, signIn, TestContext.Current.CancellationToken); diff --git a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs index a8bb656..e088085 100644 --- a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs +++ b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs @@ -456,7 +456,6 @@ 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.Storage.Tests/SpelunkSignInStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/SpelunkSignInStoreTests.cs new file mode 100644 index 0000000..e91da92 --- /dev/null +++ b/tests/DodoSSH.Client.Storage.Tests/SpelunkSignInStoreTests.cs @@ -0,0 +1,87 @@ +namespace DodoSSH.Client.Storage.Tests; + +/// +/// The Spelunks this device is signed in to: one sign-in per Spelunk, sealed whole, and gone with the cache. +/// +public sealed class SpelunkSignInStoreTests +{ + private const string Spelunk = "https://api.spelunk.example.com"; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + [Fact] + public async Task ASignInRoundTrips_AndIsFoundByItsSpelunk() + { + using var harness = await CacheHarness.CreateAsync(); + var store = Store(harness); + + (await store.FindAsync(Spelunk, Token)).ShouldBeNull("nothing is signed in yet"); + + var signIn = SignIn(Spelunk, "spelunkapp_one"); + await store.SaveAsync(signIn, Token); + + (await store.FindAsync(Spelunk, Token)).ShouldBe(signIn); + (await store.FindAsync("https://spelunk.elsewhere.example", Token)).ShouldBeNull(); + } + + [Fact] + public async Task SigningInAgainToTheSameSpelunk_Replaces_AndOthersStay() + { + using var harness = await CacheHarness.CreateAsync(); + var store = Store(harness); + + await store.SaveAsync(SignIn(Spelunk, "spelunkapp_old"), Token); + await store.SaveAsync(SignIn("https://spelunk.elsewhere.example", "spelunkapp_other"), Token); + await store.SaveAsync(SignIn(Spelunk, "spelunkapp_new"), Token); + + var all = await store.ListAsync(Token); + all.Count.ShouldBe(2); + (await store.FindAsync(Spelunk, Token)).ShouldNotBeNull().Token.ShouldBe("spelunkapp_new"); + } + + [Fact] + public async Task ForgettingOne_LeavesTheOthers_AndForgettingAll_LeavesNone() + { + using var harness = await CacheHarness.CreateAsync(); + var store = Store(harness); + + await store.SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token); + await store.SaveAsync(SignIn("https://spelunk.elsewhere.example", "spelunkapp_two"), Token); + + await store.ForgetAsync(Spelunk, Token); + (await store.ListAsync(Token)).ShouldHaveSingleItem().ApiBase.ShouldBe("https://spelunk.elsewhere.example"); + + await store.ForgetAllAsync(Token); + (await store.ListAsync(Token)).ShouldBeEmpty(); + } + + [Fact] + public async Task AnotherUsersCacheKey_OpensNothing() + { + using var owner = await CacheHarness.CreateAsync(); + using var stranger = await CacheHarness.CreateAsync(); + + await Store(owner).SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token); + + (await new SpelunkSignInStore(owner.Factory, stranger.Protector, TimeProvider.System).ListAsync(Token)) + .ShouldBeEmpty(); + } + + [Fact] + public async Task ResettingTheCache_SignsThisDeviceOutOfEverySpelunk() + { + // What signing out of DodoSSH does on disk, whatever reached Spelunk before it. + using var harness = await CacheHarness.CreateAsync(); + + await Store(harness).SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token); + await harness.Factory.ResetAsync(Token); + + (await Store(harness).ListAsync(Token)).ShouldBeEmpty(); + } + + private static SpelunkSignInStore Store(CacheHarness harness) => + new(harness.Factory, harness.Protector, TimeProvider.System); + + private static SpelunkSignInRecord SignIn(string apiBase, string token) => + new(apiBase, token, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.FromUnixTimeSeconds(1_790_000_000)); +} diff --git a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs index 74b9767..a51df87 100644 --- a/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/AadResourceTypeTests.cs @@ -43,7 +43,6 @@ 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) @@ -78,7 +77,6 @@ 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 @@ -255,10 +253,6 @@ 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, @@ -419,13 +413,6 @@ 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 8faba4f..79acabd 100644 --- a/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/ItemKindsTests.cs @@ -30,7 +30,6 @@ public sealed class ItemKindsTests SyncEntityType.ConnectionLogEntry, SyncEntityType.ActivityLogEntry, SyncEntityType.ObjectStore, - SyncEntityType.SpelunkKey, ]); } diff --git a/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs b/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs index 05c6096..1e37879 100644 --- a/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs +++ b/tests/DodoSSH.Client.Sync.Tests/SyncEngineTests.cs @@ -271,7 +271,7 @@ public sealed class SyncEngineTests /// /// /// The cursor a pull returns is past every change it read, including the changes of types it did not ask - /// for. A build that adds a type to the list — SpelunkKey did, ObjectStore before it — and carried on from + /// for. A build that adds a type to the list — ObjectStore did — and carried on from /// that cursor would never receive the items of the new type made before it arrived. /// [Fact] diff --git a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs index 735abd8..b21692c 100644 --- a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs +++ b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs @@ -96,7 +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)] + [InlineData(CryptoSpec.AadResourceType.SpelunkSignIn, 18)] public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected) { ((int)type).ShouldBe(expected);