Skip to main content

scll_core/workflow/
card_status.rs

1//! Steps 11, 12 & 12a — `set_card_status` / `get_card_status` /
2//! `get_card_inventory` (PDD §5.11/§5.12/§5.12a).
3//!
4//! `set_card_status`: SET STATUS (INS F0, P1 `0x80`) over an ISD session;
5//! forward provisioning + reversible lock/unlock; `TERMINATED` refused (§2.2);
6//! same-state no-op detected before any APDU (via `lifecycle::check_transition`,
7//! GPCS v2.3.1 §5.1.1 / Table 11-6). `get_card_status`: GET STATUS (INS F2,
8//! P1 `0x80`, P2 `0x02`, Data `4F00`) → decode `'9F70'` to [`CardLifeCycle`].
9//! `get_card_inventory`: GET STATUS across the three registry scopes
10//! (P1 `0x80`/`0x40`/`0x10`, GPCS v2.3.1 §11.4) with `63 10` paging, into a
11//! [`CardInventory`] — the `gp --list` equivalent.
12//!
13//! ISD-ness of the session is the caller's contract (the session was opened
14//! against the ISD); a registry-backed `SessionNotIsd` check needs the
15//! inventory snapshot that fast discovery skips, so it is not raised here.
16
17use heapless::{String, Vec};
18
19use crate::aid::Aid;
20use crate::backend::{Scp02Backend, Scp03Backend};
21use crate::command::get_status::{get_status_p2, P2_FIRST_TLV, P2_NEXT_TLV};
22use crate::command::set_status::set_card_status as set_card_status_cmd;
23use crate::error::{ScllError, Warning, WarningKind};
24use crate::lifecycle::{check_transition, TransitionPlan};
25use crate::limits::{
26    MAX_APPLETS, MAX_ELFS, MAX_MODULES_PER_ELF, MAX_SDS, MAX_STATUS_PAGES, MAX_STATUS_SCOPE_BYTES,
27    MAX_WARNINGS,
28};
29use crate::model::{ApplicationEntry, CardInventory, ExecutableLoadFileEntry, SecurityDomainEntry};
30use crate::report::{
31    CardLifeCycle, GetCardInventoryParams, GetCardInventoryReport, GetCardStatusParams,
32    GetCardStatusReport, SetCardStatusParams, SetCardStatusReport,
33};
34use crate::response::{parse_status_e3, parse_status_registry, RegistryEntry};
35use crate::scp::ScpSession;
36use crate::tlv;
37use crate::transport::Transport;
38use crate::workflow::session::{self, SW_CONDITIONS, SW_MORE_DATA, SW_OK, SW_REF_NOT_FOUND};
39
40/// ISD / card scope P1 for GET STATUS and SET STATUS (GPCS v2.3.1 Table 11-33/86).
41const P1_ISD_SCOPE: u8 = 0x80;
42/// GET STATUS scope: Applications and Supplementary Security Domains
43/// (GPCS v2.3.1 Table 11-33, P1 `0x40`).
44const P1_APP_SD_SCOPE: u8 = 0x40;
45/// GET STATUS scope: Executable Load Files **and** their Executable Modules
46/// (GPCS v2.3.1 Table 11-33, P1 `0x10`; supersets the `0x20` ELF-only scope).
47const P1_ELF_MODULE_SCOPE: u8 = 0x10;
48/// Security Domain privilege — privileges byte 1, bit b8 (GPCS v2.3.1
49/// Table 11-7). Distinguishes a Supplementary Security Domain from a plain
50/// Application in the `0x40` scope, exactly as `gp --list` does.
51const PRIV_SECURITY_DOMAIN: u8 = 0x80;
52
53/// §5.12 — read the card (ISD) life-cycle state (read-only, idempotent).
54///
55/// # Errors
56/// Returns a transport / backend / [`ScllError::Card`] error if the GET STATUS
57/// exchange fails. A response with no parseable `'E3'`/`'9F70'` is **not** an
58/// error: it yields [`CardLifeCycle::Unknown`] plus a
59/// [`WarningKind::GetStatusParseFailed`].
60pub fn get_card_status<B>(
61    t: &mut dyn Transport,
62    backend: &B,
63    session: &mut ScpSession,
64    isd_aid: &[u8],
65) -> Result<GetCardStatusReport, ScllError>
66where
67    B: Scp02Backend + Scp03Backend,
68{
69    // See `read_status_scope`: GET STATUS response chaining (§11.4, Table
70    // 11-38) splits at the byte level, not on entry boundaries, so the scope
71    // is accumulated across any `63 10` pages before parsing — the single-page
72    // `get_status()` call this replaced would mis-parse a split response the
73    // same way `get_card_inventory` did (confirmed live against a JCOP 4 P71;
74    // in practice the ISD-only entry is small and rarely splits, but nothing
75    // in the spec guarantees that).
76    let (data, scope_truncated) = read_status_scope(t, backend, session, P1_ISD_SCOPE)?;
77    let mut warnings: Vec<Warning, MAX_WARNINGS> = Vec::new();
78    let decoded = if let Some(s) = parse_status_e3(&data)? {
79        s
80    } else {
81        let _ = warnings.push(Warning {
82            kind: WarningKind::GetStatusParseFailed,
83            detail: String::new(),
84        });
85        CardLifeCycle::Unknown(0)
86    };
87    if scope_truncated {
88        let _ = warnings.push(Warning {
89            kind: WarningKind::GetStatusParseFailed,
90            detail: String::new(),
91        });
92    }
93    let isd = Aid::new(isd_aid)?;
94    Ok(GetCardStatusReport {
95        state: decoded,
96        effective: GetCardStatusParams {
97            raw_state_byte: raw_byte(decoded),
98            decoded_state: decoded,
99            isd_aid: isd,
100        },
101        warnings,
102    })
103}
104
105/// §5.12a — enumerate the card's object inventory (the `gp --list` equivalent):
106/// Security Domains, Application instances, and Executable Load Files with their
107/// modules. Read-only and idempotent; modifies no card state.
108///
109/// Runs GET STATUS over the three registry scopes (GPCS v2.3.1 Table 11-33),
110/// each with `63 10` "more data" paging (Table 11-38):
111///   * P1 `0x80` — the Issuer Security Domain (recorded once, with no parent);
112///   * P1 `0x40` — Applications + Supplementary Security Domains, split by the
113///     Security Domain privilege bit (Table 11-7), with the ISD de-duplicated;
114///   * P1 `0x10` — Executable Load Files and their Executable Modules (`'84'`).
115///
116/// Capacity is bounded by the `CardInventory` limits (`MAX_SDS` / `MAX_APPLETS`
117/// / `MAX_ELFS`) and the per-scope page cap (`MAX_STATUS_PAGES`); hitting either
118/// yields a valid prefix plus [`WarningKind::InventoryTruncated`], never an
119/// error. Malformed individual AIDs are skipped; a structurally broken GET
120/// STATUS page is an [`ScllError::MalformedResponse`].
121///
122/// # Errors
123/// Returns [`ScllError::SecurityStatusNotSatisfied`] (`6982`, channel not open /
124/// insufficient level), a mapped [`ScllError`] for any other non-`9000`/`6310`/
125/// `6A88` SW, or a transport / backend error. An empty scope (`6A88`) is not an
126/// error — it contributes no entries.
127pub fn get_card_inventory<B>(
128    t: &mut dyn Transport,
129    backend: &B,
130    session: &mut ScpSession,
131    isd_aid: &[u8],
132) -> Result<GetCardInventoryReport, ScllError>
133where
134    B: Scp02Backend + Scp03Backend,
135{
136    let isd = Aid::new(isd_aid)?;
137    let mut security_domains: Vec<SecurityDomainEntry, MAX_SDS> = Vec::new();
138    let mut applets: Vec<ApplicationEntry, MAX_APPLETS> = Vec::new();
139    let mut elfs: Vec<ExecutableLoadFileEntry, MAX_ELFS> = Vec::new();
140    let mut truncated = false;
141
142    // 1) ISD scope (P1=0x80): exactly the Issuer Security Domain, shown once,
143    //    with no parent SD. Its AID is taken from the '4F' echo when valid.
144    truncated |= collect_scope(t, backend, session, P1_ISD_SCOPE, |e| {
145        let aid = Aid::new(e.aid).unwrap_or_else(|_| isd.clone());
146        security_domains
147            .push(SecurityDomainEntry {
148                aid,
149                life_cycle_state: e.life_cycle,
150                privileges: e.privileges,
151                associated_sd_aid: None,
152            })
153            .is_ok()
154    })?;
155
156    // 2) Applications + Supplementary SDs (P1=0x40). Classify by the Security
157    //    Domain privilege bit; de-duplicate the ISD (it is an SD, already held).
158    truncated |= collect_scope(t, backend, session, P1_APP_SD_SCOPE, |e| {
159        if e.aid == isd.as_bytes() {
160            return true; // ISD shown once — intentional skip, not truncation
161        }
162        let Ok(aid) = Aid::new(e.aid) else {
163            return true; // malformed AID — skip, not truncation
164        };
165        if e.privileges[0] & PRIV_SECURITY_DOMAIN != 0 {
166            let parent = e.associated_sd_aid.and_then(|s| Aid::new(s).ok());
167            security_domains
168                .push(SecurityDomainEntry {
169                    aid,
170                    life_cycle_state: e.life_cycle,
171                    privileges: e.privileges,
172                    associated_sd_aid: parent,
173                })
174                .is_ok()
175        } else {
176            let parent = e
177                .associated_sd_aid
178                .and_then(|s| Aid::new(s).ok())
179                .unwrap_or_else(|| isd.clone());
180            let elf = e.elf_aid.and_then(|s| Aid::new(s).ok());
181            applets
182                .push(ApplicationEntry {
183                    aid,
184                    life_cycle_state: e.life_cycle,
185                    privileges: e.privileges,
186                    associated_sd_aid: parent,
187                    associated_elf_aid: elf,
188                })
189                .is_ok()
190        }
191    })?;
192
193    // 3) Executable Load Files + Modules (P1=0x10).
194    truncated |= collect_scope(t, backend, session, P1_ELF_MODULE_SCOPE, |e| {
195        let Ok(aid) = Aid::new(e.aid) else {
196            return true;
197        };
198        let parent = e
199            .associated_sd_aid
200            .and_then(|s| Aid::new(s).ok())
201            .unwrap_or_else(|| isd.clone());
202        let mut modules: Vec<Aid, MAX_MODULES_PER_ELF> = Vec::new();
203        for m in &e.modules {
204            if let Ok(a) = Aid::new(m) {
205                let _ = modules.push(a);
206            }
207        }
208        elfs.push(ExecutableLoadFileEntry {
209            aid,
210            life_cycle_state: e.life_cycle,
211            associated_sd_aid: parent,
212            modules,
213        })
214        .is_ok()
215    })?;
216
217    let mut warnings: Vec<Warning, MAX_WARNINGS> = Vec::new();
218    if truncated {
219        let _ = warnings.push(Warning {
220            kind: WarningKind::InventoryTruncated,
221            detail: String::new(),
222        });
223    }
224
225    let (sd_count, app_count, elf_count) = (security_domains.len(), applets.len(), elfs.len());
226    Ok(GetCardInventoryReport {
227        inventory: CardInventory {
228            security_domains,
229            applets,
230            elfs,
231        },
232        effective: GetCardInventoryParams {
233            isd_aid: isd,
234            security_domain_count: sd_count,
235            application_count: app_count,
236            elf_count,
237            truncated,
238        },
239        warnings,
240    })
241}
242
243/// Drive a paged GET STATUS over one P1 scope, handing every decoded
244/// [`RegistryEntry`] to `store`. `store` returns `false` when it could not keep
245/// the entry (a `CardInventory` capacity bound); that — or a scope the
246/// underlying accumulation had to cut short — sets the returned `truncated`
247/// flag. A `6A88` empty scope ends the scan cleanly with whatever was stored.
248///
249/// Delegates the `63 10` (Table 11-38) paging to [`read_status_scope`], which
250/// accumulates raw bytes across pages before this function parses the scope
251/// exactly once — see that function's docs for why per-page parsing is unsound.
252fn collect_scope<B, F>(
253    t: &mut dyn Transport,
254    backend: &B,
255    session: &mut ScpSession,
256    p1: u8,
257    mut store: F,
258) -> Result<bool, ScllError>
259where
260    B: Scp02Backend + Scp03Backend,
261    F: FnMut(&RegistryEntry) -> bool,
262{
263    let (data, mut truncated) = read_status_scope(t, backend, session, p1)?;
264    if data.is_empty() && !truncated {
265        return Ok(false); // 6A88 empty scope, or a genuinely empty page
266    }
267    for entry in &parse_status_registry(&data)? {
268        if !store(entry) {
269            truncated = true;
270        }
271    }
272    Ok(truncated)
273}
274
275/// Drive one GET STATUS scope (`p1`) through its `63 10` "more data"
276/// continuations (Table 11-38), accumulating the **raw** response bytes into
277/// one buffer before any TLV parsing. GPCS v2.3.1 §11.4 chains the response at
278/// the **byte** level — a `'E3'` entry, or even a single value nested inside
279/// one (e.g. a module AID), can be split exactly at the page boundary — so
280/// parsing each page independently is unsound; only the fully-accumulated
281/// buffer, once the card signals `9000`, is guaranteed to be a complete,
282/// well-formed BER-TLV sequence. Confirmed against a live JCOP 4 P71
283/// (SCP02 i=0x55): a module AID's value was split exactly at the page
284/// boundary, and `gp -l -d`'s own trace shows the continuation resuming
285/// mid-value, matching `GlobalPlatformPro`'s `GPRegistry`, which buffers
286/// across pages the same way before parsing.
287///
288/// If accumulation cannot finish cleanly — the card keeps returning `63 10`
289/// past [`MAX_STATUS_PAGES`], the total exceeds [`MAX_STATUS_SCOPE_BYTES`], or
290/// the final `9000` buffer holds more top-level TLVs than [`crate::tlv::parse`]
291/// can hold — the return is the **last prefix of the buffer known to be a
292/// complete, parseable TLV sequence** (re-checked with `tlv::parse` after every
293/// page), plus `truncated = true`. This preserves the "valid prefix, never an
294/// error" contract the other inventory capacity limits already have; a
295/// genuinely malformed response (not a capacity issue) is still a hard
296/// [`crate::tlv::TlvError`] (surfaced by the caller's own `parse_status_e3` /
297/// `parse_status_registry` call on the returned prefix).
298///
299/// # Errors
300/// Returns a transport / backend error, or a mapped [`ScllError`] for any
301/// GET STATUS SW other than `9000` / `63 10` / `6A88`.
302fn read_status_scope<B>(
303    t: &mut dyn Transport,
304    backend: &B,
305    session: &mut ScpSession,
306    p1: u8,
307) -> Result<(Vec<u8, MAX_STATUS_SCOPE_BYTES>, bool), ScllError>
308where
309    B: Scp02Backend + Scp03Backend,
310{
311    let mut buf: Vec<u8, MAX_STATUS_SCOPE_BYTES> = Vec::new();
312    let mut good_len = 0usize;
313    let mut p2 = P2_FIRST_TLV;
314    let mut truncated = false;
315    let mut done = false;
316    for _ in 0..MAX_STATUS_PAGES {
317        let capdu = get_status_p2(p1, p2, &[])?;
318        let (data, sw) = session::transmit_in_session(t, backend, session, &capdu)?;
319        match sw {
320            SW_OK | SW_MORE_DATA => {
321                if buf.extend_from_slice(&data).is_err() {
322                    // MAX_STATUS_SCOPE_BYTES exceeded — stop with the last
323                    // known-good prefix rather than a partial, unparseable tail.
324                    truncated = true;
325                    break;
326                }
327                if tlv::parse(&buf).is_ok() {
328                    // Buffer so far is a complete top-level TLV sequence —
329                    // checkpoint it. True after most pages in practice (an
330                    // entry rarely straddles *every* boundary), and always
331                    // true once the card actually finishes.
332                    good_len = buf.len();
333                } else if sw == SW_OK {
334                    // Card says complete, but we can't parse it as one TLV
335                    // sequence (e.g. more top-level entries than tlv::parse
336                    // holds) — fall back to the last checkpoint instead of
337                    // erroring; a capacity limit truncates, it never fails.
338                    truncated = true;
339                }
340                if sw == SW_OK {
341                    done = true;
342                    break;
343                }
344                p2 = P2_NEXT_TLV;
345            }
346            SW_REF_NOT_FOUND => return Ok((Vec::new(), false)), // 6A88 — empty scope
347            other => return Err(ScllError::from_general_sw(other)),
348        }
349    }
350    if !done {
351        truncated = true; // MAX_STATUS_PAGES exhausted without a 9000
352    }
353    buf.truncate(good_len);
354    Ok((buf, truncated))
355}
356
357/// Inputs to [`set_card_status`]. `current_state = None` triggers a GET STATUS
358/// read first. `force` permits the spec-legal skip-ahead to `SECURED`
359/// (§5.1.2); it never bypasses the `TERMINATED` or backward-transition refusal.
360pub struct SetCardStatusArgs<'a> {
361    pub target_state: CardLifeCycle,
362    pub current_state: Option<CardLifeCycle>,
363    pub force: bool,
364    pub isd_aid: &'a [u8],
365}
366
367/// §5.11 — write the card (ISD) life-cycle state.
368///
369/// # Errors
370/// Returns [`ScllError::TerminateOutOfScope`] if `target_state` is `TERMINATED`,
371/// [`ScllError::IllegalLifecycleTransition`] for an illegal transition (or a
372/// card `6985`), or a transport / backend / [`ScllError::Card`] error.
373pub fn set_card_status<B>(
374    t: &mut dyn Transport,
375    backend: &B,
376    session: &mut ScpSession,
377    args: &SetCardStatusArgs<'_>,
378) -> Result<SetCardStatusReport, ScllError>
379where
380    B: Scp02Backend + Scp03Backend,
381{
382    let current = match args.current_state {
383        Some(s) => s,
384        None => get_card_status(t, backend, session, args.isd_aid)?.state,
385    };
386    let plan = check_transition(current, args.target_state, args.force)?;
387    let mut warnings: Vec<Warning, MAX_WARNINGS> = Vec::new();
388    let (was_no_op, p2_state_byte) = match plan {
389        TransitionPlan::NoOp => {
390            let _ = warnings.push(Warning {
391                kind: WarningKind::LifecycleNoOp,
392                detail: String::new(),
393            });
394            (true, raw_byte(args.target_state))
395        }
396        TransitionPlan::Apply { p2 } => {
397            let capdu = set_card_status_cmd(p2, args.isd_aid)?;
398            let (_d, sw) = session::transmit_in_session(t, backend, session, &capdu)?;
399            match sw {
400                SW_OK => {}
401                // 6985 means IllegalLifecycleTransition for SET STATUS (Table 11-87).
402                SW_CONDITIONS => return Err(ScllError::IllegalLifecycleTransition),
403                other => return Err(ScllError::from_general_sw(other)),
404            }
405            (false, p2)
406        }
407    };
408    let irreversible = matches!(
409        args.target_state,
410        CardLifeCycle::Initialized | CardLifeCycle::Secured
411    ) && current != CardLifeCycle::CardLocked;
412
413    Ok(SetCardStatusReport {
414        effective: SetCardStatusParams {
415            state_before: current,
416            target_state: args.target_state,
417            p1_status_type: P1_ISD_SCOPE,
418            p2_state_byte,
419            was_no_op,
420            force_used: args.force,
421            irreversible,
422        },
423        warnings,
424    })
425}
426
427/// Raw GP life-cycle byte for a [`CardLifeCycle`] (GPCS v2.3.1 Table 11-6).
428fn raw_byte(s: CardLifeCycle) -> u8 {
429    match s {
430        CardLifeCycle::OpReady => 0x01,
431        CardLifeCycle::Initialized => 0x07,
432        CardLifeCycle::Secured => 0x0F,
433        CardLifeCycle::CardLocked => 0x7F,
434        CardLifeCycle::Terminated => 0xFF,
435        CardLifeCycle::Unknown(b) => b,
436    }
437}