Security & Privacy · Whitepaper

Privt Voice - Security & Privacy Specification

Version 0.4 - 2026-08-31. Living document. This specification is updated as each phase ships. Every statement below describes the code as audited on 2026-08-31; 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 encrypted client-side into an envelope vault whose root key is gated by the Secure Enclave and Touch ID where available, or by an account passphrase (hardware-bound on an Enclave Mac, software-only otherwise; see Section 5). 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 encrypted 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, 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 the KEK — the passphrase device wrap written at vault creation on the two passphrase tiers (free tier included), or added on the SE+Touch-ID tier only when an account passphrase is attached at Privt ID registration.
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.
Per-container vault key256-bit key derived on demand as HKDF-SHA256(MK, salt "privt/vault-key/v1", info = container-id ‖ 0x00 ‖ uint32-BE(epoch)); wraps the DEK of any page nested in that container (v3 sealing). Never stored - only the per-container epoch counter persists (Section 4.4).
PageThe single user unit: a sealed item with a body and an optional set of children. note, meeting, and file are page subtypes; "folder" is no longer a separate type, just a page that has children (Section 8).
ContainerA page that has children. Containment is derived from having children, not from a subtype; nesting is a parentId sealed inside the payload, with no separate container object.
FileA page subtype whose bytes live in sealed blobs - small files inline in the payload, larger files split into blocks that are opaque random-id blobs, so the operator cannot link them or tie a file to its chunks (Section 8.1).
KEKArgon2id derivation of the passphrase (the device unlock gesture on the passphrase tiers, free tier included — also the account passphrase once a Privt ID is registered) under a blob-local salt; wraps ROOT in PassphraseWrappedRoot. Never leaves the device.
authKeyThe sign-in key: HKDF-SHA256 over an Argon2id derivation of a labelled input (privt/auth-key/v2, the Privt ID, the passphrase) under a separate random salt (Section 10.1); 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 wrapped 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.
Speaker attribution (diarization)On-device, per-meeting grouping of far-end audio into Speaker 1/2/…; voice embeddings computed on-device to cluster a single recording, then discarded (grouping, not recognition); a stable per-segment speakerId and a sealed speakerNames map inside the schema-2 note payload; no server / D1 schema change.
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. A session lasts 30 days. One made by a passphrase sign-in on the current sign-in key renews while in use, for at most 180 days from that sign-in (10.6).
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. (Shipped.)

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. End-to-end encryption off-device. Every key capable of decrypting content exists only on user devices; the operator cannot read synced content, and the metadata it can observe is listed in 15.10.
  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["Passphrase<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"]
  MK -->|"HKDF: container id + epoch"| VK["Per-container vault key<br/>v3 - nested page"]
  VK -.->|"wraps DEK for a nested page"| DEK
  DS -.->|"deposit public wraps DEK while locked"| DEK
  DEK -->|"XChaCha20-Poly1305<br/>AAD binds id, version"| CT["Item ciphertext<br/>page: note, meeting, or file"]

Three independent wraps of ROOT exist: the SE wrap (device unlock), the recovery wrap (rescue), and the passphrase wrap (device unlock on passphrase-gesture Macs, free tier included; on SE + Touch ID Macs, the Pro account's local copy); 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. A page nested inside a container seals one layer deeper: MK derives a per-container vault key (Section 4.4) that wraps that page's DEK, so containment is enforced by key derivation rather than a plaintext label.

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-encrypts 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 in one of two on-disk formats, selected by the v field:

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

v=1 (unpadded) is the form used by every key wrap in the Section 4.2 inventory (and by share blobs): the plaintext is sealed directly. v=2 (size-bucketed) is the form used for all synced item content (sealItem): before sealing, the plaintext is prefixed with a 4-byte big-endian length and zero-padded up to a size bucket - 512, 2 048, 8 192, 32 768, 131 072, 524 288, or 2 097 152 bytes, else rounded up to the next 64 KiB - so the whole length‖data‖padding buffer is authenticated inside the AEAD and the stored ciphertext size reveals only a coarse bucket, not the real item length (Section 15.10). open() accepts v ∈ {1, 2} and transparently strips the v=2 length prefix and padding.

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.

4.4 Per-container vault keys (v3 sealing) - Shipped

Items that live inside a container seal one layer deeper than the MK-wrapped path of Section 8. The gate is structural, not a type whitelist: an item seals v3 if and only if it carries a non-empty parentId and the vault is unlocked at write time, so nested notes, meetings, files, pages, and legacy folders alike take the v3 path (NoteStore.v3Container, NoteStore.sealAndWrite). Top-level items, blobs, and the reserved singletons (prefs, the vault-keys registry) carry no parentId, so they stay on the v2 MK-wrap path by design. A container item written while the vault is locked has no MK to derive under, so it falls to the deposit path (Section 7) instead and re-seals as v3 on its next unlocked write - the gate is re-evaluated on every write, not swept in a background upgrade.

The container's vault key is derived, never stored:

vault key = HKDF-SHA256(ikm  = MK,
                        salt = "privt/vault-key/v1",
                        info = <container-id UTF-8> ‖ 0x00 ‖ uint32-BE(epoch),
                        L    = 32)

The epoch is a rotation-generation counter, encoded as a 32-bit big-endian unsigned integer (truncating if needed) and appended after the container id's UTF-8 bytes and a single 0x00 separator (VaultKeyLayer.deriveVaultKey). Each write then mints a fresh 256-bit DEK, seals the content under it, and wraps that DEK under the derived vault key (PrivtVault.sealItemV3, producing an ItemBox with v:3 that carries the epoch). The two AAD strings, verbatim:

content AAD:      privt/item/v2|<id>|<version>
v3 DEK wrap AAD:  privt/dek/v3|<id>|<version>|e<epoch>

The content AAD binds only id and version - byte-identical to the v2 MK-wrapped path of Section 8, carrying neither the item type nor the wrap mode. Only the v3 DEK-wrap AAD folds in the epoch, so a wrap presented with the wrong generation fails on two counts at once: the wrong derived key and the wrong-epoch AAD. Rotating a container advances its epoch, which re-derives a new vault key and darkens the previous generation without ever touching MK or ROOT.

The container id appears in no AAD. The derived key itself is the binding - content sealed for one container cannot be opened under any other container's key, because that key never reproduces the DEK wrap - so on open the reader trial-unwraps the candidate container keys rather than reading a container id from the blob. The VaultKeyRegistry, persisted as the reserved sealed item vault-keys, records only the current epoch per container (a small, secret-free integer); it holds no key bytes for a derived container, since the key is recomputable from MK, the container id, and the epoch.

Mac (VaultKeyLayer.deriveVaultKey, PrivtVault.sealItemV3) and the web client (scripts/login-entry.mjs) derive the vault key and construct both AAD strings byte-for-byte identically, so an item sealed on either client opens on the other.

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 stays recovery-gated - meta.json is not rewritten on an ordinary unlock (legacy v1 mode is read-only) - until the user sets a gesture or a restore runs, which write the v2 axes. 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. HKDF uses an empty salt and sharedInfo = "privt/root-se/v1", also the AES-GCM AAD. .biometryCurrentSet invalidates the wrap on biometric re-enrollment: a genuine re-enrollment or restored-enclave failure (any SE key-load or ECDH error other than a Touch-ID dismissal) maps to seInvalidated, while an error whose lowercased text contains -25293 (errSecAuthFailed), cancel, or denied - a disjunction of the three - is surfaced as a retryable touchIDCancelled that leaves the wrap intact and does not re-provision. A recovery-phrase unlock then re-provisions a fresh SE wrap; the passphrase tiers keep their existing device wrap.

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

The diagram is the tier-1 wrap/unwrap. The hardware-bound tier below reuses this exact construction, encrypting 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 passphrase gesture because a fingerprint can be forced and a passphrase can't (§9 of the internal hardware-bound-passphrase design doc) - 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 encrypted 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 encrypts ROOT. The passphrase and KEK are never written; only ciphertext, salt, and KDF parameters land on disk. 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.

Changing the passphrase. From an unlocked vault ROOT is already in memory, but Change Passphrase still demands the current passphrase as an explicit proof of knowledge: it is verified on-device by re-deriving its KEK and confirming it opens the stored PassphraseWrappedRoot - exactly the check unlock performs - so simply being at an unlocked Mac is not enough to rotate it. Only then does it take a new passphrase and confirmation (minimum 12 characters). The verification never touches the in-memory ROOT and runs off the main actor; a wrong current passphrase is reported inline and nothing is re-wrapped. It is a re-wrap, not a re-key: ROOT is unchanged, so the recovery wrap, the ROOT-encrypted children (MK, identity, deposit), and every encrypted 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-encrypts 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 shipped) 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 wrapped 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: wrap DEK to deposit public key, no prompt
  App->>Disk: ciphertext plus wrapped 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. The vault has one user unit: an item is a page - a body plus an optional set of children. note, meeting, and file are page subtypes; a container is simply a page that has children, so any page can hold any page. "Folder" is no longer a privileged item type - structurally a folder is just a page with children. The createFolder path still records the legacy folder type string for backward compatibility, but nothing in the crypto or the store treats it specially: containment is defined by having children, not by subtype. Nesting is expressed by an optional parentId carried inside the sealed payload; there is no separate container object.

