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