Unlock with this machine's device key, without a passphrase or a network

The second of ADR 0007's three pieces: the seam a keystore plugs into, the wrap
cached where an offline unlock can reach it, and the unlock path itself. What is
still missing is the keystore — UnavailableDeviceKeyStore is what the application
composes for now, so behaviour is unchanged until piece three lands.

IDeviceKeyStore holds exactly 32 bytes, and only because the cache key moved
first. It would have had to hold the local cache key alongside the X25519 scalar —
a second live secret at rest, going stale on every passphrase change — had
7016ce3 not re-keyed that to the identity bundle. ADeviceUnlock_ReadsTheSameCache
ThePassphraseWrote is the test that ties the two commits together: under the old
derivation this session would have opened the identity and then found its own
cache unreadable.

The wrap is cached at registration rather than fetched at unlock, which is the
whole point. The one unlock path that exists to save the user typing must not be
the one that only works online; a laptop on a plane is precisely where a gesture
should help.

Every way this fails returns a status rather than throwing, because none of them
are exceptional — a cancelled fingerprint prompt is the most ordinary thing in
this file. Three statuses rather than one, because the caller says a different
sentence for each: no device registered (the normal state of a machine nobody
opted in on), the machine would not release the key (declined gesture, or a Hello
key invalidated by a PIN reset — deliberately indistinguishable, since the remedy
does not differ), and the key was released and did not open the wrap (a rotated
identity, which will never succeed again and needs re-registering). A keystore
returning the wrong number of bytes lands in the third rather than crashing the
unlock screen.

The subtle defect this could have shipped is in UnlockStore.Apply. That method
runs on every sign-in from a /me response, which knows nothing about this
machine's keystore — so assigning the device columns unconditionally would delete
the wrap on the next launch, and the user's fingerprint would stop working for no
visible reason and no error anywhere. The columns are therefore written only when
the incoming material carries them, with AttachDeviceAsync and DetachDeviceAsync
as the only paths that set them deliberately. Mutation tested: removing the guard
fails RefreshingTheProfile_DoesNotDiscardTheDeviceWrap and nothing else.

RegisterDeviceAsync lives on VaultSession because sealing the bundle is the one
step only an open session can do, and the session is the bundle's custodian.
Everything else arrives as a parameter, exactly as SyncAsync takes its transport,
so the session still knows nothing about how either the wire or the keystore is
implemented. Its steps are ordered so a failure cannot leave a lie behind: the key
is generated, saved locally, and only then registered with the server. A server
row whose private half was never stored is a device that can never unlock and that
the account claims can — worse than not offering the feature at all — so the write
that could produce it happens after the one that prevents it.

ForgetDeviceAsync is deliberately half a job, and says so. It stops this machine
unlocking without a passphrase, which is what a user turning the feature off means,
but the server's wrap row survives and the account will go on listing a device
that cannot unlock. Deleting it needs an endpoint that does not exist yet. Half
with the gap recorded beats a method whose name promises the other half.

The client cache gained two nullable columns and a migration, generated rather
than hand-written this time.

876 tests green, 10 of them new. Zero warnings, dotnet format clean.

