Skip to content

Commit c07c67d

Browse files
committed
attempts: 사다리 1단 캠페인을 열고 첫 측정이 경로 하나를 죽임
in-tab TLS는 천장 사다리의 첫 단이고, 모든 후속 단이 그 신뢰 모델을 상속하기 때문에 먼저다. 지금 릴레이는 https에서 TLS를 종단하므로 평문을 본다: 그래서 "신뢰하는 릴레이"가 요구사항이고, 탭 안에서 종단하면 그것이 "아무 릴레이나"로 내려간다. 첫 probe가 릴레이 코드 한 줄 전에 답을 냈다. Pyodide의 ssl은 있지만 OpenSSL이 stub이고 create_default_context().get_ca_certs()가 비어 있다. wrap_socket과 CERT_REQUIRED 기본값은 살아 있어서 표면이 그럴듯해 보이는데, 그것이 정확히 함정이다: 순진한 시도가 핸드셰이크까지 가서야 실패하거나 - 더 나쁘게 CERT_NONE으로 "고치면" - 검증 없는 TLS를 성공이라 보고한다. 졸업 게이트 2번이 그 모양을 잡으려고 있다. 남은 질문은 경로 2(WebCrypto 레코드 층)뿐이고, 원래 프레이밍이 놓친 요구가 하나 붙었다: 신뢰 루트를 어디서 가져오는가. 브라우저는 자기 루트 저장소를 페이지 스크립트에 빌려주지 않으므로 자산으로 실은 루트 번들(assetCatalog 규율이 이미 그 모양을 덮는다)이나 소비자별 핀이 후보다. 다음 probe는 레코드 층 코드를 쓰기 전에 그 비용부터 잰다. 검증: npm test 3728 passed 0 failed(attempts 구조 게이트가 README와 졸업 게이트 절을 검사한다). probe 실행 결과 2/3이고 세 번째가 RED인 것이 이 캠페인의 첫 발견이다.
1 parent 465f377 commit c07c67d

3 files changed

Lines changed: 193 additions & 1 deletion

File tree

mainPlan/stability-convergence/03-progress-ledger.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,32 @@
247247
20개짜리 함수이거나 밖에서 객체를 변이시키는 형태가 되고, 둘 다 지금보다 나쁘다. 축이 하나면
248248
파일이 길어도 그것이 축이다(개발 원칙 3: 강함은 깎아서 나온다, 쪼개서 나오는 것이 아니다).
249249

250+
## 2026-08-01 (7): 북극성 밀기와 사다리 1단 개설
251+
252+
이번 흐름의 점수 이동은 전부 게이트를 먼저 세우고 그 뒤에 숫자를 옮긴 것이다(98.5 -> 103.5).
253+
254+
- **원장이 실물보다 낮았다(+1.5).** 앞 커밋들이 next에서 항목을 지우고 evidence를 늘리면서
255+
점수를 두고 갔다. 감사가 그 불일치를 잡았고 세 축을 실물에 맞췄다.
256+
- **소켓 밀폐 CI 레인(+0.5).** 릴레이가 저장소 안에 의존성 0으로 있었으므로 러너가 릴레이와
257+
로컬 오리진을 소유하면 바깥으로 나가지 않는 레인이 된다. 네트워크 축의 수동 probe가 0이 됐다.
258+
- **정확히 한 번의 수렴 완성(+1.0).** 리더가 결과를 힙과 같은 세대에 기록하고, 끊긴 호출자는
259+
대기했다 승계자에게 한 번 다시 묻는다. 순서도 보존된다. 소비자 게이트가 두 번 구멍을 잡았다
260+
(부활 커널의 프록시 트랩, 세션 없는 follower가 리더의 힙을 모르면서 park한 것).
261+
- **x86 레인 CI 승격(+0.5).** "자산이 gitignore라 CI에서 못 돈다"가 낡은 문장이었다: 같은
262+
워크플로가 같은 자산을 매 push마다 받는다. 감사가 예고한 함정(레인이 CI에 들어오는 순간
263+
false-green이 될 음성 fixture)도 같은 커밋에서 막았다.
264+
- **구 봉투 일몰(+1.0).** 읽기만 남은 포맷은 계약이 아니라 부채였다. 브레이킹은 CHANGELOG에
265+
쌓는다: 게시는 별개 결정이고 그 결정 없이도 main은 브레이킹을 안고 간다.
266+
- **측정으로 기각한 것**: SwiftShader GPU 어댑터(requestAdapter가 null을 준다).
267+
- **상한을 확정했다.** 12축 전수 대조 결과 120은 저장소 안에서 도달 불가다(정직한 상한 113.5,
268+
릴리즈 승인 없이는 110.5). 6.5점이 upstream/브라우저 플랫폼에 있다: nogil, WASI 동적 링킹,
269+
pyemscripten wheel, Direct Sockets 인바운드, WASM 스택 직렬화, upstream 재현 빌드.
270+
271+
사다리 1단(in-tab TLS) 캠페인을 개설했고 첫 probe가 경로 하나를 즉시 죽였다: Pyodide의 ssl은
272+
OpenSSL stub이고 신뢰 루트가 0이다. 릴레이 코드를 쓰기 전에 알아낸 것이 그 순서의 값이다.
273+
250274
## NEXT
251275

