Skip to main content

Module envelope_layer

Module envelope_layer 

Source
Expand description

The server envelope layer (ADR 0006 §§2, 10, 12; cratestack#1006): opens signed requests and seals responses for the generated REST and RPC routers, before rate limiting and idempotency run.

Feature envelope (the layer and its traits, no crypto crate), or cose (adds cratestack_cose::CoseEnvelope as the ServerEnvelope, decision D7); the cratestack-pg and cratestack-api facades forward both, and each schema gets a generated cratestack_schema::axum::envelope_layer(envelope, policy, audience) that picks its transport and accepted op-contract digests.

§Placement (D12)

The layer must be the last .layer(..) on the generated router, and applied before the router is merged or nested:

let router = cratestack_schema::axum::router(db, procedures, codec, auth, limit)
    .layer(IdempotencyLayer::new(store, ttl))
    .layer(RateLimitLayer::new(buckets, config))
    .layer(envelope_layer); // runs first
let app = axum::Router::new().nest("/api", router); // mount_prefix("/api") on the layer
  • Last, so it runs first: the rate limiter and the idempotency layer key on the VerifiedPrincipal it inserts (§12), and the idempotency layer hashes the opened payload, so a client that re-seals a retry (a new cti under the same Idempotency-Key) gets the stored response, sealed afresh for the new request.
  • Through Router::layer, so MatchedPath and the path parameters are already in the request; a ServiceBuilder around the whole app loses them, and nest_service never sets MatchedPath for the outer mount.
  • Verification, including the key resolver and nonce store lookups, runs before rate limiting, and under Optional so does signing every response to a signed or nonce-bound request. Put an IP-level limiter outside this layer to bound what an unauthenticated flood can cost.

§What the layer decides, per request

  1. A bodiless OPTIONS (a CORS preflight) passes through untouched.
  2. The BindingResolver names the generated op (route and path parameters), once. Not an op: see Resolution (D6; under a Required policy a matched route nobody resolved fails closed, S2).
  3. The EnvelopePolicy picks EnvelopeMode::Required, Optional or Off for the op, seeing a PolicyRequest (no headers). There is no default (D9). A /rpc/batch call runs under the strictest mode of batch and every frame’s op (B1). The op’s row in the accepted-contract table is found from it, and the Cratestack-Contract header picks which accepted digest the request is opened under; one that names none is an unsigned 426 (see contract.rs for the rules).
  4. A request whose Content-Type is an envelope type is opened. Under Required a request that is not is refused. Under Optional it runs unsigned, and its response is sealed only if it carries a valid Cratestack-Nonce and the ResponseSealPolicy agrees (D10).
  5. An opened request reaches the router as the plain request it wraps, with the negotiated Content-Type/Accept, and gains the cratestack_core::VerifiedSigner (recorded on the handler’s context, D2, never an authentication) and the PrincipalMapper’s VerifiedPrincipal extensions.
  6. The response is sealed with a binding built from the same values the request was opened against, plus the request digest and the status. Every response to a signed request is sealed, under Optional too (S3), errors included, except the layer’s own refusals (D4).

§Payload media types (cratestack#1168)

A payload is CBOR unless the client names another type (unbound Cratestack-Payload-Type / -Accept headers, bound in the AAD): EnvelopeLayerBuilder::payload_media_types.

§Security invariants, enforced here and not by the plug-ins

  • A request whose Content-Type has the base type application/cose (any case, any parameters), or one ServerEnvelope::is_envelope_content_type claims, is opened and verified before anything else sees it, whatever the policy says, or refused with the unsigned 415: never forwarded.
  • Under Required, no unsigned request reaches the inner service, and no response to a signed request goes out plain: one the layer cannot seal (a stream, a body of an un-negotiated type) is replaced by a sealed error.
  • A verification failure is always the same coarse, unsigned 401 (D4): a replayed request must not earn a signed “401” for a request that already executed. Any other envelope error is a 500 whose detail is only logged.
  • The PrincipalMapper only ever sees a VerifiedRequest, which only this layer can construct, after the envelope verified.
  • The resolver and the policy are consulted once per request, and the response binding reuses the values the request was opened against, so no plug-in can make the two bindings disagree. The audience, the op-contract digest, the method, the canonical query, the bound headers, the payload media type and the request digest are the layer’s own.

§What is bound, and what is not

The AAD binds the audience, method, route, path parameters, canonical query (distinct keys in any order bind alike; one key’s repeated values keep their order), op-contract digest, the message’s payload type, and the Idempotency-Key and If-Match headers exactly as sent (S1; a request sending either twice is refused with a 400). For a response: the request digest and the status. Response headers (ETag, Retry-After, …) are not authenticated. An Optional response sealed for an unsigned request binds its nonce and payload, not the caller: it proves this server answered, not who asked.

§Known limits (P0)

  • Streamed responses (@stream over application/cbor-seq, SSE subscriptions) cannot be sealed until chain mode (ADR 0006 P1). A signed request gets an Accept of sealable types only, so a @stream op answers with one buffered, sealed array; a signed subscription is refused with a sealed 406 before its handler runs. Only an unsigned request under Optional can stream (plain).
  • A response is re-buffered to be sealed, up to cratestack_core::MAX_RESPONSE_REBUFFER_BYTES; a longer one becomes a sealed 500. A request body is buffered up to the layer’s max_body_bytes (413 beyond it).
  • A fallback handler (Router::fallback) sets no MatchedPath, so the layer treats its traffic as unmatched and lets plain requests through.
  • The bound digest is the called op’s contract digest (cratestack_core::op_contract_digest, binding version 2, cratestack#1123), so it moves only when that op’s wire shape does: a policy, an index, SQL, a validator, another op or a new procedure leaves it alone, and a wire-shape edit makes the old client’s op answer the unsigned 426 contract_unsupported. Semantic changes that leave every shape alone (an Int switching units) are not caught, as before. A server built with contracts = "..." also accepts the older per-op digests its lock holds while they stay wire-compatible with the current one. A signed /rpc/batch binds the whole-contract digest until batch frames carry their own, so any client-facing change refuses it. Binding version 1 messages are refused.
  • One envelope per layer: a router accepting Sign1 devices and Mac0 services at once needs the composite of cratestack#1078, which the per-request SealContext and Sealed media type make possible.
  • The response payload is copied once into the sealed message (D1; the zero-copy head/tail API is cratestack#1076).

Structs§

AcceptNamesEnvelope
Seal when the request’s Accept names application/cose (D10).
EnvelopeLayer
Opens signed requests and seals responses for a generated router; see the module docs for placement and guarantees. Build it with EnvelopeLayer::builder, or with the generated cratestack_schema::axum::envelope_layer. Cheap to clone.
EnvelopeLayerBuilder
Configuration for an super::EnvelopeLayer. Nothing is checked until build, and the calls may come in any order.
EnvelopeService
The service super::EnvelopeLayer produces.
OpenedRequest
A request an envelope verified: the payload to hand the router, the signer to record, and the context its response is sealed with.
PolicyRequest
The op a request addresses, as an super::EnvelopePolicy sees it.
ResolvedRoute
A resolved op: the binding’s route and path_params.
RestBindingResolver
REST: the RouteTransportDescriptor matching the method and the matched template (mount prefix stripped), bound by its declared path.
RouteRequest
What a resolver sees of a request: the method, the raw path, the route template axum matched (None when no route matched, or under nest_service), and the matched path parameters, percent-decoded, in the order axum reports them (a parameterised mount prefix’s first).
RpcBindingResolver
RPC: the op id axum decoded from /rpc/{op_id}, batch for /rpc/batch (decision D11), and subscribe/<op id> for a subscription. The op id and the body digest bind the call, so path parameters are empty (ADR 0006 §4), except under a parameterised mount (nest("/t/{tenant}", ..)), whose values are bound for the same reason as super::RestBindingResolver’s.
SealContext
Opaque per-request state an envelope passes from open_request to the seal_response of the same request (API-review decision, 2026-09-26).
Sealed
A sealed response body and the media type the layer stamps on it as its Content-Type (API-review decision, 2026-09-26: a composite answers each request in its own framing, so the type is per response, not per envelope).
ThumbprintPrincipal
The prefix, cose: by default, followed by the lowercase hex of the verifying key’s full RFC 9679 thumbprint, never its 8-byte kid (two keys can share one).
UnsignedRequest
An unsigned request under Optional that carries a valid Cratestack-Nonce, as a ResponseSealPolicy sees it.
VerifiedRequest
A request the envelope verified, as the PrincipalMapper sees it.

Enums§

EnvelopeMode
How the layer treats one op’s traffic (ADR 0006 §2).
Resolution
A BindingResolver’s answer.

Constants§

DEFAULT_MAX_BODY_BYTES
The default cap on a request body the layer buffers: the generated routers’ default body limit plus 16 KiB of envelope overhead (a header, a signature, and room for a post-quantum one later, ADR 0006 Q5).
DEFAULT_MAX_CONTRACT_TRIALS
The default cap on the accepted digests tried for a request that names none (EnvelopeLayerBuilder::max_contract_trials).
PAYLOAD_MEDIA_TYPE
The payload type of a sealed message that names none (ADR 0006 §4): the default, no longer the only one (EnvelopeLayerBuilder::payload_media_types).

Traits§

BindingResolver
Resolves the op a request addresses.
EnvelopePolicy
Picks the EnvelopeMode for a request, from the op it addresses.
PrincipalMapper
Names the principal a verified request is charged to: the value of the VerifiedPrincipal extension the layer inserts, which the rate limiter’s default key (princ: bucket, no cardinality budget) and the idempotency layer’s default fingerprint (princ: namespace) use.
ResponseSealPolicy
Decides whether the response to an unsigned request is sealed, under Optional. The default is AcceptNamesEnvelope (D10). A closure is a policy (second-review nit):
ServerEnvelope
What the layer needs from an envelope on the server side: open a request against its binding, seal a response against its binding.

Attribute Macros§

async_trait