Envelope:      { "id": <uuid>, "version": <int>, "box": ItemBox }   // v2/v3 omit `type`
ItemBox (v2):  { "v": 2, "wrappedDEK": <bytes>, "content": SealedBlob }
ItemBox (v3):  { "v": 3, "wrappedDEK": <bytes>, "content": SealedBlob, "epoch": <int> }

Since M6 the item type (note | meeting | file | legacy folder) travels inside the sealed payload and the envelope omits type; the wrap mode is likewise omitted and recovered by trial on open. So the synced blob classifies neither the item's subtype nor its place in the tree.

AAD strings, verbatim:

content AAD (v2 and v3):      privt/item/v2|<id>|<version>
DEK wrap AAD (under MK):      privt/dek/v1|<id>|<version>
DEK wrap AAD (v3 container):  privt/dek/v3|<id>|<version>|e<epoch>
content AAD (v1, read-only):  privt/item/v1|<id>|<version>|<type>

Every write mints a fresh 256-bit DEK, seals the padded content under it (the v=2 size-bucketed SealedBlob, Section 4.3), then wraps the DEK. Which wrap path is taken depends on where the page lives, not on its subtype:

The v3 gate is nesting, not a subtype whitelist: an item seals v3 iff it has a non-empty parentId and the vault is unlocked at write time (NoteStore.v3Container). The reserved singletons carry no parentId and stay on the v2/MK path.

Per-container vault key. A nested page's DEK is wrapped under a per-container vault key derived from MK, the container id, and a rotation epoch (the full HKDF derivation, the trial-unwrap read path, and the epoch semantics are in Section 4.4). The key is derived on demand and never stored - only the per-container epoch counter persists - and the container id appears in no AAD, so a page binds to its container by the derived key itself, not by any plaintext label.

The content AAD binds id and version only (not type, not wrap mode, not container), so ciphertext cannot be relocated between items or versions; overwrites increment version, preserving the replay binding. On open, a v == 3 box is trial-unwrapped across the candidate container keys under the v3 DEK AAD, then the content is opened under the unchanged privt/item/v2 AAD; a v2 box tries the MK wrap then the deposit wrap; a legacy v1 box (which stored mode in the clear and bound type into the content AAD) is opened by mode for backward compatibility. Unknown formats and modes are rejected. The DEK is zeroed after every use.

Subtypes. note and meeting (call capture, schema 2, Section 9.2) store their body in the sealed payload. A file page stores small content inline (base64 in the payload, up to 350 000 bytes) and chunks larger content (2 MiB chunks, up to 200 MB per file) into separate sealed blobs under store/blobs/, referenced by chunk id from the payload (Section 11 for chunk sync and tiers). Two reserved singletons sit outside the page tree: prefs (id prefs, store/prefs.pv), which encrypts the vocabulary/corrections/harvested-names dictionary under the identical construction (Section 15.8), and vaultkeys (id vault-keys, store/vault-keys.pv), the per-container epoch registry; both carry no parentId, so they stay on the v2/MK path, and the browser hides them.

Only id, version, and the box framing (the format version v, the v3 epoch, and the coarse bucketed ciphertext size) are plaintext on disk and on the wire. The subtype and the parentId tree structure are sealed; the operator can tell that a v == 3 item is nested inside some container and its rotation generation, but not which container, its subtype, or the shape of the tree (Section 15.10).

Shredding. Deleted item files (sealed ciphertext envelopes, no plaintext) 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 3/5/10/30/Disable. 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).

