четверг, 20 ноября 2014 г.

Реализация взаимодействия с веб-сервисом и формирование гостовой подписи с помощью OpenSSL

В данной статье описывается как прикрутить OpenSSL для организации подписи (платежные шлюзы билайна и мосэнергосбыта). Подпись и проверку подписи будем делать вручную, перехватывая сообщения перед отправкой/после получения. поскольку реализация веб-сервиса не оставляет другого выбора.
Будем использовать apache CXF 2.7.8 для работы с веб-сервисом. Для подписи будем использовать OpenSSL, предварительно сконвертировав ключи в pkcs12 формат, как описано в предыдущей статье.

и немного кода:

        logger.info("---------------WS-URL---------------");
        logger.info(config.getString("ws.service.url"));
        logger.info("-------------------------------------");

        QName SERVICE_NAME = new QName("http://tempuri.org", "PaymentGateService");
        QName PORT_NAME = new QName("http://tempuri.org", "PaymentGatePort");

        Service paymnetService = Service.create(SERVICE_NAME);
        paymnetService.addPort(PORT_NAME, SOAPBinding.SOAP11HTTP_BINDING, Config.getWsAddress());
        PaymentGate iface = paymnetService.getPort(PORT_NAME, PaymentGate.class);

        SignatureInterceptor transformOutInterceptor = new SignatureInterceptor();
        Client client = ClientProxy.getClient(iface);
        HTTPConduit http = (HTTPConduit) client.getConduit();
        final TrustManager[] trustAllCerts = new TrustManager[]{new X509TrustManager() {
            @Override
            public java.security.cert.X509Certificate[] getAcceptedIssuers() {
                return null;
            }
            @Override
            public void checkClientTrusted(X509Certificate[] certs, String authType) {
            }
            @Override
            public void checkServerTrusted(X509Certificate[] certs, String authType) {
            }
        }
        };
        TLSClientParameters parameters = new TLSClientParameters();
        parameters.setTrustManagers(trustAllCerts);
        parameters.setDisableCNCheck(true);
        http.setTlsClientParameters(parameters);

        HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy();

        httpClientPolicy.setConnectionTimeout(10000);
        httpClientPolicy.setAllowChunking(false);
        httpClientPolicy.setReceiveTimeout(40000);

        http.setClient(httpClientPolicy);

        client.getOutInterceptors().add(new LoggingOutInterceptor());
        client.getOutInterceptors().add(transformOutInterceptor);
        client.getInInterceptors().add(new SignatureVerificationInterseptor());
        System.setProperty("com.sun.xml.ws.transport.http.client.HttpTransportPipe.dump", String.valueOf(Config.isHttpDump()));

Для формирования подписи нам нужно получить сообщение целиком в том виде, в котором оно будет отправлено. Каноникализация производится неявным образом, путем удаления всего форматирования xml документа. используем следующий код, частично скопированный из исходников CXF. Из тонких моментов - инвертация полученной подписи. Видимо есть различия в том, как CryptoPro и OpenSSL возвращают результат подписи, кто-то в Big-endian, кто-то в Little-endian
public class SignatureInterceptor extends AbstractOutDatabindingInterceptor {
    private final static Logger logger = LoggerFactory.getLogger(SignatureInterceptor.class);

    public SignatureInterceptor() {
        super(Phase.PRE_LOGICAL);
        getBefore().add(HolderOutInterceptor.class.getName());
    }

