Am.Keyward.AspNetCore 0.14.3-preview

This is a prerelease version of Am.Keyward.AspNetCore.
dotnet add package Am.Keyward.AspNetCore --version 0.14.3-preview
                    
NuGet\Install-Package Am.Keyward.AspNetCore -Version 0.14.3-preview
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Am.Keyward.AspNetCore" Version="0.14.3-preview" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Am.Keyward.AspNetCore" Version="0.14.3-preview" />
                    
Directory.Packages.props
<PackageReference Include="Am.Keyward.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Am.Keyward.AspNetCore --version 0.14.3-preview
                    
#r "nuget: Am.Keyward.AspNetCore, 0.14.3-preview"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Am.Keyward.AspNetCore@0.14.3-preview
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Am.Keyward.AspNetCore&version=0.14.3-preview&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Am.Keyward.AspNetCore&version=0.14.3-preview&prerelease
                    
Install as a Cake Tool

AM KEYWARD

⚠️ Pre-1.0 — no external security review yet. AM KEYWARD is usable and the design is documented in full below, but no independent party has audited the crypto, the tenant isolation or the audit chain. You can run it with real secrets; do so knowing that assurance rests on this project's own testing, and weigh that against what those secrets protect. See SECURITY.md.

AM KEYWARD is an open-source, .NET-native, library-first credential & secrets manager: a building block you embed in your own .NET environment (and can offer to your own users), plus an optional standalone reference app.

It covers two halves equally:

  • Software credentials — machine/integration secrets (API keys, connection strings), scoped per project & environment, fetched by your software via an API. Each value can carry a rotation date and a note on how a new one is obtained; the date is advisory (reads keep working) and raises advance notices.
  • Human vaults — personal & team password vaults, shared to groups or individuals.

What it is / is not

  • Is: an embeddable toolkit + reference app that you self-host and operate.
  • Is not: a hosted service, and not a HashiCorp-Vault-at-scale replacement.

There is no central, vendor-hosted AM KEYWARD. Each operator runs and secures their own deployment (key custody, database, hardening) — see SECURITY.md.

How the human-vault half differs from Bitwarden / KeePass

AM KEYWARD is not trying to out-feature a dedicated password manager. Its human vaults exist so that one system covers both halves of an organization's secrets — the machine credentials your software reads at runtime and the passwords your people share — under one data model, one login, one admin, one tamper-evident audit log, with the same envelope encryption and the same operator-owned KEK.

  • vs KeePass (a local encrypted file): AM KEYWARD is server-side and multi-user with central authorization grants, tenancy isolation, versioning and audit — not a file you sync by hand.
  • vs Bitwarden (an excellent hosted/self-hosted password manager): AM KEYWARD is embeddable in your own .NET app and unifies software credentials with human vaults; it is not a hosted service and does not (yet) ship browser-extension autofill or zero-knowledge vaults (zero-knowledge is the v0.2 goal, gated on an external review). If you only need a password manager for people, use Bitwarden.

Threat model (summary)

What AM KEYWARD is designed to resist, and what it explicitly does not:

In scope / mitigated

  • Database compromise alone — a stolen DB backup yields only ciphertext; the KEK lives outside the database, so without the KEK store the envelopes cannot be decrypted.
  • Cross-tenant / cross-user leakage — defense-in-depth: a composite application query filter, a server-authoritative active tenant, and SQL Server row-level security via SESSION_CONTEXT. The RLS layer holds whatever login the runtime uses — a security policy's filter predicates apply to every principal, db_owner included, unless the predicate exempts them, and ours do not. A least-privilege runtime login is therefore optional hardening (it withholds the right to disable the policy), not what makes the isolation work. Exercised by an adversarial isolation test gate.
  • Ciphertext replay across slots — AEAD AAD binds every ciphertext to its exact tenant / owner / project / environment / item / version, so a Dev/old ciphertext cannot be moved into a Prod/current row.
  • Audit tampering — a per-tenant hash chain, single-writer/serializable append, with an out-of-band chain-head checkpoint so a DB admin who rewrites history is detectable.
  • Insider / admin abuse of emergency access — server-side recovery is dual-control break-glass with an out-of-band, append-only non-repudiation trail.
  • Secrets in telemetry — the encrypted envelope is redacted from logs; problem-details and provider exceptions never echo values or connection strings.
  • Token abuse — software-client tokens are env-scoped (scope from the persisted token, never the request), hashed at rest, rotatable/revocable, rate-limited, with advance-expiry notifications.

