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}