Files
DodoSSH/src/DodoSSH.Crypto/DshAad.cs
T
jaap-jan 8d2416a602 Add the encrypted local cache and the sync client
Three new client projects, and the wire-contract fix they needed.

DodoSSH.Client.Domain holds the decrypted item model and the three-way
merge, with no I/O at all — so the suite that decides whether a
credential can be lost runs in milliseconds with nothing to mock.
Scalars defer to the server on a genuine clash so every replica resolves
the same triple identically and two clients cannot ping-pong; directives
merge per name so two people each adding one both keep theirs; the jump
chain merges as a whole value because its order is the route. Whatever
loses is returned rather than dropped.

DodoSSH.Client.Storage is EF Core on SQLite, no SQLCipher: the rows are
already ciphertext, so an encrypted file would protect protected bytes
at the cost of a native dependency. It keeps the server's state and the
outbox in separate tables, which is what preserves the common ancestor a
merge needs. One pending operation per item, enforced by a unique index.

DodoSSH.Client.Sync is the pull/apply/push loop. Pulling never decrypts
— a change with no local work pending is plumbed as ciphertext — so a
first sync of thousands of items does not run twice as many AEAD
operations for nothing.

Contracts: EncryptedPayload gains WrappedDataKey and DataKeyId. The
specification has required a per-item data key since crypto.md §3, the
columns have existed since the first migration and DshAad.ItemPayload
binds the id, but this record had nowhere to put either — so a
spec-compliant item could not be transmitted at all. Found by writing
the client that has to produce one. Also closes a hole in
AadResourceType, which had no value for the HostTag and HostCredential
that SyncEntityType has always listed.

Four bugs the tests found, not review:

- SQLite refuses to order or compare its own DateTimeOffset mapping, and
  throws at execution rather than model build. Collecting tombstones and
  listing conflicts are both that shape, so this was a crash waiting for
  the first user with a deleted host. Timestamps are integers now, by
  convention so a later field cannot be the one left unconverted.
- SQLitePCLRaw 2.1.11, which EF resolves, is covered by
  GHSA-2m69-gcr7-jv3q. Pinned forward as a family.
- Resurrecting content from a remote deletion cleared the original
  before queueing the copy. Two transactions, so a crash between them
  lost the work; reversed, and the rescued id is derived from the
  tombstone so a replay coalesces instead of duplicating.
- Several equality assertions went through Shouldly's ShouldBe, which
  compares IEnumerable element-wise and so tested nothing about the
  Equals these types exist to provide. Corrected; the falsification that
  caught it went from 2 failures to 6.

The push response's cursor is deliberately ignored. It sits after this
client's own writes, so adopting it skips anything another client
committed at a lower sequence in the window between a pull and a push —
permanently. Re-reading one's own writes is idempotent and costs a page.
The Contracts doc that invited the shortcut now says so.

593 tests, up from 448. The delete-versus-edit rules, the ancestor
retention, the fresh operation id on coalesce and the cursor safeguard
were each verified by breaking them and watching the right test fail.
2026-07-29 10:27:37 +02:00

154 lines
7.0 KiB
C#

