BURNEDSECRET::VAULT v1.0.0
$ NEW $ DASH

// SECURITY MODEL

what we can and cannot see.

Everything on this page describes what the product does today. The narrative is written for anyone who shares a secret; the specification below it is written for anyone building a client.

What "zero-knowledge" actually means here

When you put a secret into burnedsecret, your browser (or your client library) encrypts it on your own device before anything leaves you. The encryption key never goes to our server. We store an opaque blob of encrypted bytes; we cannot read it, and neither can anyone who breaches our database, subpoenas us, or gets a court order against us. We have nothing to hand over.

The key travels separately:

We can technically delete secrets. We cannot technically read them. That distinction is the whole product.

What burnedsecret can and cannot see

Action What our servers see
You create a secret Encrypted bytes. Expiry time. Your account.
Someone burns your secret The fact that a burn happened. Not what was inside.
You create a request Your prompt, public key and webhook URL, plus the client-generated request ID, an opaque wrapped private key, IV/version and token hash.
You open a private view link Retrieval token in an HTTPS header, never the fragment root or wrapping key.
Someone fulfills your request Encrypted bytes. Not the answer. Not your private key.
Webhook fires We send the request ID. We do not send the answer.

What this means for you

How this is different from "encrypted at rest"

Most services say "encrypted at rest" and mean: we hold both the ciphertext and the decryption key. They are trusting themselves to behave. If the database is dumped, the keys go with it.

burnedsecret holds only the ciphertext. The decryption material lives on user devices, never on our servers. A database dump is useless to an attacker.

Crypto envelope specification

This is interoperability version 3 of the envelope formats. It applies to the official JavaScript and Python SDKs and to the web file flow; a client that implements it byte-for-byte interoperates with all of them.

The current SDKs — @burnedsecret/sdk 2.1.0 on npm and burnedsecret 1.2.0 on PyPI — and the web file implementation use BSF2. BSF1 inputs are rejected. There is no compatibility fallback. npm @burnedsecret/sdk 1.x releases predate BSF2 and cannot read or write the current file format.

BSF2 files

All integers are unsigned 32-bit little-endian. The 28-byte header is:

Offset Bytes Value
0 4 ASCII BSF2
4 4 Chunk size, from 1 to 1,048,576
8 4 Positive chunk count
12 16 Random wire salt

Each chunk is ciphertext_length || ciphertext || GCM_tag, with the 16-byte tag included in the length. AES-256-GCM uses the random 32-byte master key. Its IV is HKDF-SHA256(master_key, salt=wire_salt, info=BSF2-iv- || u32le(index), length=12). AAD is the exact 28-byte header followed by u32le(index). Thus count, size, salt, format version and chunk position are authenticated on every chunk.

Nonfinal plaintext chunks have exactly chunk-size bytes; the final chunk has 1..chunk-size bytes. The declared number of chunks must consume the entire blob. Header changes, removed/reordered chunks and extra trailing data fail closed. No decrypted file is returned or written until all validation completes.

The concatenated plaintext starts with one encrypted mode byte:

The tuple is filename_length || filename_UTF8 || mime_length || mime_UTF8 || message_length || message_UTF8 || content. The content must be nonempty and at most 10 MiB; message bytes are limited to 10 KiB. Parsing bounds are checked.

SDK text passphrase envelopes

Ordinary text remains raw UTF-8 inside the outer AES-GCM encryption. Protected text uses marker bytes FF 42 53 50 32 (FF followed by ASCII BSP2), followed by compact {c,i,s} JSON with the same inner passphrase primitives. Byte FF cannot begin valid UTF-8, making marker detection unambiguous for arbitrary ordinary text. Literal PP: and JSON-shaped text are preserved. Old PP: protected test data is no longer recognized as an encrypted text envelope.

This SDK text format does not change the separate browser text-passphrase flow. SDK-protected text requires an SDK reader. File envelopes are shared with web.

