diff --git a/demoapp/app/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/example/card/CardReaderFragment.kt b/demoapp/app/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/example/card/CardReaderFragment.kt index 0f05ede..9867c55 100644 --- a/demoapp/app/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/example/card/CardReaderFragment.kt +++ b/demoapp/app/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/example/card/CardReaderFragment.kt @@ -8,7 +8,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.nfc.NfcAdapter -import android.nfc.TagLostException import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -27,6 +26,7 @@ import ee.ria.DigiDoc.idcard.CodeVerificationException import ee.ria.DigiDoc.idcard.PaceTunnelException import ee.ria.DigiDoc.idcard.TokenWithPace import ee.ria.DigiDoc.smartcardreader.ApduResponseException +import ee.ria.DigiDoc.smartcardreader.CardConnectionLostException import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException import ee.ria.DigiDoc.smartcardreader.nfc.NfcSmartCardReaderManager import ee.ria.DigiDoc.smartcardreader.nfc.NfcSmartCardReaderManager.NfcStatus @@ -303,7 +303,7 @@ class CardReaderFragment : Fragment() { Toast.makeText(requireContext(), "Error communicating with card", Toast.LENGTH_SHORT).show() } else { - if (ex.cause is TagLostException) { + if (ex is CardConnectionLostException) { Toast.makeText(requireContext(), "Tag was lost", Toast.LENGTH_SHORT).show() } else { Toast.makeText(requireContext(), ex.message!!, Toast.LENGTH_SHORT).show() diff --git a/libs/id-card-lib/build.gradle.kts b/libs/id-card-lib/build.gradle.kts index a527fb8..7b21093 100644 --- a/libs/id-card-lib/build.gradle.kts +++ b/libs/id-card-lib/build.gradle.kts @@ -36,4 +36,8 @@ dependencies { implementation(project(":libs:smart-card-reader-lib")) implementation(project(":libs:card-utils-lib")) -} \ No newline at end of file + + testImplementation(libs.junit) + testImplementation(libs.mockito.core) + testImplementation(libs.truth) +} diff --git a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeNotActivatedException.java b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeNotActivatedException.java new file mode 100644 index 0000000..dc18b8b --- /dev/null +++ b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeNotActivatedException.java @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +package ee.ria.DigiDoc.idcard; + +public class CodeNotActivatedException extends IdCardException { + + private final CodeType type; + + public CodeNotActivatedException(CodeType type) { + super(type + " has not been changed, operation not allowed"); + this.type = type; + } + + public CodeType getType() { + return type; + } +} diff --git a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeType.java b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeType.java index 448d973..914298e 100644 --- a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeType.java +++ b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/CodeType.java @@ -5,15 +5,25 @@ public enum CodeType { - PIN1((byte) 0x01, (byte) 0x01), - PIN2((byte) 0x02, (byte) 0x02), - PUK((byte) 0x00, (byte) 0x03); + PIN1((byte) 0x01, (byte) 0x01, 4, 12), + PIN2((byte) 0x02, (byte) 0x02, 5, 12), + PUK((byte) 0x00, (byte) 0x03, 8, 12); public final byte value; public final byte retryValue; - CodeType(byte value, byte retryValue) { + public final int minLength; + + public final int maxLength; + + CodeType(byte value, byte retryValue, int minLength, int maxLength) { this.value = value; this.retryValue = retryValue; + this.minLength = minLength; + this.maxLength = maxLength; + } + + public boolean isLengthValid(int length) { + return length >= minLength && length <= maxLength; } } diff --git a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/Thales.java b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/Thales.java index e7e6d20..2221b63 100644 --- a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/Thales.java +++ b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/Thales.java @@ -23,6 +23,10 @@ class Thales implements Token { + private static final int TAG_VALUE_ABSENT = -1; + private static final int TAG_PIN_CHANGED = 0xDF2F; + private static final int TAG_RETRY_COUNTER = 0xDF21; + private static final Map CERT_MAP = new HashMap<>(); static { CERT_MAP.put(CertificateType.AUTHENTICATION, new byte[] {(byte) 0xAD, (byte) 0xF1, 0x34, 0x11}); @@ -96,7 +100,7 @@ private byte[] readFile(int p1, byte[] bytes) throws SmartCardReaderException { return stream.toByteArray(); } - private static int extractTagValue(byte[] data, int tag) { + private static int findTagValue(byte[] data, int tag) { TLV info = TLV.from(data); if (info != null && (info.getTag() & 0xFF) == 0xA0) { List records = parseTLVRecursive(data); @@ -106,19 +110,24 @@ private static int extractTagValue(byte[] data, int tag) { } } } - return 0; + return TAG_VALUE_ABSENT; + } + + private static int extractTagValue(byte[] data, int tag) { + int value = findTagValue(data, tag); + return value == TAG_VALUE_ABSENT ? 0 : value; } @Override public int pinChangedFlag(CodeType type) throws SmartCardReaderException { byte[] data = getData(type); - return extractTagValue(data, 0xDF2F); + return extractTagValue(data, TAG_PIN_CHANGED); } @Override public int codeRetryCounter(CodeType type) throws SmartCardReaderException { byte[] data = getData(type); - return extractTagValue(data, 0xDF21); + return extractTagValue(data, TAG_RETRY_COUNTER); } private byte[] getData(CodeType type) throws SmartCardReaderException { @@ -164,8 +173,13 @@ private byte[] sign(CodeType type, byte[] pin, byte keyRef, byte[] hash) throws @Override public byte[] calculateSignature(byte[] pin2, byte[] hash, boolean ecc) throws SmartCardReaderException { - if (pinChangedFlag(CodeType.PIN2) == 0) { - throw new SmartCardReaderException("PIN2 has not been changed, operation not allowed"); + int changedFlag = findTagValue(getData(CodeType.PIN2), TAG_PIN_CHANGED); + if (changedFlag == TAG_VALUE_ABSENT) { + throw new SmartCardReaderException( + "Could not read the PIN2 changed flag, cannot determine whether signing is allowed"); + } + if (changedFlag == 0) { + throw new CodeNotActivatedException(CodeType.PIN2); } return sign(CodeType.PIN2, pin2, (byte) 0x05, hash); } diff --git a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/TokenWithPace.java b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/TokenWithPace.java index 25f38d1..bdec267 100644 --- a/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/TokenWithPace.java +++ b/libs/id-card-lib/src/main/java/ee/ria/DigiDoc/idcard/TokenWithPace.java @@ -17,6 +17,8 @@ public interface TokenWithPace extends Token { String TAG = TokenWithPace.class.getName(); + int CAN_LENGTH = 6; + /** * Method to execute the PACE key-exchange, to allow for encrypted * communication between card and the application diff --git a/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/CodeNotActivatedExceptionTest.java b/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/CodeNotActivatedExceptionTest.java new file mode 100644 index 0000000..5ee847e --- /dev/null +++ b/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/CodeNotActivatedExceptionTest.java @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +package ee.ria.DigiDoc.idcard; + +import static com.google.common.truth.Truth.assertThat; + +import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException; + +import org.junit.Test; + +public final class CodeNotActivatedExceptionTest { + + @Test + public void message_matchesTheLegacyLiteralConsumersStringMatchOn() { + assertThat(new CodeNotActivatedException(CodeType.PIN2).getMessage()) + .isEqualTo("PIN2 has not been changed, operation not allowed"); + } + + @Test + public void getType_returnsTheCodeTypeItWasRaisedFor() { + assertThat(new CodeNotActivatedException(CodeType.PIN2).getType()) + .isEqualTo(CodeType.PIN2); + } + + @Test + public void isCaughtByExistingSmartCardReaderExceptionHandlers() { + assertThat(new CodeNotActivatedException(CodeType.PIN2)) + .isInstanceOf(SmartCardReaderException.class); + } +} diff --git a/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/CodeTypeTest.java b/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/CodeTypeTest.java new file mode 100644 index 0000000..0a1b6bd --- /dev/null +++ b/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/CodeTypeTest.java @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +package ee.ria.DigiDoc.idcard; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +public final class CodeTypeTest { + + @Test + public void minLength_isFourForPin1FiveForPin2EightForPuk() { + assertThat(CodeType.PIN1.minLength).isEqualTo(4); + assertThat(CodeType.PIN2.minLength).isEqualTo(5); + assertThat(CodeType.PUK.minLength).isEqualTo(8); + } + + @Test + public void maxLength_isTwelveForEveryCodeType() { + for (CodeType type : CodeType.values()) { + assertThat(type.maxLength).isEqualTo(12); + } + } + + @Test + public void isLengthValid_acceptsBothBoundaries() { + for (CodeType type : CodeType.values()) { + assertThat(type.isLengthValid(type.minLength)).isTrue(); + assertThat(type.isLengthValid(type.maxLength)).isTrue(); + } + } + + @Test + public void isLengthValid_rejectsJustOutsideBothBoundaries() { + for (CodeType type : CodeType.values()) { + assertThat(type.isLengthValid(type.minLength - 1)).isFalse(); + assertThat(type.isLengthValid(type.maxLength + 1)).isFalse(); + } + } + + @Test + public void isLengthValid_rejectsEmptyAndNegative() { + for (CodeType type : CodeType.values()) { + assertThat(type.isLengthValid(0)).isFalse(); + assertThat(type.isLengthValid(-1)).isFalse(); + } + } + + @Test + public void apduValues_areUnchanged() { + assertThat(CodeType.PIN1.value).isEqualTo((byte) 0x01); + assertThat(CodeType.PIN2.value).isEqualTo((byte) 0x02); + assertThat(CodeType.PUK.value).isEqualTo((byte) 0x00); + assertThat(CodeType.PIN1.retryValue).isEqualTo((byte) 0x01); + assertThat(CodeType.PIN2.retryValue).isEqualTo((byte) 0x02); + assertThat(CodeType.PUK.retryValue).isEqualTo((byte) 0x03); + } +} diff --git a/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/ThalesTest.java b/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/ThalesTest.java new file mode 100644 index 0000000..e09e825 --- /dev/null +++ b/libs/id-card-lib/src/test/java/ee/ria/DigiDoc/idcard/ThalesTest.java @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later +package ee.ria.DigiDoc.idcard; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import ee.ria.DigiDoc.smartcardreader.SmartCardReader; +import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockMakers; + +public final class ThalesTest { + + private SmartCardReader reader; + private Thales thales; + + @Before + public void before() { + reader = mock(SmartCardReader.class, withSettings().mockMaker(MockMakers.SUBCLASS)); + thales = new Thales(reader); + } + + private static byte[] pinChangedResponse(int value) { + return new byte[] {(byte) 0xA0, 0x04, (byte) 0xDF, 0x2F, 0x01, (byte) value}; + } + + private void stubGetData(byte[] response) throws SmartCardReaderException { + when(reader.transmit(anyInt(), eq(0xCB), anyInt(), anyInt(), any(), anyInt())) + .thenReturn(response); + } + + @Test + public void calculateSignature_throwsCodeNotActivated_whenTheFlagIsReadAsZero() throws Exception { + stubGetData(pinChangedResponse(0)); + + CodeNotActivatedException ex = assertThrows(CodeNotActivatedException.class, + () -> thales.calculateSignature(new byte[] {1, 2, 3, 4, 5}, new byte[32], true)); + + assertThat(ex.getType()).isEqualTo(CodeType.PIN2); + assertThat(ex).hasMessageThat() + .isEqualTo("PIN2 has not been changed, operation not allowed"); + } + + @Test + public void calculateSignature_doesNotClaimNotActivated_whenTheFlagCannotBeRead() throws Exception { + stubGetData(new byte[] {(byte) 0x90, 0x00}); + + SmartCardReaderException ex = assertThrows(SmartCardReaderException.class, + () -> thales.calculateSignature(new byte[] {1, 2, 3, 4, 5}, new byte[32], true)); + + assertThat(ex).isNotInstanceOf(CodeNotActivatedException.class); + assertThat(ex).hasMessageThat().contains("Could not read the PIN2 changed flag"); + } + + @Test + public void calculateSignature_readsTheChangedFlagWithASingleGetData() throws Exception { + stubGetData(pinChangedResponse(0)); + + assertThrows(CodeNotActivatedException.class, + () -> thales.calculateSignature(new byte[] {1, 2, 3, 4, 5}, new byte[32], true)); + + verify(reader, times(1)).transmit(anyInt(), eq(0xCB), anyInt(), anyInt(), any(), anyInt()); + } + + @Test + public void pinChangedFlag_stillReportsZeroWhenTheTagIsAbsent() throws Exception { + stubGetData(new byte[] {(byte) 0x90, 0x00}); + + assertThat(thales.pinChangedFlag(CodeType.PIN2)).isEqualTo(0); + } + + @Test + public void pinChangedFlag_reportsTheTagValueWhenPresent() throws Exception { + stubGetData(pinChangedResponse(1)); + + assertThat(thales.pinChangedFlag(CodeType.PIN2)).isEqualTo(1); + } +} diff --git a/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/CardConnectionLostException.java b/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/CardConnectionLostException.java new file mode 100644 index 0000000..8c4228b --- /dev/null +++ b/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/CardConnectionLostException.java @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later + +package ee.ria.DigiDoc.smartcardreader; + +public class CardConnectionLostException extends SmartCardReaderException { + + public CardConnectionLostException(Throwable cause) { + super(cause); + } +} diff --git a/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/NfcSmartCardReader.java b/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/NfcSmartCardReader.java index 84bd324..9b8dd87 100644 --- a/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/NfcSmartCardReader.java +++ b/libs/smart-card-reader-lib/src/main/java/ee/ria/DigiDoc/smartcardreader/nfc/NfcSmartCardReader.java @@ -6,6 +6,7 @@ import static java.util.Arrays.copyOfRange; import android.nfc.Tag; +import android.nfc.TagLostException; import android.nfc.tech.IsoDep; import androidx.annotation.NonNull; @@ -14,9 +15,9 @@ import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.Objects; import ee.ria.DigiDoc.smartcardreader.ApduResponseException; +import ee.ria.DigiDoc.smartcardreader.CardConnectionLostException; import ee.ria.DigiDoc.smartcardreader.SmartCardReader; import ee.ria.DigiDoc.smartcardreader.SmartCardReaderException; import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil; @@ -59,7 +60,7 @@ public NfcSmartCardReader(Tag tag) throws SmartCardReaderException { try { card.connect(); } catch (IOException ex) { - throw new SmartCardReaderException(ex); + throw readerException(ex); } } @@ -71,7 +72,7 @@ public void close() { try { card.close(); } catch (IOException ex) { - LoggingUtil.Companion.errorLog(TAG, Objects.requireNonNull(ex.getMessage()), ex); + LoggingUtil.Companion.errorLog(TAG, String.valueOf(ex), ex); } } @@ -113,7 +114,7 @@ protected byte[] transmit(byte[] apdu) throws SmartCardReaderException { LoggingUtil.Companion.debugLog(TAG, Hex.toHexString(apdu), null); return card.transceive(apdu); } catch (IOException ex) { - throw new SmartCardReaderException(ex); + throw readerException(ex); } } @@ -194,4 +195,11 @@ private static byte[] combineCompleteRApdu(byte[] response, byte[] missing) { System.arraycopy(response, response.length - 2, ret, cursor, 2); return ret; } + + private static SmartCardReaderException readerException(IOException ex) { + if (ex instanceof TagLostException) { + return new CardConnectionLostException(ex); + } + return new SmartCardReaderException(ex); + } } diff --git a/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/CardConnectionLostExceptionTest.java b/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/CardConnectionLostExceptionTest.java new file mode 100644 index 0000000..c65c0d5 --- /dev/null +++ b/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/CardConnectionLostExceptionTest.java @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Estonian Information System Authority +// SPDX-License-Identifier: LGPL-2.1-or-later +package ee.ria.DigiDoc.smartcardreader; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; + +import java.io.IOException; + +public final class CardConnectionLostExceptionTest { + + @Test + public void preservesTheCauseSoConsumersCanStillUnwrapIt() { + IOException cause = new IOException("tag left the field"); + assertThat(new CardConnectionLostException(cause).getCause()).isSameInstanceAs(cause); + } + + @Test + public void messageStillReportsTheUnderlyingCause() { + assertThat(new CardConnectionLostException(new IOException("tag left the field")).getMessage()) + .isEqualTo("java.io.IOException: tag left the field"); + } + + @Test + public void isCaughtByExistingSmartCardReaderExceptionHandlers() { + assertThat(new CardConnectionLostException(new IOException())) + .isInstanceOf(SmartCardReaderException.class); + } + + @Test + public void survivesACauseWithNoMessage() { + assertThat(new CardConnectionLostException(new IOException()).getMessage()) + .isEqualTo("java.io.IOException"); + } +} diff --git a/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/SmartCardReaderTest.java b/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/SmartCardReaderTest.java index 9a458da..a8c690c 100644 --- a/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/SmartCardReaderTest.java +++ b/libs/smart-card-reader-lib/src/test/java/ee/ria/DigiDoc/smartcardreader/SmartCardReaderTest.java @@ -6,6 +6,7 @@ import static com.google.common.truth.Truth.assertThat; import static org.hamcrest.CoreMatchers.equalTo; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -28,7 +29,9 @@ public final class SmartCardReaderTest { @Before public void before() throws Exception { - smartCardReader = mock(SmartCardReader.class, withSettings().mockMaker(MockMakers.SUBCLASS)); + smartCardReader = mock(SmartCardReader.class, withSettings() + .mockMaker(MockMakers.SUBCLASS) + .defaultAnswer(CALLS_REAL_METHODS)); when(smartCardReader.transmit(any())) .thenReturn(new byte[] {(byte) 0x90, 0x00}); }