aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/design/011-pure-transport.md7
-rw-r--r--docs/research/098-link-security.md4
-rw-r--r--docs/research/101-remaining-limits.md143
3 files changed, 153 insertions, 1 deletions
diff --git a/docs/design/011-pure-transport.md b/docs/design/011-pure-transport.md
index 6ad02ea..034321e 100644
--- a/docs/design/011-pure-transport.md
+++ b/docs/design/011-pure-transport.md
@@ -42,6 +42,13 @@ M3(저장소 옮기기, H3)는 [100](../research/100-moved-receiver.md)에 있�
다른 주소로 옮겨도 저장이 멈춘 칸이 없고, 옮긴 노드의 X25519는 0이며, 잃거나 겹친 기록이 없다. 그 검사를 만들다 받는 쪽이 패킷이
온 주소가 아닌 곳에서 답하던 결함도 찾아 고쳤다. 이제 H1–H4를 모두 쟀고, 남은 것은 5단계(채택과 명세)다.
+남은 한계는 [101](../research/101-remaining-limits.md)에서 풀었다.
+- 경로 MTU 블랙홀을 알아채고 1,200 B로 돌아간다.
+- 메모를 잃은 받는 쪽은 LOST로 알리고, 보내는 쪽은 run을 새로 시작하듯 이어 간다. M2 `--no-memo`의 따라잡기가 7.7초에서
+ 0.4–0.5초가 됐다.
+- M5를 ngtcp2와도 쟀다. 1 KiB 이상에서 Pure가 두 QUIC 모두보다 25 % 넘게 가볍고, 61 B에서는 ngtcp2의 1.04배, quinn의 0.90배다.
+- 패킷마다의 스트림 이름은 비용이 아니었다. 빼 보니 잡음 안이라 되돌렸다.
+
2026-09-29에 링크를 `link/`로 뗐다([009](009-layout.md)). 프로토콜은 이제 소켓, 스레드, 시계가 없는 상태이고
(`link/link-protocol.h`), UDP와 스레드는 그 위의 층이다. 그래서 한 스레드가 링크 여럿을 들 수 있다. M2를 이 보내는 쪽으로
다시 돌렸더니 결과가 같았다. 다만 재시작 뒤 가장 늦은 저장이 1초였다. 받았다고 했지만 저장 소식이 없는 기록을 1초 기다리기
diff --git a/docs/research/098-link-security.md b/docs/research/098-link-security.md
index c1d9755..ea4b471 100644
--- a/docs/research/098-link-security.md
+++ b/docs/research/098-link-security.md
@@ -71,7 +71,9 @@
새 run은 초당 256개까지만 받는다.
- X25519는 보내는 이마다 한 번이다. 비밀을 메모에 적어 두기 때문이다(095).
-변조 검사는 처음부터 통과했다. 머리를 연관 데이터로 넣은 AES-GCM 덕분이다. 위장 검사도 통과했다.
+변조 검사는 처음부터 통과했다. 머리를 연관 데이터로 넣은 AES-GCM 덕분이다. 위장 검사도 통과했다. (101에서 LOST를 더한 뒤로는, 받는
+쪽이 모르는 run의 에폭 1 이상 패킷이 받은 것보다 작은 LOST 하나를 끌어낼 수 있다. QUIC의 stateless reset과 같은 자리이고, 초당
+4,096개로 묶인다.)
## 결과
diff --git a/docs/research/101-remaining-limits.md b/docs/research/101-remaining-limits.md
new file mode 100644
index 0000000..5a1ddc8
--- /dev/null
+++ b/docs/research/101-remaining-limits.md
@@ -0,0 +1,143 @@
+# 101 — 남은 한계 넷: 블랙홀, 스트림 이름, 메모 없는 재시작, ngtcp2
+
+2026-09-30. [100](100-moved-receiver.md)이 남긴 한계를 푼다. [설계 011](../design/011-pure-transport.md)의 5단계(채택과 명세)
+앞이다.
+
+착수 기준:
+
+- **바꿀 결정.** 알려진 한계를 풀거나, 풀 필요가 없음을 재서 보인다. 그다음에 채택한다.
+- **최소 확인.**
+ - 경로 MTU 블랙홀: 결정적 검사로 재현하고 고친다. M6 시뮬레이터의 결과는 바뀌지 않아야 한다.
+ - 스트림 이름: 패킷마다 싣지 않으면 CPU와 바이트가 얼마나 주는지 잰다. 2 %가 안 되면 코드를 더하지 않는다.
+ - 메모 없는 재시작: M2 `--no-memo`가 에폭 전(095)과 같은 수준으로 돌아온다.
+ - ngtcp2: M5를 quinn과 같은 의미로 잰다.
+- **중단 기준.** 어느 것이든 보안 검사(098, 099)를 깨면 되돌린다.
+
+## 쉬운 말로
+
+- 알아낸 것:
+ - **블랙홀.** 경로가 1,452 B를 나르다가 소리 없이 큰 패킷만 버리기 시작하면, 링크가 멈췄다(3,000건 가운데 8건).
+ - 이제 두 경우에 1,200 B로 돌아간다. 큰 패킷이 잇따라 사라지는데 작은 것만 도착할 때, 그리고 탐침 두 번이 연달아 답을
+ 받지 못할 때다.
+ - 그 검사는 가상 1.2초에 끝난다. M6 시뮬레이터의 결과는 여덟 경로 모두 바이트까지 같다.
+ - **스트림 이름.** 096은 61 B에서 남은 차이를 패킷마다 싣는 스트림 이름 탓으로 봤다. 틀렸다.
+ - 이름을 빼 봤더니 61 B 패킷에 기록이 하나도 더 들어가지 않았다. 21 B가 비어도 62 B짜리 기록은 들어가지 않는다.
+ - M5의 CPU도 잡음 안이었다(61 B 0.290 → 0.286 µs, 1 KiB 같음). 새 프레임, 두 쪽의 상태, 복구 길을 더할 값이 없어 되돌렸다.
+ - **메모 없는 재시작.** 받는 쪽이 모르는 에폭의 패킷에 LOST로 답한다. QUIC의 stateless reset과 같은 자리다. 보내는 쪽은
+ run을 새로 시작하듯 이어 간다.
+ - M2(기기 1만 개)에서 저장이 멈춘 100 ms 칸이 8에서 0이 됐다.
+ - 따라잡기는 7.7초에서 0.4–0.5초가 됐다. 에폭 전(095의 0.3초)과 거의 같다.
+ - **ngtcp2.** 1 KiB와 16 KiB에서 Pure가 quinn과 ngtcp2 모두보다 25 % 넘게 가볍다. 61 B에서는 ngtcp2가 조금
+ 가볍다(0.270 대 0.280 µs).
+ - 차이는 받는 쪽에 있었다. 순서대로 온 기록을 프레임째 줄에 붙이는 길을 넣어, 0.292에서 0.280으로 좁혔다.
+- 아직 모르는 것: 손실과 재정렬이 있는 경로(M6)에서 ngtcp2와는 대 보지 않았다.
+- 사용 경험의 차이:
+ - 노드가 메모 파일을 잃고 다시 떠도 기기들은 거의 모른다.
+ - 경로 MTU가 줄어도 링크가 멈추지 않는다.
+
+## 한 것
+
+### 경로 MTU 블랙홀 (RFC 8899 §4.3)
+
+- test-link에 경우를 더했다. 보내는 쪽 데이터그램이 1,452 B로 오르는 순간부터, 경로가 1,200 B보다 큰 패킷을 모두 버린다.
+- 고치기 전에는 10분 안에 3,000건 가운데 8건만 저장됐다. 큰 패킷을 잃을 때마다 다시 큰 패킷으로 보냈기 때문이다.
+- 이제 두 경우에 1,200 B로 돌아가고, 1초 뒤 다시 탐침한다. 탐침이 실패하면 기존 백오프(3번 뒤 10분)를 따른다.
+ - 큰 패킷이 확인 없이 연달아 3개 넘게 사라졌는데, 확인이 작은 패킷만 덮고 큰 패킷은 하나도 덮지 않았을 때.
+ - 탐침 두 번이 연달아 답을 받지 못했을 때.
+- 혼잡으로 잃은 것이라면 큰 패킷도 사이사이 확인된다. 그래서 첫 조건에 걸리지 않는다. M6 시뮬레이터로 확인했다. 병목,
+ 손실, 흔들림이 있는 여덟 경로에서 결과가 바이트까지 같았다.
+
+### 스트림 이름을 빼 보았다
+
+- 설계는 이랬다.
+ - 받는 쪽이 이 run의 스트림을 안다는 증거(같은 run의 답에 실린 STORED)를 들었고, 1초 안에 답을 들었다면 STREAM 프레임을
+ 뺀다.
+ - 모르는 run의 이름 없는 기록에는 받는 쪽이 NAME으로 "이름을 대 달라"고 답한다.
+ - 쉬던 기기의 첫 패킷에는 늘 이름을 실어, 1 RTT 깨움을 지킨다.
+- 결과는 이랬다.
+ - 모든 검사를 통과했고, 깨끗한 경우의 패킷이 1.6 % 줄었다.
+ - 하지만 61 B 기록의 패킷 수는 그대로였고(93,755), M5 CPU는 잡음 안이었다.
+- 되돌렸다. 096의 "남은 차이는 스트림 이름"이라는 추정은 이것으로 틀렸음이 밝혀졌다.
+
+### LOST
+
+- **받는 쪽.** 모르는 run의 패킷이 에폭 1 이상이라 열 수 없으면 LOST로 답한다.
+ - 에폭 0(두 노드의 정적 키)으로 봉인하고, 열지 못한 패킷의 번호를 싣는다.
+ - 그 run의 답 run을 새로 뽑아 봉인하므로 논스가 겹치지 않는다.
+- **보내는 쪽.** LOST가 이름 댄 패킷이 지금 날아가는 것일 때만 받아들인다. 받아들이면 run을 새로 시작할 때처럼 한다.
+ - 에폭 0으로 돌아가 에폭 1을 새로 제안한다.
+ - 저장되지 않은 기록을 곧바로 다시 보낸다. 날아가던 것은 혼잡 신호 없이 버린다.
+- **전방 비밀성의 규칙이 넓어졌다.** 전에는 "run이 처음 시작될 때만 정적 키로 기록이 간다"였다. 이제 "받는 쪽이 에폭을
+ 잃었다고 인증된 말로 할 때"가 더해졌다. 그래도 다음 셋은 막혀 있다.
+ - LOST는 정적 키로 봉인되므로 경로 위 공격자가 만들 수 없다.
+ - 지난 LOST를 다시 보내면 이미 날아가지 않는 패킷을 이름 대므로 받아들이지 않는다.
+ - 답을 막아서 생기는 조용한 복구(2초와 탐침 4번)는 지금처럼 기록 없이 제안만 한다. 공격자가 전방 비밀성을 떨어뜨리는 길은
+ 여전히 없다.
+- **QUIC의 stateless reset 규칙을 따랐다(RFC 9000 §10.3).**
+ - LOST는 열 수 없는 패킷에 대한 답이므로, 받은 패킷보다 작을 때만 보낸다(46 B).
+ - 초당 4,096개로 따로 묶는다.
+ - 098이 "변조된 패킷에는 아무 답도 없다"고 했던 것이 여기서 달라진다. 이제 "받은 것보다 작은 LOST 말고는 없다"다.
+- **토큰 규칙을 다듬었다.** 처음에는 LOST를 넣어도 M2가 나아지지 않았다(따라잡기 8초).
+ - 메모를 잃은 뒤의 패킷은 모두 "열리지 않는 새 run"이라 폭주 방어 토큰(초당 256)을 썼다. 그래서 초당 256대만 LOST를 받았다.
+ - 이제 에폭을 몰라 키를 만들지 않은 실패는 토큰을 쓰지 않는다. 그 보내는 이의 정적 비밀은 메모에 남으므로 X25519는 보내는
+ 이마다 한 번이다.
+ - 메모 없이 도는 받는 쪽은 비밀을 남기지 못하므로 전처럼 토큰을 쓴다.
+- **검사.**
+ - 변조: 받은 것보다 작은 LOST만 허용한다.
+ - 폭주: 가짜 1만 개의 절반을 에폭 1로 보냈다. LOST 267개, 공격자에게 간 것은 12 KB였다. 공격자가 보낸 것은 6 MB다.
+ - 잊는 받는 쪽: 정적 키로 기록이 가는 것은 잊은 뒤부터 새 에폭이 시작되기 전까지만이다.
+
+### ngtcp2
+
+- **하네스.** ngtcp2 1.25.0을 OpenSSL 3.5의 QUIC TLS(ossl 백엔드)로 빌드한다([build.sh](../../research/experiments/019-idle-links/ngtcp2/build.sh)).
+ [drain.c](../../research/experiments/019-idle-links/ngtcp2/drain.c)는 quinn 하네스와 같은 일을 C로 한다.
+ - 연결 하나, 스트림 하나로 "pure" 뒤에 길이를 앞에 붙인 메시지를 보낸다. 서비스는 세기만 한다.
+ - 패킷은 GSO로 묶어 보낸다.
+ - ngtcp2는 스트림 바이트를 복사하지 않고, 확인될 때까지 앱이 들고 있어야 한다. 하네스의 바이트는 오프셋만으로 정해지므로
+ 변하지 않는 패턴 버퍼를 가리킨다.
+- **받는 쪽의 빠른 길.** 처음 재니 61 B에서 Pure가 ngtcp2의 1.08배였다. 두 쪽으로 나누니 보내는 쪽은 같았고(0.14 µs),
+ 받는 쪽이 0.15 대 0.13 µs였다.
+ - 순서대로 온 RECORDS 프레임은 쥔 기록도 꼬리 조각도 없으면, 프레임 단위로 한 번 검사하고 기록을 줄에 바로 이어 붙인다.
+ - 그 뒤 받는 쪽은 0.133–0.143 µs다.
+ - Pure 쪽 호스트는 기록마다 보낸 그대로인지 대조한다(0.007 µs). QUIC 서비스 둘은 세기만 한다.
+
+## 결과
+
+**M2, 메모 없이 다시 뜬 받는 쪽** (기기 1만 개, 80초, 60초에 kill -9, 두 라운드,
+`research/experiments/019-idle-links/2026-09-30-lost-linux-aarch64.jsonl`):
+
+| | 099(LOST 전) | 이 문서 |
+|---|---|---|
+| 저장이 멈춘 100 ms 칸 | 8 | 0 |
+| 따라잡기 | 7.7–7.8초 | 0.4–0.5초 |
+| 저장까지 p99 / 최대 | 4.1–4.2초 / 7.7–7.8초 | 5.8–5.9 ms / 48–57 ms |
+| 다시 뜬 뒤 10초의 CPU, 노드 / 생성기 | 5.4–5.6 / 4.7–4.8초 | 3.8–3.9 / 2.2–2.4초 |
+| 다시 보낸 기록 | 61–62k | 10k(기기마다 하나) |
+
+**M5, 세 구현** (두 쪽 CPU 합, 다섯 라운드의 중앙값, µs/메시지,
+`research/experiments/019-idle-links/2026-09-30-transport-three-linux-aarch64.jsonl`):
+
+| 크기 | Pure | quinn | ngtcp2 | 초당 전달 Pure / quinn / ngtcp2 |
+|---|---:|---:|---:|---|
+| 61 B | 0.280 | 0.310 | 0.270 | 581만 / 607만 / 617만 |
+| 1 KiB | 2.62 | 3.94 | 3.54 | 75만 / 51만 / 54만 |
+| 16 KiB | 40.2 | 60.2 | 54.4 | 5.0만 / 3.2만 / 3.6만 |
+
+61 B에서 Pure는 라운드마다 0.274–0.321 µs로 흔들린다. ngtcp2는 0.27로 고르다.
+
+## 판정
+
+- 넷 가운데 셋을 풀었다(블랙홀, 메모 없는 재시작, ngtcp2 비교). 하나(스트림 이름)는 풀 필요가 없음을 쟀다.
+- H4를 다시 본다. 두 QUIC 구현 모두와 대어, 1 KiB 이상에서 25 % 넘게 가볍다. 61 B에서는 ngtcp2의 1.04배, quinn의 0.90배다.
+ 011의 기준(10 %) 안이다. 61 B에서는 QUIC과 비긴다고 본다.
+- 보안 검사는 모두 통과한다. LOST로 달라진 한 줄("변조된 패킷에 답 없음" → "받은 것보다 작은 LOST 말고는 없음")은 QUIC의
+ stateless reset과 같은 자리다.
+
+## 한계와 다음
+
+- M6(손실, 재정렬, 병목)을 ngtcp2와는 대지 않았다.
+- 61 B에서 남은 비용은 보내는 쪽의 기록마다의 일(패킷 채우기, 확인 처리)이다. 기록 단위로 정확히 한 번을 지키는 값이다.
+ run 단위로 묶어 보았으나 벤치에서 차이가 없어 되돌렸다.
+- 메모는 보내는 이마다 run 하나의 에폭만 적는다. 한 노드에서 링크 여럿이 오는 보내는 이는, 받는 쪽이 다시 뜨면 나머지 run이
+ LOST로 에폭을 새로 연다. 이제는 멈추지 않는다.
+- 다음은 011의 5단계, 채택과 명세다.