    public void handleMessage(Message message) throws Fault {
        try {
            OutputStream out = new ByteArrayOutputStream();
            Exchange exchange = message.getExchange();
            Service service = exchange.getService();
            XMLOutputFactory instance = XMLOutputFactory.newInstance();
            XMLStreamWriter xmlWriter = instance.createXMLStreamWriter(out);

            DataWriter<XMLStreamWriter> dataWriter = getDataWriter(message, service, XMLStreamWriter.class);
            OperationInfo op = exchange.getBindingOperationInfo().getOperationInfo();
            List<MessagePartInfo> parts = op.getInput().getMessageParts();
            MessageContentsList objs = MessageContentsList.getContentsList(message);
            for (MessagePartInfo part : parts) {
                if (objs.hasValue(part)) {
                    Object o = objs.get(part);
                    if (!part.getConcreteName().getLocalPart().equals("digitalSignature")) {
                        dataWriter.write(o, part, xmlWriter);
                    }
                }
            }
            xmlWriter.flush();
            logger.debug("SIGNATURE BEGIN");
            logger.debug(out.toString());
            String signature = sign(out.toString());
            logger.debug(signature);
            logger.debug("signature length = " + signature.length());
            logger.debug("SIGNATURE END");
            xmlWriter.close();
            for (MessagePartInfo part : parts) {
                if (part.getConcreteName().getLocalPart().equals("digitalSignature")) {
                    DigitalSignature ds = new DigitalSignature();
                    ds.setSignature(signature);
                    objs.put(part, ds);
                }
            }
        } catch (Exception ex) {
            throw new Fault(ex);
        }
    }

    private static String sign(String content) throws SignatureException{
        File f = null;
        File fOut = null;
        try {
            f = File.createTempFile("input", "data");
            FileOutputStream foutStream = new FileOutputStream(f);
            IOUtils.write(content, foutStream);
            foutStream.flush();
            IOUtils.closeQuietly(foutStream);
            fOut = File.createTempFile("input", "sng");

            ProcessBuilder pb = new ProcessBuilder(Config.getInstance().getString("crypto.command"),
                    "dgst",
                    "-engine",
                    "gost",
                    "-binary",
                    "-md_gost94",
                    "-out",
                    fOut.getAbsolutePath(),
                    "-sign",
                    Config.getInstance().getString("crypto.private.key"),
                    f.getAbsolutePath());

            logger.debug(StringUtils.join(pb.command(), " "));

            Process p = pb.start();
            InputStream is = p.getErrorStream();
            p.waitFor();
            if (p.exitValue() != 0) {
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                IOUtils.copy(is, out);
                logger.error(out.toString("UTF-8"));
                IOUtils.closeQuietly(is);
                IOUtils.closeQuietly(out);
                throw new SignatureException("OpenSSL return error code: " + p.exitValue());
            }
            IOUtils.closeQuietly(is);
            FileInputStream fis = new FileInputStream(fOut);
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            IOUtils.copy(fis, bos);
            IOUtils.closeQuietly(fis);
            byte[] res = bos.toByteArray();
            IOUtils.closeQuietly(bos);
            // Инвертируем полученную подпись - иначе она считается невалидной.
            byte[] inverted = new byte[res.length];
            for(int i = 0; i< res.length; i++){
                inverted[res.length-i-1] = res[i];
            }
            return new String(Base64.encodeBase64(inverted, false));
        } catch (IOException ex) {
            throw new SignatureException(ex.getMessage(), ex);
        } catch (InterruptedException e) {
            logger.error("Signature process was interrupted, returning null...");
            return null;
        } finally {
            FileUtils.deleteQuietly(f);
            FileUtils.deleteQuietly(fOut);
        }
    }


}
И проверка подписи:
public class SignatureVerificationInterseptor extends LoggingInInterceptor {
    private final static Logger logger = LoggerFactory.getLogger(SignatureVerificationInterseptor.class);

    public SignatureVerificationInterseptor() {
        super();
    }

    @Override
    protected String formatLoggingMessage(LoggingMessage loggingMessage) {
        String soapXmlPayload = loggingMessage.getPayload().toString();

        try {
            String content = soapXmlPayload.substring(soapXmlPayload.indexOf("<soapenv:Body>") + "<soapenv:Body>".length(), 
                                                   soapXmlPayload.indexOf("</soapenv:Body>"));
            String signature = soapXmlPayload.substring(soapXmlPayload.indexOf("<signature>") + "<signature>".length(),
                                                   soapXmlPayload.indexOf("</signature>"));
            logger.debug("signature verifying");
            logger.debug(content);
            logger.debug(signature);
            if (!verify(content, signature)) {
                logger.debug(super.formatLoggingMessage(loggingMessage));
                throw new SignatureVerificationException("Signature verification error");
            }
        }catch (SignatureVerificationException ex){
            throw ex;
        }catch (Exception ex){
            logger.debug(super.formatLoggingMessage(loggingMessage));
            throw new RuntimeException("Signature not found in soap envelope");
        }
        return super.formatLoggingMessage(loggingMessage);
    }

