|
| 1 | +# inTabTls - terminate TLS inside the tab |
| 2 | + |
| 3 | +One campaign, one question. |
| 4 | + |
| 5 | +## Question |
| 6 | + |
| 7 | +**Can a tab terminate TLS itself, so the WS-to-TCP relay carries ciphertext it cannot read?** |
| 8 | + |
| 9 | +Today `pyproc/socket` reaches the network through a relay that dials the real TCP endpoint. For |
| 10 | +`https://` the relay terminates TLS (`src/capabilities/socketBridge.js` bootstrap: `ssl.wrap_socket` |
| 11 | +is a pass-through because the relay already did the crypto). That is honest and documented, and it |
| 12 | +has one consequence that shapes everything above it: **the relay must be trusted.** It sees plaintext. |
| 13 | + |
| 14 | +That requirement is what rung 1 of the ceiling ladder exists to remove |
| 15 | +([product direction](../../../docs/product/vision.md#where-the-ceiling-moves-next)). If TLS terminates |
| 16 | +inside the tab, the relay becomes untrusted infrastructure and the requirement drops from "a relay you |
| 17 | +trust" to "any relay at all". Every later rung inherits that trust model, which is why it is first. |
| 18 | + |
| 19 | +## Hypothesis |
| 20 | + |
| 21 | +Python's `ssl` module can drive a TLS handshake over a socket that is only a byte pipe, and the relay |
| 22 | +can be reduced to exactly that pipe. Two candidate paths, and the campaign has to measure both rather |
| 23 | +than assume one: |
| 24 | + |
| 25 | +1. **CPython's own `ssl`** on the Pyodide build. The blocker to measure first is whether the Pyodide |
| 26 | + distribution ships a usable `_ssl` at all, and if so whether its OpenSSL can complete a handshake |
| 27 | + with sockets that are JSPI-suspended rather than real file descriptors. |
| 28 | +2. **WebCrypto-backed TLS in JS**, with Python seeing a plain socket. This trades a large surface |
| 29 | + (a TLS 1.3 record layer over `crypto.subtle`) for not depending on the engine's OpenSSL. |
| 30 | + |
| 31 | +If both fail, the failure is specific and worth recording: it names which layer cannot terminate TLS |
| 32 | +without a real file descriptor, and that is the finding. |
| 33 | + |
| 34 | +## Probes |
| 35 | + |
| 36 | +| Probe | What it measures | Status | |
| 37 | +|---|---|---| |
| 38 | +| `sslSurfaceProbe.html` | Whether the engine's `ssl` can drive a real handshake: module presence, `SSLContext`, `wrap_socket`, and trust roots | measured, and it kills path 1 | |
| 39 | + |
| 40 | +The order was deliberate: measure the engine's `ssl` before writing any relay code. It paid on the |
| 41 | +first probe. |
| 42 | + |
| 43 | +### Measured (2026-08-01, Edge headless) |
| 44 | + |
| 45 | +``` |
| 46 | +ssl module found |
| 47 | +SSLContext ok: OPENSSL_VERSION = "OpenSSL (stub)" |
| 48 | +capabilities wrap, verify, nocerts |
| 49 | +``` |
| 50 | + |
| 51 | +**Path 1 is dead.** Pyodide ships an `ssl` module whose OpenSSL is a stub, and |
| 52 | +`create_default_context().get_ca_certs()` is empty - there are no trust roots in the distribution. |
| 53 | +`wrap_socket` exists and `verify_mode` defaults to `CERT_REQUIRED`, which is exactly the trap: the |
| 54 | +surface looks complete enough that a naive attempt would appear to work right up until a handshake, |
| 55 | +and would then either fail obscurely or - worse, if someone "fixed" it by setting `CERT_NONE` - do |
| 56 | +unverified TLS while reporting success. Graduation gate 2 exists to catch precisely that shape. |
| 57 | + |
| 58 | +So the campaign's remaining question is path 2 only, and it now carries a second requirement the |
| 59 | +original framing missed: **where do the trust roots come from?** A TLS 1.3 record layer over |
| 60 | +`crypto.subtle` still needs a certificate chain to validate against, and the browser will not lend |
| 61 | +its own root store to page script. The honest candidates are a vendored root bundle shipped as an |
| 62 | +asset with its own provenance entry (the `assetCatalog.json` discipline already covers this shape), |
| 63 | +or pinning per consumer. Neither is free, and the next probe has to measure the cost before any |
| 64 | +record-layer code is written - the same order that just paid. |
| 65 | + |
| 66 | +## 졸업 게이트 |
| 67 | + |
| 68 | +Move to `src/` only when all of these hold, measured in a real browser: |
| 69 | + |
| 70 | +1. **The relay never sees plaintext.** A relay instrumented to log everything it forwards records only |
| 71 | + ciphertext for an `https://` request that Python completes successfully. |
| 72 | +2. **The certificate is actually verified.** A handshake against a host whose certificate does not |
| 73 | + validate fails, and it fails with a Python-level error a consumer can catch - not a hang, and not a |
| 74 | + silent downgrade to plaintext. |
| 75 | +3. **`urllib` is unmodified.** The same consumer code that works today keeps working: the surface is |
| 76 | + `socket`, not a new API. |
| 77 | +4. **The cost is stated.** Handshake latency and per-request overhead against the current |
| 78 | + relay-terminated path, measured on the same page, recorded here. |
| 79 | +5. **The hermetic lane still holds.** `npm run test:socket` (relay plus a local origin, no traffic |
| 80 | + leaves the machine) covers the new path too, so the surface does not lose its CI gate to gain TLS. |
| 81 | + |
| 82 | +Closing the campaign deletes this folder; the record lives in the ledger and in git history. |
0 commit comments