Skip to main content

basil_proto/
invocation.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Sealed-invocation plaintext body schemas and the basil content-type
6//! registry.
7//!
8//! Envelope canonicalization moved to the `basil-cose` strict COSE profile;
9//! this module owns what stays basil-specific: the registered COSE content
10//! types (RFC 9052 protected header 3) that select a plaintext body schema,
11//! the deterministic CBOR body codecs, and [`InvocationStatus`].
12
13use std::fmt;
14
15/// The only supported sealed-invocation envelope version.
16pub const SECURE_MESSAGE_VERSION: u32 = 1;
17/// Default signed request TTL when `expires_at_unix` is absent.
18pub const DEFAULT_EXPIRES_AFTER_SECS: u32 = 60;
19
20/// COSE content type for [`SignInvocationRequest`] plaintext bodies.
21///
22/// Registry values are media-type-shaped (`type/subtype`) because the
23/// `basil-cose` profile enforces RFC 9052 tstr content-type semantics; bare
24/// names such as `basil.sign-request` are rejected by its strict decoder.
25pub const CONTENT_TYPE_SIGN_REQUEST: &str = "application/basil.sign-request";
26/// COSE content type for [`SignInvocationResponse`] plaintext bodies.
27pub const CONTENT_TYPE_SIGN_RESPONSE: &str = "application/basil.sign-response";
28/// COSE content type for [`MintJwtInvocationRequest`] plaintext bodies.
29///
30/// Reserved registry entry: the broker executes only sealed `Sign`
31/// invocations and rejects this content type with `UNSUPPORTED_CONTENT_TYPE`.
32pub const CONTENT_TYPE_MINT_JWT_REQUEST: &str = "application/basil.mint-jwt-request";
33/// COSE content type for [`MintJwtInvocationResponse`] plaintext bodies.
34///
35/// Reserved registry entry: see [`CONTENT_TYPE_MINT_JWT_REQUEST`].
36pub const CONTENT_TYPE_MINT_JWT_RESPONSE: &str = "application/basil.mint-jwt-response";
37/// COSE content type for [`MintNatsUserInvocationRequest`] plaintext bodies.
38///
39/// Reserved registry entry: the broker executes only sealed `Sign`
40/// invocations and rejects this content type with `UNSUPPORTED_CONTENT_TYPE`.
41pub const CONTENT_TYPE_MINT_NATS_USER_REQUEST: &str = "application/basil.mint-nats-user-request";
42/// COSE content type for [`MintNatsUserInvocationResponse`] plaintext bodies.
43///
44/// Reserved registry entry: see [`CONTENT_TYPE_MINT_NATS_USER_REQUEST`].
45pub const CONTENT_TYPE_MINT_NATS_USER_RESPONSE: &str = "application/basil.mint-nats-user-response";
46
47/// Every registered basil invocation content type, request/response pairs in
48/// registry order. Only the sign pair is executable today; the mint pairs are
49/// reserved wire-format contracts pinned by fixtures.
50pub const INVOCATION_CONTENT_TYPES: [&str; 6] = [
51    CONTENT_TYPE_SIGN_REQUEST,
52    CONTENT_TYPE_SIGN_RESPONSE,
53    CONTENT_TYPE_MINT_JWT_REQUEST,
54    CONTENT_TYPE_MINT_JWT_RESPONSE,
55    CONTENT_TYPE_MINT_NATS_USER_REQUEST,
56    CONTENT_TYPE_MINT_NATS_USER_RESPONSE,
57];
58
59/// Errors returned while validating or canonicalizing sealed invocation data.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum InvocationError {
62    /// A required header field was absent or empty.
63    MissingField(&'static str),
64    /// A CBOR plaintext body did not match its declared schema.
65    InvalidBody(&'static str),
66}
67
68impl fmt::Display for InvocationError {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        match self {
71            Self::MissingField(field) => write!(f, "missing required header field `{field}`"),
72            Self::InvalidBody(reason) => write!(f, "invalid invocation body: {reason}"),
73        }
74    }
75}
76
77impl std::error::Error for InvocationError {}
78
79/// Status code carried inside encrypted invocation response bodies.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum InvocationStatusCode {
82    /// Operation succeeded.
83    Ok = 1,
84    /// Policy denied the operation.
85    Denied = 2,
86    /// The request body or operation fields were invalid.
87    InvalidRequest = 3,
88    /// The broker hit an internal operation error.
89    InternalError = 4,
90}
91
92impl InvocationStatusCode {
93    /// Convert a CBOR integer status code into a typed value.
94    #[must_use]
95    pub const fn from_u64(value: u64) -> Option<Self> {
96        match value {
97            1 => Some(Self::Ok),
98            2 => Some(Self::Denied),
99            3 => Some(Self::InvalidRequest),
100            4 => Some(Self::InternalError),
101            _ => None,
102        }
103    }
104}
105
106/// Status carried inside encrypted invocation response bodies.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct InvocationStatus {
109    /// Machine-readable status code.
110    pub code: InvocationStatusCode,
111    /// Stable sanitized reason token.
112    pub reason: String,
113    /// Optional sanitized diagnostic.
114    pub message: Option<String>,
115    /// Whether the caller may retry unchanged.
116    pub retryable: bool,
117}
118
119impl InvocationStatus {
120    /// Successful operation status.
121    #[must_use]
122    pub fn ok() -> Self {
123        Self {
124            code: InvocationStatusCode::Ok,
125            reason: "OK".to_string(),
126            message: None,
127            retryable: false,
128        }
129    }
130
131    /// Policy-denied operation status.
132    #[must_use]
133    pub fn denied() -> Self {
134        Self {
135            code: InvocationStatusCode::Denied,
136            reason: "UNAUTHORIZED".to_string(),
137            message: Some("not authorized".to_string()),
138            retryable: false,
139        }
140    }
141
142    /// Invalid-request operation status.
143    #[must_use]
144    pub fn invalid_request(reason: impl Into<String>) -> Self {
145        Self {
146            code: InvocationStatusCode::InvalidRequest,
147            reason: reason.into(),
148            message: None,
149            retryable: false,
150        }
151    }
152
153    /// Internal-error operation status.
154    #[must_use]
155    pub fn internal_error() -> Self {
156        Self {
157            code: InvocationStatusCode::InternalError,
158            reason: "INTERNAL_ERROR".to_string(),
159            message: None,
160            retryable: true,
161        }
162    }
163
164    fn encode_cbor(&self, out: &mut Vec<u8>) {
165        cbor_map(out, 4);
166        cbor_u64(out, 1);
167        cbor_u64(out, self.code as u64);
168        cbor_u64(out, 2);
169        cbor_text(out, &self.reason);
170        cbor_u64(out, 3);
171        cbor_optional_text(out, self.message.as_deref());
172        cbor_u64(out, 4);
173        cbor_bool(out, self.retryable);
174    }
175}
176
177/// CBOR body selected by [`CONTENT_TYPE_SIGN_REQUEST`].
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct SignInvocationRequest {
180    /// Catalog signing key id.
181    pub key_id: String,
182    /// Raw message to sign.
183    pub message: Vec<u8>,
184    /// `SigningAlgorithm` integer value from the protobuf schema.
185    pub algorithm: i32,
186}
187
188impl SignInvocationRequest {
189    /// Encode this body as deterministic CBOR.
190    #[must_use]
191    pub fn to_cbor_bytes(&self) -> Vec<u8> {
192        let mut out = Vec::new();
193        cbor_map(&mut out, 3);
194        cbor_u64(&mut out, 1);
195        cbor_text(&mut out, &self.key_id);
196        cbor_u64(&mut out, 2);
197        cbor_bytes(&mut out, &self.message);
198        cbor_u64(&mut out, 3);
199        cbor_i64(&mut out, i64::from(self.algorithm));
200        out
201    }
202
203    /// Decode a deterministic CBOR `SignInvocationRequest`.
204    ///
205    /// # Errors
206    ///
207    /// Returns [`InvocationError`] when the body does not match the v1 schema.
208    pub fn from_cbor_bytes(bytes: &[u8]) -> Result<Self, InvocationError> {
209        let mut decoder = CborDecoder::new(bytes);
210        decoder.map_len(3, "SignInvocationRequest")?;
211        decoder.key(1)?;
212        let key_id = decoder.text("key_id")?;
213        decoder.key(2)?;
214        let message = decoder.bytes("message")?;
215        decoder.key(3)?;
216        let algorithm = decoder.i64("algorithm")?;
217        decoder.finish()?;
218        let algorithm = i32::try_from(algorithm)
219            .map_err(|_| InvocationError::InvalidBody("algorithm out of range"))?;
220        Ok(Self {
221            key_id,
222            message,
223            algorithm,
224        })
225    }
226}
227
228/// CBOR body selected by [`CONTENT_TYPE_SIGN_RESPONSE`].
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct SignInvocationResponse {
231    /// Trusted operation status.
232    pub status: InvocationStatus,
233    /// Policy generation used for the operation decision.
234    pub policy_generation: u64,
235    /// Raw signature bytes.
236    pub signature: Option<Vec<u8>>,
237}
238
239impl SignInvocationResponse {
240    /// Encode this body as deterministic CBOR.
241    #[must_use]
242    pub fn to_cbor_bytes(&self) -> Vec<u8> {
243        let mut out = Vec::new();
244        cbor_map(&mut out, 3);
245        cbor_u64(&mut out, 1);
246        self.status.encode_cbor(&mut out);
247        cbor_u64(&mut out, 2);
248        cbor_u64(&mut out, self.policy_generation);
249        cbor_u64(&mut out, 3);
250        cbor_optional_bytes(&mut out, self.signature.as_deref());
251        out
252    }
253
254    /// Decode a deterministic CBOR `SignInvocationResponse`.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`InvocationError`] when the body does not match the v1 schema.
259    pub fn from_cbor_bytes(bytes: &[u8]) -> Result<Self, InvocationError> {
260        let mut decoder = CborDecoder::new(bytes);
261        decoder.map_len(3, "SignInvocationResponse")?;
262        decoder.key(1)?;
263        let status = decoder.status()?;
264        decoder.key(2)?;
265        let policy_generation = decoder.u64("policy_generation")?;
266        decoder.key(3)?;
267        let signature = decoder.optional_bytes("signature")?;
268        decoder.finish()?;
269        if status.code == InvocationStatusCode::Ok && signature.is_none() {
270            return Err(InvocationError::InvalidBody(
271                "successful sign response missing signature",
272            ));
273        }
274        if status.code != InvocationStatusCode::Ok && signature.is_some() {
275            return Err(InvocationError::InvalidBody(
276                "non-success sign response carries signature",
277            ));
278        }
279        Ok(Self {
280            status,
281            policy_generation,
282            signature,
283        })
284    }
285}
286
287/// CBOR body selected by [`CONTENT_TYPE_MINT_JWT_RESPONSE`].
288///
289/// Reserved format contract: encode-only (no decoder), pinned by fixtures.
290/// The broker does not mint JWTs over sealed invocations; it rejects
291/// [`CONTENT_TYPE_MINT_JWT_REQUEST`] with `UNSUPPORTED_CONTENT_TYPE`.
292#[derive(Debug, Clone, PartialEq, Eq)]
293pub struct MintJwtInvocationResponse {
294    /// Trusted operation status.
295    pub status: InvocationStatus,
296    /// Policy generation used for the operation decision.
297    pub policy_generation: u64,
298    /// Minted JWT when status is [`InvocationStatusCode::Ok`].
299    pub jwt: Option<String>,
300    /// JWT expiry when known.
301    pub expires_at_unix: Option<u64>,
302}
303
304impl MintJwtInvocationResponse {
305    /// Encode this body as deterministic CBOR.
306    #[must_use]
307    pub fn to_cbor_bytes(&self) -> Vec<u8> {
308        let mut out = Vec::new();
309        cbor_map(&mut out, 4);
310        cbor_u64(&mut out, 1);
311        self.status.encode_cbor(&mut out);
312        cbor_u64(&mut out, 2);
313        cbor_u64(&mut out, self.policy_generation);
314        cbor_u64(&mut out, 3);
315        cbor_optional_text(&mut out, self.jwt.as_deref());
316        cbor_u64(&mut out, 4);
317        cbor_optional_u64(&mut out, self.expires_at_unix);
318        out
319    }
320}
321
322/// CBOR body selected by [`CONTENT_TYPE_MINT_NATS_USER_RESPONSE`].
323///
324/// Reserved format contract: encode-only (no decoder), pinned by fixtures.
325/// The broker does not mint NATS users over sealed invocations; it rejects
326/// [`CONTENT_TYPE_MINT_NATS_USER_REQUEST`] with `UNSUPPORTED_CONTENT_TYPE`.
327#[derive(Debug, Clone, PartialEq, Eq)]
328pub struct MintNatsUserInvocationResponse {
329    /// Trusted operation status.
330    pub status: InvocationStatus,
331    /// Policy generation used for the operation decision.
332    pub policy_generation: u64,
333    /// Minted NATS JWT when status is [`InvocationStatusCode::Ok`].
334    pub jwt: Option<String>,
335    /// JWT expiry when known.
336    pub expires_at_unix: Option<u64>,
337}
338
339impl MintNatsUserInvocationResponse {
340    /// Encode this body as deterministic CBOR.
341    #[must_use]
342    pub fn to_cbor_bytes(&self) -> Vec<u8> {
343        let mut out = Vec::new();
344        cbor_map(&mut out, 4);
345        cbor_u64(&mut out, 1);
346        self.status.encode_cbor(&mut out);
347        cbor_u64(&mut out, 2);
348        cbor_u64(&mut out, self.policy_generation);
349        cbor_u64(&mut out, 3);
350        cbor_optional_text(&mut out, self.jwt.as_deref());
351        cbor_u64(&mut out, 4);
352        cbor_optional_u64(&mut out, self.expires_at_unix);
353        out
354    }
355}
356
357/// CBOR body selected by [`CONTENT_TYPE_MINT_JWT_REQUEST`].
358///
359/// Reserved format contract: the broker rejects this content type with
360/// `UNSUPPORTED_CONTENT_TYPE`, so sending it always fails. It is pinned by
361/// fixtures for the day mint support lands.
362#[derive(Debug, Clone, PartialEq, Eq)]
363pub struct MintJwtInvocationRequest {
364    /// Catalog signing key id.
365    pub key_id: String,
366    /// JWT subject.
367    pub subject: Option<String>,
368    /// TTL in whole seconds.
369    pub ttl_secs: Option<u64>,
370    /// Canonical JSON bytes for additional claims.
371    pub claims_json: Vec<u8>,
372}
373
374impl MintJwtInvocationRequest {
375    /// Encode this body as deterministic CBOR.
376    #[must_use]
377    pub fn to_cbor_bytes(&self) -> Vec<u8> {
378        let mut out = Vec::new();
379        cbor_map(&mut out, 4);
380        cbor_u64(&mut out, 1);
381        cbor_text(&mut out, &self.key_id);
382        cbor_u64(&mut out, 2);
383        cbor_optional_text(&mut out, self.subject.as_deref());
384        cbor_u64(&mut out, 3);
385        cbor_optional_u64(&mut out, self.ttl_secs);
386        cbor_u64(&mut out, 4);
387        cbor_bytes(&mut out, &self.claims_json);
388        out
389    }
390}
391
392/// CBOR body selected by [`CONTENT_TYPE_MINT_NATS_USER_REQUEST`].
393///
394/// Reserved format contract: the broker rejects this content type with
395/// `UNSUPPORTED_CONTENT_TYPE`, so sending it always fails. It is pinned by
396/// fixtures for the day mint support lands.
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub struct MintNatsUserInvocationRequest {
399    /// Account signing key id.
400    pub account_key_id: String,
401    /// User public `NKey`.
402    pub user_nkey: String,
403    /// User name.
404    pub name: String,
405    /// TTL in whole seconds.
406    pub ttl_secs: Option<u64>,
407    /// Owning account identity, populated when `account_key_id` is a signing
408    /// key rather than the account identity itself. Becomes the minted user
409    /// JWT's `nats.issuer_account` claim.
410    pub issuer_account: Option<String>,
411}
412
413impl MintNatsUserInvocationRequest {
414    /// Encode this body as deterministic CBOR.
415    #[must_use]
416    pub fn to_cbor_bytes(&self) -> Vec<u8> {
417        let mut out = Vec::new();
418        cbor_map(&mut out, 5);
419        cbor_u64(&mut out, 1);
420        cbor_text(&mut out, &self.account_key_id);
421        cbor_u64(&mut out, 2);
422        cbor_text(&mut out, &self.user_nkey);
423        cbor_u64(&mut out, 3);
424        cbor_text(&mut out, &self.name);
425        cbor_u64(&mut out, 4);
426        cbor_optional_u64(&mut out, self.ttl_secs);
427        cbor_u64(&mut out, 5);
428        cbor_optional_text(&mut out, self.issuer_account.as_deref());
429        out
430    }
431
432    /// Decode a deterministic CBOR `MintNatsUserInvocationRequest`.
433    ///
434    /// # Errors
435    ///
436    /// Returns [`InvocationError`] when the body does not match the v1 schema.
437    pub fn from_cbor_bytes(bytes: &[u8]) -> Result<Self, InvocationError> {
438        let mut decoder = CborDecoder::new(bytes);
439        decoder.map_len(5, "MintNatsUserInvocationRequest")?;
440        decoder.key(1)?;
441        let account_key_id = decoder.text("account_key_id")?;
442        decoder.key(2)?;
443        let user_nkey = decoder.text("user_nkey")?;
444        decoder.key(3)?;
445        let name = decoder.text("name")?;
446        decoder.key(4)?;
447        let ttl_secs = decoder.optional_u64("ttl_secs")?;
448        decoder.key(5)?;
449        let issuer_account = decoder.optional_text("issuer_account")?;
450        decoder.finish()?;
451        Ok(Self {
452            account_key_id,
453            user_nkey,
454            name,
455            ttl_secs,
456            issuer_account,
457        })
458    }
459}
460
461fn cbor_map(out: &mut Vec<u8>, len: u64) {
462    cbor_type_len(out, 5, len);
463}
464
465fn cbor_text(out: &mut Vec<u8>, value: &str) {
466    cbor_type_len(out, 3, u64::try_from(value.len()).unwrap_or(u64::MAX));
467    out.extend_from_slice(value.as_bytes());
468}
469
470fn cbor_bytes(out: &mut Vec<u8>, value: &[u8]) {
471    cbor_type_len(out, 2, u64::try_from(value.len()).unwrap_or(u64::MAX));
472    out.extend_from_slice(value);
473}
474
475fn cbor_optional_text(out: &mut Vec<u8>, value: Option<&str>) {
476    match value {
477        Some(value) => cbor_text(out, value),
478        None => cbor_null(out),
479    }
480}
481
482fn cbor_optional_bytes(out: &mut Vec<u8>, value: Option<&[u8]>) {
483    match value {
484        Some(value) => cbor_bytes(out, value),
485        None => cbor_null(out),
486    }
487}
488
489fn cbor_optional_u64(out: &mut Vec<u8>, value: Option<u64>) {
490    match value {
491        Some(value) => cbor_u64(out, value),
492        None => cbor_null(out),
493    }
494}
495
496fn cbor_bool(out: &mut Vec<u8>, value: bool) {
497    out.push(if value { 0xf5 } else { 0xf4 });
498}
499
500fn cbor_null(out: &mut Vec<u8>) {
501    out.push(0xf6);
502}
503
504fn cbor_i64(out: &mut Vec<u8>, value: i64) {
505    if value >= 0 {
506        cbor_u64(out, value.unsigned_abs());
507    } else {
508        cbor_type_len(out, 1, value.unsigned_abs() - 1);
509    }
510}
511
512fn cbor_u64(out: &mut Vec<u8>, value: u64) {
513    cbor_type_len(out, 0, value);
514}
515
516fn cbor_type_len(out: &mut Vec<u8>, major: u8, value: u64) {
517    let head = major << 5;
518    if value <= 23 {
519        out.push(head | u8::try_from(value).unwrap_or(23));
520    } else if let Ok(v) = u8::try_from(value) {
521        out.extend_from_slice(&[head | 0x18, v]);
522    } else if let Ok(v) = u16::try_from(value) {
523        out.push(head | 0x19);
524        out.extend_from_slice(&v.to_be_bytes());
525    } else if let Ok(v) = u32::try_from(value) {
526        out.push(head | 0x1a);
527        out.extend_from_slice(&v.to_be_bytes());
528    } else {
529        out.push(head | 0x1b);
530        out.extend_from_slice(&value.to_be_bytes());
531    }
532}
533
534#[derive(Debug)]
535struct CborDecoder<'a> {
536    bytes: &'a [u8],
537    offset: usize,
538}
539
540impl<'a> CborDecoder<'a> {
541    const fn new(bytes: &'a [u8]) -> Self {
542        Self { bytes, offset: 0 }
543    }
544
545    const fn finish(&self) -> Result<(), InvocationError> {
546        if self.offset == self.bytes.len() {
547            Ok(())
548        } else {
549            Err(InvocationError::InvalidBody("trailing CBOR bytes"))
550        }
551    }
552
553    fn map_len(&mut self, expected: u64, schema: &'static str) -> Result<(), InvocationError> {
554        let len = self.type_len(5, schema)?;
555        if len == expected {
556            Ok(())
557        } else {
558            Err(InvocationError::InvalidBody("unexpected CBOR map length"))
559        }
560    }
561
562    fn key(&mut self, expected: u64) -> Result<(), InvocationError> {
563        let actual = self.u64("map key")?;
564        if actual == expected {
565            Ok(())
566        } else {
567            Err(InvocationError::InvalidBody("unexpected CBOR map key"))
568        }
569    }
570
571    fn status(&mut self) -> Result<InvocationStatus, InvocationError> {
572        self.map_len(4, "InvocationStatus")?;
573        self.key(1)?;
574        let code = self.u64("status.code")?;
575        let code = InvocationStatusCode::from_u64(code).ok_or(InvocationError::InvalidBody(
576            "invalid invocation status code",
577        ))?;
578        self.key(2)?;
579        let reason = self.text("status.reason")?;
580        if reason.is_empty() {
581            return Err(InvocationError::InvalidBody(
582                "empty invocation status reason",
583            ));
584        }
585        self.key(3)?;
586        let message = self.optional_text("status.message")?;
587        self.key(4)?;
588        let retryable = self.bool("status.retryable")?;
589        Ok(InvocationStatus {
590            code,
591            reason,
592            message,
593            retryable,
594        })
595    }
596
597    fn u64(&mut self, field: &'static str) -> Result<u64, InvocationError> {
598        self.type_len(0, field)
599    }
600
601    fn i64(&mut self, field: &'static str) -> Result<i64, InvocationError> {
602        let initial = self.take(field)?;
603        let major = initial >> 5;
604        let additional = initial & 0x1f;
605        let value = self.len_value(additional, field)?;
606        match major {
607            0 => i64::try_from(value)
608                .map_err(|_| InvocationError::InvalidBody("positive integer out of range")),
609            1 => {
610                let magnitude = i64::try_from(value)
611                    .map_err(|_| InvocationError::InvalidBody("negative integer out of range"))?;
612                Ok(-1 - magnitude)
613            }
614            _ => Err(InvocationError::InvalidBody("expected CBOR integer")),
615        }
616    }
617
618    fn text(&mut self, field: &'static str) -> Result<String, InvocationError> {
619        let len = self.type_len(3, field)?;
620        let bytes = self.take_n(len, field)?;
621        std::str::from_utf8(bytes)
622            .map(str::to_string)
623            .map_err(|_| InvocationError::InvalidBody("invalid UTF-8 text"))
624    }
625
626    fn optional_text(&mut self, field: &'static str) -> Result<Option<String>, InvocationError> {
627        if self.peek() == Some(0xf6) {
628            self.offset += 1;
629            return Ok(None);
630        }
631        self.text(field).map(Some)
632    }
633
634    fn bytes(&mut self, field: &'static str) -> Result<Vec<u8>, InvocationError> {
635        let len = self.type_len(2, field)?;
636        Ok(self.take_n(len, field)?.to_vec())
637    }
638
639    fn optional_bytes(&mut self, field: &'static str) -> Result<Option<Vec<u8>>, InvocationError> {
640        if self.peek() == Some(0xf6) {
641            self.offset += 1;
642            return Ok(None);
643        }
644        self.bytes(field).map(Some)
645    }
646
647    fn optional_u64(&mut self, field: &'static str) -> Result<Option<u64>, InvocationError> {
648        if self.peek() == Some(0xf6) {
649            self.offset += 1;
650            return Ok(None);
651        }
652        self.u64(field).map(Some)
653    }
654
655    fn bool(&mut self, field: &'static str) -> Result<bool, InvocationError> {
656        match self.take(field)? {
657            0xf4 => Ok(false),
658            0xf5 => Ok(true),
659            _ => Err(InvocationError::InvalidBody("expected CBOR bool")),
660        }
661    }
662
663    fn type_len(
664        &mut self,
665        expected_major: u8,
666        field: &'static str,
667    ) -> Result<u64, InvocationError> {
668        let initial = self.take(field)?;
669        let major = initial >> 5;
670        if major != expected_major {
671            return Err(InvocationError::InvalidBody("unexpected CBOR type"));
672        }
673        self.len_value(initial & 0x1f, field)
674    }
675
676    /// Decode an integer or length argument, rejecting non-minimal forms:
677    /// deterministic CBOR (RFC 8949 ยง4.2.1) requires the shortest encoding.
678    fn len_value(&mut self, additional: u8, field: &'static str) -> Result<u64, InvocationError> {
679        let (value, minimum) = match additional {
680            value @ 0..=23 => return Ok(u64::from(value)),
681            24 => (self.take(field).map(u64::from)?, 24),
682            25 => (
683                self.take_array::<2>(field)
684                    .map(u16::from_be_bytes)
685                    .map(u64::from)?,
686                u64::from(u8::MAX) + 1,
687            ),
688            26 => (
689                self.take_array::<4>(field)
690                    .map(u32::from_be_bytes)
691                    .map(u64::from)?,
692                u64::from(u16::MAX) + 1,
693            ),
694            27 => (
695                self.take_array::<8>(field).map(u64::from_be_bytes)?,
696                u64::from(u32::MAX) + 1,
697            ),
698            _ => {
699                return Err(InvocationError::InvalidBody(
700                    "unsupported CBOR additional info",
701                ));
702            }
703        };
704        if value < minimum {
705            return Err(InvocationError::InvalidBody(
706                "non-minimal CBOR integer or length encoding",
707            ));
708        }
709        Ok(value)
710    }
711
712    fn peek(&self) -> Option<u8> {
713        self.bytes.get(self.offset).copied()
714    }
715
716    fn take(&mut self, field: &'static str) -> Result<u8, InvocationError> {
717        let byte = self
718            .bytes
719            .get(self.offset)
720            .copied()
721            .ok_or(InvocationError::InvalidBody(field))?;
722        self.offset += 1;
723        Ok(byte)
724    }
725
726    fn take_array<const N: usize>(
727        &mut self,
728        field: &'static str,
729    ) -> Result<[u8; N], InvocationError> {
730        self.take_n(u64::try_from(N).unwrap_or(u64::MAX), field)?
731            .try_into()
732            .map_err(|_| InvocationError::InvalidBody("short CBOR integer"))
733    }
734
735    fn take_n(&mut self, len: u64, field: &'static str) -> Result<&'a [u8], InvocationError> {
736        let len = usize::try_from(len)
737            .map_err(|_| InvocationError::InvalidBody("CBOR length out of range"))?;
738        let end = self
739            .offset
740            .checked_add(len)
741            .ok_or(InvocationError::InvalidBody("CBOR length overflow"))?;
742        let out = self
743            .bytes
744            .get(self.offset..end)
745            .ok_or(InvocationError::InvalidBody(field))?;
746        self.offset = end;
747        Ok(out)
748    }
749}
750
751#[cfg(test)]
752#[allow(clippy::unwrap_used)]
753mod tests {
754    use super::*;
755
756    fn hex(bytes: &[u8]) -> String {
757        let mut out = String::with_capacity(bytes.len() * 2);
758        for byte in bytes {
759            out.push(hex_nibble(byte >> 4));
760            out.push(hex_nibble(byte & 0x0f));
761        }
762        out
763    }
764
765    const fn hex_nibble(nibble: u8) -> char {
766        match nibble {
767            0 => '0',
768            1 => '1',
769            2 => '2',
770            3 => '3',
771            4 => '4',
772            5 => '5',
773            6 => '6',
774            7 => '7',
775            8 => '8',
776            9 => '9',
777            10 => 'a',
778            11 => 'b',
779            12 => 'c',
780            13 => 'd',
781            14 => 'e',
782            15 => 'f',
783            _ => '?',
784        }
785    }
786
787    #[test]
788    fn content_type_registry_values_are_media_type_shaped() {
789        for value in INVOCATION_CONTENT_TYPES {
790            let (kind, subtype) = value.split_once('/').unwrap();
791            assert_eq!(kind, "application", "{value}");
792            assert!(subtype.starts_with("basil."), "{value}");
793            assert!(!subtype.contains('/'), "{value}");
794            assert_eq!(value.trim(), value, "{value}");
795        }
796    }
797
798    #[test]
799    fn request_bodies_have_deterministic_cbor() {
800        let request = SignInvocationRequest {
801            key_id: "publisher.signing.2026q3".to_string(),
802            message: b"payload".to_vec(),
803            algorithm: 1,
804        };
805        assert_eq!(
806            SignInvocationRequest::from_cbor_bytes(&request.to_cbor_bytes()).unwrap(),
807            request
808        );
809        assert_eq!(
810            hex(&request.to_cbor_bytes()),
811            "a30178187075626c69736865722e7369676e696e672e32303236713302477061796c6f61640301"
812        );
813
814        let response = SignInvocationResponse {
815            status: InvocationStatus::ok(),
816            policy_generation: 42,
817            signature: Some(vec![0xAB; 64]),
818        };
819        assert_eq!(
820            SignInvocationResponse::from_cbor_bytes(&response.to_cbor_bytes()).unwrap(),
821            response
822        );
823        assert_eq!(
824            hex(&response.to_cbor_bytes()),
825            "a301a4010102624f4b03f604f402182a035840abababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababababab"
826        );
827
828        let denied = SignInvocationResponse {
829            status: InvocationStatus::denied(),
830            policy_generation: 42,
831            signature: None,
832        };
833        assert_eq!(
834            SignInvocationResponse::from_cbor_bytes(&denied.to_cbor_bytes()).unwrap(),
835            denied
836        );
837    }
838
839    #[test]
840    fn non_minimal_cbor_integer_and_length_forms_are_rejected() {
841        // SignInvocationRequest { key_id: "k", message: [0x01], algorithm: 1 }
842        // in the minimal deterministic encoding.
843        let minimal = [0xa3, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x01];
844        let decoded = SignInvocationRequest::from_cbor_bytes(&minimal).unwrap();
845        assert_eq!(decoded.to_cbor_bytes(), minimal);
846
847        let non_minimal: [&[u8]; 8] = [
848            // map length 3 in one-byte form
849            &[0xb8, 0x03, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x01],
850            // map key 1 in one-byte form
851            &[0xa3, 0x18, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x01],
852            // text length 1 in one-byte form
853            &[0xa3, 0x01, 0x78, 0x01, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x01],
854            // byte-string length 1 in one-byte form
855            &[0xa3, 0x01, 0x61, 0x6b, 0x02, 0x58, 0x01, 0x01, 0x03, 0x01],
856            // algorithm 1 in one-byte form
857            &[0xa3, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x18, 0x01],
858            // algorithm 1 in two-byte form
859            &[
860                0xa3, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x19, 0x00, 0x01,
861            ],
862            // algorithm 1 in four-byte form
863            &[
864                0xa3, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x1a, 0x00, 0x00, 0x00, 0x01,
865            ],
866            // algorithm 1 in eight-byte form
867            &[
868                0xa3, 0x01, 0x61, 0x6b, 0x02, 0x41, 0x01, 0x03, 0x1b, 0x00, 0x00, 0x00, 0x00, 0x00,
869                0x00, 0x00, 0x01,
870            ],
871        ];
872        for bytes in non_minimal {
873            assert_eq!(
874                SignInvocationRequest::from_cbor_bytes(bytes),
875                Err(InvocationError::InvalidBody(
876                    "non-minimal CBOR integer or length encoding"
877                )),
878                "{}",
879                hex(bytes)
880            );
881        }
882    }
883
884    #[test]
885    fn mint_nats_user_request_carries_issuer_account() {
886        let with_issuer = MintNatsUserInvocationRequest {
887            account_key_id: "nats.account.signing".to_string(),
888            user_nkey: "UDXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4".to_string(),
889            name: "svc-a".to_string(),
890            ttl_secs: Some(300),
891            issuer_account: Some(
892                "ADXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4".to_string(),
893            ),
894        };
895        let decoded =
896            MintNatsUserInvocationRequest::from_cbor_bytes(&with_issuer.to_cbor_bytes()).unwrap();
897        assert_eq!(decoded, with_issuer);
898        assert_eq!(
899            decoded.issuer_account.as_deref(),
900            Some("ADXU4RCSJNZOIQHZNWXHXORDPRTGNJAHAHFRGZNEEJCPQTT2M7NLCNF4")
901        );
902
903        // The account-identity path omits the claim; it must round-trip as absent.
904        let without_issuer = MintNatsUserInvocationRequest {
905            issuer_account: None,
906            ..with_issuer
907        };
908        let decoded =
909            MintNatsUserInvocationRequest::from_cbor_bytes(&without_issuer.to_cbor_bytes())
910                .unwrap();
911        assert_eq!(decoded, without_issuer);
912        assert_eq!(decoded.issuer_account, None);
913    }
914}