    private static boolean verify(String content, String signature){
        File signatureFile = null;
        File datafile = null;
        try {
            signatureFile = File.createTempFile("output", "sng");
            FileOutputStream signatureStream = new FileOutputStream(signatureFile);
            byte[] sig = Base64.decodeBase64(signature.getBytes());
            // Инвертируем полученную подпись - иначе она считается невалидной.
            byte[] inverted = new byte[sig.length];
            for(int i = 0; i< sig.length; i++){
                inverted[sig.length-i-1] = sig[i];
            }
            IOUtils.write(inverted, signatureStream);
            signatureStream.flush();
            IOUtils.closeQuietly(signatureStream);


            datafile = File.createTempFile("output", "data");
            FileOutputStream dataStream = new FileOutputStream(datafile);
            IOUtils.write(content, dataStream);
            dataStream.flush();
            IOUtils.closeQuietly(dataStream);

            ProcessBuilder pb = new ProcessBuilder(Config.getInstance().getString("crypto.command"),
                    "dgst",
                    "-engine",
                    "gost",
                    "-binary",
                    "-verify",
                    Config.getInstance().getString("crypto.public.cert"),
                    "-signature",
                    signatureFile.getAbsolutePath(),
                    datafile.getAbsolutePath());

            logger.debug(StringUtils.join(pb.command(), " "));

            Process p = pb.start();
            InputStream is = p.getErrorStream();
            p.waitFor();
            if (p.exitValue() != 0) {
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                IOUtils.copy(is, out);
                logger.error(out.toString("UTF-8"));
                IOUtils.closeQuietly(is);
                IOUtils.closeQuietly(out);
                return false;
            }
            return true;
        }catch (Exception ex){
            logger.error(ex.getClass().toString() + " message:" + ex.getMessage(), ex);
            return false;
        } finally {
            FileUtils.deleteQuietly(signatureFile);
            FileUtils.deleteQuietly(datafile);
        }
    }

вторник, 20 мая 2014 г.

Создание нескольких тегов ds:Signature в одном SOAP сообщении с использованием Apache CXF

Недавно мне потребовалось осуществить вызов веб-сервиса, защищенного WS-Security. Нужно было передать UsernameToken и подписать Body и UsernameToken двумя разными подписями. Решал попробовать решить эту задачу с помощью Apache CXF, в основном из-за того, что там не нужно вручную модифицировать wsdl и добавлять хитрые policy. Как оказалось, реализовать это в версии CXF 2.X невозможно без низкоуровневого манипулирования документом, что мне не очень-то хотелось делать. К счастью, наткнулся на этот архив http://osdir.com/ml/users-cxf-apache/2013-09/msg00285.html, откуда узнал, что недавно вышла версия CXF-3.0.0-milestone2 и там уже можно реализовать задуманное довольно просто.

Итак, приступим. В принципе для конфигурирования можно было бы использовать spring, но почти все настройки сделаны в коде
        // получаем интерфейс сервиса, который будем вызывать  
        PaymentService_Service service = new PaymentService_Service();
        PaymentService iface = service.getPaymentServiceImplPort();
        Client client = ClientProxy.getClient(iface);
        HTTPConduit http = (HTTPConduit) client.getConduit();
        TLSClientParameters parameters = new TLSClientParameters();

        // задаем keyStore и trustStore для работы по https с клиентским сертификатом
        parameters.setSSLSocketFactory(createSSLContext().getSocketFactory());
        http.setTlsClientParameters(parameters);

        HTTPClientPolicy httpClientPolicy = new HTTPClientPolicy();

        httpClientPolicy.setConnectionTimeout(10000);
        httpClientPolicy.setAllowChunking(false);
        httpClientPolicy.setReceiveTimeout(30000);

        http.setClient(httpClientPolicy);