Folder to page migration. A one-time, gated pass folds the retired folder item class into the unified page model, in which any note can hold children. It runs once per vault on the first unlock after upgrade, behind the didFolderPageMigration latch in config.json, and only after resolveConflicts (so a folder just re-materialized from stashed sync is caught) and after the per-container key registry has loaded. For each folder item it sets title from the old name (defaulting to Untitled), writes an empty html body, drops name, and rewrites the type to note, then re-seals through the ordinary write path at version + 1 - v2 (DEK wrapped under MK) for a top-level item, v3 (DEK wrapped under the parent container's per-container key) for a nested one, chosen by the same "non-empty parentId while unlocked" gate as any write. parentId is preserved, and the file's modification time is restored after the write so list order does not shift. The pass is idempotent: a converted item is now type note, so a re-run skips it, as it skips every non-folder and the reserved prefs/vaultkeys singletons. Critically, a container's children are not re-keyed. A child's v3 wrap derives from its parent's id, and that id is the container's stable UUID, which the reseal never changes - the reseal advances only version - so no child is orphaned and every child stays readable under the unchanged parent key. The pass is best-effort and never wedges unlock: any throw leaves the latch false to retry on the next unlock, the latch is set only on a clean, complete run, and the diagnostic records a converted count only, never ids or titles.

8.1 Files and Page Covers - Shipped

A file is a small file item whose sealed payload is a manifest - {name, mime, size, chunks[], parentId} - never the bytes on their own. Two storage paths keep a file indistinguishable from a note on disk and on the wire.

Small files (≤ 350 000 bytes, smallFileMaxBytes). The raw bytes are base64-encoded into the manifest field data with chunks empty; the item rides the ordinary item path and seals under the identical construction as a note (Section 8), never touching the blob namespace. The bound is chosen so the base64 payload (≈1.33×) still fits the 524 288-byte pad tier (Section 4.3): the ciphertext lands in the same size bucket a medium note occupies, so the operator cannot tell a small file from a note. Anything larger chunks into blobs.

Larger files. The bytes are split into ≤2 MiB blocks (fileChunkBytes = 2 097 148 - 2 MiB minus the seal's 4-byte length prefix - so a full chunk pads to exactly the 2 097 152-byte top tier and every chunk is uniform length). Each block is sealed as its own opaque blob at store/blobs/<uuid>.pv by sealBlobAndWrite, which reuses the per-item DEK mint, the MK-or-deposit wrap, and the v=2 size-bucket pad verbatim (the type passed is blob, ignored by the v=2 content AAD); a blob is immutable content and is always version 1. listItems never touches a file body, and once synced the operator holds a pile of unlinked opaque blobs - the manifest that ties them into a file lives only inside the sealed file item.

The blob id is a random UUID, never a content hash. A content-addressed id would let the operator deduplicate identical blobs across accounts - a cross-account confirmation oracle ("does anyone else store this exact file?") that would tell the operator something about content it cannot decrypt - so two identical files are always two independent, unlinkable blobs and the operator can never link or confirm them. The guardrail is load-bearing and marked as such at the id assignment in sealBlobAndWrite.

Page covers are stored the same way. A cover image is an encrypted blob, and the sealed page payload references it as the string blob:<uuid> in payload.cover. The operator therefore cannot tie a cover to its page; it sees one more opaque blob among the chunks. The Mac never keeps a cover on disk - openBlobBytes decrypts a cover fetched from the server on demand - but cover ids are folded into the sync reconcile live set so the operator's blob garbage collection never reaps a page's still-referenced cover (SyncEngine).

Per-file size is bounded at 200 MB (maxFileBytes, an import and in-memory-reassembly sanity bound, since openFile decrypts chunk by chunk into RAM); the aggregate store ceiling is a server-side Pro quota (Sections 11.1, 11.2), not a local limit. Deleting a file shreds each chunk blob that no other live file still references - a chunk can be shared, since a conflict copy re-seals the same chunks[] and a cloud restore reuses chunk ids, so a chunk is shredded only once no live item is proven to reference it. A cover blob is shredded with its page. Chunk and cover blobs enter the metadata floor only as coarse per-blob sizes (Section 15.10) and are covered by the burn purge set (Section 13).

9. Capture Engine and Local Data Flow - Shipped

Transcription runs entirely in-process on the native engine (Parakeet on CoreML via FluidAudio); the Coordinator constructs NativeEngine directly.

9.1 Dictation Lane

Microphone audio (16 kHz mono Float32) accumulates in memory only while the push-to-talk key is held (or, after a double-tap latches hands-free mode, until a single tap ends the session); 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:2, title, startedAt, durationSeconds, model:"parakeet-coreml", speakerNames:{id->name}, speakers, segments:[{t, speaker, speakerId, text}]}. speaker is a diarized display label ("Speaker 1/2/..." for far-end audio, "Me" for the mic lane) derived from the stable speakerId plus the sealed speakerNames map (Section 9.7); schema-1 items - segments carrying only speaker, no speakerId - are read as a legacy fallback. No plaintext transcript file is written on the native path - the sole disk writes are encrypted 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 the Privt Voice binary itself, debouncing session end over eight empty polls (~16 s); a recording prompt is shown on every detected call.

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

An optional multilingual model (Parakeet TDT 0.6B v3, ~25 European languages, FluidAudio AsrModels version .v3) is a separate, larger one-time download, and the on-device diarization models (Section 9.7) are also fetched - both from the same first-party mirror models.stayprivt.com by default, with the same one-shot huggingface.co fallback and local caching. No third-party host is contacted unless the mirror is unreachable, so all model traffic is first-party by default.

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). The app has no webhook or other off-device transcript-delivery path: a dictation final reaches only the focused app or the pasteboard, and a call note only the encrypted store.

9.7 Speaker Diarization

Recorded meetings are diarized entirely on-device. After a call ends, the retained far-end ("Them") audio is grouped into per-meeting clusters (Speaker 1, Speaker 2, ...) by FluidAudio's DiarizerManager (CoreML/ANE); the local microphone lane ("Me") is never diarized. A fresh DiarizerManager is constructed per meeting, so no speaker state carries between recordings - this is per-recording grouping, not cross-meeting recognition. The 256-dimension voice embeddings FluidAudio computes are used only to cluster a single recording and are never read, copied, persisted, synced, or reused to identify anyone across meetings; only (cluster-label, start, end) intervals are kept, and the far-end audio buffer is zero-wiped immediately after the pass and never written to disk. Honest residual: FluidAudio holds those embeddings in storage the app can only release (cleanup() + dealloc), not memset_s, with core-dump denial (see the MemoryHygiene limitation, 15.18) as the backstop. Users may rename a speaker or reassign a segment; those human-typed names are third-party PII sealed inside the encrypted note envelope - the schema-2 speakerNames map keyed by speakerId (Section 9.2) - with no D1 or server schema change, so the operator metadata floor (15.10) is unchanged. Diarizer models load from the first-party mirror models.stayprivt.com with a one-shot huggingface.co fallback, identically to the ASR models (9.5). Automatic cross-meeting / voiceprint recognition is a deliberate non-goal, deferred pending a public privacy discussion (15.19).

10. Privt ID Account Cryptography - Shipped

