Skip to main content

scll_core/
lifecycle.rs

1//! Card (ISD) life-cycle transition state machine — PDD §5.11.
2//!
3//! Verified against GPCS v2.3.1 §5.1.1.1–.5 and Figure 5-1 (PDF p. 54):
4//! - `OP_READY → INITIALIZED → SECURED` irreversible (§5.1.1.2/.3).
5//! - `SECURED ↔ CARD_LOCKED` reversible (§5.1.1.4).
6//! - any → `TERMINATED` irreversible (§5.1.1.5) — **refused** as a set target (§2.2).
7//! - Skip-ahead to `SECURED` is spec-legal (§5.1.2) — gated behind `force`.
8//! - Same-state = no-op (card rejects per §11.10.2.2); detected before any APDU.
9//!
10//! P2 target bytes are the Card Life Cycle Coding of GPCS v2.3.1 Table 11-6
11//! (`INITIALIZED = 0x07`, `SECURED = 0x0F`, `CARD_LOCKED = 0x7F`), as required
12//! by SET STATUS §11.10.2.2.
13
14use crate::error::ScllError;
15use crate::report::CardLifeCycle;
16
17/// SET STATUS P2 byte for `INITIALIZED` (GPCS v2.3.1 Table 11-6).
18const P2_INITIALIZED: u8 = 0x07;
19/// SET STATUS P2 byte for `SECURED` (GPCS v2.3.1 Table 11-6).
20const P2_SECURED: u8 = 0x0F;
21/// SET STATUS P2 byte for `CARD_LOCKED` (GPCS v2.3.1 Table 11-6).
22const P2_CARD_LOCKED: u8 = 0x7F;
23
24/// Validate a requested transition against the verified matrix.
25/// `force` permits skip-ahead to `SECURED`; never bypasses the `TERMINATED`
26/// refusal or backward-transition refusal.
27///
28/// # Errors
29/// Returns [`ScllError::IllegalLifecycleTransition`] for a backward or
30/// otherwise illegal transition, or [`ScllError::TerminateOutOfScope`] if
31/// `target` is `TERMINATED` (refused as a set target).
32pub fn check_transition(
33    current: CardLifeCycle,
34    target: CardLifeCycle,
35    force: bool,
36) -> Result<TransitionPlan, ScllError> {
37    use CardLifeCycle::{CardLocked, Initialized, OpReady, Secured, Terminated, Unknown};
38
39    // TERMINATED is never a valid set target, under any `force` (§2.2 / §5.1.1.5).
40    if matches!(target, Terminated) {
41        return Err(ScllError::TerminateOutOfScope);
42    }
43    // An unknown byte is not a settable target state.
44    if matches!(target, Unknown(_)) {
45        return Err(ScllError::IllegalLifecycleTransition);
46    }
47    // Same state ⇒ no-op; the card rejects a same-state SET STATUS (§11.10.2.2),
48    // so the library reports it without sending an APDU.
49    if current == target {
50        return Ok(TransitionPlan::NoOp);
51    }
52    // TERMINATED is final: no transition leaves it. Unknown current state cannot
53    // be validated against the matrix, so it is refused conservatively.
54    if matches!(current, Terminated | Unknown(_)) {
55        return Err(ScllError::IllegalLifecycleTransition);
56    }
57
58    let p2 = match (current, target) {
59        (OpReady, Initialized) => P2_INITIALIZED, // forward (§5.1.1.2)
60        (Initialized | CardLocked, Secured) => P2_SECURED, // forward / unlock (§5.1.1.3/.4)
61        (OpReady, Secured) if force => P2_SECURED, // skip-ahead, force only (§5.1.2)
62        (Secured, CardLocked) => P2_CARD_LOCKED,  // lock (§5.1.1.4)
63        _ => return Err(ScllError::IllegalLifecycleTransition), // backward / skip-ahead w/o force
64    };
65    Ok(TransitionPlan::Apply { p2 })
66}
67
68/// Outcome of a legality check: either a no-op, or the P2 byte to send.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum TransitionPlan {
71    NoOp,             // already in target — emit WarningKind::LifecycleNoOp, send nothing
72    Apply { p2: u8 }, // e.g. 0x07 INITIALIZED, 0x0F SECURED, 0x7F CARD_LOCKED
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::report::CardLifeCycle::{
79        CardLocked, Initialized, OpReady, Secured, Terminated, Unknown,
80    };
81
82    const VALID: [CardLifeCycle; 4] = [OpReady, Initialized, Secured, CardLocked];
83
84    #[test]
85    fn terminated_is_never_a_target_under_any_force() {
86        for &current in &[
87            OpReady,
88            Initialized,
89            Secured,
90            CardLocked,
91            Terminated,
92            Unknown(0x42),
93        ] {
94            for force in [false, true] {
95                assert!(matches!(
96                    check_transition(current, Terminated, force),
97                    Err(ScllError::TerminateOutOfScope)
98                ));
99            }
100        }
101    }
102
103    #[test]
104    fn forward_provisioning_is_one_way() {
105        assert_eq!(
106            check_transition(OpReady, Initialized, false).unwrap(),
107            TransitionPlan::Apply { p2: 0x07 }
108        );
109        assert_eq!(
110            check_transition(Initialized, Secured, false).unwrap(),
111            TransitionPlan::Apply { p2: 0x0F }
112        );
113        // Backward is refused, with or without force.
114        for force in [false, true] {
115            for &(from, to) in &[
116                (Initialized, OpReady),
117                (Secured, Initialized),
118                (Secured, OpReady),
119                (CardLocked, Initialized),
120                (CardLocked, OpReady),
121            ] {
122                assert!(matches!(
123                    check_transition(from, to, force),
124                    Err(ScllError::IllegalLifecycleTransition)
125                ));
126            }
127        }
128    }
129
130    #[test]
131    fn skip_ahead_to_secured_requires_force() {
132        assert!(matches!(
133            check_transition(OpReady, Secured, false),
134            Err(ScllError::IllegalLifecycleTransition)
135        ));
136        assert_eq!(
137            check_transition(OpReady, Secured, true).unwrap(),
138            TransitionPlan::Apply { p2: 0x0F }
139        );
140    }
141
142    #[test]
143    fn lock_and_unlock_are_reversible() {
144        assert_eq!(
145            check_transition(Secured, CardLocked, false).unwrap(),
146            TransitionPlan::Apply { p2: 0x7F }
147        );
148        assert_eq!(
149            check_transition(CardLocked, Secured, false).unwrap(),
150            TransitionPlan::Apply { p2: 0x0F }
151        );
152    }
153
154    #[test]
155    fn same_state_is_a_no_op() {
156        for &s in &VALID {
157            assert_eq!(check_transition(s, s, false).unwrap(), TransitionPlan::NoOp);
158        }
159    }
160
161    #[test]
162    fn unknown_states_are_refused() {
163        // Unknown target.
164        assert!(matches!(
165            check_transition(Secured, Unknown(0x99), false),
166            Err(ScllError::IllegalLifecycleTransition)
167        ));
168        // Unknown current (valid, non-terminated target).
169        assert!(matches!(
170            check_transition(Unknown(0x99), Secured, true),
171            Err(ScllError::IllegalLifecycleTransition)
172        ));
173    }
174
175    #[test]
176    fn nothing_leaves_terminated() {
177        for &to in &VALID {
178            assert!(matches!(
179                check_transition(Terminated, to, true),
180                Err(ScllError::IllegalLifecycleTransition)
181            ));
182        }
183    }
184}