Skip to content

Security: LifetimeLabsDev/privacynotes.app

SECURITY.md

Security

This document describes how PrivacyNotes handles your data, what we protect against, and what we don't. It's written for users who want to know what they're trusting, and for anyone reviewing the project.

If you find a vulnerability, please email privacynotes@lifetimelabs.dev instead of opening a public issue.

In one paragraph

PrivacyNotes is end-to-end encrypted. Your 12-word recovery phrase is generated on your device. From it we derive two keys: one to encrypt every note, task, and journal entry before it leaves the device, and one to sign requests so the server can tell it's you without knowing who you are. The server stores ciphertext and a public key. Under self-custody, which is the default and what you get unless you choose otherwise at signup, neither the server nor we hold the keys required to decrypt it, and those live only on your devices.

One deliberate exception, which you choose at signup and which we would rather state up front than bury: if you sign up with Google or Apple, you can opt into having us store your phrase so that a new device is one click instead of twelve words. That is custodial mode, and it means we can decrypt your notes. It is off unless you pick it, it is reversible, and it is described in full in Custodial mode below.

What the server sees

The server is Supabase (Postgres + Row Level Security) in Zurich. For each user it stores:

  • A public Ed25519 key, which acts as the account identifier.
  • Encrypted blobs of notes, tasks, journal entries, settings, and version history.
  • Nonces and timestamps required to sync and resolve order.
  • Sizes of the encrypted blobs (a row in a database has a size).

It does not store: titles, body text, tags, or anything derived from them. Phrase-flow users sign up with anonymous Supabase auth - no email, no name, no identifier tied to who they are. OAuth users (Google, Apple) have an identity from the provider attached to their session; see the OAuth section below for what that does and does not mean.

It does not store your recovery phrase either, unless you chose custodial mode at signup, in which case it stores an encrypted copy. That is the one case where the sentence above stops being the whole story, and it gets its own section below.

If the server is compromised tomorrow, the attacker gets a database of opaque bytes addressed by public key. They can correlate which key syncs at which time and infer crude things like "this user has roughly 200 notes," but they cannot read content. The exception is custodial users: an attacker who reached both the database and the phrase-encryption key would be able to decrypt their notes.

Cryptography

We rely on the noble suite by Paul Miller. These are audited, widely used libraries; if they're wrong, a lot of the ecosystem is wrong with us.

Phrase to keys. A BIP-39 12-word phrase (128 bits of entropy) is converted to a 64-byte seed via mnemonicToSeedSync. From the seed:

HKDF-SHA256(seed, info="privacynotes-signing-v1")    → 32-byte Ed25519 private key
HKDF-SHA256(seed, info="privacynotes-encryption-v1") → 32-byte symmetric key

The HKDF salt is omitted. RFC 5869 §2.2 permits this when the input keying material has high entropy, which the BIP-39 seed does. Domain separation between the two derived keys is enforced by the distinct info strings. We could add an explicit salt in a future key derivation version and may do so.

Encryption. XChaCha20-Poly1305 with a 256-bit key, 192-bit nonce, and 128-bit auth tag. Nonces come from crypto.getRandomValues() via @noble/hashes/utils.randomBytes. The 192-bit nonce space puts random-collision risk far past anything a single user could plausibly produce.

Authentication. Sensitive endpoints (device registration, account deletion, key linking) require an Ed25519 signature over a structured challenge that includes the session's auth UID. The binding prevents replay across sessions.

Known limitation: no AAD. Note ID and owner pubkey are not bound to the ciphertext as Associated Authenticated Data. A server-side attacker with direct write access to the database could swap two ciphertexts that belong to the same user. They cannot cross-swap between users because the keys differ, and they cannot read either blob. Fixing this requires a ciphertext format migration, which we'd rather do once, carefully, than rush.

PIN protection

The optional 4-digit PIN is a UI gate, not a cryptographic boundary. It's there to keep someone glancing at your screen from opening your phrase modal or a sensitive note. It is not designed to resist someone who already has your phrase (which decrypts everything regardless) or someone who can extract the local storage hash and brute-force it offline.

What it does:

  • PBKDF2-SHA256, 600,000 iterations, 16-byte random salt, 256-bit output.
  • Constant-time comparison on verify.
  • Exponential lockout in the UI after 5 wrong attempts, doubling each round. Counter lives in localStorage so closing a tab doesn't reset it. Same counter applies across every PIN entry surface in the app.

What it doesn't do:

  • 4-digit PINs have 10,000 values. 600k PBKDF2 iterations slow this down enough that mashing the UI is hopeless, but an attacker with the hash and modern hardware brute-forces it in minutes to hours. We're upfront about this. If you want stronger protection, rely on OS-level disk encryption and screen lock. The phrase storage guidance in the app says as much.

Locked notes and PIN-protected notes carry flags inside the ciphertext. They are hidden or gated by the client. They are not separately encrypted, because layering a second key derived from the PIN would mean losing those notes if you forget the PIN. We treat lock/PIN-protect as organizational privacy, not cryptographic access control. This is a trade we've made deliberately.

Sync

Pull-then-push, last-write-wins by updated_at. If you edit the same note offline on two devices, the later timestamp wins on next sync and the earlier edit is lost. No CRDT, no merge UI. This is a known limitation and accepted for the current version.

Deletes are hard deletes and propagate to the server during the push phase.

What we measure

The server-side data we hold beyond ciphertext and the pubkey identifier:

  • Device records, one per registered device per account. Stores device_id, device_name (user-entered text), a coarse platform label (web / desktop / ios / android), created_at, last_seen_at, an optional revoked_at, and a device_group identifier we use to keep multiple browser installs on the same physical machine within one slot.
  • Device-grouping hashes, four per device. Each is HMAC-SHA256 of a coarse environment signal (OS family, WebGL renderer, CPU core count, browser language), computed on your device using a pepper derived from your BIP-39 phrase. Purpose: keep multiple browsers on the same physical machine within one device slot for free-tier limits - not behavioral tracking, not advertising, not shared with anyone. The server never sees the raw values and cannot reverse the hashes without the pepper, which lives only on your device. Because the pepper is per-user, the same hardware produces different hashes for different accounts - we cannot correlate a device across accounts. Caveat: browser updates and hardened browsers (Brave, Mullvad, Tor) can change the underlying signals; if that happens, your device may be placed in a new slot until the old record is pruned or revoked. You won't be locked out, but you may temporarily consume an extra slot.
  • Per-account quotas: counters for the number of notes, total ciphertext bytes, and image bytes you currently hold. Used to enforce free vs. Pro storage caps. Derived from the data you've stored, not from anything you've told us about yourself.
  • Subscription records if you've bought Pro or extra storage through Paddle. Includes a payment provider subscription id and your pubkey. Email and billing details are held by Paddle as Merchant of Record, not by us.
  • Aggregate operator dashboards: signups per day, retention cohorts, activation funnel, totals, platform breakdown (web vs. desktop vs. mobile). All computed in-database from the records above. No individual content is involved, and no cross-user fingerprint correlation is possible.

What we explicitly do not collect or persist on accounts: user-agent strings, geolocation, behavioral telemetry, analytics events tied to note content, or anything that lets us reach you outside the app. We do not run third-party JavaScript on the application.

What does touch IP addresses, honestly: Cloudflare and Supabase transiently process them at the network layer for routing and abuse prevention, and we use them for the IP-level rate limits described under "Quotas and abuse protection." We do not write IPs into user records or associate them with content. They exist where the stack inherently sees them, and nowhere else.

OAuth users (Google, Apple)

OAuth is a convenience entry point. Your recovery phrase is still generated on your device at first sign-in. OAuth provides three things: a one-click way to return on a known device, abuse protection (Google and Apple have already proved you are a real human), and a stable identity to attach things like Pro subscriptions to.

It also costs you something, whichever mode you pick: the email Google or Apple shares with us is linked to your public key in our database. Phrase-only users have no such link. If being unidentifiable to us matters more than a fast sign-in, use the phrase flow.

At OAuth signup you choose between two modes, and the choice is the most consequential one in the product:

  • Self-custody (the default). Your phrase never leaves your device. On a new device we recognize you but cannot unlock anything for you: you type your phrase or scan a QR sign-in code from a device you already use. Identity is one thing, key material is another, and in this mode we never conflate them.
  • Custodial. You ask us to keep a copy of your phrase so a new device is one click. See below.

Custodial mode

If you chose custodial at signup, we hold your recovery phrase. It is encrypted at rest with AES-256-GCM under a key held by the server, not by you, and it exists so you can sign in on a new device with nothing but your Google or Apple account.

What that means, stated plainly: we can decrypt your notes. Not "in theory," not "if you count some exotic attack." The phrase is the root of every key, we have a copy, and we have the key that unwraps it. So:

  • A valid legal order compelling us could produce your plaintext.
  • A deep enough compromise of our server, reaching both the database and the phrase key, would produce your plaintext.
  • A dishonest future version of this company could read your notes without telling you.

None of those are true for self-custody users. There is nothing on our side to compel, steal, or betray: an attacker who takes the entire database gets ciphertext and a dead end where the phrase should be.

A known weakness we are not hiding. Retrieving a custodial phrase requires only a valid session token. Unlike account deletion, it is not gated behind a second signature challenge, because at that point in the flow you may not have a signing key yet: the phrase is what derives it. A stolen session token is therefore enough to exfiltrate a custodial phrase for as long as that token lives. This is flagged for the third-party audit and written up in THREAT_MODEL.md.

It is a one-way ratchet. You can move from custodial to self-custody at any time and we delete our copy. You cannot go back. That direction is deliberate: the safe move should always be available, and the unsafe one should require a decision you make once, knowingly.

Why offer it at all. For a lot of people the realistic alternative to a custodial encrypted notes app is not a stricter one, it is a plaintext one, or a notes app owned by an advertising company. We would rather offer the trade honestly, default it to off, and let you climb the ratchet, than pretend the trade does not exist. But if you came here for the guarantee the rest of this document describes, use the phrase flow or switch to self-custody in Settings.

What we trust

  • Your device and browser. If the operating system is compromised or the browser is malicious, none of the above helps. This is the universal limit of in-app encryption.
  • The cryptographic libraries. @noble/ciphers, @noble/hashes, @noble/ed25519, @scure/bip39. Audited and widely deployed.
  • The frontend bundle, on the web. Frontend assets built from this codebase and served over Cloudflare. A compromised CDN deployment could ship malicious JavaScript that exfiltrates the phrase. This is the standard supply-chain risk for any web app, and we have not yet added Subresource Integrity or reproducible web builds.
  • Less so in the native apps. Code-signed native builds narrow that boundary structurally, and they have shipped: macOS is notarized, Windows is Authenticode-signed via Azure, Linux ships a signed AppImage and deb, and the Android APK is pinned to a signing certificate we cannot rotate without every sideloaded user's app refusing the update. A native build is a fixed artifact rather than code re-fetched on every load, so tampering means re-signing and re-distributing, which leaves a trail. If your threat model includes us being compromised or coerced, prefer the native apps over the web app.

What we don't protect against

  • A compromised endpoint. Malware on your machine reads plaintext while the app is unlocked, same as any E2E system.
  • A targeted adversary with custodial access to your unlocked device.
  • You losing your recovery phrase, under self-custody. There is no reset. We chose this on purpose; a recoverable phrase would be a backdoor, and custodial mode is that backdoor offered honestly, by name, to the people who decide they want it.
  • Social engineering. Nobody from PrivacyNotes will ever ask for your phrase. If someone does, they are not us.
  • Denial of service against our infrastructure providers.
  • Future cryptographically relevant quantum computers, eventually. XChaCha20-Poly1305 at 256-bit keys remains strong against Grover-class attacks; Ed25519 does not against Shor. This is not a near-term practical concern but is worth noting.
  • Server-side deletion. The server cannot read your notes, but it can drop the rows. Because the sync engine is pull-then-push with last-write-wins, a compromised server (or anyone who obtains valid session credentials) can delete notes server-side, and the next sync will propagate the deletion to your local copy. Encrypted local exports are the only mitigation we offer today; a snapshot-style local backup that survives a hostile server pull is on the list.

Quotas and abuse protection

  • Cloudflare Turnstile on signup.
  • 1 MB CHECK constraint on each ciphertext row.
  • Per-user quotas enforced by Postgres triggers (10,000 notes, 50 MB free, 500 MB Pro).
  • Anonymous accounts that never link a pubkey are purged after 7 days by a scheduled job. Accounts that finished setup are never deleted for inactivity.
  • IP-level rate limiting on signup and sync.

Audit status

No third-party audit has been performed yet. The project is pre-revenue. The first significant spend out of beta revenue is a third-party security audit, with Cure53 as the target firm. We've reached out for a scoping conversation. We'd rather state this plainly than wave at "audit coming soon."

In the meantime, the cryptographic code lives in this open-core repo so anyone who wants to inspect or critique the design can do so without trusting our word for it.

Reporting a vulnerability

Email privacynotes@lifetimelabs.dev. That contact is also published in our PGP-signed security.txt (https://privacynotes.app/.well-known/security.txt), so you can confirm it's authentic, and if you'd like to encrypt your report our public key is at https://privacynotes.app/.well-known/pgp-key.txt. If a fix requires coordination, we'll work with you on timing before public disclosure. We don't currently run a paid bounty program; we'd like to once it's responsibly affordable.

There aren't any published security advisories