namespace DodoSSH.Client.Session;
///
/// How far withdrawing a device key got.
///
///
/// Three outcomes rather than a boolean, because the middle one is a state the user has to be told about
/// and can act on. The local half always happens; only the server half can be out of reach.
///
public enum DeviceRevocation
{
/// There was no device key here to withdraw.
NothingRegistered,
///
/// This machine can no longer unlock itself, but the account still lists the device.
///
///
/// Offline. The wrap on the server is the user's bundle sealed to a key this machine has now destroyed,
/// so nothing can open it — but it is still a row that should not be there, and a reinstall would pull
/// it down again and offer a device unlock that cannot work.
///
LocalOnly,
/// Gone from this machine and from the account.
Complete,
}
///
/// Where this machine keeps the private half of its device key.
///
///
///
/// An interface because the answer is a platform decision and a security decision, recorded in
/// ADR 0007: on Windows a gesture guards it, and the gesture is the whole value — it is what stops a
/// process running as the user from unlocking the vault silently. Nothing in this assembly should know
/// which gesture, and nothing above it should be able to skip one.
///
///
/// Exactly 32 bytes, and only because the cache key moved. This holds the X25519 device private
/// key and nothing else. It would have had to hold the local cache key too — a second live secret at
/// rest, going stale on every passphrase change — had that key not been re-keyed to the identity bundle
/// first. See docs/crypto.md §3.2.
///
///
/// Every member may fail or refuse, and refusal is ordinary rather than exceptional: a user can cancel a
/// fingerprint prompt, a Hello key is invalidated when the PIN is reset, and a machine may have no
/// keystore at all. therefore returns null rather than throwing, and the
/// caller's answer is always the same — ask for the passphrase.
///
///
public interface IDeviceKeyStore
{
/// Whether this machine can keep a device key at all.
///
/// Asked before offering to register one. Registering a device whose private half does not survive
/// the process would put a wrap on the server that nothing can ever open, and make the account's
/// device list claim a capability this machine does not have.
///
ValueTask IsAvailableAsync(CancellationToken cancellationToken);
/// Stores the device private key, replacing any already held.
/// The raw 32-byte X25519 scalar.
/// Cancellation token.
ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken);
///
/// Retrieves the device private key, prompting for whatever guards it.
///
///
/// The raw scalar, or if there is none, the user declined, or the platform
/// has invalidated it. The three are deliberately not distinguished: the caller does the same thing
/// in each case, and a message naming which one would be describing the keystore rather than telling
/// the user anything they can act on.
///
ValueTask TryLoadAsync(CancellationToken cancellationToken);
/// Discards the stored key.
///
/// Local only. The server's wrap row outlives this and has to be deleted separately, or the account
/// will go on listing a device that can no longer unlock anything.
///
ValueTask ForgetAsync(CancellationToken cancellationToken);
}
///
/// A machine with nowhere to keep a device key.
///
///
/// What the application composes until a real keystore is wired up, and the honest answer for a platform
/// that has none. Reports unavailable and holds nothing, so unlock asks for the passphrase exactly as it
/// did before any of this existed — a placeholder that changes no behaviour rather than one that pretends.
///
public sealed class UnavailableDeviceKeyStore : IDeviceKeyStore
{
///
public ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(false);
///
public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken) =>
throw new NotSupportedException(
"This machine has no device key store. Check IsAvailableAsync before offering to register one.");
///
public ValueTask TryLoadAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(null);
///
public ValueTask ForgetAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
}