aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authoreunhhu <[email protected]>2026-09-27 19:17:02 +0100
committereunhhu <[email protected]>2026-09-27 19:17:02 +0100
commit6d276be51ba89b04a60bb16b5179c015ad202c7c (patch)
tree1d30ae7bbb2d1db9e9a1b627d0fb76dc26eaf074
parent313c4fa1720a0019c87083d3523b6643606c5aa6 (diff)
downloadpure-6d276be51ba89b04a60bb16b5179c015ad202c7c.tar.gz
pure-6d276be51ba89b04a60bb16b5179c015ad202c7c.zip
Seal in one session a wire, authenticated by the sender's key (088)
A sealed request cost three X25519 operations (about 0.5 ms), so sealed sends drained at half the plain rate. A wire now keeps one HPKE context, encapsulated at its first seal, and seals each request as the context's next number (a failed seal or a restart starts a new session); the header carries the session's encapsulated key and the number, so a receiver opens a session once per connection thread (16 remembered, the least used replaced) and each seal by its number, also after it restarts, unasked. A sender with a key of its own seals in HPKE auth mode and names that key in the header; a receiver started with --sender NAME=KEY takes deliveries only from the senders it names, each sealed with its key (403 sender otherwise). The body is now the ciphertext and its tag. Sealed sends, sender authenticated, drain at 461-464k a second against 462-470k plain and 207-234k sealed a request at a time (Raspberry Pi 5). The check adds: a wire's requests share one session numbered from 0; a changed number or sender key does not open; a receiver restarted naming its sender opens the same session again; another node claiming that sender's name with its own key is refused and applies nothing.
-rw-r--r--CHECKPOINT.md2
-rw-r--r--README.md7
-rw-r--r--docs/research/088-sealed-sessions.md83
-rw-r--r--research/experiments/014-sealed-sends/2026-09-27-sessions-linux-aarch64.jsonl9
-rw-r--r--research/experiments/014-sealed-sends/drain.py14
-rw-r--r--serve/crypto-openssl.c89
-rw-r--r--serve/crypto.c26
-rw-r--r--serve/crypto.h16
-rw-r--r--serve/deliver.c94
-rw-r--r--serve/deliver.h9
-rw-r--r--serve/seal.h53
-rw-r--r--serve/serve.c145
-rw-r--r--tests/check.py110
13 files changed, 502 insertions, 155 deletions
diff --git a/CHECKPOINT.md b/CHECKPOINT.md
index 6aaf810..3373ba7 100644
--- a/CHECKPOINT.md
+++ b/CHECKPOINT.md
@@ -15,7 +15,7 @@
| 앱 표현 | 상태(수, text와 커서·마크, 레코드의 리스트와 키 있는 리스트, 인덱스), 입력(키, text, 필드 있는 메시지, 타이머), 원자적 이벤트 핸들러, 순수 보기(조건식, 표와 걸러진 표, count·sum·max·min·first), `fn`·`use` 라이브러리, text 비교, 시간(`now`, `every`, `floor`, `%`). 로드할 때 타입·스택·합류·커서와 인덱스의 소속을 모두 검증하고 이벤트의 실행량에 상한이 있다 | 008, aup `docs/design/002-dsl-direction.md`, aup `make check`(파이썬 참조 모델 대조, 진단 132건) |
| 영속 | 선언한 상태가 곧 영속 상태다. 트리 저장소에 커밋당 동기화 한 번(헤드를 바이트 바로 앞에 두어 한 연속 구간), 이벤트 묶음마다 저장 한 번. `kill -9` 뒤 확인 응답한 이벤트가 모두 남는다. 레코드·키가 바뀐 모듈로 바꿔도 데이터가 이름으로 옮겨진다 | [076](docs/research/076-one-sync-commit.md), [081](docs/research/081-tail-records.md), 008 상태 영속·교체 |
| 인터페이스 | 한 명세가 TUI(셀 단위 갱신), CLI, 문장(NUI), 웹 GUI가 된다. 저장하면 실행 중인 앱이 상태를 이어 새 모듈로 바뀐다(`aup watch`, `vega … --watch`) | Vega `docs/design/001-first-component.md`, Vega `make check`(가상 터미널, 헤드리스 Chromium 포함) |
-| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 앱의 `send`는 이벤트와 함께 저장되고, `--send 출력=URL`로 다른 앱이나 HTTP 끝점에 순서대로 묶어 전해지며, 받는 앱은 번호마다 한 번 적용한다(서버를 죽였다 살려도 정확히 한 번). `--seal 출력=공개키`면 받는 노드의 키로 봉인해(HPKE) 사이의 중계가 읽지도 바꾸지도 못한다(봉인 전달 233k/s, 봉인 없이 470k/s). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md) |
+| HTTP 서비스 | 보기가 GET, 입력이 POST다(JSON 본문과 그 안의 그룹 객체를 제자리에서 읽는다, 라우터 코드 없음). 앱의 `send`는 이벤트와 함께 저장되고, `--send 출력=URL`로 다른 앱이나 HTTP 끝점에 순서대로 묶어 전해지며, 받는 앱은 번호마다 한 번 적용한다(서버를 죽였다 살려도 정확히 한 번). `--seal 출력=공개키`면 받는 노드의 키로 봉인해(HPKE, 전달선마다 세션 하나, 보내는 노드의 키로 인증) 사이의 중계가 읽지도 바꾸지도 못하고, 받는 노드는 `--sender 이름=키`로 보낸 이를 가린다(봉인 전달 462k/s, 봉인 없이 466k/s). 답은 저장 뒤에 나간다. 상태를 가진 앱은 소유자 스레드 하나가 입력을 적용하고, 연결 스레드는 마지막 저장의 스냅숏으로 보기를 답한다(쓰기가 동기화하는 동안에도 읽기는 기다리지 않는다) | 008 HTTP 호스트, [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md), [086](docs/research/086-sealed-sends.md), [088](docs/research/088-sealed-sessions.md) |
| 성능(Pi 5) | 2026-09-27, 같은 기기에서 axum·drogon과 함께 쟀다. 문서 서버(한 스레드): 64 B GET 139k/s(CPU 1초당 140k; axum 127k·63k, drogon 147k·63k), 1 MiB GET 2,362/s(axum 1,304, drogon 754), 충돌 안전 PUT은 axum-atomic의 5.1–5.8배. 앱 서비스: 내구성 있는 쓰기 64연결 21.3k/s(axum·drogon 내구 약 200/s), 합계 GET 126k/s·두 스레드 153k(axum 136k, drogon 151k), 20행 페이지 두 스레드 148k(axum 93k, drogon 138k), 쓰기와 섞인 읽기 119k/s(axum·drogon 내구 1.1–1.4k/s). 유휴 RSS 2.3 MiB(axum 2.8, drogon 10.9), 최대 3.2–5.8 MiB(axum 6.6–9.7, drogon 11.9–78.8) | [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md), [080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md), [087](docs/research/087-small-reads-and-idle-memory.md) |
키로 나눈 소유자와 저장을 넘길 스레드는 하지 않기로 했다. 이 장치는 동기화를 한 줄로 세워, 나누면 쓰기가 23% 줄고 넘겨도
diff --git a/README.md b/README.md
index 4aa46ba..bb5cecb 100644
--- a/README.md
+++ b/README.md
@@ -7,12 +7,13 @@ terminal UI, a CLI, spoken sentences and a web page through [Vega](/srv/git/vega
`pure-serve`: views are GETs and inputs are POSTs, with JSON bodies (groups included) read in place; answers are
durable, a commit being one contiguous write and one sync; views are read from a snapshot of the last save, so
reads never wait for a sync. What an app sends is saved with its event and delivered over HTTP in order and in
-batches, and a receiving app applies each message once; sealed to the receiver's key (HPKE), no relay between
-them can read or change it. Measurements against axum and drogon on a Raspberry Pi 5
+batches, and a receiving app applies each message once; sealed to the receiver's key (HPKE, one session a wire,
+authenticated by the sender's key), no relay between them can read or change it, at the speed of unsealed sends. Measurements against axum and drogon on a Raspberry Pi 5
are in [075](docs/research/075-http-host-against-axum.md), [077](docs/research/077-apps-as-http-services.md),
[080](docs/research/080-snapshot-reads.md), [081](docs/research/081-tail-records.md),
[082](docs/research/082-json-in-place.md), [083](docs/research/083-delivery-over-http.md),
-[086](docs/research/086-sealed-sends.md) and [087](docs/research/087-small-reads-and-idle-memory.md); the status table is
+[086](docs/research/086-sealed-sends.md), [087](docs/research/087-small-reads-and-idle-memory.md) and
+[088](docs/research/088-sealed-sessions.md); the status table is
[CHECKPOINT.md](CHECKPOINT.md).
The source follows the principle below, a small core with the rest selected around it
diff --git a/docs/research/088-sealed-sessions.md b/docs/research/088-sealed-sessions.md
new file mode 100644
index 0000000..4f9bda2
--- /dev/null
+++ b/docs/research/088-sealed-sessions.md
@@ -0,0 +1,83 @@
+# 088 — 봉인한 세션과 보낸 이: 키 합의는 세션에 한 번, 출처는 키에 묶어
+
+2026-09-27. [086](086-sealed-sends.md)의 "다음" 중 둘. 봉인한 전달이 봉인하지 않은 전달의 절반 속도였던 것(요청마다 X25519
+세 번)과, base 모드라서 `X-Pure-Source`가 인증되지 않던 것.
+
+착수 기준:
+
+- 바꿀 결정: 요청마다 캡슐화할지, 전달선마다 세션을 하나 두고 그 안에서 번호로 봉인할지(RFC 9180의 여러 번 봉인). 보낸 이는
+ HPKE auth 모드로 인증할지(보내는 노드의 키가 봉인에 들어간다), 서명을 따로 붙일지.
+- 최소 확인: 받는 쪽이 다시 시작해도 따로 묻지 않고 세션을 여는지, 번호나 보낸 이 키를 바꾸면 열리지 않는지, 이름 붙은 보낸 이의
+ 이름을 다른 키로 쓰는 노드가 거절되는지 검사로 지킨다. 전달 속도를 요청마다 캡슐화하던 빌드와 번갈아 잰다.
+- 중단 기준: 봉인하지 않은 전달이 느려지거나, 한 번 봉인한 것이 다른 번호로 열리면 되돌린다.
+
+## 한 것
+
+**세션.** 보내는 쪽은 전달선마다 HPKE 문맥을 하나 두고, 첫 봉인 때 한 번 캡슐화한다. 그 뒤 요청은 문맥의 번호로 봉인한다(넌스는
+기본 넌스와 번호의 XOR). 번호는 OpenSSL의 보내는 쪽 문맥이 스스로 올리므로 같은 넌스가 두 번 쓰이지 않는다. 봉인이 실패하면
+다음 시도는 새 세션을 만든다. 프로세스가 다시 시작하면 새 세션이다. 헤더가 세션과 번호를 싣는다.
+
+```
+X-Pure-Seal: x25519-sha256-aes128gcm <캡슐화한 키 64 hex> <번호> [<보낸 이의 공개키 64 hex>]
+```
+
+본문은 이제 암호문과 태그 16 B뿐이다(캡슐화한 키는 헤더로 갔다). 받는 쪽의 연결 스레드는 연 세션을 16개까지 기억하고, 가장 덜
+쓴 것부터 바꾼다. 세션마다 캡슐화를 한 번 풀고(X25519 한두 번), 요청마다 번호를 정해(`OSSL_HPKE_CTX_set_seq`, 받는 쪽만 할
+수 있다) 연다. 캡슐화한 키가 요청마다 오므로 받는 쪽이 다시 시작하거나 요청이 다른 스레드로 가도 묻지 않고 연다. 봉인은 여전히
+요청의 출처·첫 번호·개수에 묶인다(AAD).
+
+**보낸 이.** 보내는 노드에 `--key`가 있으면 auth 모드로 봉인한다. 그 노드의 키가 키 합의에 들어가므로, 헤더에 적은 보낸 이의
+공개키에 맞는 개인키를 가진 쪽만 받는 쪽이 여는 봉인을 만들 수 있다. 받는 노드는 `--sender NAME=KEY`로 보낸 이를 이름과 키로
+적을 수 있다. 적으면 그 노드는 적힌 보낸 이의 전달만 받는다. 출처가 적힌 이름이고, auth 모드이며, 헤더의 키가 적힌 키와 같아야
+한다. 아니면 403 `sender`다. 헤더의 키가 거짓이면 봉인이 열리지 않는다(400 `seal`).
+
+| 요청이 | 답 |
+|---|---|
+| 봉인이 열린다 | 200, 번호마다 한 번 적용 |
+| 한 바이트, 번호, 보낸 이의 키, 출처·번호·개수 중 무엇이든 바뀌었다 | 400 `seal`, 적용 없음 |
+| 봉인이 없다(`--sealed-only`나 `--sender`인 노드) | 403 `unsealed` |
+| 적힌 보낸 이가 아니거나 그 키가 아니다(`--sender`인 노드) | 403 `sender` |
+
+**검사(`server_sealed`).** 086의 것에 더해 이것들을 본다.
+
+- 보내는 쪽이 제 키로 봉인한다. 한 전달선의 요청(53–57개)이 모두 한 세션이고, 번호가 0부터 하나씩 오른다.
+- 번호를 바꾼 요청과 보낸 이의 키를 바꾼 요청은 400 `seal`이다.
+- 키 없이(base 모드) 다른 노드의 키로 봉인한 요청은 400 `seal`이다.
+- 받는 노드를 `--sender 출처=보낸이키`로 다시 띄우면, 보내는 쪽의 다음 주문이 같은 세션으로 가서 열리고 적용된다.
+- 같은 모듈이라 같은 출처 이름을 쓰는 다른 노드가 제 키로 봉인하면 403 `sender`이고 아무것도 적용되지 않는다.
+
+여섯 번 연달아, 그리고 `make check` 전체에서 통과한다.
+
+## 장치가 말한 것
+
+086과 같은 방법이다. 받는 서버를 멈춘 채 보내기 20,000개를 쌓고 푼다. 세 라운드이고 변형의 순서는 돌린다. 봉인은 보내는 쪽도
+제 키로 한다(auth 모드). 기준은 요청마다 캡슐화하던 빌드(`bdb5f10`)의 봉인이다
+([원시 결과](../../research/experiments/014-sealed-sends/2026-09-27-sessions-linux-aarch64.jsonl)).
+
+| | 보내기/s |
+|---|---:|
+| 봉인 없음 | 462.2–470.2k |
+| 봉인, 세션과 보낸 이 인증(지금) | **461.2–463.7k** |
+| 봉인, 요청마다 캡슐화(전) | 207.3–233.7k |
+
+봉인의 비용이 절반에서 1% 안쪽이 됐다. 요청마다 들던 X25519 세 번(약 0.5 ms)이 세션에 한 번(auth 모드는 보내는 쪽 셋, 받는
+쪽 둘)이 되고, 요청마다 남은 것은 AES-GCM이다(약 10 KiB 묶음에 양쪽 합쳐 약 9 µs).
+
+## 성질과 한계
+
+- **넌스.** 보내는 쪽 문맥의 번호는 OpenSSL이 올리고 되돌릴 수 없다. 세션은 프로세스 안에서만 살고, 실패하면 버린다. 그래서
+ 한 키와 넌스로 두 평문을 봉인하는 일이 없다.
+- **재전송.** 기록한 요청을 다시 보내면 열린다(같은 세션, 같은 번호). 그러나 출처와 번호로 한 번만 적용된다(083의 성질).
+ 다른 번호나 다른 출처로 옮기면 열리지 않는다.
+- **보낸 이 인증의 한계.** auth 모드는 키 누설 사칭(KCI)에 약하다(RFC 9180 9.1.1). 받는 노드의 개인키를 훔친 쪽은 그 노드에게
+ 아무 보낸 이로나 봉인할 수 있다. 받는 노드의 키는 그 노드가 받는 모든 것을 여는 키이기도 하므로, 그것을 지키는 것이 먼저다.
+- **전방 비밀성.** 아직 없다. 받는 노드의 키를 훔치면 기록해 둔 세션이 열린다. 받는 키를 돌려 쓰는 것(서명한 사전 키)이나
+ 메신저의 래칫이 다음이다.
+- **비용 공격.** 모르는 캡슐화 키를 단 요청은 열 때마다 캡슐화를 한 번 푼다(X25519, 약 0.16 ms). 열리지 않는 세션은 기억하지
+ 않는다. 출처별 속도 제한은 남은 일이다.
+- **메타데이터.** 출처, 번호, 개수, 경로, 크기, 그리고 이제 보낸 이의 공개키가 보인다. 중계는 누가 누구에게 보내는지 안다.
+
+## 다음
+
+- 받는 키 돌리기(서명한 사전 키)로 전방 비밀성을 세션 단위로.
+- 메신저: 사용자마다 한 노드, 연락처는 `--sender` 목록, 화면은 Vega.
diff --git a/research/experiments/014-sealed-sends/2026-09-27-sessions-linux-aarch64.jsonl b/research/experiments/014-sealed-sends/2026-09-27-sessions-linux-aarch64.jsonl
new file mode 100644
index 0000000..55c4f0c
--- /dev/null
+++ b/research/experiments/014-sealed-sends/2026-09-27-sessions-linux-aarch64.jsonl
@@ -0,0 +1,9 @@
+{"round": 0, "variant": "plain", "sends": 20000, "sends_per_second": 462162, "receiver_requests": 85, "delivery_attempts": 80}
+{"round": 0, "variant": "sealed", "sends": 20000, "sends_per_second": 461190, "receiver_requests": 85, "delivery_attempts": 80}
+{"round": 0, "variant": "baseline", "sends": 20000, "sends_per_second": 207310, "receiver_requests": 90, "delivery_attempts": 80}
+{"round": 1, "variant": "sealed", "sends": 20000, "sends_per_second": 463529, "receiver_requests": 85, "delivery_attempts": 80}
+{"round": 1, "variant": "baseline", "sends": 20000, "sends_per_second": 233662, "receiver_requests": 89, "delivery_attempts": 80}
+{"round": 1, "variant": "plain", "sends": 20000, "sends_per_second": 468355, "receiver_requests": 85, "delivery_attempts": 80}
+{"round": 2, "variant": "baseline", "sends": 20000, "sends_per_second": 233342, "receiver_requests": 89, "delivery_attempts": 80}
+{"round": 2, "variant": "plain", "sends": 20000, "sends_per_second": 470174, "receiver_requests": 85, "delivery_attempts": 80}
+{"round": 2, "variant": "sealed", "sends": 20000, "sends_per_second": 463747, "receiver_requests": 85, "delivery_attempts": 80}
diff --git a/research/experiments/014-sealed-sends/drain.py b/research/experiments/014-sealed-sends/drain.py
index 1a2819e..b40e820 100644
--- a/research/experiments/014-sealed-sends/drain.py
+++ b/research/experiments/014-sealed-sends/drain.py
@@ -4,10 +4,11 @@
A sender app (tests/check.py's LINK_SENDER) takes --sends orders from eight
clients while its receiver is stopped (SIGSTOP), so the sends pile up in the
sender's store; the receiver is then continued and timed until it has
-applied them all. Runs plain and sealed (--seal to the receiver's --key) in
-alternating order per round, and a --baseline pure-serve (plain only) when
-given. Both servers on cores 0-2. Then the AEADs and X25519 on one core
-(openssl speed). Prints one JSON line per run.
+applied them all. Runs plain and sealed (--seal to the receiver's --key,
+the sender sealing with its own --key) in rotated order per round, and a
+--baseline pure-serve (plain, or sealed with --baseline-sealed) when given,
+with its pure-crypto.so beside it. Both servers on cores 0-2. Then the
+AEADs and X25519 on one core (openssl speed). Prints one JSON line per run.
"""
import argparse
import json
@@ -53,7 +54,7 @@ def run(work, binary, sends, sealed):
key = ['--key', work / 'key' / 'receiver.key'] if sealed else []
receiver, started = start([binary, '--module', work / 'receiver.pmod', '--store', work / 'b', *key])
os.kill(receiver.pid, signal.SIGSTOP)
- seal = ['--seal', f'ordered={started["public_key"]}'] if sealed else []
+ seal = ['--seal', f'ordered={started["public_key"]}', '--key', work / 'key' / 'sender.key'] if sealed else []
sender, sender_started = start([binary, '--module', work / 'sender.pmod', '--store', work / 'a',
'--send', f'ordered=http://127.0.0.1:{started["port"]}/inputs/order', *seal])
@@ -100,6 +101,7 @@ def main():
parser.add_argument('--aup', type=Path, default=ROOT.parent / 'aup' / 'build' / 'aup')
parser.add_argument('--binary', type=Path, default=ROOT / 'build' / 'pure-serve')
parser.add_argument('--baseline', type=Path, help='an earlier pure-serve, run plain')
+ parser.add_argument('--baseline-sealed', action='store_true', help='run the baseline sealed instead')
parser.add_argument('--sends', type=int, default=20000)
parser.add_argument('--rounds', type=int, default=3)
args = parser.parse_args()
@@ -111,7 +113,7 @@ def main():
for round_ in range(args.rounds):
variants = [('plain', args.binary, False), ('sealed', args.binary, True)]
if args.baseline:
- variants.append(('baseline', args.baseline.resolve(), False))
+ variants.append(('baseline', args.baseline.resolve(), args.baseline_sealed))
shift = round_ % len(variants)
for name, binary, sealed in variants[shift:] + variants[:shift]:
result = run(work, binary, args.sends, sealed)
diff --git a/serve/crypto-openssl.c b/serve/crypto-openssl.c
index 38b4628..127ae38 100644
--- a/serve/crypto-openssl.c
+++ b/serve/crypto-openssl.c
@@ -56,11 +56,14 @@ static int tls_read(PureTls *tls, void *bytes, int size) { return SSL_read((SSL
static int tls_write(PureTls *tls, const void *bytes, int size) { return SSL_write((SSL *)tls, bytes, size); }
static void tls_close(PureTls *tls) { SSL_free((SSL *)tls); }
-/* ---- Sealing: HPKE base mode (RFC 9180), X25519 and HKDF-SHA256, with the
- * AEAD the suite names. */
+/* ---- Sealing: HPKE (RFC 9180), X25519 and HKDF-SHA256, with the AEAD the
+ * suite names; auth mode when the sender seals with a key of its own. A
+ * session is one encapsulation, its seals numbered by the context. */
struct PureSealKey { EVP_PKEY *key; };
+struct PureSealer { OSSL_HPKE_CTX *context; };
+struct PureOpener { OSSL_HPKE_CTX *context; };
-static const unsigned char info[] = "pure sealed send 1";
+static const unsigned char info[] = "pure sealed send 2";
static bool suite_named(const char *name, OSSL_HPKE_SUITE *out) {
OSSL_HPKE_SUITE suite = {OSSL_HPKE_KEM_ID_X25519, OSSL_HPKE_KDF_ID_HKDF_SHA256, 0};
@@ -120,34 +123,68 @@ static void seal_key_free(PureSealKey *key) {
free(key);
}
-static bool seal(const char *suite_name, const unsigned char peer[PURE_SEAL_KEY], const char *aad,
- const unsigned char *plain, size_t size, unsigned char *out) {
+static PureSealer *sealer(const char *suite_name, const unsigned char peer[PURE_SEAL_KEY], const PureSealKey *own,
+ unsigned char enc[PURE_SEAL_KEY]) {
OSSL_HPKE_SUITE suite;
- if (!suite_named(suite_name, &suite)) return false;
- OSSL_HPKE_CTX *context = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, suite, OSSL_HPKE_ROLE_SENDER, NULL, NULL);
- size_t encapsulated = PURE_SEAL_KEY, sealed = size + 16;
- bool ok = context && OSSL_HPKE_encap(context, out, &encapsulated, peer, PURE_SEAL_KEY, info, sizeof(info) - 1) == 1 &&
- encapsulated == PURE_SEAL_KEY &&
- OSSL_HPKE_seal(context, out + PURE_SEAL_KEY, &sealed, (const unsigned char *)aad, strlen(aad), plain, size) == 1 &&
- sealed == size + 16;
- OSSL_HPKE_CTX_free(context);
- return ok;
+ PureSealer *made = suite_named(suite_name, &suite) ? calloc(1, sizeof(*made)) : NULL;
+ if (!made) return NULL;
+ made->context = OSSL_HPKE_CTX_new(own ? OSSL_HPKE_MODE_AUTH : OSSL_HPKE_MODE_BASE, suite, OSSL_HPKE_ROLE_SENDER,
+ NULL, NULL);
+ size_t length = PURE_SEAL_KEY;
+ if (!made->context || (own && OSSL_HPKE_CTX_set1_authpriv(made->context, own->key) != 1) ||
+ OSSL_HPKE_encap(made->context, enc, &length, peer, PURE_SEAL_KEY, info, sizeof(info) - 1) != 1 ||
+ length != PURE_SEAL_KEY) {
+ OSSL_HPKE_CTX_free(made->context);
+ free(made);
+ return NULL;
+ }
+ return made;
+}
+
+static bool seal(PureSealer *s, const char *aad, const unsigned char *plain, size_t size, unsigned char *out,
+ uint64_t *n) {
+ size_t sealed = size + PURE_SEAL_OVERHEAD;
+ return OSSL_HPKE_CTX_get_seq(s->context, n) == 1 &&
+ OSSL_HPKE_seal(s->context, out, &sealed, (const unsigned char *)aad, strlen(aad), plain, size) == 1 &&
+ sealed == size + PURE_SEAL_OVERHEAD;
+}
+
+static void sealer_free(PureSealer *s) {
+ if (!s) return;
+ OSSL_HPKE_CTX_free(s->context);
+ free(s);
}
-static bool unseal(const PureSealKey *key, const char *suite_name, const char *aad, const unsigned char *sealed,
- size_t size, unsigned char *plain) {
+static PureOpener *opener(const PureSealKey *key, const char *suite_name, const unsigned char enc[PURE_SEAL_KEY],
+ const unsigned char *from) {
OSSL_HPKE_SUITE suite;
- if (!key || size < PURE_SEAL_OVERHEAD || !suite_named(suite_name, &suite)) return false;
- OSSL_HPKE_CTX *context = OSSL_HPKE_CTX_new(OSSL_HPKE_MODE_BASE, suite, OSSL_HPKE_ROLE_RECEIVER, NULL, NULL);
+ PureOpener *made = key && suite_named(suite_name, &suite) ? calloc(1, sizeof(*made)) : NULL;
+ if (!made) return NULL;
+ made->context = OSSL_HPKE_CTX_new(from ? OSSL_HPKE_MODE_AUTH : OSSL_HPKE_MODE_BASE, suite, OSSL_HPKE_ROLE_RECEIVER,
+ NULL, NULL);
+ if (!made->context || (from && OSSL_HPKE_CTX_set1_authpub(made->context, from, PURE_SEAL_KEY) != 1) ||
+ OSSL_HPKE_decap(made->context, enc, PURE_SEAL_KEY, key->key, info, sizeof(info) - 1) != 1) {
+ OSSL_HPKE_CTX_free(made->context);
+ free(made);
+ return NULL;
+ }
+ return made;
+}
+
+/* A receiver may set the number (a sender may not: it would reuse a nonce). */
+static bool unseal(PureOpener *o, uint64_t n, const char *aad, const unsigned char *sealed, size_t size,
+ unsigned char *plain) {
size_t opened = size - PURE_SEAL_OVERHEAD;
- bool ok = context &&
- OSSL_HPKE_decap(context, sealed, PURE_SEAL_KEY, key->key, info, sizeof(info) - 1) == 1 &&
- OSSL_HPKE_open(context, plain, &opened, (const unsigned char *)aad, strlen(aad), sealed + PURE_SEAL_KEY,
- size - PURE_SEAL_KEY) == 1 &&
- opened == size - PURE_SEAL_OVERHEAD;
- OSSL_HPKE_CTX_free(context);
- return ok;
+ return size >= PURE_SEAL_OVERHEAD && OSSL_HPKE_CTX_set_seq(o->context, n) == 1 &&
+ OSSL_HPKE_open(o->context, plain, &opened, (const unsigned char *)aad, strlen(aad), sealed, size) == 1 &&
+ opened == size - PURE_SEAL_OVERHEAD;
+}
+
+static void opener_free(PureOpener *o) {
+ if (!o) return;
+ OSSL_HPKE_CTX_free(o->context);
+ free(o);
}
const PureCrypto pure_crypto = {PURE_CRYPTO_VERSION, tls_start, tls_open, tls_read, tls_write, tls_close,
- seal_key, seal_key_free, seal, unseal};
+ seal_key, seal_key_free, sealer, seal, sealer_free, opener, unseal, opener_free};
diff --git a/serve/crypto.c b/serve/crypto.c
index 8806ad2..8eae6d1 100644
--- a/serve/crypto.c
+++ b/serve/crypto.c
@@ -106,16 +106,30 @@ PureSealKey *pure_seal_key(const char *path, char public_hex[2 * PURE_SEAL_KEY +
return c ? c->seal_key(path, public_hex, error, size) : NULL;
}
-/* A key exists only once the table is loaded. */
+/* A key or a session exists only once the table is loaded. */
void pure_seal_key_free(PureSealKey *key) { if (key) crypto->seal_key_free(key); }
-bool pure_seal(const unsigned char peer[PURE_SEAL_KEY], const char *aad, const unsigned char *plain, size_t size,
- unsigned char *out) {
+PureSealer *pure_sealer(const unsigned char peer[PURE_SEAL_KEY], const PureSealKey *own,
+ unsigned char enc[PURE_SEAL_KEY]) {
const PureCrypto *c = loaded(NULL, 0);
- return c && c->seal(pure_seal_suite(), peer, aad, plain, size, out);
+ return c ? c->sealer(pure_seal_suite(), peer, own, enc) : NULL;
}
-bool pure_unseal(const PureSealKey *key, const char *suite, const char *aad, const unsigned char *sealed, size_t size,
+bool pure_seal(PureSealer *s, const char *aad, const unsigned char *plain, size_t size, unsigned char *out,
+ uint64_t *n) {
+ return crypto->seal(s, aad, plain, size, out, n);
+}
+
+void pure_sealer_free(PureSealer *s) { if (s) crypto->sealer_free(s); }
+
+PureOpener *pure_opener(const PureSealKey *key, const char *suite, const unsigned char enc[PURE_SEAL_KEY],
+ const unsigned char *from) {
+ return key ? crypto->opener(key, suite, enc, from) : NULL;
+}
+
+bool pure_unseal(PureOpener *o, uint64_t n, const char *aad, const unsigned char *sealed, size_t size,
unsigned char *plain) {
- return key && crypto->unseal(key, suite, aad, sealed, size, plain);
+ return crypto->unseal(o, n, aad, sealed, size, plain);
}
+
+void pure_opener_free(PureOpener *o) { if (o) crypto->opener_free(o); }
diff --git a/serve/crypto.h b/serve/crypto.h
index 5083b23..4919c49 100644
--- a/serve/crypto.h
+++ b/serve/crypto.h
@@ -8,7 +8,7 @@
* first time a server seals, opens a seal or connects to an https receiver
* (serve/crypto.c): one that does none of these maps no OpenSSL, 2.3 MiB
* less resident and 0.6 MiB less of its own. */
-enum { PURE_CRYPTO_VERSION = 1 };
+enum { PURE_CRYPTO_VERSION = 2 };
typedef struct {
unsigned version;
@@ -20,12 +20,18 @@ typedef struct {
int (*tls_read)(PureTls *, void *bytes, int size);
int (*tls_write)(PureTls *, const void *bytes, int size);
void (*tls_close)(PureTls *);
- /* Sealing, as serve/seal.h says, in the suite named. */
+ /* Sealing, as serve/seal.h says; a sender's sessions in the suite named. */
PureSealKey *(*seal_key)(const char *path, char public_hex[2 * PURE_SEAL_KEY + 1], char *error, size_t size);
void (*seal_key_free)(PureSealKey *);
- bool (*seal)(const char *suite, const unsigned char peer[PURE_SEAL_KEY], const char *aad,
- const unsigned char *plain, size_t size, unsigned char *out);
- bool (*unseal)(const PureSealKey *, const char *suite, const char *aad, const unsigned char *sealed, size_t size,
+ PureSealer *(*sealer)(const char *suite, const unsigned char peer[PURE_SEAL_KEY], const PureSealKey *own,
+ unsigned char enc[PURE_SEAL_KEY]);
+ bool (*seal)(PureSealer *, const char *aad, const unsigned char *plain, size_t size, unsigned char *out,
+ uint64_t *n);
+ void (*sealer_free)(PureSealer *);
+ PureOpener *(*opener)(const PureSealKey *, const char *suite, const unsigned char enc[PURE_SEAL_KEY],
+ const unsigned char *from);
+ bool (*unseal)(PureOpener *, uint64_t n, const char *aad, const unsigned char *sealed, size_t size,
unsigned char *plain);
+ void (*opener_free)(PureOpener *);
} PureCrypto;
#endif
diff --git a/serve/deliver.c b/serve/deliver.c
index 99f2f53..36b39f4 100644
--- a/serve/deliver.c
+++ b/serve/deliver.c
@@ -49,8 +49,13 @@ struct PureWire {
size_t answer_head, answer_count, answer_capacity;
Answer taken; /* the one the owner took last, kept until it takes the next */
uint32_t placed[2 * PURE_MODULE_FIELDS];
- bool sealed; /* the sends go sealed to peer's key */
- unsigned char peer[PURE_SEAL_KEY];
+ /* Sealed sends: to peer's key, authenticated by the node's own key when
+ * it has one (its public half is from), in one session made at the first
+ * seal (and again after one fails); enc opens it. */
+ bool sealed;
+ unsigned char peer[PURE_SEAL_KEY], from[PURE_SEAL_KEY], enc[PURE_SEAL_KEY];
+ const PureSealKey *own;
+ PureSealer *sealer;
};
/* TLS verifies a receiver's certificate against the system's CAs or the
@@ -518,11 +523,57 @@ static bool form_answer(const PureWire *w, int status, const Text *body, const P
static int send_request(PureWire *w, const char *request, size_t size, const Pending *p, Answer *answer);
-void pure_wire_seal(PureWire *w, const unsigned char peer[PURE_SEAL_KEY]) {
+void pure_wire_seal(PureWire *w, const unsigned char peer[PURE_SEAL_KEY], const PureSealKey *own,
+ const unsigned char *from) {
memcpy(w->peer, peer, PURE_SEAL_KEY);
+ w->own = own;
+ if (own) memcpy(w->from, from, PURE_SEAL_KEY);
w->sealed = true;
}
+/* count sends from p as one body: one object, or an array of them. */
+static void put_body(unsigned char *at, const Pending *p, unsigned count) {
+ for (unsigned i = 0; i < count; ++i) {
+ if (count > 1) *at++ = i ? ',' : '[';
+ memcpy(at, p[i].body, p[i].size);
+ at += p[i].size;
+ }
+ if (count > 1) *at = ']';
+}
+
+static void hex(const unsigned char *bytes, size_t size, char *out) {
+ static const char digits[] = "0123456789abcdef";
+ for (size_t i = 0; i < size; ++i) out[2 * i] = digits[bytes[i] >> 4], out[2 * i + 1] = digits[bytes[i] & 15];
+ out[2 * size] = 0;
+}
+
+/* The body sealed whole in the wire's session, bound to its source, first
+ * number and count; the X-Pure-Seal line that opens it (suite, session,
+ * number, and the sender's key when it sealed with its own) into line. */
+static unsigned char *seal_body(PureWire *w, const Pending *p, unsigned count, size_t body, char *line, size_t size) {
+ unsigned char *plain = malloc(body), *sealed = malloc(body + PURE_SEAL_OVERHEAD);
+ char aad[PURE_SOURCE + 48];
+ snprintf(aad, sizeof(aad), "%s %llu %u", w->source, (unsigned long long)p->seq, count);
+ uint64_t n = 0;
+ if (plain && sealed) {
+ put_body(plain, p, count);
+ if (!w->sealer) w->sealer = pure_sealer(w->peer, w->own, w->enc);
+ }
+ bool ok = plain && sealed && w->sealer && pure_seal(w->sealer, aad, plain, body, sealed, &n);
+ free(plain);
+ if (!ok) {
+ pure_sealer_free(w->sealer); /* the next try makes a new session */
+ w->sealer = NULL;
+ free(sealed);
+ return NULL;
+ }
+ char enc[2 * PURE_SEAL_KEY + 1], from[2 * PURE_SEAL_KEY + 2] = "";
+ hex(w->enc, PURE_SEAL_KEY, enc);
+ if (w->own) from[0] = ' ', hex(w->from, PURE_SEAL_KEY, from + 1);
+ snprintf(line, size, "X-Pure-Seal: %s %s %llu%s\r\n", pure_seal_suite(), enc, (unsigned long long)n, from);
+ return sealed;
+}
+
/* One request for count sends from p (numbered in a row): one object, or
* an array of them for a receiver that takes batches. A final answer to a
* send whose answers are wired is made into *answer. */
@@ -530,19 +581,17 @@ static int post(PureWire *w, const Pending *p, unsigned count, Answer *answer) {
size_t body = count > 1 ? count + 1 : 0;
for (unsigned i = 0; i < count; ++i) body += p[i].size;
size_t length = body + (w->sealed ? PURE_SEAL_OVERHEAD : 0);
- char batch[32] = "", seal[48] = "";
+ char batch[32] = "", seal[256] = "";
if (count > 1) snprintf(batch, sizeof(batch), "X-Pure-Count: %u\r\n", count);
- if (w->sealed) snprintf(seal, sizeof(seal), "X-Pure-Seal: %s\r\n", pure_seal_suite());
+ unsigned char *sealed = w->sealed ? seal_body(w, p, count, body, seal, sizeof(seal)) : NULL;
+ if (w->sealed && !sealed) return AGAIN;
/* The request in one piece, its head and then a batch in its brackets:
* one write where there were two a send (a batch drained at 91k sends a
- * second, and at 470k in one piece). Sealed, the body is sealed whole,
- * bound to its source, first number and count. */
- enum { HEAD = 2048 }; /* the longest path, host and source leave room to spare */
+ * second, and at 470k in one piece). */
+ enum { HEAD = 2048 }; /* the longest path, host, source and seal leave room to spare */
char *request = malloc(HEAD + length);
- unsigned char *plain = w->sealed ? malloc(body) : NULL;
- if (!request || (w->sealed && !plain)) {
- free(request);
- free(plain);
+ if (!request) {
+ free(sealed);
return AGAIN;
}
int head = snprintf(request, HEAD,
@@ -550,23 +599,9 @@ static int post(PureWire *w, const Pending *p, unsigned count, Answer *answer) {
"X-Pure-Source: %s\r\nX-Pure-Sequence: %llu\r\n%s%sIdempotency-Key: %s-%llu\r\n\r\n",
w->path, w->host, w->port, length, w->source, (unsigned long long)p->seq, batch, seal, w->source,
(unsigned long long)p->seq);
- unsigned char *at = plain ? plain : (unsigned char *)request + head;
- for (unsigned i = 0; i < count; ++i) {
- if (count > 1) *at++ = i ? ',' : '[';
- memcpy(at, p[i].body, p[i].size);
- at += p[i].size;
- }
- if (count > 1) *at++ = ']';
- if (plain) {
- char aad[PURE_SOURCE + 48];
- snprintf(aad, sizeof(aad), "%s %llu %u", w->source, (unsigned long long)p->seq, count);
- bool sealed = pure_seal(w->peer, aad, plain, body, (unsigned char *)request + head);
- free(plain);
- if (!sealed) {
- free(request);
- return AGAIN;
- }
- }
+ if (sealed) memcpy(request + head, sealed, length);
+ else put_body((unsigned char *)request + head, p, count);
+ free(sealed);
int result = send_request(w, request, (size_t)head + length, p, answer);
free(request);
return result;
@@ -706,6 +741,7 @@ void pure_wire_close(PureWire *w) {
pthread_mutex_unlock(&w->lock);
if (w->started) pthread_join(w->thread, NULL);
pure_tls_close(w->session);
+ pure_sealer_free(w->sealer);
if (w->fd >= 0) close(w->fd);
for (size_t i = 0; i < w->count; ++i) {
free(w->queue[w->head + i].body);
diff --git a/serve/deliver.h b/serve/deliver.h
index 70b03b9..f7427c5 100644
--- a/serve/deliver.h
+++ b/serve/deliver.h
@@ -1,6 +1,7 @@
#ifndef PURE_DELIVER_H
#define PURE_DELIVER_H
#include "module.h"
+#include "seal.h"
/* Delivering an app's saved sends over HTTP (docs/research/083-delivery-over-http.md).
* A wire takes one output's sends in order: each goes as a JSON object of
@@ -23,10 +24,12 @@ void pure_wire_trust(const char *ca_file);
PureWire *pure_wire_open(const PureModule *m, const char *output, const char *url, const char *source,
char *error, size_t size);
uint32_t pure_wire_output(const PureWire *);
-/* Seal the wire's sends to the receiver's public key (serve/seal.h): each
+/* Seal the wire's sends to the receiver's public key (serve/seal.h),
+ * authenticated by own (whose public half is from) when given: each
* request's body, bound to its source, first number and count, goes with
- * X-Pure-Seal naming the suite. Before pure_wire_start. */
-void pure_wire_seal(PureWire *, const unsigned char peer[32]);
+ * X-Pure-Seal naming the suite, the session, the seal's number and the
+ * sender's key. Before pure_wire_start. */
+void pure_wire_seal(PureWire *, const unsigned char peer[32], const PureSealKey *own, const unsigned char *from);
/* The owner's side: queue a saved send numbered after the last queued one
* (it is encoded now); false when it cannot be (a bytes field that is not
* UTF-8 has no JSON form), and the wire then stops at it. */
diff --git a/serve/seal.h b/serve/seal.h
index 663d860..a020e0d 100644
--- a/serve/seal.h
+++ b/serve/seal.h
@@ -2,34 +2,55 @@
#define PURE_SEAL_H
#include <stdbool.h>
#include <stddef.h>
+#include <stdint.h>
-/* Sends sealed end to end (docs/research/086-sealed-sends.md): a receiving
- * node's key opens what a sender sealed to its public key, and nothing
- * between them (a relay, a proxy, a mailbox) can read or change it. HPKE
- * (RFC 9180) base mode: X25519, HKDF-SHA256, and AES-128-GCM where the CPU
- * has AES instructions, else ChaCha20-Poly1305. A private key lives in
+/* Sends sealed end to end (docs/research/086-sealed-sends.md, 088): a
+ * receiving node's key opens what a sender sealed to its public key, and
+ * nothing between them (a relay, a proxy, a mailbox) can read or change it.
+ * HPKE (RFC 9180): X25519, HKDF-SHA256, and AES-128-GCM where the CPU has AES
+ * instructions, else ChaCha20-Poly1305; auth mode when the sender has a key
+ * of its own, so the receiver knows who sealed. A sender keeps one session
+ * (one encapsulation) and numbers its seals in it; a receiver opens a
+ * session once and then each seal by its number. A private key lives in
* OpenSSL's secure heap: locked in memory, out of core dumps, cleared when
* freed. OpenSSL comes with pure-crypto.so (serve/crypto.c), mapped the
- * first time a key is read or a send sealed. */
-enum { PURE_SEAL_KEY = 32, PURE_SEAL_OVERHEAD = 32 + 16 }; /* the encapsulated key, then the tag */
+ * first time a key is read or a session made. */
+enum { PURE_SEAL_KEY = 32, PURE_SEAL_OVERHEAD = 16 }; /* a public key; a seal's tag */
typedef struct PureSealKey PureSealKey;
+typedef struct PureSealer PureSealer; /* a sender's session */
+typedef struct PureOpener PureOpener; /* a receiver's session */
+
+/* Whether this machine can seal (pure-crypto.so maps), with why not in error. */
+bool pure_seal_ready(char *error, size_t size);
/* A node's key: read from path, or made and written there (0600) when there
* is none; its public half in hex. NULL with why in error. */
PureSealKey *pure_seal_key(const char *path, char public_hex[2 * PURE_SEAL_KEY + 1], char *error, size_t size);
void pure_seal_key_free(PureSealKey *);
-/* Whether this machine can seal (pure-crypto.so maps), with why not in error. */
-bool pure_seal_ready(char *error, size_t size);
/* A public key from its hex. */
bool pure_seal_peer(const char *hex, unsigned char out[PURE_SEAL_KEY]);
/* The suite this machine seals with, as the X-Pure-Seal header names it. */
const char *pure_seal_suite(void);
-/* plain sealed to peer, bound to aad: size + PURE_SEAL_OVERHEAD bytes into out. */
-bool pure_seal(const unsigned char peer[PURE_SEAL_KEY], const char *aad, const unsigned char *plain, size_t size,
- unsigned char *out);
-/* Opened with key under suite (either this machine's or the other known one):
- * size - PURE_SEAL_OVERHEAD bytes into plain; false when it does not open
- * (another key, another aad, or changed on the way). */
-bool pure_unseal(const PureSealKey *, const char *suite, const char *aad, const unsigned char *sealed, size_t size,
+
+/* A session sealing to peer, authenticated by own when given: its
+ * encapsulated key into enc (what a receiver opens the session with). */
+PureSealer *pure_sealer(const unsigned char peer[PURE_SEAL_KEY], const PureSealKey *own,
+ unsigned char enc[PURE_SEAL_KEY]);
+/* plain sealed as the session's next seal, bound to aad: size +
+ * PURE_SEAL_OVERHEAD bytes into out, and its number into n. */
+bool pure_seal(PureSealer *, const char *aad, const unsigned char *plain, size_t size, unsigned char *out,
+ uint64_t *n);
+void pure_sealer_free(PureSealer *);
+
+/* A session a sender made to key's public half, in suite (this machine's or
+ * the other known one), from its enc; from is the sender's public key when
+ * it sealed with its own (NULL otherwise). NULL when it does not open. */
+PureOpener *pure_opener(const PureSealKey *key, const char *suite, const unsigned char enc[PURE_SEAL_KEY],
+ const unsigned char *from);
+/* The session's seal number n: size - PURE_SEAL_OVERHEAD bytes into plain;
+ * false when it does not open (another session, another aad, or changed on
+ * the way). */
+bool pure_unseal(PureOpener *, uint64_t n, const char *aad, const unsigned char *sealed, size_t size,
unsigned char *plain);
+void pure_opener_free(PureOpener *);
#endif
diff --git a/serve/serve.c b/serve/serve.c
index 5088d8a..0e60e2a 100644
--- a/serve/serve.c
+++ b/serve/serve.c
@@ -71,9 +71,27 @@ typedef enum { ROUTE_NONE, ROUTE_OBJECT, ROUTE_VIEW, ROUTE_INPUT } RouteKind;
typedef struct { RouteKind kind; uint32_t target; size_t from, count; } Route;
/* This node's key, for deliveries sealed to it (--key), and whether it
- * takes only those (--sealed-only). */
+ * takes only those (--sealed-only), or only those from the senders named,
+ * each by the key it seals with (--sender NAME=KEY). */
static PureSealKey *seal_key;
static bool sealed_only;
+static struct Sender { char name[PURE_SOURCE + 1]; unsigned char key[PURE_SEAL_KEY]; } senders[PURE_MODULE_INPUTS];
+static unsigned sender_count;
+
+/* A delivery's X-Pure-Seal: the suite, the sender's session (its
+ * encapsulated key), the seal's number in it and, when the sender sealed
+ * with a key of its own, that key. */
+typedef struct {
+ char suite[32]; /* empty: not sealed */
+ unsigned char enc[PURE_SEAL_KEY], from[PURE_SEAL_KEY];
+ uint64_t n;
+ bool authenticated;
+} Seal;
+
+/* Sessions a connection thread has opened, the least used replaced first:
+ * a sender's key agreement is made once a session, not once a request. */
+enum { SESSIONS = 16 };
+typedef struct { Seal seal; PureOpener *opener; uint64_t used; } Session;
/* Allocated when accepted and reused after closing: memory follows the
* connections that are open, not the configured maximum. */
@@ -101,10 +119,11 @@ typedef struct Connection {
char source[PURE_SOURCE + 1]; /* a delivered message's sender (X-Pure-Source), or empty */
uint64_t sequence; /* and its number (X-Pure-Sequence) */
unsigned batch; /* how many numbered messages the body holds (X-Pure-Count) */
- /* A sealed delivery: the suite it names (X-Pure-Seal), what the seal is
- * bound to (source, number, count), and how many plain bytes the body
- * holds once opened, cleared when its ticket goes. */
- char seal[40], aad[PURE_SOURCE + 48];
+ /* A sealed delivery: its seal (X-Pure-Seal), what the seal is bound to
+ * (source, number, count), and how many plain bytes the body holds once
+ * opened, cleared when its ticket goes. */
+ Seal seal;
+ char aad[PURE_SOURCE + 48];
size_t opened;
int64_t expected;
size_t body_size, received, in_used;
@@ -207,6 +226,8 @@ struct Host {
uint64_t timeout_ms;
uint64_t now; /* the monotonic clock in ms, read once a round: deadlines count from it */
uint64_t accepted, requests, expired;
+ Session sessions[SESSIONS];
+ uint64_t session_uses;
/* What the owner hands back to answer (swapped for answering), and the
* eventfd that says so; pending counts requests at the owner. */
pthread_mutex_t lock;
@@ -416,6 +437,34 @@ static void answer_app(Connection *c, PureOutcome result, bool view, PureFault f
if (!result.body.size) release_ticket(c);
}
+/* "SUITE ENC N[ FROM]": the keys as 64 hex digits, the number in decimal. */
+static bool read_seal(const char *value, Seal *seal) {
+ char copy[256], *rest, *suite = NULL, *enc = NULL, *n = NULL, *from = NULL, *extra = NULL;
+ if (strlen(value) >= sizeof(copy)) return false;
+ strcpy(copy, value);
+ suite = strtok_r(copy, " ", &rest);
+ if (suite) enc = strtok_r(NULL, " ", &rest);
+ if (enc) n = strtok_r(NULL, " ", &rest);
+ if (n) from = strtok_r(NULL, " ", &rest);
+ if (from) extra = strtok_r(NULL, " ", &rest);
+ uint64_t sequence;
+ if (!n || extra || strlen(suite) >= sizeof(seal->suite) || !pure_seal_peer(enc, seal->enc) ||
+ !number(n, UINT64_MAX, &sequence) || (from && !pure_seal_peer(from, seal->from)))
+ return false;
+ strcpy(seal->suite, suite);
+ seal->n = sequence;
+ seal->authenticated = from != NULL;
+ return true;
+}
+
+/* A sender this node names, sealing with the key it was named with. */
+static bool known_sender(const char *source, const Seal *seal) {
+ for (unsigned i = 0; i < sender_count; ++i)
+ if (!strcmp(senders[i].name, source))
+ return seal->authenticated && !memcmp(senders[i].key, seal->from, PURE_SEAL_KEY);
+ return false;
+}
+
/* Parse one request head of `size` bytes (through the blank line): the same
* bounded HTTP/1.1 subset as server.c, plus Connection, POST and the app
* routes. A query stays in head for start_request; an unknown path is
@@ -464,7 +513,7 @@ static bool parse_head(Connection *c, char *head, size_t size, char **query) {
c->source[0] = 0;
c->sequence = 0;
c->batch = 1;
- c->seal[0] = 0;
+ c->seal.suite[0] = 0;
bool counted = false;
c->body_size = 0;
c->json = false;
@@ -517,9 +566,8 @@ static bool parse_head(Connection *c, char *head, size_t size, char **query) {
if (c->sequence || !number(value, UINT64_MAX, &n) || !n) return false;
c->sequence = n;
} else if (!strcasecmp(line, "X-Pure-Seal")) {
- /* A delivery sealed to this node's key, in the suite named. */
- if (*c->seal || strlen(value) >= sizeof(c->seal)) return false;
- strcpy(c->seal, value);
+ /* A delivery sealed to this node's key: suite, session, number[, sender]. */
+ if (*c->seal.suite || !read_seal(value, &c->seal)) return false;
} else if (!strcasecmp(line, "X-Pure-Count")) {
/* Numbered messages from X-Pure-Sequence on, as a JSON array. */
if (counted || !number(value, BATCH, &n) || !n) return false;
@@ -656,13 +704,35 @@ static const char *convert(Connection *c) {
return NULL;
}
+/* The session a seal was made in: one this thread opened before, or opened
+ * now in place of the least used; NULL when it does not open. */
+static PureOpener *session(Host *host, const Seal *seal) {
+ Session *least = &host->sessions[0];
+ for (unsigned i = 0; i < SESSIONS; ++i) {
+ Session *k = &host->sessions[i];
+ if (k->opener && !memcmp(k->seal.enc, seal->enc, PURE_SEAL_KEY) && !strcmp(k->seal.suite, seal->suite) &&
+ k->seal.authenticated == seal->authenticated &&
+ (!seal->authenticated || !memcmp(k->seal.from, seal->from, PURE_SEAL_KEY))) {
+ k->used = ++host->session_uses;
+ return k->opener;
+ }
+ if (k->used < least->used) least = k;
+ }
+ PureOpener *opener = pure_opener(seal_key, seal->suite, seal->enc, seal->authenticated ? seal->from : NULL);
+ if (!opener) return NULL;
+ pure_opener_free(least->opener);
+ *least = (Session){*seal, opener, ++host->session_uses};
+ return opener;
+}
+
/* A sealed body opened where it lies, or false (another key, another
- * delivery's binding, or changed on the way). */
+ * session or sender, another delivery's binding, or changed on the way). */
static bool open_seal(Connection *c) {
if (c->body_size < PURE_SEAL_OVERHEAD) return false;
+ PureOpener *opener = session(c->host, &c->seal);
size_t size = c->body_size - PURE_SEAL_OVERHEAD;
- unsigned char *plain = malloc(size ? size : 1);
- bool opened = plain && pure_unseal(seal_key, c->seal, c->aad, c->body, c->body_size, plain);
+ unsigned char *plain = opener ? malloc(size ? size : 1) : NULL;
+ bool opened = plain && pure_unseal(opener, c->seal.n, c->aad, c->body, c->body_size, plain);
if (opened) {
memcpy(c->body, plain, size);
c->body_size = size;
@@ -676,7 +746,7 @@ static bool open_seal(Connection *c) {
/* A request's body is in: an app input's JSON becomes its payload, and the
* request is ready, or it is answered 400 and never runs. */
static void body_done(Connection *c) {
- if (*c->seal && !open_seal(c)) {
+ if (*c->seal.suite && !open_seal(c)) {
release_ticket(c); /* never made ready: it never runs */
respond(c, 400, "Bad Request", "seal", 0, (PureSpan){0});
return;
@@ -701,11 +771,13 @@ static void start_request(Connection *c, size_t head_size) {
memmove(c->in, c->in + head_size, extra);
c->in_used = extra;
if (!parse_head(c, head, head_size, &query)) { refuse(c, 400, "Bad Request", "request"); return; }
- /* A node that keeps its deliveries sealed refuses the others; a seal
- * needs this node's key, and binds the delivery's source, number and count. */
- if (*c->source && sealed_only && !*c->seal) { refuse(c, 403, "Forbidden", "unsealed"); return; }
- if (*c->seal) {
+ /* A node that keeps its deliveries sealed refuses the others, and one
+ * that names its senders refuses any other sender; a seal needs this
+ * node's key, and binds the delivery's source, number and count. */
+ if (*c->source && (sealed_only || sender_count) && !*c->seal.suite) { refuse(c, 403, "Forbidden", "unsealed"); return; }
+ if (*c->seal.suite) {
if (!*c->source || !seal_key || !c->json) { refuse(c, 400, "Bad Request", "seal"); return; }
+ if (sender_count && !known_sender(c->source, &c->seal)) { refuse(c, 403, "Forbidden", "sender"); return; }
snprintf(c->aad, sizeof(c->aad), "%s %llu %u", c->source, (unsigned long long)c->sequence, c->batch);
}
if (c->body_size > PURE_OBJECT_LIMIT) { refuse(c, 413, "Content Too Large", "limit"); return; }
@@ -1697,6 +1769,7 @@ static void stop_shard(Host *host) {
if (host->listener >= 0) close(host->listener);
if (host->epoll >= 0) close(host->epoll);
if (host->wake >= 0) close(host->wake);
+ for (unsigned i = 0; i < SESSIONS; ++i) pure_opener_free(host->sessions[i].opener);
while (host->closed) {
Connection *c = host->closed;
host->closed = c->next;
@@ -1753,7 +1826,8 @@ static bool open_wires(Executor *ex, const PureModule *m, const char *const *sen
}
/* --seal OUTPUT=KEY: that output's sends sealed to the receiver's public key (hex). */
-static bool wire_seals(Executor *ex, const PureModule *m, const char *const *seals, unsigned count) {
+static bool wire_seals(Executor *ex, const PureModule *m, const char *const *seals, unsigned count,
+ const unsigned char *own_public) {
char error[512];
if (count && !pure_seal_ready(error, sizeof(error))) {
fprintf(stderr, "--seal: %s\n", error);
@@ -1775,7 +1849,25 @@ static bool wire_seals(Executor *ex, const PureModule *m, const char *const *sea
fprintf(stderr, "--seal takes OUTPUT=KEY for an output given to --send, the key as 64 hex digits: %s\n", seals[i]);
return false;
}
- pure_wire_seal(wire, peer);
+ pure_wire_seal(wire, peer, seal_key, own_public); /* sealed with this node's key when it has one */
+ }
+ return true;
+}
+
+/* --sender NAME=KEY: deliveries from source NAME are taken only sealed with
+ * that key (hex); with any, only named senders are taken. */
+static bool name_senders(const char *const *named, unsigned count) {
+ for (unsigned i = 0; i < count; ++i) {
+ const char *equals = strchr(named[i], '=');
+ size_t length = equals ? (size_t)(equals - named[i]) : 0;
+ struct Sender *sender = &senders[sender_count];
+ if (!length || length > PURE_SOURCE || !pure_seal_peer(equals + 1, sender->key)) {
+ fprintf(stderr, "--sender takes NAME=KEY, the key as 64 hex digits: %s\n", named[i]);
+ return false;
+ }
+ memcpy(sender->name, named[i], length);
+ sender->name[length] = 0;
+ ++sender_count;
}
return true;
}
@@ -1818,8 +1910,8 @@ int main(int argc, char **argv) {
static PureModule module;
static Owner owner;
const char *module_path = NULL, *store = NULL, *name = NULL, *sends[PURE_MODULE_INPUTS], *replies[PURE_MODULE_INPUTS];
- const char *seals[PURE_MODULE_INPUTS], *key_path = NULL;
- unsigned send_count = 0, reply_count = 0, seal_count = 0;
+ const char *seals[PURE_MODULE_INPUTS], *named[PURE_MODULE_INPUTS], *key_path = NULL;
+ unsigned send_count = 0, reply_count = 0, seal_count = 0, named_count = 0;
unsigned port = 8767, threads = 1;
size_t budget = 64u << 20, capacity = 1024;
uint64_t timeout_ms = 10000;
@@ -1835,6 +1927,7 @@ int main(int argc, char **argv) {
else if (!strcmp(argv[i], "--name")) name = argv[i + 1];
else if (!strcmp(argv[i], "--send-ca")) pure_wire_trust(argv[i + 1]);
else if (!strcmp(argv[i], "--reply") && reply_count < PURE_MODULE_INPUTS) replies[reply_count++] = argv[i + 1];
+ else if (!strcmp(argv[i], "--sender") && named_count < PURE_MODULE_INPUTS) named[named_count++] = argv[i + 1];
else if (!strcmp(argv[i], "--port") && number(argv[i + 1], 65535, &value)) port = (unsigned)value;
else if (!strcmp(argv[i], "--budget") && number(argv[i + 1], SIZE_MAX, &value) && value) budget = (size_t)value;
else if (!strcmp(argv[i], "--connections") && number(argv[i + 1], 1u << 20, &value) && value) capacity = (size_t)value;
@@ -1852,7 +1945,10 @@ int main(int argc, char **argv) {
char error[256];
if (!(seal_key = pure_seal_key(key_path, public_key, error, sizeof(error)))) { fprintf(stderr, "--key %s\n", error); return 2; }
}
- if (sealed_only && !seal_key) { fputs("--sealed-only needs --key\n", stderr); return 2; }
+ if ((sealed_only || named_count) && !seal_key) { fputs("--sealed-only and --sender need --key\n", stderr); return 2; }
+ if (!name_senders(named, named_count)) return 2;
+ unsigned char own_public[PURE_SEAL_KEY];
+ if (seal_key && !pure_seal_peer(public_key, own_public)) return 2;
if (pure_store_init(store) != PURE_OK) { fputs("Cannot initialize the store directory\n", stderr); return 1; }
signal(SIGPIPE, SIG_IGN);
struct sigaction stop_action = {.sa_handler = stop};
@@ -1884,7 +1980,7 @@ int main(int argc, char **argv) {
owner.executor.publishes = true;
if (!open_wires(&owner.executor, &module, sends, send_count, name) ||
!wire_replies(&owner.executor, &module, replies, reply_count) ||
- !wire_seals(&owner.executor, &module, seals, seal_count)) return 2;
+ !wire_seals(&owner.executor, &module, seals, seal_count, seal_key ? own_public : NULL)) return 2;
after_save(&owner.executor); /* the snapshot, and the sends stored before */
for (unsigned i = 0; i < owner.executor.wire_count; ++i)
if (!pure_wire_start(owner.executor.wires[i], wire_delivered, &owner)) { fputs("Cannot start a wire\n", stderr); return 1; }
@@ -1955,6 +2051,7 @@ int main(int argc, char **argv) {
usage:
fputs("pure-serve --module FILE.pmod --store DIRECTORY [--port 8767] [--threads 1|N|auto] [--budget BYTES] "
"[--connections 1024] [--timeout-ms 10000] [--send OUTPUT=http[s]://host:port/path ...] [--name NAME] "
- "[--send-ca FILE] [--reply OUTPUT=INPUT ...] [--seal OUTPUT=KEY ...] [--key FILE] [--sealed-only]\n", stderr);
+ "[--send-ca FILE] [--reply OUTPUT=INPUT ...] [--seal OUTPUT=KEY ...] [--key FILE] [--sealed-only] "
+ "[--sender NAME=KEY ...]\n", stderr);
return 2;
}
diff --git a/tests/check.py b/tests/check.py
index b80bf94..76a23e3 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -769,12 +769,17 @@ def server_links(aup, pure, work):
def server_sealed(aup, pure, work):
- """Sends sealed end to end (--seal to the receiver's --key): through a relay
- that records what passes, every order is applied once and the relay saw
- no item and no field name; one byte changed on the way is refused (400
- seal) and applies nothing, the recorded request sent again applies
- nothing twice, an unsealed delivery is refused (403) by a node that keeps
- its deliveries sealed, and a seal to another key does not open."""
+ """Sends sealed end to end (--seal to the receiver's --key), in one session
+ a wire and authenticated by the sender's own --key: through a relay that
+ records what passes, every order is applied once, the relay saw no item
+ and no field name, and the wire's requests share one session, numbered
+ in order. One byte changed on the way, another number or another
+ sender's key is refused (400 seal) and applies nothing; the recorded
+ request sent again applies nothing twice; an unsealed delivery is
+ refused (403) by a node that keeps its deliveries sealed; a seal to
+ another key does not open. Restarted naming its sender (--sender), the
+ receiver opens that sender's session again unasked, and refuses another
+ node claiming the sender's name with its own key (403 sender)."""
import select
import threading
import time
@@ -807,12 +812,25 @@ def server_sealed(aup, pure, work):
finally:
connection.close()
+ def buy(port, item, count):
+ assert request(port, {'Content-Type': 'application/json'}, json.dumps({'item': item, 'count': count}).encode(),
+ '/inputs/buy')[0] == 200
+
+ def wait_for(condition, seconds=20):
+ deadline = time.time() + seconds
+ while not condition() and time.time() < deadline:
+ time.sleep(0.05)
+ return condition()
+
+ def count():
+ return int(http(port_b, 'GET', '/views/count')[3])
+
key = work / 'seal-receiver.key'
receiver, started = start([pure / 'build/pure-serve', '--module', receiver_module, '--store', work / 'seal-b',
'--key', key, '--sealed-only'])
port_b, public_key = started['port'], started['public_key']
assert len(public_key) == 64 and (key.stat().st_mode & 0o777) == 0o600, (public_key, oct(key.stat().st_mode))
- seen = []
+ seen = [] # (headers, body, status, X-Pure-Error) of each request relayed
class Relay(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
@@ -821,11 +839,11 @@ def server_sealed(aup, pure, work):
body = self.rfile.read(int(self.headers['Content-Length']))
headers = {name: value for name, value in self.headers.items()
if name.lower().startswith('x-pure-') or name.lower() in ('content-type', 'idempotency-key')}
- seen.append((headers, body))
connection = HTTPConnection('127.0.0.1', port_b, timeout=10)
connection.request('POST', self.path, body, headers)
answer = connection.getresponse()
data = answer.read()
+ seen.append((headers, body, answer.status, answer.getheader('X-Pure-Error')))
self.send_response(answer.status)
for name, value in answer.getheaders():
if name.lower().startswith('x-pure-'):
@@ -841,58 +859,78 @@ def server_sealed(aup, pure, work):
relay = ThreadingHTTPServer(('127.0.0.1', 0), Relay)
port_relay = relay.server_address[1]
threading.Thread(target=relay.serve_forever, daemon=True).start()
+ wire = f'ordered=http://127.0.0.1:{port_relay}/inputs/order'
sender, sender_started = start([pure / 'build/pure-serve', '--module', sender_module, '--store', work / 'seal-a',
- '--send', f'ordered=http://127.0.0.1:{port_relay}/inputs/order',
- '--seal', f'ordered={public_key}'])
- port_a = sender_started['port']
- stranger = None
+ '--key', work / 'seal-sender.key', '--send', wire, '--seal', f'ordered={public_key}'])
+ port_a, sender_key = sender_started['port'], sender_started['public_key']
+ stranger = impostor = None
try:
orders, total = 60, 0
for i in range(orders):
- count = i % 7 + 1
- total += count
- assert request(port_a, {'Content-Type': 'application/json'},
- json.dumps({'item': f'secret-{i}', 'count': count}).encode(), '/inputs/buy')[0] == 200
- deadline = time.time() + 20
- while int(http(port_b, 'GET', '/views/count')[3]) < orders and time.time() < deadline:
- time.sleep(0.05)
- assert int(http(port_b, 'GET', '/views/count')[3]) == orders
+ total += i % 7 + 1
+ buy(port_a, f'secret-{i}', i % 7 + 1)
+ assert wait_for(lambda: count() == orders), count()
assert int(http(port_b, 'GET', '/views/total')[3]) == total
- assert seen and all('X-Pure-Seal' in headers for headers, _ in seen), seen[:1]
- assert not any(b'secret' in body or b'item' in body or b'city' in body for _, body in seen)
- headers, body = seen[0]
+ seals = [headers['X-Pure-Seal'].split() for headers, _, _, _ in seen]
+ assert seen and all(len(parts) == 4 and parts[3] == sender_key for parts in seals), seals[:1]
+ assert len({parts[1] for parts in seals}) == 1 # one session
+ numbers = [int(parts[2]) for parts in seals]
+ assert numbers == list(range(len(numbers))), numbers
+ assert all(status == 200 for _, _, status, _ in seen)
+ assert not any(b'secret' in body or b'item' in body or b'city' in body for _, body, _, _ in seen)
+ headers, body, _, _ = seen[0]
+ suite, enc, n, _ = headers['X-Pure-Seal'].split()
changed = bytearray(body)
changed[len(changed) // 2] ^= 1
assert request(port_b, headers, bytes(changed)) == (400, 'seal')
+ renumbered = dict(headers, **{'X-Pure-Seal': f'{suite} {enc} {int(n) + 1} {sender_key}'})
+ assert request(port_b, renumbered, body) == (400, 'seal')
+ other_sender = f'{suite} {enc} {n} {public_key}'
+ assert request(port_b, dict(headers, **{'X-Pure-Seal': other_sender}), body) == (400, 'seal')
assert request(port_b, headers, body)[0] == 200 # heard before: applied once
plain = {name: value for name, value in headers.items() if name != 'X-Pure-Seal'}
assert request(port_b, plain, json.dumps({'item': 'plain', 'count': 1, 'to': {'city': 'x'}}).encode()) == \
(403, 'unsealed')
+ assert count() == orders
# A sender sealing to another node's key: the receiver cannot open it.
other, other_started = start([pure / 'build/pure-serve', '--module', receiver_module, '--store', work / 'seal-c',
'--key', work / 'seal-other.key'])
other.terminate()
other.wait()
stranger, stranger_started = start([pure / 'build/pure-serve', '--module', sender_module, '--store',
- work / 'seal-d', '--send', f'ordered=http://127.0.0.1:{port_relay}/inputs/order',
- '--seal', f'ordered={other_started["public_key"]}'])
+ work / 'seal-d', '--send', wire, '--seal',
+ f'ordered={other_started["public_key"]}'])
before = len(seen)
- assert request(stranger_started['port'], {'Content-Type': 'application/json'},
- json.dumps({'item': 'stranger', 'count': 5}).encode(), '/inputs/buy')[0] == 200
- deadline = time.time() + 5
- while len(seen) == before and time.time() < deadline:
- time.sleep(0.05)
- time.sleep(0.2)
- assert len(seen) > before and int(http(port_b, 'GET', '/views/count')[3]) == orders
- # The same key file gives the same public key after a restart.
+ buy(stranger_started['port'], 'stranger', 5)
+ assert wait_for(lambda: len(seen) > before, 5)
+ assert seen[before][2:] == (400, 'seal') and len(seen[before][0]['X-Pure-Seal'].split()) == 3
+ assert count() == orders
+ # Restarted naming its sender: the same key opens the sender's session
+ # again, and another node claiming the sender's name is refused.
+ source = seen[0][0]['X-Pure-Source']
receiver.terminate()
receiver.wait()
receiver, again = start([pure / 'build/pure-serve', '--module', receiver_module, '--store', work / 'seal-b',
- '--key', key, '--sealed-only'])
+ '--key', key, '--sender', f'{source}={sender_key}'])
+ port_b = again['port']
assert again['public_key'] == public_key
- return {'orders': orders, 'relayed': before, 'suite': seen[0][0]['X-Pure-Seal']}
+ before = len(seen)
+ buy(port_a, 'after', 2)
+ assert wait_for(lambda: count() == orders + 1), count()
+ assert seen[-1][0]['X-Pure-Seal'].split()[1] == enc # the same session, opened again unasked
+ impostor, impostor_started = start([pure / 'build/pure-serve', '--module', sender_module, '--store',
+ work / 'seal-e', '--key', work / 'seal-impostor.key', '--send', wire,
+ '--seal', f'ordered={public_key}'])
+ before = len(seen)
+ buy(impostor_started['port'], 'impostor', 9)
+ assert wait_for(lambda: any(h['X-Pure-Seal'].split()[3] == impostor_started['public_key']
+ for h, _, _, _ in seen[before:]), 5)
+ claimed = [entry for entry in seen[before:] if entry[0]['X-Pure-Seal'].split()[3] != sender_key]
+ assert claimed and claimed[0][0]['X-Pure-Source'] == source and claimed[0][2:] == (403, 'sender'), claimed[:1]
+ assert count() == orders + 1
+ return {'orders': orders, 'relayed': len(seen), 'suite': suite, 'sessions': 1}
finally:
- for process in (sender, receiver, stranger):
+ for process in (sender, receiver, stranger, impostor):
if process:
process.terminate()
process.wait()