diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..f3cc8ba --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5ab5b6d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,114 @@ +# EditorConfig for DodoSSH — https://editorconfig.org +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{json,yml,yaml,js,ts,css,html,axaml,xaml,csproj,props,targets,slnx}] +indent_size = 2 + +[*.md] +# Two trailing spaces are a hard line break in Markdown. +trim_trailing_whitespace = false + +[*.{cmd,bat,ps1}] +end_of_line = crlf + +[*.cs] +indent_size = 4 + +#### Language conventions #### + +csharp_style_namespace_declarations = file_scoped:error +csharp_using_directive_placement = outside_namespace:error +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion +csharp_prefer_braces = true:suggestion +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_expression_bodied_methods = when_on_single_line:suggestion +csharp_style_expression_bodied_properties = true:suggestion + +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_readonly_field = true:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion + +# Async methods must be suffixed Async (VSTHRD200 equivalent via naming rules below). +dotnet_naming_rule.async_methods_end_in_async.severity = warning +dotnet_naming_rule.async_methods_end_in_async.symbols = any_async_method +dotnet_naming_rule.async_methods_end_in_async.style = ends_with_async +dotnet_naming_symbols.any_async_method.applicable_kinds = method +dotnet_naming_symbols.any_async_method.required_modifiers = async +dotnet_naming_style.ends_with_async.required_suffix = Async +dotnet_naming_style.ends_with_async.capitalization = pascal_case + +dotnet_naming_rule.interfaces_start_with_i.severity = warning +dotnet_naming_rule.interfaces_start_with_i.symbols = any_interface +dotnet_naming_rule.interfaces_start_with_i.style = starts_with_i +dotnet_naming_symbols.any_interface.applicable_kinds = interface +dotnet_naming_style.starts_with_i.required_prefix = I +dotnet_naming_style.starts_with_i.capitalization = pascal_case + +dotnet_naming_rule.private_fields_are_camel_case.severity = warning +dotnet_naming_rule.private_fields_are_camel_case.symbols = private_field +dotnet_naming_rule.private_fields_are_camel_case.style = camel_case_style +dotnet_naming_symbols.private_field.applicable_kinds = field +dotnet_naming_symbols.private_field.applicable_accessibilities = private +dotnet_naming_style.camel_case_style.capitalization = camel_case + +#### Diagnostics #### + +# Formatting violations fail the build; `dotnet format --verify-no-changes` gates CI. +dotnet_diagnostic.IDE0055.severity = error + +# ConfigureAwait is not meaningful in ASP.NET Core (no SynchronizationContext). It IS +# meaningful in the Avalonia client, which re-enables CA2007 in its own .editorconfig. +dotnet_diagnostic.CA2007.severity = none + +# Prefer LoggerMessage source generation over ILogger extension calls — allocation-free +# and gives structured events by construction. Warning, so it is visible but not a wall +# during early development; raised to error once the logging pass lands in M4. +dotnet_diagnostic.CA1848.severity = warning + +# Exceptions carry ProblemDetails codes, not localised text. +dotnet_diagnostic.CA1303.severity = none + +# CA1711 reserves the suffixes Flags, Permission, Collection, Stream and friends for +# .NET Framework CAS and BCL base types that have no bearing on this codebase. The BCL +# itself ships BindingFlags. PermissionFlags is the clearest name for a [Flags] enum of +# permissions, and contorting domain vocabulary to satisfy a legacy rule costs more than +# it returns. +dotnet_diagnostic.CA1711.severity = none + +# CA1724 flags any type whose name collides with a BCL *namespace* (e.g. a type named +# Permissions vs System.Security.Permissions). Namespace-qualified resolution makes this +# a non-issue in practice and it heavily constrains domain naming. +dotnet_diagnostic.CA1724.severity = none + +# We use file-scoped namespaces and modern C#; these fire on deliberate style choices. +dotnet_diagnostic.CA1812.severity = none # internal types instantiated by DI +dotnet_diagnostic.CA1849.severity = warning # sync call in async method + +[tests/**/*.cs] +# Test classes are instantiated by xunit, and test data is often public static. +dotnet_diagnostic.CA1812.severity = none +dotnet_diagnostic.CA1034.severity = none + +[src/DodoSSH.Infrastructure/Migrations/*.cs] +# EF Core generates these; do not lint or format them. +generated_code = true +dotnet_analyzer_diagnostic.severity = none +dotnet_diagnostic.IDE0055.severity = none diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fa4fd9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Actions are pinned to commit SHAs, not tags: a tag can be moved to point at new code, +# which would let a compromised action run with this workflow's permissions. +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + CI: true + +jobs: + build: + name: build and test (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + # Locked mode fails if packages.lock.json does not match the project files, so a + # dependency cannot change without the lock file change being reviewed. + - name: restore + run: dotnet restore DodoSSH.slnx --locked-mode + + - name: verify formatting + run: dotnet format DodoSSH.slnx --verify-no-changes --no-restore + + - name: build + run: dotnet build DodoSSH.slnx --no-restore --configuration Release + + - name: test + run: dotnet test DodoSSH.slnx --no-build --configuration Release + + # Integration tests land in M1 and need Docker for Testcontainers (PostgreSQL, + # OpenSSH). They run on this ubuntu job because macOS runners have no Docker daemon. + + build-windows: + name: build (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + global-json-file: global.json + cache: true + cache-dependency-path: '**/packages.lock.json' + + - name: restore + run: dotnet restore DodoSSH.slnx --locked-mode + + # Build only. Day-to-day development happens in Rider on Windows, so a + # Windows-specific compile break must fail CI even though the tests run on Linux. + - name: build + run: dotnet build DodoSSH.slnx --no-restore --configuration Release diff --git a/.gitignore b/.gitignore index e6127b9..5a3161f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,10 @@ [Oo]ut/ [Ll]og/ [Ll]ogs/ -artifacts/ +# artifacts/* rather than artifacts/ — git does not descend into an excluded directory, +# so a negation under a directory-level ignore never matches. The committed OpenAPI +# document and schema snapshots live here and are CI-diffed. +artifacts/* !artifacts/openapi/ !artifacts/schema/ diff --git a/BannedSymbols.txt b/BannedSymbols.txt new file mode 100644 index 0000000..b842337 --- /dev/null +++ b/BannedSymbols.txt @@ -0,0 +1,42 @@ +# Banned APIs, enforced by Microsoft.CodeAnalysis.BannedApiAnalyzers (RS0030). +# Format: ; +# See docs/adr/ for the reasoning behind each group. + +## Time — everything in DodoSSH is UTC and must be fakeable in tests. +P:System.DateTime.Now;Use TimeProvider.GetUtcNow(). All DodoSSH timestamps are UTC (timestamptz) and must be injectable for tests. +P:System.DateTime.UtcNow;Use TimeProvider.GetUtcNow() so time can be faked in tests. +P:System.DateTime.Today;Use TimeProvider.GetUtcNow().Date. +P:System.DateTimeOffset.Now;Use TimeProvider.GetUtcNow(). +P:System.DateTimeOffset.UtcNow;Use TimeProvider.GetUtcNow() so time can be faked in tests. + +## Identifiers — UUIDv7 gives sortable PKs with good index locality, and clients +## must be able to mint ids offline. +M:System.Guid.NewGuid;Use Guid.CreateVersion7() for sortable primary keys. + +## Randomness — anything key-, token- or nonce-adjacent must be cryptographic. +T:System.Random;Use RandomNumberGenerator for anything security-relevant, or inject a seeded generator for tests. + +## Sync-over-async — deadlocks under ASP.NET and stalls the Avalonia UI thread. +P:System.Threading.Tasks.Task`1.Result;Await the task instead; .Result deadlocks and hides exceptions in an AggregateException. +M:System.Threading.Tasks.Task.Wait;Await the task instead. +M:System.Threading.Tasks.Task.WaitAll;Use Task.WhenAll with await. +M:System.Threading.Tasks.Task.WaitAny;Use Task.WhenAny with await. +M:System.Threading.Tasks.Task.GetAwaiter;Await the task directly rather than blocking on the awaiter. + +## Encoding — must be explicit, never the ambient codepage. +P:System.Text.Encoding.Default;Specify the encoding explicitly; Encoding.Default varies by platform. + +## Culture-sensitive string handling is already covered by CA1304/CA1307/CA1311, +## which AnalysisLevel=latest-All turns on. Not duplicated here. + +## Cryptography — the client holds key material in libsodium guarded memory, and +## MD5/SHA1 have no place in this product. Fingerprints are SHA-256. +T:System.Security.Cryptography.MD5;Banned. SSH fingerprints are SHA-256; see docs/crypto.md. +T:System.Security.Cryptography.SHA1;Banned. Use SHA-256 or better. +T:System.Security.Cryptography.Rfc2898DeriveBytes;PBKDF2 is not our KDF. Use Argon2id via DodoSSH.Crypto; see docs/crypto.md. +T:System.Security.SecureString;Deprecated and not cross-platform. Use a pooled byte[] zeroed with CryptographicOperations.ZeroMemory. + +# Note: constant-time comparison of secrets (CryptographicOperations.FixedTimeEquals +# over Enumerable.SequenceEqual) is enforced by a BannedSymbols.txt scoped to +# DodoSSH.Crypto, not globally — banning SequenceEqual everywhere is pure noise in +# business logic and tests, and noisy bans just train people to suppress them. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..9e6faf9 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,51 @@ + + + + net10.0 + latest + enable + enable + + + + true + true + + latest-Recommended + Recommended + + true + + + + true + true + true + true + + true + + + + DodoTech + DodoSSH + en + + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..afd11b7 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,52 @@ + + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DodoSSH.slnx b/DodoSSH.slnx index 296747e..88b63ab 100644 --- a/DodoSSH.slnx +++ b/DodoSSH.slnx @@ -1,3 +1,27 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DodoSSH/DodoSSH.csproj b/DodoSSH/DodoSSH.csproj deleted file mode 100644 index 78bc66b..0000000 --- a/DodoSSH/DodoSSH.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - net10.0 - enable - enable - - - - - - - diff --git a/DodoSSH/DodoSSH.http b/DodoSSH/DodoSSH.http deleted file mode 100644 index b5aeecc..0000000 --- a/DodoSSH/DodoSSH.http +++ /dev/null @@ -1,6 +0,0 @@ -@DodoSSH_HostAddress = http://localhost:5233 - -GET {{DodoSSH_HostAddress}}/weatherforecast/ -Accept: application/json - -### diff --git a/DodoSSH/Program.cs b/DodoSSH/Program.cs deleted file mode 100644 index d5e0ef3..0000000 --- a/DodoSSH/Program.cs +++ /dev/null @@ -1,41 +0,0 @@ -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container. -// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi -builder.Services.AddOpenApi(); - -var app = builder.Build(); - -// Configure the HTTP request pipeline. -if (app.Environment.IsDevelopment()) -{ - app.MapOpenApi(); -} - -app.UseHttpsRedirection(); - -var summaries = new[] -{ - "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" -}; - -app.MapGet("/weatherforecast", () => - { - var forecast = Enumerable.Range(1, 5).Select(index => - new WeatherForecast - ( - DateOnly.FromDateTime(DateTime.Now.AddDays(index)), - Random.Shared.Next(-20, 55), - summaries[Random.Shared.Next(summaries.Length)] - )) - .ToArray(); - return forecast; - }) - .WithName("GetWeatherForecast"); - -app.Run(); - -record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) -{ - public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); -} \ No newline at end of file diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..49b1c30 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Api/DodoSSH.Api.csproj b/src/DodoSSH.Api/DodoSSH.Api.csproj new file mode 100644 index 0000000..f82e071 --- /dev/null +++ b/src/DodoSSH.Api/DodoSSH.Api.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs new file mode 100644 index 0000000..08f26b0 --- /dev/null +++ b/src/DodoSSH.Api/Program.cs @@ -0,0 +1,26 @@ +using DodoSSH.Api.Setup; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDodoOpenApi(); +builder.Services.AddDodoHealthChecks(); + +// DateTime.UtcNow is banned repo-wide (see BannedSymbols.txt); everything takes +// TimeProvider so time can be faked in tests. +builder.Services.AddSingleton(TimeProvider.System); + +var app = builder.Build(); + +// Deliberately no UseHttpsRedirection: the API is always fronted by a reverse proxy +// (Caddy in the reference compose stack) which terminates TLS. Redirecting here +// produces redirect loops behind a proxy. HTTPS in development comes from the +// launch profile instead. + +app.MapDodoHealthChecks(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +await app.RunAsync().ConfigureAwait(false); diff --git a/DodoSSH/Properties/launchSettings.json b/src/DodoSSH.Api/Properties/launchSettings.json similarity index 100% rename from DodoSSH/Properties/launchSettings.json rename to src/DodoSSH.Api/Properties/launchSettings.json diff --git a/src/DodoSSH.Api/Setup/HealthChecks.cs b/src/DodoSSH.Api/Setup/HealthChecks.cs new file mode 100644 index 0000000..040a54e --- /dev/null +++ b/src/DodoSSH.Api/Setup/HealthChecks.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace DodoSSH.Api.Setup; + +/// +/// Health check wiring. +/// +/// +/// The split between liveness and readiness is deliberate and load-bearing for the +/// relay: /healthz/live checks the process and nothing else, so a transient +/// PostgreSQL outage cannot cause the orchestrator to restart the container and +/// guillotine every live SSH session. Dependency checks belong in +/// /healthz/ready, which only removes the instance from load balancing. +/// +internal static class HealthChecks +{ + /// Tag for checks that gate readiness (dependencies). + internal const string ReadyTag = "ready"; + + /// Tag for checks that gate startup completion. + internal const string StartupTag = "startup"; + + internal static IServiceCollection AddDodoHealthChecks(this IServiceCollection services) + { + services.AddHealthChecks(); + + // Dependency checks are registered by the milestone that introduces the + // dependency, each tagged ReadyTag: + // M1 — PostgreSQL, OIDC discovery + JWKS reachability, pending migrations + // M4 — Data Protection key ring readability + return services; + } + + internal static WebApplication MapDodoHealthChecks(this WebApplication app) + { + // Liveness: process is running and the pipeline responds. No dependencies. + app.MapHealthChecks("/healthz/live", new HealthCheckOptions + { + Predicate = _ => false, + }).AllowAnonymous(); + + // Readiness: safe to route traffic here. + app.MapHealthChecks("/healthz/ready", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains(ReadyTag), + }).AllowAnonymous(); + + // Startup: one-time initialisation finished (K8s startupProbe). + app.MapHealthChecks("/healthz/startup", new HealthCheckOptions + { + Predicate = registration => registration.Tags.Contains(StartupTag), + }).AllowAnonymous(); + + return app; + } +} diff --git a/src/DodoSSH.Api/Setup/OpenApi.cs b/src/DodoSSH.Api/Setup/OpenApi.cs new file mode 100644 index 0000000..70d7053 --- /dev/null +++ b/src/DodoSSH.Api/Setup/OpenApi.cs @@ -0,0 +1,27 @@ +namespace DodoSSH.Api.Setup; + +/// +/// OpenAPI document configuration. +/// +/// +/// The generated document exists for third parties and a future CLI. It is emitted at +/// build time to artifacts/openapi/v1.json and diffed in CI so an unintended +/// contract change fails the pull request. The desktop client's actual contract is the +/// DodoSSH.Contracts assembly, guarded by PublicApiAnalyzers. +/// +internal static class OpenApi +{ + internal const string DocumentName = "v1"; + + internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services) + { + services.AddOpenApi(DocumentName); + + // Added in M1, once there are endpoints to describe: + // - a document transformer contributing the OAuth2 authorizationCode + PKCE + // security scheme, so the document is usable from a generated client + // - a schema transformer mapping byte[] to {type: string, format: byte}, + // since every ciphertext field crosses the wire as base64 + return services; + } +} diff --git a/DodoSSH/appsettings.Development.json b/src/DodoSSH.Api/appsettings.Development.json similarity index 100% rename from DodoSSH/appsettings.Development.json rename to src/DodoSSH.Api/appsettings.Development.json diff --git a/DodoSSH/appsettings.json b/src/DodoSSH.Api/appsettings.json similarity index 100% rename from DodoSSH/appsettings.json rename to src/DodoSSH.Api/appsettings.json diff --git a/src/DodoSSH.Api/packages.lock.json b/src/DodoSSH.Api/packages.lock.json new file mode 100644 index 0000000..643f1f7 --- /dev/null +++ b/src/DodoSSH.Api/packages.lock.json @@ -0,0 +1,34 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.AspNetCore.OpenApi": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "d4Atx9IHq7JgX0F/h7Db+m9zAUzC+cKdI9k+OWnnyQIOUQtfvjIEuhvbjPigVMkAmPUgCbJ8Yp6M9ghUqHtJSQ==", + "dependencies": { + "Microsoft.OpenApi": "2.0.0" + } + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "Microsoft.OpenApi": { + "type": "CentralTransitive", + "requested": "[2.11.0, )", + "resolved": "2.11.0", + "contentHash": "/ignjfdeKT2SGLIR7QEv19KnI0rvoxRG/TYDOZdK9EsWLjKK9IK8i1Mo5NRm9PRV3i64DzlTqnIflWvoyfljLg==" + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Contracts/DodoSSH.Contracts.csproj b/src/DodoSSH.Contracts/DodoSSH.Contracts.csproj new file mode 100644 index 0000000..a530d67 --- /dev/null +++ b/src/DodoSSH.Contracts/DodoSSH.Contracts.csproj @@ -0,0 +1,28 @@ + + + + + + true + true + true + + + + + + + + + + + + diff --git a/src/DodoSSH.Contracts/ProblemCodes.cs b/src/DodoSSH.Contracts/ProblemCodes.cs new file mode 100644 index 0000000..35d580f --- /dev/null +++ b/src/DodoSSH.Contracts/ProblemCodes.cs @@ -0,0 +1,48 @@ +namespace DodoSSH.Contracts; + +/// +/// Stable machine-readable error codes returned in the code extension of an +/// RFC 9457 ProblemDetails response. +/// +/// +/// These live in Contracts so the client switches on constants rather than parsing prose. +/// The values are part of the public contract: add freely, never rename or repurpose. +/// +public static class ProblemCodes +{ + /// The base URI that every problem type is formed under. + public const string TypeBaseUri = "https://dodossh.dev/problems/"; + + /// A push operation's expectedVersion did not match the stored row. + public const string VaultConflict = "vault-conflict"; + + /// The caller is authenticated but lacks the required permission. + public const string Forbidden = "forbidden"; + + /// The sync cursor was malformed, or failed its integrity tag. + public const string InvalidCursor = "invalid-cursor"; + + /// An Idempotency-Key was reused with a different request body. + public const string IdempotencyKeyReuse = "idempotency-key-reuse"; + + /// The caller has not yet enrolled a public key, so no vault is reachable. + public const string EnrollmentRequired = "enrollment-required"; + + /// Enrollment was attempted for a user who already holds a current key. + public const string AlreadyEnrolled = "already-enrolled"; + + /// The relay refused the requested target. Never states why, to avoid a probe oracle. + public const string RelayTargetRejected = "relay-target-rejected"; + + /// The relay ticket is expired, already used, or not valid for this node. + public const string RelayTicketInvalid = "relay-ticket-invalid"; + + /// A per-user or per-node relay session limit was reached. + public const string RelayLimitReached = "relay-limit-reached"; + + /// The client is older than the server's minClientVersion. + public const string ClientTooOld = "client-too-old"; + + /// A push batch exceeded the operation count or payload size cap. + public const string PushBatchTooLarge = "push-batch-too-large"; +} diff --git a/src/DodoSSH.Contracts/PublicAPI.Shipped.txt b/src/DodoSSH.Contracts/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/DodoSSH.Contracts/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..ad0d54b --- /dev/null +++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt @@ -0,0 +1,14 @@ +#nullable enable +DodoSSH.Contracts.ProblemCodes +const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string! +const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string! +const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string! +const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string! +const DodoSSH.Contracts.ProblemCodes.IdempotencyKeyReuse = "idempotency-key-reuse" -> string! +const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string! +const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string! +const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string! +const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string! +const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string! +const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string! +const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string! diff --git a/src/DodoSSH.Contracts/packages.lock.json b/src/DodoSSH.Contracts/packages.lock.json new file mode 100644 index 0000000..eaea3bb --- /dev/null +++ b/src/DodoSSH.Contracts/packages.lock.json @@ -0,0 +1,31 @@ +{ + "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==" + }, + "Microsoft.CodeAnalysis.PublicApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "W4kJGezNIKLzo0Ak5FAQDFvkMf2U7DtGL4THmHyRSApfKsKt5V+eX/bU0ZLKAt/uf9Bb2o1bi0YDKj/GRB/vYQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Crypto/CryptoSpec.cs b/src/DodoSSH.Crypto/CryptoSpec.cs new file mode 100644 index 0000000..3158016 --- /dev/null +++ b/src/DodoSSH.Crypto/CryptoSpec.cs @@ -0,0 +1,47 @@ +namespace DodoSSH.Crypto; + +/// +/// Constants of the DodoSSH cryptographic specification. +/// +/// +/// docs/crypto.md is the normative specification; this type must agree with it exactly. +/// The implementation of the envelope, AAD derivation and key wrapping lands in M1, once +/// the specification and its test vectors are frozen. Nothing else may be built on top of +/// an unfrozen AAD: only clients can re-encrypt, so a change after users hold data cannot +/// be migrated server-side. +/// +public static class CryptoSpec +{ + /// Magic prefix identifying a DSH1 envelope. + public const string EnvelopeMagic = "DSH1"; + + /// Version of the AAD derivation rule that payloads are bound to. + /// + /// Stored per row as payload_aad_version so a future change can be applied + /// lazily, re-encrypting on next write rather than in a migration. + /// + public const short CurrentAadVersion = 1; + + /// Domain-separation prefix for every AAD computation. + public const string AadDomainPrefix = "dsh1\n"; + + /// Identifiers for the algorithms an envelope may declare. + public enum AlgorithmId : byte + { + /// Reserved; never written. + Unspecified = 0, + + /// Symmetric content encryption under a known key. + XChaCha20Poly1305 = 1, + + /// Symmetric fallback where XChaCha20 is unavailable. + Aes256Gcm = 2, + + /// Anonymous-sender seal to an X25519 public key. + SealToX25519 = 3, + + // 4 is reserved for a hybrid X25519 + ML-KEM-768 seal. Store-now-decrypt-later is + // a genuine threat for long-lived SSH keys, so the identifier is claimed now even + // though the construction ships later. + } +} diff --git a/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj b/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj new file mode 100644 index 0000000..6907b9c --- /dev/null +++ b/src/DodoSSH.Crypto/DodoSSH.Crypto.csproj @@ -0,0 +1,19 @@ + + + + + + true + true + + + + + + + diff --git a/src/DodoSSH.Crypto/packages.lock.json b/src/DodoSSH.Crypto/packages.lock.json new file mode 100644 index 0000000..fd59966 --- /dev/null +++ b/src/DodoSSH.Crypto/packages.lock.json @@ -0,0 +1,25 @@ +{ + "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==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Domain/Authorization/PermissionFlags.cs b/src/DodoSSH.Domain/Authorization/PermissionFlags.cs new file mode 100644 index 0000000..d6e1bcf --- /dev/null +++ b/src/DodoSSH.Domain/Authorization/PermissionFlags.cs @@ -0,0 +1,41 @@ +namespace DodoSSH.Domain.Authorization; + +/// +/// PermissionFlags a subject (user or team) may hold over a vault or an individual resource. +/// +/// +/// +/// Evaluation is a plain union across the subject's direct grants and the grants held by +/// teams they belong to. There are deliberately no Deny rules: union-only evaluation is +/// monotonic and straightforward to test, and Deny can be added later additively if a +/// real need appears. Express restriction by granting narrowly instead. +/// +/// +/// is a user-interface hint, not a security boundary. SSH +/// terminates on the client, so opening a session requires the credential's plaintext on +/// that machine; "may connect but may not view the key" is therefore unenforceable in +/// this architecture. Treat it as an anti-shoulder-surfing convenience and never document +/// it as access control. See docs/adr/0001-e2ee-trust-model.md. +/// +/// +[Flags] +public enum PermissionFlags +{ + /// No access. + None = 0, + + /// May fetch and decrypt the resource. Implies the ability to use it. + Read = 1 << 0, + + /// May create, modify and soft-delete resources in the vault. + Write = 1 << 1, + + /// Intent hint that the subject uses this host for sessions. Not a boundary. + Connect = 1 << 2, + + /// May grant access to other subjects, which requires re-wrapping the vault key. + Share = 1 << 3, + + /// May administer the vault itself: rename, rekey, manage ACLs. + Admin = 1 << 4, +} diff --git a/src/DodoSSH.Domain/DodoSSH.Domain.csproj b/src/DodoSSH.Domain/DodoSSH.Domain.csproj new file mode 100644 index 0000000..fe98849 --- /dev/null +++ b/src/DodoSSH.Domain/DodoSSH.Domain.csproj @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/src/DodoSSH.Domain/packages.lock.json b/src/DodoSSH.Domain/packages.lock.json new file mode 100644 index 0000000..722652b --- /dev/null +++ b/src/DodoSSH.Domain/packages.lock.json @@ -0,0 +1,19 @@ +{ + "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==" + } + } + } +} \ No newline at end of file diff --git a/src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj b/src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj new file mode 100644 index 0000000..411994e --- /dev/null +++ b/src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + diff --git a/src/DodoSSH.Infrastructure/packages.lock.json b/src/DodoSSH.Infrastructure/packages.lock.json new file mode 100644 index 0000000..7698d81 --- /dev/null +++ b/src/DodoSSH.Infrastructure/packages.lock.json @@ -0,0 +1,22 @@ +{ + "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.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..daeb3f8 --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,37 @@ + + + + + + + false + true + + false + + Exe + true + true + + + + + $(NoWarn);CA1707;CA2007;CA1861;CA1052;CA1515 + + + + + + + + + + + + + + diff --git a/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj b/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj new file mode 100644 index 0000000..1f65125 --- /dev/null +++ b/tests/DodoSSH.Contracts.Tests/DodoSSH.Contracts.Tests.csproj @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Contracts.Tests/ProblemCodesTests.cs b/tests/DodoSSH.Contracts.Tests/ProblemCodesTests.cs new file mode 100644 index 0000000..c4c76b2 --- /dev/null +++ b/tests/DodoSSH.Contracts.Tests/ProblemCodesTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using DodoSSH.Contracts; + +namespace DodoSSH.Contracts.Tests; + +/// +/// Problem codes are part of the public wire contract: the client switches on them. +/// +public sealed class ProblemCodesTests +{ + private static IReadOnlyList CodeFields => + [.. typeof(ProblemCodes) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Where(f => !string.Equals(f.Name, nameof(ProblemCodes.TypeBaseUri), StringComparison.Ordinal))]; + + [Fact] + public void AllCodes_AreUnique() + { + // A duplicated value would make two distinct failures indistinguishable to the + // client, which is the whole point of having codes rather than parsing messages. + var values = CodeFields.Select(f => (string)f.GetRawConstantValue()!).ToList(); + + values.Distinct(StringComparer.Ordinal).Count().ShouldBe(values.Count); + } + + [Fact] + public void AllCodes_AreKebabCase() + { + // Codes are appended to TypeBaseUri to form the ProblemDetails type URI, so they + // must be URL-safe and stylistically consistent. + foreach (var field in CodeFields) + { + var value = (string)field.GetRawConstantValue()!; + + value.ShouldMatch("^[a-z][a-z0-9]*(-[a-z0-9]+)*$", $"{field.Name} is not kebab-case"); + } + } + + [Fact] + public void TypeBaseUri_IsAnAbsoluteHttpsUriEndingInSlash() + { + Uri.TryCreate(ProblemCodes.TypeBaseUri, UriKind.Absolute, out var uri).ShouldBeTrue(); + uri!.Scheme.ShouldBe(Uri.UriSchemeHttps); + ProblemCodes.TypeBaseUri.ShouldEndWith("/"); + } +} diff --git a/tests/DodoSSH.Contracts.Tests/packages.lock.json b/tests/DodoSSH.Contracts.Tests/packages.lock.json new file mode 100644 index 0000000..7cac625 --- /dev/null +++ b/tests/DodoSSH.Contracts.Tests/packages.lock.json @@ -0,0 +1,202 @@ +{ + "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" + } + }, + "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]" + } + }, + "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==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "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==" + }, + "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.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "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]" + } + }, + "dodossh.contracts": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs new file mode 100644 index 0000000..d4849f7 --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/CryptoSpecTests.cs @@ -0,0 +1,45 @@ +using DodoSSH.Crypto; + +namespace DodoSSH.Crypto.Tests; + +/// +/// Pins the specification constants that are written into stored data. +/// +/// +/// These are not busywork. The envelope magic and AAD version are persisted in every +/// ciphertext row, and only clients can re-encrypt: if one of these changes without a +/// deliberate migration path, existing vaults stop decrypting and the server cannot help. +/// +public sealed class CryptoSpecTests +{ + [Fact] + public void EnvelopeMagic_IsStable() + { + CryptoSpec.EnvelopeMagic.ShouldBe("DSH1"); + } + + [Fact] + public void CurrentAadVersion_IsStable() + { + // Bumping this requires a lazy re-encrypt-on-write path in the client first. + CryptoSpec.CurrentAadVersion.ShouldBe((short)1); + } + + [Theory] + [InlineData(CryptoSpec.AlgorithmId.XChaCha20Poly1305, 1)] + [InlineData(CryptoSpec.AlgorithmId.Aes256Gcm, 2)] + [InlineData(CryptoSpec.AlgorithmId.SealToX25519, 3)] + public void AlgorithmId_HasStableWireValue(CryptoSpec.AlgorithmId algorithm, int expected) + { + ((int)algorithm).ShouldBe(expected); + } + + [Fact] + public void AlgorithmId_4_IsReservedForHybridPostQuantumSeal() + { + // Reserved for X25519 + ML-KEM-768. Claimed now so the identifier cannot be + // reused: store-now-decrypt-later is a real threat for long-lived SSH keys. + // AlgorithmId is byte-backed, matching the single alg_id byte in the envelope. + Enum.IsDefined(typeof(CryptoSpec.AlgorithmId), (byte)4).ShouldBeFalse(); + } +} diff --git a/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj b/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj new file mode 100644 index 0000000..3599401 --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/DodoSSH.Crypto.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Crypto.Tests/packages.lock.json b/tests/DodoSSH.Crypto.Tests/packages.lock.json new file mode 100644 index 0000000..5ef1fad --- /dev/null +++ b/tests/DodoSSH.Crypto.Tests/packages.lock.json @@ -0,0 +1,202 @@ +{ + "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" + } + }, + "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]" + } + }, + "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==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "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==" + }, + "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.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "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]" + } + }, + "dodossh.crypto": { + "type": "Project" + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Domain.Tests/Authorization/PermissionFlagsTests.cs b/tests/DodoSSH.Domain.Tests/Authorization/PermissionFlagsTests.cs new file mode 100644 index 0000000..3bf96ac --- /dev/null +++ b/tests/DodoSSH.Domain.Tests/Authorization/PermissionFlagsTests.cs @@ -0,0 +1,51 @@ +using DodoSSH.Domain.Authorization; + +namespace DodoSSH.Domain.Tests.Authorization; + +/// +/// Permission algebra. This grows into the full effective-permission suite in M3; for now +/// it pins the flag values, because they are persisted as an integer column and changing +/// one silently reinterprets every stored ACL row. +/// +public sealed class PermissionFlagsTests +{ + [Theory] + [InlineData(PermissionFlags.None, 0)] + [InlineData(PermissionFlags.Read, 1)] + [InlineData(PermissionFlags.Write, 2)] + [InlineData(PermissionFlags.Connect, 4)] + [InlineData(PermissionFlags.Share, 8)] + [InlineData(PermissionFlags.Admin, 16)] + public void Flag_HasStableWireValue(PermissionFlags flag, int expected) + { + // These values are persisted; a change reinterprets existing ACL rows. + ((int)flag).ShouldBe(expected); + } + + [Fact] + public void Union_CombinesGrantsFromMultipleSubjects() + { + // Effective permissions are a union across direct and team-derived grants. + var direct = PermissionFlags.Read; + var viaTeam = PermissionFlags.Write | PermissionFlags.Connect; + + var effective = direct | viaTeam; + + effective.HasFlag(PermissionFlags.Read).ShouldBeTrue(); + effective.HasFlag(PermissionFlags.Write).ShouldBeTrue(); + effective.HasFlag(PermissionFlags.Connect).ShouldBeTrue(); + effective.HasFlag(PermissionFlags.Admin).ShouldBeFalse(); + effective.HasFlag(PermissionFlags.Share).ShouldBeFalse(); + } + + [Fact] + public void Union_IsMonotonic() + { + // The deliberate consequence of having no Deny rules: adding a grant can only + // ever widen access, never narrow it. M3's evaluator relies on this. + var before = PermissionFlags.Read; + var after = before | PermissionFlags.Admin; + + (after & before).ShouldBe(before); + } +} diff --git a/tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj b/tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj new file mode 100644 index 0000000..4512fea --- /dev/null +++ b/tests/DodoSSH.Domain.Tests/DodoSSH.Domain.Tests.csproj @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tests/DodoSSH.Domain.Tests/packages.lock.json b/tests/DodoSSH.Domain.Tests/packages.lock.json new file mode 100644 index 0000000..b95395b --- /dev/null +++ b/tests/DodoSSH.Domain.Tests/packages.lock.json @@ -0,0 +1,202 @@ +{ + "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" + } + }, + "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]" + } + }, + "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==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "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==" + }, + "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.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "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]" + } + }, + "dodossh.domain": { + "type": "Project" + } + } + } +} \ No newline at end of file