Skip to content

About

Production-grade credentials & secret-management engine — ASP.NET Core MVC (.NET 10), Clean Architecture, envelope encryption, ABAC, tamper-evident auditing, dynamic secrets.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

268 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🌒 EclipsVault

A production-grade credentials & secret-management engine built on ASP.NET Core MVC (.NET 10) with strict Clean Architecture, envelope encryption, mandatory MFA, attribute-based access control, fail-closed immutable auditing, and active intrusion defence.

Solution layout

EclipsVault.slnx                  # modern XML solution format
├── EclipsVault.Core/             # Pure C#. ZERO package references (BCL only).
│   ├── Domain/                   #   Entities, enums, domain exceptions
│   └── Application/              #   Organised by FEATURE, not by technical kind:
│       ├── Abstractions/         #     infra "ports" (crypto, hashing, TOTP, cache,
│       │                         #       blacklist, revocation, session registry, avatar…)
│       ├── Abac/                 #     pure ABAC rule engine
│       ├── Authentication/       #     sign-in + TOTP workflow, auth DTOs
│       ├── Secrets/              #     secret service, repo port, DTOs
│       ├── Users/                #     user admin service, repo port, DTOs
│       ├── Profile/              #     self-service profile service
│       ├── DynamicSecrets/      #     leased, minted-on-demand backend credentials
│       ├── Networks/             #     trusted-network service + repo port, canonical IP/CIDR rules
│       ├── Auditing/             #     audit-log reader
│       ├── Dashboard/            #     overview aggregation
│       ├── Activity/             #     per-user activity feed (personal security log)
│       ├── Sessions/             #     signed-in-devices DTOs + pure User-Agent parser
│       ├── SecurityCheckup/      #     per-user posture scoring (pure evaluator + read-model)
│       └── DataExport/           #     personal-data export (self-scoped, metadata-only)
├── EclipsVault.Infrastructure/   # Implements Core interfaces.
│   ├── Persistence/              #   EF Core 10 DbContext, Fluent API Configurations/,
│   │                             #     Repositories/, audit SaveChangesInterceptor,
│   │                             #     Migrations/ (schema source of truth), seeder
│   ├── Security/                 #   grouped into subfolders:
│   │     ├── Cryptography/       #     Argon2id, AES-256-GCM envelope crypto + KEK, TOTP,
│   │     │                       #       API-key factory, audit checkpoint signer
│   │     ├── Defense/            #     IP blacklist + session revocation + session registry
│   │     │                       #       (in-memory AND Redis variants), intrusion response,
│   │     │                       #       trusted networks, breached-password screen
│   │     └── WebAuthn/           #     the passkey relying-party verifier
│   ├── Auditing/                 #   audit checkpoint / export service
│   ├── Distributed/              #   Redis connection options (shared-state switch)
│   ├── Media/                    #   SkiaSharp avatar processor
│   ├── Caching/                  #   envelope cache (in-memory AND Redis variants)
│   ├── Logging/ · Workers/
├── EclipsVault.Web/              # Composition root. Thin controllers, ViewModels,
│   ├── Authorization/ · Middleware/ · Services/ · Extensions/ · Views/
├── EclipsVault.AuditVerifier/    # Standalone console tool. Verifies an exported audit
│                                 #   bundle offline — references Core only, no app/DB.
└── EclipsVault.Tests/            # xUnit. Pure-Core invariants: ABAC decision matrix,
                                  #   audit hash-chain, bundle verifier, recovery codes, crypto

Dependency rule: Web → Infrastructure → Core. Nothing ever points outward. Database entities never leave Infrastructure; controllers see DTOs; views see ViewModels.

Reproducible, gated builds — package versions are pinned centrally in Directory.Packages.props with committed packages.lock.json files (CI restores in --locked-mode); Directory.Build.props turns compiler/nullable warnings into build errors. dotnet test EclipsVault.Tests runs the unit suite and dotnet test EclipsVault.IntegrationTests the HTTP integration suite (needs Docker); the GitHub Actions workflow (.github/workflows/ci.yml) builds, tests, and fails on any vulnerable dependency. Local secrets never enter source control — see Configuration & secrets below.

Application is sliced by feature (vertical slices inside the layer): each folder holds the interface, its DTOs, and the service for one capability, so a feature is read and changed in one place. Cross-feature references resolve through per-project GlobalUsings.cs, so adding a feature folder needs no per-file using edits. Abstractions/ is the exception — it holds the technical "port" interfaces that Infrastructure implements.

Auditing is a single cross-cutting port. An audit row has exactly two ways into the database, both in Infrastructure/Persistence, and no service may hand-write one:

  1. IAuditSink.WriteAsync(AuditEntry) (Core.Application.Abstractions → AuditSink) — for a standalone event (a reveal, a sign-in, lifting an IP block). It is fail-closed: any write failure escalates to AuditWriteFailedException, so a secret read that cannot be audited aborts before decrypting. Rows are committed in groups (AuditGroupCommitter) because the chain lock is what caps the read path, but the caller is still released only once its own row is committed — batching changes the throughput, never the guarantee.
  2. AuditSaveChangesInterceptor — for an event that must be atomic with a data change, which the sink cannot give you because it commits separately (the change could persist unaudited if the audit insert then failed). The interceptor injects the row into the same SaveChanges batch, so the change and its audit row commit or roll back together. It audits Secret (create/update/shred/delete), SecretGrant (share/revoke), TrustedNetwork (add/remove — both move the ABAC network boundary), and DynamicSecretLease (issue/revoke/expire/failure).

The interceptor is also the choke point through which every row passes on its way to the database (whether injected there or added by the sink), so it stamps each one into the tamper-evidence hash chain just before persistence. Repositories persist their own aggregate only; a service never borrows another aggregate's repository — or a DbContext — just to record an event.

Schema is owned by EF Core Migrations (Infrastructure/Migrations/); there is no EnsureCreated and no hand-written DDL. Add a schema change with:

dotnet ef migrations add <Name> --project EclipsVault.Infrastructure --startup-project EclipsVault.Infrastructure

A design-time IDesignTimeDbContextFactory lets the tooling build the context without booting the web host.

Applying them is a deploy job's work, not the vault's. Development migrates itself on startup. Anywhere else the running service will not change the schema: a service that migrates on boot holds rights to drop and rewrite every table — the audit trail included — for its entire life, having needed them for a few seconds, and anyone who reaches its connection string inherits them. Run this from your deploy job, with a login the vault itself does not have:

ConnectionStrings__DefaultConnection="…" \
dotnet ef database update --project EclipsVault.Infrastructure --startup-project EclipsVault.Web

Checking is not applying, so the app still verifies: a schema behind the code fails startup rather than serving against it. For a single-node install with no deploy pipeline, Database:MigrateOnStartup=true restores the old behaviour — and the trade is explicit, since the app's login then needs DDL rights.

Prerequisites

  • .NET 10 SDK
  • Docker (for the dependencies below)
  • Trusted dev cert: dotnet dev-certs https --trust (recommended)

Run it

The dependencies are all real, open-source (or first-party) services — brought up with one command rather than mocked in-process:

docker compose up -d      # SQL Server + Vault + Mailpit + Redis
(cd EclipsVault.AngularUi && npm ci && npm run build)   # the Angular UI's pages (Node: see its .nvmrc)
dotnet run --project EclipsVault.Web
# → https://localhost:7443
Service Purpose UI
SQL Server persistence (or PostgreSQL — see below) —
Mailpit captures notification emails (real SMTP) http://127.0.0.1:8025
HashiCorp Vault holds the master key when Crypto:Engine=VaultTransit http://127.0.0.1:8200 (dev-root)
Redis shared distributed state when Redis:Enabled=true (opt-in) 127.0.0.1:6379 (dev-redis-password)
Keycloak a real OpenID Connect provider for SSO http://127.0.0.1:8081 (admin / dev-admin)

docker-compose.yml is a developer's laptop, not a deployment. The passwords are in the file and therefore public, Vault runs in dev mode (in-memory, unsealed, known root token), and nothing speaks TLS. Two things it does do properly, because they are free: every port is published to 127.0.0.1 rather than Docker's default of every interface — otherwise a laptop on a café or office network hands SQL Server, Vault and Redis to that network, silently, with the credentials above — and every image is pinned to an exact version, since a floating :latest means what starts tomorrow is not what you tested and a supply-chain compromise arrives with nothing in the diff.

Redis requires a password even locally. It holds the answers to "is this session revoked?" and "is this address blocked?", so anyone who can write it can restore a session its owner signed out of and lift an intrusion block — the vault's kill switches become advisory to whoever reaches port 6379. Startup refuses a passwordless connection string (Redis:AllowUnauthenticated=true to override, for an instance genuinely unreachable by anything else). Developing against an authenticated Redis also means the production configuration is the one that has been exercised.

(You can still point the app at your own SQL Server / PostgreSQL / SMTP / KMS / IdP via configuration.)

The Keycloak realm is imported from keycloak/eclipsvault-realm.json, not clicked together in a UI, so every developer gets the same IdP. It ships two users, both with the dev-user password: devon@eclipsvault.local, which matches the seeded dev-user account and signs in; and idp-only-user@eclipsvault.local, which is genuine to the IdP and unknown to the vault — it exists to prove the vault refuses it. The IdP proves who you are; it does not decide whether you are allowed in.

Configuration & secrets — the database connection string is deliberately absent from appsettings.json so credentials are never committed. Local development reads it (plus the dev KEK fallback and seed passwords) from appsettings.Development.json, which is git-ignored — copy appsettings.Development.json.template to create it. For any real deployment, supply it via the environment (ConnectionStrings__DefaultConnection) or a secret store, using a least-privilege login and TLS enforced (Encrypt=True;TrustServerCertificate=False on SQL Server, SSL Mode=Require on PostgreSQL). Startup fails fast with a clear message if it is missing.

Choosing the database — Database:Provider selects the engine: SqlServer (default) or Postgres. An unrecognised value fails at startup rather than falling back, because silently starting on a database nobody chose is how secrets end up somewhere their operator is not looking.

# PostgreSQL — no licence, and the schema is created for you on first run
docker run -d --name ev_pg -e POSTGRES_PASSWORD=... -e POSTGRES_DB=EclipsVaultUmbraDb -p 5432:5432 postgres:17

Database__Provider=Postgres \
ConnectionStrings__DefaultConnection="Host=localhost;Port=5432;Database=EclipsVaultUmbraDb;Username=postgres;Password=..." \
dotnet run --project EclipsVault.Web

