aboutsummaryrefslogtreecommitdiffstats
path: root/docs/research
diff options
context:
space:
mode:
Diffstat (limited to 'docs/research')
-rw-r--r--docs/research/086-sealed-sends.md96
1 files changed, 96 insertions, 0 deletions
diff --git a/docs/research/086-sealed-sends.md b/docs/research/086-sealed-sends.md
new file mode 100644
index 0000000..08394b5
--- /dev/null
+++ b/docs/research/086-sealed-sends.md
@@ -0,0 +1,96 @@
+# 086 — 봉인한 전달: 중계가 읽지 못하는 앱 사이 메시지
+
+2026-09-27. [083](083-delivery-over-http.md)의 전달(앱의 `send`가 저장된 뒤 HTTP로 순서대로 묶여 가고, 받는 앱은 번호마다 한
+번 적용한다)에 종단 간 봉인을 더한 것. 로드맵의 E2EE 메신저로 가는 첫 단계다. 저장 형식과 전달 순서는 그대로다.
+
+착수 기준:
+
+- 바꿀 결정: 앱 사이 메시지를 TLS(구간마다 풀린다)에만 맡길지, 받는 노드의 공개키로 본문을 봉인해 그 사이의 중계·프록시·
+ 우편함이 읽지도 바꾸지도 못하게 할지. 방식을 새로 만들지 않는다. 표준 HPKE(RFC 9180, MLS·Oblivious HTTP·ECH가 쓰는 것)를
+ 쓴다.
+- 최소 확인: 기록하는 중계를 사이에 두고 모든 주문이 한 번씩 적용되며 중계가 항목도 필드 이름도 보지 못하는지, 한 바이트를
+ 바꾸면 거절되고 아무것도 적용되지 않는지, 기록한 요청을 다시 보내도 두 번 적용되지 않는지, 다른 키로 봉인한 것이 열리지
+ 않는지를 검사로 지킨다. 전달 속도를 봉인 전후로 번갈아 잰다.
+- 중단 기준: 봉인하지 않는 전달이 조금이라도 느려지면 되돌린다.
+
+## 한 것
+
+**봉인(`serve/seal.c`).** HPKE base 모드, X25519·HKDF-SHA256에 AEAD는 CPU에 AES 명령이 있으면 AES-128-GCM, 없으면
+ChaCha20-Poly1305다. OpenSSL 3.5의 `OSSL_HPKE_*`를 쓴다. 요청 하나의 본문(묶음이면 대괄호까지 한 덩어리)을 통째로 봉인하고,
+봉인은 그 요청의 출처·첫 번호·개수(`"source seq count"`)에 묶인다(AAD). 그래서 중계가 봉인한 본문을 다른 번호나 다른 출처로
+옮기거나 개수를 바꾸면 열리지 않는다. 본문은 캡슐화한 키 32 B, 암호문, 태그 16 B다. 헤더 `X-Pure-Seal`이 스위트를 적는다
+(`x25519-sha256-aes128gcm`이나 `x25519-sha256-chacha20poly1305`). 받는 쪽은 둘 다 연다. `Content-Type`은 연 뒤의 형식(JSON)을
+말한다. `Content-Encoding`이 gzip 위의 형식을 말하는 것과 같다.
+
+**키.** `--key FILE`은 원시 32 B 파일이다. 없으면 만들어 0600으로 쓰고 동기화한 뒤에 쓴다. 있으면 소유자만 읽을 수 있고 정확히
+32 B여야 한다(심볼릭 링크는 따라가지 않는다). 개인키는 OpenSSL의 보안 힙에 둔다. 메모리에 잠기고, 코어 덤프에 들지 않으며,
+풀 때 지워진다. 시작할 때 내는 JSON에 공개키(`"public_key"`, hex)가 있다.
+
+**보내는 쪽.** `--seal OUTPUT=KEY`는 `--send`로 연결한 출력의 요청을 받는 노드의 공개키로 봉인한다.
+
+**받는 쪽.** `X-Pure-Seal`이 있는 요청은 출처와 이 노드의 키가 있어야 하고 JSON이어야 한다(아니면 400 `seal`). 본문은 받은
+자리에서 열린다. 열리지 않으면(다른 키, 다른 요청의 묶임, 도중에 바뀜) 400 `seal`로 답하고 아무것도 적용하지 않는다. 연 평문은
+요청의 티켓이 반납될 때 0으로 지운다. `--sealed-only`인 노드는 봉인하지 않은 전달(`X-Pure-Source`가 있고 봉인이 없는 것)을
+403 `unsealed`로 거절한다.
+
+**한 덩어리로 보내기.** 봉인하려면 본문이 한 덩어리여야 해서, 먼저 요청을 버퍼 하나에 만들어 한 번에 쓰게 바꿨다. 전에는 묶음의
+보내기마다 쉼표나 대괄호 한 번, 본문 한 번을 썼다. 이것만으로 봉인하지 않는 전달이 91k → 470k 보내기/s가 됐다(아래). 머리를
+본문과 따로 한 번 더 써도 468k로 같았다. 이득은 본문 쪽이다. 버퍼 하나가 더 단순해서 그대로 뒀다. 이제 혼자 가는 보내기도 세그먼트
+둘이 아니라 하나로 간다.
+
+**검사(`tests/check.py`의 `server_sealed`).** 보내는 앱과 받는 앱 사이에 지나가는 요청을 모두 기록하는 파이썬 중계를 둔다.
+
+- 주문 60개가 모두 한 번씩 적용되고 합계가 맞는다.
+- 중계가 본 요청(25–26개) 모두에 `X-Pure-Seal`이 있고, 어느 본문에도 `secret`·`item`·`city`가 없다.
+- 기록한 요청의 가운데 한 비트를 바꿔 보내면 400 `seal`이다.
+- 원래 요청을 다시 보내면 200이지만 두 번 적용되지 않는다.
+- 봉인을 뺀 전달은 403 `unsealed`다.
+- 다른 노드의 키로 봉인하는 보내는 쪽의 요청은 중계를 지나도 열리지 않아 아무것도 적용되지 않는다.
+- 같은 키 파일로 다시 시작하면 공개키가 같다.
+
+이 검사를 새 디렉터리에서 여덟 번 연달아 돌려 모두 통과했다. 검사는 호스트가 포트를 스스로 고르게 한다(`--port 0`). 빈 포트를
+미리 알아낸 뒤 닫으면 그 사이에 다른 연결이 그 임시 포트를 가져갈 수 있다. 서버 검사 전체(`make check`)도 통과한다.
+
+## 장치가 말한 것
+
+Raspberry Pi 5(Cortex-A76 2.4 GHz), 루프백.
+
+**전달.** 받는 서버를 멈춘(`SIGSTOP`) 채 보내기 20,000개를 쌓고, 다시 움직이게 한 뒤 다 적용될 때까지 잰다. 두 서버는 코어
+0–2를 쓰고, 변형의 순서는 라운드마다 돌린다. 전달 요청은 어느 변형이든 80개다(묶음 하나에 최대 256개).
+([drain.py](../../research/experiments/014-sealed-sends/drain.py),
+[원시 결과](../../research/experiments/014-sealed-sends/2026-09-27-drain-linux-aarch64.jsonl).)
+
+| | 보내기/s (세 라운드) |
+|---|---:|
+| 전(보내기마다 두 번 씀, f2fd910) | 91.0–95.5k |
+| 한 덩어리, 봉인 없음 | **469.0–469.9k** |
+| 한 덩어리, 봉인 | **232.9–235.1k** |
+
+**기본 연산.** 코어 하나(`openssl speed`, 2초):
+
+| | 64 B | 1 KiB | 16 KiB |
+|---|---:|---:|---:|
+| AES-128-GCM | 183 MB/s | 1,350 MB/s | 2,282 MB/s |
+| ChaCha20-Poly1305 | 257 MB/s | 682 MB/s | 708 MB/s |
+
+X25519는 초당 6,105번(한 번 164 µs)이다. 묶음 본문(약 10 KiB)에서 AES-GCM이 세 배 빠르다. 혼자 가는 보내기(약 50 B)에서는
+ChaCha가 빠르지만 그 차이는 0.1 µs다. 봉인 비용의 대부분은 키 합의다. 요청 하나에 보내는 쪽이 두 번(임시 키 만들기, 합의),
+받는 쪽이 한 번, 모두 약 0.5 ms다. 한 전달선은 요청을 하나씩 보내고 답을 기다린다. 그래서 요청 80개면 약 40 ms가 된다. 봉인
+없이 20,000개가 걸리는 43 ms에 이것이 더해지면 83 ms, 240k/s로 잰 값과 맞는다. 대칭 암호에 드는 것은 요청마다 양쪽을 합쳐
+약 9 µs다.
+
+**봉인을 쓰지 않는 요청.** 헤더 하나와 분기 몇 개가 늘었다. 전(f2fd910)과 번갈아 네 라운드씩 쟀다(`benchmarks/http/run.py
+--baseline`). 앱의 합계 GET 64연결은 111,829 대 111,826/s, 문서의 64 B GET은 116,134 대 116,804/s이고, RSS는 같다.
+
+## 다음
+
+- **세션.** 전달선마다 캡슐화를 한 번만 하고, 그 뒤 요청은 HPKE 문맥 안의 번호로 봉인한다(RFC 9180의 여러 번 봉인). 캡슐화한
+ 키를 요청마다 헤더에 실으면 받는 쪽은 다시 시작한 뒤에도 따로 묻지 않고 열 수 있다. 키 합의가 세션당 한 번이 되어 봉인 전달이
+ 봉인하지 않은 전달에 가까워질 것이다.
+- **보낸 이 인증.** base 모드는 공개키를 아는 누구나 봉인할 수 있어서 `X-Pure-Source`가 인증되지 않는다. HPKE auth 모드로
+ 출처를 그 노드의 키에 묶고, `--sealed-only` 노드는 아는 보낸 이만 받게 한다.
+- **전방 비밀성.** 받는 노드의 키를 훔치면 기록해 둔 과거 전달이 열린다. 받는 키를 돌려 쓰거나(서명한 사전 키) 메신저에서는
+ 래칫을 쓴다.
+- **메타데이터.** 출처·번호·개수·경로·크기는 보인다. 크기는 채워 넣기로 가릴 수 있다.
+- **라우트 인증.** `--sealed-only`는 봉인하지 않은 전달을 거절할 뿐이다. 노드의 입력 라우트는 여전히 누구의 요청이든 받는다.
+ 앱 라우트의 인증은 따로 남은 일이다.