burnedsecret 1.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .pytest_cache/
7
+ .venv/
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.5
2
+ Name: burnedsecret
3
+ Version: 1.1.0
4
+ Summary: Official zero-knowledge SDK for burnedsecret.com
5
+ Project-URL: Homepage, https://burnedsecret.com
6
+ Project-URL: Documentation, https://burnedsecret.com/docs
7
+ Project-URL: Repository, https://github.com/JensrudJ/burnedsecret
8
+ Author: burnedsecret
9
+ License-Expression: MIT
10
+ Requires-Python: >=3.10
11
+ Requires-Dist: cryptography>=42
12
+ Requires-Dist: requests>=2.32
13
+ Provides-Extra: dev
14
+ Requires-Dist: build>=1.2; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: responses>=0.25; extra == 'dev'
17
+ Requires-Dist: twine>=5.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # burnedsecret — Official Python SDK for burnedsecret.com
21
+
22
+ When you put a secret into burnedsecret, your browser (or your client library)
23
+ encrypts it on your own device before anything leaves you. The encryption key
24
+ never goes to our server. We store an opaque blob of encrypted bytes; we cannot
25
+ read it, and neither can anyone who breaches our database, subpoenas us, or
26
+ gets a court order against us. We have nothing to hand over.
27
+
28
+ This package is the official Python client. All crypto runs in your process —
29
+ the server only sees ciphertext, IVs, and (for the request flow) RSA-OAEP
30
+ wrapped keys. The decryption material lives in your code (URL fragments, or
31
+ PKCS8 bytes you stash in a KMS), never on our servers.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install burnedsecret
37
+ ```
38
+
39
+ Python 3.10 or newer is required. The SDK pulls in `cryptography>=42` and
40
+ `requests>=2.32` and nothing else.
41
+
42
+ ## Quick start
43
+
44
+ ### One-way secret (you share a password with someone)
45
+
46
+ ```python
47
+ from burnedsecret import BurnedSecret
48
+
49
+ bs = BurnedSecret(api_key="bs_...")
50
+
51
+ url, secret_id = bs.create_secret("hunter2", ttl=86400)
52
+ # url looks like: https://burnedsecret.com/s/<id>#k=<base64url AES key>
53
+ # the part after '#' never leaves the caller's process — browsers do not
54
+ # transmit URL fragments to servers, and this SDK only sends ciphertext + IV.
55
+
56
+ plaintext = bs.read_secret(url) # burns the secret on read
57
+ ```
58
+
59
+ ### Secret request (you ask someone to send you a password)
60
+
61
+ ```python
62
+ url, request_id, private_key = bs.create_request(
63
+ prompt="Send me your AWS root key",
64
+ webhook_url="https://acme.example/hooks/bs",
65
+ ttl=86400,
66
+ )
67
+ # Persist `private_key` (PKCS8 DER bytes) — without it, the fulfillment is
68
+ # unrecoverable. Use bs.export_private_key(private_key) → str for KMS storage.
69
+
70
+ # Later, after the fulfiller has submitted their answer:
71
+ plaintext = bs.read_fulfillment(request_id, private_key)
72
+ ```
73
+
74
+ When the request was created with custom fields, `read_fulfillment` returns a
75
+ `dict` mapping field name → value instead of a string:
76
+
77
+ ```python
78
+ url, request_id, private_key = bs.create_request(
79
+ prompt="Send me your AWS keypair",
80
+ fields=[
81
+ {"name": "access_key_id", "type": "text", "required": True},
82
+ {"name": "secret_access_key", "type": "password", "required": True},
83
+ ],
84
+ )
85
+ data = bs.read_fulfillment(request_id, private_key)
86
+ # data == {"access_key_id": "AKIA...", "secret_access_key": "wJal..."}
87
+ ```
88
+
89
+ ### Storing private keys in your KMS
90
+
91
+ ```python
92
+ # At request-creation time:
93
+ url, request_id, priv = bs.create_request(prompt="...")
94
+ kms_blob = BurnedSecret.export_private_key(priv) # str (base64url no-padding)
95
+ my_kms.put(f"bs/req/{request_id}", kms_blob)
96
+
97
+ # At fulfillment-retrieval time:
98
+ kms_blob = my_kms.get(f"bs/req/{request_id}")
99
+ priv = BurnedSecret.import_private_key(kms_blob)
100
+ plaintext = bs.read_fulfillment(request_id, priv)
101
+ ```
102
+
103
+ ## Public-key handling
104
+
105
+ Your decryption material never goes to our servers. For requests, the SDK
106
+ generates an RSA-4096 keypair locally and only sends the public key (SPKI DER,
107
+ base64url). The private key is returned to you as PKCS8 DER bytes — persist it
108
+ in your KMS or secret store. If you lose it, the fulfillment is unrecoverable.
109
+ That's the whole point.
110
+
111
+ The SDK never derives or stores key material outside of the values you receive
112
+ from `create_secret` (which returns the URL containing the key in its fragment)
113
+ and `create_request` (which returns the private key bytes). Anything your
114
+ process needs to keep, the SDK hands you and forgets.
115
+
116
+ ## Error handling
117
+
118
+ The SDK raises a small exception hierarchy:
119
+
120
+ ```
121
+ BurnedSecretError
122
+ ├── ApiError (HTTP-level failure; has .status_code, .code)
123
+ │ ├── NotFoundError 404 — secret/request not found
124
+ │ ├── BurnedError 410 — secret or fulfillment already consumed
125
+ │ ├── LegacyApiError 410 — pre-Phase-21 document, not API-accessible
126
+ │ └── RateLimitError 429 — has .retry_after_seconds
127
+ └── CryptoError local AES/RSA failure (bad key, malformed data)
128
+ ```
129
+
130
+ ```python
131
+ from burnedsecret import (
132
+ BurnedSecret, BurnedError, NotFoundError, RateLimitError, CryptoError,
133
+ )
134
+
135
+ bs = BurnedSecret(api_key="bs_...")
136
+ try:
137
+ plaintext = bs.read_secret(url)
138
+ except BurnedError:
139
+ print("Secret has already been viewed and burned.")
140
+ except NotFoundError:
141
+ print("Secret never existed or expired.")
142
+ except RateLimitError as e:
143
+ print(f"Too many requests — retry after {e.retry_after_seconds}s")
144
+ except CryptoError:
145
+ print("The URL fragment is wrong or the ciphertext is corrupted.")
146
+ ```
147
+
148
+ `BurnedSecret(api_key="...")` itself raises `ValueError` when the API key is
149
+ missing or does not start with `bs_`.
150
+
151
+ ## Test vectors and interop
152
+
153
+ The wire format — AES-256-GCM with a 12-byte IV and a 128-bit appended tag,
154
+ RSA-OAEP-SHA256 over a 4096-bit modulus, SPKI/PKCS8 DER with base64url no
155
+ padding — is pinned at `https://burnedsecret.com/api/v1/test-vectors.json`.
156
+
157
+ Any third-party Python implementation can prove conformance by round-tripping
158
+ that file. The same file lives in this repo at `web/api/v1/test-vectors.json`
159
+ and powers `tests/test_interop.py`, which asserts four invariants:
160
+
161
+ 1. AES encrypt with `secret.aes_key + iv + plaintext` is byte-identical to
162
+ `secret.ciphertext`.
163
+ 2. AES decrypt of `secret.ciphertext` recovers `secret.plaintext_utf8`.
164
+ 3. RSA-OAEP decrypt of `request.wrapped_key` with `request.private_key_pkcs8`
165
+ recovers `request.content_aes_key`.
166
+ 4. AES encrypt with the unwrapped key reproduces `request.ciphertext`.
167
+
168
+ If your SDK round-trips all four, it is wire-compatible with this one and with
169
+ the official JavaScript and Flutter clients.
170
+
171
+ ## Crypto specification
172
+
173
+ Full algorithm parameters, key formats, and design rationale:
174
+ `.planning/design/zero-knowledge-architecture.md` in this repository.
175
+
176
+ ## Development
177
+
178
+ ```bash
179
+ git clone https://github.com/JensrudJ/burnedsecret
180
+ cd burnedsecret/sdk-python
181
+ pip install -e ".[dev]"
182
+ pytest -v
183
+ ```
184
+
185
+ The test suite has 18 cases: 4 interop conformance + 4 crypto round-trips +
186
+ 6 client wire-contract + 4 error-mapping. The RSA-4096 keygen test is marked
187
+ `@pytest.mark.slow` and runs in about three seconds; run `pytest -v -m "not slow"`
188
+ to skip it during fast inner-loop iteration.
189
+
190
+ ## Releases
191
+
192
+ Releases are published from Codemagic on tags pushed to the burnedsecret repo:
193
+ - `@burnedsecret/sdk` (npm): tag matching `sdk-js-vMAJOR.MINOR.PATCH`
194
+ - `burnedsecret` (PyPI): tag matching `sdk-py-vMAJOR.MINOR.PATCH`
195
+
196
+ The version in the tag must match the version in `package.json` / `pyproject.toml`.
197
+
198
+ Every push to `dev` runs the test suite and the cross-SDK round-trip gate (D-15) but does NOT publish.
199
+
200
+ Each SDK release workflow also runs cross-SDK round trips at its own checkout
201
+ before publishing. Built packages must pass clean consumer installation checks.
202
+ A release tag must exactly match its package version; a tagged release fails
203
+ if its publishing credential is missing. Builds and publication run in Codemagic.
204
+
205
+ ## License
206
+
207
+ MIT — see `LICENSE` in the repository root.
208
+
209
+
210
+ ### Authenticated envelope formats
211
+
212
+ File secrets now use **BSF2**, which authenticates the complete header and each
213
+ chunk's position. Old BSF1 files are rejected; there is no legacy fallback.
214
+ This is an intentional format break for previously disposable test data.
215
+ Python 1.1.0, JavaScript 2.0.0 and the updated web file viewer use this format;
216
+ JavaScript 1.2.0 does not support it.
217
+
218
+ Unprotected text remains raw UTF-8, including literal `PP:` strings. Protected
219
+ SDK text uses a binary BSP2 marker that cannot collide with UTF-8 plaintext.
220
+ Collect a passphrase before fetching a protected secret: a missing/wrong
221
+ passphrase discovered after burn-on-read cannot be recovered by refetching.
222
+ See [the envelope specification](../docs/crypto-envelopes.md) for framing and
223
+ interop vector version 3. Browser text protection is a separate flow; SDK
224
+ passphrase-protected text still requires an SDK reader.
@@ -0,0 +1,205 @@
1
+ # burnedsecret — Official Python SDK for burnedsecret.com
2
+
3
+ When you put a secret into burnedsecret, your browser (or your client library)
4
+ encrypts it on your own device before anything leaves you. The encryption key
5
+ never goes to our server. We store an opaque blob of encrypted bytes; we cannot
6
+ read it, and neither can anyone who breaches our database, subpoenas us, or
7
+ gets a court order against us. We have nothing to hand over.
8
+
9
+ This package is the official Python client. All crypto runs in your process —
10
+ the server only sees ciphertext, IVs, and (for the request flow) RSA-OAEP
11
+ wrapped keys. The decryption material lives in your code (URL fragments, or
12
+ PKCS8 bytes you stash in a KMS), never on our servers.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install burnedsecret
18
+ ```
19
+
20
+ Python 3.10 or newer is required. The SDK pulls in `cryptography>=42` and
21
+ `requests>=2.32` and nothing else.
22
+
23
+ ## Quick start
24
+
25
+ ### One-way secret (you share a password with someone)
26
+
27
+ ```python
28
+ from burnedsecret import BurnedSecret
29
+
30
+ bs = BurnedSecret(api_key="bs_...")
31
+
32
+ url, secret_id = bs.create_secret("hunter2", ttl=86400)
33
+ # url looks like: https://burnedsecret.com/s/<id>#k=<base64url AES key>
34
+ # the part after '#' never leaves the caller's process — browsers do not
35
+ # transmit URL fragments to servers, and this SDK only sends ciphertext + IV.
36
+
37
+ plaintext = bs.read_secret(url) # burns the secret on read
38
+ ```
39
+
40
+ ### Secret request (you ask someone to send you a password)
41
+
42
+ ```python
43
+ url, request_id, private_key = bs.create_request(
44
+ prompt="Send me your AWS root key",
45
+ webhook_url="https://acme.example/hooks/bs",
46
+ ttl=86400,
47
+ )
48
+ # Persist `private_key` (PKCS8 DER bytes) — without it, the fulfillment is
49
+ # unrecoverable. Use bs.export_private_key(private_key) → str for KMS storage.
50
+
51
+ # Later, after the fulfiller has submitted their answer:
52
+ plaintext = bs.read_fulfillment(request_id, private_key)
53
+ ```
54
+
55
+ When the request was created with custom fields, `read_fulfillment` returns a
56
+ `dict` mapping field name → value instead of a string:
57
+
58
+ ```python
59
+ url, request_id, private_key = bs.create_request(
60
+ prompt="Send me your AWS keypair",
61
+ fields=[
62
+ {"name": "access_key_id", "type": "text", "required": True},
63
+ {"name": "secret_access_key", "type": "password", "required": True},
64
+ ],
65
+ )
66
+ data = bs.read_fulfillment(request_id, private_key)
67
+ # data == {"access_key_id": "AKIA...", "secret_access_key": "wJal..."}
68
+ ```
69
+
70
+ ### Storing private keys in your KMS
71
+
72
+ ```python
73
+ # At request-creation time:
74
+ url, request_id, priv = bs.create_request(prompt="...")
75
+ kms_blob = BurnedSecret.export_private_key(priv) # str (base64url no-padding)
76
+ my_kms.put(f"bs/req/{request_id}", kms_blob)
77
+
78
+ # At fulfillment-retrieval time:
79
+ kms_blob = my_kms.get(f"bs/req/{request_id}")
80
+ priv = BurnedSecret.import_private_key(kms_blob)
81
+ plaintext = bs.read_fulfillment(request_id, priv)
82
+ ```
83
+
84
+ ## Public-key handling
85
+
86
+ Your decryption material never goes to our servers. For requests, the SDK
87
+ generates an RSA-4096 keypair locally and only sends the public key (SPKI DER,
88
+ base64url). The private key is returned to you as PKCS8 DER bytes — persist it
89
+ in your KMS or secret store. If you lose it, the fulfillment is unrecoverable.
90
+ That's the whole point.
91
+
92
+ The SDK never derives or stores key material outside of the values you receive
93
+ from `create_secret` (which returns the URL containing the key in its fragment)
94
+ and `create_request` (which returns the private key bytes). Anything your
95
+ process needs to keep, the SDK hands you and forgets.
96
+
97
+ ## Error handling
98
+
99
+ The SDK raises a small exception hierarchy:
100
+
101
+ ```
102
+ BurnedSecretError
103
+ ├── ApiError (HTTP-level failure; has .status_code, .code)
104
+ │ ├── NotFoundError 404 — secret/request not found
105
+ │ ├── BurnedError 410 — secret or fulfillment already consumed
106
+ │ ├── LegacyApiError 410 — pre-Phase-21 document, not API-accessible
107
+ │ └── RateLimitError 429 — has .retry_after_seconds
108
+ └── CryptoError local AES/RSA failure (bad key, malformed data)
109
+ ```
110
+
111
+ ```python
112
+ from burnedsecret import (
113
+ BurnedSecret, BurnedError, NotFoundError, RateLimitError, CryptoError,
114
+ )
115
+
116
+ bs = BurnedSecret(api_key="bs_...")
117
+ try:
118
+ plaintext = bs.read_secret(url)
119
+ except BurnedError:
120
+ print("Secret has already been viewed and burned.")
121
+ except NotFoundError:
122
+ print("Secret never existed or expired.")
123
+ except RateLimitError as e:
124
+ print(f"Too many requests — retry after {e.retry_after_seconds}s")
125
+ except CryptoError:
126
+ print("The URL fragment is wrong or the ciphertext is corrupted.")
127
+ ```
128
+
129
+ `BurnedSecret(api_key="...")` itself raises `ValueError` when the API key is
130
+ missing or does not start with `bs_`.
131
+
132
+ ## Test vectors and interop
133
+
134
+ The wire format — AES-256-GCM with a 12-byte IV and a 128-bit appended tag,
135
+ RSA-OAEP-SHA256 over a 4096-bit modulus, SPKI/PKCS8 DER with base64url no
136
+ padding — is pinned at `https://burnedsecret.com/api/v1/test-vectors.json`.
137
+
138
+ Any third-party Python implementation can prove conformance by round-tripping
139
+ that file. The same file lives in this repo at `web/api/v1/test-vectors.json`
140
+ and powers `tests/test_interop.py`, which asserts four invariants:
141
+
142
+ 1. AES encrypt with `secret.aes_key + iv + plaintext` is byte-identical to
143
+ `secret.ciphertext`.
144
+ 2. AES decrypt of `secret.ciphertext` recovers `secret.plaintext_utf8`.
145
+ 3. RSA-OAEP decrypt of `request.wrapped_key` with `request.private_key_pkcs8`
146
+ recovers `request.content_aes_key`.
147
+ 4. AES encrypt with the unwrapped key reproduces `request.ciphertext`.
148
+
149
+ If your SDK round-trips all four, it is wire-compatible with this one and with
150
+ the official JavaScript and Flutter clients.
151
+
152
+ ## Crypto specification
153
+
154
+ Full algorithm parameters, key formats, and design rationale:
155
+ `.planning/design/zero-knowledge-architecture.md` in this repository.
156
+
157
+ ## Development
158
+
159
+ ```bash
160
+ git clone https://github.com/JensrudJ/burnedsecret
161
+ cd burnedsecret/sdk-python
162
+ pip install -e ".[dev]"
163
+ pytest -v
164
+ ```
165
+
166
+ The test suite has 18 cases: 4 interop conformance + 4 crypto round-trips +
167
+ 6 client wire-contract + 4 error-mapping. The RSA-4096 keygen test is marked
168
+ `@pytest.mark.slow` and runs in about three seconds; run `pytest -v -m "not slow"`
169
+ to skip it during fast inner-loop iteration.
170
+
171
+ ## Releases
172
+
173
+ Releases are published from Codemagic on tags pushed to the burnedsecret repo:
174
+ - `@burnedsecret/sdk` (npm): tag matching `sdk-js-vMAJOR.MINOR.PATCH`
175
+ - `burnedsecret` (PyPI): tag matching `sdk-py-vMAJOR.MINOR.PATCH`
176
+
177
+ The version in the tag must match the version in `package.json` / `pyproject.toml`.
178
+
179
+ Every push to `dev` runs the test suite and the cross-SDK round-trip gate (D-15) but does NOT publish.
180
+
181
+ Each SDK release workflow also runs cross-SDK round trips at its own checkout
182
+ before publishing. Built packages must pass clean consumer installation checks.
183
+ A release tag must exactly match its package version; a tagged release fails
184
+ if its publishing credential is missing. Builds and publication run in Codemagic.
185
+
186
+ ## License
187
+
188
+ MIT — see `LICENSE` in the repository root.
189
+
190
+
191
+ ### Authenticated envelope formats
192
+
193
+ File secrets now use **BSF2**, which authenticates the complete header and each
194
+ chunk's position. Old BSF1 files are rejected; there is no legacy fallback.
195
+ This is an intentional format break for previously disposable test data.
196
+ Python 1.1.0, JavaScript 2.0.0 and the updated web file viewer use this format;
197
+ JavaScript 1.2.0 does not support it.
198
+
199
+ Unprotected text remains raw UTF-8, including literal `PP:` strings. Protected
200
+ SDK text uses a binary BSP2 marker that cannot collide with UTF-8 plaintext.
201
+ Collect a passphrase before fetching a protected secret: a missing/wrong
202
+ passphrase discovered after burn-on-read cannot be recovered by refetching.
203
+ See [the envelope specification](../docs/crypto-envelopes.md) for framing and
204
+ interop vector version 3. Browser text protection is a separate flow; SDK
205
+ passphrase-protected text still requires an SDK reader.
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "burnedsecret"
3
+ version = "1.1.0"
4
+ description = "Official zero-knowledge SDK for burnedsecret.com"
5
+ readme = "README.md"
6
+ authors = [{ name = "burnedsecret" }]
7
+ license = "MIT"
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "cryptography>=42",
11
+ "requests>=2.32",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8",
17
+ "responses>=0.25",
18
+ "build>=1.2",
19
+ "twine>=5.0",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://burnedsecret.com"
24
+ Documentation = "https://burnedsecret.com/docs"
25
+ Repository = "https://github.com/JensrudJ/burnedsecret"
26
+
27
+ [build-system]
28
+ requires = ["hatchling"]
29
+ build-backend = "hatchling.build"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/burnedsecret"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ pythonpath = ["src"]
37
+ markers = [
38
+ "slow: tests that take more than a couple seconds (RSA-4096 keygen, etc.)",
39
+ ]
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """Python-side decrypter for the cross-SDK round-trip (Phase 26 capstone).
3
+
4
+ Reads the JS-produced envelope JSON from argv[1] (or stdin), decrypts EVERY
5
+ interop shape, asserts byte-exact recovery, and exits non-zero on any mismatch.
6
+ Prints a literal PASS line that Codemagic greps as a gate predicate.
7
+
8
+ Usage: python sdk-python/scripts/decrypt_from_js.py /tmp/js_out.json
9
+ python sdk-python/scripts/decrypt_from_js.py < /tmp/js_out.json
10
+ """
11
+ import json
12
+ import sys
13
+
14
+ from burnedsecret import file_envelope
15
+ from burnedsecret.crypto import aes_decrypt, rsa_oaep_decrypt
16
+ from burnedsecret.encoding import b64u_decode, b64u_encode
17
+ from burnedsecret.passphrase import unwrap_text_envelope
18
+
19
+
20
+ def _fail(msg: str) -> None:
21
+ print(f"MISMATCH: {msg}", file=sys.stderr)
22
+ sys.exit(1)
23
+
24
+
25
+ def _eq(got, want, label: str) -> None:
26
+ if got != want:
27
+ _fail(f"{label}: got {got!r} want {want!r}")
28
+
29
+
30
+ def _read_envelope() -> dict:
31
+ if len(sys.argv) >= 2:
32
+ with open(sys.argv[1], "r", encoding="utf-8") as f:
33
+ return json.load(f)
34
+ return json.load(sys.stdin)
35
+
36
+
37
+ def main() -> int:
38
+ env = _read_envelope()
39
+
40
+ # -- Secret flow: AES only --------------------------------------------------
41
+ s_key = b64u_decode(env["secret"]["aes_key_b64u"])
42
+ s_iv = b64u_decode(env["secret"]["iv_b64u"])
43
+ s_ct = b64u_decode(env["secret"]["ciphertext_b64u"])
44
+ s_pt = aes_decrypt(s_ct, s_key, s_iv).decode("utf-8")
45
+ _eq(s_pt, env["secret"]["expected_plaintext"], "secret plaintext")
46
+
47
+ # -- Request flow: RSA-OAEP unwrap then AES decrypt -------------------------
48
+ pkcs8 = b64u_decode(env["request"]["private_key_pkcs8_b64u"])
49
+ wrapped = b64u_decode(env["request"]["wrapped_key_b64u"])
50
+ r_raw_key = rsa_oaep_decrypt(wrapped, pkcs8)
51
+ r_ct = b64u_decode(env["request"]["ciphertext_b64u"])
52
+ r_iv = b64u_decode(env["request"]["iv_b64u"])
53
+ r_pt = aes_decrypt(r_ct, r_raw_key, r_iv).decode("utf-8")
54
+ _eq(r_pt, env["request"]["expected_plaintext"], "request plaintext")
55
+ if b64u_encode(r_raw_key) != env["request"]["content_aes_key_b64u"]:
56
+ _fail("request AES key recovery - RSA-OAEP failure")
57
+
58
+ # -- Passphrase (text) flow: outer AES decrypt THEN inner PP: unwrap --------
59
+ pp_key = b64u_decode(env["passphrase"]["aes_key_b64u"])
60
+ pp_iv = b64u_decode(env["passphrase"]["iv_b64u"])
61
+ pp_ct = b64u_decode(env["passphrase"]["ciphertext_b64u"])
62
+ pp_inner = aes_decrypt(pp_ct, pp_key, pp_iv)
63
+ pp_pt = unwrap_text_envelope(pp_inner, env["passphrase"]["passphrase"])
64
+ _eq(pp_pt, env["passphrase"]["expected_plaintext"], "passphrase plaintext")
65
+
66
+ # -- File flow: BSF1 decrypt (no passphrase) --------------------------------
67
+ f_blob = b64u_decode(env["file"]["blob_b64u"])
68
+ f_master = b64u_decode(env["file"]["master_key_b64u"])
69
+ f_name, f_mime, f_msg, f_content = file_envelope.decrypt(f_blob, f_master)
70
+ _eq(f_name, env["file"]["expected_filename"], "file filename")
71
+ _eq(f_mime, env["file"]["expected_mime"], "file mime")
72
+ _eq(f_msg.decode("utf-8"), env["file"]["expected_message"], "file message")
73
+ if b64u_encode(f_content) != env["file"]["expected_content_b64u"]:
74
+ _fail("file content bytes")
75
+
76
+ # -- File + passphrase flow: BSF1 decrypt WITH passphrase -------------------
77
+ fp_blob = b64u_decode(env["file_passphrase"]["blob_b64u"])
78
+ fp_master = b64u_decode(env["file_passphrase"]["master_key_b64u"])
79
+ fp_name, fp_mime, fp_msg, fp_content = file_envelope.decrypt(
80
+ fp_blob, fp_master, passphrase=env["file_passphrase"]["passphrase"]
81
+ )
82
+ _eq(fp_name, env["file_passphrase"]["expected_filename"], "file_passphrase filename")
83
+ _eq(fp_mime, env["file_passphrase"]["expected_mime"], "file_passphrase mime")
84
+ _eq(fp_msg.decode("utf-8"), env["file_passphrase"]["expected_message"], "file_passphrase message")
85
+ if b64u_encode(fp_content) != env["file_passphrase"]["expected_content_b64u"]:
86
+ _fail("file_passphrase content bytes")
87
+
88
+ print(
89
+ "CROSS-SDK ROUND-TRIP (Python decrypts JS): PASS - "
90
+ "text + request + passphrase + file + file_passphrase"
91
+ )
92
+ return 0
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())