   public void testWebMethod() throws Exception {
        List<HandlerAction> actions = new ArrayList<HandlerAction>();
        {

            // говорим, что мы хотим UsernameToken
            HandlerAction action = new HandlerAction();
            action.setAction(WSConstants.UT);
            actions.add(action);
        }

        // говорим, что мы хотим отдельную подпись для Body
        {

            // определим объект(ы) которые будем подписывать
            // сначала локальное имя тега (без namespace), затем namespace, потом одно из значений Content или Element
            WSEncryptionPart part = new WSEncryptionPart(WSConstants.ELEM_BODY, "http://schemas.xmlsoap.org/soap/envelope/", "Content" );
            List<WSEncryptionPart> parts = new ArrayList<WSEncryptionPart>();
            parts.add(part);
            addSignatureAction(actions, parts);
        }

        // говорим, что мы хотим отдельную подпись для UsernameToken
        {
            WSEncryptionPart part = new WSEncryptionPart("UsernameToken", WSConstants.WSSE_NS, "Content" );
            List<WSEncryptionPart> parts = new ArrayList<WSEncryptionPart>();
            parts.add(part);
            addSignatureAction(actions, parts);
        }

        Client client = ClientProxy.getClient(iface);
        client.getInInterceptors().add(new LoggingInInterceptor());
        client.getOutInterceptors().add(new LoggingOutInterceptor());

        Map<String,Object> inProps= new HashMap<String,Object>();
        // указываем наш список действий
        inProps.put(WSHandlerConstants.HANDLER_ACTIONS, actions);

        // пароль простым текстом (он у нас всегда пустой)
        inProps.put(WSHandlerConstants.PASSWORD_TYPE, WSConstants.PW_TEXT);

        // Колбэк для задания паролей от keystore для подписи и UsernameToken
        inProps.put(WSHandlerConstants.PW_CALLBACK_CLASS, PasswordCallbackHandler.class.getName());

        // пользователь, который будет указан в UsernameToken
        inProps.put(WSHandlerConstants.USER, "00000000");

        client.getOutInterceptors().add(new WSS4JOutInterceptor(inProps));
        String result = iface.doAnyMethodOfService();

    }

  
    Поскольку используется не финальная версия, есть некоторые недоработки, в частности, нельзя задать некоторые свойства по-старому, нужно в обязательном порядке перечислить их в объекте token.
    private void addSignatureAction(List<HandlerAction> actions, List<WSEncryptionPart> parts) throws WSSecurityException {
        HandlerAction action = new HandlerAction();
        SignatureActionToken token = new SignatureActionToken();
        token.setIncludeSignatureToken(true);
        token.setParts(parts);

        // файл с описанием криптопровайдера
        token.setCryptoProperties("sig.properties");

        // алиас приватного ключа. Пароль задается в классе PasswordCallbackHandler
        token.setUser("1");

        // будет ли включен в тег BinarySecurityToken один сертификат или вся цепочка
        token.setUseSingleCert(true);

        // каким образом информация о сертификате будет представлена в структуре ds:KeyInfo
        // в данном случае ссылкой на тег BinarySecurityToken, в которой лежит весь сертификат
        token.setKeyIdentifierId(WSConstants.BST_DIRECT_REFERENCE);
        token.getCrypto(); // set up fields
        action.setAction(WSConstants.SIGN);
        action.setActionToken(token);
        actions.add(action);
    }


Класс для задания паролей к хранилищу и токену
public class PasswordCallbackHandler implements CallbackHandler {
    @Override
    public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
        WSPasswordCallback pc = (WSPasswordCallback) callbacks[0];
        if (WSPasswordCallback.USERNAME_TOKEN == pc.getUsage()) {
            pc.setPassword("");
        }
        if (WSPasswordCallback.SIGNATURE == pc.getUsage()) {

            pc.setPassword("changeit");
        }
    }
}


Кочующая из проекта в проект c небольшими вариациями функция по получению SSLContext. В данном случае используется два разных keystore, один для хранения приватного ключа и сертификата, второй для хранения цепочки сертификатов сервера.

