Skip to content

Latest commit

 

History

History
446 lines (333 loc) · 26.3 KB

README.md

File metadata and controls

446 lines (333 loc) · 26.3 KB

SecureVault — Secrets Manager

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.


Functionality

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 Users value 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 .json files is blocked by middleware so the data files are not served as static files.

Technology Stack

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

Running the Application

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 run

For 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.


Data Storage

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.

users.json

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 }

entries/0001.json, entries/0002.json, etc.

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.

Concurrent editing

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.

Upgrading an existing entries.json

  1. Stop every old application process and securely back up the complete installed App_Data and external key ring separately. New publish output excludes App_Data; keep the installed data directory in place. An older deployment artifact may still contain data, so do not copy its App_Data over the installation.
  2. Deploy the new binaries and configure the existing key ring (or provision one for a plaintext installation).
  3. 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 original entries.json remains intact throughout staging.
  4. 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 removes entries.json only after success. No automatic backup is retained.
  5. 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.


access.json

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.


requests.json

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

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 HttpOnly and 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.
  • .json files under App_Data/ are blocked at the middleware level — direct URL access to any .json file returns 404 Not Found.

User Roles

Regular user

  • Can view all entry titles in the list.
  • Can read entry contents only for entries they own, entries where the Users field 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.

Administrator (IsAdmin: true)

  • All regular-user capabilities, plus:
  • Can read all entry contents regardless of the Users field.
  • Can edit all entries.
  • Receives no special UI beyond this — there is no separate admin panel.

Per-Entry Access Control

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 (CreatedBy field).
  • Any user with IsAdmin: true.

All users can always see the entry title in the listing — only the contents are restricted.

Editing

Only users who can read an entry may edit it. This prevents direct URL navigation to /Entries/Edit/{id} from bypassing access control.


Change History

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.


Access Request Workflow

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 ]                             │
└─────────────────────────────────────────────────┘

Submitting a request

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.

Owner notification

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]│
└──────────────────────────────────────────────────────────────┘

Approving a request

When the owner clicks Approve:

  1. The requesting user's username is appended to the entry's Users field.
  2. The request record is removed from requests.json.
  3. 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.

Declining a request

When the owner clicks Decline:

  1. The request record is removed from requests.json.
  2. The entry's Users field is not changed — the user still cannot read the entry.
  3. The user may submit a new request in the future (there is no permanent block).

Seed Data

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.


Automatic Data Migrations

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.

Key Rotation, Backup, and Recovery

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.

  1. Stop all application processes. Back up App_Data, including entries/.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.
  2. 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.
  3. 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.
  4. 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.

Validation

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.


Project Structure

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