Skip to main content

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}