using DodoSSH.Contracts;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Client.Storage;
///
/// Thrown when the cache belongs to a different account than the one signing in.
///
///
/// Loud on purpose. Silently adopting the cache would mix one user's items into another's vault list
/// and, worse, would offer an unlock prompt whose passphrase can never work.
///
public sealed class CacheIdentityMismatchException : InvalidOperationException
{
/// Creates the exception.
public CacheIdentityMismatchException(string message)
: base(message)
{
}
/// Creates the exception.
public CacheIdentityMismatchException()
: base("This cache belongs to a different account.")
{
}
/// Creates the exception.
public CacheIdentityMismatchException(string message, Exception innerException)
: base(message, innerException)
{
}
}
///
/// The material an offline unlock needs.
///
///
///
/// This store is the reason the client works on a plane. The Argon2id salt and the wrapped secret
/// bundle are cached the moment the server hands them over, so deriving the master key and opening the
/// bundle need no network at all. Fetching either at unlock time would make an offline launch
/// impossible, which is the most common moment a user actually needs their vault.
///
///
/// Neither value is a secret. The salt is public by construction and the bundle is ciphertext whose key
/// exists only in the user's head. The master key itself is never written here or anywhere else.
///
///
public sealed class UnlockStore(IDbContextFactory contexts, TimeProvider clock)
{
/// Reads the cached material, or null when this cache has never been enrolled.
public async Task ReadAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.AsNoTracking()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
return row is null ? null : ToStored(row);
}
///
/// Writes the material, replacing what is there.
///
///
/// The cache already holds a different user. One cache file is one account; see
/// for why multiple accounts are not half-supported here.
///
public async Task SaveAsync(StoredUnlockMaterial material, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(material);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
row = new UnlockMaterialRow();
context.Add(row);
}
else if (row.UserId != material.UserId)
{
throw new CacheIdentityMismatchException(
$"This cache holds user {row.UserId}; refusing to overwrite it with {material.UserId}.");
}
Apply(row, material, clock.GetUtcNow());
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
/// Records a device wrap against the existing profile.
///
///
/// Separate from because a device is registered long after enrollment, from an
/// unlocked session, and nothing else about the profile changes when it happens.
///
/// This cache has never been enrolled.
public async Task AttachDeviceAsync(
Guid deviceId,
byte[] wrap,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(wrap);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
throw new InvalidOperationException(
"This cache is not enrolled, so there is no profile to attach a device to.");
}
row.DeviceId = deviceId;
row.DeviceWrappedPrivateKey = wrap;
row.UpdatedAtUtc = clock.GetUtcNow();
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// Forgets the device wrap, so this machine goes back to asking for the passphrase.
///
/// Local only, and deliberately survivable: the server's wrap row is deleted separately, and a cache
/// that has forgotten its wrap while the server still lists the device is merely a machine that asks
/// for a passphrase. The reverse — a wrap here for a device the server has revoked — is the one that
/// would be confusing, and it resolves itself the moment the wrap fails to open.
///
public async Task DetachDeviceAsync(CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var row = await context.Set()
.SingleOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (row is null)
{
return;
}
row.DeviceId = null;
row.DeviceWrappedPrivateKey = null;
row.UpdatedAtUtc = clock.GetUtcNow();
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
///
/// The device columns are written only when the incoming material carries them. This method
/// runs on every sign-in, from a /me response that knows nothing about this machine's
/// keystore — so assigning them unconditionally would quietly delete the device wrap on the next
/// launch, and the user would find their fingerprint had stopped working for no visible reason. An
/// enrollment that registers a device in the same breath still gets them written, because it supplies
/// them.
///
private static void Apply(
UnlockMaterialRow row,
StoredUnlockMaterial material,
DateTimeOffset now)
{
if (material.DeviceWrappedPrivateKey is not null)
{
row.DeviceWrappedPrivateKey = material.DeviceWrappedPrivateKey;
row.DeviceId = material.DeviceId;
}
row.ServerUrl = material.ServerUrl;
row.UserId = material.UserId;
row.Issuer = material.Issuer;
row.Subject = material.Subject;
row.Email = material.Email;
row.DisplayName = material.DisplayName;
row.KeyGeneration = material.KeyGeneration;
row.WrappedPrivateKey = material.WrappedPrivateKey;
row.KdfAlgorithm = material.KdfParameters.Algorithm;
row.KdfSalt = material.KdfParameters.Salt;
row.KdfMemoryKibibytes = material.KdfParameters.MemoryKibibytes;
row.KdfPasses = material.KdfParameters.Passes;
row.KdfParallelism = material.KdfParameters.Parallelism;
row.UpdatedAtUtc = now;
}
private static StoredUnlockMaterial ToStored(UnlockMaterialRow row) =>
new(
row.ServerUrl,
row.UserId,
row.Issuer,
row.Subject,
row.Email,
row.DisplayName,
row.KeyGeneration,
row.WrappedPrivateKey,
new KdfParameters(
row.KdfAlgorithm,
row.KdfSalt,
row.KdfMemoryKibibytes,
row.KdfPasses,
row.KdfParallelism),
row.UpdatedAtUtc,
row.DeviceWrappedPrivateKey,
row.DeviceId);
}