The derived-verifier auth, sessions, rate limiting (10.1, 10.2, 10.5, 10.6), the email-free Account Identity (10.3), and its account-ID-keyed wire protocol (10.4) are Shipped and live. The identity is the client-derived 8-character Privt ID; no email is ever an authentication or identity input - every request keys on the account ID. A contact email is optional, opt-in, deletable, and used only for notification (Section 2), and burn destroys it.

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:

KEK     = Argon2id(passphrase, wrap salt)                                    salt embedded in the wrap blob
authKey = HKDF-SHA256(ikm  = Argon2id("privt/auth-key/v2" || 0x00 || Privt ID || 0x00 || passphrase, saltAuth),
                      salt = empty, info = "privt/auth-key/v2", L = 32)

saltAuth is generated at registration and stored server-side with the parameters (kdfParams, which carry "auth": 2 for this format); the Privt ID is the canonical 8-character form. The two keys are derived from different Argon2id inputs, and the sign-in key passes through a one-way HKDF step; with the Privt ID in the input, each passphrase guess against a stored sign-in verifier costs a full Argon2id for that one account. privt/root-passphrase/v1 labels the wrap. Before deriving a sign-in key, both clients check the parameters the server returned: Argon2id v1.3, p 1, ops 3 to 10, memory 256 MiB to 1 GiB, a 16-byte salt; anything else is refused and nothing is sent. Parameters are compile-time constants - not per-device tuned - but blob and kdfParams are self-describing, so future tuning is mechanical within those bounds.

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 on the software+passphrase and SE+Touch ID tiers; in the hardware-bound SE+passphrase tier the inner blob is instead Enclave-sealed at vault/root.passphrase.se.json and no plaintext copy is kept.

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", "authVersion": 2, "kdfSalt": b64(16B),
  "kdfParams": {"alg":"argon2id13","v":19,"ops":3,"mem":268435456,"p":1,"auth":2},
  "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, authVersion: 2} 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, authVersion: 2} returns {token, expiresAt, wrappedRootPassphrase, kdfSalt, kdfParams}. Accounts created earlier move to this format at their next passphrase sign-in (POST /api/auth/upgrade, a migration-only route). 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 (echoed verbatim; checked against the clients' bounds and stored normalized, auth: 2 marking the sign-in key format), the two wrapped-ROOT blobs and the optional ROOT-encrypted children bundle - all three held as R2 objects (bucket privt-wrapped-blobs, keys wrapped/<userId>/{root-passphrase,root-recovery,children}), never in D1, whose columns keep only a 1-byte placeholder for legacy fallback; these R2 objects are the crypto-shred targets, deleted first at burn (15.14) - the optional recovery-login verifier hash, entitlement state, the optional peppered burn-code hash, and a burned flag. Session tokens are keyed by token_hash, not by any passphrase-derived value. The email column holds the row's own uuid, never a real address (auditable with one query). A passphrase change (Section 5, when the account is signed in) first writes the re-wrapped ROOT to the R2 keystore (bucket privt-wrapped-blobs, object wrapped/<userId>/root-passphrase), then runs a single UPDATE of four columns - SHA-256(authKey), kdf_salt_auth, kdf_params, and wrapped_root_passphrase (this last set to a 1-byte placeholder, since the real blob now lives in R2 - 15.14) - leaving wrapped_root_recovery and the recovery-login verifier hash untouched (the recovery wrap never changes), then issues the session-revoking DELETE described below. No plaintext ever transits: the inbound authKey is a one-way sign-in key (10.1) the server stores only as SHA-256 of it, and wrappedRootPassphrase is opaque ciphertext the server cannot open. Sessions are keyed by a random token hash, not passphrase-derived (token_hash is SHA-256 of a random 32-byte token), so a change is never a cryptographic session invalidation. As a deliberate security policy, however, the change-password handler additionally issues DELETE FROM sessions WHERE user_id = ? AND token_hash <> caller, revoking every other session and keeping only the device that performed the rotation signed in; all other devices must re-authenticate with the new passphrase. This server-side rotation and session revocation is server-tested; its deploy is a consent-gated follow-up (§11.1). 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>/<version> (version-fenced, with the flat items/<userId>/<itemId> kept only as a legacy read fallback until backfill completes) 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. A session lasts 30 days. A session made by a passphrase sign-in on the current sign-in key (a login, a registration, or the one-step upgrade of an older account, 10.4) renews while it is in use: an authenticated request made with less than 15 days left moves its expiry to 30 days after that request, on the same token. It never renews past 180 days from the day of that sign-in, however often it is used; after that the device must sign in again. Every other session (a recovery-phrase sign-in, or a sign-in on the older key) ends 30 days after it began. The server keeps only the token's hash, so the token itself says which kind it is: a session that renews carries a two-character marker before its 32 bytes. A session's start and expiry are stored to the UTC day only, the start rounded down and the expiry rounded up, so a session never ends sooner than stated and its stored times place it only to the day. An expired session is deleted when its token is next presented, and an hourly sweep deletes the rest. GET /api/me returns the session's expiry after that request and whether a later use can still move it (renewable). The client persists non-secret session fields (such as accountId, expiresAt, entitlement, whether the session can still renew, and a mirror of the optional contact-email state) in Privt Voice/account.json (0600, sealed at rest under a per-device key kept in the Keychain); 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 min / 1 hContact-email verification; the resend and code-check paths consume 5/15 min, the attach/replace send path 5/1 h, all sharing one window; keyed by the session's uuid, never attacker-controllable
arm:<userId>101 hBurn-code arming; self-keyed, bounds the arming-collision oracle
regip:<CF-Connecting-IP>201 hRegistration backstop, per source IP; skipped when the edge header is absent (local dev / tests)
paramip:<CF-Connecting-IP>6010 minPOST /api/auth/params handle-existence probe, per source IP

Turnstile verification is code-complete server-side, and the production secret is set. The public /burn page is therefore a live, hard-gated Turnstile surface: it embeds the widget and POST /api/burn verifies the token before any code lookup. The app's auth flows (register, login, login-recovery) verify a token only when one is present and otherwise pass - the macOS client ships no Turnstile code yet, so those flows lean on the Durable-Object and per-IP rate limits until the client sends turnstileToken, at which point they flip to required (15.12).

11. Sync Protocol - Shipped

11.1 Endpoint Inventory

