Skip to main content

dvb_ci_runtime/
session.rs

1//! SPDU session layer — a sans-IO mechanism over the transport layer
2//! (ETSI EN 50221 §7.2).
3//!
4//! Multiplexes logical sessions (one per resource in use) over the transport
5//! connection: allocates/tracks `session_nb`s, answers `open_session_request`
6//! for resources the host advertises, opens `create_session` on demand, and
7//! routes `session_number`+APDU to/from the resource bound to a session. It is
8//! mechanism only — *which* resources the host provides is the caller's policy,
9//! supplied as the `provides` predicate to [`SessionLayer::on_spdu`].
10
11use std::collections::BTreeMap;
12
13use crate::resource::ser;
14use broadcast_common::Parse;
15use dvb_ci::resource::ResourceId;
16use dvb_ci::spdu::{
17    CloseSessionRequest, CloseSessionResponse, CreateSessionResponse, OpenSessionRequest,
18    OpenSessionResponse, SessionNumber, SessionStatus, tags,
19};
20
21/// What the session layer wants done after handling one SPDU.
22#[derive(Debug, Default, Clone, PartialEq, Eq)]
23pub struct SessionOut {
24    /// SPDUs to hand down to the transport layer (each becomes a `T_Data_Last`).
25    pub spdus: Vec<Vec<u8>>,
26    /// `(session_nb, apdu_bytes)` to pass up to the resource layer.
27    pub apdus: Vec<(u16, Vec<u8>)>,
28    /// Sessions newly opened (`session_nb`, bound resource).
29    pub opened: Vec<(u16, ResourceId)>,
30    /// `session_nb`s that closed.
31    pub closed: Vec<u16>,
32}
33
34/// The session table + `session_nb` allocator.
35#[derive(Debug, Default)]
36pub struct SessionLayer {
37    sessions: BTreeMap<u16, ResourceId>,
38    next: u16,
39}
40
41impl SessionLayer {
42    /// New, empty session layer.
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            sessions: BTreeMap::new(),
47            next: 1, // session_nb 0 is reserved
48        }
49    }
50
51    /// Resource bound to `session_nb`, if open.
52    #[must_use]
53    pub fn resource_of(&self, session_nb: u16) -> Option<ResourceId> {
54        self.sessions.get(&session_nb).copied()
55    }
56
57    /// The lowest open `session_nb` bound to `resource`, if any.
58    ///
59    /// Walks only the open sessions (a handful per slot), where the caller
60    /// used to probe every one of the 65 535 representable numbers through
61    /// [`resource_of`](Self::resource_of) (audit r10-O-9).
62    #[must_use]
63    pub fn session_for(&self, resource: ResourceId) -> Option<u16> {
64        self.sessions
65            .iter()
66            .find(|&(_, &r)| r == resource)
67            .map(|(&n, _)| n)
68    }
69
70    /// All open `(session_nb, resource)` pairs, ascending by `session_nb`.
71    #[must_use]
72    pub fn sessions(&self) -> Vec<(u16, ResourceId)> {
73        self.sessions.iter().map(|(&n, &r)| (n, r)).collect()
74    }
75
76    /// Number of open sessions.
77    #[must_use]
78    pub fn len(&self) -> usize {
79        self.sessions.len()
80    }
81
82    /// Whether there are no open sessions.
83    #[must_use]
84    pub fn is_empty(&self) -> bool {
85        self.sessions.is_empty()
86    }
87
88    /// r10-W-18: probe past any `session_nb` still in `self.sessions` —
89    /// `next` wraps around after 65535 allocations (a long-running slot with
90    /// enough session churn, e.g. repeated MMI dialogues, can reach this),
91    /// and without the probe a wrapped-around number could alias a session
92    /// that is still open, silently corrupting its `resource_of` mapping.
93    fn alloc(&mut self) -> u16 {
94        let start = self.next;
95        loop {
96            let nb = self.next;
97            self.next = self.next.checked_add(1).filter(|&n| n != 0).unwrap_or(1);
98            if !self.sessions.contains_key(&nb) {
99                return nb;
100            }
101            // Every one of the 65535 representable numbers is in use — an
102            // exhaustion this extreme is not realistic for one CI slot's
103            // handful of concurrent resource sessions, but return the
104            // original candidate rather than loop forever.
105            if self.next == start {
106                return nb;
107            }
108        }
109    }
110
111    /// Open a session to a **module-provided** resource (host-initiated):
112    /// returns the `open_session_request` SPDU to send. Sessions are opened the
113    /// same way in both directions (§8.4.1) — the host sends
114    /// `open_session_request`, and the module (the resource provider) assigns the
115    /// `session_nb` in its `open_session_response`. (`create_session`/0x93 is a
116    /// resource-manager-internal primitive; a real CAM rejects it with
117    /// `status=0xF0` — verified live against an AlphaCrypt.) The session is
118    /// recorded once the module's `open_session_response(ok)` arrives.
119    pub fn create_session(&mut self, resource: ResourceId) -> dvb_ci::Result<Vec<u8>> {
120        ser(&OpenSessionRequest {
121            length_form: dvb_ci::length::LengthForm::Minimal,
122            resource,
123        })
124    }
125
126    /// Wrap an APDU for sending on `session_nb` (`session_number` + body).
127    pub fn send_apdu(&self, session_nb: u16, apdu: &[u8]) -> dvb_ci::Result<Vec<u8>> {
128        let mut v = ser(&SessionNumber {
129            length_form: dvb_ci::length::LengthForm::Minimal,
130            session_nb,
131        })?;
132        v.extend_from_slice(apdu);
133        Ok(v)
134    }
135
136    /// Begin closing `session_nb`: returns the `close_session_request` SPDU.
137    pub fn close(&mut self, session_nb: u16) -> dvb_ci::Result<Vec<u8>> {
138        self.sessions.remove(&session_nb);
139        ser(&CloseSessionRequest {
140            length_form: dvb_ci::length::LengthForm::Minimal,
141            session_nb,
142        })
143    }
144
145    /// Bind a module-chosen `session_nb` to `resource` (from
146    /// `open_session_response`/`create_session_response`), returning
147    /// whether the binding is now in effect.
148    ///
149    /// r10-W-18: `session_nb` is the MODULE's own choice, not ours — a
150    /// misbehaving (or hostile) module reusing a number already bound to a
151    /// DIFFERENT resource would otherwise silently alias that resource's
152    /// session, misrouting its APDUs from then on. `0` is also rejected: it
153    /// is the reserved value `alloc` never hands out. Re-asserting the SAME
154    /// resource on an already-open `session_nb` is treated as an idempotent
155    /// success, not a collision (a module may legitimately repeat its own
156    /// prior response).
157    fn try_bind_module_chosen(&mut self, session_nb: u16, resource: ResourceId) -> bool {
158        if session_nb == 0 {
159            return false;
160        }
161        match self.sessions.get(&session_nb) {
162            Some(&existing) => existing == resource,
163            None => {
164                self.sessions.insert(session_nb, resource);
165                true
166            }
167        }
168    }
169
170    /// Handle one inbound SPDU. `provides` answers "does the host provide this
171    /// resource?" for an incoming `open_session_request`.
172    pub fn on_spdu(
173        &mut self,
174        spdu: &[u8],
175        provides: impl Fn(ResourceId) -> bool,
176    ) -> dvb_ci::Result<SessionOut> {
177        let mut out = SessionOut::default();
178        match spdu.first().copied() {
179            // Module wants a host-provided resource.
180            Some(tags::OPEN_SESSION_REQUEST) => {
181                // A malformed CAM SPDU is surfaced (`?` -> Notification::Error),
182                // never dropped silently.
183                let req = OpenSessionRequest::parse(spdu)?;
184                if provides(req.resource) {
185                    let session_nb = self.alloc();
186                    self.sessions.insert(session_nb, req.resource);
187                    out.spdus.push(ser(&OpenSessionResponse {
188                        length_form: dvb_ci::length::LengthForm::Minimal,
189                        status: SessionStatus::Ok,
190                        resource: req.resource,
191                        session_nb,
192                    })?);
193                    out.opened.push((session_nb, req.resource));
194                } else {
195                    out.spdus.push(ser(&OpenSessionResponse {
196                        length_form: dvb_ci::length::LengthForm::Minimal,
197                        status: SessionStatus::ResourceNonExistent,
198                        resource: req.resource,
199                        session_nb: 0,
200                    })?);
201                }
202            }
203            // Module's reply to our open_session_request (host opened a
204            // module-provided resource); the module assigns the session_nb.
205            // r10-W-18: the assignment is not trusted blindly — see
206            // `try_bind_module_chosen`.
207            Some(tags::OPEN_SESSION_RESPONSE) => {
208                let resp = OpenSessionResponse::parse(spdu)?;
209                if resp.status == SessionStatus::Ok {
210                    out.opened.extend(
211                        self.try_bind_module_chosen(resp.session_nb, resp.resource)
212                            .then_some((resp.session_nb, resp.resource)),
213                    );
214                }
215            }
216            // (Legacy) module's reply to a create_session, if any module uses it.
217            Some(tags::CREATE_SESSION_RESPONSE) => {
218                let resp = CreateSessionResponse::parse(spdu)?;
219                if resp.status == SessionStatus::Ok {
220                    out.opened.extend(
221                        self.try_bind_module_chosen(resp.session_nb, resp.resource)
222                            .then_some((resp.session_nb, resp.resource)),
223                    );
224                }
225            }
226            // Peer closes a session.
227            Some(tags::CLOSE_SESSION_REQUEST) => {
228                let req = CloseSessionRequest::parse(spdu)?;
229                let known = self.sessions.remove(&req.session_nb).is_some();
230                out.spdus.push(ser(&CloseSessionResponse {
231                    length_form: dvb_ci::length::LengthForm::Minimal,
232                    status: if known {
233                        SessionStatus::Ok
234                    } else {
235                        SessionStatus::ResourceNonExistent
236                    },
237                    session_nb: req.session_nb,
238                })?);
239                if known {
240                    out.closed.push(req.session_nb);
241                }
242            }
243            // Ack of a close we initiated.
244            Some(tags::CLOSE_SESSION_RESPONSE) => {
245                let resp = CloseSessionResponse::parse(spdu)?;
246                self.sessions.remove(&resp.session_nb);
247                out.closed.push(resp.session_nb);
248            }
249            // Data: session_number(nb) + APDU body.
250            Some(tags::SESSION_NUMBER) => {
251                let sn = SessionNumber::parse(spdu)?;
252                // The header is `sn.header_len()` long: 4 bytes when the
253                // `length_field` is minimally coded, longer for a CAM that
254                // codes it in a long form (`90 82 00 02 ..`).
255                let header_len = sn.header_len();
256                if spdu.len() > header_len {
257                    out.apdus.push((sn.session_nb, spdu[header_len..].to_vec()));
258                }
259            }
260            _ => {}
261        }
262        Ok(out)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use dvb_ci::resource::{APPLICATION_INFORMATION, RESOURCE_MANAGER};
270
271    fn provides_rm(r: ResourceId) -> bool {
272        r == RESOURCE_MANAGER
273    }
274
275    #[test]
276    fn session_for_returns_the_lowest_open_session_of_a_resource() {
277        let mut s = SessionLayer::new();
278        assert_eq!(s.session_for(RESOURCE_MANAGER), None);
279        let rm_req = ser(&OpenSessionRequest {
280            length_form: dvb_ci::length::LengthForm::Minimal,
281            resource: RESOURCE_MANAGER,
282        })
283        .unwrap();
284        let ai_req = ser(&OpenSessionRequest {
285            length_form: dvb_ci::length::LengthForm::Minimal,
286            resource: APPLICATION_INFORMATION,
287        })
288        .unwrap();
289        let all = |_: ResourceId| true;
290        let rm = s.on_spdu(&rm_req, all).unwrap().opened[0].0;
291        let ai = s.on_spdu(&ai_req, all).unwrap().opened[0].0;
292        assert_ne!(rm, ai);
293        assert_eq!(s.session_for(RESOURCE_MANAGER), Some(rm));
294        assert_eq!(s.session_for(APPLICATION_INFORMATION), Some(ai));
295        // Closing the session removes it from the lookup.
296        s.close(rm).unwrap();
297        assert_eq!(s.session_for(RESOURCE_MANAGER), None);
298    }
299
300    #[test]
301    fn open_request_for_provided_resource_grants_and_tracks() {
302        let mut s = SessionLayer::new();
303        let req = ser(&OpenSessionRequest {
304            length_form: dvb_ci::length::LengthForm::Minimal,
305            resource: RESOURCE_MANAGER,
306        })
307        .unwrap();
308        let out = s.on_spdu(&req, provides_rm).unwrap();
309        assert_eq!(out.opened.len(), 1);
310        let (nb, res) = out.opened[0];
311        assert_eq!(res, RESOURCE_MANAGER);
312        assert_eq!(s.resource_of(nb), Some(RESOURCE_MANAGER));
313        // reply is an open_session_response with status ok
314        let resp = OpenSessionResponse::parse(&out.spdus[0]).unwrap();
315        assert_eq!(resp.status, SessionStatus::Ok);
316        assert_eq!(resp.session_nb, nb);
317    }
318
319    #[test]
320    fn open_request_for_absent_resource_denied() {
321        let mut s = SessionLayer::new();
322        let req = ser(&OpenSessionRequest {
323            length_form: dvb_ci::length::LengthForm::Minimal,
324            resource: APPLICATION_INFORMATION,
325        })
326        .unwrap();
327        let out = s.on_spdu(&req, provides_rm).unwrap();
328        assert!(out.opened.is_empty());
329        let resp = OpenSessionResponse::parse(&out.spdus[0]).unwrap();
330        assert_eq!(resp.status, SessionStatus::ResourceNonExistent);
331        assert!(s.is_empty());
332    }
333
334    #[test]
335    fn create_session_tracked_on_ok_response() {
336        let mut s = SessionLayer::new();
337        let _spdu = s.create_session(APPLICATION_INFORMATION).unwrap();
338        // module replies ok for session 1
339        let resp = ser(&CreateSessionResponse {
340            length_form: dvb_ci::length::LengthForm::Minimal,
341            status: SessionStatus::Ok,
342            resource: APPLICATION_INFORMATION,
343            session_nb: 1,
344        })
345        .unwrap();
346        let out = s.on_spdu(&resp, |_| false).unwrap();
347        assert_eq!(out.opened, vec![(1, APPLICATION_INFORMATION)]);
348        assert_eq!(s.resource_of(1), Some(APPLICATION_INFORMATION));
349    }
350
351    /// r10-W-18: a module-chosen `session_nb` that already maps to a
352    /// DIFFERENT resource must be rejected, not silently overwritten —
353    /// otherwise a misbehaving module aliases an existing session and its
354    /// APDUs are misrouted from then on.
355    #[test]
356    fn module_chosen_session_nb_colliding_with_a_different_resource_is_rejected() {
357        let mut s = SessionLayer::new();
358        // Session 1 is already RESOURCE_MANAGER (opened via the ordinary path).
359        let open = ser(&OpenSessionRequest {
360            length_form: dvb_ci::length::LengthForm::Minimal,
361            resource: RESOURCE_MANAGER,
362        })
363        .unwrap();
364        let opened = s.on_spdu(&open, provides_rm).unwrap().opened;
365        let nb = opened[0].0;
366        assert_eq!(s.resource_of(nb), Some(RESOURCE_MANAGER));
367
368        // The module now claims that SAME session_nb for a totally
369        // different resource via create_session_response.
370        let colliding = ser(&CreateSessionResponse {
371            length_form: dvb_ci::length::LengthForm::Minimal,
372            status: SessionStatus::Ok,
373            resource: APPLICATION_INFORMATION,
374            session_nb: nb,
375        })
376        .unwrap();
377        let out = s.on_spdu(&colliding, |_| false).unwrap();
378        assert!(
379            out.opened.is_empty(),
380            "a colliding module-chosen session_nb must not be reported as opened"
381        );
382        assert_eq!(
383            s.resource_of(nb),
384            Some(RESOURCE_MANAGER),
385            "the original binding must survive the collision attempt"
386        );
387    }
388
389    /// r10-W-18: `session_nb = 0` is reserved (`alloc` never hands it out)
390    /// and must never be accepted as a module-chosen binding.
391    #[test]
392    fn module_chosen_session_nb_zero_is_rejected() {
393        let mut s = SessionLayer::new();
394        let resp = ser(&OpenSessionResponse {
395            length_form: dvb_ci::length::LengthForm::Minimal,
396            status: SessionStatus::Ok,
397            resource: APPLICATION_INFORMATION,
398            session_nb: 0,
399        })
400        .unwrap();
401        let out = s.on_spdu(&resp, |_| false).unwrap();
402        assert!(out.opened.is_empty());
403        assert_eq!(s.resource_of(0), None);
404    }
405
406    /// r10-W-18: `alloc` must skip any `session_nb` still open, even across
407    /// the 65535 wraparound — otherwise a long-running slot with enough
408    /// session churn eventually reissues a number that aliases a session
409    /// that never closed.
410    #[test]
411    fn alloc_probes_past_a_still_open_session_across_wraparound() {
412        let mut s = SessionLayer::new();
413        // Force the allocator right up to the wraparound boundary.
414        s.next = 65535;
415        let first = s.alloc();
416        assert_eq!(first, 65535);
417        s.sessions.insert(first, APPLICATION_INFORMATION);
418        // Next candidate would be `65535 -> checked_add overflows -> 1`.
419        // Occupy 1 directly (as if it were opened earlier and never closed).
420        s.sessions.insert(1, RESOURCE_MANAGER);
421        let second = s.alloc();
422        assert_ne!(
423            second, 1,
424            "alloc must not hand out a session_nb that is still open"
425        );
426        assert_eq!(s.resource_of(1), Some(RESOURCE_MANAGER), "untouched");
427    }
428
429    #[test]
430    fn session_number_routes_apdu_up() {
431        let mut s = SessionLayer::new();
432        let apdu = [0x9F, 0x80, 0x21, 0x00];
433        let mut spdu = ser(&SessionNumber {
434            length_form: dvb_ci::length::LengthForm::Minimal,
435            session_nb: 7,
436        })
437        .unwrap();
438        spdu.extend_from_slice(&apdu);
439        let out = s.on_spdu(&spdu, |_| false).unwrap();
440        assert_eq!(out.apdus, vec![(7, apdu.to_vec())]);
441    }
442
443    #[test]
444    fn close_request_acks_and_removes() {
445        let mut s = SessionLayer::new();
446        // open one first
447        let req = ser(&OpenSessionRequest {
448            length_form: dvb_ci::length::LengthForm::Minimal,
449            resource: RESOURCE_MANAGER,
450        })
451        .unwrap();
452        let nb = s.on_spdu(&req, provides_rm).unwrap().opened[0].0;
453        // peer closes it
454        let close = ser(&CloseSessionRequest {
455            length_form: dvb_ci::length::LengthForm::Minimal,
456            session_nb: nb,
457        })
458        .unwrap();
459        let out = s.on_spdu(&close, |_| false).unwrap();
460        assert_eq!(out.closed, vec![nb]);
461        assert!(s.is_empty());
462        // reply is a close_session_response
463        assert_eq!(out.spdus[0][0], tags::CLOSE_SESSION_RESPONSE);
464    }
465
466    #[test]
467    fn send_apdu_prefixes_session_number() {
468        let s = SessionLayer::new();
469        let wire = s.send_apdu(3, &[0xAA, 0xBB]).unwrap();
470        let sn = SessionNumber::parse(&wire).unwrap();
471        assert_eq!(sn.session_nb, 3);
472        assert_eq!(&wire[SessionNumber::HEADER_LEN..], &[0xAA, 0xBB]);
473    }
474
475    /// r10-W-4: a CAM may code the `session_number` `length_field` in a long
476    /// form (`90 82 00 02 ..`, the `82 00 NN` shape captured on hardware in
477    /// #337 at the TPDU layer). The APDU body then starts after the longer
478    /// header; slicing at the fixed 4-byte `HEADER_LEN` handed the APDU layer
479    /// two stray session bytes.
480    #[test]
481    fn session_number_in_a_long_length_form_still_yields_the_apdu() {
482        let mut s = SessionLayer::new();
483        let wire = [0x90, 0x82, 0x00, 0x02, 0x00, 0x03, 0xAA, 0xBB];
484        let out = s.on_spdu(&wire, |_| false).unwrap();
485        assert_eq!(out.apdus, vec![(3, vec![0xAA, 0xBB])]);
486    }
487
488    #[test]
489    fn closing_an_unallocated_session_is_answered_not_allocated() {
490        let mut s = SessionLayer::new();
491        let req = ser(&CloseSessionRequest {
492            length_form: dvb_ci::length::LengthForm::Minimal,
493            session_nb: 77,
494        })
495        .unwrap();
496        let out = s.on_spdu(&req, |_| true).unwrap();
497        let resp = CloseSessionResponse::parse(&out.spdus[0]).unwrap();
498        assert_eq!(resp.status, SessionStatus::ResourceNonExistent);
499        assert!(out.closed.is_empty(), "no session was closed");
500    }
501}