Skip to content

Repository files navigation

NaCl

Backend CI Frontend CI Go React License: MIT Live Demo

A single-binary password manager implementing envelope encryption (AES-256-GCM over an Argon2id-derived key) with a Go HTTP backend serving an embedded React SPA.

Live demo: https://nacl-g5mw.onrender.com

login

What is it?

NaCl is a password manager. You create an account, you store the credentials you use to log into other services (your email, your social media, anything), and NaCl keeps them encrypted so they're unreadable to anyone who gets hold of the database.

The name comes from the chemical formula for salt, and a cryptographic salt is what NaCl uses to protect the key that encrypts your data.

One design choice is worth flagging up front: if you ever change your login password, none of your stored credentials need to be re-encrypted. The cost of a password rotation is constant, whether you've saved five credentials or five hundred. The rest of this document explains how that works.

Overview

NaCl is a monolithic web application: a Go HTTP backend serving a React 19 single-page application, both compiled into one self-contained binary.

The architecture is recorded as a set of Architecture Decision Records (ADRs) in docs/decisions.md, and the encryption design is fully documented in nacl_backend/docs/encryption_flow.md. The repository follows strict conventional commits and is exercised by two path-scoped GitHub Actions workflows.

Table of Contents

  1. What is it?
  2. Overview
  3. Features
  4. How it works at a glance
  5. The crypto in short
  6. Envelope encryption and key rotation
  7. DTO contracts: Go structs and Zod schemas
  8. Tech stack
  9. Architecture
  10. Observability
  11. Quickstart
  12. Development
  13. CI / CD
  14. Disclaimers
  15. Roadmap
  16. Further reading
  17. Author & License

Features

  • Account management: register, log in, and rotate the login password.
  • Credential vault: store service credentials with an optional description and encryption algorithm tag.
  • Re-authentication on access: every encryption or decryption operation requires the user to re-enter the login password.
  • Atomic key rotation: changing the login password re-encrypts only the master key; credential rows are untouched.
  • Audit log: create, update, and delete operations are recorded per user and listed chronologically.
  • Single-binary deployment: frontend and backend ship as one Go binary in an alpine runtime image.

Credential Form

new

Vault

vault

Account Page

account

How it works at a glance

You register an account with a login password. Behind the scenes NaCl:

  • Generates a 32-byte salt and a 32-byte random master key.
  • Derives a wrapping key from your password + salt with Argon2id.
  • Encrypts the master key with that wrapping key using AES-256-GCM.
  • Stores the Argon2id password hash, salt, and encrypted master key, never your password.

When you save a credential (say, your Gmail password), NaCl:

  • Asks you to re-enter your login password (re-authentication).
  • Decrypts the master key in memory.
  • Encrypts the service username and password with the master key.
  • Writes the ciphertext to the database and logs the operation to the audit table.

When you change your login password, only the master key is re-encrypted; your stored credentials stay exactly as they are.

The crypto in short

Two keys, not one:

  • A random master key encrypts your credentials.
  • Your login password derives a wrapping key (via Argon2id) that encrypts only the master key.

So when you change your login password, you only re-encrypt the master key: O(1), not O(vault size). The full design, including the registration, decryption, and rotation flows in detail, is in nacl_backend/docs/encryption_flow.md.

Envelope encryption and key rotation

The two-key design earns its keep on password rotation. With a naive scheme that derives the encryption key directly from the login password, rotating your password means re-encrypting every credential row. NaCl avoids that: the master key does the bulk encryption work, and the login password only ever wraps the master key.

Registration

  • Generate a 32-byte salt and a 32-byte random master key.
  • Derive a wrapping key from the password + salt with Argon2id.
  • Encrypt the master key with the wrapping key using AES-256-GCM.
  • Store the Argon2id password hash, salt, and encrypted master key in the users table.

Decryption (per operation)

  • Verify the login password against the Argon2id hash.
  • Derive the wrapping key from the password + stored salt.
  • Decrypt the master key in memory.
  • Decrypt the credential's service username and password with the master key.

Rotation

  • Verify the old login password.
  • Decrypt the master key with the old wrapping key.
  • Derive a new wrapping key from the new password.
  • Re-encrypt the master key with the new wrapping key.
  • Update the password_hash and encrypted_master_key atomically in one transaction.

Credential rows are never read or written during rotation. The cost of a rotation is independent of how many credentials you have stored.

Full design document and rationale: nacl_backend/docs/encryption_flow.md. ADRs covering crypto choices: docs/decisions.md.

DTO contracts: Go structs and Zod schemas

The Go struct in nacl_backend/internal/dto/dto.go is the single source of truth for request and response shapes. The frontend mirrors those shapes with Zod schemas in nacl_frontend/src/lib/: one set for requests, one for responses.

The crosswalk between the Go DTOs and the Zod schemas is documented in docs/dto-schema-map.md. If the two sides drift, the Zod schemas' .strict() mode rejects unknown keys at runtime, so a mismatch surfaces as a failing request rather than silent acceptance of an unexpected shape.

The same DTO types feed the generic DecodeAndValidate[T] helper used by every handler: the request is decoded into a typed struct and validated in one step, before the service layer is called. That keeps the handler layer doing only HTTP work, with no parsing or validation logic scattered across it.

Where to look:

For a full field-by-field mapping between the Go and TypeScript sides, see docs/dto-schema-map.md.

Tech stack

Backend

Written in Go. HTTP routing via chi, PostgreSQL access via pgx with sqlc query generation, migrations via goose, JWT auth (HS256), and Argon2id password hashing. AES-256-GCM is used for symmetric encryption of credentials and the master key.

Frontend

Written in TypeScript on React 19, built with Vite. Forms via react-hook-form, runtime validation via zod (mirroring the backend DTOs), HTTP via axios. Plain CSS for styling.

Infrastructure

  • PostgreSQL for persistence.
  • Docker for the runtime.
  • Migrations run on application startup; no manual SQL step is needed.

Architecture

flowchart LR
    Browser["Browser\nReact SPA\n(Vite dev → proxies /api to backend)"] -->|HTTP /api/*| Router["Router"]
    Router --> MW["Middleware\nlogging → recovery → JWT validation"]
    MW --> Handler["Handler\n1. decode + validate DTO\n2. call service\n3. map errors to HTTP"]
    Handler --> Service["Service\nusers · credentials · operations\ncrypto orchestration"]
    Router --> Static["Embedded static FS\nSPA fallback"]
    Static --> Browser
Loading

Three properties worth pointing out in this diagram:

  1. The handler layer is pure HTTP. Neither the database nor the encryption layer appears between the handler and the service. Handlers do only DTO decode + validate, call the service, and map errors to HTTP status codes.
  2. The embedded filesystem serves the SPA from the same binary. Client-side routes resolve on direct navigation or refresh, not only on client-side clicks.
  3. The service is the crypto orchestrator. Every operation that touches plaintext lives in the service, so the handler layer cannot accidentally leak plaintext or call crypto directly.

The layered design enforces separation of concerns by dependency direction. Each layer depends only on the one below it and exposes a narrow interface to the one above: the router dispatches to handlers, handlers speak only HTTP and DTOs, the service owns business logic and crypto orchestration, and the data layer is consumed by the service alone. Errors defined in the service are caught at the handler boundary and mapped to HTTP status codes, so transport concerns never leak into business logic and vice versa.

Architectural decisions are recorded as ADRs in docs/decisions.md.

Observability

NaCl uses Go's log/slog package for structured logging throughout the request lifecycle, with a dual-handler design that separates developer-facing diagnostics from operational log output.

Dual-handler logger

On startup the server initialises a slog.Logger backed by two handlers running simultaneously via slog.NewMultiHandler:

  • A text handler writing to stderr at Debug level -- human-readable output for local development and troubleshooting.
  • A JSON handler writing to a configurable log file at Info level -- machine-parseable output suited for ingestion by log aggregators.

Because the two handlers are independent, debug-level diagnostics appear on stderr during local work while only Info-and-above entries reach the log file.

Request-scoped logging

Every HTTP request passes through the RequestLogger middleware, which captures and logs the method, path, response status code, client IP, and wall-clock duration. A typical request produces:

{"time":"2026-06-12T18:09:25.863776373-06:00","level":"INFO","msg":"Served request","method":"GET","path":"/","status":200,"client_ip":"100.127.73.104","duration":6110}

The status code is captured via a wrapper around the standard ResponseWriter, so the log entry always reflects the final status code regardless of where in the handler chain the request terminated. Durations are reported in nanoseconds.

Structured error context

Errors carry structured attributes through the apperr package. The logger's replaceAttr function intercepts any attribute whose key is "error", unwraps the error chain, and emits the full chain as a group:

{
  "time": "2026-06-12T20:56:18.760336969-06:00",
  "level": "ERROR",
  "msg": "could not create user",
  "error": {
    "message": "duplicate key: users_username_key",
    "username": "some username",
    "endpoint": "POST /api/users"
  }
}

The apperr.WithAttrs helper attaches key-value pairs at the call site, and the logger surfaces them automatically without special handling in the middleware or handler layers.

Panic recovery

The Recovery middleware catches panics from downstream handlers, logs the panic value as a structured error, and writes a 500 response. Recovery at this boundary ensures a single panicking request cannot bring down the process.

Audit log

Create, update, and delete operations on credentials are recorded in a dedicated operations database table, keyed by user. The audit log provides an immutable history of vault changes independent of the request log stream, accessible through GET /api/operations.

Graceful shutdown

On receiving SIGINT or SIGTERM, the server drains in-flight requests within a 5-second timeout window before exiting. Each shutdown phase is logged at Debug level so the sequence is traceable in the output.

Quickstart

Option A: Docker Compose (recommended)

Requires Docker. Build and start both services:

git clone https://github.com/ManoloEsS/NaCl.git
cd NaCl
docker compose up --build

PostgreSQL starts first with a health check, then NaCl connects, runs migrations automatically, and serves the SPA on port 3333.

The container ships the frontend embedded in the Go binary; no separate web server is required.

# Run in the background
docker compose up --build -d

# Stop and remove the containers
docker compose down

Important notes:

  • No persistent volume. Postgres data is stored inside the container and will be lost when the container is removed. For evaluation and development this is fine, but add a named volume if you need data to survive docker compose down.
  • Change the JWT secret. The default value in docker-compose.yml is a placeholder. Replace JWT_SECRET: "change-me-to-a-secret" with a strong random value before storing real data.
  • Log output. The text handler writes to stderr (visible in docker compose logs). JSON logs go to a file inside the container by default; set SALT_LOG_FILE in docker-compose.yml to redirect them.

Option B: Full local dev environment

Requires Go, Node, Docker, and the tools goose, sqlc, and air installed via go install ...@latest.

# From nacl_backend/: starts Postgres, applies migrations, hot-reloads Go
git clone https://github.com/ManoloEsS/NaCl.git
cd NaCl/nacl_backend
make dev-full
# From nacl_frontend/: Vite dev server on :5173, proxies /api to :3333
cd NaCl/nacl_frontend
npm install
npm run dev

See nacl_backend/docs/dev_env_config.md for the full prerequisites list.

Option C: Manual build

# Build frontend into nacl_backend/static
cd nacl_frontend && npm ci && npm run build

# Build the Go binary (embeds static/)
cd ../nacl_backend && go build -o nacl .
./nacl

Development

The local development workflow uses Docker for PostgreSQL. You can also point DATABASE_URL at any existing Postgres instance and skip the make db-* commands below.

Environment variables

Variable Required Description
DATABASE_URL yes PostgreSQL connection string for the dev database.
DATABASE_URL_TEST tests PostgreSQL connection string for the test database.
JWT_SECRET yes (random fallback) HS256 signing key. A random value is generated if missing.
SALT_PORT no Listen port. Falls back to PORT, then a platform default.
PORT no Alternate listen port (Render compatibility).
SALT_LOG_FILE no Path to the request log file.
DB_SSL no Enables SSL on the database connection.

A working .env template is included and exported by the Makefile at the start of every invocation.

Backend (nacl_backend/)

Target Effect
make dev Hot-reload via air.
make dev-full Start a Docker PostgreSQL container, create databases, migrate, then make dev (requires Docker).
make db-start db-stop db-restart db-reset Start/stop/restart a Docker PostgreSQL container (requires Docker).
make db-migrate db-down db-status Goose migrations.
make db-new name=add_users Create a new timestamped migration.
make db-test-clean Start an isolated Docker PostgreSQL container on port 5433 and apply migrations (requires Docker).
make build run clean Compile, run, or remove the binary.
make test Reset test DB and run go test ./... through tparse.
make lint fmt fmt-check ci Mirror the CI checks.

Frontend (nacl_frontend/)

Script Effect
npm run dev Vite dev server.
npm run build tsc -b && vite build into ../nacl_backend/static.
npm run lint ESLint.
npm run format Prettier.
npm test / npm run test:ui Playwright E2E.

API reference

For the full list of API endpoints with request and response shapes, see docs/dto-schema-map.md.

CI / CD

Two workflows under .github/workflows/:

  • backend-ci.yml runs on PRs to main touching nacl_backend/**. It lints with golangci-lint, then spins up a PostgreSQL service container, runs migrations and sqlc generate, builds, and runs go test -race ./....
  • frontend-ci.yml runs on push and PR to main touching nacl_frontend/**. It runs ESLint and tsc --noEmit, then Playwright, then a production build.

Both workflows are path-scoped, so a frontend-only change does not trigger the backend pipeline and vice versa.

See docs/ci_cd.md.

Disclaimers

This is a learning project and is explicitly not production-ready. Please do not store real credentials in it.

Intentional MVP exclusions

The following are documented as out of scope for this MVP and recorded in docs/decisions.md and docs/user_stories.md:

  • No rate limiting on authentication endpoints.
  • No second-factor authentication.
  • No token blacklist; logout is client-side only.
  • No email verification or account lockout.
  • No CAPTCHA.

Accepted trade-offs

  • Server-side encryption. The master key exists in backend memory for the duration of each operation, so NaCl does not satisfy the strict zero-knowledge threat model of client-side encryption schemes. A compromised server can exfiltrate keys. This is acknowledged openly in nacl_backend/docs/encryption_flow.md.
  • No ciphertext padding. AES-GCM ciphertext length leaks plaintext length. Acceptable for short password fields and consistent with industry practice, but documented as such.

Roadmap

Things we plan to work on in the future:

  • 2FA / WebAuthn
  • Server-side token blacklisting
  • Rate limiting and account lockout
  • Email verification and password reset
  • True client-side encryption for a zero-knowledge posture
  • Additional encryption algorithms to choose from

Further reading

The remaining files under docs/ and nacl_backend/docs/ are bonus material on specific topics (serving React from Go, error handling, migrations, frontend setup, CSS patterns).

Author & License

Built by Manolo Estrada (@ManoloEsS).

Licensed under the MIT License.

About

Password manager that implements envelope encryption.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages