Privt Voice — Security & Privacy Specification

Version 0.3 — 2026-08-20. Living document. This specification is updated as each phase ships. Every statement below describes the code as audited on 2026-08-20; where the implementation is weaker than the design intent, the gap is recorded in Section 15 rather than omitted. Features carry one of four statuses:

StatusMeaning
ShippedPresent in the current macOS build or live on the deployed worker at api.stayprivt.com, verified against source and (for the server) by live probes.
DeployedLive on the worker at api.stayprivt.com (server surface); the matching client surface may still be in an unshipped build.
ImplementedCode complete with tests, not yet deployed or not yet reachable from the UI.
PlannedDesign only. No code exists.

Abstract

Privt Voice is a macOS menu-bar application for dictation and meeting transcription. Speech recognition runs entirely on-device; transcripts are sealed client-side into an envelope-encrypted vault whose root key is gated by the Secure Enclave and Touch ID. The free tier transmits no user content. The optional Pro account adds ciphertext-only backup and sync through a Cloudflare Worker: the server stores hashed verifiers, wrapped copies of the root key, and sealed envelopes, and can decrypt none of them. This document specifies the key hierarchy, formats and parameters, server behavior, the share and burn mechanisms, the threat model, and current limitations.

1. Scope and Conventions

This specification covers the macOS client (notari, marketed as Privt Voice) and the privt-id Cloudflare Worker. JSON formats use Swift Codable conventions (binary fields as base64 strings); AAD strings are given verbatim; paths are relative to ~/Library/Application Support/ unless absolute. Cryptographic primitives come from libsodium (XChaCha20-Poly1305-IETF, Argon2id, X25519, Ed25519, sealed boxes, CSPRNG), Apple CryptoKit/Security (Secure Enclave P-256 key agreement, HKDF-SHA256, AES-GCM, SHA-256), and — in the browser share viewer only — the @noble/ciphers implementation of the same XChaCha20-Poly1305-IETF construction; only the composition is original to this design.

2. Terminology

TermDefinition
ROOT256-bit key from the libsodium CSPRNG at vault creation; apex of the hierarchy. Plaintext only in memory while unlocked. Wrapped independently by the SE key, the recovery entropy, and (Pro) the KEK.
MK (App Master Key)256-bit per-application key (domain voice), wrapped under ROOT; wraps DEKs while unlocked.
DEKFresh 256-bit key per item write; encrypts one item version's content.
KEKArgon2id derivation of the account passphrase under a blob-local salt; wraps ROOT in PassphraseWrappedRoot. Never leaves the device.
authKeyIndependent Argon2id derivation of the same passphrase under a separate random salt; sent at register/login. The server stores only its SHA-256.
Deposit keypairX25519 pair for locked-mode writes: public half plaintext on disk, secret half ROOT-wrapped. While locked, DEKs are sealed to the public half.
Share Key32-byte key carried in a share URL fragment, never sent to the server; used directly as the snapshot AEAD key.
SE keySecure Enclave P-256 key-agreement key, biometry-bound, non-exportable; its opaque dataRepresentation is stored as a file, not in the keychain.
Recovery entropy128 CSPRNG-plus-mix bits encoded as a 12-word BIP-39 phrase; HKDF of it wraps ROOT.
Account ID (Privt ID)8-character Crockford-base32 handle (charset 0-9 A-Z minus I L O U; 40 bits), derived client-side as a 5-byte HKDF-SHA256 of the recovery entropy with info privt/account-id/v1|N, N a derivation counter starting at 0 (a rare collision or a future rotation re-prompts the 12 words to derive the next N; stored in the clear as a lookup alias, with no recovery entropy at rest). A lookup alias only — every internal reference keys on the permanent internal account uuid.
Contact emailOptional, opt-in, deletable notification address. Never an account identity, never unique, never an authentication input; destroyed by burn.
Session token32 CSPRNG bytes, base64url, returned once; the server stores its SHA-256. TTL 30 days.
Burn codeDestroy-only credential: two wordlist pairs generated client-side (~41 bits), canonicalized to lowercase a–z; the server stores HMAC-SHA-256(pepper, "privt/burn/v3|" ‖ code) where the pepper is a worker secret held outside the database. The code alone resolves and burns the account. (Implemented; not deployed.)

3. Design Principles

  1. Local first. All transcription is on-device. The free tier's only required network activity is a one-time model download event (9.5, 15.11); no user content is transmitted on any tier without explicit opt-in.
  2. Zero knowledge off-device. Every key capable of decrypting content exists only on user devices; the operator cannot read synced content.
  3. No novel primitives. Audited implementations only; every blob carries a v field so algorithms can be replaced without breaking stored data.
  4. Stated limits. Where a guarantee is not achieved, Section 15 says so.

4. Key Hierarchy — Shipped

flowchart TD
  SE["Secure Enclave P-256 key<br/>Touch ID gated, per device"] -->|"ECDH then HKDF then AES-GCM"| ROOT
  RP["Recovery entropy<br/>BIP-39, 12 words, 128-bit"] -->|"HKDF then XChaCha20"| ROOT
  PP["Account passphrase, Pro<br/>Argon2id derives KEK"] -->|"XChaCha20 unwrap"| ROOT
  ROOT["ROOT<br/>256-bit account key"] -->|"wraps"| MK["MK<br/>App Master Key, voice"]
  ROOT -->|"wraps"| ID["Identity keypair<br/>Ed25519 and X25519"]
  ROOT -->|"wraps"| DS["Deposit secret<br/>X25519"]
  MK -->|"wraps, fresh per write"| DEK["Per-item DEK<br/>256-bit"]
  DS -.->|"deposit public seals DEK while locked"| DEK
  DEK -->|"XChaCha20-Poly1305<br/>AAD binds id, version, type"| CT["Item ciphertext<br/>note or meeting"]

Three independent wraps of ROOT exist: the SE wrap (device unlock), the recovery wrap (rescue), and the passphrase wrap (Pro account); a successful recovery-phrase or passphrase unlock re-provisions the SE wrap. MK, the identity keypair (generated at creation for future sharing and key transparency; currently unused), and the deposit secret are wrapped only under ROOT.

4.1 Entropy Mixing at Creation — Shipped

During onboarding the user moves the mouse until 520 mousemove events are collected, each recorded as clientX,clientY,performance.now(), joined with ;. Key material is then HKDF-SHA256(system_random(n) ‖ SHA-256(trace), info = "privt/entropy-mix/v1/<domain>") with domains root (32 B), mk (32 B), and recovery (16 B). An empty trace degrades to raw system randomness; a predictable trace cannot weaken the output. The recovery phrase is displayed exactly once; onboarding also offers an optional user-chosen plaintext download (Limitation 15.15).

4.2 Vault File Inventory

Directory: Privt Voice/vault, mode 0700; all files written atomically, then chmod 0600.

FileContentsProtectionAAD / info string
se-key.blobSE key dataRepresentation (opaque; usable only by that enclave)
root.se.jsonSEWrap of ROOT: {v:1, eph, sealed} (secure-enclave mode only)SE ECDH → HKDF-SHA256 → AES-GCMprivt/root-se/v1 (HKDF info and GCM AAD)
root.recovery.jsonROOTHKDF(recovery entropy) → XChaCha20-Poly1305privt/root-recovery/v1 (info and AAD)
root.passphrase.jsonPassphraseWrappedRoot (Section 10.2). The device wrap on no-SE Macs (hardware=software, written at creation); on SE Macs only the Pro account's local copy (gesture=touchID), never a device unlock path, and absent in the hardware-bound tier. Rewritten in place by Change Passphrase (Section 5): the operation atomically overwrites this wrap under a fresh KEK and never rotates ROOTArgon2id KEK → XChaCha20-Poly1305privt/root-passphrase/v1
root.passphrase.se.jsonSEWrap of the PassphraseWrappedRoot bytes — the hardware-bound device wrap (hardware=secure-enclave, gesture=passphrase only). Rewritten in place by Change Passphrase (Section 5): a fresh non-biometric SE key re-seals the new inner wrap, the superseded key and ciphertext gone; ROOT is never rotatednon-biometric SE ECDH → HKDF-SHA256 → AES-GCMprivt/root-passphrase-se/v1
se-passphrase-key.blobNon-biometric SE key dataRepresentation (.privateKeyUsage only; usable only by that enclave)
mk.voice.jsonMKROOTprivt/mk/voice/v1
identity.jsonEd25519 + X25519 identity keysROOTprivt/identity/v1
deposit.pubDeposit public keyplaintext by design
deposit.sec.jsonDeposit secret keyROOTprivt/deposit/v1
recovery.auth.jsonRecovery-login verifier key (HKDF of the recovery entropy)ROOTprivt/recovery-auth/v1
account.idCanonical 8-char account IDplaintext by design (lookup alias, not a secret)
meta.json{v:2, createdAt, hardware, gesture}, hardware ∈ {secure-enclave, software}, gesture ∈ {touchID, passphrase}. Legacy v1 {mode} is read-only: secure-enclave→(secure-enclave,touchID), passphrase→(software,passphrase), else routed to recoveryplaintext

4.3 Sealed Blob Format

Every XChaCha20-Poly1305 wrap is a JSON SealedBlob:

{ "v": 1, "alg": "xchacha20poly1305ietf", "n": <24-byte random nonce>, "ct": <ciphertext + Poly1305 tag> }

The AAD is never stored; the opener must supply it, so a blob copied to a foreign context fails authentication. The 192-bit nonce makes random nonce generation statistically safe without counter coordination across devices.

5. Device Gate: Two Axes — Hardware and Gesture — Shipped

A vault records two independent axes in meta.json: hardware — whether a Secure Enclave is present and used (secure-enclave | software) — and gesture — what the user provides to unlock (touchID | passphrase). The two combine into three device-gate tiers. Legacy v1 vaults carried a single conflated mode; it is read for migration (secure-enclave→(secure-enclave, touchID), passphrase→(software, passphrase)), and any unknown shape routes to the recovery phrase and is rewritten in v2 form at the next unlock. No unlock path uses the macOS login password: the client never invokes LAContext device-password evaluation nor the .devicePasscode/.userPresence access-control flags; the only biometric policy consulted is .deviceOwnerAuthenticationWithBiometrics, used solely to detect Touch-ID availability. A Mac with an Enclave but no Touch ID uses our passphrase, hardware-bound (below), never the OS password.

Tier 1 — secure-enclave / touchID. The shipped biometric gate, enforced by the Secure Enclave, not by application logic. The SE key is created with SecAccessControlCreateWithFlags(kSecAttrAccessibleWhenUnlockedThisDeviceOnly, [.privateKeyUsage, .biometryCurrentSet]); the enclave itself refuses the ECDH without a live biometric match, so a modified client binary cannot bypass the check. A fresh SE keypair is generated at every provision.

sequenceDiagram
  participant App
  participant SE as Secure Enclave
  participant Disk
  Note over App,Disk: WRAP at provision — silent, public-key side
  App->>SE: generate enclave keypair, biometry bound
  App->>App: generate ephemeral P-256 keypair
  App->>App: ECDH with enclave public then HKDF-SHA256
  App->>Disk: AES-GCM sealed ROOT plus ephemeral public in root.se.json
  Note over App,Disk: UNWRAP at unlock — Touch ID fires inside the enclave
  App->>Disk: read root.se.json and se-key.blob
  App->>SE: ECDH with ephemeral public, requires live biometric
  SE-->>App: shared secret, only after Touch ID
  App->>App: HKDF then AES-GCM open, ROOT in memory

HKDF uses an empty salt and sharedInfo = "privt/root-se/v1", which is also the AES-GCM AAD. .biometryCurrentSet invalidates the wrap on biometric re-enrollment (error -25293seInvalidated); recovery-phrase and passphrase unlocks re-provision a fresh SE wrap. The diagram is the tier-1 wrap/unwrap. The hardware-bound tier below reuses this exact construction, sealing the PassphraseWrappedRoot bytes instead of ROOT, under sharedInfo/AAD "privt/root-passphrase-se/v1", with a non-biometric .privateKeyUsage key so the strip is silent.

Tier 1 (hardware-bound) — secure-enclave / passphrase. For an SE Mac whose user unlocks with a passphrase — because it has no Touch ID, or chose the coercion-resistant gesture — the passphrase blob is wrapped a second time inside the Enclave. Inner: ROOT is wrapped by wrapRootWithPassphrase (Argon2id 256 MiB, ops 3 → XChaCha20-Poly1305), the exact shipped PassphraseWrappedRoot. Outer: those inner bytes are sealed to a separate, non-biometric Enclave key — access control [.privateKeyUsage] only, so it is usable silently by the app on this machine with no prompt, no biometric, no OS password — via the same ECDH → HKDF → AES-GCM construction under sharedInfo/AAD "privt/root-passphrase-se/v1", stored in root.passphrase.se.json. The plain root.passphrase.json is not written in this tier. To open, the Enclave strips the outer layer silently (machine-bound), then the passphrase KEK strips the inner. A stolen disk yields the doubly-wrapped blob but no usable Enclave, so the inner PassphraseWrappedRoot is never revealed and the passphrase cannot even be attacked offline. Because this key is not .biometryCurrentSet, it survives Touch-ID re-enrollment; only a full erase/restore mints a new enclave and orphans it, after which the recovery phrase is the rescue.

Tier 3 — software / passphrase. On a Mac with no Enclave there is no outer layer to add: ROOT's device wrap is the inner PassphraseWrappedRoot alone (root.passphrase.json), the same construction that backs the Pro account (Section 10.2) — a passphrase the user sets at onboarding, stretched with Argon2id under a blob-local salt to a KEK that seals ROOT. The passphrase and KEK are never written; only ciphertext, salt, and KDF parameters land on disk. This removes the earlier software-mode fallback, which wrote a plaintext 256-bit device key (device-key.blob). The honest difference from the hardware-bound tier: this blob is offline brute-forceable at full Argon2id cost per guess if the passphrase is weak (15.4); passphrase strength is the at-rest security here.

In all three tiers the lock screen collects only the gesture the tier names; deviceUnwrap opens silently only for touchID and otherwise demands the passphrase, never opening a passphrase vault without it. The recovery phrase (Section 6) remains the universal rescue in every tier. Any stray legacy device-key.blob is deleted on first unlock.

Changing the passphrase. From an unlocked vault, ROOT is already in memory, so the user is already authenticated: Change Passphrase requires the new passphrase (and a confirmation, minimum 8 characters) and no old passphrase — the unlocked ROOT is the proof of authorization, mirroring the gesture toggle, which likewise re-wraps from the in-memory ROOT. It is a re-wrap, not a re-key: ROOT is unchanged, so the recovery wrap, the ROOT-sealed children (MK, identity, deposit), and every sealed item stay valid and untouched. The operation re-derives a fresh Argon2id KEK (fresh random salt) and re-wraps ROOT in the current tier: the software tier atomically overwrites root.passphrase.json; the hardware-bound tier mints a fresh non-biometric SE key and re-seals the new inner PassphraseWrappedRoot into root.passphrase.se.json, deleting any plain copy so the invariant "no plain root.passphrase.json in the SE tier" holds. Writes are atomic (temp file + rename); the superseded wrap is overwritten at its stable path and the cross-tier artifact is removed, so no stale openable wrap survives (the software tier's old KEK cannot open the new blob; the SE tier's old key is destroyed). meta.json is not rewritten — the tier and both axes are unchanged, and the vault stays unlocked across the change. Change Passphrase is offered only where gesture=passphrase; on an SE + Touch-ID Mac there is no passphrase gesture to change (that tier's account passphrase is not user-rotatable in v1 — a stated boundary; switch the gesture first). The at-rest properties are exactly those of the re-wrapped tier (Sections 15.4, 15.17): a change replaces the stored wrap, it does not upgrade a tier or alter its offline-exposure class. On a signed-in account the new material propagates to Privt ID (Section 10.5).

6. Recovery Phrase — Shipped

16 bytes of mixed entropy encode as 12 BIP-39 words; the checksum is the first 4 bits of SHA-256(entropy). The wordlist is integrity-pinned at load: exactly 2048 words, SHA-256 2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda. Decoding lowercases input and rejects checksum failures. Because the input is already high-entropy, the wrap is HKDF-SHA256(entropy, info = "privt/root-recovery/v1") → XChaCha20-Poly1305 with the same string as AAD — no password stretching. For a vault with no passphrase attached, if both the SE wrap and the phrase are lost, the data is unrecoverable by anyone, including the operator; the UI states this at creation. Once a Pro passphrase is attached, a third wrap exists locally and server-side (Section 10.2) — reachable through the restore path (15.5; server live, client in an unshipped build) and not cryptographically void, so the unrecoverability claim does not extend to such vaults. No short-PIN recovery exists (Section 15.4). No copy of the recovery entropy is persisted anywhere on disk: the 12-word phrase is not reconstructible from a live, unlocked vault. Account-ID derivation at an arbitrary counter N (a rare registration collision, or a future ID rotation) re-prompts the 12 words rather than reading a stored copy.

7. Locked-Mode Capture: Deposit Keypair — Shipped

A locked vault refuses reads but accepts writes. While locked, a fresh DEK encrypts the item and is sealed to the deposit public key with a libsodium sealed box (ephemeral X25519 + XSalsa20-Poly1305); the operation requires no secret and triggers no prompt. The item becomes readable at the next unlock, which releases the deposit secret from under ROOT.

sequenceDiagram
  participant Engine as Transcription engine
  participant App as App, vault LOCKED
  participant Disk
  participant SE as Next unlock, Touch ID
  Engine->>App: finished transcript in memory
  App->>App: fresh DEK encrypts transcript
  App->>App: seal DEK to deposit public key, no prompt
  App->>Disk: ciphertext plus sealed DEK
  Note over App,Disk: saved encrypted immediately, unreadable by anyone including the app
  SE->>App: biometric unlock releases ROOT then deposit secret
  App->>App: item now readable

The self-test verifies that a locked-mode save is unreadable while locked and opens after unlock.

8. Item Encryption and Local Store — Shipped

One file per item at Privt Voice/store/<uuid>.pv:

Envelope:  { "id": <uuid>, "type": "note" | "meeting", "version": <int>, "box": ItemBox }
ItemBox:   { "v": 1, "mode": "mk" | "box", "wrappedDEK": <bytes>, "content": SealedBlob }

AAD strings, verbatim:

content AAD:              privt/item/v1|<id>|<version>|<type>
DEK wrap AAD (mode mk):   privt/dek/v1|<id>|<version>

In mode mk the DEK is wrapped under MK; in mode box (captured while locked) wrappedDEK is a sealed box to the deposit public key, which has no AAD mechanism. The content AAD binds id, version, and type, so ciphertext cannot be relocated between items or versions; type is absent from the DEK-wrap AAD. Unknown modes are rejected; overwrites increment version. Item types are note, meeting, and one reserved singleton prefs (id prefs, store/prefs.pv) that seals the vocabulary/corrections/harvested-names dictionary under the identical construction (§15.8); the browser hides it. Envelope fields id, type, version, and mode are plaintext on disk and on the wire (15.10).

Shredding. Deleted plaintext leftovers are overwritten once with zeros, synchronized, then unlinked; APFS copy-on-write may retain old extents (15.7).

Auto-lock. Default 5 minutes; 0 disables; the UI offers 1/5/15/never. The timer re-arms on notes-window activity and after unlock; expiry drops keys and clears decrypted content from the notes page. Locking is read-side only — capture continues through the deposit path (idle scope: 15.13).

9. Capture Engine and Local Data Flow — Shipped

The default backend is native in-process transcription (engineBackend: "native"); a Python sidecar over a Unix socket remains as a config-file rollback with no UI switch.

9.1 Dictation Lane

Microphone audio (16 kHz mono Float32) accumulates in memory only while the push-to-talk key is held; nothing touches disk. A 1 Hz loop re-transcribes the whole buffer once at least 1 s of new audio exists and commits the longest agreeing prefix between consecutive passes (LocalAgreement-2): partials only grow. On release, a full-context pass produces the final, post-processed by the vocabulary corrector. Buffers of 0.1 s or less, ASR errors, and mic-start failures all yield an empty final.

9.2 Call Capture

Two voice-activity lanes — "Me" (microphone) and "Them" (system-audio tap) — segment speech with these parameters:

ParameterValue
Sample rate16 000 Hz mono Float32
Silence thresholdRMS 0.004
Trailing-silence close0.8 s
Maximum segment30 s
Pre-voicing retention0.3 s

Pre-voice silence beyond the 0.3 s pre-roll is the only audio discarded during a call. Segments are transcribed serially and streamed to the panel; on stop they are sorted by start time and saved from memory as a meeting item: {schema:1, title, startedAt, durationSeconds, model:"parakeet-coreml", speakers, segments:[{t, speaker, text}]}. No plaintext transcript file is written on the native path — the sole disk writes are sealed envelopes; the debug log records event names and content lengths only, never transcript text (15.1).

9.3 Tap Scoping and Call Detection

The system-audio tap (macOS 14.2+) is process-scoped to the detected call application when one is known, with a global-tap fallback for manual recording. Call detection polls Core Audio's process list every 2 s for processes running input, excluding any process whose name contains python, uv, notari, or privt — a substring filter that also blinds detection to any Python-based conferencing tool, not merely to the app itself — debouncing session end over two empty polls; detection prompts and calendar-title matching are config-gated.

9.4 Microphone Policy

Input selection prefers built-in, then wired transports, skipping Bluetooth, BLE, virtual, aggregate, and unknown transports (a Bluetooth mic degrades headphone audio to HFP); absent both, the system default is used.

9.5 Model Acquisition

The speech model is FluidInference/parakeet-unified-en-0.6b-coreml (int8 encoder, decoder, joint network, vocabulary), fetched at first launch from the configured model base URL — default models.stayprivt.com, the deployed first-party mirror, with a one-shot fallback to huggingface.co — and cached under FluidAudio/Models/. The fetch is one download event but many HTTPS requests (a tree listing plus one GET per file), and the library may re-contact the host for corrupt-cache recovery or load-failure diagnosis. No offline pin is configured (15.11).

9.6 Output Injection and Logging

Dictation finals are delivered as synthetic keyboard events — chunked CGEvent Unicode payloads typed directly into the focused element — and never touch the pasteboard on this default path. When no editable element has focus, the final is instead placed on the pasteboard for manual paste, marked org.nspasteboard.ConcealedType/TransientType so clipboard managers skip recording it. A legacy paste mode (deliveryMode = "paste") synthesizes ⌘V with the concealed marker set and restores the previous pasteboard contents about 250 ms after the paste, only if nothing else has written to the pasteboard in between (15.2). A debug log is written in every build; it records event names and content lengths only — transcript text, the account ID, and the optional contact email are never written (15.1). Webhook delivery of transcripts to a user-configured URL exists only on the Python backend; the native backend ignores those settings.

10. Privt ID Account Cryptography — mixed status

The derived-verifier auth, sessions, and rate limiting (10.1, 10.2, 10.5, 10.6) are Shipped and live. The email-free Account Identity (10.3) and its account-ID-keyed wire protocol (10.4) are Implemented, not deployed: the code is complete with tests, but the live worker still authenticates by email until the pivot migration is applied and the worker redeployed.

10.1 Passphrase KDF

ParameterValue
AlgorithmArgon2id v1.3 (libsodium Argon2ID13)
Iterations (ops)3
Memory268 435 456 bytes (256 MiB)
Lanes (p)1 (fixed by libsodium)
Salt16 bytes, CSPRNG
Output32 bytes

One passphrase yields two keys via two independent random salts: authKey (salt generated at registration; salt and params stored server-side) and KEK (salt embedded in the wrap blob). libsodium's Argon2id accepts no context label, so domain separation rests entirely on salt independence; privt/root-passphrase/v1 labels the wrap, not the KDF. Parameters are compile-time constants — not per-device tuned — but blob and kdfParams are self-describing, so future tuning is mechanical.

10.2 PassphraseWrappedRoot

{ "v": 1, "alg": "argon2id13", "ops": 3, "mem": 268435456,
  "salt": <16 bytes>, "sealed": <SealedBlob of ROOT under KEK, AAD "privt/root-passphrase/v1"> }

Unwrapping reads ops/mem from the blob and requires v == 1 && alg == "argon2id13". A local copy lives at vault/root.passphrase.json.

10.3 Account Identity (Privt ID)

The account identity is an 8-character Crockford-base32 handle (charset 0-9 A-Z minus I L O U; 40 bits), derived on the client as the first 5 bytes of HKDF-SHA256(recovery entropy, info = "privt/account-id/v1|" + N), N a decimal derivation counter starting at 0. The info-string format is frozen: the client submits its stored account.id first; on a 409 collision it re-confirms the 12 words to derive the next N; a future ID rotation likewise re-prompts the phrase; a device holding only the 12-word phrase re-locates the account by deriving candidates for N = 0, 1, 2, … and probing the recovery login until one authenticates. The server cannot verify the derivation — it treats the handle as an opaque unique string — and holds nothing invertible toward the phrase: HKDF is one-way and the 128→40 bit truncation is lossy (≈2⁸⁸ entropies per handle). Input is normalized before use (uppercase, separators stripped, I/L→1, O→0); the UI displays XXXX-XXXX. The handle is a lookup alias only: every foreign key, R2 path, and salt keys on the internal account uuid, so a future handle rotation is a one-column update. Rotation, when it ships, will not unlink an account from the operator — the internal row persists and links old to new; operator-level unlinking is burn plus a new account. account.id is written in the clear at vault creation (a lookup alias, not a secret) for every tier, free included; no recovery entropy is stored at rest. Email plays no role in identity: registration and login carry no email field; an optional contact address can be attached for notifications and removed at any time (Section 11).

10.4 Auth Wire Protocol

Registration (requires an unlocked vault; both Argon2id passes run off the main thread, roughly 1–2 s):

POST /api/auth/register
{ "accountId", "kdfSalt": b64(16B), "kdfParams": {"alg":"argon2id13","v":19,"ops":3,"mem":268435456,"p":1},
  "authKey": b64(32B), "wrappedRootPassphrase": b64(blob), "wrappedRootRecovery": b64(blob),
  "recoveryAuthKey": b64(32B, optional) }
→ 201 { "token", "expiresAt" } | 409 account_taken (client re-derives at N+1 and retries)

Login is two steps: POST /api/auth/params {accountId} returns {kdfSalt, kdfParams} — this endpoint is unauthenticated and therefore discloses handle existence, but a handle is an opaque 40-bit tag, not a person — then POST /api/auth/login {accountId, authKey} returns {token, expiresAt, wrappedRootPassphrase, kdfSalt, kdfParams}. POST /api/auth/login-recovery {accountId, recoveryAuthKey} authenticates from the 12-word phrase alone and doubles as the probe of the phrase-restore counter scan. No endpoint accepts an email address as an identifier. The client ignores login's echoed blob and instead fetches all wrapped material via GET /api/account/keys during restore (15.5).

10.5 Server Storage

Per user, the server stores: the account handle (account_id, unique, lookup alias only), an optional contact email (nullable, non-unique, deletable), SHA-256(authKey), kdf_salt_auth, kdf_params (opaque, echoed verbatim, never policed), the two wrapped-ROOT blobs (the crypto-shred targets), the optional ROOT-sealed children bundle, the optional recovery-login verifier hash, entitlement state, the optional peppered burn-code hash, and a burned flag. The legacy email column is semantically retired: it holds the row's own uuid and no address (a physical-schema constraint of the additive migration; auditable with one query). A passphrase change (Section 5, when the account is signed in) is a single UPDATE of exactly four columns — SHA-256(authKey), kdf_salt_auth, kdf_params, and wrapped_root_passphrase — leaving wrapped_root_recovery and the recovery-login verifier hash untouched (the recovery wrap never changes). No plaintext ever transits: the inbound authKey is a client-side Argon2id output the server stores only as SHA-256 of it, and wrappedRootPassphrase is opaque ciphertext the server cannot open. Sessions are not passphrase-derived (token_hash is SHA-256 of a random 32-byte token), so existing sessions survive a change; forcing sign-out on rotation is deliberately out of scope (a rotation compromises no key). Items, shares, sessions, and R2 keys are unchanged and key on the internal uuid — never the handle. The items table holds id, user, version, ciphertext size, updated-at, and tombstone flag — deliberately no title, type, or content columns; shares hold slug, user, size, policy, view count, and revoked/reported flags. R2 keys are items/<userId>/<itemId> and shares/<slug>, so no handler can address another user's objects. Verifier comparison remains a constant-time fixed-length loop with the dummy-digest discipline for unknown and burned accounts.

10.6 Sessions and Rate Limiting

Session tokens are 32 CSPRNG bytes, base64url, returned once; the server stores SHA-256 only, TTL 30 days with lazy expiry deletion. The client persists {accountId, expiresAt, entitlement} (plus a mirror of the optional contact-email state) in Privt Voice/account.json (0600); the token is held in the macOS Keychain (WhenUnlockedThisDeviceOnly), never in the file — 15.9. A Durable Object enforces strongly consistent sliding-window limits; refused attempts are not recorded, and login success resets the window:

NamespaceLimitWindowBehavior
login:<accountId>515 minHard block; correct passphrase refused while blocked
recovery:<accountId>515 minHard block; tolerant of the phrase-restore N-scan (one probe per candidate)
verify:<userId>515 minContact-email verification; keyed by the session's uuid, never attacker-controllable
arm:<userId>101 hBurn-code arming; self-keyed, bounds the arming-collision oracle
burnip:<CF-Connecting-IP>101 hHard block per source IP; replaces the per-email burn limiter (Section 13)
report:<slug>515 min

Turnstile verification is code-complete server-side and fail-closed when a secret is configured (register and login); the macOS client contains no Turnstile code and sends no turnstileToken, and the deployed worker runs with an empty secret and skips the check (15.12).

11. Sync Protocol — Shipped

11.1 Endpoint Inventory

MethodPathAuth / gatePurposeStatus
POST/api/auth/registernone (Turnstile when configured)Create account: {accountId, kdfSalt, kdfParams, authKey, wrappedRootPassphrase, wrappedRootRecovery, recoveryAuthKey?}; 409 account_taken → client re-derives at N+1Shipped
POST/api/auth/loginnone; Turnstile when configured; login DOSession issue: {accountId, authKey}Shipped
POST/api/auth/paramsnonePre-login salt fetch: {accountId}Shipped
POST/api/auth/login-recoverynone; recovery DORecovery-phrase session: {accountId, recoveryAuthKey}; doubles as the phrase-restore scan probeShipped
GET/api/meBearer{accountId, contactEmail, contactEmailVerified, entitlement, entitlementUntil}Shipped
POST/api/auth/logoutBearerSession revokeShipped
POST/api/account/emailBearerAttach/replace optional contact email; kicks verificationShipped
DELETE/api/account/emailBearerRemove contact email + pending verification (idempotent)Shipped
POST/api/account/passwordBearer only (not Pro — lapsed accounts must keep rotating)Passphrase rotation after a client-side Change Passphrase: {newAuthKey, newKdfSalt, newKdfParams, newWrappedRootPassphrase}; one UPDATE of the four passphrase-derived columns, recovery columns untouched; no plaintext transitsAdded (server-tested); deploy is a consent-gated follow-up
POST/api/dev/entitleADMIN_SECRETDev entitlement flip, keyed by accountIdShipped (live — see 15.12)
GET/api/items?since=<ms>Bearer + ProMetadata listShipped
GET/api/items/:idBearer + ProFetch sealed envelopeShipped
PUT/api/items/:idBearer + Pro; X-Base-Version headerUpload envelope (raw octet-stream, ≤2 MiB); 409 on version conflictShipped
DELETE/api/items/:idBearer + ProTombstoneShipped
POST/api/sharesBearer + ProCreate share snapshotShipped
GET/api/sharesBearer only (lapsed accounts must revoke)List own sharesShipped
DELETE/api/shares/:idBearer, ownerRevokeShipped
POST/api/report/:idnone; report DOAbuse report, no existence oracleShipped
GET/s/:idnoneConstant viewer pageShipped
GET/s/:id/blobnone — slug is the capabilitySealed snapshot, atomic view claimShipped
POST/api/account/burn-codeBearer only; arm DOArm burn code; 409 code_collision → client generates a fresh code; refuses (5xx) when the pepper secret is unconfiguredShipped
POST/api/burnnone; per-IP DO onlyCrypto-shred: {code} — the code alone resolves the account; always {ok:true}Shipped
GET/burnnoneConstant public duress page, single code field (self-contained, hash-pinned inline script; served by the API worker)Shipped

JSON bodies are capped at 64 KiB; all JSON responses carry Cache-Control: no-store; unhandled errors return a bare 500. Foreign, absent, and tombstoned resources return an identical generic 404.

11.2 Sync Algorithm

Sync runs only for signed-in Pro accounts: on save/delete with a 3 s debounce, every 5 minutes, and on demand. Each pass pulls, then pushes. Pull lists metadata {id, version, updatedAt, size, deleted} since the last cursor, downloads server-newer envelopes byte-for-byte, and applies tombstones. Push sends pending deletes, then uploads files modified since the last push, with X-Base-Version for optimistic concurrency. Conflict resolution is last-write-wins with the server preferred: a 409 fetches the server copy and overwrites the local file; a server-newer pull overwrites locally edited unpushed files; losing edits are not backed up and the user is not notified (Limitation 15.3). The whole pass moves sealed envelopes and never decrypts, so it works with the vault locked. Caps: 2 MiB per envelope, 2000 live items, 100 MiB ciphertext per user.

11.3 What the Operator Can See

note, meeting, and the reserved prefs item sync — all opaque sealed envelopes (15.8); the operator gains one more id/type/version/size triple (type prefs) and no content. The uploaded envelope is opaque to server logic but is parseable JSON: the operator can read id, type, version, and mode (box indicates capture while locked), plus exact sizes and timing. Content, titles, and speaker labels are inside the AEAD (full floor: 15.10).

12. Share Links — Shipped

A share is a sealed snapshot posted to POST /api/shares; the server mints a 22-character base64url slug (16 CSPRNG bytes) and stores the snapshot without parsing it. The client half is shipped: from the note toolbar, the app seals a plaintext snapshot (title and body) under a fresh 32-byte CSPRNG Share Key using the same libsodium AEAD envelope as the vault, uploads it Pro-gated with optional expiry and view-limit policy — an unlocked vault is required, since the snapshot is sealed from the decrypted note — and assembles <apiBase>/s/<slug>#<key-base64url>. The finished URL is placed on the pasteboard with the org.nspasteboard Concealed/Transient markers so clipboard managers skip it; the key is never persisted or logged. A management sheet lists the account's shares (policy metadata only — the server keeps no titles) and revokes them; listing and revocation are session-gated but deliberately not Pro-gated, so a lapsed account can still kill its outstanding links. The Share Key travels only in the URL fragment. Snapshot format:

{ "v": 1, "alg": "xchacha20poly1305ietf", "n": <b64 24-byte nonce>, "ct": <b64> }   AAD: "privt/share/v1"

The viewer uses the 32-byte Share Key directly as the AEAD key — no per-share HKDF step exists — and the AAD is a constant label (the slug is assigned after sealing and cannot be bound). Unfurler defense is structural: GET /s/:id returns one of two constant HTML pages — a script-free neutral page (Open Graph tags only) to bot-signature user agents, the viewer page to all others — touches no storage, consumes nothing, and within each class is byte-identical for existent and nonexistent slugs; views are consumed only by the explicit reveal fetch to /s/:id/blob, which claims a view atomically in a single conditional UPDATE (proven exact under concurrency in tests) and refuses bot user agents and document navigations without consuming. The viewer is self-contained under a strict CSP (default-src 'none', hash-pinned script) and renders plaintext via textContent only; it decrypts with the @noble/ciphers XChaCha20-Poly1305-IETF implementation (~5.8 KB gzipped) rather than libsodium, whose WASM build is ~32× larger gzipped (~41× raw) and would force a 'wasm-unsafe-eval' CSP loosening. Expiry, max-views (1 = one-time), revocation, and an abuse-report endpoint (always {ok:true}; no existence oracle) are included. Caps: 2 MiB per snapshot, 200 active shares.

13. Burn — mixed status (email-keyed burn Deployed; code-only peppered burn Implemented, not deployed)

The live worker today runs the earlier {email, code} burn. The code-only peppered burn described here is Implemented with tests but not deployed — it ships with the identity-pivot migration and redeploy.

A burn code is a destroy-only credential over a ciphertext-only database, and the code alone is the trigger: POST /api/burn {code} — no email, no account identifier. Arming stores HMAC-SHA-256(pepper, "privt/burn/v3|" ‖ code), where the pepper is a worker secret that never exists in the database: a stolen database therefore contains nothing attackable offline — without the pepper the stored value is a keyed PRF output over an unknown 256-bit key (HMAC, not a length-extendable SHA-256(secret‖message) concatenation). The hash is globally unique (enforced when arming; a collision makes the client generate a fresh code), so the server resolves a presented code by hashing it and looking the digest up by unique index — wrong code and unknown code are literally the same index miss, and the response is {ok:true} in every case. The code is client-generated as two modifier–noun pairs from the pinned 800×1651 wordlist (~41 bits), canonicalized to lowercase a–z on both ends. The entropy reduction from the earlier three-pair format is paid for by the pepper (no offline path exists) and bounded online by per-IP throttling only — deliberately no global limiter and no limiter keyed by any attacker-choosable value, because a flooded limiter must never block a victim's real burn; denial-of-burn is the one unacceptable failure. Residual honesty: an attacker sharing the victim's NAT IP could exhaust that IP's budget; multi-target guessing across a large armed population is monitored (alert-only) with a documented tripwire to grow codes via the versioned format. Execution is unchanged: synchronous, idempotent, ordered to fail toward destruction — one UPDATE destroys the wrapped keys, verifiers, KDF material, the burn hash, and the optional contact email (burning also severs the one opt-in real-world link), then sessions, R2 objects, and bookkeeping rows. The tombstone row keeps the account handle forever — burned handles answer every endpoint with the same generic refusal as nonexistent ones and are never reused. Arming remains session-gated, displays the code exactly once, and no disarm path exists.

Burns execute from GET /burn, a constant self-contained page served by the API worker (strict CSP, hash-pinned inline script, no external resources, nothing request-derived) with a single code field, that POSTs /api/burn and shows one neutral confirmation for every 200 — the page distinguishes only "submitted" from "not submitted", never credential outcomes. Backup-retention copies and device-local data are not reached (15.14).

14. Threat Model

14.1 Server Compromise or Operator Compulsion

A full copy of D1 and R2 yields hashed verifiers, wrapped-ROOT blobs, sealed envelopes, and metadata — no content keys, no plaintext. The recovery-wrapped ROOT is sealed under 128-bit entropy and is not brute-forceable. The passphrase-wrapped ROOT is exposed to offline dictionary attack at full Argon2id cost per guess (15.4); a compelled or compromised operator gains exactly this plus the metadata floor (15.10). The verifier is a hash of a memory-hard derivation and cannot be replayed. A malicious server can withhold, roll back, or delete ciphertext (availability, not confidentiality) and can exploit server-preferred LWW to discard client edits; it cannot forge envelopes that authenticate.

14.2 Device Theft

With FileVault enabled and the machine powered off, all local data is protected at the disk layer. On an unlocked volume, vault items remain sealed under ROOT, whose release requires a live biometric on SE machines; the previously-exposed vocabulary config and session-token file are now sealed under MK (store/prefs.pv) and in the Keychain respectively (15.8, 15.9), so the remaining plaintext-by-design surfaces are the deposit public key, the account.id alias, the meta.json axes, and the burn armed-marker — plus a pasteboard remnant only from manual-paste or legacy-paste delivery (15.2). On Macs without a Secure Enclave, ROOT is sealed under an Argon2id passphrase wrap (root.passphrase.json): a stolen disk image yields only ciphertext, its salt, and KDF parameters — no usable key at rest, so opening it needs the passphrase (exposed to offline dictionary attack at full Argon2id cost per guess, 15.4) or the recovery phrase. On an SE Mac whose gesture is a passphrase, the wrap is instead hardware-bound (root.passphrase.se.json, Section 5): the Argon2id blob is itself sealed inside a non-biometric Enclave key, so a stolen disk image cannot reveal the inner blob at all and the passphrase is not even offline-attackable — the offline attack that tier 3 permits is closed. In every tier this eliminates the retired plaintext device-key.blob; meta.json records hardware/gesture, not the retired mode.

14.3 Network Observer

All traffic is TLS. An observer learns that the client talks to api.stayprivt.com (Pro) and the model host at first launch (models.stayprivt.com; huggingface.co only if the mirror is unreachable), plus sizes and timing. No content transits on any tier unless the user opts into webhooks on the Python backend.

14.4 Malicious Unfurl Bots and Link Scanners

Prefetch bots receive the constant script-free neutral page (Section 12), not the viewer; user agents never transmit the fragment, views are consumed only by an explicit reveal action, and both routes reject bot signatures fail-closed. The 128-bit slug is not enumerable.

14.5 Coercion and Duress

The burn code destroys server-side decryption material in one unauthenticated, uniformly answered request. The code is armed from the client's settings pane and exercised from any browser at /burn with the code alone — no account identifier, no session, no confirmation step, one uniform response. Because the endpoint is throttled per source IP only, no remote party can lock a victim out of their own burn by knowing an identifier; the residual (an adversary on the victim's own network exhausting that network's budget) is stated in 15.14. Boundaries are stated in 15.14 — burn does not reach retention backups or the stolen device itself, and it requires network reachability.

Out of scope: a compromised operating system or root malware on the user's Mac; memory forensics against a running, unlocked process (see 15.6); traffic-analysis resistance.

15. Limitations and Non-goals

Each item states what the code does today.

15.1 Debug log — redacted (resolved). The logger's stated policy is event names and state transitions only, and the current build enforces it: dictation finals, call segments, titles, partial excerpts, correction pairs, and the optional contact email are recorded as lengths and counts only — no transcript text, no account ID, and no email string is written to ~/Library/Logs/PrivtVoice/app.log (an always-on event log, 5 MB rotation; --debug adds stderr mirroring). "Plaintext transcript content never touches disk on the native path" now holds, subject to the pasteboard (15.2) surface (the vocabulary dictionary is now sealed — 15.8). No key material is logged. The entry is retained to preserve numbering; earlier revisions correctly reported a leak here, fixed as of this version.

15.2 Delivery-channel exposure (mitigated). The pasteboard is readable by any process with no permission prompt, which made the previous always-on-clipboard delivery a standing infostealer target. Typed delivery is now the default: finals reach the focused app as synthetic keyboard events and never touch the pasteboard. Residual surfaces, stated exactly: a process holding an event tap can observe synthetic keystrokes, but event taps sit behind the Input Monitoring/Accessibility TCC gates — the transcript moved from a permissionless channel to a permission-gated one, not to no channel. With no editable focus the final is deliberately left on the pasteboard (concealed-marked) for manual paste; the legacy paste mode exposes the final on the pasteboard for roughly 250 ms; concealed markers are honored by clipboard managers by convention, not enforced by the OS; and the receiving application retains whatever is delivered into it.

15.3 Conflict copies (resolved). Sync is still last-write-wins for the canonical item, but a locally-edited, unpushed note is no longer silently lost when the server diverges. Before any of the three destructive operations — a server-newer overwrite, a push 409, or a tombstone delete — the losing local sealed blob is copied to a conflicts/ holding directory if the local file has an unpushed edit (mtime later than the last push; the push-409 case is always a conflict). This Phase 1 runs in any lock state (ciphertext only); the reserved prefs singleton is exempt, where LWW is acceptable (15.8). On the next unlock a resolver (Phase 2) decrypts each preserved blob via its own envelope, re-seals it as a fresh note titled "(conflicted copy <date>)" under a new id (hence new AAD), deletes the stash only after the replacement is written, and keeps the stash to retry if decryption is not yet possible — nothing is dropped even across an app restart. Residuals: a Phase-1 copy that itself fails (disk full, permissions) is logged loudly rather than swallowed, but that edit is then lost (the guarantee degrades from "never lost" to "never lost silently"); and a conflict that recurs before an unlock yields multiple timestamped copies, each surfaced as its own note.

15.4 Offline brute-force of the passphrase blob; no SVR-grade claim. The server necessarily stores PassphraseWrappedRoot, and on no-SE Macs the same blob is also the local device wrap (root.passphrase.json) — so passphrase strength is the at-rest security on those machines, not merely a Pro-account property. Anyone holding a database copy or a stolen no-SE disk image can guess passphrases offline at full Argon2id cost per guess — rate limits bind only the online path. The operator runs no attested-enclave infrastructure, so no Signal-SVR-style protection for short secrets is claimed, and an operator-controlled rate limiter is not presented as a substitute for that property. Passphrase strength is therefore the user's responsibility. KDF parameters are fixed constants, not per-device tuned; domain separation between authKey and KEK rests solely on independent random salts, as libsodium's Argon2id accepts no context label. This offline exposure is the software tier's property. On an SE Mac with a passphrase gesture the wrap is hardware-bound (root.passphrase.se.json, Section 5): the PassphraseWrappedRoot is sealed inside a non-biometric Enclave key, so a stolen disk — or the server's copy, which is only ever the plain inner blob — cannot begin the offline attack against that machine's device wrap. The server still stores the plain PassphraseWrappedRoot for cross-device restore, so the offline-attack exposure of the server's copy is unchanged; hardware-binding protects the local disk, not the operator's database row. A passphrase change (Section 5) re-wraps under the same tier's construction, so these at-rest properties are unchanged: the software tier's new root.passphrase.json (and the server's replaced PassphraseWrappedRoot) remain offline-attackable at full Argon2id cost per guess, and the SE tier's new wrap stays hardware-bound. A change replaces the stored blob but does not alter its offline-exposure class — a weak new passphrase is as offline-exposed as a weak old one on those machines.

15.5 New-device bootstrap — server Deployed, client in an unshipped build. The restore loop is closed in code: enrollment uploads a ROOT-sealed bundle of the child keys (MK, identity, deposit) alongside the wrapped ROOTs; GET /api/account/keys returns all wrapped material to an authenticated session; sign-in on a vault-less machine rebuilds the vault — passphrase path via PrivtVault.restore, recovery-phrase path via POST /api/auth/login-recovery with a domain-separated HKDF verifier that decrypts nothing — minting a fresh per-device Secure Enclave key. The server half is deployed; no shipped build contains the client paths yet, so today restoring onto a new device still requires the original vault directory. Accounts enrolled before the bundle upload existed have no wrapped_children server-side and must re-enroll to become restorable.

15.6 Key zeroization and pinning (resolved; residuals stated). The session-lived secrets — ROOT, the app MK, and the deposit secret — are held in SecureBytes, a reference-type wrapper over one manually-managed raw buffer (no copy-on-write aliasing). For as long as the vault is unlocked the buffer is mlock'd, so the key never pages to swap or a hibernation image, and it is scrubbed in place with memset_s (guaranteed not elidable, unlike memset) at lock() and again on deallocation. This replaces the prior defeated path, where lock() zeroed a copy-on-write copy and freed the original buffer unscrubbed. Handing a key to the AEAD primitives goes through withKey/withData, which materialize a transient copy for one call and memset_s-scrub it immediately after (uniquely referenced at scrub time, so the scrub is effective). Residuals, stated plainly: (a) the passphrase-KDF path snapshots ROOT into an ordinary, unpinned array before an off-actor Argon2id for the ~1–2 s of that operation, then frees it unscrubbed — Swift cannot reliably scrub a value-array captured into a @Sendable closure, and the snapshot is taken for race-safety against a concurrent lock(); bounded to passphrase-change operations and mitigated by macOS encrypted swap. (b) create does not zero the freshly generated deposit-secret transient after sealing it. (c) Software AEAD requires the key in RAM, so an adversary who already has memory-read on the unlocked session can read a key in use; no userspace mitigation closes this and the app does not claim to — the Secure Enclave gates release of ROOT, the strongest boundary short of in-enclave bulk crypto.

15.7 APFS shred limits. Plaintext sweeps use a single zero-fill pass, sync, and unlink. APFS copy-on-write and SSD wear-leveling can retain stale extents; this is not forensic-grade erasure. FileVault is the intended backstop.

15.8 Vocabulary and settings — sealed (resolved). customVocabulary, replacements, and harvestedNames are now sealed under the App Master Key as a single reserved prefs item in the encrypted store (store/prefs.pv), using the same per-item DEK, AAD (privt/item/v1|prefs|<version>|prefs), and locked-write deposit path as notes and meetings. At rest only ciphertext exists; the plaintext is decrypted into memory only while the vault is unlocked, where the native correction layer reads it. While locked, custom-vocabulary correction is simply unavailable — dictation and capture are unaffected — and no plaintext copy is kept on disk. On first unlock any legacy plaintext in notari/config.json is sealed into the store and the three keys are stripped from the file (best-effort overwrite; APFS copy-on-write and FileVault caveats as in 15.7). The reserved item syncs for Pro users as an opaque envelope like any other, closing the earlier "unsynced" gap; it is hidden from the notes browser. Earlier revisions correctly reported plaintext here, fixed as of this version.

15.9 Session token — Keychain (resolved; no refresh). The Privt ID session token is stored in the macOS Keychain as a generic-password item (kSecAttrAccessibleWhenUnlockedThisDeviceOnly, stable service/account labels), not in a file. account.json now holds only non-secret session fields (accountId, expiresAt, entitlement, and the optional contact-email mirror); on load, a token found in a legacy account.json is moved to the Keychain and stripped from the file. Expiry remains client-side only with no refresh — an expired session still silently disables sync until re-login; that limitation is unchanged and tracked separately.

15.10 Metadata floor. (Describes the identity-pivot model — Implemented, not yet deployed. The currently deployed database still keys accounts by email; the floor below becomes accurate on pivot deployment.) The operator's database holds an opaque 8-character account tag and ciphertext — no name, no email, no real-world identity — unless the user opts into a contact email (deletable, destroyed by burn). What the operator can observe: account handle and creation time; the opt-in contact address while attached; entitlement state; envelope plaintext fields id, type (note vs meeting), version (edit count), and mode (box reveals capture-while-locked); exact ciphertext sizes — no padding buckets exist; item counts and update timing; session counts; share slugs, policy, view counts, and access timing; IPs and user agents at the Cloudflare edge. Payment is the honest boundary: when billing ships, Stripe — not our database — necessarily knows who paid, and payment-processor records survive both ID rotation and burn; no Privt identifiers enter Stripe metadata, and webhooks resolve customers to the internal uuid only. Free-tier users appear in none of this.

15.11 Model download. The "one-time download" is one event but dozens of HTTPS requests at first app launch, and the library may re-contact the host for corrupt-cache recovery or load-failure diagnosis. The client now defaults its model base URL to the first-party mirror models.stayprivt.com, with a one-shot fallback to huggingface.co; the mirror service (a read-through Worker over R2 at models.stayprivt.com) is deployed: the client's download talks only to first-party infrastructure, and on a cold cache miss the Worker — not the client — fetches the file from huggingface.co server-side and stores it. No offline pin is configured.

15.12 Deployed-configuration gaps — closable only with client code changes. The live worker runs with ENVIRONMENT = "dev", leaving POST /api/dev/entitle reachable in production: anyone can flip any account's entitlement by account ID (Pro gating only; no data exposure). Setting ENVIRONMENT = "production" is not sufficient by itself: the shipped client's only Pro-upgrade path calls this endpoint after registration, so closing it breaks the app's upgrade flow until a real billing path ships. The Turnstile secret is empty, so bot verification is skipped: login is protected by the Durable Object alone, and registration currently has no bot gate. This gap is likewise not configuration-only: the client contains no Turnstile code and sends no turnstileToken, and the server fails closed when a secret is set, so configuring the secret today would refuse all registration and login until client Turnstile support ships.

15.13 Auto-lock idle detection is narrow. The idle timer counts only notes-window activity; a vault left unlocked with the window untouched locks on the last-armed timer, and activity elsewhere in the app does not extend it.

15.14 Burn boundaries. Burn destroys current-state rows and objects synchronously but does not reach D1 point-in-time-recovery or R2 versioning retention copies, nor any device-local vault; the tombstone persists as a permanently reserved account handle (payment-processor records also survive — 15.10); and the endpoint must be reachable at the moment of duress. The stored verifier is a peppered hash under a worker secret held outside the database: a database copy alone yields nothing offline-attackable, while an adversary holding both the database and the worker secret store can recover every armed code in one fast unsalted pass over ~41 bits (minutes of GPU time) — accepted because that adversary class already controls the worker that executes burns and serves wrapped blobs, so the pepper adds no new single point of failure while removing one (database-only theft). Pepper rotation is impossible without a universal re-arm, and deleting the secret while codes are armed makes real burns silently ineffective behind the uniform response — an operator-error class mitigated by loud logging and a hard arming refusal, not by any silent fallback. The code's ~41 bits are sized for the blind, per-IP-throttled online channel; global lookup divides untargeted guessing cost by the armed population, with a documented client-side escalation back to three pairs (~61 bits) before that becomes material. The per-IP limiter's residual cuts one way only: an adversary on the victim's own network (shared NAT/CGNAT) can exhaust that network's budget and briefly block a burn from it — escaped by switching networks; no remote party can block anyone's burn by knowing an identifier. The authenticated arming endpoint's collision signal is an exact-string existence oracle, rate-limited per account, yielding only "this code is armed by someone" — never by whom. The armed/not-armed flag is device-local plaintext metadata (like 15.9's session file): an inspector of the device learns that a duress code exists.

15.15 Recovery phrase download. The application never persists the phrase, but onboarding offers a user-chosen plaintext download (privt-recovery-phrase.txt, 0600). "Never persisted" holds for the application's own storage, not for what the user elects to write.

15.16 Relocatable packaging (resolved). Resources (BIP-39 and burn wordlists, onboarding/notes/settings HTML, brand SVGs, sounds, app icon) now load through a single relocatable root: in a packaged .app it is Contents/Resources, into which the build mirrors the repo subtree (app/Resources/… and assets/…) so the WebViews' relative references (../../assets, sounds/) resolve identically to the development checkout; in dev the root is discovered by walking up from the executable to the checkout. No absolute development path remains in the shipped binary, so the app runs from any location. The BIP-39 wordlist remains hash-pinned, and the burn wordlist shape-pinned (version and exact list lengths), regardless of location.

15.17 Gesture toggle and the non-biometric SE key. The passphrase-binding Enclave key carries .privateKeyUsage only — usable silently by the app while the macOS session is unlocked. This is intentional (the strip must be prompt-free so the passphrase is the sole gesture), and it means the key does not itself gate on any user presence: its whole job is machine-binding, and the passphrase is the knowledge factor layered on top. Switching gesture (Settings › Privacy) re-mints the appropriate key and re-wraps ROOT from an unlocked vault; the old wrap files are deleted, but forensic disk retention (15.7) may leave stale extents of a retired wrap until pages are reused. Change Passphrase (Section 5) re-wraps under the same tier from an unlocked vault via the same primitive and inherits the same page-retention caveat for the overwritten wrap. Unlike the Touch-ID key, the non-biometric key is not .biometryCurrentSet, so it is not invalidated by biometric re-enrollment — only by a new enclave (erase/restore), after which root.passphrase.se.json is unopenable and the recovery phrase is the rescue. After-recovery re-set. Because that orphaned state leaves resolvedAxes() still reporting (secure-enclave, passphrase), the lock screen presents the passphrase field, and a (correct) passphrase attempt fails at the silent SE strip with seInvalidated. The app then routes to the recovery phrase, which is tier-independent and unlocks; on success it detects the orphan by attempting the silent strip (never by guessing) and prompts to set a new passphrase for this Mac, minting a fresh non-biometric SE key and re-wrapping ROOT — the identical construction to the gesture toggle, curing the orphan. The step is skippable (the vault stays recovery-only until set) and is also offered in Settings › Privacy so a dismissed prompt can be completed later; if the account is signed in, the new passphrase propagates to Privt ID (Section 10.5).

Non-goals. Protection against a compromised OS or root malware; forensic-grade disk erasure; a metadata-free service; short-secret (PIN) recovery without attested hardware; plausible deniability. Post-quantum asymmetric cryptography is deferred: every stored blob is wrapped symmetrically at 256 bits, which Grover degrades only to ~128-bit strength; the sole future asymmetric exposure is member-to-member sharing, which will ship as hybrid ML-KEM-768 + X25519, never post-quantum-only. All formats are versioned today to make that migration mechanical.

16. Verification — Shipped

notari --vault-selftest runs 36 tier-independent checks against a temporary passphrase-mode vault (forceSoftware ⇒ ROOT wrapped under Argon2id, the no-SE at-rest posture): BIP-39 (wordlist pin, three published spec vectors, random round-trip, swapped-word rejection — 6); AEAD (round-trip, wrong-key, AAD-mismatch, tamper rejection — 4); passphrase (Argon2id determinism with salt separation, ROOT wrap/unwrap, wrong-passphrase rejection — 3); hierarchy (creation with entropy mixing, item round-trip, wrong-id AAD rejection, lock drops keys, passphrase unlock, deposit save readable only after unlock, deposit-wrapped item unreadable while locked, wrong-passphrase rejection, no-usable-key-at-rest assertion — device-key.blob absent and meta.hardware == "software"/meta.gesture == "passphrase", recovery-phrase rescue in passphrase mode, new-device restore with no key blob on disk, wrong-phrase rejection — 12); Privt ID and burn-code pins (account-ID derivation vectors at N = 0/1/10, input canonicalization including I/L/O mapping and U rejection, account.id written at creation with no id.entropy.json on disk, candidate derivation from a re-entered phrase with foreign-phrase rejection, the legacy no-account.id mint, no id.entropy.json after a phrase unlock, two-pair burn-code shape — 8); change passphrase in the software tier (the new passphrase unlocks and opens an existing item, the old passphrase is rejected, and the on-disk root.passphrase.json opens with the new passphrase but not the old — a no-stale-openable-artifact assertion — 3). When the host has a Secure Enclave it adds a hardware-bound tier block (10 more, 46 total): create an SE+passphrase vault and assert meta.hardware == "secure-enclave" and meta.gesture == "passphrase", that root.passphrase.se.json and se-passphrase-key.blob exist while root.passphrase.json is absent, an item round-trips, passphrase unlock strips the silent SE outer then the Argon2id inner, wrong-passphrase is rejected, the recovery phrase rescues the vault, the load-bearing assertion that root.passphrase.se.json is not openable by the passphrase KEK alone (the SE outer layer is required), and — after a change passphrase in this tier — that the new passphrase unlocks via the silent SE strip plus the fresh Argon2id inner, the old passphrase is rejected, and no stale plain artifact survives (root.passphrase.json absent, the re-sealed SE file still not openable by the KEK alone). On a host without an Enclave the block prints skip and does not fail. Share and burn behavior carry server-side test suites, including a concurrency proof that view claims are exact, constancy/CSP-hash proofs for the public burn page, and a denial-of-burn regression (a victim's correct burn from one IP succeeds while an attacker floods from another).

17. Status Summary

FeatureStatus
Key hierarchy, two-axis device gate (SE+Touch ID, hardware-bound SE+passphrase, software+passphrase), gesture toggle, recovery phrase, entropy mixing, item AEAD, deposit locked-write, sealed store, auto-lock, self-testShipped
Native engine: dictation, call lanes, tap scoping, direct sealed save, model auto-downloadShipped
Typed (clipboard-free) dictation delivery; concealed-marked manual-paste fallback; legacy paste with clipboard restoreShipped
Account crypto (client), server auth/sessions/rate-limit DO, item sync, capsShipped
Debounced + periodic LWW sync, tombstones, locked-vault syncShipped
Share and burn server halves (endpoints, viewer, unfurler defense, tests)Deployed
New-device restore: children-bundle upload, GET /api/account/keys, recovery-phrase loginDeployed server-side; client paths in an unshipped build
Email-free identity (account_id registration/login, phrase-restore N-scan, optional contact email)Shipped
Code-only peppered burn (two-pair codes, global code lookup, per-IP limiting)Shipped
R2 model mirror (models.stayprivt.com read-through Worker)Deployed
Contact-email verification (schema, endpoints, rate limit; delivery dark until a sending domain exists)Deployed (dark)
/api/dev/entitle admin-secret gateDeployed
Identity keypair (generated, unused); Turnstile (server-side only; live secret unset)Implemented
Burn client half (code generator, arming UI; public /burn page served by the API worker)Shipped
Share client half (snapshot sealing/posting, share sheet, manage/revoke)Shipped
Turnstile client token support; non-dev Pro upgrade path (billing)Planned
Web account portal, Stripe billing, passkeys, iOS clientPlanned
Vocabulary encryption and sync; padding buckets; key transparency; member-to-member sharing (hybrid PQ)Planned

18. References

  1. RFC 9106 — Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications.
  2. libsodium documentation — AEAD (XChaCha20-Poly1305-IETF), sealed boxes, crypto_pwhash (Argon2id 1.3), CSPRNG.
  3. BIP-39 — Mnemonic Code for Generating Deterministic Keys (Bitcoin Improvement Proposal 39).
  4. Apple Platform Security Guide — Secure Enclave, Data Protection, FileVault.
  5. RFC 8439 — ChaCha20 and Poly1305 for IETF Protocols; draft-irtf-cfrg-xchacha — XChaCha: eXtended-nonce ChaCha and AEAD_XChaCha20_Poly1305.
  6. RFC 5869 — HMAC-based Extract-and-Expand Key Derivation Function (HKDF).
  7. OWASP Password Storage Cheat Sheet.