private SSLContext createSSLContext() throws Exception{
        KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
        trustStore.load(new FileInputStream("/home/grigory/dev/project/src/test/key/https.jks"), "changeit".toCharArray());
        KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
        keyStore.load(new FileInputStream("/home/grigory/dev/project/src/test/key/client.jks"), "changeit".toCharArray());
        TrustManagerFactory tmf = TrustManagerFactory.getInstance("SunX509");
        tmf.init(trustStore);
        KeyManagerFactory kmf = KeyManagerFactory.getInstance("SunX509");
        kmf.init(keyStore, "changeit".toCharArray());
        SSLContext sslContext = SSLContext.getInstance("SSL");
        sslContext.init(kmf.getKeyManagers() , tmf.getTrustManagers(), new SecureRandom());
        return sslContext;

    }


И для примера содержимое файла sig.properites (должен лежать в classpath)
org.apache.ws.security.crypto.provider=org.apache.ws.security.components.crypto.Merlin
org.apache.ws.security.crypto.merlin.keystore.type=jks
org.apache.ws.security.crypto.merlin.keystore.password=changeit
org.apache.ws.security.crypto.merlin.keystore.file=/home/grigory/dev/project/src/test/key/sig.jks




четверг, 6 марта 2014 г.

Формирование и проверка УИН ГИБДД

С февраля 2014 в России вводится новый Универсальный Идентификатор начислений (УИН) http://savepayment.ru/soft/53/uin-v-platezhnom-poruchenii. Это 20-ти значная строка, которая содержит цифры и русские и латинские буквы. И если ранее штраф идентифицировался по номеру и дате платежного постановления, то теперь будут УИНы. В принципе, можно осуществить конвертацию УИН в номер дату платежного постановления и обратно. Алгоритм формирования УИН для ГИБДД описан тут (осторожно, множество опечаток в тестовых данных): Письмо МВД России от 9 августа 2013 г. № 13/9-4902 "Об информировании кредитных организаций о правилах формирования уникального идентификатора начислений" 
UPDATE: с какого-то момента 2014 года опять все поменялось, и УИН ГИБДД теперь содержит только цифры, без букв, и в нем уже не закодирован номер и дата постановления. http://savepayment.ru/recommendation/51/kvitantsiya-gibdd-izmenilas  из такого УИНа нельзя достать номер и дату постановления!
Непосредственно реализация конвертации УИН старого образца в пару номер/дата постановления

public class UIN {
    private static final char[] map =   {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
            'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
            'А', 'Б', 'В', 'Г', 'Д', 'Е', 'Ж', 'З', 'И', 'К', 'Л', 'М', 'Н', 'О', 'П', 'Р', 'С', 'Т', 'У', 'Ф', 'Х', 'Ц', 'Ч', 'Ш', 'Ь', 'Э', 'Ю', 'Я'};


    private static final int[] weights = { 0,  1,   2,   3,   4,   5,   6,   7,   8,   9,
            1,   3,   17,  29,   6,   30,  31, 13,  32,  33,  10,  34,  12,  35,  14,  16,  36,  37,  38,  18,  39,  40,  41,  21,  19,  42,
            1,   2,   3,   4,    5,   6,   7,  8,   9,   10,  11,  12,  13,  14,  15,  16,  17,  18,  19,  20,  21,  22,  23,  24,  42,  26,  27,  28 };


    public static boolean checkCRC(String uin){
        if(uin.length() != 20){
            throw new RuntimeException("incorrect uin length");
        }
        String finalNumber = uin.substring(0, 19);
        int crc = calculateCRC(finalNumber, 1);
        if(crc % 11 == 10){
            crc = calculateCRC(finalNumber, 3);
            if(crc %11 == 10){
                crc = 0;
            }
        }
        crc = crc%11;
        return uin.charAt(19) == Character.forDigit(crc, 10);
    }

    public static String getFineNumber(String uin){
        if(uin.length() != 20){
            throw new RuntimeException("incorrect uin length");
        }
        return uin.substring(7,19).replaceAll("(Z){1,12}$", "");
    }


