-
Notifications
You must be signed in to change notification settings - Fork 0
Security Model
What skulid protects against, and what it doesn't.
skulid is built for a single user running it on their own infrastructure (homelab, VPS, behind a tunnel). It is not multi-tenant and the auth model assumes the operator is the only legitimate user.
| Threat | Mitigation |
|---|---|
| Someone else discovers your URL and tries to log in | TOFU owner claim; non-owner Google logins get 403 |
| DB dump leaks via backup theft | OAuth tokens are AES-256-GCM sealed; key is in env |
| Google push spoofing | Per-channel HMAC token verified on every webhook |
| Session cookie tampering | HMAC-SHA256-signed cookies |
| Replay of an old session | 30-day expiry built into the cookie payload |
| Cross-site request forgery | Mutating routes require POST; SameSite=Lax cookies |
| Cross-site scripting | All template output goes through html/template
|
| Loop attack (mirror writes triggering more mirrors) |
extendedProperties.private loop guard |
| Malicious AI tool calls | Confirmation required for every destructive write |
Trust On First Use. The first Google account to complete the
OAuth login claims this instance permanently. Their
google_sub (the stable user ID Google returns) is recorded in the
setting table.
- Subsequent logins must match that
google_subor get rejected. - The
Connect Google accountflow is for adding additional Google accounts under the same owner — it doesn't change ownership. - To reset ownership, you have to manually delete the setting row.
Refresh and access tokens go through crypto.Sealer:
- AES-256-GCM with a fresh 12-byte nonce per ciphertext.
- The nonce is prepended to the ciphertext, the whole thing is base64 encoded.
- The 32-byte key comes from
ENCRYPTION_KEY(base64-decoded).
If the DB is dumped without the key, the tokens are unrecoverable. If the key is exposed, every stored token can be decrypted — treat it like the master secret it is.
Cookies are payload.signature where:
payload = base64(googleSub|email|issuedAtUnix)signature = base64(HMAC-SHA256(SESSION_SECRET, payload))
The server verifies the signature on every request and refuses any
session older than 30 days. There is no server-side session store —
sessions are purely cookie-based, which means rotating
SESSION_SECRET invalidates everything.
Cookies are HttpOnly, SameSite=Lax, and Secure whenever
EXTERNAL_URL is HTTPS.
Google sends push notifications to /api/webhooks/google. The handler
checks:
-
X-Goog-Channel-Idmatches a row insync_token. -
X-Goog-Channel-Tokenmatches the per-channel HMAC token we generated when registering — verified withsubtle.ConstantTimeCompare. -
X-Goog-Resource-Idmatches what we stored (warned-but-accepted on mismatch — Google sometimes rotates these).
Unknown channel IDs return 200 OK so Google stops retrying. (Returning
4xx would only flood our logs.)
- We request offline access with
prompt=consentso we always get a refresh token. Without one we can't run unattended. - Scopes:
openid email profileplus the full Google Calendar scope. We can't do read-only because the app needs to write mirrors and smart blocks. - The OAuth state cookie + intent cookie are short-lived (10 min) and validated on the callback.
When ANTHROPIC_API_KEY is set, the AI assistant has access to the
same calendars as you. It can call read tools without confirmation
(list calendars, list events, find free time) and propose write tools
(create, update, delete, move event). Every write requires you to
click "Apply" before it actually hits Google.
Conversations are stored in Postgres for 30 days, then deleted. You can also delete a conversation manually at any time.
If you don't trust the AI assistant feature, just don't set
ANTHROPIC_API_KEY — the routes are unregistered and the nav link
hidden when it's absent.
docker-compose.yml supplies a placeholder default for SESSION_SECRET,
ENCRYPTION_KEY, both Google client credentials and EXTERNAL_URL. Without a
content check, a .env that fails to load produces a silently insecure
instance: refresh tokens sealed with an all-zero key published in this repo,
session cookies signed with a known secret (so an owner session is forgeable
regardless of TOFU), and EXTERNAL_URL falling back to plain http, which drops
the Secure flag off that cookie.
The daemon therefore refuses to start on any of those values — see
Configuration → Refusing to start on unsafe values.
SKULID_ALLOW_INSECURE_CONFIG=1 overrides it for local testing, at the cost of
a red banner on every page.
This matters more here than for most self-hosted tools: EXTERNAL_URL has to
be publicly reachable for Google to deliver push notifications, so the host is
internet-facing by design.
Connecting an employer's Workspace account puts their data in scope. Two things bound the exposure:
-
No event content is persisted.
event_linkstores Google event IDs and etags;audit_logstores IDs and action verbs. Titles, descriptions and attendees are fetched, transformed in memory, written to the target, and dropped. - The assistant is the one egress path, and it is per-account switchable. See AI Assistant § Excluding an account.
Newly discovered calendars arrive disabled, so connecting an account registers no watch channels until you choose.
What this does not address is the employer's own policy: whether their Workspace admin permits an unverified third-party app at all, and whether their rules allow their calendar data to transit a host you run. Both are outside anything this code can enforce.
- Local network attacker who can read process memory. Tokens are decrypted into memory whenever a sync runs.
-
Compromised host. If someone has root on the box, the
ENCRYPTION_KEYis in env; the DB is too. - Malicious Google itself. We trust Google's TLS certs and the Google Calendar API responses.
- Side-channel timing. We don't constant-time-compare every user-controllable string — only the webhook channel token.
-
DoS. No rate limiting on
/api/webhooks/google(relies on Google's own throttling) or on the OAuth callback.