Retrieval and vectors

Collect a passphrase before a burn-on-read retrieval. Missing or incorrect passphrase errors happen after retrieval; a second fetch cannot recover a consumed one-view secret. No server-side retry/acknowledgement allowance is added.

/api/v1/test-vectors.json version 3 pins the file and text-envelope bytes. The vectors are produced by the repository's independent generator. Existing base AES/RSA vectors are preserved; only the new envelope vectors are updated.

Portable request view v1

Portable creation is mandatory for every new request, whether it comes from the web app, the SDKs, the CLI or the browser extension, and the private link is the only way to read a reply. Records created before this contract without portable material are unavailable: they are never consumed and there is no conversion. The submission link cannot read the answer; the separate private link /r/{id}/view#s=<base64url S> works signed out and grants one collective read to everyone holding it.

S remains in the fragment and optionally a versioned origin-scoped browser cache with D. K is the portable wrapping key, distinct from the fulfiller's content AES key; T is the retrieval credential. Only T is sent in the HTTPS Authorization header. The server stores the opaque wrapped D and SHA-256(T), never S, K, D or plaintext T.

Cache is optional convenience. Failed writes must leave the private link usable; legacy PKCS8-only cache entries are left untouched and are never used to read a reply. Bare view URLs may use cached S, but explicit malformed or wrong fragments must never fall back to cache. Pasted links must match the configured origin and current request. Evict cache material on known terminal results and clear memory references on disposal; JavaScript heap zeroization is not guaranteed. Losing both the private link and cache prevents recovery. Notifications carry fragment-free URLs only and cannot restore access. Deliberate local copy, QR and mailto actions share through the user's chosen device or mail provider; opening mailto does not prove sending.

Exact bytes

Generate all inputs locally before wrapping: client CSPRNG request_id is canonical unpadded base64url of 16 random bytes (22 characters); independently generate S (32 random bytes), a per-request RSA-4096 keypair, and a fresh 12-byte IV. Never reuse production IDs, S or IVs. All binary wire fields below use canonical unpadded base64url: reject padding, whitespace, invalid alphabet, noncanonical unused bits and wrong decoded lengths by decode/re-encode equality.

U(x) means UTF-8 without BOM/newline/NUL, || means byte concatenation, I = U(request_id) (the exact 22-character encoding), and H = SHA-256(raw SPKI DER) (32 bytes, not a digest of the base64 text).

salt      = U("BurnedSecret/request-view/v1/salt") || 0x00 || I
wrap_info = U("BurnedSecret/request-view/v1/wrap")
read_info = U("BurnedSecret/request-view/v1/retrieve")
K = HKDF-SHA256-extract-and-expand(IKM=S, salt=salt, info=wrap_info, L=32)
T = HKDF-SHA256-extract-and-expand(IKM=S, salt=salt, info=read_info, L=32)
AAD = U("BurnedSecret/request-view/v1/pkcs8") || 0x00 || I || 0x00 || H
wrapped_private_key = AES-256-GCM(K, IV, plaintext=raw PKCS8 DER, AAD=AAD)
retrieval_token_hash = SHA-256(raw T)

GCM uses a 96-bit IV and 128-bit authentication tag appended to ciphertext. K and T are separate 32-byte outputs, not slices of a shared output. S is a root secret, not a direct AES key. Version 1 fixes all labels and algorithms; there is no caller-controlled algorithm, salt, info or AAD negotiation. The fixed ID and hash sizes make binding unambiguous. Changing the request ID changes both K and T; changing SPKI, ciphertext, IV, tag or version must fail before consume.

Envelope validation

The creation pair is request_id plus portable_view; both are required and both must be fully valid. Reject missing or partial pairs, explicit nulls, unsupported versions, malformed encodings and oversize fields before any write. portable_view has exactly these keys, with no extra keys:

Key Exact requirement
version JSON integer 1, excluding bool and non-integer numeric types
wrapped_private_key Canonical unpadded base64url; 17..8192 decoded bytes, including appended tag
iv Canonical unpadded base64url; exactly 12 decoded bytes
retrieval_token_hash Canonical unpadded base64url of SHA-256(raw T); exactly 32 decoded bytes

Enforce encoded-size bounds before decoding and decoded-size bounds afterward. The existing public-key validation still applies. The server cannot validate private-key plaintext or prove that a creator used matching S/key material; the client verifies envelope binding and imports the unwrapped RSA key before consumption. Reject retrieve-axis CAPTCHA on every request creation because the private-link read path cannot enforce it; preserve fulfill-axis CAPTCHA.

Creation and metadata

POST /v1/requests retains existing create authentication, fields and response shape, and every client supplies the complete pair defined above. A create without it is rejected with 400 INVALID_INPUT before any write. The server uses a create-only write at the validated client request_id; an existing ID returns 409 conflict without overwrite. Never retrofit portable material onto an existing record.

Public metadata adds only portable_view_available: boolean for this feature; it never returns the portable envelope, verifier, T or fulfillment. Existing submission metadata and restrictions remain intact. Public metadata is not a substitute for authorized view status.

Status and consume

GET /v1/requests/{id}/view
Authorization: Bearer <canonical unpadded base64url(T)>
200 response: {
  status: "waiting" | "ready",
  expires_at,
  public_key,
  portable_view: { version: 1, wrapped_private_key, iv }
}

POST /v1/requests/{id}/view/consume
Authorization: Bearer <canonical unpadded base64url(T)>
body: empty (zero bytes)
200 response: existing fulfillment response shape
  { id, ciphertext, iv, wrapped_key, fulfilled_at }
  # Preserve existing fulfillment variants and optional fields as applicable.

T must decode canonically to exactly 32 bytes. For live records, hash the raw bytes and compare to the stored digest with constant-time comparison. The hash itself is never a credential. Do not accept account bearer fallback on these routes, a query/body token, or a redirect that forwards the bearer. Apply bounded IP rate limits separately from account-key quotas. TLS is mandatory.

Status is non-consuming and contains no fulfillment; it returns the stored SPKI and wrapped-key envelope for local binding verification. Polling and answer arrival never burn. The client unwraps and imports D locally before explicit Reveal sends POST, suppresses duplicate activation, and never retries consume automatically. Waiting polls every 10 seconds only while visible; reveal, disposal and terminal outcomes cancel polling.

Condition on portable routes HTTP / code
Missing or malformed bearer 401 PORTABLE_UNAVAILABLE
Invalid live bearer; missing, deleted or expired record 404 PORTABLE_UNAVAILABLE
Authorized waiting consume 409 REQUEST_NOT_READY
Valid live credential failing applicable retrieve restrictions 403 ACCESS_DENIED
Unexpired consumed tombstone with any syntactically valid 32-byte bearer 410 FULFILLMENT_BURNED

Use generic error bodies without echoed inputs or credential material. The 410 body contains only the generic terminal error, no payload, prompt, public key, owner, timestamps, envelope, restrictions or verifier. Every portable response, including errors and terminal results, sets Cache-Control: no-store and Pragma: no-cache; edge/CDN/service-worker behavior must preserve these bounds. Status never returns retrieval_token_hash.

Atomic consumption and lifecycle

The authenticated owner fulfillment read route is retired. It returns a generic 404 before any authentication, rate-limit or database work, and it never mutates or burns a record. Account credentials (API keys and sign-in sessions) create requests but never read replies. The portable POST is the only read, and it runs in the shared consume transaction described here.

For live records, every transaction attempt reads the current record and checks current capability authorization, expiry, applicable retrieval IP/geo restrictions and fulfilled/unread state before returning an answer. Recheck after transaction retries. Invalid live credentials never mutate or burn. Capture the existing fulfillment response, atomically erase fulfillment and the entire portable_view, and return only after commit. Concurrent portable consumes have at most one winner, and retired-route attempts are denied without touching the record. Keep network, email and audit side effects outside the retryable callback.

