diff --git a/SECURITY.md b/SECURITY.md index fe1447f..d9b287a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,25 +11,26 @@ ## Reporting a vulnerability -Do not open a public issue for a suspected vulnerability. Use one of the following private channels. +**Do not open a public issue for a suspected vulnerability.** Instead, use one of the following private channels. -- Email: qtfelix@qu4nt.space -- GitHub: [Report a vulnerability](https://github.com/Quant-Off/pairier/security/advisories/new) (private vulnerability reporting) +- Email: +- GitHub: [Private vulnerability reporting](https://github.com/Quant-Off/pairier/security/advisories/new) Please include the affected component and version (release or commit hash), steps to reproduce or a proof of concept, and the impact you expect. Reports in Korean or English are both welcome. -- You will receive an acknowledgement within 7 days. +- You will receive an acknowledgement within 7 days at the latest. - Confirmed issues are fixed in a patch release and credited in the advisory unless you ask otherwise. - Please allow up to 90 days before public disclosure so that a fix can be released. If the fix ships earlier, disclosure can happen earlier. ## Supported versions -| Version | Supported | -|---------|-----------| -| 1.0.x | Yes | -| Unreleased commits | No | +| Version | Supported | +|--------------------|-----------| +| 2.0.x | Yes | +| 1.0.x | No | +| Unreleased commits | No | -Security fixes are released only for the latest 1.0.x release. +Security fixes are released only for the latest 2.0.x release. To move off 1.0.x, follow [Upgrading from 1.0.0](USAGE.md#upgrading-from-100). ## Scope @@ -47,16 +48,86 @@ Out of scope, given the trust boundaries below: - Resource exhaustion of a node by its own operator. - Attacks that require a tampered build toolchain or a compromised operating system. +## Threat model + +Assets: + +- `root.key`, the 32-byte root key that seals every key blob of a node +- Key blobs: symmetric keys, received keys, and the identity, KEM, audit and TLS private keys +- Registered member cards and their fingerprints, which decide whom a node trusts +- Envelopes in transit and in the inbox, and the audit log with the heads witnessed by other members + +Adversaries that pairier is designed to resist: + +- A network attacker who can read, change, drop, replay or inject traffic between nodes +- Anyone whose card is not registered on the node and who connects to `kmsd` +- A registered member who acts maliciously, for example by impersonating another member, flooding the inbox, or rewriting its own audit history +- A thief of a disk or a backup that does not contain `root.key` +- A bug in the host's network or parsing code that does not let an attacker run arbitrary code as the node user + +Adversaries outside the design are listed under Scope. Above all, code running as the node user can read `root.key` and open every blob without the core. + ## Trust boundaries -pairier is designed around "Abandon trust. Choose isolation." +pairier is designed under the Zero-Trust and Air-Gapped Ready principles. -- Key material exists in plaintext only inside `kms-core`, a separate process. The host never sees an unsealed key. +- Key material exists in plaintext only inside `kms-core`, a separate process. The host process never handles an unsealed key. - Every key is stored as a blob sealed under the node's root key. `root.key` is the only secret in the node directory. +- `root.key` itself is a plaintext file with no passphrase. `kmsd` and `kmsctl` start the core under the same user, so process separation protects keys against flaws in the host's network and parsing code, not against code that already runs as the node user. - Trust between members comes only from identity card fingerprints verified over a separate channel. There is no CA and no trust on first use. - Nothing listens on the network except `kmsd`, and `kmsd` accepts only mTLS clients whose certificate matches a registered member's card. - Builds use vendored dependencies only and run without network access. +## Host and core channel + +- The host creates a `socketpair` and passes one end to `kms-core` as fd 3. No socket path appears on the filesystem and nothing listens, so no other process can connect. +- The core is started with an empty environment, `/` as its working directory and no standard input or output. Only standard error and fd 3 are inherited, and the core closes every other fd itself. +- Before it accepts the channel or loads the root key, the core sets `umask 077`, disables core dumps, denies `ptrace`, sets `no_new_privs` and, on Linux, locks all memory with `mlockall`. If memory cannot be locked, the core refuses to start unless `--allow-unlocked-memory` is given. +- The core accepts the channel only if it is an `AF_UNIX` stream socket and the peer's UID matches its own, or the `--peer-uid` given in standalone socket mode. +- Every frame has a fixed 16-byte header, length-prefixed fields and a 1 MiB body limit. `HELLO` must come first with a matching protocol version, trailing bytes are rejected, and any framing violation ends the session. +- The host applies a fixed deadline to every core call (10 seconds by default). A cancelled request does not break the shared core session, and a core that does not exit in time is killed. + +## Communication between members + +pairier protects member-to-member traffic in two layers. mTLS authenticates the channel, and the envelope signature authenticates every key that is sent. A peer that passes the TLS layer still cannot deliver a key without the sender's identity key. + +TLS layer: + +- TLS 1.3 only. Go 1.27 offers the X25519MLKEM768 hybrid key exchange first, including in FIPS mode, so recorded traffic between pairier nodes resists later decryption by a quantum computer as long as ML-KEM holds. TLS authentication itself uses classical ECDSA P-256. +- Each node's TLS key is an ECDSA P-256 key held in the core. The core signs the TLS handshake. The private key never enters `kmsd` or `kmsctl`. +- The server requires a client certificate. It accepts the certificate only if its subject equals a registered member id and its public key equals the TLS key in that member's verified card. The TLS handshake then proves that the client holds the matching private key. +- The client pins the server in the same way, comparing the server key with the key in the recipient's card and the subject with the recipient's member id. It sends its own certificate only after the server passes this check, so an impostor server never learns which member is connecting. +- Every request is checked again against the registered card. Once a renewed card is registered with `member add`, the old TLS key no longer authenticates. +- `kmsctl` accepts only `https://` URLs and never uses a proxy. pairier clients speak HTTP/1.1. + +HTTP service: + +- Endpoints: `GET /v1/health`, `GET /v1/card`, `POST /v1/inbox`, `GET /v1/witness`. All of them require an authenticated member. +- Limits: 64 concurrent connections, 10 seconds each for the TLS handshake and the request header, 16 KiB of header, 15 seconds per request, and a request body no larger than the largest valid envelope. +- At most 64 pending envelopes per sender. Further envelopes are refused with `429`. +- Responses carry `Cache-Control: no-store`, `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`. + +Envelope layer: + +- A KXP1 envelope holds a context, a key package and a signature by the sender's identity key over both. +- The context names the sender and the recipient, and carries the creation time, a 16-byte random nonce and the sender's current audit log head. A context without an audit head is refused, which includes every envelope created by 1.0.0. +- The package is produced inside the sender's core. The key is sealed to the recipient's KEM key (DHKEM P-256, or MLKEM768-P256 in PQC mode), the context is bound to it, and the recipient's core compares the context in constant time before it opens the package. +- The sender can grant only a subset of the key's usage, and only from a key that has `export`. The recipient's core imports only 256-bit symmetric keys. +- The receiver checks that it is the recipient, that the sender is registered, that the signature algorithm matches the sender's card and that the signature verifies. `kmsd` also requires the TLS peer to be the sender named in the envelope, so one member cannot deliver an envelope in another's name. +- The receiver records the sender's audit head. If a head with the same sequence number but a different hash was seen earlier, the envelope is refused and the conflict is written to the audit log. +- No freshness window is enforced on the creation time. A replayed envelope is refused as a duplicate while it is pending, and once the key has been imported, importing it again fails because a key with the same id already exists. +- Envelopes, identity cards, audit checkpoints and TLS handshakes use distinct message formats, and identity, audit and TLS keys are separate keys, so a signature made for one purpose is not valid for another. + +## Local key storage + +- The node directory must be `0700` and owned by the current user, and files must have no group or other permissions. Symbolic links are never followed. +- Records are written through a temporary file, synced, and moved into place, followed by a sync of the directory. Key records are write-once and are never overwritten. +- `root.key` is created by the core with mode `0400`. The core opens it without following links and requires a regular file owned by the current user, without group or other permissions, with a single link and exactly 32 bytes. +- In the core the root key lives in a dedicated memory region with guard pages on both sides. The region is locked, excluded from core dumps, and zeroized when released. +- Each blob is sealed with AES-256-GCM under a key and nonce derived by HKDF-SHA256 from the root key and a fresh 32-byte salt. The blob header (version, algorithm, usage, key id, label) is fully authenticated as associated data. +- The core rejects every operation outside the usage assigned to a key. On every use, the host checks stored metadata and public keys against the values the core recomputes from the blob. +- Node start, key generation, encryption, decryption, member changes, sent and received envelopes, inbox decisions, card renewal and witness conflicts are written to the hash-chained audit log, and each entry is signed with the core's audit key. + ## Known limitations These are design choices or open items that are disclosed here rather than treated as vulnerabilities. Reports that go beyond them are welcome. @@ -64,6 +135,17 @@ These are design choices or open items that are disclosed here rather than treat - The MLKEM768-P256 hybrid KEM follows draft-ietf-hpke-pq (HPKE KEM id 0x0050), which is still an Internet-Draft and may change before it becomes an RFC. The core keeps the private key in expanded form instead of the 32-byte seed the draft defines. This does not change the public key, the ciphertext or the shared secret. - A member who receives a key with `export` can send it on to others. Omit `export` to prevent re-sharing. - The audit log is a hash chain with a per-entry signature by the core's audit key. Anyone who can start the core as the node user can produce valid entries. Truncating the log tail or rolling the log back is detected only against heads that other members witnessed from the envelopes they received, or heads recorded outside the node. Entries after the last witnessed head are not protected, a member keeps at most 4096 heads per sender and drops the lowest sequence numbers first, and a dishonest witness can only cause a false alarm. +- `root.key` is not wrapped by a passphrase or an external key, so the node can start unattended. A backup of the node directory contains the root key and must be protected like it. +- `kmsd` and `kmsctl` always start the core themselves under the same user. The core already supports running under a separate user on a socket path (`--socket`, `--peer-uid`), but `kmsd` and `kmsctl` cannot connect to it yet. +- Key lifecycle is incomplete. There is no command to delete a key, `card renew` leaves the previous TLS key blob in `keys/`, the identity and audit keys cannot be rotated, the KEM key is replaced only when its algorithm is retired, and the root key cannot be rotated. +- There is no command to remove a member or revoke a card. +- Identity cards carry no generation number. `member add` accepts any validly signed card with the same identity key, including an older one, so verify the fingerprint of every renewed card. +- `kmsd` accepts TLS session resumption. A resumed connection skips the handshake-time certificate check and the proof of the TLS key, and only the per-request lookup of the certificate against the registered card remains. The session ticket key lives only in `kmsd` memory. pairier clients never resume, and pushing an envelope still requires the sender's identity signature. +- `kmsd` also offers HTTP/2 to clients that ask for it, although pairier clients use only HTTP/1.1. +- The client that `kmsctl` uses to reach a peer's `kmsd` follows HTTP redirects. Only a peer that has already passed the pin check can send one. +- Anyone who holds a member's card can make `kmsd` run one card signature check in the core per connection attempt before the handshake fails. Core calls are serialized, and the 64-connection limit bounds the load. +- The configuration file is checked for group and other write permission but not for its owner, and the core binary it names is not checked for owner or permissions. Keep both owned by the node user or root. +- The `fips` feature selects the AWS-LC FIPS 140-3 validated module, but whether every algorithm listed below is covered by the module's certificate has not been confirmed. Check the CMVP certificate of the exact module version before relying on FIPS for a given algorithm. CI does not yet assert that `kmsd` reports `go_fips=true`. - On macOS the core runs without locked memory and the FIPS core is not statically linked. macOS is for development only. - `make audit` uses a local RustSec advisory database that must be refreshed on a connected machine. - The Rust wire decoder mutation test is seeded, not coverage guided. @@ -93,5 +175,7 @@ Releases are also reproducible. Running `make dist FIPS=1` in a git checkout of - Core: aws-lc-rs. The `fips` feature selects the AWS-LC FIPS 140-3 validated module (static Linux builds). - Host: Go standard library only, built with `GOFIPS140=certified` by default. - Algorithms: AES-256-GCM, ECDSA P-256/P-384, Ed25519, DHKEM(P-256) per RFC 9180, ML-DSA-65/87, ML-KEM-768/1024, and the MLKEM768-P256 hybrid KEM from draft-ietf-hpke-pq. -- mTLS: the TLS key is an ECDSA P-256 key held in the core. `kmsd` and `kmsctl` pass the whole message to be signed, and the core hashes and signs it. -- The hybrid KEM of 1.0.0 (`0x0210`) is retired. The core still opens such keys so that pending envelopes can be received, but it no longer creates them or seals to them. +- Key derivation: HKDF-SHA256 for key blobs and key packages, with a separate label for each. +- mTLS: TLS 1.3 with the X25519MLKEM768 key exchange preferred. The TLS key is an ECDSA P-256 key held in the core. `kmsd` and `kmsctl` pass the whole message to be signed, and the core hashes and signs it. +- Identity cards are self-signed by the identity key, and the fingerprint is the SHA-256 hash of the signed card content. +- The hybrid KEM of 1.0.0 (`0x0210`) is retired. The core opens such keys only so that a node created by 1.0.0 can start and run `card renew`. It no longer creates them, seals to them or imports keys with them. diff --git a/SECURITY_KR.md b/SECURITY_KR.md index f817104..a3a2660 100644 --- a/SECURITY_KR.md +++ b/SECURITY_KR.md @@ -11,25 +11,26 @@ ## 취약점 신고 -취약점으로 의심되는 내용은 공개 이슈로 올리지 마시고, 다음 비공개 채널 중 하나를 이용해 주세요. +**취약점으로 의심되는 내용은 공개 이슈로 올리지 말아주시고**, 그 대신 다음 비공개 채널 중 하나를 이용해 주세요. -- 이메일: qtfelix@qu4nt.space -- GitHub: [Report a vulnerability](https://github.com/Quant-Off/pairier/security/advisories/new) (비공개 취약점 신고) +- 이메일: +- GitHub: [비공개 취약점 신고](https://github.com/Quant-Off/pairier/security/advisories/new) -영향을 받는 구성 요소와 버전(릴리즈 또는 커밋 해시), 재현 절차 또는 개념 증명, 예상되는 영향을 함께 적어 주세요. 한국어와 영어 모두 괜찮습니다. +영향을 받는 구성 요소와 버전(릴리즈 또는 커밋 해시), 재현 절차 또는 개념 증명, 예상되는 영향을 함께 적어 주세요. 한국어, 영어 모두 괜찮습니다. -- 7일 이내에 접수 확인 답변을 드립니다. +- 최대 7일 이내에 접수 확인 답변을 드립니다. - 확인된 문제는 패치 릴리즈로 수정하며, 원하지 않으시는 경우를 제외하고 권고문에 신고자를 표기합니다. - 수정본이 배포될 수 있도록 공개까지 최대 90일을 기다려 주세요. 수정본이 더 빨리 나오면 더 빨리 공개할 수 있습니다. ## 지원 버전 -| 버전 | 지원 여부 | -|------|-----------| -| 1.0.x | 지원 | +| 버전 | 지원 여부 | +|-------------|---------| +| 2.0.x | 지원 | +| 1.0.x | 지원하지 않음 | | 릴리즈되지 않은 커밋 | 지원하지 않음 | -보안 수정은 최신 1.0.x 릴리즈에만 제공됩니다. +보안 수정은 최신 2.0.x 릴리즈에만 제공됩니다. 1.0.x에서 옮기려면 [1.0.0에서 업그레이드](USAGE_KR.md#100에서-업그레이드)를 따르세요. ## 범위 @@ -47,16 +48,86 @@ - 운영자 자신에 의한 노드 자원 고갈 - 변조된 빌드 툴체인이나 침해된 운영 체제를 전제로 하는 공격 +## 위협 모델 + +보호 대상: + +- 노드의 모든 키 blob을 봉인(sealed)하는 32바이트 루트 키인 `root.key` +- 키 blob: 대칭 키, 받은 키, 그리고 신원·KEM·감사·TLS 개인키 +- 노드가 누구를 신뢰할지 정하는 등록된 구성원 카드와 그 지문 +- 전송 중이거나 inbox에 있는 봉투, 그리고 다른 구성원이 증인으로 기록한 head를 포함한 감사 로그 + +pairier가 막도록 설계된 공격자: + +- 노드 사이의 트래픽을 읽고, 바꾸고, 버리고, 재전송하고, 끼워 넣을 수 있는 네트워크 공격자 +- 노드에 카드가 등록되지 않은 상태로 `kmsd`에 접속하는 모든 사람 +- 다른 구성원 사칭, inbox 넘치게 하기, 자기 감사 기록 고쳐 쓰기처럼 악의적으로 행동하는 등록된 구성원 +- `root.key`가 들어 있지 않은 디스크나 백업을 훔친 사람 +- 공격자가 노드 사용자 권한으로 임의 코드를 실행할 수 있게 하지는 않는, 호스트의 네트워크나 파싱 코드 결함 + +설계 밖의 공격자는 범위 항목에 적혀 있습니다. 무엇보다, 노드 사용자 권한으로 실행되는 코드는 `root.key`를 읽어 코어 없이 모든 blob을 열 수 있습니다. + ## 신뢰 경계 -pairier는 "Abandon trust. Choose isolation." 원칙으로 설계되었습니다. +pairier는 제로 트러스트(Zero-Trust) 폐쇄형 친화(Air-Gapped) 원칙아래 설계되었습니다. -- 키 재료는 별도 프로세스인 `kms-core` 안에서만 평문으로 존재합니다. 호스트는 봉인이 풀린 키를 보지 못합니다. +- 키 재료는 별도 프로세스인 `kms-core` 안에서만 평문으로 존재합니다. 호스트 프로세스는 봉인이 풀린 키를 다루지 않습니다. - 모든 키는 노드의 루트 키로 봉인된 blob으로 저장됩니다. 노드 디렉토리의 비밀은 `root.key` 하나뿐입니다. +- `root.key` 자체는 패스프레이즈가 없는 평문 파일입니다. `kmsd`와 `kmsctl`은 코어를 같은 사용자로 기동하므로, 프로세스 분리는 호스트의 네트워크·파싱 코드 결함으로부터 키를 보호할 뿐, 이미 노드 사용자로 실행되는 코드로부터 보호하지는 않습니다. - 구성원 간 신뢰는 별도 채널로 확인한 신원 카드 지문에서만 나옵니다. CA도, 최초 접속 신뢰(TOFU)도 없습니다. - 네트워크를 listen하는 것은 `kmsd`뿐이며, `kmsd`는 등록된 구성원 카드와 인증서가 일치하는 mTLS 클라이언트만 받습니다. - 빌드는 벤더링된 의존성만 사용하며 네트워크 없이 동작합니다. +## 호스트와 코어 사이 채널 + +- 호스트는 `socketpair`를 만들고 한쪽 끝을 fd 3으로 `kms-core`에 넘깁니다. 파일 시스템에 소켓 경로가 생기지 않고 아무것도 listen하지 않으므로 다른 프로세스는 접속할 수 없습니다. +- 코어는 빈 환경 변수, 작업 디렉토리 `/`, 표준 입력과 표준 출력 없이 기동됩니다. 표준 오류와 fd 3만 상속되며, 코어가 나머지 fd를 직접 모두 닫습니다. +- 코어는 채널을 받거나 루트 키를 읽기 전에 `umask 077`을 설정하고, 코어 덤프를 끄고, `ptrace`를 거부하고, `no_new_privs`를 설정하며, Linux에서는 `mlockall`로 모든 메모리를 잠급니다. 메모리를 잠글 수 없으면 `--allow-unlocked-memory`가 주어지지 않는 한 기동을 거부합니다. +- 코어는 채널이 `AF_UNIX` 스트림 소켓이고 상대의 UID가 자신의 UID, 또는 독립 소켓 모드에서 지정한 `--peer-uid`와 같을 때만 받습니다. +- 모든 프레임은 고정 16바이트 헤더, 길이 접두 필드, 1 MiB 본문 제한을 따릅니다. `HELLO`가 일치하는 프로토콜 버전으로 가장 먼저 와야 하고, 남는 바이트는 거부되며, 프레이밍 위반이 있으면 세션이 끝납니다. +- 호스트는 모든 코어 호출에 고정 deadline(기본 10초)을 적용합니다. 요청이 취소되어도 공유 코어 세션은 깨지지 않고, 제때 종료되지 않는 코어는 강제 종료됩니다. + +## 구성원 간 통신 + +pairier는 구성원 간 트래픽을 두 계층으로 보호합니다. mTLS가 채널을 인증하고, 봉투 서명이 보내는 모든 키를 인증합니다. TLS 계층을 통과한 상대라도 송신자의 신원 키 없이는 키를 전달할 수 없습니다. + +TLS 계층: + +- TLS 1.3만 사용합니다. Go 1.27은 FIPS 모드에서도 X25519MLKEM768 하이브리드 키 교환을 가장 먼저 제안하므로, 기록된 pairier 노드 간 트래픽은 ML-KEM이 안전한 한 양자 컴퓨터를 이용한 사후 복호화에 견딥니다. TLS 인증 자체는 고전 방식인 ECDSA P-256을 사용합니다. +- 각 노드의 TLS 키는 코어 안에 있는 ECDSA P-256 키입니다. TLS 핸드셰이크 서명은 코어가 수행하며, 개인키는 `kmsd`나 `kmsctl`에 들어가지 않습니다. +- 서버는 클라이언트 인증서를 요구합니다. 인증서의 subject가 등록된 구성원 id와 같고 공개키가 그 구성원의 검증된 카드에 있는 TLS 키와 같을 때만 받습니다. 이어서 TLS 핸드셰이크가 클라이언트가 그에 맞는 개인키를 가지고 있음을 증명합니다. +- 클라이언트도 같은 방식으로 서버를 고정합니다. 서버 키를 수신자 카드의 키와, subject를 수신자 구성원 id와 비교합니다. 서버가 이 검사를 통과한 뒤에야 자기 인증서를 보내므로, 가짜 서버는 어느 구성원이 접속하는지 알 수 없습니다. +- 모든 요청은 등록된 카드와 다시 대조됩니다. 갱신된 카드가 `member add`로 등록되면 이전 TLS 키로는 더 이상 인증되지 않습니다. +- `kmsctl`은 `https://` URL만 받고 프록시를 쓰지 않습니다. pairier 클라이언트는 HTTP/1.1을 사용합니다. + +HTTP 서비스: + +- 엔드포인트: `GET /v1/health`, `GET /v1/card`, `POST /v1/inbox`, `GET /v1/witness`. 모두 인증된 구성원만 호출할 수 있습니다. +- 제한: 동시 연결 64개, TLS 핸드셰이크와 요청 헤더에 각각 10초, 헤더 16 KiB, 요청당 15초, 요청 본문은 유효한 봉투의 최대 크기 이하 +- 송신자당 대기 중인 봉투는 최대 64개입니다. 그 이상은 `429`로 거부됩니다. +- 응답에는 `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`가 붙습니다. + +봉투 계층: + +- KXP1 봉투는 컨텍스트, 키 패키지, 그리고 둘 모두에 대한 송신자 신원 키의 서명을 담습니다. +- 컨텍스트는 송신자와 수신자를 지정하고, 생성 시각, 16바이트 난수 nonce, 송신자의 현재 감사 로그 head를 담습니다. 감사 head가 없는 컨텍스트는 거부되며, 1.0.0이 만든 봉투가 모두 여기에 해당합니다. +- 패키지는 송신자 코어 안에서 만들어집니다. 키는 수신자의 KEM 키(DHKEM P-256, PQC 모드에서는 MLKEM768-P256)로 봉인되고 컨텍스트가 결합되며, 수신자 코어는 패키지를 열기 전에 컨텍스트를 상수 시간으로 비교합니다. +- 송신자는 `export`가 있는 키에서만, 그 키 용도의 부분 집합만 부여할 수 있습니다. 수신자 코어는 256비트 대칭 키만 가져옵니다. +- 수신 측은 자신이 수신자인지, 송신자가 등록되어 있는지, 서명 알고리즘이 송신자 카드와 일치하는지, 서명이 검증되는지 확인합니다. `kmsd`는 TLS 상대가 봉투에 적힌 송신자와 같아야 한다는 조건도 확인하므로, 한 구성원이 다른 구성원 이름으로 봉투를 전달할 수 없습니다. +- 수신 측은 송신자의 감사 head를 기록합니다. 같은 순번에 다른 해시인 head를 이전에 본 적이 있으면 봉투를 거부하고 그 충돌을 감사 로그에 기록합니다. +- 생성 시각에 대한 유효 기간은 강제하지 않습니다. 재전송된 봉투는 대기 중일 때는 중복으로 거부되고, 키를 이미 가져온 뒤에는 다시 가져오려 해도 같은 id의 키가 있으므로 실패합니다. +- 봉투, 신원 카드, 감사 체크포인트, TLS 핸드셰이크는 서로 다른 메시지 형식을 쓰고, 신원·감사·TLS 키는 서로 다른 키이므로, 한 용도로 만든 서명은 다른 용도에서 유효하지 않습니다. + +## 로컬 키 저장소 + +- 노드 디렉토리는 `0700`이고 현재 사용자 소유여야 하며, 파일에는 그룹이나 기타 권한이 없어야 합니다. 심볼릭 링크는 따라가지 않습니다. +- 기록은 임시 파일에 쓰고 동기화한 뒤 제자리로 옮기며, 이어서 디렉토리도 동기화합니다. 키 기록은 한 번만 쓰이고 덮어쓰지 않습니다. +- `root.key`는 코어가 `0400` 모드로 만듭니다. 코어는 링크를 따라가지 않고 파일을 열며, 현재 사용자 소유이고, 그룹이나 기타 권한이 없고, 링크가 하나뿐이고, 정확히 32바이트인 일반 파일만 받습니다. +- 코어 안에서 루트 키는 양쪽에 가드 페이지가 있는 전용 메모리 영역에 있습니다. 이 영역은 잠겨 있고, 코어 덤프에서 제외되며, 해제될 때 0으로 지워집니다. +- 각 blob은 루트 키와 새로 만든 32바이트 salt에서 HKDF-SHA256으로 유도한 키와 nonce로 AES-256-GCM 봉인됩니다. blob 헤더(버전, 알고리즘, 용도, 키 id, 라벨)는 전부 연관 데이터로 인증됩니다. +- 코어는 키에 지정된 용도를 벗어나는 모든 작업을 거부합니다. 호스트는 사용할 때마다 저장된 메타데이터와 공개키를 코어가 blob에서 다시 계산한 값과 대조합니다. +- 노드 기동, 키 생성, 암호화, 복호화, 구성원 변경, 보내고 받은 봉투, inbox 처리, 카드 갱신, 증인 충돌은 해시 체인 감사 로그에 기록되며, 각 항목은 코어의 감사 키로 서명됩니다. + ## 알려진 한계 다음은 취약점이 아니라 설계상 선택이거나 공개된 미해결 항목입니다. 이 범위를 넘어서는 신고는 환영합니다. @@ -64,6 +135,17 @@ pairier는 "Abandon trust. Choose isolation." 원칙으로 설계되었습니다 - MLKEM768-P256 하이브리드 KEM은 draft-ietf-hpke-pq(HPKE KEM id 0x0050)를 따릅니다. 아직 Internet-Draft이므로 RFC가 되기 전에 바뀔 수 있습니다. 코어는 개인키를 초안이 정의한 32바이트 시드 대신 확장된 형태로 보관하며, 공개키, 암호문, 공유 비밀은 달라지지 않습니다. - `export` 권한과 함께 키를 받은 구성원은 그 키를 다른 구성원에게 다시 보낼 수 있습니다. 재공유를 막으려면 `export`를 주지 마세요. - 감사 로그는 항목마다 코어의 감사 키로 서명한 해시 체인입니다. 노드 사용자 권한으로 코어를 기동할 수 있으면 유효한 항목을 만들 수 있습니다. 로그 끝부분을 잘라 내거나 이전 로그로 되돌리는 것은, 다른 구성원이 받은 봉투에서 증인으로 보관한 head나 노드 밖에 기록해 둔 head와 대조할 때만 탐지됩니다. 마지막으로 증인이 본 head 이후의 항목은 보호되지 않고, 구성원은 송신자마다 head를 최대 4096개까지 보관하며 순번이 가장 낮은 것부터 버립니다. 정직하지 않은 증인은 오탐만 일으킬 수 있습니다. +- 노드가 무인으로 기동할 수 있도록 `root.key`는 패스프레이즈나 외부 키로 감싸지 않습니다. 노드 디렉토리 백업에는 루트 키가 들어 있으므로 루트 키와 같은 수준으로 보호해야 합니다. +- `kmsd`와 `kmsctl`은 항상 코어를 같은 사용자로 직접 기동합니다. 코어는 이미 별도 사용자로 소켓 경로에서 실행하는 방식(`--socket`, `--peer-uid`)을 지원하지만, `kmsd`와 `kmsctl`은 아직 그 코어에 접속할 수 없습니다. +- 키 수명 주기가 완전하지 않습니다. 키를 삭제하는 명령이 없고, `card renew`는 이전 TLS 키 blob을 `keys/`에 남기며, 신원 키와 감사 키는 교체할 수 없고, KEM 키는 알고리즘이 폐기될 때만 교체되며, 루트 키도 교체할 수 없습니다. +- 구성원을 제거하거나 카드를 폐기하는 명령이 없습니다. +- 신원 카드에는 세대 번호가 없습니다. `member add`는 같은 신원 키로 올바르게 서명된 카드라면 이전 카드도 받으므로, 갱신된 카드마다 지문을 확인하세요. +- `kmsd`는 TLS 세션 재개를 받습니다. 재개된 연결은 핸드셰이크 시점의 인증서 검사와 TLS 키 소유 증명을 건너뛰고, 요청마다 인증서를 등록된 카드와 대조하는 검사만 남습니다. 세션 티켓 키는 `kmsd` 메모리에만 있습니다. pairier 클라이언트는 세션을 재개하지 않으며, 봉투를 보내려면 여전히 송신자의 신원 서명이 필요합니다. +- pairier 클라이언트는 HTTP/1.1만 쓰지만, `kmsd`는 요청하는 클라이언트에게 HTTP/2도 제공합니다. +- `kmsctl`이 상대의 `kmsd`에 접속할 때 쓰는 클라이언트는 HTTP 리다이렉트를 따라갑니다. 리다이렉트를 보낼 수 있는 것은 이미 고정 검사를 통과한 상대뿐입니다. +- 구성원 카드를 가진 사람은 누구나 핸드셰이크가 실패하기 전에 접속 시도마다 `kmsd`가 코어에서 카드 서명 검사를 한 번 실행하게 만들 수 있습니다. 코어 호출은 직렬로 처리되며, 동시 연결 64개 제한이 부하를 묶습니다. +- 설정 파일은 그룹과 기타 쓰기 권한만 검사하고 소유자는 검사하지 않으며, 설정이 가리키는 코어 바이너리의 소유자나 권한도 검사하지 않습니다. 둘 다 노드 사용자나 root 소유로 두세요. +- `fips` feature는 AWS-LC FIPS 140-3 검증 모듈을 선택하지만, 아래에 나열한 모든 알고리즘이 모듈 인증서 범위에 들어가는지는 확인하지 않았습니다. 특정 알고리즘에 FIPS를 기대하기 전에 해당 모듈 버전의 CMVP 인증서를 확인하세요. CI는 아직 `kmsd`가 `go_fips=true`를 보고하는지 확인하지 않습니다. - macOS에서는 코어가 메모리 잠금 없이 실행되고 FIPS 코어도 정적 링크되지 않습니다. macOS는 개발용입니다. - `make audit`는 로컬 RustSec 권고 데이터베이스를 사용하며, 네트워크가 연결된 장비에서 갱신해야 합니다. - Rust 와이어 디코더 변이 테스트는 시드 기반이며 커버리지 유도 방식이 아닙니다. @@ -90,8 +172,10 @@ $ sha256sum --strict -c SHA256SUMS ## 암호 -- 코어: aws-lc-rs. `fips` feature로 AWS-LC FIPS 140-3 검증 모듈을 선택합니다 (Linux 정적 빌드). +- 코어: aws-lc-rs. `fips` feature로 AWS-LC FIPS 140-3 검증 모듈을 선택합니다(Linux 정적 빌드). - 호스트: Go 표준 라이브러리만 사용하며 기본적으로 `GOFIPS140=certified`로 빌드합니다. - 알고리즘: AES-256-GCM, ECDSA P-256/P-384, Ed25519, RFC 9180 DHKEM(P-256), ML-DSA-65/87, ML-KEM-768/1024, draft-ietf-hpke-pq의 MLKEM768-P256 하이브리드 KEM -- mTLS: TLS 키는 코어 안에 있는 ECDSA P-256 키입니다. `kmsd`와 `kmsctl`은 서명할 메시지 전체를 넘기고, 코어가 해시와 서명을 수행합니다. -- 1.0.0의 하이브리드 KEM(`0x0210`)은 폐기되었습니다. 대기 중인 봉투를 받을 수 있도록 코어가 그 키를 여전히 열 수 있지만, 새로 만들거나 그 키로 봉인하지는 않습니다. +- 키 유도: 키 blob과 키 패키지 모두 HKDF-SHA256을 사용하며, 각각 별도 라벨을 사용합니다. +- mTLS: X25519MLKEM768 키 교환을 우선하는 TLS 1.3입니다. TLS 키는 코어 안에 있는 ECDSA P-256 키입니다. `kmsd`와 `kmsctl`은 서명할 메시지 전체를 넘기고, 코어가 해시와 서명을 수행합니다. +- 신원 카드는 신원 키로 자체 서명되며, 지문은 서명 대상 카드 내용의 SHA-256 해시입니다. +- 1.0.0의 하이브리드 KEM(`0x0210`)은 폐기되었습니다. 코어는 1.0.0에서 만든 노드가 기동하고 `card renew`를 실행할 수 있도록 그 키를 여는 것만 허용하며, 새로 만들거나 그 키로 봉인하거나 그 키로 키를 가져오지는 않습니다. diff --git a/USAGE.md b/USAGE.md index a764c29..96f8453 100644 --- a/USAGE.md +++ b/USAGE.md @@ -46,10 +46,10 @@ Go binaries are built with the Go Cryptographic Module (`GOFIPS140=certified`) b ```bash $ kms-core --version -kms-core 1.0.0 (kms-engine 1.0.0, fips=true, pqc=true) +kms-core 2.0.0 (kms-engine 2.0.0, fips=true, pqc=true) $ kmsctl version -kmsctl 1.0.0 (go go1.27.2, go_fips=true) +kmsctl 2.0.0 (go go1.27.2, go_fips=true) ``` ## Installation @@ -61,7 +61,7 @@ The distribution tarball contains `kms-core`, `kmsd`, `kmsctl`, `VERSION`, `sbom ```bash $ minisign -Vm SHA256SUMS -p minisign.pub $ sha256sum --strict --ignore-missing -c SHA256SUMS -$ tar -xzf pairier-1.0.0-linux-amd64.tar.gz +$ tar -xzf pairier-2.0.0-linux-amd64.tar.gz ``` `SHA256SUMS` lists the files for both architectures, and `--ignore-missing` skips the ones you did not download. @@ -142,7 +142,7 @@ If the recipient keeps `kmsd` running, the sender can push the envelope directly ```bash bob$ kmsd --dir ~/.pairier --listen 0.0.0.0:7443 -kmsd 1.0.0 member bob listening on 0.0.0.0:7443 core_fips=false go_fips=true pqc=true +kmsd 2.0.0 member bob listening on 0.0.0.0:7443 core_fips=false go_fips=true pqc=true alice$ kmsctl --dir ~/.pairier peer health --member bob --url https://bob.example:7443 ok bob @@ -205,10 +205,16 @@ send the new card and fingerprint to every member and restart kmsd `card renew` creates a new TLS key inside the core and issues a new certificate for it. If the KEM key uses the retired hybrid KEM (`hybrid-p256-ml-kem-768`, created by 1.0.0 with `--pqc`), it is replaced with an `mlkem768-p256` key. The identity and audit keys stay the same, so members update the card with `member add` after verifying the new fingerprint over a separate channel, as before. -- Nodes created by 1.0.0 kept the TLS private key outside the core. They must run `card renew` before `kmsd`, `key push`, `peer health` and `audit verify --peer` work again. -- Members can no longer send keys to a card that still uses the retired hybrid KEM. -- After the KEM key is replaced, envelopes sealed to the old card can no longer be received. Accept pending envelopes before renewing. -- Envelopes now carry the sender's audit head, which 1.0.0 cannot read. Upgrade every member of a group together. +### Upgrading from 1.0.0 + +This version cannot exchange envelopes with 1.0.0 in either direction, and nodes created by 1.0.0 must renew their cards. Upgrade a group in the following order. + +1. Stop sending keys. While every node still runs 1.0.0, accept or reject all pending envelopes with `inbox accept` and `inbox reject`, and import every offline envelope file with `key receive`. This version refuses every envelope created by 1.0.0, because those envelopes carry no audit head. +2. Upgrade every member of the group together. Envelopes now carry the sender's audit head, which 1.0.0 cannot read. +3. Run `card renew` on every node. Nodes created by 1.0.0 kept the TLS private key outside the core, so `kmsd`, `key push`, `peer health` and `audit verify --peer` work only after renewal. +4. Send the new card and fingerprint to every member. Members register it with `member add` after verifying the fingerprint over a separate channel. Then restart `kmsd`. + +Members cannot send keys to a card that still uses the retired hybrid KEM, and the node that owns that card cannot receive keys until it runs `card renew`. ## kmsctl command reference @@ -415,6 +421,7 @@ The directory that holds the socket must be owned by the core user or root. A di | `transport: socket directory ... is writable by other users` | Remove group and other write permission from the socket directory. | | `node: tls key is held outside the core, ...` | The node was created by 1.0.0. Run `card renew`, send the new card and fingerprint to members and restart kmsd. | | `node: member card uses a retired kem, ...` | Ask the member to run `card renew`, then register the new card. | +| `exchange: malformed object: envelope from pairier 1.0.0 ...` | The envelope was created by 1.0.0, which this version refuses. Ask the sender to upgrade and send the key again. | | `node: member identity key differs from the registered card` | The card is not a renewal of the registered member. Check the fingerprint over a separate channel. | | `node: member audit head conflicts with a head witnessed earlier` | The sender's audit log forked or was rolled back. Investigate the sender's node. Delete `witness/.json` only after confirming a backup restore over a separate channel. | | `` linker `link.exe` not found `` | Native Windows builds are not supported. Build inside WSL2 (see [Build](#build)). | diff --git a/USAGE_KR.md b/USAGE_KR.md index 67f492d..01131b3 100644 --- a/USAGE_KR.md +++ b/USAGE_KR.md @@ -46,10 +46,10 @@ Go 바이너리는 기본적으로 Go 인증 암호 모듈(`GOFIPS140=certified` ```bash $ kms-core --version -kms-core 1.0.0 (kms-engine 1.0.0, fips=true, pqc=true) +kms-core 2.0.0 (kms-engine 2.0.0, fips=true, pqc=true) $ kmsctl version -kmsctl 1.0.0 (go go1.27.2, go_fips=true) +kmsctl 2.0.0 (go go1.27.2, go_fips=true) ``` ## 설치 @@ -61,7 +61,7 @@ kmsctl 1.0.0 (go go1.27.2, go_fips=true) ```bash $ minisign -Vm SHA256SUMS -p minisign.pub $ sha256sum --strict --ignore-missing -c SHA256SUMS -$ tar -xzf pairier-1.0.0-linux-amd64.tar.gz +$ tar -xzf pairier-2.0.0-linux-amd64.tar.gz ``` `SHA256SUMS`에는 두 아키텍처의 파일이 모두 들어 있으며, `--ignore-missing`은 받지 않은 파일을 건너뜁니다. @@ -142,7 +142,7 @@ af8477cc1076f219bfb047d846629eb8 aes-256-gcm decrypt project/db from alice ```bash bob$ kmsd --dir ~/.pairier --listen 0.0.0.0:7443 -kmsd 1.0.0 member bob listening on 0.0.0.0:7443 core_fips=false go_fips=true pqc=true +kmsd 2.0.0 member bob listening on 0.0.0.0:7443 core_fips=false go_fips=true pqc=true alice$ kmsctl --dir ~/.pairier peer health --member bob --url https://bob.example:7443 ok bob @@ -205,10 +205,16 @@ send the new card and fingerprint to every member and restart kmsd `card renew`는 코어 안에서 새 TLS 키를 만들고 그 키로 새 인증서를 발급합니다. KEM 키가 폐기된 하이브리드 KEM(1.0.0에서 `--pqc`로 만든 `hybrid-p256-ml-kem-768`)이면 `mlkem768-p256` 키로 교체합니다. 신원 키와 감사 키는 그대로이므로, 구성원은 새 지문을 이전과 같이 별도 채널로 확인한 뒤 `member add`로 카드를 갱신합니다. -- 1.0.0에서 만든 노드는 TLS 개인키를 코어 밖에 두었습니다. `card renew`를 실행해야 `kmsd`, `key push`, `peer health`, `audit verify --peer`를 다시 쓸 수 있습니다. -- 폐기된 하이브리드 KEM을 쓰는 카드로는 더 이상 키를 보낼 수 없습니다. -- KEM 키를 교체한 뒤에는 이전 카드로 봉인된 봉투를 받을 수 없습니다. 갱신 전에 대기 중인 봉투를 먼저 수락하세요. -- 봉투에 송신자의 감사 로그 head가 담기며 1.0.0은 이를 읽지 못합니다. 그룹의 모든 구성원을 함께 업그레이드하세요. +### 1.0.0에서 업그레이드 + +이 버전은 1.0.0과 어느 방향으로도 봉투를 주고받을 수 없고, 1.0.0에서 만든 노드는 카드를 갱신해야 합니다. 그룹은 다음 순서로 업그레이드하세요. + +1. 키 전송을 멈춥니다. 모든 노드가 아직 1.0.0일 때 대기 중인 봉투를 `inbox accept`와 `inbox reject`로 모두 처리하고, 오프라인 봉투 파일도 모두 `key receive`로 가져옵니다. 1.0.0이 만든 봉투에는 감사 head가 없으므로 이 버전은 그런 봉투를 모두 거부합니다. +2. 그룹의 모든 구성원을 함께 업그레이드합니다. 봉투에 송신자의 감사 로그 head가 담기며 1.0.0은 이를 읽지 못합니다. +3. 모든 노드에서 `card renew`를 실행합니다. 1.0.0에서 만든 노드는 TLS 개인키를 코어 밖에 두었으므로, 갱신해야 `kmsd`, `key push`, `peer health`, `audit verify --peer`를 쓸 수 있습니다. +4. 새 카드와 지문을 모든 구성원에게 보냅니다. 구성원은 별도 채널로 지문을 확인한 뒤 `member add`로 등록합니다. 그다음 `kmsd`를 다시 시작합니다. + +폐기된 하이브리드 KEM을 쓰는 카드로는 키를 보낼 수 없고, 그 카드의 노드도 `card renew`를 실행하기 전에는 키를 받을 수 없습니다. ## kmsctl 명령 참조 @@ -415,6 +421,7 @@ $ kms-core --socket /run/pairier/core.sock --root-key-file /path/root.key --pqc | `transport: socket directory ... is writable by other users` | 소켓 디렉토리에서 그룹과 기타 사용자의 쓰기 권한을 빼야 합니다. | | `node: tls key is held outside the core, ...` | 1.0.0에서 만든 노드입니다. `card renew`를 실행하고 새 카드와 지문을 구성원에게 보낸 뒤 kmsd를 다시 시작해야 합니다. | | `node: member card uses a retired kem, ...` | 구성원에게 `card renew`를 요청한 뒤 새 카드를 등록해야 합니다. | +| `exchange: malformed object: envelope from pairier 1.0.0 ...` | 1.0.0이 만든 봉투이며 이 버전은 받지 않습니다. 송신자가 업그레이드한 뒤 키를 다시 보내야 합니다. | | `node: member identity key differs from the registered card` | 등록된 구성원의 갱신 카드가 아닙니다. 별도 채널로 지문을 확인해야 합니다. | | `node: member audit head conflicts with a head witnessed earlier` | 송신자의 감사 로그가 분기했거나 되돌려졌습니다. 송신자 노드를 조사해야 합니다. 백업 복원임을 별도 채널로 확인한 경우에만 `witness/.json`을 지웁니다. | | `` linker `link.exe` not found `` | Windows 네이티브 빌드는 지원하지 않습니다. WSL2 안에서 빌드해야 합니다([빌드](#빌드) 참고). | diff --git a/core/Cargo.lock b/core/Cargo.lock index 0ac4e7d..af951a1 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -169,7 +169,7 @@ dependencies = [ [[package]] name = "kms-core" -version = "1.0.0" +version = "2.0.0" dependencies = [ "kms-engine", "kms-sys", @@ -180,7 +180,7 @@ dependencies = [ [[package]] name = "kms-engine" -version = "1.0.0" +version = "2.0.0" dependencies = [ "aws-lc-rs", "kms-sys", @@ -190,7 +190,7 @@ dependencies = [ [[package]] name = "kms-sys" -version = "1.0.0" +version = "2.0.0" dependencies = [ "libc", "zeroize", @@ -198,7 +198,7 @@ dependencies = [ [[package]] name = "kms-wire" -version = "1.0.0" +version = "2.0.0" [[package]] name = "libc" diff --git a/core/Cargo.toml b/core/Cargo.toml index 44d170f..9f65f33 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*"] [workspace.package] -version = "1.0.0" +version = "2.0.0" edition = "2024" rust-version = "1.95" license = "Apache-2.0" diff --git a/core/crates/kms-engine/src/lib.rs b/core/crates/kms-engine/src/lib.rs index 4ef5520..0a7fb04 100644 --- a/core/crates/kms-engine/src/lib.rs +++ b/core/crates/kms-engine/src/lib.rs @@ -161,6 +161,14 @@ impl Engine { Ok(k) } + fn open_current_kem(&self, blob: &[u8]) -> Result { + let k = self.open_for(blob, AlgKind::Kem, kms_wire::usage::DECAPSULATE)?; + if k.alg.is_retired() { + return Err(Error::UnsupportedAlgorithm); + } + Ok(k) + } + pub fn aead_encrypt( &self, blob: &[u8], @@ -271,7 +279,7 @@ impl Engine { { return Err(Error::LimitExceeded); } - let k = self.open_for(kem_blob, AlgKind::Kem, kms_wire::usage::DECAPSULATE)?; + let k = self.open_current_kem(kem_blob)?; let imported = export::open(k.alg, &k.secret, package, context)?; if imported.alg.kind() != AlgKind::Symmetric || imported.secret.len() != aead::KEY_LEN { return Err(Error::InvalidArgument); @@ -293,7 +301,7 @@ impl Engine { } pub fn decapsulate(&self, blob: &[u8], ciphertext: &[u8]) -> Result>, Error> { - let k = self.open_for(blob, AlgKind::Kem, kms_wire::usage::DECAPSULATE)?; + let k = self.open_current_kem(blob)?; kem::decapsulate(k.alg, &k.secret, ciphertext) } } @@ -535,7 +543,7 @@ mod tests { #[cfg(feature = "pqc")] #[test] - fn retired_hybrid_opens_but_never_seals() { + fn retired_hybrid_opens_but_never_seals_or_imports() { let e = engine(); let decap = Usage::from_bits(usage::DECAPSULATE).unwrap(); let legacy = Alg::HybridP256MlKem768; @@ -575,9 +583,15 @@ mod tests { context: b"ctx", }; let pkg = super::export::seal(&pending, &public_key, &opened.secret).unwrap(); - let imp = e.import_key(&kem_blob, &pkg, b"ctx").unwrap(); - assert_eq!(imp.key_id, k.key_id); - assert_eq!(imp.usage, grant); + assert_eq!( + e.import_key(&kem_blob, &pkg, b"ctx"), + Err(Error::UnsupportedAlgorithm) + ); + let (ct, _) = super::kem::encapsulate(legacy, &public_key).unwrap(); + assert!(matches!( + e.decapsulate(&kem_blob, &ct), + Err(Error::UnsupportedAlgorithm) + )); } fn exchange_roundtrip(sender: &Engine, recipient: &Engine, kem_alg: Alg) { diff --git a/host/internal/core/client.go b/host/internal/core/client.go index 4b2b2c9..aa4bf46 100644 --- a/host/internal/core/client.go +++ b/host/internal/core/client.go @@ -222,15 +222,9 @@ func (c *Client) call(ctx context.Context, req, rsp wire.Message) error { return err } - deadline := time.Now().Add(c.timeout) - if d, ok := ctx.Deadline(); ok && d.Before(deadline) { - deadline = d - } - if err := c.conn.SetDeadline(deadline); err != nil { + if err := c.conn.SetDeadline(time.Now().Add(c.timeout)); err != nil { return c.failLocked(err) } - stop := context.AfterFunc(ctx, func() { _ = c.conn.SetDeadline(time.Unix(1, 0)) }) - defer stop() frame := make([]byte, 0, len(hb)+len(body)) frame = append(frame, hb[:]...) diff --git a/host/internal/core/client_test.go b/host/internal/core/client_test.go index 3b391dd..cb79863 100644 --- a/host/internal/core/client_test.go +++ b/host/internal/core/client_test.go @@ -237,29 +237,57 @@ func TestBadHelloRejected(t *testing.T) { } } -func TestContextCancelUnblocksAndBreaks(t *testing.T) { - conn := fakeCore(t, func(h wire.Header, _ []byte) (wire.Header, []byte) { +func slowCore(t *testing.T, delay time.Duration) net.Conn { + t.Helper() + return fakeCore(t, func(h wire.Header, _ []byte) (wire.Header, []byte) { if h.Op == wire.OpHello { return helloHandler(0)(h, nil) } - time.Sleep(2 * time.Second) + time.Sleep(delay) rh, _ := wire.NewResponse(h.Op, h.ID, 0) return rh, nil }) - c, err := newClient(context.Background(), conn, nil, 5*time.Second) +} + +func TestCallerCancelDoesNotBreakSession(t *testing.T) { + c, err := newClient(context.Background(), slowCore(t, 200*time.Millisecond), nil, 5*time.Second) if err != nil { t.Fatal(err) } defer c.Close() ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() + if err := c.Ping(ctx); err != nil { + t.Fatalf("call abandoned by its caller failed: %v", err) + } + if err := c.Ping(context.Background()); err != nil { + t.Fatalf("session broken after an abandoned call: %v", err) + } + done, cancelDone := context.WithCancel(context.Background()) + cancelDone() + if err := c.Ping(done); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled before sending, got %v", err) + } + if err := c.Ping(context.Background()); err != nil { + t.Fatalf("session broken after a cancelled caller: %v", err) + } +} + +func TestHungCoreBreaksSessionAfterTimeout(t *testing.T) { + c, err := newClient(context.Background(), slowCore(t, 2*time.Second), nil, 200*time.Millisecond) + if err != nil { + t.Fatal(err) + } + defer c.Close() start := time.Now() - err = c.Ping(ctx) - if !errors.Is(err, ErrBroken) { + if err := c.Ping(context.Background()); !errors.Is(err, ErrBroken) { t.Fatalf("expected ErrBroken, got %v", err) } if time.Since(start) > time.Second { - t.Fatal("cancel did not unblock the call promptly") + t.Fatal("timeout did not unblock the call promptly") + } + if err := c.Ping(context.Background()); !errors.Is(err, ErrBroken) { + t.Fatalf("expected ErrBroken after timeout, got %v", err) } } diff --git a/host/internal/exchange/context.go b/host/internal/exchange/context.go index 55768a7..b50894b 100644 --- a/host/internal/exchange/context.go +++ b/host/internal/exchange/context.go @@ -13,7 +13,6 @@ import ( const ( ContextVersion = 2 - contextV1 = 1 MaxMemberID = 64 NonceLen = 16 AuditHashLen = 32 @@ -67,8 +66,6 @@ func NewContext(sender, recipient string, now time.Time, auditSeq uint64, auditH return c, nil } -func (c *Context) HasAuditHead() bool { return c.AuditSeq != 0 } - func (c *Context) Encode() ([]byte, error) { if !ValidMemberID(c.Sender) || !ValidMemberID(c.Recipient) { return nil, ErrBadMemberID @@ -93,7 +90,13 @@ func DecodeContext(b []byte) (*Context, error) { } r := wire.NewReader(b) v, err := r.U8() - if err != nil || (v != contextV1 && v != ContextVersion) { + if err != nil { + return nil, ErrFormat + } + if v == 1 { + return nil, fmt.Errorf("%w: envelope from pairier 1.0.0 is no longer accepted", ErrFormat) + } + if v != ContextVersion { return nil, ErrFormat } c := &Context{} @@ -111,16 +114,14 @@ func DecodeContext(b []byte) (*Context, error) { return nil, ErrFormat } copy(c.Nonce[:], nonce) - if v == ContextVersion { - if c.AuditSeq, err = r.U64(); err != nil || c.AuditSeq == 0 { - return nil, ErrFormat - } - hash, err := r.Array(AuditHashLen) - if err != nil { - return nil, ErrFormat - } - copy(c.AuditHash[:], hash) + if c.AuditSeq, err = r.U64(); err != nil || c.AuditSeq == 0 { + return nil, ErrFormat + } + hash, err := r.Array(AuditHashLen) + if err != nil { + return nil, ErrFormat } + copy(c.AuditHash[:], hash) if err := r.Finish(); err != nil { return nil, ErrFormat } diff --git a/host/internal/exchange/exchange_test.go b/host/internal/exchange/exchange_test.go index 5c6567d..7f285fa 100644 --- a/host/internal/exchange/exchange_test.go +++ b/host/internal/exchange/exchange_test.go @@ -27,7 +27,7 @@ func TestContextRoundtripAndValidation(t *testing.T) { t.Fatal(err) } if d.Sender != "alice" || d.Recipient != "bob" || d.CreatedAt != 1_700_000_000 || d.Nonce != c.Nonce || - !d.HasAuditHead() || d.AuditSeq != 7 || !bytes.Equal(d.AuditHash[:], head) { + d.AuditSeq != 7 || !bytes.Equal(d.AuditHash[:], head) { t.Fatalf("mismatch %+v", d) } w := wire.NewWriter() @@ -37,9 +37,8 @@ func TestContextRoundtripAndValidation(t *testing.T) { w.U64(5) w.Bytes(c.Nonce[:]) v1, _ := w.Finish() - old, err := DecodeContext(v1) - if err != nil || old.HasAuditHead() || old.Sender != "alice" || old.CreatedAt != 5 { - t.Fatalf("version 1 context: %+v %v", old, err) + if _, err := DecodeContext(v1); !errors.Is(err, ErrFormat) { + t.Fatalf("version 1 context without an audit head accepted: %v", err) } zero := append([]byte{}, b...) copy(zero[len(b)-4-AuditHashLen-8:], make([]byte, 8)) diff --git a/host/internal/node/node.go b/host/internal/node/node.go index 67d0195..c27cda9 100644 --- a/host/internal/node/node.go +++ b/host/internal/node/node.go @@ -133,9 +133,6 @@ func addWitnessHead(heads []store.WitnessHead, head store.WitnessHead) ([]store. } func (n *Node) witness(member string, ectx *exchange.Context) error { - if !ectx.HasAuditHead() { - return nil - } head := store.WitnessHead{Seq: ectx.AuditSeq, Hash: hex.EncodeToString(ectx.AuditHash[:]), SeenAt: n.opts.now().Unix()} var prior string err := n.st.UpdateWitness(member, func(r *store.WitnessRecord) (bool, error) { diff --git a/host/internal/node/node_test.go b/host/internal/node/node_test.go index ca48fae..0caadcb 100644 --- a/host/internal/node/node_test.go +++ b/host/internal/node/node_test.go @@ -933,3 +933,66 @@ func TestPeersWitnessAuditHeads(t *testing.T) { t.Fatalf("rollback hidden from the witnessed heads: %v", err) } } + +func TestEnvelopeWithoutAuditHeadIsRejected(t *testing.T) { + ctx := context.Background() + alice, _ := initNode(t, "alice", false) + bob, _ := initNode(t, "bob", false) + pair(t, alice, bob) + k, err := alice.GenerateKey(ctx, "headless", wire.UsageDecrypt|wire.UsageExport) + if err != nil { + t.Fatal(err) + } + rec, err := alice.Key(ctx, k.KeyID) + if err != nil { + t.Fatal(err) + } + card, err := alice.Member(ctx, "bob") + if err != nil { + t.Fatal(err) + } + var nonce [exchange.NonceLen]byte + if _, err := rand.Read(nonce[:]); err != nil { + t.Fatal(err) + } + w := wire.NewWriter() + w.U8(1) + w.String("alice") + w.String("bob") + w.U64(uint64(alice.opts.now().Unix())) + w.Bytes(nonce[:]) + cb, err := w.Finish() + if err != nil { + t.Fatal(err) + } + pkg, err := alice.core.ExportKey(ctx, rec.Blob, card.KemAlg, card.KemPublicKey, wire.UsageDecrypt, cb) + if err != nil { + t.Fatal(err) + } + sw := wire.NewWriter() + sw.Bytes(cb) + sw.Bytes(pkg) + sw.U16(alice.identity.Alg) + body, err := sw.Finish() + if err != nil { + t.Fatal(err) + } + sig, err := alice.core.Sign(ctx, alice.identity.Blob, append([]byte("KXP1"), body...)) + if err != nil { + t.Fatal(err) + } + env := &exchange.Envelope{Context: cb, Package: pkg, SigAlg: alice.identity.Alg, Signature: sig} + raw, err := env.Encode() + if err != nil { + t.Fatal(err) + } + if _, err := bob.StoreInbox(ctx, raw, "alice"); !errors.Is(err, exchange.ErrFormat) { + t.Fatalf("envelope without audit head stored: %v", err) + } + if _, err := bob.Receive(ctx, raw); !errors.Is(err, exchange.ErrFormat) { + t.Fatalf("envelope without audit head received: %v", err) + } + if heads, err := bob.Witnessed("alice"); err != nil || len(heads) != 0 { + t.Fatalf("witness record changed: %v %v", heads, err) + } +}