252-
이 이니셔티브의 목록은 비었다. 다음 작업은 새 이니셔티브 개설부터다(mainPlan/README.md 절차).
276+
이 이니셔티브의 목록은 비었다(북극성 밀기는 축의 next와 계약 실태 표가 들고 간다). 다음 작업은 새 이니셔티브 개설부터다(mainPlan/README.md 절차).
253277
남은 부채는 목록이 아니라 [계약 실태 표](../../docs/operations/contractReality.md)와 북극성 축의
254278
`next`가 들고 있다: 그 둘이 "무엇이 아직 참이 아닌가"의 정본이다.

tests/attempts/inTabTls/README.md

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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.
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<!doctype html>
2+
<!--
3+
캠페인의 첫 측정. 릴레이 코드를 한 줄도 쓰기 전에 답해야 하는 질문이다: 이 엔진의 파이썬이
4+
TLS를 스스로 할 수 있는가? CPython의 ssl이 살아 있으면 경로 2(JS에서 TLS 1.3 레코드 층을
5+
직접 구현)는 통째로 불필요하고, 없으면 경로 1이 죽는다. 어느 쪽이든 이 한 페이지가 가른다.
6+
-->
7+
<html lang="ko">
8+
<head>
9+
<meta charset="utf-8">
10+
<title>probe: python ssl surface</title>
11+
<style>body{font:14px/1.5 system-ui;margin:2rem;max-width:62rem}pre{background:#f4f4f5;padding:1rem;border-radius:8px;white-space:pre-wrap}</style>
12+
<script>
13+
window.addEventListener("error", (event) => {
14+
fetch("/gateReport", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ok: false, checks: [{ name: "module 초기화", pass: false, info: event.message }] }) }).catch(() => {});
15+
});
16+
</script>
17+
</head>
18+
<body>
19+
<h1>파이썬 ssl 표면</h1>
20+
<pre id="out">실행 중...</pre>
21+
<script type="module">
22+
import { boot } from "/index.js";
23+
24+
const out = document.getElementById("out");
25+
const checks = [];
26+
const check = (name, pass, info = "") => {
27+
checks.push({ name, pass: !!pass, info: String(info) });
28+
out.textContent += `\n${pass ? "PASS" : "FAIL"} ${name}${info ? ` (${info})` : ""}`;
29+
};
30+
const report = async () => {
31+
const ok = checks.length > 0 && checks.every((entry) => entry.pass);
32+
try { await fetch("/gateReport", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ok, checks }) }); } catch (error) {}
33+
};
34+
35+
const params = new URLSearchParams(location.search);
36+
try {
37+
const machine = await boot({ ...(params.get("indexURL") ? { indexURL: params.get("indexURL") } : {}) });
38+
const rt = machine.runtime;
39+
40+
// (1) ssl 모듈이 import되는가. 이것이 없으면 경로 1(CPython 자체 TLS)은 즉시 죽는다.
41+
const importReport = String(rt.run([
42+
"import importlib.util as _u",
43+
"spec = _u.find_spec('ssl')",
44+
"'found' if spec is not None else 'missing'",
45+
].join("\n")));
46+
check("ssl 모듈이 배포에 있다", importReport === "found", importReport);
47+
48+
// (2) 실제로 import되고 SSLContext를 만들 수 있는가(스펙 존재와 동작은 다른 사실이다).
49+
const contextReport = String(rt.run([
50+
"def probeSslContext():",
51+
" try:",
52+
" import ssl",
53+
" ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)",
54+
" return 'ok:' + ssl.OPENSSL_VERSION",
55+
" except BaseException as error:",
56+
" return type(error).__name__ + ': ' + str(error)[:80]",
57+
"probeSslContext()",
58+
].join("\n")));
59+
check("SSLContext를 만들 수 있다", contextReport.startsWith("ok:"), contextReport);
60+
61+
// (3) 핸드셰이크에 필요한 것들이 실제로 있는가. wrap_socket과 인증서 검증 스위치가
62+
// 없으면 "TLS를 한다"가 아니라 "TLS 흉내를 낸다"가 된다.
63+
const capabilityReport = String(rt.run([
64+
"def probeSslCapabilities():",
65+
" try:",
66+
" import ssl",
67+
" ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)",
68+
" bits = []",
69+
" bits.append('wrap' if hasattr(ctx, 'wrap_socket') else 'nowrap')",
70+
" bits.append('verify' if ctx.verify_mode == ssl.CERT_REQUIRED else 'noverify')",
71+
" bits.append('certs' if ssl.create_default_context().get_ca_certs() else 'nocerts')",
72+
" return ','.join(bits)",
73+
" except BaseException as error:",
74+
" return type(error).__name__ + ': ' + str(error)[:80]",
75+
"probeSslCapabilities()",
76+
].join("\n")));
77+
check("wrap_socket과 인증서 검증 재료가 있다", capabilityReport === "wrap,verify,certs", capabilityReport);
78+
79+
await machine.dispose();
80+
} catch (error) {
81+
check("예외 없음", false, String(error?.message || error).slice(-200));
82+
}
83+
await report();
84+
</script>
85+
</body>
86+
</html>

0 commit comments

Comments
 (0)