Skip to main content

cratestack_axum/envelope_layer/
mod.rs

1//! The server envelope layer (ADR 0006 §§2, 10, 12; cratestack#1006): opens
2//! signed requests and seals responses for the generated REST and RPC
3//! routers, before rate limiting and idempotency run.
4//!
5//! Feature `envelope` (the layer and its traits, no crypto crate), or `cose`
6//! (adds `cratestack_cose::CoseEnvelope` as the [`ServerEnvelope`],
7//! decision D7); the `cratestack-pg` and `cratestack-api` facades forward
8//! both, and each schema gets a generated
9//! `cratestack_schema::axum::envelope_layer(envelope, policy, audience)`
10//! that picks its transport and accepted op-contract digests.
11//!
12//! # Placement (D12)
13//!
14//! The layer must be the **last** `.layer(..)` on the generated router, and
15//! applied before the router is `merge`d or `nest`ed:
16//!
17//! ```text
18//! let router = cratestack_schema::axum::router(db, procedures, codec, auth, limit)
19//!     .layer(IdempotencyLayer::new(store, ttl))
20//!     .layer(RateLimitLayer::new(buckets, config))
21//!     .layer(envelope_layer); // runs first
22//! let app = axum::Router::new().nest("/api", router); // mount_prefix("/api") on the layer
23//! ```
24//!
25//! - Last, so it runs first: the rate limiter and the idempotency layer key
26//!   on the `VerifiedPrincipal` it inserts (§12), and the idempotency layer
27//!   hashes the opened payload, so a client that re-seals a retry (a new
28//!   `cti` under the same `Idempotency-Key`) gets the stored response,
29//!   sealed afresh for the new request.
30//! - Through `Router::layer`, so `MatchedPath` and the path parameters are
31//!   already in the request; a `ServiceBuilder` around the whole app loses
32//!   them, and `nest_service` never sets `MatchedPath` for the outer mount.
33//! - Verification, including the key resolver and nonce store lookups,
34//!   runs **before** rate limiting, and under `Optional` so does signing
35//!   every response to a signed or nonce-bound request. Put an IP-level
36//!   limiter outside this layer to bound what an unauthenticated flood can
37//!   cost.
38//!
39//! # What the layer decides, per request
40//!
41//! 1. A bodiless `OPTIONS` (a CORS preflight) passes through untouched.
42//! 2. The [`BindingResolver`] names the generated op (route and path
43//!    parameters), once. Not an op: see [`Resolution`] (D6; under a
44//!    `Required` policy a matched route nobody resolved fails closed, S2).
45//! 3. The [`EnvelopePolicy`] picks [`EnvelopeMode::Required`], `Optional`
46//!    or `Off` for the op, seeing a [`PolicyRequest`] (no headers). There
47//!    is no default (D9). A `/rpc/batch` call runs under the strictest mode
48//!    of `batch` and every frame's op (B1).
49//!    The op's row in the accepted-contract table is found from it, and the
50//!    `Cratestack-Contract` header picks which accepted digest the request
51//!    is opened under; one that names none is an unsigned `426` (see
52//!    `contract.rs` for the rules).
53//! 4. A request whose `Content-Type` is an envelope type is opened. Under
54//!    `Required` a request that is not is refused. Under `Optional` it runs
55//!    unsigned, and its response is sealed only if it carries a valid
56//!    `Cratestack-Nonce` and the [`ResponseSealPolicy`] agrees (D10).
57//! 5. An opened request reaches the router as the plain request it wraps,
58//!    with the negotiated `Content-Type`/`Accept`, and gains the
59//!    `cratestack_core::VerifiedSigner` (recorded on the handler's context,
60//!    D2, never an authentication) and the [`PrincipalMapper`]'s
61//!    `VerifiedPrincipal` extensions.
62//! 6. The response is sealed with a binding built from the same values the
63//!    request was opened against, plus the request digest and the status.
64//!    Every response to a signed request is sealed, under `Optional` too
65//!    (S3), errors included, except the layer's own refusals (D4).
66//!
67//! # Payload media types (cratestack#1168)
68//!
69//! A payload is CBOR unless the client names another type (unbound `Cratestack-Payload-Type`
70//! / `-Accept` headers, bound in the AAD): [`EnvelopeLayerBuilder::payload_media_types`].
71//!
72//! # Security invariants, enforced here and not by the plug-ins
73//!
74//! - A request whose `Content-Type` has the base type `application/cose`
75//!   (any case, any parameters), or one [`ServerEnvelope::is_envelope_content_type`]
76//!   claims, is opened and verified before anything else sees it, whatever
77//!   the policy says, or refused with the unsigned `415`: never forwarded.
78//! - Under `Required`, no unsigned request reaches the inner service, and
79//!   no response to a signed request goes out plain: one the layer cannot
80//!   seal (a stream, a body of an un-negotiated type) is replaced by a sealed error.
81//! - A verification failure is always the same coarse, **unsigned** `401`
82//!   (D4): a replayed request must not earn a signed "401" for a request
83//!   that already executed. Any other envelope error is a `500` whose
84//!   detail is only logged.
85//! - The [`PrincipalMapper`] only ever sees a [`VerifiedRequest`], which
86//!   only this layer can construct, after the envelope verified.
87//! - The resolver and the policy are consulted once per request, and the
88//!   response binding reuses the values the request was opened against,
89//!   so no plug-in can make the two bindings disagree. The audience, the
90//!   op-contract digest, the method, the canonical query, the bound headers, the
91//!   payload media type and the request digest are the layer's own.
92//!
93//! # What is bound, and what is not
94//!
95//! The AAD binds the audience, method, route, path parameters, canonical
96//! query (distinct keys in any order bind alike; one key's repeated values
97//! keep their order), op-contract digest, the message's payload type, and the
98//! `Idempotency-Key` and `If-Match` headers exactly as sent (S1; a request
99//! sending either twice is refused with a `400`). For a response: the
100//! request digest and the status. **Response headers** (`ETag`,
101//! `Retry-After`, ...) **are not authenticated.** An `Optional` response
102//! sealed for an *unsigned* request binds its nonce and payload, not the
103//! caller: it proves this server answered, not who asked.
104//!
105//! # Known limits (P0)
106//!
107//! - Streamed responses (`@stream` over `application/cbor-seq`, SSE
108//!   subscriptions) cannot be sealed until `chain` mode (ADR 0006 P1). A
109//!   signed request gets an `Accept` of sealable types only, so a `@stream`
110//!   op answers with one buffered, sealed array; a signed subscription is
111//!   refused with a sealed `406` before its handler runs. Only an unsigned
112//!   request under `Optional` can stream (plain).
113//! - A response is re-buffered to be sealed, up to
114//!   `cratestack_core::MAX_RESPONSE_REBUFFER_BYTES`; a longer one becomes a
115//!   sealed `500`. A request body is buffered up to the layer's
116//!   `max_body_bytes` (`413` beyond it).
117//! - A fallback handler (`Router::fallback`) sets no `MatchedPath`, so the
118//!   layer treats its traffic as unmatched and lets plain requests through.
119//! - The bound digest is the called op's contract digest
120//!   (`cratestack_core::op_contract_digest`, binding version 2,
121//!   cratestack#1123), so it moves only when that op's wire shape does: a
122//!   policy, an index, SQL, a validator, another op or a new procedure
123//!   leaves it alone, and a wire-shape edit makes the old client's op
124//!   answer the unsigned `426 contract_unsupported`. Semantic changes that
125//!   leave every shape alone (an `Int` switching units) are not caught, as
126//!   before. A server built with `contracts = "..."` also accepts the
127//!   older per-op digests its lock holds while they stay wire-compatible
128//!   with the current one. A signed `/rpc/batch` binds the whole-contract digest until
129//!   batch frames carry their own, so any client-facing change refuses it.
130//!   Binding version 1 messages are refused.
131//! - One envelope per layer: a router accepting Sign1 devices and Mac0
132//!   services at once needs the composite of cratestack#1078, which the
133//!   per-request [`SealContext`] and [`Sealed`] media type make possible.
134//! - The response payload is copied once into the sealed message (D1; the
135//!   zero-copy head/tail API is cratestack#1076).
136
137mod batch;
138mod bound;
139mod build;
140mod builder;
141mod builder_payload;
142mod contract;
143#[cfg(feature = "cose")]
144mod cose_impl;
145mod dispatch;
146mod generated;
147mod inputs;
148mod layer;
149mod media;
150mod mode;
151mod opened;
152mod payload;
153mod policy_request;
154mod principal;
155mod refusal;
156mod request;
157mod resolver;
158mod resolver_payload;
159mod resolver_rest;
160mod resolver_rpc;
161mod seal;
162mod seal_policy;
163mod server_envelope;
164mod server_envelope_arc;
165mod service;
166mod signed;
167mod unresolved;
168mod unsigned;
169
170#[cfg(test)]
171mod tests;
172
173/// For implementing [`ServerEnvelope`] and [`PrincipalMapper`] without a
174/// direct `async-trait` dependency.
175pub use async_trait::async_trait;
176pub use builder::EnvelopeLayerBuilder;
177pub use layer::EnvelopeLayer;
178pub use mode::{EnvelopeMode, EnvelopePolicy};
179pub use opened::{OpenedRequest, SealContext, Sealed};
180pub use policy_request::PolicyRequest;
181pub use principal::{PrincipalMapper, ThumbprintPrincipal, VerifiedRequest};
182pub use resolver::{BindingResolver, Resolution, ResolvedRoute, RouteRequest};
183pub use resolver_rest::RestBindingResolver;
184pub use resolver_rpc::RpcBindingResolver;
185pub use seal_policy::{AcceptNamesEnvelope, ResponseSealPolicy, UnsignedRequest};
186pub use server_envelope::ServerEnvelope;
187pub use service::EnvelopeService;
188
189/// The payload type of a sealed message that names none (ADR 0006 §4): the
190/// default, no longer the only one ([`EnvelopeLayerBuilder::payload_media_types`]).
191pub const PAYLOAD_MEDIA_TYPE: &str = "application/cbor";
192
193/// The default cap on the accepted digests tried for a request that names
194/// none ([`EnvelopeLayerBuilder::max_contract_trials`]).
195pub const DEFAULT_MAX_CONTRACT_TRIALS: usize = 4;
196
197/// The default cap on a request body the layer buffers: the generated
198/// routers' default body limit plus 16 KiB of envelope overhead (a header,
199/// a signature, and room for a post-quantum one later, ADR 0006 Q5).
200pub const DEFAULT_MAX_BODY_BYTES: usize = cratestack_core::DEFAULT_BODY_LIMIT_BYTES + 16 * 1024;