Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Fixed
- Fixed automatic chaining of APDU responses with `61XX` status words to properly accumulate all data segments when the
card returns chained responses (issue [#86]).

## [3.3.6] - 2025-10-23
### Added
Expand Down Expand Up @@ -242,6 +245,7 @@ It also brings many major API changes.
[2.0.1]: https://github.com/eclipse-keyple/keyple-service-java-lib/compare/2.0.0...2.0.1
[2.0.0]: https://github.com/eclipse-keyple/keyple-service-java-lib/releases/tag/2.0.0

[#86]: https://github.com/eclipse-keyple/keyple-service-java-lib/issues/86
[#79]: https://github.com/eclipse-keyple/keyple-service-java-lib/issues/79
[#78]: https://github.com/eclipse-keyple/keyple-service-java-lib/issues/78
[#74]: https://github.com/eclipse-keyple/keyple-service-java-lib/issues/74
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
group = org.eclipse.keyple
title = Keyple Service Java Lib
description = Keyple core components
version = 3.3.6-SNAPSHOT
version = 3.3.7-SNAPSHOT

# Java Configuration
javaSourceLevel = 1.8
Expand Down
136 changes: 100 additions & 36 deletions src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java
Original file line number Diff line number Diff line change
Expand Up @@ -380,45 +380,109 @@ private ApduResponseAdapter processApduRequest(ApduRequestSpi apduRequest)
elapsed10ms / 10.0);
}

if (apduResponse.getDataOut().length == 0 && isAutomaticStatusCodeHandlingEnabled) {
if (isAutomaticStatusCodeHandlingEnabled) {

if ((apduResponse.getStatusWord() & SW1_MASK) == SW_6100) {
// RL-SW-61XX.1
// Build a GetResponse APDU command with the provided "le"
byte[] getResponseApdu = {
(byte) 0x00,
(byte) 0xC0,
(byte) 0x00,
(byte) 0x00,
(byte) (apduResponse.getStatusWord() & SW2_MASK)
};
// Execute APDU
apduResponse =
processApduRequest(new ApduRequest(getResponseApdu).setInfo("Internal Get Response"));

} else if ((apduResponse.getStatusWord() & SW1_MASK) == SW_6C00) {
// RL-SW-6CXX.1
// Update the last command with the provided "le"
apduRequest.getApdu()[apduRequest.getApdu().length - 1] =
(byte) (apduResponse.getStatusWord() & SW2_MASK);
// Replay the last command APDU
apduResponse = processApduRequest(apduRequest);

} else if (ApduUtil.isCase4(apduRequest.getApdu())
&& apduRequest.getSuccessfulStatusWords().contains(apduResponse.getStatusWord())) {
// RL-SW-ANALYSIS.1
// RL-SW-CASE4.1 (SW=6200 not taken into account here)
// Build a GetResponse APDU command with the original "le"
byte[] getResponseApdu = {
(byte) 0x00,
(byte) 0xC0,
(byte) 0x00,
(byte) 0x00,
apduRequest.getApdu()[apduRequest.getApdu().length - 1]
};
// Execute GetResponse APDU
apduResponse =
processApduRequest(new ApduRequest(getResponseApdu).setInfo("Internal Get Response"));
// Handle chained responses by accumulating data from multiple GET RESPONSE commands
List<byte[]> dataChunks = new ArrayList<>();

// Add initial data if present
if (apduResponse.getDataOut().length > 0) {
dataChunks.add(apduResponse.getDataOut());
}

// Keep sending GET RESPONSE until we get a status word other than 61XX
while ((apduResponse.getStatusWord() & SW1_MASK) == SW_6100) {
// Build a GetResponse APDU command with the length from SW2
byte[] getResponseApdu = {
(byte) 0x00,
(byte) 0xC0,
(byte) 0x00,
(byte) 0x00,
(byte) (apduResponse.getStatusWord() & SW2_MASK)
};

if (logger.isDebugEnabled()) {
long timeStamp = System.nanoTime();
long elapsed10ms = (timeStamp - before) / 100000;
this.before = timeStamp;
logger.debug(
"Reader [{}] --> GET RESPONSE (chained): {}, elapsed {} ms",
this.getName(),
HexUtil.toHex(getResponseApdu),
elapsed10ms / 10.0);
}

// Execute APDU directly to avoid recursive status handling
byte[] responseBytes = readerSpi.transmitApdu(getResponseApdu);
apduResponse = new ApduResponseAdapter(responseBytes);

if (logger.isDebugEnabled()) {
long timeStamp = System.nanoTime();
long elapsed10ms = (timeStamp - before) / 100000;
this.before = timeStamp;
logger.debug(
"Reader [{}] <-- apduResponse (chained): {}, elapsed {} ms",
this.getName(),
apduResponse,
elapsed10ms / 10.0);
}

// Add data from this response
if (apduResponse.getDataOut().length > 0) {
dataChunks.add(apduResponse.getDataOut());
}
}

// Merge all data chunks into a single response with the final status word
if (!dataChunks.isEmpty()) {
int totalLength = 0;
for (byte[] chunk : dataChunks) {
totalLength += chunk.length;
}

byte[] completeApdu = new byte[totalLength + 2]; // +2 for status word
int offset = 0;
for (byte[] chunk : dataChunks) {
System.arraycopy(chunk, 0, completeApdu, offset, chunk.length);
offset += chunk.length;
}

// Append final status word
completeApdu[totalLength] = (byte) ((apduResponse.getStatusWord() >> 8) & 0xFF);
completeApdu[totalLength + 1] = (byte) (apduResponse.getStatusWord() & 0xFF);

apduResponse = new ApduResponseAdapter(completeApdu);
}

} else if (apduResponse.getDataOut().length == 0) {
// Handle 6CXX and Case4 only when there's no data in the response

if ((apduResponse.getStatusWord() & SW1_MASK) == SW_6C00) {
// RL-SW-6CXX.1
// Update the last command with the provided "le"
apduRequest.getApdu()[apduRequest.getApdu().length - 1] =
(byte) (apduResponse.getStatusWord() & SW2_MASK);
// Replay the last command APDU
apduResponse = processApduRequest(apduRequest);

} else if (ApduUtil.isCase4(apduRequest.getApdu())
&& apduRequest.getSuccessfulStatusWords().contains(apduResponse.getStatusWord())) {
// RL-SW-ANALYSIS.1
// RL-SW-CASE4.1 (SW=6200 not taken into account here)
// Build a GetResponse APDU command with the original "le"
byte[] getResponseApdu = {
(byte) 0x00,
(byte) 0xC0,
(byte) 0x00,
(byte) 0x00,
apduRequest.getApdu()[apduRequest.getApdu().length - 1]
};
// Execute GetResponse APDU
apduResponse =
processApduRequest(new ApduRequest(getResponseApdu).setInfo("Internal Get Response"));
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -558,4 +558,155 @@ public void isCardPresent_whenReaderSpiFails_shouldKRCE() throws Exception {
localReaderAdapter.register();
localReaderAdapter.isCardPresent();
}

/*
* APDU chaining tests (61XX status word handling)
*/

@Test
public void transmitCardRequest_with61XXResponse_withNoInitialData_shouldChainGetResponse()
throws Exception {
byte[] requestApdu = HexUtil.toByteArray("00A4040000");
// First response: no data, status 6110 (16 bytes available)
byte[] firstResponseApdu = HexUtil.toByteArray("6110");
// GET RESPONSE command: 00C0000010
byte[] getResponseApdu = HexUtil.toByteArray("00C0000010");
// GET RESPONSE response: 16 bytes + 9000
byte[] getResponseCApdu = HexUtil.toByteArray("112233445566778899AABBCCDDEEFF009000");

when(apduRequestSpi.getApdu()).thenReturn(requestApdu);
when(readerSpi.transmitApdu(requestApdu)).thenReturn(firstResponseApdu);
when(readerSpi.transmitApdu(getResponseApdu)).thenReturn(getResponseCApdu);

LocalReaderAdapter localReaderAdapter = new LocalReaderAdapter(readerSpi, PLUGIN_NAME);
localReaderAdapter.register();
CardResponseApi response =
localReaderAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.CLOSE_AFTER);

// Should return the merged data with final status word
assertThat(response.getApduResponses().get(0).getApdu()).isEqualTo(getResponseCApdu);
assertThat(response.getApduResponses().get(0).getStatusWord()).isEqualTo(0x9000);
assertThat(response.getApduResponses().get(0).getDataOut())
.isEqualTo(HexUtil.toByteArray("112233445566778899AABBCCDDEEFF00"));
}

@Test
public void transmitCardRequest_with61XXResponse_withInitialData_shouldChainAndAccumulateData()
throws Exception {
byte[] requestApdu = HexUtil.toByteArray("00A4040000");
// First response: 9 bytes of data, status 6108 (8 more bytes available)
byte[] firstResponseApdu = HexUtil.toByteArray("AABBCCDDEE112233446108");
// GET RESPONSE command: 00C0000008
byte[] getResponseApdu = HexUtil.toByteArray("00C0000008");
// GET RESPONSE response: 8 bytes + 9000
byte[] getResponseCApdu = HexUtil.toByteArray("55667788990011229000");

when(apduRequestSpi.getApdu()).thenReturn(requestApdu);
when(readerSpi.transmitApdu(requestApdu)).thenReturn(firstResponseApdu);
when(readerSpi.transmitApdu(getResponseApdu)).thenReturn(getResponseCApdu);

LocalReaderAdapter localReaderAdapter = new LocalReaderAdapter(readerSpi, PLUGIN_NAME);
localReaderAdapter.register();
CardResponseApi response =
localReaderAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.CLOSE_AFTER);

// Should return all data accumulated: first 9 bytes + second 8 bytes = 17 bytes total
byte[] expectedData = HexUtil.toByteArray("AABBCCDDEE112233445566778899001122");
byte[] expectedApdu = HexUtil.toByteArray("AABBCCDDEE1122334455667788990011229000");

assertThat(response.getApduResponses().get(0).getApdu()).isEqualTo(expectedApdu);
assertThat(response.getApduResponses().get(0).getStatusWord()).isEqualTo(0x9000);
assertThat(response.getApduResponses().get(0).getDataOut()).isEqualTo(expectedData);
}

@Test
public void transmitCardRequest_with61XXResponse_multipleChains_shouldAccumulateAllData()
throws Exception {
byte[] requestApdu = HexUtil.toByteArray("00A4040000");
// First response: 4 bytes, status 6104 (4 more bytes)
byte[] firstResponseApdu = HexUtil.toByteArray("AABBCCDD6104");
// GET RESPONSE: 00C0000004 (will be sent twice with different responses)
byte[] getResponseApdu = HexUtil.toByteArray("00C0000004");
// Second response: 4 bytes, status 6104 (4 more bytes)
byte[] secondResponseApdu = HexUtil.toByteArray("112233446104");
// Third response: 4 bytes, status 9000 (done)
byte[] thirdResponseApdu = HexUtil.toByteArray("556677889000");

when(apduRequestSpi.getApdu()).thenReturn(requestApdu);
when(readerSpi.transmitApdu(requestApdu)).thenReturn(firstResponseApdu);
// Chain multiple responses for the same GET RESPONSE command
when(readerSpi.transmitApdu(getResponseApdu))
.thenReturn(secondResponseApdu)
.thenReturn(thirdResponseApdu);

LocalReaderAdapter localReaderAdapter = new LocalReaderAdapter(readerSpi, PLUGIN_NAME);
localReaderAdapter.register();
CardResponseApi response =
localReaderAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.CLOSE_AFTER);

// Should accumulate all three chunks: 4 + 4 + 4 = 12 bytes
byte[] expectedData = HexUtil.toByteArray("AABBCCDD1122334455667788");
byte[] expectedApdu = HexUtil.toByteArray("AABBCCDD11223344556677889000");

assertThat(response.getApduResponses().get(0).getApdu()).isEqualTo(expectedApdu);
assertThat(response.getApduResponses().get(0).getStatusWord()).isEqualTo(0x9000);
assertThat(response.getApduResponses().get(0).getDataOut()).isEqualTo(expectedData);
}

@Test
public void
transmitCardRequest_with61XXResponse_finalStatusNotSuccess_shouldReturnAllDataWithFinalStatus()
throws Exception {
byte[] requestApdu = HexUtil.toByteArray("00A4040000");
// First response: 4 bytes, status 6104
byte[] firstResponseApdu = HexUtil.toByteArray("AABBCCDD6104");
// GET RESPONSE: 00C0000004
byte[] getResponseApdu = HexUtil.toByteArray("00C0000004");
// Second response: 4 bytes, status 6283 (file invalidated)
byte[] secondResponseApdu = HexUtil.toByteArray("112233446283");

when(apduRequestSpi.getApdu()).thenReturn(requestApdu);
// Allow both 9000 and 6283 as successful
when(apduRequestSpi.getSuccessfulStatusWords())
.thenReturn(new HashSet<Integer>(Arrays.asList(0x9000, 0x6283)));
when(readerSpi.transmitApdu(requestApdu)).thenReturn(firstResponseApdu);
when(readerSpi.transmitApdu(getResponseApdu)).thenReturn(secondResponseApdu);

LocalReaderAdapter localReaderAdapter = new LocalReaderAdapter(readerSpi, PLUGIN_NAME);
localReaderAdapter.register();
CardResponseApi response =
localReaderAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.CLOSE_AFTER);

// Should accumulate data and return final status 6283
byte[] expectedData = HexUtil.toByteArray("AABBCCDD11223344");
byte[] expectedApdu = HexUtil.toByteArray("AABBCCDD112233446283");

assertThat(response.getApduResponses().get(0).getApdu()).isEqualTo(expectedApdu);
assertThat(response.getApduResponses().get(0).getStatusWord()).isEqualTo(0x6283);
assertThat(response.getApduResponses().get(0).getDataOut()).isEqualTo(expectedData);
}

@Test
public void transmitCardRequest_with6100Response_withMaxLength_shouldRequestMaxBytes()
throws Exception {
byte[] requestApdu = HexUtil.toByteArray("00A4040000");
// Response: status 6100 (0 bytes in SW2 means 256 bytes available)
byte[] firstResponseApdu = HexUtil.toByteArray("6100");
// GET RESPONSE should request 0x00 (which means 256)
byte[] getResponseApdu = HexUtil.toByteArray("00C0000000");
// Return some data with 9000
byte[] getResponseCApdu = HexUtil.toByteArray("AABBCCDD9000");

when(apduRequestSpi.getApdu()).thenReturn(requestApdu);
when(readerSpi.transmitApdu(requestApdu)).thenReturn(firstResponseApdu);
when(readerSpi.transmitApdu(getResponseApdu)).thenReturn(getResponseCApdu);

LocalReaderAdapter localReaderAdapter = new LocalReaderAdapter(readerSpi, PLUGIN_NAME);
localReaderAdapter.register();
CardResponseApi response =
localReaderAdapter.transmitCardRequest(cardRequestSpi, ChannelControl.CLOSE_AFTER);

assertThat(response.getApduResponses().get(0).getApdu()).isEqualTo(getResponseCApdu);
assertThat(response.getApduResponses().get(0).getStatusWord()).isEqualTo(0x9000);
}
}