For consumed portable records, replace the document with a minimal tombstone containing only api_shape, user_id, status=fulfilled, expires_at, fulfillment_burned_at and portable_view_consumed=true. Retain neither the wrapped private key, IV, version, token hash, public key, prompt, notification addresses, restrictions nor any second verifier. The retained user_id attributes audit events and cleanup to the owner; it grants no read access.

After verifier deletion the server cannot authenticate a later T. The generic 410 for a syntactically valid bearer reports terminal existence, not authenticated identity. This limited disclosure until original expiry is an explicit tradeoff; malformed/missing bearer still gets 401. Expired tombstones return generic 404 unavailable, even before scheduled deletion.

Enforce expiry on reads immediately. Delete expired v1 records transactionally, rechecking current expiry so cleanup cannot race a fulfill/consume into resurrection; delete tombstones at original expiry too. Whole-request deletion removes the whole document. Keep legacy cleanup behavior separate. Scheduled physical erasure is eventual, not an exact-time SLA; deployed backups/retention require separate verification. No second-read allowance or recovery cache.

Fulfillment consumption semantics (confirmed 2026-09-12)

Request fulfillments are strictly burn-on-read, including partner retrieval. The server deletes the envelope in the retrieval transaction before returning it; this is not confirmation of delivery, decryption, or durable storage by the recipient. A lost HTTP response or a recipient crash can therefore lose the fulfillment permanently. Partners must surface that failure and request new credentials; they must not promise recoverability or blindly retry a consumed fulfillment. No second-read allowance or acknowledgement mode is part of this contract. This decision concerns request fulfillments, not separately supported multi-view secret options.

Threat model and leakage boundaries

S theft grants retrieval and decryption capability. T theft can consume/burn the ciphertext but does not provide S, K or D and cannot decrypt it. Knowing the submission URL, request ID, public key or stored hash grants no live read/burn access. Client unwrapping protects against accidental loss; server authorization remains mandatory and must not trust that the client performed it.

Never send S/K/D, full private URLs or answers to logging, tracing, analytics, error reports or server-generated notifications. Redact T and Authorization headers at every layer; do not dump requests or credential-bearing exceptions. Browser fragments are readable by local scripts/extensions and deliberate share actions, so fragment placement alone is not leak prevention. Stored verifiers and envelopes are also excluded from diagnostics and notification payloads.

The accepted construction and this threat model are not an independent security audit. Required evidence includes no-mutation invalid-token tests, wrong wrapping key failure before consume, signed-out retrieval, real concurrent one-winner transactions (portable/portable races plus retired-route denial), expiry/restriction enforcement, safe unavailability of records without portable material, independent vectors and log/notification leak tests. High/critical security findings block acceptance.

Portable vectors at /api/v1/test-vectors.json contain fixed request ID, S, SPKI/PKCS8 fixtures, IV, salt/info/AAD bytes, K, T, SHA-256(T) and appended-tag ciphertext. They are public test material, never production keys. Browser, cross-device, real concurrency and deployed operational acceptance remain pending after the Codemagic build; these are not claims of completed acceptance.

Build your own SDK

Start from the specification above and the interop vectors at /api/v1/test-vectors.json. The vector file pins the algorithms (AES-256-GCM, RSA-OAEP-SHA256 with 4096-bit keys, PBKDF2-SHA256 at 600,000 iterations, base64url without padding) alongside known inputs and their expected outputs.

A conformant client reproduces every vector byte-for-byte. If your implementation round-trips the file and text envelopes and the base AES and RSA vectors, it interoperates with the official SDKs and the web app.

The REST contract is documented at /docs/api and the raw spec is served at /api/v1/openapi.json. The official JavaScript and Python SDKs are open reference implementations you can read alongside the spec.