Security and privacy
Authentication and sessions
- Passkeys only (WebAuthn / FIDO2,
@simplewebauthn/server): discoverable credentials, user verification required, relying party = host ofAPP_URL. Touch ID / Face ID / security keys. Biometric data never leaves the device; the server stores a public key and a signature counter. - Enrolment happens only (a) with a one-time token created from the node's shell (first setup: valid 7 days,
written to a 600 file; later:
tsx scripts/setup-token.ts, 30 minutes) or (b) from an authenticated session. The token travels in the URL fragment, which browsers do not send to servers. - Recovery: eight one-use codes shown once at first enrolment (stored as SHA-256); or
tsx scripts/setup-token.ts --recoveryon the node, which replaces every passkey and revokes every session. - Sessions: 32-byte random token in an
HttpOnly; Secure; SameSite=Laxcookie, stored hashed, 30 days, listed and revocable in Settings → Security. - CSRF: every state-changing admin call must (1) come from our origin (
Origin,Sec-Fetch-Site) and (2) carry the session's CSRF token inX-CSRF-Token. Public POSTs also require same-origin. - Enumeration: login uses discoverable credentials (no account list in the challenge); every failure returns
the same
login_failed.
Authorisation
- One guard per API surface, executed before the handler is resolved (
makeRouter). Every admin query is scoped byuser_id; identifiers are UUIDs validated before use. - Visitors act only through their token: the API has no public endpoint that takes a request or booking id (no IDOR surface). Tokens are 256-bit, stored hashed, expire 30 days after the appointment, can be revoked.
- The public API exposes: profile, active types (without
location_detail), free slots. Never the agenda, other visitors, reasons, notes.
Input, abuse
- Zod validation on every body and query; parameterised SQL only.
- Public form: honeypot field, signed form token (minimum fill time in production, 3 h maximum age), consent required, 8 submissions / hour and 30 / day per IP, at most 3 open requests per e-mail address, optional allow / block lists of e-mail domains.
- Rate limits on slots, management links, WebAuthn and recovery codes (5 / 15 min).
- ICS subscriptions: https only, public addresses only (SSRF guard), 8 MB cap, 25 s timeout.
- Avatar upload: PNG / JPEG / WebP by magic bytes, 1 MB, served with
nosniff.
Headers
CSP (default-src 'self', no third-party origin, frame-ancestors 'none', form-action 'self',
object-src 'none'), X-Frame-Options: DENY, X-Content-Type-Options, Referrer-Policy,
Permissions-Policy, HSTS. Residual risk: script-src allows 'unsafe-inline' (decision D-13); the application
never renders user-supplied HTML (React escapes everything, e-mail templates escape explicitly).
Secrets
Only in .env on the node (mode 600) and in the mld manifest on the gateway; never in Git
(pnpm secrets:scan), never sent to the browser. APP_SECRET signs form tokens, derives visitor tokens and the
AES-256-GCM key of external calendar addresses.
Personal information (Québec, Law 25) — assessment
| Topic | Practice |
|---|---|
| Minimisation | Name, e-mail, optional organization, reason, answers. IP kept on the request for abuse handling. |
| Consent | Mandatory checkbox, timestamp stored (consent_at), policy at /privacy. |
| Confidentiality | Reason and answers: admin area only. Not in URLs, logs, e-mails, ICS, audit metadata. |
| Retention | settings.retention_days (default 730): requests, bookings, orphan contacts, audit and notification logs are deleted by the worker. Backups: 30 days. |
| Access / portability | Admin → Contacts → export JSON. |
| Erasure | Admin → Contacts → delete (contact, requests, answers, links, notifications, past bookings); refused while a confirmed appointment is upcoming. |
| Responsible person | The owner; a public contact address appears on /privacy when "Reply-to" is set. |
| Processors | The e-mail provider (address + message content). Database and backups stay on the owner's machines. |
| Not done | No formal privacy impact assessment, no incident register: to be completed by the owner if the service is used beyond personal scheduling. |
Logging
Application logs carry error names and messages, never request bodies. The audit trail records who did what to which entity, with metadata limited to identifiers and counts.
Backups
scripts/backup.sh daily: pg_dump → AES-256 (openssl, PBKDF2, passphrase in a 600 file outside the repo) →
verified by decrypt + pg_restore --list; 30 days kept. Restore procedure and its test: OPERATIONS.md.
Known limits
- One owner account (the schema carries
user_ideverywhere; multi-user is not exposed). - No CAPTCHA: honeypot + rate limits + approval by default were judged proportionate.
- Passkey flows are covered end-to-end with Chromium's virtual authenticator; Touch ID / Face ID on real devices must be checked by the owner on their own hardware.