Security Model
hypercal owns its authentication — there is no external IdP, no forward-auth, no reverse-proxy SSO. The proxy in front of it only terminates TLS. That means the app's own credential handling is load-bearing, so it's worth knowing how it works.
The primitives live in core/src/lib/security/, one module per credential type, none of them talking to each other. These modules only establish identity.Who may read or write which calendar is a separate concern, centralized in core/src/lib/access.ts.
Credentials
| Credential | Module | Storage |
|---|---|---|
| Account password | passwords.ts | scrypt hash |
| Session token | sessions.ts | sha256 of a 32-byte CSPRNG token |
| TOTP secret | totp.ts | stored, RFC 6238 |
| TOTP recovery code | totp.ts | scrypt hash, single use |
| Passkey | webauthn.ts | public key + counter |
| App password | appPasswords.ts | sha256 of a 32-byte CSPRNG token |
| Remote CalDAV password | secretBox.ts | AES-256-GCM, sealed with CALDAV_SECRET |
Why the hashing isn't uniform
It looks inconsistent. It isn't, and it shouldn't be "fixed":
- scrypt for account passwords and recovery codes. These are user-chosen or low-entropy, so someone with the database could guess them offline. A deliberately slow hash is the entire defence.
- sha256 for session tokens and app passwords. These are 32 bytes from a CSPRNG — there is nothing to guess, and brute-forcing 256 bits doesn't become feasible because the hash is fast. The hash exists only so a database leak can't be replayed directly.
Making the fast ones slow would add a scrypt computation to every authenticated request and every CalDAV call without adding security: a self-inflicted denial of service.
Sign-in flows
Password. POST /api/auth/login. If TOTP is enabled the response is a short-lived pending login (pending_logins) rather than a session; the second step is POST /api/auth/login/totp with a TOTP code or a single-use recovery code.
Passkey. POST /api/auth/passkey/login/options → …/verify, WebAuthn via @simplewebauthn. Passkeys work alongside passwords, not instead of them. WEBAUTHN_ORIGIN is required in production and the server refuses to start without it: it is the expectedOrigin an assertion is verified against, and deriving that from the caller's own Origin header would compare a value with itself. WEBAUTHN_RP_ID defaults to that origin's hostname.
Session. An opaque token in an HttpOnly, SameSite cookie; Secure when NODE_ENV=production. requireAuth resolves it to a users.id and sets c.get("user"). A password change revokes all of that user's other sessions.
App passwords are the CalDAV path only: HTTP Basic against /dav, revocable individually, never the account password. They're shown once at creation and stored hashed.
Rate limiting
Two layers, because they answer different attacks.
Per IP — fixed window (rateLimit.ts)
| Endpoint | Limit |
|---|---|
POST /api/auth/login | 10 / min |
POST /api/auth/login/totp | 10 / min |
POST /api/auth/register | 5 / min |
POST /api/auth/passkey/login/verify | 20 / min |
PATCH /api/me | 10 / min |
Behind a reverse proxy this reads the proxy's address unless you set TRUST_PROXY=1, which makes it use X-Forwarded-For. Without that, the throttle is effectively global rather than per-client. State is in memory, so it resets when the process restarts.
Per username — escalating delay (loginThrottle.ts)
An IP limiter cannot see the attack that matters for a password: a few attempts each from many hosts, all against one account. So failures are also counted against the account, in SQLite — surviving restarts, and independent of where they came from.
Five failures are free. Each one after that imposes a delay before the next attempt is accepted, doubling from 2 s and capping at 5 minutes, which settles sustained guessing at roughly 12 attempts an hour per account.
Deliberately a throttle, not a lockout: a lockout would let anyone who knows a username lock its owner out of their own calendar. A targeted user sees slow sign-ins, and their passkey — which does not touch this path — still works.
Three separate counters, so one credential's failures never slow another: password (pw:), TOTP/recovery code (totp:), and CalDAV Basic auth (dav:). The CalDAV one counts only failures, leaving polling clients that authenticate correctly untouched at any frequency.
Attempts against usernames that do not exist are recorded exactly like real ones, so a throttled response never reveals whether an account is there.
Transport and headers
Applied to every response (secureHeaders in core/src/app.ts):
- CSP:
default-src 'self',script-src 'self',object-src 'none',base-uri 'self',frame-ancestors 'none',img-src 'self' data: blob:,connect-src 'self'.style-srcallows'unsafe-inline'because Vite/Tailwind inject<style>tags. form-action 'self', so a form on the page cannot post elsewhere.X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy: no-referrer.- HSTS (
max-age=15552000; includeSubDomains) in production only — dev runs over plain HTTP.
Request bodies on /api/* are capped at the hard attachment ceiling + 1 MB (413 past that); the attachment route enforces the finer admin-configured per-file limit on top.
User-supplied CSS
A user's custom theme (Settings → Appearance) carries a free-form CSS string. It is deliberately not sanitized, and that is defensible here:
- It is per-account.
/api/me/settingsis scoped to the authenticated user, so nobody can set a theme for anyone else. The author and the audience are the same person. - The server treats it as opaque JSON. It is stored, echoed back to its own author, and never rendered into an HTML response — which is the rule that keeps it out of XSS territory, since
</style><script>in a server-rendered<style>block would be a stored-XSS sink and that one would be cross-user. - The client injects it as
textContenton a single<style>element, neverinnerHTML.style-srcalready allows'unsafe-inline'for Vite/Tailwind, so nothing new was opened up. - CSS exfiltration by attribute selector needs a remote fetch, and there is none:
default-src 'self',img-src 'self' data: blob:andfont-src 'self'refuseurl(https://…),@import url(https://…)and a remote@font-face. A stripper for those would be theatre, and a trivially bypassed one.
Two caveats worth stating plainly. These headers come from core, so the Vite dev server and the Capacitor WebView (which serves the document from the asset loader, with no CSP) do not carry them. And the realistic risk is not attack but self-inflicted lockout: a rule that hides the page also hides the settings panel. ?safemode=1 skips the injection, in the pre-paint script as well as at runtime, so recovery never requires clearing site data — which would take the offline queue and the encryption state with it.
Cross-site request forgery
The session cookie is SameSite=Lax, which blocks cross-site POSTs in mainstream browsers — but Lax counts sibling subdomains as same-site, so an instance at calendar.example.com is not isolated from other content under example.com. middleware/csrf.ts closes that: a state-changing /api request (POST/PUT/PATCH/DELETE) whose Origin header is present and is not this instance's origin gets a 403.
A missing Origin is allowed on purpose. Browsers always send it on cross-origin state-changing requests, form submissions included, and a malicious page cannot suppress it — so a request without one did not come from a browser being tricked, and rejecting it would only break curl, scripts and the native Android client. The allowed set is WEBAUTHN_ORIGIN, plus https://localhost (the Capacitor app's own origin), plus anything in CSRF_ALLOWED_ORIGINS.
/dav is exempt: it authenticates per request with HTTP Basic instead of a cookie, so there is no ambient authority for a forged request to borrow.
SSRF protection
Subscription feeds and CalDAV links fetch URLs the user supplies, so core/src/lib/ssrf.ts blocks private, loopback, link-local, and cloud-metadata addresses — validated across redirects, not just on the initial URL.
End-to-end encryption
Optional, per calendar. When a calendar is marked encrypted, its event content is encrypted in the browser and the server stores only ciphertext.
- One random 32-byte data key (DEK) per user, wrapped in slots (
user_encryption.bundle, schema inshared/src/encryption.ts). - Slot kinds:
password(KEK = PBKDF2-SHA256 over the login password) andrecovery(KEK = PBKDF2-SHA256 over a one-time recovery code). Minimum 100 000 iterations, enforced by the schema.wrappedDekisbase64(iv ‖ ciphertext ‖ tag), AES-GCM. - The password is the only unlock method. A legacy
passkeyslot kind (KEK via a WebAuthn PRF output) was removed; the client stops writing them and prunes them on the next write. - Changing the password re-wraps the password slot — stored ciphertext stays valid, so a password change is not a re-encryption event.
/api/me/encryptionstores and serves the bundle verbatim: that endpoint never sees a credential or the unwrapped DEK.
What this does and does not defend against
Read this before assuming it is zero-knowledge — it is not.
The KEK is derived from the login password itself, not a separate passphrase. That same password is sent to the server on every sign-in and verified there with scrypt. So while /api/me/encryption never sees a credential, /api/auth/login sees exactly the one the KEK comes from.
The consequence, stated plainly:
- Protects against disclosure at rest — a stolen disk, a leaked backup, a copied database file, a curious host who reads the volume. This is the threat the feature exists for.
- Does not protect against a compromised or malicious running server. An attacker in control of the server can log the password at
POST /api/auth/login, fetch the bundle, derive the KEK and unwrap the DEK — for every user who signs in after the compromise. Users who never sign in again keep their data sealed, because their password is never transmitted.
This is a deliberate trade-off, not an oversight: it buys "no second passphrase to remember and lose", which is the trade this project chose for a family instance, where the operator is generally the data owner. If you are hosting for people who should be protected from you, this feature does not do that, and a genuine zero-knowledge mode would require a separate passphrase that never reaches the server.
Consequences of the server not holding plaintext
- Encrypted calendars are excluded from ICS and
.hypercalendarexport, and from the/davCalDAV server. - They cannot be linked to a remote CalDAV collection.
- Search runs against a blind index, not plaintext — see below.
- If both the password and every recovery code are lost, the data is unrecoverable. That's the point.
The blind search index and what it leaks
Encrypted events are still searchable. The client derives a searchKey from the DEK (HKDF, hypercalendar-search-v1) and stores one row per token in event_search_tokens: a deterministic truncated HMAC (10 bytes) of each normalised trigram of the title, description and location. A query is tokenised the same way and matched server-side by equality.
No plaintext is stored. But this is searchable symmetric encryption, and it has a well-studied leakage profile that "no plaintext" does not convey:
- Trigram frequency. Tokens are deterministic per user and natural-language trigram frequency is heavily skewed, so a server holding enough of one user's index can map tokens back to trigrams with reasonable confidence and reconstruct substrings.
- Volume. The number of distinct tokens on an event approximates how much text it contains.
- Query and access patterns. Search requests reveal which tokens a user searched for and which events matched.
Against the threat this feature targets — disclosure at rest — a single database snapshot leaks frequency and volume but no query patterns. Against a server observing traffic over time, it leaks meaningfully more. That is inherent to having server-side search over encrypted data at all; the alternative is downloading everything and searching client-side.
secretBox.ts is the deliberate opposite case: a secret the server must be able to read back, because background CalDAV sync has to authenticate while nobody is signed in. Hence CALDAV_SECRET.
Authorization
Identity established, core/src/lib/access.ts decides access. Every handler scopes its queries by the authenticated users.id. The exceptions, all deliberate and all requiring a valid session:
- the explicit sharing paths (
calendar_shares,event_shares,event_attendees), - the free/busy endpoint, which returns opaque busy intervals,
- the user directory,
GET /api/avatars/:userId, which serves any user's profile picture — avatars have to render beside attendees and shared calendars to be worth having.
/api/admin/* additionally requires users.is_admin, which is set on the first registered account. Admins can close registration, and read/create database snapshots.
Threat model
Built for self-hosting for an owner and a few family members, behind a TLS-terminating proxy. Some behaviors are intentional and become the operator's problem:
- Free/busy is shared across all users of an instance. "Find a time" exposes other registered users' busy intervals — opaque, no titles or details — by design. Only register accounts you trust. Users can remove themselves from the invite directory with
discovery_disabled, but that is a directory setting, not an access control. - Data at rest is not encrypted (outside the optional per-calendar E2E feature above). SQLite stores plaintext on disk, attachments and avatars included. Use an encrypted volume if the host is untrusted, and restrict the database file's ownership and permissions to the app user.
- Backups are the operator's responsibility. Nothing leaves the host on its own.
AUTH_DEV_AUTOLOGINcannot be set in production. It promotes every session-less request to a seeded admin user.NODE_ENV=productionplus this flag is now a fatal configuration error rather than a warning — the server exits at boot. The Playwright suites useNODE_ENV=e2e, which behaves like production but permits the bypass.WEBAUTHN_ORIGINis required in production. It is the origin a passkey assertion is verified against; without it the server would fall back to the caller's ownOriginheader and check a value against itself. Also fatal at boot when missing.- Secure cookies, HSTS and static SPA serving engage under
NODE_ENV=productionore2e(isProdLike).
Reporting a vulnerability
Do not open a public issue. Open a confidential issue on this project, or email the maintainer. Include a description, reproduction steps, affected version or commit, and impact. Expect an initial response within a few days; please allow reasonable time for a fix before disclosure. Full policy, including how to verify a signed release, is in the Security policy.