1pub mod reference;
25
26pub const WIRE_VERSION: u32 = 1;
28
29pub const ACCEPTED_WIRE_VERSIONS: &[u32] = &[1];
32
33pub const CALL_PATH_PREFIX: &str = "/_bynk/call/";
35
36pub const CALL_METHOD: &str = "POST";
38
39pub const CONTRACT_HEADER: &str = "X-Bynk-Contract";
41
42pub const CALLER_HEADER: &str = "X-Bynk-Caller";
44
45pub const WIRE_HEADER: &str = "X-Bynk-Wire";
47
48pub const TIMESTAMP_HEADER: &str = "X-Bynk-Timestamp";
51
52pub const SIGNATURE_HEADER: &str = "X-Bynk-Signature";
55
56pub const SIGNATURE_WINDOW_SECONDS: u64 = 300;
61
62pub const MAX_BODY_BYTES: usize = 1_048_576;
66
67pub const SIGNING_SCHEME: &str = "BYNK-CALL-1";
70
71pub const EDGE_FROM_PREFIX: &str = "BYNK_CALL_FROM_";
74
75pub const EDGE_TO_PREFIX: &str = "BYNK_CALL_TO_";
78
79pub const MIGRATE_PATH_PREFIX: &str = "/_bynk/migrate/";
83
84pub const MIGRATE_CALLER: &str = "bynk.migrate";
87
88pub const MIGRATE_KEY: &str = "BYNK_MIGRATE_KEY";
91
92pub 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
110pub fn edge_suffix(context: &str) -> Option<String> {
114 is_edge_name(context).then(|| context.to_ascii_uppercase().replace('.', "__"))
115}
116
117pub fn edge_key_from(caller: &str) -> Option<String> {
121 edge_suffix(caller).map(|s| format!("{EDGE_FROM_PREFIX}{s}"))
122}
123
124pub fn edge_key_to(callee: &str) -> Option<String> {
128 edge_suffix(callee).map(|s| format!("{EDGE_TO_PREFIX}{s}"))
129}
130
131#[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
174pub const MALFORMED_JSON_DETAILS: &str = "Invalid request body";
176
177pub struct Header {
179 pub name: &'static str,
180 pub required: &'static str,
181 pub meaning: &'static str,
182}
183
184pub 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#[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
232pub 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 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 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 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 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 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 assert_ne!(edge_suffix("a.b"), edge_suffix("a_b"));
371 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 #[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}