boopr is an invite-only social network for close friends. Posts, photos, videos, comments, reactions, and profiles are end-to-end encrypted: they are encrypted on the author's device under keys the service does not hold, and decrypted only on the devices of the people they were shared with. Direct messages, available in limited internal testing and rolling out publicly after launch, are end-to-end encrypted with the Signal Protocol. This document specifies how.
It is written for security engineers and cryptographers. It describes the system as shipped in July 2026 on iOS and Android, states its guarantees precisely, and states its limitations with equal precision. Where boopr does not protect something, this document says so. The document is versioned and will be updated as the system changes; material changes to the cryptographic design will always be reflected here before or alongside their release.
The server is a courier, not a reader. Content is encrypted before it leaves the device and decrypted only on recipients' devices. The service stores ciphertext, routes it, and enforces access control; it holds no key capable of decrypting any user's posts, media, comments, messages, or profile. This is a structural property of the deployed design rather than a policy switch: the service as built has no decryption capability to enable or misuse. The residual trust users must place in the client software itself is addressed explicitly in the threat model.
No key escrow, for anyone, ever. boopr does not hold users' keys, and does not offer a managed-recovery tier that would require it to. Account recovery is built from a client-held 24-word phrase and an optional PIN vault sealed with OPAQUE (RFC 9807), a construction in which the server verifies a PIN without ever learning it and stores a vault it cannot open. The consequence is accepted openly: a user who loses both credentials loses their data, and boopr cannot help them. This document treats that as a design commitment rather than a support gap.
Standard constructions, standard primitives. boopr does not invent cryptography. Direct messages use the Signal Protocol (X3DH and the Double Ratchet). PIN recovery uses OPAQUE. Recovery phrases are BIP39. Symmetric encryption is XChaCha20-Poly1305 (AES-256-GCM appears in two server-internal roles and in the operating-system keystores' hardware key wrapping, all described in this paper); key agreement is X25519; signatures are Ed25519; key derivation is HKDF-SHA-256 (HKDF-SHA-512 in one server-internal case: deriving the OPAQUE vault's server key material from its root secret). Client primitives come from the @noble and @scure libraries; server cryptography uses the Go standard library and golang.org/x/crypto; OPAQUE on both sides is the bytemare/opaque Go library, pinned to one version and bound into the mobile clients so client and server run identical protocol code. The one narrow, deliberate exception (a natively composed XChaCha20 construction for media throughput) is documented in this paper.
Every byte format is versioned. Every encrypted blob the system persists or transmits carries self-describing framing, and readers fail closed on formats they do not recognize. Nearly all blobs use a common envelope carrying a family magic and a version byte; the exceptions (the fMP4 post-video container, with its own magic and a wider version field; Signal wire messages, which use the protocol's own type-discriminated framing; and hardware-wrapped keychain values, which carry a versioned ASCII prefix instead) are noted where they arise. The server structurally validates uploaded envelope-framed ciphertexts against a registry of known formats without decrypting them; direct message bodies, which the receiving client authenticates, are bounds-checked instead. A blob's construction is never inferred from context or offset arithmetic.
Fail closed. Unknown envelope versions, missing identity anchors, keychain write failures, unparseable server records, and ambiguous states resolve to refusal, not best-effort continuation. Where a failure path must destroy key material, it archives before it zeroes and never charges the user's attempt counters for server-side faults.
Limits are documented, not marketed around. End-to-end encryption protects content; it does not hide who talks to whom, when, or how much. Screenshot detection deters; it cannot prevent a second camera. Every technical section of this document ends with its limitations, and the threat model below states plainly what boopr does not defend against.
The assets under protection, in descending order of sensitivity: (1) content, meaning post bodies, media, comments, reactions, direct messages, and profile contents including display names; (2) long-term key material and the recovery credentials that reconstruct it; (3) the social graph and interaction metadata, which the design protects only partially, as detailed below.
A network attacker (hostile Wi-Fi, compromised path, certificate misissuance). All traffic is TLS-protected; content is additionally end-to-end encrypted, so even a successful TLS interception yields ciphertext for content. What a TLS-level attacker does gain is transport-layer material: session tokens, request metadata, and server-distributed public keys. The client currently relies on operating-system trust-store validation without certificate pinning (a documented deferral discussed in the transport section), so a compromised certificate authority or a user-installed root is inside this boundary for everything except end-to-end encrypted content. An *active* interceptor in that position is stronger still: because friends' public keys travel over the same channel, it is equivalent to an active malicious server for relationships formed during the attack window, and the key-substitution analysis below applies to it in full.
A database breach. An attacker who exfiltrates the entire database obtains no content: post bodies, media, comments, reactions, messages, profiles, and vault contents are ciphertext under keys the server does not hold. They obtain all plaintext metadata, and this document enumerates it honestly in the server architecture section: the full friend graph, each post's recipient list and timing, interaction attribution and counts, message timing and participant pairs, and account metadata. boopr treats metadata exposure in a breach as a real harm and minimizes fields where it can (IP addresses are never stored raw; codes and tokens are stored only as keyed hashes; profile display names are ciphertext), but the routing metadata the service needs to function is genuinely there to be taken.
A malicious or legally compelled operator. A passive operator, or anyone compelling one, reads exactly what the database breach reads: metadata, not content. An *active* malicious operator can attempt key substitution, because friends' public keys are distributed by the server. For direct messages, keys are pinned on first use, changes block the session until explicitly approved, and 60-digit safety numbers allow out-of-band verification; a server that substitutes keys before first contact defeats trust-on-first-use unless users verify manually. For the profile-key surface, no equivalent verification ceremony exists yet, and the limitation is stated in the relevant sections. boopr currently ships trust-on-first-use with pinning, visible key-change refusal, and manual verification for direct messages, and no verification ceremony for the profile-key surface; key transparency is under evaluation. No design in this document assumes an honest server for content confidentiality against a passive adversary; active-attack resistance is weaker and documented per surface.
A coerced or compromised client distributor. boopr writes and distributes the only client, so every guarantee in this document ultimately rests on the client behaving as described. A malicious or legally compelled client update could exfiltrate keys or plaintext, and no server-side property defends against that. This is true of every consumer end-to-end encrypted product whose vendor ships the client, and boopr does not claim to have solved it. Within that boundary: updates reach devices only through the platform app stores' signing and review pipeline, the client contains no mechanism for loading or patching code outside that path, and this document's published claims are falsifiable, so a client whose behavior contradicts them is externally reportable. Users whose threat model includes a coerced distributor need reproducible builds and binary transparency, which boopr does not currently provide; the planned third-party audit is a point-in-time mitigation, not a substitute.
A stolen device. Key material at rest is wrapped by hardware-backed keys (Secure Enclave on iOS, Keystore with StrongBox where available on Android) and never written in plaintext; account creation aborts on devices where hardware-backed storage is unavailable. Session-scoped storage is wiped on logout with wipe verification at next launch; stores holding sensitive plaintext are additionally encrypted at rest, while stores holding only already-encrypted payloads or non-sensitive flags may forgo the extra layer. A device unlocked by its OS-level credentials is substantially inside the trust boundary, which is why App Lock (an optional passcode or biometric lock over the app itself) exists; a device compromised at the OS level is out of scope entirely.
A thief holding the recovery phrase. The 24-word phrase is a full-compromise credential by construction: every long-term key derives from it. If the legitimate device was active within the previous 14 days, recovery is held in a cooling-off window during which the incumbent device is notified and can reject the attempt; a dormant account has no such tripwire. The PIN vault is exactly as powerful as the phrase and is defended by an online-only guessing budget of 10 attempts against OPAQUE, with no offline-attackable artifact short of a full server-secret compromise. boopr states plainly: guard the phrase like the master credential it is.
A malicious friend. Someone you shared content with has the content; no cryptography changes that. Revocation on unfriending is access control (key records are deleted; future content is rotated away from them), not retroactive cryptographic erasure. Screenshot detection notifies the affected friend when the platform reports a capture on protected surfaces, and is honestly framed as deterrence among trusted people: a second camera, a modified client, or a platform that never reports the capture produces no notification.
boopr does not defend against a compromised operating system or hardware on the user's device. It is not an anonymity network: it does not hide traffic patterns, message timing, payload sizes, or the fact that an account uses boopr, and ciphertexts are not padded. It does not provide cryptographic deniability guarantees beyond those inherited from the constructions used, and the deviations that weaken deniability relative to Signal are documented in the direct-messages section. It does not attempt server-side content moderation of encrypted content; moderation of encrypted content relies on user reporting from the devices that can read it. Availability against the operator is out of scope: the operator can always refuse service. Finally, the deployed cryptography is entirely classical: no post-quantum key agreement is fielded yet, and the harvest-now-decrypt-later discussion in Cryptographic Foundations applies to recorded ciphertext.
| Scenario | Content | Metadata and keys |
|---|---|---|
| Network interception (TLS intact) | Protected (TLS + E2E) | Protected by TLS; traffic patterns visible |
| Network interception (TLS defeated, passive) | Protected (E2E) | Session tokens and transit metadata exposed |
| Network interception (TLS defeated, active, during relationship formation) | Equivalent to an active malicious server: DM keys pinned and manually verifiable; profile-key surface vulnerable (documented) | Session tokens and transit metadata exposed |
| Full database breach | Protected (ciphertext only) | All stored plaintext metadata exposed (enumerated in this document) |
| Compelled disclosure by the operator | Nothing to disclose beyond ciphertext | Stored metadata disclosable |
| Active malicious server (key substitution) | DMs: pinned after first use, manually verifiable; profile surface: vulnerable pre-verification, documented | Metadata visible as above |
| Stolen device (OS credentials intact) | Hardware-wrapped keys resist extraction | Same |
| Stolen recovery phrase | Full compromise; cooling-off window if the account is active | Full compromise |
| Malicious authorized friend | Deterrence and revocation only | Sees what they were shown |
boopr implements no cryptographic primitives, with one deliberate exception (an HChaCha20 core in the native media path) discussed at the end of this section. On the client, all symmetric encryption for end-to-end protected data uses XChaCha20-Poly1305 with a 24-byte nonce, imported from @noble/ciphers. Asymmetric operations use Ed25519 for signatures and X25519 for Diffie-Hellman key agreement, from @noble/curves. Key derivation uses HKDF-SHA-256, and chain-key ratcheting uses HMAC-SHA-256, both from @noble/hashes. Recovery phrases are BIP39 mnemonics (24 words, 256 bits of entropy, English wordlist) generated with @scure/bip39.
The server uses the Go standard library: crypto/ed25519 for signature verification (the server verifies signatures but never generates them), AES-256-GCM via crypto/aes and crypto/cipher for two server-side uses (encrypting stored push tokens at rest, described below, and sealing the short-lived OPAQUE login ticket during PIN recovery, specified in the recovery section), crypto/hmac with SHA-256 and SHA-512, crypto/rand, and crypto/subtle. The only external cryptographic dependencies are golang.org/x/crypto/hkdf, the bytemare/opaque and bytemare/ksf libraries (OPAQUE with Argon2id, used for PIN-protected recovery), and golang-jwt/jwt/v5 for session tokens.
Randomness is drawn exclusively from platform CSPRNGs: crypto.getRandomValues on the client JavaScript side (via the React Native polyfill), SecRandomCopyBytes in native iOS code, java.security.SecureRandom in native Android code, and crypto/rand on the server. Math.random does not appear anywhere in the client's cryptography modules.
Every encrypted blob the client persists or transmits is framed by a self-describing envelope so that a reader can always determine which construction produced a blob. The base layout is:
magic (4 ASCII bytes) || version (1 byte) || nonce (24 bytes) || ciphertext + Poly1305 tagThe current version byte is 0x01 and the minimum valid length is 45 bytes. Readers fail closed on an unrecognized version byte with a dedicated error type, deliberately distinct from the error raised for malformed or tampered data, so that future format changes are never misreported as corruption.
Three variants extend the base layout:
uint32 key version, used for blob families encrypted under a rotating key so that readers can dispatch to the correct key version.The 4-byte magic identifies the blob family. The server maintains the canonical registry of wire-crossing blob-family magics (content blobs such as BPST, BPIM, BPV3, BPAS; key wraps such as BPKP, BPKF, BPKG) and structurally validates every uploaded envelope-framed ciphertext against it: device-local formats (such as the BPSS session-state blob) use the same framing on the client but never cross the wire and are deliberately outside the server registry. magic, version byte, and size bounds are checked without decrypting anything. Magics are unique across the entire system; one magic was renamed after a collision was found between two families, and uniqueness is now treated as an invariant.
One format deviates from the standard envelope: the streaming post-video container uses a 14-byte header (BPV3, a uint16 version, a segment count, and an init-segment size), followed by a segment-size table and per-segment ciphertexts. Each fMP4 segment is encrypted under a key derived with HKDF-SHA-256 using the info string boopr-video-fmp4-v3, with a 14-byte associated-data block binding the segment's index and the total segment count, so segments cannot be reordered or transplanted between videos. The other exception is the Signal Protocol wire message (a type byte, ratchet header, and ciphertext), which uses the protocol's own type-discriminated framing rather than the shared envelope; the server bounds-checks these bodies and treats them as opaque.
Two mechanisms prevent a ciphertext from one context being accepted in another.
First, for blob families that share a single symmetric key (posts, group content, and direct-message reactions and edits, which share one conversation-level key), the client's envelope module binds the 5-byte header (magic and version) as AEAD associated data at both encryption and decryption. Double Ratchet messages achieve the equivalent binding through the Signal construction itself, which authenticates the ratchet header as associated data. A blob sealed as one type fails Poly1305 authentication if reframed under another type's magic. Media blob families instead derive a distinct subkey per purpose with HKDF-SHA-256, using versioned info strings such as boopr-image-full-v1, boopr-image-thumb-v1, and boopr-dm-voice-v1, so a wrap or blob of one family cannot be unwrapped or decrypted as another.
Second, every Ed25519 signature carries a versioned ASCII context prefix. Contexts must match the pattern boopr-[a-z0-9-]+-vN, and the signed bytes are the ASCII context, a colon, then the message. The identity signing key signs exactly three message classes: boopr-login-v1:, boopr-invite-v1:, and boopr-signed-prekey-v1:. The composition is exported so client and server construct identical bytes; the server implements verification only, with explicit key-size (32) and signature-size (64) checks before verifying.
Secret comparisons use timing-safe primitives: an XOR-accumulating constantTimeEqual on the client (used for pinned-identity-key comparison, among others) and subtle.ConstantTimeCompare at every server-side secret comparison, including blind-index lookups and device-token matching. Before every X25519 agreement, public keys are checked in constant time against the standard list of low-order and degenerate Curve25519 points (all-zeros, the identity, the small-order points, p, p-1). The list is mirrored on the client and the server, and the server rejects low-order keys at upload.
Large media blobs are encrypted in native code for throughput. The native modules mirror the JavaScript construction exactly: iOS composes XChaCha20-Poly1305 from CryptoKit's ChaChaPoly plus a hand-implemented HChaCha20 subkey step; Android composes it from the platform ChaCha20-Poly1305 cipher plus hand-implemented HChaCha20 and an HMAC-SHA-256-based HKDF. Both produce and parse the same 5-byte envelope header and derive per-blob keys with HKDF-SHA-256.
boopr's deployed cryptography is classical. All key agreement is X25519, and no post-quantum or hybrid KEM is fielded. A future quantum adversary who records ciphertext today could decrypt it once cryptanalytically relevant hardware exists; the exposure is largest for the surfaces without forward secrecy (posts, media, and profile keys, where content keys are wrapped to long-term X25519 identity keys) and for server-stored direct-message ciphertext. A hybrid post-quantum key agreement in the style of PQXDH is under evaluation; the versioned envelope, versioned derivation strings, and version-tagged key wraps described in this section exist precisely so that migration can ship without format ambiguity. This document will be updated when the posture changes.
The header-as-AAD binding covers only the post, group, and direct-message reaction and edit blob families; Double Ratchet messages are instead protected by the Signal construction's authentication of the ratchet header. Image media blobs encrypted through the native path pass no AEAD associated data and rely solely on per-family HKDF info strings for type separation (video segments and thumbnails do bind their headers). The hand-implemented HChaCha20 core in the native modules is the one deliberate exception to the rule against reimplementing primitives; its correctness rests on byte-for-byte interoperability with @noble/ciphers rather than on an independently audited implementation. The client's constantTimeEqual performs a non-constant-time length check, documented as acceptable because the compared lengths are not secret. One client HKDF info string (the device-local message-cache string boopr-sent-message-cache, which never crosses a device boundary) lacks an explicit version suffix; the two protocol-critical Signal strings carry one (boopr-x3dh-shared-secret-v1, boopr-signal-root-v1); the X25519 identity-key derivation carries its version in the HKDF salt rather than the info string, and the server-side blind-index derivations are domain-separated (blind-index-<domain>) but carry no version suffix, so a scheme change would be deployed under a new domain string. Envelope framing is integrity metadata, not confidentiality: the server observes every blob's magic, version, and size by design, so the type and approximate size of each encrypted object are visible to the server even though it cannot read the content of end-to-end encrypted blobs. Server-side device push tokens are encrypted at rest with AES-256-GCM under a server-held key and are decryptable by the server by design, since the server must read them to deliver notifications. This section covers primitives and framing only; key management, message protocols, and recovery are specified in their own sections.
The key hierarchy specified here is normative; every other section that mentions identity-key derivation refers to this one.
Every boopr account is identified by two long-term key pairs generated on the user's device: an Ed25519 signing key pair and an X25519 encryption key pair, both from @noble/curves. The Ed25519 public key is the account's primary cryptographic identity; the server stores it under a uniqueness constraint and resolves accounts by it at login.
Identity generation begins with a 24-word BIP39 recovery phrase (English wordlist, @scure/bip39) carrying 256 bits of platform-CSPRNG entropy (crypto.getRandomValues; natively SecRandomCopyBytes on iOS, java.security.SecureRandom on Android). The first 32 bytes of the BIP39 seed become the Ed25519 private key seed. The X25519 private key derives deterministically from the signing seed with HKDF-SHA-256, salt boopr-v{version}-encryption-key-salt (the version is currently 1 and is recorded server-side at registration), info boopr-x25519-encryption-key, 32 bytes out. The profile encryption key derives from the same seed with salt boopr-v1-profile-key-salt and info boopr-profile-key-v{version}, whose version increments on rotation (for example, on unfriending). Both version numbers are recorded server-side so future derivation changes stay distinguishable. Every long-term key is a deterministic function of the signing seed, and the 24-word phrase alone reconstitutes the full identity on a recovered device.
Private keys are ephemeral in memory: generation, signing, and derivation paths zero private key buffers after use, copying values that alias the seed before return. At rest, the identity keys and recovery phrase live in the OS keychain under a strict hardware-wrapping mode that throws rather than ever writing plaintext when the hardware-backed data encryption key is unavailable (the wrapping construction is described elsewhere). Account creation aborts if hardware-backed storage cannot initialize, so identity keys never exist on a device without it.
At registration the client transmits only public material: the base64-encoded Ed25519 and X25519 public keys, the encryption key derivation version, an encrypted profile blob, an invite code, a device identifier, and non-cryptographic attestation fields (age attestation, declared state, policy-document SHA-256 hashes, platform and version data). No private key, seed, or phrase leaves the device; the payload carries no password, email address, or phone number.
The server validates submitted keys: Ed25519 keys must be exactly 32 bytes, non-zero, and canonically encoded (y coordinate below 2^255 - 19); X25519 keys are checked for length and, in constant time, against the all-zero point and the seven non-trivial low-order Curve25519 points, then parsed through Go's crypto/ecdh. The client applies the same rejection before every X25519 shared-secret computation, including on its own keys and on ephemeral keys from received envelopes.
Authentication is passwordless Ed25519 challenge-response. The client signs the ASCII string boopr-login-v1:{unix_timestamp}:{base64_nonce}. The server enforces a 60-second timestamp window in either direction, a 32-byte nonce, and verifies the signature over the challenge against the submitted public key, then atomically records the nonce for cross-instance single use (6-minute lifetime), so a captured challenge cannot be replayed while the coordination store is reachable (the degraded mode is described in the transport section); only then does it resolve the account by signing public key. Unknown keys receive the same generic failure as invalid signatures, preventing account enumeration; account status such as suspension is disclosed only after the signature verifies, visible only to the key holder.
Every Ed25519 signature carries a versioned ASCII context matching boopr-[a-z0-9-]+-v[1-9][0-9]*. The signed bytes are the context, a colon, then the message. Each signed message class is composed by a deterministic builder mirrored byte for byte by the backend verifier, so a signature produced for one purpose cannot be replayed in another. Semantically relevant fields are bound inside the signed bytes: an invite's tier attribute is length-prefixed within the boopr-invite-v1: payload, so mutating it invalidates the signature.
Friends' public keys are distributed by the server: connection records and friend-request events carry each friend's stored encryption public key, which the client uses to wrap keys to that friend.
For direct messages (available in limited internal testing and rolling out publicly after launch), the client pins both the peer's X25519 encryption key and Ed25519 signing key in a device-local OS keychain entry on first contact. Any later mismatch aborts the handshake as a potential man-in-the-middle; a failure to persist the pin is treated as untrusted rather than proceeding. Inbound session establishment is fail-closed: the sender's identity key must match the connection record or a previously pinned key (constant-time comparison), and with no anchor the client refuses the session.
Manual verification uses 60-digit safety numbers: HKDF-SHA-256 over the sorted concatenation of each party's account identifier and X25519 identity key, zero salt, info boopr-safety-number-v2, yields 48 bytes rendered as 12 groups of 5 digits (each a 32-bit value reduced modulo 100000, maximum bias about 0.0016%). Field widths are asserted before hashing to prevent boundary-shift collisions, and a QR payload supports in-person scanning.
Each installation holds a random 128-bit device identifier from the platform CSPRNG; it is non-secret, not seed-derived, and survives recovery. boopr enforces a single active device per account through a server-side device generation counter: logins from superseded devices are rejected, and recovery onto a new device is held in a cooling-off window while an incumbent device exists.
boopr encrypts every post end-to-end on the author's device. The server stores and routes ciphertext, validates each blob family's framing (magic, version, size bounds), and enforces access control, but holds no key capable of decrypting post content, media, comments, or likes.
The client generates a fresh 32-byte content key per post from a cryptographically secure random source. The plaintext is a JSON structure (content type: text, image, album, or video; caption; dimensions; tags; a blurHash placeholder) encrypted with XChaCha20-Poly1305; audience metadata is deliberately not part of it, and rides the author-only sidecar described below. A versioned envelope frames the ciphertext: 4-byte magic BPST, 1-byte version (0x01), 24-byte nonce, ciphertext, 16-byte Poly1305 tag. Readers fail closed on unknown version bytes rather than parsing by offset. The 5-byte header is bound as AEAD associated data, so a post-content blob cannot be reframed as a like blob under the same key.
Media encrypts separately under HKDF-SHA-256 subkeys of the content key: single images under boopr-image-full-v1 and boopr-image-thumb-v1 (BPIM framing); album posts under one subkey per image, boopr-album-image-{index}-{full|thumb}-v1 (BPAM), with no pixel data in the content JSON; video segments under boopr-video-fmp4-v3 (BPV3) and thumbnails under boopr-video-thumb-v1 (BPVT), with video metadata encrypted as BPST.
For each recipient the client generates an ephemeral X25519 key pair, computes the shared secret with the recipient's public key, derives a wrapping key via HKDF-SHA-256 (32 zero-byte salt, info boopr-post-key-v1), and encrypts the content key with XChaCha20-Poly1305. The BPKP packet is exactly 109 bytes: magic (4), version (1), ephemeral public key (32), nonce (24), encrypted key with tag (48). The server validates the framing on every write path (history sharing requires the exact 109-byte length; post creation bounds packets between 109 bytes and 1 KB), and both sides reject low-order X25519 points before the exchange. The author also wraps the key to their own public key, so authors can re-decrypt their own posts.
The server validates every wrap, silently drops wraps addressed to non-friends, and inserts the post and its key records in one transaction, capped at 1001 wraps (1000 friends plus the author). Delivery fan-out includes each recipient's own wrap, so recipients decrypt without an extra fetch.
The feed is strictly chronological in SQL, with no ranking or reordering, over a 90-day window; full history remains available on profile views.
Audiences, the friend lists used to target posts, exist only on the client; the server has no audience table or API. At post time the client resolves an audience to a recipient set and submits only per-recipient wraps. Audience names, stable IDs, and selections ride a separate author-only envelope (BPAS) encrypted under a key only the author can derive, stored alongside the post and returned by the server only to its author. They are a sidecar rather than part of the post content because embedding them in the single ciphertext every recipient decrypts let co-recipients read co-recipient identifiers and the author's private list names. One bit does reach the server: whether that sidecar is present at all, which distinguishes a post restricted to a subset of friends from one shared with every friend, and nothing beyond that distinction. Audience definitions back up for device restore as end-to-end encrypted BPCD blobs keyed via HKDF-SHA-256 from the profile key (info boopr-client-data-audiences-v1).
When a friendship is accepted, each author's client walks its own post history, decrypts each post locally, applies audience filtering, re-wraps the content key to the new friend as a BPKP packet, and submits batches of 50 keys (the server caps each request at 100), rate-limited to five requests per minute. The server verifies the friendship, skips any post not owned by the caller, and inserts idempotently. Completion markers key on the server-issued acceptance timestamp; unfriending or blocking deletes key records in both directions, and a re-friend restarts sharing under a new epoch. A server kill switch and a per-user preference gate the mechanism, and content keys are zeroed after each re-wrap.
A like is a single emoji encrypted under the post's content key (BPRC framing, header bound as AAD, at most 4 KB). The server checks that the liker holds a key record, then (for others' posts) verifies a live bidirectional friendship atomically with the insert; failures return a generic 404 to prevent post enumeration.
Comments default to author-only visibility: only the post author and the commenter can read them. An author-only body encrypts under a fresh random 32-byte per-comment key (BPCA framing), ECDH-wrapped to exactly those two parties as BPKC packets with info boopr-comment-key-v1, domain-separated from post-key wraps. Audience comments encrypt under an HKDF-SHA-256 subkey of the post's content key (info boopr-post-comment-v1, BPCM framing), so every key holder can read them. Visibility is chosen by the commenter and bound to the envelope construction they actually used: the server reads it from the ciphertext's magic bytes, keeping fan-out consistent with the key structure of the blob itself, and scopes reads in SQL: the author sees all comments; any other viewer sees audience comments plus only their own author-only comments, whose existence is otherwise never revealed; author-only ciphertext never fans out to non-recipients. Since audience members may not be friends of a commenter, each audience comment upserts an encrypted profile snapshot (display name, optional small avatar) under boopr-comment-snapshot-v1 (BPCS framing).
Profiles (display name, avatar, and optional fields) are end-to-end encrypted under a key derived from the account seed, distributed to friends as individually wrapped copies, and rotated on every unfriend and block. This section specifies that lifecycle and the cryptography of graph formation. Identity-key derivation is specified in Identity and Key Management.
Profile content is encrypted under a 32-byte symmetric key derived via HKDF-SHA-256 from the signing seed, with salt boopr-v1-profile-key-salt and info boopr-profile-key-v{version}. The version starts at 1 and increments on rotation; because it participates in the info string, each rotation yields an unrelated key still recoverable from the phrase. The server stores the current version in plaintext, so a restored device re-derives the key.
Profile fields (display name, optional bio, birthday month and day only, relationship status, short free-text fields) are JSON-serialized and encrypted with XChaCha20-Poly1305, framed as BPRF magic, version byte 0x01, a 24-byte random nonce, and ciphertext with a 16-byte Poly1305 tag. Decryption rejects any blob lacking the magic (unencrypted profiles are never accepted) and returns one generic error for all failures; only an unsupported envelope version propagates distinctly.
Static avatars are re-encoded to JPEG with EXIF stripped, resized to 800 px and 200 px variants, and encrypted under per-variant keys derived from the profile key (boopr-avatar-full-v1, boopr-avatar-thumb-v1) beneath the shared BPRA magic; animated GIF avatars are preserved as GIF (capped near 5 MB) with GIF metadata blocks stripped, the thumbnail variant carrying an embedded 200 px JPEG preview alongside the animation; profile videos use dedicated magics BPPV and BPPT under their own derived keys. Server-stored client-data blobs use the BPCD envelope, which embeds the profile-key version; readers derive any past version from the seed, so these blobs survive incomplete rotations.
The profile key reaches each friend as a 109-byte ECDH key-wrap packet: BPKF magic, version 0x01, ephemeral X25519 public key, 24-byte nonce, and XChaCha20-Poly1305 ciphertext of the key. The wrap key is HKDF-SHA-256 over the X25519 shared secret between a fresh per-wrap ephemeral key and the friend's encryption public key (info boopr-profile-key-wrap-v1); peer keys are validated against all-zero and low-order points before ECDH. The server validates each wrap's framing before storing or forwarding it but never sees the key. Wrap exchange rides the friend-code redemption and acceptance requests themselves and is echoed over WebSocket, closing delivery races: the accepter's wrap is stored atomically within the acceptance transaction, while the redeemer's wrap is written immediately after the redemption transaction commits. A batch endpoint handles redistribution. Received keys live in the hardware-backed keychain; intermediate secrets are zeroed.
Friend codes are server-generated: 40 bits of CSPRNG entropy as 8 characters from a 32-character alphabet excluding 0, 1, I, O (FRND-XXXX-XXXX); single-use, revocable, 24-hour expiry, at most 5 active. The server stores only blind-index hashes: HMAC-SHA-256 over the normalized code under per-domain keys derived via HKDF-SHA-256 from a server-side master key, compared in constant time. Redemption is authenticated, rate-limited to 10 per minute per user per API instance, uniform-error, and fully transactional; the redeemer becomes the requester and only the creator can accept, so an intercepted code cannot force a connection. The graph is capped at 1000 connections per user; the cap error is deliberately generic to avoid leaking the other user's graph state.
Invite codes use the same generator (BOOP-XXXX-XXXX, 7-day expiry, separate blind-index domain). Each invite carries an Ed25519 signature by the inviter over a deterministic message: the prefix boopr-invite-v1:, the inviter and invite UUIDs, a big-endian creation timestamp, and a length-prefixed tier attribute, bound inside the signed bytes because it confers lifetime status. The server verifies the signature and tier authorization at creation and persists the signature with the code hash.
Unfriending triggers rotation; blocking rides the same removal path, and rotation runs even when removing the last friend. A block runs the same server-visible teardown as an unfriend (connection removal, key-record deletion, rotation), so the server cannot distinguish the two; the block designation itself lives only in the blocker's end-to-end encrypted block list, and enforcement that requires knowing about the block (for example, filtering a blocked party's direct messages) therefore happens on the client. The client increments the version by 1 and re-encrypts and re-uploads the profile document and both avatar variants with the version bump in one request; rotation aborts rather than advance the version past any blob it cannot re-cover. Since these blobs carry no key-version tag, the client aborts if any blob cannot be re-encrypted; the server rejects non-monotonic bumps and bumps lacking a re-encrypted profile document (avatar and video re-coverage is enforced by the client-side abort above). On failure the local key rolls back and the owed rotation is recorded durably and retried. After success the new key is re-wrapped to every remaining friend, backed by a durable retry queue (removed friends are scrubbed) and a delivered-version record that can only cause redundant sends, never withhold a key. A client-side mutex serializes concurrent rotations.
Teardown scrubs the ex-friend's profile key from the keychain, purges their content from memory, and evicts them from live broadcast sets; they receive only the remover's user ID and a timestamp. In shared-feed groups (limited internal testing, public rollout after launch), a member's profile key is additionally wrapped under the group key, extending profile decryption to non-friend group members by design.
boopr encrypts all media on the client before upload: post images, albums, GIFs, videos, avatars, boops, and direct message attachments. The server stores and relays ciphertext only.
With one exception, every at-rest media ciphertext shares one framing: a 4-byte ASCII magic identifying the blob family, a 1-byte version (currently 0x01), a 24-byte nonce, and the XChaCha20-Poly1305 ciphertext with its 16-byte tag. The exception is post video: fMP4 segments are stored as bare nonce plus ciphertext under a container header that carries its own version, described in the Video subsection. Readers fail closed on unrecognized version bytes with a dedicated unsupported-version error. Client primitives come from the @noble libraries: xchacha20poly1305 from @noble/ciphers, x25519 from @noble/curves, and HKDF-SHA-256 from @noble/hashes.
Each post carries a fresh random 32-byte content key. Content metadata (caption, dimensions, media format, tags, BlurHash) is encrypted under it as a BPST envelope, with the 5-byte magic || version prefix bound as AEAD associated data to prevent blob-type substitution. The key reaches each authorized recipient as the 109-byte BPKP ECDH wrap specified in the feed section.
Post images are re-encoded to JPEG before encryption, stripping EXIF and defeating source-encoding steganography. Inputs above 8192 px are rejected; outputs are capped at 3000 px plus a downscaled thumbnail. Single images encrypt as BPIM envelopes under HKDF subkeys of the content key (boopr-image-full-v1, boopr-image-thumb-v1). Album posts (2 to 20 images) encrypt each image as a BPAM envelope under boopr-album-image-{index}-{full|thumb}-v1. GIF posts keep the raw bytes (15 MB maximum) with editor metadata (Comment, Plain Text, and non-loop-control Application Extensions) stripped from the GIF89a structure. BlurHash placeholders are computed client-side from plaintext and embedded inside the encrypted BPST JSON; for posts the server never sees the BlurHash.
Post videos use a fragmented-MP4 pipeline: a native decode, re-encode, and segmentation stage produces H.264 High Profile video, short side capped at 1080 px, AAC audio, metadata stripped, and duration capped at 180 seconds. Each fMP4 segment is encrypted independently with XChaCha20-Poly1305 under HKDF-SHA-256(content key, zero salt, boopr-video-fmp4-v3), stored as nonce(24) || ciphertext+tag. A 14-byte associated-data block binds the BPV3 magic, a uint16 version (3), the uint32 segment index (0 for the init segment), and the total segment count, so segments cannot be reordered, dropped, or transplanted between videos. Thumbnails use BPVT envelopes (boopr-video-thumb-v1). Non-post videos (boops, profile videos, direct message videos) use a separate compression path and encrypt the whole MP4 as a single envelope blob.
Avatars encrypt as BPRA envelopes under HKDF subkeys of the user's profile key (boopr-avatar-full-v1, boopr-avatar-thumb-v1, zero salt). When the profile key rotates (on every unfriend or block), the client re-encrypts avatar blobs from the old subkeys to the new, so no blob remains under a retired key. Profile videos use BPPV/BPPT envelopes; boops use a per-boop content key with BPCT content, BPBV/BPBT video subkeys, and a BPKB ECDH key-wrap family.
Direct messages, available in limited internal testing and rolling out publicly after launch, generate a random 32-byte attachment key per attachment, transported inside the end-to-end encrypted Signal Protocol message content. Attachment media encrypts under HKDF subkeys of that key: BPDM images (boopr-dm-image-full-v1, boopr-dm-image-thumb-v1), BPDV/BPDT video and thumbnail (boopr-dm-video-v1, boopr-dm-video-thumb-v1), and BPVM voice messages.
Bulk encryption is offloaded to native modules implementing XChaCha20-Poly1305 as vendored HChaCha20 plus the platform ChaCha20-Poly1305 primitive, with a @noble fallback when the module is absent. The post-video segment path is pinned byte-for-byte to the @noble implementation by frozen cross-platform test vectors; the image path is not yet vector-pinned. Intermediate plaintext files are deleted and in-memory key material is zeroed in cleanup handlers that run even on failure.
Uploads travel through the authenticated API, never directly to storage. The server validates every blob structurally (magic, version, size bounds) without the ability to decrypt. Downloads pass through authenticated endpoints that verify post access and a live bidirectional friendship or group membership. Encrypted post and album thumbnails are served public, immutable with a one-year lifetime; the bytes are undecryptable without the separately gated post key. In CDN mode the backend mints HMAC-SHA-256 signed URLs with a 15-minute lifetime.
Video plays back through in-memory streaming decryption: encrypted chunks feed a native session holding the content key (never returned to JavaScript after session open) and a bounded LRU of decrypted segments, drained when the app locks. On iOS, decrypted fMP4 is served to AVPlayer as HLS by a loopback HTTP server bound to 127.0.0.1, writing nothing to disk; each request must present a per-generation random 128-bit path token compared in constant time.
Media encryption protects content bytes, not structure or context. The server holds extensive plaintext metadata per post: author, content type, timestamp, image dimensions, album size, and the full recipient list; for videos also duration, dimensions, and per-segment size and duration tables, a detailed structural fingerprint of every video. No ciphertext is length-padded (each is plaintext plus a small fixed overhead: 45 bytes for envelope blobs, 40 bytes for bare video segments), so the server and delivery edge learn exact compressed sizes and could fingerprint videos by their segment-size sequence. The delivery infrastructure sees per-request access patterns, and edge-cached encrypted thumbnails stay fetchable by URL without authentication after the first authorized request, with deletion relying on best-effort cache purge. Media blob envelopes (the BPIM/BPAM/BPRA/BPDM families) bind no AEAD associated data, so cross-type substitution resistance rests on HKDF info-string separation alone, and the lite thumbnail shares its key and info string with the full thumbnail, leaving the two ciphertexts interchangeable. The native XChaCha20-Poly1305 implementations are custom construction code, not an audited library; the video implementations are pinned by frozen-vector tests, the image implementations are not yet. Media is not sender-signed: any recipient holding a post's content key could forge a valid ciphertext under it. Decrypted media exists in process memory during display and playback. Nonces are random with no counter bookkeeping, relying on the 192-bit XChaCha20 nonce space. Tagged user identifiers are transmitted and stored in plaintext, so the server learns who is tagged even though tag positions and names are encrypted. Because validation is structural only, the server cannot inspect content, and moderation of encrypted media relies on user reporting rather than server-side scanning.
boopr direct messages are end-to-end encrypted with the Signal Protocol: X3DH key agreement followed by the Double Ratchet, with deliberate, documented deviations. The feature is available in limited internal testing and rolling out publicly after launch. Protocol primitives come from the @noble libraries: X25519, Ed25519, XChaCha20-Poly1305, HKDF-SHA-256, HMAC-SHA-256, and SHA-256 (attachment media may be encrypted by the byte-compatible native path described in Encrypted Media). The server stores only opaque ciphertext for message bodies.
Each account derives two long-term keypairs from one BIP39 24-word recovery phrase, exactly as specified in Identity and Key Management. boopr does not use XEdDSA on a single identity key as Signal does; signing and encryption identities are distinct keys sharing one root.
X3DH follows the Signal pattern: DH1=DH(IKa,SPKb), DH2=DH(EKa,IKb), DH3=DH(EKa,SPKb), plus DH4=DH(EKa,OPKb) when a one-time prekey is available. The session key is HKDF-SHA-256 over F||DH1||DH2||DH3[||DH4] with F = 32 bytes of 0xFF, a 32-zero-byte salt, and info boopr-x3dh-shared-secret-v1.
Signed prekeys deviate from the specification: the Ed25519 signature covers a domain-separated message, the ASCII context boopr-signed-prekey-v1: followed by the 4-byte big-endian prekey ID and the 32-byte prekey public key. Binding the ID prevents prekey-slot replay; the versioned context prevents cross-purpose signature reuse; the server verifies the same construction at upload. The initiating client fails closed if the peer's signing key is missing and verifies the signed-prekey signature before any DH operation. Every X25519 public key entering the protocol is checked in constant time against an eight-entry blacklist of low-order and degenerate points; the server mirrors this.
Prekey bundles are served only to bidirectionally accepted friends; other requesters receive a generic not-found response, scoping prekey draining and identity-key harvesting to a requester's own social graph. One-time prekey consumption is atomic, and the bundle omits the remaining-key count to resist draining reconnaissance. Clients replenish below 20 unused one-time prekeys (server cap 200); signed prekeys rotate on a 24-hour timer, both sides retaining the 7 most recent. On exhaustion, agreement proceeds as three-way X3DH without the OPK term.
Peer identity trust is trust-on-first-use: both public keys are pinned on first contact in a device-local OS keychain entry. A later mismatch blocks session establishment until the user explicitly approves the change; a keychain failure during pinning aborts the handshake. On receive, an inbound prekey message's identity key is verified against a trusted anchor before any session is created; a mismatch renders a visible security-error marker. Users can compare the 60-digit safety numbers specified in Identity and Key Management; a QR payload supports scan-based verification.
The root KDF is HKDF-SHA-256 (IKM = DH output, salt = current root key, info boopr-signal-root-v1, 64 bytes) split into new root and chain keys; the chain KDF is HMAC-SHA-256 with the specification's constant ordering. In place of the specification's AES-256-CBC plus HMAC recipe, the 32-byte message key directly keys XChaCha20-Poly1305 with a 24-byte nonce of 16 random bytes plus an 8-byte big-endian counter, guarding against RNG duplication.
The ratchet header (DH public key, previous-chain length, counter) is bound as AEAD associated data on every message; the first message of a session additionally binds both identity keys, and type-discriminator bytes let a reader tell which construction produced a blob. A single message may skip at most 1000 keys ahead within its chain; skipped keys are cached under a global cap of 5000 with oldest-first eviction and a 7-day TTL, and chain length is capped at 100,000. Messages decrypt oldest-first, and keys and plaintext buffers are zeroed after use. Simultaneous initiation resolves deterministically in favor of the lexicographically higher identity public key. A per-peer decrypt coordinator, a ratchet mutex, and a persisted filter of decrypted message IDs prevent double advancement of the ratchet across concurrent delivery paths. A receiver lacking the signed prekey an inbound message references uploads a fresh one and files a resend request; the sender re-encrypts from its local plaintext cache under a fresh session, a path Signal does not provide.
Serialized session state is versioned; older formats force a wipe and fresh X3DH, newer formats fail closed. At rest, the client's envelope module frames it as magic, version byte, 24-byte nonce, and XChaCha20-Poly1305 ciphertext under a key derived from the encryption private key (info boopr-session-storage-v1). Server-side backups add a per-backup random 32-byte salt (info boopr-session-backup-v1); the server stores the opaque blob with a client-supplied monotonic version, accepts writes only when the version increases, and never decrypts it. Unknown envelope versions are treated as format drift, handled non-destructively and distinct from corruption. Decrypted plaintext is deliberately cached device-locally under a key derived from the long-term encryption key (info boopr-sent-message-cache) so conversations survive restarts without re-running the ratchet.
Attachments are encrypted client-side under a random 32-byte key per attachment (album messages carry one key per image), carried inside the encrypted message body; per-use keys derive via HKDF-SHA-256 with media-specific info strings (boopr-dm-image-full-v1, boopr-dm-voice-v1, boopr-dm-video-v1, and thumbnail variants), and still images are re-encoded to JPEG with EXIF stripped before encryption; GIF attachments keep their original format with metadata stripped so animation survives, and receive a static JPEG thumbnail. The server validates envelope framing structurally and enforces size caps without decrypting. Reactions and edits do not use the ratchet: they encrypt under a static conversation key derived by ECDH between both parties' long-term encryption keys (info boopr-dm-reaction-v1), with each envelope's magic and version bound as AEAD associated data so the co-keyed blob types cannot be substituted for one another.
Server-visible metadata is extensive and plaintext: participant pairs, sender identity, timestamps, message type, media durations, disappearing-message timers, edit and reaction timing, delivery and read state, typing indicators, ciphertext sizes, and image dimensions for photo attachments. There is no sealed sender, and ciphertext persists indefinitely as a mailbox, unlike Signal's delete-after-delivery model.
Identity keys are server-distributed with first-use pinning; boopr does not currently provide key transparency. A malicious server can substitute keys before a first pin exists and intercept that session; safety numbers allow manual detection but nothing forces verification. The receive-path anchor is itself server-supplied and does not defend against a server that poisons both the connection record and an injected prekey message; anchoring receive on the keychain pin is planned, not delivered.
Forward secrecy is deliberately weakened at rest: the plaintext cache, local session state, and server-side backups all derive keys from the long-term encryption key, itself derived from the recovery phrase, so the phrase plus a backup blob yields all backed-up session states. Reactions and edits have no forward secrecy. Identity keys are bound as associated data only on the first message. Ratchet public keys and counters are not header-encrypted. One-time-prekey exhaustion silently downgrades X3DH to three DH terms. The resend path is a boopr-specific deviation with its own consequence: a resend request causes the sender to re-encrypt cached plaintext under a freshly established session, so a server able to inject resend requests can force session re-establishment at will; the re-established session passes the same anchor and pin checks as any other, so the path adds no new key-substitution power, but it does let the server trigger re-encryption of previously sent content under current keys. The protocol is single-device by design. Deniability is weaker than Signal's: signed prekeys use plain Ed25519 from a signing identity that also signs other account operations, making that identity non-repudiable, though message contents are not signed. boopr does not currently pin the backup version client-side, so the server could serve an older backup blob. A block is a client-side designation, but it cascades to an unfriend, and the server enforces a live bidirectional friendship on the direct-message side channels: reactions, typing, read receipts, attachments, edit history, and resend requests are all refused once the friendship is gone. What that teardown does not do is erase history: ciphertext already delivered to the recipient's mailbox stays there.
boopr binds each account to a single device. An account is its Ed25519 identity keypair: the signing public key is unique per account, the server keeps no device table, and prekeys and session backups carry no device identifier. Recovery replaces the authoritative device and decommissions the old one. Two credentials perform recovery: a 24-word phrase and an optional PIN vault.
At registration the client silently generates a standard BIP39 24-word English mnemonic encoding 256 bits of entropy from the platform CSPRNG; it is not shown during onboarding. The first 32 bytes of the BIP39 seed are the Ed25519 signing private key; the X25519 encryption private key derives exactly as specified in Identity and Key Management (HKDF-SHA-256 from the signing seed under the versioned salt, currently version 1). Every other long-lived secret derives from the same seed under versioned info strings (boopr-profile-key-v{version} and the equivalent strings for other seed-derived feature keys), so the phrase alone reconstructs the full identity. Input validation requires exactly 24 words and verifies the BIP39 checksum before any derivation, without revealing which word failed.
Restore proves key possession with an Ed25519 signature over boopr-login-v1:{unix_ts}:{base64_nonce}; the server records the key and nonce atomically across instances, blocking replay. A side-effect-free probe reports only account existence (an unknown key returns the same generic failure as a bad login), and the client wipes resident local state only after the probe succeeds, because a mistyped phrase can still pass the checksum. If the restored public key differs from the resident identity, the client runs a full isolation wipe of keychain and session-scoped storage, neither of which is namespaced by account.
The vault stores both private keys and the phrase server-side, sealed under a 6-digit PIN and addressed by a Recovery ID: HKDF-SHA-256(signingPrivateKey, info="boopr-recovery-id-v1"), base32-encoded as BPVR-XXXX-XXXX-XXXX-XXXX (80 bits). Deriving the identifier from a secret key means public identity data neither locates a vault nor burns its attempt counter; the server stores only a keyed blind index, HMAC-SHA-256(k, recoveryID).
PIN verification uses OPAQUE (RFC 9807). The same Go implementation runs on both sides at one pinned version, and both assert that the frozen suite (OPRF and AKE on Ristretto255-SHA512, Argon2id stretching, SHA-512 KDF, MAC, and hash) serializes to the exact expected bytes, refusing to operate on drift. Per-user OPRF keys prevent cross-account guessing. Login is stateless: between start and finish the server carries an AES-256-GCM-sealed ticket binding the expected client MAC, the vault's Recovery-ID hash, and a 60-second expiry. The server never sees the PIN; each guess is an online OPRF evaluation, so no offline-attackable artifact exists short of a compromise of the server's own vault secret (see the limitations). A wrong PIN fails client-side inside OPAQUE, and the vault ciphertext is released only after the server verifies the final key-confirmation message. The plaintext is encrypted with XChaCha20-Poly1305 under HKDF-SHA-256(exportKey, info="boopr-vault-enc-v1"), framed as a versioned BPVL envelope the server validates and version-tags.
The vault self-destructs after 10 attempts. The counter increments atomically at login start, before any cryptography runs, so disconnecting mid-protocol yields no free guess; a successful login resets it. On exhaustion the sealed bytes are archived to tombstone columns before the live columns are zeroed, so a fault that burned attempts stays recoverable; explicit user deletion zeroes without archiving. An unparseable stored record returns a distinct server error, never advances the counter, and never renders as an incorrect PIN. A separate side-effect-free existence check rejects mistyped Recovery IDs before PIN entry.
Sessions are capped at three per account, revoked oldest-first under a row lock. Each account carries an integer device generation: a login presenting a nonzero generation lower than the current one is refused as superseded, while a recovered device deletes its stored generation and bootstraps at zero. The generation check runs inside the session-creation transaction under the recovery bump's row lock, so a login racing recovery cannot leave two authoritative devices. Recovery decommissions the old device in one transaction: revoke all other sessions, increment the generation, void the push tokens. Device transfer reuses the same primitive, and refresh tokens are device-bound.
If an incumbent session was active within the previous 14 days, a recovery login is held pending for a cooling-off window (24 hours by default). The pending session reaches only three endpoints: recovery status, recovery cancel, and device-transfer completion (which promotes it solely with the old device's explicit consent, proven by a valid transfer code). It has no data access and no push-token registration, and the incumbent is notified over WebSocket and push, bypassing notification preferences. Approval promotes the pending session; rejection revokes it, bumps the generation, and suppresses automatic finalization for twice the window, so a rejected attempt cannot quietly convert into an automatic takeover; a renewed attempt restarts the pending-recovery cycle and re-notifies the incumbent. Absent a response, a sweep finalizes after the window (the lost-phone path); if the incumbent check errors, recovery is delayed rather than admitted.
Because every long-lived key re-derives from the recovered seed, finalization restores the encrypted Signal session backup and server-backed client data (audiences, block list, mute list, group keys) and forces fresh prekey publication so messaging peers re-establish sessions (direct messages are available in limited internal testing and roll out publicly after launch). Both are end-to-end encrypted under seed-derived keys: client-data blobs under per-type profile-key subkeys (HKDF-SHA-256(profileKey, info="boopr-client-data-{dataType}-v1")), the session backup under HKDF-SHA-256 of the X25519 private key (info boopr-session-backup-v1) with a replay counter. Post content decrypts because per-post keys are wrapped to each recipient's X25519 public key via ephemeral-static X25519. PIN recovery derives keys from the vault before touching device state and wipes the resident account only after the server verifies the PIN, so a failed recovery never damages a working device.
The phrase is a full-compromise credential: it reconstructs the identity keys, every profile-key version, every other seed-derived feature key, the Recovery ID, and the session-backup key, so a holder can authenticate and decrypt the server-held backups. The only takeover friction is the cooling-off window, and only when an incumbent session was active within 14 days; a dormant account is taken over immediately, with no owner notification path. That window is time-boxed, not consent-gated (silence finalizes after 24 hours), and the PIN vault is exactly as powerful as the phrase, since it contains it.
The 10^6 PIN space rests entirely on the online 10-attempt counter and Argon2id stretching. Anyone who learns a Recovery ID can destroy the vault with 10 wrong guesses (denial of recovery), and one attempt is spent at login start, so a crash mid-login costs a guess. An attacker holding both the server's vault secret and the stored records can mount an offline dictionary attack on the PIN (the standard OPAQUE server-compromise limitation). Single-device enforcement is server-trusted policy, not cryptography: a malicious server could decline to revoke the old device, and it observes recovery metadata (vault existence, attempt counts, timing, session and pending-recovery events). Recovery does not restore local-only data: locally decrypted message history is not backed up, ratchet state restores only to the last snapshot (backup uploads pause while backgrounded, so it can lag), and boopr does not currently limit retention of tombstoned vault material. The switch that pauses recovery fails open, as does the pending-session restriction on a database error. Finally, the phrase is generated silently and surfaced in settings rather than forced during onboarding: a user who never views it and does not create a PIN vault has no recovery path when the device is lost. That is the single most consequential usability decision in the design, and it is deliberate.
boopr treats screen capture as an accountability problem, not a prevention problem. When the operating system reports a capture on a protected surface, the client notifies the affected friend with an end-to-end encrypted report. (Direct messaging, one protected surface, is available in limited internal testing and rolls out publicly after launch; its capture handling is documented here in full.)
On iOS, a custom native detector observes UIApplication.userDidTakeScreenshotNotification for screenshots. Screen-recording and mirroring state comes from the UITraitCollection sceneCaptureState trait on iOS 17 and later, with UIScreen.capturedDidChangeNotification and UIScreen.isCaptured as the pre-iOS-17 fallback; both signals funnel through one emitter that fires only on an actual state change, so a transition observed by both produces a single event. No third-party capture-protection library is used.
On Android API 34 and later, the client registers Activity.ScreenCaptureCallback under the auto-granted android.permission.DETECT_SCREEN_CAPTURE permission. Earlier releases (minimum API 29) fall back to a MediaStore ContentObserver that matches new image paths against known screenshot name substrings within a two-second DATE_ADDED window; this requires an image-read permission, detects nothing without it, and runs only when the callback is unavailable. Screen-recording detection exists on API 35 and later via WindowManager.addScreenRecordingCallback, reporting visibility transitions only; a recording in progress at registration is deliberately not reported on either platform. Events are debounced at the listener and per-peer layers on both platforms, with an additional one-second native debounce on Android.
Protection is configured per screen in three tiers. Tier 1 surfaces (one-to-one chat, boop viewing) detect, report to the peer, and play a heavy haptic on the capturing device. Tier 2 surfaces (feed, post detail, friend profiles, comment threads) detect and report only. Tier 3 surfaces (recovery phrase display, own profile, device-transfer QR code, the conversation list, and other sensitive screens) prevent, show a local warning, and never report.
Prevention itself is platform-constrained. On iOS, in-app screenshot prevention is permanently disabled: the UITextField.isSecureTextEntry reparenting technique is a silent no-op (Core Animation permits a single superlayer) and, when active, made VoiceOver read the entire app as one secure text field. iOS protection is therefore post-capture detection and reporting, plus an opaque overlay installed at sceneWillResignActive, before the app-switcher snapshot, whenever App Lock is enabled or a sensitive screen is mounted. On Android, WindowManager.LayoutParams.FLAG_SECURE blocks capture on tier 3 screens and app-wide under App Lock, re-applied before the first frame renders. Tier 1 peer-content surfaces deliberately disable FLAG_SECURE: certain OEM builds enforce it by aborting the screenshot gesture before Activity.ScreenCaptureCallback fires, which would suppress the report. boopr accepts capturable content there in exchange for the report landing.
A screenshot report is end-to-end encrypted to the affected friend. The client derives a 32-byte key from an X25519 ECDH between the reporter's identity encryption private key and the peer's identity encryption public key, expanded with HKDF-SHA-256 using a 32-byte zero salt and the info string boopr-screenshot-narc-v1. Peer public keys are validated against low-order points before the ECDH. The payload is encrypted with XChaCha20-Poly1305 under a random 24-byte nonce and carried in a versioned envelope: the 4-byte magic BPSN, one version byte (currently 0x01), the nonce, then ciphertext and tag. Readers fail closed on unknown versions. The plaintext is exactly {context, timestamp, reference_id}; the identifier of the captured item exists only inside the ciphertext. The key is a static-static construction over long-term identity keys and provides no forward secrecy.
Feed attribution binds a capture to the single post covering at least 60% of the viewport; when no post clears the threshold at the moment of capture, the most recent post that did remains the target. Captures of the user's own content never generate a report; the server also rejects self-reports.
The server stores each report as ciphertext alongside sender, recipient, timestamp, and a plaintext surface category constrained to dm, profile, boop, feed, or comment. Submission requires a bidirectional friendship, caps the ciphertext at 2 KB, rate-limits to 10 events per sender and 5 per sender-recipient pair per minute, and deduplicates retries by idempotency key. Rows are deleted after 30 days. Delivery is a WebSocket frame plus a content-free push (fixed strings, no sender identity); frames to offline recipients are dropped, and a recipient-scoped backfill endpoint (capped at 100 events) recovers them on reconnect, push tap, and refresh. Recipients may disable these notifications, but the event row is still stored. An operator kill switch can pause the write path while reads stay open. Delivery logs record identifiers, booleans, and sizes only.
On the client, decrypted incoming reports persist in an MMKV instance encrypted under a key derived via HKDF-SHA-256 from the hardware-backed session wrapping key, hydrated only after that key exists. Both the incoming store and the failed-submission queue are wiped on logout, with verification and a fail-closed sentinel on next launch.
FLAG_SECURE tradeoff above).boopr's server routes and stores ciphertext: content is encrypted on the client before upload, while routing, timing, and safety metadata are plaintext.
Post bodies, media, and thumbnails are opaque ciphertext, client-encrypted before upload and held in database columns or the encrypted blob store depending on size. Profile contents (display name, avatar, profile video) are end-to-end encrypted; the server never sees display names. Comment bodies, like bodies, and boop media are ciphertext, as are direct message bodies (direct messages are available in limited internal testing and rolling out publicly after launch). Per-user lists (audiences, block list, mute list, group keys) are opaque encrypted blobs typed only by a plaintext data_type label: the block and mute lists are end-to-end encrypted and client-enforced, and the server sees only that a blob exists and when it changes. The connection teardown a block triggers uses the same removal operation as an unfriend, though an observer correlating a teardown with the block-list blob updating moments later could infer that a block occurred. PIN-recovery vaults use OPAQUE (RFC 9807); the vault is sealed under the OPAQUE export key, which the server cannot decrypt. Signal protocol prekey material held server-side is public keys and signatures only, and Signal session backups are client-encrypted blobs.
Social graph. Connection rows carry plaintext user, friend, and requester identifiers, status, and creation and acceptance timestamps. The only ciphertext column is the friend-wrapped profile key.
Posts and audiences. Author, content type (text, image, album, video), timestamps, image count, and dimensions are plaintext. Audience membership is plaintext: one key-wrap row per (post, recipient), so the server knows which users can read each post and its audience size.
Interactions. Likes carry plaintext post and user identifiers (the like body is ciphertext), so the server counts them directly. Comment existence, author, timestamps, and a deliberate plaintext visibility field (author_only or audience) are visible; the server gates comment counts and fanout on that field without decrypting. Photo tags are fully plaintext. Boop rows expose sender, recipient, status, opened and replayed timestamps, 24-hour expiry, viewing timer, and replay permission.
Message metadata (limited internal testing, rolling out publicly after launch). Conversation participant pairs, message timing and type, edit and delivery metadata, attachment MIME types and dimensions, and voice durations are plaintext, as are typing indicators and read receipts.
Accounts. The user row stores plaintext public keys, key version counters, trust level, and preference toggles. Activity is coarse: last_active_at updates at most once per hour.
Invite waitlist. Someone who wants an invite but has no code can leave an email address from the welcome or registration screen through an unauthenticated endpoint (POST /api/v1/auth/request-invite), which stores the normalized address under a uniqueness constraint alongside the screen it came from, a pending or invited status, and timestamps. This is the only email address the service collects anywhere, and it belongs to a person who is not yet a user: no account record holds one, and the row carries no link to an account. Fulfillment is manual by design, because no automated mailing system exists: an operator reads the list and sends a code by hand. The handler contract keeps the address out of the logs, it is never used for marketing and never shared, and it is deleted on request through the same operator tool that lists and exports the list. The endpoint carries its own kill switch (waitlist_enabled).
Invite, friend, group, and transfer codes are never stored raw; the server keeps a keyed blind index, HMAC-SHA-256 under per-domain keys derived with HKDF-SHA-256 (info strings blind-index-<domain>). Refresh tokens are likewise stored only as HMAC-SHA-256 hashes. Invites are client-signed with Ed25519, binding inviter, invite identifier, timestamp, and tier into the signed bytes. Push device tokens are encrypted at rest with AES-256-GCM in a versioned envelope (BPTE magic, version byte, 12-byte nonce), with the owning user and platform bound as AEAD associated data; this is server-side encryption, since the server must decrypt tokens to deliver pushes.
At registration the client IP resolves to a two-letter US state via an on-server geolocation database; only the state is stored, and the IP itself is never logged or persisted. Legal acceptance events store an HMAC-SHA-256 hash of the IP under a monthly rotating salt (derived over boopr-ip-hash-v1- plus year and month), alongside user agent, platform, and OS and app versions, retained ten years for legal compliance.
Push payloads carry no content: a type-generic title and body ("New message", "Tap to open"), a routing type field, and optional post or group identifiers for deep linking. Android pushes are data-only. An earlier encrypted-preview channel was removed, with the recorded decision that boopr will never carry decrypted content in push payloads. Unfriending and blocking generate no push; the affected client learns of the change only through a silent connection refresh.
The HTTP request logger records method, path, status, latency, request ID, and a truncated client-version header, never bodies or authorization headers. On the server the never-log rule is per-call-site convention rather than a central redaction layer; the mobile client additionally passes every log line through a best-effort pattern redaction pass (keys, tokens, mnemonic phrases, PINs) before remote shipping, which cannot guarantee coverage. Handler logs include user UUIDs; logs ship to self-hosted aggregation, and the mobile client labels its log stream with the first 16 hex characters of the SHA-256 of the user ID. Raw push tokens are never logged, only a short SHA-256 fingerprint.
Account deletion requires a typed confirmation string and runs in two phases: an immediate single-transaction scrub (profile ciphertext nulled, public keys irrecoverably overwritten, and all authored content, sessions, keys, connections, and device tokens deleted, recovery vault zeroed), then hard row deletion after a 7-day grace period. The feed never serves posts older than 90 days.
A full database compromise exposes every plaintext field above: the friend graph, each post's recipient list and audience size, like and comment attribution, boop timelines, and full message metadata. Only content bodies stay encrypted, and plaintext size and dimension fields enable traffic-analysis-style inference. Screenshot events reveal who captured whose content and on which surface. Read receipts, typing indicators, and per-category preference toggles are plaintext, exposing each user's privacy posture.
Push-token encryption protects database dumps only; a live-server compromise recovers tokens because the server holds the key. Push payloads reveal event types and routing identifiers to Apple and Google push infrastructure. Abuse reports are a deliberate operator-readable plaintext channel, anonymized rather than deleted on account deletion to preserve safety audit chains. Acceptance-event IP hashes are linkable within a calendar month, and the operator can verify a candidate IP by recomputation, which is the design goal. The mobile log pseudonym does not resist targeted brute force over known user IDs, and the server-side never-log rule is per-call-site convention rather than a central redaction layer (the mobile client adds a best-effort central redaction pass, noted above).
Blob deletion after account deletion is best-effort and asynchronous; encrypted media can transiently outlive database rows until an orphan scanner runs. The user row persists, with plaintext metadata columns, through the 7-day grace period, and the idempotency cache retains full response bodies for 24 hours, while the separate video-upload idempotency reservation table retains its rows (a client-supplied upload identifier and the post identifier it resolved to, not response bodies) for 7 days. Group membership, roles, and group-to-post mappings are plaintext for the group features in limited internal testing, rolling out publicly after launch. At-rest encryption of storage volumes and log retention are infrastructure-level controls outside this document's scope.
All client-server traffic uses TLS; plain HTTP exists only for local-simulator development. iOS App Transport Security is enforced and boopr's Android configuration disables cleartext traffic.
The mobile client does not pin TLS certificates; it relies on OS trust-store validation. This is a documented deferral: the API is fronted by a third-party edge proxy whose managed certificate rotates under a key boopr does not control, so pinning would strand deployed clients at rotation. The plan of record adds a self-controlled key with current and backup SPKI pins after a report-only rollout.
Every response carries Strict-Transport-Security (one year, subdomains included), X-Frame-Options: DENY, X-Content-Type-Options: nosniff, a strict referrer policy, a deny-all Content-Security-Policy, and a Permissions-Policy disabling camera, microphone, and geolocation. Because TLS terminates at the edge, the origin authenticates the edge: the origin authenticates that requests arrived via the edge and answers direct connections with the same generic not-found body it returns for an unknown route, so no other tenant of the same proxy network can front the origin. X-Forwarded-For is honored only from a configured trusted-proxy CIDR set at the rightmost untrusted hop; True-Client-IP and X-Real-IP are never trusted.
An account is a client-generated Ed25519 signing keypair; no passwords, email addresses, or phone numbers exist in the flow. Registration submits the Ed25519 public key (unique server-side), an X25519 encryption public key with a version integer, and an invite code. The one email address the service ever holds is the optional pre-registration waitlist entry inventoried in the server architecture section, collected before any account exists and never linked to one.
Login is Ed25519 challenge-response with domain separation: the client signs boopr-login-v1:{unix_timestamp}:{base64_nonce}. The server enforces a ±60-second window, a 32-byte nonce, and single use of the (public key, nonce) pair via an atomic set-if-absent record in a cross-instance store, so a captured signed login cannot be replayed against any instance while the shared store is reachable (the degraded mode is described in the limitations). The identity key signs exactly three message classes under versioned ASCII prefixes: login challenges, invites, and signed prekeys for direct messaging (available in limited internal testing, rolling out publicly after launch).
Access tokens are HS256 JWTs, 1-hour lifetime, with a kid derived from SHA-256 of the signing key; verification selects exactly one key by kid, and a current-plus-previous key ring supports zero-downtime rotation. Claims carry only identity; authorization attributes are read from the database at decision time, because a token outlives the row it was minted from.
Refresh tokens are 32 CSPRNG bytes, 7-day lifetime, stored only as HMAC-SHA-256 hashes under a key derived via HKDF-SHA-256 with the domain-separation string boopr-refresh-token-hmac-v1 as the HKDF salt. Each refresh rotates the token with theft detection (RFC 6819 §5.2.2.3): reuse within a short grace period is treated as a concurrent legitimate request, while reuse outside it revokes the session. Refresh tokens are also device-bound to a 128-bit random identifier compared in constant time.
Every authenticated request re-validates the session against the database through a 10-second cache, so revocation takes effect within about 10 seconds rather than the token lifetime. At most 3 sessions are active per user, and a monotonic device-generation epoch enforces single-device semantics atomically with session creation; a login from a new device during an active incumbent session is held pending with zero data access for a cooling-off window; the incumbent can approve or reject it, silence finalizes it when the window elapses (see Account Recovery), and the pending device cannot approve its own recovery. WebSocket authentication uses single-use tickets rather than URL tokens; logout drops the live socket and push tokens; session revocation drops the live socket.
Invite codes are 8 characters from a 32-character alphabet (40 bits of entropy) from a CSPRNG, never stored in plaintext: the database holds a blind index, the HMAC-SHA-256 of the normalized code under a per-domain key derived via HKDF-SHA-256 with info string blind-index-<domain>. Every user-created invite carries an Ed25519 signature by the inviter over a versioned message (boopr-invite-v1: prefix) binding the inviter, invite identifier, creation time, and tier attribute, so a database-tampered tier breaks the signature, and a server-side authorization matrix independently restricts who may issue each tier. Quota is decremented at creation (not redemption), invites expire after 7 days, and redemption locks the invite row against double-consumption. Invalid, expired, revoked, and forged invites return one generic failure, and suspended or banned status is disclosed only after a valid challenge signature proves key possession.
Rate limiting is two-tier. Security-critical authentication limiters run against a cross-instance shared store with atomic fixed-window operations, degrading within 150 ms to per-instance in-memory limits if the store is unreachable, so an outage never locks users out of authentication. Registration-flow limits are layered per IP, globally per endpoint, and per (signing key, IP) pair, bounding brute force without enabling targeted lockout. Raw client IPs are never persisted; abuse signals store HMAC-SHA-256 hashes under a monthly-rotating salt in time-limited rows.
An operator kill-switch table gates every client-facing write surface; each API instance caches it with a 5-second TTL, so a flip propagates fleet-wide within about 5 seconds without restart, enforced server-side before request bodies are read. An unauthenticated configuration endpoint publishes switch and maintenance state, server time, and a per-platform minimum-version gate for graceful client degradation and recovery. Mutating requests may carry an Idempotency-Key; responses are cached keyed on (user, key) and bound to a SHA-256 hash of the request body (reusing a key with a different body is rejected), and concurrent requests carrying the same key serialize through a reservation so the handler executes at most once; cross-user replay is architecturally impossible. Request logs exclude bodies, authentication headers, and client IPs.
The guarantees in this document depend as much on engineering discipline as on primitive selection. The following rules are enforced across the codebase and checked at design time for every new feature. They are published here because they are commitments about how the system will continue to be built, not just how it was built.
Versioned formats at every boundary. Any blob that is persisted or transmitted (database columns, blob storage, device keychains and caches, WebSocket frames, QR payloads) carries a magic and version through the shared envelope construction or an explicit version field. Raw nonce || ciphertext concatenations parsed by offset are forbidden. If a reader cannot tell which construction wrote a blob, the format is considered wrong regardless of whether it currently works. The server maintains the canonical registry of wire-crossing blob families and structurally validates every one of them at ingest, including the screenshot-report and messaging session-backup families; magic uniqueness across families is a maintained invariant, checked at review.
Domain separation on every signature. Every Ed25519 signature covers a versioned ASCII context prefix (boopr-<purpose>-vN:) followed by the message, composed by a single canonical builder per message class, each mirrored byte for byte by the verifier. Semantically load-bearing fields are bound inside the signed bytes rather than carried alongside them: an invite's tier travels inside the signature, so tampering with the stored tier invalidates the invite.
Key rotation must re-cover everything under the old key. When a rotating key exists (the profile key rotates on every unfriend and block), every blob family encrypted under it is enumerated: blobs are re-encrypted and re-uploaded at rotation, or carry a key-version tag so readers dispatch correctly. Rotation is atomic from the client's perspective: it aborts rather than leaving a mixed state, rolls back on failure, and durably retries. A decryption failure during recovery surfaces to the user; it never silently drops data.
Destructive paths are guarded. Code that deletes or regenerates key material on failure retries transient errors, checks for surviving wrapped data before concluding a state is unrecoverable, archives before it zeroes (the recovery vault's tombstone columns), and treats an unparseable server-side record as a server fault, never as a user error that burns attempt counters.
Kill switches ship with the feature. Client-facing write surfaces launch with operator kill switches (registration, invites, posts, comments, post likes, boops, direct-message writes, friend requests, profile updates, video upload, purchases, recovery, reports, and key backfill among them), propagated fleet-wide within seconds and enforced server-side before request bodies are read, with a client-visible configuration endpoint so deployed apps degrade gracefully. A switch added after an incident cannot protect clients already in the field, so it is added before there is an incident.
Wire contracts are fleet contracts. Deployed clients tolerate unknown enum values, notification types, and response fields (unknown means ignore, with generic fallback), because a shipped client fleet cannot be force-updated. Formats that might ever need platform-targeted control carry per-platform fields from the start.
Uniform failure surfaces. Endpoints that could disclose account or content existence return byte-identical generic failures for the distinct underlying causes (unknown key versus bad signature at login; missing post versus unauthorized access), and secret comparisons are constant-time on both client and server.
Hygiene as invariants, not habits. All JavaScript cryptography lives in one module tree with primitives imported from the vetted libraries (the native media-encryption modules, the vector-pinned exception noted in Encrypted Media, live outside it); secure randomness comes only from platform CSPRNGs; SQL is parameterized exclusively; migrations are applied forward-only; keys, tokens, decrypted content, and request bodies are never logged. The native post-video encryption implementation is pinned to the JavaScript reference by frozen cross-platform test vectors, exercised by a dedicated test net that drives the JavaScript, iOS, and Android implementations against one fixture file; the native image-encryption modules do not yet carry equivalent vector pinning, and the net runs as a suite rather than a build gate.
These practices are convention and review, enforced by lint rules and tests where mechanizable, but not all of them are machine-checked. The no-logging rule in particular is enforced per call site rather than through a central redaction layer. Publishing the rules makes the externally observable subset (versioned formats on the wire, uniform failure surfaces, push payload contents) checkable from outside; the remainder is stated as a commitment that a future audit can hold the system to.
boopr's source code is not public. This document exists so that the design can be evaluated anyway: it is written to be specific enough that a reader can identify design-level weaknesses without the code, and its claims are stated so that they can be checked from outside where possible. An independent third-party security audit is planned following launch, and its findings will be published. This document will be corrected wherever an audit contradicts it.
Several claims in this document are externally observable. Push notification payloads can be inspected on a device to confirm they carry no content. TLS configuration and response security headers are visible to any client. Users can proxy their own device's traffic and observe that uploaded content is ciphertext framed exactly as described here, that registration transmits no password, email, or phone number, and that direct-message safety numbers change exactly when this document says they should. The BIP39 phrase can be validated against the public standard. Independent verification of these claims is welcome. Traffic inspection has limits worth stating: it cannot establish what the client does with keys internally, and no external observation can. The residual trust in the client binary itself is stated in the threat model; the planned audit and this document's falsifiable claims are the current mitigations.
Reports are received at security@boopr.com. Include enough detail to reproduce the issue; encrypted mail is accepted, with a PGP key available on request to security@boopr.com. A machine-readable disclosure policy is published at boopr.com/.well-known/security.txt.
boopr commits to good-faith safe harbor: security research conducted against your own accounts and devices, without accessing other users' data, degrading the service, or exfiltrating more than the minimum needed to demonstrate the issue, will not be met with legal action. Acknowledgment within 48 hours, a substantive assessment within 7 days, and credit (if desired) on a published acknowledgments page. There is no paid bounty program at this time; that status will be revisited as the service grows.
Claims in this document are intended to be true of the shipped system, not aspirational. If you find a place where the software's behavior contradicts this document, that discrepancy is itself a reportable issue, and the document or the software will be corrected, whichever is wrong.
| Version | Date | Changes |
|---|---|---|
| 1.0 | July 2026 | Initial publication. |
| 1.0.1 | July 2026 | Signal HKDF info strings versioned; screenshot-report and session-backup blobs structurally validated at ingest; idempotency body binding; kill-switch enumeration. |
| 1.0.2 | July 2026 | Data inventory: pre-registration waitlist. Accuracy pass: audience metadata sidecar, history-share snapshot semantics, recording detection, DM side-channel enforcement, recovery reveal gating, idempotency retention, disclosure SLA alignment. |