Out of scope / operator-owned

  • KEK custody and loss — losing the KEK is total, unrecoverable data loss; the operator backs it up (offline, split) and rehearses the restore. See the operations runbook.
  • The escrow read path returns plaintext by design — for server-side vaults and the software API, the server can decrypt; this is the acknowledged trade-off of escrow (zero-knowledge human vaults are v0.2).
  • Host hardening — OS, network exposure, TLS, and the security of the identity provider are the operator's responsibility.
  • No external security review yet (pre-1.0) — nothing here has been audited by an independent party, so the assurance behind every mitigation above is this project's own testing. That is a real limit on how much weight the list deserves, not a reason it cannot be used; judge it against the value of what you store.

Security & operations

  • SECURITY.md — reporting, operator responsibilities, no-warranty.
  • Operations & KEK/DR runbook — key custody, backup/restore order, KEK rotation and compromise response, monitoring/health endpoints, break-glass, GDPR erasure.

Embedding in your own ASP.NET Core / Blazor app

AM KEYWARD is library-first: you add it to your own .NET 10 Blazor Web App (interactive server) and it brings its services, API, and feature UI. Reference it either as the published preview NuGet packages (dotnet add package Am.Keyward.Infrastructure --prerelease, same for Am.Keyward.AspNetCore, Am.Keyward.Ui.Blazor, and Am.Keyward.Api if you expose the REST APIs) or as direct ProjectReferences (e.g. a git submodule). Deployed applications that only read their secrets reference the lightweight Am.Keyward.Client package instead — see Consuming secrets from a deployed application.

