diff options
| -rw-r--r-- | docs/design/011-pure-transport.md | 7 | ||||
| -rw-r--r-- | docs/research/098-link-security.md | 4 | ||||
| -rw-r--r-- | docs/research/101-remaining-limits.md | 143 |
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단계, 채택과 명세다. |