MethodPathAuth / gatePurposeStatus
POST/api/auth/registernone (Turnstile when configured); per-IP DO backstop 20/hCreate account: {accountId, authVersion: 2, 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, authVersion: 2}; a key is checked only against a verifier of its own formatShipped
POST/api/auth/paramsper-IP DO backstop 60/10minPre-login salt fetch: {accountId, authVersion: 2}; during migration also a single-use nonce for /api/auth/upgradeShipped
POST/api/auth/upgradenone; login DO (shared with login); server flagMoves an account created earlier to the current sign-in format at its next passphrase sign-in; answers like login (10.4)Migration only
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, expiresAt, renewable}: the session's expiry after this request, and whether a later use can still move it (10.6)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/auth/send-verificationBearer; verify DO(Re)send the contact-email verification code; always {ok:true} (oracle-free, idempotent)Shipped
POST/api/auth/verify-emailBearer; verify DOConfirm the emailed {code}; constant-time against a dummy digestShipped
POST/api/account/passwordBearer only (not Pro - lapsed accounts must keep rotating)Passphrase rotation after a client-side Change Passphrase: {authVersion: 2, newAuthKey, newKdfSalt, newKdfParams, newWrappedRootPassphrase}; the re-wrapped ROOT is written to the R2 keystore first, then one D1 UPDATE sets auth_key_hash/kdf_salt_auth/kdf_params (the wrapped_root_passphrase column holds only a 1-byte placeholder - the real blob lives in R2), recovery columns untouched; also revokes all other sessions (keeps only the caller signed in); no plaintext transitsAdded (server-tested); deploy is a consent-gated follow-up
PUT/api/account/keysBearerBack up the ROOT-encrypted children bundle (MK/identity/deposit) - the new-device restore material; the wrapped ROOT itself is stored at register and re-wrapped only on password change, never here; only ciphertext transits (10.4, 15.5)Shipped (client backup)
GET/api/account/keysBearerFetch the wrapped key material for a new-device restoreShipped (15.5)
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 encrypted 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
GET/s/:idnoneConstant viewer pageShipped
GET/s/:id/blobnone - slug is the capabilityEncrypted 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; Turnstile-gated (the /burn page carries a token)Crypto-shred: {code} - the code alone resolves the account; a solved-challenge request always returns {ok:true} regardless of code outcome (a failed/absent challenge is a 403 about the challenge, not the code); constant-time (a miss shreds a decoy sentinel, §13)Shipped
GET/burnnoneConstant public burn page, single code field; embeds a Cloudflare Turnstile widget (loads challenges.cloudflare.com; CSP allowlists that host) and disables submit until the challenge is solved; hash-pinned inline script; served by the API workerShipped

JSON bodies are capped at 64 KiB; all JSON responses carry Cache-Control: no-store and X-Content-Type-Options: nosniff, and the worker's HTML surfaces (/burn, /s/:id, /pro, /billing/return) add HSTS, nosniff, and CSP frame-ancestors 'none' (anti-MIME-sniff, anti-clickjacking, and an SSL-strip backstop); unhandled errors return a bare 500. Foreign and absent resources return an identical generic 404; a tombstoned item is a generic 404 to non-owners but returns 410 {error:"deleted", version} to its owner on GET /api/items/:id (an owner-only heal/differentiate path, bound to user_id, not a cross-user existence oracle). Billing endpoints - POST /api/billing/checkout, POST /api/billing/portal, GET /api/billing/start, the public GET /pro chooser and GET /billing/return landing, and the Stripe-signature-authenticated POST /api/stripe/webhook that grants entitlement - drive Stripe Checkout and are live; none move key material - payment happens on Stripe, not the worker.

11.2 Sync Algorithm

Sync runs only for signed-in Pro accounts that have not turned cloud sync off (a signed-in Pro user with sync disabled is local-only Pro - nothing uploads or downloads): 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; the losing edit is never discarded - before any overwrite or tombstone the sealed local blob is stashed in conflictsDir, and on the next vault unlock resolveConflicts re-materializes it as a fresh, visible note titled <title> (conflicted copy <date>) that then syncs (§15.3 is the preservation mechanism). There is no toast or alert, but the recovered note appears in the list. The whole pass moves encrypted 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

Every synced item is an opaque Envelope{id, version, box} - parseable JSON the server stores and forwards but never reads for meaning. Three fields the earlier format kept in the clear are gone (M6): the item type now travels inside the sealed payload and is absent from the envelope, so the blob no longer classifies itself as note, meeting, file, or folder; the wrap mode is omitted and recovered by trial-unwrap on open, so the locked-capture flag no longer leaks; and parentId is sealed inside the content, so the folder/container graph - which page nests under which - no longer appears on the wire.

