Skip to main content

bynk_check/
wire_format.rs

1//! #1969: the cross-context wire format, version 1, as a published surface.
2//!
3//! The interop track's D1 (`design/archive/retired-tracks.md`, interop track) makes the format a
4//! caller outside the project can speak a contract with its own version,
5//! apart from the compiler's: the path, method and headers of a call; the
6//! order of the callee's checks, with their status codes and error bodies; the
7//! JSON encoding of each boundary type; and the contract hash.
8//!
9//! This module is the single source for the parts that are constants. The
10//! emitter builds the `/_bynk/call/` dispatch from [`CallCheck`] and the header
11//! names below, and [`mod@reference`] renders `reference/wire-format.md` from the
12//! same values plus [`crate::wire`]'s encoding model and [`crate::contract`]'s
13//! hash. `bynkc/tests/wire_reference.rs` holds the page, the emitted dispatch,
14//! the runtime's `callService` and the generated codecs to it.
15//!
16//! **Stability (version 1).** Additive only: a new status code, a new error
17//! `kind` or a new optional header may be added within the version. Changing an
18//! existing encoding, check, status or the hash's normal form is a new wire
19//! version, which a callee accepts beside the old one for an overlap window
20//! ([`ACCEPTED_WIRE_VERSIONS`]). That includes the request signature
21//! ([`signing_input`]): its canonical input and its headers are part of the
22//! version, the keys are not.
23
24pub mod reference;
25
26/// The wire version this compiler's callers speak.
27pub const WIRE_VERSION: u32 = 1;
28
29/// The wire versions this compiler's callees accept. A request without
30/// [`WIRE_HEADER`] is version 1.
31pub const ACCEPTED_WIRE_VERSIONS: &[u32] = &[1];
32
33/// The path prefix a call is addressed to; the service name follows it.
34pub const CALL_PATH_PREFIX: &str = "/_bynk/call/";
35
36/// The only method a call may use.
37pub const CALL_METHOD: &str = "POST";
38
39/// The caller's compiled hash of the callee's contract.
40pub const CONTRACT_HEADER: &str = "X-Bynk-Contract";
41
42/// The calling context's qualified name.
43pub const CALLER_HEADER: &str = "X-Bynk-Caller";
44
45/// The wire version the caller speaks; absent means 1.
46pub const WIRE_HEADER: &str = "X-Bynk-Wire";
47
48/// I2 (#1971): when the caller signed the request, in whole seconds since the
49/// Unix epoch, as a decimal integer.
50pub const TIMESTAMP_HEADER: &str = "X-Bynk-Timestamp";
51
52/// I2 (#1971): the request's signature, HMAC-SHA256 over [`signing_input`]
53/// under the edge's key, as 64 lowercase hex digits.
54pub const SIGNATURE_HEADER: &str = "X-Bynk-Signature";
55
56/// I2 (#1971): how far, in seconds, a request's timestamp may be from the
57/// callee's clock, either way. A requirement, not a tuning knob (ADR
58/// `per-edge-call-signature`): it bounds how long a captured request can be
59/// replayed, and nothing else does.
60pub const SIGNATURE_WINDOW_SECONDS: u64 = 300;
61
62/// I2 (#1971): the largest body a callee reads, in bytes. The body is read
63/// under this cap before it is hashed, so a sender without a key cannot make
64/// the callee buffer and hash an unbounded one.
65pub const MAX_BODY_BYTES: usize = 1_048_576;
66
67/// I2 (#1971): the first line of [`signing_input`], naming the scheme, so a
68/// signature over this input can never verify as one over another format's.
69pub const SIGNING_SCHEME: &str = "BYNK-CALL-1";
70
71/// I2 (#1971): the secret a callee holds for the edge from one caller,
72/// [`edge_key_from`].
73pub const EDGE_FROM_PREFIX: &str = "BYNK_CALL_FROM_";
74
75/// I2 (#1971): the secret a caller holds for the edge to one callee,
76/// [`edge_key_to`].
77pub const EDGE_TO_PREFIX: &str = "BYNK_CALL_TO_";
78
79/// State-migrations S4 (#2259 Decision D): the prefix of `bynk migrate
80/// sweep`'s wake route, `/_bynk/migrate/<Class>`. Not part of the published
81/// call wire format: only the toolchain calls it.
82pub const MIGRATE_PATH_PREFIX: &str = "/_bynk/migrate/";
83
84/// State-migrations S4 (#2259): the caller name a sweep signs as. The route
85/// admits no other caller, so a context's edge key cannot wake instances.
86pub const MIGRATE_CALLER: &str = "bynk.migrate";
87
88/// State-migrations S4 (#2259): the secret a sweep's key is set under for the
89/// sweep's duration. Without it the route answers `404`.
90pub const MIGRATE_KEY: &str = "BYNK_MIGRATE_KEY";
91
92/// I2 (#1971): whether `context` is a name an edge key can be derived from:
93/// dot-separated segments, each a lowercase letter, then lowercase letters
94/// and digits, with single underscores between runs of them (`shop.orders`,
95/// `a_b.c2`). On that domain [`edge_suffix`] is injective, which is what
96/// keeps one caller's key from verifying another's name.
97pub fn is_edge_name(context: &str) -> bool {
98    !context.is_empty()
99        && context.split('.').all(|segment| {
100            let bytes = segment.as_bytes();
101            bytes.first().is_some_and(u8::is_ascii_lowercase)
102                && bytes.last() != Some(&b'_')
103                && !segment.contains("__")
104                && bytes
105                    .iter()
106                    .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'_')
107        })
108}
109
110/// I2 (#1971): the part of an edge key's name that names the context at its
111/// other end: upper case, a dot written `__` (`shop.orders` → `SHOP__ORDERS`).
112/// `None` for a name outside [`is_edge_name`]'s domain.
113pub fn edge_suffix(context: &str) -> Option<String> {
114    is_edge_name(context).then(|| context.to_ascii_uppercase().replace('.', "__"))
115}
116
117/// I2 (#1971): the secret the **callee** holds for calls from `caller`
118/// (`BYNK_CALL_FROM_SHOP__ORDERS`). The callee picks it by the request's
119/// [`CALLER_HEADER`], so an undeclared caller finds no key and is refused.
120pub fn edge_key_from(caller: &str) -> Option<String> {
121    edge_suffix(caller).map(|s| format!("{EDGE_FROM_PREFIX}{s}"))
122}
123
124/// I2 (#1971): the secret the **caller** holds for calls to `callee`
125/// (`BYNK_CALL_TO_SHOP__PAYMENT`). Same value as the callee's
126/// [`edge_key_from`] for the same edge.
127pub fn edge_key_to(callee: &str) -> Option<String> {
128    edge_suffix(callee).map(|s| format!("{EDGE_TO_PREFIX}{s}"))
129}
130
131/// I2 (#1971): the bytes a request's signature is computed over. One field
132/// per line, `\n`-separated, no trailing newline:
133///
134/// 1. [`SIGNING_SCHEME`];
135/// 2. the caller, as sent in [`CALLER_HEADER`];
136/// 3. the callee's qualified context name, so a signature verifies at one
137///    callee only;
138/// 4. the method;
139/// 5. the URL's pathname exactly as sent, with no query string and no
140///    percent-normalisation;
141/// 6. [`CONTRACT_HEADER`] as sent, empty when absent;
142/// 7. [`WIRE_HEADER`] as sent, `1` when absent, so the version a callee
143///    decodes under is the one that was signed;
144/// 8. [`TIMESTAMP_HEADER`] as sent;
145/// 9. the SHA-256 of the body's bytes, 64 lowercase hex digits.
146///
147/// Every header the callee acts on is in it. Header values cannot hold a
148/// newline, so no field can forge another's line.
149#[allow(clippy::too_many_arguments)]
150pub fn signing_input(
151    caller: &str,
152    callee: &str,
153    method: &str,
154    path: &str,
155    contract: &str,
156    wire: &str,
157    timestamp: &str,
158    body_sha256_hex: &str,
159) -> String {
160    [
161        SIGNING_SCHEME,
162        caller,
163        callee,
164        method,
165        path,
166        contract,
167        wire,
168        timestamp,
169        body_sha256_hex,
170    ]
171    .join("\n")
172}
173
174/// The `details` of a `MalformedJson` refusal. The HTTP routes use the same.
175pub const MALFORMED_JSON_DETAILS: &str = "Invalid request body";
176
177/// One header of a call request, as the reference documents it.
178pub struct Header {
179    pub name: &'static str,
180    pub required: &'static str,
181    pub meaning: &'static str,
182}
183
184/// Every header a call carries, in the order the reference lists them.
185pub const HEADERS: &[Header] = &[
186    Header {
187        name: CONTRACT_HEADER,
188        required: "yes",
189        meaning: "The caller's hash of the service's contract, 16 lowercase hex digits. Must equal the callee's; a missing header does not.",
190    },
191    Header {
192        name: CALLER_HEADER,
193        required: "yes",
194        meaning: "The calling context's qualified name (`shop.orders`), which the handler reads as `c.identity`, or a foreign caller's actor name (`Legacy`). Selects the key the signature is checked under, so it is proved, not claimed.",
195    },
196    Header {
197        name: TIMESTAMP_HEADER,
198        required: "yes",
199        meaning: "When the caller signed the request, in whole seconds since the Unix epoch.",
200    },
201    Header {
202        name: SIGNATURE_HEADER,
203        required: "yes",
204        meaning: "HMAC-SHA256 of the signing input under the edge's key, 64 lowercase hex digits. See *Authentication* below.",
205    },
206    Header {
207        name: WIRE_HEADER,
208        required: "no",
209        meaning: "The wire version the caller speaks, exactly the decimal digits of an accepted version (`1`, not `01` or `+1`). Absent means `1`.",
210    },
211    Header {
212        name: "content-type",
213        required: "no",
214        meaning: "Bynk callers send `application/json`. The callee does not check it.",
215    },
216];
217
218/// One check the callee makes on a call, in the published order.
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
220pub enum CallCheck {
221    Method,
222    BodyTooLarge,
223    Authentication,
224    WireVersion,
225    UnknownService,
226    NotAdmitted,
227    Contract,
228    MalformedJson,
229    Arguments,
230}
231
232/// The callee's checks, in the order it makes them. The first that fails
233/// answers the request.
234///
235/// I2 (#1971): a signature is checked under the key the caller header
236/// selects, so a call without a caller fails [`CallCheck::Authentication`];
237/// there is no separate missing-caller check after it.
238pub const CALL_CHECKS: [CallCheck; 9] = [
239    CallCheck::Method,
240    CallCheck::BodyTooLarge,
241    CallCheck::Authentication,
242    CallCheck::WireVersion,
243    CallCheck::UnknownService,
244    CallCheck::NotAdmitted,
245    CallCheck::Contract,
246    CallCheck::MalformedJson,
247    CallCheck::Arguments,
248];
249
250impl CallCheck {
251    /// The status a failure of this check answers with.
252    pub fn status(self) -> u16 {
253        match self {
254            CallCheck::Method => 405,
255            CallCheck::BodyTooLarge => 413,
256            CallCheck::Authentication => 401,
257            CallCheck::WireVersion => 400,
258            CallCheck::UnknownService => 404,
259            CallCheck::NotAdmitted => 403,
260            CallCheck::Contract => 409,
261            CallCheck::MalformedJson => 400,
262            CallCheck::Arguments => 400,
263        }
264    }
265
266    /// The `kind` of the JSON error body, for a check that answers with one.
267    /// `Arguments` answers with the codec's own `StructuralMismatch` or
268    /// `RefinementViolation`, so it has no single kind.
269    pub fn kind(self) -> Option<&'static str> {
270        match self {
271            CallCheck::WireVersion => Some("UnsupportedWireVersion"),
272            CallCheck::Contract => Some("ContractMismatch"),
273            CallCheck::MalformedJson => Some("MalformedJson"),
274            CallCheck::Method
275            | CallCheck::BodyTooLarge
276            | CallCheck::Authentication
277            | CallCheck::UnknownService
278            | CallCheck::NotAdmitted
279            | CallCheck::Arguments => None,
280        }
281    }
282
283    /// The plain-text body of a check that answers with one.
284    pub fn text_body(self) -> Option<&'static str> {
285        match self {
286            CallCheck::BodyTooLarge => Some("Payload Too Large"),
287            CallCheck::Authentication => Some("Unauthorized"),
288            CallCheck::UnknownService => Some("Not found"),
289            CallCheck::NotAdmitted => Some("Forbidden"),
290            _ => None,
291        }
292    }
293
294    /// What the check tests, for the reference.
295    pub fn condition(self) -> &'static str {
296        match self {
297            CallCheck::Method => "The method is `POST`.",
298            CallCheck::BodyTooLarge => {
299                "The body is at most 1 MiB (1048576 bytes). Read under the cap, before anything is hashed."
300            }
301            CallCheck::Authentication => {
302                "`X-Bynk-Signature` verifies, under the key the callee holds for the edge from `X-Bynk-Caller`, over the signing input, and `X-Bynk-Timestamp` is within 300 seconds of the callee's clock. Compared in constant time."
303            }
304            CallCheck::WireVersion => {
305                "`X-Bynk-Wire` is absent or names a version the callee accepts."
306            }
307            CallCheck::UnknownService => {
308                "The path names a service of the callee's context with an `on call` handler."
309            }
310            CallCheck::NotAdmitted => {
311                "The service's handler admits the caller the signature proved: a Bynk context by `Caller` (the default), a foreign caller only by its own `Foreign` actor."
312            }
313            CallCheck::Contract => "`X-Bynk-Contract` equals the callee's hash of that service.",
314            CallCheck::MalformedJson => "The body parses as JSON.",
315            CallCheck::Arguments => {
316                "The body decodes as the handler's parameters (see *Encoding*)."
317            }
318        }
319    }
320
321    /// The response a failure gives, for the reference.
322    pub fn response(self) -> String {
323        let text = match self {
324            CallCheck::Method => "empty body, `Allow: POST`",
325            CallCheck::BodyTooLarge => "text `Payload Too Large`",
326            CallCheck::Authentication => "text `Unauthorized`",
327            CallCheck::WireVersion => {
328                let accepted: Vec<String> =
329                    ACCEPTED_WIRE_VERSIONS.iter().map(u32::to_string).collect();
330                return format!(
331                    "`{{\"kind\":\"UnsupportedWireVersion\",\"accepted\":[{}]}}`",
332                    accepted.join(",")
333                );
334            }
335            CallCheck::UnknownService => "text `Not found`",
336            CallCheck::NotAdmitted => "text `Forbidden`",
337            CallCheck::Contract => {
338                r#"`{"kind":"ContractMismatch","service":"…","expected":"…","actual":"…"}`"#
339            }
340            CallCheck::MalformedJson => {
341                r#"`{"kind":"MalformedJson","details":"Invalid request body"}`"#
342            }
343            CallCheck::Arguments => "a `StructuralMismatch` or `RefinementViolation` body",
344        };
345        text.to_string()
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn edge_keys_name_the_context_at_the_other_end() {
355        assert_eq!(
356            edge_key_from("shop.orders").as_deref(),
357            Some("BYNK_CALL_FROM_SHOP__ORDERS")
358        );
359        assert_eq!(
360            edge_key_to("shop.payment").as_deref(),
361            Some("BYNK_CALL_TO_SHOP__PAYMENT")
362        );
363        assert_eq!(edge_suffix("a_b.c2").as_deref(), Some("A_B__C2"));
364    }
365
366    #[test]
367    fn edge_suffixes_never_collide_on_their_domain() {
368        // A dot and an underscore stay apart, which a plain
369        // `replace('.', "_")` would merge.
370        assert_ne!(edge_suffix("a.b"), edge_suffix("a_b"));
371        // Names that would make the encoding ambiguous are outside it.
372        for bad in [
373            "", "a..b", ".a", "a.", "a__b", "_a", "a_", "a._b", "A.b", "a-b", "1a", "a b",
374        ] {
375            assert!(!is_edge_name(bad), "{bad:?} is not an edge name");
376            assert_eq!(edge_key_from(bad), None);
377        }
378        for good in ["integration", "shop.orders", "a_b.c2", "x1.y_2_z"] {
379            assert!(is_edge_name(good), "{good:?} is an edge name");
380        }
381    }
382
383    /// I3 (#1971): the invariant that keeps foreign callers and contexts apart
384    /// on the wire. A foreign actor's name starts with a capital letter
385    /// (`bynk.actor.foreign_name_is_context_name`), and no name that does is
386    /// an edge name, so no foreign caller's name selects an edge key. The
387    /// runtime's `edgeSuffix` pins the same property.
388    #[test]
389    fn no_capitalised_name_is_an_edge_name() {
390        for name in ["Legacy", "L", "Legacy.orders", "LEGACY", "Ledger_2"] {
391            assert!(!is_edge_name(name), "{name:?}");
392            assert_eq!(edge_key_from(name), None, "{name:?}");
393        }
394    }
395
396    #[test]
397    fn the_signing_input_is_one_field_per_line() {
398        assert_eq!(
399            signing_input(
400                "shop.orders",
401                "shop.payment",
402                "POST",
403                "/_bynk/call/authorise",
404                "0123456789abcdef",
405                "1",
406                "1700000000",
407                "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
408            ),
409            "BYNK-CALL-1\nshop.orders\nshop.payment\nPOST\n/_bynk/call/authorise\n0123456789abcdef\n1\n1700000000\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
410        );
411    }
412
413    #[test]
414    fn the_checks_state_the_published_limits() {
415        assert!(
416            CallCheck::BodyTooLarge
417                .condition()
418                .contains(&MAX_BODY_BYTES.to_string())
419        );
420        assert!(
421            CallCheck::Authentication
422                .condition()
423                .contains(&format!("{SIGNATURE_WINDOW_SECONDS} seconds"))
424        );
425    }
426}