diff --git a/DodoSSH.slnx b/DodoSSH.slnx index 3a3beb6..5b125b7 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -16,6 +16,7 @@ + @@ -24,6 +25,7 @@ + diff --git a/docs/crypto.md b/docs/crypto.md index d928201..08b1c85 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -387,6 +387,38 @@ Appends must be serialised (the server takes a deployment-wide advisory lock). T appends reading the same head would produce two entries claiming the same predecessor, which is indistinguishable from the fork the chain exists to detect. +### 7.3 Vault key grant — canonical encoding + +> **Added 2026-07-28.** §7 named the grant tuple without specifying its encoding. This fills that in, +> using the same conventions as §7.1. Pinned by `GrantStatementCodecTests`. + +``` +grant = "dsh1/grant/v1" 13 bytes, literal + || u32 keyGeneration big-endian + || u8 grantKind 1 = Member, 2 = Recovery, 3 = Escrow + || vaultId 16 bytes, RFC 4122 big-endian + || granteeUserId 16 bytes, all-zero for a non-member grant + || granteeKeyFingerprint 32 bytes + || SHA-256(wrappedKey) 32 bytes + || granterUserId 16 bytes + || granterKeyFingerprint 32 bytes + || keyLogHead 0x00, or 0x01 followed by 32 bytes + || i64 grantedAt big-endian, Unix milliseconds, UTC +``` + +Signed with context `dsh1/sig/grant/v1`. + +- **The digest of the wrapped key, not the key.** A verifier must be able to check who issued a grant + without holding the vault key, which is the whole point of separating attribution from access. +- **`grantKind` values are load-bearing.** They must match `DodoSSH.Domain.GrantKind` exactly; the + crypto-layer enum is named `GrantPurpose` only to avoid a name collision in the server, where both + are visible. Renumbering either would make every grant of the changed kind fail verification + permanently. +- **The key log head is optional, with a presence byte.** Absent for a self-grant: there is no third + party whose key could have been substituted, and the log entry that would supply a head is written + by the server in the same transaction, so a client cannot have signed over it. Without the presence + byte, "no head" and "a head of 32 zero bytes" would be indistinguishable. + ## 8. Fingerprints and versioning ``` diff --git a/src/DodoSSH.Client.Api/ClientEnrollment.cs b/src/DodoSSH.Client.Api/ClientEnrollment.cs new file mode 100644 index 0000000..f2774c8 --- /dev/null +++ b/src/DodoSSH.Client.Api/ClientEnrollment.cs @@ -0,0 +1,315 @@ +using System.Security.Cryptography; +using DodoSSH.Client.Auth; +using DodoSSH.Contracts; +using DodoSSH.Crypto; +using NSec.Cryptography; + +namespace DodoSSH.Client.Api; + +/// What enrolling produced, for the caller to hold and persist. +/// +/// The bundle and the vault key are live secrets. The caller owns their lifetime and must dispose the +/// bundle; neither is ever written anywhere but the OS keystore and the encrypted local cache. +/// +/// The server's answer, including the vault and key log position. +/// The identity key pair, unlocked for this session. +/// The personal vault's key, in plaintext for this session. +/// +/// The enrolled device's X25519 private key. Belongs in the OS keystore — it is what lets a later +/// launch unlock without the passphrase. +/// +/// +/// The generated recovery code, which must be shown to the user once and never stored. Losing this +/// along with the passphrase and every device means the vault is unrecoverable, and no server-side +/// reset is possible by design. +/// +public sealed record EnrollmentOutcome( + EnrollmentResponse Response, + UserSecretBundle Bundle, + byte[] PersonalVaultKey, + byte[] DevicePrivateKey, + string RecoveryCode); + +/// +/// Runs enrollment: generate keys, have the identity provider sign over them, and publish. +/// +/// +/// +/// Ordering here is not a matter of taste. The secret bundle's AAD binds to the server-assigned user +/// id, so /me must be read before anything can be wrapped — which is why /me provisions +/// the account and returns its id even when it reports that enrollment is required. +/// +/// +/// Everything the server receives is opaque to it. It gets public keys, wrapped blobs it cannot open, +/// and signatures it does not verify beyond the statement's own. That is the whole point: the server +/// stores the vault and cannot read it. +/// +/// +public sealed class ClientEnrollment( + DodoSshApiClient api, + IKeyBindingAuthorizer keyBinding, + TimeProvider clock) +{ + /// Bytes of entropy behind a recovery code. + private const int RecoveryEntropyBytes = 20; + + /// + /// Enrolls the caller. + /// + /// + /// The result of , which supplies the user id the wraps + /// bind to. + /// + /// The vault passphrase. Never transmitted or stored. + /// Human-readable name for this machine. + /// Display name for the personal vault. Plaintext, as vault names are. + /// Cancellation token. + public async Task EnrollAsync( + MeResponse me, + string passphrase, + string deviceName, + string vaultName, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(me); + ArgumentException.ThrowIfNullOrWhiteSpace(passphrase); + ArgumentException.ThrowIfNullOrWhiteSpace(deviceName); + + var now = clock.GetUtcNow(); + var bundle = UserSecretBundle.Create(now); + + try + { + var statement = BuildStatement(me, bundle, now, deviceName); + + // The provider signs over the statement's hash, which is what stops the DodoSSH server + // fabricating a key for a user who never enrolled. See ADR 0001. + var idToken = await keyBinding + .AuthorizeKeyBindingAsync( + KeyStatementCodec.ComputeNonce(ToFields(statement)), + cancellationToken) + .ConfigureAwait(false); + + var request = BuildRequest( + me, bundle, statement, idToken, passphrase, vaultName, now, out var material); + + var response = await api.EnrollAsync(request, cancellationToken).ConfigureAwait(false); + + return new EnrollmentOutcome( + response, + bundle, + material.VaultKey, + material.DevicePrivateKey, + material.RecoveryCode); + } + catch + { + // The caller never receives the bundle on failure, so this is the only place that can + // release its guarded memory. + bundle.Dispose(); + throw; + } + } + + private static KeyStatement BuildStatement( + MeResponse me, + UserSecretBundle bundle, + DateTimeOffset now, + string deviceName) => + new( + Version: KeyStatementCodec.CurrentVersion, + Issuer: me.Issuer, + Subject: me.Subject, + Email: me.Email, + EncryptionPublicKey: bundle.EncryptionPublicKey, + SigningPublicKey: bundle.SigningPublicKey, + KeyGeneration: 1, + CreatedAt: now, + DeviceName: deviceName); + + /// Secrets the caller keeps after a successful enrollment. + private readonly record struct SessionMaterial( + byte[] VaultKey, + byte[] DevicePrivateKey, + string RecoveryCode); + + /// + /// The passphrase is a parameter rather than a field, so it lives only for the duration of this call + /// and never becomes state on a long-lived object that a heap dump would find. + /// + private static EnrollmentRequest BuildRequest( + MeResponse me, + UserSecretBundle bundle, + KeyStatement statement, + string idToken, + string passphrase, + string vaultName, + DateTimeOffset now, + out SessionMaterial material) + { + var descriptor = DshAad.UserSecretBundle(me.UserId); + + // A fresh salt per wrap, and the parameters travel with it — so raising the cost later is a + // per-user migration at next unlock rather than a breaking change. + var passphraseSalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize); + var recoverySalt = RandomNumberGenerator.GetBytes(CryptoSpec.SaltSize); + var recoveryCode = GenerateRecoveryCode(); + + byte[] passphraseWrap; + byte[] recoveryWrap; + + using (var master = MasterKey.Derive( + passphrase, passphraseSalt, Argon2Profile.PassphraseDefault)) + { + passphraseWrap = master.WrapBundle(bundle, descriptor); + } + + // The recovery code carries real entropy, so it needs far less stretching than a passphrase. + using (var recoveryMaster = MasterKey.Derive( + recoveryCode, recoverySalt, Argon2Profile.RandomSecret)) + { + recoveryWrap = recoveryMaster.WrapBundle(bundle, descriptor); + } + + using var deviceKey = Key.Create( + KeyAgreementAlgorithm.X25519, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + var devicePublicKey = deviceKey.PublicKey.Export(KeyBlobFormat.RawPublicKey); + var deviceWrap = bundle.SealTo(devicePublicKey, descriptor); + + var vault = BuildPersonalVault(me, bundle, vaultName, now, out var vaultKey); + + material = new SessionMaterial( + vaultKey, + deviceKey.Export(KeyBlobFormat.RawPrivateKey), + recoveryCode); + + return new EnrollmentRequest( + Statement: statement, + StatementSignature: DshSignatures.SignKeyStatement( + bundle.SigningKey, + KeyStatementCodec.Encode(ToFields(statement))), + IdentityProviderToken: idToken, + WrappedPrivateKey: passphraseWrap, + KdfParameters: ToContract(passphraseSalt, Argon2Profile.PassphraseDefault), + DevicePublicKey: devicePublicKey, + DeviceWrappedPrivateKey: deviceWrap, + RecoveryWrappedPrivateKey: recoveryWrap, + RecoveryKdfParameters: ToContract(recoverySalt, Argon2Profile.RandomSecret), + PersonalVault: vault); + } + + /// + /// The vault id is chosen here rather than by the server, which is what makes enrollment safely + /// retryable and is required by the grant signature — the signed tuple covers the vault id. + /// + /// A self-grant carries no key log head: there is no third party whose key could have been + /// substituted, and the log entry that would supply one is written by the server in the same + /// transaction, so it cannot be signed over here. + /// + /// + private static PersonalVaultRequest BuildPersonalVault( + MeResponse me, + UserSecretBundle bundle, + string vaultName, + DateTimeOffset now, + out byte[] vaultKey) + { + var vaultId = Guid.CreateVersion7(); + vaultKey = VaultKeys.Create(); + + var wrappedVaultKey = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1); + + var fingerprint = DshCrypto.ComputeFingerprint( + bundle.EncryptionPublicKey, + bundle.SigningPublicKey); + + var grant = GrantStatementCodec.Encode( + vaultId, + keyGeneration: 1, + GrantPurpose.Member, + granteeUserId: me.UserId, + granteeKeyFingerprint: fingerprint, + wrappedKey: wrappedVaultKey, + granterUserId: me.UserId, + granterKeyFingerprint: fingerprint, + keyLogHead: default, + grantedAt: now); + + return new PersonalVaultRequest( + VaultId: vaultId, + Name: vaultName, + WrappedVaultKey: wrappedVaultKey, + GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, grant), + GrantedAt: now); + } + + /// + /// Generates a printable recovery code. + /// + /// + /// Base32 over Crockford's alphabet, which omits I, L, O and U — so a code read aloud or copied off + /// a screen cannot be mistranscribed into a different valid code, and cannot spell anything + /// unfortunate. Grouped for legibility, and the groups are not part of the secret: the derivation + /// uses the string exactly as shown, dashes included, because that is what the user will type back. + /// + private static string GenerateRecoveryCode() + { + const string Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + const int BitsPerCharacter = 5; + const int CharactersPerGroup = 5; + + // 20 bytes is 160 bits, which divides evenly into 32 five-bit characters — so no bits are + // discarded and no padding is needed. Separators go between groups, hence one fewer than the + // number of groups. + var totalCharacters = RecoveryEntropyBytes * 8 / BitsPerCharacter; + var separators = (totalCharacters - 1) / CharactersPerGroup; + + var entropy = RandomNumberGenerator.GetBytes(RecoveryEntropyBytes); + var characters = new char[totalCharacters + separators]; + + var index = 0; + + for (var position = 0; position < totalCharacters; position++) + { + if (position > 0 && position % CharactersPerGroup == 0) + { + characters[index++] = '-'; + } + + var value = 0; + for (var offset = 0; offset < BitsPerCharacter; offset++) + { + var bit = (position * BitsPerCharacter) + offset; + value = (value << 1) | ((entropy[bit / 8] >> (7 - (bit % 8))) & 1); + } + + characters[index++] = Alphabet[value]; + } + + CryptographicOperations.ZeroMemory(entropy); + + return new string(characters); + } + + private static KdfParameters ToContract(byte[] salt, Argon2Profile profile) => + new( + Algorithm: "argon2id", + Salt: salt, + MemoryKibibytes: profile.MemoryKibibytes, + Passes: profile.Passes, + Parallelism: Argon2Profile.Parallelism); + + private static KeyStatementFields ToFields(KeyStatement statement) => + new( + statement.Version, + statement.Issuer, + statement.Subject, + statement.Email, + statement.EncryptionPublicKey, + statement.SigningPublicKey, + statement.KeyGeneration, + statement.CreatedAt, + statement.DeviceName); +} diff --git a/src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj b/src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj new file mode 100644 index 0000000..d8ba847 --- /dev/null +++ b/src/DodoSSH.Client.Api/DodoSSH.Client.Api.csproj @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Api/DodoSshApiClient.cs b/src/DodoSSH.Client.Api/DodoSshApiClient.cs new file mode 100644 index 0000000..f63e6ec --- /dev/null +++ b/src/DodoSSH.Client.Api/DodoSshApiClient.cs @@ -0,0 +1,175 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Api; + +/// Supplies the bearer token for API calls, refreshing it when needed. +/// +/// An abstraction because token lifetime is the auth layer's problem, not the API client's. The +/// client asks for a token per request and never caches one, so a refresh that happens mid-session is +/// invisible here rather than something every call site has to remember to handle. +/// +public interface IAccessTokenProvider +{ + /// Returns a currently-valid access token. + ValueTask GetAccessTokenAsync(CancellationToken cancellationToken); +} + +/// +/// The typed client for one DodoSSH server. +/// +/// +/// +/// Everything goes through DodoSSH.Contracts and its source-generated serialiser, which is the +/// actual contract between the two sides — not the OpenAPI document. Requests are written with +/// StrictRequestOptions on the server and read here with ResponseOptions, so an older +/// client tolerates a newer server's extra fields instead of failing on them. +/// +/// +/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can. +/// Everything else carries a bearer token. +/// +/// +public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) +{ + private const string MetaPath = "/api/v1/meta"; + private const string ConfigurationPath = "/.well-known/dodossh-configuration"; + private const string MePath = "/api/v1/me"; + private const string EnrollmentPath = "/api/v1/me/enrollment"; + + /// + /// Reads the server's capabilities, versions and limits. + /// + /// + /// Unauthenticated, and the replacement for URL-based API versioning: when client and server + /// upgrade independently — normal for self-hosted software — a client has to ask what this + /// particular server supports rather than assume. See ADR 0002. + /// + public Task GetMetaAsync(CancellationToken cancellationToken) => + GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken); + + /// + /// Reads everything needed to begin authenticating. + /// + /// + /// This is the onboarding story: the user types one server URL and the client discovers the OIDC + /// authority, the client id, the scopes and the relay from it. + /// + public Task GetConfigurationAsync(CancellationToken cancellationToken) => + GetAnonymousAsync( + ConfigurationPath, + DodoSshJsonContext.Default.DodoSshConfiguration, + cancellationToken); + + /// + /// Reads the caller's profile, unlock material and reachable vaults. + /// + /// + /// The first authenticated call a client makes, and the only one that works before enrollment. It + /// also provisions the account, so its UserId is available before enrolling — which matters, + /// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier. + /// + public Task GetMeAsync(CancellationToken cancellationToken) => + SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken); + + /// Publishes the caller's first identity key and creates their personal vault. + public Task EnrollAsync( + EnrollmentRequest request, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Post, + EnrollmentPath, + JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest), + DodoSshJsonContext.Default.EnrollmentResponse, + cancellationToken); + + /// Reads vault changes after a cursor. + /// + /// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is + /// wanted. + /// + public Task SyncPullAsync( + Guid vaultId, + SyncPullRequest request, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Post, + $"/api/v1/vaults/{vaultId}/sync/pull", + JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest), + DodoSshJsonContext.Default.SyncPullResponse, + cancellationToken); + + /// + /// Applies a batch of vault changes. + /// + /// + /// Succeeds with per-operation status even when individual operations failed, so one stale item + /// cannot block everything else a client queued while offline. Callers must inspect + /// SyncPushResult.Status rather than treating a 200 as everything having applied. + /// + public Task SyncPushAsync( + Guid vaultId, + SyncPushRequest request, + CancellationToken cancellationToken) => + SendAsync( + HttpMethod.Post, + $"/api/v1/vaults/{vaultId}/sync/push", + JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest), + DodoSshJsonContext.Default.SyncPushResponse, + cancellationToken); + + private async Task GetAnonymousAsync( + string path, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(HttpMethod.Get, path); + return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false); + } + + private async Task SendAsync( + HttpMethod method, + string path, + HttpContent? content, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, path) { Content = content }; + + var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false); + } + + private async Task SendCoreAsync( + HttpRequestMessage request, + System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + { + using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var body = await response.Content + .ReadAsStringAsync(cancellationToken) + .ConfigureAwait(false); + + throw DodoSshApiException.FromResponse(response.StatusCode, body); + } + + var value = await response.Content + .ReadFromJsonAsync(typeInfo, cancellationToken) + .ConfigureAwait(false); + + // A 200 with a null body is a server bug, but it must not surface as a NullReferenceException + // three frames further up where the cause is invisible. + return value ?? throw new DodoSshApiException( + HttpStatusCode.OK, + null, + $"The server returned an empty body where a {typeof(T).Name} was expected."); + } +} diff --git a/src/DodoSSH.Client.Api/DodoSshApiException.cs b/src/DodoSSH.Client.Api/DodoSshApiException.cs new file mode 100644 index 0000000..4274310 --- /dev/null +++ b/src/DodoSSH.Client.Api/DodoSshApiException.cs @@ -0,0 +1,64 @@ +using System.Net; +using System.Text.Json; + +namespace DodoSSH.Client.Api; + +/// +/// A DodoSSH server returned an error. +/// +/// +/// Carries the RFC 9457 code extension rather than only the status and prose. The codes are +/// constants in DodoSSH.Contracts.ProblemCodes, so the client can branch on +/// enrollment-required means run enrollment, vault-conflict means +/// merge and retry — instead of matching on a message that is free to change. +/// +public sealed class DodoSshApiException(HttpStatusCode statusCode, string? code, string message) + : Exception(message) +{ + /// The HTTP status. + public HttpStatusCode StatusCode { get; } = statusCode; + + /// The stable machine-readable code, when the server supplied one. + public string? Code { get; } = code; + + /// + /// Builds an exception from a response body. + /// + /// + /// Tolerates a body that is not ProblemDetails, or not JSON at all. A reverse proxy returning its + /// own HTML 502 is a normal thing to meet, and losing the status code while trying to parse it + /// would replace a diagnosable failure with a parse error. + /// + internal static DodoSshApiException FromResponse(HttpStatusCode statusCode, string body) + { + string? code = null; + var detail = body; + + try + { + using var document = JsonDocument.Parse(body); + var root = document.RootElement; + + if (root.ValueKind == JsonValueKind.Object) + { + code = ReadString(root, "code"); + detail = ReadString(root, "detail") ?? ReadString(root, "title") ?? body; + } + } + catch (JsonException) + { + // Not JSON. The status code still tells the caller what happened. + } + + var summary = string.IsNullOrWhiteSpace(detail) + ? $"The server returned {(int)statusCode}." + : $"The server returned {(int)statusCode}: {detail}"; + + return new DodoSshApiException(statusCode, code, summary); + } + + private static string? ReadString(JsonElement root, string name) => + root.TryGetProperty(name, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; +} diff --git a/src/DodoSSH.Client.Api/packages.lock.json b/src/DodoSSH.Client.Api/packages.lock.json new file mode 100644 index 0000000..d9e6c15 --- /dev/null +++ b/src/DodoSSH.Client.Api/packages.lock.json @@ -0,0 +1,46 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "dodossh.client.auth": { + "type": "Project" + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Client.Auth/OidcClient.cs b/src/DodoSSH.Client.Auth/OidcClient.cs index eb928ea..a77334b 100644 --- a/src/DodoSSH.Client.Auth/OidcClient.cs +++ b/src/DodoSSH.Client.Auth/OidcClient.cs @@ -6,6 +6,25 @@ using System.Text.Json; namespace DodoSSH.Client.Auth; +/// +/// Obtains an identity-provider signature over a set of public keys. +/// +/// +/// Narrower than the whole OIDC client on purpose. Enrollment needs exactly this one capability, and +/// depending on the full client would drag discovery, token exchange and refresh into every test of +/// it — which is how a test for key binding ends up needing a stubbed token endpoint. +/// +public interface IKeyBindingAuthorizer +{ + /// + /// Runs an authorization whose nonce is a key statement's hash. + /// + /// From KeyStatementCodec.ComputeNonce. + /// Cancels the wait for the browser. + /// The ID token to hand to the enrollment endpoint. + Task AuthorizeKeyBindingAsync(string bindingNonce, CancellationToken cancellationToken); +} + /// /// Authorization Code with PKCE on a loopback redirect, for a native public client. /// @@ -26,7 +45,7 @@ public sealed class OidcClient( HttpClient http, IBrowserLauncher browser, TimeProvider clock, - OidcClientOptions options) + OidcClientOptions options) : IKeyBindingAuthorizer { private readonly OidcDiscoveryClient discovery = new(http); diff --git a/src/DodoSSH.Crypto/GrantStatementCodec.cs b/src/DodoSSH.Crypto/GrantStatementCodec.cs new file mode 100644 index 0000000..e35a373 --- /dev/null +++ b/src/DodoSSH.Crypto/GrantStatementCodec.cs @@ -0,0 +1,260 @@ +using System.Buffers.Binary; +using System.Security.Cryptography; +using NSec.Cryptography; + +namespace DodoSSH.Crypto; + +/// +/// Why a vault key grant exists. +/// +/// +/// +/// Named GrantPurpose rather than GrantKind only to avoid colliding with +/// DodoSSH.Domain.GrantKind, which the server uses for the same concept. Both are visible in the +/// server, and an ambiguous name there would need qualifying at every use. +/// +/// +/// The numeric values must match that enum exactly. They are covered by a grant signature, so a +/// renumbering would make every grant of the changed kind fail verification for good. A test pins them. +/// +/// +public enum GrantPurpose : byte +{ + /// Not a legal value. + Unspecified = 0, + + /// Wrapped to a member's identity key. + Member = 1, + + /// Wrapped to a recovery key held by the vault owner. + Recovery = 2, + + /// Wrapped to a team break-glass key. + Escrow = 3, +} + +/// +/// The canonical encoding of a vault key grant, as signed by the granter. See docs/crypto.md §7.3. +/// +/// +/// +/// Sealing a vault key is anonymous-sender by construction, so a grant proves nothing about who +/// created it. Without a signature over this tuple a server could fabricate a grant containing a key +/// of its own choosing, and the recipient would unwrap it successfully and be none the wiser. The +/// signature makes that detectable and attributable — it cannot make it impossible, since verifying +/// the contents would require the server to hold the key. +/// +/// +/// The signature covers SHA-256(wrappedKey) rather than the wrapped key itself, so a verifier +/// does not need the vault key to check who issued the grant. +/// +/// +public static class GrantStatementCodec +{ + /// Domain-separating prefix. + public static ReadOnlySpan Label => "dsh1/grant/v1"u8; + + private const int LabelLength = 13; + + /// Length without a key log head. + private const int BaseLength = + LabelLength + + sizeof(uint) // key generation + + 1 // grant kind + + 16 // vault id + + 16 // grantee user id + + CryptoSpec.DigestSize // grantee key fingerprint + + CryptoSpec.DigestSize // SHA-256 of the wrapped key + + 16 // granter user id + + CryptoSpec.DigestSize // granter key fingerprint + + 1 // key log head presence + + sizeof(long); // timestamp, Unix milliseconds + + /// + /// Writes the canonical encoding. + /// + /// The vault the key belongs to. + /// Generation the grant is for, so a superseded one cannot be replayed. + /// Why the grant exists. + /// Who may open it. for a non-member grant. + /// The exact identity key it was wrapped to. + /// The sealed vault key; only its digest is covered. + /// Who issued it. + /// The granter's identity key. + /// + /// The key log head the granter observed, or null. Null for a self-grant, where there is no third + /// party whose key could have been substituted — and where the log entry that would supply the head + /// is written in the same transaction, so the client could not have signed over it. + /// + /// Signing time; truncated to milliseconds. + public static byte[] Encode( + Guid vaultId, + uint keyGeneration, + GrantPurpose kind, + Guid granteeUserId, + ReadOnlySpan granteeKeyFingerprint, + ReadOnlySpan wrappedKey, + Guid granterUserId, + ReadOnlySpan granterKeyFingerprint, + ReadOnlySpan keyLogHead, + DateTimeOffset grantedAt) + { + Validate(kind, granteeKeyFingerprint, wrappedKey, granterKeyFingerprint, keyLogHead); + + var buffer = new byte[BaseLength + keyLogHead.Length]; + var span = buffer.AsSpan(); + + Label.CopyTo(span); + var offset = LabelLength; + + BinaryPrimitives.WriteUInt32BigEndian(span[offset..], keyGeneration); + offset += sizeof(uint); + + span[offset++] = (byte)kind; + + offset = WriteGuid(span, offset, vaultId); + offset = WriteGuid(span, offset, granteeUserId); + + granteeKeyFingerprint.CopyTo(span[offset..]); + offset += CryptoSpec.DigestSize; + + // The digest, not the key. A verifier must be able to check attribution without holding the + // vault key. + SHA256.HashData(wrappedKey, span.Slice(offset, CryptoSpec.DigestSize)); + offset += CryptoSpec.DigestSize; + + offset = WriteGuid(span, offset, granterUserId); + + granterKeyFingerprint.CopyTo(span[offset..]); + offset += CryptoSpec.DigestSize; + + if (keyLogHead.IsEmpty) + { + span[offset++] = 0; + } + else + { + span[offset++] = 1; + keyLogHead.CopyTo(span[offset..]); + offset += CryptoSpec.DigestSize; + } + + BinaryPrimitives.WriteInt64BigEndian(span[offset..], grantedAt.ToUnixTimeMilliseconds()); + offset += sizeof(long); + + if (offset != buffer.Length) + { + throw new InvalidOperationException( + $"Grant encoding wrote {offset} bytes but reserved {buffer.Length}."); + } + + return buffer; + } + + private static void Validate( + GrantPurpose kind, + ReadOnlySpan granteeKeyFingerprint, + ReadOnlySpan wrappedKey, + ReadOnlySpan granterKeyFingerprint, + ReadOnlySpan keyLogHead) + { + if (kind == GrantPurpose.Unspecified) + { + throw new ArgumentOutOfRangeException(nameof(kind), kind, "A grant kind is required."); + } + + RequireDigest(granteeKeyFingerprint, nameof(granteeKeyFingerprint)); + RequireDigest(granterKeyFingerprint, nameof(granterKeyFingerprint)); + + if (wrappedKey.IsEmpty) + { + throw new ArgumentException("A wrapped key is required.", nameof(wrappedKey)); + } + + if (!keyLogHead.IsEmpty && keyLogHead.Length != CryptoSpec.DigestSize) + { + throw new ArgumentException( + $"A key log head is {CryptoSpec.DigestSize} bytes or absent.", + nameof(keyLogHead)); + } + } + + /// Signs a grant encoding. + public static byte[] Sign(Key signingKey, ReadOnlySpan canonicalGrant) + { + ArgumentNullException.ThrowIfNull(signingKey); + + return SignatureAlgorithm.Ed25519.Sign(signingKey, BuildMessage(canonicalGrant)); + } + + /// + /// Verifies a grant signature against the granter's published signing key. + /// + /// + /// Clients verify these; the server stores them opaquely. Server-side verification would be a + /// convenience and never the boundary, and would put an asymmetric implementation on a machine + /// that is supposed to hold no keys. + /// + public static bool Verify( + ReadOnlySpan granterSigningPublicKey, + ReadOnlySpan canonicalGrant, + ReadOnlySpan signature) + { + if (granterSigningPublicKey.Length != CryptoSpec.PublicKeySize + || signature.Length != CryptoSpec.SignatureSize) + { + return false; + } + + PublicKey publicKey; + try + { + publicKey = PublicKey.Import( + SignatureAlgorithm.Ed25519, + granterSigningPublicKey, + KeyBlobFormat.RawPublicKey); + } + catch (FormatException) + { + return false; + } + + return SignatureAlgorithm.Ed25519.Verify(publicKey, BuildMessage(canonicalGrant), signature); + } + + /// + /// Context-prefixed, so a grant signature can never be replayed as a key statement signature or an + /// attestation. + /// + private static byte[] BuildMessage(ReadOnlySpan canonicalGrant) + { + var context = CryptoSpec.SigningContexts.Grant; + var message = new byte[context.Length + canonicalGrant.Length]; + + context.CopyTo(message); + canonicalGrant.CopyTo(message.AsSpan(context.Length)); + + return message; + } + + private static int WriteGuid(Span destination, int offset, Guid value) + { + // RFC 4122 big-endian, as everywhere else in this specification. + if (!value.TryWriteBytes(destination[offset..], bigEndian: true, out _)) + { + throw new InvalidOperationException("Failed to write a grant identifier."); + } + + return offset + 16; + } + + private static void RequireDigest(ReadOnlySpan value, string parameterName) + { + if (value.Length != CryptoSpec.DigestSize) + { + throw new ArgumentException( + $"Expected a {CryptoSpec.DigestSize}-byte fingerprint, got {value.Length}.", + parameterName); + } + } +} diff --git a/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs b/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs new file mode 100644 index 0000000..97c6869 --- /dev/null +++ b/tests/DodoSSH.Client.Api.Tests/ClientEnrollmentTests.cs @@ -0,0 +1,356 @@ +using System.Net; +using System.Text.Json; +using DodoSSH.Client.Auth; +using DodoSSH.Contracts; +using DodoSSH.Crypto; + +namespace DodoSSH.Client.Api.Tests; + +/// +/// The client half of enrollment: what it sends, and what it keeps to itself. +/// +/// +/// The most valuable assertions here are the negative ones about the request body. The server is +/// supposed to be unable to read anything it stores, and this is where that either holds or quietly +/// stops holding — a refactor that put a passphrase or a private key into the request would be +/// invisible to every other test in the repository. +/// +public sealed class ClientEnrollmentTests : IDisposable +{ + private const string Passphrase = "correct horse battery staple"; + private const string EnrollmentPath = "/api/v1/me/enrollment"; + + private static readonly Guid UserId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + + private readonly StubServer server = new(); + private readonly HttpClient http = new(); + + public ClientEnrollmentTests() => http.BaseAddress = server.BaseUrl; + + /// + public void Dispose() + { + http.Dispose(); + server.Dispose(); + } + + [Fact] + public async Task Enroll_SendsAStatementTheServerCanVerify() + { + var binding = new CapturingKeyBinding(); + var outcome = await EnrollAsync(binding); + + using var bundle = outcome.Bundle; + + var request = ReadRequest(); + + // The statement must describe the caller, or the server rejects it — and the signature must + // verify against the statement's own signing key, which is what proves possession. + request.Statement.Issuer.ShouldBe("https://idp.example/realms/dodossh"); + request.Statement.Subject.ShouldBe("alice"); + request.Statement.KeyGeneration.ShouldBe(1); + request.Statement.EncryptionPublicKey.ShouldBe(bundle.EncryptionPublicKey); + request.Statement.SigningPublicKey.ShouldBe(bundle.SigningPublicKey); + + DshSignatures.VerifyKeyStatement( + request.Statement.SigningPublicKey, + KeyStatementCodec.Encode(ToFields(request.Statement)), + request.StatementSignature) + .ShouldBeTrue(); + } + + [Fact] + public async Task Enroll_BindsTheKeysWithTheStatementsOwnHash() + { + // The nonce handed to the identity provider must be this statement's hash and no other, + // otherwise the token binds keys nobody is publishing. + var binding = new CapturingKeyBinding(); + var outcome = await EnrollAsync(binding); + outcome.Bundle.Dispose(); + + var request = ReadRequest(); + + binding.RequestedNonce.ShouldBe( + KeyStatementCodec.ComputeNonce(ToFields(request.Statement))); + } + + [Fact] + public async Task Enroll_SendsNothingTheServerCouldUseToOpenTheVault() + { + var outcome = await EnrollAsync(new CapturingKeyBinding()); + + using var bundle = outcome.Bundle; + var body = server.LastBody(EnrollmentPath); + + // The passphrase and the recovery code exist only on this machine. + body.ShouldNotContain(Passphrase); + body.ShouldNotContain(outcome.RecoveryCode); + + // Nor may any private key appear, in any encoding the serialiser might have chosen. + body.ShouldNotContain(Convert.ToBase64String(outcome.PersonalVaultKey)); + body.ShouldNotContain(Convert.ToBase64String(outcome.DevicePrivateKey)); + body.ShouldNotContain(Convert.ToHexString(outcome.PersonalVaultKey)); + } + + [Fact] + public async Task Enroll_WrapsTheSameBundleThreeWays() + { + // One bundle, several wraps, which is what makes a passphrase change a single-row update + // rather than a re-encryption of the vault. + var outcome = await EnrollAsync(new CapturingKeyBinding()); + + using var bundle = outcome.Bundle; + var request = ReadRequest(); + var descriptor = DshAad.UserSecretBundle(UserId); + + using var viaPassphrase = OpenWithPassphrase(request, Passphrase, descriptor); + viaPassphrase.ShouldNotBeNull(); + viaPassphrase.SigningPublicKey.ShouldBe(bundle.SigningPublicKey); + + request.RecoveryWrappedPrivateKey.ShouldNotBeNull(); + request.RecoveryKdfParameters.ShouldNotBeNull(); + + using var viaRecovery = OpenWithRecovery(request, outcome.RecoveryCode, descriptor); + viaRecovery.ShouldNotBeNull(); + viaRecovery.SigningPublicKey.ShouldBe(bundle.SigningPublicKey); + + // The device wrap is sealed to the device key rather than derived, so it opens with the + // private half the caller was handed to put in the OS keystore. + request.DevicePublicKey.ShouldNotBeNull(); + request.DeviceWrappedPrivateKey.ShouldNotBeNull(); + + using var deviceKey = NSec.Cryptography.Key.Import( + NSec.Cryptography.KeyAgreementAlgorithm.X25519, + outcome.DevicePrivateKey, + NSec.Cryptography.KeyBlobFormat.RawPrivateKey); + + using var viaDevice = UserSecretBundle.TryOpenSealed( + deviceKey, request.DeviceWrappedPrivateKey, descriptor); + + viaDevice.ShouldNotBeNull(); + viaDevice.SigningPublicKey.ShouldBe(bundle.SigningPublicKey); + } + + [Fact] + public async Task Enroll_SendsKdfParametersStrongEnoughForTheServerToAccept() + { + // The server enforces a floor. Sending anything below it fails enrollment against a real + // server while passing every stub, so the values are asserted here rather than discovered + // later. + var outcome = await EnrollAsync(new CapturingKeyBinding()); + outcome.Bundle.Dispose(); + + var request = ReadRequest(); + + request.KdfParameters.Algorithm.ShouldBe("argon2id"); + request.KdfParameters.MemoryKibibytes.ShouldBe(Argon2Profile.PassphraseDefault.MemoryKibibytes); + request.KdfParameters.Passes.ShouldBe(Argon2Profile.PassphraseDefault.Passes); + request.KdfParameters.Parallelism.ShouldBe(1); + request.KdfParameters.Salt.Length.ShouldBe(CryptoSpec.SaltSize); + + // A distinct salt per wrap. Reusing one would let a single cracking effort cover both. + request.RecoveryKdfParameters!.Salt.ShouldNotBe(request.KdfParameters.Salt); + } + + [Fact] + public async Task Enroll_SignsThePersonalVaultGrantOverTheCanonicalTuple() + { + var outcome = await EnrollAsync(new CapturingKeyBinding()); + + using var bundle = outcome.Bundle; + var request = ReadRequest(); + var vault = request.PersonalVault; + + var fingerprint = DshCrypto.ComputeFingerprint( + bundle.EncryptionPublicKey, bundle.SigningPublicKey); + + var grant = GrantStatementCodec.Encode( + vault.VaultId, + keyGeneration: 1, + GrantPurpose.Member, + granteeUserId: UserId, + granteeKeyFingerprint: fingerprint, + wrappedKey: vault.WrappedVaultKey, + granterUserId: UserId, + granterKeyFingerprint: fingerprint, + keyLogHead: default, + grantedAt: vault.GrantedAt); + + GrantStatementCodec.Verify(bundle.SigningPublicKey, grant, vault.GrantSignature) + .ShouldBeTrue("A grant the granter's own key cannot verify is one no client will accept."); + } + + [Fact] + public async Task Enroll_SealsThePersonalVaultKeyToTheEnrollingIdentity() + { + var outcome = await EnrollAsync(new CapturingKeyBinding()); + + using var bundle = outcome.Bundle; + var vault = ReadRequest().PersonalVault; + + VaultKeys.TryUnwrap(bundle.EncryptionKey, vault.WrappedVaultKey, vault.VaultId, 1) + .ShouldBe(outcome.PersonalVaultKey); + } + + [Fact] + public async Task Enroll_ProducesAReadableRecoveryCode() + { + // Read aloud or copied off a screen, so the alphabet omits the characters that get + // mistranscribed. + var outcome = await EnrollAsync(new CapturingKeyBinding()); + outcome.Bundle.Dispose(); + + outcome.RecoveryCode.ShouldNotBeNullOrWhiteSpace(); + outcome.RecoveryCode.ShouldContain("-"); + + foreach (var character in outcome.RecoveryCode.Replace("-", string.Empty, StringComparison.Ordinal)) + { + "0123456789ABCDEFGHJKMNPQRSTVWXYZ".ShouldContain(character); + } + } + + [Fact] + public async Task Enroll_DisposesTheBundleWhenTheServerRefuses() + { + // The caller never receives the bundle on failure, so nothing else could release its guarded + // memory. + server.StubProblem( + EnrollmentPath, "POST", 409, ProblemCodes.AlreadyEnrolled, "Already enrolled."); + + var enrollment = new ClientEnrollment( + new DodoSshApiClient(http, new StubTokenProvider()), + new CapturingKeyBinding(), + TimeProvider.System); + + var exception = await Should.ThrowAsync(async () => + await enrollment.EnrollAsync( + Me(), Passphrase, "laptop", "Personal", TestContext.Current.CancellationToken)); + + exception.Code.ShouldBe(ProblemCodes.AlreadyEnrolled); + exception.StatusCode.ShouldBe(HttpStatusCode.Conflict); + } + + [Fact] + public async Task Enroll_RejectsAnEmptyPassphraseBeforeTouchingTheNetwork() + { + var binding = new CapturingKeyBinding(); + + var enrollment = new ClientEnrollment( + new DodoSshApiClient(http, new StubTokenProvider()), + binding, + TimeProvider.System); + + await Should.ThrowAsync(async () => + await enrollment.EnrollAsync( + Me(), string.Empty, "laptop", "Personal", TestContext.Current.CancellationToken)); + + binding.RequestedNonce.ShouldBeNull("Nothing should reach the identity provider."); + } + + // ---- Helpers ---- + + private async Task EnrollAsync(CapturingKeyBinding binding) + { + server.StubEnrollment(new EnrollmentResponse( + UserId: UserId, + KeyGeneration: 1, + Fingerprint: new byte[32], + PersonalVaultId: Guid.CreateVersion7(), + DeviceId: Guid.CreateVersion7(), + KeyLogSequence: 1)); + + var enrollment = new ClientEnrollment( + new DodoSshApiClient(http, new StubTokenProvider()), + binding, + TimeProvider.System); + + return await enrollment.EnrollAsync( + Me(), Passphrase, "laptop", "Personal", TestContext.Current.CancellationToken); + } + + private EnrollmentRequest ReadRequest() + { + var request = JsonSerializer.Deserialize( + server.LastBody(EnrollmentPath), + DodoSshJsonContext.Default.EnrollmentRequest); + + request.ShouldNotBeNull(); + return request; + } + + private static MeResponse Me() => + new( + UserId: UserId, + Issuer: "https://idp.example/realms/dodossh", + Subject: "alice", + Email: "alice@example.com", + DisplayName: "Alice", + EnrollmentRequired: true, + KeyGeneration: null, + WrappedPrivateKey: null, + KdfParameters: null, + Vaults: []); + + private static UserSecretBundle? OpenWithPassphrase( + EnrollmentRequest request, + string passphrase, + AadDescriptor descriptor) + { + // Reconstructed from the stored parameters, exactly as a client unlocking on another device + // would do after reading them from /me. + using var master = MasterKey.Derive( + passphrase, + request.KdfParameters.Salt, + Argon2Profile.FromStoredParameters( + request.KdfParameters.MemoryKibibytes, + request.KdfParameters.Passes, + request.KdfParameters.Parallelism)); + + return master.TryOpenBundle(request.WrappedPrivateKey, descriptor); + } + + private static UserSecretBundle? OpenWithRecovery( + EnrollmentRequest request, + string recoveryCode, + AadDescriptor descriptor) + { + var parameters = request.RecoveryKdfParameters!; + + using var master = MasterKey.Derive( + recoveryCode, + parameters.Salt, + Argon2Profile.FromStoredParameters( + parameters.MemoryKibibytes, parameters.Passes, parameters.Parallelism)); + + return master.TryOpenBundle(request.RecoveryWrappedPrivateKey!, descriptor); + } + + private static KeyStatementFields ToFields(KeyStatement statement) => + new( + statement.Version, + statement.Issuer, + statement.Subject, + statement.Email, + statement.EncryptionPublicKey, + statement.SigningPublicKey, + statement.KeyGeneration, + statement.CreatedAt, + statement.DeviceName); + + /// Records the nonce it was asked to bind, and returns a token carrying it. + private sealed class CapturingKeyBinding : IKeyBindingAuthorizer + { + internal string? RequestedNonce { get; private set; } + + public Task AuthorizeKeyBindingAsync( + string bindingNonce, + CancellationToken cancellationToken) + { + RequestedNonce = bindingNonce; + + // Shape only. The real token's signature and nonce are the server's to verify, and doing + // it here would just be testing the stub. + return Task.FromResult($"header.{bindingNonce}.signature"); + } + } +} diff --git a/tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj b/tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj new file mode 100644 index 0000000..8a02710 --- /dev/null +++ b/tests/DodoSSH.Client.Api.Tests/DodoSSH.Client.Api.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs b/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs new file mode 100644 index 0000000..ad1e439 --- /dev/null +++ b/tests/DodoSSH.Client.Api.Tests/DodoSshApiClientTests.cs @@ -0,0 +1,239 @@ +using System.Net; +using DodoSSH.Contracts; + +namespace DodoSSH.Client.Api.Tests; + +/// +/// The wire contract: what goes out, what comes back, and how failures surface. +/// +/// +/// The problem-code assertions matter most. Those codes are how a client decides what to do next — +/// enrollment-required means enroll, vault-conflict means merge and retry — so losing one +/// while parsing an error turns an actionable failure into an opaque one. +/// +public sealed class DodoSshApiClientTests : IDisposable +{ + private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f"); + + private readonly StubServer server = new(); + private readonly HttpClient http = new(); + + public DodoSshApiClientTests() => http.BaseAddress = server.BaseUrl; + + /// + public void Dispose() + { + http.Dispose(); + server.Dispose(); + } + + [Fact] + public async Task Meta_IsFetchedWithoutAToken() + { + // A client has to be able to ask what a server supports before it can authenticate, so this + // one must not require a bearer token. + server.StubMeta(new MetaResponse( + ServerVersion: "1.2.3", + ApiVersions: [1], + SyncProtocolVersion: 1, + CryptoSpecVersion: 1, + Features: ["teams"], + MinClientVersion: null, + MaxOperationsPerPush: 500, + MaxPayloadBytes: 8 * 1024 * 1024, + MaxItemPayloadBytes: 256 * 1024)); + + var meta = await Client().GetMetaAsync(TestContext.Current.CancellationToken); + + meta.ServerVersion.ShouldBe("1.2.3"); + meta.MaxOperationsPerPush.ShouldBe(500); + + server.LastAuthorization("/api/v1/meta").ShouldBeNull(); + } + + [Fact] + public async Task Me_CarriesTheBearerToken() + { + server.StubMe(UnenrolledMe()); + + var me = await Client().GetMeAsync(TestContext.Current.CancellationToken); + + me.EnrollmentRequired.ShouldBeTrue(); + server.LastAuthorization("/api/v1/me").ShouldBe("Bearer test-access-token"); + } + + [Fact] + public async Task Me_RoundTripsVaultSummaries() + { + // Byte arrays through the source-generated serialiser, which is the one thing most likely to + // go wrong silently between the two sides. + var wrappedKey = new byte[] { 1, 2, 3, 4, 5 }; + + server.StubMe(UnenrolledMe() with + { + EnrollmentRequired = false, + KeyGeneration = 1, + WrappedPrivateKey = [9, 8, 7], + KdfParameters = new KdfParameters("argon2id", [1, 2, 3], 262144, 4, 1), + Vaults = + [ + new VaultSummary( + VaultId: VaultId, + Name: "Personal", + IsPersonal: true, + TeamId: null, + KeyGeneration: 1, + Permissions: 31, + WrappedVaultKey: wrappedKey, + RekeyRequired: false), + ], + }); + + var me = await Client().GetMeAsync(TestContext.Current.CancellationToken); + + var vault = me.Vaults.ShouldHaveSingleItem(); + vault.WrappedVaultKey.ShouldBe(wrappedKey); + vault.KeyGeneration.ShouldBe(1u); + me.WrappedPrivateKey.ShouldBe([9, 8, 7]); + me.KdfParameters!.MemoryKibibytes.ShouldBe(262144); + } + + [Fact] + public async Task AProblemResponse_KeepsItsCode() + { + server.StubProblem( + "/api/v1/me", "GET", 403, ProblemCodes.EnrollmentRequired, "Publish an identity key first."); + + var exception = await Should.ThrowAsync(async () => + await Client().GetMeAsync(TestContext.Current.CancellationToken)); + + exception.Code.ShouldBe(ProblemCodes.EnrollmentRequired); + exception.StatusCode.ShouldBe(HttpStatusCode.Forbidden); + exception.Message.ShouldContain("Publish an identity key first."); + } + + [Fact] + public async Task ANonJsonError_StillReportsItsStatus() + { + // A reverse proxy in front of a dead server returns HTML. Losing the status code while trying + // to parse that as ProblemDetails would replace a diagnosable failure with a parse error. + server.StubGatewayError("/api/v1/me", "GET"); + + var exception = await Should.ThrowAsync(async () => + await Client().GetMeAsync(TestContext.Current.CancellationToken)); + + exception.StatusCode.ShouldBe(HttpStatusCode.BadGateway); + exception.Code.ShouldBeNull(); + } + + [Fact] + public async Task Push_SendsTheBatchAndReturnsPerOperationStatus() + { + // A push succeeds with mixed results on purpose, so one stale item cannot block everything + // else a client queued while offline. Callers must read the statuses rather than trusting + // the 200. + var applied = Guid.CreateVersion7(); + var conflicted = Guid.CreateVersion7(); + + server.StubPush(VaultId, new SyncPushResponse( + Results: + [ + new SyncPushResult(applied, SyncOperationStatus.Applied, 1, 10, null, null), + new SyncPushResult(conflicted, SyncOperationStatus.Conflict, 2, 11, null, null), + ], + Cursor: "next-cursor")); + + var request = new SyncPushRequest( + [ + new SyncPushOperation( + applied, + SyncEntityType.Host, + Guid.CreateVersion7(), + SyncOperation.Upsert, + null, + new EncryptedPayload([1, 2, 3], 1, 1), + new SyncPlaintextFields()), + ]); + + var response = await Client().SyncPushAsync( + VaultId, request, TestContext.Current.CancellationToken); + + response.Results.Count.ShouldBe(2); + response.Results[0].Status.ShouldBe(SyncOperationStatus.Applied); + response.Results[1].Status.ShouldBe(SyncOperationStatus.Conflict); + response.Cursor.ShouldBe("next-cursor"); + + var body = server.LastBody($"/api/v1/vaults/{VaultId}/sync/push"); + body.ShouldContain("operations"); + body.ShouldContain("Upsert"); + } + + [Fact] + public async Task Pull_RoundTripsChangesAndTheCursor() + { + var entityId = Guid.CreateVersion7(); + + server.StubPull(VaultId, new SyncPullResponse( + Changes: + [ + new SyncChange( + SyncEntityType.Host, + entityId, + SyncOperation.Upsert, + Version: 1, + ChangeSequence: 5, + Payload: new EncryptedPayload([4, 5, 6], 1, 1), + PlaintextFields: new SyncPlaintextFields(RelayEnabled: false), + UpdatedAt: DateTimeOffset.FromUnixTimeSeconds(1_750_000_000)), + ], + NextCursor: "cursor-2", + HasMore: false, + ServerTime: DateTimeOffset.FromUnixTimeSeconds(1_750_000_001), + CurrentKeyGeneration: 1)); + + var response = await Client().SyncPullAsync( + VaultId, + new SyncPullRequest("cursor-1", null, null), + TestContext.Current.CancellationToken); + + var change = response.Changes.ShouldHaveSingleItem(); + change.EntityId.ShouldBe(entityId); + change.Payload!.Envelope.ShouldBe([4, 5, 6]); + response.NextCursor.ShouldBe("cursor-2"); + response.HasMore.ShouldBeFalse(); + + server.LastBody($"/api/v1/vaults/{VaultId}/sync/pull").ShouldContain("cursor-1"); + } + + [Fact] + public async Task AConflictOnPush_SurfacesItsCode() + { + server.StubProblem( + $"/api/v1/vaults/{VaultId}/sync/push", + "POST", + 409, + ProblemCodes.VaultConflict, + "Stale version."); + + var exception = await Should.ThrowAsync(async () => + await Client().SyncPushAsync( + VaultId, new SyncPushRequest([]), TestContext.Current.CancellationToken)); + + exception.Code.ShouldBe(ProblemCodes.VaultConflict); + } + + private DodoSshApiClient Client() => new(http, new StubTokenProvider()); + + private static MeResponse UnenrolledMe() => + new( + UserId: Guid.Parse("0192f0c8-9999-7aaa-8bbb-cccccccccccc"), + Issuer: "https://idp.example", + Subject: "alice", + Email: "alice@example.com", + DisplayName: "Alice", + EnrollmentRequired: true, + KeyGeneration: null, + WrappedPrivateKey: null, + KdfParameters: null, + Vaults: []); +} diff --git a/tests/DodoSSH.Client.Api.Tests/StubServer.cs b/tests/DodoSSH.Client.Api.Tests/StubServer.cs new file mode 100644 index 0000000..dd5f46d --- /dev/null +++ b/tests/DodoSSH.Client.Api.Tests/StubServer.cs @@ -0,0 +1,125 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using DodoSSH.Contracts; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; + +namespace DodoSSH.Client.Api.Tests; + +/// A stand-in DodoSSH server. +/// +/// Responses are built from the real contract types and the real source-generated serialiser, so the +/// client is reading exactly the shape a live server produces rather than hand-written JSON that +/// happens to satisfy it. +/// +internal sealed class StubServer : IDisposable +{ + private readonly WireMockServer server = WireMockServer.Start(); + + internal Uri BaseUrl => new(server.Url!, UriKind.Absolute); + + /// Requests received, so tests can assert on what was sent. + internal IReadOnlyList Requests => server.LogEntries.ToList(); + + internal void StubMe(MeResponse response) => + StubJson("/api/v1/me", "GET", 200, JsonSerializer.Serialize( + response, DodoSshJsonContext.Default.MeResponse)); + + internal void StubEnrollment(EnrollmentResponse response) => + StubJson("/api/v1/me/enrollment", "POST", 200, JsonSerializer.Serialize( + response, DodoSshJsonContext.Default.EnrollmentResponse)); + + internal void StubMeta(MetaResponse response) => + StubJson("/api/v1/meta", "GET", 200, JsonSerializer.Serialize( + response, DodoSshJsonContext.Default.MetaResponse)); + + internal void StubPush(Guid vaultId, SyncPushResponse response) => + StubJson($"/api/v1/vaults/{vaultId}/sync/push", "POST", 200, JsonSerializer.Serialize( + response, DodoSshJsonContext.Default.SyncPushResponse)); + + internal void StubPull(Guid vaultId, SyncPullResponse response) => + StubJson($"/api/v1/vaults/{vaultId}/sync/pull", "POST", 200, JsonSerializer.Serialize( + response, DodoSshJsonContext.Default.SyncPullResponse)); + + /// Stubs an RFC 9457 problem response. + internal void StubProblem(string path, string method, int statusCode, string code, string detail) + { + var problem = new JsonObject + { + ["type"] = ProblemCodes.TypeBaseUri + code, + ["title"] = "Request failed", + ["status"] = statusCode, + ["detail"] = detail, + ["code"] = code, + }; + + StubJson(path, method, statusCode, problem.ToJsonString()); + } + + /// Stubs a non-JSON error, as a reverse proxy in front of a dead server would return. + internal void StubGatewayError(string path, string method) => + server + .Given(Request.Create().WithPath(path).UsingMethod(method)) + .RespondWith(Response.Create() + .WithStatusCode(502) + .WithHeader("Content-Type", "text/html") + .WithBody("

