Signed transport (COSE envelope layer)
EnvelopeLayer is a tower layer for the generated REST and RPC routers. It opens
signed requests (COSE_Sign1 or COSE_Mac0 bodies), hands the router the plain CBOR payload
they wrap, and seals the router’s response against the same request. A signature then
covers the payload and what the request was for: the service, the method, the route and
its parameters, the query, the op’s wire contract, and the Idempotency-Key / If-Match headers.
This is ADR 0006’s P0 scope: unary messages and nonce replay protection. Streams (chain
mode) are P1, so a signed request never gets a stream (see Limits).
Use it when a TLS terminator, a proxy or a message queue sits between the client and the
service and must not be able to alter or replay a request: payments, device commands,
service-to-service calls across a trust boundary. The layer is opt-in per op. The digest a
signed request binds is its op’s contract digest: it moves only when that op’s wire shape
does, so a policy, an index, SQL, a validator, another op or a new procedure leaves every older
client working (cratestack#1123, see
Evolving a schema under signed clients).
Enable it
The server facades carry two features, both off by default:cose (with or without envelope), no cratestack-cose, p256 or ed25519-dalek
is in the facade’s graph. The generated envelope_layer follows the feature of the facade a
schema is compiled through, so one crate turning envelope on changes nothing for another
crate’s schemas in the same build.
Wire it
Build the COSE envelope, then the layer through the schema’s generatedenvelope_layer(envelope, policy, audience). It picks the schema’s transport (REST or RPC),
its route descriptors and its ACCEPTED_CONTRACTS, and returns the builder:
audienceis this service’s configured logical id, which every binding carries. It is not theHostheader, must not be empty, and must differ from the audience this service seals its own outbound requests for.- The nonce store is the replay cache.
InMemoryNonceStoreprotects one process; behind several replicas use a shared one, such ascratestack_cose::auth::AuthNonceStore::redis(url)(theauthfeature of a directcratestack-cosedependency; see the crate README for the Redis requirements). .build()refuses an empty audience, a missing policy or transport, an empty REST route table, a zero body limit, anallow_unresolvedtemplate not starting with/, and an envelope whose media type is notapplication/coseor a type it claims.
.mount_prefix(..) wins over the prefix the generated function
passes (the root), whichever order they come in. The other builder calls:
Without the generated function,
EnvelopeLayer::builder(envelope, audience, cratestack_schema::ACCEPTED_CONTRACTS) takes .policy(..) and one of .rest(prefix, cratestack_schema::axum::ROUTE_TRANSPORTS), .rpc(prefix) or .binding_resolver(..);
neither the policy nor the transport has a default.
Placement
The layer must be the last.layer(..) on the generated router, applied before the
router is nested or merged:
- Last, so it runs first. The rate limiter and the idempotency layer key on the
VerifiedPrincipalit inserts (cose:<hex thumbprint>by default), so a signed client needs neitherAuthorizationnorConnectInfo. The idempotency layer hashes the opened payload, so a client that re-seals a retry (a newctiunder the sameIdempotency-Key) gets the stored response, sealed afresh for the new request. - Through
Router::layer, soMatchedPathand the path parameters are in the request. AServiceBuilderaround the whole app loses them, andnest_servicenever setsMatchedPathfor the outer mount. - An IP-level limiter outside it. Verification, with its key-resolver and nonce-store
lookups, runs before rate limiting, and under
Optionalso does signing every response to a signed or nonce-bound request. Bound what an unauthenticated flood can cost with a limiter in front of the envelope.
DEFAULT_MAX_BODY_BYTES, is that limit plus 16 KiB of envelope overhead; raise it
with .max_body_bytes(..) if the router gets a larger limit.
Keys that change at run time
(Since 0.15.4, cratestack#1149; through 0.15.3 write your ownCoseVerifierResolver.)
StaticVerifierResolver is fixed when you build it. For devices or peers that enrol after the
server starts, use cratestack_cose::RegistryVerifierResolver: share it as an Arc, give the
envelope one clone as its CoseVerifierResolver, and keep one for register and revoke.
- Key types. Ed25519, ESP256, and both HMAC algorithms (256/64 and 256/256).
with_max_keys(n)is a hard bound. A new key past it fails withConflict; registering a key that is already present succeeds even when the registry is full.new()is unbounded.- Concurrency. Each call takes one short
stdRwLock, never held across an.await. Aregisterorrevokeis atomic with respect toresolve, and aregisterthat returned is visible to every laterresolve. A request whoseresolvealready returned the key still finishes verifying with it, so a revocation applies to requests that resolve after it returns, not to ones already in flight. - Per process. The state lives in memory. Every replica needs the same registrations (and a shared nonce store, see above), and a restart starts empty: re-register from your own store.
- Collisions. The
kidis only 8 bytes.revoke(kid)removes every key under it: both algorithms of one HMAC secret, and any unrelated key whosekidhappens to collide. To cut off one device, userevoke_key(&key). - Unknown and revoked look the same. An unknown
kidresolves to no keys (not an error), so a revoked key and a never-registered one both get the coarse, unsigned401. len()andis_empty()report how many keys are held.
Modes
AnEnvelopePolicy picks a mode per op. EnvelopeMode is itself a policy (one mode for every
op), and so is any Fn(&PolicyRequest<'_>) -> EnvelopeMode. There is no default.
- Everything is signed under
Required,GET,HEADandDELETEincluded: a bodiless request seals an empty payload and reaches the router bodiless. A signedHEADstill sends its COSE message as a request body, to which RFC 9110 gives no semantics: hyper andreqwestpass it over HTTP/1.1, but an intermediary may drop it (the request then fails,401) or refuse the request. PreferGETunderRequired. - A signed request is always answered sealed, under
Optionalas underRequired, with itsAcceptforced toapplication/cbor. A verification failure is the401, never a downgrade to unsigned. - An unsigned seal under
Optionalbinds the nonce and the payload, not the caller. It proves this server answered this nonce and body, not who asked; do not read it as an authenticated exchange. - In every mode, a body whose
Content-Typeisapplication/cose(any case, any parameters) is opened and verified, or refused with the415. It is never forwarded. - A bodiless
OPTIONS(a CORS preflight) passes untouched on both transports.
Per-op policy
A policy sees aPolicyRequest: the method, the op (a REST route template such as
/widgets/{id}, or an RPC op id such as procedure.ping), and two flags. It sees no header,
so no request can talk it into a weaker mode.
impl EnvelopePolicy,
not an Fn bound, so the compiler has nothing to infer it from. The same holds for
closure principal mappers and seal policies.
- An RPC subscription is shown with its bare op id (
model.Widget.subscribe) andis_subscription()set. /rpc/batchruns under the strictest mode ofbatchitself and of every frame’s op (is_batch_frame()set, methodPOST), so a batch cannot carry an op its policy would refuse unsigned. The frames are read by the body’s baseContent-Type(parameters and case ignored).EnvelopePolicy::unresolved_mode()decides traffic the layer cannot attribute to one op (next section). It defaults toRequired; anEnvelopeModeused as the policy answers itself.
Fail closed
Under aRequired unresolved_mode, a route the router matched but the layer cannot bind
is refused with an unsigned 500 instead of passing unsigned. That is what a wrong or
missing mount prefix, nest_service, or route-descriptor drift looks like. The response body
is a generic internal error; the server log says envelope misconfigured (target
cratestack, throttled to once a minute) and names the matched route and the configured
prefix.
- Hand-written routes on the same router (
/health, a.well-knownpath) are listed with.allow_unresolved(["/health"]), relative to the mount prefix. Plain traffic to them passes; a COSE body is still the415. - Another method on a generated path is the layer’s own
405with anAllowheader (body codeMETHOD_NOT_ALLOWEDon REST,invalid_argumenton RPC), so a hand-written handler for that method cannot run unsigned. - An unmatched path (a
404) passes through, as does aRouter::fallbackhandler, which sets noMatchedPathand is therefore not protected. - A closure policy fails closed by default.
.unresolved_mode(EnvelopeMode::Optional)(orOff) is the explicit opt-out: unbindable routes then pass plain, with a warning logged once per process, and so does every route when the prefix is wrong. Preferallow_unresolved. - A plain batch whose frames the layer cannot read is refused unless the strictest of
batchandunresolved_modeisOff: the unsigned401underRequired, an unsigned400otherwise. A signed batch is opened unless both areOff(then the415); once opened, unreadable frames are a sealed400, and a batch whose every answer isOffis the unsigned415. /rpc/{op_id}never binds an op id that isbatch, contains/or has no., checked on the decoded value (so/rpc/%62atchis caught): a COSE body there is the415.
What is bound
The external AAD, which both sides rebuild and nobody sends, binds:- the audience, the method, and the route: the REST route template the schema declares, or the
RPC op id (
batchfor/rpc/batch,subscribe/<op id>for a subscription); - every matched path parameter, a parameterised mount’s (
nest("/t/{tenant}", ..)) included, so a request signed for tenantacannot be replayed at tenantb; - the query in
canonical_queryform: distinct keys bind alike in any order, but one key’s repeated values keep their order (?tag=a&tag=bis not?tag=b&tag=a). An RPC call binds its query too; generated RPC clients send none, and a client that adds one (a cache-buster) must bind it; - the op contract digest and the payload media type: the request’s own type on a request, the
response’s own type on a response,
application/cborunless the peers negotiated another (see Payload media types). The digest is the called op’s (OP_CONTRACTSon the client,ACCEPTED_CONTRACTSon the server; a signed/rpc/batchbinds the whole-contractCLIENT_CONTRACT_SHA256_BYTES). The client names the digest it used in the unboundCratestack-Contractheader (see Evolving a schema under signed clients); bound_headers:Idempotency-KeyandIf-Matchexactly as sent (UTF-8, untrimmed), or null. A proxy that strips the key from a re-sealed retry (so it would run twice) or altersIf-Matchbreaks the signature. A request sending either header twice, or a value that is not UTF-8, is refused with an unsigned400;- for a response: the request digest (SHA-256 of the request’s exact COSE bytes, or for an
unsigned
Optionalrequest, of its nonce and payload) and the status.
ETag, Retry-After, Location and the rest can be
altered in transit. Don’t base a security decision on one.
What the router sees
An opened request reaches the router as the plain request it wraps: the payload with the type it was sealed under asContent-Type (no Content-Type for an empty payload) and the negotiated
response types as Accept. Both are application/cbor unless the client named another type (see
Payload media types); the layer’s own Cratestack-Payload-* headers are
removed before the router sees the request.
- The
AuthProviderauthenticates that payload. A client that also sendsAuthorization: Signaturesigns the payload, not the COSE bytes. - The layer inserts
cratestack_core::VerifiedSigner(visible to theAuthProviderinRequestContext::extensions), and generated handlers record it on the context (ctx.verified_signer()). It is a fact, not an authentication: identity stays theAuthProvider’s decision, unless you wrap the provider inCoseAuthProvider(Since 0.16.0, cratestack#1077.) - The layer inserts
VerifiedPrincipal, which the rate limiter and the idempotency layer key on (princ:<sha256>); see Idempotency and Rate limiting.
Authenticate a signer
A signed client sends noAuthorization header, so your header AuthProvider has nothing to
authenticate and the request stays anonymous. CoseAuthProvider closes that gap (since 0.16.0,
cratestack#1077). It wraps the header provider and, when the header path gives no identity, asks
the registries whether they still hold the verified signer’s key. Depend on cratestack-cose with
features = ["auth"], and on cratestack-auth, which owns DeviceKeyResolver. Pass the wrapped
provider to the generated router in place of the header provider:
- Your header provider runs first. An identity it returns wins, and the registries are not asked. An error from it is returned as is: a header the provider refuses is never replaced by the signer. A header it accepts but maps to anonymous is not a refusal, and the signer supersedes it.
- With no verified signer, the answer is the header provider’s. The signer is read only from the request’s in-process extensions, never from a header or body, so a client cannot fake one.
- A verified signer is matched by its full thumbprint; the 8-byte
kidis only a prefix. The service registry is asked first, then the device registry, which is asked only when device authentication is on. A key that is unknown or revoked is anonymous, not an error, so policy refuses it as it refuses any anonymous caller. A failure of a registry that is asked is a500, so an outage is not mistaken for an unknown key. Unlikecratestack-auth’s verifier, which treats a JWKS/service error as a miss, a service-registry error here is not a miss: it fails the request, including for a device signer, even with the gate off (the service registry is asked first). This fails closed.
cose:<thumbprint> with the default prefix, the same id the envelope’s
ThumbprintPrincipal charges the signer to. The context carries kind (service or device),
thumbprint (bare lowercase hex) and alg (the COSE algorithm id).
Device keys are off by default. A device key proves a device, not a user. With the gate off, an
enrolled device signer is anonymous, and the device registry is not consulted (a failing service
registry still errors, as above). A service key authenticates either way.
Security. With authenticate_devices(true), any request sealed by an enrolled device key is
authenticated, with no user (no id_jwt), on every method, whatever the header provider’s
TransportCallerMode. auth() != null then means “holds an enrolled device or service key”, and
revoking the key, not token expiry, is what bounds access. Turn the gate on only where device
enrolment is itself gated. A rule that needs a signed-in user cannot rely on auth() != null alone.
Two more limits:
- Under
Required, the envelope does not bind a header identity to the signer. When the header provider authenticates, its identity wins, so one device can act as the user named in another device’s header. - An ESP256 or WebCrypto device key authenticates only through the service registry (as
kindservice), becauseDeviceKeyResolveris Ed25519-only.
Payload media types
Since CrateStack 0.15.4 (cratestack#1168).
Cratestack-Contract, carry the choice:
A type is a lowercase
type/subtype of RFC 9110 token characters, with no parameters, no q and
no wildcard, at most 127 bytes; an accept list is one to eight distinct types. Nothing is
normalised, so a client and a server cannot disagree about a spelling.
Nothing about the wire changes for a peer that does not use this. The COSE message and the
binding version (2) are the same: the type was already element 8 of the AAD. A request binding
names the request payload’s type; a response binding names the response payload’s own type,
which need not be the request’s (form in, JSON out). A message that names no type is
byte-identical to 0.15.3’s, so a 0.15.3 client and a 0.15.4 layer interoperate.
The headers select, the AAD authenticates. A request header that lies about the payload
makes the AAD the layer rebuilds differ from the client’s, so the signature fails with the coarse
unsigned 401. A response whose Cratestack-Payload-Type lies fails the client’s verification
(Unverified).
Opting in
application/cbor for both, so a layer that never calls
payload_media_types changes nothing. What an op allows is that set intersected with the
types its route declares: RestBindingResolver reads each descriptor’s
capabilities.request_types / response_types (the generated routes declare CBOR and JSON; a
hand-written service builds its own static table of RouteTransportDescriptors), and
RpcBindingResolver the RPC binding’s CBOR and JSON. A custom BindingResolver narrows a route
with ResolvedRoute::with_payload_types(request, response), and sets none otherwise.
/rpc/batch stays CBOR both ways.
build() refuses a type outside the grammar, one that may never be sealed
(application/cose*, application/cbor-seq, text/event-stream, multipart/*), an empty request
set, and a response set with neither application/cbor nor application/json (the layer’s own
errors are sealed in one of them).
What happens to a request
All of these are decided from the headers alone, before any key is looked up, any signature checked or any nonce spent, and go out unsigned like the other pre-verification refusals:
What the router answers is sealed under the type it labelled the response with, when that type
was negotiated (parameters such as
charset=utf-8 are ignored). A success in any other type
is a sealed 500. An error in any other type (axum’s own 413, a text/plain fallback) is
re-encoded in the transport’s error shape, in the client’s first choice of CBOR or JSON, and
sealed; the layer’s own sealed errors use that choice too. A service’s own JSON error envelope
(Stripe’s {"error": {...}}) is in a negotiated type, so it is sealed as it is.
An unsigned, nonce-bound request under Optional negotiates its response type the same way
(its request payload is not sealed, so only Cratestack-Payload-Accept is read).
Errors
The layer’s own refusals go out unsigned: they are decided before (or instead of) a verified request, and a replayed request must not earn a signed answer. Everything else a signed request gets back is sealed, errors included.Extension points
Each mechanism is a trait with the decided default installed by the builder:ServerEnvelope::open_request returns an OpenedRequest carrying an opaque per-request
SealContext, which the layer hands back, untouched, to the same request’s seal_response;
that returns Sealed { body, media_type }, so a composite can answer Mac0 with Mac0.
ServerEnvelope is also implemented for Arc<T>, and envelope_layer::async_trait is
re-exported for implementing it.
Wrapping CoseEnvelope? Delegate through the trait’s full path.
CoseEnvelope also has inherent, typed open_request / seal_response methods, and
method-call syntax picks those, so self.inner.open_request(body, bind) does not type-check:
Err(CratestackError::Unauthorized(_)) (a revoked device) is the unsigned 401;
any other error is a sealed 500. Pick a prefix no other source of VerifiedPrincipal in the
application uses: the rate limiter and the idempotency layer see only the string.
What the layer enforces whatever a plug-in does
- A request with an
application/cosebody (or one the envelope claims) is opened and verified before anything else sees it, or refused with the415, whatever the policy says. - Under
Required, no unsigned request reaches the router and no response to a signed request goes out plain; one the layer cannot seal is replaced by a sealed error. - Every verification failure is the same coarse, unsigned
401; any other envelope error is a500whose detail is only logged. - The
PrincipalMapperonly ever sees aVerifiedRequest, which has no public constructor. An empty principal is refused (a sealed500). - 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 disagree.
- A
Sealedwhose media type is notapplication/coseor one the envelope claims is never sent.
The Rust client
Turn on thecose feature of the facade the client crate uses (cratestack-client,
cratestack-pg or cratestack-api; it is off by default and pulls cratestack-cose, never
axum), build a client-role CoseEnvelope, and give it to the client:
Required client. Every request is sealed (a bodiless one seals
an empty payload), every response must be a sealed answer to that request, and nothing falls back
to plain:
The generated code tells the client which route each call is for, so a REST call binds the
template (
/widgets/{id}) and the values (7), and an RPC call its op id (batch for
/rpc/batch). It also hands over the schema’s OP_CONTRACTS (with_contracts), so each call binds
the digest of its own op and names it in the Cratestack-Contract header; a call to an op the
table lacks is a BadInput and is never sent. A hand-built client of one op pins its digest with
with_contract_sha. Idempotency-Key and If-Match are bound as you pass them. A request authorizer
still runs, over the plain payload with the codec’s Content-Type, which is what the
server’s AuthProvider sees once it has opened the seal.
Codecs other than CBOR. The codec’s CONTENT_TYPE is the type of every sealed request, and
HttpClientCodec::payload_accept (default: the same type) the types it reads back, so a
JsonCodec client can take an envelope, and so can a hand-written form-in/JSON-out codec that
overrides payload_accept. CBOR sends nothing extra; any other type travels in
Cratestack-Payload-Type / Cratestack-Payload-Accept and needs a layer that opted in
(Payload media types). with_envelope refuses (BadInput) a codec whose
type can never be sealed, and a sealed /rpc/batch over a non-CBOR codec is BadInput, never
sent.
Sealing a call you send yourself
ClientEnvelope::seal_call is the sealing the generated clients run on, for a caller that does
its own HTTP (a Node SDK over wasm, a hand-written adapter):
SealedCall::headers holds every header the seal depends on; send them as given and add
anything else (an Authorization-style header, a tracing id) yourself. PendingResponse::open
returns EnvelopeError::Unsigned for an unsealed answer, UnexpectedPayloadType for a type the
call did not ask for, and Unverified for a failed verification. The same bad-input rules apply as
for a generated call: a payload or accept type that cannot be sealed, a bound header with
surrounding whitespace, and a non-CBOR /rpc/batch are BadInput, and nothing is signed.
Redirects are never followed. A sealed request is bound to one route: a 303 would turn it
into a plain authenticated GET at a path the proxy chose, a 307 would re-send the sealed bytes
to another Location. CratestackClient::new builds its reqwest::Client with
redirect::Policy::none(), so a redirect is an unsigned answer (Unsigned { status: 303 }). A
client you supply with with_http_client or with_middleware_client must not follow redirects
either; if it does, the answer from a URL other than the sealed one is Unverified, which
catches the plain GET, but not a hop that already received the sealed bytes.
On wasm32 (a browser) the redirect is followed for you. fetch follows it before the client
sees the response, and reqwest 0.13’s wasm client cannot set redirect: "error". The client then
detects the redirect (Unverified) only after the redirected request was sent, so the sealed bytes
have already reached the Location. Keep redirecting hops out of the path of a browser client.
A router mounted under path parameters. When the server nests the router under a prefix with
parameters (Router::nest("/t/{tenant}", ..) and .mount_prefix("/t/{tenant}") on the layer), the
seal binds those values ahead of the route’s own, so a request signed for one tenant cannot be
replayed at another. Name them on the envelope, in order, with
ClientEnvelope::with_mount_params(vec!["acme".into()]), and put the mount in the base URL. Wrong
or missing values fail verification (an unsigned 401). A plain prefix such as /api has no
parameters and needs nothing.
Response headers are not signed. ETag, Retry-After and Idempotency-Replayed come from
the transport and reach you as the proxy sent them; the seal covers the status and the payload.
Read a version from the payload when it must be trustworthy. Idempotency-Key and If-Match
must each be given once, without leading or trailing whitespace; the client refuses anything else
locally (BadInput), since a hop may fold or trim it.
Retries. Do not put a stock retry layer (RetryTransientMiddleware) in front of a sealed
client: it ignores the idempotency marker and replays the sealed bytes, which the server refuses.
A key in a platform keystore. Android Keystore and iOS SecKey sign through an async call
and answer with a DER signature. ExternalSigner::esp256 takes the public key and an async
callback that receives the full to-be-signed bytes (the keystore hashes them itself, as
SHA256withECDSA), works out the kid from the key, and converts a DER answer to the 64-byte
form the envelope needs; a raw r || s answer is accepted too. ExternalSigner::ed25519(&public_key, sign)
is the same for an Ed25519 key that cannot leave its store (a Node KeyObject, a KMS): the callback
signs the whole to-be-signed bytes (pure Ed25519) and returns the 64-byte signature:
wasm32-unknown-unknown the signer and its future need not be Send (a JsFuture is
not): CoseSigner and CoseVerifierResolver follow RequestAuthorizer’s target split.
A signer that builds for both targets uses
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] and
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))].
Retries. Each sealed request carries a fresh cti, and the server answers a replayed one
with an unsigned 401. So the client marks sealed requests non-idempotent for
reqwest-middleware (RequestIdempotency::new(false), whatever the method), and a retry layer
has to send the call again through the client, which reseals it, instead of replaying the
bytes.
The FFI runtime. RuntimeHandle::with_envelope(config, envelope, contracts) (the generated OP_CONTRACTS) takes the
envelope out of band, because keys cannot travel in RuntimeConfigWire; the config names it with
RuntimeEnvelopeConfig::CoseSign1 or CoseMac0, and RuntimeHandle::new refuses those with a
BadInput that says so. A raw request over the bridge must be an RPC one (/rpc/{op_id} or
/rpc/batch): a raw REST path carries no route template to bind. A failure reaches the host as
one of the existing error codes with an envelope_* remote_code
(envelope_unsigned, envelope_contract_unsupported (with HTTP status 426),
envelope_unverified, envelope_seal, envelope_open, envelope_unexpected_payload_type,
envelope_streams_unsupported).
The Dart client
(Since 0.15.4, cratestack#1151; through 0.15.3, a Dart or Flutter app cannot seal a request.)package:cratestack_cbor/cose.dart seals requests and opens responses for a Dart or Flutter app. It is
not a Dart implementation of COSE: ADR 0006 section 11 keeps one, so the call goes over the bridge the
codec already uses to the same cratestack-cose the server runs (flutter_rust_bridge on native
platforms, the cratestack-cbor-wasm build on the web). The canonical query and the AAD are built by
that Rust code, and there is no crypto in the package’s lib/. An app that only uses the codec imports
nothing new.
ClientEnvelope.create starts the backend if nothing has yet, and returns an envelope for one
service. sealRequest takes the CBOR payload and resolves to the bytes to send. openResponse takes
the response body, the exact bytes you sent, and the HTTP status, and resolves to the verified
payload with the signer’s kid, its algorithm and the key’s RFC 9679 thumbprint. mode, mediaType
and kid are getters on the envelope.
The binding. A CallBinding is what one call’s signature is bound to, as in
What is bound. The audience is the envelope’s, so it is not repeated per call.
Where the digest comes from: a client generated by
generate-dart carries cratestackOpContracts,
keyed like the Rust client’s OP_CONTRACTS (the RPC op id, batch for transport rpc, or
<METHOD> <route template> on REST), with the digests as lowercase hex, so decode the hex to 32 bytes.
cratestack contract digest --schema <file> prints the same values. The generated Dart code itself
still sends unsigned requests; sealing is a call you make around its transport.
- Mount parameters come first.
pathParamsis the list the server’s router matched, in order. If it is nested under a path with parameters (nest("/t/{tenant}", ..)), the mount’s values come before the route’s own, as in the Rust client’swith_mount_params. - Send the bound headers yourself. An
Idempotency-KeyorIf-Matchgoes into theCallBindingand onto the request, byte for byte, or the server answers401. - Send
Cratestack-Contract.ClientEnvelope.contractHeaderValue(digest)gives its value (11 characters of unpadded base64url). It is not bound; it tells a server that accepts several digests which one you sealed under. Call it aftercreate, because it needs the backend started. - Reseal, never replay. Each seal carries a fresh
cti, so a retry callssealRequestagain. - Redirects. The same rule as the Rust client: do not follow them with your HTTP client. On the web the browser follows them for you.
Neither is for a user’s device. A seed in the app’s memory is a key an attacker with the process
can read, and Dart cannot wipe memory, so drop the signer once the envelope is built. For an HMAC
envelope, pin the server with
CoseServerKey.hmac(alg, secret); for Sign1 use
CoseServerKey.ed25519(publicKey) or CoseServerKey.p256Sec1(sec1), which verifies ESP256.
Keystore signing is coming: a callback signer that hands the to-be-signed bytes to the Android
Keystore or the Secure Enclave, like ExternalSigner::esp256 on the Rust side. There is no date for
it, and nothing in this release signs ESP256.
Required only. Like the Rust client, a Dart envelope seals every request and opens only a response
to a sealed request. There is no Optional client and no fallback to plain.
Errors. Everything the Rust side refuses is a CoseException, a sealed class, so a switch over
the caught exception is exhaustive:
Mistakes that can never be right are not a
CoseException: a digest that is not 32 bytes or a
non-HMAC algorithm for an HMAC type is an ArgumentError from the constructor, and
contractHeaderValue before the backend has started is a StateError. A CoseRejected on a
response is not the same as the server’s refusal of your request: that arrives as a 401 or 426
without a seal, which you read from the HTTP status, as in the Rust client’s
table.
Size. The vendored binaries are built once, so cratestack-cose and its crypto add about 460 KB
to the stripped Linux x86_64 library and about 270 KB to the web .wasm, for every app that uses
cratestack_cbor, signed or not. @cratestack/cbor-web on npm is built without it and stays
codec-only.
Claim objects
(Since 0.17.0, cratestack#1231.) A claim object is a signed payload that does not die with a request. It is a separate format incratestack-cose, built for a payload that one device signs offline and another device delivers
later: a QR code scanned by a terminal that is online, a voucher handed over by a messaging app. The
envelope above cannot carry that. It is bound to one request, and the layer refuses it outside a
±300 s iat window (DEFAULT_SKEW_SECS) or on a repeated cti. Whether a claim object has
expired, or may be presented twice, is the application’s decision, so the crate does not make it.
Use the envelope for a call between a client and a service. Use a claim object when the payload
itself must travel on its own and be verified by whoever receives it. A claim object is not a
replacement for the envelope on a request: it carries no binding to one and does no replay
protection.
Sign and open
sign_claims(signer, typ, &CwtClaims, payload, external_aad) returns the object’s bytes.
open_claims(resolver, bytes, expected_typ, external_aad) returns an OpenedClaims: the kid, the
alg, the key_thumbprint of the key that verified (two keys can share a kid, so the thumbprint
identifies the signer), the claims and the payload. The signer and the resolver are the
envelope’s own CoseSigner and CoseVerifierResolver, so an in-process key, a KMS or an HSM all
work, and StaticVerifierResolver and RegistryVerifierResolver resolve keys as before.
What the crate checks, and what it leaves to you
open_claims verifies the signature and the typ. It does not read a clock and it does not
remember anything. In particular:
- An object with no
expnever expires. The same bytes open as many times as they are presented. Put anexpin every object that should not live forever, and compare it with your own clock. audis not compared. Check it against the audience you serve.ctiis not recorded. To make an object single-use, give it actiand keep the ones you accepted, in a store whose lifetime matches the object’s.- Verified is not fresh. The
OpenedClaimsdoc says so on purpose: a valid signature means the holder of the key signed these bytes at some point, nothing more.
The wire shape
A taggedCOSE_Sign1 (tag 18), RFC 9052:
algis Ed25519 (-19) or ESP256 (-9), the fully specified ids of RFC 9864;kidis the 8-byte thumbprint prefix, as in the envelope.- Label
15carries the CWT claims (RFC 8392, in a COSE header by RFC 9597). Each claim is written only when present.audis a single string of at mostMAX_AUD_LEN(256) bytes,expandiatare whole seconds since the Unix epoch, andctiis 1 toMAX_CTI_LEN(64) bytes. - Label
16istyp(RFC 9596), mandatory, 1 toMAX_TYP_LEN(128) bytes. It names what the object is, it is signed, andopen_claimsrefuses any other (WrongType), so an object minted for one purpose cannot be presented as another. external_aadis signed but not sent: the opener must supply the same bytes. Put in it what the object is bound to without carrying, such as a tenant or a purpose. Empty is fine.- Encoding is deterministic (RFC 8949 section 4.2.1) and an ESP256 signature is low-
s. The opener is as strict as the envelope’s: no unknown, duplicate or out-of-order label, no non-minimal or indefinite head, nothing unprotected, no detached payload, no trailing bytes.
Kept apart from the envelope
The envelope’s parser refuses header label16 and the claim opener requires it. So a claim object
presented to the EnvelopeLayer is refused, and an envelope presented to open_claims is
Malformed, even when both were correctly signed. cratestack-cose’s
tests/claims_domain_separation.rs checks both directions.
Errors
open_claims and sign_claims return ClaimsError (#[non_exhaustive], so match with a
wildcard arm):
The verdict variants carry nothing about the bytes received, so forwarding one to the peer leaks
nothing beyond the variant.
ClaimsError::into_error() maps Malformed, WrongType and
Unverified to the envelope’s coarse 401, and everything else to a 500.
Evolving a schema under signed clients
A signed request binds the digest of the op it calls (binding version 2). That digest covers the op’s transport, key and kind, its input and output types, and every model, type and enum reachable from them, in their wire projection. It does not cover anything that only the server runs or stores. So, for a client built from an older schema:
A signed
/rpc/batch binds the whole-contract digest for now, so any row above that changes the client contract, including “add a procedure”, refuses a signed batch from an older client with the 426 (see Limits).
A .cstack edit that changes an op’s shape is therefore a deliberate, per-op break, and the cheapest
way to make one is to add a new procedure beside the old one. The reviewed list of attributes that do
not count (DROPPED_ATTRIBUTES) is fail-loud: an attribute the list does not name counts, so a newly
added attribute moves digests until it is reviewed onto the list. cratestack contract digest prints
every op’s digest and cratestack contract print --op <key> the canonical form that is hashed, which
is how to see why one moved.
How the server picks the digest
The AAD is never sent, and a server can accept more than one digest for an op, so the client names the one it used in a header that is not bound and not trusted:- The header selects, it never widens. It only chooses among digests the server already accepts
for the op, and the signature covers all 32 bytes, so a lie is the ordinary
401. - A selector that names no accepted digest is an unsigned
426withRpcErrorBody { code: "contract_unsupported" }(RESTCONTRACT_UNSUPPORTED), answered before any key lookup. It reveals only that this op’s shape is no longer served, which every client binary already carries; it is unsigned, so the client treats it as a hint. The same answer goes to an unsigned, nonce-bound request whose well-formed selector names nothing, so both paths behave alike. - Why the
426carries noUpgradeheader. RFC 9110 §15.5.22 asks for one.Upgradeis a connection-level field (§7.8), forbidden on HTTP/2 (RFC 9113 §8.2.2), stripped by hyper’s h2 server and dropped by proxies, and no protocol token names “this op’s contract”, so a compliant header could neither arrive nor say anything. Every other status already means something here (409is the idempotency and transaction layers,410is heuristically cacheable,412belongs to conditional headers,400is the malformed selector), so426is kept and the client recognises the refusal by the body’s code alone, never by the status: a proxy’s own426staysUnsigned. - Without the header (a hand-built client) the layer tries the op’s accepted digests newest first,
at most
max_contract_trials(default 4). A message’s nonce is recorded only once it verifies, so a failed trial burns nothing. The COSE envelope parses the message and resolves its key once (open_request_any) and repeats only the signature check, so a forged message costs one parse, one key resolution and at most that many verifications. - The response is sealed under the digest the request used, and the client opens it under the digest it sent.
- A digest from another op is refused: op B’s accepted list does not hold op A’s digest, even with a matching route and selector.
[current] until the server names a contract lock (next section), which
only appends older digests to it.
Keeping older clients working through a compatible change
Adding an optional field to a model or an args type changes the digest of every op that reaches it, yet an old client’s messages still decode under the new shape. To keep accepting them, commit a contract lock next to the schema and name it in the server macro:contract lock|check|prune: 0 ok, 1 a failed verdict (check: the current
contract is not locked, or breaks a locked one; lock: it breaks a locked one), 2 a tool error (an
unreadable schema or lock, a bad date or flag value). check --json prints one JSON document on every
path, so a CI parser never meets an empty stdout; a tool error is {"ok": false, "error": "...", "client_contract": ..., "locked": false, "incompatible": []} with exit 2.
The lock is JSON: contracts holds each distinct canonical op contract once, under its digest, and
generations (oldest first) records, per locked moment, the client contract digest, a locked_at
date, a note and each op’s digest at that moment. The macro recomputes every stored contract’s
digest (a hand edit is a compile error), reads the file at expansion with fs::read_to_string
(an include_bytes! of it only makes cargo track it, so editing it rebuilds the crate), and emits ACCEPTED_CONTRACTS as [current, ...locked, newest first] per op. No clock is
read at build time: dates come from contract lock --date (default today, UTC). A date is a real
YYYY-MM-DD calendar date, checked where it enters (--date, a lock file’s locked_at) and
compared as a date by prune --before; 10/02/2026 is refused rather than compared as text, which
would have pruned a generation locked yesterday. A lock of a format this build does not know is
reported as “format N”.
What counts as compatible. Every locked contract of an op the schema still has is judged against
the current one by a conservative classifier (anything it does not recognise is breaking), at compile
time and by contract check. Old is the signer’s shape:
- The op’s transport, key, kind, verb, model, procedure name, return type and kept attributes are unchanged.
- An argument or field may be added only as optional. The one exception is a field with
@defaulton a model op’s own model: only a model’s create input leaves a@defaultfield out. Atype, or a model reached as a procedure argument, decodes a@defaultfield as required, so an old client’s message would fail to decode; that is refused. AFindMany<T>?argument is refused too (its generated field is never optional and has no default);Page<T>cannot be an argument at all. A required argument, or a field only the input reaches, may become optional. Variants may be appended to an enum only the input reaches. A declaration only the output reaches may gain any field, because decoders ignore keys they do not know. - Everything else is breaking, notably: removing an argument or field (the signed value would be
silently ignored, a meaning the signer never produced), any retype, an arity change in a direction
that reads it, a variant added to an enum an old client decodes, inserting, removing or reordering
variants, reordering arguments, and adding, removing or changing an attribute of a kept field or
declaration (a
@defaultfield is not in the create input, so adding one to an existing field drops what an old client sent). A model op’s model counts as both input and output.
cratestack contract prune --op <key>: older clients of that op get the 426 and no
other op is touched. contract lock refuses to write while the current contract breaks a locked one,
so the prune is always a separate, reviewed step. Each compatible rule is backed by a fixture-based
round trip through the generated types (not random inputs), and the refused @default case is
pinned the same way. A signed /rpc/batch never takes history (see
Limits). A lock holds every distinct contract of every generation, so its first generation
is the size of the whole client contract; later ones add only the ops that changed.
Upgrading from binding version 1
Binding version 2 is a flag day. A 0.15.2-or-later server refuses a 0.15.0 or 0.15.1 client’s messages and a 0.15.2-or-later client’s are refused by a 0.15.0 or 0.15.1 server, both as the coarse unsigned401. Nothing in the AAD’s layout moved
except what element 7 holds, so the version number is what tells the two apart, and there is no opt-in
to keep accepting version 1. A custom BindingResolver (a versioned /v1/... route, say) must map
its routes to op keys: return the op id as the route, or call ResolvedRoute::with_contract_key;
a signed request to a route with no contract row is a logged 500. The shared vectors in
cratestack-cose/tests/vectors are regenerated for version 2, with neg-binding-v1,
neg-contract-sha and neg-contract-cross-op.
Limits
- Streams cannot be sealed until ADR 0006 P1 (
chainmode). A signed request getsAcceptof sealable types only (never a stream), so a@streamop answers with one buffered, sealed array; a signed subscription is a sealed406before its handler runs. Only an unsigned request underOptionalstreams (plain), so give subscriptionsOptionalorOffin the policy. - Responses are re-buffered to be sealed, up to
MAX_RESPONSE_REBUFFER_BYTES(8 MiB); a longer one becomes a sealed500. The payload is copied once into the sealed message; the zero-copy API is cratestack#1076. - A semantic change with no shape change is not caught. The digest covers what decodes, so an
Intfield that switches from major to minor units, or a validator that changes what a value means, leaves it alone. Change the op, or add a new one, when meaning changes. - A signed
/rpc/batchbinds the whole-contract digest (CLIENT_CONTRACT_SHA256_BYTES), and is accepted only when it equals the server’s current one, so any client-facing change (a new op, an edit to any op, locked or not) refuses a signed batch from an older client with the426. Per-frame digests are a follow-up. - A claim object has no freshness or replay check. It is a different format from the envelope
(see Claim objects): the crate verifies the signature and leaves
exp,audandctito your code. (Since 0.17.0, cratestack#1231.) - One envelope per layer. A router accepting Sign1 devices and Mac0 services at once needs the composite of cratestack#1078.
- The generated Dart and TypeScript clients do not sign. ADR 0006 §11 has them use the Rust
runtime rather than a second implementation, so their generated code stays unsigned; the Flutter
runtime mirror rejects an envelope until P1. A Dart or Flutter app seals through
cratestack_cbor(since 0.15.4, cratestack#1151). The published@cratestack/cbor-weband the napi addon do not. - A version 2 server refuses binding version 1 (0.15.0 peers), with no opt-in: upgrade the server and its clients together. Any future change to the AAD bumps the version again.
- A signer is recorded, not authenticated, unless you wrap your provider in
CoseAuthProvider(since 0.16.0, cratestack#1077). Device keys authenticate only withauthenticate_devices(true); see Authenticate a signer.
Read next
- ADR 0006 for the envelope, the AAD layout and the threat model
- Idempotency and Rate limiting, which key on the principal the layer inserts
- Auth provider for identity, which the envelope does not decide
- Claim objects for a signed payload that is delivered later, outside any request