Skip to content

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

CredentialModuleStorage
Account passwordpasswords.tsscrypt hash
Session tokensessions.tssha256 of a 32-byte CSPRNG token
TOTP secrettotp.tsstored, RFC 6238
TOTP recovery codetotp.tsscrypt hash, single use
Passkeywebauthn.tspublic key + counter
App passwordappPasswords.tssha256 of a 32-byte CSPRNG token
Remote CalDAV passwordsecretBox.tsAES-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):

EndpointLimit
POST /api/auth/login10 / min
POST /api/auth/login/totp10 / min
POST /api/auth/register5 / min
PATCH /api/me10 / 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-src allows '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 in shared/src/encryption.ts).
  • Slot kinds: password (KEK = PBKDF2-SHA256 over the login password) and recovery (KEK = PBKDF2-SHA256 over a one-time recovery code). Minimum 100 000 iterations, enforced by the schema. wrappedDek is base64(iv ‖ ciphertext ‖ tag), AES-GCM.
  • The password is the only unlock method. A legacy passkey slot 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 .hypercalendar export, and from the /dav CalDAV 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_AUTOLOGIN must 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.