0. Prerequisites — the Keyward pages are interactive-server and [Authorize]-protected, so your app needs interactivity, auth-state cascading and (that's all — localization comes with AddKeywardUi):

builder.Services.AddRazorComponents().AddInteractiveServerComponents();
builder.Services.AddCascadingAuthenticationState();   // [Authorize]/<AuthorizeView> need the cascade
// ...plus your own cookie/OIDC authentication setup.

1. Register the services — one call wires up the EF Core DbContext (schema amkeyward), envelope crypto, audit, vaults, tokens, break-glass and monitoring:

// The KEK comes from your provider (Azure Key Vault / HSM / a protected file) — NEVER from the DB/appsettings.
// On Windows, DpapiKekFile is the packaged recommended custody: a DPAPI-protected file in a directory YOU
// choose (outside the database and outside any folder a deploy replaces), created on first run:
//   var (kek, kekId, created) = DpapiKekFile.LoadOrCreate(@"C:\ProgramData\<Company>\<App>\keyward");
// `created` is your cue to log the "back it up offline" warning once — see the escrow note below.
var keywardConn = builder.Configuration.GetConnectionString("Keyward")!;
var (kek, kekId) = LoadKekFromYourProvider();
builder.Services.AddKeyward(keywardConn, kek, kekId);

// Prefer this overload when the KEK stays in a KMS/HSM (the raw key never enters the process). Supply your
// own IKekProvider, or a KeyRingKekProvider holding the current + prior versions during a KEK rotation:
// builder.Services.AddKeyward(keywardConn, sp => new KeyRingKekProvider(currentKekId, keksByVersion));

// Tell the embedded UI which tenant to operate in. A SINGLE-ORGANIZATION host takes the packaged glue —
// one call registers the fixed-tenant IKeywardWorkspaceContext AND the circuit handler that pins the tenant
// scope (pair it with app.UseKeywardSingleTenant below for the HTTP/SSR path). A MULTI-TENANT host instead
// implements IKeywardWorkspaceContext (Am.Keyward.Core.Abstractions) from its own selection and calls
// ITenantScopeSetter.SetTenant itself.
builder.Services.AddKeywardSingleTenant(myTenantId);

// Register the UI's own services (circuit-scoped state + localization for the Keyward strings, six
// languages built in).
// ProductName is what your users see (browser tab, brand, texts, e-mails); default "AM KEYWARD".
// PublicBaseUrl (optional) enables absolute links in notification e-mails sent by background jobs.
// NotificationLanguage (optional) sets the language for those background e-mails (account e-mails
// follow the request culture); default English.
// TokenEnvironmentVariableName / ClientApiBasePath (optional) only shape the ready-to-run PowerShell
// snippet shown next to a freshly issued app token. By default the variable name is derived PER
// APPLICATION (application "Bvd.Li.Toolbox" -> KEYWARD_BVD_LI_TOOLBOX_TOKEN), so two applications on one
// host never collide; set TokenEnvironmentVariableName only if every application here reads one fixed
// variable. ClientApiBasePath is the path you mapped MapKeywardClientApi at.
builder.Services.AddKeywardUi(o =>
{
    o.ProductName = "Contoso Secrets";
    o.PublicBaseUrl = "https://secrets.contoso.com";
    o.NotificationLanguage = "en";
    o.TokenEnvironmentVariableName = "CONTOSO_SECRETS_TOKEN";   // default: derived per application
    o.ClientApiBasePath = "/keyward/api/v1";                    // default
});

// Optional — transient notifications ("Vault created", "Moved", errors). Keyward depends only on the
// IKeywardNotifier port. Standalone it shows its own BlazorBlueprint-styled toast (KeywardToastHost). If
// your app already has a toast system, override the port so Keyward's notifications use it and are
// indistinguishable from the rest of your app (registered with TryAdd, so your registration wins):
builder.Services.AddScoped<IKeywardNotifier, MyToastNotifier>();   // e.g. routes onto BlazorBlueprint's BbToast

2. Bind identity at the edge. The libraries are identity-agnostic: they read ICurrentUser / ICurrentTenant, which you set from your auth. Your auth layer stamps the Keyward AppUser id onto the signed-in principal as the KeywardClaims.UserId claim (however you map it — ASP.NET Identity, external OIDC, ...); the Am.Keyward.AspNetCore package then establishes the current user on both the HTTP and the Blazor-circuit path for you:

builder.Services.AddKeywardBlazorUserScope();   // circuit handler: current user per Blazor circuit
// ...
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();                            // MUST come after auth — tokens bind to the signed-in user
app.UseKeywardCurrentUser();                     // middleware: current user per HTTP request
app.UseKeywardSingleTenant(myTenantId);          // single-organization host: pin the tenant on the HTTP path

Creating those Keyward records is done for you. IKeywardIdentityBinder (registered by AddKeyward) finds or just-in-time creates the AppUser for your user id, keeps its flags in sync and reconciles the tenant membership — including removing it when you withdraw vault access. All you decide is what the user may be, from your own access model. In ASP.NET Core that is a claims factory plus one call:

public sealed class MyKeywardClaimsFactory(
    UserManager<MyUser> users, RoleManager<IdentityRole> roles, IOptions<IdentityOptions> options,
    IKeywardIdentityBinder binder, ILogger<MyKeywardClaimsFactory> logger)
    : UserClaimsPrincipalFactory<MyUser, IdentityRole>(users, roles, options)
{
    protected override async Task<ClaimsIdentity> GenerateClaimsAsync(MyUser user)
    {
        var identity = await base.GenerateClaimsAsync(user);   // your role claims
        var isAdmin = identity.HasClaim(Options.ClaimsIdentity.RoleClaimType, "Admin");

        await KeywardClaimsBinding.ApplyAsync(
            identity, binder, user.Id, user.UserName ?? user.Id, myTenantId,
            isAdmin ? KeywardIdentityBinding.Administrator : KeywardIdentityBinding.Member,
            logger);

        return identity;   // Keyward unavailable? The user still signs in, only Keyward pages are dead.
    }
}
  • KeywardClaims.UserId must be a GUID that exists in Keyward's Users table (AppUser). Use IKeywardIdentityBinder (above) — it creates the AppUser and the TenantMembership for you. The Tenant itself is seeded once at startup: KeywardSingleTenantSeeder.EnsureSeededAsync(...) covers the fixed tenant and, optionally, your own machine-secrets application. Without these rows the pages render empty and read-only.
  • Tenant selection stays yours where it is app-specific: a multi-tenant host registers a CircuitHandler that calls ITenantScopeSetter.SetTenant(...) in OnCircuitOpenedAsync with the same tenant its IKeywardWorkspaceContext returns, and pins the same tenant per HTTP request. A single-organization host gets both from AddKeywardSingleTenant + UseKeywardSingleTenant. If either scope is missing, pages fail with "Tenant scope mismatch" — the HTTP one bites first, on a prerendering page.
  • Symptom check: a Keyward page stuck at "Loading…" for a signed-in user means the circuit has no Keyward user — verify AddKeywardBlazorUserScope() is registered and the principal carries a valid-GUID KeywardClaims.UserId.

3. Discover the feature pages. The pages live under the /amkeyward/* route prefix (so they can't collide with your routes). Add the RCL assembly to the endpoint and the router — and route through AuthorizeRouteView (a plain RouteView ignores [Authorize]):

app.MapRazorComponents<App>()
   .AddInteractiveServerRenderMode()
   .AddAdditionalAssemblies(typeof(Am.Keyward.Ui.Blazor.KeywardRoutes).Assembly);
<Router AppAssembly="..." AdditionalAssemblies="new[] { typeof(Am.Keyward.Ui.Blazor.KeywardRoutes).Assembly }">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
            <NotAuthorized>@* redirect to your login *@</NotAuthorized>
        </AuthorizeRouteView>
    </Found>
</Router>

4. Drop the navigation into your layout (localized, auth-aware, no hardcoded routes):

<KeywardNav />

Style its .nav-link / .nav-group-label classes in your layout (see the reference shell's NavMenu.razor.css) — the nav deliberately adopts the host's look. KeywardNav lists the end-user pages; place links to the admin pages (KeywardRoutes.Groups, KeywardRoutes.DefaultEnvironments) in your own administration section.

5. Optional REST APIs. The software-client read API (deployed apps fetch their secrets with a bearer token) needs the service registration, the rate-limiter middleware AND the endpoint mapping — the endpoints don't exist otherwise:

builder.Services.AddKeywardSoftwareClientApi();  // "Keyward.SoftwareClient" scheme + rate-limiter policy
// ...
app.UseRateLimiter();                            // the mapped group requires the middleware
app.MapKeywardClientApi();                       // GET /keyward/api/v1/secrets[/{key}]
// Optional management REST API, guarded by YOUR admin policy:
app.MapKeywardApi(authorizationPolicy: "YourAdminPolicy");

With the client API mapped, Keyward also records access statistics per app token (last access + IP, requests per day, seen IPs) and derives rule-based access alerts (never-seen IP, active again after a long silence) — visible on each application's «Statistics» tab and optionally e-mailed to opted-in admins. Recording is in-memory on the hot path and persisted batched; tune or disable via the Keyward:TokenAccess section (Enabled, FlushIntervalSeconds, RetentionDays, SilenceAlertDays). Details: docs/software-client-api.md. Behind a proxy, configure forwarded-headers middleware so the recorded IP is the client's, not the proxy's.

Building on that signal, heartbeat monitoring (a dead-man's switch per app token) alarms on the failure an application cannot report itself: a scheduled task that never starts sends no error mail — it just goes silent. Enable a monitor on the application's «Monitoring» tab (maximum silence, weekday/time watch window so a Mo–Fr job doesn't false-alarm over the weekend, optional all-clear mail, snooze); consumers that load their configuration through Keyward need no change at all, long-running services ping GET /ping (KeywardSecretsClient.PingAsync). Configure via Keyward:Monitoring (Enabled, CheckIntervalSeconds, TimeZone). TimeZone is the installation's zone — it governs the watch windows, the statistics day buckets and the timestamps in notification mails; unset it defaults to the server's local zone. Timestamps are stored in UTC and shown in the viewer's own local time zone in the UI.

Consuming secrets from a deployed application (Am.Keyward.Client)

The consumer side of the software-client API is one line in the deployed application's Program.cs — no hand-rolled HTTP. Am.Keyward.Client is deliberately lightweight (no EF Core, no server code; just the configuration provider and a typed HTTP client):

builder.Configuration.AddKeywardSecrets(o =>
{
    o.ServiceUri = new Uri("https://keyward.example.com");
    o.ApplicationName = "Bvd.Li.Toolbox";   // token read from KEYWARD_BVD_LI_TOOLBOX_TOKEN
});

The bulk read (GET /secrets) lands in IConfiguration, so ConnectionStrings:Main, options binding etc. resolve like any other configuration value — later sources overlay earlier ones as usual. The token comes from the same per-application environment variable the Keyward UI's deployment PowerShell snippet sets on the host (set o.TokenEnvironmentVariableName for a fixed name, or o.Token to pass it directly).

Defaults are deliberate: the source fails the host loudly at startup when no token is deployed or the server is unreachable (an app whose connection strings live in Keyward must not silently start without them; set o.Optional = true to tolerate it), and the startup load retries with backoff (LoadRetryCount/LoadRetryDelay) so a service booting right after a host reboot does not lose the race against the Keyward server. Set o.ReloadInterval to re-read periodically and raise a configuration reload when values changed (IOptionsMonitor<T> picks it up); a failed refresh keeps the last known good values.

For direct runtime reads (a secret fetched on demand rather than at startup), register the typed client via IHttpClientFactory and inject KeywardSecretsClient:

builder.Services.AddKeywardSecretsClient(o =>
{
    o.ServiceUri = new Uri("https://keyward.example.com");
    o.ApplicationName = "Bvd.Li.Toolbox";
});
// ...then: string? value = await keywardSecretsClient.GetAsync("ConnectionStrings:Main", ct);

Styling and routes come for free. The UI theme is component-scoped CSS in the RCL, so it is folded into your app's standard {Assembly}.styles.css bundle automatically — no extra stylesheet <link> needed (the template's existing bundle link covers it). Override the look by redefining the --kw-* CSS variables on .keyward-ui; dark mode / color themes react to html.dark / html[data-theme="…"] if your host toggles them (see the reference shell's js/keyward-theme.js) — with no toggling you get the light default. The vault/application pages use <KeywardUi Fill="true">: in a plain layout they render with a normal page scroll; for the pinned header + independently scrolling panes, make your content container a fixed-height flex column and add .your-content:has(> .keyward-ui.kw-fill) { overflow: hidden; display: flex; flex-direction: column; } (see the reference shell's MainLayout.razor.css). To pin the UI language, add UseRequestLocalization with your supported cultures; without it the pages follow the server culture (en/de/fr/it/es/pt ship built in). The feature routes are the /amkeyward/* namespace; use the KeywardRoutes constants for links.

Database & migrations

  • You provide the database via the connection string passed to AddKeyward(connectionString, …). AM KEYWARD is SQL Server only (incl. Azure SQL). The reference shell reads it from ConnectionStrings:Keyward (override it in appsettings.json, an environment variable, or user-secrets; it falls back to a localhost dev default). AM KEYWARD never hardcodes the database name — that lives in your connection string.
  • It coexists in your database. All AM KEYWARD tables live in a dedicated schema amkeyward with a schema-scoped migrations-history table, so you can point it at the same database your host app already uses without colliding with your tables or your own EF migrations.
  • Migrations are not automatic just from the connection string. AddKeyward only registers the DbContext; the connection string only says where the database is. Apply Keyward's migrations one of these ways (the reference shell does the first two):
    • call MigrateAsync() at startup — this creates the database if it doesn't exist and applies pending migrations (using Am.Keyward.Infrastructure.Persistence;):
      await using var scope = app.Services.CreateAsyncScope();
      await scope.ServiceProvider.GetRequiredService<KeywardDbContext>().Database.MigrateAsync();
      
    • and/or run a periodic safety-net (re-applies pending migrations if the DB is swapped under a running app) — see the reference shell's DatabaseMigrationBackgroundService and the DatabaseMigration config section (Enabled, CheckIntervalSeconds);
    • or apply them out-of-band in your deploy step (dotnet ef database update).
  • Two database logins — optional hardening, not a prerequisite. Tenant row-level security is enforced against every principal, db_owner included, so a single privileged connection gets the same isolation; the split only withholds the right to disable the policy or alter the schema. Adopt it where it is cheap (a dedicated AM KEYWARD database), skip it where AM KEYWARD is embedded in a host's own database and a password would have to be created, deployed and rotated for that one property. If you adopt it, apply migrations out-of-band with the migrator login (the runtime login cannot DDL) and give AddKeyward only the runtime connection string; run db/setup-logins.sql once after the first migration. See docs/database-logins.md.

UI design principle — match BlazorBlueprint

The embedded Keyward UI MUST follow BlazorBlueprint's behaviour and layout — its controls, spacing, interaction patterns and notifications should be indistinguishable from a BlazorBlueprint host, so Keyward looks and feels native inside one. Keyward stays self-contained (no hard dependency on BlazorBlueprint, its own --kw-* tokens and components) so it also embeds anywhere; this principle governs that its own UI is kept visually and behaviourally aligned with BlazorBlueprint's conventions.

Two layers realise this:

  • Behaviour via ports. Where a host has a better-integrated primitive, Keyward depends on a small port, not on the concrete control, and the host wires the real thing. The transient-notification port IKeywardNotifier is the reference: a BlazorBlueprint host overrides it to use BbToast (real BB, identical to the rest of the app); standalone, Keyward's built-in KeywardToastHost renders a BlazorBlueprint-styled toast (bottom-right, auto-dismiss, per-kind accent) that mimics it.
  • Layout via the token contract. Keyward's own components style against --kw-* tokens chosen to match BlazorBlueprint's look; a host maps them to its theme.

Consequence for contributions: new Keyward UI, and any review of existing UI, is measured against BlazorBlueprint — if a control or message deviates from how BlazorBlueprint would present it, adjust Keyward (or add a port the host fills), don't diverge. The current UI is being reviewed against this and adjusted where it deviates (the notification split — success → toast, errors → inline — was the first pass).

Tech

.NET 10 · Blazor Server · ASP.NET Core · EF Core (Microsoft SQL Server) · MIT licensed.

Build

dotnet build Am.Keyward.slnx
dotnet test Am.Keyward.slnx

Requires the .NET 10 SDK; data/integration tests require a local SQL Server reachable on localhost via Integrated Security.

Documentation

End-user & operator documentation lives in docs/ and grows as features ship:

  • Software-client API — how a deployed app fetches its secrets with a token.
  • Database logins — the least-privilege runtime login vs. the migration login that underpins tenant isolation.

License

MIT © 2026 Ardimedia Anstalt

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.14.3-preview 75 9/4/2026
0.14.2-preview 122 8/25/2026
0.14.1-preview 154 8/17/2026
0.14.0-preview 77 8/16/2026
0.13.2-preview 104 8/13/2026
0.13.1-preview 72 8/13/2026
0.13.0-preview 71 8/13/2026
0.12.0-preview 67 8/13/2026
0.11.4-preview 105 8/8/2026
0.11.3-preview 67 8/8/2026
0.11.2-preview 69 8/7/2026
0.11.1-preview 75 8/7/2026
0.11.0-preview 67 8/6/2026
0.10.1-preview 71 8/6/2026
0.10.0-preview 59 8/6/2026
0.9.1-preview 81 8/4/2026
0.9.0-preview 63 8/4/2026
0.8.0-preview 64 8/4/2026
0.7.2-preview 73 7/31/2026
0.7.1-preview 52 7/30/2026
Loading failed