Skip to main content

burnedsecret — Official Python SDK for burnedsecret.com

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.

This package is the official Python client. All crypto runs in your process — the server only sees ciphertext, IVs, and (for the request flow) RSA-OAEP wrapped keys. The decryption material lives in your code (URL fragments, or PKCS8 bytes you stash in a KMS), never on our servers.

Installation

pip install burnedsecret

Python 3.10 or newer is required. The SDK pulls in cryptography>=42 and requests>=2.32 and nothing else.

Quick start

One-way secret (you share a password with someone)

from burnedsecret import BurnedSecret

bs = BurnedSecret(api_key="bs_...")

url, secret_id = bs.create_secret("hunter2", ttl=86400)
# url looks like: https://burnedsecret.com/s/<id>#k=<base64url AES key>
# the part after '#' never leaves the caller's process — browsers do not
# transmit URL fragments to servers, and this SDK only sends ciphertext + IV.

plaintext = bs.read_secret(url)   # burns the secret on read

Secret request (you ask someone to send you a password)

url, request_id, private_key = bs.create_request(
    prompt="Send me your AWS root key",
    webhook_url="https://acme.example/hooks/bs",
    ttl=86400,
)
# Persist `private_key` (PKCS8 DER bytes) — without it, the fulfillment is
# unrecoverable. Use bs.export_private_key(private_key) → str for KMS storage.

# Later, after the fulfiller has submitted their answer:
plaintext = bs.read_fulfillment(request_id, private_key)

When the request was created with custom fields, read_fulfillment returns a dict mapping field name → value instead of a string:

url, request_id, private_key = bs.create_request(
    prompt="Send me your AWS keypair",
    fields=[
        {"name": "access_key_id", "type": "text", "required": True},
        {"name": "secret_access_key", "type": "password", "required": True},
    ],
)
data = bs.read_fulfillment(request_id, private_key)
# data == {"access_key_id": "AKIA...", "secret_access_key": "wJal..."}

Storing private keys in your KMS

# At request-creation time:
url, request_id, priv = bs.create_request(prompt="...")
kms_blob = BurnedSecret.export_private_key(priv)   # str (base64url no-padding)
my_kms.put(f"bs/req/{request_id}", kms_blob)

# At fulfillment-retrieval time:
kms_blob = my_kms.get(f"bs/req/{request_id}")
priv = BurnedSecret.import_private_key(kms_blob)
plaintext = bs.read_fulfillment(request_id, priv)

Verifying webhooks

When a request is fulfilled (or a secret is burned) burnedsecret POSTs a flat JSON event to your webhook_url, signed with HMAC-SHA256 over the exact body bytes. Fetch your signing secret once from the dashboard API (GET /v1/keys/webhook-secret, Firebase-session auth) and keep it server-side.

from flask import Flask, request
from burnedsecret import BurnedSecret, WebhookSignatureError, verify_webhook

app = Flask(__name__)
bs = BurnedSecret(api_key="bs_...")

@app.post("/webhooks/burnedsecret")
def burnedsecret_hook():
    try:
        event = verify_webhook(
            request.get_data(),                    # RAW bytes — never re-serialised JSON
            request.headers.get("X-Signature"),    # "sha256=<hex>"
            WEBHOOK_SIGNING_SECRET,
        )
    except WebhookSignatureError:
        return "", 401
    # X-Webhook-ID is unique per delivery: store it and ignore repeats (idempotency).
    if event["event"] == "request.fulfilled":
        priv = BurnedSecret.import_private_key(my_kms.get(f"bs/req/{event['request_id']}"))
        data = bs.read_fulfillment(event["request_id"], priv)   # burn-on-read: exactly once
    return "", 204

Payloads never contain plaintext, keys, or URLs with key fragments:

{"event": "request.fulfilled", "request_id": "...", "fulfilled_at": "2026-09-14T10:00:00Z"}
{"event": "secret.burned",     "secret_id": "...",  "burned_at":    "2026-09-14T10:00:00Z"}

sign_webhook_body(body, secret) produces the same header the server sends, for building fixtures in your own tests. Deliveries are dispatched by a scheduled processor and retried with backoff, so expect minutes rather than seconds between fulfilment and delivery.

Public-key handling

Your decryption material never goes to our servers. For requests, the SDK generates an RSA-4096 keypair locally and only sends the public key (SPKI DER, base64url). The private key is returned to you as PKCS8 DER bytes — persist it in your KMS or secret store. If you lose it, the fulfillment is unrecoverable. That's the whole point.

The SDK never derives or stores key material outside of the values you receive from create_secret (which returns the URL containing the key in its fragment) and create_request (which returns the private key bytes). Anything your process needs to keep, the SDK hands you and forgets.

Error handling

The SDK raises a small exception hierarchy:

BurnedSecretError
├── ApiError                  (HTTP-level failure; has .status_code, .code)
│   ├── NotFoundError         404 — secret/request not found
│   ├── BurnedError           410 — secret or fulfillment already consumed
│   ├── LegacyApiError        410 — pre-Phase-21 document, not API-accessible
│   └── RateLimitError        429 — has .retry_after_seconds
├── CryptoError               local AES/RSA failure (bad key, malformed data)
├── PassphraseRequiredError   protected secret read without a passphrase
└── WebhookSignatureError     incoming webhook failed HMAC verification (respond 401)
from burnedsecret import (
    BurnedSecret, BurnedError, NotFoundError, RateLimitError, CryptoError,
)