What the operator can still read per envelope: id, version (edit count), the box format version v (2 for a top-level page, 3 for a nested one) and, on a v == 3 box, the epoch (that page's per-container rotation generation); plus a coarse size bucket (one of seven padding tiers, then 64 KiB steps above 2 MiB) and a day-granularity updated_at that encodes only per-account edit order, never time of day - though raw request-arrival timing stays observable at the network layer. So the operator can tell that a v == 3 item is nested inside some container and its rotation generation, but not which container, the item's subtype, or the shape of the tree. The reserved singletons (prefs, the vault-keys registry) sync as opaque envelopes like any other - each adds one more id/version/size row and no content. Content, titles, speaker labels, item subtype, and tree structure are inside the AEAD (full floor: 15.10).

12. Share Links - Shipped

A share is an encrypted 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 encrypts 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 encrypted from the decrypted note - and assembles <shareBase>/<slug>#<key-base64url> (concretely https://privt.sh/<slug>#…) - a clean public host distinct from the API host, with a bare slug and no /s/ prefix; the worker still honours the legacy /s/<slug> path for older links. 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 encryption 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 and to any request lacking a Sec-Fetch-Dest: document header (fail-closed, so genuinely old browsers and header-stripping fetchers get it too), the viewer page only to modern real navigations - 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), and revocation are enforced, and dead snapshots are physically shredded from R2, not merely gated in D1: revoking deletes the R2 object, a one-time link shreds its snapshot on the exhausting reveal, and an hourly cron reaps expired or exhausted blobs and rows as a backstop - so a dead share is destroyed, not left resident. Abuse is reported by email ([email protected], surfaced in the viewer footer), not an API endpoint. Caps: 2 MiB per snapshot, 200 active shares.

13. Burn - Shipped

Burn is the code-only peppered mechanism - no email, no account identifier; the code alone is the trigger. (The optional contact email is still destroyed by a burn, below - it is wiped as data, never used to key the operation.)

A burn code is a destroy-only credential over a ciphertext-only database: 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, so a stolen database 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. Two pairs stay safe because the pepper closes the offline path entirely (no offline attack exists against a ciphertext-only, peppered store) and the remaining online channel is a blind, challenge-gated one that cannot be searched at scale.

The online channel is gated by a Cloudflare Turnstile challenge, not by a per-IP counter. The earlier per-IP Durable-Object limiter was removed: it failed closed behind shared NAT and CGNAT, where a single abusive address could exhaust the bucket a co-located victim needs for a genuine burn, and denial-of-burn is the one unacceptable failure. Turnstile stops automated guessing without ever returning a 429 to a real person; the challenge concerns the request, never the credential, so a challenge failure (403) leaks nothing about the code. A zone-level WAF per-IP rate rule and an alert-only aggregate-volume rule (the multi-target guessing tripwire) remain documented owner dashboard actions, and the versioned hash format is the lever to grow the code space if that tripwire ever fires. There is deliberately no global limiter and no limiter keyed by any attacker-choosable value.

Burn work is decoupled from the response so the reply carries no timing signal. Every request performs the same fixed-count work regardless of outcome: one indexed lookup, then one shred against a resolved target - the real account on an armed hit, a reserved sentinel id on any miss (wrong code, unknown code, or a burned tombstone), against which the deletes touch absent objects and the UPDATE affects zero rows. The load-bearing crypto-shred is that fixed pair and it runs inline, completing before {ok:true}: the wrapped-ROOT key blobs are destroyed first by an R2 delete (an un-versioned bucket, so the delete is final and reaches backups, which is why they were moved out of D1, whose Time-Travel history would otherwise keep zeroed columns restorable for ~30 days), then one D1 UPDATE zeroes the verifiers, KDF material, the burn hash, the 1-byte wrapped-key placeholders, and the optional contact email (burning severs the one opt-in real-world link) and writes the tombstone. Ordering fails toward destruction - the keys die first, so any partial failure still leaves the account math-dead. The variable-size, non-security hygiene purge - the already-math-dead item envelopes, share snapshots, and their bookkeeping rows - is not on the response path: it runs after the reply is sent and is reconciled by the hourly cron. This removes the earlier existence-and-armed timing oracle, where a real burn ran a data-scaling delete before replying while a miss returned at once, so the response time revealed both that a code matched and how much data the account held. What remains is a sub-millisecond difference in the lookup itself (one row versus none), below the network noise floor, and because a burn is single-use a correct code yields exactly one such sample - and only to an attacker already holding the code - so it accelerates no search. The property is no measurable oracle to a realistic observer and no data-scaling signal at all, not a cryptographic constant-time proof.

Sessions are deliberately kept, not destroyed, and never renewed after the burn, so each ends on the expiry it had then - a burned account's still-valid token resolves to 410 account_burned, the beacon that drives each device's self-wipe. 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 page served by the API worker (strict CSP, hash-pinned inline script, no external resources besides the Turnstile widget, nothing request-derived) with a single code field, which carries a Turnstile token, POSTs /api/burn, and shows one neutral confirmation for every 200 - the page distinguishes only "submitted" from "not submitted", never credential outcomes. Any residual D1 point-in-time or R2 version retention holds only math-dead ciphertext and metadata, never usable keys; a device kept permanently offline is not reached, but a burned account's server answers every still-valid session with a 410, so each signed-in device crypto-shreds its own vault on next contact (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, encrypted envelopes, and metadata - no content keys, no plaintext. The recovery-wrapped ROOT is encrypted 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. Its destructive reach is not limited to server-held ciphertext, however: because the client crypto-shreds on any 410 account_burned response (14.5), a compromised or compelled operator can force every signed-in device to self-wipe its local vault and Keychain by returning that response to any authenticated request - it need not run a real burn or even set burned=1 in D1.

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 encrypted under ROOT, whose release requires a live biometric on SE machines; the vocabulary config and session-token file are encrypted 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). For a signed-in account, the entitlement, the session expiry, and the opt-in contact email with its verified flag are held in account.json, sealed under a per-device key kept in the Keychain, except in the one case 15.9 states: when that key cannot be read and no sealed file is there, account.json is written in plaintext until the next save that reads the key. On Macs without a Secure Enclave, ROOT is encrypted 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 encrypted 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 no usable key sits at rest; meta.json records hardware and gesture.

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) - the same first-party mirror serves the ASR, optional multilingual, and diarizer models (9.5, 9.7), so model traffic is first-party by default - plus sizes and timing. No plaintext content transits on any tier - the free tier sends nothing off-device, and Pro uploads only ciphertext to api.stayprivt.com.

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 the page route is fail-closed on an absent Sec-Fetch-Dest (serving the neutral page), while the blob route refuses bot user agents and top-level document navigations but deliberately lets an absent header through (fail-open on that one signal) so legitimate reveals still work on older browsers and curl. The 128-bit slug is not enumerable.

14.5 Account compromise

Burn is the last resort for when you believe your account or one of your devices has been compromised. 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 bot-gated by a Turnstile challenge rather than any per-IP or victim-keyable limiter, no remote party can lock a victim out of their own burn by knowing an identifier, and there is no shared-NAT budget a co-located adversary could exhaust (15.14). Boundaries are stated in 15.14 - burn does not reach retention backups, and a signed-in device is wiped only once it reconnects (one kept permanently offline is never reached); the operation requires network reachability. Because the server answers a burned account's still-valid session with 410 account_burned, a burn propagates to the user's other signed-in devices as a self-wipe as each comes online - it is not confined to the server. Stated honestly, this beacon is unauthenticated at the transport boundary: the client acts on the 410 account_burned status and body alone, and the app pins no certificate (all traffic goes through the system trust store), so an active on-path attacker able to present a system-trusted certificate for api.stayprivt.com - a TLS-inspecting root on a managed Mac, or a mis-issued/coerced public certificate - can forge the beacon with no burn code and trigger the same irreversible local destruction. The salient residual here is active network interception - an accepted risk until a server-signed burn beacon or certificate pinning closes it.

14.6 Malicious Note Content

