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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user