boopr is a private 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 are end-to-end encrypted with MLS (RFC 9420) on a hybrid post-quantum ciphersuite. This document specifies how.
It is written for security engineers and cryptographers. It describes the system as shipped in September 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 MLS (RFC 9420) via OpenMLS with the libcrux provider. 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; MLS wire messages, which carry RFC 9420's own versioned framing behind a boopr discriminator and ciphersuite header; 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; the direct-messages section states plainly that message authorship is non-repudiable to the conversation partner. 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 post-quantum posture is split by surface: direct messages use a hybrid post-quantum KEM (X-Wing) for all their asymmetric operations, while every other surface remains classical X25519, so the harvest-now-decrypt-later discussion in Cryptographic Foundations applies to recorded ciphertext on those classical surfaces.
| 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 from @noble/hashes. Recovery phrases are BIP39 mnemonics (24 words, 256 bits of entropy, English wordlist) generated with @scure/bip39. Direct-message cryptography is the one surface implemented outside the JavaScript module tree: a native Rust module embeds OpenMLS with the libcrux cryptographic provider (X-Wing hybrid KEM, ChaCha20-Poly1305, SHA-256, Ed25519), specified in the direct-messages section.
The server uses the Go standard library: crypto/ed25519 for signature verification and for exactly one signing role, sealing News editions as described in Server Architecture (for every user-originated construction 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 direct-message wire body (a discriminator byte, a framing version, the 16-bit MLS ciphersuite identifier, then the RFC 9420 MLSMessage bytes, which carry their own versioning and are not double-framed); 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. MLS application messages achieve the equivalent binding through the protocol itself, which authenticates its framing and group context 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 two message classes: boopr-login-v1: and boopr-mls-credential-v1: (the attestation binding the derived MLS signature key to the account, specified in Direct Messages). Retired contexts, including the Signal-era signed-prekey context, stay registered as never-reuse identifiers. 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.
Direct messages are post-quantum hybrid: every HPKE operation in the MLS ciphersuite uses X-Wing, which combines ML-KEM-768 with X25519 so that breaking the key agreement requires breaking both. Recorded direct-message ciphertext is therefore not exposed to a harvest-now-decrypt-later adversary unless both components fail. The rest of the system remains classical: posts, media, profile keys, and boops wrap content keys to long-term X25519 identity keys, and a future quantum adversary who records that ciphertext today could decrypt it once cryptanalytically relevant hardware exists. The exposure is largest for those surfaces without forward secrecy. Signatures are classical Ed25519 everywhere, including inside the MLS credential chain: quantum-forgeable identity is a real future risk, but unlike confidentiality it is not retroactive. The versioned envelope, versioned derivation strings, and version-tagged key wraps described in this section exist precisely so that extending the hybrid posture to the remaining surfaces can ship without format ambiguity. This document will be updated as the posture changes.
The header-as-AAD binding covers only the post, group, and direct-message reaction and edit blob families; MLS application messages are instead protected by the protocol's own authenticated framing. 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 direct-message derivation strings carry one (boopr-mls-signature-key-v1, boopr-dm-backup-key-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, a device identifier, a server-issued registration challenge with its proof-of-work nonce, an optional hardware device attestation (described under abuse resistance), 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 rather than carried alongside them.
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, 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 blocks the conversation as a potential man-in-the-middle until explicitly approved; a failure to persist the pin is treated as untrusted rather than proceeding. The MLS credential chain anchors to this pin: a peer's MLS signature key is accepted only when its attestation verifies against the pinned identity key, as specified in Direct Messages.
Manual verification uses 60-digit safety numbers: SHA-512 over the versioned context boopr-safety-number-v1: followed by both parties' Ed25519 identity public keys in sorted order, rendered as 12 groups of 5 digits (each a 5-byte chunk reduced modulo 100000). A QR payload supports in-person scanning. The number is deliberately anchored to the long-term identity keys rather than to any protocol-specific key material, so it survives protocol migrations and MLS key rotation; the credential attestation specified in Direct Messages is what chains the protocol keys to the identity the number attests.
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.
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 generate a random 32-byte attachment key per attachment, transported inside the end-to-end encrypted MLS 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 Messaging Layer Security protocol (MLS, RFC 9420). The implementation is OpenMLS with the libcrux cryptographic provider, compiled from Rust into the client as a native module; JavaScript holds only ciphertext and plaintext, and group state never crosses the native boundary. Exactly one ciphersuite is spoken: MLS_256_XWING_CHACHA20POLY1305_SHA256_Ed25519 (code point 0x004D), which uses the X-Wing hybrid key-encapsulation mechanism (ML-KEM-768 combined with X25519, draft-06) for every HPKE operation, ChaCha20-Poly1305 for AEAD, SHA-256 for hashing, and Ed25519 for leaf signatures. The suite is pinned as a constant rather than negotiated: both endpoints are boopr clients, so there is nothing to negotiate and no downgrade to offer. The server stores only opaque ciphertext for message bodies. An earlier internal-only Signal Protocol implementation was removed before public release and never carried production traffic; its wire discriminators, signing context, and envelope magics are retired and registered as never-reuse identifiers.
MLS signs KeyPackages and leaf nodes with a dedicated signature key, never the account identity key itself: HKDF-SHA-256(signingKeySeed, salt boopr-v1-mls-signature-key-salt, info boopr-mls-signature-key-v1, 32 bytes), with the Ed25519 public key taken from that seed. The identity key already signs other message classes under boopr's own domain-separated constructions, and handing the same key to a second protocol with its own signing rules would be cross-protocol reuse that neither scheme's analysis covers. The MLS key is bound to the account by an attestation: the identity key signs boopr-mls-credential-v1: || userId || mlsSignaturePublicKey, and the credential carried in each leaf is a versioned encoding (a version byte, the 16-byte account UUID, and the 64-byte attestation). Verifying a peer is two steps: MLS verifies the leaf signature against the MLS key, then the client verifies the attestation against the identity key it has already pinned for that peer. A failed attestation is treated as an identity change and routes to the same explicit key-change approval flow as a pin mismatch; it is never silently accepted. Because the MLS signature seed derives from the recovery phrase, a recovered device reconstructs its MLS signing identity with no server round trip.
KeyPackages fill the role prekeys filled in DH-based designs. Clients publish batches of 20 and replenish below 20 unused; publication is idempotent on the package reference. Each package is single-use, and the server marks it used at handout rather than at consumption, so a package can never be served twice. There is deliberately no reusable last-resort package: a client whose pool is drained causes a typed failure for the sender rather than weakening the forward secrecy of every Welcome that a reusable package would touch. Only bidirectionally accepted friends may claim a user's packages; other requesters receive a generic not-found response, scoping pool draining and key harvesting to a requester's own social graph. Claiming is a POST with a per-requester-per-target rate limit, and a user can read only their own remaining-package count, never a target's.
A 1:1 conversation is an MLS group of two. The initiator claims one of the peer's KeyPackages, creates the group under a 32-byte random group identifier, and submits the Welcome. The identifier is random rather than derived from the participant pair, so re-friending produces an unlinkable new group rather than a resurrected one. The server maps conversations to group identifiers (it already knows who is talking to whom, so this adds no metadata) and stores Welcomes for pull-based discovery: the invitee polls a recipient-scoped endpoint and skips groups it already holds, so discovery is idempotent and consumes nothing. Group-changing commits are two-phase on the client: local state never advances until the delivery service accepts the commit, and on an epoch conflict the client discards its own commit and applies the peer's winning one, so simultaneous operations cannot fork the group. Commit catch-up reads are never kill-switched, because a client mid-epoch must be able to finish applying what it was already sent. Membership of the two-member group is structurally immutable in the native layer: a Welcome seating anything other than exactly two members, an external commit, or any commit carrying an add or remove proposal is refused before it can merge, so a malicious peer or server cannot splice a third member into a live conversation; the join path independently re-asserts the same invariant.
Application messages are MLS private messages encrypted from the group's secret tree; per-message keys are derived and discarded per the RFC's schedule, which is what provides forward secrecy within an epoch. On the wire each body is framed as a 1-byte discriminator, a 1-byte framing version, the big-endian 16-bit ciphersuite identifier, and the RFC 9420 MLSMessage bytes, which carry their own versioning and are deliberately not double-framed. Carrying the ciphersuite identifier in boopr's own framing is what keeps the unregistered X-Wing code point reversible: a future suite change is a readable version dispatch, not an undecryptable blob. Decryption is storage-atomic: the native layer persists advanced group state before releasing plaintext, and if state cannot be persisted the plaintext is deliberately dropped and the failure surfaced as retryable, so a crash can never leave a message both consumed and unread. Duplicate delivery surfaces as a typed non-error and is dropped silently, because replay is a normal consequence of retry on a flaky network.
Each client issues a periodic self-update commit per conversation on a 24-hour cadence, rotating its own leaf key. A compromise of current group state therefore stops yielding future messages within about a day of the compromised party next being online to rotate. The rotation carries no membership change, and the same two-phase commit rule applies: the client never advances its epoch until the delivery service accepts, and on conflict it adopts the peer's commit and retries at the next cadence.
Sending requires a live bidirectional friendship, checked inside the same transaction that writes the message. Unfriending or blocking removes the member and deletes the group; the conversation's side channels (reactions, typing indicators, read receipts, attachments, edit history) are all refused once the friendship is gone. What teardown does not do is erase history: ciphertext already delivered to the recipient's mailbox stays there until its owner deletes it.
MLS group state is a single whole-state snapshot the Rust core rewrites atomically on every state advance, framed with its own magic and version byte so the blob is self-describing before it reaches any platform layer. At rest it lives in platform secure storage: a device-only Keychain record on iOS (never included in backups), and on Android sealed with AES-256-GCM under a hardware-keystore key. Decrypt-side persistence is completed before plaintext is released, as described above. Message history is backed up server-side per message (decision recorded as D7): each message's plaintext is encrypted client-side into a versioned BDMB envelope under HKDF-SHA-256(signingKeySeed, salt boopr-v1-dm-backup-key-salt, info boopr-dm-backup-key-v1), a recovery-phrase-derived key the server never holds. The server stores the blobs it cannot read and returns them at recovery; the message identifier is bound inside each sealed blob and re-checked against the server row at restore, so an untrusted server cannot graft one message's body onto another's identifier. A backup blob carries the message body, which for media messages includes the attachment keys; the attachment ciphertext itself is never in the backup, but it remains in the mailbox, so a restored device can re-fetch and decrypt media that still exists. MLS groups themselves are not backed up: a recovered device restores its history from the backup and re-establishes each conversation fresh, driven by the resend flow, under which a peer that cannot decrypt an inbound message files a request and the sender re-establishes a new group and re-sends from its own local cache.
Attachments are encrypted client-side under a random 32-byte key per attachment (album messages carry one key per image), transported inside the end-to-end 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 ride the MLS group: 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. The trade is stated in the limitations.
Peer identity trust is trust-on-first-use: the peer's long-term keys are pinned on first contact in a device-local OS keychain entry, and a later mismatch blocks the conversation until the user explicitly approves the change. Manual verification uses 60-digit safety numbers computed from the two accounts' long-term Ed25519 identity public keys, sorted, hashed with SHA-512 under the versioned context boopr-safety-number-v1: and rendered as 12 groups of 5 digits; a QR payload supports in-person scanning. Safety numbers are deliberately anchored to the account identity keys rather than to MLS key material, so they survive protocol migrations and MLS key rotation; the D2 attestation is what chains the MLS keys to the identity those numbers attest, which is why the attestation check fails closed.
Server-visible metadata is extensive and plaintext: participant pairs, sender identity, timestamps, message type, media durations, edit and reaction timing, delivery and read state, typing indicators, ciphertext sizes, and image dimensions for photo attachments. Disappearing-message expiry reaches the server as a timestamp rounded to the nearest hour; the exact timer rides inside the encrypted body. Group identifiers, KeyPackage inventory levels, Welcome and commit timing, and the 24-hour rotation cadence are likewise visible. There is no sealed sender, and ciphertext persists as a mailbox until the owner deletes it, unlike Signal's delete-after-delivery model (housekeeping bounds the protocol residue, not the mail: claimed KeyPackages and the commit-and-Welcome logs of tombstoned groups are deleted after 30 days). The server enforces none of the MLS structure: it stores KeyPackages opaquely without verifying the credential attestation, accepts any structurally plausible ciphersuite value, and applies only size bounds to message bodies, so ciphersuite pinning and credential verification are enforced entirely by the clients, which are the only parties with standing to check them.
Identity keys are server-distributed with first-use pinning; boopr does not provide key transparency. A malicious server can substitute keys before a first pin exists and intercept that conversation; safety numbers allow manual detection but nothing forces verification. The X-Wing ciphersuite is a draft (draft-06) on a private-use code point with no IANA registration: there is no interoperability with any other MLS implementation, and a change in the draft's combiner would require a migration, mitigated by the ciphersuite identifier travelling in boopr's own framing. The hybrid construction is post-quantum for confidentiality only: leaf signatures and the identity attestation remain classical Ed25519, so a future quantum adversary could forge identities but not decrypt recorded ciphertext.
Post-compromise healing happens at epoch boundaries on a 24-hour cadence, not on every message as a Diffie-Hellman ratchet step would; a compromise is readable for up to a day rather than until the next round trip. Forward secrecy is deliberately weakened at rest: message history is mirrored server-side under a recovery-phrase-derived key, so the phrase plus the stored blobs yields the full backed-up history. Reactions and edits have no forward secrecy at all (a static conversation key), and no last-resort KeyPackage means a first message to a user with a drained pool fails until they replenish. MLS application messages are signed by the sender's leaf key, which the D2 attestation chains to the account identity, so message authorship is non-repudiable to the conversation partner by design; boopr makes no deniability claim for message content. The protocol is single-device. Credentials are visible to the server; they contain the account identifier the server already knows and an attestation over data it already has, and nothing else may be added to them without redoing that analysis. A block is a client-side designation, but it cascades to an unfriend, and the server enforces a live bidirectional friendship on the send path and every side channel.
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 KeyPackages and message 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 server-backed client data (audiences, block list, mute list, group keys) and the direct-message history backup, then publishes fresh MLS KeyPackages so conversations re-establish. All of it is 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")), message-history blobs under the recovery-phrase-derived backup key specified in Direct Messages. 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 restores message bodies (and the attachment keys inside them) through the server-side history backup; attachment bytes are re-fetched from the mailbox where they still exist, and live MLS group state is never backed up: conversations re-establish fresh on the recovered device. 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 is one protected surface; 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 and the direct-message history backup. 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. MLS KeyPackages held server-side are public material by construction (public HPKE keys, the signed leaf, and the credential), and direct-message history backups are client-encrypted blobs the server stores but can never read.
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. 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. MLS group identifiers, KeyPackage inventory, and Welcome and commit timing are visible to the delivery service by construction.
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.
Registration attestation verdict. The only artifact retained from the signup device check is a verdict label naming the mechanism that verified (Apple App Attest, Android hardware key attestation, the GrapheneOS variant of the latter, or none) and the environment it was issued for, recorded on the account row at write time. The attestation object or certificate chain and the single-use challenge are discarded once verification completes; the service holds no email address for anyone.
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. 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.
The News briefing is the one surface where content originates from the operator instead of a user, and it inverts the usual flow: instead of user ciphertext moving through a server that cannot read it, a public edition moves from the server to every client. Editions are machine-assembled from primary official sources, published on fixed twice-daily slots, and identical for every account. The server signs each edition: Ed25519 over the exact serialized payload bytes under the versioned context boopr-news-bundle-v1:, with the current public-key set advertised on the unauthenticated configuration endpoint, so key rotation is an overlap of old and new keys rather than a client release. This is the single exception to the rule that the server never generates signatures, and it exists because the client refuses to render an edition whose signature does not verify: a compromised delivery path can suppress the briefing but cannot alter it. Stated honestly, the signature authenticates the operator's pipeline and no more: the live service holds the signing key and serves the public-key set, so it does not defend against a compromise of the backend itself. Clients additionally refuse editions that fall more than a bounded window behind the newest edition they have verified, limiting replay of stale editions; the newest signed edition itself remains replayable after a withdrawal, which only a short-lived signed current-pointer could close. Reads are private by construction: the edition is fetched as one public bundle, the client reports nothing back, and no per-user rows exist for the feature. Whether an account reads, hides, or ignores the briefing is not represented server-side at all.
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 is open: it submits the Ed25519 public key (unique server-side), an X25519 encryption public key with a version integer, a server-issued registration challenge with a proof-of-work nonce, and an optional hardware device attestation. The service holds no email address for anyone.
Registration abuse resistance. With no invite gate, the anti-automation control is a challenge with proof of work and hardware device attestation, verified in-house. The client first requests a challenge: a 62-byte ticket carrying a magic and version (BRC1), the issue time, the attested and unattested difficulty targets, 16 random bytes, and an HMAC-SHA-256 over the preceding bytes under a server key with the domain prefix boopr-reg-challenge-v1:. A ticket is valid for ten minutes and is burned single-use at registration through the fail-closed limiter primitive. The client then searches for a 64-bit nonce such that SHA-256(boopr-pow-v1: || ticket || nonce) has at least the required number of leading zero bits: the attested target when a verified hardware attestation accompanies the request, the unattested target otherwise (18 and 22 bits at launch; operator-tunable, and the client refuses targets above 26). Attestation is verified from the platform root certificates, never through a third-party integrity API: Apple App Attest (certificate chain to Apple's App Attest root, nonce bound to the ticket, relying-party hash of the team and bundle identifiers, counter zero, production or development environment) and Android Keystore key attestation (chain to Google's hardware attestation roots with the published revocation list, challenge bound to the ticket, TEE or StrongBox security level, verified boot with a locked bootloader, and an application identifier matching boopr's signing certificates; a self-signed verified-boot key is accepted only when it appears in an embedded, signature-checked GrapheneOS fingerprint set). Play Integrity is not used, so de-Googled devices attest. A device without a hardware-backed keystore, or an emulator, registers at the unattested difficulty; an operator switch can require attestation outright. Per-address challenge and registration limits, an escalation that raises the difficulty for addresses that have registered repeatedly (evaluated over salted address hashes, never raw addresses), and a registration-surge alarm complete the layer. The attestation object and certificate chain are discarded after verification; only the verdict label and environment are recorded on the account.
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 two message classes under versioned ASCII prefixes: login challenges and the credential attestation that binds the derived MLS signature key to the account (see Direct Messages).
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.
Friend 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>. Invalid, expired, and revoked codes return one generic failure, and suspended or banned status is disclosed only after a valid challenge signature proves key possession. Registration itself is protected by the challenge, proof of work, and device attestation described under identity and authentication above.
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 direct-message history-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.
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, unattested registration, posts, comments, post likes, boops, direct-message writes, the MLS delivery service, message backups, 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. |
| 1.1 | September 2026 | Direct messages rewritten for the public release: MLS (RFC 9420, OpenMLS with the libcrux provider) on the X-Wing hybrid post-quantum ciphersuite replaces the internal Signal Protocol implementation, which never carried production traffic. New: derived and attested MLS signature key, KeyPackage lifecycle, two-phase commits, 24-hour post-compromise rotation, identity-anchored safety numbers, recovery-phrase-keyed message-history backup. Post-quantum posture split by surface. News briefing added to the server-architecture inventory, including the server-side edition-signing exception. Internal-testing caveats for direct messages removed. |