diff options
| -rw-r--r-- | CHECKPOINT.md | 2 | ||||
| -rw-r--r-- | README.md | 7 | ||||
| -rw-r--r-- | docs/research/088-sealed-sessions.md | 83 | ||||
| -rw-r--r-- | research/experiments/014-sealed-sends/2026-09-27-sessions-linux-aarch64.jsonl | 9 | ||||
| -rw-r--r-- | research/experiments/014-sealed-sends/drain.py | 14 | ||||
| -rw-r--r-- | serve/crypto-openssl.c | 89 | ||||
| -rw-r--r-- | serve/crypto.c | 26 | ||||
| -rw-r--r-- | serve/crypto.h | 16 | ||||
| -rw-r--r-- | serve/deliver.c | 94 | ||||
| -rw-r--r-- | serve/deliver.h | 9 | ||||
| -rw-r--r-- | serve/seal.h | 53 | ||||
| -rw-r--r-- | serve/serve.c | 145 | ||||
| -rw-r--r-- | tests/check.py | 110 |
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% 줄고 넘겨도 @@ -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() |