Remaining: the Windows Hello store and the unlock-screen UI. That is where the
Windows target framework lands, and where automated testing stops — a gesture
needs hardware and a person, so the last piece is the one that has to be looked at
rather than asserted.
This commit is contained in:
2026-07-30 13:37:35 +02:00
parent db4a8ed3d3
commit 1faea42b94
10 changed files with 1204 additions and 2 deletions
+85
View File
@@ -0,0 +1,85 @@
namespace DodoSSH.Client.Session;
/// <summary>
/// Where this machine keeps the private half of its device key.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Exactly 32 bytes, and only because the cache key moved.</b> 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.
/// </para>
/// <para>
/// 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. <see cref="TryLoadAsync"/> therefore returns null rather than throwing, and the
/// caller's answer is always the same — ask for the passphrase.
/// </para>
/// </remarks>
public interface IDeviceKeyStore
{
/// <summary>Whether this machine can keep a device key at all.</summary>
/// <remarks>
/// 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.
/// </remarks>
ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken);
/// <summary>Stores the device private key, replacing any already held.</summary>
/// <param name="devicePrivateKey">The raw 32-byte X25519 scalar.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the device private key, prompting for whatever guards it.
/// </summary>
/// <returns>
/// The raw scalar, or <see langword="null"/> 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.
/// </returns>
ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken);
/// <summary>Discards the stored key.</summary>
/// <remarks>
/// 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.
/// </remarks>
ValueTask ForgetAsync(CancellationToken cancellationToken);
}
/// <summary>
/// A machine with nowhere to keep a device key.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public sealed class UnavailableDeviceKeyStore : IDeviceKeyStore
{
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(false);
/// <inheritdoc />
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken) =>
throw new NotSupportedException(
"This machine has no device key store. Check IsAvailableAsync before offering to register one.");
/// <inheritdoc />
public ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult<byte[]?>(null);
/// <inheritdoc />
public ValueTask ForgetAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}
+151
View File
@@ -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
/// <summary>The cached KDF parameters are not something this build can use.</summary>
UnsupportedKdf = 5,
/// <summary>No device key is registered on this machine, so there is nothing to unlock with.</summary>
/// <remarks>
/// 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.
/// </remarks>
NoDeviceKey = 6,
/// <summary>
/// A device key is registered but this machine would not hand it over.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
DeviceKeyUnavailable = 7,
/// <summary>The device key was retrieved and did not open the wrap.</summary>
/// <remarks>
/// What a rotated identity looks like from a machine whose device wrap predates it. Distinct from
/// <see cref="DeviceKeyUnavailable"/> 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.
/// </remarks>
DeviceKeyRejected = 8,
}
/// <summary>The result of an unlock attempt.</summary>
@@ -130,6 +157,130 @@ public sealed class SessionOpener(
}
}
/// <summary>
/// Attempts to open the vault with this machine's device key instead of the passphrase.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public async Task<UnlockOutcome> 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);
}
/// <remarks>
/// 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.
/// </remarks>
private async Task<UnlockOutcome> 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;
}
}
/// <remarks>
/// 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.
/// </remarks>
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));
}
/// <remarks>
/// 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
@@ -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; }
/// <remarks>
/// 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.
/// </remarks>
internal UnlockStore Unlock { get; }
/// <summary>Runs one synchronisation pass over the active vault.</summary>
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -137,6 +147,95 @@ public sealed class VaultSession : IAsyncDisposable
return engine.SyncAsync(ActiveVaultId, cancellationToken);
}
/// <summary>
/// Registers this machine's device key, so a later launch can unlock without the passphrase.
/// </summary>
/// <param name="api">The transport, supplied per call as <see cref="SyncAsync"/> takes its own.</param>
/// <param name="deviceKeys">Where the private half will live. See ADR 0007.</param>
/// <param name="deviceName">What to call this machine in the account's device list.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// <see langword="true"/> when a device was registered; <see langword="false"/> when this machine has
/// nowhere to keep the key, which is not a failure — it is the answer for a platform with no keystore.
/// </returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Ordered so a failure cannot leave a lie behind.</b> 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.
/// </para>
/// </remarks>
public async Task<bool> 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;
}
/// <summary>
/// Withdraws this machine's device key, locally.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>
/// Reads the conflicts a person still needs to see.
/// </summary>
+21
View File
@@ -43,6 +43,27 @@ internal sealed class UnlockMaterialRow
/// <summary>The secret bundle, wrapped under the passphrase-derived key. Ciphertext.</summary>
public byte[] WrappedPrivateKey { get; set; } = [];
/// <summary>
/// The same bundle sealed to this machine's device key, when one is registered. Ciphertext.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
public byte[]? DeviceWrappedPrivateKey { get; set; }
/// <summary>The server's id for this device, so its wrap can be revoked.</summary>
/// <remarks>
/// 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.
/// </remarks>
public Guid? DeviceId { get; set; }
public string KdfAlgorithm { get; set; } = string.Empty;
public byte[] KdfSalt { get; set; } = [];
@@ -0,0 +1,416 @@
// <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("20260730112940_AddDeviceWrap")]
partial class AddDeviceWrap
{
/// <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.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,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddDeviceWrap : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "device_id",
table: "unlock_material",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<byte[]>(
name: "device_wrapped_private_key",
table: "unlock_material",
type: "BLOB",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "device_id",
table: "unlock_material");
migrationBuilder.DropColumn(
name: "device_wrapped_private_key",
table: "unlock_material");
}
}
}
@@ -329,6 +329,14 @@ namespace DodoSSH.Client.Storage.Migrations
.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");
+3 -1
View File
@@ -58,7 +58,9 @@ public sealed record StoredUnlockMaterial(
uint KeyGeneration,
byte[] WrappedPrivateKey,
KdfParameters KdfParameters,
DateTimeOffset UpdatedAt);
DateTimeOffset UpdatedAt,
byte[]? DeviceWrappedPrivateKey = null,
Guid? DeviceId = null);
/// <summary>A cached vault and the grant that opens it.</summary>
/// <param name="VaultId">The vault.</param>
+80 -1
View File
@@ -96,11 +96,88 @@ public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts,
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Records a device wrap against the existing profile.
/// </summary>
/// <remarks>
/// Separate from <see cref="SaveAsync"/> because a device is registered long after enrollment, from an
/// unlocked session, and nothing else about the profile changes when it happens.
/// </remarks>
/// <exception cref="InvalidOperationException">This cache has never been enrolled.</exception>
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<UnlockMaterialRow>()
.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);
}
/// <summary>Forgets the device wrap, so this machine goes back to asking for the passphrase.</summary>
/// <remarks>
/// 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.
/// </remarks>
public async Task DetachDeviceAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set<UnlockMaterialRow>()
.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);
}
/// <remarks>
/// <b>The device columns are written only when the incoming material carries them.</b> This method
/// runs on every sign-in, from a <c>/me</c> 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.
/// </remarks>
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<ClientCacheContext> contexts,
row.KdfMemoryKibibytes,
row.KdfPasses,
row.KdfParallelism),
row.UpdatedAtUtc);
row.UpdatedAtUtc,
row.DeviceWrappedPrivateKey,
row.DeviceId);
}