Public Access
Freeze DodoSSH.Contracts v0.1 (M1)
The second M1 gate. This assembly, not the OpenAPI document, is the client's contract, so PublicApiAnalyzers now tracks all 540 public members: a renamed DTO property becomes a build error rather than a runtime deserialisation failure on someone's laptop. Contract surface: - EncryptedPayload carries the envelope plus the KeyGeneration and AadVersion columns needed to recompute AAD, since AAD is derived from the row rather than transmitted. - Sync: push with per-operation status (Applied/Conflict/Forbidden/Invalid/Duplicate) so one stale item cannot block a whole offline queue; a Conflict returns the server's row for client-side three-way merge, because the server cannot merge ciphertext. - Enrollment: KeyStatement whose hash becomes the OIDC nonce, so the identity provider signs over the public keys and this server cannot fabricate a key for a user who never enrolled. - Meta and .well-known configuration: capability negotiation instead of URL versioning, which is what a self-hosted product needs when client and server upgrade independently. - SyncPlaintextFields deliberately has no label or name field. ACL admin runs client-side where names can be decrypted, so the server never needs a searchable title. Two design problems found by writing the tests rather than assuming: - Hand-constructing JsonSerializerOptions and merely pointing its resolver at the context silently discards every source-generated setting. JsonSerializerDefaults.Web replaces NumberHandling.Strict with AllowReadingFromString, so "1" would be accepted where 1 is meant — invisible until two implementations disagree. Callers now use ResponseOptions or StrictRequestOptions; StrictRequestOptions is derived by copying so it cannot drift. - StrictRequestOptions had a static-initialisation cycle: it read the generated Default property from the same type's initialiser and got null. Now lazy. Requests reject unmapped members so a client typo is a 400; responses tolerate them so an older client can read a newer server. Enums cross the wire as strings, so reordering one cannot silently reinterpret stored data. Also: excluded source-generator output from PublicApiAnalyzers. The JSON generator emits a public member per serialisable type, which would have added hundreds of mechanical entries and drowned the ones describing the actual wire contract. And disabled MA0048's one-type-per-file rule: splitting SyncPullRequest from SyncPullResponse makes a reviewer open two files to understand one endpoint. Verified: 0 warnings, 95 tests pass, format clean.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
namespace DodoSSH.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Argon2id parameters as stored and transmitted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stored in plaintext per wrap row. Salts are not secrets, and keeping the parameters with the
|
||||
/// wrap makes raising them later a per-user, unlock-time migration rather than a breaking
|
||||
/// change — an older client can still open its own wrap.
|
||||
/// <para>
|
||||
/// <see cref="MemoryKibibytes"/> is kibibytes, matching both the storage column and libsodium.
|
||||
/// Client code should go through <c>Argon2Profile</c> rather than handling the number directly;
|
||||
/// see docs/crypto.md §2 for why that unit needs guarding.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Algorithm">KDF identifier. Currently always <c>argon2id</c>.</param>
|
||||
/// <param name="Salt">16-byte salt.</param>
|
||||
/// <param name="MemoryKibibytes">Memory cost in KiB.</param>
|
||||
/// <param name="Passes">Number of passes.</param>
|
||||
/// <param name="Parallelism">Lanes. Always 1; libsodium supports no other value.</param>
|
||||
public sealed record KdfParameters(
|
||||
string Algorithm,
|
||||
byte[] Salt,
|
||||
int MemoryKibibytes,
|
||||
int Passes,
|
||||
int Parallelism);
|
||||
|
||||
/// <summary>
|
||||
/// The self-describing, signed statement binding a user to their public keys.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its SHA-256 is used as the <c>nonce</c> of a fresh OIDC authorization, so the resulting ID
|
||||
/// token is signed by the identity provider over exactly these keys. That is what stops the
|
||||
/// DodoSSH server fabricating a key for a user who never enrolled. See ADR 0001.
|
||||
/// </remarks>
|
||||
/// <param name="Version">Statement format version.</param>
|
||||
/// <param name="Issuer">OIDC issuer.</param>
|
||||
/// <param name="Subject">OIDC subject.</param>
|
||||
/// <param name="Email">Email at enrollment time, for display only.</param>
|
||||
/// <param name="EncryptionPublicKey">X25519 public key, 32 bytes.</param>
|
||||
/// <param name="SigningPublicKey">Ed25519 public key, 32 bytes.</param>
|
||||
/// <param name="KeyGeneration">Generation of this key pair, starting at 1.</param>
|
||||
/// <param name="CreatedAt">When the client generated the keys.</param>
|
||||
/// <param name="DeviceName">Human-readable name of the enrolling device.</param>
|
||||
public sealed record KeyStatement(
|
||||
int Version,
|
||||
string Issuer,
|
||||
string Subject,
|
||||
string? Email,
|
||||
byte[] EncryptionPublicKey,
|
||||
byte[] SigningPublicKey,
|
||||
int KeyGeneration,
|
||||
DateTimeOffset CreatedAt,
|
||||
string DeviceName);
|
||||
|
||||
/// <summary>A request to enroll a user's first identity key pair.</summary>
|
||||
/// <param name="Statement">The key statement.</param>
|
||||
/// <param name="StatementSignature">Ed25519 self-signature over the statement.</param>
|
||||
/// <param name="IdentityProviderToken">
|
||||
/// An ID token whose <c>nonce</c> equals the SHA-256 of the statement. The server verifies it
|
||||
/// against the provider's JWKS.
|
||||
/// </param>
|
||||
/// <param name="WrappedPrivateKey">
|
||||
/// The user's secret bundle sealed under the passphrase-derived key. Opaque to the server.
|
||||
/// </param>
|
||||
/// <param name="KdfParameters">Parameters needed to re-derive the wrapping key.</param>
|
||||
/// <param name="DevicePublicKey">
|
||||
/// X25519 public key of this device, so the bundle can also be wrapped to the device and
|
||||
/// unlocked without re-entering the passphrase.
|
||||
/// </param>
|
||||
/// <param name="RecoveryWrappedPrivateKey">
|
||||
/// The same bundle sealed under a recovery-code-derived key.
|
||||
/// </param>
|
||||
/// <param name="RecoveryKdfParameters">Parameters for the recovery wrap.</param>
|
||||
public sealed record EnrollmentRequest(
|
||||
KeyStatement Statement,
|
||||
byte[] StatementSignature,
|
||||
string IdentityProviderToken,
|
||||
byte[] WrappedPrivateKey,
|
||||
KdfParameters KdfParameters,
|
||||
byte[]? DevicePublicKey,
|
||||
byte[]? RecoveryWrappedPrivateKey,
|
||||
KdfParameters? RecoveryKdfParameters);
|
||||
|
||||
/// <summary>Result of a successful enrollment.</summary>
|
||||
/// <param name="UserId">The user's identifier.</param>
|
||||
/// <param name="KeyGeneration">The generation now current.</param>
|
||||
/// <param name="Fingerprint">Identity fingerprint over both public keys.</param>
|
||||
/// <param name="PersonalVaultId">The automatically created personal vault.</param>
|
||||
/// <param name="DeviceId">The enrolled device, when a device key was supplied.</param>
|
||||
/// <param name="KeyLogSequence">
|
||||
/// Position of this statement in the append-only key log. Clients cache the log head and
|
||||
/// include it in signed operations, which is what makes a forked view detectable.
|
||||
/// </param>
|
||||
public sealed record EnrollmentResponse(
|
||||
Guid UserId,
|
||||
int KeyGeneration,
|
||||
byte[] Fingerprint,
|
||||
Guid PersonalVaultId,
|
||||
Guid? DeviceId,
|
||||
long KeyLogSequence);
|
||||
|
||||
/// <summary>A public-key directory entry.</summary>
|
||||
/// <remarks>
|
||||
/// Before wrapping a vault key to one of these, a client must verify it: check the identity
|
||||
/// provider binding, compare against its pinned fingerprint, and confirm the key log head is
|
||||
/// consistent. Wrapping to an unverified key is the one mistake that undoes end-to-end
|
||||
/// encryption entirely.
|
||||
/// </remarks>
|
||||
/// <param name="UserId">The user.</param>
|
||||
/// <param name="Email">Email, for display.</param>
|
||||
/// <param name="DisplayName">Display name.</param>
|
||||
/// <param name="EncryptionPublicKey">X25519 public key.</param>
|
||||
/// <param name="SigningPublicKey">Ed25519 public key.</param>
|
||||
/// <param name="Fingerprint">Identity fingerprint.</param>
|
||||
/// <param name="KeyGeneration">Generation of this key pair.</param>
|
||||
/// <param name="KeyLogSequence">Key log position of the statement that introduced it.</param>
|
||||
public sealed record DirectoryEntry(
|
||||
Guid UserId,
|
||||
string? Email,
|
||||
string? DisplayName,
|
||||
byte[] EncryptionPublicKey,
|
||||
byte[] SigningPublicKey,
|
||||
byte[] Fingerprint,
|
||||
int KeyGeneration,
|
||||
long KeyLogSequence);
|
||||
|
||||
/// <summary>The caller's own profile and unlock state.</summary>
|
||||
/// <param name="UserId">The user.</param>
|
||||
/// <param name="Issuer">OIDC issuer.</param>
|
||||
/// <param name="Subject">OIDC subject.</param>
|
||||
/// <param name="Email">Email.</param>
|
||||
/// <param name="DisplayName">Display name.</param>
|
||||
/// <param name="EnrollmentRequired">
|
||||
/// True when no identity key exists yet, so the client must run enrollment before anything else.
|
||||
/// </param>
|
||||
/// <param name="KeyGeneration">Current key generation, when enrolled.</param>
|
||||
/// <param name="WrappedPrivateKey">
|
||||
/// The passphrase wrap of the secret bundle, for unlocking on this device.
|
||||
/// </param>
|
||||
/// <param name="KdfParameters">Parameters to re-derive the wrapping key.</param>
|
||||
/// <param name="Vaults">Vaults the caller can reach.</param>
|
||||
public sealed record MeResponse(
|
||||
Guid UserId,
|
||||
string Issuer,
|
||||
string Subject,
|
||||
string? Email,
|
||||
string? DisplayName,
|
||||
bool EnrollmentRequired,
|
||||
int? KeyGeneration,
|
||||
byte[]? WrappedPrivateKey,
|
||||
KdfParameters? KdfParameters,
|
||||
IReadOnlyList<VaultSummary> Vaults);
|
||||
|
||||
/// <summary>A vault the caller can reach, with the wrapped key needed to open it.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Display name. Plaintext, and only for vaults, not for items.</param>
|
||||
/// <param name="IsPersonal">Whether this is the caller's personal vault.</param>
|
||||
/// <param name="TeamId">Owning team, for a team vault.</param>
|
||||
/// <param name="KeyGeneration">Current key generation.</param>
|
||||
/// <param name="Permissions">The caller's effective permissions, as a flags value.</param>
|
||||
/// <param name="WrappedVaultKey">
|
||||
/// The vault key sealed to the caller's X25519 key. Absent when a grant is awaiting re-wrap
|
||||
/// after a rekey, in which case the vault is temporarily unreadable and a member holding Share
|
||||
/// must complete it.
|
||||
/// </param>
|
||||
/// <param name="RekeyRequired">Whether a membership change has left this vault needing a rekey.</param>
|
||||
public sealed record VaultSummary(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
bool IsPersonal,
|
||||
Guid? TeamId,
|
||||
uint KeyGeneration,
|
||||
int Permissions,
|
||||
byte[]? WrappedVaultKey,
|
||||
bool RekeyRequired);
|
||||
Reference in New Issue
Block a user