seshcookie 0.1.0

Stateless, encrypted, type-safe session cookies for Rust web applications.
# seshcookie-rs

Stateless, encrypted, typed session cookies for Rust web applications — a Rust port of the Go [seshcookie](https://github.com/bpowers/seshcookie) library, targeting Axum 0.8 + Tower + Tokio. Published to crates.io as `seshcookie`.

Last verified: 2026-04-24.

## Authoritative design

The architecture, wire format, threat model, acceptance criteria, and implementation phases live in **`docs/design-plans/2026-04-24-seshcookie-rs.md`**. Read that before making architectural changes. Acceptance criteria are identified as `seshcookie-rs.AC{N}.{M}` and tests must reference them.

## Source layout

The crate is partitioned along the Functional Core / Imperative Shell line. Each module's first doc comment declares its side: a `pattern: Functional Core` annotation means the module is pure (no I/O, no clock reads, no randomness — `now` and RNG arrive as parameters), and a `pattern: Imperative Shell` annotation means the module performs side effects and delegates decisions to the core.

- `src/lib.rs` — module wiring and the public re-exports listed under "Public API surface" below. Also exposes a `#[doc(hidden)] pub mod __testing` for integration-test cookie construction.
- `src/error.rs` (Functional Core) — `BuildError`, `SessionRejection`, and the `IntoResponse` impl that maps `NotMounted` to HTTP 500.
- `src/keys.rs` (Functional Core) — `SessionKeys` builder; per-key entropy validation; orders IKMs primary-first for downstream HKDF derivation.
- `src/aead.rs` (Functional Core) — `DerivedKey` (HKDF-SHA256 derivation, ChaCha20-Poly1305 seal/open). Wraps `ring::aead`.
- `src/envelope.rs` (Functional Core) — layer-1 plaintext format `version || issued_at || payload_json` and its parse/format helpers. Bumping `format_version` happens here.
- `src/codec.rs` (Functional Core) — base64url-no-pad cookie value encode/decode and the cookie-attribute serialization for `Set-Cookie`.
- `src/config.rs` (Functional Core) — `SessionConfig` builder, defaults, and the `SameSite` re-export. Cookie-attribute byte validation lives here.
- `src/state.rs` (Functional Core) — `SessionState<T>` and `should_rewrite` (the pure rewrite decision function). The five rewrite triggers in the "Cookie rewrite discipline" section below are implemented here.
- `src/layer.rs` (Imperative Shell) — `SessionLayer<T>`, `SessionService<S, T>`, request/response wiring; owns the single `SystemRandom` and the swappable clock. Production code reads `SystemTime::now`; tests inject deterministic clocks via the `pub(crate) with_clock` helper.
- `src/extract.rs` (Imperative Shell) — Axum extractor for `Session<T>` (required) and the blanket `Option<Session<T>>` (optional). All `Session<T>` handle methods are `async`.
- `tests/axum_integration.rs` — end-to-end tests against a real Axum router; exercises the `__testing` carve-out for the AC9.2 concurrency test.
- `examples/axum_login.rs` — runnable example wiring the layer into an Axum app.

## Public API surface

Re-exported from `seshcookie::` at the crate root:

- `BuildError`, `SessionRejection` (from `error`)
- `SessionKeys` (from `keys`)
- `SessionConfig`, `SameSite` (from `config`; `SameSite` is a re-export of `cookie::SameSite`)
- `SessionLayer<T>`, `SessionService<S, T>` (from `layer`)
- `Session<T>` (from `extract`)

Plus `seshcookie::__testing` — `#[doc(hidden)]`, **not part of the SemVer surface**. It exists solely so `tests/axum_integration.rs` can build cookie values for the AC9.2 100-concurrent-requests test without re-implementing the seal pipeline. Application code must never import it; its contents may change without a major version bump.

## Coding standards

These are durable project rules. They override personal preference or convenience.

### Async discipline (strict)

- **No `std::sync::Mutex`, `std::sync::RwLock`, or `parking_lot` primitives in code this crate owns.** Use `tokio::sync::Mutex` / `tokio::sync::RwLock`.
- `Session<T>` handle methods are `async` by construction. Do not add blocking variants.
- Construct `ring::rand::SystemRandom` exactly once per `SessionLayer` (the first `fill()` call can block on OS RNG init) and share it via `Arc`.
- Do not introduce `async_trait`. Use return-position `impl Trait` in traits (MSRV 1.95 supports it; Axum 0.8 uses it).

### Crypto

- Use `ring` for AEAD (ChaCha20-Poly1305) and KDF (HKDF-SHA256). Do not introduce `chacha20poly1305`, `aes-gcm`, `hkdf`, `rand_core`, or other RustCrypto crates.
- Do not add Argon2id or any password-hashing KDF. The library requires a high-entropy caller-provided secret.
- Do not reach for any crypto primitive outside `ring` without a design-level discussion.

### Serialization

- Session payloads serialize via `serde_json`. Do not introduce Protocol Buffers, `prost`, `build.rs` codegen, CBOR, MessagePack, `postcard`, or `bincode`.

### Stateless only

- The library never touches a backing store. Do not add persistence hooks, database calls, or `SessionStore` traits. If a future use case requires stateful sessions, use a different crate (`tower-sessions`), not this one.

### Silent fallback on cookie-level problems

- Malformed, forged, expired, and unrecognized-version cookies are treated as *absent* sessions (`payload = None`). The middleware never returns an HTTP-level error for a cookie-level problem. Downstream auth middleware decides the 401.

### Wire format

- `format_version` and `issued_at` live **inside** the AEAD plaintext. Do not add unauthenticated metadata to the cookie value.
- Cookie value is exactly `base64url_no_pad(nonce(12B) || ciphertext || tag(16B))`. Do not add a `sc1_`-style prefix.
- Changing the wire format requires bumping `format_version` and documenting the transition in the design plan's `Forward compatibility` section.

### Cookie rewrite discipline

`Set-Cookie` is emitted on the response **only** in these cases:

1. The session payload's serialized plaintext hash differs from what was decrypted on entry (actual data change).
2. The session was explicitly cleared (`Session::take` or `Session::clear`).
3. The incoming cookie was decrypted with a non-primary rotation key (auto-migrate to primary).
4. The opt-in sliding-refresh threshold (`SessionConfig::refresh_after`) was exceeded.
5. The incoming cookie had expired (emit delete to clean up browser state).

Do not add new triggers without updating the rewrite decision table in the design plan.

### `issued_at` semantics

- **New session** (payload went `None``Some`): `issued_at = now()`.
- **Continuing session** with ordinary mutation: preserve prior `issued_at`.
- **Sliding refresh fires**: bump `issued_at = now()`.
- **Rotation-only rewrite** (no data change): preserve prior `issued_at`. Rotation must not extend session lifetime.

### Cookie splitting

Not supported. Session payloads must fit a single ~4 KB cookie. If a consumer needs larger payloads, they should move data out of the cookie (and this is the wrong crate for them).

## Dependencies

**Allowed (runtime):**

- `ring` — crypto backend
- `cookie` — cookie header parse / serialize
- `base64` — URL-safe no-pad encoding
- `serde` + `serde_json` — payload encoding
- `tower`, `tower-layer`, `tower-service` — middleware traits
- `http` — HTTP request / response types
- `axum-core` — extractor trait
- `tokio` (with the `sync` feature at minimum) — async mutex
- `thiserror` — error derives
- `tracing` — optional, behind a feature flag

**Forbidden unless explicitly approved in a design discussion:**

- `tower-cookies` — invasive on downstream apps
- `tower-sessions` — wrong abstraction for stateless cookies
- `biscotti` — uses AES-GCM (conflicts with ChaCha20 choice)
- `prost`, any `protobuf-*` — this crate is protobuf-free
- `argon2` — no password-hashing KDF
- `parking_lot` — async discipline requires `tokio::sync::*`
- Any RustCrypto crate (`chacha20poly1305`, `aes-gcm`, `hkdf`, `rand_core`, etc.) — stay on `ring`
- `async_trait` — return-position `impl Trait` in traits (RPITIT) is standard in MSRV 1.95

**Dev-dependencies allowed:**

- `axum` — for integration tests and examples
- `proptest` — property-based testing
- `tokio-test` — test utilities
- `http-body-util` — collecting `axum_core::response::Response` bodies in assertions

## Testing

- `cargo test` must pass on stable and on MSRV 1.95.
- `cargo test --doc` must pass — every public API item has at least one doc example.
- `cargo clippy --all-targets --all-features -- -D warnings` must pass.
- `cargo fmt --check` must pass.
- `cargo llvm-cov --fail-under-lines 95` must pass (≥ 95% line coverage).
- `cargo deny check` must pass.
- CI runs all of the above on both stable and 1.95. Do not commit code that fails any of these locally.
- Tests reference acceptance-criteria identifiers (e.g., `seshcookie-rs.AC3.2`) in their names or doc comments so the AC → test mapping is traceable.
- No test hits the network. Time-sensitive tests take a `SystemTime` or `Clock` parameter rather than reading the wall clock directly.

## Toolchain

- MSRV: Rust 1.95. Do not bump without a design-level discussion.
- Edition: 2024.
- `rust-toolchain.toml` (when added) pins the CI version; day-to-day development should use the pinned version or newer stable.

## Scope boundaries

- No Go or JS wire-format compatibility — this is a fresh format.
- No Bearer-token or non-cookie transports.
- No server-side session stores.
- No cookie splitting.
- No sliding refresh by default (opt-in via `SessionConfig::refresh_after`).
- No crypto alternatives (AES-GCM, RustCrypto) — ChaCha20-Poly1305 via `ring` is the single path.

## When to update this file

Update `CLAUDE.md` when a constraint changes — a dependency moves from "forbidden" to "allowed", a new coding rule is adopted, MSRV shifts, the test coverage threshold changes. Keep the rationale brief; the design plan holds the long-form "why".