← Back

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:

the user's explicit consent

key fragment)

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

DataServer can read?
User email address (required for magic-link login)Yes
User display nameYes
Household nameYes (non-sensitive by policy)
Household membershipYes (needed for routing)
Magic-link tokensYes (short-lived, one-use)
Sit type on a share linkYes (non-sensitive)
Check-in timestamps, activity typeYes (operational metadata)
Home address, Wi-Fi password, door codes, alarm codesNo
Home section instructions (garage, TV, appliances, etc.)No
Contact phone numbers, emails, notesNo
Vet phone, email, address, notesNo
Pet feeding, medication name/dose/schedule, quirksNo
Walk / place addresses and notesNo
Check-in free-text noteNo

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+)

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.

Keys

per-user salt. Never leaves the browser.

phrase with its own random salt. Wraps a copy of the user key so a

forgotten passphrase is recoverable without operator access.

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).

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.

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

data without the user's explicit consent (pasting a recovery code

into a support ticket, inviting the operator to the household).

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 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.

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

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:

EndpointLimit
POST /api/auth/request (magic link)5/hour per email, 30/day per IP
POST /api/households/:id/invite10/hour per inviter, 20/day per household
POST /api/help5/hour per email, 20/hour per IP
POST /api/s/:token/check-ins60/hour per share token, 200/hour per IP
POST /api/s/:token/vet-contact30/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

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

limits camera to our origin (for sitter photo capture on check-in)

(HS256) with a 30-day rolling expiration

Compliance mapping

and encryption of personal data; "confidentiality ... of

processing systems."

does not trigger notification for loss-of-DB incidents.

information is not subject to the private right of action.

applicable (we are not a covered entity) but mirrors the pattern

for handling medical info like medication schedules.

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:

different ciphertext)

otherwise

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.