PetSitting · Privacy & encryption
This document describes how PetSitting handles sensitive data and what
the operators (Drew / LooniTech Labs) can and cannot see.
Threat model
In scope: protect the contents of a household's guidebook (home
access codes, Wi-Fi passwords, contact phone numbers, pet medication
details, check-in notes) from:
- Someone who gains read access to the Turso database
- LooniTech Labs operators performing routine support who do not have
the user's explicit consent
- An attacker who intercepts a share link's token alone (without the
key fragment)
- Credential stuffing, targeted signup spam, brute-force sign-in
attempts, and sustained API abuse
Out of scope: a full endpoint compromise (malware on the owner's
device, XSS on our origin) can read plaintext after decryption, as in
every end-to-end system. We minimize this surface with a strict
Content-Security-Policy and by refusing to load third-party scripts.
What the server can see
| Data | Server can read? |
| User email address (required for magic-link login) | Yes |
| User display name | Yes |
| Household name | Yes (non-sensitive by policy) |
| Household membership | Yes (needed for routing) |
| Magic-link tokens | Yes (short-lived, one-use) |
| Sit type on a share link | Yes (non-sensitive) |
| Check-in timestamps, activity type | Yes (operational metadata) |
| Home address, Wi-Fi password, door codes, alarm codes | No |
| Home section instructions (garage, TV, appliances, etc.) | No |
| Contact phone numbers, emails, notes | No |
| Vet phone, email, address, notes | No |
| Pet feeding, medication name/dose/schedule, quirks | No |
| Walk / place addresses and notes | No |
| Check-in free-text note | No |
Every row in the "No" group is stored as opaque v1.<iv>.<ciphertext>
and can only be decrypted with the household's master key. The server
never holds that key. **The server refuses to persist plaintext into
those columns** - every write is validated to be either null, empty,
or a v1 ciphertext blob (src/lib/enforceCiphertext.js).
Cryptographic design
Primitives (current posture, 2025+)
- AES-GCM-256 for all symmetric encryption (WebCrypto
AES-GCM) - PBKDF2-HMAC-SHA256 at 1,200,000 iterations for passphrase-derived
keys. This is 2x the OWASP Password Storage Cheat Sheet floor of
600,000, giving headroom as attacker hardware improves through
2027+. We stay on PBKDF2 because WebCrypto does not yet ship
Argon2id in stable channels; we will switch to Argon2id-via-WebCrypto
as soon as it's broadly available.
- Random 12-byte IV per encryption; never reused
- Random 16-byte salt per user; never reused
- All randomness from
crypto.getRandomValues
Keys
- User key - derived in the browser from the user's passphrase +
per-user salt. Never leaves the browser.
- Recovery key - derived from a one-time printable recovery
phrase with its own random salt. Wraps a copy of the user key so a
forgotten passphrase is recoverable without operator access.
- Household master key - random 256-bit AES key generated in the
browser, one per household. Every household member has their own
copy wrapped with their user key; only wrapped blobs ever leave the
browser (household_member_keys.wrapped_master_key).
- Share link key - random 256-bit AES key generated in the
owner's browser when a share link is created. The owner's browser
wraps the household master key with this share key and uploads the
ciphertext (share_links.wrapped_master_key). The share key is
delivered to the sitter only through the URL fragment of the share
link. URL fragments are never sent to the server.
- Invite key - random 256-bit AES key generated in the owner's
browser. The master key is re-wrapped with it and the ciphertext
is uploaded to household_invites.wrapped_master_key. The invite
key itself never leaves the owner's browser through our system:
we build the invite URL client-side with #i=<invite-key> and the
owner shares it directly through their preferred channel
(iMessage, text, WhatsApp, etc.). The server does not send the
invite email.
Why no server-held master key
- To guarantee the operator cannot read any household's sensitive
data without the user's explicit consent (pasting a recovery code
into a support ticket, inviting the operator to the household).
- To make a database leak non-catastrophic - all sensitive content
would still be encrypted at the application layer.
Data flow
Household member (authed)
1. User signs in with magic link (rate limited: 5/hour per email,
30/day per IP).
2. First sign-in for that user: browser prompts for a passphrase.
The browser derives a user key, generates a recovery code, and
uploads only the KDF salt, a verifier ciphertext, and a
recovery-wrapped copy of the user key. The server never sees the
passphrase, the recovery code, or the user key.
3. First household: browser generates a fresh master key and uploads
one copy wrapped with the user key.
4. Later sign-ins: browser prompts for passphrase, derives user key,
verifies against the server-held verifier ciphertext; pulls
wrapped household keys and unwraps locally.
5. Unlocked keys live in sessionStorage scoped to the tab.
Sitter (share link)
1. Owner's browser generates a random share key, wraps the master
key with it, uploads the ciphertext. URL is built as
https://petsitting.loonitechlabs.com/s/<token>#k=<base64-share-key>.
2. Sitter opens the link. The server sees only <token>; the
#k=... fragment stays in the browser.
3. The sitter's browser fetches the encrypted guidebook payload,
uses the fragment key to unwrap the master key, and decrypts
every sensitive field client-side.
Invite flow (zero server-touch on the key)
1. Owner (signed in, unlocked) enters the invitee's email.
2. Owner's browser generates a fresh invite key, re-wraps the
master key with it, and uploads only the ciphertext to
POST /api/households/:id/invite. The invite key stays in the
browser.
3. Server creates an invite row (with the wrapped master) and
returns an invite ID + token.
4. Owner's browser constructs the full URL
https://petsitting.loonitechlabs.com/accept-invite?inviteId=<id>#i=<key>
and shows it with Copy / Share controls. The owner sends it via
whatever channel they like.
5. Invitee opens the link. /accept-invite is a public route. The
page stashes the #i=... fragment in sessionStorage keyed by
invite ID, then immediately strips the fragment from the URL
bar to minimize shoulder-surfing and browser-history leakage.
6. If the invitee isn't signed in, the page shows a sign-in form
that sends a magic link with a safe next pointing back to
/accept-invite?inviteId=<id>. The magic link flow is standard:
one-time token, short TTL.
7. After the invitee signs in (or if they were already signed in),
/accept-invite reads the stashed fragment, fetches the
wrapped master key, unwraps with the invite key, re-wraps with
the invitee's user key, uploads their wrapped copy, marks the
invite accepted, and lands them in the household.
8. The fragment is removed from sessionStorage after a
successful handoff.
The operator's system never holds the invite key in memory, in
logs, or in transit. The only server-side record of the
handshake is the wrapped master key, which cannot be opened
without the invite key.
Privacy hardening on the invite page
- The server page does not surface the intended invitee's email or
the target household's name to a user whose session email does
not match the invite's email. This prevents information
disclosure through guessed invite IDs.
- Accepted and expired invites present dedicated states with no
data leakage.
Photo uploads
Check-in photos and pet photos are **encrypted in the browser
before upload**. The client wraps each photo in a binary container
([0x01 byte][12-byte IV][AES-GCM ciphertext]) and uploads it to
Vercel Blob as application/octet-stream. The stored blob is
opaque ciphertext; even if a blob URL leaks (logs, caches, ISP
metadata), the image content cannot be recovered without the
household master key. Viewers fetch the blob, decrypt client-side,
and display via a local object URL.
Emails and notifications
Because the server cannot read sensitive content, emails about
check-ins, medication delivery, and vet contact **do not include the
content**. Free-text notes and photos are omitted ("Note encrypted
- open the app to read"). Medication names and doses appear in the
email only when they are not encrypted (they are currently always
encrypted). Every notification includes a deep link to the authed
activity view where the household member's browser decrypts the
detail.
Recovery and operator access
A forgotten passphrase can be recovered with the one-time recovery
code the user saves at setup. If both are lost, the data is
unrecoverable by anyone - including the operator. This is by design
and is the tradeoff for real end-to-end encryption.
The operator (Drew) cannot read any household's sensitive fields
without the user's explicit, in-app consent. Consent is expressed
by the user adding Drew as a temporary member of their household,
which requires their own browser to wrap the master key for Drew's
user key (the same flow as any other invite). When support is done,
the user removes the membership and the wrapped copy is deleted.
Abuse prevention
All write-heavy and externally-facing endpoints are rate-limited
against a database-backed sliding window:
| Endpoint | Limit |
POST /api/auth/request (magic link) | 5/hour per email, 30/day per IP |
POST /api/households/:id/invite | 10/hour per inviter, 20/day per household |
POST /api/help | 5/hour per email, 20/hour per IP |
POST /api/s/:token/check-ins | 60/hour per share token, 200/hour per IP |
POST /api/s/:token/vet-contact | 30/hour per share token |
Every rate-limit hit and every sensitive operation (login, invite
sent, invite accepted, share rotate, vet contact) is recorded in the
audit_log table.
Transport and origin hardening
- HTTPS enforced via TLS 1.2+ at the Vercel edge;
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload Content-Security-Policylimits scripts toself(plus the
framework inline hashes required for Next.js App Router
hydration), blocks third-party scripts, restricts image sources
to our origin + Vercel Blob, restricts frame-ancestors to
none (clickjacking protection), and disallows object-src
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originCross-Origin-Opener-Policy: same-originPermissions-Policydisables microphone, geolocation, FLoC, and
limits camera to our origin (for sitter photo capture on check-in)
- Session cookies:
HttpOnly,Secure,SameSite=Lax, signed JWT
(HS256) with a 30-day rolling expiration
Compliance mapping
- GDPR Article 32 (security of processing) - pseudonymization
and encryption of personal data; "confidentiality ... of
processing systems."
- GDPR Article 34 (breach notification) - encrypted-at-rest data
does not trigger notification for loss-of-DB incidents.
- CCPA § 1798.150 safe harbor - encrypted or redacted personal
information is not subject to the private right of action.
- HIPAA § 164.312(a)(2)(iv) - encryption of ePHI. Not directly
applicable (we are not a covered entity) but mirrors the pattern
for handling medical info like medication schedules.
- NIST SP 800-63B - passphrase-derived key meets the guidance
for memorized secret verifiers (length not capped, PBKDF2 with
approved hash and sufficient work factor, unique per-user salt).
Testing and verification
Crypto primitives have a unit-test suite at
scripts/test-crypto.mjs. Run with npm test. Coverage:
- Base64 and base64url round-tripping
- AES-GCM encrypt/decrypt under valid and wrong keys
- Tampered ciphertext rejection
- Unique IV per encryption (same plaintext + same key yields
different ciphertext)
- PBKDF2 determinism given fixed passphrase + salt, divergence
otherwise
- Key wrap/unwrap round-trip with a separate wrapping key
- Share key handoff: re-wrap a master with a share key, unwrap on
the receiving side from raw bytes
For the broader security posture see SECURITY_CHECKLIST.md.
Reporting a vulnerability
Use the [contact form](/help) and choose "Other" with a subject
line that starts with [security]. We aim to acknowledge within
one business day. If the report itself is sensitive, mention that
in the note and we'll respond with a secure channel.