namespace DodoSSH.Crypto;
/// <summary>
/// Named constructors for every AAD descriptor the specification defines. See docs/crypto.md §4.
/// </summary>
/// <remarks>
/// <para>
/// The AAD binding is the most valuable structural property in the design: it is what stops a
/// server that holds every ciphertext from pasting one row's bytes onto another, rolling a row back
/// to a superseded key generation, replaying a revoked grant, or substituting a metadata blob for a
/// payload. None of that follows from access control.
/// </para>
/// <para>
/// All of it also depends on callers getting the purpose, resource type and ids right at every call
/// site. Hand-constructing <see cref="AadDescriptor"/> makes that a matter of care; going through
/// these factories makes it a matter of picking the right method name, which is the difference
/// between a property that holds and one that mostly holds.
/// </para>
/// </remarks>
public static class DshAad
{
/// <summary>
/// Binds a wrap of the user's secret bundle to that user and key generation.
/// </summary>
/// <remarks>
/// <para>
/// Every wrap kind — passphrase, device, recovery, escrow — protects the same bundle and uses
/// this same descriptor. In particular a device wrap is <em>not</em> bound to its device row:
/// the client cannot be, since the server assigns the device id after the wrap is built, and it
/// need not be, because the wrap is sealed to that device's public key. Relocating the row to
/// another device gains an attacker nothing they could open.
/// </para>
/// <para>
/// Including the generation means a server cannot serve back a superseded bundle after a key
/// rotation. The client learns the current generation from <c>/me</c>, so a lie there produces
/// a tag failure rather than a silent downgrade.
/// </para>
/// </remarks>
/// <param name="userId">
/// The user's identifier, as assigned by the server. A client must therefore read
/// <c>/api/v1/me</c> — which provisions the account and returns its id even before enrollment —
/// before it can build a wrap.
/// </param>
/// <param name="keyGeneration">Generation of the identity key pair inside the bundle.</param>
public static AadDescriptor UserSecretBundle(Guid userId, uint keyGeneration = 1) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.UserSecretBundle,
CryptoSpec.AadResourceType.User,
userId,
keyGeneration: keyGeneration);
/// <summary>
/// Binds a vault key grant to its vault and generation.
/// </summary>
/// <remarks>
/// The generation is what makes revocation stick against a malicious server: after a rekey it
/// cannot re-serve a previous generation's grant to a removed member, because the AAD no longer
/// matches. That is a bound on future reads only — anything already downloaded is already gone,
/// which is why offboarding means rotating the SSH credentials themselves. See ADR 0001.
/// </remarks>
public static AadDescriptor VaultKeyGrant(Guid vaultId, uint keyGeneration) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.VaultKeyGrant,
CryptoSpec.AadResourceType.Vault,
vaultId,
keyGeneration: keyGeneration);
/// <summary>Binds an item's data key, wrapped under the vault key, to that item and version.</summary>
public static AadDescriptor ItemDataKey(
CryptoSpec.AadResourceType resourceType,
Guid itemId,
uint keyGeneration,
uint itemVersion) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemDataKey,
resourceType,
itemId,
keyGeneration: keyGeneration,
itemVersion: itemVersion);
/// <summary>Binds an item's payload to the item, its data key, generation and version.</summary>
/// <param name="resourceType">What kind of item this is.</param>
/// <param name="itemId">The item.</param>
/// <param name="dataKeyId">
/// The data key the payload is under — the <c>content_key_id</c> column. Reserved so that
/// per-item grants can make item-level access cryptographic in M5 without a migration.
/// </param>
/// <param name="keyGeneration">Vault key generation in force.</param>
/// <param name="itemVersion">Item version, so an earlier version cannot be replayed.</param>
public static AadDescriptor ItemPayload(
CryptoSpec.AadResourceType resourceType,
Guid itemId,
Guid dataKeyId,
uint keyGeneration,
uint itemVersion) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemPayload,
resourceType,
itemId,
dataKeyId,
keyGeneration,
itemVersion);
/// <summary>
/// Binds an item's encrypted metadata.
/// </summary>
/// <remarks>
/// A distinct purpose from <see cref="ItemPayload"/> on the same item, which is what stops a
/// server swapping one blob for the other — a substitution that would otherwise leave a client
/// decrypting a password where it expected a display name.
/// </remarks>
public static AadDescriptor ItemMetadata(
CryptoSpec.AadResourceType resourceType,
Guid itemId,
Guid dataKeyId,
uint keyGeneration,
uint itemVersion) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.ItemMetadata,
resourceType,
itemId,
dataKeyId,
keyGeneration,
itemVersion);
/// <summary>
/// Binds a record in the client's own on-disk cache to the row that holds it.
/// </summary>
/// <remarks>
/// <para>
/// Separate from every server-side purpose so a cache record can never be accepted as vault
/// content, nor the reverse. The threat model differs too: the cache is local, so the adversary
/// is a process or a backup with access to the file rather than the server.
/// </para>
/// <para>
/// <b>Changed 2026-07-29</b> from taking the user id to taking the record's identity. The user
/// was already bound by the key — <c>LocalCacheKey</c> is derived from that user's master key, so
/// another user's record cannot decrypt at all — which left the AAD binding nothing, and a cache
/// record could be moved to a different row of the same user's cache. For plaintext columns like
/// a relay address that is not academic: swapping two rows would point one host's connection at
/// another host's address. No cache has ever been written, so there is nothing to migrate.
/// </para>
/// </remarks>
/// <param name="resourceType">What kind of item the record belongs to.</param>
/// <param name="recordId">The row it belongs to — an item id, or a conflict entry's own id.</param>
public static AadDescriptor LocalCache(
CryptoSpec.AadResourceType resourceType,
Guid recordId) =>
AadDescriptor.Create(
CryptoSpec.AadPurpose.LocalCache,
resourceType,
recordId);
}