diff --git a/CHANGELOG.md b/CHANGELOG.md index a873aab1..e6f110b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/gradle.properties b/gradle.properties index 83e44f67..21a449ae 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 diff --git a/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java b/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java index 2698323a..a4903d60 100644 --- a/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java +++ b/src/main/java/org/eclipse/keyple/core/service/LocalReaderAdapter.java @@ -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 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")); + } } } diff --git a/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java b/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java index 97dff940..b5519fdb 100644 --- a/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java +++ b/src/test/java/org/eclipse/keyple/core/service/LocalReaderAdapterTest.java @@ -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(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); + } }