502 Bad Gateway

")); + + /// The body of the last request to a path. + internal string LastBody(string path) + { + var entries = server.LogEntries + .Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true) + .ToList(); + + if (entries.Count == 0) + { + throw new InvalidOperationException($"Nothing was sent to {path}."); + } + + return entries[^1].RequestMessage?.Body ?? string.Empty; + } + + /// The Authorization header of the last request to a path. + internal string? LastAuthorization(string path) + { + var entries = server.LogEntries + .Where(e => e.RequestMessage?.Path?.EndsWith(path, StringComparison.Ordinal) == true) + .ToList(); + + if (entries.Count == 0) + { + return null; + } + + var headers = entries[^1].RequestMessage?.Headers; + + return headers is not null && headers.TryGetValue("Authorization", out var values) + ? values.FirstOrDefault() + : null; + } + + /// + public void Dispose() + { + server.Stop(); + server.Dispose(); + } + + private void StubJson(string path, string method, int statusCode, string body) => + server + .Given(Request.Create().WithPath(path).UsingMethod(method)) + .RespondWith(Response.Create() + .WithStatusCode(statusCode) + .WithHeader("Content-Type", "application/json") + .WithBody(body)); +} + +/// Hands out a fixed token, so tests can assert it reached the wire. +internal sealed class StubTokenProvider(string token = "test-access-token") : IAccessTokenProvider +{ + /// + public ValueTask GetAccessTokenAsync(CancellationToken cancellationToken) => + ValueTask.FromResult(token); +} diff --git a/tests/DodoSSH.Client.Api.Tests/packages.lock.json b/tests/DodoSSH.Client.Api.Tests/packages.lock.json new file mode 100644 index 0000000..fdef368 --- /dev/null +++ b/tests/DodoSSH.Client.Api.Tests/packages.lock.json @@ -0,0 +1,1409 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "WireMock.Net": { + "type": "Direct", + "requested": "[2.13.0, )", + "resolved": "2.13.0", + "contentHash": "msedNpcc2vBHSpmdpRmKSxJUsMIIA/MgeJw1OfiOnHEbpxiXDKWNe/LSOGfyMtzzoeWmbQ4XDu1FpwyBG+8JMQ==", + "dependencies": { + "WireMock.Net.GraphQL": "2.13.0", + "WireMock.Net.Matchers.SystemTextJsonPath": "2.13.0", + "WireMock.Net.MimePart": "2.13.0", + "WireMock.Net.Minimal": "2.13.0", + "WireMock.Net.OpenTelemetry": "2.13.0", + "WireMock.Net.ProtoBuf": "2.13.0" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "AnyOf": { + "type": "Transitive", + "resolved": "0.5.0.1", + "contentHash": "WDQw5Qos3mhCumSCgKD70TM1dmqBAuJFGv1cFtNTwTaDLZR7kGy33M5C+L0vZV/bNRNwyi5ABvRGPWHL17rkNw==" + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Fare": { + "type": "Transitive", + "resolved": "2.2.1", + "contentHash": "21XZo/yuXK1k0EUhdLnjgRD4n0HQYmPFchV6uaORcRc65rasZ1vdm2dmJXPBKZiIBztRRYRmmg/B76W721VWkA==" + }, + "GraphQL": { + "type": "Transitive", + "resolved": "8.5.0", + "contentHash": "BZkfH7GVacTZEkyqa4XN9mW12UA/0XYrpEkkrJnNBf0Pqw8CZWQfItLaWgG2C1Ju9YHH1UUG+LmIpo8iMP6pKA==", + "dependencies": { + "GraphQL-Parser": "9.5.0", + "GraphQL.Analyzers": "8.5.0" + } + }, + "GraphQL-Parser": { + "type": "Transitive", + "resolved": "9.5.0", + "contentHash": "5XWJGKHdVi8pyD4P0EglmJmlXEGs0HzvGlEBf3+/Ve1jLYBBKIOkKvY0Ej17b9Kn1bbBxkrmghqbmsMbkLL1nQ==" + }, + "GraphQL.Analyzers": { + "type": "Transitive", + "resolved": "8.5.0", + "contentHash": "jwfvZD5agmw9J8iZEe6BUfKAY+/lC7EqDQg+6JRwXaQ6G/MCLy8jyBc2SHF/2JdAtrkygc1bVyCUP5mR2PHzVA==" + }, + "GraphQL.NewtonsoftJson": { + "type": "Transitive", + "resolved": "8.5.0", + "contentHash": "tAeUoUhJih5fdZRCV0ue3G/gsu8YBiyNZkgLVFyk0wTk8vJGLTBDJaP5o5LVo1edVnk0bR+0/PaNXAEJxkVrTw==", + "dependencies": { + "GraphQL": "[8.5.0, 9.0.0)", + "Newtonsoft.Json": "13.0.3" + } + }, + "Handlebars.Net": { + "type": "Transitive", + "resolved": "2.1.6", + "contentHash": "WsYWCEXsIM6hEOSOSRHtIYLjC8BnbT5MVmqhNKRqUI7qiv0t8x3nJiBTEv0ZZfvUAMAFnadGIzSsS/U2anVG1Q==" + }, + "Handlebars.Net.Helpers": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "MZ0/Nvy3XdEy/igZD4fJy5HiUKcKUA170Sq+2RmJFsGiWSqlroXzp8TQqJTDCi1aBtETfQOVdBG0YNvuDs9+uQ==", + "dependencies": { + "Handlebars.Net.Helpers.Core": "2.5.5" + } + }, + "Handlebars.Net.Helpers.Core": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "vLTL6UrLUPPiWDCKig8FLhSU+i9J4n/8RfrhadvnvxqziyK0ArxKMT2gLqQ+X/8vJaRcI9zvD5HxA8KjWbq3Dw==", + "dependencies": { + "Handlebars.Net": "2.1.6", + "Stef.Validation": "0.1.1" + } + }, + "Handlebars.Net.Helpers.Humanizer": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "A7TmfLtv7x8HiVckXBmKmOAsO5GKxjSOjxymXS70upqzLLH8BjrhFl+QIGFCdVIWQRx3+yNjGcsz/JXNwt9YZg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "Humanizer": "[2.14.1, 4.0.0)" + } + }, + "Handlebars.Net.Helpers.Json": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "iRBo/ik0M8M6ezJt4QzZm5KQptEdeh6bVtnDbieuxh5YPTUsPMFvtoq0gg426PwrahE+5rXoFZmIM11Oy5GwTg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "Newtonsoft.Json": "13.0.3" + } + }, + "Handlebars.Net.Helpers.Random": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "zKcfFDN4QxgEjk4Em9yz/PQu0mBpIgEaqjhacg2Fl6M0oSsF7VBVflae2WRM9MtiVeRTwLkVwcy7TvJ6iqFuVQ==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "RandomDataGenerator.Net": "1.0.19" + } + }, + "Handlebars.Net.Helpers.Xeger": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "J+w9KalIuYlTKMeIv8eoisdoMEz44elri0UOLtfTAuDbADwnBBsJGp4kAQI107+hBcqery9OCRCXm8fvH4eCxQ==", + "dependencies": { + "Fare": "2.2.1", + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5" + } + }, + "Handlebars.Net.Helpers.XPath": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "uUGzjR5w5YCv+BdWQ4RpWAho0tUG0zfAKG5v+abXS6+E+fjbfSshOg7LyoWTVcGTWO0PouukhSMUFaumB2K4tg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5", + "XPath2.Extensions": "1.1.5" + } + }, + "Handlebars.Net.Helpers.Xslt": { + "type": "Transitive", + "resolved": "2.5.5", + "contentHash": "bOaX47avO4Uja6jTZcBAgS5KjL/2ZaewCpB0Oy7cVegctPyxiiRx/T44XGSt0133hHry9f5nJVsjFKNLrYq0Pg==", + "dependencies": { + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Core": "2.5.5" + } + }, + "Humanizer": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "/FUTD3cEceAAmJSCPN9+J+VhGwmL/C12jvwlyM1DFXShEMsBzvLzLqSrJ2rb+k/W2znKw7JyflZgZpyE+tI7lA==", + "dependencies": { + "Humanizer.Core.af": "2.14.1", + "Humanizer.Core.ar": "2.14.1", + "Humanizer.Core.az": "2.14.1", + "Humanizer.Core.bg": "2.14.1", + "Humanizer.Core.bn-BD": "2.14.1", + "Humanizer.Core.cs": "2.14.1", + "Humanizer.Core.da": "2.14.1", + "Humanizer.Core.de": "2.14.1", + "Humanizer.Core.el": "2.14.1", + "Humanizer.Core.es": "2.14.1", + "Humanizer.Core.fa": "2.14.1", + "Humanizer.Core.fi-FI": "2.14.1", + "Humanizer.Core.fr": "2.14.1", + "Humanizer.Core.fr-BE": "2.14.1", + "Humanizer.Core.he": "2.14.1", + "Humanizer.Core.hr": "2.14.1", + "Humanizer.Core.hu": "2.14.1", + "Humanizer.Core.hy": "2.14.1", + "Humanizer.Core.id": "2.14.1", + "Humanizer.Core.is": "2.14.1", + "Humanizer.Core.it": "2.14.1", + "Humanizer.Core.ja": "2.14.1", + "Humanizer.Core.ko-KR": "2.14.1", + "Humanizer.Core.ku": "2.14.1", + "Humanizer.Core.lv": "2.14.1", + "Humanizer.Core.ms-MY": "2.14.1", + "Humanizer.Core.mt": "2.14.1", + "Humanizer.Core.nb": "2.14.1", + "Humanizer.Core.nb-NO": "2.14.1", + "Humanizer.Core.nl": "2.14.1", + "Humanizer.Core.pl": "2.14.1", + "Humanizer.Core.pt": "2.14.1", + "Humanizer.Core.ro": "2.14.1", + "Humanizer.Core.ru": "2.14.1", + "Humanizer.Core.sk": "2.14.1", + "Humanizer.Core.sl": "2.14.1", + "Humanizer.Core.sr": "2.14.1", + "Humanizer.Core.sr-Latn": "2.14.1", + "Humanizer.Core.sv": "2.14.1", + "Humanizer.Core.th-TH": "2.14.1", + "Humanizer.Core.tr": "2.14.1", + "Humanizer.Core.uk": "2.14.1", + "Humanizer.Core.uz-Cyrl-UZ": "2.14.1", + "Humanizer.Core.uz-Latn-UZ": "2.14.1", + "Humanizer.Core.vi": "2.14.1", + "Humanizer.Core.zh-CN": "2.14.1", + "Humanizer.Core.zh-Hans": "2.14.1", + "Humanizer.Core.zh-Hant": "2.14.1" + } + }, + "Humanizer.Core": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, + "Humanizer.Core.af": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "BoQHyu5le+xxKOw+/AUM7CLXneM/Bh3++0qh1u0+D95n6f9eGt9kNc8LcAHLIOwId7Sd5hiAaaav0Nimj3peNw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ar": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "3d1V10LDtmqg5bZjWkA/EkmGFeSfNBcyCH+TiHcHP+HGQQmRq3eBaLcLnOJbVQVn3Z6Ak8GOte4RX4kVCxQlFA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.az": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "8Z/tp9PdHr/K2Stve2Qs/7uqWPWLUK9D8sOZDNzyv42e20bSoJkHFn7SFoxhmaoVLJwku2jp6P7HuwrfkrP18Q==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.bg": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "S+hIEHicrOcbV2TBtyoPp1AVIGsBzlarOGThhQYCnP6QzEYo/5imtok6LMmhZeTnBFoKhM8yJqRfvJ5yqVQKSQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.bn-BD": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "U3bfj90tnUDRKlL1ZFlzhCHoVgpTcqUlTQxjvGCaFKb+734TTu3nkHUWVZltA1E/swTvimo/aXLtkxnLFrc0EQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.cs": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "jWrQkiCTy3L2u1T86cFkgijX6k7hoB0pdcFMWYaSZnm6rvG/XJE40tfhYyKhYYgIc1x9P2GO5AC7xXvFnFdqMQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.da": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "5o0rJyE/2wWUUphC79rgYDnif/21MKTTx9LIzRVz9cjCIVFrJ2bDyR2gapvI9D6fjoyvD1NAfkN18SHBsO8S9g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.de": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "9JD/p+rqjb8f5RdZ3aEJqbjMYkbk4VFii2QDnnOdNo6ywEfg/A5YeOQ55CaBJmy7KvV4tOK4+qHJnX/tg3Z54A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.el": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "Xmv6sTL5mqjOWGGpqY7bvbfK5RngaUHSa8fYDGSLyxY9mGdNbDcasnRnMOvi0SxJS9gAqBCn21Xi90n2SHZbFA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.es": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "e//OIAeMB7pjBV1HqqI4pM2Bcw3Jwgpyz9G5Fi4c+RJvhqFwztoWxW57PzTnNJE2lbhGGLQZihFZjsbTUsbczA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fa": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "nzDOj1x0NgjXMjsQxrET21t1FbdoRYujzbmZoR8u8ou5CBWY1UNca0j6n/PEJR/iUbt4IxstpszRy41wL/BrpA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fi-FI": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "Vnxxx4LUhp3AzowYi6lZLAA9Lh8UqkdwRh4IE2qDXiVpbo08rSbokATaEzFS+o+/jCNZBmoyyyph3vgmcSzhhQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "2p4g0BYNzFS3u9SOIDByp2VClYKO0K1ecDV4BkB9EYdEPWfFODYnF+8CH8LpUrpxL2TuWo2fiFx/4Jcmrnkbpg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.fr-BE": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "o6R3SerxCRn5Ij8nCihDNMGXlaJ/1AqefteAssgmU2qXYlSAGdhxmnrQAXZUDlE4YWt/XQ6VkNLtH7oMqsSPFQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.he": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "FPsAhy7Iw6hb+ZitLgYC26xNcgGAHXb0V823yFAzcyoL5ozM+DCJtYfDPYiOpsJhEZmKFTM9No0jUn1M89WGvg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.hr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "chnaD89yOlST142AMkAKLuzRcV5df3yyhDyRU5rypDiqrq2HN8y1UR3h1IicEAEtXLoOEQyjSAkAQ6QuXkn7aw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.hu": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "hAfnaoF9LTGU/CmFdbnvugN4tIs8ppevVMe3e5bD24+tuKsggMc5hYta9aiydI8JH9JnuVmxvNI4DJee1tK05A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.hy": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "sVIKxOiSBUb4gStRHo9XwwAg9w7TNvAXbjy176gyTtaTiZkcjr9aCPziUlYAF07oNz6SdwdC2mwJBGgvZ0Sl2g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.id": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "4Zl3GTvk3a49Ia/WDNQ97eCupjjQRs2iCIZEQdmkiqyaLWttfb+cYXDMGthP42nufUL0SRsvBctN67oSpnXtsg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.is": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "R67A9j/nNgcWzU7gZy1AJ07ABSLvogRbqOWvfRDn4q6hNdbg/mjGjZBp4qCTPnB2mHQQTCKo3oeCUayBCNIBCw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.it": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "jYxGeN4XIKHVND02FZ+Woir3CUTyBhLsqxu9iqR/9BISArkMf1Px6i5pRZnvq4fc5Zn1qw71GKKoCaHDJBsLFw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ja": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "TM3ablFNoYx4cYJybmRgpDioHpiKSD7q0QtMrmpsqwtiiEsdW5zz/q4PolwAczFnvrKpN6nBXdjnPPKVet93ng==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ko-KR": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "CtvwvK941k/U0r8PGdEuBEMdW6jv/rBiA9tUhakC7Zd2rA/HCnDcbr1DiNZ+/tRshnhzxy/qwmpY8h4qcAYCtQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ku": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "vHmzXcVMe+LNrF9txpdHzpG7XJX65SiN9GQd/Zkt6gsGIIEeECHrkwCN5Jnlkddw2M/b0HS4SNxdR1GrSn7uCA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.lv": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "E1/KUVnYBS1bdOTMNDD7LV/jdoZv/fbWTLPtvwdMtSdqLyRTllv6PGM9xVQoFDYlpvVGtEl/09glCojPHw8ffA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ms-MY": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "vX8oq9HnYmAF7bek4aGgGFJficHDRTLgp/EOiPv9mBZq0i4SA96qVMYSjJ2YTaxs7Eljqit7pfpE2nmBhY5Fnw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.mt": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "pEgTBzUI9hzemF7xrIZigl44LidTUhNu4x/P6M9sAwZjkUF0mMkbpxKkaasOql7lLafKrnszs0xFfaxQyzeuZQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.nb": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "mbs3m6JJq53ssLqVPxNfqSdTxAcZN3njlG8yhJVx83XVedpTe1ECK9aCa8FKVOXv93Gl+yRHF82Hw9T9LWv2hw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.nb-NO": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "AsJxrrVYmIMbKDGe8W6Z6//wKv9dhWH7RsTcEHSr4tQt/80pcNvLi0hgD3fqfTtg0tWKtgch2cLf4prorEV+5A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.nl": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "24b0OUdzJxfoqiHPCtYnR5Y4l/s4Oh7KW7uDp+qX25NMAHLCGog2eRfA7p2kRJp8LvnynwwQxm2p534V9m55wQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.pl": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "17mJNYaBssENVZyQHduiq+bvdXS0nhZJGEXtPKoMhKv3GD//WO0mEfd9wjEBsWCSmWI7bjRqhCidxzN+YtJmsg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.pt": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "8HB8qavcVp2la1GJX6t+G9nDYtylPKzyhxr9LAooIei9MnQvNsjEiIE4QvHoeDZ4weuQ9CsPg1c211XUMVEZ4A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ro": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "psXNOcA6R8fSHoQYhpBTtTTYiOk8OBoN3PKCEDgsJKIyeY5xuK81IBdGi77qGZMu/OwBRQjQCBMtPJb0f4O1+A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.ru": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "zm245xUWrajSN2t9H7BTf84/2APbUkKlUJpcdgsvTdAysr1ag9fi1APu6JEok39RRBXDfNRVZHawQ/U8X0pSvQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sk": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "Ncw24Vf3ioRnbU4MsMFHafkyYi8JOnTqvK741GftlQvAbULBoTz2+e7JByOaasqeSi0KfTXeegJO+5Wk1c0Mbw==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sl": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "l8sUy4ciAIbVThWNL0atzTS2HWtv8qJrsGWNlqrEKmPwA4SdKolSqnTes9V89fyZTc2Q43jK8fgzVE2C7t009A==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "rnNvhpkOrWEymy7R/MiFv7uef8YO5HuXDyvojZ7JpijHWA5dXuVXooCOiA/3E93fYa3pxDuG2OQe4M/olXbQ7w==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sr-Latn": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "nuy/ykpk974F8ItoQMS00kJPr2dFNjOSjgzCwfysbu7+gjqHmbLcYs7G4kshLwdA4AsVncxp99LYeJgoh1JF5g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.sv": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "E53+tpAG0RCp+cSSI7TfBPC+NnsEqUuoSV0sU+rWRXWr9MbRWx1+Zj02XMojqjGzHjjOrBFBBio6m74seFl0AA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.th-TH": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "eSevlJtvs1r4vQarNPfZ2kKDp/xMhuD00tVVzRXkSh1IAZbBJI/x2ydxUOwfK9bEwEp+YjvL1Djx2+kw7ziu7g==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.tr": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "rQ8N+o7yFcFqdbtu1mmbrXFi8TQ+uy+fVH9OPI0CI3Cu1om5hUU/GOMC3hXsTCI6d79y4XX+0HbnD7FT5khegA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.uk": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "2uEfujwXKNm6bdpukaLtEJD+04uUtQD65nSGCetA1fYNizItEaIBUboNfr3GzJxSMQotNwGVM3+nSn8jTd0VSg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.uz-Cyrl-UZ": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "TD3ME2sprAvFqk9tkWrvSKx5XxEMlAn1sjk+cYClSWZlIMhQQ2Bp/w0VjX1Kc5oeKjxRAnR7vFcLUFLiZIDk9Q==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.uz-Latn-UZ": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "/kHAoF4g0GahnugZiEMpaHlxb+W6jCEbWIdsq9/I1k48ULOsl/J0pxZj93lXC3omGzVF1BTVIeAtv5fW06Phsg==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.vi": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "rsQNh9rmHMBtnsUUlJbShMsIMGflZtPmrMM6JNDw20nhsvqfrdcoDD8cMnLAbuSovtc3dP+swRmLQzKmXDTVPA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.zh-CN": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "uH2dWhrgugkCjDmduLdAFO9w1Mo0q07EuvM0QiIZCVm6FMCu/lGv2fpMu4GX+4HLZ6h5T2Pg9FIdDLCPN2a67w==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.zh-Hans": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "WH6IhJ8V1UBG7rZXQk3dZUoP2gsi8a0WkL8xL0sN6WGiv695s8nVcmab9tWz20ySQbuzp0UkSxUQFi5jJHIpOQ==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "Humanizer.Core.zh-Hant": { + "type": "Transitive", + "resolved": "2.14.1", + "contentHash": "VIXB7HCUC34OoaGnO3HJVtSv2/wljPhjV7eKH4+TFPgQdJj2lvHNKY41Dtg0Bphu7X5UaXFR4zrYYyo+GNOjbA==", + "dependencies": { + "Humanizer.Core": "[2.14.1]" + } + }, + "JmesPath.Net": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "sL1LeqBm+BWSKvgZN/T470IqkXcKQXmOYsRUZU18jDZeiIBmvUfIe9m3VhiII/jOK/6WmrQ+W8Pqwz3k28WX9g==", + "dependencies": { + "JmesPath.Net.Parser": "1.1.0", + "Newtonsoft.Json": "13.0.4" + } + }, + "JmesPath.Net.Parser": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "NLTE/dPy8lMcZO6E7SL5Jw3fay8Vesll7+hkeRVSRaVNg1RRyPBV3/u6CM7QNgtnzhvyFPDyxUHUuRdh0vzCSg==" + }, + "Json.More.Net": { + "type": "Transitive", + "resolved": "3.0.1", + "contentHash": "fRctF2J2SILYG6wqP21drmeEODmCVkVQ/b3MndDu2fT1swfySyUgq7ePCk+aENGlDcIm05fyfjh9vcuqDEfv3w==" + }, + "JsonConverter.Abstractions": { + "type": "Transitive", + "resolved": "0.13.0", + "contentHash": "Ci3nuKx3GgMDfW9JA4dJpU+hJV5G1ve72mploQP8ivSDpOmo2QbfVAQkxsVzb3UQJCgwxxM6rdE/fPXwM0yj0g==" + }, + "JsonConverter.Newtonsoft.Json": { + "type": "Transitive", + "resolved": "0.13.0", + "contentHash": "K6doeW12emLiJV4laUf58y3kkjng6/IARRtC7+20qIOBrP1pBxGRsjz2IfxCgSBkTML3q6FWe7O+/UE374lsaA==", + "dependencies": { + "JsonConverter.Abstractions": "0.13.0", + "Newtonsoft.Json": "13.0.4", + "Stef.Validation": "0.1.1" + } + }, + "JsonConverter.System.Text.Json": { + "type": "Transitive", + "resolved": "0.13.0", + "contentHash": "UtRbkZT16Z0OVZ9n/h60E0GlUDTul/DjpuuajdsCNvefsmTxf6WNB8Cq9Hjwu9pGjfqOaFOmaz8k54DaHBmc0g==", + "dependencies": { + "JsonConverter.Abstractions": "0.13.0", + "Stef.Validation": "0.1.1" + } + }, + "JsonPath.Net": { + "type": "Transitive", + "resolved": "3.0.2", + "contentHash": "Cmt2mvPYOLljjqSfM1xUYZYTPf8MPbwv2XpCpPxxq9u23/CGrz/fljgd1fJUNujd3+E1adNOyF1TLwppbyQwxg==", + "dependencies": { + "Json.More.Net": "3.0.1" + } + }, + "MetadataReferenceService.Abstractions": { + "type": "Transitive", + "resolved": "0.0.1", + "contentHash": "Sf5ip58vlqWkQIAULIOKFIIFuhtRd8lChsJRZdFo746NVApEp/qgxNf/zCLjbB/RA/8TQGXWrFPKpqjyeh3EMg==", + "dependencies": { + "Microsoft.CodeAnalysis.CSharp": "4.8.0", + "Stef.Validation": "0.1.1" + } + }, + "MetadataReferenceService.Default": { + "type": "Transitive", + "resolved": "0.0.1", + "contentHash": "ihrchqYobpQMA9tn0W+MGD3oe5onqCttbR3lQfEiVzwF0V9/DS+K4YtvsUPGDC9XIie2Xw3lugSSk97k+OUwnQ==", + "dependencies": { + "MetadataReferenceService.Abstractions": "0.0.1" + } + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.AspNetCore.Http": { + "type": "Transitive", + "resolved": "2.3.9", + "contentHash": "+CcfWi1LoKYbcxt+3toO4xbBG+qSSMbPuuow+cbZKIrITXuu1geN1traamL4jG8QaHdHGm3M0eCh+EOgdMgNPA==", + "dependencies": { + "Microsoft.AspNetCore.Http.Abstractions": "2.3.0", + "Microsoft.AspNetCore.WebUtilities": "2.3.0", + "Microsoft.Extensions.ObjectPool": "8.0.11", + "Microsoft.Extensions.Options": "8.0.2", + "Microsoft.Net.Http.Headers": "2.3.8" + } + }, + "Microsoft.AspNetCore.Http.Abstractions": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "39r9PPrjA6s0blyFv5qarckjNkaHRA5B+3b53ybuGGNTXEj1/DStQJ4NWjFL6QTRQpL9zt7nDyKxZdJOlcnq+Q==", + "dependencies": { + "Microsoft.AspNetCore.Http.Features": "2.3.0" + } + }, + "Microsoft.AspNetCore.Http.Features": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "f10WUgcsKqrkmnz6gt8HeZ7kyKjYN30PO7cSic1lPtH7paPtnQqXPOveul/SIPI43PhRD4trttg4ywnrEmmJpA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.AspNetCore.WebUtilities": { + "type": "Transitive", + "resolved": "2.3.0", + "contentHash": "trbXdWzoAEUVd0PE2yTopkz4kjZaAIA7xUWekd5uBw+7xE8Do/YOVTeb9d9koPTlbtZT539aESJjSLSqD8eYrQ==", + "dependencies": { + "Microsoft.Net.Http.Headers": "2.3.0" + } + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.CodeAnalysis.Analyzers": { + "type": "Transitive", + "resolved": "3.3.4", + "contentHash": "AxkxcPR+rheX0SmvpLVIGLhOUXAKG56a64kV9VQZ4y9gR9ZmPXnqZvHJnmwLSwzrEP6junUF11vuc+aqo5r68g==" + }, + "Microsoft.CodeAnalysis.Common": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "/jR+e/9aT+BApoQJABlVCKnnggGQbvGh7BKq2/wI1LamxC+LbzhcLj4Vj7gXCofl1n4E521YfF9w0WcASGg/KA==", + "dependencies": { + "Microsoft.CodeAnalysis.Analyzers": "3.3.4" + } + }, + "Microsoft.CodeAnalysis.CSharp": { + "type": "Transitive", + "resolved": "4.8.0", + "contentHash": "+3+qfdb/aaGD8PZRCrsdobbzGs1m9u119SkkJt8e/mk3xLJz/udLtS2T6nY27OTXxBBw10HzAbC8Z9w08VyP/g==", + "dependencies": { + "Microsoft.CodeAnalysis.Common": "[4.8.0]" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "H4SWETCh/cC5L1WtWchHR6LntGk3rDTTznZMssr4cL8IbDmMWBxY+MOGDc/ASnqNolLKPIWHWeuC1ddiL/iNPw==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "d2kDKnCsJvY7mBVhcjPSp9BkJk48DsaHPg5u+Oy4f8XaOqnEedRy/USyvnpHL92wpJ6DrTPy7htppUUzskbCXQ==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tMF9wNh+hlyYDWB8mrFCQHQmWHlRosol1b/N2Jrefy1bFLnuTlgSYmPyHNmz8xVQgs7DpXytBRWxGhG+mSTp0g==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "f0RBabswJq+gRu5a+hWIobrLWiUYPKMhCD9WO3sYBAdSy3FFH14LMvLVFZc2kPSCimBLxSuitUhsd6tb0TAY6A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "L3AdmZ1WOK4XXT5YFPEwyt0ep6l8lGIPs7F5OOBZc77Zqeo01Of7XXICy47628sdVl0v/owxYJTe86DTgFwKCA==" + }, + "Microsoft.Extensions.Diagnostics.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "SfK89ytD61S7DgzorFljSkUeluC1ncn6dtZgwc0ot39f/BEYWBl5jpgvodxduoYAs1d9HG8faCDRZxE95UMo2A==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.FileProviders.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "/ppSdehKk3fuXjlqCDgSOtjRK/pSHU8eWgzSHfHdwVm5BP4Dgejehkw+PtxKG2j98qTDEHDst2Y99aNsmJldmw==", + "dependencies": { + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Hosting.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "KrN6TGFwCwqOkLLk/idW/XtDQh+8In+CL9T4M1Dx+5ScsjTq4TlVbal8q532m82UYrMr6RiQJF2HvYCN0QwVsA==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.FileProviders.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "BStFkd5CcnEtarlcgYDBcFzGYCuuNMzPs02wN3WBsOFoYIEmYoUdAiU+au6opzoqfTYJsMTW00AeqDdnXH2CvA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "FU/IfjDfwaMuKr414SSQNTIti/69bHEMb+QKrskRb26oVqpx3lNFXMjs/RC9ZUuhBhcwDM2BwOgoMw+PZ+beqQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0" + } + }, + "Microsoft.Extensions.Logging.Configuration": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "j8zcwhS6bYB6FEfaY3nYSgHdpiL2T+/V3xjpHtslVAegyI1JUbB9yAt/BFdvZdsNbY0Udm4xFtvfT/hUwcOOOg==", + "dependencies": { + "Microsoft.Extensions.Configuration": "10.0.0", + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging": "10.0.0", + "Microsoft.Extensions.Logging.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Options.ConfigurationExtensions": "10.0.0" + } + }, + "Microsoft.Extensions.ObjectPool": { + "type": "Transitive", + "resolved": "8.0.11", + "contentHash": "6ApKcHNJigXBfZa6XlDQ8feJpq7SG1ogZXg6M4FiNzgd6irs3LUAzo0Pfn4F2ZI9liGnH1XIBR/OtSbZmJAV5w==" + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "8oCAgXOow5XDrY9HaXX1QmH3ORsyZO/ANVHBlhLyCeWTH5Sg4UuqZeOTWJi6484M+LqSx0RqQXDJtdYy2BNiLQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Options.ConfigurationExtensions": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "tL9cSl3maS5FPzp/3MtlZI21ExWhni0nnUCF8HY4npTsINw45n9SNDbkKXBMtFyUFGSsQep25fHIDN4f/Vp3AQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "10.0.0", + "Microsoft.Extensions.Configuration.Binder": "10.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "Microsoft.Extensions.Options": "10.0.0", + "Microsoft.Extensions.Primitives": "10.0.0" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "10.0.0", + "contentHash": "inRnbpCS0nwO/RuoZIAqxQUuyjaknOOnCEZB55KSMMjRhl0RQDttSmLSGsUJN3RQ3ocf5NDLFd2mOQViHqMK5w==" + }, + "Microsoft.IdentityModel.Abstractions": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "5nInt1KKSpKQBlhe6gXz4yKxRzRUQa21vCvSIIKKzAI2e1r9PHQOZc7aRzBA8L/JCvBxLbCxelvUqun6qwWPJg==" + }, + "Microsoft.IdentityModel.JsonWebTokens": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "CZMom/ZoWcgjxLMxmCmcEkuoA0OA4swN1CGeMBQyxF/hEZgRbWK9EnWVJ9/oMUq3D1+OGJjnbN+W6gFq9kZcEg==", + "dependencies": { + "Microsoft.IdentityModel.Tokens": "6.34.0" + } + }, + "Microsoft.IdentityModel.Logging": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "E0AbluNkI30/VKa96PxJhhFZDx/NGYIXFrRIRq1N5/V0TToaiuc3hM90QLFszT2BBQefnp/wjm12ilSudmt9bg==", + "dependencies": { + "Microsoft.IdentityModel.Abstractions": "6.34.0" + } + }, + "Microsoft.IdentityModel.Protocols": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "xrqYK+V3FW+fMQ5oI7cwku2wj1RHz8qym3kh+rD+BTgCw1RmfFyWrLQ8/rVEqTl2nn4NcC0N+sHk0Q4qQ8dK9A==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "6.34.0", + "Microsoft.IdentityModel.Tokens": "6.34.0" + } + }, + "Microsoft.IdentityModel.Protocols.OpenIdConnect": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "SN3eZtssgpfnTCUlKsTJn9/0UiSc/HsbGLFl5Xp8vXFLXBeweWiDu54jFngSirjtJd6lSw3GgZhK5LZvVXGGLQ==", + "dependencies": { + "Microsoft.IdentityModel.Protocols": "6.34.0", + "System.IdentityModel.Tokens.Jwt": "6.34.0" + } + }, + "Microsoft.IdentityModel.Tokens": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "PEPcGMqbEwEwbpQ6nTld9Nqq6V5BPZSOfk71qXZ7h7DuGuxa13bWvjImhJba5Ko88YvIuZuOBJWFZmjLfwbNXA==", + "dependencies": { + "Microsoft.IdentityModel.Logging": "6.34.0" + } + }, + "Microsoft.Net.Http.Headers": { + "type": "Transitive", + "resolved": "2.3.8", + "contentHash": "JO60u/VVUdaZfv4XQ//zgcH54y8rnxdpcvXnsDqWLKB4adDKaCiaozixDfQ/6H+PKYfkNV2CL8b8U+F9mciE3Q==", + "dependencies": { + "Microsoft.Extensions.Primitives": "8.0.0" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "Namotion.Reflection": { + "type": "Transitive", + "resolved": "2.1.2", + "contentHash": "7tSHAzX8GWKy0qrW6OgQWD7kAZiqzhq+m1503qczuwuK6ZYhOGCQUxw+F3F4KkRM70aB6RMslsRVSCFeouIehw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.4", + "contentHash": "pdgNNMai3zv51W5aq268sujXUyx7SNdE2bj1wZcWjAQrKMFZV260lbqYop1d2GM67JI1huLRwxo9ZqnfF/lC6A==" + }, + "NJsonSchema": { + "type": "Transitive", + "resolved": "10.9.0", + "contentHash": "IBPo6Srxn2MEcIFM3HdM4QImrJbsIeujENQyzHL2Pv6wLsKSYAyAEilecRqaLOhoy3snEiPLx7hhv7opbhOxKQ==", + "dependencies": { + "Namotion.Reflection": "2.1.2", + "Newtonsoft.Json": "9.0.1" + } + }, + "NJsonSchema.Extensions": { + "type": "Transitive", + "resolved": "0.2.0", + "contentHash": "zLHUfuCmnaaQbKxqvTALrxhXV6Pbdy4G3ZlAI+7oaXdJmSyQPpMHGcxDBmw0+qHziT7jVImxU1BjcidKJHeprg==", + "dependencies": { + "NJsonSchema": "10.9.0" + } + }, + "NSwag.Core": { + "type": "Transitive", + "resolved": "13.16.1", + "contentHash": "xiX+H3Bv6zxrqJExPepO5WQVutkDUMdlUA3NqQ8VguwsYwJlkV05eF8XvmbJn/yGJWUag7vLImuXAoj0/327Bg==", + "dependencies": { + "NJsonSchema": "10.7.2", + "Newtonsoft.Json": "9.0.1" + } + }, + "OpenTelemetry": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "N0i6WjPoHPbZyms1ugbDIFAJFuGlpeExJMU/+XSL0lQRUkg/D0utFkDoLXf8Z1km5B+xVZ2GyMXXiX8qdeNmPg==", + "dependencies": { + "Microsoft.Extensions.Diagnostics.Abstractions": "10.0.0", + "Microsoft.Extensions.Logging.Configuration": "10.0.0", + "OpenTelemetry.Api.ProviderBuilderExtensions": "1.15.3" + } + }, + "OpenTelemetry.Api": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "fX+fkCysfPut+qCcT3bKqyX4QN9Saf4CgX8HLOHywEVD+Xr7sULtfuypITpoDysjx8R59dn/3mWhgimMH8cm/g==" + }, + "OpenTelemetry.Api.ProviderBuilderExtensions": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "SYn0lqYDwLMWhv/zlNGsQcl2yX++yTumanX46bmOZE/ZDOd1WjPBO2kZaZgKLEZTZk48pavIFGJ6vOvxXgWVFQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.0", + "OpenTelemetry.Api": "1.15.3" + } + }, + "OpenTelemetry.Exporter.OpenTelemetryProtocol": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "FEXJepcseTGbATiCkUfP7ipoFEYYfl/0UmmUwi0KxCPg9PaUA8ab2P1LGopK+/HExasJ1ZutFhZrN6WvUIR23g==", + "dependencies": { + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Extensions.Hosting": { + "type": "Transitive", + "resolved": "1.15.3", + "contentHash": "u8n/W8yIlqv0BXZmvId1iVaeWXG42tGKdTkuLYg5g57Y/r9CeUNzqtrSHNdG5IoO8iPX79w3v+WsbAHgUQbfeg==", + "dependencies": { + "Microsoft.Extensions.Hosting.Abstractions": "10.0.0", + "OpenTelemetry": "1.15.3" + } + }, + "OpenTelemetry.Instrumentation.AspNetCore": { + "type": "Transitive", + "resolved": "1.15.2", + "contentHash": "2nPd7r0ug/gd6/CNFL6Rlu+RSQ9WYGSGHAYQ1ssbSqyzKJpqTunfx2I/1O0WB5k+L0cyXbG4XVZpoSoUc3M7wg==", + "dependencies": { + "OpenTelemetry.Api.ProviderBuilderExtensions": "[1.15.3, 2.0.0)" + } + }, + "protobuf-net": { + "type": "Transitive", + "resolved": "3.2.52", + "contentHash": "XbZurNU3B/VaL/5OJ0kshO+AWxsZroI1saKuLfZpDwH2ngb2K9bdF1nIW6elFOViZw7TQCmfVZapxrMKCDqecQ==", + "dependencies": { + "protobuf-net.Core": "3.2.52" + } + }, + "protobuf-net.Core": { + "type": "Transitive", + "resolved": "3.2.52", + "contentHash": "zOpGtUo2QTgbsiI0D0yCe8aUTgDPov6kqIu1CDHI6isqhYcAHdirRrdnfsQXmAUfAWx1LwVYGgC6xe6fNS4UAg==" + }, + "ProtoBufJsonConverter": { + "type": "Transitive", + "resolved": "0.11.0", + "contentHash": "lxvcZQlCtgYZpfm9hhAJVZ1jsPkb9g3fyaAOnQLyEu8MiAowEIdED6jzwfjqLqOhY4AahqnbfRvZ904Ud43X7w==", + "dependencies": { + "MetadataReferenceService.Default": "0.0.1", + "Microsoft.CodeAnalysis.CSharp": "4.8.0", + "Newtonsoft.Json": "13.0.3", + "Stef.Validation": "0.1.1", + "protobuf-net": "3.2.52" + } + }, + "RamlToOpenApiConverter.SourceOnly": { + "type": "Transitive", + "resolved": "0.21.0", + "contentHash": "x0g2c4tgPC5i+ZofhlhSeiWAsOjzkatv523tKGglx0mL05JYevQ5sYPP4r0xthpRAv99/6EuBJ6TbbipsHTMzA==" + }, + "RandomDataGenerator.Net": { + "type": "Transitive", + "resolved": "1.0.19.1", + "contentHash": "OkAqBA69VbYYg+biX2DYWcucOI/yEivkdJ/XPqife/mQAC0r/NArcAU/EI3i/1oRFUUCjibV0b7qEjK+TYTqDw==", + "dependencies": { + "Fare": "2.2.1", + "Stef.Validation": "0.1.1" + } + }, + "Scriban.Signed": { + "type": "Transitive", + "resolved": "7.2.5", + "contentHash": "Fu1AjcAyrZIAW9LIhxVgVyo5EMVdwLhXagKKI2A1UZoI0Wvz2CiRT+VXp1tuiMq2JhlkjVyebj/JQcF4koZacg==" + }, + "SharpYaml": { + "type": "Transitive", + "resolved": "2.1.4", + "contentHash": "/iwULhVBpTjD4wPZhLU+eUWBanDvri/2AGx5YbaAj5kp9kXzhqUfJEy56H5Yi+c+OXsdm/oKD1aTKB24BFp8cw==" + }, + "SimMetrics.Net": { + "type": "Transitive", + "resolved": "1.0.5", + "contentHash": "LaSDYOJDh2WncgRboqiWtk/Igqoim/LV7v808qBeWY/f36Ol5oEKguEYpKrWw5ap8KYP0SRXf7/v3zil9koY6Q==" + }, + "Stef.Validation": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "OfzmxQMK4eBzmobph43p1NsLTgVAC3XGTcvQS0odhsdL6uS7UsFzBMK6S9mAfIijZdLW4q98aZ3dtTOeTaQo6Q==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.IdentityModel.Tokens.Jwt": { + "type": "Transitive", + "resolved": "6.34.0", + "contentHash": "c0misfmFT3QxKY+a16PGlj+DtiUzoPaf26m2avyPZaLRc9vlIdLtmovfRY5MqN+y/SEoBSRXrgVaeZGPgFQQ6w==", + "dependencies": { + "Microsoft.IdentityModel.JsonWebTokens": "6.34.0", + "Microsoft.IdentityModel.Tokens": "6.34.0" + } + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "TinyMapper.Signed": { + "type": "Transitive", + "resolved": "4.0.0", + "contentHash": "W5uc9QXp8PUgP3VQ1Qyt3vK8ptyjj38tJ7nEAtRKA6R/4e6+2gsgYrAmRg9fCK1hhe3E0yeAm5acC14qx2CINg==" + }, + "WireMock.Net.Abstractions": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "7ZmPVJxlSBj0E7d47PHLydz15w0TEJ6WH2m7T/hucT3QfxbhN07AV0mwBoyO/T6jv+Af+hgEgmlep3v9TACxPw==" + }, + "WireMock.Net.GraphQL": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "3nMUB7E8ner6fpdOZe91qx/1O3ebEOke1dCeCw+wIvnmtPhlfdOuss2saICqc1nrwLv2bAOVQ29606Ayinf7kw==", + "dependencies": { + "GraphQL.NewtonsoftJson": "8.5.0", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.Matchers.SystemTextJsonPath": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "uhuQW2mFvi2X3v5Om3nDYYaF/ApkDHsFB0A1pklyLJnBUp1iUp+6Imt3t0j1bpMggHKaXJXEhDCqoDxvGP4FIQ==", + "dependencies": { + "JsonPath.Net": "3.0.2", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.MimePart": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "bFJsUJ+zKOZC8FMO+8kyRHW51Qt5DkKRKS7//dzuBcuJVY2XdEnOdfaAlMfnN9T73XrHCi8skU+G1xdfWD2x3g==", + "dependencies": { + "Stef.Validation": "0.3.0", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.Minimal": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "7VIKKuEiAHa19x7rbyf1s90UymTVQVTXz1CZc9mUM7DdW1LdaYyHUqBK4mBycvzsxuam7+jkU7l+aMJGAb0kBA==", + "dependencies": { + "JmesPath.Net": "1.1.0", + "Microsoft.IdentityModel.Protocols.OpenIdConnect": "6.34.0", + "NJsonSchema.Extensions": "0.2.0", + "NSwag.Core": "13.16.1", + "Scriban.Signed": "7.2.5", + "SimMetrics.Net": "1.0.5", + "TinyMapper.Signed": "4.0.0", + "WireMock.Net.OpenApiParser": "2.13.0", + "WireMock.Net.Shared": "2.13.0", + "WireMock.Org.Abstractions": "2.13.0" + } + }, + "WireMock.Net.OpenApiParser": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "XQ2hgycULdhp1F16OqixwDwz2zaPtk3rpdurmL9MLZjr8TUWMCyHfStXeaU/vHu/of/xnYAh+qB0dwtdmGwY0Q==", + "dependencies": { + "Newtonsoft.Json": "13.0.4", + "RamlToOpenApiConverter.SourceOnly": "0.21.0", + "RandomDataGenerator.Net": "1.0.19.1", + "SharpYaml": "2.1.4", + "Stef.Validation": "0.3.0", + "WireMock.Net.Abstractions": "2.13.0", + "YamlDotNet": "18.1.0" + } + }, + "WireMock.Net.OpenTelemetry": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "3SjRQcAd1pPZXB7jtj7vx7cbWdkskQl030W2QJldIOf+P9VtvKPJ5SsNCKI43Eg5O7RyrYuy2AQVdik7lbUi/Q==", + "dependencies": { + "OpenTelemetry.Exporter.OpenTelemetryProtocol": "1.15.3", + "OpenTelemetry.Extensions.Hosting": "1.15.3", + "OpenTelemetry.Instrumentation.AspNetCore": "1.15.2", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.ProtoBuf": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "WRG6cujOXRe3ZiStkElH82lOlDhS3YiV/cpXQbnCLpTkf+F/RBmrivzqO7ILcTccVKdwiIbT+ANdCR0vVCXZMg==", + "dependencies": { + "ProtoBufJsonConverter": "0.11.0", + "WireMock.Net.Shared": "2.13.0" + } + }, + "WireMock.Net.Shared": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "vvA0ssOFv3IoQI5UL5dr3mtAgF5imyit999sWEJYtXp7lLeA/fGvmbYLEpG5CeQ1RRtEcIxnJkRdtcttYmIC4Q==", + "dependencies": { + "AnyOf": "0.5.0.1", + "Handlebars.Net.Helpers": "2.5.5", + "Handlebars.Net.Helpers.Humanizer": "2.5.5", + "Handlebars.Net.Helpers.Json": "2.5.5", + "Handlebars.Net.Helpers.Random": "2.5.5", + "Handlebars.Net.Helpers.XPath": "2.5.5", + "Handlebars.Net.Helpers.Xeger": "2.5.5", + "Handlebars.Net.Helpers.Xslt": "2.5.5", + "JsonConverter.Newtonsoft.Json": "0.13.0", + "JsonConverter.System.Text.Json": "0.13.0", + "Microsoft.AspNetCore.Http": "2.3.9", + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2", + "Stef.Validation": "0.3.0", + "WireMock.Net.Abstractions": "2.13.0" + } + }, + "WireMock.Org.Abstractions": { + "type": "Transitive", + "resolved": "2.13.0", + "contentHash": "1z7W3ryp+xufZ77ux3NODNMl/jw00Koagpj8aOaFQQgOQuxJMd8hICi1ErF1DsIXd8Ewp13s2HGntj4yWeRfRA==" + }, + "XPath2": { + "type": "Transitive", + "resolved": "1.1.5", + "contentHash": "LQg7kZyAmmb+qvv5TiOuuijxN97rRbR05qbMkVIH+i+sx9CA2UNUKGNtdVxWEXOabS8BIwlXm6ox1OOTjvZ6jw==" + }, + "XPath2.Extensions": { + "type": "Transitive", + "resolved": "1.1.5", + "contentHash": "oEbdGUJsF25QL3Vj1GgSlT2xdbxnka5dKcjuA9CouWCV/l9ecSfypOv78B1+YUD8a8w47prLNw2i3ofLNcrbGA==", + "dependencies": { + "Newtonsoft.Json": "13.0.3", + "XPath2": "1.1.5" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "YamlDotNet": { + "type": "Transitive", + "resolved": "18.1.0", + "contentHash": "5K+9KFg2TdTl7VXv88Qzi/0lqK6JFoNP3lRuImPYGRV7K/QYklDyTrj4+A+KAki1JsQi6qKY+hDyY7d6WRqjrw==" + }, + "dodossh.client.api": { + "type": "Project", + "dependencies": { + "DodoSSH.Client.Auth": "[1.0.0, )", + "DodoSSH.Contracts": "[1.0.0, )", + "DodoSSH.Crypto": "[1.0.0, )" + } + }, + "dodossh.client.auth": { + "type": "Project" + }, + "dodossh.contracts": { + "type": "Project" + }, + "dodossh.crypto": { + "type": "Project", + "dependencies": { + "NSec.Cryptography": "[26.4.0, )" + } + }, + "libsodium": { + "type": "CentralTransitive", + "requested": "[1.0.22, )", + "resolved": "1.0.22", + "contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ==" + }, + "NSec.Cryptography": { + "type": "CentralTransitive", + "requested": "[26.4.0, )", + "resolved": "26.4.0", + "contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==", + "dependencies": { + "libsodium": "[1.0.22, 1.0.23)" + } + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Crypto.Tests/GrantStatementCodecTests.cs b/tests/DodoSSH.Crypto.Tests/GrantStatementCodecTests.cs new file mode 100644 index 0000000..32561ec --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/GrantStatementCodecTests.cs @@ -0,0 +1,223 @@ +using NSec.Cryptography; + +namespace DodoSSH.Crypto.Tests; + +/// +/// The vault key grant tuple and its signature. See docs/crypto.md §7.3. +/// +/// +/// Sealing a vault key is anonymous-sender, so without this signature a server could fabricate a grant +/// containing a key of its own choosing and the recipient would unwrap it happily. Most of these tests +/// are therefore about a signature failing to transfer between contexts it should not. +/// +public sealed class GrantStatementCodecTests +{ + private static readonly Guid VaultA = Guid.Parse("0192f0c8-1111-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid VaultB = Guid.Parse("0192f0c8-2222-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid Alice = Guid.Parse("0192f0c8-3333-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly Guid Bob = Guid.Parse("0192f0c8-4444-7c3d-8e4f-5a6b7c8d9e0f"); + private static readonly DateTimeOffset GrantedAt = DateTimeOffset.FromUnixTimeMilliseconds(1_750_000_000_123); + + [Fact] + public void Encode_IsDeterministic() + { + Encode().ShouldBe(Encode()); + } + + [Fact] + public void Encode_BeginsWithTheDomainLabel() + { + var encoding = Encode(); + + encoding.AsSpan(0, GrantStatementCodec.Label.Length) + .SequenceEqual(GrantStatementCodec.Label) + .ShouldBeTrue(); + } + + [Theory] + [InlineData("vault")] + [InlineData("generation")] + [InlineData("kind")] + [InlineData("grantee")] + [InlineData("granteeFingerprint")] + [InlineData("wrappedKey")] + [InlineData("granter")] + [InlineData("granterFingerprint")] + [InlineData("keyLogHead")] + [InlineData("grantedAt")] + public void ChangingAnyField_ChangesTheEncoding(string field) + { + var baseline = Encode(); + + var altered = field switch + { + "vault" => Encode(vaultId: VaultB), + "generation" => Encode(keyGeneration: 2), + "kind" => Encode(kind: GrantPurpose.Recovery), + "grantee" => Encode(granteeUserId: Bob), + "granteeFingerprint" => Encode(granteeFingerprint: Fingerprint(0xB0)), + "wrappedKey" => Encode(wrappedKey: Bytes(80, 0x99)), + "granter" => Encode(granterUserId: Bob), + "granterFingerprint" => Encode(granterFingerprint: Fingerprint(0xC0)), + "keyLogHead" => Encode(keyLogHead: Fingerprint(0xD0)), + "grantedAt" => Encode(grantedAt: GrantedAt.AddMilliseconds(1)), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, "Unknown field."), + }; + + altered.ShouldNotBe(baseline); + } + + [Fact] + public void AbsentAndPresentKeyLogHeads_EncodeDifferently() + { + // The presence byte, without which a grant carrying no head and one carrying 32 zero bytes + // would be indistinguishable. + Encode(keyLogHead: default).ShouldNotBe(Encode(keyLogHead: new byte[32])); + } + + [Fact] + public void TheSignatureCoversOnlyTheWrappedKeysDigest() + { + // So a verifier can check who issued a grant without holding the vault key. The encoding is + // 32 bytes of digest regardless of how large the wrapped key is. + var small = Encode(wrappedKey: Bytes(48, 0x11)); + var large = Encode(wrappedKey: Bytes(4096, 0x11)); + + small.Length.ShouldBe(large.Length); + small.ShouldNotBe(large); + } + + [Fact] + public void AFreshSignature_Verifies() + { + using var key = CreateSigningKey(); + var grant = Encode(); + + var signature = GrantStatementCodec.Sign(key, grant); + + signature.Length.ShouldBe(CryptoSpec.SignatureSize); + GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeTrue(); + } + + [Fact] + public void ASignatureOverAnotherGrant_DoesNotVerify() + { + // The property that matters: a grant for one vault cannot be replayed onto another. + using var key = CreateSigningKey(); + + var signature = GrantStatementCodec.Sign(key, Encode(vaultId: VaultA)); + + GrantStatementCodec.Verify(PublicKeyBytes(key), Encode(vaultId: VaultB), signature) + .ShouldBeFalse(); + } + + [Fact] + public void ASignatureFromAnotherGranter_DoesNotVerify() + { + using var key = CreateSigningKey(); + using var other = CreateSigningKey(); + var grant = Encode(); + + var signature = GrantStatementCodec.Sign(other, grant); + + GrantStatementCodec.Verify(PublicKeyBytes(key), grant, signature).ShouldBeFalse(); + } + + [Fact] + public void AKeyStatementSignature_DoesNotVerifyAsAGrant() + { + // Context separation. Without it a signature produced in one role could be presented in + // another, which is the whole reason each context string exists. + using var key = CreateSigningKey(); + var grant = Encode(); + + var wrongContext = DshSignatures.SignKeyStatement(key, grant); + + GrantStatementCodec.Verify(PublicKeyBytes(key), grant, wrongContext).ShouldBeFalse(); + } + + [Theory] + [InlineData(0)] + [InlineData(31)] + [InlineData(33)] + public void AMalformedPublicKey_ReturnsFalseRatherThanThrowing(int length) + { + using var key = CreateSigningKey(); + var grant = Encode(); + var signature = GrantStatementCodec.Sign(key, grant); + + GrantStatementCodec.Verify(new byte[length], grant, signature).ShouldBeFalse(); + } + + [Fact] + public void Encode_RejectsAnUnspecifiedKind() + { + Should.Throw(() => Encode(kind: GrantPurpose.Unspecified)); + } + + [Fact] + public void Encode_RejectsAnEmptyWrappedKey() + { + Should.Throw(() => Encode(wrappedKey: [])); + } + + [Fact] + public void Encode_RejectsAWrongLengthFingerprint() + { + Should.Throw(() => Encode(granteeFingerprint: new byte[16])); + } + + [Fact] + public void Encode_RejectsAWrongLengthKeyLogHead() + { + Should.Throw(() => Encode(keyLogHead: new byte[16])); + } + + [Fact] + public void ThePurposeValues_MatchTheDomainEnum() + { + // Covered by the signature, so a renumbering would make every grant of the changed kind fail + // verification. Domain cannot be referenced from here, so the values are asserted literally + // against docs/crypto.md §7.3. + ((byte)GrantPurpose.Member).ShouldBe((byte)1); + ((byte)GrantPurpose.Recovery).ShouldBe((byte)2); + ((byte)GrantPurpose.Escrow).ShouldBe((byte)3); + } + + // ---- Helpers ---- + + private static byte[] Encode( + Guid? vaultId = null, + uint keyGeneration = 1, + GrantPurpose kind = GrantPurpose.Member, + Guid? granteeUserId = null, + byte[]? granteeFingerprint = null, + byte[]? wrappedKey = null, + Guid? granterUserId = null, + byte[]? granterFingerprint = null, + byte[]? keyLogHead = null, + DateTimeOffset? grantedAt = null) => + GrantStatementCodec.Encode( + vaultId ?? VaultA, + keyGeneration, + kind, + granteeUserId ?? Alice, + granteeFingerprint ?? Fingerprint(0x40), + wrappedKey ?? Bytes(80, 0x77), + granterUserId ?? Alice, + granterFingerprint ?? Fingerprint(0x60), + keyLogHead ?? [], + grantedAt ?? GrantedAt); + + private static byte[] Fingerprint(byte seed) => Bytes(CryptoSpec.DigestSize, seed); + + private static byte[] Bytes(int length, byte seed) => + [.. Enumerable.Range(0, length).Select(i => (byte)(seed + i))]; + + private static Key CreateSigningKey() => + Key.Create( + SignatureAlgorithm.Ed25519, + new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }); + + private static byte[] PublicKeyBytes(Key key) => key.PublicKey.Export(KeyBlobFormat.RawPublicKey); +}