bs = BurnedSecret(api_key="bs_...")
try:
    plaintext = bs.read_secret(url)
except BurnedError:
    print("Secret has already been viewed and burned.")
except NotFoundError:
    print("Secret never existed or expired.")
except RateLimitError as e:
    print(f"Too many requests — retry after {e.retry_after_seconds}s")
except CryptoError:
    print("The URL fragment is wrong or the ciphertext is corrupted.")

BurnedSecret(api_key="...") itself raises ValueError when the API key is missing or does not start with bs_.

Test vectors and interop

The wire format — AES-256-GCM with a 12-byte IV and a 128-bit appended tag, RSA-OAEP-SHA256 over a 4096-bit modulus, SPKI/PKCS8 DER with base64url no padding — is pinned at https://burnedsecret.com/api/v1/test-vectors.json.

Any third-party Python implementation can prove conformance by round-tripping that file. The same file lives in this repo at web/api/v1/test-vectors.json and powers tests/test_interop.py, which asserts four invariants:

  1. AES encrypt with secret.aes_key + iv + plaintext is byte-identical to secret.ciphertext.
  2. AES decrypt of secret.ciphertext recovers secret.plaintext_utf8.
  3. RSA-OAEP decrypt of request.wrapped_key with request.private_key_pkcs8 recovers request.content_aes_key.
  4. AES encrypt with the unwrapped key reproduces request.ciphertext.

If your SDK round-trips all four, it is wire-compatible with this one and with the official JavaScript and Flutter clients.

Crypto specification

Full algorithm parameters, key formats, and design rationale: .planning/design/zero-knowledge-architecture.md in this repository.

Development

git clone https://github.com/JensrudJ/burnedsecret
cd burnedsecret/sdk-python
pip install -e ".[dev]"
pytest -v

The test suite has 18 cases: 4 interop conformance + 4 crypto round-trips + 6 client wire-contract + 4 error-mapping. The RSA-4096 keygen test is marked @pytest.mark.slow and runs in about three seconds; run pytest -v -m "not slow" to skip it during fast inner-loop iteration.

Releases

Releases are published from Codemagic on tags pushed to the burnedsecret repo:

  • @burnedsecret/sdk (npm): tag matching sdk-js-vMAJOR.MINOR.PATCH
  • burnedsecret (PyPI): tag matching sdk-py-vMAJOR.MINOR.PATCH

The version in the tag must match the version in package.json / pyproject.toml.

Every push to dev runs the test suite and the cross-SDK round-trip gate (D-15) but does NOT publish.

Each SDK release workflow also runs cross-SDK round trips at its own checkout before publishing. Built packages must pass clean consumer installation checks. A release tag must exactly match its package version; a tagged release fails if its publishing credential is missing. Builds and publication run in Codemagic.

License

MIT — see LICENSE in the repository root.

Authenticated envelope formats

File secrets now use BSF2, which authenticates the complete header and each chunk's position. Old BSF1 files are rejected; there is no legacy fallback. This is an intentional format break for previously disposable test data. Python 1.1.0, JavaScript 2.0.0 and the updated web file viewer use this format; JavaScript 1.2.0 does not support it.

Unprotected text remains raw UTF-8, including literal PP: strings. Protected SDK text uses a binary BSP2 marker that cannot collide with UTF-8 plaintext. Collect a passphrase before fetching a protected secret: a missing/wrong passphrase discovered after burn-on-read cannot be recovered by refetching. See the envelope specification for framing and interop vector version 3. Browser text protection is a separate flow; SDK passphrase-protected text still requires an SDK reader.

Metadata

Release files for burnedsecret 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for burnedsecret 1.2.0
File Size Uploaded
burnedsecret-1.2.0.tar.gz 29.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for burnedsecret 1.2.0
File Interpreter ABI Platform
burnedsecret-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 51.8 kB

Release files / burnedsecret-1.2.0.tar.gz

Download URL burnedsecret-1.2.0.tar.gz
Size 29.8 kB
Tags Source
SHA-256 checksum
How to use checksums
a13f8facc328da5946ee55c48cc37acdeb0b52e68105cb83fd839df960bf736a
BLAKE2b-256 checksum
How to use checksums
ab6af90bc1217b3c094699d5b5fcb0980dfb5623d5338a2d7d7dbb41fcf6824c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.5

Release files / burnedsecret-1.2.0-py3-none-any.whl

Download URL burnedsecret-1.2.0-py3-none-any.whl
Size 22.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
eb68e8c56d6092f8595217e81b98b630ea13e85dbe31de31ea9d94c5c6383d41
BLAKE2b-256 checksum
How to use checksums
da92199de603774524955d5358ce4e881b5e050b45b48a84f8bac1ddc0410e2d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.5

Release history Release notifications | RSS feed

This release

1.2.0 This release

2 release files

1.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page