diff --git a/src/DodoSSH.Client.Session/DeviceKeys.cs b/src/DodoSSH.Client.Session/DeviceKeys.cs new file mode 100644 index 0000000..6e5ff30 --- /dev/null +++ b/src/DodoSSH.Client.Session/DeviceKeys.cs @@ -0,0 +1,85 @@ +namespace DodoSSH.Client.Session; + +/// +/// Where this machine keeps the private half of its device key. +/// +/// +/// +/// An interface because the answer is a platform decision and a security decision, recorded in +/// ADR 0007: on Windows a gesture guards it, and the gesture is the whole value — it is what stops a +/// process running as the user from unlocking the vault silently. Nothing in this assembly should know +/// which gesture, and nothing above it should be able to skip one. +/// +/// +/// Exactly 32 bytes, and only because the cache key moved. This holds the X25519 device private +/// key and nothing else. It would have had to hold the local cache key too — a second live secret at +/// rest, going stale on every passphrase change — had that key not been re-keyed to the identity bundle +/// first. See docs/crypto.md §3.2. +/// +/// +/// Every member may fail or refuse, and refusal is ordinary rather than exceptional: a user can cancel a +/// fingerprint prompt, a Hello key is invalidated when the PIN is reset, and a machine may have no +/// keystore at all. therefore returns null rather than throwing, and the +/// caller's answer is always the same — ask for the passphrase. +/// +/// +public interface IDeviceKeyStore +{ + /// Whether this machine can keep a device key at all. + /// + /// Asked before offering to register one. Registering a device whose private half does not survive + /// the process would put a wrap on the server that nothing can ever open, and make the account's + /// device list claim a capability this machine does not have. + /// + ValueTask IsAvailableAsync(CancellationToken cancellationToken); + + /// Stores the device private key, replacing any already held. + /// The raw 32-byte X25519 scalar. + /// Cancellation token. + ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken); + + /// + /// Retrieves the device private key, prompting for whatever guards it. + /// + /// + /// The raw scalar, or if there is none, the user declined, or the platform + /// has invalidated it. The three are deliberately not distinguished: the caller does the same thing + /// in each case, and a message naming which one would be describing the keystore rather than telling + /// the user anything they can act on. + /// + ValueTask TryLoadAsync(CancellationToken cancellationToken); + + /// Discards the stored key. + /// + /// Local only. The server's wrap row outlives this and has to be deleted separately, or the account + /// will go on listing a device that can no longer unlock anything. + /// + ValueTask ForgetAsync(CancellationToken cancellationToken); +} + +/// +/// A machine with nowhere to keep a device key. +/// +/// +/// What the application composes until a real keystore is wired up, and the honest answer for a platform +/// that has none. Reports unavailable and holds nothing, so unlock asks for the passphrase exactly as it +/// did before any of this existed — a placeholder that changes no behaviour rather than one that pretends. +/// +public sealed class UnavailableDeviceKeyStore : IDeviceKeyStore +{ + /// + public ValueTask IsAvailableAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(false); + + /// + public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) => + throw new NotSupportedException( + "This machine has no device key store. Check IsAvailableAsync before offering to register one."); + + /// + public ValueTask TryLoadAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(null); + + /// + public ValueTask ForgetAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask; +} diff --git a/src/DodoSSH.Client.Session/SessionOpener.cs b/src/DodoSSH.Client.Session/SessionOpener.cs index 4e47409..00455f8 100644 --- a/src/DodoSSH.Client.Session/SessionOpener.cs +++ b/src/DodoSSH.Client.Session/SessionOpener.cs @@ -1,6 +1,8 @@ +using System.Security.Cryptography; using DodoSSH.Client.Storage; using DodoSSH.Client.Sync; using DodoSSH.Crypto; +using NSec.Cryptography; namespace DodoSSH.Client.Session; @@ -41,6 +43,31 @@ public enum UnlockStatus /// The cached KDF parameters are not something this build can use. UnsupportedKdf = 5, + + /// No device key is registered on this machine, so there is nothing to unlock with. + /// + /// The ordinary state for a machine nobody has opted in on, and not an error. A caller that offers + /// device unlock should check this before showing a gesture prompt that cannot lead anywhere. + /// + NoDeviceKey = 6, + + /// + /// A device key is registered but this machine would not hand it over. + /// + /// + /// The user declined the gesture, or the platform invalidated the key — a Hello key does not survive a + /// PIN reset. The two are deliberately not distinguished: the remedy is the passphrase either way, and + /// a message naming which one describes the keystore rather than telling the user anything useful. + /// + DeviceKeyUnavailable = 7, + + /// The device key was retrieved and did not open the wrap. + /// + /// What a rotated identity looks like from a machine whose device wrap predates it. Distinct from + /// because this one will never succeed again — the wrap is for a + /// bundle that no longer exists, and the device has to be registered afresh from an unlocked session. + /// + DeviceKeyRejected = 8, } /// The result of an unlock attempt. @@ -130,6 +157,130 @@ public sealed class SessionOpener( } } + /// + /// Attempts to open the vault with this machine's device key instead of the passphrase. + /// + /// + /// + /// Touches no network, exactly as the passphrase path does not: the device wrap is cached at + /// registration precisely so the one unlock that saves the user typing is not the one that needs to be + /// online. A gesture on a plane is the case this exists for. + /// + /// + /// Every failure returns rather than throws, and every failure has the same remedy — ask for the + /// passphrase. That is why the caller gets a status and a sentence and not an exception: none of these + /// are exceptional, and a cancelled fingerprint prompt is the most ordinary thing here. + /// + /// + public async Task UnlockWithDeviceAsync( + IDeviceKeyStore deviceKeys, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(deviceKeys); + + var profile = await ReadProfileAsync(cancellationToken).ConfigureAwait(false); + + if (profile is null) + { + return new UnlockOutcome( + UnlockStatus.NotEnrolled, + null, + "This machine is not enrolled yet. Sign in to a DodoSSH server to set it up."); + } + + if (profile.DeviceWrappedPrivateKey is not { } wrap) + { + return new UnlockOutcome( + UnlockStatus.NoDeviceKey, + null, + "This machine has no device key registered. Unlock with your passphrase."); + } + + var material = await deviceKeys.TryLoadAsync(cancellationToken).ConfigureAwait(false); + + if (material is null) + { + return new UnlockOutcome( + UnlockStatus.DeviceKeyUnavailable, + null, + "This machine did not release its device key. Unlock with your passphrase."); + } + + return await OpenWithDeviceAsync(profile, wrap, material, cancellationToken) + .ConfigureAwait(false); + } + + /// + /// The raw scalar is zeroed before this returns whatever happens. It came out of a keystore into a + /// managed array, which is the one span of its life nothing else is guarding it. + /// + private async Task OpenWithDeviceAsync( + StoredUnlockMaterial profile, + byte[] wrap, + byte[] material, + CancellationToken cancellationToken) + { + UserSecretBundle? bundle; + + try + { + bundle = OpenSealedBundle(profile, wrap, material); + } + finally + { + CryptographicOperations.ZeroMemory(material); + } + + if (bundle is null) + { + return new UnlockOutcome( + UnlockStatus.DeviceKeyRejected, + null, + "This machine's device key no longer opens the vault. Unlock with your passphrase; the " + + "device can then be registered again."); + } + + var protector = LocalCacheProtector.From(bundle); + + try + { + return await BuildSessionAsync(profile, bundle, protector, cancellationToken) + .ConfigureAwait(false); + } + catch + { + protector.Dispose(); + bundle.Dispose(); + throw; + } + } + + /// + /// Returns null for a scalar of the wrong length as well as for a wrap that does not open, because a + /// keystore handing back something that is not a key is the same situation from here: this machine + /// cannot unlock and the passphrase can. + /// + private static UserSecretBundle? OpenSealedBundle( + StoredUnlockMaterial profile, + byte[] wrap, + byte[] material) + { + if (material.Length != CryptoSpec.SymmetricKeySize) + { + return null; + } + + using var deviceKey = Key.Import( + KeyAgreementAlgorithm.X25519, + material, + KeyBlobFormat.RawPrivateKey); + + return UserSecretBundle.TryOpenSealed( + deviceKey, + wrap, + DshAad.UserSecretBundle(profile.UserId, profile.KeyGeneration)); + } + /// /// The master key lives only inside this method — it opens the bundle and is then done with. The cache /// protector derives from the bundle rather than from the master key, which is what lets a device or diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs index 973c3b5..0201ab3 100644 --- a/src/DodoSSH.Client.Session/VaultSession.cs +++ b/src/DodoSSH.Client.Session/VaultSession.cs @@ -1,8 +1,10 @@ +using System.Security.Cryptography; using DodoSSH.Client.Api; using DodoSSH.Client.Storage; using DodoSSH.Client.Sync; using DodoSSH.Contracts; using DodoSSH.Crypto; +using NSec.Cryptography; namespace DodoSSH.Client.Session; @@ -74,6 +76,7 @@ public sealed class VaultSession : IAsyncDisposable SyncState = new SyncStateStore(caches); Conflicts = new ConflictStore(caches, protector, clock); Vault = new VaultStore(caches, clock); + Unlock = new UnlockStore(caches, clock); Hosts = new HostRepository(Items, Outbox, keyring); SshKeys = new SshKeyRepository(Items, Outbox, keyring); Credentials = new CredentialRepository(Items, Outbox, keyring); @@ -123,6 +126,13 @@ public sealed class VaultSession : IAsyncDisposable internal VaultStore Vault { get; } + /// + /// Held so registering or forgetting a device can record it against the profile. Built here with the + /// other stores rather than on demand, so the cache factory does not have to be kept as a field for + /// one method's sake. + /// + internal UnlockStore Unlock { get; } + /// Runs one synchronisation pass over the active vault. /// The transport. Supplied per call because a session outlives any one connection. /// Cancellation token. @@ -137,6 +147,95 @@ public sealed class VaultSession : IAsyncDisposable return engine.SyncAsync(ActiveVaultId, cancellationToken); } + /// + /// Registers this machine's device key, so a later launch can unlock without the passphrase. + /// + /// The transport, supplied per call as takes its own. + /// Where the private half will live. See ADR 0007. + /// What to call this machine in the account's device list. + /// Cancellation token. + /// + /// when a device was registered; when this machine has + /// nowhere to keep the key, which is not a failure — it is the answer for a platform with no keystore. + /// + /// + /// + /// Here rather than in a service above, because sealing the bundle is the one step only an open session + /// can do and this type is the bundle's custodian. Everything else — the call, the keystore — arrives as + /// a parameter, so the session still knows nothing about how either is implemented. + /// + /// + /// Ordered so a failure cannot leave a lie behind. The key is generated, stored locally, and only + /// then registered with the server; the local wrap is cached last, once the server has accepted it. A + /// server row whose private half was never saved is a device that can never unlock and that the account + /// claims can, which is worse than not offering the feature — so the write that could produce it happens + /// after the one that prevents it. + /// + /// + public async Task RegisterDeviceAsync( + IAccountApi api, + IDeviceKeyStore deviceKeys, + string deviceName, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(disposed, this); + ArgumentNullException.ThrowIfNull(api); + ArgumentNullException.ThrowIfNull(deviceKeys); + ArgumentException.ThrowIfNullOrWhiteSpace(deviceName); + + if (!await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(false)) + { + return false; + } + + using var deviceKey = Key.Create( + KeyAgreementAlgorithm.X25519, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + var publicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); + var wrap = bundle.SealTo(publicKey, DshAad.UserSecretBundle(Profile.UserId, Profile.KeyGeneration)); + + var privateKey = deviceKey.Export(KeyBlobFormat.RawPrivateKey); + + try + { + await deviceKeys.SaveAsync(privateKey, cancellationToken).ConfigureAwait(false); + } + finally + { + CryptographicOperations.ZeroMemory(privateKey); + } + + var registered = await api + .RegisterDeviceAsync(new RegisterDeviceRequest(deviceName, publicKey, wrap), cancellationToken) + .ConfigureAwait(false); + + await Unlock.AttachDeviceAsync(registered.DeviceId, wrap, cancellationToken) + .ConfigureAwait(false); + + return true; + } + + /// + /// Withdraws this machine's device key, locally. + /// + /// + /// Deliberately incomplete, and the gap is recorded rather than papered over: the server's wrap row + /// survives this, so the account will go on listing a device that can no longer unlock. Deleting it + /// needs an endpoint that does not exist yet. Until then the honest half is this one — the machine stops + /// being able to unlock without a passphrase, which is what a user asking to turn it off means. + /// + public async Task ForgetDeviceAsync(IDeviceKeyStore deviceKeys, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(disposed, this); + ArgumentNullException.ThrowIfNull(deviceKeys); + + await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false); + + await Unlock.DetachDeviceAsync(cancellationToken) + .ConfigureAwait(false); + } + /// /// Reads the conflicts a person still needs to see. /// diff --git a/src/DodoSSH.Client.Storage/CacheRows.cs b/src/DodoSSH.Client.Storage/CacheRows.cs index d99c13e..8b14066 100644 --- a/src/DodoSSH.Client.Storage/CacheRows.cs +++ b/src/DodoSSH.Client.Storage/CacheRows.cs @@ -43,6 +43,27 @@ internal sealed class UnlockMaterialRow /// The secret bundle, wrapped under the passphrase-derived key. Ciphertext. public byte[] WrappedPrivateKey { get; set; } = []; + /// + /// The same bundle sealed to this machine's device key, when one is registered. Ciphertext. + /// + /// + /// Cached for the same reason the passphrase wrap is: so unlock needs no network. Fetching it at + /// unlock time would mean the one unlock path that exists to save the user typing only works when + /// they are online, which is backwards — a laptop on a plane is exactly where this should help. + /// + /// Holding it here is what makes whatever guards the device key as strong as the passphrase for this + /// cache, which is stated in ADR 0007 rather than hidden in a column comment. + /// + /// + public byte[]? DeviceWrappedPrivateKey { get; set; } + + /// The server's id for this device, so its wrap can be revoked. + /// + /// Kept because forgetting a device locally is only half the job: the server's row outlives it and + /// would go on claiming this machine can unlock. See ADR 0007's consequences. + /// + public Guid? DeviceId { get; set; } + public string KdfAlgorithm { get; set; } = string.Empty; public byte[] KdfSalt { get; set; } = []; diff --git a/src/DodoSSH.Client.Storage/Migrations/20260730112940_AddDeviceWrap.Designer.cs b/src/DodoSSH.Client.Storage/Migrations/20260730112940_AddDeviceWrap.Designer.cs new file mode 100644 index 0000000..b4b02ea --- /dev/null +++ b/src/DodoSSH.Client.Storage/Migrations/20260730112940_AddDeviceWrap.Designer.cs @@ -0,0 +1,416 @@ +// +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("20260730112940_AddDeviceWrap")] + partial class AddDeviceWrap + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("EntityType") + .HasColumnType("INTEGER") + .HasColumnName("entity_type"); + + b.Property("EntityId") + .HasColumnType("TEXT") + .HasColumnName("entity_id"); + + b.Property("AadVersion") + .HasColumnType("INTEGER") + .HasColumnName("aad_version"); + + b.Property("ChangeSequence") + .HasColumnType("INTEGER") + .HasColumnName("change_sequence"); + + b.Property("DataKeyId") + .HasColumnType("TEXT") + .HasColumnName("data_key_id"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER") + .HasColumnName("is_deleted"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("Payload") + .HasColumnType("BLOB") + .HasColumnName("payload"); + + b.Property("ProtectedFields") + .HasColumnType("BLOB") + .HasColumnName("protected_fields"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.Property("Version") + .HasColumnType("INTEGER") + .HasColumnName("version"); + + b.Property("WrappedDataKey") + .HasColumnType("BLOB") + .HasColumnName("wrapped_data_key"); + + b.HasKey("VaultId", "EntityType", "EntityId") + .HasName("pk_item"); + + b.HasIndex("VaultId", "ChangeSequence") + .HasDatabaseName("ix_item_vault_id_change_sequence"); + + b.HasIndex("VaultId", "EntityType") + .HasDatabaseName("ix_item_vault_id_entity_type"); + + b.ToTable("item", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("IsPersonal") + .HasColumnType("INTEGER") + .HasColumnName("is_personal"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("Permissions") + .HasColumnType("INTEGER") + .HasColumnName("permissions"); + + b.Property("RekeyRequired") + .HasColumnType("INTEGER") + .HasColumnName("rekey_required"); + + b.Property("TeamId") + .HasColumnType("TEXT") + .HasColumnName("team_id"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.Property("WrappedVaultKey") + .HasColumnType("BLOB") + .HasColumnName("wrapped_vault_key"); + + b.HasKey("VaultId") + .HasName("pk_vault"); + + b.ToTable("vault", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Acknowledged") + .HasColumnType("INTEGER") + .HasColumnName("acknowledged"); + + b.Property("Detail") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("detail"); + + b.Property("DetectedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("detected_at_utc"); + + b.Property("EntityId") + .HasColumnType("TEXT") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("INTEGER") + .HasColumnName("entity_type"); + + b.Property("Kind") + .HasColumnType("INTEGER") + .HasColumnName("kind"); + + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.HasKey("Id") + .HasName("pk_conflict"); + + b.HasIndex("VaultId", "Acknowledged") + .HasDatabaseName("ix_conflict_vault_id_acknowledged"); + + b.HasIndex("VaultId", "EntityType", "EntityId") + .HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id"); + + b.ToTable("conflict", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b => + { + b.Property("Sequence") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasColumnName("sequence"); + + b.Property("AadVersion") + .HasColumnType("INTEGER") + .HasColumnName("aad_version"); + + b.Property("AncestorAadVersion") + .HasColumnType("INTEGER") + .HasColumnName("ancestor_aad_version"); + + b.Property("AncestorDataKeyId") + .HasColumnType("TEXT") + .HasColumnName("ancestor_data_key_id"); + + b.Property("AncestorKeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("ancestor_key_generation"); + + b.Property("AncestorPayload") + .HasColumnType("BLOB") + .HasColumnName("ancestor_payload"); + + b.Property("AncestorProtectedFields") + .HasColumnType("BLOB") + .HasColumnName("ancestor_protected_fields"); + + b.Property("AncestorVersion") + .HasColumnType("INTEGER") + .HasColumnName("ancestor_version"); + + b.Property("AncestorWrappedDataKey") + .HasColumnType("BLOB") + .HasColumnName("ancestor_wrapped_data_key"); + + b.Property("Attempts") + .HasColumnType("INTEGER") + .HasColumnName("attempts"); + + b.Property("DataKeyId") + .HasColumnType("TEXT") + .HasColumnName("data_key_id"); + + b.Property("EntityId") + .HasColumnType("TEXT") + .HasColumnName("entity_id"); + + b.Property("EntityType") + .HasColumnType("INTEGER") + .HasColumnName("entity_type"); + + b.Property("ExpectedVersion") + .HasColumnType("INTEGER") + .HasColumnName("expected_version"); + + b.Property("IsParked") + .HasColumnType("INTEGER") + .HasColumnName("is_parked"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("LastError") + .HasColumnType("TEXT") + .HasColumnName("last_error"); + + b.Property("Operation") + .HasColumnType("INTEGER") + .HasColumnName("operation"); + + b.Property("OperationId") + .HasColumnType("TEXT") + .HasColumnName("operation_id"); + + b.Property("Payload") + .HasColumnType("BLOB") + .HasColumnName("payload"); + + b.Property("ProtectedFields") + .HasColumnType("BLOB") + .HasColumnName("protected_fields"); + + b.Property("QueuedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("queued_at_utc"); + + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("WrappedDataKey") + .HasColumnType("BLOB") + .HasColumnName("wrapped_data_key"); + + b.HasKey("Sequence") + .HasName("pk_outbox"); + + b.HasIndex("OperationId") + .IsUnique() + .HasDatabaseName("ix_outbox_operation_id"); + + b.HasIndex("VaultId", "EntityType", "EntityId") + .IsUnique() + .HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id"); + + b.HasIndex("VaultId", "IsParked", "Sequence") + .HasDatabaseName("ix_outbox_vault_id_is_parked_sequence"); + + b.ToTable("outbox", (string)null); + }); + + modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b => + { + b.Property("VaultId") + .HasColumnType("TEXT") + .HasColumnName("vault_id"); + + b.Property("Cursor") + .HasColumnType("TEXT") + .HasColumnName("cursor"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("LastPulledAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("last_pulled_at_utc"); + + b.Property("LastPushedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("last_pushed_at_utc"); + + b.Property("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("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("DeviceId") + .HasColumnType("TEXT") + .HasColumnName("device_id"); + + b.Property("DeviceWrappedPrivateKey") + .HasColumnType("BLOB") + .HasColumnName("device_wrapped_private_key"); + + b.Property("DisplayName") + .HasColumnType("TEXT") + .HasColumnName("display_name"); + + b.Property("Email") + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("Issuer") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("issuer"); + + b.Property("KdfAlgorithm") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("kdf_algorithm"); + + b.Property("KdfMemoryKibibytes") + .HasColumnType("INTEGER") + .HasColumnName("kdf_memory_kibibytes"); + + b.Property("KdfParallelism") + .HasColumnType("INTEGER") + .HasColumnName("kdf_parallelism"); + + b.Property("KdfPasses") + .HasColumnType("INTEGER") + .HasColumnName("kdf_passes"); + + b.Property("KdfSalt") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("kdf_salt"); + + b.Property("KeyGeneration") + .HasColumnType("INTEGER") + .HasColumnName("key_generation"); + + b.Property("ServerUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("server_url"); + + b.Property("Subject") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subject"); + + b.Property("UpdatedAtUtc") + .HasColumnType("INTEGER") + .HasColumnName("updated_at_utc"); + + b.Property("UserId") + .HasColumnType("TEXT") + .HasColumnName("user_id"); + + b.Property("WrappedPrivateKey") + .IsRequired() + .HasColumnType("BLOB") + .HasColumnName("wrapped_private_key"); + + b.HasKey("Id") + .HasName("pk_unlock_material"); + + b.ToTable("unlock_material", null, t => + { + t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/DodoSSH.Client.Storage/Migrations/20260730112940_AddDeviceWrap.cs b/src/DodoSSH.Client.Storage/Migrations/20260730112940_AddDeviceWrap.cs new file mode 100644 index 0000000..7745baa --- /dev/null +++ b/src/DodoSSH.Client.Storage/Migrations/20260730112940_AddDeviceWrap.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DodoSSH.Client.Storage.Migrations +{ + /// + public partial class AddDeviceWrap : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "device_id", + table: "unlock_material", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "device_wrapped_private_key", + table: "unlock_material", + type: "BLOB", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "device_id", + table: "unlock_material"); + + migrationBuilder.DropColumn( + name: "device_wrapped_private_key", + table: "unlock_material"); + } + } +} diff --git a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs index dc2082b..cb9478b 100644 --- a/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs +++ b/src/DodoSSH.Client.Storage/Migrations/ClientCacheContextModelSnapshot.cs @@ -329,6 +329,14 @@ namespace DodoSSH.Client.Storage.Migrations .HasColumnType("INTEGER") .HasColumnName("id"); + b.Property("DeviceId") + .HasColumnType("TEXT") + .HasColumnName("device_id"); + + b.Property("DeviceWrappedPrivateKey") + .HasColumnType("BLOB") + .HasColumnName("device_wrapped_private_key"); + b.Property("DisplayName") .HasColumnType("TEXT") .HasColumnName("display_name"); diff --git a/src/DodoSSH.Client.Storage/StoredTypes.cs b/src/DodoSSH.Client.Storage/StoredTypes.cs index fc55d8b..7741ac7 100644 --- a/src/DodoSSH.Client.Storage/StoredTypes.cs +++ b/src/DodoSSH.Client.Storage/StoredTypes.cs @@ -58,7 +58,9 @@ public sealed record StoredUnlockMaterial( uint KeyGeneration, byte[] WrappedPrivateKey, KdfParameters KdfParameters, - DateTimeOffset UpdatedAt); + DateTimeOffset UpdatedAt, + byte[]? DeviceWrappedPrivateKey = null, + Guid? DeviceId = null); /// A cached vault and the grant that opens it. /// The vault. diff --git a/src/DodoSSH.Client.Storage/UnlockStore.cs b/src/DodoSSH.Client.Storage/UnlockStore.cs index 2122ce4..bf67ad5 100644 --- a/src/DodoSSH.Client.Storage/UnlockStore.cs +++ b/src/DodoSSH.Client.Storage/UnlockStore.cs @@ -96,11 +96,88 @@ public sealed class UnlockStore(IDbContextFactory contexts, await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } + /// + /// Records a device wrap against the existing profile. + /// + /// + /// Separate from because a device is registered long after enrollment, from an + /// unlocked session, and nothing else about the profile changes when it happens. + /// + /// This cache has never been enrolled. + public async Task AttachDeviceAsync( + Guid deviceId, + byte[] wrap, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(wrap); + + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + throw new InvalidOperationException( + "This cache is not enrolled, so there is no profile to attach a device to."); + } + + row.DeviceId = deviceId; + row.DeviceWrappedPrivateKey = wrap; + row.UpdatedAtUtc = clock.GetUtcNow(); + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// Forgets the device wrap, so this machine goes back to asking for the passphrase. + /// + /// Local only, and deliberately survivable: the server's wrap row is deleted separately, and a cache + /// that has forgotten its wrap while the server still lists the device is merely a machine that asks + /// for a passphrase. The reverse — a wrap here for a device the server has revoked — is the one that + /// would be confusing, and it resolves itself the moment the wrap fails to open. + /// + public async Task DetachDeviceAsync(CancellationToken cancellationToken) + { + var context = contexts.CreateDbContext(); + await using var scope = context.ConfigureAwait(false); + + var row = await context.Set() + .SingleOrDefaultAsync(cancellationToken) + .ConfigureAwait(false); + + if (row is null) + { + return; + } + + row.DeviceId = null; + row.DeviceWrappedPrivateKey = null; + row.UpdatedAtUtc = clock.GetUtcNow(); + + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// The device columns are written only when the incoming material carries them. This method + /// runs on every sign-in, from a /me response that knows nothing about this machine's + /// keystore — so assigning them unconditionally would quietly delete the device wrap on the next + /// launch, and the user would find their fingerprint had stopped working for no visible reason. An + /// enrollment that registers a device in the same breath still gets them written, because it supplies + /// them. + /// private static void Apply( UnlockMaterialRow row, StoredUnlockMaterial material, DateTimeOffset now) { + if (material.DeviceWrappedPrivateKey is not null) + { + row.DeviceWrappedPrivateKey = material.DeviceWrappedPrivateKey; + row.DeviceId = material.DeviceId; + } + row.ServerUrl = material.ServerUrl; row.UserId = material.UserId; row.Issuer = material.Issuer; @@ -133,5 +210,7 @@ public sealed class UnlockStore(IDbContextFactory contexts, row.KdfMemoryKibibytes, row.KdfPasses, row.KdfParallelism), - row.UpdatedAtUtc); + row.UpdatedAtUtc, + row.DeviceWrappedPrivateKey, + row.DeviceId); } diff --git a/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs b/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs new file mode 100644 index 0000000..8b41ba1 --- /dev/null +++ b/tests/DodoSSH.Client.Session.Tests/DeviceUnlockTests.cs @@ -0,0 +1,302 @@ +using DodoSSH.Client.Domain; +using DodoSSH.Client.Storage; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Session.Tests; + +/// +/// Unlocking with this machine's device key instead of the passphrase. +/// +/// +/// +/// The headline test is the offline one: register a device, close the vault, and open it again with no +/// server and no passphrase. That is the whole feature, and it is the case a design that fetched the wrap +/// at unlock time would have failed. +/// +/// +/// The rest are the ways it is allowed to fail, and every one of them has the same remedy — ask for the +/// passphrase. They are separate statuses rather than one because the caller shows a different sentence +/// for a machine that was never registered than for a gesture somebody declined. +/// +/// +public sealed class DeviceUnlockTests : IAsyncLifetime +{ + private const string Passphrase = "correct horse battery staple"; + private const string ServerUrl = "https://dodossh.example"; + + private static readonly Argon2Profile CheapProfile = + Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1); + + private readonly FakeAccountServer server = new(); + private readonly StubKeyBinding keyBinding = new(); + private readonly FakeDeviceKeyStore deviceKeys = new(); + + private ClientCacheFactory caches = null!; + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + /// + public async ValueTask InitializeAsync() + { + caches = ClientCacheFactory.ForMemory($"device-{Guid.CreateVersion7():N}"); + await caches.MigrateAsync(Token); + + await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token); + } + + /// + public ValueTask DisposeAsync() + { + caches.Dispose(); + return ValueTask.CompletedTask; + } + + [Fact] + public async Task ARegisteredDevice_UnlocksWithNoPassphraseAndNoNetwork() + { + // The feature. Everything else in this file is about the ways it declines to happen. + await using (var first = await UnlockAsync()) + { + (await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token)).ShouldBeTrue(); + } + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.IsUnlocked.ShouldBeTrue(outcome.Message); + await using var session = outcome.Session!; + + session.Profile.UserId.ShouldBe(server.UserId); + session.ActiveVaultId.ShouldNotBe(Guid.Empty); + } + + [Fact] + public async Task ADeviceUnlock_ReadsTheSameCacheThePassphraseWrote() + { + // Why the cache key had to move off the master key. A device unlock never computes one, so under the + // old derivation this session would have opened the identity and then found its own cache + // unreadable — see docs/crypto.md §3.2. + Guid hostId; + + await using (var first = await UnlockAsync()) + { + await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + + hostId = await first.Hosts.CreateAsync( + first.ActiveVaultId, + new HostSecret { Label = "db", Hostname = "db.internal", Username = "deploy" }, + Token); + } + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + outcome.IsUnlocked.ShouldBeTrue(outcome.Message); + + await using var session = outcome.Session!; + var hosts = await session.Hosts.ListAsync(session.ActiveVaultId, Token); + + hosts.Items.ShouldContain(host => host.EntityId == hostId); + } + + [Fact] + public async Task WithNoDeviceRegistered_ItSaysSoRatherThanFailing() + { + // The ordinary state of a machine nobody has opted in on. A caller checks this before showing a + // gesture prompt that could not lead anywhere. + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey); + outcome.Session.ShouldBeNull(); + } + + [Fact] + public async Task WhenTheGestureIsDeclined_ItAsksForThePassphrase() + { + await using (var first = await UnlockAsync()) + { + await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + } + + // What a cancelled fingerprint prompt looks like from here, and what a Hello key invalidated by a + // PIN reset looks like too. Deliberately the same status: the remedy does not differ. + deviceKeys.Decline = true; + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.Status.ShouldBe(UnlockStatus.DeviceKeyUnavailable); + outcome.Session.ShouldBeNull(); + } + + [Fact] + public async Task WhenTheStoredKeyDoesNotOpenTheWrap_ItIsRejectedRatherThanRetried() + { + await using (var first = await UnlockAsync()) + { + await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + } + + // A different key of the right length: what a rotated identity leaves behind on a machine whose + // wrap predates it. Distinct from a declined gesture because this one will never succeed again. + deviceKeys.Overwrite(new byte[CryptoSpec.SymmetricKeySize]); + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected); + outcome.Session.ShouldBeNull(); + } + + [Fact] + public async Task WhenTheKeystoreReturnsSomethingThatIsNotAKey_ItIsRefusedNotThrown() + { + // A keystore handing back the wrong number of bytes is a broken keystore, and the answer is still a + // passphrase prompt rather than a crash on the unlock screen. + await using (var first = await UnlockAsync()) + { + await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + } + + deviceKeys.Overwrite([1, 2, 3]); + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.Status.ShouldBe(UnlockStatus.DeviceKeyRejected); + } + + [Fact] + public async Task OnAMachineWithNoKeystore_RegisteringDeclinesAndRegistersNothing() + { + // Registering a device whose private half does not survive the process would put a wrap on the + // server that nothing can open, and make the account claim a capability this machine lacks. + await using var session = await UnlockAsync(); + + var registered = await session.RegisterDeviceAsync( + server, new UnavailableDeviceKeyStore(), "this laptop", Token); + + registered.ShouldBeFalse(); + server.RegisteredDevices.ShouldBeEmpty(); + } + + [Fact] + public async Task ForgettingTheDevice_SendsThisMachineBackToThePassphrase() + { + await using (var first = await UnlockAsync()) + { + await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + } + + await using (var second = (await Opener().UnlockWithDeviceAsync(deviceKeys, Token)).Session!) + { + await second.ForgetDeviceAsync(deviceKeys, Token); + } + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.Status.ShouldBe(UnlockStatus.NoDeviceKey); + + // And the passphrase still works, which is the property that makes forgetting safe to offer. + await using var byPassphrase = await UnlockAsync(); + byPassphrase.ActiveVaultId.ShouldNotBe(Guid.Empty); + } + + [Fact] + public async Task RefreshingTheProfile_DoesNotDiscardTheDeviceWrap() + { + // The trap in UnlockStore.Apply. /me is re-read on every sign-in and knows nothing about this + // machine's keystore, so writing its device columns unconditionally would delete the wrap on the + // next launch — and the user's fingerprint would stop working for no visible reason. + await using (var first = await UnlockAsync()) + { + await first.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + } + + await Provisioner().RefreshAsync(ServerUrl, Token); + + var outcome = await Opener().UnlockWithDeviceAsync(deviceKeys, Token); + + outcome.IsUnlocked.ShouldBeTrue(outcome.Message); + await outcome.Session!.DisposeAsync(); + } + + [Fact] + public async Task TheWrapReachesTheServerAndTheKeyDoesNot() + { + // The division the whole design rests on: the server stores a sealed bundle it cannot open, and the + // private half never leaves this machine. + await using var session = await UnlockAsync(); + + await session.RegisterDeviceAsync(server, deviceKeys, "this laptop", Token); + + server.RegisteredDevices.Count.ShouldBe(1); + + var stored = deviceKeys.Peek().ShouldNotBeNull(); + stored.Length.ShouldBe(CryptoSpec.SymmetricKeySize); + + // Nothing the server holds contains the private scalar. + foreach (var wrap in server.RegisteredDevices.Values) + { + Convert.ToHexString(wrap).ShouldNotContain( + Convert.ToHexString(stored), Case.Insensitive); + } + } + + // ---- Helpers ---- + + private SessionOpener Opener() => new(caches, TimeProvider.System); + + private AccountProvisioner Provisioner() => + new(server, keyBinding, caches, TimeProvider.System, CheapProfile); + + private async Task UnlockAsync() + { + var outcome = await Opener().UnlockAsync(Passphrase, Token); + + outcome.IsUnlocked.ShouldBeTrue(outcome.Message); + return outcome.Session!; + } +} + +/// +/// A device key store that keeps its key in a field. +/// +/// +/// Stands in for whatever guards the key on a real machine. The gesture is the entire security value of the +/// real thing, so what this fake models is the two ways the gesture ends: it hands the key over, or it does +/// not. is a cancelled prompt and an invalidated key at once, which is exactly how much +/// the caller is allowed to know. +/// +internal sealed class FakeDeviceKeyStore : IDeviceKeyStore +{ + private byte[]? key; + + /// When set, the next load refuses, as a cancelled gesture does. + internal bool Decline { get; set; } + + /// Whether this machine can keep a key at all. + internal bool IsAvailable { get; set; } = true; + + /// Reads the stored key without a gesture, for assertions only. + internal byte[]? Peek() => key; + + /// Replaces the stored key, standing in for a rotated or corrupted keystore entry. + internal void Overwrite(byte[] replacement) => key = replacement; + + /// + public ValueTask IsAvailableAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(IsAvailable); + + /// + public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) + { + key = devicePrivateKey.ToArray(); + return ValueTask.CompletedTask; + } + + /// + public ValueTask TryLoadAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(Decline ? null : key?.ToArray()); + + /// + public ValueTask ForgetAsync(CancellationToken cancellationToken) + { + key = null; + return ValueTask.CompletedTask; + } +}