diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/design/006-native-protocol.md | 7 | ||||
| -rw-r--r-- | docs/design/009-layout.md | 2 | ||||
| -rw-r--r-- | docs/design/011-pure-transport.md | 22 | ||||
| -rw-r--r-- | docs/design/012-link-protocol.md | 324 |
4 files changed, 351 insertions, 4 deletions
diff --git a/docs/design/006-native-protocol.md b/docs/design/006-native-protocol.md index 09c4075..27a1b37 100644 --- a/docs/design/006-native-protocol.md +++ b/docs/design/006-native-protocol.md @@ -12,12 +12,15 @@ TLS 1.3/TCP 위에 실은 피어 호스트를 검증했다. [026 — scalar 피� 상태를 앱 상태와 한 커밋에 두어 연결 개념을 없앤다. 아래 "새 UDP 전송은 … 별도 연구 후보로 연다"는 조건은 그 가설과 중단 기준이 판정한다. +2026-09-29: 011의 가설과 검사가 모두 서서, 노드 사이 전달의 기본을 연결 없는 링크로 정했다. 명세는 +[012 — 링크 프로토콜](012-link-protocol.md)이다. HTTP 전달은 웹, 기존 도구, 제3자와 잇는 경계로 남는다. + ## 결정 Pure 간 통신은 HTTP 메서드·URL·문자열 헤더를 필수 실행 모델로 삼지 않는다. 앱이 작성한 연결과 데이터 형식에서 자체 바이너리 통신 형식을 준비하는 방향으로 진행한다. -HTTP는 웹·기존 도구와 연결하는 호환 경계로 유지한다. 첫 검증 전송은 TLS 1.3/TCP이며, -최종 범용 전송 정책은 아직 선택하지 않았다. +HTTP는 웹·기존 도구와 연결하는 호환 경계로 유지한다. 첫 검증 전송은 TLS 1.3/TCP였다. +노드 사이의 전송은 2026-09-29에 연결 없는 링크로 정했다(위). 외부 런타임·서비스·프로그램과의 통신도 핵심 요구사항이다. Pure끼리의 자체 형식과 별도로 상대가 사용하는 프로토콜·형식으로 요청을 받고 보낼 수 있어야 한다. 외부에 Pure 사용을 diff --git a/docs/design/009-layout.md b/docs/design/009-layout.md index 3c36194..5339c5b 100644 --- a/docs/design/009-layout.md +++ b/docs/design/009-layout.md @@ -92,7 +92,7 @@ git 저장소로는 떼지 않았다. 저장소를 나눈 비용은 경계를 � 링크가 33 %, Vega가 6 %다. - Vega는 C 3만 3천 줄 가운데 Pure 헤더를 쓰는 곳이 3개 파일(4.7 %)이다. 따로 두는 편이 싸다. -- 링크의 전선 형식은 011의 남은 단계(에폭, 경로, 보안, M6)에서 더 바뀐다. +- 링크의 전선 형식은 이제 명세다([012](012-link-protocol.md), 판 0). 다음 판은 판 번호와 키 유도 문자열을 함께 바꾼다. - 명세를 쓰고 Pure 밖의 구현이 생기면 `git subtree split`으로 그대로 뗀다. ## 비용 diff --git a/docs/design/011-pure-transport.md b/docs/design/011-pure-transport.md index 034321e..5513476 100644 --- a/docs/design/011-pure-transport.md +++ b/docs/design/011-pure-transport.md @@ -1,6 +1,7 @@ # 011 — 연결 없는 전송: 전송의 상태를 앱의 데이터로 -작성일: 2026-09-28(같은 날 첫 판을 버리고 다시 씀). 상태: 설계. 1단계를 구현했고 M1, M2, M4, M5, M6를 쟀다. +작성일: 2026-09-28(같은 날 첫 판을 버리고 다시 씀). 상태: 채택(2026-09-29). Pure 노드 사이 전달의 기본이다. 명세는 +[012](012-link-protocol.md)다. [외부 피드백 002](../outside-feedback/feedback-002.md)의 "QUIC을 능가할 자체 raw socket 프로토콜"에서 시작한다. 1단계(링크 시제품과 kill -9 검사)는 [093](../research/093-connectionless-link.md)에 있다. 어느 쪽을 죽여도 핸드셰이크 없이 @@ -49,6 +50,25 @@ M3(저장소 옮기기, H3)는 [100](../research/100-moved-receiver.md)에 있� - M5를 ngtcp2와도 쟀다. 1 KiB 이상에서 Pure가 두 QUIC 모두보다 25 % 넘게 가볍고, 61 B에서는 ngtcp2의 1.04배, quinn의 0.90배다. - 패킷마다의 스트림 이름은 비용이 아니었다. 빼 보니 잡음 안이라 되돌렸다. +5단계(채택과 명세)는 2026-09-29에 했다. 중단 기준표의 마지막 줄대로다. + +- H1–H4가 모두 섰다. +- 잃음·겹침 검사, 논스 검사, 보안 검사를 모두 통과한다. +- 전달당 CPU는 1 KiB 이상에서 두 QUIC보다 25 % 넘게 가볍다. 61 B에서는 ngtcp2의 1.04배로 10 % 안이다. +- TCP와의 몫은 ±25 % 안이다. +- 전송 코드는 C 3,097줄이다. + +그래서 연결 없는 링크를 노드 사이 전달의 기본으로 삼는다(`--send OUTPUT=pure://host:port/INPUT`). HTTP 전달은 제3자와 +브라우저를 잇는 경계로 남고, 중계를 지나는 봉인한 전달([088](../research/088-sealed-sessions.md))도 그대로다. M6를 ngtcp2와 +재지는 않았다. 가드레일은 quinn과 TCP로 쟀다. + +명세 [012](012-link-protocol.md)를 쓰면서 구현의 구멍 셋을 찾아 고쳤다. +- 받는 쪽이 받지 않는 스트림을 보내는 쪽은 아무 말도 듣지 못해 매초 다시 보냈다. 이제 REFUSED로 이유를 듣고 드물게 다시 시도한다. +- 받는 쪽이 쥘 수 없는 16–64 MiB 기록이 영원히 다시 갔다. +- 한 노드의 출력 셋 이상이 같은 노드로 가면 run들이 받는 쪽의 두 자리를 서로 밀어냈다. + +명세만 보고 파이썬으로 따로 쓴 받는 쪽이 `pure-serve` 노드와 에폭 바꿈까지 맞붙는다. + 2026-09-29에 링크를 `link/`로 뗐다([009](009-layout.md)). 프로토콜은 이제 소켓, 스레드, 시계가 없는 상태이고 (`link/link-protocol.h`), UDP와 스레드는 그 위의 층이다. 그래서 한 스레드가 링크 여럿을 들 수 있다. M2를 이 보내는 쪽으로 다시 돌렸더니 결과가 같았다. 다만 재시작 뒤 가장 늦은 저장이 1초였다. 받았다고 했지만 저장 소식이 없는 기록을 1초 기다리기 diff --git a/docs/design/012-link-protocol.md b/docs/design/012-link-protocol.md new file mode 100644 index 0000000..359dfbe --- /dev/null +++ b/docs/design/012-link-protocol.md @@ -0,0 +1,324 @@ +# 012 — 링크 프로토콜 명세, 판 0 + +작성일: 2026-09-29. 상태: 명세 판 0. + +- 구현은 `link/link-protocol.c`이고, 호스트 API는 `link/link-protocol.h`다. +- 설계와 측정은 [011](011-pure-transport.md)과 연구 093–101에 있다. +- 이 문서만 보고 파이썬으로 따로 쓴 받는 쪽이 `pure-serve` 노드와 맞붙는다(`tests/check.py`의 + `server_links_independent`). 그 받는 쪽은 `cryptography` 패키지만 쓰고 `link/`의 코드는 쓰지 않는다. 노드가 보낸 주문 + 200건이 모두 저장되고, 첫 패킷 하나 뒤로는 두 쪽이 따로 셈한 에폭 1로 봉인된다. + +이 문서는 Pure 노드 사이의 연결 없는 링크가 선 위에서 하는 일을 정한다. 다른 구현이 이 문서만 보고 Pure 노드와 주고받을 수 있게 +하는 것이 목표다. 그래서 바이트 배치, 키 유도, 그리고 상대가 알아야 하는 규칙을 모두 적는다. 혼잡 제어처럼 한쪽이 혼자 정해도 +되는 것은 "지역 선택"으로 따로 표시한다. + +## 용어 + +- **노드**: X25519 키 쌍을 가진다. 공개 키는 32 B다. +- **보내는 쪽**: 한 노드의 출력 하나가 저장한 보내기를 다른 노드의 입력 하나로 보낸다. 시작할 때마다 **run**을 새로 뽑는다. + run은 0이 아닌 무작위 u64다. +- **받는 쪽**: 받아들일 보내는 쪽을 (source 이름, 공개 키) 목록으로 가진다. +- **스트림**: source 이름과 input 이름의 쌍이다. 각 이름은 1–64 B다. 메시지의 shape(u32)가 함께 간다. +- **기록**: 스트림 안에서 1부터 연속된 번호(u64)를 가진 바이트열이다. 0–16 MiB다. +- **들은 번호**: 받는 쪽 앱이 적용하고 저장한 마지막 기록 번호다. +- **답**: 받는 쪽이 보내는 쪽의 run에 보내는 패킷이다. + +정수는 모두 little-endian이다. 논스를 만들 때 패킷 번호만 big-endian으로 넣는다(QUIC과 같다). + +## 전송 + +링크는 UDP 위에 있다. 데이터그램 하나가 패킷 하나다. 받는 쪽의 주소와 포트, 입력 이름은 배선이 준다 +(`pure://host:port/INPUT`). 받는 쪽의 공개 키도 배선이 준다(`--seal OUTPUT=KEY`). + +- 패킷은 37–1,452 B다. 머리 21 B, 봉인한 몸 0 B 이상, 태그 16 B다. +- 보내는 쪽은 처음에 1,200 B 이하로 보낸다. 경로가 1,452 B를 나른다고 확인한 뒤에만 그만큼 보낸다(아래 "경로 MTU"). +- 답은 1,200 B 이하다. +- 이 범위를 벗어난 데이터그램은 답 없이 버린다. + +## 패킷 + +| 위치 | 크기 | 내용 | +|---:|---:|---| +| 0 | 1 | `0x70 \| (에폭 & 0x0f)`: 높은 4비트는 판(0x7이 판 0), 낮은 4비트는 봉인한 에폭 번호의 아래 4비트 | +| 1 | 4 | 봉인한 노드의 키 id(u32): 그 노드 공개 키 32 B의 FNV-1a 32비트(시작값 2166136261, 곱수 16777619) | +| 5 | 8 | run(u64): 보내는 쪽 패킷은 보내는 쪽의 run, 답은 받는 쪽이 그 run에 쓰는 답 run | +| 13 | 8 | 패킷 번호(u64): run 안에서 0부터 하나씩 는다 | +| 21 | n | 프레임들을 AES-128-GCM으로 봉인한 것. 추가 인증 데이터는 머리 21 B 전부다 | +| 21+n | 16 | GCM 태그 | + +키 id는 찾기 위한 힌트일 뿐이다. 두 키가 같은 id를 가질 수 있으므로, 받는 쪽은 그 id를 가진 허용된 키를 차례로 시도한다. + +## 판 + +첫 바이트의 높은 4비트가 판이다. 이 문서는 판 0(0x7)을 정한다. 다른 판의 패킷은 답 없이 버린다. + +판 협상은 선 위에 없다. 받는 쪽이 말하는 판은 키와 함께 배선에서 온다. 인증되지 않은 판 협상 패킷은 위조와 증폭의 길이 되고, +두 노드는 어차피 서로의 키를 미리 알아야 한다. 다음 판은 첫 바이트의 높은 4비트와 키 유도 문자열의 `0`을 함께 바꾼다. 그래서 +판이 다른 두 구현은 서로의 패킷을 열 수 없고, 한 판의 키가 다른 판에서 다시 쓰이지 않는다. + +## 키 + +**정적 비밀.** `S = X25519(자기 개인 키, 상대 공개 키)`의 32 B 그대로다. 양쪽에서 같다. 모두 0이면(작은 차수의 키) 링크를 +만들지 않는다. + +**HKDF.** HKDF-SHA256(RFC 5869)의 Extract와 Expand를 쓴다. salt는 ASCII `pure link 0`(11 B)이고, IKM은 32 B 비밀이다. + +**패킷 키.** `HKDF(비밀, info, 28)`의 앞 16 B가 AES-128 키, 뒤 12 B가 논스 기반이다. + +``` +info = "pure link 0 keys" (16 B) ‖ from 공개 키 (32) ‖ to 공개 키 (32) ‖ from_run (u64) ‖ to_run (u64) +``` + +| 패킷 | 비밀 | from | to | from_run | to_run | +|---|---|---|---|---|---| +| 보내는 쪽 → 받는 쪽 | 보내는 쪽이 봉인하는 에폭의 비밀 | 보내는 쪽 | 받는 쪽 | 보내는 쪽 run | 0 | +| 답 | 그 run이 지금 있는 에폭의 비밀 | 받는 쪽 | 보내는 쪽 | 답 run | 보내는 쪽 run | +| LOST | 에폭 0의 비밀(S) | 받는 쪽 | 보내는 쪽 | 새로 뽑은 답 run | 보내는 쪽 run | + +**논스.** 논스 기반 12 B의 4–11번째 바이트에 패킷 번호를 big-endian으로 XOR한다. run이 키에 들어가므로, 노드가 다시 시작해도 +한 키 아래 논스가 겹치지 않는다. 받는 쪽은 보내는 쪽 run마다 답 run을 새로 뽑는다. 쉬던 보내는 쪽이 돌아와 자리를 다시 만들 +때에도 새로 뽑는다. + +**에폭.** 에폭 0의 비밀은 S다. 에폭 n(≥ 1)의 비밀은 다음과 같다. + +``` +E(n) = HKDF(IKM = X25519(보내는 쪽 에폭 n 개인 키, 받는 쪽 에폭 n 공개 키), + info = "pure link 0 epoch" (17 B) ‖ n (u32) ‖ E(n-1) (32 B), 32) +``` + +두 개인 키는 합의한 뒤 곧 지운다. 그래서 두 노드의 정적 키가 나중에 새어도 에폭 1부터의 패킷은 열리지 않는다. 사슬을 +처음부터 다시 시작할 때(아래 "에폭을 잃음")에는 E(1)을 다시 E(0) = S에서 만든다. + +## 프레임 + +봉인한 몸은 프레임의 연속이다. 각 프레임은 1 B 종류로 시작한다. 읽는 쪽이 모르는 종류를 만나면 그 패킷의 나머지를 읽지 않는다. +PADDING도 그렇게 끝을 채운다. 이름은 u8 길이(1–64)와 그 바이트다. `names`는 source 이름 다음에 input 이름이다. + +| 종류 | 이름 | 방향 | 배치 | +|---:|---|---|---| +| 0x00 | PADDING | 모두 | 패킷 끝까지 0 | +| 0x01 | STREAM | 보냄 | names, u32 shape | +| 0x02 | RECORD | 보냄 | u64 번호, u32 기록 크기, u32 오프셋, u16 길이, 바이트[길이] | +| 0x03 | ACK | 답 | u64 가장 큰 번호, u32 지연(µs), u8 구간 수, 구간마다 u64 처음과 u64 끝(새것부터) | +| 0x04 | STORED | 답 | names, u64 들은 번호, u8 깃발(1: 틈 뒤의 기록을 받지 않았다), u32 허가 | +| 0x05 | PING | 보냄 | 없음 | +| 0x06 | RECORDS | 보냄 | u64 첫 번호, u8 개수, 기록마다 크기(LEB128)와 바이트 | +| 0x07 | CHALLENGE | 답 | u64 | +| 0x08 | RESPONSE | 보냄 | u64 | +| 0x09 | OFFER | 보냄 | u32 에폭, 공개 키 32 B | +| 0x0a | ACCEPT | 답 | u32 에폭, 공개 키 32 B, 답하는 OFFER 공개 키의 앞 4 B | +| 0x0b | LOST | 답 | u64 패킷 번호 | +| 0x0c | REFUSED | 답 | names, u8 이유(1: 그 이름의 메시지 입력이 없다, 2: 입력의 shape가 다르다, 3: 이 키는 그 source로 보낼 수 없다) | + +- RECORD와 RECORDS는 그 앞의 가장 가까운 STREAM의 스트림에 속한다. 앞에 STREAM이 없으면 버린다. +- RECORDS는 번호가 이어진 온전한 기록들이다. LEB128은 7비트씩, 낮은 것부터, 10 B까지다. 기록 하나가 패킷 끝을 넘으면 그 + 기록과 나머지 패킷을 읽지 않는다. +- RECORD는 기록의 한 조각이다. 받는 쪽은 `오프셋 > 크기`, `길이 > 크기 - 오프셋`, `길이 0이면서 오프셋 > 0`, `크기 > 16 MiB`인 + 것을 버린다. +- 답에서 REFUSED는 같은 패킷의 ACK 뒤에 둔다. 보내는 쪽은 한 답 패킷을 끝까지 읽은 뒤 거절이 끝났는지 판단한다. +- 보내는 쪽 패킷은 모두 확인을 부른다. STREAM이나 PING이 늘 있기 때문이다. 답은 확인을 부르지 않는다. + +## 보내는 쪽 + +**시작.** + +1. run을 뽑는다. +2. S를 만들고 에폭 0의 키로 봉인한다. +3. 에폭 1의 키 쌍을 만들어 OFFER로 낸다. + +첫 패킷부터 기록을 싣는다. 핸드셰이크는 없다. + +**패킷.** + +- 패킷 번호는 run 안에서 다시 쓰지 않는다. 봉인했지만 보내지 못한 패킷의 번호도 다시 쓰지 않는다. +- 탐침(PING뿐인 패킷)을 빼면 모든 패킷은 STREAM으로 시작한다. 다음에 받아 둔 CHALLENGE에 대한 RESPONSE(최근 4개)를 + 넣는다. 그다음 OFFER(받아들여질 때까지), 그다음 기록을 넣는다. +- 보낸 적 없는 온전한 기록은 남은 자리에 들어가면 RECORDS에 넣는다. 번호가 이어지면 한 RECORDS에 모은다. 그 밖에는 RECORD + 조각으로 보낸다. 남은 자리가 64 B 이상이면 조각을 잘라서라도 채운다. +- 번호가 한도 이하인 기록만 보낸다. 한도는 STORED마다 `들은 번호 + min(허가, 65536)`(허가가 0이면 1)으로 계산하고, 그 가운데 + 가장 큰 값이다. STORED를 듣기 전에는 저장되지 않은 가장 오래된 기록 번호 - 1 + 1024다. + +**다시 보내기.** 패킷을 다시 보내지는 않는다. 그 패킷이 나른 기록 조각을 새 패킷에 싣는다. 다음 경우에 다시 보낸다. + +- 패킷을 잃었다고 판단할 때. 판단은 RFC 9002를 따른다(지역 선택). +- ACK로 받았다고 들은 조각이 1초 안에 저장되지 않을 때. 받는 쪽이 다시 시작했을 수 있다. +- STORED에 틈 깃발이 올 때. + +STORED의 들은 번호 이하인 기록은 덜어 낸다. 보내는 앱은 그때 그 보내기를 outbox에서 지운다. + +**답 받기.** 답은 머리의 키 id가 받는 쪽 키의 id인 것만 본다. 머리의 에폭 비트가 가리키는 키로 연다. 후보는 지금 에폭, 그 앞 +에폭, 에폭 0이다. 답 run이 무엇이든 받는다. + +- ACK와 STORED는 가장 큰 값으로만 받는다. 다시 보낸 옛 답은 아무것도 되돌리지 못한다. +- CHALLENGE는 다음에 나가는 패킷에서 RESPONSE로 되돌려 준다. 그것만을 위한 패킷은 만들지 않는다. + +**에폭 바꿈.** + +- 한 시간 또는 1 GiB마다 다음 에폭 n+1의 키 쌍을 OFFER로 낸다. 이 주기는 지역 선택이며 이보다 길면 안 된다. +- ACCEPT의 에폭이 낸 에폭과 같고 끝 4 B가 낸 공개 키의 앞 4 B와 같으면 E(n)을 만든다. 그 다음부터 에폭 n으로 봉인한다. +- 앞 에폭의 키는 늦게 오는 답을 열 때까지만 둔다. + +**에폭을 잃음.** + +- 아직 날고 있는 패킷을 가리키는 LOST를 들으면, 에폭 0으로 돌아가 에폭 1을 다시 낸다. 저장되지 않은 기록은 모두 다시 보낸다. + 날고 있던 패킷은 혼잡으로 세지 않는다. 에폭 1 이상에 있거나 사슬을 다시 시작하던 중일 때만 이렇게 한다. +- 에폭 1 이상에서 확인을 부르는 패킷이 나가 있는데 2초 동안 아무 답도 없고 탐침 4번이 연달아 답을 받지 못하면, 에폭 0으로 + 돌아가 에폭 1을 다시 낸다. 이때는 받는 쪽이 받아들일 때까지 기록을 싣지 않고, OFFER만 RTT(100 ms 이상)마다 보낸다. 받은 + 것이 없으니 공격자가 답을 막고 있을 수도 있어, 정적 키 아래로 기록을 다시 보내지 않는다. + +**거절.** 자기 스트림에 대한 REFUSED를 들으면 기록을 보내지 않는다. + +- 기다린 뒤 기록을 실은 패킷 하나로 다시 시도한다. +- 기다림은 1초에서 시작해 거절될 때마다 두 배로 늘고, 64초에서 멈춘다. +- 시도한 패킷을 확인한 답에 REFUSED가 없으면 거절이 끝난다. +- STORED를 들어도 끝나며, 이때 기다림도 처음으로 돌아간다. +- 시도한 패킷보다 앞선 패킷들에 대한 REFUSED는 무시한다. + +**경로 MTU**(DPLPMTUD, RFC 8899). + +- 패킷이 가득 찬 적이 있고 RTT를 한 번 잰 뒤, 1,452 B짜리 PING(나머지는 PADDING)을 탐침으로 보낸다. +- 탐침이 확인되면 그 크기로 보낸다. +- 탐침을 잃으면 1초 뒤에 다시 보낸다. 세 번 잃으면 그다음은 10분 뒤다. +- 다음 둘 가운데 하나가 되면 1,200 B로 돌아가 1초 뒤에 다시 탐침한다. + - 작은 패킷만 확인되는 동안 큰 패킷을 연달아 3개 잃는다. + - 탐침 시간 초과(PTO)가 2번 연달아 난다. + +## 받는 쪽 + +**여는 순서.** + +1. 크기와 판을 본다. +2. 키 id가 같은 허용된 키 가운데 그 run을 아는 것부터, 다음에 모르는 것을 차례로 시도한다. +3. run마다 다음 에폭, 지금 에폭, 앞 에폭의 키를 머리의 에폭 비트가 맞을 때 시도한다. 그 run이 에폭 1 이상에 있고 에폭 + 비트가 0이면, 사슬을 다시 시작한 것일 수 있으니 에폭 0의 키도 시도한다. + +- 처음 보는 run은 에폭 0에서 시작한다. 그 run의 에폭을 기억해 두었다면 그 에폭에서 시작한다(아래 "메모"). +- 처음 보는 run의 키는 토큰이 있을 때만 만든다. 그 키로 열리지 않으면 토큰 하나를 쓴다. 토큰은 초당 256개 차고 256개까지 + 쌓인다(지역 선택). 그래서 꾸며 낸 패킷 폭주에 드는 HKDF에 상한이 있고, 열리는 보내는 쪽 천 개가 한꺼번에 깨어나도 토큰을 + 쓰지 않는다. +- run의 자리는 패킷이 열린 뒤에만 만든다. 위조한 패킷은 아무 자리도 밀어내지 못한다. +- 이미 받은 패킷 번호(구간 32개까지 기억)의 패킷은 ACK만 다시 하고 프레임은 처리하지 않는다. 그보다 오래된 번호는 기록의 + 들은 번호와 STORED의 "가장 큰 값" 규칙이 막는다. + +**주소.** + +- 답은 그 run에서 번호가 가장 큰 패킷이 온 곳으로 보낸다. 받은 주소(원격)와 도착한 자기 주소(로컬)에서 보낸다. +- 새 주소로는 CHALLENGE를 보낸다. 같은 값이 RESPONSE로 돌아오기 전까지, 그 주소로 보내는 답은 그 주소에서 받은 바이트의 3배를 + 넘지 않는다(RFC 9000 §8). +- 옛 패킷을 다시 보내도 주소는 움직이지 않는다. + +**답.** + +- 확인을 부르는 패킷을 받은 뒤의 다음 차례에 답 패킷 하나에 다음을 넣는다. + - CHALLENGE(확인 전) + - ACCEPT(다음 에폭이 쓰이기 전) + - ACK: 가장 큰 번호를 받은 뒤 지난 시간과 구간들 + - 그 run이 마지막으로 보낸 스트림마다 STORED + - REFUSED +- STORED는 저장이 끝났을 때에도 보낸다. + - 허가는 1024다. 받는 쪽이 그 스트림에서 16 MiB를 쥐고 있으면 1이다. + - 들은 번호 + 1024보다 큰 기록은 버린다. + - 들은 번호 이하의 기록이 다시 오면 STORED를 다시 보낸다. +- 받는 쪽은 들은 번호 다음부터 번호 순서로 온전한 기록을 앱에 넘긴다. 앱이 적용하고 저장한 뒤에만 STORED의 들은 번호를 올린다. + 틈에서 멈추고, 틈 뒤의 기록을 버렸으면 깃발 1을 세운다. + +**OFFER를 받아들임.** 다음 둘 가운데 하나일 때만 받아들인다. + +- 에폭 0의 패킷에 실린 에폭 1(사슬의 시작 또는 다시 시작) +- 지금 에폭 n의 패킷에 실린 n+1 + +받아들이는 방법과 제한은 다음과 같다. + +- 받는 쪽은 에폭 n의 키 쌍을 만들어 E(n)을 셈하고 ACCEPT를 보낸다. 그 에폭의 패킷이 처음 열릴 때까지 답마다 다시 넣는다. +- 같은 OFFER가 다시 오면 같은 ACCEPT를 보낸다. +- 한 피어에 새 에폭은 초당 한 번까지다. +- 지역 선택으로 다음 제한을 둔다. + - 기록과 함께 온 OFFER는 초당 256번(한꺼번에 8번)까지만 받아들인다. + - 받는 쪽 키 쌍은 1초 동안 그 초의 ACCEPT들이 함께 쓴다. + - X25519는 답을 보낸 뒤에 한다. +- 사슬을 다시 시작한 run에는 답도 에폭 0으로 돌아가 보낸다. + +**LOST.** 다음 조건을 모두 만족하면 LOST를 보낸다. + +- 처음 보는 run이다. +- 에폭 비트가 0이 아니다. +- 그 에폭을 모른다. 다시 시작하면서 메모를 잃었거나, 메모가 그 피어의 다른 run을 들고 있을 때다. +- 패킷이 46 B보다 크다. +- 초당 4,096번 이하다. + +LOST는 그 패킷의 번호를 담아 에폭 0의 키로 봉인한다. 답 run은 새로 뽑고, 패킷 번호는 0이다. 크기는 46 B로, 그것을 부른 +패킷보다 작다. QUIC의 stateless reset(RFC 9000 §10.3)과 같은 자리이지만, LOST는 두 노드의 정적 키로 봉인되어 경로 위의 +공격자가 만들 수 없다. + +**REFUSED.** STREAM을 받아들이지 않으면, 그 run의 다음 답에 그 스트림에 대한 REFUSED를 한 번 넣는다. + +**쉬는 피어.** 30초 동안 조용하고 쥔 것이 없는 피어는 run, 주소, 스트림을 놓는다(지역 선택). 다음 패킷이 다시 만든다. + +**메모.** 선 위의 일은 아니지만 재시작에 관계된다. 받는 쪽은 피어마다 S와, 그 피어의 run 하나에 대해 받아들인 에폭을 파일에 +적어 둘 수 있다. 다시 떠도 그 run은 비대칭 연산 없이 이어진다. 메모에 없는 run은 LOST를 받고 에폭을 다시 시작한다. + +## 수와 지역 선택 + +| 이름 | 값 | 성격 | +|---|---:|---| +| 판 0의 첫 바이트 | 0x70–0x7f | 명세 | +| 머리 / 태그 | 21 B / 16 B | 명세 | +| 첫 데이터그램 / 가장 큰 데이터그램 | 1,200 B / 1,452 B | 명세 | +| 이름 | 1–64 B | 명세 | +| 기록 | 16 MiB 이하 | 명세 | +| 허가의 상한(보내는 쪽이 따르는) | 65,536 | 명세 | +| ACCEPT의 되풀이 | 4 B | 명세 | +| 첫 허가(STORED 전) | 1,024 | 명세 | +| 새 주소의 증폭 한도 | 받은 바이트의 3배 | 명세 | +| LOST 크기 조건 | 부른 패킷 > 46 B | 명세 | +| 에폭 주기 | 1시간 또는 1 GiB 이하 | 상한만 명세 | +| 거절 뒤 기다림 | 1초부터 두 배, 64초까지 | 지역 선택 | +| 에폭을 잃었다고 볼 때 | 2초와 PTO 4번 | 지역 선택 | +| 받은 뒤 저장을 기다리는 시간 | 1초 | 지역 선택 | +| RTT 첫값, ACK 지연 상한, PTO 상한 | 100 ms, 25 ms, 1초 | 지역 선택 | +| 혼잡 제어 | CUBIC(RFC 9438, C 0.4, β 0.7), 첫 창 10 데이터그램, 페이싱, 잘못 본 손실은 되돌림 | 지역 선택(표준 알고리즘만) | +| 재정렬 인내 | 패킷 3–256개, 시간 RTT/8부터 최소 RTT의 1/4씩 RTT까지 | 지역 선택 | +| 키 만들기 / 에폭 수락 / LOST | 초당 256 / 256(한꺼번에 8) / 4,096 | 지역 선택 | +| 쉬는 피어 | 30초 | 지역 선택 | +| 피어당 run 자리 | 64까지 | 지역 선택 | + +## 오류와 알림 + +선 위의 알림은 둘이다. 둘 다 인증된다. + +- **LOST**: 받는 쪽이 그 run의 에폭을 모른다. +- **REFUSED**: 받는 쪽이 그 스트림을 받지 않는다. 이유 부호는 위와 같다. `link/link.c`는 이유를 운영자에게 한 줄로 알린다. + HTTP 전달이 받는 쪽의 거절을 알리는 것과 같은 모양이다. + +그 밖의 실패(열리지 않는 패킷, 허용되지 않은 키, 모르는 판)에는 답하지 않는다. 답하면 공격자에게 신호와 증폭을 준다. + +## 보안 성질 + +경로 위의 공격자에 대해서는 [098](../research/098-link-security.md)과 [099](../research/099-link-epochs.md)의 검사가 +`link/test-link.c`에 있다. + +- **바꾼 패킷**: 열리지 않는다. 그 run을 모르는 받는 쪽이 에폭 1 이상의 패킷에 보내는 LOST(그보다 작다) 말고는 답이 없다. +- **다시 보낸 패킷**: 기록은 한 번만 적용된다. ACK와 STORED는 가장 큰 값으로만 받는다. 주소는 움직이지 않는다. +- **위장**: 허용되지 않은 키나 source로는 아무것도 열리거나 전달되지 않는다. +- **폭주**: 모르는 run의 HKDF는 초당 256번, X25519는 피어마다 한 번(메모)이다. +- **나중에 두 노드의 키를 얻음**: 에폭 0의 패킷만 열린다. run의 첫 왕복과, LOST 뒤 다음 에폭이 받아들여지기 전까지가 에폭 0이다. + QUIC 0-RTT와 같은 한계다. + +알려진 한계: + +- 받는 쪽의 개인 키를 가진 이는 그 받는 쪽에게 어느 보내는 쪽으로든 봉인할 수 있다(Noise KK의 첫 메시지와 같은 KCI). 에폭은 + 이것을 바꾸지 않는다. 다만 다른 run의 에폭 안으로는 들어오지 못한다. +- 메모는 피어마다 run 하나의 에폭만 든다. 한 노드의 출력 여럿이 같은 노드로 가면, 받는 쪽이 다시 뜬 뒤나 30초 넘게 쉰 뒤에 + 나머지 run은 LOST를 받고 에폭 0에서 다시 시작한다. +- NAT 뒤에서 쉬는 기기에 먼저 보내는 일은 풀지 않는다. + +## 이 명세와 구현 + +- 구현은 `link/link-protocol.c`(상태, 소켓 없음)와 `link/link.c`(UDP, GSO/GRO, 스레드)다. +- 전송 코드는 C 3,097줄이다(프로토콜 2,510, UDP 587). 011의 한도는 5,000줄이다. +- 이 명세를 쓰면서 구현의 구멍 셋을 찾아 고쳤다. 셋 다 `link/test-link.c`에 검사가 있다. + - 받는 쪽이 스트림을 받지 않을 때 보내는 쪽이 아무 말도 듣지 못해 매초 기록을 다시 보냈다. REFUSED를 더했다. + - 보내는 쪽은 64 MiB까지의 기록을 받아 두었지만 받는 쪽은 16 MiB까지만 쥘 수 있었다. 그 사이 크기의 기록은 영원히 다시 + 갔다. 기록의 상한을 16 MiB로 맞췄다. + - 받는 쪽은 피어마다 run 자리를 둘만 두었다. 한 노드의 출력 셋 이상이 같은 노드로 가면 run들이 서로 자리를 밀어내어, 답이 + 다른 run으로 갔다. 살아 있는 run이 더 오면 자리를 늘린다. |
