Public Access
Spelunk: sign each device in through the browser instead of keeping Spelunk API keys in the vault
A Spelunk API key reaches one organisation, so a server elsewhere - oc-linux in Orange Climate - was refused before it reached the agent. Each device now signs in to a Spelunk once, under Settings -> Security -> Spelunk or from a Spelunk host's editor: the OAuth authorization code flow with PKCE for a native app (RFC 8252), with Spelunk as the authorization server. The browser opens Spelunk's /api/app-sign-in/authorize with a loopback redirect (the app's own scheme on Android), the person signs in there however they normally do and allows "DodoSSH on <device>", and the code is redeemed at /api/app-sign-in/token for an app session token. That token opens terminals in every organisation its person is in, with the role they have on each server at the time; tunnel tickets send no organisation. The sign-in stays on the device: sealed in the local cache under the LocalCacheKey (AAD resource type 18, bound to its row), never synced, wiped with the cache, and only ever sent to the Spelunk it came from. A 401 from Spelunk forgets it; SIGN OUT ends it there and here, and so does signing out of DodoSSH. Before every Spelunk connection DodoSSH asks its own identity provider whether its sign-in still stands (a forced refresh, at most once a minute), and an account it refuses is signed out of DodoSSH and of every Spelunk on the device. A route names no key any more: Spelunk hosts are written at host schema 11, which an older build shows read-only and asks to be updated for, and a payload that still names a key credential decodes with the id skipped. The SpelunkKey item type is gone: SyncEntityType 14, ChangeEntityType 14 and AAD type 17 are retired, the server drops spelunk_key and its change-log rows, and the client cache drops its cached items, outbox entries and conflicts of that type. A link still never starts a sign-in: from a Spelunk this device is not signed in to it opens the editor, which offers to. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
902761d37b
commit
ef365f462e
62 files changed
+4327
-1945
No files matched your search
@@ -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);
|
||||
|
||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||
@@ -1094,98 +1093,3 @@ internal sealed class ObjectStoreKind : IItemKind
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ObjectStoreKind"/>'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.
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkKeyKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
|
||||
|
||||
database.SpelunkKeys.Add(key);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -271,7 +271,15 @@ 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 redirect — and names this phone by its model, 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.
|
||||
|
||||
@@ -1290,8 +1290,8 @@
|
||||
|
||||
<!--
|
||||
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
|
||||
one unticks the other. The key is a keychain credential chosen here, never typed here. See
|
||||
VaultViewModel.Spelunk.cs.
|
||||
one unticks the other. No credential is chosen here: the host is reached as whoever this phone signed
|
||||
in to that Spelunk as, and a button signs in when it has not. See VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
@@ -1303,16 +1303,13 @@
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
||||
PlaceholderText="Spelunk server id" />
|
||||
<TextBlock Classes="detail" Text="Spelunk API key" />
|
||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||
Text="{Binding EditorSpelunkSignInNote}"
|
||||
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<Button Classes="secondary" MinHeight="44" HorizontalAlignment="Stretch"
|
||||
Content="SIGN IN TO SPELUNK"
|
||||
Command="{Binding SignInToEditorSpelunkCommand}"
|
||||
IsVisible="{Binding EditorSpelunkNeedsSignIn}" />
|
||||
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
@@ -1321,7 +1318,7 @@
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="body"
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Choose a Spelunk API key (Admin role) saved from the desktop's Settings → Security, or a credential whose password is the key; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
|
||||
|
||||
<!--
|
||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||
|
||||
@@ -707,8 +707,8 @@
|
||||
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
|
||||
because a host has one route. A host ticked here is reached through the Spelunk agent already
|
||||
on the server, so it needs no open SSH port at all. The fields appear only when the box is
|
||||
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
|
||||
and synced like every other secret. See VaultViewModel.Spelunk.cs.
|
||||
ticked. No credential is chosen here: the host is reached as whoever this device signed in to
|
||||
that Spelunk as, and the card says who, or offers to sign in. See VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
@@ -724,25 +724,20 @@
|
||||
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
||||
PlaceholderText="Spelunk server id" />
|
||||
<!--
|
||||
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
|
||||
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
|
||||
the authentication picker's does, so an unrelated edit cannot drop it. Typing an address
|
||||
a key is saved for picks that key when nothing is picked yet.
|
||||
Whether this device can reach that Spelunk, and as whom; or a button to sign in, which
|
||||
opens the browser and waits there. Nothing about it is saved with the host.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="Spelunk API key" />
|
||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||
Text="{Binding EditorSpelunkSignInNote}"
|
||||
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding EditorSpelunkNeedsSignIn}">
|
||||
<Button Classes="accent" Height="34" Content="SIGN IN TO SPELUNK"
|
||||
Command="{Binding SignInToEditorSpelunkCommand}"
|
||||
IsVisible="{Binding !IsSigningInToSpelunk}" />
|
||||
<Button Classes="ghost" Height="34" Content="CANCEL"
|
||||
Command="{Binding SignInToEditorSpelunkCancelCommand}"
|
||||
IsVisible="{Binding IsSigningInToSpelunk}" />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Spelunk signs the user in: a key pair made in memory for each connection, its public
|
||||
half sent with the ticket, and the agent's one-connection SSH server accepting only
|
||||
@@ -754,12 +749,12 @@
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<!--
|
||||
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
||||
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
|
||||
only while Spelunk and its agent are.
|
||||
What the route asks for and what it costs, said where the decision is made: this device
|
||||
signed in to that Spelunk, DodoSSH online to vouch for it, and a host that is reached only
|
||||
while Spelunk and its agent are.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -26,13 +26,12 @@
|
||||
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
|
||||
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
|
||||
|
||||
SPELUNK is where a Spelunk API key is filled in: an address and a key, saved as a Spelunk key item in the
|
||||
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
|
||||
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
|
||||
because saving it is the user saying so. A second key for an address already saved replaces the first.
|
||||
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
|
||||
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
|
||||
same ConfirmDeleteCard every other deletion does, with a count of the hosts that would stop connecting.
|
||||
SPELUNK is where this device signs in to a Spelunk: an address and SIGN IN, which opens the browser at
|
||||
Spelunk and waits for it (VaultViewModel.SignInToSpelunkCommand, cancellable while it waits). What comes
|
||||
back stays on this device — it is not a keychain item and does not sync — and links from that Spelunk
|
||||
connect straight away because signing in is the user saying so. Signing in again to an address replaces
|
||||
the sign-in. The list below shows who each sign-in is and how many hosts reach Spelunk through it, never
|
||||
its token, and SIGN OUT ends it in Spelunk and here.
|
||||
|
||||
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
|
||||
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
|
||||
@@ -115,76 +114,66 @@
|
||||
|
||||
<TextBlock Classes="settingssection" Text="SPELUNK" />
|
||||
|
||||
<!--
|
||||
Signing this device in to a Spelunk. Spelunk asks in the browser, however its person signs in there,
|
||||
and what it gives back stays on this device: each device signs in on its own, and signing out of
|
||||
DodoSSH signs out of every Spelunk too. See VaultViewModel.SpelunkSignIns.cs.
|
||||
-->
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel>
|
||||
<Border Classes="settingsrow">
|
||||
<StackPanel Spacing="10">
|
||||
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
|
||||
<TextBlock Classes="settingsrowtitle" Text="Sign in to Spelunk" />
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="Hosts reached through Spelunk use this key, and links from that Spelunk connect straight away. It is kept encrypted in your personal vault, syncs to your other devices, and is only ever sent to the address below. Use a key with the Admin role." />
|
||||
Text="Hosts reached through Spelunk connect as you, in every organisation you are in there, and links from that Spelunk connect straight away. Spelunk asks in your browser. The sign-in stays on this device and ends when you sign out here, in Spelunk, or of DodoSSH." />
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10"
|
||||
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
|
||||
PlaceholderText="API key (spelunk_…)">
|
||||
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkSignInFormApi}"
|
||||
PlaceholderText="Spelunk API address (https://api.…)">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.SignInToSpelunkCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
|
||||
Command="{Binding Vault.SaveSpelunkKeyCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
|
||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="SIGN IN"
|
||||
Command="{Binding Vault.SignInToSpelunkCommand}"
|
||||
IsVisible="{Binding !Vault.IsSigningInToSpelunk, FallbackValue=True}" />
|
||||
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CANCEL"
|
||||
Command="{Binding Vault.SignInToSpelunkCancelCommand}"
|
||||
IsVisible="{Binding Vault.IsSigningInToSpelunk, FallbackValue=False}" />
|
||||
</Grid>
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
Text="{Binding Vault.SpelunkKeyFormNote}"
|
||||
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
|
||||
Text="{Binding Vault.SpelunkSignInFormNote}"
|
||||
IsVisible="{Binding Vault.HasSpelunkSignInFormNote, FallbackValue=False}" />
|
||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||
Text="Unlock your keychain to save a Spelunk key." />
|
||||
Text="Unlock your keychain to sign in to Spelunk." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
|
||||
<Border Margin="24,12" Padding="20" CornerRadius="12"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1"
|
||||
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Vault.SpelunkKeys}">
|
||||
<ItemsControl ItemsSource="{Binding Vault.SpelunkSignIns}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
|
||||
<DataTemplate x:DataType="vm:SpelunkSignInRowViewModel">
|
||||
<Border Classes="settingsrow" Padding="24,14">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
|
||||
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding Label}" />
|
||||
<Border Classes="chip" IsVisible="{Binding HasVaultBadge}" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding VaultBadge}" />
|
||||
</Border>
|
||||
<TextBlock Classes="hint" FontSize="11" Text="{Binding Badge}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding Who}" />
|
||||
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||
Text="{Binding ApiBase}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
|
||||
Text="{Binding UsedBy}" />
|
||||
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
|
||||
<Button Grid.Column="2" Classes="ghost" Height="30" Content="SIGN OUT"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.SignOutOfSpelunkCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
|
||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||
</Button>
|
||||
ToolTip.Tip="Sign this device out of this Spelunk. Hosts reached through it stop connecting until you sign in again." />
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -3,7 +3,7 @@ using Avalonia.Controls;
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal sealed partial class SettingsSecurityPage : UserControl
|
||||
{
|
||||
|
||||
@@ -145,18 +145,26 @@ public static class HostSecretCodec
|
||||
/// shows the host, refuses to edit it, and says to update.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a route that signs in is written here. Every other Spelunk host stays at
|
||||
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
|
||||
/// No longer written: every Spelunk host is written at <see cref="SpelunkSignedInDeviceSchemaVersion"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int SpelunkSignInSchemaVersion = 10;
|
||||
|
||||
/// <summary>The version from which a <see cref="SpelunkRoute"/> names no API key.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public const int SpelunkSignedInDeviceSchemaVersion = 11;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
/// <remarks>
|
||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||
/// maximum. A new field added below this line has to be named here too.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
|
||||
public const int CurrentSchemaVersion = SpelunkSignedInDeviceSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -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; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
|
||||
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
|
||||
/// </remarks>
|
||||
/// <remarks>Last, and null unless true. See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.</remarks>
|
||||
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.
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key and the one Spelunk it may be sent to, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address is what makes this an item of its own.</b> 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
|
||||
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="ApiKey"/> is a password, and everything this codebase does about passwords applies to
|
||||
/// it</b>, as it does to <see cref="ObjectStoreSecret.SecretAccessKey"/>: 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkKeySecret : IVaultSecret
|
||||
{
|
||||
/// <summary>The longest API key stored.</summary>
|
||||
/// <remarks>
|
||||
/// Far beyond any real key, for the reason <see cref="SpelunkRoute.MaxApiBaseLength"/> exists: an
|
||||
/// unbounded string is the shape a corrupted payload takes, and this one goes into a request header.
|
||||
/// </remarks>
|
||||
public const int MaxApiKeyLength = 4096;
|
||||
|
||||
/// <summary>What the user calls this key.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk API this key is for, as <see cref="SpelunkRoute.TryNormalizeApiBase"/> writes it, and the
|
||||
/// only address it is ever sent to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Normalised for the reason <see cref="SpelunkRoute.ApiBase"/> is: it is compared, byte for byte, with
|
||||
/// the address a host or a link names.
|
||||
/// </remarks>
|
||||
public required string ApiBase { get; init; }
|
||||
|
||||
/// <summary>The API key itself, as Spelunk issued it.</summary>
|
||||
public required string ApiKey { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded Spelunk key payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Key">The key.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="ObjectStoreSecretCodec"/>, for the same reasons and with the same guarantees.
|
||||
/// </remarks>
|
||||
public static class SpelunkKeySecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a key to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The key is not valid for storage.</exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
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;
|
||||
@@ -1,89 +0,0 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged Spelunk key, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The key to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record SpelunkKeyMergeResult(
|
||||
SpelunkKeySecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a Spelunk key against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The address and the key merge as one value, not two.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The key never reaches the conflict log</b>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkKeySecretMerge
|
||||
{
|
||||
/// <summary>The name a conflict over the address and key is recorded under.</summary>
|
||||
public const string PairField = "ApiBaseAndKey";
|
||||
|
||||
/// <summary>Produces the merged key.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static SpelunkKeyMergeResult Merge(
|
||||
SpelunkKeySecret ancestor,
|
||||
SpelunkKeySecret local,
|
||||
SpelunkKeySecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -15,15 +15,15 @@ namespace DodoSSH.Client.Domain;
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Inside the host's encrypted payload, and nowhere else.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
|
||||
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> 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 <see cref="SpelunkKeySecret"/>, 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.
|
||||
/// <b>No credential travels with the host.</b> Each device signs in to a Spelunk once, through the browser,
|
||||
/// and keeps what that gives it to itself (see <c>SpelunkSignIns</c>): 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkRoute
|
||||
@@ -50,32 +50,13 @@ public sealed record SpelunkRoute
|
||||
/// <summary>The server's id in Spelunk.</summary>
|
||||
public required Guid ServerId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The keychain item holding the Spelunk API key: a <see cref="SpelunkKeySecret"/>, or a credential whose
|
||||
/// password is the key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public required Guid ApiKeyCredentialId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>HTTPS, or plain HTTP to this machine only.</b> 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
|
||||
/// <b>HTTPS, or plain HTTP to this machine only.</b> 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.
|
||||
/// </para>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
{
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private TokenSet tokens = initial;
|
||||
private DateTimeOffset lastRefreshedAt = DateTimeOffset.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refreshes now, whatever the expiry says, unless that was done within <paramref name="within"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>invalid_grant</c> 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.
|
||||
/// </remarks>
|
||||
/// <exception cref="OidcException">The provider refused, or could not be asked.</exception>
|
||||
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
|
||||
/// </remarks>
|
||||
IVaultEventStream Events { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Asks the identity provider, now, whether this sign-in still stands — at most once a minute.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <exception cref="DodoSSH.Client.Auth.OidcException">
|
||||
/// The provider refused (<c>invalid_grant</c>), or could not be asked.
|
||||
/// </exception>
|
||||
Task VerifySignInAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Reaches a host through the server, for a host set to connect that way.
|
||||
/// </summary>
|
||||
@@ -261,6 +314,10 @@ public sealed class ServerConnection : IVaultServer
|
||||
/// <inheritdoc />
|
||||
public IAccountApi Account => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task VerifySignInAsync(CancellationToken cancellationToken) =>
|
||||
tokens.RefreshNowAsync(TimeSpan.FromMinutes(1), cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
|
||||
/// <summary>A sign-in to Spelunk that did not happen; the message says why, in words for the user.</summary>
|
||||
public sealed class SpelunkSignInException : Exception
|
||||
{
|
||||
public SpelunkSignInException()
|
||||
{
|
||||
}
|
||||
|
||||
public SpelunkSignInException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SpelunkSignInException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Signs this device in to a Spelunk, and out again.</summary>
|
||||
public interface ISpelunkAccounts
|
||||
{
|
||||
/// <summary>
|
||||
/// Signs this device in to the Spelunk at <paramref name="apiBase"/> through the browser, and returns what
|
||||
/// to keep. Nothing is kept here; the caller stores the result.
|
||||
/// </summary>
|
||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||
/// <param name="cancellationToken">Abandons the sign-in — the user closing the dialog that waits for it.</param>
|
||||
/// <exception cref="SpelunkSignInException">It did not happen; the message says why.</exception>
|
||||
Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Tells Spelunk this device is signing out, so its sign-in ends there too. Best effort: never throws.</summary>
|
||||
Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The OAuth authorization code flow with PKCE for a native app (RFC 8252), against Spelunk as the
|
||||
/// authorization server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The browser opens Spelunk's <c>/api/app-sign-in/authorize</c>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not <see cref="OidcClient"/>.</b> 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 <c>DodoSSH.Client.Auth</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <c>VaultViewModel</c>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkAccounts : ISpelunkAccounts
|
||||
{
|
||||
/// <summary>What Spelunk knows this app as.</summary>
|
||||
public const string ClientId = "dodossh";
|
||||
|
||||
/// <summary>The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache.</summary>
|
||||
private const int MaxTokenLength = 512;
|
||||
|
||||
/// <summary>The page the loopback listener shows once Spelunk has sent the browser back.</summary>
|
||||
private const string CompletionHtml =
|
||||
"""
|
||||
<!doctype html><html><head><meta charset="utf-8"><title>DodoSSH</title></head>
|
||||
<body style="font-family:system-ui;margin:4rem;text-align:center">
|
||||
<h1>Done</h1><p>You can close this tab and return to DodoSSH.</p>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
private static readonly HttpClient SharedHttp = new(
|
||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly IBrowserLauncher browser;
|
||||
private readonly Func<string, IAuthorizationCallback> callbackFactory;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly string deviceName;
|
||||
|
||||
/// <param name="browser">Opens Spelunk's sign-in.</param>
|
||||
/// <param name="callbackFactory">
|
||||
/// What waits for Spelunk's answer: the same seam as <see cref="OidcClientOptions.CallbackFactory"/>, so a
|
||||
/// phone answers through its own scheme.
|
||||
/// </param>
|
||||
/// <param name="clock">For the wait's timeout.</param>
|
||||
/// <param name="deviceName">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public SpelunkAccounts(
|
||||
IBrowserLauncher browser,
|
||||
Func<string, IAuthorizationCallback>? callbackFactory = null,
|
||||
TimeProvider? clock = null,
|
||||
string? deviceName = null)
|
||||
: this(SharedHttp, browser, callbackFactory, clock, deviceName)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Sign-ins whose requests are supplied, for tests.</summary>
|
||||
internal SpelunkAccounts(
|
||||
HttpClient http,
|
||||
IBrowserLauncher browser,
|
||||
Func<string, IAuthorizationCallback>? 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();
|
||||
}
|
||||
|
||||
/// <summary>How long the browser has to come back.</summary>
|
||||
public TimeSpan BrowserTimeout { get; init; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SpelunkSignInRecord> 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));
|
||||
|
||||
using var callback = callbackFactory("/callback");
|
||||
var redirectUri = callback.RedirectUri.ToString();
|
||||
|
||||
var query = string.Join('&', new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["client_id"] = ClientId,
|
||||
["redirect_uri"] = redirectUri,
|
||||
["response_type"] = "code",
|
||||
["code_challenge"] = pkce.CodeChallenge,
|
||||
["code_challenge_method"] = PkcePair.Method,
|
||||
["state"] = state,
|
||||
["device_name"] = deviceName,
|
||||
}.Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value)}"));
|
||||
|
||||
await browser.OpenAsync(new Uri($"{normalized}/api/app-sign-in/authorize?{query}", UriKind.Absolute), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using var timeout = new CancellationTokenSource(BrowserTimeout, clock);
|
||||
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||
|
||||
CallbackResult answer;
|
||||
try
|
||||
{
|
||||
answer = await callback.WaitForCallbackAsync(CompletionHtml, linked.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new SpelunkSignInException("Spelunk did not send the browser back in time. Try signing in again.");
|
||||
}
|
||||
|
||||
// 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 ({answer.ErrorDescription ?? 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);
|
||||
}
|
||||
|
||||
private async Task<SpelunkSignInRecord> 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(response.StatusCode == HttpStatusCode.BadRequest
|
||||
? "Spelunk did not accept the sign-in. It may have taken too long; try again."
|
||||
: string.Create(CultureInfo.InvariantCulture, $"Spelunk would not finish the sign-in (HTTP {(int)response.StatusCode})."));
|
||||
}
|
||||
|
||||
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.
|
||||
if (token is not { Token: { Length: > 0 and <= MaxTokenLength } value, SessionId: { } sessionId, User: { Email: { } email } user }
|
||||
|| !value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_'))
|
||||
{
|
||||
throw new SpelunkSignInException(Unintelligible);
|
||||
}
|
||||
|
||||
return new SpelunkSignInRecord(apiBase, value, sessionId, email, user.DisplayName ?? email, clock.GetUtcNow());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(signIn);
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, new Uri($"{signIn.ApiBase}/api/app-sign-in/session", UriKind.Absolute));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", signIn.Token);
|
||||
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or InvalidOperationException)
|
||||
{
|
||||
// Unreachable, or already signed out: 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.
|
||||
}
|
||||
}
|
||||
|
||||
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.";
|
||||
}
|
||||
|
||||
/// <summary>What the app sends to redeem its code.</summary>
|
||||
internal sealed class SpelunkTokenRequestDocument
|
||||
{
|
||||
public string? ClientId { get; set; }
|
||||
|
||||
public string? Code { get; set; }
|
||||
|
||||
public string? CodeVerifier { get; set; }
|
||||
|
||||
public string? RedirectUri { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A signed-in app's token, as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkTokenRequestDocument))]
|
||||
[JsonSerializable(typeof(SpelunkTokenDocument))]
|
||||
internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext;
|
||||
@@ -11,17 +11,17 @@ namespace DodoSSH.Client.Session;
|
||||
/// <para>
|
||||
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
|
||||
/// <c>dodossh://spelunk/connect?api=…&server=…&name=…&host=…</c>. It says which Spelunk and which
|
||||
/// server, and what to call it. <b>It never carries a credential</b>, 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. <b>It never carries a credential</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Anything on the machine can hand this application a link</b> — 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 <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// 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
|
||||
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||
|
||||
@@ -69,7 +69,7 @@ public interface ISpelunkTunnel
|
||||
/// </summary>
|
||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
||||
/// <param name="token">This device's sign-in to that Spelunk, which authorises the ticket and is sent nowhere else.</param>
|
||||
/// <param name="port">
|
||||
/// 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
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
||||
Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken);
|
||||
@@ -303,7 +303,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -313,7 +313,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 +336,27 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims the stored key and refuses one that could not be a header value.
|
||||
/// </summary>
|
||||
/// <summary>Refuses a stored token that could not be a header value.</summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="FormatException"/> from inside
|
||||
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
|
||||
/// One that would otherwise surface as a <see cref="FormatException"/> from inside <see cref="HttpClient"/>
|
||||
/// rather than as a sentence. It came from Spelunk and was checked then, so this is a damaged cache.
|
||||
/// </remarks>
|
||||
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<SpelunkTicketDocument> IssueTicketAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
string token,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -370,7 +365,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 +490,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 "
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
@@ -201,13 +201,6 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public ObjectStoreRepository ObjectStores { get; }
|
||||
|
||||
/// <summary>Spelunk API keys and the addresses they may be sent to, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public SpelunkKeyRepository SpelunkKeys { get; }
|
||||
|
||||
/// <summary>The connections this vault has recorded, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
|
||||
@@ -247,6 +240,13 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
internal RememberedSignInStore SignIn { get; }
|
||||
|
||||
/// <summary>The Spelunks this device is signed in to, and what each sign-in gave it.</summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>SpelunkAccounts</c>.
|
||||
/// </remarks>
|
||||
public SpelunkSignInStore SpelunkSignIns { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
|
||||
/// </summary>
|
||||
|
||||
@@ -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 <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
|
||||
/// host already sends it to.
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never starts a sign-in by itself, and only
|
||||
/// connects through a Spelunk this device is already signed in to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||
|
||||
@@ -373,6 +373,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly ISpelunkTunnel? spelunk;
|
||||
|
||||
/// <summary>What signs this device in to Spelunk, handed to each vault this shell opens.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private readonly ISpelunkAccounts? spelunkAccounts;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
|
||||
/// </summary>
|
||||
@@ -594,12 +601,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Func<string, Task>? openInEditor = null,
|
||||
Func<string, Action, IDisposable>? 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;
|
||||
@@ -3036,7 +3044,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
|
||||
@@ -3320,6 +3330,33 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
||||
/// <summary>
|
||||
/// The identity provider refused this account when the vault checked it — before a Spelunk connection.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
|
||||
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
rememberedToken = null;
|
||||
@@ -3614,6 +3651,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
|
||||
/// <remarks>
|
||||
/// 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 <see cref="BuildAuthenticationChoices"/> keeps one.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
|
||||
|
||||
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
|
||||
[ObservableProperty]
|
||||
private AuthenticationChoice? editorSelectedSpelunkKey;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
||||
/// </summary>
|
||||
@@ -119,7 +106,7 @@ internal sealed partial class VaultViewModel
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The request a route is used for is made with <see cref="Through"/>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||
@@ -170,13 +157,13 @@ internal sealed partial class VaultViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="SpelunkApiKeyFor"/>.
|
||||
/// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see
|
||||
/// <see cref="SpelunkSignInForAsync"/>. 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
||||
@@ -194,17 +181,25 @@ 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
|
||||
{
|
||||
return await Spelunk
|
||||
.OpenAsync(
|
||||
route.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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -220,19 +215,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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> 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.
|
||||
/// <b>Otherwise, when this device is signed in to that Spelunk, the host is made and connected to.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The first link from a Spelunk with no key opens the editor instead, and nothing more.</b> 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.
|
||||
/// <b>A link from a Spelunk this device is not signed in to opens the editor instead, and nothing
|
||||
/// more.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||
@@ -251,15 +244,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);
|
||||
}
|
||||
|
||||
/// <summary>The personal vault, when this session can read and write it.</summary>
|
||||
@@ -273,33 +266,8 @@ internal sealed partial class VaultViewModel
|
||||
private static bool IsSpelunkTag(TagSecret tag) =>
|
||||
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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)));
|
||||
|
||||
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
|
||||
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
|
||||
/// <summary>The host a link makes: what the link says, signing in through Spelunk.</summary>
|
||||
private static HostSecret HostFromLink(SpelunkLink link, TagSet tags) => new()
|
||||
{
|
||||
Label = link.Name,
|
||||
Hostname = link.Hostname,
|
||||
@@ -309,13 +277,12 @@ internal sealed partial class VaultViewModel
|
||||
{
|
||||
ApiBase = link.ApiBase,
|
||||
ServerId = link.ServerId,
|
||||
ApiKeyCredentialId = apiKeyCredentialId,
|
||||
SignInThroughSpelunk = true,
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
|
||||
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
|
||||
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
@@ -335,7 +302,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 +325,14 @@ internal sealed partial class VaultViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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 +357,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 +425,16 @@ internal sealed partial class VaultViewModel
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
|
||||
/// holding its Spelunk API key.
|
||||
/// </summary>
|
||||
/// <summary>Whether a host needs this key or credential to connect, as its resolved binding.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||
@@ -484,36 +443,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);
|
||||
}
|
||||
|
||||
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>The editor's route, or null when the box is not ticked.</summary>
|
||||
@@ -536,7 +468,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>One saved Spelunk key, as a row on the Security page.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkKeyRowViewModel(VaultItem<SpelunkKeySecret> 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;
|
||||
|
||||
/// <summary>The one address this key is ever sent to.</summary>
|
||||
internal string ApiBase => key.Secret.ApiBase;
|
||||
|
||||
/// <summary>The vault's name, when there is more than one to tell apart; empty otherwise.</summary>
|
||||
internal string VaultBadge { get; init; } = string.Empty;
|
||||
|
||||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||
|
||||
/// <summary>How many hosts reach Spelunk with this key, in words.</summary>
|
||||
internal string UsedBy { get; init; } = string.Empty;
|
||||
|
||||
internal bool IsReadOnly => key.IsReadOnly;
|
||||
|
||||
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A saved key says where it may go.</b> 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 <see cref="SpelunkApiKeyFor"/>. A link from that
|
||||
/// Spelunk connects straight away, because saving the key is the user saying so — see
|
||||
/// <see cref="KnownSpelunkKey"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultViewModel
|
||||
{
|
||||
/// <summary>Every Spelunk key this session can read, the personal vault's first.</summary>
|
||||
internal ObservableCollection<SpelunkKeyRowViewModel> SpelunkKeys { get; } = [];
|
||||
|
||||
/// <summary>Whether there is any saved key to list.</summary>
|
||||
internal bool HasSpelunkKeys => SpelunkKeys.Count > 0;
|
||||
|
||||
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
||||
[ObservableProperty]
|
||||
private string spelunkKeyFormApi = string.Empty;
|
||||
|
||||
/// <summary>The API key in the Security page's form, as pasted. Cleared the moment it is saved.</summary>
|
||||
[ObservableProperty]
|
||||
private string spelunkKeyFormKey = string.Empty;
|
||||
|
||||
/// <summary>What the form has to say about the last save, or empty.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string spelunkKeyFormNote = string.Empty;
|
||||
|
||||
/// <summary>Whether <see cref="SpelunkKeyFormNote"/> has anything in it.</summary>
|
||||
internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0;
|
||||
|
||||
partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote));
|
||||
|
||||
/// <summary>
|
||||
/// Picks the saved key for the address being typed, when nothing is chosen yet.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the
|
||||
/// address would be choosing for them.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Saves the key in the form for the address in the form.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the form into the key it would save, and where; or says on the form why it cannot.
|
||||
/// </summary>
|
||||
/// <param name="vaultId">The vault the key goes into: the personal one, when there is one.</param>
|
||||
/// <param name="existing">The key already saved there for that address, which this one replaces.</param>
|
||||
/// <param name="key">The key to save, keeping the existing one's name.</param>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Asks whether a saved Spelunk key should go.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the Spelunk key that was agreed to.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <returns>How many Spelunk keys would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// Read after the hosts, because each row says how many of them use it.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadSpelunkKeysAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var unreadable = 0;
|
||||
var rows = new List<SpelunkKeyRowViewModel>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A saved Spelunk key goes to its own address or nowhere.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential
|
||||
/// is — see <see cref="TryBuildAuthentication"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">The key cannot be sent to this host's Spelunk.</exception>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>The saved key for a Spelunk, the given vault's first, or null when none is saved.</summary>
|
||||
private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) =>
|
||||
SpelunkKeys
|
||||
.Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal))
|
||||
.OrderByDescending(row => row.VaultId == preferredVault)
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>The active vault when this session may write to it, for a session with no personal vault.</summary>
|
||||
private Guid? WritableActiveVault() =>
|
||||
session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite)
|
||||
?.VaultId;
|
||||
|
||||
/// <summary>The name a key saved from the form is given: where it goes.</summary>
|
||||
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",
|
||||
};
|
||||
|
||||
/// <summary>What the hosts that reach Spelunk with a key would be left with, or nothing when none do.</summary>
|
||||
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.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
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;
|
||||
|
||||
/// <summary>One Spelunk this device is signed in to, as a row on the Security page.</summary>
|
||||
/// <remarks>Carries the token for the sign-out that needs it, and shows nothing of it: the XAML binds the address and who.</remarks>
|
||||
internal sealed class SpelunkSignInRowViewModel(SpelunkSignInRecord record, int hosts)
|
||||
{
|
||||
internal SpelunkSignInRecord Record => record;
|
||||
|
||||
internal string ApiBase => record.ApiBase;
|
||||
|
||||
/// <summary>Who this device is signed in as there.</summary>
|
||||
internal string Who => string.Equals(record.DisplayName, record.Email, StringComparison.Ordinal)
|
||||
? record.Email
|
||||
: $"{record.DisplayName} · {record.Email}";
|
||||
|
||||
/// <summary>How many hosts reach Spelunk through it, in words.</summary>
|
||||
internal string UsedBy { get; } = hosts switch
|
||||
{
|
||||
0 => "No host uses it yet",
|
||||
1 => "1 host uses it",
|
||||
_ => $"{hosts} hosts use it",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signing this device in to Spelunk, and what a sign-in lets a host and a link do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per device, through the browser.</b> 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 (<see cref="VaultSession.SpelunkSignIns"/>) and
|
||||
/// opens terminals on that Spelunk's servers in every organisation its person is in, as them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It ends with the person.</b> 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 (<see cref="VerifyDodoSshSignInAsync"/>), so an
|
||||
/// account removed from DodoSSH stops reaching Spelunk too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultViewModel
|
||||
{
|
||||
/// <summary>What signs in to Spelunk when no caller supplied something else: the system browser and a loopback listener.</summary>
|
||||
private static readonly ISpelunkAccounts DefaultSpelunkAccounts = new SpelunkAccounts(new SystemBrowserLauncher());
|
||||
|
||||
/// <summary>The Spelunks this device is signed in to.</summary>
|
||||
internal ObservableCollection<SpelunkSignInRowViewModel> SpelunkSignIns { get; } = [];
|
||||
|
||||
/// <summary>Whether there is any sign-in to list.</summary>
|
||||
internal bool HasSpelunkSignIns => SpelunkSignIns.Count > 0;
|
||||
|
||||
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
||||
[ObservableProperty]
|
||||
private string spelunkSignInFormApi = string.Empty;
|
||||
|
||||
/// <summary>What the Security page's form has to say about the last sign-in, or empty.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasSpelunkSignInFormNote))]
|
||||
private string spelunkSignInFormNote = string.Empty;
|
||||
|
||||
/// <summary>Whether a sign-in is waiting for the browser.</summary>
|
||||
[ObservableProperty]
|
||||
private bool isSigningInToSpelunk;
|
||||
|
||||
/// <summary>Who the editor's Spelunk would be reached as from this device, or why it would not be.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorSpelunkSignInNote = string.Empty;
|
||||
|
||||
/// <summary>Whether this device still has to sign in to the Spelunk the editor names.</summary>
|
||||
[ObservableProperty]
|
||||
private bool editorSpelunkNeedsSignIn;
|
||||
|
||||
internal bool HasSpelunkSignInFormNote => SpelunkSignInFormNote.Length > 0;
|
||||
|
||||
/// <inheritdoc cref="DefaultSpelunkAccounts" />
|
||||
private ISpelunkAccounts SpelunkAccounts => spelunkAccounts ?? DefaultSpelunkAccounts;
|
||||
|
||||
partial void OnEditorSpelunkApiChanged(string value) => UpdateEditorSpelunkSignIn();
|
||||
|
||||
/// <summary>Signs this device in to the Spelunk in the Security page's form.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Signs this device in to the Spelunk the host editor names.</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>Signs this device out of one Spelunk: there, when it can be reached, and here regardless.</summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>The whole of a sign-in, from either place it starts; returns what to say about it.</summary>
|
||||
/// <remarks>
|
||||
/// <b>DodoSSH has to be online and signed in to start one</b>, for the reason it has to be to use one:
|
||||
/// Spelunk is reached as somebody this DodoSSH account vouches for on this device.
|
||||
/// </remarks>
|
||||
private async Task<string> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses unless DodoSSH is online and its account still signed in — asked of the identity provider
|
||||
/// itself, at most once a minute (see <see cref="IVaultServer.VerifySignInAsync"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
/// <c>accountRefused</c>). 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.
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">Not online, not signed in, or refused; the message says which.</exception>
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What to reach a host's Spelunk with: this device's sign-in there, once DodoSSH's own has been checked.
|
||||
/// </summary>
|
||||
/// <exception cref="SpelunkTunnelException">There is none, or DodoSSH's own was refused.</exception>
|
||||
private async Task<SpelunkSignInRecord> 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.");
|
||||
}
|
||||
|
||||
/// <summary>Forgets a sign-in Spelunk refused, so the next attempt asks for a new one instead of repeating the refusal.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <remarks>Read after the hosts, because each row says how many of them reach Spelunk through it.</remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Says, in the host editor, whether this device can reach the Spelunk it names.</summary>
|
||||
/// <param name="keepNote">Whether to leave a sign-in's own outcome in place rather than restating the state.</param>
|
||||
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}.";
|
||||
}
|
||||
}
|
||||
@@ -1343,9 +1343,6 @@ internal enum DeletionTarget
|
||||
|
||||
/// <summary>A tag, from the vault screen.</summary>
|
||||
Tag,
|
||||
|
||||
/// <summary>A saved Spelunk key, from the Security page.</summary>
|
||||
SpelunkKey,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<string, CancellationToken, Task>? accountRefused = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
@@ -4564,7 +4563,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.
|
||||
@@ -4619,7 +4618,6 @@ internal sealed partial class VaultViewModel(
|
||||
// longer say what the user chose.
|
||||
var authentication = EditorSelectedAuthentication;
|
||||
var groupId = EditorSelectedGroup?.EntityId;
|
||||
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
|
||||
|
||||
BuildTagChoices();
|
||||
|
||||
@@ -4638,8 +4636,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)
|
||||
@@ -11149,10 +11145,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;
|
||||
}
|
||||
|
||||
@@ -109,6 +109,29 @@ internal sealed class RememberedSignInRow
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>This device's sign-in to one Spelunk.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per device, never synced: each device signs in through its own browser, and Spelunk lists and signs out
|
||||
/// each one on its own. Like <see cref="RememberedSignInRow"/>, sign-out deletes every row with the rest of
|
||||
/// the cache.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkSignInRow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The sign-in, sealed under the LocalCacheKey. Ciphertext.</summary>
|
||||
public byte[] Sealed { get; set; } = [];
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||
/// <remarks>
|
||||
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||
|
||||
@@ -74,6 +74,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
|
||||
ConfigureUnlockMaterial(modelBuilder);
|
||||
ConfigureRememberedSignIn(modelBuilder);
|
||||
ConfigureSpelunkSignIns(modelBuilder);
|
||||
ConfigureVaults(modelBuilder);
|
||||
ConfigureItems(modelBuilder);
|
||||
ConfigureOutbox(modelBuilder);
|
||||
@@ -123,6 +124,15 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureSpelunkSignIns(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<SpelunkSignInRow>(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<CachedVaultRow>(entity =>
|
||||
|
||||
@@ -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."),
|
||||
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long?>("SweepMark")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sweep_mark");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<byte[]>("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<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("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<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("Hidden")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("hidden");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("permissions");
|
||||
|
||||
b.Property<bool>("RekeyRequired")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<byte[]>("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<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Acknowledged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("acknowledged");
|
||||
|
||||
b.Property<byte[]>("Detail")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<long>("DetectedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("detected_at_utc");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<Guid>("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<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<byte?>("AncestorAadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_aad_version");
|
||||
|
||||
b.Property<Guid?>("AncestorDataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ancestor_data_key_id");
|
||||
|
||||
b.Property<uint?>("AncestorKeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_key_generation");
|
||||
|
||||
b.Property<byte[]>("AncestorPayload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_payload");
|
||||
|
||||
b.Property<byte[]>("AncestorProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_protected_fields");
|
||||
|
||||
b.Property<int?>("AncestorVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_version");
|
||||
|
||||
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_wrapped_data_key");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("attempts");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int?>("ExpectedVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("expected_version");
|
||||
|
||||
b.Property<bool>("IsParked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_parked");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<Guid>("OperationId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("operation_id");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("QueuedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("queued_at_utc");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<byte[]>("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<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("Sealed")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed");
|
||||
|
||||
b.Property<long>("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<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<string>("Cursor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cursor");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<long?>("LastPulledAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pulled_at_utc");
|
||||
|
||||
b.Property<long?>("LastPushedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<string>("PulledTypes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pulled_types");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
b.Property<long?>("SweepMark")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sweep_mark");
|
||||
|
||||
b.Property<long>("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<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("DeviceId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("device_wrapped_private_key");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("issuer");
|
||||
|
||||
b.Property<string>("KdfAlgorithm")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kdf_algorithm");
|
||||
|
||||
b.Property<int>("KdfMemoryKibibytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_memory_kibibytes");
|
||||
|
||||
b.Property<int>("KdfParallelism")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_parallelism");
|
||||
|
||||
b.Property<int>("KdfPasses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_passes");
|
||||
|
||||
b.Property<byte[]>("KdfSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("kdf_salt");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<byte[]>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public partial class AddSpelunkSignIns : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>(type: "TEXT", nullable: false),
|
||||
@sealed = table.Column<byte[]>(name: "sealed", type: "BLOB", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_spelunk_sign_in", x => x.id);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "spelunk_sign_in");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -344,6 +344,27 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("Sealed")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed");
|
||||
|
||||
b.Property<long>("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<Guid>("VaultId")
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using DodoSSH.Crypto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>This device's sign-in to one Spelunk, as <see cref="SpelunkSignInStore"/> keeps it.</summary>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as a host's route stores it.</param>
|
||||
/// <param name="Token">What Spelunk issued this device: sent as the bearer token on every ticket request.</param>
|
||||
/// <param name="SessionId">Spelunk's id for this sign-in, which its person sees under Your account.</param>
|
||||
/// <param name="Email">Who it is signed in as, for saying so.</param>
|
||||
/// <param name="DisplayName">The same, as Spelunk names them.</param>
|
||||
/// <param name="SignedInAtUtc">When this device signed in.</param>
|
||||
public sealed record SpelunkSignInRecord(
|
||||
string ApiBase,
|
||||
string Token,
|
||||
Guid SessionId,
|
||||
string Email,
|
||||
string DisplayName,
|
||||
DateTimeOffset SignedInAtUtc)
|
||||
{
|
||||
/// <summary>Keeps the token out of logs and debugger views.</summary>
|
||||
public override string ToString() => $"{Email} at {ApiBase}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see
|
||||
/// <see cref="SpelunkSignInRow"/> for why per device and why sealed whole.
|
||||
/// </summary>
|
||||
public sealed class SpelunkSignInStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>Every sign-in this key can open. One that will not open is skipped, as a stale cache record is.</summary>
|
||||
public async Task<IReadOnlyList<SpelunkSignInRecord>> ListAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<SpelunkSignInRow>()
|
||||
.AsNoTracking()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows
|
||||
.Select(Open)
|
||||
.OfType<SpelunkSignInRecord>()
|
||||
.OrderBy(record => record.ApiBase, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>The sign-in for a Spelunk, or null when this device is not signed in to it.</summary>
|
||||
public async Task<SpelunkSignInRecord?> FindAsync(string apiBase, CancellationToken cancellationToken) =>
|
||||
(await ListAsync(cancellationToken).ConfigureAwait(false))
|
||||
.FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal));
|
||||
|
||||
/// <summary>Keeps a sign-in, replacing any this device had for the same Spelunk.</summary>
|
||||
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<SpelunkSignInRow>().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);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the sign-in for a Spelunk, if this device had one.</summary>
|
||||
public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var rows = await context.Set<SpelunkSignInRow>().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);
|
||||
}
|
||||
|
||||
/// <summary>Forgets every Spelunk sign-in on this device.</summary>
|
||||
public async Task ForgetAllAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
await context.Set<SpelunkSignInRow>().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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -170,9 +170,6 @@ internal static class ItemKinds
|
||||
|
||||
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
|
||||
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
|
||||
|
||||
(SyncEntityType.SpelunkKey, static (outbox, conflicts, keyring) =>
|
||||
new ItemReconciler<SpelunkKeySecret>(SpelunkKeyKind.Instance, outbox, conflicts, keyring)),
|
||||
];
|
||||
|
||||
/// <summary>The types to ask the server for, in a fixed order.</summary>
|
||||
@@ -1061,82 +1058,3 @@ internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
|
||||
return secret with { Label = label };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Spelunk keys.</summary>
|
||||
internal sealed class SpelunkKeyKind : IItemKind<SpelunkKeySecret>
|
||||
{
|
||||
internal static SpelunkKeyKind Instance { get; } = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType EntityType => SyncEntityType.SpelunkKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Noun => "Spelunk key";
|
||||
|
||||
/// <inheritdoc />
|
||||
public OpenedItem<SpelunkKeySecret>? TryOpen(
|
||||
EncryptedPayload payload,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
int itemVersion)
|
||||
{
|
||||
var document = SpelunkKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
|
||||
|
||||
return document is null
|
||||
? null
|
||||
: new OpenedItem<SpelunkKeySecret>(document.Key, document.IsReadOnly);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public EncryptedPayload Seal(
|
||||
SpelunkKeySecret secret,
|
||||
ReadOnlySpan<byte> vaultKey,
|
||||
Guid entityId,
|
||||
uint keyGeneration,
|
||||
int itemVersion) =>
|
||||
SpelunkKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Nothing. The address is the only candidate and must not go: nothing on the server calls Spelunk.
|
||||
/// </summary>
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Fields(SpelunkKeySecret secret) => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public MergedItem<SpelunkKeySecret> Merge(
|
||||
SpelunkKeySecret ancestor,
|
||||
SpelunkKeySecret local,
|
||||
SpelunkKeySecret remote)
|
||||
{
|
||||
var merged = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
return new MergedItem<SpelunkKeySecret>(merged.Merged, merged.Conflicts);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<string> Changes(SpelunkKeySecret before, SpelunkKeySecret after)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(before);
|
||||
ArgumentNullException.ThrowIfNull(after);
|
||||
|
||||
var changed = new List<string>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SpelunkKeySecret Relabel(SpelunkKeySecret secret, string label)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(secret);
|
||||
|
||||
return secret with { Label = label };
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a Spelunk key into an item payload and back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors <see cref="ObjectStoreCipher"/> exactly, including the version rule in <see cref="SyncVersions"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The resource type is named, never cast.</b> <c>SyncEntityType.SpelunkKey</c> is 14 and
|
||||
/// <c>CryptoSpec.AadResourceType.SpelunkKey</c> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkKeyCipher
|
||||
{
|
||||
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.SpelunkKey;
|
||||
|
||||
/// <summary>Encrypts a Spelunk key.</summary>
|
||||
/// <param name="key">The key. Must be valid for storage.</param>
|
||||
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
|
||||
/// <param name="entityId">The item id, which the AAD binds.</param>
|
||||
/// <param name="keyGeneration">The vault's current key generation.</param>
|
||||
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
|
||||
public static EncryptedPayload Seal(
|
||||
SpelunkKeySecret key,
|
||||
ReadOnlySpan<byte> 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decrypts a Spelunk key.</summary>
|
||||
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
|
||||
public static SpelunkKeySecretDocument? TryOpen(
|
||||
EncryptedPayload payload,
|
||||
ReadOnlySpan<byte> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.Sync;
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk keys in this vault, decrypted, with unpushed local changes laid over them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkKeyRepository(
|
||||
ItemStore items,
|
||||
OutboxStore outbox,
|
||||
VaultKeyring keyring,
|
||||
IActivityLogSink? activity = null)
|
||||
{
|
||||
private readonly VaultItemRepository<SpelunkKeySecret> keys =
|
||||
new(SpelunkKeyKind.Instance, items, outbox, keyring, activity);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
|
||||
public Task<ItemListing<SpelunkKeySecret>> ListAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.ListAsync(vaultId, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
|
||||
public Task<Guid> CreateAsync(
|
||||
Guid vaultId,
|
||||
SpelunkKeySecret key,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.CreateAsync(vaultId, key, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
|
||||
public Task UpdateAsync(
|
||||
Guid vaultId,
|
||||
Guid entityId,
|
||||
SpelunkKeySecret key,
|
||||
CancellationToken cancellationToken) =>
|
||||
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
|
||||
|
||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
||||
keys.DeleteAsync(vaultId, entityId, cancellationToken);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ public sealed class SyncEngine
|
||||
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,13 +67,6 @@ public enum SyncEntityType
|
||||
/// </remarks>
|
||||
ObjectStore = 13,
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key, and the one Spelunk address it may be sent to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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 <c>SpelunkKeySecret</c>.
|
||||
/// </remarks>
|
||||
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.
|
||||
}
|
||||
@@ -174,8 +174,11 @@ public static class CryptoSpec
|
||||
/// <summary>An S3-compatible bucket, and the credentials that reach it.</summary>
|
||||
ObjectStore = 16,
|
||||
|
||||
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
|
||||
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.
|
||||
|
||||
/// <summary>This device's sign-in to one Spelunk. Local cache only; never synced.</summary>
|
||||
SpelunkSignIn = 18,
|
||||
}
|
||||
|
||||
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
||||
|
||||
@@ -556,62 +556,6 @@ public sealed class VaultObjectStore : IVaultItem
|
||||
public Guid UpdatedByUserId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Spelunk API key and the Spelunk address it may be sent to, as ciphertext.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="VaultObjectStore"/>'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.
|
||||
/// </remarks>
|
||||
public sealed class VaultSpelunkKey : IVaultItem
|
||||
{
|
||||
/// <summary>Primary key. UUIDv7, generated by the client so a key can be added offline.</summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>Owning vault.</summary>
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
/// <summary>Owning vault.</summary>
|
||||
public Vault? Vault { get; set; }
|
||||
|
||||
/// <summary>The encrypted key and its address: a DSH1 envelope. Opaque to the server.</summary>
|
||||
public byte[] Payload { get; set; } = [];
|
||||
|
||||
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
|
||||
public byte[]? DataKeyWrap { get; set; }
|
||||
|
||||
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
|
||||
public Guid? ContentKeyId { get; set; }
|
||||
|
||||
/// <summary>Vault key generation this payload was encrypted under.</summary>
|
||||
public int KeyGeneration { get; set; }
|
||||
|
||||
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
|
||||
public short PayloadAadVersion { get; set; }
|
||||
|
||||
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
|
||||
public int Version { get; set; }
|
||||
|
||||
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
|
||||
public long ChangeSequence { get; set; }
|
||||
|
||||
/// <summary>Creation timestamp.</summary>
|
||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Last modification timestamp.</summary>
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Soft-delete marker; a tombstone, so an offline client learns the key went away.</summary>
|
||||
public DateTimeOffset? DeletedAtUtc { get; set; }
|
||||
|
||||
/// <summary>Who created it.</summary>
|
||||
public Guid CreatedByUserId { get; set; }
|
||||
|
||||
/// <summary>Who last modified it.</summary>
|
||||
public Guid UpdatedByUserId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One connection that was made, as ciphertext.
|
||||
/// </summary>
|
||||
|
||||
@@ -58,8 +58,7 @@ public enum ChangeEntityType
|
||||
/// <summary>An S3-compatible bucket, and the credentials that reach it. M2.</summary>
|
||||
ObjectStore = 13,
|
||||
|
||||
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
|
||||
SpelunkKey = 14,
|
||||
// 14 was SpelunkKey, removed with its table; see the RemoveSpelunkKeyItem migration. Not to be reused.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -384,41 +384,6 @@ public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration<VaultObj
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <see cref="VaultSpelunkKey"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ObjectStoreConfiguration"/> again: a key and the address it goes to, neither of which belongs
|
||||
/// in a column.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkKeyConfiguration : IEntityTypeConfiguration<VaultSpelunkKey>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<VaultSpelunkKey> 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"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
||||
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
||||
{
|
||||
|
||||
@@ -81,9 +81,6 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
||||
/// <summary>S3-compatible buckets and their credentials, held as ciphertext.</summary>
|
||||
public DbSet<VaultObjectStore> ObjectStores => Set<VaultObjectStore>();
|
||||
|
||||
/// <summary>Spelunk API keys and the addresses they may be sent to, held as ciphertext.</summary>
|
||||
public DbSet<VaultSpelunkKey> SpelunkKeys => Set<VaultSpelunkKey>();
|
||||
|
||||
/// <summary>The per-vault change log that delta sync reads.</summary>
|
||||
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
||||
|
||||
|
||||
+1911
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public partial class RemoveSpelunkKeyItem : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM dodo.sync_change WHERE entity_type = 14;");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "spelunk_key",
|
||||
schema: "dodo");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "spelunk_key",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
change_sequence = table.Column<long>(type: "bigint", nullable: false),
|
||||
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
key_generation = table.Column<int>(type: "integer", nullable: false),
|
||||
payload = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
|
||||
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
version = table.Column<int>(type: "integer", nullable: false),
|
||||
xmin = table.Column<uint>(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1491,87 +1491,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("ContentKeyId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("content_key_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<Guid>("CreatedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<byte[]>("DataKeyWrap")
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("data_key_wrap");
|
||||
|
||||
b.Property<DateTimeOffset?>("DeletedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("deleted_at_utc");
|
||||
|
||||
b.Property<int>("KeyGeneration")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("bytea")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<short>("PayloadAadVersion")
|
||||
.HasColumnType("smallint")
|
||||
.HasColumnName("payload_aad_version");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UpdatedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("updated_by_user_id");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<uint>("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<Guid>("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")
|
||||
|
||||
Reference in new issue
Block a user