Public Access
Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes Enter, which is the gesture everybody makes after typing a password and which did nothing until they found the button. Signing in survives a relaunch. The refresh token is kept in the local cache, sealed under the vault's own cache key, so a later launch resumes the session through the refresh grant with no browser and nobody present — and because it is sealed under that key, only an unlocked vault can resume it. A locked client therefore cannot reach the server at all, which is a consequence worth stating rather than working around; docs/crypto.md §3.2 records it. Every sync pass asks the shell for a connection rather than reading one captured at unlock, so a laptop that unlocked on a train is online within a minute of finding a network, with nothing pressed. Unlocking itself still never waits on a socket. Signing out empties this machine: the profile, the cached items, the outbox and this machine's device key, with the account's row withdrawn when the server can be reached. It asks first and says what it costs — the outbox count when the vault is open, an admission that it cannot be counted when it is not, and the shells that keep running either way. The vault is on the server and is untouched, which is what makes the same button the only honest answer to a forgotten passphrase, so it is on the unlock screen as well as in preferences. It cannot end the session at the identity provider, and says so. Two defects surfaced on the way. The synchronisation pass that runs when the vault opens never ran at all: the loop is started from inside the unlock command, so the busy flag it yields to was raised by that command — the first sync was a minute late on every launch. And signing in from preferences while unlocked threw an unlock screen over an open vault whose keys were still in memory. The unlock card and the new confirmation live in their own controls because MainWindow cannot be laid out headless, so markup left inside it is markup no test can measure; both are now measured at the window's minimum size in the shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
@@ -78,6 +78,37 @@ internal sealed class UnlockMaterialRow
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in this machine may resume without opening a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A single row, like <see cref="UnlockMaterialRow"/> and for the same reason: one cache holds one
|
||||
/// account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The token is sealed under the LocalCacheKey, which is the whole point of storing it here.</b> A
|
||||
/// refresh token is a long-lived credential for the account — not for the vault, which nothing but the
|
||||
/// passphrase opens — so a copy of this file lifted off a stolen laptop must not be one. Sealing it under
|
||||
/// a key that exists only while the vault is unlocked means the sign-in can only be resumed by somebody
|
||||
/// who has already opened the vault, which is exactly the moment the application wants it: unlock, then
|
||||
/// come back online by itself. It also means a locked machine cannot reach the server at all, which is a
|
||||
/// consequence worth stating rather than a limitation to work around.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RememberedSignInRow
|
||||
{
|
||||
/// <summary>The only legal primary key.</summary>
|
||||
internal const int SingletonId = 1;
|
||||
|
||||
public int Id { get; set; } = SingletonId;
|
||||
|
||||
/// <summary>The refresh token, sealed under the LocalCacheKey. Ciphertext.</summary>
|
||||
public byte[] SealedRefreshToken { 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
|
||||
|
||||
@@ -73,6 +73,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
ArgumentNullException.ThrowIfNull(modelBuilder);
|
||||
|
||||
ConfigureUnlockMaterial(modelBuilder);
|
||||
ConfigureRememberedSignIn(modelBuilder);
|
||||
ConfigureVaults(modelBuilder);
|
||||
ConfigureItems(modelBuilder);
|
||||
ConfigureOutbox(modelBuilder);
|
||||
@@ -102,6 +103,26 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
entity.Property(row => row.KdfSalt).IsRequired();
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// A table of its own rather than two more columns on <c>unlock_material</c>, because the two rows have
|
||||
/// opposite lifetimes: the unlock material is what makes this machine work offline and must survive
|
||||
/// everything short of a reset, while a remembered sign-in is dropped the moment the server stops
|
||||
/// accepting it. Deleting one must never be able to take the other with it.
|
||||
/// </remarks>
|
||||
private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<RememberedSignInRow>(entity =>
|
||||
{
|
||||
entity.ToTable(
|
||||
"remembered_sign_in",
|
||||
table => table.HasCheckConstraint(
|
||||
"ck_remembered_sign_in_singleton",
|
||||
$"id = {RememberedSignInRow.SingletonId}"));
|
||||
|
||||
entity.HasKey(row => row.Id);
|
||||
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||
{
|
||||
|
||||
@@ -123,6 +123,59 @@ public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>,
|
||||
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties the cache: every row of every table, and the pages they were written on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What signing out means on disk. The profile, the wrapped bundle, the item mirror, the outbox and
|
||||
/// the conflict log all go; the schema stays, so the application is usable again immediately and does
|
||||
/// not have to be restarted to be set up afresh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Emptied rather than deleted, and then vacuumed.</b> Deleting the file is the obvious move and is
|
||||
/// worse here: the database is in WAL mode, so it is three files rather than one — a routine that
|
||||
/// removes <c>cache.db</c> and leaves <c>-wal</c> behind loses to a checkpoint that puts some of it
|
||||
/// back — and on Windows the pooled connections hold the file open, so the delete fails outright while
|
||||
/// the application is running. The <c>VACUUM</c> is the half that makes this a wipe rather than a
|
||||
/// hide: SQLite marks deleted pages free without overwriting them, so ciphertext and sealed records
|
||||
/// would otherwise stay legible in the file until something happened to reuse the page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The migrations history is deliberately left alone. It describes the shape of the tables, not the
|
||||
/// user, and clearing it would make the next launch try to apply every migration to a schema that
|
||||
/// already has them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
// Taken from the model rather than listed here, so a table added later is emptied by a sign-out
|
||||
// without anyone having to remember this method exists.
|
||||
var tables = context.Model.GetEntityTypes()
|
||||
.Select(entityType => entityType.GetTableName())
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
// A table name cannot be a parameter, so it is quoted rather than bound. The value comes from
|
||||
// this assembly's own model metadata and never from input; the doubling is what keeps that
|
||||
// true of a name somebody eventually writes with a quote in it.
|
||||
var sql = string.Concat("DELETE FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
|
||||
|
||||
// EF1002 and CA2100 both describe interpolating a value into SQL, which is what the two lines
|
||||
// above are; the value is the one thing here that cannot come from a user.
|
||||
#pragma warning disable EF1002, CA2100
|
||||
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore EF1002, CA2100
|
||||
}
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
// <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("20260731082424_AddRememberedSignIn")]
|
||||
partial class AddRememberedSignIn
|
||||
{
|
||||
/// <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>("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.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
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.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<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
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,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRememberedSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "remembered_sign_in",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
sealed_refresh_token = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_remembered_sign_in", x => x.id);
|
||||
table.CheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "remembered_sign_in");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,30 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
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.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Crypto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in this machine may resume without opening a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One refresh token, sealed under the LocalCacheKey and bound to the user it belongs to. Everything about
|
||||
/// why it is sealed rather than stored — and what a locked machine therefore cannot do — is on
|
||||
/// <see cref="RememberedSignInRow"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server it belongs to is deliberately not recorded here: <c>unlock_material</c> already holds it, and
|
||||
/// two copies of one fact is two facts that can disagree. A cache holds one account and one server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RememberedSignInStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
Guid userId,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>Remembers a refresh token, replacing whatever was there.</summary>
|
||||
/// <remarks>
|
||||
/// Called again whenever the provider rotates the token. Keeping the one this client first received
|
||||
/// would leave a rotating provider refusing the next launch, which is the failure that reads as "the
|
||||
/// application randomly signs me out".
|
||||
/// </remarks>
|
||||
public async Task SaveAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<RememberedSignInRow>()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new RememberedSignInRow();
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(refreshToken);
|
||||
|
||||
try
|
||||
{
|
||||
row.SealedRefreshToken = protector.Protect(
|
||||
CryptoSpec.AadResourceType.User, userId, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The managed copy this method made, not the string it was handed — see the remark on
|
||||
// ReadAsync for what a .NET string does and does not allow here.
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
|
||||
row.UpdatedAtUtc = clock.GetUtcNow();
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the remembered token, or null when there is none this key can open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Null rather than an exception for a record that will not open, on the same reasoning as
|
||||
/// <see cref="LocalCacheProtector.TryUnprotect"/>: a cache written under a different identity is an
|
||||
/// ordinary situation and the answer is to sign in again, not to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It comes back as a <see cref="string"/>, which cannot be wiped. That is the same bargain the private
|
||||
/// key and password editors already make — every HTTP client on the way to the token endpoint wants a
|
||||
/// string — and pretending otherwise with a <c>SecureString</c> would buy nothing this process's memory
|
||||
/// does not already give away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<string?> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<RememberedSignInRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var opened = protector.TryUnprotect(
|
||||
CryptoSpec.AadResourceType.User, userId, row.SealedRefreshToken);
|
||||
|
||||
return opened is null ? null : Encoding.UTF8.GetString(opened);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the remembered sign-in, so the next launch has to use a browser.</summary>
|
||||
/// <remarks>
|
||||
/// Used when the provider refuses the token — a revoked session, a rotation this machine missed — as
|
||||
/// well as when the user signs out. Keeping a token that has already been refused would mean retrying
|
||||
/// it once a minute for the life of the profile.
|
||||
/// </remarks>
|
||||
public async Task ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
await context.Set<RememberedSignInRow>()
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user