ADR 0006: COSE Envelope Modes and Key Management
Status
Accepted — 2026-09-24. Proposed and accepted the same day; the maintainer recorded all ten decisions below (cratestack#1003). P0’s envelope is implemented in thecratestack-cose crate
(cratestack#1005): unary Mac0/Sign1, the
external AAD, nonce replay and the shared test vectors, plus the auth feature’s adapters. The
server layer that calls it from the generated routers is cratestack#1006 (shipped in 0.14.0; see
Signed transport); the Rust client is cratestack#1007. A proof of concept exists outside this repository (maintainer, 2026-09-24). The design and
the measurements below come from that work; the proof of concept’s fixture was reconstructed for
the vectors because its script was not available.
Amended 2026-09-30 (binding version 2). The AAD no longer binds the whole-schema digest. It binds
the digest of the op being called, so a server-only schema edit no longer refuses every signed client
(cratestack#1123, epic
#1030; §4, “Op contracts”). Binding version 1
froze with 0.15.0, so this is a breaking wire change with a flag day: a version 2 verifier refuses
version 1.
Amended 2026-10-09 (negotiated payload types). The inner payload is no longer fixed to CBOR. The type
was already element 8 of the AAD (§4), so only which string goes in became negotiable: two unbound
selector headers, Cratestack-Payload-Type and Cratestack-Payload-Accept, default to
application/cbor, and a response binding names the response payload’s own type
(cratestack#1168; §2, “Payload types”). No
binding-version change, no COSE header change, and a message that names no type is byte-identical to
0.15.3’s.
This fills the slot ADR 0001 reserved as “ADR 0006: COSE Envelope Modes
and Key Management”. It keeps 0001’s envelope principle (“COSE is not a codec. COSE wraps encoded
bytes”) and its processing order (HTTP body → envelope.open → codec.decode).
Date
2026-09-24 (proposed and accepted)Decisions
The maintainer’s decisions, recorded 2026-09-24 in cratestack#1003. Where a decision differs from the recommendation or needs more than one line, the paragraph below the table says what it means.
A decision that changes a public trait (D1, the async
CratestackEnvelope below) is breaking. That
is acceptable before 1.0, and today only NoEnvelope implements it.
Q2, what “opt-in” means. The server’s response-signing key is EdDSA unless a deployment
configures ES256, typically because the key lives in a cloud KMS or HSM, or because a browser client
holds it as a non-extractable WebCrypto key. The CoseSigner / CoseVerifierResolver traits (§1)
already carry the algorithm, so both ship in P0. The shared test vectors (P0) are byte-exact for
EdDSA, which is deterministic. For ES256, whose signatures are randomized, they assert verification
instead of comparing bytes.
Q4, the counter lives behind the store trait. The client already persists state through the
ClientStateStore trait (cratestack-core/src/store/client_state.rs), which has in-memory, JSON
file, SQLite (cratestack-client-store-sqlite) and Redis (cratestack-client-store-redis)
implementations, and applications can provide their own. window mode does not pick a store.
It adds a contract to the trait: persisting a queued signed frame and advancing the counter is
one atomic operation, and a crash leaves either both or neither. Each shipped implementation meets
it in its own way:
- the JSON file store writes a temporary file, fsyncs it, then renames it over the old one. Today it
uses a plain
std::fs::write, which is not crash-atomic. - SQLite uses one transaction.
- Redis uses
MULTI/EXECor a Lua script.
window mode unchanged. The exact trait
method is designed in P2 (#1015).
Q5, the reserved hybrid slot. No post-quantum algorithm ships now. Algorithm agility (the
protected alg header) plus chain mode is the migration path. Chain mode amortizes one large
signature (ML-DSA-44 is about 2.4 KB) over a checkpoint window. What “reserved” commits P0 to:
- Nothing assumes a signature length. Buffers, size limits and the chain checkpoint parser size
the signature from
alg, never from a hard-coded 64 bytes. - A hybrid is one
algvalue. A composite algorithm identifier (a classical and a post-quantum signature over the sameSig_structure, as in the IETF composite-signature work) goes in the existingalgheader, and the composite key’s thumbprint prefix is thekid. The COSE structure, the AAD and the checkpoint payload do not change, so adding it is not a wire break. - Resolution is by
(kid, alg), asCoseVerifierResolver::resolvealready specifies. One device can hold a classical key and a hybrid key side by side during migration. - The AAD’s binding-version field (§4, currently
1) is the escape hatch if a future scheme needs a different binding.
- Algorithm identifiers (§3): -19 (Ed25519) and -9 (ESP256), the fully specified algorithms of RFC 9864, which deprecates -8 (EdDSA) and -7 (ES256). Both encode in one byte, so no size changes. Nothing has shipped with -8/-7, so verifiers accept only -19 and -9 (and the Mac0 ids).
- REST responses are bound to the resource, not only to the route shape (§4). The AAD gains
path_params: the matched path parameter values in template order, as the router decoded them (empty for RPC, whose op id and body digest already bind it). With the template alone, a signedGET /accounts/1response would verify as the answer toGET /accounts/2. That gap matters because Q1 signs every GET response. - Crate placement (§11):
cratestack-cosesits at L2 with an optionalauthfeature. Without the feature it depends oncratestack-coreonly, so clients and the wasm/napi builds stay free ofcratestack-auth’s Redis, reqwest and rustls dependencies. With it, the crate owns everything auth-specific too: theServiceSigningKeyandDeviceKeyResolveradapters, the Redis nonce bridge, and the COSE enrolment code.cratestack_auth::{build,parse}_cose_enroll_responsemove there, which is a breaking change (no known in-repo consumers). - P0 defaults:
- The shared vectors cover both
ctishapes: 16 random bytes (P0noncemode) and a 2-byte counter (the §3 measurements).ctiis injectable on the seal side for that purpose. - Multi-replica
noncereplay bridgescratestack-auth’s existing Redis nonce store, keyed by(kid, cti). DeviceKeyResolvergains a required lookup-by-thumbprint method. This is breaking, so implementors get a compile error instead of every COSE device request failing silently.- A Mac0
kidis the RFC 9679 thumbprint prefix of the key. Deployers list their key ids so the thumbprints can be precomputed, and secrets shorter than 32 bytes are rejected. - Errors, per §10: every failed check is the same coarse
401; a backend outage (key resolver or nonce store) is a500. - The schema SHA first hashed the raw
.cstacktext, so a comment-only schema edit would reject every signed client. cratestack#1065 replaced it with a canonical schema identity (§4, “Schema identity (#1065)”). That still refused every client on any schema edit, so binding version 2 replaced it in the AAD with the called op’s contract digest (§4, “Op contracts”).
- The shared vectors cover both
- The AAD binds the op’s contract digest, not the schema’s. Element 7 is
contract_sha, the digest of the wire closure of the op being called.BINDING_VERSIONis2. - An unbound
Cratestack-Contractheader selects the digest. It carries the first 8 bytes of the digest (unpadded base64url, 11 characters) and chooses which of the digests the server already accepts for the op to verify under. The signature covers all 32 bytes, so the header widens nothing. - A digest the server does not accept is an unsigned
426 contract_unsupported, answered before any key is looked up. Without the header, the server tries the accepted digests newest first, at most a configured number (default 4). - The response is sealed under the digest the request used.
- Version 2 verifiers refuse version 1, with no opt-in.
- A signed
/rpc/batchbinds the whole-contract digest for now, accepted only when it equals the server’s current one. Per-frame digests are a follow-up. - The compatible-history lock (digests of older contracts a server still accepts, checked by a classifier) shipped in the change after binding version 2 (cratestack#1123, 2026-09-30): see §4, “The compatible-history lock”. Without a lock the accepted list per op has one member.
- The AAD binds the recipient (§4). A new
audienceelement carries a configured logical service identifier, not the Host header, which gateways rewrite. Without it:- one signed request opens at any two services that share a schema and a route, each with its own nonce store;
- in Mac0 mode, a service’s own outgoing request is a valid incoming request to itself.
- Signed responses to bodiless requests are fresh (§4). A GET has no body, so its request digest
used to be
SHA-256("")every time, and a year-old signed response still verified for a new GET of the same URL. The client now sendsCratestack-Nonce: 16 random bytes, base64url without padding, on every request it wants a verified response to, which inRequiredmode is all of them. For an unsigned request,request_digest = SHA-256(nonce ‖ payload). Each signed response is then bound to exactly one request, with no server state. - Encode in place stays (§1), through an additive hook.
CratestackCodecgains a providedencode_intoandCratestackEnvelopea providedseal_value. The COSE envelope overridesseal_valueto encode straight into its output buffer, so neither trait merged in cratestack#1066 breaks. HMAC and ES256 compute over the MAC/Sig structure incrementally. So does Ed25519, using the two-pass PureEdDSA streaming ined25519-dalek’sMultipartSigner(maintainer, 2026-09-25). That is the same computation as thehazmatmodule’s streaming API, but dalek expands the key itself, which avoids the key-leak misusehazmatwarns about. No algorithm makes a contiguous to-be-signed copy. Streamed signatures are byte-identical to the standardsignfor every vector and a range of payload sizes. The streamed verify is as strict asverify_strict: dalek’s streaming verify does not check that R decompresses, that R is not small-order, or that the public key is not weak, so cratestack adds those checks itself, and tests prove each one matters. - A response binds the kind of request it answers (§4). The response AAD carries
request_kind:0means the request was unsigned and its digest isSHA-256(nonce ‖ payload);1means it was signed and its digest isSHA-256(request COSE bytes). Without it, the two digest forms could collide: a signed requestCre-presented as an unsigned twin with nonceC[..16]and bodyC[16..]produced a server response that verified as the answer toC. A response carriesrequest_kind,request_digestandstatustogether; a request carries none of them. - An empty
audienceis misuse (a500), because it silently gives up the protection. A service’s inbound audience must differ from the audience it uses when sending to peers: a shared name such asinternaldefeats reflection protection. - Kept as specified: the replay store is keyed by
(kid, cti). Two keys that share an 8-byte kid could interfere, but targeting that costs about 2⁶⁴ work and only causes a denial of service. The Mac0KeyProvideradapter refuses an empty id list, a repeated id, and two ids that resolve to the same secret; it reads the keys once at load, so a rotation means a rebuild. - Security hardening, found by the same review and implemented in #1005:
- the verified principal is the thumbprint of the key that actually verified, and that key’s
kidmust match the header; - an HMAC key is bound to exactly one of alg 4 or 5, so a 256/256 deployment never accepts a 64-bit tag;
- ESP256 signatures are low-S only, which keeps “one message, one encoding”;
- the skew bound is validated when the envelope is built.
- the verified principal is the thumbprint of the key that actually verified, and that key’s
Context
What exists today
Verified againstcratestack main on 2026-09-24.
Two defects in today’s
HmacEnvelope, independent of this proposal:
- It signs a different serialization than it sends. The MAC covers
serde_json::to_vec(&body), while the envelope travels as CBOR. Verification works only because both sides re-serialize aserde_json::Valueidentically. Any value that JSON represents lossily (bytes, big integers, CBOR tags, float edge cases) breaks that silently. COSE removes the issue: the payload is a byte string, and the verifier checks the bytes it received. - It spends bytes badly: 147 B per message, against 41 B for the COSE_Mac0 equivalent.
Codec parity today
The goal is that every client and server surface can speakapplication/cbor,
application/cbor-seq and the COSE media types.
cbor-seq parity is tracked separately from COSE because it needs no decision from this ADR. It is a
prerequisite for §6 and §7 on every surface.
Measurements
These numbers come from the proof of concept (coset 0.4.2, ed25519-dalek, hmac/sha2,
minicbor-serde configured like CborCodec). The fixture is a 7-field payment row: 112 B as CBOR
with string keys. The bench script is not yet in this repository; bringing it in is part of P0’s
shared test vectors.
Unary, one row:
Signature headers are unique per request, so HTTP/2 HPACK cannot compress them, and clients rarely
compress request bodies. Raw size is the uplink cost, and on the target links the uplink is
usually the scarcer direction.
Streams, 1 000 rows:
Ed25519 signatures are 64 bytes of incompressible noise. Signing every item costs +81% raw but ×4.25
after compression, because the signatures break the redundancy compressors exploit. Chained
checkpoints keep a compressed stream within a few percent of unsigned.
Integer keys (§9), same rows, unsigned: 112 → 57 B per row raw (−49%); zstd total 21 919 →
18 471 (−16%).
Goals and non-goals
Goals- End-to-end integrity and origin authentication of payloads. They must survive TLS-terminating hops (CDNs, load balancers, operator and corporate proxies), storage in the offline SQLite store, and relay between devices.
- Low byte overhead, especially on the uplink and on streams.
- Streaming as a first-class case: incremental verification, detectable truncation, and resumption (P3).
- Offline-first: a mutation signed when it was created stays valid for upload days later, without opening a replay hole.
- Auth headers become optional for signed traffic. A verified COSE key identifies the caller, so
writes do not also need
Authorization: Signature(§12). - No canonicalization anywhere. Verify the exact bytes that were sent.
- One Rust implementation shared by the Rust, JS (wasm/napi) and Flutter (FRB) clients.
- Confidentiality. TLS covers transit; COSE_Encrypt0 for data at rest can come later on the same structure.
- Multi-signer COSE_Sign, or COSE_Mac with recipients.
- Replacing TLS or the enrolment protocol.
Decision (proposed)
1. Layering: the codec stays simple, the envelope binds
bstr is the codec output, byte for byte. The sealer encodes straight into the COSE
buffer: it reserves up to 9 bytes for the bstr head, encodes, then patches the head. The call path
is the provided CratestackEnvelope::seal_value over CratestackCodec::encode_into (see “Decisions
from the P0 security review”); seal(payload) remains for callers that already hold encoded bytes. The verifier
checks those bytes and hands the same slice to CborCodec::decode. Nothing is re-serialized.
The envelope is not a new CratestackCodec implementation. Signing needs a key, request context
(route, method, request digest) and async (so KMS and HSM backends can plug in). Adding those to the
codec trait would touch every C: CratestackCodec bound across the server and all clients. The
envelope seam already exists and only needs to become real:
KeyProvider::resolve_signing_key returns raw key bytes.
That fits HMAC, but not asymmetric keys held in an HSM or KMS. Add:
KeyProvider → Mac0 keys, DeviceKeyResolver → device Ed25519 keys, and
ServiceSigningKey → a CoseSigner.
2. Media types
The inner payload media type is not sent in a COSE header (label 3 would cost bytes on every message).
It is bound through the AAD instead (§4), so a verifier cannot be tricked into decoding the payload as
something else.
Payload types (amended 2026-10-09). The type bound in the AAD is carried by two unbound selector
headers, the same pattern as
Cratestack-Contract (they select among what the verifier already allows
and never widen it, because the AAD carries the whole string): Cratestack-Payload-Type (a request’s type;
on a sealed response, that response’s type) and Cratestack-Payload-Accept (the response types the client
reads, in order). Absent means application/cbor. A request binding names the request payload’s type; a
response binding names the response payload’s own type. A payload type is a lowercase type/subtype of
token characters without parameters or wildcards; an envelope (application/cose*), a stream
(application/cbor-seq, text/event-stream) and a multipart body are never sealable. What an op allows is
the layer’s opt-in set intersected with the types its route declares; /rpc/batch stays CBOR. A selector
repeated or malformed is a 400, a request type the op does not allow a 415, and no acceptable response
type a 406, all unsigned and before any key lookup; a header that lies fails the signature (the coarse
401). The client never decodes a type it did not request.
Whether a request or response is sealed at all is still negotiated by the existing Accept and
Content-Type headers (application/cose); the inner payload type is the selector headers above. The
router gains a policy:
Required (unsigned requests → 401 unauthenticated), Optional (verify when present), or Off.
3. Message layout
- kid = an 8-byte thumbprint prefix. It is derived from the key, so no kid strings are stored, and rotation is self-describing. The birthday bound is about 2³² keys, and the resolver returns several candidates on the rare collision.
- Algorithms by direction.
- Device → server: Ed25519 (-19). Device keys are already Ed25519, and these messages must be provable later.
- Server → client: Ed25519 by default, or ESP256 (-9) where the key lives in an HSM or as non-extractable WebCrypto (Q2).
- Service → service inside one trust domain: Mac0 HMAC 256/256, or HMAC 256/64 on constrained links. A 64-bit tag is safe only because forgery requires online attempts, and those are rate limited.
- Why not Mac0 for devices? It is cheaper (41 B against 98 B), but with a symmetric key the server could forge device messages, so non-repudiation is lost.
4. External AAD: binding without bytes on the wire
bound_headers (added 2026-09-26, cratestack#1006, inside version 1 before the freeze).
- A request binding has 9 elements and a response binding 12.
bound_headersis always present:[null, null]when a request carries neither header, and a response repeats its request’s values. - A request that carries either header twice, or a value that is not UTF-8, is refused with an
unsigned
400before anything is bound. - An empty value binds as
"", notnull. - It defeats an on-path party that strips
Idempotency-Keyfrom a re-sealed retry (the operation would run twice), or that strips or changesIf-Match(a conditional update would become a blind one). - Response headers (
ETag,Retry-After) remain unauthenticated.
- Version 1 bound
schema_sha, the whole-schema identity below. Every change before its freeze was part of it:path_params,audience, the nonce-based unsigned digest, and cratestack#1065’s schema identity. It froze with 0.15.0, the first release in which generated routers and clients put the envelope on the wire (cratestack#1006, #1007). (Decision 2026-09-26, taken when v0.13.0 was cut: tying the freeze to the crate’s first release would have forced a new version for #1065 with no peer to be compatible with.) - Version 2 (cratestack#1123, 2026-09-30, 0.15.2) replaces element 7 with
contract_sha, the called op’s contract digest. Every other element keeps its position, so the array lengths (9 and 12) are unchanged, and the version number is the domain separation: the same 32 bytes under 1 and 2 never produce the same AAD. - From 0.15.0 on, any change to the element list or to how an element is derived bumps the version. Verifiers reject versions they do not accept, and a version 2 verifier accepts only 2: accepting version 1 would cost a second trial verification on every failure (the version is not on the wire) and keep a downgrade path alive for peers that, one day after 0.15.0, almost certainly had not shipped. Version 1 peers must be upgraded together with their servers.
- cross-endpoint replay: a body signed for
payment.createfails onpayment.refund; - response swapping: a response is bound to its request and its status code;
- wire-shape drift, per op: a client whose wire shape for an op differs from the server’s fails closed for that op, and only that op;
- cross-service replay and reflection: the
audiencenames the recipient; - header stripping:
bound_headerscoversIdempotency-KeyandIf-Match; - stale responses: an unsigned request’s digest includes the client’s
Cratestack-Nonce, so a signed response answers exactly one request; - digest-form confusion:
request_kindkeeps a response to an unsigned request from verifying as the response to a signed one.
op_id, never the raw URL path, because gateways rewrite prefixes. (The same fact made
Router::nest break descriptor lookup in cratestack#877.)
Schema identity (#1065). Version 1 bound schema_sha, the schema’s canonical identity, which
is not a hash of the .cstack text. Version 2 no longer binds it: it remains the identity the
warn-only x-cratestack-schema-sha drift header carries (SCHEMA_SHA256), and the op contract
digests below are what the AAD carries. It is SHA-256(b"cratestack/schema-identity/v1\0" ‖ canonical JSON), computed by
cratestack_core::schema_digest over the parsed schema, and every producer calls that one function:
the three include_*_schema! macros and the CLI that bakes the constant into generated Dart and
TypeScript clients. The digest used to be computed twice, over the source bytes, once in the macros
crate and once in the CLI.
- Excluded: source spans,
///docs, and the whitespace of attribute text outside string literals, including"""SQL bodies (@default( false )is@default(false)). Comments never reach the parser’s output. The contents of"...",'...'and"""..."""literals stay verbatim, because@@sql("...")bodies and regexes carry meaning in their whitespace. Still digest-changing, by design (fail-loud): reordering attributes, a trailing comma in[...], and- 1versus-1. - Sorted by name: top-level declarations (models, types, enums, mixins, procedures, views,
queries) and the fields of models, types, mixins and views, so moving a declaration is not a
contract change. When the positional P3 codec (§9) lands, whatever assigns its ids (declared in
the schema or kept in a lockfile) must enter the identity, or the domain tag must move to
/v2; field order is sorted away today, so ids cannot ride on it. - Declared order kept: enum variants, attribute lists and procedure arguments. No codec encodes a
variant by its index (enums travel by name), but the first variant is the type’s
Defaultand Postgres orders an enum by declaration, so the order is part of the contract. - Built by hand, versioned by tag. The canonical form is written node by node rather than by
serialising the IR, so a new IR field enters the digest by a deliberate edit and never through a
#[serde(default)]. A golden digest test pins it; the/v1tag makes any later change to the form a new identity on purpose. - Cost (why version 2 exists): the whole IR is hashed, not only the wire surface, so a
server-only edit (a policy, an
@@index, a view’s SQL) also changed the digest, and every client built before the edit was refused with an unsigned401indistinguishable from a revoked key. Mobile clients cannot be force-updated, so underRequiredeach schema deploy would lock out every installed app.
Page<T> against T[]. Cross-op and cross-service replay are already defeated by
route and audience. The binding never guaranteed server behaviour: handler code, policies,
validators and SQL do not change how bytes decode, so a digest that moves on such an edit refuses
for no security reason. So the digest is per op, over what decodes:
- In the digest: the op’s transport, key and kind, its input and output roots (a procedure’s
arguments and return type; for a model op the verb and the model), and the closure: every
model, type, enum and view reachable from the roots through field types, generic arguments,
relations and
@computed(params:), each in its wire projection (@server_onlyfields removed, since they are on no wire). The key is the RPCop_id, or"<METHOD> <route template>"on REST, so@api_versionis in it. - Out of it (
DROPPED_ATTRIBUTES, each entry reviewed against the generators that read it, with a test that pins the readers):@@allow,@@deny, procedure@allow/@deny/@authorize,@@index,@@sqlbodies,@@audit,@@retain,@@internal(it decides whether an op exists, which is the key),@@unique,@@soft_delete,@@subscribe/@@emit,@no_idempotency,@no_rate_limit,@isolation,@pii,@sensitive,@db_enforce, field@unique, view@from, procedure@deprecated, and the value validators (@length,@regex,@email,@range, …). Thedatasource, config blocks,extensionblocks, theauthblock, the schema-level MCP config, queries, unreachable declarations, every other op, docs and spans are out entirely. - Fail-loud: an attribute not on the reviewed list stays in, including attributes added later, so a new one moves digests until someone reviews it onto the list.
- Derivation.
SHA-256(b"cratestack/op-contract/v1\0" ‖ canonical JSON), written node by node with every IR node destructured exhaustively, like the schema identity. If the derivation rules change, the tag moves toop-contract/v2. A second digest,client_contract_digest = SHA-256(b"cratestack/client-contract/v1\0" ‖ canonical JSON of the sorted [key, hex(op digest)] table), is the build identity of the whole client-facing contract, and what a signed/rpc/batchbinds. - Not caught, as before: a semantic change with no shape change (an
Intswitching from major to minor units). A later@contract_revision(n)attribute could force a break on purpose.
Cratestack-Contract header: the
first 8 bytes, unpadded base64url. The header is never trusted: it only selects among digests the
server already accepts, and the signature covers all 32 bytes, so a lie is the ordinary 401.
- Header present and naming an accepted digest: one verification.
- Header present and naming none: an unsigned
426withRpcErrorBody { code: "contract_unsupported" }(RESTCONTRACT_UNSUPPORTED), before any key lookup. - Header absent (a hand-built client): the op’s accepted digests are tried newest first, capped
(default 4). A nonce is recorded only after a message verifies, so a failed trial burns nothing,
and the COSE envelope parses the message and resolves its key once (
open_request_any), so a forged message costs one parse, one key resolution and at most the cap in verifications. - The response is sealed under the digest the request opened under.
- A signed
/rpc/batchhas one AAD and many ops: until frames carry their own digests it binds the whole-contract digest, accepted only when equal to the server’s. - The accepted list per op is
[current], plus the older digests of a committed lock that a conservative classifier judges wire-compatible (next paragraph).
include_server_schema!(.., contracts = "x.contracts.lock") accepts, per op, [current, ...locked, newest first]. The lock is a committed,
content-addressed JSON file (format, domain, contracts: canonical op contract by digest,
generations: client contract digest, date, note and op digests, oldest first) maintained by
cratestack contract lock|check|prune. It widens nothing a peer can influence: the accepted set is
fixed at build time from the file, the macro recomputes every stored contract’s digest, and the
selector header still only chooses among accepted digests. Admission is decided by
cratestack_core::classify(old, new) per op, at compile time (an incompatible entry is a compile
error naming the op and reason) and in contract check. It is security-critical and so refuses by
default: identical op identity, return type and kept attributes; additions only as optional (or
@default on a model op’s own model, the one place a create input omits the field; a type or a
model used as an argument decodes it as required, so there it is refused, and so is an added
FindMany<T>? argument); required to optional only on input-only declarations; enum variants appended
only to input-only enums; any added field on output-only declarations; every removal, retype,
insertion, reorder or attribute change refused. Removing a field is refused even though the server
would decode the message, because the signed value would be silently ignored. A round-trip test
encodes values of the old generated types and decodes them as the new ones (and the reverse for
replies) for each compatible rule (fixture-based, not random inputs), and pins that the refused
@default edit really fails to decode. tests/vectors/contract.json pins the canonical contract bytes,
digests and client contract digest of a fixture schema, for a future non-Rust sealer; the generated
TypeScript and Dart constants are checked against it too, which is parity (both generators call the
same Rust function), while the independent check is recomputing the digests from the canonical
strings with plain SHA-256.
5. Freshness and replay, offline-aware
BothHmacEnvelope (ENVELOPE_DEFAULT_CLOCK_SKEW_SECS = 300) and the signed-request verifier gate
freshness on a clock-skew window. A mutation signed in a dead zone and uploaded three hours later is
rejected. Signing at upload time instead loses when and where the data was created.
window is the anti-replay window of IPsec (RFC 4303 §3.4.3) and OSCORE (RFC 8613 §7.4):
- No time limit on validity: a request signed days ago is accepted if its counter is new.
- Out-of-order delivery within the window is tolerated, which covers parallel batch uploads.
- Server state is O(1) per device.
iatis still signed, for audit and for an optional per-op policy such as@signed(max_age: "30d").- The device persists the counter in the same write as the queued mutation. A reused counter reads as a replay (Q4). Reinstalling the app re-enrols with a fresh key.
- Multi-replica servers keep the per-kid window in Redis or PG with compare-and-set, using the backends the idempotency and rate-limit stores already use.
(kid, cti) when no Idempotency-Key
header is present. The first delivery executes. A byte-identical resend within the idempotency TTL
gets the stored response. A resend after the TTL is rejected by the window. The handler never runs
twice.
6. Streams: chain mode
Items stay bare CBOR, as in today’s application/cbor-seq. The server keeps a running hash and
periodically emits a signed checkpoint:
- Cadence: every 64 items, 32 KiB or 2 s, whichever comes first (Q3). Ops can override with
@stream(checkpoint: 16|64|256). - The terminal checkpoint is mandatory. It carries
end: true, or theRpcErrorBodyon failure. In signed mode it replaces the unsignedTag(48900, …)sentinel, so a forged error is detectable too. - Truncation is detectable. A body that ends without a terminal checkpoint is
Incomplete. Today a stream cut short by a middlebox that closes cleanly looks like a short, successful stream. - Reordering, dropping or injecting items breaks
hat the next checkpoint. - Detection:
RPC_STREAM_ERROR_TAGgets a siblingRPC_STREAM_CHECKPOINT_TAG = 48901. The RustCborSeqChunkDecoderand the TS scanner (packages/cratestack-ts-types/src/cbor-seq.ts) classify the leading tag. The tag wrapper keeps a checkpoint distinct from a legitimate item that is itself a COSE_Sign1.
Verifiedbuffers items until their checkpoint verifies. Added latency is at most one checkpoint interval, and memory is at most N items.Optimisticreleases items at once and raises a tamper error at the checkpoint. This fits the offline store: a SQLite transaction per checkpoint window, committed when it verifies and rolled back when it doesn’t.
cti = seq) is opt-in per op, for items that
must be verifiable on their own later.
Resumable streams (P3): a checkpoint may carry resume, an opaque server cursor. Because the
checkpoint is server-signed, the server can accept it back statelessly. The client sends
Cratestack-Resume: <base64url of the last verified checkpoint>, and the server verifies its own
signature and continues the chain from h_seq. A sync that dies at 80% resumes at 80%. This works
only for producers that can restart from a cursor (keyset-paginated reads), marked
@stream(resumable).
Subscriptions: SSE is text, so binary COSE would need base64 (+33%). Signed subscriptions get a
GET cbor-seq binding in chain mode with a time-based cadence, rather than signing over SSE.
7. Uploads: signing the offline queue
- When a mutation is created offline, the Rust runtime wraps it as a COSE_Sign1
RpcRequestframe withcti = next counterand persists those exact bytes. The queue is tamper-evident at rest, andiatrecords the device clock at creation. - On connectivity, the client flushes with
POST /rpc/batchandContent-Type: application/vnd.cratestack.cose-seq+cbor-seq. Each frame is independently signed. - The server verifies each frame on its own. Results return as a chain-signed cbor-seq keyed by
cti, so a partial failure fails only its frames. - After a dropped connection the client resends the unacknowledged frames. Anything already applied hits the §5 idempotency path.
BATCH_MAX_ITEMS (1 000) and DEFAULT_BODY_LIMIT_BYTES (2 MiB) apply unchanged. The client splits
large queues into several batches.
8. Key distribution
- Server signing keys are published as a COSE_KeySet at
/.well-known/cratestack/cose-keys, next to the existingjwks_router. The client pins at enrolment: the enrolment response is already a COSE_Sign1 (cose_enroll.rs) and gains the response-signing key thumbprint(s). - Device keys use the existing
DeviceKeyResolver, indexed additionally by thumbprint prefix. - Rotation: the keyset holds old and new keys, and the kid selects between them.
9. Schema-bound integer keys (separate codec, P3)
The payload saving (−49% raw, −16% compressed) is larger than anything signing costs, and it helps unsigned traffic too. It is orthogonal to COSE and ships asCborPackedCodec (D4). It needs stable
field ids, via explicit @wire(n) or ids locked in a committed lockfile, with renumbering a schema
error. The op contract digest in the AAD makes a mismatch fail closed when signed; unsigned, the existing
x-cratestack-schema-sha check is the guard.
10. Errors and threat model
- Verification failures return
401withRpcErrorBody{code: "unauthenticated"}and a coarse reason. The response must never reveal which check failed. A backend failure (the key resolver or the nonce store is unreachable) is a500, logged server-side: it says nothing about the message, and operators can tell an outage from an attack. - One refusal is unsigned and not a
401:426 contract_unsupported(binding version 2). It answers aCratestack-Contractselector that names no digest accepted for the op, before any key lookup, so it reveals only that this op’s wire shape is no longer served: public information, since the digests ship in every client binary. It is not one of the checks this section forbids telling apart (signature, key, freshness, replay, audience, AAD). Being unsigned it is a hint, never proof: the client surfacesEnvelopeError::ContractUnsupported { op }and must not treat the server’s contract as known. The client reads the body only for its code, so a proxy’s own426staysUnsigned. It deliberately carries noUpgradeheader: RFC 9110 §15.5.22 asks for one, butUpgradeis connection-specific (§7.8), forbidden on HTTP/2 (RFC 9113 §8.2.2) and stripped by proxies, and no protocol token names an op contract;426is kept because every other 4xx already means something else here (409idempotency and transactions,410heuristically cacheable,412conditional headers,400the malformed selector). - Error responses are signed too, or a hop could inject fake errors.
- A client in
Requiredmode rejects unsigned or wrongly signed responses. It never falls back to plain. - Why sign over TLS at all: TLS ends at the first terminating hop. Signed data stays verifiable at rest and when relayed between devices. Financial audit needs non-repudiation. Signed responses can be cached by untrusted edges.
- Existing mechanisms:
HmacEnvelopebecomes Mac0 mode: deprecated for one minor release, then removed.Authorization: Signaturestays for bodiless requests (GET). For writes in signed mode, COSE subsumes it (method and route are in the AAD) and saves about 300 B of headers.- The 48900 sentinel is unchanged in unsigned mode.
- The idempotency stream bypass still applies to signed streams.
11. One implementation, every client
One Rust crate,cratestack-cose, implements this ADR’s wire format itself, with a strict
parser. It uses coset only as an interop oracle in its tests and for the legacy enrolment
challenge, whose code moved into its auth feature (cratestack#1005). It sits at L2
with an optional auth feature (see “Decisions taken while scoping P0”): without the feature it
depends on cratestack-core only. It is consumed by:
cratestack-client-rust(native, plus the Flutter runtime via FRB), which lifts the “not implemented” guard;cratestack-cbor-wasmand-napi, which gainseal,openand a chunk-fedStreamOpener. The React Native story in cratestack#893 plans a Rust-backed codec on device and inherits this.
cratestack_cbor
exposes cratestack-cose over flutter_rust_bridge natively and the cratestack-cbor-wasm build on
the web, as package:cratestack_cbor/cose.dart. That release seals with in-memory keys (HMAC and
Ed25519) in Required mode only. Signing with a key in a platform keystore, and the chain mode
streams need, are not part of it. See the guide’s
The Dart client.
12. Placement relative to the existing layers
This section was added while reviewing the proposal against the codebase. It is what “auth headers become optional” requires.- The envelope must run before rate limiting and idempotency. Both layers derive their caller key
from the
Authorizationheader by default: they hash it, fall back to theConnectInfopeer, and refuse with412when neither exists (cratestack-axum/src/{ratelimit/key_fn.rs,idempotency/}). A COSE-only client sends noAuthorizationheader, so it would be bucketed by IP address or refused. The envelope’sopentherefore runs first and inserts the verified kid as aVerifiedPrincipalrequest extension. Rate limiting already treats that as a non-caller-mintableprinc:bucket that needs no cardinality budget (cratestack#871), which closes the “unverified header mints buckets” class for signed traffic. The idempotency principal fingerprint needs the same hook. This is P0, not a later phase: without it, the P0 wiring breaks existing throttling and replay behaviour for COSE clients. - The
(kid, cti)idempotency key belongs in the L3OpExecutor(ADR 0015). Its input is not an HTTP request, which is exactly the property that motivated building L3. Idempotency admission (slice 1) and rate-limit admission (slice 2, cratestack#877) already decide there. The in-process path from ADR 0018 then gets the same replay semantics without a second implementation.
Consequences
Positive- One signing mechanism replaces
HmacEnvelopeand, for writes,Authorization: Signature, at a quarter to a third of their byte cost. - Signed streams cost single-digit percent over unsigned, and truncation becomes detectable, which it is not today.
- Offline mutations stay valid for upload indefinitely without a replay hole.
- Breaking change to
CratestackEnvelope, and toRuntimeEnvelopeConfigconsumers once the placeholder becomes real. - New server state per device key (the window), with compare-and-set on multi-replica deployments.
- Clients that bypass the Rust runtime cannot use signed mode.
Alternatives considered
- COSE as a
CratestackCodecimplementation. Rejected (§1): no key, no request context, noasync, and it would touch every codec bound in the workspace. - Per-item signing as the stream default. Rejected on the measurements: ×4.25 after compression. It remains available per op.
- Timestamp skew plus a nonce store for device keys. Rejected for devices (§5): it cannot accept an offline mutation older than the skew window. It stays the mode for service keys.
- Mac0 for device traffic. Rejected (§3): a symmetric key lets the server forge device messages.
Phasing
Tracked by the epic cratestack#1030 (one story per phase). The unsigned streaming it builds on is cratestack#1029 (cbor-seq parity).Test plan (acceptance)
- Vectors: identical bytes from Rust native, wasm and napi for the same key and payload. Deterministic Ed25519 makes this exact.
- Tampering: flip one bit in each of the payload, the protected header, the AAD route, the AAD
contract digest and the request digest → reject. A request bound under another op’s digest
(
neg-contract-cross-op) and one signed over a version 1 AAD (neg-binding-v1) → reject. Assert on encoded bytes, not decoded values. - Contract lock: a locked, compatible older digest opens and its response seals under it; an incompatible lock entry is a compile error naming the op and reason. Candidates that differ in anything but the contract digest, and an empty candidate list, are refused before any key lookup.
- Streams: truncate before the terminal checkpoint →
Incomplete. Reorder, drop or insert an item → reject at the next checkpoint. A forged error item → reject. A legitimate COSE_Sign1 item → not treated as a checkpoint. - Replay: resend the same bytes → idempotent response, handler not re-run. Counter below the window → reject. Out of order within the window → accept. Counter reuse → reject.
- Offline: mutation signed at T, uploaded at T+72 h → accepted in
windowmode, rejected innoncemode. - Layer placement (§12): a COSE-only request with no
Authorizationheader and noConnectInfo→ not refused with412, and charged to itsprinc:bucket. - Fuzzing:
CborSeqChunkDecoderover arbitrary bytes containing tags 18, 48900 and 48901.