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;
+ }
+}