Note bodies can arrive from an untrusted source - a compromised or compelled Pro sync server, an imported file, or the pasteboard - so rendered content is treated as attacker-influenced. Three defenses apply, defense-in-depth. (1) Note HTML is sanitized with a pinned, vendored DOMPurify (vendor/purify.min.js) against a fixed allowlist before it reaches innerHTML, failing closed to plain textContent if DOMPurify does not load. (2) The notes UI is served not over file:// but over a private privtapp:// WKURLSchemeHandler - a stable tuple origin that lets a strict CSP actually enforce (default-src 'none', script-src 'self' with no unsafe-inline, connect-src 'none'), with path-traversal confinement under the resource root. (3) A navigation lock allows only same-origin privtapp:// navigations in-frame, opens user-clicked http(s)/mailto links in the default browser without navigating the WebView, and drops file:, data:, javascript:, and every other scheme. A stored-XSS or data-exfiltration payload in synced, imported, or pasted note content therefore cannot run in the app. This is the local counterpart to the browser share-viewer defenses in Section 12.

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 encrypted - 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 encrypted blob is copied to a conflicts/ holding directory if (and only 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-encrypts it as a fresh note whose title carries "(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 - so 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 (never lost, occasionally a duplicate to prune).

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; the sign-in key and the KEK are derived from different Argon2id inputs (a labelled, account-bound input for the sign-in key, the passphrase alone for the KEK), with a one-way HKDF step on the sign-in key (10.1). 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 encrypted 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 - Shipped. The restore loop is closed in code: enrollment uploads a ROOT-encrypted 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. Both halves ship: the server endpoints are deployed, and the client restore paths - CloudClient.restoreVault(passphrase:) and restoreByPhrase(words:), backed by PrivtVault.restore - are wired into onboarding and Settings with no feature flag, so a vault-less machine rebuilds from the account alone. 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). Two properties hold 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, and the deposit secret was only nil'd. Handing a key to the AEAD primitives (seal/open take a Bytes key) goes through withKey/withData, which materialize a transient copy for the duration of one call and memset_s-scrub it immediately after; those transients are uniquely referenced at scrub time, so the scrub is effective. Residuals, stated plainly: (a) the passphrase-KDF path (attach/change passphrase) snapshots ROOT into an ordinary, unpinned array before handing it to an off-actor Argon2id, for the ~1-2 s of that operation; it is taken for race-safety against a concurrent lock(), and is scrubbed in place with memset_s via a defer once the detached task releases its captured copy - the buffer is uniquely referenced again at that point, so the scrub lands (not a copy-on-write copy). The only residual now is that the snapshot is unpinned during the KDF window, so it can page to swap - mitigated by macOS's encrypted swap - not that it is freed unscrubbed; this is bounded to passphrase-provisioning operations. (b) The create path does not zero the freshly generated deposit secret transient after encrypting it. (c) Most fundamentally, software AEAD requires the key in RAM to run, so an adversary who already has memory-read on the unlocked session (root, an attached debugger, injected code) 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, which is the strongest available 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 - encrypted (resolved). customVocabulary, replacements, and harvestedNames are now encrypted 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/v2|prefs|<version>, the type dropped from the content AAD at M6), 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 the app's config.json is encrypted 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 and expiry. 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 holds only non-secret session fields, such as accountId, expiresAt, entitlement, whether the session can still renew, and the optional contact-email mirror, sealed at rest under a per-device key kept in the Keychain; it is written in plaintext only when that key cannot be read and no sealed file is there, and sealed by the next save that reads the key. A copy of the same fields sits in the Keychain beside the token, trusted for that token only, so the session still restores when account.json cannot be opened or read; it is taken over the file when a save wrote it and could not write account.json, and whether the session can still renew is read from it alone, never from a file an earlier session left. A missing account.json means signed out: a launch that finds the token without it removes the token and its copy from the Keychain without asking the server, so it deletes no session, and that session, while still live on the server, ends on its own expiry. A sign-out asks the server to delete its session; one the server does not receive deletes no session either. A file that is there but cannot be read (an I/O or permission error) is not taken as signed out and clears nothing: the session restores from the Keychain copy, and without that copy the launch starts with no session and reads the file again after 30 seconds and then every 15 minutes, until it reads. On load, a token found in a legacy account.json is moved to the Keychain and stripped from the file. The Mac keeps its copy of the expiry current from GET /api/me: in a session's last 15 days it asks at launch and then at most once an hour, and it stops asking once the server says the session can no longer renew. A Mac that unlocks with its passphrase signs in again in the background, at an unlock in a session's last 15 days when that session will not renew, and signs the old session out only once the new one is saved. A Mac that unlocks with Touch ID signs in again by hand when its session ends. A session that renews ends at most 30 days after its last use (rounded up to the day), and no later than 180 days after its sign-in. The limits, stated: a stolen token keeps working, however often it is used, until it is signed out, the account is burned, or its session ends. A passphrase change made on another device ends this session; the device that makes the change keeps its own, so a change made on the Mac the token was taken from does not end it. The stored expiry of a session that renews dates its last use to a window of about 15 days.

15.10 Metadata floor. 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, version (edit count), the box format version v (2 top-level, 3 nested) and, on a nested item, the epoch (its per-container rotation generation) - while the item subtype (note, meeting, file, page) and the parentId tree structure are sealed inside the payload since M6 (Section 8), so the operator sees that a v == 3 item nests inside some container but not which container, its subtype, or the tree shape; a coarse ciphertext size bucket - synced item content is zero-padded inside the AEAD to one of the fixed tiers (512 B / 2 KiB / 8 KiB / 32 KiB / 128 KiB / 512 KiB / 2 MiB, then 64 KiB steps), so only the bucket size leaks, not the real length (legacy v=1 items predating the feature stay unpadded until re-saved and still reveal their exact size); item counts and day-coarse update timing (the server clock is quantized to the day, not the minute); session counts, and for a session that renews, its last use to within about 15 days (15.9); share slugs, policy, view counts, and access timing; IPs and user agents at the Cloudflare edge. Payment is the honest boundary: 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. The optional multilingual model (Parakeet TDT 0.6B v3) and the on-device diarizer models (9.7) default to the same mirror with the same one-shot huggingface.co fallback, so no third-party host is contacted unless the mirror is unreachable. No offline pin is configured.

15.12 Deployed-configuration gaps. POST /api/dev/entitle is gated by ADMIN_SECRET (a Worker secret): it requires a matching X-Admin-Secret header (constant-time) regardless of ENVIRONMENT, so the public can no longer self-grant Pro - the real Pro path is Stripe Checkout (Section 12), now live. The Turnstile secret is set, and burn uses it: the public /burn page hard-verifies its token before any code lookup, so automated burn-code guessing is challenge-gated. The app's auth flows share the same secret but verify softly - they check a token only when one is present, and the macOS client sends none yet, so registration, login, and recovery-login currently lean on their Durable-Object and per-IP rate limits (login:<accountId>, regip:<IP> 20/hour) rather than a CAPTCHA. This is deliberate: because the shared verifier fails closed on a bad token, making auth hard-required today - before the client ships turnstileToken - would refuse every app user; the flip to required waits on client Turnstile support (17).

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. The wrapped-ROOT key blobs (ROOT encrypted by the passphrase KEK, ROOT encrypted by the recovery key, and the ROOT-encrypted children bundle) live in a dedicated R2 bucket (privt-wrapped-blobs, versioning off), not in D1, precisely because D1's always-on Time Travel keeps a non-purgeable ~30-day history: a burn that only zeroed D1 columns would leave those key blobs restorable for the retention window, silently defeating the crypto-shred. Burn therefore deletes the R2 key objects as its first destructive step (fail toward destruction, and R2 has no rolling backup, so the delete is final), before the D1 shred; with the keys gone, every synced ciphertext is math-dead regardless of any backup. The D1 columns hold only a 1-byte placeholder (no key ever enters D1's history). Residual boundaries remain: because burned=1 makes the server answer any still-valid session with 410 account_burned, every signed-in device self-wipes (live keys memset, local dirs and Keychain crypto-shredded, then relaunched to a blank first-run) the next time it makes an authenticated request - but a device kept permanently offline, or one never signed into the account, is never reached; item-ciphertext and share objects in privt-items are deleted synchronously but that bucket carries no versioning; 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 the burn. 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, challenge-gated 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 burn endpoint is bot-gated by a Turnstile challenge, not a per-IP limiter - the earlier burnip limiter was removed because it failed closed behind shared NAT/CGNAT and could block a co-located victim's genuine burn, the one failure this endpoint must never have - so no remote party can block anyone's burn by knowing an identifier, and there is no shared-network budget for a co-located adversary to exhaust. 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 burn 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).

