Security Model
hyper-calendar 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. Behind a proxy set WEBAUTHN_RP_ID and WEBAUTHN_ORIGIN or verification will fail.
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
Fixed-window, keyed by client IP (rateLimit.ts):
| Endpoint | Limit |
|---|---|
POST /api/auth/login | 10 / min |
POST /api/auth/login/totp | 10 / min |
POST /api/auth/register | 5 / 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.
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. 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.
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.
- The server stores and serves the bundle verbatim and never sees a credential or the unwrapped DEK.
Consequences that follow from the server not having the plaintext, and that are worth stating plainly:
- Encrypted calendars are excluded from ICS and
.hypercalendarexport, and from the/davCalDAV server. - They cannot be linked to a remote CalDAV collection.
- They contribute nothing to the search index — there's no plaintext to tokenize.
- If both the password and every recovery code are lost, the data is unrecoverable. That's the point.
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 only cross-user reads are the explicit sharing paths (calendar_shares, event_shares, event_attendees) and the free/busy endpoint.
/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_AUTOLOGINmust never be set in production. It bypasses authentication completely.- Secure cookies and HSTS engage only under
NODE_ENV=production.
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.