feat: add post-quantum hybrid KEM + SQLCipher persistence
Feature 1 — Post-Quantum Hybrid KEM (X25519 + ML-KEM-768): - Create hybrid_kem.rs with keygen, encrypt, decrypt + 11 unit tests - Wire format: version(1) | x25519_eph_pk(32) | mlkem_ct(1088) | nonce(12) | ct - Add uploadHybridKey/fetchHybridKey RPCs to node.capnp schema - Server: hybrid key storage in FileBackedStore + RPC handlers - Client: hybrid keypair in StoredState, auto-wrap/unwrap in send/recv/invite/join - demo-group runs full hybrid PQ envelope round-trip Feature 2 — SQLCipher Persistence: - Extract Store trait from FileBackedStore API - Create SqlStore (rusqlite + bundled-sqlcipher) with encrypted-at-rest SQLite - Schema: key_packages, deliveries, hybrid_keys tables with indexes - Server CLI: --store-backend=sql, --db-path, --db-key flags - 5 unit tests for SqlStore (FIFO, round-trip, upsert, channel isolation) Also includes: client lib.rs refactor, auth config, TOML config file support, mdBook documentation, and various cleanups by user. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
177
docs/src/protocol-layers/quic-tls.md
Normal file
177
docs/src/protocol-layers/quic-tls.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# QUIC + TLS 1.3
|
||||
|
||||
quicnprotochat uses QUIC (RFC 9000) with mandatory TLS 1.3 (RFC 9001) as its client-to-server transport layer. This page explains why QUIC was chosen over raw TCP, how the `quinn` and `rustls` crates are integrated, and what security properties the transport provides.
|
||||
|
||||
## Why QUIC over raw TCP
|
||||
|
||||
The M1 milestone used raw TCP sockets with a Noise\_XX handshake for transport encryption (see [Noise\_XX Handshake](noise-xx.md)). Starting from M3, the project migrated to QUIC for several reasons:
|
||||
|
||||
| Property | Raw TCP + Noise | QUIC + TLS 1.3 |
|
||||
|---|---|---|
|
||||
| **Multiplexed streams** | Single stream; application must multiplex manually | Native bidirectional streams; each RPC call gets its own stream |
|
||||
| **0-RTT resumption** | Not available; full handshake every time | Built-in; returning clients can send data in the first flight |
|
||||
| **Head-of-line blocking** | A lost TCP segment blocks all subsequent data | Only the affected stream is blocked; other streams proceed |
|
||||
| **NAT traversal** | TCP requires keep-alives; NAT rebinding breaks connections | UDP-based; connection migration survives NAT rebinding |
|
||||
| **TLS integration** | Separate Noise handshake layered on top of TCP | TLS 1.3 is integral to the QUIC handshake; no extra round-trips |
|
||||
| **Ecosystem support** | Custom framing codec required | `capnp-rpc` can use QUIC bidirectional streams directly via `tokio-util` compat layer |
|
||||
|
||||
The migration also simplified the codebase: the custom `LengthPrefixedCodec` framing layer and the `into_capnp_io()` bridge (documented in [Noise\_XX Handshake](noise-xx.md)) are no longer needed on the QUIC path because `capnp-rpc` reads and writes directly on the QUIC stream.
|
||||
|
||||
## Crate integration
|
||||
|
||||
quicnprotochat uses the following crates for QUIC and TLS:
|
||||
|
||||
- **`quinn 0.11`** -- The async QUIC implementation for Tokio. Provides `Endpoint`, `Connection`, and bidirectional stream types.
|
||||
- **`quinn-proto 0.11`** -- The protocol-level types, including `QuicServerConfig` and `QuicClientConfig` wrappers that bridge `rustls` into `quinn`.
|
||||
- **`rustls 0.23`** -- The TLS implementation. quicnprotochat uses it in strict TLS 1.3 mode with no fallback to TLS 1.2.
|
||||
- **`rcgen 0.13`** -- Self-signed certificate generation for development and testing.
|
||||
|
||||
### Server configuration
|
||||
|
||||
The server builds its QUIC endpoint configuration in `build_server_config()` (in `quicnprotochat-server/src/main.rs`):
|
||||
|
||||
```rust
|
||||
let mut tls = rustls::ServerConfig::builder_with_protocol_versions(&[&TLS13])
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(cert_chain, key)?;
|
||||
tls.alpn_protocols = vec![b"capnp".to_vec()];
|
||||
|
||||
let crypto = QuicServerConfig::try_from(tls)?;
|
||||
Ok(ServerConfig::with_crypto(Arc::new(crypto)))
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
1. **TLS 1.3 strict mode**: `builder_with_protocol_versions(&[&TLS13])` ensures no TLS 1.2 fallback. This is a hard requirement: TLS 1.2 lacks the 0-RTT and full forward secrecy guarantees that quicnprotochat relies on.
|
||||
|
||||
2. **No client certificate authentication**: `with_no_client_auth()` means the server does not verify client certificates at the TLS layer. Client authentication is handled at the application layer via Ed25519 identity keys and MLS credentials. This is a deliberate design choice -- MLS provides stronger authentication properties than TLS client certificates.
|
||||
|
||||
3. **ALPN negotiation**: The Application-Layer Protocol Negotiation extension is set to `b"capnp"`, advertising that this endpoint speaks Cap'n Proto RPC. Both client and server must agree on this protocol identifier or the TLS handshake fails.
|
||||
|
||||
4. **`QuicServerConfig` bridge**: The `quinn-proto` crate provides `QuicServerConfig::try_from(tls)` to adapt the `rustls::ServerConfig` for use with QUIC. This handles the QUIC-specific TLS parameters (transport parameters, QUIC header protection keys) automatically.
|
||||
|
||||
### Client configuration
|
||||
|
||||
The client performs the mirror operation. It loads the server's DER-encoded certificate from a local file and constructs a `rustls::ClientConfig`:
|
||||
|
||||
```rust
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(CertificateDer::from(cert_bytes))?;
|
||||
|
||||
let tls = rustls::ClientConfig::builder_with_protocol_versions(&[&TLS13])
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
tls.alpn_protocols = vec![b"capnp".to_vec()];
|
||||
|
||||
let crypto = QuicClientConfig::try_from(tls)?;
|
||||
```
|
||||
|
||||
The client trusts exactly one certificate: the server's self-signed cert loaded from disk. There is no system trust store involved, which simplifies the trust model but requires out-of-band distribution of the server certificate.
|
||||
|
||||
### Per-connection handling
|
||||
|
||||
Each accepted QUIC connection spawns a handler task:
|
||||
|
||||
```rust
|
||||
let (send, recv) = connection.accept_bi().await?;
|
||||
let (reader, writer) = (recv.compat(), send.compat_write());
|
||||
|
||||
let network = twoparty::VatNetwork::new(reader, writer, Side::Server, Default::default());
|
||||
let service: node_service::Client = capnp_rpc::new_client(NodeServiceImpl { store, waiters });
|
||||
RpcSystem::new(Box::new(network), Some(service.client)).await?;
|
||||
```
|
||||
|
||||
The `tokio-util` compat layer (`compat()` and `compat_write()`) converts Quinn's `RecvStream` and `SendStream` into types that implement `futures::AsyncRead` and `futures::AsyncWrite`, which `capnp-rpc`'s `VatNetwork` requires. The entire Cap'n Proto RPC system then runs over this single QUIC bidirectional stream.
|
||||
|
||||
Because `capnp-rpc` uses `Rc<RefCell<>>` internally (making it `!Send`), all RPC tasks run on a `tokio::task::LocalSet`. The server spawns each connection handler via `tokio::task::spawn_local`.
|
||||
|
||||
## Certificate trust model
|
||||
|
||||
quicnprotochat currently uses a **trust-on-first-use (TOFU)** model with self-signed certificates:
|
||||
|
||||
1. On first start, the server generates a self-signed certificate using `rcgen::generate_simple_self_signed` with SANs for `localhost`, `127.0.0.1`, and `::1`.
|
||||
2. The certificate and private key are persisted to disk as DER files (default: `data/server-cert.der` and `data/server-key.der`).
|
||||
3. Clients must obtain the server's certificate file out-of-band and reference it via the `--ca-cert` flag or `QUICNPROTOCHAT_CA_CERT` environment variable.
|
||||
|
||||
This model is adequate for development and single-server deployments. The roadmap includes:
|
||||
|
||||
- **ACME integration** (Let's Encrypt) for production deployments with publicly-routable servers.
|
||||
- **Certificate pinning** to detect MITM attacks even when a CA is compromised.
|
||||
- **Certificate transparency** log monitoring for detecting misissued certificates.
|
||||
|
||||
## Self-signed certificate generation
|
||||
|
||||
The server's `generate_self_signed()` function:
|
||||
|
||||
```rust
|
||||
let subject_alt_names = vec![
|
||||
"localhost".to_string(),
|
||||
"127.0.0.1".to_string(),
|
||||
"::1".to_string(),
|
||||
];
|
||||
let issued = generate_simple_self_signed(subject_alt_names)?;
|
||||
|
||||
fs::write(cert_path, issued.cert.der())?;
|
||||
fs::write(key_path, &issued.key_pair.serialize_der())?;
|
||||
```
|
||||
|
||||
The generated certificate includes both DNS and IP SANs so that clients can connect using either `localhost` or an IP address. The client specifies the expected server name via `--server-name` (default: `localhost`), which must match one of the certificate's SANs.
|
||||
|
||||
## Security properties
|
||||
|
||||
The QUIC + TLS 1.3 layer provides:
|
||||
|
||||
| Property | Mechanism |
|
||||
|---|---|
|
||||
| **Transport confidentiality** | All application data is encrypted with AES-128-GCM or ChaCha20-Poly1305 (negotiated during the TLS handshake) |
|
||||
| **Server authentication** | The client verifies the server's certificate against the locally-trusted DER file |
|
||||
| **Forward secrecy** | TLS 1.3 exclusively uses ephemeral Diffie-Hellman key exchange; session keys are not derivable from the server's long-term key |
|
||||
| **Replay protection** | QUIC packet numbers and TLS 1.3's anti-replay mechanism prevent replay attacks |
|
||||
| **Connection migration** | QUIC connection IDs allow the client to change IP addresses without re-handshaking |
|
||||
|
||||
### What TLS does *not* provide
|
||||
|
||||
- **Client authentication**: Handled by MLS identity credentials at the application layer. See [MLS (RFC 9420)](mls.md).
|
||||
- **End-to-end encryption**: TLS terminates at the server. The server can read the Cap'n Proto RPC framing and message routing metadata. Payload confidentiality is provided by MLS. See [MLS (RFC 9420)](mls.md).
|
||||
- **Post-quantum resistance**: TLS 1.3 key exchange uses classical ECDHE. Post-quantum protection of application data is provided by the [Hybrid KEM](hybrid-kem.md) layer (M5 milestone).
|
||||
- **Mutual peer authentication**: For peer-to-peer scenarios, the M1-era [Noise\_XX](noise-xx.md) transport provides mutual authentication with identity hiding.
|
||||
|
||||
## Comparison with Noise\_XX (M1 approach)
|
||||
|
||||
| Aspect | Noise\_XX (M1) | QUIC + TLS 1.3 (M3+) |
|
||||
|---|---|---|
|
||||
| **Transport** | Raw TCP | UDP (QUIC) |
|
||||
| **Handshake** | 3-message Noise XX pattern | TLS 1.3 (1-RTT or 0-RTT) |
|
||||
| **Mutual auth** | Both peers authenticate static X25519 keys | Server-only at TLS layer; mutual auth via MLS |
|
||||
| **Identity hiding** | Initiator's identity hidden until message 3 | No identity hiding at TLS layer |
|
||||
| **Stream multiplexing** | None (single stream) | Native QUIC streams |
|
||||
| **RPC bridge** | `into_capnp_io()` with `tokio::io::duplex` | Direct `compat()` wrapper on QUIC stream |
|
||||
| **Codebase location** | `quicnprotochat-core/src/noise.rs` | `quicnprotochat-server/src/main.rs`, client `lib.rs` |
|
||||
|
||||
The Noise\_XX path remains useful for direct peer-to-peer connections (without a central server) and as a fallback transport. Both paths carry identical Cap'n Proto message payloads, so the application layer is transport-agnostic.
|
||||
|
||||
## Configuration reference
|
||||
|
||||
### Server
|
||||
|
||||
| Environment Variable | CLI Flag | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `QUICNPROTOCHAT_LISTEN` | `--listen` | `0.0.0.0:7000` | QUIC listen address |
|
||||
| `QUICNPROTOCHAT_TLS_CERT` | `--tls-cert` | `data/server-cert.der` | TLS certificate path |
|
||||
| `QUICNPROTOCHAT_TLS_KEY` | `--tls-key` | `data/server-key.der` | TLS private key path |
|
||||
| `QUICNPROTOCHAT_DATA_DIR` | `--data-dir` | `data` | Persistent storage directory |
|
||||
|
||||
### Client
|
||||
|
||||
| Environment Variable | CLI Flag | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `QUICNPROTOCHAT_CA_CERT` | `--ca-cert` | `data/server-cert.der` | Server certificate to trust |
|
||||
| `QUICNPROTOCHAT_SERVER_NAME` | `--server-name` | `localhost` | Expected TLS server name (must match certificate SAN) |
|
||||
| `QUICNPROTOCHAT_SERVER` | `--server` | `127.0.0.1:7000` | Server address (per-subcommand) |
|
||||
|
||||
## Further reading
|
||||
|
||||
- [Noise\_XX Handshake](noise-xx.md) -- The M1-era transport layer that QUIC replaced.
|
||||
- [Cap'n Proto Serialisation and RPC](capn-proto.md) -- The RPC layer that runs on top of QUIC streams.
|
||||
- [Service Architecture](../architecture/service-architecture.md) -- How the server's `NodeServiceImpl` binds to the QUIC endpoint.
|
||||
- [ADR-006: PQ Gap in Noise Transport](../design-rationale/adr-006-pq-gap.md) -- Discusses the post-quantum gap in both the Noise and TLS transport layers.
|
||||
Reference in New Issue
Block a user