Public Access
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:
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Client.Storage;
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Client.Sync;
|
using DodoSSH.Client.Sync;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Session;
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
@@ -41,6 +43,31 @@ public enum UnlockStatus
|
|||||||
|
|
||||||
/// <summary>The cached KDF parameters are not something this build can use.</summary>
|
/// <summary>The cached KDF parameters are not something this build can use.</summary>
|
||||||
UnsupportedKdf = 5,
|
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>
|
/// <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>
|
/// <remarks>
|
||||||
/// The master key lives only inside this method — it opens the bundle and is then done with. The cache
|
/// 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
|
/// 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.Api;
|
||||||
using DodoSSH.Client.Storage;
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Client.Sync;
|
using DodoSSH.Client.Sync;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
|
using NSec.Cryptography;
|
||||||
|
|
||||||
namespace DodoSSH.Client.Session;
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ public sealed class VaultSession : IAsyncDisposable
|
|||||||
SyncState = new SyncStateStore(caches);
|
SyncState = new SyncStateStore(caches);
|
||||||
Conflicts = new ConflictStore(caches, protector, clock);
|
Conflicts = new ConflictStore(caches, protector, clock);
|
||||||
Vault = new VaultStore(caches, clock);
|
Vault = new VaultStore(caches, clock);
|
||||||
|
Unlock = new UnlockStore(caches, clock);
|
||||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||||
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
||||||
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
||||||
@@ -123,6 +126,13 @@ public sealed class VaultSession : IAsyncDisposable
|
|||||||
|
|
||||||
internal VaultStore Vault { get; }
|
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>
|
/// <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="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
@@ -137,6 +147,95 @@ public sealed class VaultSession : IAsyncDisposable
|
|||||||
return engine.SyncAsync(ActiveVaultId, cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// Reads the conflicts a person still needs to see.
|
/// Reads the conflicts a person still needs to see.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -43,6 +43,27 @@ internal sealed class UnlockMaterialRow
|
|||||||
/// <summary>The secret bundle, wrapped under the passphrase-derived key. Ciphertext.</summary>
|
/// <summary>The secret bundle, wrapped under the passphrase-derived key. Ciphertext.</summary>
|
||||||
public byte[] WrappedPrivateKey { get; set; } = [];
|
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 string KdfAlgorithm { get; set; } = string.Empty;
|
||||||
|
|
||||||
public byte[] KdfSalt { get; set; } = [];
|
public byte[] KdfSalt { get; set; } = [];
|
||||||
|
|||||||
+416
@@ -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")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("id");
|
.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")
|
b.Property<string>("DisplayName")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("display_name");
|
.HasColumnName("display_name");
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ public sealed record StoredUnlockMaterial(
|
|||||||
uint KeyGeneration,
|
uint KeyGeneration,
|
||||||
byte[] WrappedPrivateKey,
|
byte[] WrappedPrivateKey,
|
||||||
KdfParameters KdfParameters,
|
KdfParameters KdfParameters,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt,
|
||||||
|
byte[]? DeviceWrappedPrivateKey = null,
|
||||||
|
Guid? DeviceId = null);
|
||||||
|
|
||||||
/// <summary>A cached vault and the grant that opens it.</summary>
|
/// <summary>A cached vault and the grant that opens it.</summary>
|
||||||
/// <param name="VaultId">The vault.</param>
|
/// <param name="VaultId">The vault.</param>
|
||||||
|
|||||||
@@ -96,11 +96,88 @@ public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts,
|
|||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
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(
|
private static void Apply(
|
||||||
UnlockMaterialRow row,
|
UnlockMaterialRow row,
|
||||||
StoredUnlockMaterial material,
|
StoredUnlockMaterial material,
|
||||||
DateTimeOffset now)
|
DateTimeOffset now)
|
||||||
{
|
{
|
||||||
|
if (material.DeviceWrappedPrivateKey is not null)
|
||||||
|
{
|
||||||
|
row.DeviceWrappedPrivateKey = material.DeviceWrappedPrivateKey;
|
||||||
|
row.DeviceId = material.DeviceId;
|
||||||
|
}
|
||||||
|
|
||||||
row.ServerUrl = material.ServerUrl;
|
row.ServerUrl = material.ServerUrl;
|
||||||
row.UserId = material.UserId;
|
row.UserId = material.UserId;
|
||||||
row.Issuer = material.Issuer;
|
row.Issuer = material.Issuer;
|
||||||
@@ -133,5 +210,7 @@ public sealed class UnlockStore(IDbContextFactory<ClientCacheContext> contexts,
|
|||||||
row.KdfMemoryKibibytes,
|
row.KdfMemoryKibibytes,
|
||||||
row.KdfPasses,
|
row.KdfPasses,
|
||||||
row.KdfParallelism),
|
row.KdfParallelism),
|
||||||
row.UpdatedAtUtc);
|
row.UpdatedAtUtc,
|
||||||
|
row.DeviceWrappedPrivateKey,
|
||||||
|
row.DeviceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unlocking with this machine's device key instead of the passphrase.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
caches = ClientCacheFactory.ForMemory($"device-{Guid.CreateVersion7():N}");
|
||||||
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
|
await Provisioner().EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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<VaultSession> UnlockAsync()
|
||||||
|
{
|
||||||
|
var outcome = await Opener().UnlockAsync(Passphrase, Token);
|
||||||
|
|
||||||
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||||
|
return outcome.Session!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A device key store that keeps its key in a field.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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. <see cref="Decline"/> is a cancelled prompt and an invalidated key at once, which is exactly how much
|
||||||
|
/// the caller is allowed to know.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
|
||||||
|
{
|
||||||
|
private byte[]? key;
|
||||||
|
|
||||||
|
/// <summary>When set, the next load refuses, as a cancelled gesture does.</summary>
|
||||||
|
internal bool Decline { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this machine can keep a key at all.</summary>
|
||||||
|
internal bool IsAvailable { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Reads the stored key without a gesture, for assertions only.</summary>
|
||||||
|
internal byte[]? Peek() => key;
|
||||||
|
|
||||||
|
/// <summary>Replaces the stored key, standing in for a rotated or corrupted keystore entry.</summary>
|
||||||
|
internal void Overwrite(byte[] replacement) => key = replacement;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromResult(IsAvailable);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
key = devicePrivateKey.ToArray();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromResult(Decline ? null : key?.ToArray());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
key = null;
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user