Public Access
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting. A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed. A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for. The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
467 lines
17 KiB
C#
467 lines
17 KiB
C#
using DodoSSH.Domain;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
|
|
|
namespace DodoSSH.Infrastructure.Configurations;
|
|
|
|
/// <summary>Maps <see cref="SshHost"/>.</summary>
|
|
public sealed class HostConfiguration : IEntityTypeConfiguration<SshHost>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<SshHost> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("host");
|
|
builder.HasKey(h => h.Id);
|
|
|
|
// Client-generated UUIDv7: items must be creatable offline, with their ids.
|
|
builder.Property(h => h.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(h => h.Payload).IsRequired();
|
|
builder.Property(h => h.Hostname).HasMaxLength(255);
|
|
|
|
builder.HasIndex(h => new { h.VaultId, h.ChangeSequence });
|
|
|
|
builder.HasIndex(h => h.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_host_vault_live");
|
|
|
|
// The relay resolves its target from this row, so an address is stored only when relay is
|
|
// deliberately enabled for the host. Enforced in the database rather than in application
|
|
// code: a bug that let a host carry a plaintext address without opting in would silently
|
|
// widen what the server can see. See ADR 0004.
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_relay_target",
|
|
"""
|
|
(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)
|
|
OR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)
|
|
"""));
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_port_range",
|
|
"port IS NULL OR (port BETWEEN 1 AND 65535)"));
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="VaultSshKey"/>.</summary>
|
|
/// <remarks>
|
|
/// Mirrors <see cref="HostConfiguration"/> in everything the two types share, and deliberately has no
|
|
/// analogue of the relay CHECK: a key carries no address, which is the reason it is its own table.
|
|
/// </remarks>
|
|
public sealed class SshKeyConfiguration : IEntityTypeConfiguration<VaultSshKey>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultSshKey> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("ssh_key");
|
|
builder.HasKey(k => k.Id);
|
|
|
|
// Client-generated UUIDv7: keys must be creatable offline, with their ids.
|
|
builder.Property(k => k.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(k => k.Payload).IsRequired();
|
|
|
|
// SHA256:base64 of a 32-byte digest is 50 characters; the ceiling leaves room for another
|
|
// algorithm without a migration, and refuses anything that is plainly not a fingerprint.
|
|
builder.Property(k => k.PublicKeyFingerprint).HasMaxLength(128);
|
|
|
|
builder.HasIndex(k => new { k.VaultId, k.ChangeSequence });
|
|
|
|
builder.HasIndex(k => k.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_ssh_key_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_ssh_key_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultCredential"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The narrowest shape any item table takes — shared now with <see cref="KnownHostKeyConfiguration"/> — and
|
|
/// deliberately so: no relay CHECK, and unlike <see cref="SshKeyConfiguration"/> not even a fingerprint
|
|
/// column. There is nothing about a password that is safe to hold in the clear, so there is nothing here but
|
|
/// the envelope and its bookkeeping.
|
|
/// </remarks>
|
|
public sealed class CredentialConfiguration : IEntityTypeConfiguration<VaultCredential>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultCredential> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("credential");
|
|
builder.HasKey(c => c.Id);
|
|
|
|
// Client-generated UUIDv7: credentials must be creatable offline, with their ids.
|
|
builder.Property(c => c.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(c => c.Payload).IsRequired();
|
|
|
|
builder.HasIndex(c => new { c.VaultId, c.ChangeSequence });
|
|
|
|
builder.HasIndex(c => c.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_credential_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_credential_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultKnownHostKey"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Identical to <see cref="CredentialConfiguration"/>, and the sameness is the statement: a pin has no
|
|
/// plaintext column either. It would have been easy to add a <c>host</c> column here for a "known hosts"
|
|
/// screen to list without decrypting, and that column would have handed the operator the map of every user's
|
|
/// estate. The client decrypts its own vault; it does not need the server's help to sort a list.
|
|
/// </remarks>
|
|
public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration<VaultKnownHostKey>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultKnownHostKey> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("known_host_key");
|
|
builder.HasKey(k => k.Id);
|
|
|
|
// Client-generated UUIDv7: a pin must be recordable offline, with its id.
|
|
builder.Property(k => k.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(k => k.Payload).IsRequired();
|
|
|
|
builder.HasIndex(k => new { k.VaultId, k.ChangeSequence });
|
|
|
|
builder.HasIndex(k => k.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_known_host_key_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_known_host_key_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultHostGroup"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same shape again, and by now the sameness is the design rather than a coincidence: most item types
|
|
/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is a
|
|
/// type where plaintext columns would have been tempting and are not defensible — a <c>name</c> here would
|
|
/// let the server order a list it never draws, and a <c>parent_id</c> would draw the shape of every user's
|
|
/// estate, both in exchange for telling the operator how each of them files their machines. Groups nest
|
|
/// through a pointer inside the payload; nothing about that is visible from this table.
|
|
/// </remarks>
|
|
public sealed class HostGroupConfiguration : IEntityTypeConfiguration<VaultHostGroup>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultHostGroup> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("host_group");
|
|
builder.HasKey(g => g.Id);
|
|
|
|
// Client-generated UUIDv7: a group must be creatable offline, with its id, because the host that
|
|
// names it is created offline too and needs something to point at.
|
|
builder.Property(g => g.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(g => g.Payload).IsRequired();
|
|
|
|
builder.HasIndex(g => new { g.VaultId, g.ChangeSequence });
|
|
|
|
builder.HasIndex(g => g.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_host_group_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_host_group_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultTag"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="HostGroupConfiguration"/> again, down to the index names. The one thing worth stating is what
|
|
/// is <em>not</em> here: no <c>host_tag</c> join table, because membership is a set inside each host's
|
|
/// payload — see <see cref="VaultTag"/> — so there is nothing to join and no second table to keep in step.
|
|
/// </remarks>
|
|
public sealed class TagConfiguration : IEntityTypeConfiguration<VaultTag>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultTag> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("tag");
|
|
builder.HasKey(t => t.Id);
|
|
|
|
// Client-generated UUIDv7: a tag must be creatable offline, with its id, because the host that
|
|
// wears it is created offline too and needs something to point at.
|
|
builder.Property(t => t.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(t => t.Payload).IsRequired();
|
|
|
|
builder.HasIndex(t => new { t.VaultId, t.ChangeSequence });
|
|
|
|
builder.HasIndex(t => t.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_tag_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_tag_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultSnippet"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Identical to <see cref="CredentialConfiguration"/>. A snippet's label would be harmless on its own and the
|
|
/// commands beside it would not be, and splitting one out would give the server half a map for no feature.
|
|
/// </remarks>
|
|
public sealed class SnippetConfiguration : IEntityTypeConfiguration<VaultSnippet>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultSnippet> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("snippet");
|
|
builder.HasKey(s => s.Id);
|
|
|
|
// Client-generated UUIDv7: a snippet must be writable offline, with its id.
|
|
builder.Property(s => s.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(s => s.Payload).IsRequired();
|
|
|
|
builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
|
|
|
|
builder.HasIndex(s => s.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_snippet_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_snippet_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultConnectionLogEntry"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The same envelope-and-bookkeeping shape as every other item kind, and this is the one where holding
|
|
/// something in the clear would have been most useful and least defensible: a <c>started_at</c> column would
|
|
/// let the server order a log without decrypting it, and would hand the operator every user's working hours.
|
|
/// The client sorts its own log.
|
|
/// </para>
|
|
/// <para>
|
|
/// The live index is not decoration here. This is the only table that grows without a person adding
|
|
/// anything to it — a busy user produces entries all day — so the query retention runs, "everything in this
|
|
/// vault that is not already a tombstone", is the one that has to stay cheap.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class ConnectionLogEntryConfiguration : IEntityTypeConfiguration<VaultConnectionLogEntry>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultConnectionLogEntry> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("connection_log_entry");
|
|
builder.HasKey(e => e.Id);
|
|
|
|
// Client-generated UUIDv7, and here the ordering it carries is load-bearing rather than incidental:
|
|
// a log is read newest-first, and the id is the only creation time an item has.
|
|
builder.Property(e => e.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(e => e.Payload).IsRequired();
|
|
|
|
builder.HasIndex(e => new { e.VaultId, e.ChangeSequence });
|
|
|
|
builder.HasIndex(e => e.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_connection_log_entry_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_connection_log_entry_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultActivityLogEntry"/>.
|
|
/// </summary>
|
|
/// <inheritdoc cref="ConnectionLogEntryConfiguration" path="/remarks" />
|
|
public sealed class ActivityLogEntryConfiguration : IEntityTypeConfiguration<VaultActivityLogEntry>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultActivityLogEntry> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("activity_log_entry");
|
|
builder.HasKey(e => e.Id);
|
|
|
|
builder.Property(e => e.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(e => e.Payload).IsRequired();
|
|
|
|
builder.HasIndex(e => new { e.VaultId, e.ChangeSequence });
|
|
|
|
builder.HasIndex(e => e.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_activity_log_entry_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_activity_log_entry_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultObjectStore"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="CredentialConfiguration"/> again, which is the right comparison: a secret access key is a
|
|
/// password, and the endpoint beside it is an address on somebody's own network as often as it is Amazon's.
|
|
/// Neither belongs in a column.
|
|
/// </remarks>
|
|
public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration<VaultObjectStore>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultObjectStore> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("object_store");
|
|
builder.HasKey(o => o.Id);
|
|
|
|
// Client-generated UUIDv7: a bucket must be addable offline, with its id.
|
|
builder.Property(o => o.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(o => o.Payload).IsRequired();
|
|
|
|
builder.HasIndex(o => new { o.VaultId, o.ChangeSequence });
|
|
|
|
builder.HasIndex(o => o.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_object_store_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_object_store_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Maps <see cref="VaultSpelunkKey"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <see cref="ObjectStoreConfiguration"/> again: a key and the address it goes to, neither of which belongs
|
|
/// in a column.
|
|
/// </remarks>
|
|
public sealed class SpelunkKeyConfiguration : IEntityTypeConfiguration<VaultSpelunkKey>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultSpelunkKey> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("spelunk_key");
|
|
builder.HasKey(s => s.Id);
|
|
|
|
// Client-generated UUIDv7: a key must be addable offline, with its id.
|
|
builder.Property(s => s.Id).ValueGeneratedNever();
|
|
builder.UseXminConcurrencyToken();
|
|
|
|
builder.Property(s => s.Payload).IsRequired();
|
|
|
|
builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
|
|
|
|
builder.HasIndex(s => s.VaultId)
|
|
.HasFilter("deleted_at_utc IS NULL")
|
|
.HasDatabaseName("ix_spelunk_key_vault_live");
|
|
|
|
builder.ToTable(t => t.HasCheckConstraint(
|
|
"ck_spelunk_key_version",
|
|
"version >= 1"));
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
|
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<VaultChange> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("sync_change");
|
|
builder.HasKey(c => c.Sequence);
|
|
|
|
// Identity ALWAYS rather than BY DEFAULT: nothing may supply its own sequence value, or
|
|
// cursor ordering stops meaning anything.
|
|
builder.Property(c => c.Sequence).UseIdentityAlwaysColumn();
|
|
|
|
builder.Property(c => c.EntityType).HasConversion<int>();
|
|
builder.Property(c => c.Operation).HasConversion<int>();
|
|
|
|
// The delta-pull access path: everything after a cursor, for one vault.
|
|
builder.HasIndex(c => new { c.VaultId, c.Sequence });
|
|
|
|
// Latest change for a given entity, used when resolving a conflict.
|
|
builder.HasIndex(c => new { c.VaultId, c.EntityId, c.Sequence })
|
|
.IsDescending(false, false, true);
|
|
}
|
|
}
|
|
|
|
/// <summary>Maps <see cref="SyncOperationReceipt"/>.</summary>
|
|
public sealed class SyncOperationReceiptConfiguration : IEntityTypeConfiguration<SyncOperationReceipt>
|
|
{
|
|
/// <inheritdoc />
|
|
public void Configure(EntityTypeBuilder<SyncOperationReceipt> builder)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(builder);
|
|
|
|
builder.ToTable("sync_operation_receipt");
|
|
|
|
// The client-generated operation id is the key, which is what makes a retried push
|
|
// exactly-once per operation rather than per batch.
|
|
builder.HasKey(r => r.OperationId);
|
|
builder.Property(r => r.OperationId).ValueGeneratedNever();
|
|
|
|
builder.HasIndex(r => new { r.VaultId, r.CreatedAtUtc });
|
|
}
|
|
}
|