cratestack_cose/aad.rs
1//! External AAD: the request context a message is bound to, encoded, never
2//! sent (ADR 0006 §4, as amended while scoping P0, by the maintainer's
3//! decisions on cratestack#1005: `audience` on 2026-09-24, `request_kind`
4//! on 2026-09-25, by decision S1 after the cratestack#1006 security
5//! review: `bound_headers` on 2026-09-26, and by the maintainer's decision
6//! of 2026-09-30 on cratestack#1123: binding version 2, `contract_sha`).
7//!
8//! ```cddl
9//! external_aad = bstr .cbor [
10//! 2, ; binding version
11//! audience: tstr, ; the recipient's configured id, non-empty
12//! method: tstr,
13//! route: tstr, ; RPC op_id; REST route template
14//! path_params: [* tstr], ; REST: matched values in template order; RPC: []
15//! query: tstr / null,
16//! contract_sha: bstr .size 32, ; the called op's contract digest
17//! payload_type: tstr, ; request: the request payload's type;
18//! ; response: the response payload's own type
19//! bound_headers: [ ; request headers with semantics,
20//! idempotency_key: tstr / null, ; exactly as sent; null if absent
21//! if_match: tstr / null,
22//! ],
23//! ? request_kind: uint, ; responses: 0 unsigned request, 1 signed
24//! ? request_digest: bstr .size 32, ; responses, see request_digest*
25//! ? status: uint, ; responses
26//! ]
27//! ```
28//!
29//! **Binding version 2** (cratestack#1123, EXT-14) replaced version 1's
30//! element 7, `schema_sha` (`cratestack_core::schema_digest`, the whole
31//! IR), with `contract_sha`: `cratestack_core::op_contract_digest`, the
32//! digest of the wire closure of the op being called. Every other element
33//! keeps its position and meaning, so the array lengths (9 for a request,
34//! 12 for a response) are unchanged; the version number is the domain
35//! separation, because the same 32 bytes under version 1 and version 2
36//! never produce the same AAD. A version 2 verifier does not accept a
37//! version 1 message (it would need a second trial verification on every
38//! failure, and a downgrade path kept open).
39//!
40//! The element is the digest the *sender* used, and the verifier may accept
41//! several for one op, so the sender names it in the unbound
42//! `Cratestack-Contract` header (see `cratestack_core::ContractSelector`);
43//! it selects, it never widens: the AAD carries all 32 bytes.
44//!
45//! `audience` sits right after the version so that every other field keeps
46//! its relative position; `bound_headers` is one fixed-length array, so a
47//! request binding is 9 elements and a response binding 12.
48//!
49//! `bound_headers` exists because an on-path party could otherwise strip
50//! or swap those headers on a signed request without breaking its
51//! signature: dropping `Idempotency-Key` from a re-sealed retry runs the
52//! operation twice, and dropping `If-Match` turns a conditional update into
53//! a blind one. Their values are bound as the header carried them, with no
54//! normalisation (see `cratestack_core::BoundHeaders`).
55//!
56//! Three rules the CDDL leaves open are pinned here, because the client and
57//! the server each rebuild this array from their own context and any
58//! disagreement is a `401` on every request:
59//!
60//! - **`query` is `null` when there is no query *or* it is empty.** A
61//! router that sees `/x?` and a client that built `/x` must agree, and
62//! the `?` alone carries nothing to bind.
63//! - **The three response elements travel together**: 12 elements for a
64//! response, 9 for a request. `Binding` makes anything else
65//! unrepresentable (its response half is one `Option`).
66//! - **An empty `audience` is refused** with a `500`, not encoded: it binds
67//! no recipient, so it would silently give up the cross-service and
68//! reflection protection the element exists for.
69//!
70//! Nothing is normalised beyond that: `method`, `route`, the path
71//! parameter values and the bound header values are bound exactly as given
72//! (an empty header value is bound as `""`, not as `null`). The array encoding keeps
73//! the fields apart, so `["a", "b"]` and `["ab"]` bind differently.
74
75use cratestack_core::{Binding, CratestackError};
76
77use crate::cbor::write::{self, MAJOR_ARRAY, MAJOR_UINT, NULL};
78use crate::error::misuse;
79
80/// The binding version, the first array element. Q5's escape hatch: a
81/// future binding scheme gets a new number instead of a new wire format.
82///
83/// Version 1 froze in 0.15.0, the first release with both wire peers: the
84/// server layer (cratestack#1006) and the Rust client (cratestack#1007).
85/// Version 2 (cratestack#1123) binds the per-op `contract_sha` where version
86/// 1 bound the whole-IR `schema_sha`. From 0.15.0 on, any change to the
87/// elements or to how one is derived bumps this number, and verifiers
88/// reject versions they do not accept: a version 2 verifier accepts only 2
89/// (ADR 0006 §4).
90pub const BINDING_VERSION: u64 = 2;
91
92/// Encode the external AAD for `bind`: the bytes that go into the
93/// `Sig_structure` / `MAC_structure` as `external_aad`. Fails with
94/// `CratestackError::Internal` (misuse) for an empty `audience`.
95///
96/// Public so other language bindings and the shared vectors can check
97/// their own encoding against this one.
98pub fn external_aad(bind: &Binding<'_>) -> Result<Vec<u8>, CratestackError> {
99 if bind.audience.is_empty() {
100 return Err(misuse(
101 "an empty audience binds no recipient; configure the service's logical id",
102 ));
103 }
104 let query = bind.query.as_deref().filter(|query| !query.is_empty());
105 let headers = &bind.bound_headers;
106 let bound = [&headers.idempotency_key, &headers.if_match];
107 // 3 more than before `bound_headers`: its array head and two `null`s.
108 let mut out = Vec::with_capacity(
109 99 + bind.audience.len()
110 + bind.route.len()
111 + bind.method.len()
112 + bound
113 .iter()
114 .map(|value| value.as_deref().map_or(0, str::len))
115 .sum::<usize>(),
116 );
117 let elements = if bind.response.is_some() { 12 } else { 9 };
118 write::head(&mut out, MAJOR_ARRAY, elements);
119 write::head(&mut out, MAJOR_UINT, BINDING_VERSION);
120 write::tstr(&mut out, &bind.audience);
121 write::tstr(&mut out, &bind.method);
122 write::tstr(&mut out, &bind.route);
123 write::head(
124 &mut out,
125 MAJOR_ARRAY,
126 write::len_arg(bind.path_params.len()),
127 );
128 for value in bind.path_params.iter() {
129 write::tstr(&mut out, value);
130 }
131 match query {
132 Some(query) => write::tstr(&mut out, query),
133 None => out.push(NULL),
134 }
135 write::bstr(&mut out, &bind.contract_sha);
136 write::tstr(&mut out, &bind.payload_media_type);
137 write::head(&mut out, MAJOR_ARRAY, 2);
138 for value in bound {
139 match value {
140 Some(value) => write::tstr(&mut out, value),
141 None => out.push(NULL),
142 }
143 }
144 if let Some(response) = &bind.response {
145 write::head(
146 &mut out,
147 MAJOR_UINT,
148 u64::from(response.request.kind.code()),
149 );
150 write::bstr(&mut out, &response.request.digest);
151 write::head(&mut out, MAJOR_UINT, u64::from(response.status));
152 }
153 Ok(out)
154}