A lightweight ASP.NET Core MVC web application for storing and sharing secrets (passwords, tokens, API keys, etc.) within a small team. State is persisted in files, with an encrypted vault and password hashes after first login — no external database required.
SecureVault provides a small team with a browser-based place to share selected passwords and keep shared secret entries in a common vault while controlling who can read each entry's contents.
- Users sign in with accounts stored in
App_Data/users.json; authenticated sessions expire after 30 minutes of inactivity. - Signed-in users can create entries with a title, free-form secret details, and an optional comma-separated access list.
- The entries page shows all entry titles and includes client-side search by title. Entries the current user cannot read are shown as restricted instead of exposing their details.
- Entry contents are readable by the owner, explicitly listed users, administrators, or everyone when the entry's
Usersvalue is"*". - Users who cannot read an entry can submit an access request. Entry owners see pending request counts in the sidebar and can approve or decline requests from the requests inbox.
- Approving a request adds the requester to the entry's access list; declining removes the pending request without changing access.
- Users who can read an entry can edit it. Each create or update records a history snapshot so the details page can show prior versions.
- The app logs successful logins, entry creation, entry updates, and authorized entry opens to
App_Data/access.json. - On startup, the app creates missing JSON data files and runs lightweight data migrations for history records, admin flags, and entry ownership.
- Direct browser access to
.jsonfiles is blocked by middleware so the data files are not served as static files.
| Component | Details |
|---|---|
| Framework | ASP.NET Core MVC (.NET 10) |
| Language | C# |
| Storage | JSON files in App_Data/ |
| Authentication | Server-side sessions |
| Serialization | System.Text.Json |
Before the first start after upgrading, provision a vault key ring outside the application directory. With PowerShell 7, create a dedicated directory and restrict it to your account (for local development):
$keyDirectory = Join-Path $env:LOCALAPPDATA 'SecureVaultKeys'
New-Item -ItemType Directory -Path $keyDirectory -ErrorAction Stop
$account = [Security.Principal.WindowsIdentity]::GetCurrent().Name
icacls $keyDirectory /inheritance:r /grant:r "${account}:(OI)(CI)F"
if ($LASTEXITCODE -ne 0) { throw 'Could not restrict key directory permissions.' }
./scripts/New-VaultKeyRing.ps1 -Path (Join-Path $keyDirectory 'keyring.json')
$env:Vault__KeyRingPath = Join-Path $keyDirectory 'keyring.json'
dotnet runFor a service deployment, use a directory outside the deployed site, grant its service identity read access to the key ring, and configure Vault__KeyRingPath in the service environment (or Vault:KeyRingPath in configuration). On Linux, restrict the directory and key file to the administrator/service account, for example directory mode 0700 and file mode 0600. Use actual external storage paths, not links back into the application directory. Never commit the key ring or package it with the application or vault backups. The provisioning script expects directory permissions to be set first.
The app starts on the configured port (see Properties/launchSettings.json). Before listening it validates the keys, encrypts any legacy plaintext entries, and runs data migrations. A missing, invalid, or incorrect key stops startup; there is no generated-key fallback. Provision accounts manually as described below. Existing passwords stay in plaintext until each user's first successful login.
Application state lives in App_Data/; the encryption key ring lives separately. Each entry has its own encrypted file under App_Data/entries/, while account, request, and audit records remain readable JSON. Restrict App_Data to the service identity and administrators. Writes use a flushed temporary file followed by atomic replacement. Cooperating application processes sharing the same local data directory serialize storage operations through .store.lock. Stop all application processes before manual edits, backup/restore, upgrades, or key rotation. Multi-file operations such as entry save plus audit logging are still not transactional.
Stores user accounts.
[
{
"Username": "admin",
"Password": "<unique initial password supplied privately>",
"IsAdmin": true
},
{
"Username": "alice",
"Password": "<another unique initial password>",
"IsAdmin": false
}
]| Field | Type | Description |
|---|---|---|
Username |
string | Login name. Case-sensitive for login, case-insensitive for access checks. |
Password |
string? | Temporary plaintext provisioning password. The field is removed after successful login. |
PasswordHash |
string? | ASP.NET Core Identity V3 salted PBKDF2-HMAC-SHA512 hash (210,000 iterations). Written by the application; includes salt, work factor, and format version. |
IsAdmin |
bool | When true the user can read all entries regardless of per-entry restrictions. |
There is no registration UI. Stop the app, add an account to users.json with Username, Password, and IsAdmin, then restart. Omit PasswordHash for a new account; do not set it to an empty string. Keep usernames unique ignoring case. Existing Password fields are preserved until successful authentication. The password itself does not change; users keep using the same password after migration.
On successful login, the application verifies the credential, writes PasswordHash, and removes Password from that user's object in the same file before issuing a session. Other accounts keep their initial passwords. A failed login leaves the credentials unchanged. Repeated logins verify the stored hash, upgrading an older Identity hash when needed. If both fields exist, only the hash is accepted; successful hash verification removes the stale plaintext. An empty or malformed hash fails login instead of falling back to plaintext.
To reset a password manually, stop the app, remove that account's PasswordHash, and set a new Password. It will be hashed on the next successful login. This does not revoke already active sessions. Unused bootstrap passwords remain readable by design; remove unused accounts or arrange their first login promptly. Old Git revisions, backups, and deployment copies are not scrubbed by migration.
After migration an account has this shape (the hash below is only a placeholder):
{ "Username": "alice", "PasswordHash": "<application-generated Identity hash>", "IsAdmin": false }Each file stores one entry and its complete history as an AES-256-GCM encrypted envelope with a fresh random 96-bit nonce and 128-bit authentication tag on each write. Existing numeric IDs are preserved: entry 7 becomes entries/0007.json; entry 10000 becomes entries/10000.json. Names use only decimal digits and .json, and grow beyond four digits without truncation. New IDs are allocated under the filesystem lock so concurrent creators cannot receive the same ID.
Format, entry ID, purpose, and key ID are authenticated. Moving ciphertext to a different entry's filename fails authentication. Each file contains Format, KeyId, Nonce, Ciphertext, and Tag; it must not be edited manually. Saving one entry replaces only that entry file, leaving unrelated entry files untouched. An unreadable entry is reported as an error, never silently overwritten or omitted from a list; startup validation also fails if any entry cannot be decrypted.
The following is the decrypted logical schema for one file, not the on-disk representation:
{
"Id": 1,
"Title": "Production DB",
"Details": "host=db.example.com\nuser=app\npassword=hunter2",
"Users": "alice, bob",
"CreatedBy": "admin",
"History": [
{
"ChangedAtUtc": "2026-03-08T10:00:00Z",
"ChangedBy": "admin",
"Title": "Production DB",
"Details": "host=db.example.com\nuser=app\npassword=hunter2",
"Users": "alice, bob"
}
]
}| Field | Type | Description |
|---|---|---|
Id |
int | Auto-incrementing identifier. |
Title |
string | Human-readable name shown in the entry list. |
Details |
string | The secret content — free-form text (passwords, keys, notes, etc.). |
Users |
string? | Access control list — see Per-Entry Access Control. null/empty means private; "*" means everyone. |
CreatedBy |
string | Username of the entry owner. Set automatically at creation time. |
History |
array | Full audit trail of every version — see Change History. |
If Alice and Bob both open an entry, Alice saves, and Bob later submits his older form, Bob's submitted content becomes current. Alice's saved version remains in history, followed by Bob's version with its author and timestamp. The winner is the last committed save, regardless of when the form was opened or the request started. Fields are not merged.
The application takes an exclusive filesystem lock, reloads the latest entry, preserves any current version missing from legacy history, appends the submitted version, and atomically replaces that one encrypted file. Current content and history commit together. Access-list approvals follow the same locking and history rules. The lock covers storage operations across the entire vault, including users, requests, and audit records, to avoid deadlocks and lost updates between those files. A competing process waits up to 30 seconds for the filesystem lock and then fails without writing if the lock remains busy. Process termination releases the OS lock; do not delete .store.lock to unlock it.
Use a local filesystem that supports exclusive file sharing and atomic same-directory replacement. The tested configuration is multiple .NET processes on Windows using the same data directory. Network shares, cloud-synced folders, separate replicas, and filesystems with different lock/rename semantics are not validated. File coordination alone does not configure shared browser sessions or load balancing.
- Stop every old application process and securely back up the complete installed
App_Dataand external key ring separately. New publish output excludesApp_Data; keep the installed data directory in place. An older deployment artifact may still contain data, so do not copy itsApp_Dataover the installation. - Deploy the new binaries and configure the existing key ring (or provision one for a plaintext installation).
- Before accepting requests, startup reads either the old plaintext array or its encrypted envelope, validates unique positive IDs, and writes encrypted individual files into
App_Data/entries.migrating/. The originalentries.jsonremains intact throughout staging. - Startup reads back and verifies every staged entry and its history, then renames the staging directory to
entries/. It verifies the published copy against the original and removesentries.jsononly after success. No automatic backup is retained. - An interruption before publication resumes staging from the unchanged original. An interruption after publication but before source removal resumes verification/removal. A changed source, conflicting individual files, invalid IDs, or missing keys stops migration without choosing one conflicting version. Restore a consistent backup or resolve the reported conflict; do not delete files blindly to force startup.
The entries/.layout marker records the layout version and migration source fingerprint. Include it in backups and keep it in place. Temporary files are not treated as entries. An intentionally empty legacy array remains empty. A new installation with no existing vault receives the sample entry. Password migration is unchanged: bootstrap Password remains until each account's first successful login.
The old application cannot read the individual-file layout. Rollback requires restoring the pre-upgrade data snapshot along with the old application. Restoring an old entries.json alongside newer individual entry files is rejected rather than overwriting the newer versions.
An append-only audit log. Every significant event is recorded with a UTC timestamp.
[
{
"TimestampUtc": "2026-03-08T09:55:00Z",
"User": "admin",
"Action": "LoginSuccess",
"EntryId": null,
"EntryTitle": null,
"Notes": null
},
{
"TimestampUtc": "2026-03-08T10:01:00Z",
"User": "alice",
"Action": "OpenEntry",
"EntryId": 1,
"EntryTitle": "Production DB",
"Notes": null
}
]Recorded actions:
| Action | Trigger |
|---|---|
LoginSuccess |
Successful login |
CreateEntry |
New entry saved |
UpdateEntry |
Existing entry edited and saved |
OpenEntry |
Entry details page viewed by a user who has read access |
OpenEntry is only logged when the viewing user actually has access; restricted views do not produce a log entry.
Pending access requests from users who do not have permission to read an entry.
[
{
"Id": 1,
"RequestedBy": "charlie",
"EntryId": 1,
"RequestedAtUtc": "2026-03-08T11:30:00Z"
}
]| Field | Type | Description |
|---|---|---|
Id |
int | Auto-incrementing identifier. |
RequestedBy |
string | Username of the user requesting access. |
EntryId |
int | The entry they want to access. |
RequestedAtUtc |
DateTime | When the request was submitted (UTC). |
Requests are removed when approved or declined. There can be at most one pending request per user per entry.
Authentication uses ASP.NET Core's server-side session middleware.
- Sessions expire after 30 minutes of inactivity (configurable in
Program.cs). - The session cookie is
HttpOnlyand marked as essential. - On login, the username is stored in the session under the key
"username". - On logout, the entire session is cleared.
- Every controller action checks for a valid session before serving content; unauthenticated requests are redirected to the login page.
.jsonfiles underApp_Data/are blocked at the middleware level — direct URL access to any.jsonfile returns404 Not Found.
- Can view all entry titles in the list.
- Can read entry contents only for entries they own, entries where the
Usersfield includes them, or entries open to everyone. - Can create new entries (they become the owner).
- Can edit entries they are allowed to read.
- Can request access to entries they cannot read.
- Sees pending access requests for entries they own.
- All regular-user capabilities, plus:
- Can read all entry contents regardless of the
Usersfield. - Can edit all entries.
- Receives no special UI beyond this — there is no separate admin panel.
The Users field on each entry controls who can read its contents. The field is evaluated as follows (first matching rule wins):
Users value |
Who can read the contents |
|---|---|
null or empty |
Private — owner and admins only (default) |
"*" |
Everyone |
"alice, bob" |
alice and bob only (plus owner and admins) |
Regardless of the Users field, the following always have read access:
- The entry owner (
CreatedByfield). - Any user with
IsAdmin: true.
All users can always see the entry title in the listing — only the contents are restricted.
Only users who can read an entry may edit it. This prevents direct URL navigation to /Entries/Edit/{id} from bypassing access control.
Every create or update operation appends a snapshot to the entry's History array. Each snapshot captures the complete state at that moment:
{
"ChangedAtUtc": "2026-03-08T12:00:00Z",
"ChangedBy": "alice",
"Title": "Production DB",
"Details": "host=db.example.com\npassword=newpassword",
"Users": "alice, bob"
}The Details page shows the full change history in a collapsible section, newest version first. If the title changed between versions, the old title is highlighted.
The current entry always reflects the latest version. History is read-only — individual history entries cannot be deleted or edited through the UI.
When a user tries to open an entry they cannot read, they see an Access restricted panel instead of the contents. The panel shows who owns the entry and offers a Request access button.
┌─────────────────────────────────────────────────┐
│ 🔒 Access restricted │
│ │
│ This entry is owned by alice. │
│ You can request access and the owner will │
│ be notified. │
│ │
│ [ Request access ] │
└─────────────────────────────────────────────────┘
Clicking Request access creates a record in requests.json. If a request is already pending, the button is replaced with a notice:
⏳ Request sent — waiting for alice to approve.
A user can have at most one pending request per entry. Submitting again (e.g. by reloading) is silently ignored.
The next time the entry owner loads any page, a red notification badge appears on their avatar in the sidebar footer, showing the count of pending requests. A highlighted link appears below the avatar reading "N pending requests".
Clicking the link opens /Requests, which lists every pending request for entries the current user owns:
┌──────────────────────────────────────────────────────────────┐
│ C charlie │
│ requested access to Production DB │
│ 2026-03-08 11:30 UTC │
│ [ ✓ Approve ] [ ✗ Decline]│
└──────────────────────────────────────────────────────────────┘
When the owner clicks Approve:
- The requesting user's username is appended to the entry's
Usersfield. - The request record is removed from
requests.json. - The user can now read the entry on their next visit.
If the entry's Users field is already null or "*" (everyone has access), the request is simply removed — no change to the entry is needed.
When the owner clicks Decline:
- The request record is removed from
requests.json. - The entry's
Usersfield is not changed — the user still cannot read the entry. - The user may submit a new request in the future (there is no permanent block).
On first run (or whenever a data file is missing), the application creates default data:
| File | Default content |
|---|---|
users.json |
Not auto-created; provision accounts manually |
entries/0001.json |
One encrypted sample entry titled "First Entry" owned by admin, only when no vault exists |
access.json |
Empty array |
requests.json |
Empty array |
There is no generated login credential. Any previously distributed credentials must be replaced in each deployment.
When the application starts it first migrates a legacy vault to individual entry files or rotates existing files using an older retained key, then runs the existing schema migrations. Account passwords migrate separately at successful login:
| Migration | What it does |
|---|---|
EntryFileStore.Initialize |
Stages and verifies individual encrypted files from a plaintext or encrypted entries.json, resumes interrupted migration, and rotates existing entry files to the active key. |
MigrateEntries |
Adds a synthetic (pre-history) history record (with ChangedAtUtc = DateTime.MinValue) to any entry that has no history, preserving the entry's current field values. |
MigrateUsers |
Sets IsAdmin = true on any user whose username is "admin" if the flag is missing or false. |
MigrateEntryOwners |
Assigns CreatedBy to entries where the field is empty. The default owner is the first admin user found in users.json; if no admin exists, the string "admin" is used as a fallback. |
These migrations allow the application to be upgraded in place without manually editing the JSON files.
The external key ring has an ActiveKeyId and a Keys dictionary of key IDs to Base64-encoded 32-byte random keys. The key ring itself is a secret: this protects a copy of App_Data, not a compromised running host with access to both data and keys. Audit records still contain usernames, entry titles, and activity metadata.
- Stop all application processes. Back up
App_Data, includingentries/.layout, and the key ring to separately access-controlled storage. Protect any old plaintext backup as a secret; migration cannot erase historical copies or recoverable disk blocks. - Run
./scripts/New-VaultKeyRing.ps1 -Path $env:Vault__KeyRingPath -Rotate. This retains old keys and creates a new active key. Back up the updated ring separately before restarting. - Restart the app. Startup decrypts each entry file with its recorded key ID, then atomically replaces files using an older key with the active key. Verify entry contents and history and confirm each entry file has the new
KeyId. If rotation is interrupted, files may temporarily use different retained keys; restart resumes rotation. - Keep old keys for as long as backups using them must remain recoverable. Never overwrite an existing key's value. Losing a required key permanently loses the encrypted data.
To restore, stop all application processes, restore a consistent App_Data snapshot (including the entry directory and .layout), restore the corresponding key ring to its external path with restricted permissions, and restart. The ring must include every key ID used by the snapshot. Restore the entire snapshot without mixing old entries.json, staging data, or newer entry files. Retained old keys allow startup to decrypt and rotate an older backup. A missing/incorrect key stops startup without replacing the affected entry; previously rotated entries remain valid. Restore the correct key rather than deleting entry files to bypass the error. Treat users restored from a pre-migration backup as plaintext bootstrap accounts again.
Run dotnet test SecureVault.sln -c Release. The suite uses disposable directories and synthetic accounts; it covers first-login migration, hash precedence and upgrade, failed writes, individual-file encryption, tampering/file swapping, key loss, rotation/restore, interrupted migration, stale forms, concurrent edits/creation in separate processes, process termination, and an actual HTTP login/session across restarts. It does not mutate the workspace's App_Data.
Implementation references: ASP.NET Core Identity password hashing configuration, Identity PasswordHasher implementation, and .NET AES-GCM nonce/authentication requirements.
SecureVault/
├── App_Data/ JSON data files (auto-created at runtime)
│ ├── users.json
│ ├── entries/ One encrypted JSON file per numeric entry ID
│ │ ├── .layout
│ │ └── 0001.json
│ ├── .store.lock Persistent filesystem coordination file
│ ├── access.json
│ └── requests.json
├── Controllers/
│ ├── AccountController.cs Login / logout
│ ├── EntriesController.cs Entry CRUD + access request submission
│ └── RequestsController.cs Approve / decline access requests
├── Models/
│ ├── User.cs
│ ├── Entry.cs
│ ├── EntryHistoryRecord.cs
│ ├── AccessRequest.cs
│ ├── AccessLogEntry.cs
│ ├── EntryListItemViewModel.cs
│ ├── EntryDetailsViewModel.cs
│ ├── AccessRequestViewModel.cs
│ ├── EntryCreateViewModel.cs
│ └── EntryEditViewModel.cs
├── Services/
│ ├── JsonDataStore.cs Data access, access control, history, schema migrations
│ ├── EntryFileStore.cs Individual entry files and resumable legacy migration
│ ├── StoreFileLock.cs Coordination between application processes
│ ├── AtomicFile.cs Flushed atomic file replacement
│ └── VaultEncryption.cs Authenticated encryption and external key ring
├── Views/
│ ├── Account/Login.cshtml
│ ├── Entries/
│ │ ├── Index.cshtml Entry list (all entries, locked ones visually distinguished)
│ │ ├── Details.cshtml Entry detail, access-denied panel, change history
│ │ ├── Create.cshtml
│ │ └── Edit.cshtml
│ ├── Requests/
│ │ └── Index.cshtml Pending requests inbox for entry owners
│ └── Shared/_Layout.cshtml Sidebar, notification badge
└── wwwroot/css/site.css Dark theme, all component styles