15.18 Local self-wipe boundaries. The device self-wipe (14.5, 15.14) crypto-shreds every directory the app writes under ~/Library (the vault, config.json, and the event log at ~/Library/Logs/PrivtVoice), deletes all Keychain items under our service, memset_s-scrubs the live keys first (15.6), and clears the pasteboard. It cannot reach OS-managed surfaces it never owned, and does not claim to: (a) the unified system log may retain diagnostic lines the OS wrote and that no application can erase - which is exactly why the app routes its own diagnostics to the redacted, shreddable app.log rather than NSLog, and carries only error types (never content) at the few remaining NSLog call-sites; (b) the swap file - a key can page out only during the bounded, unpinned passphrase-KDF window (15.6), and swap is FileVault-encrypted; (c) /cores - core dumps are disabled via setrlimit (best-effort); and (d) OS backups - Time Machine and APFS local snapshots. Crypto-shred keeps a backup that captured only ciphertext math-dead (the wrapped-ROOT blobs it would need are passphrase-wrapped and, after a burn, also destroyed server-side, 15.14), so the residual is confined to any plaintext that reached one of these surfaces before the wipe - which is why the app minimizes what it ever writes to them. Forensic-grade local erasure remains a non-goal (15.7); the guarantee is crypto-shred of the keys, not physical erasure of every byte the OS may have touched.

15.19 Speaker diarization is per-meeting only. On-device meeting diarization (Section 9.7) groups a single recording's far-end audio; it is deliberately not cross-meeting recognition. The load-bearing decisions: the 256-dimension voice embeddings FluidAudio computes are used only to cluster that one recording and are discarded (never read, copied, persisted, synced, or reused across meetings), with the honest residual that FluidAudio holds them in storage the app can only release, not memset_s (15.18 is the backstop); a fresh DiarizerManager per meeting means no speaker state carries between recordings; user-typed speaker names are third-party PII sealed inside the encrypted note envelope (schema-2 speakerNames) with no D1 or server schema change, so the metadata floor (15.10) is unchanged. Automatic voice-biometric or cross-meeting speaker recognition is deferred, pending a public privacy discussion.

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; voice-biometric or cross-meeting speaker recognition (15.19). 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

The vault self-test (--vault-selftest) runs 46 tier-independent checks (44 on hosts without Keychain access) 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, size-padding v=2 bucket - 5); 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 - no plaintext key file present 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); sealed prefs item (seal/open round-trip, type-AAD binding, and the vocab-key migration strip - 3, §15.8); conflict-copy sync (a stashed losing edit re-materializes as a titled copy with its body intact, plus the reconcile/preserve/stash-digest decisions - 4, §15.3); and, on hosts with Keychain access, the session token in the Keychain (set/get/delete round-trip and the token→Keychain migration - 2, §15.9). When the host has a Secure Enclave it adds a hardware-bound tier block (10 more, 56 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-encrypted 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 burn timing-oracle guards: a wrong or unknown code returns {ok:true} and never touches a real armed account, and a hit and a miss issue the identical inline shred (the same keystore-delete shape, against the real account or the decoy sentinel), so a future edit reintroducing a data-dependent branch fails CI.

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, encrypted store, auto-lock, self-testShipped
Native engine: dictation, call lanes, tap scoping, direct encrypted 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 loginShipped
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, Turnstile-gated online channel, constant-time decoy shred)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)Implemented
Turnstile: live and hard-gated on the /burn page (secret set); soft on app auth flows until the client ships tokensDeployed
Burn client half (code generator, arming UI; public /burn page served by the API worker)Shipped
Share client half (snapshot encryption/posting, share sheet, manage/revoke)Shipped
Turnstile client token support (flips app register/login/recovery from soft to required)Planned
Web account portal, passkeys, iOS clientPlanned
In-app authenticated Wipe (from Settings, alongside "Erase this Mac"): a session-gated one-tap to crypto-shred this device and fire the server-side burn from inside the app - the same crypto-shred and 410 self-wipe beacon that /burn already triggers (15.14), surfaced as a deliberate authenticated control rather than a browser-entered burn code, and distinct from the local-only "Erase this Mac"Planned
Device & session list: in the Settings Privt ID pane, show each signed-in device (last-seen) and revoke a single session without burning the account - the granular complement to the all-or-nothing burn and the planned in-app WipePlanned
Vocabulary, corrections & harvested names encrypted as the reserved prefs item and synced (15.8)Shipped
On-device meeting diarization: per-recording far-end grouping (Speaker 1/2/…), embeddings discarded, names sealed in the schema-2 note payload (9.7, 15.19)Shipped
Note-rendering WebView hardening: vendored DOMPurify sanitize (fail-closed), privtapp:// scheme + strict CSP, same-origin navigation lock (14.6)Shipped
Size-bucket ciphertext padding: item content is zero-padded inside the AEAD to a fixed bucket (512 B / 2 KiB / 8 KiB / 32 KiB / 128 KiB / 512 KiB / 2 MiB, then 64 KiB steps) so only a coarse bucket leaks, not the note length (15.10)Shipped
Unified page model (folder→page merge): one page unit with children, per-container vault keys (v3 sealing), one-time gated on-unlock migration (4.4, 8)Shipped
Encrypted files and page covers as sealed blobs: small files inline, larger files chunked into opaque random-id blobs with no cross-account dedup oracle (8.1)Shipped
Metadata minimization: edge-IP blinding via an independent OHTTP relay so the operator never sees a source IP; purge D1 point-in-time-recovery / R2 version retention on burn so a crypto-shred reaches the backups (15.14)Planned
Privacy-Pass anonymous-credential payment unlinking — blind-signed entitlement tokens so a paid account is cryptographically unlinkable from its Stripe payment (removes the one hard real-world link in 15.10)Planned
Cross-meeting / persistent voiceprint speaker recognition (deferred pending a public privacy discussion, 15.19)Planned
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.