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); }