    public static Date getFineDate(String uin){
        if(uin.length() != 20){
            throw new RuntimeException("incorrect uin length");
        }
        int res = 0;
        String dateStr = uin.substring(5,7);
        for (int i = dateStr.length(); i > 0; i--) {
            int index = lookupIndex(dateStr.charAt(dateStr.length() - i));
            res += index * Math.pow(64, i - 1);
        }
        Calendar c = Calendar.getInstance();
        int year = c.get(Calendar.YEAR);
        if (year % 10 == 0 && res % 10 != 0) { // в 2020 году могут оплатить штраф за 2019-й
            c.set(Calendar.YEAR, ((year / 10) - 1) * 10 + res % 10);
        } else {
            c.set(Calendar.YEAR, (year / 10) * 10 + res % 10);
        }
        c.set(Calendar.DAY_OF_YEAR, res / 10); // важно сначала установить год, а потом день года
        return c.getTime();
    }

    private static int lookupIndex(char ch) {
        for (int i = 0; i < map.length; i++) {
            if (map[i] == ch) {
                return i;
            }
        }
        throw new RuntimeException("Invalid character: '" + ch + "'");
    }

    private static int calculateCRC(String finalNumber, int startIndex) {
        int crc = 0;
        int i = startIndex;
        for(char ch : finalNumber.toCharArray()){
            crc += i++ * (weights[lookupIndex(ch)]%10);
            if(i==11){
                i=1;
            }
        }
        return crc;
    }
}

юнит-тест, использующий исправленные входные данные отсюда: http://www.garant.ru/products/ipo/prime/doc/70339846

@Test
    public void testUIN() throws Exception {
        SimpleDateFormat sdf = new SimpleDateFormat("dd.MM.yyyy");

        Assert.assertTrue(UIN.checkCRC("18810R564BK229683ZZ8"));
        Assert.assertTrue(UIN.checkCRC("18810ЗД57KT002522ZZ2"));
        Assert.assertTrue(UIN.checkCRC("18810HФ50АО309188ZZ5"));

        Assert.assertEquals("64BK229683", UIN.getFineNumber("18810R564BK229683ZZ8"));
        Assert.assertEquals("57KT002522", UIN.getFineNumber("18810ЗД57KT002522ZZ2"));
        Assert.assertEquals("50АО309188", UIN.getFineNumber("18810HФ50АО309188ZZ5"));

        Assert.assertEquals("22.06.2013", sdf.format(UIN.getFineDate("18810R564BK229683ZZ8")));
        Assert.assertEquals("24.04.2013", sdf.format(UIN.getFineDate("18810HФ50АО309188ZZ5")));
        Assert.assertEquals("05.10.2012", sdf.format(UIN.getFineDate("18810ЗД57KT002522ZZ2")));
    }


и небольшой юнит-тест, который использует те-же функции, для обратной конвертации. Он не оформлен как класс и написан непонятно зачем:
    @Test
    public void testPackUinDate() throws Exception {
        String number ="64BK229683";
        String dateStr = "22.06.2013";
        Date d = new SimpleDateFormat("dd.MM.yyyy").parse(dateStr);

        Calendar c = Calendar.getInstance();
        c.setTime(d);

        int tmp = (c.get(Calendar.DAY_OF_YEAR)) * 10 + (c.get(Calendar.YEAR) % 10);
        String result = "";
        while (tmp > 64) {
            result += map[tmp % 64];
            tmp = tmp / 64;
        }
        result += map[tmp];
        Assert.assertEquals("R5", StringUtils.reverse(result));
        String finalNumber = "18810" +  StringUtils.reverse(result) +  StringUtils.rightPad(number, 12, 'Z');
        Assert.assertEquals("18810R564BK229683ZZ", finalNumber);

        int crc = calculateCRC(finalNumber, 1);
        if(crc % 11 == 10){
            crc = calculateCRC(finalNumber, 3);
            if(crc %11 == 10){
                crc = 0;
            }
        }
        crc = crc%11;
        Assert.assertEquals(8, crc);
        Assert.assertEquals("18810R564BK229683ZZ8", finalNumber+crc);

    }