// Stateful APDU/EMV transaction analysis.
// This layer is intentionally above TLV/APDU codecs: it checks application
// selection, command order, AFL-driven record access, mandatory data objects,
// CDOL length and terminal-visible status words.
pub(all) struct SessionIssue {
step : Int
code : String
severity : String
detail : String
}
pub(all) struct SessionReport {
verdict : String
candidates : Array[String]
selected_aid : String?
aip : String?
afl : Array[AflEntry]
records : Array[(Int, Int)]
issues : Array[SessionIssue]
steps : Int
}
fn hex_compact(bytes : Array[Int]) -> String {
let out = StringBuilder()
for b in bytes {
out.write_char(hex_nibble_char(b >> 4))
out.write_char(hex_nibble_char(b))
}
out.to_string()
}
fn issue(
issues : Array[SessionIssue],
step : Int,
code : String,
severity : String,
detail : String,
) -> Unit {
issues.push({ step, code, severity, detail })
}
pub fn collect_tag_values(items : Array[Tlv], tag : Int) -> Array[Array[Int]] {
let out : Array[Array[Int]] = Array::new()
for item in items {
if item.tag == tag {
out.push(item.value)
}
for child in collect_tag_values(item.children, tag) {
out.push(child)
}
}
out
}
pub fn parse_cdol(value : Array[Int]) -> Array[(Int, Int)] {
let out : Array[(Int, Int)] = Array::new()
let mut i = 0
while i < value.length() {
let first = value[i]
i = i + 1
let tag = if (first & 0x1f) == 0x1f && i < value.length() {
let second = value[i]
i = i + 1
(first << 8) | second
} else {
first
}
if i >= value.length() {
break
}
let len = value[i]
i = i + 1
out.push((tag, len))
}
out
}
pub fn cdol_total_length(fields : Array[(Int, Int)]) -> Int {
let mut total = 0
for field in fields {
total = total + field.1
}
total
}
pub(all) suberror DolError { InvalidDol }
// EMV DOL lengths are one-byte field widths, not BER value lengths.
pub fn parse_dol(bytes : Array[Int]) -> Array[(Int, Int)] raise DolError {
let out : Array[(Int, Int)] = []
let mut i = 0
while i < bytes.length() {
let first = bytes[i]
if first <= 0 || first >= 255 { raise InvalidDol }
let mut tag = first
i = i + 1
if (first & 31) == 31 {
let mut count = 1
while true {
if i >= bytes.length() || count >= 4 { raise InvalidDol }
let b = bytes[i]
if count == 1 && (b & 127) == 0 { raise InvalidDol }
tag = (tag << 8) | b
i = i + 1
count = count + 1
if (b & 128) == 0 { break }
}
}
if i >= bytes.length() { raise InvalidDol }
let length = bytes[i]
if length < 0 || length > 255 { raise InvalidDol }
out.push((tag,length))
i = i + 1
}
out
}
fn is_ppse_select(command : Apdu) -> Bool {
command.ins == 0xa4 && command.p1 == 0x04 &&
ascii_of(command.data).contains("2PAY")
}
fn record_items(response : ApduResponse) -> Array[Tlv] raise TlvError {
let items = parse_tlv(response.data)
match find_tag(items, 0x70) {
Some(template) => template.children
None => items
}
}
pub fn analyze_session(raw_report : TxReport) -> SessionReport {
let report = resolve_apdu_procedures(raw_report)
let issues : Array[SessionIssue] = Array::new()
let candidates : Array[String] = Array::new()
let records : Array[(Int, Int)] = Array::new()
let mut selected_aid : String? = None
let mut aip : String? = None
let mut afl : Array[AflEntry] = Array::new()
let mut gpo_seen = false
let mut cdol_length = 0
let mut pdol_length = 0
let mut completed = false
let mut pan_seen = false
let mut expiry_seen = false
for _ in report.errors {
issue(issues, 0, "LOG_PARSE_ERROR", "fail", "log contains an unparseable line or APDU")
}
let mut i = 0
while i < report.steps.length() {
let step = report.steps[i]
match step.apdu {
None => {
issue(issues, i + 1, "UNPAIRED_STEP", "fail", "response or malformed command without a paired command")
i = i + 1
}
Some(command) => {
let step_no = i + 1
let response = if i + 1 < report.steps.length() {
report.steps[i + 1].response
} else {
None
}
let response_tlv = if i + 1 < report.steps.length() {
report.steps[i + 1].tlv
} else {
[]
}
match response {
None => issue(issues, step_no, "MISSING_RESPONSE", "fail", "command has no following response")
Some(resp) => {
if resp.sw != 0x9000 {
issue(
issues,
step_no,
"STATUS_ERROR",
"fail",
"SW=" + hex_int(resp.sw, 4) + " " + status_description(resp.sw),
)
i = i + 2
continue
}
if command.ins == 0xa4 && command.p1 == 0x04 {
// An application selection starts a fresh transaction context.
selected_aid = None
aip = None
afl = []
records.clear()
gpo_seen = false
cdol_length = 0
pdol_length = 0
completed = false
pan_seen = false
expiry_seen = false
if is_ppse_select(command) {
candidates.clear()
for value in collect_tag_values(response_tlv, 0x4f) {
candidates.push(hex_compact(value))
}
if candidates.length() == 0 {
issue(issues, step_no, "PPSE_NO_CANDIDATES", "review", "PPSE response has no 4F ADF names")
}
} else {
let requested = hex_compact(command.data)
let returned = match find_tag(response_tlv, 0x84) {
Some(tag) => hex_compact(tag.value)
None => {
issue(issues, step_no, "MISSING_DF_NAME", "fail", "SELECT response has no 84 DF name")
""
}
}
selected_aid = Some(if returned.length() > 0 { returned } else { requested })
match find_tag(response_tlv,0x9f38) {
Some(dol) => try {
pdol_length = cdol_total_length(parse_dol(dol.value))
} catch { _ => issue(issues,step_no,"INVALID_PDOL","fail","SELECT contains a malformed PDOL") }
None => ()
}
if requested.length() == 0 || returned.length() > 0 && !returned.has_prefix(requested) {
issue(
issues,
step_no,
"AID_MISMATCH",
"fail",
"requested " + requested + " but card returned " + returned,
)
}
if candidates.length() > 0 {
let mut found = false
for candidate in candidates {
if candidate == returned {
found = true
}
}
if !found {
issue(issues, step_no, "AID_NOT_IN_CANDIDATES", "review", "selected AID is not in PPSE candidates")
}
}
}
} else if command.ins == 0xa8 {
let pdol = try { find_tag(parse_tlv(command.data),0x83) } catch { _ => None }
match pdol {
Some(tag) => if tag.value.length() != pdol_length {
issue(issues,step_no,"PDOL_LENGTH_MISMATCH","fail","GPO command does not match SELECT PDOL field lengths")
}
None => issue(issues,step_no,"GPO_COMMAND_TEMPLATE","fail","GPO command has no valid 83 PDOL template")
}
if selected_aid.is_none() {
issue(issues, step_no, "GPO_BEFORE_SELECT", "fail", "GPO has no successful application selection")
}
gpo_seen = true
let gpo = try {
parse_gpo_response(resp.data)
} catch {
_ => None
}
match gpo {
Some(info) => {
aip = Some(hex_compact(info.aip))
afl = parse_afl(info.afl)
if afl.length() == 0 {
issue(issues, step_no, "GPO_NO_AFL", "fail", "GPO response has an empty AFL")
}
}
None => issue(issues, step_no, "GPO_TEMPLATE", "fail", "GPO response is not 77/80")
}
} else if command.ins == 0xb2 {
let sfi = (command.p2 >> 3) & 0x1f
let record = command.p1
let mut in_afl = false
for entry in afl {
if entry.sfi == sfi && record >= entry.first_record && record <= entry.last_record {
in_afl = true
}
}
if !in_afl {
issue(
issues,
step_no,
"RECORD_NOT_IN_AFL",
"fail",
"READ RECORD sfi=" + sfi.to_string() + " record=" + record.to_string() + " is outside AFL",
)
}
records.push((sfi, record))
let items = try {
record_items(resp)
} catch {
_ => []
}
if items.length() == 0 {
issue(issues, step_no, "RECORD_TEMPLATE", "fail", "READ RECORD response has no usable TLV")
} else {
let pan = find_tag(items, 0x5a).is_some() || find_tag(items, 0x57).is_some()
pan_seen = pan_seen || pan
expiry_seen = expiry_seen || find_tag(items, 0x5f24).is_some()
match find_tag(items, 0x8c) {
Some(tag) => try { cdol_length = cdol_total_length(parse_dol(tag.value)) }
catch { _ => issue(issues,step_no,"INVALID_CDOL","fail","record contains a malformed CDOL1") }
None => ()
}
}
} else if command.ins == 0xae {
completed = true
for entry in afl {
for number in entry.first_record..<(entry.last_record + 1) {
if !records.contains((entry.sfi, number)) {
issue(issues, step_no, "AFL_RECORD_MISSING", "fail", "GENERATE AC before all AFL records were read")
}
}
}
if !gpo_seen {
issue(issues, step_no, "GENERATE_AC_BEFORE_GPO", "fail", "GENERATE AC before GPO")
}
if records.length() == 0 {
issue(issues, step_no, "GENERATE_AC_BEFORE_RECORD", "fail", "GENERATE AC before any READ RECORD")
}
if cdol_length == 0 {
issue(issues, step_no, "MISSING_CDOL", "review", "no 8C CDOL1 was seen before GENERATE AC")
} else if command.data.length() != cdol_length {
issue(
issues,
step_no,
"CDOL_LENGTH_MISMATCH",
"fail",
"CDOL1 expects " + cdol_length.to_string() + " bytes but command has " + command.data.length().to_string(),
)
}
}
}
}
i = i + (if response.is_some() { 2 } else { 1 })
}
}
}
if !completed {
issue(issues, report.steps.length(), "INCOMPLETE_TRANSACTION", "review", "capture has no successful GENERATE AC; no complete transaction verdict is available")
}
if records.length() > 0 && !pan_seen {
issue(issues, report.steps.length(), "MISSING_PAN", "fail", "none of the application records has 5A or 57")
}
if records.length() > 0 && !expiry_seen {
issue(issues, report.steps.length(), "MISSING_EXPIRY", "review", "no 5F24 expiry was found across application records")
}
let mut fail = false
let mut review = false
for item in issues {
if item.severity == "fail" {
fail = true
} else {
review = true
}
}
{
verdict: if fail { "fail" } else if review { "review" } else { "pass" },
candidates,
selected_aid,
aip,
afl,
records,
issues,
steps: report.steps.length(),
}
}
pub fn render_session(report : SessionReport) -> String {
let out = StringBuilder()
out.write_string("verdict: " + report.verdict + "\n")
out.write_string("steps: " + report.steps.to_string() + "\n")
out.write_string("candidates: " + report.candidates.join(",") + "\n")
match report.selected_aid {
Some(aid) => out.write_string("selected AID: " + aid + "\n")
None => out.write_string("selected AID: -\n")
}
match report.aip {
Some(value) => out.write_string("AIP: " + value + "\n")
None => ()
}
out.write_string("AFL: " + report.afl.length().to_string() + "\n")
out.write_string("records read: " + report.records.length().to_string() + "\n")
for item in report.issues {
out.write_string(
"issue step=" + item.step.to_string() + " " + item.severity + " " +
item.code + ": " + item.detail + "\n",
)
}
out.to_string()
}