First launch creates and seeds the EclipsVaultUmbraDb database ("umbra" — the darkest part of an eclipse's shadow).

Account Password Attributes
vault-admin ChangeMe!Umbra#2026-Admin TopSecret clearance, project GLOBAL
dev-user ChangeMe!Umbra#2026-Dev Standard clearance, project PHOENIX

These accounts exist in Development only. The passwords above are published here, which is what "compromised" means, so they are also in the bundled compromised-password corpus — the same screen that rejects them at every password change rejects them at the production bootstrap too, from one list rather than a second rule that could drift.

Anywhere other than Development, none of this is seeded: no dev account, no sample secrets, no decoys, no dynamic-secret roles. A vault that invents its own administrator on first boot is one that anyone who has read this source can sign in to. Instead, on an empty vault, supply a password you choose out-of-band:

Seed__AdminPassword='<a password unique to this deployment>' dotnet run --project EclipsVault.Web

That creates vault-admin once; remove the setting afterwards, as it is only read while the vault has no accounts. It is screened like any other password (minimum 12 characters, not in the corpus). If it is missing or rejected, startup fails and the process exits non-zero rather than falling back to a default — an orchestrator or deploy job must not see a refused boot as a healthy one. ASPNETCORE_ENVIRONMENT being unset counts as production, so forgetting to set it fails closed rather than seeding known credentials.

Sign in with either the username or the email (both seeded accounts also accept their @eclipsvault.local email). On first sign-in each account walks through mandatory TOTP enrollment (add the displayed key to any authenticator app). No session cookie exists until the second factor passes.

Once a user registers a passkey (Profile → Passkeys), the login page offers “Sign in with a passkey” for a fully passwordless sign-in — a passkey verifies the user on their device, so it satisfies both factors on its own and skips the password and TOTP steps entirely.

Lost your authenticator? The two-factor step has a “Use a recovery code” link. Recovery codes are single-use backup codes you generate from Profile → Security → Recovery codes (shown exactly once, stored only as salted Argon2id hashes); one redeems in place of the TOTP code, then is permanently consumed. Resetting the authenticator — by the owner or an admin — invalidates any outstanding codes.

Emails are generated automatically. When an admin provisions a user they supply a first and last name; the vault assigns a unique firstname.lastname.N@<domain> address, where N increments for people who share a name (a second "Saba Sabashvili" becomes saba.sabashvili.2@…). The domain is Identity:EmailDomain (default eclipsvault.local). Email is a unique login identity (enforced by a unique index).

Account lockout — after 5 consecutive failed password/TOTP attempts (configurable via Lockout:MaxFailedAttempts / Lockout:LockoutMinutes) the account is locked for 15 minutes; a successful sign-in resets the counter. Admins see a Locked badge in the Users console and can clear it with Unlock. This complements the per-IP rate limiter (which throttles the request rate) by throttling per-account guessing.

Master key (KEK)

In Development, a fallback KEK from appsettings.Development.json is used (with a loud warning). For anything real:

export ECLIPSVAULT_KEK="$(openssl rand -base64 32)"

Startup fails fast if the KEK is missing or malformed outside Development.

Rotating the KEK: generate a new key and set it as ECLIPSVAULT_KEK, move the previous key into ECLIPSVAULT_KEK_RETIRED (a ;-separated list — existing DEKs are still unwrapped with it), restart, then run the rotation from the Encryption keys admin page. Once it reports everything on the current KEK, the retired key can be removed.

KMS-backed master key (no KEK in the process): set Crypto:Engine=VaultTransit and point Vault:Address / VAULT_TOKEN at a HashiCorp Vault with a Transit key. The master key then lives in Vault — the app only ever holds single-use DEKs, which Vault wraps and unwraps. Locally you can try it with a dev Vault:

docker run -d --name vault-dev -p 8200:8200 -e VAULT_DEV_ROOT_TOKEN_ID=dev-root hashicorp/vault
curl -s -H "X-Vault-Token: dev-root" -X POST -d '{"type":"transit"}' http://127.0.0.1:8200/v1/sys/mounts/transit
curl -s -H "X-Vault-Token: dev-root" -X POST http://127.0.0.1:8200/v1/transit/keys/eclipsvault
Crypto__Engine=VaultTransit VAULT_TOKEN=dev-root dotnet run --project EclipsVault.Web

New secrets are then sealed with the Vault-held key (visible as KekId = vault:eclipsvault:v1). Switching a database that already holds locally-wrapped secrets is a re-encryption migration; the two engines are not interchangeable per-secret.

Horizontal scale-out (Redis)

By construction the app holds three pieces of shared runtime state — the session-revocation kill switch, the intrusion IP blacklist, and the encrypted-envelope cache. On a single node these live in process memory. Set Redis:Enabled=true and they move behind the same Core interfaces into Redis, so the app can run as multiple replicas behind a load balancer: a revocation or block raised on one node is honoured by all of them, and a write on one node evicts the cached envelope everywhere.

docker compose up -d          # includes Redis on :6379
Redis__Enabled=true dotnet run --project EclipsVault.Web

The design keeps the per-request checks cheap: revocation is a single keyed lookup, and because the blacklist keys each block by the offending address's canonical range (NetworkRules.ToBlockRange — /24, /64, or an exact loopback host), "is this IP blocked?" is one O(1) key lookup rather than a scan of every block. Verified end-to-end against a real Redis: a revoked session's cookie is rejected across a full app restart (in-process state would forget it), and an independent Redis client sees the shared envelope appear on reveal and disappear on rotate. Blocks persist (AOF) across restarts; revocation markers self-expire after Redis:RevocationRetentionHours (longer than any session). With Redis:Enabled=false (the default) nothing changes and no Redis is required.

The app

  • Overview dashboard — secret counts by environment, critical-alert count for the last 24 h, a recent-activity feed (admins see everyone; others see their own actions), and expiry warnings: a banner plus an "Expiring soon" panel listing secrets within 7 days of their TTL, each with a countdown and a one-click Rotate link. The lifecycle worker also emails the owner once per deadline (recorded in the Notifications outbox), so a secret can't quietly reach its TTL while nobody is looking at the dashboard. To keep a secret, rotate it with a renewal period — that is what moves the deadline; rotating without one replaces the value but leaves the TTL, and the worker still shreds it on schedule.
  • Secrets — searchable list, detail view with one-shot reveal + copy-to-clipboard, guarded delete. Every open/reveal is audited. Revealing a high-sensitivity value on a stale session prompts inline step-up re-authentication (a fresh authenticator code) before it decrypts. Admins additionally see a decoy badge on honey-tokens with a confirm-gated open.
  • Dynamic secrets — credentials the vault mints rather than stores. Pick a role, ask for a lease, and the engine creates a real SQL Server login on demand, shows it to you exactly once, and destroys it when the lease elapses (or when you hand it back). Roles are gated by the same ABAC rules as stored secrets, so you only see what you could actually issue. Every mint, hand-back, and expiry is audited — and a credential the vault fails to destroy is flagged critical rather than quietly marked done.
  • Secret rotation & version history — rotate a secret's value (with an optional change note); the value it replaces is kept as its own envelope-encrypted SecretVersion. A rotation timeline lets you reveal a prior value (audited, fail-closed, before any decryption) or revert to it (which archives the current value first). Versions hold real key material, so they are purged when the secret is shredded or deleted.
  • Secret sharing / access grants — a secret's project members (and admins) can grant a named user access to that specific secret from its Sharing panel, optionally with an expiry. The grantee sees it under Shared with me (sidebar) and can open it. A grant crosses the project boundary only — the ABAC clearance, network, and time rules still apply, so sharing never widens the clearance ceiling. Grants are consulted live by the ABAC handler, every share/revoke is audited, and grants cascade-delete with the secret.
  • Shared by me (sidebar: Shared by me, every user) — the access-review counterpart to Shared with me: one place to see every grant you have handed out (across all your secrets) — who has access to what, when you granted it, and any expiry — so you can recertify it and revoke anything no longer needed. Strictly self-scoped: the list is keyed by your username, and revoke is issuer-scoped — RevokeIssuedAsync only removes a grant if you are the one who issued it, refusing (indistinguishably from "not found", so it can't be used to probe grant ids) otherwise. This closes the IDOR a naive "revoke by id" would open: verified end-to-end that one user cannot revoke a grant another user issued, even with its real id, while revoking their own succeeds. Reuses the existing grant store — no new persistence.
  • Secret access requests (sidebar: Access requests) — the self-service side of grants. When ABAC denies a secret, its Denied page offers a Request access form; the request lands in a review queue for that secret's reviewers (an administrator, or a member of its project). Approving creates an ordinary grant for the requester (so the same ABAC rules apply — a grant fixes only a project denial, never clearance/network/time); rejecting records the decision. Requesters track their own requests and can withdraw a pending one; the denial reasons are snapshotted onto the request so a reviewer sees exactly what failed. Every transition is audited, and requests cascade-delete with the secret.
  • Notifications / email delivery (sidebar: Notifications, admin) — domain events send email: an access request being approved/rejected notifies the requester, a password change sends a security notice, and provisioning a user sends a welcome. The transport is pluggable behind the Core IEmailSender port — SMTP for real delivery or a Log transport (Email:Sender) — and every message is recorded to an outbox admins view on the Notifications page (recipient, subject, event, transport, delivery status), which also shows a delivery-status banner so it's obvious at a glance whether email is actually being sent and where. In development, docker compose up includes Mailpit (an open-source SMTP server + web UI), so notifications genuinely deliver — view them at http://localhost:8025. Notifications are fail-soft: a delivery failure is captured as a Failed outbox row, never breaking the operation that triggered it.
  • Profile (every user, self-service) — editable display name and email; profile picture upload (JPEG/PNG, validated, re-encoded to a safe 256×256 PNG) with a generated identicon fallback; change password (current-password check, breached-password screening, Argon2id re-hash — which revokes every other session, so a device signed in with the old password is turned out on its next request while the device that set the new one stays in); reset own authenticator; generate MFA recovery codes (single-use backup codes); register and remove passkeys for passwordless sign-in; and sign out everywhere (server-side session revocation). The login username is fixed — it is the audit-trail anchor.
  • My activity (sidebar: My activity, every user) — a personal security log: a plain-language, paged feed of the signed-in user's own audit trail — sign-ins (including rejected second factors and recovery-code use), secret reveals, shares, and account changes — newest first, each tagged with a category and a severity accent so notable events stand out. It is scoped strictly to the caller by user id, so it discloses nothing about anyone else, and it gives ordinary users (not just admins, who have the global Audit log) a way to review their account and spot anything they didn't do. A read-only projection over the same immutable trail — the mapping from raw audit action to friendly wording is a pure, unit-tested Core function (ActivityDescriber) that covers every action and degrades gracefully for any added later.
  • My access (sidebar: My access, every user) — answers the single most common question in an attribute-based system — "why can't I open this?" — without anyone reading policy code. It shows the user their own clearance and project, the live request context (is your current network trusted? is the production access window open right now?), and a grid of what you can open this moment across every sensitivity × environment, each cell either "Can open" or a short reason (Clearance too low, Untrusted network, Outside access hours). Crucially it is computed from the same pure rule engine (SecretAccessPolicy) and the same environmental snapshot (IAccessContextProvider) that actually guard secrets — enforcement and explanation share one source of truth, so the page can never drift from reality (verified end-to-end: the grid said "Confidential → clearance too low" and opening a Confidential secret was denied for exactly that reason). It also spells out the one thing that surprises admins — clearance dominates sensitivity, but the environmental rules apply to everyone, so even TopSecret is bound by the production window and trusted-network requirement — and flags that intrusion-detection decoys sit outside these rules entirely. Strictly self-scoped and disclosure-minimal: it evaluates only the caller's own attributes and never reveals the trusted-network ranges (only whether the current network is trusted). Refactor: the trusted-network + production-window computation was lifted out of SecretAccessHandler into the shared IAccessContextProvider this feature reuses.
  • Your data (sidebar: Your data, every user) — self-service data access & portability (GDPR-style): every user can see exactly what account and security metadata EclipsVault holds about them — identity, two-step/passkey/backup-code status, signed-in devices, access requests they've filed, and their recent activity — and download a portable JSON copy. Strictly self-scoped (the export is always built for the caller's own id) and deliberately metadata-only: there is no field anywhere in the export tree that can carry a secret value, ciphertext, key material, a password, a TOTP seed, or a backup code — secret values are encrypted and access-controlled and are never exported (a unit test walks the serialized document and fails if any credential-bearing field name ever appears). Downloading is an antiforgery-protected POST (so it can't be triggered by a cross-site request or a drive-by prefetch), the response is served Content-Disposition: attachment + Cache-Control: no-store (never rendered inline, never cached), and the export is audited (PersonalDataExported) so it shows up in the user's own My activity and the global Audit log. A pure read-model service composes the export from the existing self-service APIs — no new persistence, no decryption path.
  • Security checkup (sidebar: Security checkup, every user) — a scored, plain-language read on how well the signed-in user's own account is protected: four controls — two-step sign-in, one-time backup codes, a phishing-resistant passkey, and how many devices are signed in — each rated Secured / Recommended / Action needed, rolled up into a 0–100 score, a grade (At risk → Fair → Good → Strong), and the single most important next step surfaced first, with a button that deep-links to where it's fixed. The whole scoring model is a pure, unit-tested Core function (SecurityCheckupEvaluator, weighted controls with severity-ranked output); a thin read-model service composes the posture from the existing account services (profile, passkeys, recovery codes, session registry), each read keyed by the caller's own id so it is self-scoped by construction and discloses nothing about anyone else. Read-only — every remediation link points at an existing self-service page. The score ring is rendered with SVG presentation attributes (a computed stroke-dasharray), so it fits the strict, no-inline-styles CSP with no escape hatch.
  • Signed-in devices (sidebar: Signed-in devices, every user) — the per-session complement to "sign out everywhere": every user sees each place their account is currently signed in (device, IP, when it signed in, and last-active), with the current one marked, and can revoke an individual session (or all the others) without disturbing the device they're on. Each session carries a random id in its cookie; the server keeps a registry of live sessions and a per-session tombstone, and the cookie validator checks it on every request — so a revoked session is rejected on its next request, on every node. Strictly self-scoped (every registry call is keyed by the caller's own user id, so a user can only ever see and revoke their own sessions), each revoke is audited (SessionRevokedByUser) and shows up in My activity. Runtime state, so — like the revocation kill switch and IP blacklist — it is in-process on a single node and Redis-backed across a cluster (an atomic Lua upsert throttles the per-request last-seen touch to one round-trip and never resurrects a revoked session).
  • Sign-in history (sidebar: Security ▸ Sign-in history, every user) — a security-focused timeline of just the caller's authentication events — successful sign-ins (password+2FA, passkey, recovery code, and passed step-ups), rejected attempts (wrong password, bad code, failed step-up), and the account-lock lifecycle — drawn from the same immutable audit trail as My activity but narrowed to the "was this you?" question. Its headline signal is location familiarity derived purely from your own event stream (never an external geo lookup): an IP becomes established the first time you successfully sign in from it, so a failed attempt from an address you've never signed in from is flagged Unfamiliar and rolled up into an "attention" banner — while a mistyped password on a device you do use (which ends in a success) is deliberately not flagged, avoiding false alarms. The classification and the location/summary logic are pure, unit-tested Core functions (SignInEventClassifier + SignInHistoryBuilder), and the whole view is a read-only projection scoped strictly to the caller by user id, so it discloses nothing about anyone else. Architecture: rather than pull the entire feed to surface a narrow slice, it adds a focused, DB-side-filtered reader (IAuditLogReader.ListForActorByActionsAsync, translated to a SQL IN) so the query does the narrowing. Verified end-to-end against the live trail: with only same-host sign-ins the page shows 0 unfamiliar and no banner; injecting two failed attempts from a never-seen address (as unchained Sequence=0 rows, invisible to the tamper-evident chain and removed afterward) correctly rendered 2 from unfamiliar places, the Unfamiliar location badges, and the attention banner — with the audit hash chain left provably gap-free.
  • Passkeys / WebAuthn — register one or more authenticators (Touch ID, Windows Hello, a security key) from the profile, then sign in with no password or TOTP. Registration and assertion are verified server-side (relying-party id hash, origin, challenge, user-verification flag, and the ECDSA/RSA signature over a stored COSE public key), the ceremony challenge is held in a server-side session, and the signature counter is checked on every assertion for cloned-authenticator detection. Every register / sign-in / removal is audited.
  • Administration (TopSecret clearance only):
    • Users — provision accounts (Argon2id + breached-password screening + first-login TOTP enrollment); edit role (clearance + project, which revokes the user's sessions so the change applies at next sign-in); enable/disable an account (disabling revokes sessions immediately and blocks sign-in); force logout; reset a lost authenticator; delete. Guards block disabling/demoting/deleting yourself or the last administrator.
    • Networks — shows your current address exactly as the vault sees it (on a VPN that's the VPN egress) with a one-click Trust this address button. Manage runtime-trusted CIDR ranges (applied immediately, no restart) and lift intrusion-defence IP blocks. Note: network trust is a property of the request's source address, never of a user account.
    • Service accounts — provision non-interactive identities (name + clearance + project) for applications; issue API keys (shown once, stored only as a SHA-256 hash, optional expiry) — each optionally scoped to narrow it below the account (clearance ceiling, single project, or metadata-only); revoke keys; and disable/delete accounts. See the Programmatic API below.
    • Audit log — filterable viewer over the immutable trail; critical rows highlighted; a Verify integrity action re-walks the hash chain and flags any tampering. An Attestation panel signs the current chain head into a checkpoint and exports a signed bundle that can be verified offline with the EclipsVault.AuditVerifier tool.
    • Encryption keys — shows which master KEK each secret is wrapped under and runs a rotation that re-wraps everything under the current KEK. See KEK rotation under Security architecture.

The distinction is clean: profile actions only ever touch your own account and can never change clearance or project; clearance and project are administrative and live in the Users console. Login username (immutable audit anchor) is separate from the editable display name.

  • Designed empty states — every list page that can be empty (Shared with me, Access requests, Notifications, Secrets…) renders a reusable _EmptyState component — an icon, a heading, a plain explanation of what the page is for, and a call-to-action where one makes sense — so a page with no rows reads as "nothing here yet, here's what it's for" instead of a blank, broken-looking screen.
  • UI foundation — one app shell (_Layout sidebar / _AuthLayout centered card), a token-based design system in site.css with light and dark themes (toggle in the sidebar; the choice is persisted in a cookie and applied server-side on <html data-theme> for a flash-free first paint), flash notifications (FlashExtensions + _Flash partial), and data-attribute behaviours in site.js (data-confirm, data-copy, data-print, data-filter, data-flash, data-theme-toggle, data-reveal, data-strength, data-command-source, data-command-open, data-command-chord). The sidebar foot is a single account menu — your face, your name, your clearance, opening onto Profile / appearance / Sign out — rather than three controls competing for a 244px rail; Profile is named once, in the one place a face and a name already promise it goes. New features should compose these pieces: add a controller + views using page-header/panel/data-table, a sidebar entry in _Layout, and flash feedback on POST-redirect. Because everything references design tokens, both themes come for free.
  • Command palette (⌘K / Ctrl+K, every user) — reachable by the search field at the top of the sidebar, which shows the chord beside it (rewritten to Ctrl K off the Mac, since telling a Windows user to press ⌘ names a key their keyboard does not have). A shortcut nobody can see is a feature only its author uses, so the shortcut gets something to point at. One keystroke to jump to any page or open any secret, with arrow-key navigation, ↵ to open, esc to close (focus returns where it came from) and a focus-trapped dialog. Both halves are deliberately not lists maintained in JavaScript. Navigation is harvested from the sidebar's own <a> elements — the sidebar is already rendered per-caller, so an admin-only entry is not in the DOM for anyone else and the palette therefore offers exactly the routes that user was already offered; a hardcoded command list would be a second copy of the navigation and of its authorization, and it would rot the day someone adds an admin page. Secrets come from /Secrets/Search, which is the Secrets list with a substring match bolted on: the same ListAsync (so honey tokens are excluded and the palette can never invite someone to open a decoy) and the same VisibleToAsync ABAC filter, so a search can only return names the caller could already enumerate. Verified against the live vault: as a Standard-clearance PHOENIX user, Production, Global, and Api_Key all return nothing — the two TopSecret decoys and the above-clearance secret are unreachable by search — while the one secret they are entitled to is found; a single-character query returns nothing, and an anonymous request is refused.
  • Typeface — self-hosted, because the CSP says so. The UI is set in Geist (SIL OFL 1.1), served from wwwroot/fonts as two variable woff2 files (~138 KB for every weight of both the sans and the mono). It is deliberately not a Google Fonts @import: the vault ships style-src 'self'; font-src 'self', which blocks a CDN outright, and loosening that to allow one would let a third party inject stylesheets into pages that display secrets — a real exfiltration channel via attribute selectors and url() — leak every user's IP to that third party on each page load, and fail entirely on the isolated networks this product is deployed into. Self-hosting keeps the policy strict and the vault working with no egress at all.
  • Password fields — an input opts into a show/hide eye with data-reveal, and a live strength meter with data-strength. The meter is advisory: it estimates guessing cost (alphabet × length, discounted for repetition and keyboard runs) entirely in the browser — no candidate password is transmitted for it. It deliberately does not demand an uppercase-and-a-symbol: the same NIST 800-63B guidance the vault screens against advises against composition rules, and a checklist insisting on a symbol would assert a rule the server does not enforce. The two rules that are enforced — 12 characters, and absence from the breach corpus — are reported separately, the latter by the pre-existing debounced data-breach-check (which does POST the candidate to the vault's own server, where the corpus lives).

Programmatic API

Applications retrieve secrets over a small JSON API using a service-account API key — no browser session, no TOTP. The key is presented as a bearer token, and access is governed by the exact same ABAC policy as the interactive UI (clearance, project, network range, and time window all apply) — including the list. Knowing that Production_AWS_Root_Key exists in GLOBAL is worth having before a single value is read, so enumeration is an access decision in its own right and runs the same rules through the same handler. The one rule it drops is the metadata-only scope, since being able to enumerate but not read is the entire definition of such a key.

# List secret metadata (names only — no values), narrowed to what this key may know exists
curl -H "Authorization: Bearer evk_…" https://localhost:7443/api/v1/secrets

# Retrieve and decrypt one secret's value (ABAC-gated)
curl -H "X-Api-Key: evk_…" https://localhost:7443/api/v1/secrets/<id>
# 200 → { "id": "...", "name": "...", "value": "..." }
# 403 → { "error": "forbidden", "reasons": ["Clearance 'Standard' is below required sensitivity 'Confidential'."] }
# 401 → missing / invalid / expired / revoked key, or the account is disabled

Both Authorization: Bearer <token> and X-Api-Key: <token> are accepted. Every API retrieval is audited against the calling service account, and honey-tokens still trip the intrusion response over the API (returning a bland 404).

Per-key scopes — an individual key can be issued with a scope that narrows it below its service account (never above), for least-privilege that is tighter than the account itself:

  • Clearance ceiling — the key acts with min(account clearance, ceiling), so a TopSecret account can hand out a key that only reaches Internal secrets.
  • Project scope — pins the key to a single project. This binds even a TopSecret account (which otherwise crosses project boundaries), and also filters what the metadata list returns.
  • Metadata-only — the key may enumerate metadata (GET /api/v1/secrets) but every value read (GET /api/v1/secrets/{id}) returns 403.
  • Network binding (IP allow-list) — the key may carry a list of source CIDR ranges (e.g. 10.8.0.0/24, 203.0.113.7); presented from any other address it is rejected outright at authentication with 401, before the account is even resolved. A leaked key is useless off-network. Enforced against the real client IP (see forwarded-headers handling), so it holds behind a load balancer.

Scopes are enforced by the same pure ABAC engine as everything else — they simply add deny-rules — so a scoped key can only ever see a strict subset of what the account could. Denials name the exact scope that stopped them (e.g. "This API key is scoped to project 'PHOENIX'."), and each key's scope is recorded in its issue audit.

Security architecture

  • Passwords — Argon2id (Konscious), 64 MiB / 3 iterations / 4 lanes, unique random 16-byte salt per user, constant-time verify, timing-equalised unknown-user path.

  • Breached-password screening — every password set (admin provisioning) or changed (self-service) is screened against a compromised-password corpus bundled with the app as an embedded resource and loaded once into a case-folded HashSet for O(1) checks. A compromised value is refused with a clear message. The check is fully offline (nothing leaves the process, no HIBP round-trip), and the change-password / create-user forms also run a debounced live check as you type (behind auth + antiforgery; the candidate is only screened in memory, never logged, audited, or stored). Behind the Core IBreachedPasswordScreen port, so a full HIBP/SecLists corpus or a k-anonymity range API is a one-class swap. Implements NIST 800-63B §5.1.1.2 and OWASP ASVS 2.1.7.

  • Passkeys / WebAuthn — a self-contained relying-party implementation (no third-party FIDO2 dependency): the ceremony logic parses CBOR/COSE with System.Formats.Cbor and verifies signatures with the BCL's ECDsa/RSA primitives (ES256 + RS256). User verification is required, so a passkey is a genuine two-factor credential; the random challenge lives only in a server-side session (never trusted from the client); origin, relying-party id hash, ceremony type, and the monotonic signature counter are all checked. The relying-party id/name/origins are config (WebAuthn section) and the whole thing sits behind the Core IPasskeyService port, so swapping in a cloud FIDO2 service is a one-class change.

  • MFA recovery codes — single-use "look-up secrets" (NIST 800-63B) that stand in for the authenticator when it is unavailable. A generation issues ten codes (~50 bits each, from an unambiguous 32-symbol alphabet), invalidates any prior set, and shows the plaintext exactly once; only a salted Argon2id hash per code is stored (below 63B's 112-bit bar, so a moderate-work-factor KDF is mandated). Redemption runs on the same MFA-pending gate as TOTP, verifies against every unused code (no early-out timing signal), consumes the matched code, and folds into account lockout and the audit trail (RecoveryCodesGenerated, RecoveryCodeUsed). Both authenticator-reset paths — self-service and admin — purge outstanding codes so they can't outlive the factor they back up. Migration AddMfaRecoveryCodes.

  • Step-up re-authentication — revealing a secret at or above a configurable sensitivity (StepUp:MinimumSensitivity, default Secret) demands a fresh authenticator code when the session's last strong authentication is older than StepUp:MaxAuthAgeMinutes (default 10). The reveal is blocked inline until a valid TOTP is entered; a successful step-up refreshes a vault:stepup_time claim so the window reopens without re-prompting on every value. The decision is a pure, unit-tested Core rule; both success and failure are audited (StepUpVerified / StepUpFailed). Implements NIST SP 800-63B §4.2.3 reauthentication and PCI-DSS re-auth for sensitive access.

  • Envelope encryption — every secret gets a fresh single-use 32-byte DEK; payload sealed with AES-256-GCM; DEK wrapped by the master KEK from the environment; both blobs (nonce|tag|ct) stored side-by-side with a KekId. Plaintext and DEKs are zeroed (CryptographicOperations.ZeroMemory) as soon as possible.

  • A payload is bound to the row it lives in — GCM proves a blob has not been edited but says nothing about where it came from, so on its own it does not stop an envelope being moved. Each payload is sealed with its row's immutable identity as associated data (SecretBinding): the live value to the secret's id, an archived value to its own version id and its parent's. Without this, anyone who could write the database could escalate with a single UPDATE — copy the ciphertext, wrapped DEK, key id and algorithm of a secret you may not read onto one you may, then read it through the front door — or put a rotated-away credential back as the current one. Now the tag check fails and the read is refused. Only immutable identity is bound, so a rename or a reclassification is not a re-encryption; the binding is on the payload rather than the wrapped DEK, which is sufficient (a moved envelope still fails under the new row's binding) and keeps KEK rotation a pure re-wrap that never decrypts anything. Archiving and restoring a version genuinely re-encrypt rather than copying the envelope across — refusing to make that move by hand is what keeps its signature meaningful when someone makes it in the database instead.

    Secrets written before this are refused, because Algorithm sits unauthenticated next to the ciphertext: if a row could ask to be read the old, unbound way, anyone who can rewrite the row can ask for it, and the binding would be decoration. Adopting it on an existing vault is therefore an operator's deliberate, bounded act — set Crypto:AllowUnauthenticatedLegacyBlobs=true, start once to re-seal every secret and version, then turn it back off. While it is on, the binding can be bypassed by anyone who can write the database, which is exactly why it is not the default and not permanent.

  • KEK rotation & key lifecycle — the provider holds one current KEK plus any number of retired KEKs (ECLIPSVAULT_KEK + ECLIPSVAULT_KEK_RETIRED), each identified by a stable id; a DEK is always unwrapped with whichever KEK sealed it. The admin Encryption keys page shows which KEK every secret is on and runs a rotation that re-wraps each secret's (and archived version's) DEK under the current KEK — the payload ciphertext is never touched, so no secret is decrypted (honey-tokens included), and shredded tombstones (no key material) are skipped. Aligns with NIST 800-57 cryptoperiods and PCI-DSS 3.6/3.7.

  • Crypto factory & KMS-backed master key — CryptoEngineFactory resolves the engine named by Crypto:Engine. Two engines ship: the default local AesGcmLocal, and VaultTransit, which keeps the master key in HashiCorp Vault — the payload is still sealed with a local single-use AES-256-GCM DEK, but that DEK is wrapped, unwrapped, and rotated by Vault's Transit engine, so the KEK never exists in process memory, a crash dump, or the database. Switching is one config value (Crypto:Engine=VaultTransit) plus a VAULT_TOKEN; the business layer is untouched. Each secret records which Vault key version wraps it (vault:<key>:v<n>). Addresses the "master key in an env var" gap directly. The ICryptoEngine contract is asynchronous: a network-backed engine wraps and unwraps every DEK over the wire, and on a vault where each reveal does exactly that, a synchronous contract would block a thread-pool thread per operation — so VaultTransit genuinely awaits its Transit calls while the local engine completes synchronously behind the same interface. (The one deliberate exception is the Data Protection key ring, whose framework IXmlEncryptor/IXmlDecryptor are synchronous by contract — a cold path touched at key generation and startup, not per request.)

  • ABAC — AuthorizationHandler<SecretAccessRequirement, SecretDetailsDto> extracts subject claims (clearance, project), computes runtime context (a configurable time window for Production secrets — UTC by default, or a real IANA zone via Abac:TimeZoneId so "business hours" track the org's locale and DST — and a trusted-network check for Confidential+ against static config plus the DB-backed runtime trusted networks), and delegates to the pure rule engine SecretAccessPolicy in Core (fully unit-testable, no framework types — see the EclipsVault.Tests decision matrix). Denials surface their exact reasons on the Denied page.

  • Fail-closed audit — an EF SaveChangesInterceptor injects an AuditLogs row into the same transaction as every secret insert/update/delete; if the audit can't be written the whole transaction rolls back. Reads are audited before decryption — no audit row, no plaintext, and the caller gets a 503, never data.

  • Tamper-evident audit trail — the same interceptor is the single choke point for every audit insert, so it stamps each row into a hash chain: EntryHash = SHA-256(row content ‖ previous row's hash), with a monotonic sequence. Any edit, deletion, insertion, or reorder — even by someone with direct database access — breaks the chain and is caught by the Verify integrity action on the Audit page, which pinpoints the exact broken entry. The chain head advances only on commit (a rolled-back write leaves no gap), it is seeded from the persisted tail on restart, and the whole pre-existing history is back-filled into the chain at first startup. A row staged for a batch that fails is discarded with it rather than left in the change tracker, where the next write on the shared context would stamp and commit it — a correctly hashed entry for something that never happened. Implements the audit-trail controls in PCI-DSS 10.5 and NIST 800-53 AU-9/AU-10, and produces the tamper-evident evidence a SOC 2 CC7 audit relies on — SOC 2 attests an organisation's operating controls over time, so a codebase supplies evidence toward that audit rather than "passing" it.

  • Signed audit checkpoints & external verification — the hash chain is tamper-evident to anyone with the database; a signed checkpoint makes it tamper-evident to anyone with the public key. The admin Audit page can sign the current chain head (ECDSA P-256 / SHA-256 over the head sequence + hash) and export a self-contained bundle — every chained row, the signed checkpoint, and the public key. The standalone EclipsVault.AuditVerifier tool re-walks the chain and checks the signature offline, with no access to the vault, its database, or its private key — so an outside auditor can prove the trail was not rewritten by an insider with database access, who can delete rows and recompute every hash but cannot forge the signature. The private key comes from ECLIPSVAULT_AUDIT_SIGNING_KEY (dev uses an ephemeral key) — which is the boundary of that claim and is stated as such: someone who can read the host's environment holds the signing key itself, and a chain they rewrite and re-sign verifies even against a pinned key, because the key is genuinely the vault's. Threat model item 12 covers it, including the mitigation that costs nothing — export bundles on a schedule and have the auditor retain them, since a later rewrite contradicts every bundle already in their hands. Extends the RFC 6962-style transparency model to the vault's own log. Verifier compatibility: a bundle records the format it was written in, and a verifier reads its own format and every older one — so a bundle an auditor archived years ago keeps verifying. The reverse does not hold: a verifier older than the vault that exported the bundle refuses it and says so explicitly, rather than recomputing hashes under the wrong scheme and reporting an honest trail as edited. Auditors should keep a verifier at least as new as the vault. One exception, stated plainly because it is the case that bites: the 1.0.0 verifier shipped before this check existed, so given a bundle from a later vault it reports INVALID — "the row was edited". That is a verifier that is too old, not a trail that was tampered with; re-run with the current verifier to get a true answer.

  • Honey-tokens — seeded decoys (Production_AWS_Root_Key, Global_SQL_SA_Password). They are never enumerated — not to anyone, in the UI or the API. Any by-id read bypasses ABAC entirely and: revokes the caller's sessions (server-side kill switch checked on every request), blacklists the source IP range (/24 v4, /64 v6) in middleware, writes a critical audit row, and emits a Fatal Serilog alert. The attacker just sees a sign-out. Because no list leads to one, the only way to reach a decoy is with an id obtained out of band — a database dump, a backup, a stolen envelope — which is exactly the reader a decoy exists to catch, and makes every trip unambiguous. That is also what makes it a trap rather than a hazard: decoys used to be shown to ordinary users as bait indistinguishable from real secrets, so a single click cost them their session and blocked their network range, and the only warning was the decoy badge shown to the TopSecret administrators who were already safe.

  • Break-glass recovery — the block page carries a Recover access link to /Account/Recover, the one path the IP-blacklist middleware exempts. It demands all factors at once (password + TOTP), is restricted to TopSecret clearance, is rate limited, and audits every attempt (BreakGlassRecovery). A verified admin lifts the block on their own range and is signed straight back in; anyone else is refused and the block stands. This means a locked-out administrator always has a way back to the dashboard, without a process restart.

  • TTL shredder — a BackgroundService sweeps every 60 s, destroys expired key material (row remains as an auditable tombstone), evicts cache entries and logs the event. A 5-minute demo secret is seeded so you can watch it happen.

  • Caching — cache-aside via IMemoryCache, 5-minute absolute TTL, encrypted envelopes only (never plaintext), eagerly evicted on every write/update/delete.

  • Transport & headers — a strict Content-Security-Policy (default-src 'self', no unsafe-inline; object-src/frame-src/frame-ancestors 'none'; connect-src/form-action/base-uri 'self'), X-Frame-Options: DENY, nosniff, Referrer-Policy: no-referrer, Permissions-Policy, COOP + CORP same-origin, and X-Permitted-Cross-Domain-Policies: none on every response; HSTS outside dev with a credentials-grade policy (1-year max-age, includeSubDomains, and preload, rather than the framework's 30-day default); Cache-Control: no-store on every HTML response (so authenticated vault pages are never written to the browser's disk cache or served from the back button after sign-out, while static assets and images keep their own caching); SameSite=Strict + HttpOnly + Secure on all cookies; global AutoValidateAntiforgeryToken; per-IP fixed-window rate limiting on the auth surface (returns 429); default-deny fallback authorization policy; no Server header. Because the CSP forbids inline scripts and styles, all interaction is wired through site.js data-attributes (no inline onclick/style anywhere) — a CSP-clean pattern rather than a 'unsafe-inline' escape hatch.

  • Data Protection key ring — persisted, shared, and sealed with the vault's own KEK — those keys encrypt every authentication cookie, antiforgery token, TempData entry and session, so they are not incidental plumbing. Left to the framework's default they live under the user profile: per machine, and in a container per process. Every consequence then reads as something else — a restart signs everyone out, a second replica cannot decrypt the first's cookies, form posts fail antiforgery for no visible reason — so outside Development the app refuses to start unless DataProtection:KeyRingPath points at a durable directory shared by every node (AllowEphemeralKeys=true if you genuinely intend one throwaway node). The ring is encrypted at rest with the vault's own ICryptoEngine, which makes the files inert without ECLIPSVAULT_KEK — a key ring in the clear is the cookie-signing key sitting in a directory, and anyone who can read that volume or its backups can mint a session for any account. Going through the engine rather than the raw KEK means rotating the KEK doesn't lock the vault out of its own key ring, and a VaultTransit deployment protects the ring with a master key that never enters the process.

  • Session management & revocation — an interactive session is issued a random id at sign-in that travels in the cookie. A server-side session registry (runtime state, in-process on one node / Redis-backed across a cluster, like the other shared-state stores) tracks each live session and holds a tombstone for revoked ones; the cookie's OnValidatePrincipal checks both the account-wide kill switch (revoke everything issued before an instant — used by "sign out everywhere", role/enable changes, and the intrusion response) and the per-session tombstone, so a single "signed-in device" can be signed out on its own and is rejected on its next request, on every node. Last-seen is refreshed as best-effort metadata (throttled, never able to fail a valid session), and every self-service revoke is audited.

  • Real client IP behind a proxy — ForwardedHeaders middleware recovers the true caller address from X-Forwarded-For, but only from proxies you list in ForwardedHeaders:KnownProxies (with none listed, the socket address is used — safe for direct exposure). Every IP-based control — the rate limiter, the intrusion IP-blacklist, the ABAC trusted-network check, and the audit SourceIp — depends on this being correct, so it must be configured whenever the app runs behind a load balancer or ingress.

  • Host filtering — AllowedHosts ships as * (the framework default) so the app runs out of the box, but that accepts any Host header. Set it to the vault's own hostname(s) in production so a spoofed or reflected Host is rejected by the host-filtering middleware; the app logs a warning at startup outside Development while it is still permissive, in the same spirit as the KEK dev-fallback warning.

Security & operations documentation

The mechanisms above are the what. These documents are the boundary and the runbooks — read them before a production deployment, and share the threat model with anyone evaluating the vault:

  • Threat model — what EclipsVault defends against and, explicitly, what it does not; the assets, actors, and the operator-responsibility boundary.
  • Security policy — how to report a vulnerability, the in-scope surface, and the safe-harbor terms.
  • Incident response — the triage → fix → disclose → notify runbook for a reported weakness.
  • Backup & recovery — the three things to back up, the KEK-custody rule, and a tested restore procedure.
  • Verifying a release — every release image is keyless-signed with Sigstore cosign; verify it (and its signed SBOM) before you run it.
  • Production install — the deployment runbook (required configuration, least-privilege schema migration, verification).
  • Installing a licence — where the token goes, what each status message means, and why nothing stops when a licence is missing.

Extension points

  • Cloud KMS — implement ICryptoEngine, register it in CryptoEngineFactory, set Crypto:Engine. The VaultTransit engine is the reference implementation; an AWS KMS or Azure Key Vault engine follows the same shape (wrap/unwrap the DEK via the provider's SDK).
  • Passkey relying party — the WebAuthn ceremonies live behind the Core IPasskeyService port (implemented in Infrastructure/Security/WebAuthn); a managed FIDO2 service could replace the built-in verifier without touching the domain, controllers, or views.
  • Multi-node — set Redis:Enabled=true to back the IP blacklist, session revocation, and envelope cache with Redis (see Horizontal scale-out); the Redis*/InMemory* pairs implement the same Core interfaces, so adding another distributed store (e.g. the rate-limiter partition) is the same swap.
  • Migrations — schema is managed by EF Core Migrations (Infrastructure/Migrations/), applied at startup via MigrateAsync(). The existing dev database was baselined onto the initial migration, so no data was lost in the switch-over.

Proposed roadmap

Shipped so far — both built on the existing seams and each verified end-to-end:

  • ✅ Account lockout — 5 failed password/TOTP attempts lock the account (FailedAccessCount/LockedUntilUtc on User, enforced in VaultAuthenticationService), with an admin Unlock. Complements the per-IP rate limiter.

  • ✅ Secret rotation & version history — prior values kept as envelope-encrypted SecretVersion rows; timeline with reveal/revert. First feature delivered through the EF-migration workflow (AddSecretVersioning).

  • ✅ Login by username or email + auto-generated unique emails — first.last.N@domain, N per name; email is a unique login identity. Migration AddUniqueEmailIndex.

  • ✅ Expiry notifications — dashboard banner + "Expiring soon" panel with countdowns and Rotate links, over the existing lifecycle/dashboard infra.

  • ✅ Secret sharing / access grants — per-user grants (optional expiry) that satisfy the ABAC project rule only; "Shared with me" sidebar page; audited; cascade-deletes. Migration AddSecretGrants.

  • ✅ Service accounts & scoped API keys — non-interactive identities with SHA-256-hashed keys and their own ABAC attributes; /api/v1/secrets for programmatic retrieval, governed by the same policy. Migration AddServiceAccounts.

  • ✅ Passkeys / WebAuthn — passwordless MFA: register authenticators from the profile and sign in with no password or TOTP. A self-contained relying-party verifier (CBOR/COSE + BCL crypto, ES256/RS256) behind the IPasskeyService port; user-verification required; challenge held server-side; signature-counter clone detection; every ceremony audited. No schema change — the PasskeyCredentials table shipped in InitialCreate.

  • ✅ Light/dark theme toggle — the design system is tokenised, so the light theme is a single :root[data-theme="light"] token block (surfaces invert to layered whites, hairlines become black-alpha, the amber accent deepens to stay readable). The choice persists in an EclipsVault.Theme cookie the server reads to stamp data-theme on <html>, so the first paint is already correct (no flash) — CSP-safe, no inline script. A sidebar toggle flips it live.

  • ✅ Per-key API scopes — a key can be issued narrower than its service account: a clearance ceiling, a single permitted project (binding even a TopSecret account), and/or metadata-only. Scope travels as claims and is enforced as extra deny-rules in the same pure ABAC engine, so a scoped key can only ever see a subset of the account. Issue-time UI in the Service accounts console; each key's scope is shown on its row and recorded in its issue audit. Migration AddApiKeyScopes.

  • ✅ Secret access requests — the self-service loop over the existing grants system, surfaced as a new Access requests sidebar page. A denied secret's page offers a request form; reviewers (admins or project members) approve (creating a grant) or reject from a queue; approval is governed by the same ABAC engine (a grant only fixes a project denial). Denial reasons are snapshotted for the reviewer, every transition is audited, and requests cascade-delete with the secret. Migration AddAccessRequests.

  • ✅ Notifications / email delivery — a pluggable IEmailSender transport (SMTP for prod, a dev Log transport, chosen by Email:Sender) with a fail-soft INotificationService that composes messages for domain events (access-request decisions, password changes, user provisioning) and records every one to an outbox, viewable on a new admin Notifications sidebar page. Migration AddEmailLog.

  • ✅ Tamper-evident audit log — hash-chained audit trail (each row commits to the previous), with an admin Verify integrity action that detects and pinpoints any modification, deletion, or reorder. Pre-existing history is back-filled into the chain at startup. Migration AddAuditChain. Maps to PCI-DSS 10.5 and NIST 800-53 AU-9/AU-10; supplies evidence toward SOC 2 CC7 (an organisational audit, not a code property). (Security-standards hardening track.)

  • ✅ KEK rotation & key lifecycle — a multi-KEK provider (current + retired keys, resolvable by id) and an admin Encryption keys page that re-wraps every secret's DEK under the current KEK — payload ciphertext untouched, shredded tombstones skipped, every rotation audited (KekRotated). No schema change (the KekId seam already existed). NIST 800-57, PCI-DSS 3.6/3.7. (Security-standards hardening track.)

  • ✅ MFA recovery codes — single-use backup codes, generated from the profile and shown once, that redeem in place of TOTP when the authenticator is lost. Stored only as salted Argon2id hashes; verified without an early-out timing signal; consumed on use; wired into lockout and the audit trail; and purged whenever the authenticator is reset. Migration AddMfaRecoveryCodes. NIST 800-63B "look-up secrets". (Security-standards hardening track.)

  • ✅ Breached-password screening — every password set or changed is refused if it appears in a bundled, offline compromised-password corpus (embedded resource, loaded once into a case-folded HashSet), with a debounced live check on the change-password / create-user forms. Behind the IBreachedPasswordScreen port. NIST 800-63B §5.1.1.2, OWASP ASVS 2.1.7. (Security-standards hardening track — this completes the track.)

  • ✅ Signed audit checkpoints & offline verification — the hash-chained audit trail can now be signed (ECDSA P-256) at its head and exported as a self-contained bundle, verifiable offline by the new standalone EclipsVault.AuditVerifier project with no access to the app, its database, or its private key. This lifts tamper-evidence from "provable to anyone with the DB" to "provable to anyone with the public key," defeating even an insider who rewrites the whole chain. Migration AddAuditCheckpoints; new Infrastructure/Auditing + reorganised Infrastructure/Security (Cryptography/ · Defense/ · WebAuthn/). RFC 6962-style transparency; NIST 800-53 AU-9.

  • ✅ API-key network binding (IP allow-listing) — a service-account key can be pinned to source CIDR ranges; presented from anywhere else it is rejected at authentication (401). Enforced against the real client IP, so a leaked key is useless off-network. Migration AddApiKeyIpAllowlist. Shipped alongside an architecture cleanup: all IP/CIDR logic (address normalisation, range matching, block-range derivation) consolidated from three duplicated copies (Web + two Infra services) into one canonical NetworkRules in Core, now reused by ABAC, the trusted-network store, the intrusion blacklist, and key binding.

  • ✅ Step-up re-authentication for sensitive reveals — revealing a Secret/TopSecret value with a stale session forces a fresh authenticator code, entered inline on the secret's own page, before decryption. The decision is a pure Core rule (IStepUpService.IsRequired) driven by StepUp:MinimumSensitivity / MaxAuthAgeMinutes; a successful step-up refreshes a strong-auth claim so the window reopens briefly; both outcomes are audited. New Application/StepUp feature slice, no schema change. NIST 800-63B §4.2.3.

Engineering hardening (from a security & architecture review — all landed):

  • ✅ Secrets out of source — the DB connection string left appsettings.json; it now comes from the environment/secret store (dev value in git-ignored appsettings.Development.json). Least-privilege login + Encrypt=True for real deployments.
  • ✅ Correct client IP behind a proxy — ForwardedHeaders with an explicit known-proxy allowlist, so rate limiting, the IP-blacklist, ABAC trusted-network, and audit SourceIp stay accurate behind a load balancer.
  • ✅ Test suite + CI — EclipsVault.Tests (xUnit) covering the ABAC matrix, audit hash-chain, recovery-code format, and crypto round-trip; a GitHub Actions pipeline that builds, tests, and audits dependencies. It immediately caught a latent bug (the rate limiter set a non-existent HTTP 430 instead of 429).
  • ✅ Reproducible builds — central package management with pinned versions + committed lockfiles; warnings-as-errors quality gate.
  • ✅ Timezone-aware access window, throttled API-key last-used writes, and a shared audit-writer extension (dedupe).
  • ✅ No N+1 across the secrets list — enumerating secrets runs each row through the real ABAC handler (so the list can never drift from what a read enforces), but the handler was issuing a per-row SecretGrants lookup for every candidate — an explicit grant only ever lifts the project rule, yet the query ran even for rows the caller could already see. Measured on a 42-secret list: 42 grant queries per page load. The handler now decides without a grant first and consults the engine itself — not a second copy of the project rule — for whether a grant could change the outcome, querying the database only for the rows where it is decisive (cross-project, otherwise-allowed). Re-measured: 0 grant queries for a same-project list, one query only per genuinely grant-relevant row, and the request-invariant environmental snapshot (network trust, production window) is memoised once per request instead of recomputed per row. Verified live end-to-end, including that a real cross-project grant still makes its secret visible.
  • ✅ Distributed state for horizontal scale-out (Redis) — the three pieces of shared runtime state (session revocation, the intrusion IP blacklist, the encrypted-envelope cache) can now live in Redis behind their existing Core interfaces, so the app runs as multiple replicas: a revocation or block on one node is enforced on all, and a write evicts the cached envelope everywhere. The async interfaces are backed by either Redis* or InMemory* implementations selected by Redis:Enabled; the blacklist keys each block by its canonical range so the hot-path check stays a single O(1) lookup. Verified end-to-end against a real Redis, including a revoked cookie staying dead across a full app restart. See Horizontal scale-out.

Toward production — bigger initiatives (need infrastructure or are multi-week features, best sequenced one at a time):

  • ✅ HA audit chain — the head moved into the database. This was the bug that quietly made the Redis scale-out above untrue. AuditChain was a process singleton whose head was seeded once at startup, so on a second replica node B's head went stale the moment node A appended — and never recovered, because a node only advances its own head. Every subsequent audited write on B then collided with the unique sequence index and rolled back; since the sink is fail-closed, secrets became unreadable on that node. Not a subtle race — an outage.

    A hash chain is a linked list (row N commits to N-1's hash), so appends cannot be parallelised: the head must be read and advanced under a lock held until the insert commits. That lock and that head now both live in the database — a transaction-scoped sp_getapplock spanning read head → stamp → insert → commit — so all replicas serialise on the one thing that actually knows the head. Serialising audited writes cluster-wide is inherent to the guarantee, not a cost worth optimising away. The synchronous SaveChanges path now refuses rather than silently writing an unstamped row outside the chain.

    Verified on a real two-replica deployment: two instances against one database, 50 concurrent audited writes fired at both at once — 50/50 succeeded, the head advanced by exactly 50, and the chain came out with 0 duplicate sequences, 0 gaps, 0 unchained rows and 0 broken links. Both replicas independently reported chain intact from their own Verify integrity page.

  • ✅ Shared auth throttle — the last piece of single-node state. The built-in rate limiter partitions in memory, so every replica granted the same caller its own full budget: scaling out silently multiplied what an attacker was allowed to try. The budget now lives behind an IAuthThrottle port with in-memory and Redis implementations, selected by the same Redis:Enabled switch as the other three stores. Redis INCR is the arbiter, so two nodes cannot each read a count and both decide they're within budget; only the attempt that creates a counter sets its expiry, so a steady stream can't push the window along forever. A refused attempt still spends a permit, so hammering can't walk the counter back under the limit.

    It replaces ASP.NET's limiter with an async MVC filter on AccountController: System.Threading.RateLimiting's partitions are per-process by design and its acquire path is synchronous, so a distributed counter can't plug into it. The filter meters only the unsafe methods — the POSTs that submit a password, TOTP, recovery code, or passkey assertion — and lets idempotent GETs (the sign-in forms, the SSO callback) through unmetered: those test no caller-supplied secret, so they are not the brute-force surface, and metering them would let one office behind a single NAT address 429 its own users on ordinary page loads. A Redis outage refuses authentication rather than waving it through unmetered — the throttle damps the rate of guessing, while per-account lockout (persisted, already shared) bounds the number.

    Measured on two replicas against one Redis, budget of 5: Redis off — node A granted 5 and node B granted 5, so one caller got 10. Redis on — node A spent the shared 5, and node B refused all five with 429. Exactly the budget, no matter how many replicas.

    With this, every piece of shared runtime state — session revocation, the intrusion blacklist, the envelope cache, the audit chain head, and now the auth budget — is distributed, and the vault genuinely runs as multiple replicas.

  • ✅ KMS-backed KEK (HashiCorp Vault Transit) — the master key can now live in Vault instead of an environment variable: the VaultTransit crypto engine wraps/unwraps each DEK via Vault's Transit API, so the KEK never enters process memory. Opt-in via Crypto:Engine=VaultTransit; verified end-to-end against a local dev Vault (a secret created and revealed through the app, stored with KekId=vault:eclipsvault:v1 and a vault:v1: wrapped DEK). An AWS KMS / Azure Key Vault engine would follow the same ICryptoEngine shape (needs the respective cloud credentials).

  • ✅ Dynamic secrets & leasing — the vault now mints credentials instead of only storing them. A role is a recipe, not a value: ask for one and the engine creates a real SQL Server login on the spot, hands it to you once, and destroys it when the lease elapses. Nothing is stored — a leaked lease table yields no live credentials, because the password exists only in the response that issued it. The seeded roles are genuinely usable: issue phoenix_db_reader and you can connect to the vault's own database with it, read (and only read), and watch it stop working when its lease is up.

    A role is just another ABAC resource. It carries the same project/environment/sensitivity attributes a stored secret does, so issuing is decided by the same handler and the same pure rule engine — the role list shows only what your clearance and project permit, and issuing re-checks rather than trusting that filter. This is what IAbacResource is for: one access decision, not a second copy to drift.

    Leasing is the half that makes it safe. A dedicated DynamicLeaseWorker reaps on a tighter interval than the secret sweep (a lease is only as precise as the reaper), so "short-lived" is enforced rather than promised. A caller may ask for less than the role's ceiling, never more. A credential the vault mints but then fails to record is rolled back off the backend — an unrecorded live credential is the one thing a leasing engine must never leave behind, since nothing would ever come back to reap it. If a backend refuses to destroy a credential the lease lands in RevocationFailed with the reason attached and a critical audit row, because that credential may still work and a human needs to know — closing it as "revoked" would be a lie the audit trail then repeats. Migration AddDynamicSecrets.

    Injection is a checked invariant, not a convention. CREATE LOGIN cannot be parameterised, so the credential is rendered into DDL as text. CredentialMint only ever produces strictly alphanumeric values, and CredentialStatementTemplate refuses to render anything else rather than trying to escape it — so minting a credential cannot become arbitrary SQL execution even if a future caller generates its own names. Role statements are seeded out of band, never authored in the UI: they run with the vault's backend rights, so defining a role is a privileged act.

  • ✅ Managed rotation — the vault changes the real upstream credential. Dynamic secrets cover credentials the vault mints itself; this covers the ones that already exist. Bind a stored secret to a backend principal and "rotate" stops meaning re-encrypt what you paste in: the vault generates a new password, changes the real principal upstream, and stores the result. The two move together, so the vault can't end up confidently serving a password that no longer works — the silent drift you get when an operator changes one and pastes the other.

    Ordering is the whole design. The upstream change goes first: if the backend refuses, nothing moved and the stored value is still the truth. If storing then fails, the live credential has moved and the vault would be serving a dead password — so the upstream change is put back to the value the vault still holds, mirroring the roll-back that protects a minted credential. If even that fails, the mismatch is recorded as a critical SecretUpstreamRotationDrifted row, because only a human can reconcile it and pretending otherwise would be a lie the audit trail repeats. No per-secret statements exist: ALTER LOGIN is fixed in the backend, so a managed secret can never become a place to hide arbitrary SQL. Migration AddManagedRotation.

    Verified against a real SQL Server login: created app_probe with a known password and stored it in the vault, confirmed it connected, then rotated through the UI. The old password then failed (Login failed for user 'app_probe') and the password the vault now held connected as app_probe — proving the real credential moved and the stored copy stayed in step. The old value was archived to version history, and the trail carries both the atomic SecretUpdated and a SecretUpstreamRotated row.

  • ✅ A failed write left its rollback behind for someone else to commit. Pinning managed rotation's failure paths in tests turned up two real bugs underneath them, both of the same shape: EF keeps a failed SaveChanges staged in the change tracker so it can be retried, but SaveChanges flushes everything pending rather than only its caller's rows — and the DbContext is scoped to the request and shared with the audit sink. So the next unrelated audit write committed the failure.

    The rotation itself came back. A rotation whose save failed stayed pending as the secret Modified and its archived version Added. Writing the drift row — the row that exists to say this rotation did not happen — was itself a SaveChanges on that context, so it committed the rotation in the same transaction, and the trail ended up carrying both SecretUpstreamRotationDrifted ("reconcile by hand") and SecretUpdated ("this worked"). For a managed secret the vault would then store a password whose upstream change had just been rolled back: exactly the drift the feature exists to prevent, produced by the code reporting it.

    The audit row came back too — correctly hashed. The interceptor injects its AuditLog during SavingChangesAsync; on failure it aborted the chain batch but left the row staged. The next save re-collected it, stamped it into the hash chain for real, and committed a valid, chained SecretUpdated entry for a change that was rolled back. Never rotation-specific: every audited entity, any failed write. A chain whose rows may be false is worth less than no chain, because it is believed.

    The fix is one invariant, not a rule at 29 call sites. EclipsVaultDbContext now reverts every pending change when a save does not commit — rewinding the entities themselves, not just their tracker state, since callers still hold them and act on what they see (a rotation putting an upstream password back while holding the new one would restore the wrong value). It lives on the context rather than in a SaveChangesInterceptor for a reason worth knowing: EF does not route a throw from the SavingChanges phase through SaveChangesFailed — measured, not assumed — and that phase is exactly where the audit chain takes its lock, making a timeout there one of the likelier ways to fail. An interceptor would have missed the case that matters most. Interceptors run inside base.SaveChanges, so the context sees every failure whatever raised it. Nothing here retries a failed save, so keeping the changes staged bought nothing and cost the guarantee.

    Both bugs were found by a test asserting the wrong thing (entity state is the repository's contract, not the service's), then proven against the live database with the real repository, interceptor and chain: before, an unrelated audit write turned OLD-VALUE into NEW-VALUE-THAT-FAILED-TO-STORE and produced the phantom row; after, neither appears, while add/rotate/shred/delete still commit with the right audit rows. Chain left at 533 rows, 0 gaps, 0 unchained. Pinned in CI by FailedSaveTests, which runs the real DbContext against in-memory SQLite — no database server needed — and which fails on four of five tests when the guard is removed.

  • ✅ Group commit — the audit chain stopped capping the read path. The chain is a linked list, so appends serialize on one cluster-wide lock held across read-head → insert → commit. Every reveal is audited fail-closed before decryption, which put that lock on the read path: one lock cycle per secret served. Measured against the real database, it peaked near 400 reveals/sec at a concurrency of 2–4 and then went backwards — ~120/sec at 32 callers, half the single-threaded rate, with a 2.5-second p99 — while the same rows written without the chain scaled past 4,000/sec. The database was never the limit; the convoy at the lock was. Adding replicas made the vault slower.

    The chain never needed a lock per row. AuditChain.BeginAsync already stamps a whole list, so the lock's cost is per batch — AuditGroupCommitter now collects the rows waiting at any moment and commits them as one, the same trick a database uses for its own write-ahead log. The batch window is not a timer: a batch is whatever accumulated while the previous batch was committing, so latency is never worse than a single write (at rest, batches are size 1) and batches grow exactly when contention would otherwise form.

    Fail-closed is untouched, and that is the point. Every caller still waits for its own row to be committed before it continues; a batch that fails aborts every operation in it. This is the precise opposite of an asynchronous audit outbox, where callers proceed and rows can be lost in a crash — leaving a change committed with nothing recording it. Nobody proceeds early; they just wait together instead of in a queue. The sink also stopped riding on the request's DbContext, which is what let an audit write commit an unrelated failed change.

    Measured after: 126 → 2,628/sec at 32 concurrent (~21×), p99 2,542ms → 33ms (~77×), and throughput now rises with concurrency (253 → 3,010/sec at 64) instead of collapsing. The two harnesses agree at concurrency 1 (232 vs 253/sec), where there is no contention — so the divergence is the fix, not the bench. Chain integrity proved by the app's own walk over 7,293 entries including 6,760 batched rows: intact, no gaps, no broken links, then confirmed end-to-end through the running app. Caveat: Docker Desktop on one laptop, so the shape transfers, not the absolute numbers.

  • ✅ PostgreSQL — the vault runs on a free database. A licensed database is a line item on every self-hosted deployment, and the vault should not be the reason for one. Only two things ever actually tied it to SQL Server: the chain lock, and one index predicate's quoting.

    The lock is now the IAuditChainLocker port — sp_getapplock on SQL Server, pg_advisory_xact_lock on PostgreSQL. The contract is that the lock is transaction-scoped, which is the whole point rather than an implementation detail: it must be held for exactly read-head → insert → commit, and released by the commit. That rules out a lease-based distributed lock (Redis/Redlock), where a writer whose lease quietly expires mid-transaction still commits, mints a duplicate sequence, and takes the node down — the lock and the head must be held by the one thing that knows whether the insert landed. The advisory key is a SHA-256 of the resource name, not string.GetHashCode(), which is randomised per process: every replica would hash the same name to a different key, take a different lock, and serialise against nobody. Selected by Database:Provider (SqlServer default, or Postgres); an unknown value fails at startup rather than quietly falling back.

    Verified against a real PostgreSQL 17: the whole model builds (including the partial unique index), and 32 concurrent writers × 30 rows produced 960 rows with 960 distinct sequences, max 960 — no duplicates, no gaps — chain intact, at ~2,100/sec. The advisory lock genuinely excludes; a lock that didn't would fork the chain silently. SQL Server is unaffected (2,759/sec at 32 concurrent, chain intact across 7,294 entries).

    Migrations are generated per provider (the DDL differs down to the column types) and EF finds a set by assembly, so PostgreSQL's live in EclipsVault.Migrations.Postgres while SQL Server's stay in Infrastructure — moving those would rewrite history for every existing deployment. Adding a migration means adding it for both engines, or the two schemas drift. The design-time factory switches on ECLIPSVAULT_DESIGN_PROVIDER.

    Proven end-to-end: booted the real app against PostgreSQL 17 with Database__Provider=Postgres, migrations created all 17 tables (the partial index landing as WHERE ("Sequence" <> 0)), the seeder ran, and a real failed sign-in wrote a correctly chained audit row — 0 unchained, 0 gaps. Supabase, Neon, or any managed Postgres needs nothing further; it is PostgreSQL.

  • ✅ SSO (OpenID Connect) — the IdP proves who you are; the vault decides what you may read. The roadmap line used to say clearance would flow from the IdP. That is the largest trust delegation a secrets vault can make: an IdP administrator who mints clearance=TopSecret reads everything, and the trail records it as legitimate — correctly hashed, signed, offline-verifiable and wrong. So clearance and project stay in the vault's own record, and elevation keeps going through AccessRequest. IdP-proposed attributes can be added later as a deliberate opt-in; starting IdP-authoritative could not be undone.

    The decision is Core, not plumbing. SsoSignInService refuses fail-closed and audits every refusal — an IdP vouching for someone the vault turns away is exactly the event a security team needs, and it is invisible unless recorded. Refusals are one message on screen and the precise reason in the trail, so an anonymous caller cannot use SSO to discover which addresses have accounts. An unverified email_verified is refused as critical: the email is the link, so an IdP that lets someone self-assert an address lets them assert someone else's, and matching on it is account takeover.

    A factor is never silently dropped. IdpMfaPolicy waives the vault's TOTP only if the IdP's amr (RFC 8176) asserts two genuinely different factors and the operator opted in (Sso:TrustIdpMultiFactor, default off). Silence is not assurance — amr is optional and most providers omit it, so absent means "asserted nothing". SMS/tel do not count. Getting this wrong the permissive way is invisible: sign-in still says success while every account is worth one IdP password.

    Verified against a real Keycloak (docker compose, realm imported from source), driving the actual authorization-code flow with PKCE and PAR. Keycloak authenticated both users happily; the vault linked devon@ and refused idp-only-user — SsoIdentityLinked and SsoSignInRefused (NoVaultAccount) in the trail, 0 gaps, 0 unchained. Live verification then caught that ASP.NET's OIDC handler deletes iss and amr as protocol noise, so the trail was recording the issuer as AuthenticationTypes.Federation — it now names the real IdP.

  • SCIM provisioning — deprovisioning at the IdP should close accounts here too.

  • ✅ Further clean-arch: one true audit choke point — the two services that reached past IAuditSink straight into DbContext to hand-build AuditLog rows (TrustedNetworkService, IntrusionResponseService) now go through the ports, making the "single cross-cutting port" rule above actually true instead of aspirational: audit rows have exactly two writers again (sink + interceptor), and Infrastructure/Security no longer touches the DbContext at all. TrustedNetworkService moved to Core/Application/Networks behind a new ITrustedNetworkRepository (which also absorbed its cache), joining every other feature-slice service in Core — and, no longer needing a database, became unit-testable. Its trusted-range validation turned out to duplicate NetworkRules.TryParseCidr, a copy that survived the IP/CIDR consolidation; it now calls it. The interceptor generalised from a Secret-only block into a small per-entity rule table, which is what lets a trusted-network add stay atomic with its audit row. IntrusionResponseService stays in Infrastructure by design — it implements an Abstractions port and its LogCritical alarm is the product, and Core has no logger.

  • ✅ Secret expiry lifecycle: renewal + expiry notices — the lifecycle worker now warns before it reaps. Each sweep emails a secret's owner once its TTL enters the same 7-day window the dashboard warns in, through the existing INotificationService (so every notice lands in the admin Notifications outbox). The notice is idempotent against a sweep that runs every minute: the secret records the deadline it was warned about (ExpiryNoticeSentForUtc) rather than a "notified" flag, which makes renewal self-healing — push the deadline out and the notice re-arms itself with no flag to clear. Migration AddSecretExpiryNotice.

    This closed a real bug it would otherwise have industrialised: the "Expiring soon" panel offers Rotate as the renewal action, but RotateAsync never touched ExpiresAtUtc — so rotating a near-TTL secret wrote a fresh credential that the reaper then shredded at the original deadline, silently destroying the value the operator had just rotated in. Rotation now takes an optional renewal period (mirroring CreateAsync's TTL), and the notice's advice names it. "Expiring soon" also had two definitions (a private const in DashboardService, and whatever the worker would have picked); both now read one SecretExpiry, so what the vault shows on screen and what it emails can't drift.

    Stamping the notice marker is bookkeeping, not an edit, so it is exempt from the SecretUpdated audit row — narrowly, by checking that the marker is the only modified property. Live verification caught that the first cut of this leaked anyway: UpdateAsync calls DbSet.Update(), which flags every property modified, making the write indistinguishable from a real edit. It writes the single column now.

Pricing & licensing

EclipsVault is self-hosted: you run it in your own environment and hold your own keys — the vendor never sees your secrets or your servers. There are two tiers, Community and Max. Baseline secret management is complete and identical in both; Max licenses the six advanced capabilities below for production, plus ongoing updates and best-effort email support. Enforcement is soft, and stays soft where it counts: your data is never withheld, baseline security is never gated, nothing already running is ever stopped, and a licence problem can never cost you an outage. Each licensed capability carries a 30-day evaluation period that starts the first time you use it — no licence key, no registration, no contact with us. Once it ends, the vault declines to begin a new privileged operation on that capability (issuing a further dynamic credential, rotating an upstream credential) and says so plainly; existing leases stay listed and revocable, and stored values stay readable.

Tier Price For
Community Free Production use included — unlimited users, unlimited deployments, no time limit, no registration, no licence key. The complete vault on SQL Server or PostgreSQL, source-available.
Max $899 one-time · per production instance · perpetual licence + 12 months of updates Production use, plus the six licensed capabilities — SSO/OIDC, external KMS (Vault Transit), Redis HA, dynamic secrets, managed rotation, and signed audit attestation — and best-effort email support.

Community is free in production. Not a trial, not a homelab exception, not seat-limited: you may run the vault to hold real credentials, in a business, indefinitely, without paying or telling anyone. The limit is capability, not scale or time — Community is everything except the six licensed capabilities. That choice is deliberate. A seat or instance cap would have to be counted, counting needs telemetry, and this product will never phone home; a limit that cannot be observed is one that cannot be enforced, and publishing an unenforceable limit is just a promise made without meaning it.

A Max licence is perpetual — buy once, run forever. The purchase bundles a 12-month update window for new feature releases; when it lapses the vault keeps running and security patches stay free — published on the current release and never backported, so getting a fix means upgrading (free within the same major version). The product may be retired only on 12 months' published notice, and because the source is published and builds reproducibly, even then you can keep building and patching it yourself.

What one licence covers. One production deployment — however many replicas, users, or secrets it holds. Specifically:

  • Replicas don't multiply the licence. A Redis-backed HA deployment is one production deployment, whatever the node count. Running Max's own multi-node feature must never cost extra; a licence that penalised turning on high availability would be arguing against itself.
  • Seats are unlimited. Onboarding the whole team costs the same as onboarding one person, so the bill never grows as you hire.
  • Non-production never needs a second licence. Development, staging, CI, and a standby you have not cut over to are not production deployments. (Community is free in production regardless — this bullet is about when a second Max licence is due.)

A second production vault — a separate environment serving different users, or one deployed for a different client — is a second deployment. Note that verification is offline and enforcement is soft, so none of this is technically metered: no instance count is measured, reported, or enforced. It is the licence you are agreeing to, honoured on your word, which is why it is written to be easy to honour.

Continuity: if maintenance ever stops, the intent is that customers keep the source and the right to run and patch what they have deployed — a paused project never strands a running vault.

Buy: <Merchant-of-Record checkout — added at launch>. Contact sabasabashvili86@gmail.com for a licence or an invoice.

Installing your license

Set the token you receive at purchase as the ECLIPSVAULT_LICENSE environment variable (how a container injects it), or point License:FilePath at a file that holds the token (e.g. a mounted secret), then restart. The admin License page shows your status, tier, and update window.

Licence

Source-available, not open source. EclipsVault is © 2026 Saba Sabashvili, all rights reserved, and is licensed rather than sold. Running the vault in production is free under the Community licence — unlimited users, unlimited deployments, no time limit, no registration, no licence key, including in the operation of a business and holding real credentials. A commercial licence is required only to use the Licensed Capabilities — SSO/OIDC, external-KMS key sealing, Redis-backed HA, dynamic secrets, managed rotation, and signed audit attestation — in production beyond a 30-day evaluation of each, which the vault starts by itself the first time you use one. Redistributing it, hosting it for others, or offering it as a service is not permitted. See LICENSE for the terms and sabasabashvili86@gmail.com for licensing.

This is the managed self-hosted model: the source is visible to customers, because a vault you cannot read is a vault you cannot audit, and running one you have not audited is a decision no security team should make. What is licensed is using the Licensed Capabilities in production.

Bundled third-party assets. The UI ships the Geist and Geist Mono typefaces (EclipsVault.Web/wwwroot/fonts) under the SIL Open Font License 1.1, whose text is vendored beside them in fonts/OFL.txt as that licence requires. The OFL permits bundling and redistribution within a larger work, including a commercial one, and imposes no obligation on EclipsVault's own terms above. No font file is renamed or modified.

Notes

  • List views show metadata only and are not per-row audited; every by-id metadata view and every reveal is audited (SecretMetadataViewed, SecretRevealed).
  • Honey-token blacklisting defaults to process-local (in-memory); tripping it from localhost blocks you until app restart — that's the feature working. With Redis:Enabled=true the block instead persists in Redis (and across restarts) and is shared by every node — lift it from the Networks console or via break-glass recovery.
  • The seeded Seed:AdminPassword / Seed:DevPassword values live only in appsettings.Development.json; change them, or remove the section and set them via user-secrets/environment.

About

Production-grade credentials & secret-management engine — ASP.NET Core MVC (.NET 10), Clean Architecture, envelope encryption, ABAC, tamper-evident auditing, dynamic secrets.

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages