Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9cfa4ce
Merge pull request #1 from syscoin/main
sidhujag Apr 20, 2026
d9c00da
Reapply "feat(backend): test-driven foundation for auth+vault PR 1"
Apr 20, 2026
5c7c52e
Reapply "refactor(backend): kdf -> HMAC-SHA256 with env pepper; schem…
Apr 20, 2026
a2abfa7
Reapply "feat(backend): pluggable mailer with verification + vote-rem…
Apr 20, 2026
c078a5a
Reapply "feat(backend): sessions, CSRF, rate limits, and auth routes …
Apr 20, 2026
2886618
Reapply "feat(backend): vault routes + server.js wiring with dual-COR…
Apr 20, 2026
4b1ee70
fix(rate-limit): canonicalize email in limiter keys (Codex P1)
Apr 20, 2026
a5c2688
fix(auth): verification links target the frontend route (Codex P1)
Apr 20, 2026
e6a417d
fix(sessions): refresh sid + csrf cookie expiries on each request (Co…
Apr 20, 2026
4bafe7d
fix: address Codex round-2 findings (IPv6 limiter, wildcard ETag, out…
Apr 20, 2026
54e8e9f
fix: address Codex round-3 findings (SQL-enforced ETag, trust proxy)
Apr 20, 2026
4a245bb
fix(auth): defer credential binding until email verification (Codex r…
Apr 20, 2026
58a2e4e
fix(auth): rescue legacy unverified users rows on verify-email (Codex…
Apr 20, 2026
626f98e
fix(auth,mailer): fail loudly on config/register errors (Codex round 6)
Apr 20, 2026
ea99ec7
fix(kdf,auth): surface pepper config failures as 503, not silent 401 …
Apr 20, 2026
526e8e8
fix(mailer): validate SMTP config eagerly at createMailer (Codex roun…
Apr 20, 2026
3a96a5d
fix(auth): replace async-handler throws with controlled 500 + add err…
Apr 20, 2026
7d0a87e
fix(auth): wrap async handlers with asyncHandler so all rejections ro…
Apr 20, 2026
5f0137f
fix(auth): make /verify-email and /change-password atomic via DB tran…
Apr 20, 2026
7c7872e
fix(csrf,auth): guard CSRF compare against multibyte headers; always …
Apr 20, 2026
4b3074c
fix(auth): send verification mail to normalized email (Codex round 12)
Apr 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# ------------------------------------------------------------
# sysnode-backend environment
# Copy this file to `.env` and fill in values. `.env` is gitignored.
# ------------------------------------------------------------

# Express server
PORT=3001
BASE_URL=http://localhost:3001
# Origin of the SPA; used for credentialed CORS on /auth and /vault and as
# the destination for email-verification magic links. Falls back to
# CORS_ORIGIN when FRONTEND_URL is unset.
CORS_ORIGIN=http://localhost:3000
FRONTEND_URL=http://localhost:3000
NODE_ENV=development

# Reverse-proxy awareness. Without this, Express treats the proxy's socket
# address as req.ip and every real client collapses into a single rate-limit
# bucket (so a few failed logins throttle everyone).
# - "loopback" : trust 127.0.0.1 / ::1 only (safe dev default)
# - "1" : single proxy hop (typical nginx in front)
# - "2" : two hops (e.g. CDN -> LB -> app)
# - "10.0.0.0/8": trust a specific CIDR
# - "true" : trust everything (ONLY for closed networks you control)
TRUST_PROXY=loopback

# SQLite database file (relative paths are resolved against CWD)
SYSNODE_DB_PATH=./data/sysnode.db

# Auth pepper: 32+ random bytes in hex. REQUIRED in production.
# Generate with: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`
SYSNODE_AUTH_PEPPER=

# SMTP (required for email verification + vote reminders).
# In production the server refuses to start unless either SMTP_HOST is set
# or MAIL_TRANSPORT=log is set explicitly (stdout-only dry-run).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
MAIL_FROM=no-reply@syscoin.dev
# Optional explicit override. Valid values: smtp | log | memory.
# Leave unset to auto-select (smtp if SMTP_HOST is set, otherwise log in
# dev / hard-fail in production).
MAIL_TRANSPORT=

# Syscoin Core RPC
SYSCOIN_RPC_HOST=localhost
SYSCOIN_RPC_PORT=8370
SYSCOIN_RPC_USER=
SYSCOIN_RPC_PASS=
SYSCOIN_RPC_LOG_LEVEL=error
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
coverage/
.env
.env.local
*.sqlite
*.sqlite-journal
data/sysnode.db*
.DS_Store
npm-debug.log*
56 changes: 56 additions & 0 deletions db/migrations/001_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
-- Migration 001: users, email_verifications, sessions, vaults.
--
-- Notes on design:
-- * All PKs are surrogate integers. Email is a natural key but we keep it as
-- a unique column to simplify reassignment and case handling.
-- * email is stored already-normalized (trim + lowercase + NFKC by the app).
-- * stored_auth is the argon2id-encoded form of the client-sent authHash.
-- * email_verified defaults 0; vault writes/reads are gated on verification.
-- * vaults is 1:1 with users and stores ONLY the encrypted blob, the public
-- per-user vault salt (saltV), and a short etag to detect stale clients.
-- * sessions table is kept for auditability/revocation; the cookie value is
-- stored hashed so DB dump does not yield session-stealing tokens.

CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
stored_auth TEXT NOT NULL,
email_verified INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);

CREATE INDEX idx_users_email ON users(email);

CREATE TABLE email_verifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
consumed_at INTEGER,
created_at INTEGER NOT NULL
);

CREATE INDEX idx_email_verifications_user ON email_verifications(user_id);

CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
user_agent TEXT,
ip TEXT
);

CREATE INDEX idx_sessions_user ON sessions(user_id);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);

CREATE TABLE vaults (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
salt_v TEXT NOT NULL,
blob TEXT NOT NULL,
etag TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
43 changes: 43 additions & 0 deletions db/migrations/002_notifications.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
-- Migration 002: vote-reminder infrastructure.
--
-- Privacy model:
-- * `notification_prefs` on users is a JSON blob holding per-user toggles.
-- voteReminders defaults to 0 (OFF). If the user never turns it on, no
-- outpoints are stored and there is no email<->MN correlation in our DB.
-- * `tracked_masternodes` stores public collateral outpoints the user opted
-- in to track. Populated only when voteReminders is enabled and the user
-- explicitly marks keys as tracked.
-- * `vote_reminder_log` is idempotency: one row per (user, proposal, bucket)
-- ensures we don't re-send the same 1-week / 3-day / 1-day reminder.
-- Retained for ~90 days via a cleanup job.
--
-- Why not encrypt outpoints: they are public on-chain and the correlation
-- being stored here is the whole point of the opt-in feature. Documented
-- explicitly in the UI toggle copy.

ALTER TABLE users ADD COLUMN notification_prefs TEXT NOT NULL DEFAULT '{}';

CREATE TABLE tracked_masternodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
collateral_txid TEXT NOT NULL,
collateral_vout INTEGER NOT NULL,
label TEXT,
created_at INTEGER NOT NULL,
UNIQUE(user_id, collateral_txid, collateral_vout)
);

CREATE INDEX idx_tracked_mn_user ON tracked_masternodes(user_id);
CREATE INDEX idx_tracked_mn_outpoint
ON tracked_masternodes(collateral_txid, collateral_vout);

CREATE TABLE vote_reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
proposal_hash TEXT NOT NULL,
bucket TEXT NOT NULL,
sent_at INTEGER NOT NULL,
UNIQUE(user_id, proposal_hash, bucket)
);

CREATE INDEX idx_vote_reminder_sent ON vote_reminder_log(sent_at);
51 changes: 51 additions & 0 deletions db/migrations/003_pending_registrations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- Migration 003: pending registrations (deferred credential binding).
--
-- Before this migration, POST /auth/register wrote the submitted authHash
-- directly into users.stored_auth with email_verified = 0. That let an
-- attacker pre-register a victim's email with the attacker's own
-- credential; the victim would later click their verification link and
-- end up with an account bound to the attacker's hash. (Codex P1 in
-- https://github.com/syscoin/sysnode-backend/pull/2.)
--
-- Under the new model:
-- - /auth/register inserts a row into pending_registrations instead of
-- mutating users. Each call issues a new row with its own token.
-- - /auth/verify-email redeems a pending row by token_hash, creates the
-- user already-verified using the stored_auth captured on that row,
-- and purges all other pendings for that email.
-- That makes it impossible for an attacker's earlier pending row to land
-- credentials on a real (post-verify) user account.
--
-- stored_auth here is the same HMAC(authHash, pepper) hex string used in
-- users.stored_auth, NOT the raw client-submitted authHash. We keep the
-- pepper layer even on the pending row so a DB leak on its own does not
-- yield authentication material.

CREATE TABLE IF NOT EXISTS pending_registrations (
token_hash TEXT PRIMARY KEY,
email_normalized TEXT NOT NULL,
stored_auth TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_pending_registrations_email
ON pending_registrations(email_normalized);

CREATE INDEX IF NOT EXISTS idx_pending_registrations_expires
ON pending_registrations(expires_at);

-- One-time backfill: the pre-deferred /auth/register flow inserted users
-- rows with email_verified = 0 before email ownership was proven. Those
-- rows are untrusted (their stored_auth belongs to "whoever submitted
-- /register last" for that email, not to a verified account owner) AND,
-- under the new flow, they become permanently stranded: verify-email
-- no longer touches pre-existing rows, so the affected user could never
-- complete email verification without manual DB surgery. (Codex round-5
-- P1 in syscoin/sysnode-backend#2.)
--
-- We delete them here. Any user whose verification was in-flight at
-- deploy time simply re-registers — no data is lost (the account never
-- had verified credentials to begin with). Rows with email_verified = 1
-- are preserved untouched.
DELETE FROM users WHERE email_verified = 0;
Loading