/* * Copyright (C) 2017 Schürmann & Breitmoser GbR * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.sufficientlysecure.keychain.securitytoken; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Arrays; import java.util.List; import android.content.Context; import android.os.SystemClock; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import org.sufficientlysecure.keychain.securitytoken.SecurityTokenInfo.TokenType; import org.sufficientlysecure.keychain.securitytoken.SecurityTokenInfo.TransportType; import org.sufficientlysecure.keychain.util.Passphrase; import timber.log.Timber; /** * This class provides a communication interface to OpenPGP applications on ISO SmartCard compliant * devices. * For the full specs, see [0] * * References: * [0] `Functional Specification of the OpenPGP application on ISO Smart Card Operating Systems` * version 3.4.1 * https://gnupg.org/ftp/specs/OpenPGP-smart-card-application-3.4.1.pdf */ public class SecurityTokenConnection { private static final int APDU_SW1_RESPONSE_AVAILABLE = 0x61; private static final String AID_PREFIX_FIDESMO = "A000000617"; private static SecurityTokenConnection sCachedInstance; @NonNull private final Transport transport; @Nullable private final Passphrase cachedPin; private final OpenPgpCommandApduFactory commandFactory; private TokenType tokenType; private CardCapabilities cardCapabilities; private OpenPgpCapabilities openPgpCapabilities; private KdfParameters kdfParameters; private SecureMessaging secureMessaging; private boolean isPw1ValidatedForSignature; // Mode 81 private boolean isPw1ValidatedForOther; // Mode 82 private boolean isPw3Validated; public static SecurityTokenConnection getInstanceForTransport( @NonNull Transport transport, @Nullable Passphrase pin) { if (sCachedInstance == null || !sCachedInstance.isPersistentConnectionAllowed() || !sCachedInstance.isConnected() || !sCachedInstance.transport.equals(transport) || (pin != null && !pin.equals(sCachedInstance.cachedPin))) { sCachedInstance = new SecurityTokenConnection(transport, pin, new OpenPgpCommandApduFactory()); } return sCachedInstance; } public static void clearCachedConnections() { sCachedInstance = null; } @VisibleForTesting SecurityTokenConnection(@NonNull Transport transport, @Nullable Passphrase pin, OpenPgpCommandApduFactory commandFactory) { this.transport = transport; this.cachedPin = pin; this.commandFactory = commandFactory; } // region connection management public void connectIfNecessary(Context context) throws IOException { if (isConnected()) { refreshConnectionCapabilities(); return; } connectToDevice(context); } /** * Connect to device and select pgp applet */ @VisibleForTesting void connectToDevice(Context context) throws IOException { try { // Connect on transport layer transport.connect(); // dummy instance for initial communicate() calls cardCapabilities = new CardCapabilities(); determineTokenType(); CommandApdu select = commandFactory.createSelectFileOpenPgpCommand(); ResponseApdu response = communicate(select); // activate connection if (!response.isSuccess()) { throw new CardException("Initialization failed!", response.getSw()); } refreshConnectionCapabilities(); isPw1ValidatedForSignature = false; isPw1ValidatedForOther = false; isPw3Validated = false; smEstablishIfAvailable(context); } catch (IOException e) { transport.release(); throw e; } } @VisibleForTesting void determineTokenType() throws IOException { tokenType = transport.getTokenTypeIfAvailable(); if (tokenType != null) { return; } CommandApdu selectFidesmoApdu = commandFactory.createSelectFileCommand(AID_PREFIX_FIDESMO); if (communicate(selectFidesmoApdu).isSuccess()) { tokenType = TokenType.FIDESMO; return; } /* We could determine if this is a yubikey here. The info isn't used at the moment, so we save the roundtrip // AID from https://github.com/Yubico/ykneo-oath/blob/master/build.xml#L16 CommandApdu selectYubicoApdu = commandFactory.createSelectFileCommand("A000000527200101"); if (communicate(selectYubicoApdu).isSuccess()) { tokenType = TokenType.YUBIKEY_UNKNOWN; return; } */ tokenType = TokenType.UNKNOWN; } public void refreshConnectionCapabilities() throws IOException { byte[] rawOpenPgpCapabilities = readData(0x00, 0x6E); OpenPgpCapabilities openPgpCapabilities = OpenPgpCapabilities.fromBytes(rawOpenPgpCapabilities); setConnectionCapabilities(openPgpCapabilities); } @VisibleForTesting void setConnectionCapabilities(OpenPgpCapabilities openPgpCapabilities) throws IOException { this.openPgpCapabilities = openPgpCapabilities; this.cardCapabilities = new CardCapabilities(openPgpCapabilities.getHistoricalBytes(), tokenType); } // endregion // region communication /** * Transceives APDU * Splits extended APDU into short APDUs and chains them if necessary * Performs GET RESPONSE command(ISO/IEC 7816-4 par.7.6.1) on retrieving if necessary * * @param commandApdu short or extended APDU to transceive * @return response from the card */ public ResponseApdu communicate(CommandApdu commandApdu) throws IOException { commandApdu = smEncryptIfAvailable(commandApdu); ResponseApdu lastResponse; lastResponse = transceiveWithChaining(commandApdu); lastResponse = readChainedResponseIfAvailable(lastResponse); lastResponse = smDecryptIfAvailable(lastResponse); return lastResponse; } @NonNull private ResponseApdu transceiveWithChaining(CommandApdu commandApdu) throws IOException { if (cardCapabilities.hasExtended()) { return transport.transceive(commandApdu); } else if (commandFactory.isSuitableForShortApdu(commandApdu)) { CommandApdu shortApdu = commandFactory.createShortApdu(commandApdu); return transport.transceive(shortApdu); } else if (cardCapabilities.hasChaining()) { ResponseApdu lastResponse = null; List chainedApdus = commandFactory.createChainedApdus(commandApdu); for (int i = 0, totalCommands = chainedApdus.size(); i < totalCommands; i++) { CommandApdu chainedApdu = chainedApdus.get(i); lastResponse = transport.transceive(chainedApdu); boolean isLastCommand = (i == totalCommands - 1); if (!isLastCommand && !lastResponse.isSuccess()) { throw new IOException("Failed to chain apdu " + "(" + i + "/" + (totalCommands-1) + ", last SW: " + lastResponse.getSw() + ")"); } } if (lastResponse == null) { throw new IllegalStateException(); } return lastResponse; } else { throw new IOException("Command too long, and chaining unavailable"); } } @NonNull private ResponseApdu readChainedResponseIfAvailable(ResponseApdu lastResponse) throws IOException { if (lastResponse.getSw1() != APDU_SW1_RESPONSE_AVAILABLE) { return lastResponse; } ByteArrayOutputStream result = new ByteArrayOutputStream(); result.write(lastResponse.getData()); do { // GET RESPONSE ISO/IEC 7816-4 par.7.6.1 CommandApdu getResponse = commandFactory.createGetResponseCommand(lastResponse.getSw2()); lastResponse = transport.transceive(getResponse); result.write(lastResponse.getData()); } while (lastResponse.getSw1() == APDU_SW1_RESPONSE_AVAILABLE); result.write(lastResponse.getSw1()); result.write(lastResponse.getSw2()); return ResponseApdu.fromBytes(result.toByteArray()); } // endregion // region secure messaging private void smEstablishIfAvailable(Context context) throws IOException { if (!openPgpCapabilities.isHasScp11bSm()) { return; } try { long elapsedRealtimeStart = SystemClock.elapsedRealtime(); secureMessaging = SCP11bSecureMessaging.establish(this, context, commandFactory); long elapsedTime = SystemClock.elapsedRealtime() - elapsedRealtimeStart; Timber.d("Established Secure Messaging in %d ms", elapsedTime); } catch (SecureMessagingException e) { secureMessaging = null; Timber.e(e, "failed to establish secure messaging"); } } private CommandApdu smEncryptIfAvailable(CommandApdu apdu) throws IOException { if (secureMessaging == null || !secureMessaging.isEstablished()) { return apdu; } try { return secureMessaging.encryptAndSign(apdu); } catch (SecureMessagingException e) { clearSecureMessaging(); throw new IOException("secure messaging encrypt/sign failure : " + e.getMessage()); } } private ResponseApdu smDecryptIfAvailable(ResponseApdu response) throws IOException { if (secureMessaging == null || !secureMessaging.isEstablished()) { return response; } try { return secureMessaging.verifyAndDecrypt(response); } catch (SecureMessagingException e) { clearSecureMessaging(); throw new IOException("secure messaging verify/decrypt failure : " + e.getMessage()); } } public void clearSecureMessaging() { if (secureMessaging != null) { secureMessaging.clearSession(); } secureMessaging = null; } // endregion // region pin management private byte[] calculateKdfIfNecessary(byte[] pin, KdfParameters.PasswordType type) throws IOException { if (!this.openPgpCapabilities.isHasKdf()) { // KDF-DO is not supported by token return pin; } KdfParameters kdfParameters = retrieveKdfDo(); if (kdfParameters == null || !kdfParameters.isHasUsesKdf()) { return pin; } else { return KdfCalculator.calculateKdf(kdfParameters.forType(type), pin); } } private KdfParameters retrieveKdfDo() throws IOException { if (this.kdfParameters != null) { return this.kdfParameters; } // query token for KDF-DO // see page 18 of [0] CommandApdu getKdfDoCommand = commandFactory.createGetDataCommand(0x00, 0xf9); ResponseApdu kdfDoResponse = communicate(getKdfDoCommand); if (!kdfDoResponse.isSuccess()) { throw new CardException("Couldn't get KDF.DO!", kdfDoResponse.getSw()); } byte[] kdfDo = kdfDoResponse.getData(); // empty KDF-DO means plain UTF-8 password is being used // see page 19 of [0] if (kdfDo.length == 0) { return null; } this.kdfParameters = KdfParameters.fromKdfDo(kdfDo); return this.kdfParameters; } public void verifyPinForSignature() throws IOException { if (isPw1ValidatedForSignature) { return; } if (cachedPin == null) { throw new IllegalStateException("Connection not initialized with Pin!"); } byte[] pin = cachedPin.toStringUnsafe().getBytes(); byte[] transformedPin = this.calculateKdfIfNecessary(pin, KdfParameters.PasswordType.PW1); CommandApdu verifyPw1ForSignatureCommand = commandFactory.createVerifyPw1ForSignatureCommand(transformedPin); // delete secrets from memory Arrays.fill(pin, (byte) 0); Arrays.fill(transformedPin, (byte) 0); ResponseApdu response = communicate(verifyPw1ForSignatureCommand); if (!response.isSuccess()) { throw new CardException("Bad PIN!", response.getSw()); } isPw1ValidatedForSignature = true; } public void verifyPinForOther() throws IOException { if (isPw1ValidatedForOther) { return; } if (cachedPin == null) { throw new IllegalStateException("Connection not initialized with Pin!"); } byte[] pin = cachedPin.toStringUnsafe().getBytes(); byte[] transformedPin = this.calculateKdfIfNecessary(pin, KdfParameters.PasswordType.PW1); CommandApdu verifyPw1ForOtherCommand = commandFactory.createVerifyPw1ForOtherCommand(transformedPin); // delete secrets from memory Arrays.fill(pin, (byte) 0); Arrays.fill(transformedPin, (byte) 0); ResponseApdu response = communicate(verifyPw1ForOtherCommand); if (!response.isSuccess()) { throw new CardException("Bad PIN!", response.getSw()); } isPw1ValidatedForOther = true; } public void verifyAdminPin(Passphrase adminPin) throws IOException { if (isPw3Validated) { return; } byte[] pin = adminPin.toStringUnsafe().getBytes(); byte[] transformedPin = this.calculateKdfIfNecessary(pin, KdfParameters.PasswordType.PW3); CommandApdu verifyPw3Command = commandFactory.createVerifyPw3Command(transformedPin); // delete secrets from memory Arrays.fill(pin, (byte) 0); Arrays.fill(transformedPin, (byte) 0); ResponseApdu response = communicate(verifyPw3Command); if (!response.isSuccess()) { throw new CardException("Bad PIN!", response.getSw()); } isPw3Validated = true; } public void invalidateSingleUsePw1() { if (!openPgpCapabilities.isPw1ValidForMultipleSignatures()) { isPw1ValidatedForSignature = false; } } public void invalidatePw3() { isPw3Validated = false; } // endregion private byte[] readData(int p1, int p2) throws IOException { ResponseApdu response = communicate(commandFactory.createGetDataCommand(p1, p2)); if (!response.isSuccess()) { throw new CardException("Failed to get pw status bytes", response.getSw()); } return response.getData(); } private String readUrl() throws IOException { byte[] data = readData(0x5F, 0x50); return new String(data).trim(); } private byte[] readUserId() throws IOException { return readData(0x00, 0x65); } public SecurityTokenInfo readTokenInfo() throws IOException { byte[][] fingerprints = new byte[3][]; fingerprints[0] = openPgpCapabilities.getFingerprintSign(); fingerprints[1] = openPgpCapabilities.getFingerprintEncrypt(); fingerprints[2] = openPgpCapabilities.getFingerprintAuth(); byte[] aid = openPgpCapabilities.getAid(); String userId = parseHolderName(readUserId()); String url = readUrl(); int pw1TriesLeft = openPgpCapabilities.getPw1TriesLeft(); int pw3TriesLeft = openPgpCapabilities.getPw3TriesLeft(); boolean hasLifeCycleManagement = cardCapabilities.hasLifeCycleManagement(); TransportType transportType = transport.getTransportType(); return SecurityTokenInfo.create(transportType, tokenType, fingerprints, aid, userId, url, pw1TriesLeft, pw3TriesLeft, hasLifeCycleManagement); } public boolean isPersistentConnectionAllowed() { return transport.isPersistentConnectionAllowed() && (secureMessaging == null || !secureMessaging.isEstablished()); } public boolean isConnected() { return transport.isConnected(); } public TokenType getTokenType() { return tokenType; } public OpenPgpCapabilities getOpenPgpCapabilities() { return openPgpCapabilities; } public OpenPgpCommandApduFactory getCommandFactory() { return commandFactory; } private static String parseHolderName(byte[] name) { try { return (new String(name, 4, name[3])).replace('<', ' '); } catch (IndexOutOfBoundsException e) { // try-catch for https://github.com/FluffyKaon/OpenPGP-Card // Note: This should not happen, but happens with // https://github.com/FluffyKaon/OpenPGP-Card, thus return an empty string for now! Timber.e(e, "Couldn't get holder name, returning empty string!"); return ""; } } }